feat(task-mcp): Add task_resolve_duplicate tool
Combines close + link operations for duplicate issues: - Closes the duplicate task (sets status to completed) - Creates bidirectional 'duplicates' link to dominant task Usage: task_resolve_duplicate(duplicate_id, dominant_id) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
34
src/index.ts
34
src/index.ts
@@ -20,7 +20,8 @@ import { testConnection, close } from './db.js';
|
||||
import { toolDefinitions } from './tools/index.js';
|
||||
import { taskAdd, taskList, taskShow, taskClose, taskUpdate } from './tools/crud.js';
|
||||
import { taskSimilar, taskContext } from './tools/search.js';
|
||||
import { taskLink, checklistAdd, checklistToggle } from './tools/relations.js';
|
||||
import { taskLink, checklistAdd, checklistToggle, taskResolveDuplicate } from './tools/relations.js';
|
||||
import { epicAdd, epicList, epicShow, epicAssign } from './tools/epics.js';
|
||||
|
||||
// Create MCP server
|
||||
const server = new Server(
|
||||
@@ -114,6 +115,37 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
checked: a.checked,
|
||||
});
|
||||
break;
|
||||
case 'task_resolve_duplicate':
|
||||
result = await taskResolveDuplicate({
|
||||
duplicate_id: a.duplicate_id,
|
||||
dominant_id: a.dominant_id,
|
||||
});
|
||||
break;
|
||||
|
||||
// Epics
|
||||
case 'epic_add':
|
||||
result = await epicAdd({
|
||||
title: a.title,
|
||||
project: a.project,
|
||||
description: a.description,
|
||||
});
|
||||
break;
|
||||
case 'epic_list':
|
||||
result = await epicList({
|
||||
project: a.project,
|
||||
status: a.status,
|
||||
limit: a.limit,
|
||||
});
|
||||
break;
|
||||
case 'epic_show':
|
||||
result = await epicShow(a.id);
|
||||
break;
|
||||
case 'epic_assign':
|
||||
result = await epicAssign({
|
||||
task_id: a.task_id,
|
||||
epic_id: a.epic_id,
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
|
||||
211
src/tools/epics.ts
Normal file
211
src/tools/epics.ts
Normal file
@@ -0,0 +1,211 @@
|
||||
// Epic operations for task management
|
||||
|
||||
import { query, queryOne, execute, getProjectKey } from '../db.js';
|
||||
import { getEmbedding, formatEmbedding } from '../embeddings.js';
|
||||
import type { Epic, Task } from '../types.js';
|
||||
|
||||
interface EpicAddArgs {
|
||||
title: string;
|
||||
project?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
interface EpicListArgs {
|
||||
project?: string;
|
||||
status?: string;
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
interface EpicAssignArgs {
|
||||
task_id: string;
|
||||
epic_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get next epic ID for a project
|
||||
*/
|
||||
async function getNextEpicId(projectKey: string): Promise<string> {
|
||||
const result = await queryOne<{ next_id: number }>(
|
||||
`INSERT INTO epic_sequences (project, next_id) VALUES ($1, 1)
|
||||
ON CONFLICT (project) DO UPDATE SET next_id = epic_sequences.next_id + 1
|
||||
RETURNING next_id`,
|
||||
[projectKey]
|
||||
);
|
||||
return `${projectKey}-E${result?.next_id || 1}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new epic
|
||||
*/
|
||||
export async function epicAdd(args: EpicAddArgs): Promise<string> {
|
||||
const { title, project = 'Unknown', description = '' } = args;
|
||||
|
||||
// Get project key
|
||||
const projectKey = await getProjectKey(project);
|
||||
|
||||
// Get next epic ID
|
||||
const epicId = await getNextEpicId(projectKey);
|
||||
|
||||
// Generate embedding
|
||||
const embedText = description ? `${title}. ${description}` : title;
|
||||
const embedding = await getEmbedding(embedText);
|
||||
const embeddingValue = embedding ? formatEmbedding(embedding) : null;
|
||||
|
||||
// Insert epic
|
||||
if (embeddingValue) {
|
||||
await execute(
|
||||
`INSERT INTO epics (id, project, title, description, embedding)
|
||||
VALUES ($1, $2, $3, $4, $5)`,
|
||||
[epicId, projectKey, title, description, embeddingValue]
|
||||
);
|
||||
} else {
|
||||
await execute(
|
||||
`INSERT INTO epics (id, project, title, description)
|
||||
VALUES ($1, $2, $3, $4)`,
|
||||
[epicId, projectKey, title, description]
|
||||
);
|
||||
}
|
||||
|
||||
return `Created epic: ${epicId}\n Title: ${title}\n Project: ${projectKey}${embedding ? '\n (embedded for semantic search)' : ''}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* List epics with filters
|
||||
*/
|
||||
export async function epicList(args: EpicListArgs): Promise<string> {
|
||||
const { project, status, limit = 20 } = args;
|
||||
|
||||
let whereClause = 'WHERE 1=1';
|
||||
const params: unknown[] = [];
|
||||
let paramIndex = 1;
|
||||
|
||||
if (project) {
|
||||
const projectKey = await getProjectKey(project);
|
||||
whereClause += ` AND e.project = $${paramIndex++}`;
|
||||
params.push(projectKey);
|
||||
}
|
||||
if (status) {
|
||||
whereClause += ` AND e.status = $${paramIndex++}`;
|
||||
params.push(status);
|
||||
}
|
||||
|
||||
params.push(limit);
|
||||
|
||||
const epics = await query<Epic & { task_count: number; open_count: number }>(
|
||||
`SELECT e.id, e.title, e.status, e.project,
|
||||
COUNT(t.id) as task_count,
|
||||
COUNT(t.id) FILTER (WHERE t.status != 'completed') as open_count
|
||||
FROM epics e
|
||||
LEFT JOIN tasks t ON t.epic_id = e.id
|
||||
${whereClause}
|
||||
GROUP BY e.id, e.title, e.status, e.project, e.created_at
|
||||
ORDER BY
|
||||
CASE e.status WHEN 'in_progress' THEN 0 WHEN 'open' THEN 1 ELSE 2 END,
|
||||
e.created_at DESC
|
||||
LIMIT $${paramIndex}`,
|
||||
params
|
||||
);
|
||||
|
||||
if (epics.length === 0) {
|
||||
return `No epics found${project ? ` for project ${project}` : ''}`;
|
||||
}
|
||||
|
||||
const lines = epics.map(e => {
|
||||
const statusIcon = e.status === 'completed' ? '[x]' : e.status === 'in_progress' ? '[>]' : '[ ]';
|
||||
const progress = e.task_count > 0 ? ` (${e.task_count - e.open_count}/${e.task_count} done)` : '';
|
||||
return `${statusIcon} ${e.id}: ${e.title}${progress}`;
|
||||
});
|
||||
|
||||
return `Epics${project ? ` (${project})` : ''}:\n\n${lines.join('\n')}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Show epic details with tasks
|
||||
*/
|
||||
export async function epicShow(id: string): Promise<string> {
|
||||
const epic = await queryOne<Epic & { created: string }>(
|
||||
`SELECT id, project, title, description, status,
|
||||
to_char(created_at, 'YYYY-MM-DD HH24:MI') as created
|
||||
FROM epics WHERE id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (!epic) {
|
||||
return `Epic not found: ${id}`;
|
||||
}
|
||||
|
||||
let output = `# ${epic.id}\n\n`;
|
||||
output += `**Title:** ${epic.title}\n`;
|
||||
output += `**Project:** ${epic.project}\n`;
|
||||
output += `**Status:** ${epic.status}\n`;
|
||||
output += `**Created:** ${epic.created}\n`;
|
||||
|
||||
if (epic.description) {
|
||||
output += `\n**Description:**\n${epic.description}\n`;
|
||||
}
|
||||
|
||||
// Get tasks in this epic
|
||||
const tasks = await query<Task>(
|
||||
`SELECT id, title, status, priority, type
|
||||
FROM tasks
|
||||
WHERE epic_id = $1
|
||||
ORDER BY
|
||||
CASE status WHEN 'in_progress' THEN 0 WHEN 'open' THEN 1 WHEN 'blocked' THEN 2 ELSE 3 END,
|
||||
CASE priority WHEN 'P0' THEN 0 WHEN 'P1' THEN 1 WHEN 'P2' THEN 2 ELSE 3 END`,
|
||||
[id]
|
||||
);
|
||||
|
||||
if (tasks.length > 0) {
|
||||
const done = tasks.filter(t => t.status === 'completed').length;
|
||||
output += `\n**Tasks:** (${done}/${tasks.length} done)\n`;
|
||||
for (const t of tasks) {
|
||||
const statusIcon = t.status === 'completed' ? '[x]' : t.status === 'in_progress' ? '[>]' : t.status === 'blocked' ? '[!]' : '[ ]';
|
||||
output += ` ${statusIcon} ${t.priority} ${t.id}: ${t.title}\n`;
|
||||
}
|
||||
} else {
|
||||
output += `\n**Tasks:** None assigned\n`;
|
||||
}
|
||||
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Assign a task to an epic
|
||||
*/
|
||||
export async function epicAssign(args: EpicAssignArgs): Promise<string> {
|
||||
const { task_id, epic_id } = args;
|
||||
|
||||
// Verify epic exists
|
||||
const epic = await queryOne<{ id: string }>(`SELECT id FROM epics WHERE id = $1`, [epic_id]);
|
||||
if (!epic) {
|
||||
return `Epic not found: ${epic_id}`;
|
||||
}
|
||||
|
||||
// Update task
|
||||
const result = await execute(
|
||||
`UPDATE tasks SET epic_id = $1, updated_at = NOW() WHERE id = $2`,
|
||||
[epic_id, task_id]
|
||||
);
|
||||
|
||||
if (result === 0) {
|
||||
return `Task not found: ${task_id}`;
|
||||
}
|
||||
|
||||
return `Assigned ${task_id} to epic ${epic_id}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unassign a task from its epic
|
||||
*/
|
||||
export async function epicUnassign(task_id: string): Promise<string> {
|
||||
const result = await execute(
|
||||
`UPDATE tasks SET epic_id = NULL, updated_at = NOW() WHERE id = $1`,
|
||||
[task_id]
|
||||
);
|
||||
|
||||
if (result === 0) {
|
||||
return `Task not found: ${task_id}`;
|
||||
}
|
||||
|
||||
return `Unassigned ${task_id} from its epic`;
|
||||
}
|
||||
@@ -135,6 +135,18 @@ export const toolDefinitions = [
|
||||
required: ['item_id', 'checked'],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'task_resolve_duplicate',
|
||||
description: 'Resolve a duplicate issue by closing it and linking to the dominant issue',
|
||||
inputSchema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
duplicate_id: { type: 'string', description: 'The duplicate task ID to close' },
|
||||
dominant_id: { type: 'string', description: 'The dominant/original task ID to keep' },
|
||||
},
|
||||
required: ['duplicate_id', 'dominant_id'],
|
||||
},
|
||||
},
|
||||
|
||||
// Epic Tools
|
||||
{
|
||||
|
||||
@@ -93,3 +93,50 @@ export async function checklistToggle(args: ChecklistToggleArgs): Promise<string
|
||||
|
||||
return `${checked ? 'Checked' : 'Unchecked'}: item #${item_id}`;
|
||||
}
|
||||
|
||||
interface ResolveDuplicateArgs {
|
||||
duplicate_id: string;
|
||||
dominant_id: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a duplicate issue by closing it and linking to the dominant issue
|
||||
* - Closes the duplicate task (sets status to completed)
|
||||
* - Creates bidirectional "duplicates" link between the two tasks
|
||||
*/
|
||||
export async function taskResolveDuplicate(args: ResolveDuplicateArgs): Promise<string> {
|
||||
const { duplicate_id, dominant_id } = args;
|
||||
|
||||
try {
|
||||
// Close the duplicate task
|
||||
const closeResult = await execute(
|
||||
`UPDATE tasks
|
||||
SET status = 'completed', completed_at = NOW(), updated_at = NOW()
|
||||
WHERE id = $1`,
|
||||
[duplicate_id]
|
||||
);
|
||||
|
||||
if (closeResult === 0) {
|
||||
return `Duplicate task not found: ${duplicate_id}`;
|
||||
}
|
||||
|
||||
// Create bidirectional duplicates link
|
||||
await execute(
|
||||
`INSERT INTO task_links (from_task_id, to_task_id, link_type)
|
||||
VALUES ($1, $2, 'duplicates')
|
||||
ON CONFLICT (from_task_id, to_task_id, link_type) DO NOTHING`,
|
||||
[duplicate_id, dominant_id]
|
||||
);
|
||||
|
||||
await execute(
|
||||
`INSERT INTO task_links (from_task_id, to_task_id, link_type)
|
||||
VALUES ($1, $2, 'duplicates')
|
||||
ON CONFLICT (from_task_id, to_task_id, link_type) DO NOTHING`,
|
||||
[dominant_id, duplicate_id]
|
||||
);
|
||||
|
||||
return `Resolved duplicate: ${duplicate_id} → ${dominant_id}\n Closed: ${duplicate_id}\n Linked: duplicates ${dominant_id}`;
|
||||
} catch (error) {
|
||||
return `Error resolving duplicate: ${error}`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user