diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 558df33..c170d9b 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -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": { "put": { "description": "Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.", diff --git a/apps/api/schema.gql b/apps/api/schema.gql index ce4a982..635aa23 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -691,6 +691,9 @@ type Issue { dueDate: String figmaUrl: String + """Whether I follow it with everything under it (false without a token)""" + followingTree: Boolean! + """History of this ticket""" history(limit: Int! = 50): [Activity!]! id: ID! @@ -1016,6 +1019,11 @@ type Mutation { duplicateIssue(issueId: 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""" leavePresence(issueId: ID!): Boolean! @@ -1129,6 +1137,9 @@ type Mutation { """Bring an archived project back (owner only)""" unarchiveProject(projectId: ID!): CommandResult! + + """Stop following a ticket with everything under it""" + unfollowTree(issueId: ID!): Boolean! unlinkIssues(input: LinkIssuesInput!): CommandResult! """Unpin a ticket""" diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 139ca6f..15a545d 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -502,6 +502,27 @@ export interface paths { patch?: 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": { parameters: { 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: { parameters: { query?: never; diff --git a/apps/mcp/src/app/tools/issues.resolver.ts b/apps/mcp/src/app/tools/issues.resolver.ts index a4f8dae..753e889 100644 --- a/apps/mcp/src/app/tools/issues.resolver.ts +++ b/apps/mcp/src/app/tools/issues.resolver.ts @@ -565,19 +565,29 @@ export class IssuesResolver { @Tool({ name: 'watch_issue', 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({ issue: issueRef, follow: z.boolean().default(true), + tree: z.boolean().default(false), }), annotations: { ...WRITE, idempotentHint: true }, }) watch( - { issue, follow }: { issue: string; follow: boolean }, + { issue, follow, tree }: { issue: string; follow: boolean; tree: boolean }, ctx: McpContext, ) { const client = this.api.as(ctx); 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(() => this.api.data( follow diff --git a/docs/03-api.md b/docs/03-api.md index 3f98d53..1f403a2 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -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, 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 })`, -`?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`, `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 @@ -540,17 +544,17 @@ que funcionan también sin token. Todas devuelven `CommandResult { id, key, version }`. -| Ámbito | Mutations | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| 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` | -| 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` | -| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | -| 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») | -| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | -| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` | +| Ámbito | Mutations | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 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`, `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)` | +| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | +| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | +| 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») | +| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | +| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` | ### 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`). -| Recurso | Rutas | -| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 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) | -| 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` | -| 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` | -| 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` | -| 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` | -| 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) | -| 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` | -| 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}` | -| 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` | +| Recurso | Rutas | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| 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) | +| 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` | +| 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}` | +| 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}` | +| 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) | +| 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`) | +| 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) | +| 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) | +| 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 ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index eed6b11..4f68b17 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -300,25 +300,25 @@ visualización de la plataforma: ## 4.6 Rutas -| Ruta | Página | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `/login` | Inicio de sesión (solo sin sesión) | -| `/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» | -| `/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) | -| `/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=` 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/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/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/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/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» | -| `/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 | +| Ruta | Página | +| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/login` | Inicio de sesión (solo sin sesión) | +| `/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» | +| `/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) | +| `/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=` 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/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/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/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/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» | +| `/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 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 diff --git a/docs/09-mcp.md b/docs/09-mcp.md index 42aae51..b68f88a 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -62,7 +62,7 @@ flowchart LR | | `set_issue_release` | Meter en una release o sacar | | | `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 | -| | `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 | | | `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 | diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index 2943b33..fa0b064 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -40,6 +40,9 @@ export * from './lib/alerts/alert-payloads'; export * from './lib/alerts/alerts'; export * from './lib/ports/alert-integrations.repository'; 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/ports/presence.store'; export * from './lib/presence.service'; diff --git a/libs/api/tracker/application/src/lib/follows/tree-follows.ts b/libs/api/tracker/application/src/lib/follows/tree-follows.ts new file mode 100644 index 0000000..cab5486 --- /dev/null +++ b/libs/api/tracker/application/src/lib/follows/tree-follows.ts @@ -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 { + constructor( + public readonly userId: string, + public readonly issueId: string, + /** False: stop following it. */ + public readonly on: boolean, + ) { + super(); + } +} + +export class IsFollowingTreeQuery extends Query { + constructor( + public readonly userId: string, + public readonly issueId: string, + ) { + super(); + } +} + +@CommandHandler(FollowTreeCommand) +export class FollowTreeCommandHandler + implements ICommandHandler +{ + constructor( + private readonly follows: TreeFollowsRepository, + private readonly queryBus: QueryBus, + ) {} + + async execute(command: FollowTreeCommand): Promise { + 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 +{ + constructor(private readonly follows: TreeFollowsRepository) {} + + execute(query: IsFollowingTreeQuery): Promise { + return this.follows.isFollowing(query.userId, query.issueId); + } +} + +export const TREE_FOLLOWS = [ + FollowTreeCommandHandler, + IsFollowingTreeQueryHandler, +]; diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts index 65ab6eb..8ffbf8b 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts @@ -3,6 +3,7 @@ import { CommentAddedEvent, IssueAssignedEvent, IssueCreatedEvent, + IssueParentChangedEvent, IssuePriority, IssueSprintChangedEvent, IssueStatusChangedEvent, @@ -24,6 +25,7 @@ import { FindCommentsRepository } from '../ports/find-comments.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindSprintsRepository } from '../ports/find-sprints.repository'; +import { TreeFollowsRepository } from '../ports/tree-follows.repository'; import { UserDirectory } from '../ports/user-directory'; import { NotificationDraft, Notifier } from './notifier'; @@ -61,10 +63,12 @@ const excerpt = (text: string, max = 120) => { */ @EventsHandler( IssueCreatedEvent, + IssueParentChangedEvent, IssueAssignedEvent, IssueStatusChangedEvent, IssueUpdatedEvent, IssueSprintChangedEvent, + IssueParentChangedEvent, CommentAddedEvent, ProjectMemberAddedEvent, ) @@ -76,6 +80,7 @@ export class NotificationsEventHandler implements IEventHandler { private readonly users: UserDirectory, private readonly findComments: FindCommentsRepository, private readonly findSprints: FindSprintsRepository, + private readonly treeFollows: TreeFollowsRepository, ) {} async handle(event: TrackerEvent): Promise { @@ -118,9 +123,23 @@ export class NotificationsEventHandler implements IEventHandler { const assigneeId = 'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null); 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)); + // 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[] = []; // A proposal from the public form: whoever runs the project should know. @@ -142,6 +161,16 @@ export class NotificationsEventHandler implements IEventHandler { }); } } + // 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) { drafts.push({ userId: assigneeId, @@ -271,6 +300,40 @@ export class NotificationsEventHandler implements IEventHandler { ); } + /** + * 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 * sprint; null for the other edits (title, labels…), too small to tell. diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts b/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts index 76a407e..6c69ba4 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifications.spec.ts @@ -5,6 +5,7 @@ import { IssuePriority, IssueSprintChangedEvent, IssueStatusChangedEvent, + IssueParentChangedEvent, IssueUpdatedEvent, NotificationKind, NotificationReadModel, @@ -23,6 +24,7 @@ import { FindProjectsRepository } from '../ports/find-projects.repository'; import { NotificationMailer } from '../ports/notification-mailer'; import { NotificationsRepository } from '../ports/notifications.repository'; import { NotificationsEventHandler } from './notifications.event-handler'; +import { TreeFollowsRepository } from '../ports/tree-follows.repository'; import { Notifier } from './notifier'; import { PushPayload, @@ -62,7 +64,12 @@ const at = (event: T, version = 2): T => 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 = {}, +) { const repo = new InMemoryNotifications(); const published: NotificationReadModel[] = []; const mailed: string[] = []; @@ -74,7 +81,9 @@ function setup(mailEnabled = false, pushEnabled = false) { .add('ana') .add('bob') .add('qa') - .add('pm'); + .add('pm') + .add('lia') + .add('gus'); const issue = { id: 'i-1', key: 'WEB-1', @@ -85,7 +94,10 @@ function setup(mailEnabled = false, pushEnabled = false) { { id: 'pm', name: 'PM' }, { 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( repo, { @@ -118,15 +130,23 @@ function setup(mailEnabled = false, pushEnabled = false) { ); const handler = new NotificationsEventHandler( notifier, - { findById: async () => issue } as unknown as FindIssuesRepository, + { + findById: async (id: string) => (id === 'e-1' ? epic : issue), + } as unknown as FindIssuesRepository, { findById: async () => ({ id: 'p-1', key: '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 }, - role: id === 'ana' ? ProjectRole.OWNER : ProjectRole.MEMBER, + role: + id === 'ana' + ? ProjectRole.OWNER + : id === 'gus' + ? ProjectRole.GUEST + : ProjectRole.MEMBER, })), }), } as unknown as FindProjectsRepository, @@ -142,11 +162,49 @@ function setup(mailEnabled = false, pushEnabled = false) { findById: async (id: string) => id === 's-14' ? { id, name: 'Sprint 14' } : null, } 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 }; } 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 () => { const { handler, repo, published } = setup(); await handler.handle( diff --git a/libs/api/tracker/application/src/lib/ports/tree-follows.repository.ts b/libs/api/tracker/application/src/lib/ports/tree-follows.repository.ts new file mode 100644 index 0000000..d785559 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/tree-follows.repository.ts @@ -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; + abstract unfollow(userId: string, issueId: string): Promise; + abstract isFollowing(userId: string, issueId: string): Promise; + /** Everyone following any of these tickets (each once). */ + abstract followersOf(issueIds: string[]): Promise; +} diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index 88e565e..bab5563 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -37,6 +37,8 @@ import { SAVED_FILTERS } from './saved-filters/saved-filters'; import { MY_VIEWS } from './saved-filters/my-views'; import { PINS } from './saved-filters/pins'; 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 { PinsService } from './pins.service'; import { SendDueRemindersCommandHandler } from './notifications/reminders'; @@ -84,6 +86,7 @@ const SERVICES = [ SavedFiltersService, PinsService, AlertsService, + TreeFollowsService, SprintsService, GitService, WebhooksService, @@ -116,6 +119,7 @@ const SERVICES = [ ...MY_VIEWS, ...PINS, ...ALERT_HANDLERS, + ...TREE_FOLLOWS, ...GIT_HANDLERS, ...WEBHOOK_HANDLERS, ...TRANSFER_HANDLERS, diff --git a/libs/api/tracker/application/src/lib/tree-follows.service.ts b/libs/api/tracker/application/src/lib/tree-follows.service.ts new file mode 100644 index 0000000..c2f43d5 --- /dev/null +++ b/libs/api/tracker/application/src/lib/tree-follows.service.ts @@ -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 { + return this.commands.execute(new FollowTreeCommand(userId, issueId, on)); + } + + isFollowing(userId: string, issueId: string): Promise { + return this.queryBus.execute(new IsFollowingTreeQuery(userId, issueId)); + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index 48ed735..ecb47ae 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -109,6 +109,7 @@ export const ReadModels = { PinnedIssue: 'PinnedIssue', AlertIntegration: 'AlertIntegration', AlertTrace: 'AlertTrace', + TreeFollow: 'TreeFollow', Sprint: 'SprintReadModel', GitIntegration: 'GitIntegration', OutgoingWebhook: 'OutgoingWebhook', @@ -360,6 +361,15 @@ export const AlertTraceSchema = SchemaFactory.createForClass(AlertTraceDocument); 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' }) class GitIntegrationDocument { /** The project's id. */ @@ -465,6 +475,7 @@ export const READ_MODEL_SCHEMAS = [ { name: ReadModels.PinnedIssue, schema: PinnedIssueSchema }, { name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema }, { name: ReadModels.AlertTrace, schema: AlertTraceSchema }, + { name: ReadModels.TreeFollow, schema: TreeFollowSchema }, { name: ReadModels.CalendarFeed, schema: CalendarFeedSchema }, { name: ReadModels.DashboardShare, schema: DashboardShareSchema }, { name: ReadModels.PushSubscription, schema: PushSubscriptionSchema }, diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/tree-follows.repository.ts b/libs/api/tracker/infrastructure/src/lib/mongo/tree-follows.repository.ts new file mode 100644 index 0000000..d7b7f50 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mongo/tree-follows.repository.ts @@ -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, + ) { + super(); + } + + async follow(follow: TreeFollow): Promise { + await this.follows.updateOne( + { userId: follow.userId, issueId: follow.issueId }, + { $setOnInsert: follow }, + { upsert: true }, + ); + } + + async unfollow(userId: string, issueId: string): Promise { + await this.follows.deleteOne({ userId, issueId }); + } + + async isFollowing(userId: string, issueId: string): Promise { + return !!(await this.follows.exists({ userId, issueId })); + } + + async followersOf(issueIds: string[]): Promise { + if (!issueIds.length) return []; + return this.follows.distinct('userId', { issueId: { $in: issueIds } }); + } +} diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts index 8e1420e..bd65809 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -15,6 +15,7 @@ import { MyViewsRepository, PinsRepository, AlertIntegrationsRepository, + TreeFollowsRepository, GitIntegrationsRepository, OutgoingWebhooksRepository, AutomationsRepository, @@ -82,6 +83,7 @@ import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository'; import { MongoMyViewsRepository } from './mongo/my-views.repository'; import { MongoPinsRepository } from './mongo/pins.repository'; import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository'; +import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository'; import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository'; import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository'; import { HttpWebhookSender } from './webhooks/http-webhook.sender'; @@ -163,6 +165,7 @@ const PORTS = [ provide: AlertIntegrationsRepository, useClass: MongoAlertIntegrationsRepository, }, + { provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository }, { provide: GitIntegrationsRepository, useClass: MongoGitIntegrationsRepository, diff --git a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts index 1dfef3d..7b0d0b7 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -18,6 +18,7 @@ import { CommentsService, IssuesService, PinsService, + TreeFollowsService, PresenceService, WorkflowsService, } from '@task-stream/api/tracker/application'; @@ -63,6 +64,7 @@ export class IssueResolver { private readonly blockers: BlockersService, private readonly presence: PresenceService, private readonly pins: PinsService, + private readonly treeFollows: TreeFollowsService, ) {} @Mutation(() => [PresenceGql], { @@ -280,6 +282,17 @@ export class IssueResolver { 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 { + return !!me && this.treeFollows.isFollowing(me.id, issue.id); + } + @ResolveField(() => Boolean, { description: 'Whether I pinned it (false without a token)', }) @@ -391,6 +404,29 @@ export class IssueResolver { 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 { + 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 { + await this.treeFollows.follow(me.id, issueId, false); + return true; + } + @Mutation(() => CommandResultGql, { description: 'Stop following a ticket' }) unwatchIssue( @CurrentUser() me: User, diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index f34e4ff..5be85fd 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -33,6 +33,7 @@ import { CommentsService, IssuesService, PinsService, + TreeFollowsService, PresenceService, ProjectsService, } from '@task-stream/api/tracker/application'; @@ -99,6 +100,7 @@ export class IssuesController { private readonly presence: PresenceService, private readonly projects: ProjectsService, private readonly pins: PinsService, + private readonly treeFollows: TreeFollowsService, ) {} @Get('me/home') @@ -203,6 +205,38 @@ export class IssuesController { 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') @ISSUE @ApiOperation({ diff --git a/libs/web/tracker/application/src/lib/events/issue.events.ts b/libs/web/tracker/application/src/lib/events/issue.events.ts index 1366f33..df7375e 100644 --- a/libs/web/tracker/application/src/lib/events/issue.events.ts +++ b/libs/web/tracker/application/src/lib/events/issue.events.ts @@ -49,6 +49,7 @@ export const issuePageEvents = eventGroup({ }>(), watchRequested: type<{ issueId: string; watching: boolean }>(), pinRequested: type<{ issueId: string; pinned: boolean }>(), + followTreeRequested: type<{ issueId: string; on: boolean }>(), trashRequested: type<{ issueId: string; trashed: boolean; diff --git a/libs/web/tracker/application/src/lib/facades/issue.facade.ts b/libs/web/tracker/application/src/lib/facades/issue.facade.ts index fbee666..826c006 100644 --- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts @@ -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. */ setPinned(pinned: boolean): void { this.withIssue((issueId) => diff --git a/libs/web/tracker/application/src/lib/stores/issue.store.ts b/libs/web/tracker/application/src/lib/stores/issue.store.ts index 8ad4ae4..0a4fedc 100644 --- a/libs/web/tracker/application/src/lib/stores/issue.store.ts +++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts @@ -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 .on(issuePageEvents.pinRequested) .pipe( diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 3cd0e56..c853dcc 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -75,6 +75,8 @@ export interface Epic { progress: ChildProgress; /** Its relations: «bloquea» / «depende de» are the roadmap's arrows. */ links: IssueLink[]; + /** Whether I follow it with everything under it. */ + followingTree: boolean; } /** What a saved view narrows the board to; empty: everything. */ @@ -192,6 +194,8 @@ export interface Issue extends IssueSummary { watchers: UserRef[]; /** Whether I keep it pinned (home page and search). */ pinned: boolean; + /** Whether I follow it with everything under it (an epic). */ + followingTree: boolean; /** Its comments marked as decisions, oldest first. */ decisions: Comment[]; checklist: ChecklistItem[]; diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 563130f..6f6742d 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -148,6 +148,8 @@ export abstract class IssuesGateway { abstract pins(): Observable; /** Pins the ticket for me (or unpins it): private, at most 20. */ abstract pin(issueId: string, pinned: boolean): Observable; + /** Follows the ticket with everything under it (or stops). */ + abstract followTree(issueId: string, on: boolean): Observable; /** * Decisions (comments marked as such) of all my projects by their text, * most recent first: here and not in CommentsGateway, for the palette. diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts index a562594..6276c63 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts @@ -51,6 +51,8 @@ import { VoteIssueDocument, WatchIssueDocument, MyPinsDocument, + FollowTreeDocument, + UnfollowTreeDocument, SearchDecisionsDocument, PinIssueDocument, UpdateChecklistItemDocument, @@ -218,6 +220,13 @@ export class GraphqlIssuesGateway extends IssuesGateway { ), ); } + followTree(issueId: string, on: boolean): Observable { + const done$: Observable = on + ? this.gql.mutate(FollowTreeDocument, { issueId }) + : this.gql.mutate(UnfollowTreeDocument, { issueId }); + return done$.pipe(map(() => undefined)); + } + pin(issueId: string, pinned: boolean): Observable { const done$: Observable = pinned ? this.gql.mutate(PinIssueDocument, { issueId }) @@ -259,6 +268,7 @@ export class GraphqlIssuesGateway extends IssuesGateway { issue: toIssueSummary(epic.issue), progress: { ...epic.progress }, links: epic.issue.links.map((l) => toIssueLink(l)), + followingTree: epic.issue.followingTree, })), ), ); diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts index 848415a..f2e464d 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-projects.gateway.ts @@ -85,6 +85,8 @@ export class GraphqlProjectsGateway extends ProjectsGateway { issue: toIssueSummary(e.issue), progress: { ...e.progress }, links: [], + // The portfolio does not offer following. + followingTree: false, })), })), ), diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index 606ac5b..23e40f6 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -121,6 +121,7 @@ fragment IssueSummaryFields on Issue { fragment IssueFields on Issue { ...IssueSummaryFields pinned + followingTree decisions { ...CommentFields } diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 4d477de..9fa76cf 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -688,6 +688,7 @@ export type IssueSummaryFieldsFragment = { export type IssueFieldsFragment = { pinned: boolean; + followingTree: boolean; description: string | null; figmaUrl: string | null; createdAt: string; @@ -933,6 +934,7 @@ export type IssueQueryVariables = Exact<{ export type IssueQuery = { issue: { pinned: boolean; + followingTree: boolean; description: string | null; figmaUrl: string | null; 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<{ issueId: string; }>; @@ -1321,6 +1335,7 @@ export type EpicsQueryVariables = Exact<{ export type EpicsQuery = { epics: Array<{ issue: { + followingTree: boolean; id: string; projectId: string; key: string; @@ -3820,6 +3835,7 @@ export const IssueFieldsFragmentDoc = { name: { kind: 'Name', value: 'IssueSummaryFields' }, }, { kind: 'Field', name: { kind: 'Name', value: 'pinned' } }, + { kind: 'Field', name: { kind: 'Name', value: 'followingTree' } }, { kind: 'Field', name: { kind: 'Name', value: 'decisions' }, @@ -6420,6 +6436,7 @@ export const IssueDocument = { name: { kind: 'Name', value: 'IssueSummaryFields' }, }, { kind: 'Field', name: { kind: 'Name', value: 'pinned' } }, + { kind: 'Field', name: { kind: 'Name', value: 'followingTree' } }, { kind: 'Field', name: { kind: 'Name', value: 'decisions' }, @@ -7452,6 +7469,93 @@ export const MyPinsDocument = { }, ], } as unknown as DocumentNode; +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; +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 = { kind: 'Document', definitions: [ @@ -8633,6 +8737,10 @@ export const EpicsDocument = { kind: 'FragmentSpread', name: { kind: 'Name', value: 'IssueSummaryFields' }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'followingTree' }, + }, { kind: 'Field', name: { kind: 'Name', value: 'links' }, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index 2d76132..f6ba3d7 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -101,6 +101,14 @@ query MyPins { } } +mutation FollowTree($issueId: ID!) { + followTree(issueId: $issueId) +} + +mutation UnfollowTree($issueId: ID!) { + unfollowTree(issueId: $issueId) +} + mutation PinIssue($issueId: ID!) { pinIssue(issueId: $issueId) } @@ -185,6 +193,7 @@ query Epics($projectId: ID!) { epics(projectId: $projectId) { issue { ...IssueSummaryFields + followingTree links { type direction diff --git a/libs/web/tracker/presenters/src/lib/pages/epics.page.ts b/libs/web/tracker/presenters/src/lib/pages/epics.page.ts index 3529e05..e1273bb 100644 --- a/libs/web/tracker/presenters/src/lib/pages/epics.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/epics.page.ts @@ -1,10 +1,13 @@ import { ChangeDetectionStrategy, Component, + DestroyRef, effect, inject, + signal, untracked, } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { RouterLink } from '@angular/router'; import { PiEmptyStateComponent, @@ -17,7 +20,8 @@ import { ProjectFacade, provideEpicsFacade, } 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'; /** /p/:key/epics — the big pieces of work and how far each has got. */ @@ -69,6 +73,22 @@ import { statusLook, typeLook } from '../ui/copy'; {{ statusLabel(epic) }} + @if (project.myRole()) { + + } >(new Map()); constructor() { 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 { const { finished, total, percent, storyPoints, finishedStoryPoints } = epic.progress; diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index 4332e7d..d794d3c 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -206,6 +206,28 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; (execute)="facade.setWatching(!facade.watching())" /> } + @if ( + project.myRole() && + (facade.children().length || issue.followingTree) + ) { + + } @if (project.myRole()) {