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:
@@ -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.",
|
||||
|
||||
@@ -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"""
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=<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/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=<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/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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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';
|
||||
|
||||
82
libs/api/tracker/application/src/lib/follows/tree-follows.ts
Normal file
82
libs/api/tracker/application/src/lib/follows/tree-follows.ts
Normal 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,
|
||||
];
|
||||
@@ -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<TrackerEvent> {
|
||||
private readonly users: UserDirectory,
|
||||
private readonly findComments: FindCommentsRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly treeFollows: TreeFollowsRepository,
|
||||
) {}
|
||||
|
||||
async handle(event: TrackerEvent): Promise<void> {
|
||||
@@ -118,9 +123,23 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
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<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) {
|
||||
drafts.push({
|
||||
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
|
||||
* sprint; null for the other edits (title, labels…), too small to tell.
|
||||
|
||||
@@ -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 = <T extends TrackerEvent>(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<string, string[]> = {},
|
||||
) {
|
||||
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(
|
||||
|
||||
@@ -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[]>;
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
24
libs/api/tracker/application/src/lib/tree-follows.service.ts
Normal file
24
libs/api/tracker/application/src/lib/tree-follows.service.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
|
||||
@@ -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 } });
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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> | 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<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' })
|
||||
unwatchIssue(
|
||||
@CurrentUser() me: User,
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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[];
|
||||
|
||||
@@ -148,6 +148,8 @@ export abstract class IssuesGateway {
|
||||
abstract pins(): Observable<IssueSummary[]>;
|
||||
/** Pins the ticket for me (or unpins it): private, at most 20. */
|
||||
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,
|
||||
* most recent first: here and not in CommentsGateway, for the palette.
|
||||
|
||||
@@ -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<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> {
|
||||
const done$: Observable<unknown> = 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,
|
||||
})),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
})),
|
||||
})),
|
||||
),
|
||||
|
||||
@@ -121,6 +121,7 @@ fragment IssueSummaryFields on Issue {
|
||||
fragment IssueFields on Issue {
|
||||
...IssueSummaryFields
|
||||
pinned
|
||||
followingTree
|
||||
decisions {
|
||||
...CommentFields
|
||||
}
|
||||
|
||||
@@ -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<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 = {
|
||||
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' },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
<span class="ts-pill" [style.--tone]="statusTone(epic)">{{
|
||||
statusLabel(epic)
|
||||
}}</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>
|
||||
<pi-progress-bar
|
||||
[label]="summary(epic)"
|
||||
@@ -86,6 +106,31 @@ import { statusLook, typeLook } from '../ui/copy';
|
||||
}
|
||||
`,
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
@@ -120,7 +165,12 @@ import { statusLook, typeLook } from '../ui/copy';
|
||||
})
|
||||
export class EpicsPage {
|
||||
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() {
|
||||
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;
|
||||
|
||||
@@ -206,6 +206,28 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
||||
(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()) {
|
||||
<pi-icon-button
|
||||
icon="pin"
|
||||
|
||||
Reference in New Issue
Block a user