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