- 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>
264 lines
7.3 KiB
TypeScript
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 },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
}
|