A comment can answer another one (replyTo), one level deep: answering a reply joins the same thread, and it must be on the same ticket. Whoever wrote the comment gets a new REPLIED notification, and the activity says "respondió a un comentario". A comment deleted while it has replies stays, without its text (deleted: true), so the thread still reads; it goes with its last reply and does not count in commentCount. The ticket page shows threads with their replies indented and a "Responder" box; REST, GraphQL and the MCP (add_comment, get_issue) take and show replyTo. Existing comments are unchanged. Docs, Word and PDF updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
629 lines
18 KiB
TypeScript
629 lines
18 KiB
TypeScript
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
|
import { z } from 'zod';
|
|
import { TaskStreamApi } from '../api/taskstream-api';
|
|
import { issueRef, limit, projectRef } from './params';
|
|
import { issueLine } from './summaries';
|
|
import { toolResult } from './tool-result';
|
|
|
|
const READ = { readOnlyHint: true, openWorldHint: false } as const;
|
|
const WRITE = {
|
|
readOnlyHint: false,
|
|
destructiveHint: false,
|
|
openWorldHint: false,
|
|
} as const;
|
|
const REMOVE = {
|
|
readOnlyHint: false,
|
|
destructiveHint: true,
|
|
openWorldHint: false,
|
|
} as const;
|
|
|
|
const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
|
|
const CATEGORY = z.enum(['TODO', 'IN_PROGRESS', 'QA', 'DONE', 'CANCELLED']);
|
|
const LINK_TYPE = z.enum([
|
|
'BLOCKS',
|
|
'DEPENDS_ON',
|
|
'TESTS',
|
|
'DUPLICATES',
|
|
'RELATES_TO',
|
|
]);
|
|
|
|
const ids = (what: string) =>
|
|
z
|
|
.array(z.string())
|
|
.optional()
|
|
.describe(`${what} ids of the project's settings (see get_project)`);
|
|
|
|
const issueFields = {
|
|
description: z.string().max(50000).optional(),
|
|
area: z.string().optional().describe('Area id (see get_project)'),
|
|
priority: PRIORITY.optional(),
|
|
labels: z.array(z.string().max(40)).max(20).optional(),
|
|
figmaUrl: z.string().url().optional().describe('Figma frame (https)'),
|
|
storyPoints: z.number().int().min(0).max(100).optional(),
|
|
startDate: z
|
|
.string()
|
|
.optional()
|
|
.describe('ISO date when work should start (roadmap)'),
|
|
dueDate: z.string().optional().describe('ISO date, e.g. 2026-11-14'),
|
|
};
|
|
|
|
type IssueFields = {
|
|
description?: string;
|
|
area?: string;
|
|
priority?: z.infer<typeof PRIORITY>;
|
|
labels?: string[];
|
|
figmaUrl?: string;
|
|
storyPoints?: number;
|
|
startDate?: string;
|
|
dueDate?: string;
|
|
};
|
|
|
|
@Resolver()
|
|
export class IssuesResolver {
|
|
constructor(private readonly api: TaskStreamApi) {}
|
|
|
|
/* ---- Reading ---- */
|
|
|
|
@Tool({
|
|
name: 'my_issues',
|
|
description:
|
|
"Tickets across all the user's projects: assigned to them (default) or anyone's, filtered by status category (e.g. QA to see what waits for testing).",
|
|
paramsSchema: z.object({
|
|
assignedToMe: z.boolean().default(true),
|
|
categories: z
|
|
.array(CATEGORY)
|
|
.optional()
|
|
.describe('Default: open ones (TODO, IN_PROGRESS, QA)'),
|
|
limit: limit(200, 50),
|
|
}),
|
|
annotations: READ,
|
|
})
|
|
mine(
|
|
params: {
|
|
assignedToMe: boolean;
|
|
categories?: z.infer<typeof CATEGORY>[];
|
|
limit: number;
|
|
},
|
|
ctx: McpContext,
|
|
) {
|
|
return toolResult(async () => {
|
|
const page = await this.api.data(
|
|
this.api.as(ctx).GET('/api/me/issues', {
|
|
params: {
|
|
query: {
|
|
assignedToMe: params.assignedToMe,
|
|
category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'],
|
|
limit: params.limit,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
return { total: page.total, items: page.items.map(issueLine) };
|
|
});
|
|
}
|
|
|
|
@Tool({
|
|
name: 'search_issues',
|
|
description:
|
|
'Search the tickets of a project by text (key, title, description) and filters. The backlog: noSprint with sort RANK.',
|
|
paramsSchema: z.object({
|
|
project: projectRef,
|
|
search: z.string().max(200).optional(),
|
|
statuses: ids('Status'),
|
|
categories: z.array(CATEGORY).optional(),
|
|
types: ids('Ticket type'),
|
|
areas: ids('Area'),
|
|
priorities: z.array(PRIORITY).optional(),
|
|
assigneeId: z.string().optional().describe('User id'),
|
|
unassigned: z.boolean().optional(),
|
|
releaseId: z.string().optional(),
|
|
labels: z.array(z.string()).optional(),
|
|
parent: z
|
|
.string()
|
|
.optional()
|
|
.describe('Only the tickets hanging from this epic or ticket (key)'),
|
|
sprintId: z.string().optional().describe('Only this sprint'),
|
|
noSprint: z
|
|
.boolean()
|
|
.optional()
|
|
.describe('Only tickets in no sprint (the backlog)'),
|
|
dueFrom: z.string().optional().describe('Due on or after (ISO date)'),
|
|
dueTo: z.string().optional().describe('Due on or before (ISO date)'),
|
|
sort: z
|
|
.enum(['RECENT', 'RANK'])
|
|
.default('RECENT')
|
|
.describe('RECENT: last updated first. RANK: backlog order'),
|
|
limit: limit(200, 30),
|
|
offset: z.number().int().min(0).default(0),
|
|
}),
|
|
annotations: READ,
|
|
})
|
|
search(
|
|
params: {
|
|
project: string;
|
|
search?: string;
|
|
statuses?: string[];
|
|
categories?: z.infer<typeof CATEGORY>[];
|
|
types?: string[];
|
|
areas?: string[];
|
|
priorities?: z.infer<typeof PRIORITY>[];
|
|
assigneeId?: string;
|
|
unassigned?: boolean;
|
|
releaseId?: string;
|
|
labels?: string[];
|
|
parent?: string;
|
|
sprintId?: string;
|
|
noSprint?: boolean;
|
|
dueFrom?: string;
|
|
dueTo?: string;
|
|
sort: 'RECENT' | 'RANK';
|
|
limit: number;
|
|
offset: number;
|
|
},
|
|
ctx: McpContext,
|
|
) {
|
|
return toolResult(async () => {
|
|
const page = await this.api.data(
|
|
this.api.as(ctx).GET('/api/projects/{project}/issues', {
|
|
params: {
|
|
path: { project: params.project },
|
|
query: {
|
|
search: params.search,
|
|
status: params.statuses,
|
|
category: params.categories,
|
|
type: params.types,
|
|
area: params.areas,
|
|
priority: params.priorities,
|
|
assigneeId: params.assigneeId,
|
|
unassigned: params.unassigned,
|
|
releaseId: params.releaseId,
|
|
label: params.labels,
|
|
parent: params.parent,
|
|
sprintId: params.sprintId,
|
|
noSprint: params.noSprint,
|
|
dueFrom: params.dueFrom,
|
|
dueTo: params.dueTo,
|
|
sort: params.sort,
|
|
limit: params.limit,
|
|
offset: params.offset,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
return { total: page.total, items: page.items.map(issueLine) };
|
|
});
|
|
}
|
|
|
|
@Tool({
|
|
name: 'get_issue',
|
|
description:
|
|
'A ticket in full: description, parent, checklist (with item ids), links, commits and pull requests, followers, allowed next statuses, and optionally its comments and history.',
|
|
paramsSchema: z.object({
|
|
issue: issueRef,
|
|
withComments: z.boolean().default(true),
|
|
withHistory: z.boolean().default(false),
|
|
}),
|
|
annotations: READ,
|
|
})
|
|
getIssue(
|
|
params: { issue: string; withComments: boolean; withHistory: boolean },
|
|
ctx: McpContext,
|
|
) {
|
|
return toolResult(async () => {
|
|
const client = this.api.as(ctx);
|
|
const path = { issue: params.issue };
|
|
const issue = await this.api.data(
|
|
client.GET('/api/issues/{issue}', { params: { path } }),
|
|
);
|
|
const comments = params.withComments
|
|
? (
|
|
await this.api.data(
|
|
client.GET('/api/issues/{issue}/comments', {
|
|
params: { path, query: { limit: 100 } },
|
|
}),
|
|
)
|
|
).items.map((c) => ({
|
|
id: c.id,
|
|
replyTo: c.replyTo,
|
|
author: c.deleted ? null : c.author.name,
|
|
at: c.createdAt,
|
|
body: c.deleted ? '(deleted; kept for its replies)' : c.body,
|
|
}))
|
|
: undefined;
|
|
const history = params.withHistory
|
|
? (
|
|
await this.api.data(
|
|
client.GET('/api/issues/{issue}/history', {
|
|
params: { path, query: { limit: 100 } },
|
|
}),
|
|
)
|
|
).map((a) => ({ at: a.occurredAt, summary: a.summary }))
|
|
: undefined;
|
|
return {
|
|
...issueLine(issue),
|
|
description: issue.description,
|
|
reporter: issue.reporter.name,
|
|
figmaUrl: issue.figmaUrl,
|
|
startDate: issue.startDate,
|
|
allowedTransitions: issue.allowedTransitions,
|
|
parent: issue.parent,
|
|
checklist: issue.checklist,
|
|
links: issue.links,
|
|
devLinks: issue.devLinks.map((d) => ({
|
|
kind: d.kind,
|
|
repository: d.repository,
|
|
ref: d.ref,
|
|
title: d.title,
|
|
state: d.state,
|
|
url: d.url,
|
|
})),
|
|
watchers: issue.watchers.map((w) => w.name),
|
|
attachments: issue.attachments.length,
|
|
trashedAt: issue.trashedAt,
|
|
createdAt: issue.createdAt,
|
|
comments,
|
|
history,
|
|
};
|
|
});
|
|
}
|
|
|
|
/* ---- Writing ---- */
|
|
|
|
@Tool({
|
|
name: 'create_issue',
|
|
description:
|
|
'Create a ticket. Type and area are ids of the project settings (see get_project); it starts in the first pending status.',
|
|
paramsSchema: z.object({
|
|
project: projectRef,
|
|
type: z.string().describe('Ticket type id, e.g. BUG'),
|
|
title: z.string().min(1).max(255),
|
|
assigneeId: z.string().optional().describe('User id (a project member)'),
|
|
releaseId: z.string().optional(),
|
|
parent: z
|
|
.string()
|
|
.optional()
|
|
.describe('Epic or ticket of the same project it breaks down (key)'),
|
|
...issueFields,
|
|
}),
|
|
annotations: WRITE,
|
|
})
|
|
create(
|
|
params: IssueFields & {
|
|
project: string;
|
|
type: string;
|
|
title: string;
|
|
assigneeId?: string;
|
|
releaseId?: string;
|
|
parent?: string;
|
|
},
|
|
ctx: McpContext,
|
|
) {
|
|
const { project, ...body } = params;
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).POST('/api/projects/{project}/issues', {
|
|
params: { path: { project } },
|
|
body,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'update_issue',
|
|
description: 'Change the title, description, type, area, priority, labels…',
|
|
paramsSchema: z.object({
|
|
issue: issueRef,
|
|
title: z.string().min(1).max(255).optional(),
|
|
type: z.string().optional().describe('Ticket type id'),
|
|
...issueFields,
|
|
}),
|
|
annotations: { ...WRITE, idempotentHint: true },
|
|
})
|
|
update(
|
|
params: IssueFields & { issue: string; title?: string; type?: string },
|
|
ctx: McpContext,
|
|
) {
|
|
const { issue, ...body } = params;
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).PATCH('/api/issues/{issue}', {
|
|
params: { path: { issue } },
|
|
body,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'change_issue_status',
|
|
description:
|
|
"Move a ticket to another status. It must be one of the ticket's allowedTransitions (see get_issue); some statuses need a reason.",
|
|
paramsSchema: z.object({
|
|
issue: issueRef,
|
|
status: z.string().describe('Status id, e.g. IN_PROGRESS'),
|
|
reason: z.string().max(5000).optional(),
|
|
}),
|
|
annotations: WRITE,
|
|
})
|
|
changeStatus(
|
|
{
|
|
issue,
|
|
status,
|
|
reason,
|
|
}: { issue: string; status: string; reason?: string },
|
|
ctx: McpContext,
|
|
) {
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).PUT('/api/issues/{issue}/status', {
|
|
params: { path: { issue } },
|
|
body: { status, reason },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'assign_issue',
|
|
description: 'Assign a ticket to a project member, or unassign it (null).',
|
|
paramsSchema: z.object({
|
|
issue: issueRef,
|
|
assigneeId: z.string().nullable().describe('User id, or null'),
|
|
}),
|
|
annotations: { ...WRITE, idempotentHint: true },
|
|
})
|
|
assign(
|
|
{ issue, assigneeId }: { issue: string; assigneeId: string | null },
|
|
ctx: McpContext,
|
|
) {
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).PUT('/api/issues/{issue}/assignee', {
|
|
params: { path: { issue } },
|
|
body: { assigneeId },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'set_issue_release',
|
|
description: 'Put a ticket in a planned release, or take it out (null).',
|
|
paramsSchema: z.object({
|
|
issue: issueRef,
|
|
releaseId: z.string().nullable(),
|
|
}),
|
|
annotations: { ...WRITE, idempotentHint: true },
|
|
})
|
|
setRelease(
|
|
{ issue, releaseId }: { issue: string; releaseId: string | null },
|
|
ctx: McpContext,
|
|
) {
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).PUT('/api/issues/{issue}/release', {
|
|
params: { path: { issue } },
|
|
body: { releaseId },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'add_comment',
|
|
description:
|
|
'Comment on a ticket (decisions, explanations, steps), or answer a comment with replyTo (one level: answering a reply joins its thread).',
|
|
paramsSchema: z.object({
|
|
issue: issueRef,
|
|
body: z.string().min(1).max(20000),
|
|
replyTo: z
|
|
.string()
|
|
.optional()
|
|
.describe('Id of the comment it answers (see get_issue)'),
|
|
}),
|
|
annotations: WRITE,
|
|
})
|
|
comment(
|
|
{ issue, body, replyTo }: { issue: string; body: string; replyTo?: string },
|
|
ctx: McpContext,
|
|
) {
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).POST('/api/issues/{issue}/comments', {
|
|
params: { path: { issue } },
|
|
body: { body, replyTo },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'link_issues',
|
|
description:
|
|
'Relate two tickets, e.g. WEB-3 DEPENDS_ON API-7 (they may be in different projects).',
|
|
paramsSchema: z.object({
|
|
issue: issueRef,
|
|
type: LINK_TYPE,
|
|
target: issueRef,
|
|
}),
|
|
annotations: WRITE,
|
|
})
|
|
link(
|
|
params: { issue: string; type: z.infer<typeof LINK_TYPE>; target: string },
|
|
ctx: McpContext,
|
|
) {
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).POST('/api/issues/{issue}/links', {
|
|
params: { path: { issue: params.issue } },
|
|
body: { type: params.type, targetIssue: params.target },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'unlink_issues',
|
|
description:
|
|
'Remove a relation between two tickets (as link_issues made it).',
|
|
paramsSchema: z.object({
|
|
issue: issueRef,
|
|
type: LINK_TYPE,
|
|
target: issueRef,
|
|
}),
|
|
annotations: REMOVE,
|
|
})
|
|
unlink(
|
|
params: { issue: string; type: z.infer<typeof LINK_TYPE>; target: string },
|
|
ctx: McpContext,
|
|
) {
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).DELETE('/api/issues/{issue}/links/{type}/{target}', {
|
|
params: { path: params },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'watch_issue',
|
|
description:
|
|
'Follow a ticket (notifications of its changes and comments) or stop following it.',
|
|
paramsSchema: z.object({
|
|
issue: issueRef,
|
|
follow: z.boolean().default(true),
|
|
}),
|
|
annotations: { ...WRITE, idempotentHint: true },
|
|
})
|
|
watch(
|
|
{ issue, follow }: { issue: string; follow: boolean },
|
|
ctx: McpContext,
|
|
) {
|
|
const client = this.api.as(ctx);
|
|
const options = { params: { path: { issue } } };
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
follow
|
|
? client.PUT('/api/issues/{issue}/watch', options)
|
|
: client.DELETE('/api/issues/{issue}/watch', options),
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'edit_comment',
|
|
description:
|
|
'Reword a comment (only its author). Comment ids come with get_issue.',
|
|
paramsSchema: z.object({
|
|
commentId: z.string().min(1),
|
|
body: z.string().min(1).max(20000),
|
|
}),
|
|
annotations: { ...WRITE, idempotentHint: true },
|
|
})
|
|
editComment(
|
|
{ commentId, body }: { commentId: string; body: string },
|
|
ctx: McpContext,
|
|
) {
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).PATCH('/api/comments/{commentId}', {
|
|
params: { path: { commentId } },
|
|
body: { body },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'delete_comment',
|
|
description: 'Delete a comment (its author or a project admin).',
|
|
paramsSchema: z.object({ commentId: z.string().min(1) }),
|
|
annotations: REMOVE,
|
|
})
|
|
deleteComment({ commentId }: { commentId: string }, ctx: McpContext) {
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).DELETE('/api/comments/{commentId}', {
|
|
params: { path: { commentId } },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
/* ---- Checklist ---- */
|
|
|
|
@Tool({
|
|
name: 'add_checklist_item',
|
|
description: "Add a step to a ticket's checklist.",
|
|
paramsSchema: z.object({
|
|
issue: issueRef,
|
|
text: z.string().min(1).max(300),
|
|
}),
|
|
annotations: WRITE,
|
|
})
|
|
addChecklistItem(
|
|
{ issue, text }: { issue: string; text: string },
|
|
ctx: McpContext,
|
|
) {
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).POST('/api/issues/{issue}/checklist', {
|
|
params: { path: { issue } },
|
|
body: { text },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'update_checklist_item',
|
|
description:
|
|
'Tick, untick or reword a checklist step. Item ids come with get_issue.',
|
|
paramsSchema: z.object({
|
|
issue: issueRef,
|
|
item: z.string().min(1).describe('Checklist item id'),
|
|
done: z.boolean().optional(),
|
|
text: z.string().min(1).max(300).optional(),
|
|
}),
|
|
annotations: { ...WRITE, idempotentHint: true },
|
|
})
|
|
updateChecklistItem(
|
|
params: { issue: string; item: string; done?: boolean; text?: string },
|
|
ctx: McpContext,
|
|
) {
|
|
const { issue, item, ...body } = params;
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).PATCH('/api/issues/{issue}/checklist/{item}', {
|
|
params: { path: { issue, item } },
|
|
body,
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'remove_checklist_item',
|
|
description: "Remove a step from a ticket's checklist.",
|
|
paramsSchema: z.object({
|
|
issue: issueRef,
|
|
item: z.string().min(1).describe('Checklist item id'),
|
|
}),
|
|
annotations: REMOVE,
|
|
})
|
|
removeChecklistItem(
|
|
{ issue, item }: { issue: string; item: string },
|
|
ctx: McpContext,
|
|
) {
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).DELETE('/api/issues/{issue}/checklist/{item}', {
|
|
params: { path: { issue, item } },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
}
|