Eyal Toledano 5119cd2d8e v017 polish (#778)
* fix(research, tasks): Make research command and task updates tag-aware

* refactor(tasks): Prevent automatic task file generation across other locations

This commit refactors several core task management functions to prevent them from automatically regenerating individual task files after modifying the main `tasks.json`.

Previously, operations like `add-task`, `clear-subtasks`, `expand-task`, and `update-task-by-id` would immediately trigger `generateTaskFiles`. This could be slow and was often unnecessary.

The calls to `generateTaskFiles` have been removed or commented out from the core task functions. Users should now run `task-master generate` explicitly to update their individual task files.

Additionally, this commit includes fixes to the `move` command to make it fully tag-aware.

* fix: move and clear subtasks mcp commands

* chore: fix format

* chore: fix unit tests

---------

Co-authored-by: Ralph Khreish <35776126+Crunchyman-ralph@users.noreply.github.com>
2025-06-15 03:47:56 +03:00

489 lines
14 KiB
JavaScript

import path from 'path';
import {
log,
readJSON,
writeJSON,
getCurrentTag,
setTasksForTag
} from '../utils.js';
import { isTaskDependentOn } from '../task-manager.js';
import generateTaskFiles from './generate-task-files.js';
/**
* Move one or more tasks/subtasks to new positions
* @param {string} tasksPath - Path to tasks.json file
* @param {string} sourceId - ID(s) of the task/subtask to move (e.g., '5' or '5.2' or '5,6,7')
* @param {string} destinationId - ID(s) of the destination (e.g., '7' or '7.3' or '7,8,9')
* @param {boolean} generateFiles - Whether to regenerate task files after moving
* @param {Object} options - Additional options
* @param {string} options.projectRoot - Project root directory for tag resolution
* @param {string} options.tag - Explicit tag to use (optional)
* @returns {Object} Result object with moved task details
*/
async function moveTask(
tasksPath,
sourceId,
destinationId,
generateFiles = false,
options = {}
) {
// Check if we have comma-separated IDs (batch move)
const sourceIds = sourceId.split(',').map((id) => id.trim());
const destinationIds = destinationId.split(',').map((id) => id.trim());
if (sourceIds.length !== destinationIds.length) {
throw new Error(
`Number of source IDs (${sourceIds.length}) must match number of destination IDs (${destinationIds.length})`
);
}
// For batch moves, process each pair sequentially
if (sourceIds.length > 1) {
const results = [];
for (let i = 0; i < sourceIds.length; i++) {
const result = await moveTask(
tasksPath,
sourceIds[i],
destinationIds[i],
false, // Don't generate files for each individual move
options
);
results.push(result);
}
// Generate files once at the end if requested
if (generateFiles) {
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
}
return {
message: `Successfully moved ${sourceIds.length} tasks/subtasks`,
moves: results
};
}
// Single move logic
// Read the raw data without tag resolution to preserve tagged structure
let rawData = readJSON(tasksPath, options.projectRoot); // No tag parameter
// Handle the case where readJSON returns resolved data with _rawTaggedData
if (rawData && rawData._rawTaggedData) {
// Use the raw tagged data and discard the resolved view
rawData = rawData._rawTaggedData;
}
// Determine the current tag
const currentTag =
options.tag || getCurrentTag(options.projectRoot) || 'master';
// Ensure the tag exists in the raw data
if (
!rawData ||
!rawData[currentTag] ||
!Array.isArray(rawData[currentTag].tasks)
) {
throw new Error(
`Invalid tasks file or tag "${currentTag}" not found at ${tasksPath}`
);
}
// Get the tasks for the current tag
const tasks = rawData[currentTag].tasks;
log(
'info',
`Moving task/subtask ${sourceId} to ${destinationId} (tag: ${currentTag})`
);
// Parse source and destination IDs
const isSourceSubtask = sourceId.includes('.');
const isDestSubtask = destinationId.includes('.');
let result;
if (isSourceSubtask && isDestSubtask) {
// Subtask to subtask
result = moveSubtaskToSubtask(tasks, sourceId, destinationId);
} else if (isSourceSubtask && !isDestSubtask) {
// Subtask to task
result = moveSubtaskToTask(tasks, sourceId, destinationId);
} else if (!isSourceSubtask && isDestSubtask) {
// Task to subtask
result = moveTaskToSubtask(tasks, sourceId, destinationId);
} else {
// Task to task
result = moveTaskToTask(tasks, sourceId, destinationId);
}
// Update the data structure with the modified tasks
rawData[currentTag].tasks = tasks;
// Always write the data object, never the _rawTaggedData directly
// The writeJSON function will filter out _rawTaggedData automatically
writeJSON(tasksPath, rawData, options.projectRoot, currentTag);
if (generateFiles) {
await generateTaskFiles(tasksPath, path.dirname(tasksPath));
}
return result;
}
// Helper functions for different move scenarios
function moveSubtaskToSubtask(tasks, sourceId, destinationId) {
// Parse IDs
const [sourceParentId, sourceSubtaskId] = sourceId
.split('.')
.map((id) => parseInt(id, 10));
const [destParentId, destSubtaskId] = destinationId
.split('.')
.map((id) => parseInt(id, 10));
// Find source and destination parent tasks
const sourceParentTask = tasks.find((t) => t.id === sourceParentId);
const destParentTask = tasks.find((t) => t.id === destParentId);
if (!sourceParentTask) {
throw new Error(`Source parent task with ID ${sourceParentId} not found`);
}
if (!destParentTask) {
throw new Error(
`Destination parent task with ID ${destParentId} not found`
);
}
// Initialize subtasks arrays if they don't exist (based on commit fixes)
if (!sourceParentTask.subtasks) {
sourceParentTask.subtasks = [];
}
if (!destParentTask.subtasks) {
destParentTask.subtasks = [];
}
// Find source subtask
const sourceSubtaskIndex = sourceParentTask.subtasks.findIndex(
(st) => st.id === sourceSubtaskId
);
if (sourceSubtaskIndex === -1) {
throw new Error(`Source subtask ${sourceId} not found`);
}
const sourceSubtask = sourceParentTask.subtasks[sourceSubtaskIndex];
if (sourceParentId === destParentId) {
// Moving within the same parent
if (destParentTask.subtasks.length > 0) {
const destSubtaskIndex = destParentTask.subtasks.findIndex(
(st) => st.id === destSubtaskId
);
if (destSubtaskIndex !== -1) {
// Remove from old position
sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
// Insert at new position (adjust index if moving within same array)
const adjustedIndex =
sourceSubtaskIndex < destSubtaskIndex
? destSubtaskIndex - 1
: destSubtaskIndex;
destParentTask.subtasks.splice(adjustedIndex + 1, 0, sourceSubtask);
} else {
// Destination subtask doesn't exist, insert at end
sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
destParentTask.subtasks.push(sourceSubtask);
}
} else {
// No existing subtasks, this will be the first one
sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
destParentTask.subtasks.push(sourceSubtask);
}
} else {
// Moving between different parents
moveSubtaskToAnotherParent(
sourceSubtask,
sourceParentTask,
sourceSubtaskIndex,
destParentTask,
destSubtaskId
);
}
return {
message: `Moved subtask ${sourceId} to ${destinationId}`,
movedItem: sourceSubtask
};
}
function moveSubtaskToTask(tasks, sourceId, destinationId) {
// Parse source ID
const [sourceParentId, sourceSubtaskId] = sourceId
.split('.')
.map((id) => parseInt(id, 10));
const destTaskId = parseInt(destinationId, 10);
// Find source parent and destination task
const sourceParentTask = tasks.find((t) => t.id === sourceParentId);
if (!sourceParentTask) {
throw new Error(`Source parent task with ID ${sourceParentId} not found`);
}
if (!sourceParentTask.subtasks) {
throw new Error(`Source parent task ${sourceParentId} has no subtasks`);
}
// Find source subtask
const sourceSubtaskIndex = sourceParentTask.subtasks.findIndex(
(st) => st.id === sourceSubtaskId
);
if (sourceSubtaskIndex === -1) {
throw new Error(`Source subtask ${sourceId} not found`);
}
const sourceSubtask = sourceParentTask.subtasks[sourceSubtaskIndex];
// Check if destination task exists
const existingDestTask = tasks.find((t) => t.id === destTaskId);
if (existingDestTask) {
throw new Error(
`Cannot move to existing task ID ${destTaskId}. Choose a different ID or use subtask destination.`
);
}
// Create new task from subtask
const newTask = {
id: destTaskId,
title: sourceSubtask.title,
description: sourceSubtask.description,
status: sourceSubtask.status || 'pending',
dependencies: sourceSubtask.dependencies || [],
priority: sourceSubtask.priority || 'medium',
details: sourceSubtask.details || '',
testStrategy: sourceSubtask.testStrategy || '',
subtasks: []
};
// Remove subtask from source parent
sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
// Insert new task in correct position
const insertIndex = tasks.findIndex((t) => t.id > destTaskId);
if (insertIndex === -1) {
tasks.push(newTask);
} else {
tasks.splice(insertIndex, 0, newTask);
}
return {
message: `Converted subtask ${sourceId} to task ${destinationId}`,
movedItem: newTask
};
}
function moveTaskToSubtask(tasks, sourceId, destinationId) {
// Parse IDs
const sourceTaskId = parseInt(sourceId, 10);
const [destParentId, destSubtaskId] = destinationId
.split('.')
.map((id) => parseInt(id, 10));
// Find source task and destination parent
const sourceTaskIndex = tasks.findIndex((t) => t.id === sourceTaskId);
const destParentTask = tasks.find((t) => t.id === destParentId);
if (sourceTaskIndex === -1) {
throw new Error(`Source task with ID ${sourceTaskId} not found`);
}
if (!destParentTask) {
throw new Error(
`Destination parent task with ID ${destParentId} not found`
);
}
const sourceTask = tasks[sourceTaskIndex];
// Initialize subtasks array if it doesn't exist (based on commit fixes)
if (!destParentTask.subtasks) {
destParentTask.subtasks = [];
}
// Create new subtask from task
const newSubtask = {
id: destSubtaskId,
title: sourceTask.title,
description: sourceTask.description,
status: sourceTask.status || 'pending',
dependencies: sourceTask.dependencies || [],
details: sourceTask.details || '',
testStrategy: sourceTask.testStrategy || ''
};
// Find insertion position (based on commit fixes)
let destSubtaskIndex = -1;
if (destParentTask.subtasks.length > 0) {
destSubtaskIndex = destParentTask.subtasks.findIndex(
(st) => st.id === destSubtaskId
);
if (destSubtaskIndex === -1) {
// Subtask doesn't exist, we'll insert at the end
destSubtaskIndex = destParentTask.subtasks.length - 1;
}
}
// Insert at specific position (based on commit fixes)
const insertPosition = destSubtaskIndex === -1 ? 0 : destSubtaskIndex + 1;
destParentTask.subtasks.splice(insertPosition, 0, newSubtask);
// Remove the original task from the tasks array
tasks.splice(sourceTaskIndex, 1);
return {
message: `Converted task ${sourceId} to subtask ${destinationId}`,
movedItem: newSubtask
};
}
function moveTaskToTask(tasks, sourceId, destinationId) {
const sourceTaskId = parseInt(sourceId, 10);
const destTaskId = parseInt(destinationId, 10);
// Find source task
const sourceTaskIndex = tasks.findIndex((t) => t.id === sourceTaskId);
if (sourceTaskIndex === -1) {
throw new Error(`Source task with ID ${sourceTaskId} not found`);
}
const sourceTask = tasks[sourceTaskIndex];
// Check if destination exists
const destTaskIndex = tasks.findIndex((t) => t.id === destTaskId);
if (destTaskIndex !== -1) {
// Destination exists - this could be overwriting or swapping
const destTask = tasks[destTaskIndex];
// For now, throw an error to avoid accidental overwrites
throw new Error(
`Task with ID ${destTaskId} already exists. Use a different destination ID.`
);
} else {
// Destination doesn't exist - create new task ID
return moveTaskToNewId(tasks, sourceTaskIndex, sourceTask, destTaskId);
}
}
function moveSubtaskToAnotherParent(
sourceSubtask,
sourceParentTask,
sourceSubtaskIndex,
destParentTask,
destSubtaskId
) {
const destSubtaskId_num = parseInt(destSubtaskId, 10);
// Create new subtask with destination ID
const newSubtask = {
...sourceSubtask,
id: destSubtaskId_num
};
// Initialize subtasks array if it doesn't exist (based on commit fixes)
if (!destParentTask.subtasks) {
destParentTask.subtasks = [];
}
// Find insertion position
let destSubtaskIndex = -1;
if (destParentTask.subtasks.length > 0) {
destSubtaskIndex = destParentTask.subtasks.findIndex(
(st) => st.id === destSubtaskId_num
);
if (destSubtaskIndex === -1) {
// Subtask doesn't exist, we'll insert at the end
destSubtaskIndex = destParentTask.subtasks.length - 1;
}
}
// Insert at the destination position (based on commit fixes)
const insertPosition = destSubtaskIndex === -1 ? 0 : destSubtaskIndex + 1;
destParentTask.subtasks.splice(insertPosition, 0, newSubtask);
// Remove the subtask from the original parent
sourceParentTask.subtasks.splice(sourceSubtaskIndex, 1);
return newSubtask;
}
function moveTaskToNewId(tasks, sourceTaskIndex, sourceTask, destTaskId) {
const destTaskIndex = tasks.findIndex((t) => t.id === destTaskId);
// Create moved task with new ID
const movedTask = {
...sourceTask,
id: destTaskId
};
// Update any dependencies that reference the old task ID
tasks.forEach((task) => {
if (task.dependencies && task.dependencies.includes(sourceTask.id)) {
const depIndex = task.dependencies.indexOf(sourceTask.id);
task.dependencies[depIndex] = destTaskId;
}
if (task.subtasks) {
task.subtasks.forEach((subtask) => {
if (
subtask.dependencies &&
subtask.dependencies.includes(sourceTask.id)
) {
const depIndex = subtask.dependencies.indexOf(sourceTask.id);
subtask.dependencies[depIndex] = destTaskId;
}
});
}
});
// Update dependencies within movedTask's subtasks that reference sibling subtasks
if (Array.isArray(movedTask.subtasks)) {
movedTask.subtasks.forEach((subtask) => {
if (Array.isArray(subtask.dependencies)) {
subtask.dependencies = subtask.dependencies.map((dep) => {
// If dependency is a string like "oldParent.subId", update to "newParent.subId"
if (typeof dep === 'string' && dep.includes('.')) {
const [depParent, depSub] = dep.split('.');
if (parseInt(depParent, 10) === sourceTask.id) {
return `${destTaskId}.${depSub}`;
}
}
// If dependency is a number, and matches a subtask ID in the moved task, leave as is (context is implied)
return dep;
});
}
});
}
// Strategy based on commit fixes: remove source first, then replace destination
// This avoids index shifting problems
// Remove the source task first
tasks.splice(sourceTaskIndex, 1);
// Adjust the destination index if the source was before the destination
// Since we removed the source, indices after it shift down by 1
const adjustedDestIndex =
sourceTaskIndex < destTaskIndex ? destTaskIndex - 1 : destTaskIndex;
// Replace the placeholder destination task with the moved task (based on commit fixes)
if (adjustedDestIndex >= 0 && adjustedDestIndex < tasks.length) {
tasks[adjustedDestIndex] = movedTask;
} else {
// Insert at the end if index is out of bounds
tasks.push(movedTask);
}
log('info', `Moved task ${sourceTask.id} to new ID ${destTaskId}`);
return {
message: `Moved task ${sourceTask.id} to new ID ${destTaskId}`,
movedItem: movedTask
};
}
export default moveTask;