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:
@@ -6314,11 +6314,7 @@
|
|||||||
"description": "Only unread ones",
|
"description": "Only unread ones",
|
||||||
"schema": {
|
"schema": {
|
||||||
"default": false,
|
"default": false,
|
||||||
"allOf": [
|
"type": "boolean"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -8952,11 +8948,7 @@
|
|||||||
"description": "Only tell what would be created",
|
"description": "Only tell what would be created",
|
||||||
"schema": {
|
"schema": {
|
||||||
"default": false,
|
"default": false,
|
||||||
"allOf": [
|
"type": "boolean"
|
||||||
{
|
|
||||||
"$ref": "#/components/schemas/Object"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
@@ -11788,10 +11780,6 @@
|
|||||||
"goodFirst"
|
"goodFirst"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"Object": {
|
|
||||||
"type": "object",
|
|
||||||
"properties": {}
|
|
||||||
},
|
|
||||||
"NotificationKind": {
|
"NotificationKind": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"enum": [
|
"enum": [
|
||||||
|
|||||||
@@ -2192,7 +2192,6 @@ export interface components {
|
|||||||
/** @description Open tickets labelled good-first-issue */
|
/** @description Open tickets labelled good-first-issue */
|
||||||
goodFirst: components["schemas"]["ShowcaseIssueDto"][];
|
goodFirst: components["schemas"]["ShowcaseIssueDto"][];
|
||||||
};
|
};
|
||||||
Object: Record<string, never>;
|
|
||||||
/** @enum {string} */
|
/** @enum {string} */
|
||||||
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER";
|
NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER";
|
||||||
NotificationDto: {
|
NotificationDto: {
|
||||||
@@ -6766,7 +6765,7 @@ export interface operations {
|
|||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
/** @description Only unread ones */
|
/** @description Only unread ones */
|
||||||
unreadOnly?: components["schemas"]["Object"];
|
unreadOnly?: boolean;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
@@ -8679,7 +8678,7 @@ export interface operations {
|
|||||||
parameters: {
|
parameters: {
|
||||||
query?: {
|
query?: {
|
||||||
/** @description Only tell what would be created */
|
/** @description Only tell what would be created */
|
||||||
dryRun?: components["schemas"]["Object"];
|
dryRun?: boolean;
|
||||||
};
|
};
|
||||||
header?: never;
|
header?: never;
|
||||||
path: {
|
path: {
|
||||||
|
|||||||
@@ -4,16 +4,21 @@ import { McpModule } from '@nestjs-mcp/server';
|
|||||||
import { TASKSTREAM_API_URL, TaskStreamApi } from './api/taskstream-api';
|
import { TASKSTREAM_API_URL, TaskStreamApi } from './api/taskstream-api';
|
||||||
import { HealthController } from './health.controller';
|
import { HealthController } from './health.controller';
|
||||||
import { McpHostsMiddleware } from './mcp-hosts.middleware';
|
import { McpHostsMiddleware } from './mcp-hosts.middleware';
|
||||||
|
import { BacklogResolver } from './tools/backlog.resolver';
|
||||||
import { IssuesResolver } from './tools/issues.resolver';
|
import { IssuesResolver } from './tools/issues.resolver';
|
||||||
|
import { NotificationsResolver } from './tools/notifications.resolver';
|
||||||
import { ProjectsResolver } from './tools/projects.resolver';
|
import { ProjectsResolver } from './tools/projects.resolver';
|
||||||
import { ReleasesResolver } from './tools/releases.resolver';
|
import { ReleasesResolver } from './tools/releases.resolver';
|
||||||
|
import { SprintsResolver } from './tools/sprints.resolver';
|
||||||
import { UsersResolver } from './tools/users.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
|
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
|
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
|
use a ticket's allowedTransitions (get_issue) to move it. Sprints and releases go by id
|
||||||
token the client sends; without one, only public projects can be read.`;
|
(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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -44,7 +49,10 @@ token the client sends; without one, only public projects can be read.`;
|
|||||||
TaskStreamApi,
|
TaskStreamApi,
|
||||||
ProjectsResolver,
|
ProjectsResolver,
|
||||||
IssuesResolver,
|
IssuesResolver,
|
||||||
|
BacklogResolver,
|
||||||
|
SprintsResolver,
|
||||||
ReleasesResolver,
|
ReleasesResolver,
|
||||||
|
NotificationsResolver,
|
||||||
UsersResolver,
|
UsersResolver,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import { AddressInfo } from 'node:net';
|
|||||||
import { TASKSTREAM_API_URL } from './api/taskstream-api';
|
import { TASKSTREAM_API_URL } from './api/taskstream-api';
|
||||||
import { AppModule } from './app.module';
|
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. */
|
/** What the fake API received. */
|
||||||
interface Received {
|
interface Received {
|
||||||
method: string;
|
method: string;
|
||||||
@@ -75,6 +78,38 @@ function fakeApi(received: Received[]): Server {
|
|||||||
code: 'BUSINESS_RULE',
|
code: 'BUSINESS_RULE',
|
||||||
message: 'Cannot move',
|
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' });
|
return reply(404, { statusCode: 404, message: 'Not found' });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -152,11 +187,24 @@ describe('TaskStream MCP', () => {
|
|||||||
'change_issue_status',
|
'change_issue_status',
|
||||||
'get_release',
|
'get_release',
|
||||||
'whoami',
|
'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]));
|
const byName = new Map(tools.map((t) => [t.name, t]));
|
||||||
expect(byName.get('get_project')?.annotations?.readOnlyHint).toBe(true);
|
expect(byName.get('get_project')?.annotations?.readOnlyHint).toBe(true);
|
||||||
expect(byName.get('create_issue')?.annotations?.readOnlyHint).toBe(false);
|
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 () => {
|
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);
|
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). */
|
/** A raw POST /mcp with chosen Host / Origin headers (fetch would not let us set Host). */
|
||||||
function rawPost(headers: Record<string, string>): Promise<number> {
|
function rawPost(headers: Record<string, string>): Promise<number> {
|
||||||
const { port } = new URL(url);
|
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,
|
destructiveHint: false,
|
||||||
openWorldHint: false,
|
openWorldHint: false,
|
||||||
} as const;
|
} as const;
|
||||||
|
const REMOVE = {
|
||||||
|
readOnlyHint: false,
|
||||||
|
destructiveHint: true,
|
||||||
|
openWorldHint: false,
|
||||||
|
} as const;
|
||||||
|
|
||||||
const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
|
const PRIORITY = z.enum(['LOWEST', 'LOW', 'MEDIUM', 'HIGH', 'CRITICAL']);
|
||||||
const CATEGORY = z.enum(['TODO', 'IN_PROGRESS', 'QA', 'DONE', 'CANCELLED']);
|
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(),
|
labels: z.array(z.string().max(40)).max(20).optional(),
|
||||||
figmaUrl: z.string().url().optional().describe('Figma frame (https)'),
|
figmaUrl: z.string().url().optional().describe('Figma frame (https)'),
|
||||||
storyPoints: z.number().int().min(0).max(100).optional(),
|
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'),
|
dueDate: z.string().optional().describe('ISO date, e.g. 2026-11-14'),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -45,6 +54,7 @@ type IssueFields = {
|
|||||||
labels?: string[];
|
labels?: string[];
|
||||||
figmaUrl?: string;
|
figmaUrl?: string;
|
||||||
storyPoints?: number;
|
storyPoints?: number;
|
||||||
|
startDate?: string;
|
||||||
dueDate?: string;
|
dueDate?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -95,7 +105,7 @@ export class IssuesResolver {
|
|||||||
@Tool({
|
@Tool({
|
||||||
name: 'search_issues',
|
name: 'search_issues',
|
||||||
description:
|
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({
|
paramsSchema: z.object({
|
||||||
project: projectRef,
|
project: projectRef,
|
||||||
search: z.string().max(200).optional(),
|
search: z.string().max(200).optional(),
|
||||||
@@ -108,6 +118,21 @@ export class IssuesResolver {
|
|||||||
unassigned: z.boolean().optional(),
|
unassigned: z.boolean().optional(),
|
||||||
releaseId: z.string().optional(),
|
releaseId: z.string().optional(),
|
||||||
labels: z.array(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),
|
limit: limit(200, 30),
|
||||||
offset: z.number().int().min(0).default(0),
|
offset: z.number().int().min(0).default(0),
|
||||||
}),
|
}),
|
||||||
@@ -126,6 +151,12 @@ export class IssuesResolver {
|
|||||||
unassigned?: boolean;
|
unassigned?: boolean;
|
||||||
releaseId?: string;
|
releaseId?: string;
|
||||||
labels?: string[];
|
labels?: string[];
|
||||||
|
parent?: string;
|
||||||
|
sprintId?: string;
|
||||||
|
noSprint?: boolean;
|
||||||
|
dueFrom?: string;
|
||||||
|
dueTo?: string;
|
||||||
|
sort: 'RECENT' | 'RANK';
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
},
|
},
|
||||||
@@ -147,6 +178,12 @@ export class IssuesResolver {
|
|||||||
unassigned: params.unassigned,
|
unassigned: params.unassigned,
|
||||||
releaseId: params.releaseId,
|
releaseId: params.releaseId,
|
||||||
label: params.labels,
|
label: params.labels,
|
||||||
|
parent: params.parent,
|
||||||
|
sprintId: params.sprintId,
|
||||||
|
noSprint: params.noSprint,
|
||||||
|
dueFrom: params.dueFrom,
|
||||||
|
dueTo: params.dueTo,
|
||||||
|
sort: params.sort,
|
||||||
limit: params.limit,
|
limit: params.limit,
|
||||||
offset: params.offset,
|
offset: params.offset,
|
||||||
},
|
},
|
||||||
@@ -160,7 +197,7 @@ export class IssuesResolver {
|
|||||||
@Tool({
|
@Tool({
|
||||||
name: 'get_issue',
|
name: 'get_issue',
|
||||||
description:
|
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({
|
paramsSchema: z.object({
|
||||||
issue: issueRef,
|
issue: issueRef,
|
||||||
withComments: z.boolean().default(true),
|
withComments: z.boolean().default(true),
|
||||||
@@ -206,10 +243,21 @@ export class IssuesResolver {
|
|||||||
description: issue.description,
|
description: issue.description,
|
||||||
reporter: issue.reporter.name,
|
reporter: issue.reporter.name,
|
||||||
figmaUrl: issue.figmaUrl,
|
figmaUrl: issue.figmaUrl,
|
||||||
storyPoints: issue.storyPoints,
|
startDate: issue.startDate,
|
||||||
dueDate: issue.dueDate,
|
|
||||||
allowedTransitions: issue.allowedTransitions,
|
allowedTransitions: issue.allowedTransitions,
|
||||||
|
parent: issue.parent,
|
||||||
|
checklist: issue.checklist,
|
||||||
links: issue.links,
|
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,
|
createdAt: issue.createdAt,
|
||||||
comments,
|
comments,
|
||||||
history,
|
history,
|
||||||
@@ -229,6 +277,10 @@ export class IssuesResolver {
|
|||||||
title: z.string().min(1).max(255),
|
title: z.string().min(1).max(255),
|
||||||
assigneeId: z.string().optional().describe('User id (a project member)'),
|
assigneeId: z.string().optional().describe('User id (a project member)'),
|
||||||
releaseId: z.string().optional(),
|
releaseId: z.string().optional(),
|
||||||
|
parent: z
|
||||||
|
.string()
|
||||||
|
.optional()
|
||||||
|
.describe('Epic or ticket of the same project it breaks down (key)'),
|
||||||
...issueFields,
|
...issueFields,
|
||||||
}),
|
}),
|
||||||
annotations: WRITE,
|
annotations: WRITE,
|
||||||
@@ -240,6 +292,7 @@ export class IssuesResolver {
|
|||||||
title: string;
|
title: string;
|
||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
releaseId?: string;
|
releaseId?: string;
|
||||||
|
parent?: string;
|
||||||
},
|
},
|
||||||
ctx: McpContext,
|
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 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) =>
|
export const limit = (max: number, fallback: number) =>
|
||||||
z
|
z
|
||||||
.number()
|
.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({
|
@Tool({
|
||||||
name: 'get_project',
|
name: 'get_project',
|
||||||
description:
|
description:
|
||||||
@@ -72,6 +125,7 @@ export class ProjectsResolver {
|
|||||||
assigneeId: z.string().optional().describe('Only this person (user id)'),
|
assigneeId: z.string().optional().describe('Only this person (user id)'),
|
||||||
releaseId: z.string().optional().describe('Only this release'),
|
releaseId: z.string().optional().describe('Only this release'),
|
||||||
area: z.string().optional().describe('Only this area id'),
|
area: z.string().optional().describe('Only this area id'),
|
||||||
|
sprintId: z.string().optional().describe('Only this sprint'),
|
||||||
perColumn: limit(200, 25),
|
perColumn: limit(200, 25),
|
||||||
}),
|
}),
|
||||||
annotations: READ,
|
annotations: READ,
|
||||||
@@ -82,6 +136,7 @@ export class ProjectsResolver {
|
|||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
releaseId?: string;
|
releaseId?: string;
|
||||||
area?: string;
|
area?: string;
|
||||||
|
sprintId?: string;
|
||||||
perColumn: number;
|
perColumn: number;
|
||||||
},
|
},
|
||||||
ctx: McpContext,
|
ctx: McpContext,
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ export class ReleasesResolver {
|
|||||||
@Tool({
|
@Tool({
|
||||||
name: 'get_release',
|
name: 'get_release',
|
||||||
description:
|
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({
|
paramsSchema: z.object({
|
||||||
releaseId,
|
releaseId,
|
||||||
withBurnup: z.boolean().default(false),
|
withBurnup: z.boolean().default(false),
|
||||||
@@ -90,6 +90,7 @@ export class ReleasesResolver {
|
|||||||
return {
|
return {
|
||||||
...releaseLine(release),
|
...releaseLine(release),
|
||||||
description: release.description,
|
description: release.description,
|
||||||
|
notes: release.notes,
|
||||||
progress,
|
progress,
|
||||||
issues: issues.items.map(issueLine),
|
issues: issues.items.map(issueLine),
|
||||||
burnup,
|
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 Issue = Schemas['IssueDto'];
|
||||||
type Project = Schemas['ProjectDto'];
|
type Project = Schemas['ProjectDto'];
|
||||||
type Release = Schemas['ReleaseDto'];
|
type Release = Schemas['ReleaseDto'];
|
||||||
|
type Sprint = Schemas['SprintDto'];
|
||||||
|
|
||||||
export const issueLine = (issue: Issue) => ({
|
export const issueLine = (issue: Issue) => ({
|
||||||
key: issue.key,
|
key: issue.key,
|
||||||
@@ -19,6 +20,10 @@ export const issueLine = (issue: Issue) => ({
|
|||||||
area: issue.area,
|
area: issue.area,
|
||||||
assignee: issue.assignee?.name ?? null,
|
assignee: issue.assignee?.name ?? null,
|
||||||
release: issue.release?.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,
|
labels: issue.labels,
|
||||||
updatedAt: issue.updatedAt,
|
updatedAt: issue.updatedAt,
|
||||||
});
|
});
|
||||||
@@ -61,3 +66,12 @@ export const releaseLine = (release: Release) => ({
|
|||||||
targetDate: release.targetDate,
|
targetDate: release.targetDate,
|
||||||
releasedAt: release.releasedAt,
|
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,
|
||||||
|
});
|
||||||
|
|||||||
@@ -32,35 +32,56 @@ flowchart LR
|
|||||||
|
|
||||||
## 9.2 Herramientas
|
## 9.2 Herramientas
|
||||||
|
|
||||||
|
54 herramientas, agrupadas por ámbito:
|
||||||
|
|
||||||
| Ámbito | Herramienta | Qué hace |
|
| Ámbito | Herramienta | Qué hace |
|
||||||
| --------- | ----------------------- | ------------------------------------------------------------------------------------------- |
|
| ------------ | ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
||||||
| Usuario | `whoami` | El usuario del token |
|
| Usuario | `whoami` | El usuario del token |
|
||||||
| | `search_users` | Buscar personas de Pireon (para asignar) |
|
| | `search_users` | Buscar personas de Pireon (para asignar) |
|
||||||
|
| | `my_notifications` | Mis notificaciones (por defecto, las no leídas) |
|
||||||
|
| | `mark_notifications_read` | Marcar como leídas unas o todas |
|
||||||
| Proyectos | `list_my_projects` | Mis proyectos |
|
| Proyectos | `list_my_projects` | Mis proyectos |
|
||||||
| | `list_public_projects` | Proyectos públicos |
|
| | `list_public_projects` | Proyectos públicos |
|
||||||
|
| | `create_project` | Crear un proyecto (quien lo crea es su dueño) |
|
||||||
| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas |
|
| | `get_project` | Equipo y configuración: ids de estados, tipos y áreas que aceptan las demás herramientas |
|
||||||
| | `get_board` | Tablero por columnas (filtros por persona, release, área) |
|
| | `get_project_showcase` | El escaparate público: hoja de ruta, releases, sprint en curso y tickets para empezar |
|
||||||
|
| | `get_board` | Tablero por columnas (filtros por persona, release, área, sprint) |
|
||||||
| | `get_project_stats` | Cifras de seguimiento |
|
| | `get_project_stats` | Cifras de seguimiento |
|
||||||
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
| | `get_project_analytics` | Estadísticas de los últimos días (creados/resueltos, flujo, tiempos, throughput, QA) |
|
||||||
| | `get_project_activity` | Actividad reciente |
|
| | `get_project_activity` | Actividad reciente |
|
||||||
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría: p. ej. lo que espera QA) |
|
| Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría: p. ej. lo que espera QA) |
|
||||||
| | `search_issues` | Buscar por texto y filtros |
|
| | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas…); el backlog: `noSprint` y orden `RANK` |
|
||||||
| | `get_issue` | Detalle, transiciones permitidas, enlaces, comentarios e historial |
|
| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios |
|
||||||
| | `create_issue` | Crear un ticket |
|
| | `create_issue` | Crear un ticket (también colgado de una épica) |
|
||||||
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas… |
|
| | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas… |
|
||||||
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
|
| | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) |
|
||||||
| | `assign_issue` | Asignar o desasignar |
|
| | `assign_issue` | Asignar o desasignar |
|
||||||
| | `set_issue_release` | Meter en una release o sacar |
|
| | `set_issue_release` | Meter en una release o sacar |
|
||||||
| | `add_comment` | Comentar |
|
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar, corregir o borrar un comentario |
|
||||||
| | `link_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) |
|
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
|
||||||
|
| | `watch_issue` | Seguir un ticket o dejar de seguirlo |
|
||||||
|
| | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar |
|
||||||
|
| Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una |
|
||||||
|
| | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo |
|
||||||
|
| | `rank_issue` | Mover un ticket en el backlog |
|
||||||
|
| | `bulk_update_issues` | El mismo cambio en hasta 100 tickets (por clave); dice cuáles no se dejaron |
|
||||||
|
| | `delete_issue` / `restore_issue` / `list_trash` | Papelera: borrar (30 días para recuperar), recuperar y ver |
|
||||||
|
| Sprints | `list_sprints` | Sprints de un proyecto |
|
||||||
|
| | `get_sprint` | Progreso, tickets y burndown |
|
||||||
|
| | `create_sprint` / `update_sprint` / `delete_sprint` | Planificar, cambiar o borrar (si no empezó) un sprint |
|
||||||
|
| | `start_sprint` / `close_sprint` | Empezarlo; cerrarlo pasando lo pendiente a otro sprint o al backlog |
|
||||||
|
| | `set_issue_sprint` | Meter un ticket en un sprint o devolverlo al backlog |
|
||||||
| Releases | `list_releases` | Releases de un proyecto |
|
| Releases | `list_releases` | Releases de un proyecto |
|
||||||
| | `upcoming_releases` | Próximas releases de mis proyectos |
|
| | `upcoming_releases` | Próximas releases de mis proyectos |
|
||||||
| | `get_release` | Progreso, tickets y burnup |
|
| | `get_release` | Progreso, notas, tickets y burnup |
|
||||||
| | `create_release` | Planificar una release |
|
| | `create_release` / `update_release` | Planificar una release o cambiar su nombre, descripción o fecha |
|
||||||
|
| | `draft_release_notes` / `set_release_notes` | Borrador de las notas desde los tickets terminados, y guardarlas |
|
||||||
| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) |
|
| | `publish_release` | Publicar (con `force` si quedan tickets sin terminar) |
|
||||||
|
| | `archive_release` | Archivar una release que no saldrá (sus tickets la dejan) |
|
||||||
|
|
||||||
Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas. Lo que es de
|
Las de lectura van marcadas como `readOnlyHint`, para que el cliente no pida confirmación al usarlas, y las que borran o
|
||||||
administración (miembros, configuración, visibilidad, borrar proyectos) no se expone: se hace desde la web.
|
archivan, como `destructiveHint`, para que la pida. Lo que es de administración (miembros, configuración, visibilidad,
|
||||||
|
borrar proyectos, webhooks, integración con git, importar y exportar CSV) no se expone: se hace desde la web.
|
||||||
|
|
||||||
## 9.3 Conectar un cliente
|
## 9.3 Conectar un cliente
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@@ -802,6 +802,7 @@ export class UpdateWebhookDto extends PartialType(CreateWebhookDto) {
|
|||||||
|
|
||||||
export class ImportQueryDto {
|
export class ImportQueryDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
description: 'Only tell what would be created',
|
description: 'Only tell what would be created',
|
||||||
})
|
})
|
||||||
@@ -1069,7 +1070,11 @@ export class PublishReleaseDto {
|
|||||||
// ---- Notifications ----------------------------------------------------------
|
// ---- Notifications ----------------------------------------------------------
|
||||||
|
|
||||||
export class MyNotificationsQueryDto {
|
export class MyNotificationsQueryDto {
|
||||||
@ApiPropertyOptional({ default: false, description: 'Only unread ones' })
|
@ApiPropertyOptional({
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
description: 'Only unread ones',
|
||||||
|
})
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Transform(toBoolean)
|
@Transform(toBoolean)
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
|
|||||||
Reference in New Issue
Block a user