feat: finalize set-status

This commit is contained in:
Ralph Khreish
2025-09-18 01:46:13 +02:00
parent d66f0e5789
commit 0d0db63c93
11 changed files with 197 additions and 135 deletions

View File

@@ -288,6 +288,26 @@ export class SetStatusCommand extends Command {
getLastResult(): SetStatusResult | undefined {
return this.lastResult;
}
/**
* Static method to register this command on an existing program
* This is for gradual migration - allows commands.js to use this
*/
static registerOn(program: Command): Command {
const setStatusCommand = new SetStatusCommand();
program.addCommand(setStatusCommand);
return setStatusCommand;
}
/**
* Alternative registration that returns the command for chaining
* Can also configure the command name if needed
*/
static register(program: Command, name?: string): SetStatusCommand {
const setStatusCommand = new SetStatusCommand(name);
program.addCommand(setStatusCommand);
return setStatusCommand;
}
}
/**

View File

@@ -3,7 +3,7 @@
"private": true,
"displayName": "TaskMaster",
"description": "A visual Kanban board interface for TaskMaster projects in VS Code",
"version": "0.25.0-rc.0",
"version": "0.24.2",
"publisher": "Hamster",
"icon": "assets/icon.png",
"engines": {

2
package-lock.json generated
View File

@@ -365,7 +365,7 @@
}
},
"apps/extension": {
"version": "0.25.0-rc.0",
"version": "0.24.2",
"dependencies": {
"task-master-ai": "*"
},

View File

@@ -12,7 +12,7 @@
"workspaces": ["apps/*", "packages/*", "."],
"scripts": {
"build": "npm run build:build-config && cross-env NODE_ENV=production tsdown",
"dev": "tsdown --watch='packages/*/src/**/*' --watch='apps/cli/src/**/*' --watch='bin/**/*' --watch='mcp-server/**/*'",
"dev": "tsdown --watch",
"turbo:dev": "turbo dev",
"turbo:build": "turbo build",
"turbo:typecheck": "turbo typecheck",

View File

