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

@@ -6,6 +6,7 @@ import {
Project,
ProjectAnalytics,
ProjectInsights,
PortfolioProject,
ProjectSettings,
ProjectsGateway,
ProjectStats,
@@ -22,6 +23,7 @@ import {
MyProjectsDocument,
ProjectAnalyticsDocument,
ProjectInsightsDocument,
PortfolioDocument,
ProjectDocument,
ProjectStatsDocument,
PublicProjectsDocument,
@@ -32,6 +34,7 @@ import {
import { GraphqlOperations } from './graphql-operations';
import {
toCommandResult,
toIssueSummary,
toProject,
toSettingsInput,
toStats,
@@ -65,6 +68,21 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
.pipe(map((d) => toStats(d.project.stats)));
}
portfolio(): Observable<PortfolioProject[]> {
return this.gql.query(PortfolioDocument, {}).pipe(
map((d) =>
d.portfolio.map((p) => ({
...(p as unknown as PortfolioProject),
epics: p.epics.map((e) => ({
issue: toIssueSummary(e.issue),
progress: { ...e.progress },
links: [],
})),
})),
),
);
}
insights(
projectId: string,
since: string | null,

View File

@@ -2062,6 +2062,86 @@ export type ProjectInsightsQuery = {
};
};
export type PortfolioQueryVariables = Exact<{ [key: string]: never }>;
export type PortfolioQuery = {
portfolio: Array<{
projectId: string;
key: string;
name: string;
isPublic: boolean;
total: number;
open: number;
overdue: number;
blocked: number;
stuck: number;
updatedAt: string;
sprint: {
sprintId: string;
name: string;
startDate: string;
endDate: string;
totalDays: number;
daysLeft: number;
tickets: number;
doneTickets: number;
points: number;
donePoints: number;
pacePerDay: number | null;
neededPerDay: number | null;
behind: boolean;
} | null;
releases: Array<{
releaseId: string;
name: string;
targetDate: string | null;
total: number;
finished: number;
likelyDate: string | null;
safeDate: string | null;
verdict: string;
}>;
epics: Array<{
issue: {
id: string;
projectId: string;
key: string;
type: string;
area: string | null;
team: string | null;
title: string;
status: string;
statusCategory: StatusCategory;
priority: IssuePriority;
labels: Array<string>;
commentCount: number;
updatedAt: string;
version: number;
votes: number;
voted: boolean;
rank: string;
storyPoints: number | null;
startDate: string | null;
dueDate: string | null;
trashedAt: string | null;
assignee: { id: string; name: string } | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
parent: { id: string; key: string; title: string } | null;
blockedBy: Array<{ id: string; key: string; title: string }>;
customFields: Array<{ fieldId: string; value: string }>;
};
progress: {
total: number;
finished: number;
percent: number;
storyPoints: number;
finishedStoryPoints: number;
};
}>;
}>;
};
export type ReleasesQueryVariables = Exact<{
idOrKey: string;
}>;
@@ -12307,6 +12387,309 @@ export const ProjectInsightsDocument = {
ProjectInsightsQuery,
ProjectInsightsQueryVariables
>;
export const PortfolioDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'query',
name: { kind: 'Name', value: 'Portfolio' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'portfolio' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{ kind: 'Field', name: { kind: 'Name', value: 'isPublic' } },
{ kind: 'Field', name: { kind: 'Name', value: 'total' } },
{ kind: 'Field', name: { kind: 'Name', value: 'open' } },
{ kind: 'Field', name: { kind: 'Name', value: 'overdue' } },
{ kind: 'Field', name: { kind: 'Name', value: 'blocked' } },
{ kind: 'Field', name: { kind: 'Name', value: 'stuck' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'sprint' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'sprintId' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'startDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'endDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'totalDays' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'daysLeft' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'tickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'doneTickets' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'points' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'donePoints' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'pacePerDay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'neededPerDay' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'behind' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'releases' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'releaseId' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'targetDate' },
},
{ kind: 'Field', name: { kind: 'Name', value: 'total' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'finished' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'likelyDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'safeDate' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'verdict' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'epics' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'issue' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: {
kind: 'Name',
value: 'IssueSummaryFields',
},
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'progress' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'total' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'finished' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'percent' },
},
{
kind: 'Field',
name: { kind: 'Name', value: 'storyPoints' },
},
{
kind: 'Field',
name: {
kind: 'Name',
value: 'finishedStoryPoints',
},
},
],
},
},
],
},
},
],
},
},
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'UserRefFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'UserRef' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'FragmentDefinition',
name: { kind: 'Name', value: 'IssueSummaryFields' },
typeCondition: {
kind: 'NamedType',
name: { kind: 'Name', value: 'Issue' },
},
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
{ kind: 'Field', name: { kind: 'Name', value: 'team' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
{ kind: 'Field', name: { kind: 'Name', value: 'commentCount' } },
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'assignee' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'FragmentSpread',
name: { kind: 'Name', value: 'UserRefFields' },
},
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'release' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'sprint' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'parent' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
],
},
},
{
kind: 'Field',
name: { kind: 'Name', value: 'blockedBy' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'votes' } },
{ kind: 'Field', name: { kind: 'Name', value: 'voted' } },
{ kind: 'Field', name: { kind: 'Name', value: 'rank' } },
{ kind: 'Field', name: { kind: 'Name', value: 'storyPoints' } },
{ kind: 'Field', name: { kind: 'Name', value: 'startDate' } },
{ kind: 'Field', name: { kind: 'Name', value: 'dueDate' } },
{
kind: 'Field',
name: { kind: 'Name', value: 'customFields' },
selectionSet: {
kind: 'SelectionSet',
selections: [
{ kind: 'Field', name: { kind: 'Name', value: 'fieldId' } },
{ kind: 'Field', name: { kind: 'Name', value: 'value' } },
],
},
},
{ kind: 'Field', name: { kind: 'Name', value: 'trashedAt' } },
],
},
},
],
} as unknown as DocumentNode<PortfolioQuery, PortfolioQueryVariables>;
export const ReleasesDocument = {
kind: 'Document',
definitions: [

View File

@@ -299,3 +299,55 @@ query ProjectInsights($idOrKey: String!, $since: String) {
}
}
}
query Portfolio {
portfolio {
projectId
key
name
isPublic
total
open
overdue
blocked
stuck
updatedAt
sprint {
sprintId
name
startDate
endDate
totalDays
daysLeft
tickets
doneTickets
points
donePoints
pacePerDay
neededPerDay
behind
}
releases {
releaseId
name
targetDate
total
finished
likelyDate
safeDate
verdict
}
epics {
issue {
...IssueSummaryFields
}
progress {
total
finished
percent
storyPoints
finishedStoryPoints
}
}
}
}