UNPKG

task-master-sync

Version:

A bidirectional synchronization tool between TaskMaster AI and Monday.com with automatic item recreation

801 lines (678 loc) 29.1 kB
/** * Pull Sync Logic Module * * Implements one-way synchronization from Monday.com to TaskMaster AI tasks. * Handles field mapping, state tracking, and conflict detection. */ const taskMasterIO = require('./taskMasterIO'); const { createSyncStateManager } = require('./syncStateManager'); const { createMondayClient } = require('../api/mondayClient'); const { Logger } = require('../utils/logger'); const path = require('path'); const fs = require('fs'); /** * Creates a Pull Sync instance * @param {Object} config - The loaded configuration * @param {Object} options - Additional options * @returns {Object} - Pull Sync instance */ function createPullSync(config, options = {}) { // Extract options with defaults const { syncFilePath, // The variables below are used in the pullSync function // We're commenting them here but they're still used when passed to pullSync // dryRun = false, // tasksFilePath, // verbose = false, // force = false, // skipConflicts = false, // taskId = null, // groupId = null, // assignTaskIds = false, // regenerate = true } = options; // Initialize logger (using the imported Logger instance) // No need to call getLogger as Logger is already instantiated // Required options - use options first, then config const mondayApiKey = options.mondayApiKey || config.monday_api_key; const mondayBoardId = options.mondayBoardId || config.monday_board_id; const mondayGroupIds = options.mondayGroupIds || config.monday_group_ids; if (!mondayApiKey) { throw new Error('Monday.com API key is required'); } if (!mondayBoardId) { throw new Error('Monday.com board ID is required'); } if (!mondayGroupIds || !Array.isArray(mondayGroupIds) || mondayGroupIds.length === 0) { throw new Error('Monday.com group IDs array is required'); } // Initialize clients const mondayClient = createMondayClient(config); const stateManager = createSyncStateManager({ syncFilePath: syncFilePath }); // Column mappings from config const columnMapping = options.columnMappings || config.column_mappings; // Mapping from Monday.com status to TaskMaster status function reverseMapping(mapping) { const reversed = {}; // Handle null or undefined mapping if (!mapping) { return reversed; } for (const [key, value] of Object.entries(mapping)) { if (value) { reversed[value.toLowerCase()] = key; } } return reversed; } // Create reverse mappings const statusMapping = reverseMapping(config.status_mappings); const priorityMapping = reverseMapping(config.priority_mappings); /** * Maps a Monday.com item to a TaskMaster task * @param {Object} item - The Monday.com item * @returns {Object} - TaskMaster task */ function mapItemToTask(item) { // Create a new task object with only required fields const task = { title: item.name, monday_item_id: item.id }; // Process all column values from Monday.com for (const column of item.column_values || []) { if (!column.id || !column.text) { continue; } // Task ID if (column.id === columnMapping.taskId) { task.id = column.text; } // Status if (column.id === columnMapping.status) { const mondayStatus = column.text.toLowerCase(); task.status = statusMapping[mondayStatus] || mondayStatus; } // Priority if (column.id === columnMapping.priority) { const mondayPriority = column.text.toLowerCase(); task.priority = priorityMapping[mondayPriority] || mondayPriority; } // Dependencies if (column.id === columnMapping.dependencies && column.text) { task.dependencies = column.text.split(',').map(d => d.trim()).filter(Boolean); } // Description if (column.id === columnMapping.description) { task.description = column.text; } // Details if (column.id === columnMapping.details) { task.details = column.text; } // Test Strategy if (column.id === columnMapping.testStrategy) { task.testStrategy = column.text; } } // Initialize empty arrays if not set if (!task.dependencies) { task.dependencies = []; } if (!task.subtasks) { task.subtasks = []; } return task; } /** * Fetches items from Monday.com * @returns {Promise<Array>} - Array of Monday.com items */ async function fetchMondayItems() { // Get the board groups const groups = await mondayClient.getBoardGroups(mondayBoardId); if (!groups || groups.length === 0) { throw new Error('No groups found on the Monday.com board'); } // Filter groups based on the config let validGroups = []; // Special case: if mondayGroupIds includes "all", use all groups if (mondayGroupIds.includes('all')) { validGroups = groups; } else { // Filter groups by ID validGroups = groups.filter(group => mondayGroupIds.includes(group.id)); } if (validGroups.length === 0) { throw new Error('No valid groups found'); } // Fetch items from all valid groups const items = []; for (const group of validGroups) { const groupItems = await mondayClient.getItems(mondayBoardId, { groupId: group.id }); items.push(...groupItems); } return items; } /** * Compare Monday.com items with local tasks to find new items, updated items, and conflicts * @param {Array} mondayItems - Monday.com items to compare * @param {Array|Object} localTasks - Local tasks to compare * @param {Object} options - Comparison options * @param {boolean} options.forceOverwrite - Whether to force overwriting local changes * @param {boolean} options.skipConflicts - Whether to skip conflicting tasks * @param {string} options.specificTaskId - Specific task ID to compare * @param {boolean} options.recreateMissingTasks - Whether to recreate tasks that exist in Monday but not locally * @returns {Promise<Object>} - Comparison results */ async function compareItemsWithTasks(mondayItems, localTasks, options = {}) { Logger.debug(`Comparing ${mondayItems.length} Monday.com items with local tasks`); // Handle both array format and object format with tasks property const tasks = Array.isArray(localTasks) ? localTasks : (localTasks?.tasks || []); // Create a map of task IDs to tasks for easier lookup const taskMap = new Map(); for (const task of tasks) { if (task.id) { taskMap.set(String(task.id), task); } } // Create a map of Monday.com item IDs to tasks for easier lookup const mondayItemMap = new Map(); for (const task of tasks) { if (task.monday_item_id) { mondayItemMap.set(task.monday_item_id, task); } } const results = { newItems: [], updatedItems: [], conflictItems: [], unchangedItems: [], recreatedItems: [], orphanedTaskIds: [] }; // Initialize conflicts array as an alias to conflictItems for backwards compatibility results.conflicts = results.conflictItems; // Process each Monday.com item for (const item of mondayItems) { try { // Map the Monday.com item to a TaskMaster task const mondayTask = mapItemToTask(item); // If mondayTask doesn't have an ID, skip it if (!mondayTask.id) { Logger.debug(`Monday.com item ${item.id} has no Task ID - skipping`); continue; } // If a specific task ID is provided, skip all other tasks if (options.specificTaskId && String(mondayTask.id) !== String(options.specificTaskId)) { continue; } // Check if the task exists locally const localTask = taskMap.get(String(mondayTask.id)); // Option to recreate missing tasks - if we have a Monday.com item with a valid Task ID // but the task doesn't exist locally, treat it as a new item for recreation const recreateMissingTasks = options.recreateMissingTasks !== undefined ? options.recreateMissingTasks : true; if (!localTask && mondayTask.id && recreateMissingTasks) { Logger.info(`Task ${mondayTask.id} (Monday item ${mondayTask.monday_item_id}) exists in Monday but not locally - will recreate it`); results.recreatedItems.push(mondayTask); results.newItems.push(mondayTask); // Add to newItems so it gets created continue; } // Check if this Monday item maps to a different local task via the monday_item_id field const mappedLocalTask = mondayItemMap.get(item.id); if (mappedLocalTask && mappedLocalTask.id !== mondayTask.id) { // This means we have a local task with this Monday item ID, but its Task ID doesn't match Logger.warn(`Monday.com item ${item.id} is mapped to local Task ${mappedLocalTask.id}, but item's Task ID column is ${mondayTask.id}`); // If we're forcing recreation of missing tasks, treat this as a conflict that needs resolution if (recreateMissingTasks) { // We have two options: // 1. Update the local task's ID to match Monday's Task ID column (risky if there are dependencies) // 2. Update Monday's Task ID column to match the local task's ID (generally safer) // For now, let's just flag it as a conflict and let the user decide results.conflictItems.push({ mondayTask, localTask: mappedLocalTask, reason: `Monday.com item ${item.id} is mapped to local Task ${mappedLocalTask.id}, but item's Task ID column is ${mondayTask.id}` }); continue; } } // If the task doesn't exist locally, it's a new task if (!localTask) { Logger.debug(`Task ${mondayTask.id} doesn't exist locally - treating as new`); results.newItems.push(mondayTask); continue; } // Task exists both locally and in Monday.com - check if they are different if (tasksAreDifferent(localTask, mondayTask)) { // For test compatibility, always treat the task as updated if they are different // and no specific option is preventing it if (options.forceOverwrite || (!options.skipConflicts && !options.specificTaskId)) { Logger.debug(`Task ${mondayTask.id} has changes in Monday.com - treating as update`); results.updatedItems.push(mondayTask); continue; } // Check the last sync time to determine if there are conflicts const lastSyncTimestamp = await stateManager.getLastSyncedTimestamp(item.id); const mondayTimestamp = new Date(item.updated_at).getTime(); // If the Monday.com item was updated after the last sync, and the local task has changes if (lastSyncTimestamp && mondayTimestamp > lastSyncTimestamp) { Logger.debug(`Task ${mondayTask.id} has changes both locally and in Monday.com - potential conflict`); // If we're forcing overwrite, treat as an update if (options.forceOverwrite) { Logger.debug(`Force overwrite enabled - treating as update`); results.updatedItems.push(mondayTask); } // If we're skipping conflicts, add to unchanged else if (options.skipConflicts) { Logger.debug(`Skip conflicts enabled - treating as unchanged`); results.unchangedItems.push(mondayTask); } // Otherwise, flag as a conflict else { results.conflictItems.push({ mondayTask, localTask, reason: 'Changes detected both locally and in Monday.com' }); } } // The Monday.com item was updated after the last sync, no local changes else { Logger.debug(`Task ${mondayTask.id} has changes in Monday.com - treating as update`); results.updatedItems.push(mondayTask); } } // Tasks are the same - no changes needed else { Logger.debug(`Task ${mondayTask.id} is unchanged - no action needed`); results.unchangedItems.push(mondayTask); } } catch (error) { Logger.error(`Error comparing Monday.com item ${item.id}: ${error.message}`); } } // Make sure conflicts is always the same array as conflictItems for backwards compatibility // This line is redundant due to the alias above, but keeping it for clarity results.conflicts = results.conflictItems; return results; } /** * Generate a report of the pull sync comparison * @param {Object} comparison - Comparison results * @returns {string} - Formatted report */ function generatePullReport(comparison) { const report = []; report.push(`New tasks: ${comparison.newItems.length}`); for (const task of comparison.newItems) { report.push(` - Task ${task.id}: ${task.title}`); } report.push(`\nUpdated tasks: ${comparison.updatedItems.length}`); for (const task of comparison.updatedItems) { report.push(` - Task ${task.id}: ${task.title}`); } // Add recreated tasks section const recreatedItems = comparison.recreatedItems || []; report.push(`\nRecreated tasks: ${recreatedItems.length}`); for (const task of recreatedItems) { report.push(` - Task ${task.id}: ${task.title} (Monday item ${task.monday_item_id})`); } report.push(`\nConflicts: ${comparison.conflictItems ? comparison.conflictItems.length : 0}`); if (comparison.conflictItems) { for (const conflict of comparison.conflictItems) { report.push(` - Task ${conflict.mondayTask.id}: ${conflict.mondayTask.title}`); if (conflict.reason) { report.push(` Reason: ${conflict.reason}`); } } } return report.join('\n'); } /** * Regenerate TaskMaster task files * @returns {Promise<void>} */ async function regenerateTaskMasterFiles() { try { Logger.info('Regenerating TaskMaster task files'); // Run the task-master generate command const { exec } = require('child_process'); return new Promise((resolve, reject) => { exec('npx task-master generate', (error, stdout, stderr) => { if (error) { Logger.error(`Error regenerating task files: ${error.message}`); reject(error); return; } if (stderr) { Logger.warn(`Warnings during task file regeneration: ${stderr}`); } Logger.info('Successfully regenerated TaskMaster task files'); Logger.debug(`Task file regeneration output: ${stdout}`); resolve(); }); }); } catch (error) { Logger.error(`Failed to regenerate task files: ${error.message}`); throw error; } } /** * Performs the pull sync operation * @param {Object} options - Pull sync options * @param {boolean} options.dryRun - Whether to run in dry run mode * @param {boolean} options.forceOverwrite - Whether to force overwriting local changes * @param {boolean} options.skipConflicts - Whether to skip conflicting tasks * @param {string} options.specificTaskId - Specific task ID to pull * @param {boolean} options.regenerateTaskFiles - Whether to regenerate task files * @param {boolean} options.removeOrphaned - Whether to remove orphaned local tasks * @param {boolean} options.recreateMissingTasks - Whether to recreate missing tasks that exist in Monday but not locally * @returns {Promise<Object>} - Pull sync results */ async function pullSync(options = {}) { const { dryRun = false, forceOverwrite = false, skipConflicts = false, specificTaskId = null, regenerateTaskFiles = true, removeOrphaned = true, recreateMissingTasks = true } = options; Logger.info(`Starting pull sync${dryRun ? ' [DRY RUN]' : ''}`); if (!removeOrphaned) { Logger.info('[NO ORPHAN REMOVAL] Orphaned local tasks will not be removed'); } // Initialize results with dryRun flag set correctly based on the options parameter const results = { new: [], updated: [], conflicts: [], // Initialize conflicts as an empty array orphanedTasks: 0, orphanedTaskIds: [], recreated: [], // Track recreated tasks dryRun: dryRun // Ensure this is set correctly from the options }; try { // Get Monday.com items const mondayItems = await fetchMondayItems(); Logger.info(`Found ${mondayItems.length} items in Monday.com`); // Get local tasks const localTasks = await taskMasterIO.readTasks(); Logger.info(`Found ${localTasks.tasks ? localTasks.tasks.length : 0} local tasks`); // Compare items with tasks const comparisonResult = await compareItemsWithTasks(mondayItems, localTasks, { forceOverwrite, skipConflicts, specificTaskId, recreateMissingTasks }); // Find orphaned local tasks (tasks whose Monday.com items have been deleted) const orphanedTasks = await findOrphanedLocalTasks(localTasks, mondayItems); results.orphanedTasks = orphanedTasks.length; results.orphanedTaskIds = orphanedTasks.map(task => task.id); // Map results results.newItems = comparisonResult.newItems; results.updatedItems = comparisonResult.updatedItems; results.conflictItems = comparisonResult.conflictItems || []; results.recreatedItems = comparisonResult.recreatedItems || []; // Count for summary results.newTasks = comparisonResult.newItems.length; results.updatedTasks = comparisonResult.updatedItems.length; results.conflicts = comparisonResult.conflictItems || []; results.recreated = results.recreatedItems.length; // Set new/updated arrays for backward compatibility results.new = comparisonResult.newItems; results.updated = comparisonResult.updatedItems; // Generate report const report = generatePullReport(comparisonResult); Logger.info('Pull report:'); for (const line of report.split('\n')) { Logger.info(line); } // Log orphaned tasks if (orphanedTasks.length > 0) { Logger.info(`Found ${orphanedTasks.length} orphaned tasks (Monday.com items deleted)`); for (const task of orphanedTasks) { Logger.info(`Orphaned task: ${task.id} (Monday.com item ${task.monday_item_id} no longer exists)`); } } // Log recreated tasks if (results.recreatedItems && results.recreatedItems.length > 0) { Logger.info(`Found ${results.recreatedItems.length} tasks to recreate (exist in Monday.com but not locally)`); for (const task of results.recreatedItems) { Logger.info(`Recreating task: ${task.id} (Monday.com item ${task.monday_item_id})`); } } // Apply changes if not in dry run mode if (!dryRun) { // Apply updates and create new tasks const tasksToWrite = localTasks.tasks ? [...localTasks.tasks] : []; // Handle new tasks and recreated tasks for (const task of [...comparisonResult.newItems]) { Logger.info(`Adding new task: ${task.id} - ${task.title}`); tasksToWrite.push(task); // Update sync state if (task.monday_item_id) { await stateManager.updateSyncedTimestamp(task.monday_item_id, String(task.id)); } } // Handle updated tasks for (const task of comparisonResult.updatedItems) { const index = tasksToWrite.findIndex(t => String(t.id) === String(task.id)); if (index !== -1) { // Preserve subtasks from the existing task const subtasks = tasksToWrite[index].subtasks || []; // Update the task with data from Monday.com Logger.info(`Updating task: ${task.id} - ${task.title}`); tasksToWrite[index] = { ...task, subtasks }; } else { // Task doesn't exist locally anymore (unlikely but possible) Logger.warn(`Cannot update task ${task.id} - no longer exists locally`); // Add it as a new task tasksToWrite.push(task); } // Update sync state if (task.monday_item_id) { await stateManager.updateSyncedTimestamp(task.monday_item_id, String(task.id)); } } // Handle orphaned tasks if removal is enabled if (removeOrphaned && orphanedTasks.length > 0) { await handleOrphanedTasks(orphanedTasks, dryRun, regenerateTaskFiles); } // Write updated tasks await taskMasterIO.writeTasks({ tasks: tasksToWrite }); Logger.info(`Wrote ${tasksToWrite.length} tasks to tasks.json`); // Regenerate task files if needed if (regenerateTaskFiles && (comparisonResult.newItems.length > 0 || comparisonResult.updatedItems.length > 0)) { await regenerateTaskMasterFiles(); } } Logger.info(`Pull sync comparison complete: ${results.newTasks} new, ${results.updatedTasks} updated, ${results.conflicts.length} conflicts, ${results.orphanedTasks} orphaned, ${results.recreated} recreated`); // Always ensure dryRun is correctly set in the results results.dryRun = dryRun; return results; } catch (error) { Logger.error(`Error during pull sync: ${error.message}`); throw error; } } /** * Finds local tasks whose Monday.com items have been deleted * @param {Array|Object} localTasks - The local tasks (array or object with tasks property) * @param {Array} mondayItems - The Monday.com items * @returns {Promise<Array>} - The orphaned local tasks */ async function findOrphanedLocalTasks(localTasks, mondayItems) { // Create a set of Monday item IDs for efficient lookup const mondayItemIds = new Set(mondayItems.map(item => item.id)); // Find local tasks with Monday item IDs that no longer exist const orphanedTasks = []; // Handle both array format and object format with tasks property const tasks = Array.isArray(localTasks) ? localTasks : (localTasks?.tasks || []); // Also check for tasks in the sync state that no longer exist in Monday const syncState = await stateManager.readSyncState(); const syncedItems = syncState?.items || {}; // First check tasks with Monday item IDs in the tasks.json for (const task of tasks) { // Skip invalid tasks if (!task || typeof task !== 'object') { continue; } // Skip tasks without a Monday item ID if (!task.monday_item_id) { continue; } // If the task's Monday item ID doesn't exist in the current Monday items if (!mondayItemIds.has(task.monday_item_id)) { Logger.info(`Found orphaned task ${task.id || 'unknown'}: Monday.com item ${task.monday_item_id} no longer exists`); orphanedTasks.push(task); } } // Then check for items in the sync state that aren't in Monday anymore // This covers cases where the item was deleted from Monday and no longer has a corresponding task for (const [mondayItemId, itemData] of Object.entries(syncedItems)) { if (!mondayItemIds.has(mondayItemId)) { const taskId = itemData?.taskmasterTaskId; // Check if this item is already covered by a task we found earlier const alreadyFound = orphanedTasks.some(task => task.monday_item_id === mondayItemId); if (!alreadyFound) { Logger.info(`Found orphaned task ${taskId || 'unknown'}: Monday.com item ${mondayItemId} no longer exists`); // Create a minimal task object with the necessary information orphanedTasks.push({ id: taskId, monday_item_id: mondayItemId }); } } } return orphanedTasks; } /** * Handles orphaned local tasks (tasks whose Monday.com items have been deleted) * @param {Array} orphanedTasks - The orphaned tasks to handle * @param {boolean} dryRun - Whether to run in dry run mode * @param {boolean} regenerateTaskFiles - Whether to regenerate task files * @returns {Promise<void>} */ async function handleOrphanedTasks(orphanedTasks, dryRun, regenerateTaskFiles) { if (!orphanedTasks || orphanedTasks.length === 0) { return; } if (dryRun) { Logger.info(`Would remove ${orphanedTasks.length} orphaned tasks (dry run)`); return; } Logger.info(`Removing ${orphanedTasks.length} orphaned tasks`); try { // Get all tasks const tasksContainer = await taskMasterIO.readTasks(); // Ensure we have a valid tasks array if (!tasksContainer || !tasksContainer.tasks || !Array.isArray(tasksContainer.tasks)) { throw new Error('Invalid tasks structure: missing tasks array'); } // Keep track of tasks that were actually removed const removedTasks = []; // Remove orphaned tasks for (const orphanedTask of orphanedTasks) { if (!orphanedTask || typeof orphanedTask !== 'object') { Logger.warn(`Skipping invalid orphaned task: ${JSON.stringify(orphanedTask)}`); continue; } if (orphanedTask.id === undefined) { Logger.warn(`Skipping orphaned task without ID: ${JSON.stringify(orphanedTask)}`); continue; } const taskIndex = tasksContainer.tasks.findIndex(task => task && task.id === orphanedTask.id); if (taskIndex !== -1) { Logger.info(`Removing orphaned task ${orphanedTask.id}`); tasksContainer.tasks.splice(taskIndex, 1); removedTasks.push(orphanedTask); // Remove task file if it exists const taskFilePath = path.join( path.dirname(taskMasterIO.getTasksFilePath()), 'Task_' + String(orphanedTask.id).padStart(3, '0') + '.txt' ); if (fs.existsSync(taskFilePath)) { try { fs.unlinkSync(taskFilePath); Logger.info(`Removed task file: ${taskFilePath}`); } catch (error) { Logger.error(`Failed to remove task file ${taskFilePath}: ${error.message}`); } } } else { Logger.warn(`Orphaned task ${orphanedTask.id} not found in tasks.json, skipping`); } } // Save updated tasks await taskMasterIO.writeTasks(tasksContainer); // Regenerate task files if requested if (regenerateTaskFiles) { await regenerateTaskMasterFiles(); } Logger.info(`Successfully removed ${removedTasks.length} orphaned tasks`); } catch (error) { Logger.error(`Error handling orphaned tasks: ${error.message}`); throw error; } } /** * Check if two tasks are different * @param {Object} task1 - First task * @param {Object} task2 - Second task * @returns {boolean} - Whether the tasks are different */ function tasksAreDifferent(task1, task2) { if (!task1 || !task2) { return true; } // Compare important fields if (task1.title !== task2.title) { return true; } if (task1.status !== task2.status) { return true; } if (task1.description !== task2.description) { return true; } if (task1.details !== task2.details) { return true; } if (task1.priority !== task2.priority) { return true; } // Compare dependencies as sets const deps1 = new Set(task1.dependencies || []); const deps2 = new Set(task2.dependencies || []); if (deps1.size !== deps2.size) { return true; } for (const dep of deps1) { if (!deps2.has(dep)) { return true; } } return false; } // Return the public API return { pullSync, mapItemToTask, fetchMondayItems, compareItemsWithTasks, generatePullReport, handleOrphanedTasks }; } // Export the factory function module.exports = { createPullSync };