feat: dashboard reads the sprint, the risks, aging and bottlenecks
A new projectInsights reading (GraphQL Project.insights, REST
/projects/{project}/insights, MCP get_project_insights), computed by pure
functions over the read model and the timeline:
- the sprint under way: days left, points done, pace against what is
needed and whether it gets there;
- tickets at risk: overdue, due within a week, blocked by an open ticket
and stuck (7 days or more in an in-progress or QA status);
- aging of the work under way against the p85 cycle time;
- average days in each in-progress and QA status, with WIP limits.
The dashboard shows them in four new panels, reloaded on every change.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1366,4 +1366,53 @@ describe('task-stream (e2e)', () => {
|
||||
);
|
||||
expect(automations).toEqual([{ name: 'Etiquetar', active: false }]);
|
||||
});
|
||||
|
||||
it('reads the dashboard: sprint under way, risks, aging and bottlenecks', async () => {
|
||||
const key = 'D' + Date.now().toString(36).slice(-5).toUpperCase();
|
||||
await rest('token-ana', 'POST', '/projects', { key, name: key });
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
const late = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Late',
|
||||
dueDate: '2020-01-01',
|
||||
})
|
||||
).body['key'];
|
||||
const waits = (
|
||||
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
|
||||
type: 'TASK',
|
||||
title: 'Waits',
|
||||
})
|
||||
).body['key'];
|
||||
await eventually(
|
||||
() => rest('token-ana', 'GET', `/issues/${waits}`),
|
||||
(r) => r.status === 200,
|
||||
);
|
||||
await rest('token-ana', 'POST', `/issues/${late}/links`, {
|
||||
type: 'BLOCKS',
|
||||
targetIssue: waits,
|
||||
});
|
||||
const insights = await eventually(
|
||||
() => rest('token-ana', 'GET', `/projects/${key}/insights`),
|
||||
(r) => r.status === 200 && r.body['risks'].blockedCount === 1,
|
||||
);
|
||||
expect(insights.body['sprint']).toBeNull();
|
||||
expect(insights.body['risks'].overdue[0]).toMatchObject({
|
||||
key: late,
|
||||
dueDate: '2020-01-01',
|
||||
});
|
||||
expect(insights.body['risks'].blocked[0].key).toBe(waits);
|
||||
expect(
|
||||
insights.body['bottlenecks'].map((b: { status: string }) => b.status),
|
||||
).toContain('IN_PROGRESS');
|
||||
const { project } = await gql(
|
||||
'token-ana',
|
||||
`query($k: String!) { project(idOrKey: $k) { insights { risks { overdueCount } aging { thresholdDays } } } }`,
|
||||
{ k: key },
|
||||
);
|
||||
expect(project.insights.risks.overdueCount).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1435,6 +1435,89 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/projects/{project}/insights": {
|
||||
"get": {
|
||||
"description": "Sprint under way (pace against time), overdue, due-soon, blocked and stuck tickets, aging of the work under way against the p85 cycle time, and average days in each in-progress and QA status.",
|
||||
"operationId": "ProjectsController_insights",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "project",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"description": "Project id or key",
|
||||
"schema": {
|
||||
"example": "WEB",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProjectInsightsDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "Invalid input",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "Missing or rejected Pireon token",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Not a member of the project (or not allowed)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "Not found",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ApiErrorDto"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"pireon-oauth": []
|
||||
},
|
||||
{
|
||||
"pireon": []
|
||||
},
|
||||
{}
|
||||
],
|
||||
"summary": "The dashboard",
|
||||
"tags": [
|
||||
"Projects"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/projects/{project}/activity": {
|
||||
"get": {
|
||||
"operationId": "ProjectsController_activity",
|
||||
@@ -11804,6 +11887,264 @@
|
||||
"qa"
|
||||
]
|
||||
},
|
||||
"SprintPulseDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sprintId": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"startDate": {
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
"endDate": {
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
"totalDays": {
|
||||
"type": "number"
|
||||
},
|
||||
"daysLeft": {
|
||||
"type": "number",
|
||||
"description": "Days left after today"
|
||||
},
|
||||
"tickets": {
|
||||
"type": "number"
|
||||
},
|
||||
"doneTickets": {
|
||||
"type": "number"
|
||||
},
|
||||
"points": {
|
||||
"type": "number"
|
||||
},
|
||||
"donePoints": {
|
||||
"type": "number"
|
||||
},
|
||||
"pacePerDay": {
|
||||
"type": "number",
|
||||
"nullable": true
|
||||
},
|
||||
"neededPerDay": {
|
||||
"type": "number",
|
||||
"nullable": true
|
||||
},
|
||||
"behind": {
|
||||
"type": "boolean",
|
||||
"description": "At this pace, what is open would not be closed"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"sprintId",
|
||||
"name",
|
||||
"startDate",
|
||||
"endDate",
|
||||
"totalDays",
|
||||
"daysLeft",
|
||||
"tickets",
|
||||
"doneTickets",
|
||||
"points",
|
||||
"donePoints",
|
||||
"pacePerDay",
|
||||
"neededPerDay",
|
||||
"behind"
|
||||
]
|
||||
},
|
||||
"InsightIssueDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {
|
||||
"type": "string",
|
||||
"format": "uuid"
|
||||
},
|
||||
"key": {
|
||||
"type": "string",
|
||||
"example": "WEB-12"
|
||||
},
|
||||
"title": {
|
||||
"type": "string"
|
||||
},
|
||||
"status": {
|
||||
"type": "string",
|
||||
"description": "Status id"
|
||||
},
|
||||
"assignee": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/UserRefDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"days": {
|
||||
"type": "number",
|
||||
"description": "Days in its status (aging, stuck) or past / to its due date"
|
||||
},
|
||||
"dueDate": {
|
||||
"type": "string",
|
||||
"nullable": true,
|
||||
"format": "date"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"key",
|
||||
"title",
|
||||
"status",
|
||||
"assignee",
|
||||
"days",
|
||||
"dueDate"
|
||||
]
|
||||
},
|
||||
"RisksDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"overdue": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/InsightIssueDto"
|
||||
}
|
||||
},
|
||||
"overdueCount": {
|
||||
"type": "number"
|
||||
},
|
||||
"dueSoon": {
|
||||
"description": "Due within 7 days",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/InsightIssueDto"
|
||||
}
|
||||
},
|
||||
"dueSoonCount": {
|
||||
"type": "number"
|
||||
},
|
||||
"blocked": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/InsightIssueDto"
|
||||
}
|
||||
},
|
||||
"blockedCount": {
|
||||
"type": "number"
|
||||
},
|
||||
"stuck": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/InsightIssueDto"
|
||||
}
|
||||
},
|
||||
"stuckCount": {
|
||||
"type": "number"
|
||||
},
|
||||
"stuckDays": {
|
||||
"type": "number"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"overdue",
|
||||
"overdueCount",
|
||||
"dueSoon",
|
||||
"dueSoonCount",
|
||||
"blocked",
|
||||
"blockedCount",
|
||||
"stuck",
|
||||
"stuckCount",
|
||||
"stuckDays"
|
||||
]
|
||||
},
|
||||
"AgingDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"issues": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/InsightIssueDto"
|
||||
}
|
||||
},
|
||||
"thresholdDays": {
|
||||
"type": "number",
|
||||
"nullable": true,
|
||||
"description": "p85 cycle time of the last 90 days"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"issues",
|
||||
"thresholdDays"
|
||||
]
|
||||
},
|
||||
"StatusTimeDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"status": {
|
||||
"type": "string"
|
||||
},
|
||||
"averageDays": {
|
||||
"type": "number",
|
||||
"nullable": true
|
||||
},
|
||||
"samples": {
|
||||
"type": "number"
|
||||
},
|
||||
"open": {
|
||||
"type": "number"
|
||||
},
|
||||
"wipLimit": {
|
||||
"type": "number",
|
||||
"nullable": true
|
||||
},
|
||||
"overLimit": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"status",
|
||||
"averageDays",
|
||||
"samples",
|
||||
"open",
|
||||
"wipLimit",
|
||||
"overLimit"
|
||||
]
|
||||
},
|
||||
"ProjectInsightsDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"generatedAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"sprint": {
|
||||
"nullable": true,
|
||||
"type": "object",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/components/schemas/SprintPulseDto"
|
||||
}
|
||||
]
|
||||
},
|
||||
"risks": {
|
||||
"$ref": "#/components/schemas/RisksDto"
|
||||
},
|
||||
"aging": {
|
||||
"$ref": "#/components/schemas/AgingDto"
|
||||
},
|
||||
"bottlenecks": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/StatusTimeDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"generatedAt",
|
||||
"sprint",
|
||||
"risks",
|
||||
"aging",
|
||||
"bottlenecks"
|
||||
]
|
||||
},
|
||||
"ActivityDto": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -18,6 +18,13 @@ input AddChecklistItemInput {
|
||||
text: String!
|
||||
}
|
||||
|
||||
type Aging {
|
||||
issues: [InsightIssue!]!
|
||||
|
||||
"""p85 cycle time of the last 90 days"""
|
||||
thresholdDays: Float
|
||||
}
|
||||
|
||||
type AreaOption {
|
||||
archived: Boolean!
|
||||
id: String!
|
||||
@@ -475,6 +482,19 @@ type GitRepository {
|
||||
secret: String
|
||||
}
|
||||
|
||||
"""A ticket the dashboard names, with the number that put it there"""
|
||||
type InsightIssue {
|
||||
assignee: UserRef
|
||||
|
||||
"""Days in its status (aging, stuck) or past / to its due date"""
|
||||
days: Float!
|
||||
dueDate: String
|
||||
id: ID!
|
||||
key: String!
|
||||
status: String!
|
||||
title: String!
|
||||
}
|
||||
|
||||
type Issue {
|
||||
"""Statuses this ticket can move to next (project's workflow)"""
|
||||
allowedTransitions: [String!]!
|
||||
@@ -955,6 +975,11 @@ type Project {
|
||||
description: String
|
||||
id: ID!
|
||||
|
||||
"""
|
||||
The dashboard: sprint under way, overdue / blocked / stuck tickets, aging and bottlenecks
|
||||
"""
|
||||
insights: ProjectInsights!
|
||||
|
||||
"""Anyone can read it, also without signing in"""
|
||||
isPublic: Boolean!
|
||||
key: String!
|
||||
@@ -996,6 +1021,15 @@ type ProjectChange {
|
||||
version: Int!
|
||||
}
|
||||
|
||||
"""The dashboard: sprint under way, risks, aging and bottlenecks"""
|
||||
type ProjectInsights {
|
||||
aging: Aging!
|
||||
bottlenecks: [StatusTime!]!
|
||||
generatedAt: String!
|
||||
risks: Risks!
|
||||
sprint: SprintPulse
|
||||
}
|
||||
|
||||
type ProjectMember {
|
||||
role: ProjectRole!
|
||||
user: UserRef!
|
||||
@@ -1221,6 +1255,21 @@ enum ReleaseStatus {
|
||||
RELEASED
|
||||
}
|
||||
|
||||
"""Tickets to look at now (worst first)"""
|
||||
type Risks {
|
||||
blocked: [InsightIssue!]!
|
||||
blockedCount: Int!
|
||||
|
||||
"""Due within 7 days"""
|
||||
dueSoon: [InsightIssue!]!
|
||||
dueSoonCount: Int!
|
||||
overdue: [InsightIssue!]!
|
||||
overdueCount: Int!
|
||||
stuck: [InsightIssue!]!
|
||||
stuckCount: Int!
|
||||
stuckDays: Int!
|
||||
}
|
||||
|
||||
input SaveFilterInput {
|
||||
criteria: SavedFilterCriteriaInput!
|
||||
name: String!
|
||||
@@ -1321,6 +1370,26 @@ type SprintProgress {
|
||||
total: Int!
|
||||
}
|
||||
|
||||
"""The sprint under way: time gone against work done"""
|
||||
type SprintPulse {
|
||||
"""At this pace, what is open would not be closed"""
|
||||
behind: Boolean!
|
||||
|
||||
"""Days left after today"""
|
||||
daysLeft: Int!
|
||||
donePoints: Int!
|
||||
doneTickets: Int!
|
||||
endDate: String!
|
||||
name: String!
|
||||
neededPerDay: Float
|
||||
pacePerDay: Float
|
||||
points: Int!
|
||||
sprintId: ID!
|
||||
startDate: String!
|
||||
tickets: Int!
|
||||
totalDays: Int!
|
||||
}
|
||||
|
||||
type SprintRef {
|
||||
id: ID!
|
||||
name: String!
|
||||
@@ -1344,6 +1413,15 @@ enum StatusCategory {
|
||||
TODO
|
||||
}
|
||||
|
||||
type StatusTime {
|
||||
averageDays: Float
|
||||
open: Int!
|
||||
overLimit: Boolean!
|
||||
samples: Int!
|
||||
status: String!
|
||||
wipLimit: Int
|
||||
}
|
||||
|
||||
type StatusTransitions {
|
||||
from: String!
|
||||
to: [String!]!
|
||||
|
||||
@@ -285,6 +285,26 @@ export interface paths {
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/projects/{project}/insights": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/**
|
||||
* The dashboard
|
||||
* @description Sprint under way (pace against time), overdue, due-soon, blocked and stuck tickets, aging of the work under way against the p85 cycle time, and average days in each in-progress and QA status.
|
||||
*/
|
||||
get: operations["ProjectsController_insights"];
|
||||
put?: never;
|
||||
post?: never;
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/api/projects/{project}/activity": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
@@ -2100,6 +2120,72 @@ export interface components {
|
||||
throughput: components["schemas"]["ThroughputDto"][];
|
||||
qa: components["schemas"]["QaStatsDto"];
|
||||
};
|
||||
SprintPulseDto: {
|
||||
/** Format: uuid */
|
||||
sprintId: string;
|
||||
name: string;
|
||||
/** Format: date */
|
||||
startDate: string;
|
||||
/** Format: date */
|
||||
endDate: string;
|
||||
totalDays: number;
|
||||
/** @description Days left after today */
|
||||
daysLeft: number;
|
||||
tickets: number;
|
||||
doneTickets: number;
|
||||
points: number;
|
||||
donePoints: number;
|
||||
pacePerDay: number | null;
|
||||
neededPerDay: number | null;
|
||||
/** @description At this pace, what is open would not be closed */
|
||||
behind: boolean;
|
||||
};
|
||||
InsightIssueDto: {
|
||||
/** Format: uuid */
|
||||
id: string;
|
||||
/** @example WEB-12 */
|
||||
key: string;
|
||||
title: string;
|
||||
/** @description Status id */
|
||||
status: string;
|
||||
assignee: components["schemas"]["UserRefDto"] | null;
|
||||
/** @description Days in its status (aging, stuck) or past / to its due date */
|
||||
days: number;
|
||||
/** Format: date */
|
||||
dueDate: string | null;
|
||||
};
|
||||
RisksDto: {
|
||||
overdue: components["schemas"]["InsightIssueDto"][];
|
||||
overdueCount: number;
|
||||
/** @description Due within 7 days */
|
||||
dueSoon: components["schemas"]["InsightIssueDto"][];
|
||||
dueSoonCount: number;
|
||||
blocked: components["schemas"]["InsightIssueDto"][];
|
||||
blockedCount: number;
|
||||
stuck: components["schemas"]["InsightIssueDto"][];
|
||||
stuckCount: number;
|
||||
stuckDays: number;
|
||||
};
|
||||
AgingDto: {
|
||||
issues: components["schemas"]["InsightIssueDto"][];
|
||||
/** @description p85 cycle time of the last 90 days */
|
||||
thresholdDays: number | null;
|
||||
};
|
||||
StatusTimeDto: {
|
||||
status: string;
|
||||
averageDays: number | null;
|
||||
samples: number;
|
||||
open: number;
|
||||
wipLimit: number | null;
|
||||
overLimit: boolean;
|
||||
};
|
||||
ProjectInsightsDto: {
|
||||
generatedAt: string;
|
||||
sprint: components["schemas"]["SprintPulseDto"] | null;
|
||||
risks: components["schemas"]["RisksDto"];
|
||||
aging: components["schemas"]["AgingDto"];
|
||||
bottlenecks: components["schemas"]["StatusTimeDto"][];
|
||||
};
|
||||
ActivityDto: {
|
||||
id: string;
|
||||
/** @example IssueStatusChanged */
|
||||
@@ -3882,6 +3968,64 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
ProjectsController_insights: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
/** @description Project id or key */
|
||||
project: string;
|
||||
};
|
||||
cookie?: never;
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ProjectInsightsDto"];
|
||||
};
|
||||
};
|
||||
/** @description Invalid input */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Missing or rejected Pireon token */
|
||||
401: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Not a member of the project (or not allowed) */
|
||||
403: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
/** @description Not found */
|
||||
404: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ApiErrorDto"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
ProjectsController_activity: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
||||
@@ -204,6 +204,23 @@ export class ProjectsResolver {
|
||||
);
|
||||
}
|
||||
|
||||
@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) and the average days in each in-progress and QA status, with WIP limits.",
|
||||
paramsSchema: z.object({ project: projectRef }),
|
||||
annotations: READ,
|
||||
})
|
||||
insights({ project }: { project: string }, ctx: McpContext) {
|
||||
return toolResult(() =>
|
||||
this.api.data(
|
||||
this.api.as(ctx).GET('/api/projects/{project}/insights', {
|
||||
params: { path: { project } },
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Tool({
|
||||
name: 'get_project_activity',
|
||||
description: 'Latest activity of a project (newest first).',
|
||||
|
||||
Reference in New Issue
Block a user