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
JavaScript
/**
* 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
};