feat: portfolio of all my projects

/cartera (and GraphQL portfolio, REST /api/me/portfolio, MCP
get_portfolio) shows every active project I belong to (not archived, not
as a guest) at a glance: open, overdue, blocked and stuck tickets, the
sprint under way and whether it gets there, planned releases with their
forecast, and the open epics with their progress. It reuses the
dashboard's computations. A release with no tickets now reads «Sin
tickets» instead of «Todo hecho», here and on the dashboard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 18:27:46 +02:00
parent 8bf7d47b2e
commit affce615a7
35 changed files with 1603 additions and 80 deletions

View File

@@ -1852,4 +1852,56 @@ describe('task-stream (e2e)', () => {
(r) => r.status === 200,
);
});
it('shows my active projects at a glance (the portfolio)', async () => {
const key = 'F' + Date.now().toString(36).slice(-5).toUpperCase();
await rest('token-ana', 'POST', '/projects', {
key,
name: `Cartera ${key}`,
});
await eventually(
() => rest('token-ana', 'GET', `/projects/${key}`),
(r) => r.status === 200,
);
const epic = (
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'FEATURE',
title: 'Pagos',
dueDate: '2030-01-01',
})
).body['key'];
await eventually(
() => rest('token-ana', 'GET', `/issues/${epic}`),
(r) => r.status === 200,
);
await rest('token-ana', 'POST', `/projects/${key}/issues`, {
type: 'TASK',
title: 'Parte',
parent: epic,
dueDate: '2020-01-01',
});
await rest('token-ana', 'POST', `/projects/${key}/releases`, {
name: '1.0',
targetDate: '2030-06-01',
});
const mine = await eventually(
() => rest('token-ana', 'GET', '/me/portfolio'),
(r) =>
r.body?.find?.((p: { key: string }) => p.key === key)?.epics?.length ===
1 &&
r.body.find((p: { key: string }) => p.key === key).releases.length ===
1,
);
const row = mine.body.find((p: { key: string }) => p.key === key);
expect(row).toMatchObject({ total: 2, open: 2, overdue: 1, sprint: null });
expect(row.releases[0]).toMatchObject({ name: '1.0', total: 0 });
expect(row.epics[0].issue.key).toBe(epic);
// Archived projects leave it.
await rest('token-ana', 'POST', `/projects/${key}/archive`);
await eventually(
() => gql('token-ana', `query { portfolio { key } }`),
(d) => !d['portfolio'].some((p: { key: string }) => p.key === key),
);
});
});

View File

