UNPKG

@boundless-oss/atlas

Version:

Atlas - MCP Server for comprehensive startup project management

656 lines 28.3 kB
import { randomUUID } from 'crypto'; import { createTool, createSuccessResult, createErrorResult } from '../../core/tool-framework.js'; // Predefined workflow templates const WORKFLOW_TEMPLATES = { simple: ['To Do', 'Doing', 'Done'], development: ['Backlog', 'To Do', 'In Progress', 'Code Review', 'Testing', 'Done'], support: ['New', 'Triaged', 'In Progress', 'Waiting for Customer', 'Resolved', 'Closed'], custom: [] }; /** * Create a new kanban board */ const createBoardTool = createTool({ name: 'create_kanban_board', description: 'Create a new kanban board with customizable columns and workflow', category: 'kanban', inputSchema: { type: 'object', properties: { name: { type: 'string', description: 'Board name', minLength: 1, maxLength: 200 }, description: { type: 'string', description: 'Board description', maxLength: 1000 }, columns: { type: 'array', items: { type: 'string', minLength: 1, maxLength: 50 }, description: 'Custom column names (overrides workflowType)', maxItems: 10 }, workflowType: { type: 'string', enum: ['simple', 'development', 'support', 'custom'], default: 'simple', description: 'Predefined workflow template' } }, required: ['name'], additionalProperties: false }, async execute(input, context) { try { const boardId = randomUUID(); const now = Date.now(); // Check for duplicate board names const existingBoardCheck = await context.db.get('SELECT id FROM kanban_boards WHERE name = ? AND project_id = ?', [input.name, context.projectId || 'default']); if (existingBoardCheck.success && existingBoardCheck.data) { return createErrorResult({ code: 'DUPLICATE_RESOURCE', message: 'A board with this name already exists', category: 'validation' }); } // Determine columns to use const columns = input.columns && input.columns.length > 0 ? input.columns : WORKFLOW_TEMPLATES[input.workflowType || 'simple'] || WORKFLOW_TEMPLATES.simple; if (columns.length === 0) { return createErrorResult({ code: 'INVALID_INPUT', message: 'At least one column is required', category: 'validation' }); } // Insert board into database const result = await context.db.run(`INSERT INTO kanban_boards (id, name, description, columns, workflow_type, project_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, [ boardId, input.name, input.description || '', JSON.stringify(columns), input.workflowType || 'simple', context.projectId || 'default', now, now ]); if (!result.success) { return createErrorResult({ code: 'DATABASE_ERROR', message: 'Failed to create board', details: { error: result.error }, category: 'system' }); } return createSuccessResult({ board: { id: boardId, name: input.name, description: input.description || '', columns, workflowType: input.workflowType || 'simple', taskCount: 0, createdAt: new Date(now).toISOString() }, message: `Kanban board "${input.name}" created successfully with ${columns.length} columns`, nextSteps: [ 'Add tasks to your board', 'Customize column names if needed', 'Set up team member assignments' ] }); } catch (error) { return createErrorResult({ code: 'EXECUTION_ERROR', message: `Failed to create board: ${error instanceof Error ? error.message : 'Unknown error'}`, category: 'execution' }); } } }); /** * Create a new task on a kanban board */ const createTaskTool = createTool({ name: 'create_kanban_task', description: 'Create a new task on a kanban board', category: 'kanban', inputSchema: { type: 'object', properties: { boardId: { type: 'string', description: 'Board ID to create task on', pattern: '^[a-zA-Z0-9-]+$' }, title: { type: 'string', description: 'Task title', minLength: 1, maxLength: 200 }, description: { type: 'string', description: 'Task description', maxLength: 2000 }, assignedTo: { type: 'string', description: 'Team member assigned to task', maxLength: 100 }, priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'], default: 'medium', description: 'Task priority level' }, tags: { type: 'array', items: { type: 'string', maxLength: 50 }, description: 'Task tags for categorization', maxItems: 10 }, dueDate: { type: 'string', format: 'date', description: 'Task due date (YYYY-MM-DD format)' }, storyPoints: { type: 'integer', description: 'Story points for estimation', minimum: 1, maximum: 100 } }, required: ['boardId', 'title'], additionalProperties: false }, async execute(input, context) { try { // Verify board exists and get first column const boardResult = await context.db.get('SELECT id, name, columns FROM kanban_boards WHERE id = ? AND project_id = ?', [input.boardId, context.projectId || 'default']); if (!boardResult.success || !boardResult.data) { return createErrorResult({ code: 'RESOURCE_NOT_FOUND', message: 'Board not found', details: { boardId: input.boardId }, category: 'validation' }); } const board = boardResult.data; const columns = JSON.parse(board.columns); const firstColumn = columns[0]; if (!firstColumn) { return createErrorResult({ code: 'INVALID_STATE', message: 'Board has no columns', details: { boardId: input.boardId }, category: 'validation' }); } const taskId = randomUUID(); const now = Date.now(); // Get next position in first column const positionResult = await context.db.get('SELECT MAX(position) as maxPosition FROM kanban_tasks WHERE board_id = ? AND column_name = ?', [input.boardId, firstColumn]); const nextPosition = (positionResult.data?.maxPosition || 0) + 1; // Validate due date if provided let dueDateTimestamp = null; if (input.dueDate) { const dueDate = new Date(input.dueDate); if (isNaN(dueDate.getTime())) { return createErrorResult({ code: 'INVALID_INPUT', message: 'Invalid due date format', category: 'validation' }); } dueDateTimestamp = dueDate.getTime(); } // Insert task into database const result = await context.db.run(`INSERT INTO kanban_tasks (id, board_id, title, description, assigned_to, priority, tags, due_date, story_points, column_name, position, status, project_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ taskId, input.boardId, input.title, input.description || '', input.assignedTo || null, input.priority || 'medium', JSON.stringify(input.tags || []), dueDateTimestamp, input.storyPoints || null, firstColumn, nextPosition, 'active', context.projectId || 'default', now, now ]); if (!result.success) { return createErrorResult({ code: 'DATABASE_ERROR', message: 'Failed to create task', details: { error: result.error }, category: 'system' }); } return createSuccessResult({ task: { id: taskId, boardId: input.boardId, title: input.title, description: input.description || '', assignedTo: input.assignedTo || null, priority: input.priority || 'medium', tags: input.tags || [], dueDate: input.dueDate || null, storyPoints: input.storyPoints || null, column: firstColumn, position: nextPosition, status: 'active', createdAt: new Date(now).toISOString() }, message: `Task "${input.title}" created on board "${board.name}" in column "${firstColumn}"`, boardName: board.name, column: firstColumn }); } catch (error) { return createErrorResult({ code: 'EXECUTION_ERROR', message: `Failed to create task: ${error instanceof Error ? error.message : 'Unknown error'}`, category: 'execution' }); } } }); /** * Move a task between columns */ const moveTaskTool = createTool({ name: 'move_kanban_task', description: 'Move a task to a different column on the kanban board', category: 'kanban', inputSchema: { type: 'object', properties: { taskId: { type: 'string', description: 'Task ID to move', pattern: '^[a-zA-Z0-9-]+$' }, toColumn: { type: 'string', description: 'Target column name', minLength: 1, maxLength: 50 }, position: { type: 'integer', description: 'Position in target column (0 = top)', minimum: 0 } }, required: ['taskId', 'toColumn'], additionalProperties: false }, async execute(input, context) { try { // Get task and verify it exists const taskResult = await context.db.get(`SELECT t.*, b.name as board_name, b.columns FROM kanban_tasks t JOIN kanban_boards b ON t.board_id = b.id WHERE t.id = ? AND t.project_id = ?`, [input.taskId, context.projectId || 'default']); if (!taskResult.success || !taskResult.data) { return createErrorResult({ code: 'RESOURCE_NOT_FOUND', message: 'Task not found', details: { taskId: input.taskId }, category: 'validation' }); } const task = taskResult.data; const columns = JSON.parse(task.columns); // Verify target column exists if (!columns.includes(input.toColumn)) { return createErrorResult({ code: 'INVALID_INPUT', message: `Column "${input.toColumn}" does not exist on this board`, details: { availableColumns: columns }, category: 'validation' }); } const oldColumn = task.column_name; // If moving to same column, just update position if (oldColumn === input.toColumn && input.position !== undefined) { // Update positions await context.db.transaction(async (tx) => { // Get current max position in target column const maxPosResult = await tx.get('SELECT MAX(position) as maxPosition FROM kanban_tasks WHERE board_id = ? AND column_name = ?', [task.board_id, input.toColumn]); const maxPosition = maxPosResult?.maxPosition || 0; const targetPosition = Math.min(input.position, maxPosition); // Shift other tasks if needed if (targetPosition < task.position) { // Moving up - shift others down await tx.run('UPDATE kanban_tasks SET position = position + 1 WHERE board_id = ? AND column_name = ? AND position >= ? AND position < ?', [task.board_id, input.toColumn, targetPosition, task.position]); } else if (targetPosition > task.position) { // Moving down - shift others up await tx.run('UPDATE kanban_tasks SET position = position - 1 WHERE board_id = ? AND column_name = ? AND position > ? AND position <= ?', [task.board_id, input.toColumn, task.position, targetPosition]); } // Update task position await tx.run('UPDATE kanban_tasks SET position = ?, updated_at = ? WHERE id = ?', [targetPosition, Date.now(), input.taskId]); }); } else { // Moving between columns await context.db.transaction(async (tx) => { // Get next position in target column const maxPosResult = await tx.get('SELECT MAX(position) as maxPosition FROM kanban_tasks WHERE board_id = ? AND column_name = ?', [task.board_id, input.toColumn]); const targetPosition = input.position !== undefined ? Math.min(input.position, (maxPosResult?.maxPosition || 0) + 1) : (maxPosResult?.maxPosition || 0) + 1; // Shift tasks in target column if needed if (input.position !== undefined) { await tx.run('UPDATE kanban_tasks SET position = position + 1 WHERE board_id = ? AND column_name = ? AND position >= ?', [task.board_id, input.toColumn, targetPosition]); } // Shift tasks in old column up await tx.run('UPDATE kanban_tasks SET position = position - 1 WHERE board_id = ? AND column_name = ? AND position > ?', [task.board_id, oldColumn, task.position]); // Update task column and position await tx.run('UPDATE kanban_tasks SET column_name = ?, position = ?, updated_at = ? WHERE id = ?', [input.toColumn, targetPosition, Date.now(), input.taskId]); }); } return createSuccessResult({ message: `Task "${task.title}" moved from "${oldColumn}" to "${input.toColumn}"`, taskId: input.taskId, taskTitle: task.title, fromColumn: oldColumn, toColumn: input.toColumn, boardName: task.board_name, newPosition: input.position }); } catch (error) { return createErrorResult({ code: 'EXECUTION_ERROR', message: `Failed to move task: ${error instanceof Error ? error.message : 'Unknown error'}`, category: 'execution' }); } } }); /** * Get board with all tasks */ const getBoardTool = createTool({ name: 'get_kanban_board', description: 'Get a kanban board with all its tasks organized by columns', category: 'kanban', readOnly: true, inputSchema: { type: 'object', properties: { boardId: { type: 'string', description: 'Board ID to retrieve', pattern: '^[a-zA-Z0-9-]+$' }, includeArchived: { type: 'boolean', description: 'Include archived tasks', default: false } }, required: ['boardId'], additionalProperties: false }, async execute(input, context) { try { // Get board info const boardResult = await context.db.get('SELECT * FROM kanban_boards WHERE id = ? AND project_id = ?', [input.boardId, context.projectId || 'default']); if (!boardResult.success || !boardResult.data) { return createErrorResult({ code: 'RESOURCE_NOT_FOUND', message: 'Board not found', details: { boardId: input.boardId }, category: 'validation' }); } const board = boardResult.data; const columns = JSON.parse(board.columns); // Get all tasks for the board const tasksQuery = input.includeArchived ? 'SELECT * FROM kanban_tasks WHERE board_id = ? ORDER BY column_name, position' : 'SELECT * FROM kanban_tasks WHERE board_id = ? AND status = ? ORDER BY column_name, position'; const tasksParams = input.includeArchived ? [input.boardId] : [input.boardId, 'active']; const tasksResult = await context.db.query(tasksQuery, tasksParams); if (!tasksResult.success) { return createErrorResult({ code: 'DATABASE_ERROR', message: 'Failed to get board tasks', details: { error: tasksResult.error }, category: 'system' }); } // Organize tasks by column const tasksByColumn = {}; columns.forEach((column) => { tasksByColumn[column] = []; }); (tasksResult.data || []).forEach((task) => { if (tasksByColumn[task.column_name]) { tasksByColumn[task.column_name].push({ id: task.id, title: task.title, description: task.description, assignedTo: task.assigned_to, priority: task.priority, tags: JSON.parse(task.tags || '[]'), dueDate: task.due_date ? new Date(task.due_date).toISOString() : null, storyPoints: task.story_points, position: task.position, status: task.status, createdAt: new Date(task.created_at).toISOString(), updatedAt: new Date(task.updated_at).toISOString() }); } }); // Calculate statistics const totalTasks = (tasksResult.data || []).length; const tasksByStatus = (tasksResult.data || []).reduce((acc, task) => { acc[task.status] = (acc[task.status] || 0) + 1; return acc; }, {}); return createSuccessResult({ board: { id: board.id, name: board.name, description: board.description, columns, workflowType: board.workflow_type, createdAt: new Date(board.created_at).toISOString(), updatedAt: new Date(board.updated_at).toISOString() }, tasksByColumn, statistics: { totalTasks, tasksByStatus, tasksByColumn: Object.fromEntries(columns.map((col) => [col, tasksByColumn[col].length])) } }); } catch (error) { return createErrorResult({ code: 'EXECUTION_ERROR', message: `Failed to get board: ${error instanceof Error ? error.message : 'Unknown error'}`, category: 'execution' }); } } }); /** * Get board analytics and insights */ const getBoardAnalyticsTool = createTool({ name: 'get_kanban_analytics', description: 'Get analytics and insights for a kanban board including cycle time and throughput', category: 'kanban', readOnly: true, inputSchema: { type: 'object', properties: { boardId: { type: 'string', description: 'Board ID to analyze', pattern: '^[a-zA-Z0-9-]+$' }, timeframe: { type: 'string', enum: ['24h', '7d', '30d', '90d'], default: '30d', description: 'Analysis timeframe' } }, required: ['boardId'], additionalProperties: false }, async execute(input, context) { try { // Verify board exists const boardResult = await context.db.get('SELECT name, columns FROM kanban_boards WHERE id = ? AND project_id = ?', [input.boardId, context.projectId || 'default']); if (!boardResult.success || !boardResult.data) { return createErrorResult({ code: 'RESOURCE_NOT_FOUND', message: 'Board not found', details: { boardId: input.boardId }, category: 'validation' }); } const board = boardResult.data; const columns = JSON.parse(board.columns); // Calculate time threshold const now = Date.now(); let timeThreshold = now; switch (input.timeframe) { case '24h': timeThreshold = now - (24 * 60 * 60 * 1000); break; case '7d': timeThreshold = now - (7 * 24 * 60 * 60 * 1000); break; case '30d': timeThreshold = now - (30 * 24 * 60 * 60 * 1000); break; case '90d': timeThreshold = now - (90 * 24 * 60 * 60 * 1000); break; } // Get task statistics for the timeframe const tasksResult = await context.db.query(`SELECT COUNT(*) as total_tasks, COUNT(CASE WHEN status = 'active' THEN 1 END) as active_tasks, COUNT(CASE WHEN status = 'archived' THEN 1 END) as completed_tasks, AVG(CASE WHEN status = 'archived' THEN updated_at - created_at END) as avg_cycle_time, column_name, priority, assigned_to FROM kanban_tasks WHERE board_id = ? AND created_at > ? GROUP BY column_name, priority, assigned_to`, [input.boardId, timeThreshold]); // Get task distribution by column const columnStatsResult = await context.db.query('SELECT column_name, COUNT(*) as count FROM kanban_tasks WHERE board_id = ? AND status = ? GROUP BY column_name', [input.boardId, 'active']); const columnStats = (columnStatsResult.data || []).reduce((acc, row) => { acc[row.column_name] = row.count; return acc; }, {}); // Ensure all columns are represented columns.forEach((col) => { if (!(col in columnStats)) { columnStats[col] = 0; } }); // Calculate throughput (completed tasks) const throughputResult = await context.db.get('SELECT COUNT(*) as completed FROM kanban_tasks WHERE board_id = ? AND status = ? AND updated_at > ?', [input.boardId, 'archived', timeThreshold]); const throughput = throughputResult.data?.completed || 0; // Get priority distribution const priorityResult = await context.db.query('SELECT priority, COUNT(*) as count FROM kanban_tasks WHERE board_id = ? AND status = ? GROUP BY priority', [input.boardId, 'active']); const priorityStats = (priorityResult.data || []).reduce((acc, row) => { acc[row.priority] = row.count; return acc; }, {}); // Get assignee workload const assigneeResult = await context.db.query('SELECT assigned_to, COUNT(*) as count FROM kanban_tasks WHERE board_id = ? AND status = ? AND assigned_to IS NOT NULL GROUP BY assigned_to', [input.boardId, 'active']); const assigneeWorkload = (assigneeResult.data || []).reduce((acc, row) => { acc[row.assigned_to] = row.count; return acc; }, {}); const taskStats = tasksResult.data?.[0] || {}; const avgCycleTimeMs = taskStats.avg_cycle_time || 0; const avgCycleTimeDays = avgCycleTimeMs > 0 ? Math.round((avgCycleTimeMs / (1000 * 60 * 60 * 24)) * 100) / 100 : 0; return createSuccessResult({ board: { id: input.boardId, name: board.name }, timeframe: input.timeframe, metrics: { totalTasks: taskStats.total_tasks || 0, activeTasks: taskStats.active_tasks || 0, completedTasks: taskStats.completed_tasks || 0, throughput, averageCycleTime: { milliseconds: avgCycleTimeMs, days: avgCycleTimeDays, humanReadable: avgCycleTimeDays > 0 ? `${avgCycleTimeDays} days` : 'No data' } }, distribution: { byColumn: columnStats, byPriority: priorityStats, byAssignee: assigneeWorkload }, insights: [ throughput > 0 ? `Completed ${throughput} tasks in the last ${input.timeframe}` : 'No tasks completed in timeframe', avgCycleTimeDays > 0 ? `Average cycle time: ${avgCycleTimeDays} days` : 'No cycle time data available', Object.keys(assigneeWorkload).length > 0 ? `${Object.keys(assigneeWorkload).length} team members have active tasks` : 'No assigned tasks' ] }); } catch (error) { return createErrorResult({ code: 'EXECUTION_ERROR', message: `Failed to get board analytics: ${error instanceof Error ? error.message : 'Unknown error'}`, category: 'execution' }); } } }); /** * Setup kanban tools */ export async function setupKanbanTools() { return { module: 'kanban', tools: [ createBoardTool, createTaskTool, moveTaskTool, getBoardTool, getBoardAnalyticsTool ] }; } //# sourceMappingURL=tools.js.map