feat(mcp): sprints, epics, backlog, trash, checklists and release notes
Thirty new tools (54 in all) for what the API gained since the MCP: create a project and read its public showcase; sprints (list, progress and burndown, plan, change, start, close with carry-over, delete, move a ticket in or out); epics and the backlog (list epics with progress, set a ticket's parent, rank); bulk edit of up to 100 tickets by key, saying which refused; the trash; checklists; following a ticket, removing a relation, editing and deleting comments; editing and archiving releases, drafting and saving their notes; notifications. Searching filters by epic, sprint and dates and sorts by backlog order; get_issue shows parent, checklist, commits and followers; create and update take parent and start date. API: `unreadOnly` and `dryRun` were documented as objects in the OpenAPI; they are booleans now. Chapter 9 lists every tool; Word and PDF regenerated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2192,7 +2192,6 @@ export interface components {
|
||||
/** @description Open tickets labelled good-first-issue */
|
||||
goodFirst: components["schemas"]["ShowcaseIssueDto"][];
|
||||
};
|
||||
Object: Record<string, never>;
|
||||
/** @enum {string} */
|
||||
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER";
|
||||
NotificationDto: {
|
||||
@@ -6766,7 +6765,7 @@ export interface operations {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Only unread ones */
|
||||
unreadOnly?: components["schemas"]["Object"];
|
||||
unreadOnly?: boolean;
|
||||
limit?: number;
|
||||
};
|
||||
header?: never;
|
||||
@@ -8679,7 +8678,7 @@ export interface operations {
|
||||
parameters: {
|
||||
query?: {
|
||||
/** @description Only tell what would be created */
|
||||
dryRun?: components["schemas"]["Object"];
|
||||
dryRun?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
|
||||
@@ -4,16 +4,21 @@ import { McpModule } from '@nestjs-mcp/server';
|
||||
import { TASKSTREAM_API_URL, TaskStreamApi } from './api/taskstream-api';
|
||||
import { HealthController } from './health.controller';
|
||||
import { McpHostsMiddleware } from './mcp-hosts.middleware';
|
||||
import { BacklogResolver } from './tools/backlog.resolver';
|
||||
import { IssuesResolver } from './tools/issues.resolver';
|
||||
import { NotificationsResolver } from './tools/notifications.resolver';
|
||||
import { ProjectsResolver } from './tools/projects.resolver';
|
||||
import { ReleasesResolver } from './tools/releases.resolver';
|
||||
import { SprintsResolver } from './tools/sprints.resolver';
|
||||
import { UsersResolver } from './tools/users.resolver';
|
||||
|
||||
const INSTRUCTIONS = `TaskStream: issue tracker (projects, tickets, QA, releases).
|
||||
const INSTRUCTIONS = `TaskStream: issue tracker (projects, tickets, epics, sprints, QA, releases).
|
||||
Projects and tickets go by key (WEB, WEB-12). Statuses, ticket types and areas are per
|
||||
project: read their ids with get_project before creating, updating or moving tickets, and
|
||||
use a ticket's allowedTransitions (get_issue) to move it. Tools act as the user whose Pireon
|
||||
token the client sends; without one, only public projects can be read.`;
|
||||
use a ticket's allowedTransitions (get_issue) to move it. Sprints and releases go by id
|
||||
(list_sprints, list_releases); the backlog is search_issues with noSprint and sort RANK.
|
||||
Tools act as the user whose Pireon token the client sends; without one, only public
|
||||
projects can be read.`;
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
@@ -44,7 +49,10 @@ token the client sends; without one, only public projects can be read.`;
|
||||
TaskStreamApi,
|
||||
ProjectsResolver,
|
||||
IssuesResolver,
|
||||
BacklogResolver,
|
||||
SprintsResolver,
|
||||
ReleasesResolver,
|
||||
NotificationsResolver,
|
||||
UsersResolver,
|
||||
],
|
||||
})
|
||||
|
||||
@@ -10,6 +10,9 @@ import { AddressInfo } from 'node:net';
|
||||
import { TASKSTREAM_API_URL } from './api/taskstream-api';
|
||||
import { AppModule } from './app.module';
|
||||
|
||||
const ISSUE_1 = '11111111-1111-4111-8111-111111111111';
|
||||
const ISSUE_2 = '22222222-2222-4222-8222-222222222222';
|
||||
|
||||
/** What the fake API received. */
|
||||
interface Received {
|
||||
method: string;
|
||||
@@ -75,6 +78,38 @@ function fakeApi(received: Received[]): Server {
|
||||
code: 'BUSINESS_RULE',
|
||||
message: 'Cannot move',
|
||||
});
|
||||
if (req.method === 'POST' && req.url === '/api/projects')
|
||||
return reply(201, { id: 'p2', key: 'DOCS', version: 1 });
|
||||
if (req.url === '/api/issues/WEB-1') return reply(200, { id: ISSUE_1 });
|
||||
if (req.url === '/api/issues/WEB-2') return reply(200, { id: ISSUE_2 });
|
||||
if (req.url === '/api/issues/bulk')
|
||||
return reply(200, {
|
||||
updated: 1,
|
||||
results: [
|
||||
{ issueId: ISSUE_1, ok: true, error: null },
|
||||
{ issueId: ISSUE_2, ok: false, error: 'Cannot move WEB-2' },
|
||||
],
|
||||
});
|
||||
if (req.url === '/api/sprints/s1/close')
|
||||
return reply(201, { id: 's1', version: 3 });
|
||||
if (req.url?.startsWith('/api/me/notifications?'))
|
||||
return reply(200, {
|
||||
unread: 1,
|
||||
items: [
|
||||
{
|
||||
id: 'n1',
|
||||
kind: 'MENTIONED',
|
||||
projectId: 'p1',
|
||||
projectKey: 'WEB',
|
||||
issueId: ISSUE_1,
|
||||
issueKey: 'WEB-1',
|
||||
actor: { id: '101', name: 'Ana' },
|
||||
summary: 'Ana te ha mencionado en WEB-1',
|
||||
readAt: null,
|
||||
createdAt: '2026-10-09T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
return reply(404, { statusCode: 404, message: 'Not found' });
|
||||
});
|
||||
}
|
||||
@@ -152,11 +187,24 @@ describe('TaskStream MCP', () => {
|
||||
'change_issue_status',
|
||||
'get_release',
|
||||
'whoami',
|
||||
'create_project',
|
||||
'list_epics',
|
||||
'bulk_update_issues',
|
||||
'delete_issue',
|
||||
'list_sprints',
|
||||
'get_sprint',
|
||||
'close_sprint',
|
||||
'draft_release_notes',
|
||||
'add_checklist_item',
|
||||
'my_notifications',
|
||||
]),
|
||||
);
|
||||
const byName = new Map(tools.map((t) => [t.name, t]));
|
||||
expect(byName.get('get_project')?.annotations?.readOnlyHint).toBe(true);
|
||||
expect(byName.get('create_issue')?.annotations?.readOnlyHint).toBe(false);
|
||||
expect(byName.get('delete_issue')?.annotations).toMatchObject({
|
||||
destructiveHint: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("calls the API with the caller's own token and compacts the answer", async () => {
|
||||
@@ -217,6 +265,68 @@ describe('TaskStream MCP', () => {
|
||||
expect(received).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('creates projects with the key in capitals', async () => {
|
||||
const result = await call(
|
||||
'create_project',
|
||||
{ key: 'docs', name: 'Documentación', isPublic: true },
|
||||
'pireon-token',
|
||||
);
|
||||
expect(result.isError).toBeUndefined();
|
||||
expect(received[0]).toMatchObject({
|
||||
method: 'POST',
|
||||
url: '/api/projects',
|
||||
body: { key: 'DOCS', name: 'Documentación', isPublic: true },
|
||||
});
|
||||
});
|
||||
|
||||
it('changes many tickets by key and says which refused', async () => {
|
||||
const result = await call(
|
||||
'bulk_update_issues',
|
||||
{ issues: ['WEB-1', 'WEB-2'], status: 'DONE' },
|
||||
'pireon-token',
|
||||
);
|
||||
expect(received.at(-1)).toMatchObject({
|
||||
method: 'POST',
|
||||
url: '/api/issues/bulk',
|
||||
body: { issueIds: [ISSUE_1, ISSUE_2], status: 'DONE' },
|
||||
});
|
||||
expect(JSON.parse(result.content[0].text)).toEqual({
|
||||
updated: 1,
|
||||
refused: [{ issue: 'WEB-2', error: 'Cannot move WEB-2' }],
|
||||
});
|
||||
});
|
||||
|
||||
it('closes a sprint carrying the unfinished tickets over', async () => {
|
||||
await call(
|
||||
'close_sprint',
|
||||
{ sprintId: 's1', carryOverTo: 's2' },
|
||||
'pireon-token',
|
||||
);
|
||||
expect(received[0]).toMatchObject({
|
||||
method: 'POST',
|
||||
url: '/api/sprints/s1/close',
|
||||
body: { carryOverTo: 's2' },
|
||||
});
|
||||
});
|
||||
|
||||
it('reads the unread notifications', async () => {
|
||||
const result = await call('my_notifications', {}, 'pireon-token');
|
||||
expect(received[0].url).toBe(
|
||||
'/api/me/notifications?unreadOnly=true&limit=30',
|
||||
);
|
||||
expect(JSON.parse(result.content[0].text)).toEqual({
|
||||
unread: 1,
|
||||
items: [
|
||||
expect.objectContaining({
|
||||
kind: 'MENTIONED',
|
||||
issue: 'WEB-1',
|
||||
who: 'Ana',
|
||||
read: false,
|
||||
}),
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
/** A raw POST /mcp with chosen Host / Origin headers (fetch would not let us set Host). */
|
||||
function rawPost(headers: Record<string, string>): Promise<number> {
|
||||
const { port } = new URL(url);
|
||||
|
||||
243
apps/mcp/src/app/tools/backlog.resolver.ts
Normal file
243
apps/mcp/src/app/tools/backlog.resolver.ts
Normal file
@@ -0,0 +1,243 @@
|
||||
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
||||
import { z } from 'zod';
|
||||
import { TaskStreamApi, type TaskStreamClient } from '../api/taskstream-api';
|
||||
import { issueRef, 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 PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
|
||||
|
||||
const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
||||
/**
|
||||
* How the work is organised: epics and the tickets hanging from them, the
|
||||
* backlog order, changes to many tickets at once and the trash.
|
||||
*/
|
||||
@Resolver()
|
||||
export class BacklogResolver {
|
||||
constructor(private readonly api: TaskStreamApi) {}
|
||||
|
||||
@Tool({
|
||||
name: 'list_epics',
|
||||
description:
|
||||
'Epics of a project (top-level tickets that group others) with the progress of the tickets hanging from each one.',
|
||||
paramsSchema: z.object({ project: projectRef }),
|
||||
annotations: READ,
|
||||
})
|
||||
epics({ project }: { project: string }, ctx: McpContext) {
|
||||
return toolResult(async () =>
|
||||
(
|
||||
await this.api.data(
|
||||
this.api.as(ctx).GET('/api/projects/{project}/epics', {
|
||||
params: { path: { project } },
|
||||
}),
|
||||
)
|
||||
).map((epic) => ({
|
||||
...issueLine(epic.issue),
|
||||
startDate: epic.issue.startDate,
|
||||
progress: epic.progress,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_issue_parent',
|
||||
description:
|
||||
'Place a ticket under an epic or another ticket of the same project (three levels at most: epic → ticket → subtask), or back to the top (null).',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
parent: z.string().nullable().describe('Epic or ticket key, or null'),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
setParent(
|
||||
{ issue, parent }: { issue: string; parent: string | null },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).PUT('/api/issues/{issue}/parent', {
|
||||
params: { path: { issue } },
|
||||
body: { parent },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'rank_issue',
|
||||
description:
|
||||
'Move a ticket in the backlog (board columns follow the same order): say which ticket ends up right above it or right below it; above null puts it at the top, below null at the bottom.',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
above: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Ticket that ends up right above (null: to the top)'),
|
||||
below: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Ticket that ends up right below (null: to the bottom)'),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
rank(
|
||||
params: { issue: string; above?: string | null; below?: string | null },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const { issue, ...body } = params;
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).PUT('/api/issues/{issue}/rank', {
|
||||
params: { path: { issue } },
|
||||
body,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'bulk_update_issues',
|
||||
description:
|
||||
'The same change on up to 100 tickets: status, assignee, sprint, release, priority, extra labels. Each ticket follows the normal rules and may refuse on its own; the answer says which.',
|
||||
paramsSchema: z.object({
|
||||
issues: z.array(issueRef).min(1).max(100),
|
||||
status: z.string().optional().describe('Status id'),
|
||||
assigneeId: z.string().optional().describe('User id of a member'),
|
||||
unassign: z.boolean().optional(),
|
||||
sprintId: z.string().optional(),
|
||||
noSprint: z.boolean().optional().describe('Back to the backlog'),
|
||||
releaseId: z.string().optional(),
|
||||
noRelease: z.boolean().optional().describe('Out of their release'),
|
||||
priority: PRIORITY.optional(),
|
||||
addLabels: z.array(z.string().max(40)).max(20).optional(),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
bulk(
|
||||
params: {
|
||||
issues: string[];
|
||||
status?: string;
|
||||
assigneeId?: string;
|
||||
unassign?: boolean;
|
||||
sprintId?: string;
|
||||
noSprint?: boolean;
|
||||
releaseId?: string;
|
||||
noRelease?: boolean;
|
||||
priority?: z.infer<typeof PRIORITY>;
|
||||
addLabels?: string[];
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const { issues, ...change } = params;
|
||||
return toolResult(async () => {
|
||||
const client = this.api.as(ctx);
|
||||
const refs = await this.ids(client, issues);
|
||||
const answer = await this.api.data(
|
||||
client.POST('/api/issues/bulk', {
|
||||
body: { issueIds: refs.map((r) => r.id), ...change },
|
||||
}),
|
||||
);
|
||||
const keyOf = new Map(refs.map((r) => [r.id, r.ref]));
|
||||
return {
|
||||
updated: answer.updated,
|
||||
refused: answer.results
|
||||
.filter((r) => !r.ok)
|
||||
.map((r) => ({ issue: keyOf.get(r.issueId), error: r.error })),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'delete_issue',
|
||||
description:
|
||||
'Move a ticket to the trash (whoever opened it, or an owner/admin). It disappears everywhere; restore_issue brings it back within 30 days, then it is deleted for good.',
|
||||
paramsSchema: z.object({ issue: issueRef }),
|
||||
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||
})
|
||||
trash({ issue }: { issue: string }, ctx: McpContext) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).DELETE('/api/issues/{issue}', {
|
||||
params: { path: { issue } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'restore_issue',
|
||||
description: 'Bring a ticket back from the trash.',
|
||||
paramsSchema: z.object({ issue: issueRef }),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
restore({ issue }: { issue: string }, ctx: McpContext) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/issues/{issue}/restore', {
|
||||
params: { path: { issue } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'list_trash',
|
||||
description: "A project's tickets in the trash, newest first.",
|
||||
paramsSchema: z.object({ project: projectRef }),
|
||||
annotations: READ,
|
||||
})
|
||||
listTrash({ project }: { project: string }, ctx: McpContext) {
|
||||
return toolResult(async () =>
|
||||
(
|
||||
await this.api.data(
|
||||
this.api.as(ctx).GET('/api/projects/{project}/trash', {
|
||||
params: { path: { project } },
|
||||
}),
|
||||
)
|
||||
).map((issue) => ({ ...issueLine(issue), trashedAt: issue.trashedAt })),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The bulk endpoint takes ids; people and models talk in keys. Keys are
|
||||
* looked up a few at a time, ids pass as they are.
|
||||
*/
|
||||
private async ids(
|
||||
client: TaskStreamClient,
|
||||
refs: string[],
|
||||
): Promise<{ ref: string; id: string }[]> {
|
||||
const found: { ref: string; id: string }[] = [];
|
||||
for (let i = 0; i < refs.length; i += 10) {
|
||||
const batch = refs.slice(i, i + 10);
|
||||
found.push(
|
||||
...(await Promise.all(
|
||||
batch.map(async (ref) =>
|
||||
UUID.test(ref)
|
||||
? { ref, id: ref }
|
||||
: {
|
||||
ref,
|
||||
id: (
|
||||
await this.api.data(
|
||||
client.GET('/api/issues/{issue}', {
|
||||
params: { path: { issue: ref } },
|
||||
}),
|
||||
)
|
||||
).id,
|
||||
},
|
||||
),
|
||||
)),
|
||||
);
|
||||
}
|
||||
return found;
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,11 @@ const WRITE = {
|
||||
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']);
|
||||
@@ -35,6 +40,10 @@ const issueFields = {
|
||||
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'),
|
||||
};
|
||||
|
||||
@@ -45,6 +54,7 @@ type IssueFields = {
|
||||
labels?: string[];
|
||||
figmaUrl?: string;
|
||||
storyPoints?: number;
|
||||
startDate?: string;
|
||||
dueDate?: string;
|
||||
};
|
||||
|
||||
@@ -95,7 +105,7 @@ export class IssuesResolver {
|
||||
@Tool({
|
||||
name: 'search_issues',
|
||||
description:
|
||||
'Search the tickets of a project by text (key, title, description) and filters.',
|
||||
'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(),
|
||||
@@ -108,6 +118,21 @@ export class IssuesResolver {
|
||||
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),
|
||||
}),
|
||||
@@ -126,6 +151,12 @@ export class IssuesResolver {
|
||||
unassigned?: boolean;
|
||||
releaseId?: string;
|
||||
labels?: string[];
|
||||
parent?: string;
|
||||
sprintId?: string;
|
||||
noSprint?: boolean;
|
||||
dueFrom?: string;
|
||||
dueTo?: string;
|
||||
sort: 'RECENT' | 'RANK';
|
||||
limit: number;
|
||||
offset: number;
|
||||
},
|
||||
@@ -147,6 +178,12 @@ export class IssuesResolver {
|
||||
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,
|
||||
},
|
||||
@@ -160,7 +197,7 @@ export class IssuesResolver {
|
||||
@Tool({
|
||||
name: 'get_issue',
|
||||
description:
|
||||
'A ticket in full: description, links, allowed next statuses, and optionally its comments and history.',
|
||||
'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),
|
||||
@@ -206,10 +243,21 @@ export class IssuesResolver {
|
||||
description: issue.description,
|
||||
reporter: issue.reporter.name,
|
||||
figmaUrl: issue.figmaUrl,
|
||||
storyPoints: issue.storyPoints,
|
||||
dueDate: issue.dueDate,
|
||||
startDate: issue.startDate,
|
||||
allowedTransitions: issue.allowedTransitions,
|
||||
parent: issue.parent,
|
||||
checklist: issue.checklist,
|
||||
links: issue.links,
|
||||
devLinks: issue.devLinks.map((d) => ({
|
||||
kind: d.kind,
|
||||
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,
|
||||
@@ -229,6 +277,10 @@ export class IssuesResolver {
|
||||
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,
|
||||
@@ -240,6 +292,7 @@ export class IssuesResolver {
|
||||
title: string;
|
||||
assigneeId?: string;
|
||||
releaseId?: string;
|
||||
parent?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
@@ -399,4 +452,167 @@ export class IssuesResolver {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@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 } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
70
apps/mcp/src/app/tools/notifications.resolver.ts
Normal file
70
apps/mcp/src/app/tools/notifications.resolver.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
||||
import { z } from 'zod';
|
||||
import { TaskStreamApi } from '../api/taskstream-api';
|
||||
import { limit } from './params';
|
||||
import { toolResult } from './tool-result';
|
||||
|
||||
@Resolver()
|
||||
export class NotificationsResolver {
|
||||
constructor(private readonly api: TaskStreamApi) {}
|
||||
|
||||
@Tool({
|
||||
name: 'my_notifications',
|
||||
description:
|
||||
"The user's notifications, newest first: assignments, status changes, failed QA, comments, mentions, followed tickets, reminders.",
|
||||
paramsSchema: z.object({
|
||||
unreadOnly: z.boolean().default(true),
|
||||
limit: limit(100, 30),
|
||||
}),
|
||||
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||
})
|
||||
mine(
|
||||
{ unreadOnly, limit }: { unreadOnly: boolean; limit: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(async () => {
|
||||
const page = await this.api.data(
|
||||
this.api.as(ctx).GET('/api/me/notifications', {
|
||||
params: { query: { unreadOnly, limit } },
|
||||
}),
|
||||
);
|
||||
return {
|
||||
unread: page.unread,
|
||||
items: page.items.map((n) => ({
|
||||
id: n.id,
|
||||
kind: n.kind,
|
||||
project: n.projectKey,
|
||||
issue: n.issueKey,
|
||||
who: n.actor?.name ?? null,
|
||||
summary: n.summary,
|
||||
read: n.readAt !== null,
|
||||
at: n.createdAt,
|
||||
})),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'mark_notifications_read',
|
||||
description: 'Mark notifications as read: these ids, or all of them.',
|
||||
paramsSchema: z.object({
|
||||
ids: z
|
||||
.array(z.string())
|
||||
.optional()
|
||||
.describe('Notification ids; absent: all'),
|
||||
}),
|
||||
annotations: {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
},
|
||||
})
|
||||
markRead({ ids }: { ids?: string[] }, ctx: McpContext) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/me/notifications/read', { body: { ids } }),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -13,6 +13,11 @@ export const issueRef = z
|
||||
|
||||
export const releaseId = z.string().min(1).describe('Release id');
|
||||
|
||||
export const sprintId = z
|
||||
.string()
|
||||
.min(1)
|
||||
.describe('Sprint id (see list_sprints)');
|
||||
|
||||
export const limit = (max: number, fallback: number) =>
|
||||
z
|
||||
.number()
|
||||
|
||||
@@ -37,6 +37,59 @@ export class ProjectsResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'create_project',
|
||||
description:
|
||||
'Create a project; the user becomes its owner. The key prefixes its tickets (WEB → WEB-1) and cannot change later.',
|
||||
paramsSchema: z.object({
|
||||
key: z
|
||||
.string()
|
||||
.regex(/^[A-Za-z][A-Za-z0-9]{1,9}$/)
|
||||
.describe('2-10 letters or digits, starting with a letter; unique'),
|
||||
name: z.string().min(1).max(120),
|
||||
description: z.string().max(5000).optional(),
|
||||
isPublic: z
|
||||
.boolean()
|
||||
.default(false)
|
||||
.describe('Anyone can read it, also without signing in'),
|
||||
}),
|
||||
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||
})
|
||||
create(
|
||||
params: {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
isPublic: boolean;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/projects', {
|
||||
body: { ...params, key: params.key.toUpperCase() },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_project_showcase',
|
||||
description:
|
||||
'The public summary of a public project: epics on the roadmap with dates and progress, latest releases (notes of shipped ones), the sprint under way and open good-first-issue tickets. Paths point into the web app.',
|
||||
paramsSchema: z.object({ project: projectRef }),
|
||||
annotations: READ,
|
||||
})
|
||||
showcase({ project }: { project: string }, ctx: McpContext) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).GET('/api/explore/projects/{project}/showcase', {
|
||||
params: { path: { project } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_project',
|
||||
description:
|
||||
@@ -72,6 +125,7 @@ export class ProjectsResolver {
|
||||
assigneeId: z.string().optional().describe('Only this person (user id)'),
|
||||
releaseId: z.string().optional().describe('Only this release'),
|
||||
area: z.string().optional().describe('Only this area id'),
|
||||
sprintId: z.string().optional().describe('Only this sprint'),
|
||||
perColumn: limit(200, 25),
|
||||
}),
|
||||
annotations: READ,
|
||||
@@ -82,6 +136,7 @@ export class ProjectsResolver {
|
||||
assigneeId?: string;
|
||||
releaseId?: string;
|
||||
area?: string;
|
||||
sprintId?: string;
|
||||
perColumn: number;
|
||||
},
|
||||
ctx: McpContext,
|
||||
|
||||
@@ -54,7 +54,7 @@ export class ReleasesResolver {
|
||||
@Tool({
|
||||
name: 'get_release',
|
||||
description:
|
||||
'A release with its progress (finished out of total), its tickets and, optionally, the day-by-day burnup.',
|
||||
'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),
|
||||
@@ -90,6 +90,7 @@ export class ReleasesResolver {
|
||||
return {
|
||||
...releaseLine(release),
|
||||
description: release.description,
|
||||
notes: release.notes,
|
||||
progress,
|
||||
issues: issues.items.map(issueLine),
|
||||
burnup,
|
||||
@@ -148,4 +149,104 @@ export class ReleasesResolver {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@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 tickets leave it.',
|
||||
paramsSchema: z.object({ releaseId }),
|
||||
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||
})
|
||||
archive({ releaseId }: { releaseId: string }, ctx: McpContext) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/releases/{releaseId}/archive', {
|
||||
params: { path: { releaseId } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@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 },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
240
apps/mcp/src/app/tools/sprints.resolver.ts
Normal file
240
apps/mcp/src/app/tools/sprints.resolver.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
||||
import { z } from 'zod';
|
||||
import { TaskStreamApi } from '../api/taskstream-api';
|
||||
import { issueRef, limit, projectRef, sprintId } from './params';
|
||||
import { issueLine, sprintLine } 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 date = (what: string) => z.string().describe(`${what} (ISO date)`);
|
||||
|
||||
@Resolver()
|
||||
export class SprintsResolver {
|
||||
constructor(private readonly api: TaskStreamApi) {}
|
||||
|
||||
@Tool({
|
||||
name: 'list_sprints',
|
||||
description:
|
||||
"A project's sprints (planned, active and closed) with their dates and goal.",
|
||||
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}/sprints', {
|
||||
params: { path: { project } },
|
||||
}),
|
||||
)
|
||||
).map(sprintLine),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_sprint',
|
||||
description:
|
||||
'A sprint with its progress (tickets and story points finished), its tickets in backlog order and, optionally, the day-by-day burndown.',
|
||||
paramsSchema: z.object({
|
||||
sprintId,
|
||||
withBurndown: z.boolean().default(false),
|
||||
limit: limit(200, 100),
|
||||
}),
|
||||
annotations: READ,
|
||||
})
|
||||
get(
|
||||
params: { sprintId: string; withBurndown: boolean; limit: number },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(async () => {
|
||||
const client = this.api.as(ctx);
|
||||
const path = { sprint: params.sprintId };
|
||||
const [sprint, progress, issues] = await Promise.all([
|
||||
this.api.data(
|
||||
client.GET('/api/sprints/{sprint}', { params: { path } }),
|
||||
),
|
||||
this.api.data(
|
||||
client.GET('/api/sprints/{sprint}/progress', { params: { path } }),
|
||||
),
|
||||
this.api.data(
|
||||
client.GET('/api/sprints/{sprint}/issues', {
|
||||
params: { path, query: { limit: params.limit } },
|
||||
}),
|
||||
),
|
||||
]);
|
||||
const burndown = params.withBurndown
|
||||
? await this.api.data(
|
||||
client.GET('/api/sprints/{sprint}/burndown', { params: { path } }),
|
||||
)
|
||||
: undefined;
|
||||
return {
|
||||
...sprintLine(sprint),
|
||||
progress,
|
||||
total: issues.total,
|
||||
issues: issues.items.map(issueLine),
|
||||
burndown,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'create_sprint',
|
||||
description: 'Plan a sprint in a project (owners and admins).',
|
||||
paramsSchema: z.object({
|
||||
project: projectRef,
|
||||
name: z.string().min(1).max(80).describe('e.g. Sprint 14'),
|
||||
goal: z.string().max(500).optional(),
|
||||
startDate: date('First day'),
|
||||
endDate: date('Last day'),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
create(
|
||||
params: {
|
||||
project: string;
|
||||
name: string;
|
||||
goal?: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const { project, ...body } = params;
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/projects/{project}/sprints', {
|
||||
params: { path: { project } },
|
||||
body,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'update_sprint',
|
||||
description:
|
||||
'Rename a sprint, change its goal or its dates (not once closed).',
|
||||
paramsSchema: z.object({
|
||||
sprintId,
|
||||
name: z.string().min(1).max(80).optional(),
|
||||
goal: z.string().max(500).nullable().optional(),
|
||||
startDate: date('First day').optional(),
|
||||
endDate: date('Last day').optional(),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
update(
|
||||
params: {
|
||||
sprintId: string;
|
||||
name?: string;
|
||||
goal?: string | null;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
},
|
||||
ctx: McpContext,
|
||||
) {
|
||||
const { sprintId, ...body } = params;
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).PATCH('/api/sprints/{sprint}', {
|
||||
params: { path: { sprint: sprintId } },
|
||||
body,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'start_sprint',
|
||||
description:
|
||||
'Start a planned sprint (only one can be under way per project).',
|
||||
paramsSchema: z.object({ sprintId }),
|
||||
annotations: WRITE,
|
||||
})
|
||||
start({ sprintId }: { sprintId: string }, ctx: McpContext) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/sprints/{sprint}/start', {
|
||||
params: { path: { sprint: sprintId } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'close_sprint',
|
||||
description:
|
||||
'Close the sprint under way. Unfinished tickets move to carryOverTo (another open sprint of the project) or back to the backlog; finished ones stay as its record.',
|
||||
paramsSchema: z.object({
|
||||
sprintId,
|
||||
carryOverTo: z
|
||||
.string()
|
||||
.nullable()
|
||||
.optional()
|
||||
.describe('Sprint id for the unfinished tickets; absent: the backlog'),
|
||||
}),
|
||||
annotations: WRITE,
|
||||
})
|
||||
close(
|
||||
{
|
||||
sprintId,
|
||||
carryOverTo,
|
||||
}: { sprintId: string; carryOverTo?: string | null },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).POST('/api/sprints/{sprint}/close', {
|
||||
params: { path: { sprint: sprintId } },
|
||||
body: { carryOverTo },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'delete_sprint',
|
||||
description: 'Delete a sprint that never started.',
|
||||
paramsSchema: z.object({ sprintId }),
|
||||
annotations: { readOnlyHint: false, destructiveHint: true },
|
||||
})
|
||||
delete({ sprintId }: { sprintId: string }, ctx: McpContext) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).DELETE('/api/sprints/{sprint}', {
|
||||
params: { path: { sprint: sprintId } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'set_issue_sprint',
|
||||
description:
|
||||
'Put a ticket in an open sprint of its project, or back to the backlog (null). For many at once: bulk_update_issues.',
|
||||
paramsSchema: z.object({
|
||||
issue: issueRef,
|
||||
sprintId: z.string().nullable().describe('Sprint id, or null'),
|
||||
}),
|
||||
annotations: { ...WRITE, idempotentHint: true },
|
||||
})
|
||||
setSprint(
|
||||
{ issue, sprintId }: { issue: string; sprintId: string | null },
|
||||
ctx: McpContext,
|
||||
) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).PUT('/api/issues/{issue}/sprint', {
|
||||
params: { path: { issue } },
|
||||
body: { sprintId },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type { Schemas } from '../api/taskstream-api';
|
||||
type Issue = Schemas['IssueDto'];
|
||||
type Project = Schemas['ProjectDto'];
|
||||
type Release = Schemas['ReleaseDto'];
|
||||
type Sprint = Schemas['SprintDto'];
|
||||
|
||||
export const issueLine = (issue: Issue) => ({
|
||||
key: issue.key,
|
||||
@@ -19,6 +20,10 @@ export const issueLine = (issue: Issue) => ({
|
||||
area: issue.area,
|
||||
assignee: issue.assignee?.name ?? null,
|
||||
release: issue.release?.name ?? null,
|
||||
sprint: issue.sprint?.name ?? null,
|
||||
parent: issue.parent?.key ?? null,
|
||||
storyPoints: issue.storyPoints,
|
||||
dueDate: issue.dueDate,
|
||||
labels: issue.labels,
|
||||
updatedAt: issue.updatedAt,
|
||||
});
|
||||
@@ -61,3 +66,12 @@ export const releaseLine = (release: Release) => ({
|
||||
targetDate: release.targetDate,
|
||||
releasedAt: release.releasedAt,
|
||||
});
|
||||
|
||||
export const sprintLine = (sprint: Sprint) => ({
|
||||
id: sprint.id,
|
||||
name: sprint.name,
|
||||
goal: sprint.goal,
|
||||
status: sprint.status,
|
||||
startDate: sprint.startDate,
|
||||
endDate: sprint.endDate,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user