@@ -2185,6 +2185,80 @@
]
}
},
"/api/me/portfolio": {
"get": {
"description": "Every active project I am a member of (not archived, not as a guest): open and at-risk tickets, the sprint under way, release forecasts and open epics.",
"operationId": "IssuesController_portfolio",
"parameters": [],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"type": "array",
"items": {
"$ref": "#/components/schemas/PortfolioProjectDto"
}
}
}
}
},
"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": "My projects at a glance",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/presence": {
"put": {
"description": "Send it every ~10 s while the page is open; entries fade after 30 s without one. Answers who else is there, the ones editing first.",
@@ -13416,6 +13490,125 @@
"role"
]
},
"ChildProgressDto": {
"type": "object",
"properties": {
"total": {
"type": "number"
},
"finished": {
"type": "number",
"description": "Done or cancelled"
},
"percent": {
"type": "number",
"minimum": 0,
"maximum": 100
},
"storyPoints": {
"type": "number"
},
"finishedStoryPoints": {
"type": "number"
}
},
"required": [
"total",
"finished",
"percent",
"storyPoints",
"finishedStoryPoints"
]
},
"EpicDto": {
"type": "object",
"properties": {
"issue": {
"$ref": "#/components/schemas/IssueDto"
},
"progress": {
"$ref": "#/components/schemas/ChildProgressDto"
}
},
"required": [
"issue",
"progress"
]
},
"PortfolioProjectDto": {
"type": "object",
"properties": {
"projectId": {
"type": "string",
"format": "uuid"
},
"key": {
"type": "string",
"example": "WEB"
},
"name": {
"type": "string"
},
"isPublic": {
"type": "boolean"
},
"total": {
"type": "number"
},
"open": {
"type": "number"
},
"overdue": {
"type": "number"
},
"blocked": {
"type": "number"
},
"stuck": {
"type": "number"
},
"sprint": {
"nullable": true,
"type": "object",
"allOf": [
{
"$ref": "#/components/schemas/SprintPulseDto"
}
]
},
"releases": {
"type": "array",
"items": {
"$ref": "#/components/schemas/ReleaseForecastDto"
}
},
"epics": {
"description": "Open epics, nearest due date first (six at most)",
"type": "array",
"items": {
"$ref": "#/components/schemas/EpicDto"
}
},
"updatedAt": {
"type": "string"
}
},
"required": [
"projectId",
"key",
"name",
"isPublic",
"total",
"open",
"overdue",
"blocked",
"stuck",
"sprint",
"releases",
"epics",
"updatedAt"
]
},
"PresenceBodyDto": {
"type": "object",
"properties": {
@@ -13573,51 +13766,6 @@
"title"
]
},
"ChildProgressDto": {
"type": "object",
"properties": {
"total": {
"type": "number"
},
"finished": {
"type": "number",
"description": "Done or cancelled"
},
"percent": {
"type": "number",
"minimum": 0,
"maximum": 100
},
"storyPoints": {
"type": "number"
},
"finishedStoryPoints": {
"type": "number"
}
},
"required": [
"total",
"finished",
"percent",
"storyPoints",
"finishedStoryPoints"
]
},
"EpicDto": {
"type": "object",
"properties": {
"issue": {
"$ref": "#/components/schemas/IssueDto"
},
"progress": {
"$ref": "#/components/schemas/ChildProgressDto"
}
},
"required": [
"issue",
"progress"
]
},
"UpdateIssueDto": {
"type": "object",
"properties": {

View File

@@ -1082,6 +1082,27 @@ type OptionCount {
value: String!
}
"""
One of my active projects at a glance: open work, risks, sprint, releases and epics
"""
type PortfolioProject {
blocked: Int!
"""Open epics, nearest due date first (six at most)"""
epics: [Epic!]!
isPublic: Boolean!
key: String!
name: String!
open: Int!
overdue: Int!
projectId: ID!
releases: [ReleaseForecast!]!
sprint: SprintPulse
stuck: Int!
total: Int!
updatedAt: String!
}
"""Someone on a ticket right now (heartbeat in the last 30 s)"""
type Presence {
"""Last heartbeat"""
@@ -1364,6 +1385,11 @@ type Query {
myViews: [MyView!]!
notificationSettings: NotificationSettings!
"""
My active projects (not archived, not as a guest) at a glance: open work, risks, sprint, release forecasts and epics
"""
portfolio: [PortfolioProject!]!
"""A project of mine, or any public one (no sign-in needed)"""
project(
"""Project id or key (e.g. "TS")"""

View File

@@ -397,6 +397,26 @@ export interface paths {
patch: operations["ProjectsController_changeMemberRole"];
trace?: never;
};
"/api/me/portfolio": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* My projects at a glance
* @description Every active project I am a member of (not archived, not as a guest): open and at-risk tickets, the sprint under way, release forecasts and open epics.
*/
get: operations["IssuesController_portfolio"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/presence": {
parameters: {
query?: never;
@@ -2440,6 +2460,36 @@ export interface components {
ChangeMemberRoleDto: {
role: components["schemas"]["ProjectRole"];
};
ChildProgressDto: {
total: number;
/** @description Done or cancelled */
finished: number;
percent: number;
storyPoints: number;
finishedStoryPoints: number;
};
EpicDto: {
issue: components["schemas"]["IssueDto"];
progress: components["schemas"]["ChildProgressDto"];
};
PortfolioProjectDto: {
/** Format: uuid */
projectId: string;
/** @example WEB */
key: string;
name: string;
isPublic: boolean;
total: number;
open: number;
overdue: number;
blocked: number;
stuck: number;
sprint: components["schemas"]["SprintPulseDto"] | null;
releases: components["schemas"]["ReleaseForecastDto"][];
/** @description Open epics, nearest due date first (six at most) */
epics: components["schemas"]["EpicDto"][];
updatedAt: string;
};
PresenceBodyDto: {
/**
* @description Editing the ticket or writing a comment
@@ -2514,18 +2564,6 @@ export interface components {
*/
parent?: string | null;
};
ChildProgressDto: {
total: number;
/** @description Done or cancelled */
finished: number;
percent: number;
storyPoints: number;
finishedStoryPoints: number;
};
EpicDto: {
issue: components["schemas"]["IssueDto"];
progress: components["schemas"]["ChildProgressDto"];
};
UpdateIssueDto: {
description?: string | null;
/**
@@ -4778,6 +4816,61 @@ export interface operations {
};
};
};
IssuesController_portfolio: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PortfolioProjectDto"][];
};
};
/** @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"];
};
};
};
};
IssuesController_touchPresence: {
parameters: {
query?: never;

View File

@@ -204,6 +204,31 @@ export class ProjectsResolver {
);
}
@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:

View File

@@ -12,12 +12,14 @@ describe('appRoutes', () => {
const children = app?.children ?? [];
expect(children.map((route) => route.path)).toEqual([
'',
'cartera',
'explorar',
'p/:key',
]);
expect(children[0].canActivate).toContain(authGuard);
expect(children[1].canActivate).toBeUndefined();
expect(children[1].canActivate).toContain(authGuard);
expect(children[2].canActivate).toBeUndefined();
expect(children[3].canActivate).toBeUndefined();
});
it('exposes every project section', () => {

View File

@@ -41,6 +41,16 @@ import {
Proyectos
</a>
}
@if (auth.isAuthenticated()) {
<a
shellNav
class="ts-nav-link"
routerLink="/cartera"
routerLinkActive="is-active"
>
Cartera
</a>
}
<a
shellNav
class="ts-nav-link"