On the project dashboard, a day with activity can be picked on the map: a side panel lists that day's moves grouped by ticket (each key opens it), with the time of each, and the project's own (sprints, releases, members) apart. The activity feed takes an `after` date (GraphQL, REST), so with `before` it reads a window such as a day; the MCP's get_project_activity takes a `day`. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
324 lines
11 KiB
TypeScript
324 lines
11 KiB
TypeScript
import { McpContext, Resolver, Tool } from '@nestjs-mcp/server';
|
|
import { z } from 'zod';
|
|
import { TaskStreamApi } from '../api/taskstream-api';
|
|
import { limit, projectRef } from './params';
|
|
import { issueLine, projectLine, projectSettings } from './summaries';
|
|
import { toolResult } from './tool-result';
|
|
|
|
const READ = { readOnlyHint: true, openWorldHint: false } as const;
|
|
|
|
@Resolver()
|
|
export class ProjectsResolver {
|
|
constructor(private readonly api: TaskStreamApi) {}
|
|
|
|
@Tool({
|
|
name: 'list_my_projects',
|
|
description: 'Projects where the signed-in user is a member.',
|
|
annotations: READ,
|
|
})
|
|
listMine(ctx: McpContext) {
|
|
return toolResult(async () =>
|
|
(await this.api.data(this.api.as(ctx).GET('/api/projects'))).map(
|
|
projectLine,
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'list_public_projects',
|
|
description: 'Public projects: anyone can read them, also without a token.',
|
|
annotations: READ,
|
|
})
|
|
listPublic(ctx: McpContext) {
|
|
return toolResult(async () =>
|
|
(await this.api.data(this.api.as(ctx).GET('/api/explore/projects'))).map(
|
|
projectLine,
|
|
),
|
|
);
|
|
}
|
|
|
|
@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. With template, it starts with the statuses, transitions, types, areas, custom fields and automation rules of that project.',
|
|
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'),
|
|
template: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
'Key of a project the user owns or administers to copy its configuration and automation rules from',
|
|
),
|
|
}),
|
|
annotations: { readOnlyHint: false, destructiveHint: false },
|
|
})
|
|
create(
|
|
params: {
|
|
key: string;
|
|
name: string;
|
|
description?: string;
|
|
isPublic: boolean;
|
|
template?: string;
|
|
},
|
|
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:
|
|
'A project with its team and settings: the status, ticket type and area ids that the other tools accept, and which statuses need a reason.',
|
|
paramsSchema: z.object({ project: projectRef }),
|
|
annotations: READ,
|
|
})
|
|
getProject({ project }: { project: string }, ctx: McpContext) {
|
|
return toolResult(async () => {
|
|
const found = await this.api.data(
|
|
this.api
|
|
.as(ctx)
|
|
.GET('/api/projects/{project}', { params: { path: { project } } }),
|
|
);
|
|
return {
|
|
...projectLine(found),
|
|
members: found.members.map((m) => ({
|
|
id: m.user.id,
|
|
name: m.user.name,
|
|
role: m.role,
|
|
})),
|
|
...projectSettings(found),
|
|
};
|
|
});
|
|
}
|
|
|
|
@Tool({
|
|
name: 'get_board',
|
|
description:
|
|
'Kanban board: one column per status (in board order) with its tickets.',
|
|
paramsSchema: z.object({
|
|
project: projectRef,
|
|
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,
|
|
})
|
|
board(
|
|
params: {
|
|
project: string;
|
|
assigneeId?: string;
|
|
releaseId?: string;
|
|
area?: string;
|
|
sprintId?: string;
|
|
perColumn: number;
|
|
},
|
|
ctx: McpContext,
|
|
) {
|
|
const { project, ...query } = params;
|
|
return toolResult(async () =>
|
|
(
|
|
await this.api.data(
|
|
this.api.as(ctx).GET('/api/projects/{project}/board', {
|
|
params: { path: { project }, query },
|
|
}),
|
|
)
|
|
).map((column) => ({
|
|
status: column.status,
|
|
total: column.total,
|
|
issues: column.issues.map(issueLine),
|
|
})),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'get_project_stats',
|
|
description:
|
|
'Tracking numbers: total and open tickets, by status, type, priority, area and open tickets per person.',
|
|
paramsSchema: z.object({ project: projectRef }),
|
|
annotations: READ,
|
|
})
|
|
stats({ project }: { project: string }, ctx: McpContext) {
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).GET('/api/projects/{project}/stats', {
|
|
params: { path: { project } },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'get_project_analytics',
|
|
description:
|
|
'Statistics over the last days: created vs resolved per week, cumulative flow, cycle and lead time (average and p85), throughput per person and QA pass/fail rate.',
|
|
paramsSchema: z.object({
|
|
project: projectRef,
|
|
days: z.number().int().min(7).max(365).default(90),
|
|
}),
|
|
annotations: READ,
|
|
})
|
|
analytics(
|
|
{ project, days }: { project: string; days: number },
|
|
ctx: McpContext,
|
|
) {
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).GET('/api/projects/{project}/analytics', {
|
|
params: { path: { project }, query: { days } },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'get_my_week',
|
|
description:
|
|
"The user's coming week and how the last one went: tickets of theirs (assigned or watched) due in the next 7 days, sprint ends and planned releases; tickets finished by them and by their teams this week and last, and their streak; sprints under way; and, when nothing is theirs, what they could pick up (unassigned, waiting for QA, proposals to review).",
|
|
paramsSchema: z.object({}),
|
|
annotations: READ,
|
|
})
|
|
myWeek(_params: Record<string, never>, ctx: McpContext) {
|
|
return toolResult(async () => {
|
|
const home = await this.api.data(this.api.as(ctx).GET('/api/me/home'));
|
|
// Compact: the page's activity feed, presence and first steps stay out.
|
|
return {
|
|
agenda: home.agenda,
|
|
me: home.me,
|
|
team: { thisWeek: home.team.thisWeek, lastWeek: home.team.lastWeek },
|
|
sprints: home.sprints,
|
|
suggestions: home.suggestions,
|
|
};
|
|
});
|
|
}
|
|
|
|
@Tool({
|
|
name: 'get_portfolio',
|
|
description:
|
|
"All the user's active projects at a glance (not archived, not as a guest): open, overdue, blocked and stuck tickets, the sprint under way and whether it gets there, planned releases with their forecast, and open epics with their progress.",
|
|
paramsSchema: z.object({}),
|
|
annotations: READ,
|
|
})
|
|
portfolio(_params: Record<string, never>, ctx: McpContext) {
|
|
return toolResult(async () => {
|
|
const projects = await this.api.data(
|
|
this.api.as(ctx).GET('/api/me/portfolio'),
|
|
);
|
|
// Compact: epics as key, title and progress.
|
|
return projects.map(({ epics, ...p }) => ({
|
|
...p,
|
|
epics: epics.map((e) => ({
|
|
key: e.issue.key,
|
|
title: e.issue.title,
|
|
dueDate: e.issue.dueDate,
|
|
percent: e.progress.percent,
|
|
})),
|
|
}));
|
|
});
|
|
}
|
|
|
|
@Tool({
|
|
name: 'get_project_insights',
|
|
description:
|
|
"The project's dashboard: the sprint under way (pace against time, behind or not), overdue, due-soon, blocked and stuck tickets, how long the work under way has been where it is (against the p85 cycle time), average days in each in-progress and QA status with WIP limits, when each planned release would be finished at the real pace (Monte Carlo), each person's open points against what they close per sprint, open tickets missing an estimate, an area or an assignee, quality (open bugs per priority, bugs created and closed per week, QA returns per area, bugs reported after each release) open tickets per option of each list custom field, activity per day over the last 12 weeks, where the time of the tickets done lately went from created to done (days and share per status, and the part spent at work: flow efficiency) and, if the public has proposed anything, the proposals funnel (received, waiting, under way, done, discarded, most voted waiting). With since, also what changed after it.",
|
|
paramsSchema: z.object({
|
|
project: projectRef,
|
|
since: z
|
|
.string()
|
|
.optional()
|
|
.describe(
|
|
'ISO instant: also tell what was created, finished or sent back from QA after it',
|
|
),
|
|
}),
|
|
annotations: READ,
|
|
})
|
|
insights(
|
|
{ project, since }: { project: string; since?: string },
|
|
ctx: McpContext,
|
|
) {
|
|
return toolResult(() =>
|
|
this.api.data(
|
|
this.api.as(ctx).GET('/api/projects/{project}/insights', {
|
|
params: { path: { project }, query: { since } },
|
|
}),
|
|
),
|
|
);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'get_project_activity',
|
|
description:
|
|
'Latest activity of a project (newest first), or that of one day (UTC) with day.',
|
|
paramsSchema: z.object({
|
|
project: projectRef,
|
|
limit: limit(200, 30),
|
|
day: z
|
|
.string()
|
|
.regex(/^\d{4}-\d{2}-\d{2}$/)
|
|
.optional()
|
|
.describe('YYYY-MM-DD: only what happened that day (UTC)'),
|
|
}),
|
|
annotations: READ,
|
|
})
|
|
activity(
|
|
{ project, limit, day }: { project: string; limit: number; day?: string },
|
|
ctx: McpContext,
|
|
) {
|
|
const window = day
|
|
? {
|
|
after: `${day}T00:00:00.000Z`,
|
|
before: new Date(
|
|
Date.parse(`${day}T00:00:00Z`) + 86_400_000,
|
|
).toISOString(),
|
|
}
|
|
: {};
|
|
return toolResult(async () =>
|
|
(
|
|
await this.api.data(
|
|
this.api.as(ctx).GET('/api/projects/{project}/activity', {
|
|
params: { path: { project }, query: { limit, ...window } },
|
|
}),
|
|
)
|
|
).map((a) => ({
|
|
at: a.occurredAt,
|
|
who: a.actor.name,
|
|
summary: a.summary,
|
|
issue: a.issueKey,
|
|
})),
|
|
);
|
|
}
|
|
}
|