Files
task-stream/apps/mcp/src/app/tools/issues.resolver.ts
Josep Piguillem ea4ef82239 feat: replies to comments
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>
2026-10-09 11:14:37 +02:00

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 } },
}),
),
);
}
}