Files
task-stream/apps/mcp/src/app/tools/releases.resolver.ts
Josep Piguillem 993f80b4b9 feat: move unfinished tickets between releases
- Bulk editing gets «Release…» (open releases or none), and the list
  view a release filter, also reachable from each release («Ver en la
  lista», ?release=).
- Archiving a release asks where its unfinished tickets go, as closing
  a sprint does: ReleaseArchivedEvent carries `moveTo` (an open release
  of the same project, or none) and the saga moves them there; finished
  ones stay. In GraphQL, REST and the MCP's archive_release.
- Chapter 10 lists the next features and the ideas to weigh.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-14 21:55:45 +02:00

264 lines
7.3 KiB
TypeScript

import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
import { z } from 'zod';
import { TaskStreamApi } from '../api/taskstream-api';
import { limit, projectRef, releaseId } from './params';
import { issueLine, releaseLine } from './summaries';
import { toolResult } from './tool-result';
const READ = { readOnlyHint: true, openWorldHint: false } as const;
@Resolver()
export class ReleasesResolver {
constructor(private readonly api: TaskStreamApi) {}
@Tool({
name: 'list_releases',
description: 'Releases of a project (planned, released and archived).',
paramsSchema: z.object({ project: projectRef }),
annotations: READ,
})
list({ project }: { project: string }, ctx: McpContext) {
return toolResult(async () =>
(
await this.api.data(
this.api.as(ctx).GET('/api/projects/{project}/releases', {
params: { path: { project } },
}),
)
).map(releaseLine),
);
}
@Tool({
name: 'upcoming_releases',
description:
"Planned releases of all the user's projects, soonest target date first.",
paramsSchema: z.object({ limit: limit(50, 10) }),
annotations: READ,
})
upcoming({ limit }: { limit: number }, ctx: McpContext) {
return toolResult(async () =>
(
await this.api.data(
this.api
.as(ctx)
.GET('/api/me/releases', { params: { query: { limit } } }),
)
).map((release) => ({
...releaseLine(release),
projectId: release.projectId,
})),
);
}
@Tool({
name: 'get_release',
description:
'A release with its progress (finished out of total), its notes, its tickets and, optionally, the day-by-day burnup.',
paramsSchema: z.object({
releaseId,
withBurnup: z.boolean().default(false),
}),
annotations: READ,
})
get(params: { releaseId: string; withBurnup: boolean }, ctx: McpContext) {
return toolResult(async () => {
const client = this.api.as(ctx);
const path = { releaseId: params.releaseId };
const [release, progress, issues] = await Promise.all([
this.api.data(
client.GET('/api/releases/{releaseId}', { params: { path } }),
),
this.api.data(
client.GET('/api/releases/{releaseId}/progress', {
params: { path },
}),
),
this.api.data(
client.GET('/api/releases/{releaseId}/issues', {
params: { path, query: { limit: 200 } },
}),
),
]);
const burnup = params.withBurnup
? await this.api.data(
client.GET('/api/releases/{releaseId}/burnup', {
params: { path },
}),
)
: undefined;
return {
...releaseLine(release),
description: release.description,
notes: release.notes,
progress,
issues: issues.items.map(issueLine),
burnup,
};
});
}
@Tool({
name: 'create_release',
description: 'Plan a release in a project (owners and admins).',
paramsSchema: z.object({
project: projectRef,
name: z.string().min(1).max(60).describe('e.g. v1.4.0'),
description: z.string().max(5000).optional(),
targetDate: z.string().optional().describe('ISO date'),
}),
annotations: { readOnlyHint: false, destructiveHint: false },
})
create(
params: {
project: string;
name: string;
description?: string;
targetDate?: string;
},
ctx: McpContext,
) {
const { project, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/projects/{project}/releases', {
params: { path: { project } },
body,
}),
),
);
}
@Tool({
name: 'publish_release',
description:
'Ship a planned release. Fails while it has unfinished tickets unless force is true.',
paramsSchema: z.object({ releaseId, force: z.boolean().default(false) }),
annotations: { readOnlyHint: false, destructiveHint: false },
})
publish(
{ releaseId, force }: { releaseId: string; force: boolean },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/releases/{releaseId}/publish', {
params: { path: { releaseId } },
body: { force },
}),
),
);
}
@Tool({
name: 'update_release',
description: 'Rename a release, describe it or move its target date.',
paramsSchema: z.object({
releaseId,
name: z.string().min(1).max(60).optional(),
description: z.string().max(5000).nullable().optional(),
targetDate: z.string().nullable().optional().describe('ISO date'),
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
update(
params: {
releaseId: string;
name?: string;
description?: string | null;
targetDate?: string | null;
},
ctx: McpContext,
) {
const { releaseId, ...body } = params;
return toolResult(() =>
this.api.data(
this.api.as(ctx).PATCH('/api/releases/{releaseId}', {
params: { path: { releaseId } },
body,
}),
),
);
}
@Tool({
name: 'archive_release',
description:
'Archive a planned release that will not ship. Its unfinished tickets move to moveTo (another open release of the project, from list_releases) or, without it, leave it.',
paramsSchema: z.object({
releaseId,
moveTo: z
.string()
.uuid()
.optional()
.describe('Open release that takes the unfinished tickets'),
}),
annotations: { readOnlyHint: false, destructiveHint: true },
})
archive(
{ releaseId, moveTo }: { releaseId: string; moveTo?: string },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).POST('/api/releases/{releaseId}/archive', {
params: { path: { releaseId } },
body: { moveTo: moveTo ?? null },
}),
),
);
}
@Tool({
name: 'draft_release_notes',
description:
"A Markdown draft of a release's notes from its finished tickets, grouped by type. Nothing is saved: polish it and save it with set_release_notes.",
paramsSchema: z.object({ releaseId }),
annotations: READ,
})
draftNotes({ releaseId }: { releaseId: string }, ctx: McpContext) {
return toolResult(
async () =>
(
await this.api.data(
this.api.as(ctx).GET('/api/releases/{releaseId}/notes/draft', {
params: { path: { releaseId } },
}),
)
).markdown,
);
}
@Tool({
name: 'set_release_notes',
description:
'Write the notes of a release (Markdown, for people: what shipped). null clears them.',
paramsSchema: z.object({
releaseId,
notes: z.string().max(20000).nullable(),
}),
annotations: {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
},
})
setNotes(
{ releaseId, notes }: { releaseId: string; notes: string | null },
ctx: McpContext,
) {
return toolResult(() =>
this.api.data(
this.api.as(ctx).PUT('/api/releases/{releaseId}/notes', {
params: { path: { releaseId } },
body: { notes },
}),
),
);
}
}