feat: follow an epic with everything under it

«Seguir todo» on a ticket with subtasks (and on each epic of the Epics
page) tells you of it and of every ticket hanging from it, at any depth,
also the ones added later: their moves, assignments, comments and plan
changes, as a follower of each, plus when a ticket is placed under it.
Guests of a private project are left out. Personal, no history (Mongo
`tree_follows`).

API: followTree / unfollowTree, Issue.followingTree, PUT/DELETE
/api/issues/{issue}/follow-tree. MCP: watch_issue with tree.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 19:59:04 +02:00
parent 179d6a5e94
commit d79ef418d3
31 changed files with 1072 additions and 62 deletions

View File

@@ -2776,6 +2776,193 @@
] ]
} }
}, },
"/api/issues/{issue}/follow-tree": {
"put": {
"description": "Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.",
"operationId": "IssuesController_followTree",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
},
"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"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Follow a ticket with everything under it (an epic)",
"tags": [
"Issues"
]
},
"delete": {
"operationId": "IssuesController_unfollowTree",
"parameters": [
{
"name": "issue",
"required": true,
"in": "path",
"description": "Ticket id or key",
"schema": {
"example": "WEB-12",
"type": "string"
}
}
],
"responses": {
"204": {
"description": ""
},
"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"
}
}
}
},
"409": {
"description": "Someone changed it at the same time: retry",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
},
"422": {
"description": "Business rule (e.g. a transition the workflow forbids)",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApiErrorDto"
}
}
}
}
},
"security": [
{
"pireon-oauth": []
},
{
"pireon": []
}
],
"summary": "Stop following a ticket with everything under it",
"tags": [
"Issues"
]
}
},
"/api/issues/{issue}/watch": { "/api/issues/{issue}/watch": {
"put": { "put": {
"description": "Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.", "description": "Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.",

View File

@@ -691,6 +691,9 @@ type Issue {
dueDate: String dueDate: String
figmaUrl: String figmaUrl: String
"""Whether I follow it with everything under it (false without a token)"""
followingTree: Boolean!
"""History of this ticket""" """History of this ticket"""
history(limit: Int! = 50): [Activity!]! history(limit: Int! = 50): [Activity!]!
id: ID! id: ID!
@@ -1016,6 +1019,11 @@ type Mutation {
duplicateIssue(issueId: ID!): CommandResult! duplicateIssue(issueId: ID!): CommandResult!
editComment(body: String!, commentId: ID!): CommandResult! editComment(body: String!, commentId: ID!): CommandResult!
"""
Follow a ticket with everything under it (an epic): its changes and those of every ticket hanging from it, also future ones
"""
followTree(issueId: ID!): Boolean!
"""I left this ticket's page""" """I left this ticket's page"""
leavePresence(issueId: ID!): Boolean! leavePresence(issueId: ID!): Boolean!
@@ -1129,6 +1137,9 @@ type Mutation {
"""Bring an archived project back (owner only)""" """Bring an archived project back (owner only)"""
unarchiveProject(projectId: ID!): CommandResult! unarchiveProject(projectId: ID!): CommandResult!
"""Stop following a ticket with everything under it"""
unfollowTree(issueId: ID!): Boolean!
unlinkIssues(input: LinkIssuesInput!): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult!
"""Unpin a ticket""" """Unpin a ticket"""

View File

@@ -502,6 +502,27 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/issues/{issue}/follow-tree": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Follow a ticket with everything under it (an epic)
* @description Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.
*/
put: operations["IssuesController_followTree"];
post?: never;
/** Stop following a ticket with everything under it */
delete: operations["IssuesController_unfollowTree"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/issues/{issue}/watch": { "/api/issues/{issue}/watch": {
parameters: { parameters: {
query?: never; query?: never;
@@ -5732,6 +5753,154 @@ export interface operations {
}; };
}; };
}; };
IssuesController_followTree: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @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"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_unfollowTree: {
parameters: {
query?: never;
header?: never;
path: {
/** @description Ticket id or key */
issue: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @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"];
};
};
/** @description Someone changed it at the same time: retry */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
/** @description Business rule (e.g. a transition the workflow forbids) */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ApiErrorDto"];
};
};
};
};
IssuesController_watch: { IssuesController_watch: {
parameters: { parameters: {
query?: never; query?: never;

View File

@@ -565,19 +565,29 @@ export class IssuesResolver {
@Tool({ @Tool({
name: 'watch_issue', name: 'watch_issue',
description: description:
'Follow a ticket (notifications of its changes and comments) or stop following it.', 'Follow a ticket (notifications of its changes and comments) or stop following it. With tree: true, an epic (or any ticket with subtasks) with everything under it, also tickets added later.',
paramsSchema: z.object({ paramsSchema: z.object({
issue: issueRef, issue: issueRef,
follow: z.boolean().default(true), follow: z.boolean().default(true),
tree: z.boolean().default(false),
}), }),
annotations: { ...WRITE, idempotentHint: true }, annotations: { ...WRITE, idempotentHint: true },
}) })
watch( watch(
{ issue, follow }: { issue: string; follow: boolean }, { issue, follow, tree }: { issue: string; follow: boolean; tree: boolean },
ctx: McpContext, ctx: McpContext,
) { ) {
const client = this.api.as(ctx); const client = this.api.as(ctx);
const options = { params: { path: { issue } } }; const options = { params: { path: { issue } } };
if (tree)
return toolResult(async () => {
await this.api.data(
follow
? client.PUT('/api/issues/{issue}/follow-tree', options)
: client.DELETE('/api/issues/{issue}/follow-tree', options),
);
return { issue, followingTree: follow };
});
return toolResult(() => return toolResult(() =>
this.api.data( this.api.data(
follow follow

View File

@@ -425,7 +425,11 @@ eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo ti
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite, sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`, `myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
`?watching=true`; sin sesión, ninguno). **Fijar**: cada persona fija hasta 20 tickets para tenerlos a mano en el `?watching=true`; sin sesión, ninguno). **Seguir todo**: seguir un ticket con todo lo que cuelga de él (una épica, un ticket con subtareas):
`followTree`/`unfollowTree`, `Issue.followingTree`; `PUT/DELETE /api/issues/{issue}/follow-tree`. Quien lo hace oye lo
mismo que un seguidor de cada ticket de debajo, a cualquier profundidad, también de los que se añadan, y además cuándo
uno entra (`IssueParentChangedEvent`). Personal y sin historial (colección `tree_follows`); los invitados de un proyecto
privado no oyen los tickets que no son suyos. **Fijar**: cada persona fija hasta 20 tickets para tenerlos a mano en el
inicio y en la búsqueda (`pinIssue`/`unpinIssue`, `myPins`, `Issue.pinned`; `GET /api/me/pins`, inicio y en la búsqueda (`pinIssue`/`unpinIssue`, `myPins`, `Issue.pinned`; `GET /api/me/pins`,
`PUT/DELETE /api/issues/{issue}/pin`). Son privados y sin historial (colección `pinned_issues`, como las vistas de «Mi `PUT/DELETE /api/issues/{issue}/pin`). Son privados y sin historial (colección `pinned_issues`, como las vistas de «Mi
trabajo»); los que están en la papelera o fuera de su alcance no se listan, y al llegar a 20 se olvidan primero los trabajo»); los que están en la papelera o fuera de su alcance no se listan, y al llegar a 20 se olvidan primero los
@@ -540,17 +544,17 @@ que funcionan también sin token.
Todas devuelven `CommandResult { id, key, version }`. Todas devuelven `CommandResult { id, key, version }`.
| Ámbito | Mutations | | Ámbito | Mutations |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` | | Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` | | Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | | Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | | Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` | | Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») | | Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | | Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` | | Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
### Subscriptions ### Subscriptions
@@ -748,25 +752,25 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
| Recurso | Rutas | | Recurso | Rutas |
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | | Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) | | Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` | | Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/follow-tree`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` | | Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | | Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | | Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | | Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | | Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | | Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | | Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) | | Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) |
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | | Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | | Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | | Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` | | Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` |
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) | | Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) |
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | | Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto

View File

@@ -300,25 +300,25 @@ visualización de la plataforma:
## 4.6 Rutas ## 4.6 Rutas
| Ruta | Página | | Ruta | Página |
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `/login` | Inicio de sesión (solo sin sesión) | | `/login` | Inicio de sesión (solo sin sesión) |
| `/auth/callback` | Retorno de Pireon (PKCE) | | `/auth/callback` | Retorno de Pireon (PKCE) |
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» | | `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos | | `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
| `/explore` | Proyectos públicos (sin sesión) | | `/explore` | Proyectos públicos (sin sesión) |
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar | | `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar |
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea) | | `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas) |
| `/p/:key/releases` | Releases | | `/p/:key/releases` | Releases |
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) | | `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea | | `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo | | `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo |
| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) | | `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) |
| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar | | `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar |
| `/p/:key/activity` | Actividad en vivo | | `/p/:key/activity` | Actividad en vivo |
| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» | | `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» |
| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» | | `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» |
| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos | | `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos |
Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien
no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de

View File

@@ -62,7 +62,7 @@ flowchart LR
| | `set_issue_release` | Meter en una release o sacar | | | `set_issue_release` | Meter en una release o sacar |
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar | | | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar |
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación | | | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
| | `watch_issue` | Seguir un ticket o dejar de seguirlo | | | `watch_issue` | Seguir un ticket o dejar de seguirlo; con `tree`, una épica con todo lo que cuelga de ella |
| | `vote_issue` | Votar un ticket o quitar el voto | | | `vote_issue` | Votar un ticket o quitar el voto |
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) | | | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción | | | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |

View File

@@ -40,6 +40,9 @@ export * from './lib/alerts/alert-payloads';
export * from './lib/alerts/alerts'; export * from './lib/alerts/alerts';
export * from './lib/ports/alert-integrations.repository'; export * from './lib/ports/alert-integrations.repository';
export * from './lib/alerts.service'; export * from './lib/alerts.service';
export * from './lib/follows/tree-follows';
export * from './lib/ports/tree-follows.repository';
export * from './lib/tree-follows.service';
export * from './lib/tracker-application.module'; export * from './lib/tracker-application.module';
export * from './lib/ports/presence.store'; export * from './lib/ports/presence.store';
export * from './lib/presence.service'; export * from './lib/presence.service';

View File

@@ -0,0 +1,82 @@
import {
Command,
CommandHandler,
ICommandHandler,
IQueryHandler,
Query,
QueryBus,
QueryHandler,
} from '@nestjs/cqrs';
import { NotFoundError } from '@task-stream/api/shared/domain';
import { IssueReadModel } from '@task-stream/api/tracker/domain';
import { TreeFollowsRepository } from '../ports/tree-follows.repository';
import { GetIssuesByIdsQuery } from '../queries/get-issues-by-ids.query';
/*
* Following a ticket with everything under it (an epic, or a ticket split
* into subtasks): told of its changes and of every ticket that hangs from
* it, at any depth, also the ones created later. Personal, no history
* (like pinned tickets).
*/
export class FollowTreeCommand extends Command<void> {
constructor(
public readonly userId: string,
public readonly issueId: string,
/** False: stop following it. */
public readonly on: boolean,
) {
super();
}
}
export class IsFollowingTreeQuery extends Query<boolean> {
constructor(
public readonly userId: string,
public readonly issueId: string,
) {
super();
}
}
@CommandHandler(FollowTreeCommand)
export class FollowTreeCommandHandler
implements ICommandHandler<FollowTreeCommand>
{
constructor(
private readonly follows: TreeFollowsRepository,
private readonly queryBus: QueryBus,
) {}
async execute(command: FollowTreeCommand): Promise<void> {
const { userId, issueId } = command;
if (!command.on) return this.follows.unfollow(userId, issueId);
const [issue]: IssueReadModel[] = await this.queryBus.execute(
new GetIssuesByIdsQuery(userId, [issueId]),
);
// A ticket I cannot read does not exist, as far as I know.
if (!issue || issue.trashedAt) throw new NotFoundError('Issue', issueId);
await this.follows.follow({
userId,
issueId,
projectId: issue.projectId,
since: new Date().toISOString(),
});
}
}
@QueryHandler(IsFollowingTreeQuery)
export class IsFollowingTreeQueryHandler
implements IQueryHandler<IsFollowingTreeQuery, boolean>
{
constructor(private readonly follows: TreeFollowsRepository) {}
execute(query: IsFollowingTreeQuery): Promise<boolean> {
return this.follows.isFollowing(query.userId, query.issueId);
}
}
export const TREE_FOLLOWS = [
FollowTreeCommandHandler,
IsFollowingTreeQueryHandler,
];

View File

@@ -3,6 +3,7 @@ import {
CommentAddedEvent, CommentAddedEvent,
IssueAssignedEvent, IssueAssignedEvent,
IssueCreatedEvent, IssueCreatedEvent,
IssueParentChangedEvent,
IssuePriority, IssuePriority,
IssueSprintChangedEvent, IssueSprintChangedEvent,
IssueStatusChangedEvent, IssueStatusChangedEvent,
@@ -24,6 +25,7 @@ import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository'; import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { TreeFollowsRepository } from '../ports/tree-follows.repository';
import { UserDirectory } from '../ports/user-directory'; import { UserDirectory } from '../ports/user-directory';
import { NotificationDraft, Notifier } from './notifier'; import { NotificationDraft, Notifier } from './notifier';
@@ -61,10 +63,12 @@ const excerpt = (text: string, max = 120) => {
*/ */
@EventsHandler( @EventsHandler(
IssueCreatedEvent, IssueCreatedEvent,
IssueParentChangedEvent,
IssueAssignedEvent, IssueAssignedEvent,
IssueStatusChangedEvent, IssueStatusChangedEvent,
IssueUpdatedEvent, IssueUpdatedEvent,
IssueSprintChangedEvent, IssueSprintChangedEvent,
IssueParentChangedEvent,
CommentAddedEvent, CommentAddedEvent,
ProjectMemberAddedEvent, ProjectMemberAddedEvent,
) )
@@ -76,6 +80,7 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
private readonly users: UserDirectory, private readonly users: UserDirectory,
private readonly findComments: FindCommentsRepository, private readonly findComments: FindCommentsRepository,
private readonly findSprints: FindSprintsRepository, private readonly findSprints: FindSprintsRepository,
private readonly treeFollows: TreeFollowsRepository,
) {} ) {}
async handle(event: TrackerEvent): Promise<void> { async handle(event: TrackerEvent): Promise<void> {
@@ -118,9 +123,23 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
const assigneeId = const assigneeId =
'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null); 'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null);
const ticket = `${issue.key}: ${issue.title}`; const ticket = `${issue.key}: ${issue.title}`;
const watcherIds =
'watchers' in issue ? issue.watchers.map((w) => w.id) : [];
const memberIds = new Set(project.members.map((m) => m.user.id)); const memberIds = new Set(project.members.map((m) => m.user.id));
// Who follows it with everything under it, or any ticket above it.
// Placed under another ticket: its new place counts (the read model may
// not have it yet).
const parentId =
event instanceof IssueParentChangedEvent
? event.parentId
: 'parent' in issue
? (issue.parent?.id ?? null)
: null;
const tree = await this.treeFollowersOf(issue.id, parentId, project);
const watcherIds = [
...new Set([
...('watchers' in issue ? issue.watchers.map((w) => w.id) : []),
...tree.followers,
]),
];
const drafts: NotificationDraft[] = []; const drafts: NotificationDraft[] = [];
// A proposal from the public form: whoever runs the project should know. // A proposal from the public form: whoever runs the project should know.
@@ -142,6 +161,16 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
}); });
} }
} }
// A ticket new under a followed one (created there or moved in): the
// only way to hear of it.
if (event instanceof IssueParentChangedEvent && event.parentId) {
for (const userId of tree.followers)
drafts.push({
userId,
kind: NotificationKind.WATCHED,
summary: `${actor} añadió ${ticket} a ${event.parentKey ?? tree.parentKey}`,
});
}
if (event instanceof IssueCreatedEvent && assigneeId) { if (event instanceof IssueCreatedEvent && assigneeId) {
drafts.push({ drafts.push({
userId: assigneeId, userId: assigneeId,
@@ -271,6 +300,40 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
); );
} }
/**
* Those following the ticket with its subtree, or any ticket above it
* (an epic, a parent), who can read it all: members that are not guests
* limited to their own tickets. Also the key of its parent.
*/
private async treeFollowersOf(
issueId: string,
parentId: string | null,
project: {
isPublic: boolean;
members: { user: { id: string }; role: ProjectRole }[];
},
): Promise<{ followers: string[]; parentKey: string | null }> {
const ids = [issueId];
let parentKey: string | null = null;
let next = parentId;
// A few levels: epic → story → subtask is as deep as trees go.
for (let depth = 0; next && depth < 5 && !ids.includes(next); depth++) {
ids.push(next);
const parent = await this.findIssues.findById(next);
parentKey ??= parent?.key ?? null;
next = parent?.parent?.id ?? null;
}
const readers = new Set(
project.members
.filter((m) => project.isPublic || m.role !== ProjectRole.GUEST)
.map((m) => m.user.id),
);
const followers = (await this.treeFollows.followersOf(ids)).filter((id) =>
readers.has(id),
);
return { followers, parentKey };
}
/** /**
* What a follower should hear of a change of priority, due date or * What a follower should hear of a change of priority, due date or
* sprint; null for the other edits (title, labels…), too small to tell. * sprint; null for the other edits (title, labels…), too small to tell.

View File

@@ -5,6 +5,7 @@ import {
IssuePriority, IssuePriority,
IssueSprintChangedEvent, IssueSprintChangedEvent,
IssueStatusChangedEvent, IssueStatusChangedEvent,
IssueParentChangedEvent,
IssueUpdatedEvent, IssueUpdatedEvent,
NotificationKind, NotificationKind,
NotificationReadModel, NotificationReadModel,
@@ -23,6 +24,7 @@ import { FindProjectsRepository } from '../ports/find-projects.repository';
import { NotificationMailer } from '../ports/notification-mailer'; import { NotificationMailer } from '../ports/notification-mailer';
import { NotificationsRepository } from '../ports/notifications.repository'; import { NotificationsRepository } from '../ports/notifications.repository';
import { NotificationsEventHandler } from './notifications.event-handler'; import { NotificationsEventHandler } from './notifications.event-handler';
import { TreeFollowsRepository } from '../ports/tree-follows.repository';
import { Notifier } from './notifier'; import { Notifier } from './notifier';
import { import {
PushPayload, PushPayload,
@@ -62,7 +64,12 @@ const at = <T extends TrackerEvent>(event: T, version = 2): T =>
occurredAt: '2026-10-08T10:00:00.000Z', occurredAt: '2026-10-08T10:00:00.000Z',
}); });
function setup(mailEnabled = false, pushEnabled = false) { function setup(
mailEnabled = false,
pushEnabled = false,
/** Ticket id → who follows it with everything under it. */
follows: Record<string, string[]> = {},
) {
const repo = new InMemoryNotifications(); const repo = new InMemoryNotifications();
const published: NotificationReadModel[] = []; const published: NotificationReadModel[] = [];
const mailed: string[] = []; const mailed: string[] = [];
@@ -74,7 +81,9 @@ function setup(mailEnabled = false, pushEnabled = false) {
.add('ana') .add('ana')
.add('bob') .add('bob')
.add('qa') .add('qa')
.add('pm'); .add('pm')
.add('lia')
.add('gus');
const issue = { const issue = {
id: 'i-1', id: 'i-1',
key: 'WEB-1', key: 'WEB-1',
@@ -85,7 +94,10 @@ function setup(mailEnabled = false, pushEnabled = false) {
{ id: 'pm', name: 'PM' }, { id: 'pm', name: 'PM' },
{ id: 'bob', name: 'Bob' }, { id: 'bob', name: 'Bob' },
], ],
parent: { id: 'e-1', key: 'WEB-10', title: 'Checkout' },
}; };
/** The epic it hangs from. */
const epic = { id: 'e-1', key: 'WEB-10', title: 'Checkout', parent: null };
const notifier = new Notifier( const notifier = new Notifier(
repo, repo,
{ {
@@ -118,15 +130,23 @@ function setup(mailEnabled = false, pushEnabled = false) {
); );
const handler = new NotificationsEventHandler( const handler = new NotificationsEventHandler(
notifier, notifier,
{ findById: async () => issue } as unknown as FindIssuesRepository, {
findById: async (id: string) => (id === 'e-1' ? epic : issue),
} as unknown as FindIssuesRepository,
{ {
findById: async () => ({ findById: async () => ({
id: 'p-1', id: 'p-1',
key: 'WEB', key: 'WEB',
name: 'Web', name: 'Web',
members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({ isPublic: false,
members: ['ana', 'bob', 'qa', 'pm', 'lia', 'gus'].map((id) => ({
user: { id, name: id }, user: { id, name: id },
role: id === 'ana' ? ProjectRole.OWNER : ProjectRole.MEMBER, role:
id === 'ana'
? ProjectRole.OWNER
: id === 'gus'
? ProjectRole.GUEST
: ProjectRole.MEMBER,
})), })),
}), }),
} as unknown as FindProjectsRepository, } as unknown as FindProjectsRepository,
@@ -142,11 +162,49 @@ function setup(mailEnabled = false, pushEnabled = false) {
findById: async (id: string) => findById: async (id: string) =>
id === 's-14' ? { id, name: 'Sprint 14' } : null, id === 's-14' ? { id, name: 'Sprint 14' } : null,
} as unknown as FindSprintsRepository, } as unknown as FindSprintsRepository,
{
followersOf: async (ids: string[]) => [
...new Set(ids.flatMap((id) => follows[id] ?? [])),
],
} as unknown as TreeFollowsRepository,
); );
return { repo, published, mailed, pushed, devices, forgotten, handler }; return { repo, published, mailed, pushed, devices, forgotten, handler };
} }
describe('notifications', () => { describe('notifications', () => {
it('tells whoever follows the epic of what happens to its tickets, not guests', async () => {
const { handler, repo } = setup(false, false, { 'e-1': ['lia', 'gus'] });
await handler.handle(
at(
new IssueStatusChangedEvent(
'ana',
'i-1',
'p-1',
'TODO',
'IN_PROGRESS',
null,
),
),
);
const lia = repo.all.filter((n) => n.userId === 'lia');
expect(lia.map((n) => n.kind)).toEqual([NotificationKind.WATCHED]);
expect(lia[0].summary).toContain('WEB-1');
// A guest only sees their own tickets: not told of the epic's.
expect(repo.all.some((n) => n.userId === 'gus')).toBe(false);
});
it('tells whoever follows the epic of a ticket added to it', async () => {
const { handler, repo } = setup(false, false, { 'e-1': ['lia'] });
await handler.handle(
at(
new IssueParentChangedEvent('ana', 'i-1', 'p-1', 'e-1', 'WEB-10', null),
),
);
expect(repo.all.map((n) => [n.userId, n.summary])).toEqual([
['lia', expect.stringContaining('añadió WEB-1: Login roto a WEB-10')],
]);
});
it('tells whoever a ticket is assigned to, not whoever assigned it', async () => { it('tells whoever a ticket is assigned to, not whoever assigned it', async () => {
const { handler, repo, published } = setup(); const { handler, repo, published } = setup();
await handler.handle( await handler.handle(

View File

@@ -0,0 +1,16 @@
/** Who follows a ticket with everything under it: personal, no history. */
export interface TreeFollow {
userId: string;
issueId: string;
projectId: string;
since: string;
}
export abstract class TreeFollowsRepository {
/** Following it again keeps the first date. */
abstract follow(follow: TreeFollow): Promise<void>;
abstract unfollow(userId: string, issueId: string): Promise<void>;
abstract isFollowing(userId: string, issueId: string): Promise<boolean>;
/** Everyone following any of these tickets (each once). */
abstract followersOf(issueIds: string[]): Promise<string[]>;
}

View File

@@ -37,6 +37,8 @@ import { SAVED_FILTERS } from './saved-filters/saved-filters';
import { MY_VIEWS } from './saved-filters/my-views'; import { MY_VIEWS } from './saved-filters/my-views';
import { PINS } from './saved-filters/pins'; import { PINS } from './saved-filters/pins';
import { ALERT_HANDLERS } from './alerts/alerts'; import { ALERT_HANDLERS } from './alerts/alerts';
import { TREE_FOLLOWS } from './follows/tree-follows';
import { TreeFollowsService } from './tree-follows.service';
import { AlertsService } from './alerts.service'; import { AlertsService } from './alerts.service';
import { PinsService } from './pins.service'; import { PinsService } from './pins.service';
import { SendDueRemindersCommandHandler } from './notifications/reminders'; import { SendDueRemindersCommandHandler } from './notifications/reminders';
@@ -84,6 +86,7 @@ const SERVICES = [
SavedFiltersService, SavedFiltersService,
PinsService, PinsService,
AlertsService, AlertsService,
TreeFollowsService,
SprintsService, SprintsService,
GitService, GitService,
WebhooksService, WebhooksService,
@@ -116,6 +119,7 @@ const SERVICES = [
...MY_VIEWS, ...MY_VIEWS,
...PINS, ...PINS,
...ALERT_HANDLERS, ...ALERT_HANDLERS,
...TREE_FOLLOWS,
...GIT_HANDLERS, ...GIT_HANDLERS,
...WEBHOOK_HANDLERS, ...WEBHOOK_HANDLERS,
...TRANSFER_HANDLERS, ...TRANSFER_HANDLERS,

View File

@@ -0,0 +1,24 @@
import { CommandRunner } from '@task-stream/api/shared/application';
import { Injectable } from '@nestjs/common';
import { QueryBus } from '@nestjs/cqrs';
import {
FollowTreeCommand,
IsFollowingTreeQuery,
} from './follows/tree-follows';
/** Following a ticket with everything under it (an epic). */
@Injectable()
export class TreeFollowsService {
constructor(
private readonly commands: CommandRunner,
private readonly queryBus: QueryBus,
) {}
follow(userId: string, issueId: string, on: boolean): Promise<void> {
return this.commands.execute(new FollowTreeCommand(userId, issueId, on));
}
isFollowing(userId: string, issueId: string): Promise<boolean> {
return this.queryBus.execute(new IsFollowingTreeQuery(userId, issueId));
}
}

View File

@@ -109,6 +109,7 @@ export const ReadModels = {
PinnedIssue: 'PinnedIssue', PinnedIssue: 'PinnedIssue',
AlertIntegration: 'AlertIntegration', AlertIntegration: 'AlertIntegration',
AlertTrace: 'AlertTrace', AlertTrace: 'AlertTrace',
TreeFollow: 'TreeFollow',
Sprint: 'SprintReadModel', Sprint: 'SprintReadModel',
GitIntegration: 'GitIntegration', GitIntegration: 'GitIntegration',
OutgoingWebhook: 'OutgoingWebhook', OutgoingWebhook: 'OutgoingWebhook',
@@ -360,6 +361,15 @@ export const AlertTraceSchema =
SchemaFactory.createForClass(AlertTraceDocument); SchemaFactory.createForClass(AlertTraceDocument);
AlertTraceSchema.index({ projectId: 1, fingerprint: 1 }, { unique: true }); AlertTraceSchema.index({ projectId: 1, fingerprint: 1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'tree_follows' })
class TreeFollowDocument {
@Prop({ type: String }) userId!: string;
@Prop({ type: String, index: true }) issueId!: string;
}
export const TreeFollowSchema =
SchemaFactory.createForClass(TreeFollowDocument);
TreeFollowSchema.index({ userId: 1, issueId: 1 }, { unique: true });
@Schema({ ...LOOSE, collection: 'git_integrations' }) @Schema({ ...LOOSE, collection: 'git_integrations' })
class GitIntegrationDocument { class GitIntegrationDocument {
/** The project's id. */ /** The project's id. */
@@ -465,6 +475,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.PinnedIssue, schema: PinnedIssueSchema }, { name: ReadModels.PinnedIssue, schema: PinnedIssueSchema },
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema }, { name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema }, { name: ReadModels.AlertTrace, schema: AlertTraceSchema },
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema }, { name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema }, { name: ReadModels.DashboardShare, schema: DashboardShareSchema },
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema }, { name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },

View File

@@ -0,0 +1,39 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import {
TreeFollow,
TreeFollowsRepository,
} from '@task-stream/api/tracker/application';
import { Model } from 'mongoose';
import { ReadModels } from './read-model.schemas';
@Injectable()
export class MongoTreeFollowsRepository extends TreeFollowsRepository {
constructor(
@InjectModel(ReadModels.TreeFollow)
private readonly follows: Model<TreeFollow>,
) {
super();
}
async follow(follow: TreeFollow): Promise<void> {
await this.follows.updateOne(
{ userId: follow.userId, issueId: follow.issueId },
{ $setOnInsert: follow },
{ upsert: true },
);
}
async unfollow(userId: string, issueId: string): Promise<void> {
await this.follows.deleteOne({ userId, issueId });
}
async isFollowing(userId: string, issueId: string): Promise<boolean> {
return !!(await this.follows.exists({ userId, issueId }));
}
async followersOf(issueIds: string[]): Promise<string[]> {
if (!issueIds.length) return [];
return this.follows.distinct('userId', { issueId: { $in: issueIds } });
}
}

View File

@@ -15,6 +15,7 @@ import {
MyViewsRepository, MyViewsRepository,
PinsRepository, PinsRepository,
AlertIntegrationsRepository, AlertIntegrationsRepository,
TreeFollowsRepository,
GitIntegrationsRepository, GitIntegrationsRepository,
OutgoingWebhooksRepository, OutgoingWebhooksRepository,
AutomationsRepository, AutomationsRepository,
@@ -82,6 +83,7 @@ import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
import { MongoMyViewsRepository } from './mongo/my-views.repository'; import { MongoMyViewsRepository } from './mongo/my-views.repository';
import { MongoPinsRepository } from './mongo/pins.repository'; import { MongoPinsRepository } from './mongo/pins.repository';
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository'; import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository'; import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
import { HttpWebhookSender } from './webhooks/http-webhook.sender'; import { HttpWebhookSender } from './webhooks/http-webhook.sender';
@@ -163,6 +165,7 @@ const PORTS = [
provide: AlertIntegrationsRepository, provide: AlertIntegrationsRepository,
useClass: MongoAlertIntegrationsRepository, useClass: MongoAlertIntegrationsRepository,
}, },
{ provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository },
{ {
provide: GitIntegrationsRepository, provide: GitIntegrationsRepository,
useClass: MongoGitIntegrationsRepository, useClass: MongoGitIntegrationsRepository,

View File

@@ -18,6 +18,7 @@ import {
CommentsService, CommentsService,
IssuesService, IssuesService,
PinsService, PinsService,
TreeFollowsService,
PresenceService, PresenceService,
WorkflowsService, WorkflowsService,
} from '@task-stream/api/tracker/application'; } from '@task-stream/api/tracker/application';
@@ -63,6 +64,7 @@ export class IssueResolver {
private readonly blockers: BlockersService, private readonly blockers: BlockersService,
private readonly presence: PresenceService, private readonly presence: PresenceService,
private readonly pins: PinsService, private readonly pins: PinsService,
private readonly treeFollows: TreeFollowsService,
) {} ) {}
@Mutation(() => [PresenceGql], { @Mutation(() => [PresenceGql], {
@@ -280,6 +282,17 @@ export class IssueResolver {
return this.commentsService.decisions(viewerOf(me), issue.id); return this.commentsService.decisions(viewerOf(me), issue.id);
} }
@ResolveField(() => Boolean, {
description:
'Whether I follow it with everything under it (false without a token)',
})
followingTree(
@CurrentUser() me: User | undefined,
@Parent() issue: IssueReadModel,
): Promise<boolean> | boolean {
return !!me && this.treeFollows.isFollowing(me.id, issue.id);
}
@ResolveField(() => Boolean, { @ResolveField(() => Boolean, {
description: 'Whether I pinned it (false without a token)', description: 'Whether I pinned it (false without a token)',
}) })
@@ -391,6 +404,29 @@ export class IssueResolver {
return true; return true;
} }
@Mutation(() => Boolean, {
description:
'Follow a ticket with everything under it (an epic): its changes and those of every ticket hanging from it, also future ones',
})
async followTree(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.treeFollows.follow(me.id, issueId, true);
return true;
}
@Mutation(() => Boolean, {
description: 'Stop following a ticket with everything under it',
})
async unfollowTree(
@CurrentUser() me: User,
@Args('issueId', { type: () => ID }) issueId: string,
): Promise<boolean> {
await this.treeFollows.follow(me.id, issueId, false);
return true;
}
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' }) @Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
unwatchIssue( unwatchIssue(
@CurrentUser() me: User, @CurrentUser() me: User,

View File

@@ -33,6 +33,7 @@ import {
CommentsService, CommentsService,
IssuesService, IssuesService,
PinsService, PinsService,
TreeFollowsService,
PresenceService, PresenceService,
ProjectsService, ProjectsService,
} from '@task-stream/api/tracker/application'; } from '@task-stream/api/tracker/application';
@@ -99,6 +100,7 @@ export class IssuesController {
private readonly presence: PresenceService, private readonly presence: PresenceService,
private readonly projects: ProjectsService, private readonly projects: ProjectsService,
private readonly pins: PinsService, private readonly pins: PinsService,
private readonly treeFollows: TreeFollowsService,
) {} ) {}
@Get('me/home') @Get('me/home')
@@ -203,6 +205,38 @@ export class IssuesController {
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false); await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
} }
@Put('issues/:issue/follow-tree')
@HttpCode(204)
@ISSUE
@ApiOperation({
summary: 'Follow a ticket with everything under it (an epic)',
description:
'Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.',
})
@ApiNoContentResponse()
@ApiCommandErrors()
async followTree(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.treeFollows.follow(
me.id,
await this.refs.issueId(me.id, issue),
true,
);
}
@Delete('issues/:issue/follow-tree')
@HttpCode(204)
@ISSUE
@ApiOperation({ summary: 'Stop following a ticket with everything under it' })
@ApiNoContentResponse()
@ApiCommandErrors()
async unfollowTree(@CurrentUser() me: User, @Param('issue') issue: string) {
await this.treeFollows.follow(
me.id,
await this.refs.issueId(me.id, issue),
false,
);
}
@Put('issues/:issue/watch') @Put('issues/:issue/watch')
@ISSUE @ISSUE
@ApiOperation({ @ApiOperation({

View File

@@ -49,6 +49,7 @@ export const issuePageEvents = eventGroup({
}>(), }>(),
watchRequested: type<{ issueId: string; watching: boolean }>(), watchRequested: type<{ issueId: string; watching: boolean }>(),
pinRequested: type<{ issueId: string; pinned: boolean }>(), pinRequested: type<{ issueId: string; pinned: boolean }>(),
followTreeRequested: type<{ issueId: string; on: boolean }>(),
trashRequested: type<{ trashRequested: type<{
issueId: string; issueId: string;
trashed: boolean; trashed: boolean;

View File

@@ -120,6 +120,13 @@ export class IssueFacade {
); );
} }
/** Follow the ticket with everything under it (an epic), or stop. */
setFollowingTree(on: boolean): void {
this.withIssue((issueId) =>
this.dispatch.followTreeRequested({ issueId, on }),
);
}
/** Pin the ticket for me (or unpin it): at hand on my home page and in search. */ /** Pin the ticket for me (or unpin it): at hand on my home page and in search. */
setPinned(pinned: boolean): void { setPinned(pinned: boolean): void {
this.withIssue((issueId) => this.withIssue((issueId) =>

View File

@@ -379,6 +379,20 @@ export const IssueStore = signalStore(
), ),
), ),
), ),
events
.on(issuePageEvents.followTreeRequested)
.pipe(
mergeMap(({ payload }) =>
runCommand(
issues.followTree(payload.issueId, payload.on),
payload.on
? 'Ahora sigues este ticket con todo lo que cuelga de él'
: 'Ya no sigues lo que cuelga de este ticket',
done,
failed,
),
),
),
events events
.on(issuePageEvents.pinRequested) .on(issuePageEvents.pinRequested)
.pipe( .pipe(

View File

@@ -75,6 +75,8 @@ export interface Epic {
progress: ChildProgress; progress: ChildProgress;
/** Its relations: «bloquea» / «depende de» are the roadmap's arrows. */ /** Its relations: «bloquea» / «depende de» are the roadmap's arrows. */
links: IssueLink[]; links: IssueLink[];
/** Whether I follow it with everything under it. */
followingTree: boolean;
} }
/** What a saved view narrows the board to; empty: everything. */ /** What a saved view narrows the board to; empty: everything. */
@@ -192,6 +194,8 @@ export interface Issue extends IssueSummary {
watchers: UserRef[]; watchers: UserRef[];
/** Whether I keep it pinned (home page and search). */ /** Whether I keep it pinned (home page and search). */
pinned: boolean; pinned: boolean;
/** Whether I follow it with everything under it (an epic). */
followingTree: boolean;
/** Its comments marked as decisions, oldest first. */ /** Its comments marked as decisions, oldest first. */
decisions: Comment[]; decisions: Comment[];
checklist: ChecklistItem[]; checklist: ChecklistItem[];

View File

@@ -148,6 +148,8 @@ export abstract class IssuesGateway {
abstract pins(): Observable<IssueSummary[]>; abstract pins(): Observable<IssueSummary[]>;
/** Pins the ticket for me (or unpins it): private, at most 20. */ /** Pins the ticket for me (or unpins it): private, at most 20. */
abstract pin(issueId: string, pinned: boolean): Observable<void>; abstract pin(issueId: string, pinned: boolean): Observable<void>;
/** Follows the ticket with everything under it (or stops). */
abstract followTree(issueId: string, on: boolean): Observable<void>;
/** /**
* Decisions (comments marked as such) of all my projects by their text, * Decisions (comments marked as such) of all my projects by their text,
* most recent first: here and not in CommentsGateway, for the palette. * most recent first: here and not in CommentsGateway, for the palette.

View File

@@ -51,6 +51,8 @@ import {
VoteIssueDocument, VoteIssueDocument,
WatchIssueDocument, WatchIssueDocument,
MyPinsDocument, MyPinsDocument,
FollowTreeDocument,
UnfollowTreeDocument,
SearchDecisionsDocument, SearchDecisionsDocument,
PinIssueDocument, PinIssueDocument,
UpdateChecklistItemDocument, UpdateChecklistItemDocument,
@@ -218,6 +220,13 @@ export class GraphqlIssuesGateway extends IssuesGateway {
), ),
); );
} }
followTree(issueId: string, on: boolean): Observable<void> {
const done$: Observable<unknown> = on
? this.gql.mutate(FollowTreeDocument, { issueId })
: this.gql.mutate(UnfollowTreeDocument, { issueId });
return done$.pipe(map(() => undefined));
}
pin(issueId: string, pinned: boolean): Observable<void> { pin(issueId: string, pinned: boolean): Observable<void> {
const done$: Observable<unknown> = pinned const done$: Observable<unknown> = pinned
? this.gql.mutate(PinIssueDocument, { issueId }) ? this.gql.mutate(PinIssueDocument, { issueId })
@@ -259,6 +268,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
issue: toIssueSummary(epic.issue), issue: toIssueSummary(epic.issue),
progress: { ...epic.progress }, progress: { ...epic.progress },
links: epic.issue.links.map((l) => toIssueLink(l)), links: epic.issue.links.map((l) => toIssueLink(l)),
followingTree: epic.issue.followingTree,
})), })),
), ),
); );

View File

@@ -85,6 +85,8 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
issue: toIssueSummary(e.issue), issue: toIssueSummary(e.issue),
progress: { ...e.progress }, progress: { ...e.progress },
links: [], links: [],
// The portfolio does not offer following.
followingTree: false,
})), })),
})), })),
), ),

View File

@@ -121,6 +121,7 @@ fragment IssueSummaryFields on Issue {
fragment IssueFields on Issue { fragment IssueFields on Issue {
...IssueSummaryFields ...IssueSummaryFields
pinned pinned
followingTree
decisions { decisions {
...CommentFields ...CommentFields
} }

View File

@@ -688,6 +688,7 @@ export type IssueSummaryFieldsFragment = {
export type IssueFieldsFragment = { export type IssueFieldsFragment = {
pinned: boolean; pinned: boolean;
followingTree: boolean;
description: string | null; description: string | null;
figmaUrl: string | null; figmaUrl: string | null;
createdAt: string; createdAt: string;
@@ -933,6 +934,7 @@ export type IssueQueryVariables = Exact<{
export type IssueQuery = { export type IssueQuery = {
issue: { issue: {
pinned: boolean; pinned: boolean;
followingTree: boolean;
description: string | null; description: string | null;
figmaUrl: string | null; figmaUrl: string | null;
createdAt: string; createdAt: string;
@@ -1151,6 +1153,18 @@ export type MyPinsQuery = {
}>; }>;
}; };
export type FollowTreeMutationVariables = Exact<{
issueId: string;
}>;
export type FollowTreeMutation = { followTree: boolean };
export type UnfollowTreeMutationVariables = Exact<{
issueId: string;
}>;
export type UnfollowTreeMutation = { unfollowTree: boolean };
export type PinIssueMutationVariables = Exact<{ export type PinIssueMutationVariables = Exact<{
issueId: string; issueId: string;
}>; }>;
@@ -1321,6 +1335,7 @@ export type EpicsQueryVariables = Exact<{
export type EpicsQuery = { export type EpicsQuery = {
epics: Array<{ epics: Array<{
issue: { issue: {
followingTree: boolean;
id: string; id: string;
projectId: string; projectId: string;
key: string; key: string;
@@ -3820,6 +3835,7 @@ export const IssueFieldsFragmentDoc = {
name: { kind: 'Name', value: 'IssueSummaryFields' }, name: { kind: 'Name', value: 'IssueSummaryFields' },
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } }, { kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } },
{ {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'decisions' }, name: { kind: 'Name', value: 'decisions' },
@@ -6420,6 +6436,7 @@ export const IssueDocument = {
name: { kind: 'Name', value: 'IssueSummaryFields' }, name: { kind: 'Name', value: 'IssueSummaryFields' },
}, },
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } }, { kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } },
{ {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'decisions' }, name: { kind: 'Name', value: 'decisions' },
@@ -7452,6 +7469,93 @@ export const MyPinsDocument = {
}, },
], ],
} as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>; } as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>;
export const FollowTreeDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'FollowTree' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'followTree' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<FollowTreeMutation, FollowTreeMutationVariables>;
export const UnfollowTreeDocument = {
kind: 'Document',
definitions: [
{
kind: 'OperationDefinition',
operation: 'mutation',
name: { kind: 'Name', value: 'UnfollowTree' },
variableDefinitions: [
{
kind: 'VariableDefinition',
variable: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
type: {
kind: 'NonNullType',
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
},
},
],
selectionSet: {
kind: 'SelectionSet',
selections: [
{
kind: 'Field',
name: { kind: 'Name', value: 'unfollowTree' },
arguments: [
{
kind: 'Argument',
name: { kind: 'Name', value: 'issueId' },
value: {
kind: 'Variable',
name: { kind: 'Name', value: 'issueId' },
},
},
],
},
],
},
},
],
} as unknown as DocumentNode<
UnfollowTreeMutation,
UnfollowTreeMutationVariables
>;
export const PinIssueDocument = { export const PinIssueDocument = {
kind: 'Document', kind: 'Document',
definitions: [ definitions: [
@@ -8633,6 +8737,10 @@ export const EpicsDocument = {
kind: 'FragmentSpread', kind: 'FragmentSpread',
name: { kind: 'Name', value: 'IssueSummaryFields' }, name: { kind: 'Name', value: 'IssueSummaryFields' },
}, },
{
kind: 'Field',
name: { kind: 'Name', value: 'followingTree' },
},
{ {
kind: 'Field', kind: 'Field',
name: { kind: 'Name', value: 'links' }, name: { kind: 'Name', value: 'links' },

View File

@@ -101,6 +101,14 @@ query MyPins {
} }
} }
mutation FollowTree($issueId: ID!) {
followTree(issueId: $issueId)
}
mutation UnfollowTree($issueId: ID!) {
unfollowTree(issueId: $issueId)
}
mutation PinIssue($issueId: ID!) { mutation PinIssue($issueId: ID!) {
pinIssue(issueId: $issueId) pinIssue(issueId: $issueId)
} }
@@ -185,6 +193,7 @@ query Epics($projectId: ID!) {
epics(projectId: $projectId) { epics(projectId: $projectId) {
issue { issue {
...IssueSummaryFields ...IssueSummaryFields
followingTree
links { links {
type type
direction direction

View File

@@ -1,10 +1,13 @@
import { import {
ChangeDetectionStrategy, ChangeDetectionStrategy,
Component, Component,
DestroyRef,
effect, effect,
inject, inject,
signal,
untracked, untracked,
} from '@angular/core'; } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { RouterLink } from '@angular/router'; import { RouterLink } from '@angular/router';
import { import {
PiEmptyStateComponent, PiEmptyStateComponent,
@@ -17,7 +20,8 @@ import {
ProjectFacade, ProjectFacade,
provideEpicsFacade, provideEpicsFacade,
} from '@task-stream/web/tracker/application'; } from '@task-stream/web/tracker/application';
import { Epic } from '@task-stream/web/tracker/domain'; import { ToastService } from '@task-stream/web/shared/ui';
import { Epic, IssuesGateway } from '@task-stream/web/tracker/domain';
import { statusLook, typeLook } from '../ui/copy'; import { statusLook, typeLook } from '../ui/copy';
/** /p/:key/epics — the big pieces of work and how far each has got. */ /** /p/:key/epics — the big pieces of work and how far each has got. */
@@ -69,6 +73,22 @@ import { statusLook, typeLook } from '../ui/copy';
<span class="ts-pill" [style.--tone]="statusTone(epic)">{{ <span class="ts-pill" [style.--tone]="statusTone(epic)">{{
statusLabel(epic) statusLabel(epic)
}}</span> }}</span>
@if (project.myRole()) {
<button
type="button"
class="ts-epic__follow"
[class.is-on]="following(epic)"
[attr.aria-pressed]="following(epic)"
[attr.title]="
following(epic)
? 'Dejar de seguir esta épica y sus tickets'
: 'Avisarme de esta épica y de todos sus tickets, también los que se añadan'
"
(click)="toggleFollow(epic)"
>
{{ following(epic) ? 'Siguiendo todo' : 'Seguir todo' }}
</button>
}
</header> </header>
<pi-progress-bar <pi-progress-bar
[label]="summary(epic)" [label]="summary(epic)"
@@ -86,6 +106,31 @@ import { statusLook, typeLook } from '../ui/copy';
} }
`, `,
styles: ` styles: `
/* A small pill, not @pi/ui's sized button. */
.ts-epic__follow {
flex: none;
height: auto !important;
min-width: 0 !important;
padding: 0.1875rem 0.625rem !important;
border: 1px solid var(--ts-line-strong);
border-radius: 999px;
background: transparent;
color: var(--ts-ink-soft);
font: inherit;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
&:hover,
&:focus-visible {
border-color: var(--ts-brand);
color: var(--ts-ink);
}
&.is-on {
border-color: transparent;
background: color-mix(in srgb, var(--ts-brand) 14%, transparent);
color: var(--ts-brand);
}
}
.ts-epics { .ts-epics {
display: grid; display: grid;
gap: 0.75rem; gap: 0.75rem;
@@ -120,7 +165,12 @@ import { statusLook, typeLook } from '../ui/copy';
}) })
export class EpicsPage { export class EpicsPage {
protected readonly facade = inject(EpicsFacade); protected readonly facade = inject(EpicsFacade);
private readonly project = inject(ProjectFacade); protected readonly project = inject(ProjectFacade);
private readonly issues = inject(IssuesGateway);
private readonly toast = inject(ToastService);
private readonly destroyRef = inject(DestroyRef);
/** Changed here since they loaded: epic id → following. */
private readonly changed = signal<ReadonlyMap<string, boolean>>(new Map());
constructor() { constructor() {
effect(() => { effect(() => {
@@ -129,6 +179,32 @@ export class EpicsPage {
}); });
} }
protected following(epic: Epic): boolean {
return this.changed().get(epic.issue.id) ?? epic.followingTree;
}
protected toggleFollow(epic: Epic): void {
const on = !this.following(epic);
const set = (value: boolean) =>
this.changed.update((m) => new Map(m).set(epic.issue.id, value));
set(on);
this.issues
.followTree(epic.issue.id, on)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: () =>
this.toast.success(
on
? `Sigues ${epic.issue.key} y todo lo que cuelga de ella`
: `Ya no sigues ${epic.issue.key}`,
),
error: () => {
set(!on);
this.toast.error('No se pudo cambiar');
},
});
}
protected summary(epic: Epic): string { protected summary(epic: Epic): string {
const { finished, total, percent, storyPoints, finishedStoryPoints } = const { finished, total, percent, storyPoints, finishedStoryPoints } =
epic.progress; epic.progress;

View File

@@ -206,6 +206,28 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
(execute)="facade.setWatching(!facade.watching())" (execute)="facade.setWatching(!facade.watching())"
/> />
} }
@if (
project.myRole() &&
(facade.children().length || issue.followingTree)
) {
<pi-icon-button
icon="kanban"
color="grey"
appearance="outline"
[text]="
issue.followingTree
? 'Siguiendo todo · ' + facade.children().length
: 'Seguir todo'
"
[attr.aria-pressed]="issue.followingTree"
[attr.title]="
issue.followingTree
? 'Dejar de seguir lo que cuelga de este ticket'
: 'Avisarme de este ticket y de todo lo que cuelga de él, también lo que se añada'
"
(execute)="facade.setFollowingTree(!issue.followingTree)"
/>
}
@if (project.myRole()) { @if (project.myRole()) {
<pi-icon-button <pi-icon-button
icon="pin" icon="pin"