@@ -17,6 +17,14 @@ export interface IStorage {
*/
loadTasks(tag?: string): Promise<Task[]>;
/**
* Load a single task by ID
* @param taskId - ID of the task to load
* @param tag - Optional tag context for the task
* @returns Promise that resolves to the task or null if not found
*/
loadTask(taskId: string, tag?: string): Promise<Task | null>;
/**
* Save tasks to storage, replacing existing tasks
* @param tasks - Array of tasks to save
@@ -175,6 +183,7 @@ export abstract class BaseStorage implements IStorage {
// Abstract methods that must be implemented by concrete classes
abstract loadTasks(tag?: string): Promise<Task[]>;
abstract loadTask(taskId: string, tag?: string): Promise<Task | null>;
abstract saveTasks(tasks: Task[], tag?: string): Promise<void>;
abstract appendTasks(tasks: Task[], tag?: string): Promise<void>;
abstract updateTask(

View File

@@ -127,7 +127,7 @@ export class TaskMapper {
/**
* Maps database status to internal status
*/
private static mapStatus(
static mapStatus(
status: Database['public']['Enums']['task_status']
): Task['status'] {
switch (status) {

View File

@@ -3,6 +3,30 @@ import { Task } from '../types/index.js';
import { Database } from '../types/database.types.js';
import { TaskMapper } from '../mappers/TaskMapper.js';
import { AuthManager } from '../auth/auth-manager.js';
import { z } from 'zod';
// Zod schema for task status validation
const TaskStatusSchema = z.enum([
'pending',
'in-progress',
'done',
'review',
'deferred',
'cancelled',
'blocked'
]);
// Zod schema for task updates
const TaskUpdateSchema = z
.object({
title: z.string().min(1).optional(),
description: z.string().optional(),
status: TaskStatusSchema.optional(),
priority: z.enum(['low', 'medium', 'high', 'critical']).optional(),
details: z.string().optional(),
testStrategy: z.string().optional()
})
.partial();
export class SupabaseTaskRepository {
constructor(private supabase: SupabaseClient<Database>) {}
@@ -60,12 +84,22 @@ export class SupabaseTaskRepository {
return TaskMapper.mapDatabaseTasksToTasks(tasks, depsData || []);
}
async getTask(accountId: string, taskId: string): Promise<Task | null> {
async getTask(_projectId: string, taskId: string): Promise<Task | null> {
// Get the current context to determine briefId (projectId not used in Supabase context)
const authManager = AuthManager.getInstance();
const context = authManager.getContext();
if (!context || !context.briefId) {
throw new Error(
'No brief selected. Please select a brief first using: tm context brief'
);
}
const { data, error } = await this.supabase
.from('tasks')
.select('*')
.eq('account_id', accountId)
.ilike('display_id', taskId)
.eq('brief_id', context.briefId)
.eq('display_id', taskId.toUpperCase())
.single();
if (error) {
@@ -107,4 +141,85 @@ export class SupabaseTaskRepository {
dependenciesByTaskId
);
}
async updateTask(
projectId: string,
taskId: string,
updates: Partial<Task>
): Promise<Task> {
// Get the current context to determine briefId
const authManager = AuthManager.getInstance();
const context = authManager.getContext();
if (!context || !context.briefId) {
throw new Error(
'No brief selected. Please select a brief first using: tm context brief'
);
}
// Validate updates using Zod schema
try {
TaskUpdateSchema.parse(updates);
} catch (error) {
if (error instanceof z.ZodError) {
const errorMessages = error.errors
.map((err) => `${err.path.join('.')}: ${err.message}`)
.join(', ');
throw new Error(`Invalid task update data: ${errorMessages}`);
}
throw error;
}
// Convert Task fields to database fields - only include fields that actually exist in the database
const dbUpdates: any = {};
if (updates.title !== undefined) dbUpdates.title = updates.title;
if (updates.description !== undefined)
dbUpdates.description = updates.description;
if (updates.status !== undefined)
dbUpdates.status = this.mapStatusToDatabase(updates.status);
if (updates.priority !== undefined) dbUpdates.priority = updates.priority;
// Skip fields that don't exist in database schema: details, testStrategy, etc.
// Update the task
const { error } = await this.supabase
.from('tasks')
.update(dbUpdates)
.eq('brief_id', context.briefId)
.eq('display_id', taskId.toUpperCase());
if (error) {
throw new Error(`Failed to update task: ${error.message}`);
}
// Return the updated task by fetching it
const updatedTask = await this.getTask(projectId, taskId);
if (!updatedTask) {
throw new Error(`Failed to retrieve updated task ${taskId}`);
}
return updatedTask;
}
/**
* Maps internal status to database status
*/
private mapStatusToDatabase(
status: string
): Database['public']['Enums']['task_status'] {
switch (status) {
case 'pending':
return 'todo';
case 'in-progress':
case 'in_progress': // Accept both formats
return 'in_progress';
case 'done':
return 'done';
default:
throw new Error(
`Invalid task status: ${status}. Valid statuses are: pending, in-progress, done`
);
}
}
}

View File

@@ -374,6 +374,7 @@ export class TaskService {
newStatus: TaskStatus;
taskId: string;
}> {
// Ensure we have storage
if (!this.storage) {
throw new TaskMasterError(
@@ -385,72 +386,42 @@ export class TaskService {
// Use provided tag or get active tag
const activeTag = tag || this.getActiveTag();
// Get all tasks to find the one to update
const result = await this.getTaskList({
tag: activeTag,
includeSubtasks: true
});
// Handle both regular tasks (e.g., "5") and subtasks (e.g., "5.2")
const taskIdStr = String(taskId);
let taskToUpdate: Task | undefined;
let oldStatus: TaskStatus;
// TODO: For now, assume it's a regular task and just try to update directly
// In the future, we can add subtask support if needed
if (taskIdStr.includes('.')) {
// Handle subtask
const [parentIdStr, subtaskIdStr] = taskIdStr.split('.');
const parentId = parseInt(parentIdStr, 10);
const subtaskId = parseInt(subtaskIdStr, 10);
const parentTask = result.tasks.find((t) => t.id === String(parentId));
if (!parentTask || !parentTask.subtasks) {
throw new TaskMasterError(
`Parent task ${parentId} not found or has no subtasks`,
ERROR_CODES.TASK_NOT_FOUND
'Subtask status updates not yet supported in API storage',
ERROR_CODES.NOT_IMPLEMENTED
);
}
const subtask = parentTask.subtasks.find(
(st) => String(st.id) === String(subtaskId)
);
if (!subtask) {
// Get the current task to get old status (simple, direct approach)
let currentTask: Task | null;
try {
// Try to get the task directly
currentTask = await this.storage.loadTask(taskIdStr, activeTag);
} catch (error) {
throw new TaskMasterError(
`Subtask ${taskIdStr} not found`,
ERROR_CODES.TASK_NOT_FOUND
`Failed to load task ${taskIdStr}`,
ERROR_CODES.TASK_NOT_FOUND,
{ taskId: taskIdStr },
error as Error
);
}
oldStatus = subtask.status;
// Update the subtask status
subtask.status = newStatus;
// Update the parent task with the modified subtask
await this.storage.updateTask(parentTask.id, parentTask, activeTag);
taskToUpdate = parentTask;
} else {
// Handle regular task
const taskIdNum = parseInt(taskIdStr, 10);
taskToUpdate = result.tasks.find(
(t) => String(t.id) === String(taskIdNum)
);
if (!taskToUpdate) {
if (!currentTask) {
throw new TaskMasterError(
`Task ${taskIdStr} not found`,
ERROR_CODES.TASK_NOT_FOUND
);
}
oldStatus = taskToUpdate.status;
const oldStatus = currentTask.status;
// Update the task status
taskToUpdate.status = newStatus;
// Save the updated task
await this.storage.updateTask(taskToUpdate.id, taskToUpdate, activeTag);
}
// Simple, direct update - just change the status
await this.storage.updateTask(taskIdStr, { status: newStatus }, activeTag);
return {
success: true,

View File

@@ -223,14 +223,6 @@ export class ApiStorage implements IStorage {
await this.ensureInitialized();
try {
if (tag) {
// Check if task is in tag
const tagData = this.tagsCache.get(tag);
if (!tagData || !tagData.tasks.includes(taskId)) {
return null;
}
}
return await this.retryOperation(() =>
this.repository.getTask(this.projectId, taskId)
);
@@ -477,6 +469,7 @@ export class ApiStorage implements IStorage {
updates: Partial<Task>,
tag?: string
): Promise<void> {
await this.ensureInitialized();
try {

View File

@@ -102,6 +102,14 @@ export class FileStorage implements IStorage {
}
}
/**
* Load a single task by ID from the tasks.json file
*/
async loadTask(taskId: string, tag?: string): Promise<Task | null> {
const tasks = await this.loadTasks(tag);
return tasks.find(task => task.id === taskId) || null;
}
/**
* Save tasks for a specific tag in the single tasks.json file
*/

View File

@@ -20,14 +20,14 @@ import {
ListTasksCommand,
ShowCommand,
AuthCommand,
ContextCommand
ContextCommand,
SetStatusCommand
} from '@tm/cli';
import {
parsePRD,
updateTasks,
generateTaskFiles,
setTaskStatus,
listTasks,
expandTask,
expandAllTasks,
@@ -1684,63 +1684,9 @@ function registerCommands(programInstance) {
});
});
// set-status command
programInstance
.command('set-status')
.alias('mark')
.alias('set')
.description('Set the status of a task')
.option(
'-i, --id <id>',
'Task ID (can be comma-separated for multiple tasks)'
)
.option(
'-s, --status <status>',
`New status (one of: ${TASK_STATUS_OPTIONS.join(', ')})`
)
.option(
'-f, --file <file>',
'Path to the tasks file',
TASKMASTER_TASKS_FILE
)
.option('--tag <tag>', 'Specify tag context for task operations')
.action(async (options) => {
// Initialize TaskMaster
const taskMaster = initTaskMaster({
tasksPath: options.file || true,
tag: options.tag
});
const taskId = options.id;
const status = options.status;
if (!taskId || !status) {
console.error(chalk.red('Error: Both --id and --status are required'));
process.exit(1);
}
if (!isValidTaskStatus(status)) {
console.error(
chalk.red(
`Error: Invalid status value: ${status}. Use one of: ${TASK_STATUS_OPTIONS.join(', ')}`
)
);
process.exit(1);
}
const tag = taskMaster.getCurrentTag();
displayCurrentTagIndicator(tag);
console.log(
chalk.blue(`Setting status of task(s) ${taskId} to: ${status}`)
);
await setTaskStatus(taskMaster.getTasksPath(), taskId, status, {
projectRoot: taskMaster.getProjectRoot(),
tag
});
});
// Register the set-status command from @tm/cli
// Handles task status updates with proper error handling and validation
SetStatusCommand.registerOn(programInstance);
// NEW: Register the new list command from @tm/cli
// This command handles all its own configuration and logic