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:
@@ -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),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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")"""
|
||||
|
||||
Reference in New Issue
Block a user