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": {
|
"/api/issues/{issue}/watch": {
|
||||||
"put": {
|
"put": {
|
||||||
"description": "Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.",
|
"description": "Followers are told when it moves, is reassigned or gets a comment. Commenting follows it too.",
|
||||||
|
|||||||
@@ -691,6 +691,9 @@ type Issue {
|
|||||||
dueDate: String
|
dueDate: String
|
||||||
figmaUrl: String
|
figmaUrl: String
|
||||||
|
|
||||||
|
"""Whether I follow it with everything under it (false without a token)"""
|
||||||
|
followingTree: Boolean!
|
||||||
|
|
||||||
"""History of this ticket"""
|
"""History of this ticket"""
|
||||||
history(limit: Int! = 50): [Activity!]!
|
history(limit: Int! = 50): [Activity!]!
|
||||||
id: ID!
|
id: ID!
|
||||||
@@ -1016,6 +1019,11 @@ type Mutation {
|
|||||||
duplicateIssue(issueId: ID!): CommandResult!
|
duplicateIssue(issueId: ID!): CommandResult!
|
||||||
editComment(body: String!, commentId: ID!): CommandResult!
|
editComment(body: String!, commentId: ID!): CommandResult!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Follow a ticket with everything under it (an epic): its changes and those of every ticket hanging from it, also future ones
|
||||||
|
"""
|
||||||
|
followTree(issueId: ID!): Boolean!
|
||||||
|
|
||||||
"""I left this ticket's page"""
|
"""I left this ticket's page"""
|
||||||
leavePresence(issueId: ID!): Boolean!
|
leavePresence(issueId: ID!): Boolean!
|
||||||
|
|
||||||
@@ -1129,6 +1137,9 @@ type Mutation {
|
|||||||
|
|
||||||
"""Bring an archived project back (owner only)"""
|
"""Bring an archived project back (owner only)"""
|
||||||
unarchiveProject(projectId: ID!): CommandResult!
|
unarchiveProject(projectId: ID!): CommandResult!
|
||||||
|
|
||||||
|
"""Stop following a ticket with everything under it"""
|
||||||
|
unfollowTree(issueId: ID!): Boolean!
|
||||||
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
||||||
|
|
||||||
"""Unpin a ticket"""
|
"""Unpin a ticket"""
|
||||||
|
|||||||
@@ -502,6 +502,27 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/issues/{issue}/follow-tree": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/**
|
||||||
|
* Follow a ticket with everything under it (an epic)
|
||||||
|
* @description Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.
|
||||||
|
*/
|
||||||
|
put: operations["IssuesController_followTree"];
|
||||||
|
post?: never;
|
||||||
|
/** Stop following a ticket with everything under it */
|
||||||
|
delete: operations["IssuesController_unfollowTree"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/issues/{issue}/watch": {
|
"/api/issues/{issue}/watch": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -5732,6 +5753,154 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
IssuesController_followTree: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Ticket id or key */
|
||||||
|
issue: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Someone changed it at the same time: retry */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
IssuesController_unfollowTree: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Ticket id or key */
|
||||||
|
issue: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
204: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Someone changed it at the same time: retry */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
IssuesController_watch: {
|
IssuesController_watch: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -565,19 +565,29 @@ export class IssuesResolver {
|
|||||||
@Tool({
|
@Tool({
|
||||||
name: 'watch_issue',
|
name: 'watch_issue',
|
||||||
description:
|
description:
|
||||||
'Follow a ticket (notifications of its changes and comments) or stop following it.',
|
'Follow a ticket (notifications of its changes and comments) or stop following it. With tree: true, an epic (or any ticket with subtasks) with everything under it, also tickets added later.',
|
||||||
paramsSchema: z.object({
|
paramsSchema: z.object({
|
||||||
issue: issueRef,
|
issue: issueRef,
|
||||||
follow: z.boolean().default(true),
|
follow: z.boolean().default(true),
|
||||||
|
tree: z.boolean().default(false),
|
||||||
}),
|
}),
|
||||||
annotations: { ...WRITE, idempotentHint: true },
|
annotations: { ...WRITE, idempotentHint: true },
|
||||||
})
|
})
|
||||||
watch(
|
watch(
|
||||||
{ issue, follow }: { issue: string; follow: boolean },
|
{ issue, follow, tree }: { issue: string; follow: boolean; tree: boolean },
|
||||||
ctx: McpContext,
|
ctx: McpContext,
|
||||||
) {
|
) {
|
||||||
const client = this.api.as(ctx);
|
const client = this.api.as(ctx);
|
||||||
const options = { params: { path: { issue } } };
|
const options = { params: { path: { issue } } };
|
||||||
|
if (tree)
|
||||||
|
return toolResult(async () => {
|
||||||
|
await this.api.data(
|
||||||
|
follow
|
||||||
|
? client.PUT('/api/issues/{issue}/follow-tree', options)
|
||||||
|
: client.DELETE('/api/issues/{issue}/follow-tree', options),
|
||||||
|
);
|
||||||
|
return { issue, followingTree: follow };
|
||||||
|
});
|
||||||
return toolResult(() =>
|
return toolResult(() =>
|
||||||
this.api.data(
|
this.api.data(
|
||||||
follow
|
follow
|
||||||
|
|||||||
@@ -425,7 +425,11 @@ eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo ti
|
|||||||
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
|
sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite,
|
||||||
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
|
o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en
|
||||||
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
|
`myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`,
|
||||||
`?watching=true`; sin sesión, ninguno). **Fijar**: cada persona fija hasta 20 tickets para tenerlos a mano en el
|
`?watching=true`; sin sesión, ninguno). **Seguir todo**: seguir un ticket con todo lo que cuelga de él (una épica, un ticket con subtareas):
|
||||||
|
`followTree`/`unfollowTree`, `Issue.followingTree`; `PUT/DELETE /api/issues/{issue}/follow-tree`. Quien lo hace oye lo
|
||||||
|
mismo que un seguidor de cada ticket de debajo, a cualquier profundidad, también de los que se añadan, y además cuándo
|
||||||
|
uno entra (`IssueParentChangedEvent`). Personal y sin historial (colección `tree_follows`); los invitados de un proyecto
|
||||||
|
privado no oyen los tickets que no son suyos. **Fijar**: cada persona fija hasta 20 tickets para tenerlos a mano en el
|
||||||
inicio y en la búsqueda (`pinIssue`/`unpinIssue`, `myPins`, `Issue.pinned`; `GET /api/me/pins`,
|
inicio y en la búsqueda (`pinIssue`/`unpinIssue`, `myPins`, `Issue.pinned`; `GET /api/me/pins`,
|
||||||
`PUT/DELETE /api/issues/{issue}/pin`). Son privados y sin historial (colección `pinned_issues`, como las vistas de «Mi
|
`PUT/DELETE /api/issues/{issue}/pin`). Son privados y sin historial (colección `pinned_issues`, como las vistas de «Mi
|
||||||
trabajo»); los que están en la papelera o fuera de su alcance no se listan, y al llegar a 20 se olvidan primero los
|
trabajo»); los que están en la papelera o fuera de su alcance no se listan, y al llegar a 20 se olvidan primero los
|
||||||
@@ -541,9 +545,9 @@ que funcionan también sin token.
|
|||||||
Todas devuelven `CommandResult { id, key, version }`.
|
Todas devuelven `CommandResult { id, key, version }`.
|
||||||
|
|
||||||
| Ámbito | Mutations |
|
| Ámbito | Mutations |
|
||||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
|
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree` (devuelven `true`), `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` |
|
||||||
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
|
| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` |
|
||||||
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||||
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` |
|
||||||
@@ -749,10 +753,10 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
|
|||||||
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||||
|
|
||||||
| Recurso | Rutas |
|
| Recurso | Rutas |
|
||||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||||
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/follow-tree`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` |
|
||||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
|
| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` |
|
||||||
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
||||||
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
||||||
|
|||||||
@@ -301,14 +301,14 @@ visualización de la plataforma:
|
|||||||
## 4.6 Rutas
|
## 4.6 Rutas
|
||||||
|
|
||||||
| Ruta | Página |
|
| Ruta | Página |
|
||||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| -------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `/login` | Inicio de sesión (solo sin sesión) |
|
| `/login` | Inicio de sesión (solo sin sesión) |
|
||||||
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
| `/auth/callback` | Retorno de Pireon (PKCE) |
|
||||||
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
|
| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» |
|
||||||
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
|
| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos |
|
||||||
| `/explore` | Proyectos públicos (sin sesión) |
|
| `/explore` | Proyectos públicos (sin sesión) |
|
||||||
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar |
|
| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar |
|
||||||
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea) |
|
| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=<id>` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas) |
|
||||||
| `/p/:key/releases` | Releases |
|
| `/p/:key/releases` | Releases |
|
||||||
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) |
|
||||||
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea |
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ flowchart LR
|
|||||||
| | `set_issue_release` | Meter en una release o sacar |
|
| | `set_issue_release` | Meter en una release o sacar |
|
||||||
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar |
|
| | `add_comment` / `edit_comment` / `delete_comment` | Comentar (o responder a un comentario), corregir o borrar |
|
||||||
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
|
| | `link_issues` / `unlink_issues` | Relacionar tickets (`DEPENDS_ON`, `BLOCKS`, `TESTS`…) o quitar la relación |
|
||||||
| | `watch_issue` | Seguir un ticket o dejar de seguirlo |
|
| | `watch_issue` | Seguir un ticket o dejar de seguirlo; con `tree`, una épica con todo lo que cuelga de ella |
|
||||||
| | `vote_issue` | Votar un ticket o quitar el voto |
|
| | `vote_issue` | Votar un ticket o quitar el voto |
|
||||||
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
|
| | `duplicate_issue` | Crear otro ticket con lo mismo (sin estado, persona ni comentarios) |
|
||||||
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
|
| | `react_to_comment` | Reaccionar a un comentario (👍 🎉…) o quitar la reacción |
|
||||||
|
|||||||
@@ -40,6 +40,9 @@ export * from './lib/alerts/alert-payloads';
|
|||||||
export * from './lib/alerts/alerts';
|
export * from './lib/alerts/alerts';
|
||||||
export * from './lib/ports/alert-integrations.repository';
|
export * from './lib/ports/alert-integrations.repository';
|
||||||
export * from './lib/alerts.service';
|
export * from './lib/alerts.service';
|
||||||
|
export * from './lib/follows/tree-follows';
|
||||||
|
export * from './lib/ports/tree-follows.repository';
|
||||||
|
export * from './lib/tree-follows.service';
|
||||||
export * from './lib/tracker-application.module';
|
export * from './lib/tracker-application.module';
|
||||||
export * from './lib/ports/presence.store';
|
export * from './lib/ports/presence.store';
|
||||||
export * from './lib/presence.service';
|
export * from './lib/presence.service';
|
||||||
|
|||||||
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,
|
CommentAddedEvent,
|
||||||
IssueAssignedEvent,
|
IssueAssignedEvent,
|
||||||
IssueCreatedEvent,
|
IssueCreatedEvent,
|
||||||
|
IssueParentChangedEvent,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
IssueSprintChangedEvent,
|
IssueSprintChangedEvent,
|
||||||
IssueStatusChangedEvent,
|
IssueStatusChangedEvent,
|
||||||
@@ -24,6 +25,7 @@ import { FindCommentsRepository } from '../ports/find-comments.repository';
|
|||||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||||
|
import { TreeFollowsRepository } from '../ports/tree-follows.repository';
|
||||||
import { UserDirectory } from '../ports/user-directory';
|
import { UserDirectory } from '../ports/user-directory';
|
||||||
import { NotificationDraft, Notifier } from './notifier';
|
import { NotificationDraft, Notifier } from './notifier';
|
||||||
|
|
||||||
@@ -61,10 +63,12 @@ const excerpt = (text: string, max = 120) => {
|
|||||||
*/
|
*/
|
||||||
@EventsHandler(
|
@EventsHandler(
|
||||||
IssueCreatedEvent,
|
IssueCreatedEvent,
|
||||||
|
IssueParentChangedEvent,
|
||||||
IssueAssignedEvent,
|
IssueAssignedEvent,
|
||||||
IssueStatusChangedEvent,
|
IssueStatusChangedEvent,
|
||||||
IssueUpdatedEvent,
|
IssueUpdatedEvent,
|
||||||
IssueSprintChangedEvent,
|
IssueSprintChangedEvent,
|
||||||
|
IssueParentChangedEvent,
|
||||||
CommentAddedEvent,
|
CommentAddedEvent,
|
||||||
ProjectMemberAddedEvent,
|
ProjectMemberAddedEvent,
|
||||||
)
|
)
|
||||||
@@ -76,6 +80,7 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
private readonly users: UserDirectory,
|
private readonly users: UserDirectory,
|
||||||
private readonly findComments: FindCommentsRepository,
|
private readonly findComments: FindCommentsRepository,
|
||||||
private readonly findSprints: FindSprintsRepository,
|
private readonly findSprints: FindSprintsRepository,
|
||||||
|
private readonly treeFollows: TreeFollowsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async handle(event: TrackerEvent): Promise<void> {
|
async handle(event: TrackerEvent): Promise<void> {
|
||||||
@@ -118,9 +123,23 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
const assigneeId =
|
const assigneeId =
|
||||||
'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null);
|
'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null);
|
||||||
const ticket = `${issue.key}: ${issue.title}`;
|
const ticket = `${issue.key}: ${issue.title}`;
|
||||||
const watcherIds =
|
|
||||||
'watchers' in issue ? issue.watchers.map((w) => w.id) : [];
|
|
||||||
const memberIds = new Set(project.members.map((m) => m.user.id));
|
const memberIds = new Set(project.members.map((m) => m.user.id));
|
||||||
|
// Who follows it with everything under it, or any ticket above it.
|
||||||
|
// Placed under another ticket: its new place counts (the read model may
|
||||||
|
// not have it yet).
|
||||||
|
const parentId =
|
||||||
|
event instanceof IssueParentChangedEvent
|
||||||
|
? event.parentId
|
||||||
|
: 'parent' in issue
|
||||||
|
? (issue.parent?.id ?? null)
|
||||||
|
: null;
|
||||||
|
const tree = await this.treeFollowersOf(issue.id, parentId, project);
|
||||||
|
const watcherIds = [
|
||||||
|
...new Set([
|
||||||
|
...('watchers' in issue ? issue.watchers.map((w) => w.id) : []),
|
||||||
|
...tree.followers,
|
||||||
|
]),
|
||||||
|
];
|
||||||
|
|
||||||
const drafts: NotificationDraft[] = [];
|
const drafts: NotificationDraft[] = [];
|
||||||
// A proposal from the public form: whoever runs the project should know.
|
// A proposal from the public form: whoever runs the project should know.
|
||||||
@@ -142,6 +161,16 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// A ticket new under a followed one (created there or moved in): the
|
||||||
|
// only way to hear of it.
|
||||||
|
if (event instanceof IssueParentChangedEvent && event.parentId) {
|
||||||
|
for (const userId of tree.followers)
|
||||||
|
drafts.push({
|
||||||
|
userId,
|
||||||
|
kind: NotificationKind.WATCHED,
|
||||||
|
summary: `${actor} añadió ${ticket} a ${event.parentKey ?? tree.parentKey}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
if (event instanceof IssueCreatedEvent && assigneeId) {
|
if (event instanceof IssueCreatedEvent && assigneeId) {
|
||||||
drafts.push({
|
drafts.push({
|
||||||
userId: assigneeId,
|
userId: assigneeId,
|
||||||
@@ -271,6 +300,40 @@ export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Those following the ticket with its subtree, or any ticket above it
|
||||||
|
* (an epic, a parent), who can read it all: members that are not guests
|
||||||
|
* limited to their own tickets. Also the key of its parent.
|
||||||
|
*/
|
||||||
|
private async treeFollowersOf(
|
||||||
|
issueId: string,
|
||||||
|
parentId: string | null,
|
||||||
|
project: {
|
||||||
|
isPublic: boolean;
|
||||||
|
members: { user: { id: string }; role: ProjectRole }[];
|
||||||
|
},
|
||||||
|
): Promise<{ followers: string[]; parentKey: string | null }> {
|
||||||
|
const ids = [issueId];
|
||||||
|
let parentKey: string | null = null;
|
||||||
|
let next = parentId;
|
||||||
|
// A few levels: epic → story → subtask is as deep as trees go.
|
||||||
|
for (let depth = 0; next && depth < 5 && !ids.includes(next); depth++) {
|
||||||
|
ids.push(next);
|
||||||
|
const parent = await this.findIssues.findById(next);
|
||||||
|
parentKey ??= parent?.key ?? null;
|
||||||
|
next = parent?.parent?.id ?? null;
|
||||||
|
}
|
||||||
|
const readers = new Set(
|
||||||
|
project.members
|
||||||
|
.filter((m) => project.isPublic || m.role !== ProjectRole.GUEST)
|
||||||
|
.map((m) => m.user.id),
|
||||||
|
);
|
||||||
|
const followers = (await this.treeFollows.followersOf(ids)).filter((id) =>
|
||||||
|
readers.has(id),
|
||||||
|
);
|
||||||
|
return { followers, parentKey };
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What a follower should hear of a change of priority, due date or
|
* What a follower should hear of a change of priority, due date or
|
||||||
* sprint; null for the other edits (title, labels…), too small to tell.
|
* sprint; null for the other edits (title, labels…), too small to tell.
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
IssuePriority,
|
IssuePriority,
|
||||||
IssueSprintChangedEvent,
|
IssueSprintChangedEvent,
|
||||||
IssueStatusChangedEvent,
|
IssueStatusChangedEvent,
|
||||||
|
IssueParentChangedEvent,
|
||||||
IssueUpdatedEvent,
|
IssueUpdatedEvent,
|
||||||
NotificationKind,
|
NotificationKind,
|
||||||
NotificationReadModel,
|
NotificationReadModel,
|
||||||
@@ -23,6 +24,7 @@ import { FindProjectsRepository } from '../ports/find-projects.repository';
|
|||||||
import { NotificationMailer } from '../ports/notification-mailer';
|
import { NotificationMailer } from '../ports/notification-mailer';
|
||||||
import { NotificationsRepository } from '../ports/notifications.repository';
|
import { NotificationsRepository } from '../ports/notifications.repository';
|
||||||
import { NotificationsEventHandler } from './notifications.event-handler';
|
import { NotificationsEventHandler } from './notifications.event-handler';
|
||||||
|
import { TreeFollowsRepository } from '../ports/tree-follows.repository';
|
||||||
import { Notifier } from './notifier';
|
import { Notifier } from './notifier';
|
||||||
import {
|
import {
|
||||||
PushPayload,
|
PushPayload,
|
||||||
@@ -62,7 +64,12 @@ const at = <T extends TrackerEvent>(event: T, version = 2): T =>
|
|||||||
occurredAt: '2026-10-08T10:00:00.000Z',
|
occurredAt: '2026-10-08T10:00:00.000Z',
|
||||||
});
|
});
|
||||||
|
|
||||||
function setup(mailEnabled = false, pushEnabled = false) {
|
function setup(
|
||||||
|
mailEnabled = false,
|
||||||
|
pushEnabled = false,
|
||||||
|
/** Ticket id → who follows it with everything under it. */
|
||||||
|
follows: Record<string, string[]> = {},
|
||||||
|
) {
|
||||||
const repo = new InMemoryNotifications();
|
const repo = new InMemoryNotifications();
|
||||||
const published: NotificationReadModel[] = [];
|
const published: NotificationReadModel[] = [];
|
||||||
const mailed: string[] = [];
|
const mailed: string[] = [];
|
||||||
@@ -74,7 +81,9 @@ function setup(mailEnabled = false, pushEnabled = false) {
|
|||||||
.add('ana')
|
.add('ana')
|
||||||
.add('bob')
|
.add('bob')
|
||||||
.add('qa')
|
.add('qa')
|
||||||
.add('pm');
|
.add('pm')
|
||||||
|
.add('lia')
|
||||||
|
.add('gus');
|
||||||
const issue = {
|
const issue = {
|
||||||
id: 'i-1',
|
id: 'i-1',
|
||||||
key: 'WEB-1',
|
key: 'WEB-1',
|
||||||
@@ -85,7 +94,10 @@ function setup(mailEnabled = false, pushEnabled = false) {
|
|||||||
{ id: 'pm', name: 'PM' },
|
{ id: 'pm', name: 'PM' },
|
||||||
{ id: 'bob', name: 'Bob' },
|
{ id: 'bob', name: 'Bob' },
|
||||||
],
|
],
|
||||||
|
parent: { id: 'e-1', key: 'WEB-10', title: 'Checkout' },
|
||||||
};
|
};
|
||||||
|
/** The epic it hangs from. */
|
||||||
|
const epic = { id: 'e-1', key: 'WEB-10', title: 'Checkout', parent: null };
|
||||||
const notifier = new Notifier(
|
const notifier = new Notifier(
|
||||||
repo,
|
repo,
|
||||||
{
|
{
|
||||||
@@ -118,15 +130,23 @@ function setup(mailEnabled = false, pushEnabled = false) {
|
|||||||
);
|
);
|
||||||
const handler = new NotificationsEventHandler(
|
const handler = new NotificationsEventHandler(
|
||||||
notifier,
|
notifier,
|
||||||
{ findById: async () => issue } as unknown as FindIssuesRepository,
|
{
|
||||||
|
findById: async (id: string) => (id === 'e-1' ? epic : issue),
|
||||||
|
} as unknown as FindIssuesRepository,
|
||||||
{
|
{
|
||||||
findById: async () => ({
|
findById: async () => ({
|
||||||
id: 'p-1',
|
id: 'p-1',
|
||||||
key: 'WEB',
|
key: 'WEB',
|
||||||
name: 'Web',
|
name: 'Web',
|
||||||
members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({
|
isPublic: false,
|
||||||
|
members: ['ana', 'bob', 'qa', 'pm', 'lia', 'gus'].map((id) => ({
|
||||||
user: { id, name: id },
|
user: { id, name: id },
|
||||||
role: id === 'ana' ? ProjectRole.OWNER : ProjectRole.MEMBER,
|
role:
|
||||||
|
id === 'ana'
|
||||||
|
? ProjectRole.OWNER
|
||||||
|
: id === 'gus'
|
||||||
|
? ProjectRole.GUEST
|
||||||
|
: ProjectRole.MEMBER,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
} as unknown as FindProjectsRepository,
|
} as unknown as FindProjectsRepository,
|
||||||
@@ -142,11 +162,49 @@ function setup(mailEnabled = false, pushEnabled = false) {
|
|||||||
findById: async (id: string) =>
|
findById: async (id: string) =>
|
||||||
id === 's-14' ? { id, name: 'Sprint 14' } : null,
|
id === 's-14' ? { id, name: 'Sprint 14' } : null,
|
||||||
} as unknown as FindSprintsRepository,
|
} as unknown as FindSprintsRepository,
|
||||||
|
{
|
||||||
|
followersOf: async (ids: string[]) => [
|
||||||
|
...new Set(ids.flatMap((id) => follows[id] ?? [])),
|
||||||
|
],
|
||||||
|
} as unknown as TreeFollowsRepository,
|
||||||
);
|
);
|
||||||
return { repo, published, mailed, pushed, devices, forgotten, handler };
|
return { repo, published, mailed, pushed, devices, forgotten, handler };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('notifications', () => {
|
describe('notifications', () => {
|
||||||
|
it('tells whoever follows the epic of what happens to its tickets, not guests', async () => {
|
||||||
|
const { handler, repo } = setup(false, false, { 'e-1': ['lia', 'gus'] });
|
||||||
|
await handler.handle(
|
||||||
|
at(
|
||||||
|
new IssueStatusChangedEvent(
|
||||||
|
'ana',
|
||||||
|
'i-1',
|
||||||
|
'p-1',
|
||||||
|
'TODO',
|
||||||
|
'IN_PROGRESS',
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const lia = repo.all.filter((n) => n.userId === 'lia');
|
||||||
|
expect(lia.map((n) => n.kind)).toEqual([NotificationKind.WATCHED]);
|
||||||
|
expect(lia[0].summary).toContain('WEB-1');
|
||||||
|
// A guest only sees their own tickets: not told of the epic's.
|
||||||
|
expect(repo.all.some((n) => n.userId === 'gus')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tells whoever follows the epic of a ticket added to it', async () => {
|
||||||
|
const { handler, repo } = setup(false, false, { 'e-1': ['lia'] });
|
||||||
|
await handler.handle(
|
||||||
|
at(
|
||||||
|
new IssueParentChangedEvent('ana', 'i-1', 'p-1', 'e-1', 'WEB-10', null),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(repo.all.map((n) => [n.userId, n.summary])).toEqual([
|
||||||
|
['lia', expect.stringContaining('añadió WEB-1: Login roto a WEB-10')],
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it('tells whoever a ticket is assigned to, not whoever assigned it', async () => {
|
it('tells whoever a ticket is assigned to, not whoever assigned it', async () => {
|
||||||
const { handler, repo, published } = setup();
|
const { handler, repo, published } = setup();
|
||||||
await handler.handle(
|
await handler.handle(
|
||||||
|
|||||||
@@ -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 { MY_VIEWS } from './saved-filters/my-views';
|
||||||
import { PINS } from './saved-filters/pins';
|
import { PINS } from './saved-filters/pins';
|
||||||
import { ALERT_HANDLERS } from './alerts/alerts';
|
import { ALERT_HANDLERS } from './alerts/alerts';
|
||||||
|
import { TREE_FOLLOWS } from './follows/tree-follows';
|
||||||
|
import { TreeFollowsService } from './tree-follows.service';
|
||||||
import { AlertsService } from './alerts.service';
|
import { AlertsService } from './alerts.service';
|
||||||
import { PinsService } from './pins.service';
|
import { PinsService } from './pins.service';
|
||||||
import { SendDueRemindersCommandHandler } from './notifications/reminders';
|
import { SendDueRemindersCommandHandler } from './notifications/reminders';
|
||||||
@@ -84,6 +86,7 @@ const SERVICES = [
|
|||||||
SavedFiltersService,
|
SavedFiltersService,
|
||||||
PinsService,
|
PinsService,
|
||||||
AlertsService,
|
AlertsService,
|
||||||
|
TreeFollowsService,
|
||||||
SprintsService,
|
SprintsService,
|
||||||
GitService,
|
GitService,
|
||||||
WebhooksService,
|
WebhooksService,
|
||||||
@@ -116,6 +119,7 @@ const SERVICES = [
|
|||||||
...MY_VIEWS,
|
...MY_VIEWS,
|
||||||
...PINS,
|
...PINS,
|
||||||
...ALERT_HANDLERS,
|
...ALERT_HANDLERS,
|
||||||
|
...TREE_FOLLOWS,
|
||||||
...GIT_HANDLERS,
|
...GIT_HANDLERS,
|
||||||
...WEBHOOK_HANDLERS,
|
...WEBHOOK_HANDLERS,
|
||||||
...TRANSFER_HANDLERS,
|
...TRANSFER_HANDLERS,
|
||||||
|
|||||||
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',
|
PinnedIssue: 'PinnedIssue',
|
||||||
AlertIntegration: 'AlertIntegration',
|
AlertIntegration: 'AlertIntegration',
|
||||||
AlertTrace: 'AlertTrace',
|
AlertTrace: 'AlertTrace',
|
||||||
|
TreeFollow: 'TreeFollow',
|
||||||
Sprint: 'SprintReadModel',
|
Sprint: 'SprintReadModel',
|
||||||
GitIntegration: 'GitIntegration',
|
GitIntegration: 'GitIntegration',
|
||||||
OutgoingWebhook: 'OutgoingWebhook',
|
OutgoingWebhook: 'OutgoingWebhook',
|
||||||
@@ -360,6 +361,15 @@ export const AlertTraceSchema =
|
|||||||
SchemaFactory.createForClass(AlertTraceDocument);
|
SchemaFactory.createForClass(AlertTraceDocument);
|
||||||
AlertTraceSchema.index({ projectId: 1, fingerprint: 1 }, { unique: true });
|
AlertTraceSchema.index({ projectId: 1, fingerprint: 1 }, { unique: true });
|
||||||
|
|
||||||
|
@Schema({ ...LOOSE, collection: 'tree_follows' })
|
||||||
|
class TreeFollowDocument {
|
||||||
|
@Prop({ type: String }) userId!: string;
|
||||||
|
@Prop({ type: String, index: true }) issueId!: string;
|
||||||
|
}
|
||||||
|
export const TreeFollowSchema =
|
||||||
|
SchemaFactory.createForClass(TreeFollowDocument);
|
||||||
|
TreeFollowSchema.index({ userId: 1, issueId: 1 }, { unique: true });
|
||||||
|
|
||||||
@Schema({ ...LOOSE, collection: 'git_integrations' })
|
@Schema({ ...LOOSE, collection: 'git_integrations' })
|
||||||
class GitIntegrationDocument {
|
class GitIntegrationDocument {
|
||||||
/** The project's id. */
|
/** The project's id. */
|
||||||
@@ -465,6 +475,7 @@ export const READ_MODEL_SCHEMAS = [
|
|||||||
{ name: ReadModels.PinnedIssue, schema: PinnedIssueSchema },
|
{ name: ReadModels.PinnedIssue, schema: PinnedIssueSchema },
|
||||||
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
|
{ name: ReadModels.AlertIntegration, schema: AlertIntegrationSchema },
|
||||||
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
|
{ name: ReadModels.AlertTrace, schema: AlertTraceSchema },
|
||||||
|
{ name: ReadModels.TreeFollow, schema: TreeFollowSchema },
|
||||||
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
{ name: ReadModels.CalendarFeed, schema: CalendarFeedSchema },
|
||||||
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
|
{ name: ReadModels.DashboardShare, schema: DashboardShareSchema },
|
||||||
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
|
{ name: ReadModels.PushSubscription, schema: PushSubscriptionSchema },
|
||||||
|
|||||||
@@ -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,
|
MyViewsRepository,
|
||||||
PinsRepository,
|
PinsRepository,
|
||||||
AlertIntegrationsRepository,
|
AlertIntegrationsRepository,
|
||||||
|
TreeFollowsRepository,
|
||||||
GitIntegrationsRepository,
|
GitIntegrationsRepository,
|
||||||
OutgoingWebhooksRepository,
|
OutgoingWebhooksRepository,
|
||||||
AutomationsRepository,
|
AutomationsRepository,
|
||||||
@@ -82,6 +83,7 @@ import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
|||||||
import { MongoMyViewsRepository } from './mongo/my-views.repository';
|
import { MongoMyViewsRepository } from './mongo/my-views.repository';
|
||||||
import { MongoPinsRepository } from './mongo/pins.repository';
|
import { MongoPinsRepository } from './mongo/pins.repository';
|
||||||
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
|
import { MongoAlertIntegrationsRepository } from './mongo/alert-integrations.repository';
|
||||||
|
import { MongoTreeFollowsRepository } from './mongo/tree-follows.repository';
|
||||||
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
import { MongoGitIntegrationsRepository } from './mongo/git-integrations.repository';
|
||||||
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
import { MongoOutgoingWebhooksRepository } from './mongo/outgoing-webhooks.repository';
|
||||||
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
|
import { HttpWebhookSender } from './webhooks/http-webhook.sender';
|
||||||
@@ -163,6 +165,7 @@ const PORTS = [
|
|||||||
provide: AlertIntegrationsRepository,
|
provide: AlertIntegrationsRepository,
|
||||||
useClass: MongoAlertIntegrationsRepository,
|
useClass: MongoAlertIntegrationsRepository,
|
||||||
},
|
},
|
||||||
|
{ provide: TreeFollowsRepository, useClass: MongoTreeFollowsRepository },
|
||||||
{
|
{
|
||||||
provide: GitIntegrationsRepository,
|
provide: GitIntegrationsRepository,
|
||||||
useClass: MongoGitIntegrationsRepository,
|
useClass: MongoGitIntegrationsRepository,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
CommentsService,
|
CommentsService,
|
||||||
IssuesService,
|
IssuesService,
|
||||||
PinsService,
|
PinsService,
|
||||||
|
TreeFollowsService,
|
||||||
PresenceService,
|
PresenceService,
|
||||||
WorkflowsService,
|
WorkflowsService,
|
||||||
} from '@task-stream/api/tracker/application';
|
} from '@task-stream/api/tracker/application';
|
||||||
@@ -63,6 +64,7 @@ export class IssueResolver {
|
|||||||
private readonly blockers: BlockersService,
|
private readonly blockers: BlockersService,
|
||||||
private readonly presence: PresenceService,
|
private readonly presence: PresenceService,
|
||||||
private readonly pins: PinsService,
|
private readonly pins: PinsService,
|
||||||
|
private readonly treeFollows: TreeFollowsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Mutation(() => [PresenceGql], {
|
@Mutation(() => [PresenceGql], {
|
||||||
@@ -280,6 +282,17 @@ export class IssueResolver {
|
|||||||
return this.commentsService.decisions(viewerOf(me), issue.id);
|
return this.commentsService.decisions(viewerOf(me), issue.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => Boolean, {
|
||||||
|
description:
|
||||||
|
'Whether I follow it with everything under it (false without a token)',
|
||||||
|
})
|
||||||
|
followingTree(
|
||||||
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Parent() issue: IssueReadModel,
|
||||||
|
): Promise<boolean> | boolean {
|
||||||
|
return !!me && this.treeFollows.isFollowing(me.id, issue.id);
|
||||||
|
}
|
||||||
|
|
||||||
@ResolveField(() => Boolean, {
|
@ResolveField(() => Boolean, {
|
||||||
description: 'Whether I pinned it (false without a token)',
|
description: 'Whether I pinned it (false without a token)',
|
||||||
})
|
})
|
||||||
@@ -391,6 +404,29 @@ export class IssueResolver {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean, {
|
||||||
|
description:
|
||||||
|
'Follow a ticket with everything under it (an epic): its changes and those of every ticket hanging from it, also future ones',
|
||||||
|
})
|
||||||
|
async followTree(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('issueId', { type: () => ID }) issueId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
await this.treeFollows.follow(me.id, issueId, true);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => Boolean, {
|
||||||
|
description: 'Stop following a ticket with everything under it',
|
||||||
|
})
|
||||||
|
async unfollowTree(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('issueId', { type: () => ID }) issueId: string,
|
||||||
|
): Promise<boolean> {
|
||||||
|
await this.treeFollows.follow(me.id, issueId, false);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
|
@Mutation(() => CommandResultGql, { description: 'Stop following a ticket' })
|
||||||
unwatchIssue(
|
unwatchIssue(
|
||||||
@CurrentUser() me: User,
|
@CurrentUser() me: User,
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ import {
|
|||||||
CommentsService,
|
CommentsService,
|
||||||
IssuesService,
|
IssuesService,
|
||||||
PinsService,
|
PinsService,
|
||||||
|
TreeFollowsService,
|
||||||
PresenceService,
|
PresenceService,
|
||||||
ProjectsService,
|
ProjectsService,
|
||||||
} from '@task-stream/api/tracker/application';
|
} from '@task-stream/api/tracker/application';
|
||||||
@@ -99,6 +100,7 @@ export class IssuesController {
|
|||||||
private readonly presence: PresenceService,
|
private readonly presence: PresenceService,
|
||||||
private readonly projects: ProjectsService,
|
private readonly projects: ProjectsService,
|
||||||
private readonly pins: PinsService,
|
private readonly pins: PinsService,
|
||||||
|
private readonly treeFollows: TreeFollowsService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get('me/home')
|
@Get('me/home')
|
||||||
@@ -203,6 +205,38 @@ export class IssuesController {
|
|||||||
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
|
await this.pins.pin(me.id, await this.refs.issueId(me.id, issue), false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('issues/:issue/follow-tree')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ISSUE
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Follow a ticket with everything under it (an epic)',
|
||||||
|
description:
|
||||||
|
'Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.',
|
||||||
|
})
|
||||||
|
@ApiNoContentResponse()
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async followTree(@CurrentUser() me: User, @Param('issue') issue: string) {
|
||||||
|
await this.treeFollows.follow(
|
||||||
|
me.id,
|
||||||
|
await this.refs.issueId(me.id, issue),
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('issues/:issue/follow-tree')
|
||||||
|
@HttpCode(204)
|
||||||
|
@ISSUE
|
||||||
|
@ApiOperation({ summary: 'Stop following a ticket with everything under it' })
|
||||||
|
@ApiNoContentResponse()
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async unfollowTree(@CurrentUser() me: User, @Param('issue') issue: string) {
|
||||||
|
await this.treeFollows.follow(
|
||||||
|
me.id,
|
||||||
|
await this.refs.issueId(me.id, issue),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Put('issues/:issue/watch')
|
@Put('issues/:issue/watch')
|
||||||
@ISSUE
|
@ISSUE
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ export const issuePageEvents = eventGroup({
|
|||||||
}>(),
|
}>(),
|
||||||
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||||
pinRequested: type<{ issueId: string; pinned: boolean }>(),
|
pinRequested: type<{ issueId: string; pinned: boolean }>(),
|
||||||
|
followTreeRequested: type<{ issueId: string; on: boolean }>(),
|
||||||
trashRequested: type<{
|
trashRequested: type<{
|
||||||
issueId: string;
|
issueId: string;
|
||||||
trashed: boolean;
|
trashed: boolean;
|
||||||
|
|||||||
@@ -120,6 +120,13 @@ export class IssueFacade {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Follow the ticket with everything under it (an epic), or stop. */
|
||||||
|
setFollowingTree(on: boolean): void {
|
||||||
|
this.withIssue((issueId) =>
|
||||||
|
this.dispatch.followTreeRequested({ issueId, on }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Pin the ticket for me (or unpin it): at hand on my home page and in search. */
|
/** Pin the ticket for me (or unpin it): at hand on my home page and in search. */
|
||||||
setPinned(pinned: boolean): void {
|
setPinned(pinned: boolean): void {
|
||||||
this.withIssue((issueId) =>
|
this.withIssue((issueId) =>
|
||||||
|
|||||||
@@ -379,6 +379,20 @@ export const IssueStore = signalStore(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
events
|
||||||
|
.on(issuePageEvents.followTreeRequested)
|
||||||
|
.pipe(
|
||||||
|
mergeMap(({ payload }) =>
|
||||||
|
runCommand(
|
||||||
|
issues.followTree(payload.issueId, payload.on),
|
||||||
|
payload.on
|
||||||
|
? 'Ahora sigues este ticket con todo lo que cuelga de él'
|
||||||
|
: 'Ya no sigues lo que cuelga de este ticket',
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
events
|
events
|
||||||
.on(issuePageEvents.pinRequested)
|
.on(issuePageEvents.pinRequested)
|
||||||
.pipe(
|
.pipe(
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ export interface Epic {
|
|||||||
progress: ChildProgress;
|
progress: ChildProgress;
|
||||||
/** Its relations: «bloquea» / «depende de» are the roadmap's arrows. */
|
/** Its relations: «bloquea» / «depende de» are the roadmap's arrows. */
|
||||||
links: IssueLink[];
|
links: IssueLink[];
|
||||||
|
/** Whether I follow it with everything under it. */
|
||||||
|
followingTree: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What a saved view narrows the board to; empty: everything. */
|
/** What a saved view narrows the board to; empty: everything. */
|
||||||
@@ -192,6 +194,8 @@ export interface Issue extends IssueSummary {
|
|||||||
watchers: UserRef[];
|
watchers: UserRef[];
|
||||||
/** Whether I keep it pinned (home page and search). */
|
/** Whether I keep it pinned (home page and search). */
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
|
/** Whether I follow it with everything under it (an epic). */
|
||||||
|
followingTree: boolean;
|
||||||
/** Its comments marked as decisions, oldest first. */
|
/** Its comments marked as decisions, oldest first. */
|
||||||
decisions: Comment[];
|
decisions: Comment[];
|
||||||
checklist: ChecklistItem[];
|
checklist: ChecklistItem[];
|
||||||
|
|||||||
@@ -148,6 +148,8 @@ export abstract class IssuesGateway {
|
|||||||
abstract pins(): Observable<IssueSummary[]>;
|
abstract pins(): Observable<IssueSummary[]>;
|
||||||
/** Pins the ticket for me (or unpins it): private, at most 20. */
|
/** Pins the ticket for me (or unpins it): private, at most 20. */
|
||||||
abstract pin(issueId: string, pinned: boolean): Observable<void>;
|
abstract pin(issueId: string, pinned: boolean): Observable<void>;
|
||||||
|
/** Follows the ticket with everything under it (or stops). */
|
||||||
|
abstract followTree(issueId: string, on: boolean): Observable<void>;
|
||||||
/**
|
/**
|
||||||
* Decisions (comments marked as such) of all my projects by their text,
|
* Decisions (comments marked as such) of all my projects by their text,
|
||||||
* most recent first: here and not in CommentsGateway, for the palette.
|
* most recent first: here and not in CommentsGateway, for the palette.
|
||||||
|
|||||||
@@ -51,6 +51,8 @@ import {
|
|||||||
VoteIssueDocument,
|
VoteIssueDocument,
|
||||||
WatchIssueDocument,
|
WatchIssueDocument,
|
||||||
MyPinsDocument,
|
MyPinsDocument,
|
||||||
|
FollowTreeDocument,
|
||||||
|
UnfollowTreeDocument,
|
||||||
SearchDecisionsDocument,
|
SearchDecisionsDocument,
|
||||||
PinIssueDocument,
|
PinIssueDocument,
|
||||||
UpdateChecklistItemDocument,
|
UpdateChecklistItemDocument,
|
||||||
@@ -218,6 +220,13 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
followTree(issueId: string, on: boolean): Observable<void> {
|
||||||
|
const done$: Observable<unknown> = on
|
||||||
|
? this.gql.mutate(FollowTreeDocument, { issueId })
|
||||||
|
: this.gql.mutate(UnfollowTreeDocument, { issueId });
|
||||||
|
return done$.pipe(map(() => undefined));
|
||||||
|
}
|
||||||
|
|
||||||
pin(issueId: string, pinned: boolean): Observable<void> {
|
pin(issueId: string, pinned: boolean): Observable<void> {
|
||||||
const done$: Observable<unknown> = pinned
|
const done$: Observable<unknown> = pinned
|
||||||
? this.gql.mutate(PinIssueDocument, { issueId })
|
? this.gql.mutate(PinIssueDocument, { issueId })
|
||||||
@@ -259,6 +268,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
|||||||
issue: toIssueSummary(epic.issue),
|
issue: toIssueSummary(epic.issue),
|
||||||
progress: { ...epic.progress },
|
progress: { ...epic.progress },
|
||||||
links: epic.issue.links.map((l) => toIssueLink(l)),
|
links: epic.issue.links.map((l) => toIssueLink(l)),
|
||||||
|
followingTree: epic.issue.followingTree,
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -85,6 +85,8 @@ export class GraphqlProjectsGateway extends ProjectsGateway {
|
|||||||
issue: toIssueSummary(e.issue),
|
issue: toIssueSummary(e.issue),
|
||||||
progress: { ...e.progress },
|
progress: { ...e.progress },
|
||||||
links: [],
|
links: [],
|
||||||
|
// The portfolio does not offer following.
|
||||||
|
followingTree: false,
|
||||||
})),
|
})),
|
||||||
})),
|
})),
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -121,6 +121,7 @@ fragment IssueSummaryFields on Issue {
|
|||||||
fragment IssueFields on Issue {
|
fragment IssueFields on Issue {
|
||||||
...IssueSummaryFields
|
...IssueSummaryFields
|
||||||
pinned
|
pinned
|
||||||
|
followingTree
|
||||||
decisions {
|
decisions {
|
||||||
...CommentFields
|
...CommentFields
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -688,6 +688,7 @@ export type IssueSummaryFieldsFragment = {
|
|||||||
|
|
||||||
export type IssueFieldsFragment = {
|
export type IssueFieldsFragment = {
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
|
followingTree: boolean;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
figmaUrl: string | null;
|
figmaUrl: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -933,6 +934,7 @@ export type IssueQueryVariables = Exact<{
|
|||||||
export type IssueQuery = {
|
export type IssueQuery = {
|
||||||
issue: {
|
issue: {
|
||||||
pinned: boolean;
|
pinned: boolean;
|
||||||
|
followingTree: boolean;
|
||||||
description: string | null;
|
description: string | null;
|
||||||
figmaUrl: string | null;
|
figmaUrl: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -1151,6 +1153,18 @@ export type MyPinsQuery = {
|
|||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type FollowTreeMutationVariables = Exact<{
|
||||||
|
issueId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type FollowTreeMutation = { followTree: boolean };
|
||||||
|
|
||||||
|
export type UnfollowTreeMutationVariables = Exact<{
|
||||||
|
issueId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type UnfollowTreeMutation = { unfollowTree: boolean };
|
||||||
|
|
||||||
export type PinIssueMutationVariables = Exact<{
|
export type PinIssueMutationVariables = Exact<{
|
||||||
issueId: string;
|
issueId: string;
|
||||||
}>;
|
}>;
|
||||||
@@ -1321,6 +1335,7 @@ export type EpicsQueryVariables = Exact<{
|
|||||||
export type EpicsQuery = {
|
export type EpicsQuery = {
|
||||||
epics: Array<{
|
epics: Array<{
|
||||||
issue: {
|
issue: {
|
||||||
|
followingTree: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
key: string;
|
key: string;
|
||||||
@@ -3820,6 +3835,7 @@ export const IssueFieldsFragmentDoc = {
|
|||||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||||
},
|
},
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } },
|
||||||
{
|
{
|
||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'decisions' },
|
name: { kind: 'Name', value: 'decisions' },
|
||||||
@@ -6420,6 +6436,7 @@ export const IssueDocument = {
|
|||||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||||
},
|
},
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
|
{ kind: 'Field', name: { kind: 'Name', value: 'pinned' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'followingTree' } },
|
||||||
{
|
{
|
||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'decisions' },
|
name: { kind: 'Name', value: 'decisions' },
|
||||||
@@ -7452,6 +7469,93 @@ export const MyPinsDocument = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>;
|
} as unknown as DocumentNode<MyPinsQuery, MyPinsQueryVariables>;
|
||||||
|
export const FollowTreeDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'FollowTree' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'followTree' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<FollowTreeMutation, FollowTreeMutationVariables>;
|
||||||
|
export const UnfollowTreeDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'UnfollowTree' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'unfollowTree' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
UnfollowTreeMutation,
|
||||||
|
UnfollowTreeMutationVariables
|
||||||
|
>;
|
||||||
export const PinIssueDocument = {
|
export const PinIssueDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -8633,6 +8737,10 @@ export const EpicsDocument = {
|
|||||||
kind: 'FragmentSpread',
|
kind: 'FragmentSpread',
|
||||||
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'followingTree' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'links' },
|
name: { kind: 'Name', value: 'links' },
|
||||||
|
|||||||
@@ -101,6 +101,14 @@ query MyPins {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mutation FollowTree($issueId: ID!) {
|
||||||
|
followTree(issueId: $issueId)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation UnfollowTree($issueId: ID!) {
|
||||||
|
unfollowTree(issueId: $issueId)
|
||||||
|
}
|
||||||
|
|
||||||
mutation PinIssue($issueId: ID!) {
|
mutation PinIssue($issueId: ID!) {
|
||||||
pinIssue(issueId: $issueId)
|
pinIssue(issueId: $issueId)
|
||||||
}
|
}
|
||||||
@@ -185,6 +193,7 @@ query Epics($projectId: ID!) {
|
|||||||
epics(projectId: $projectId) {
|
epics(projectId: $projectId) {
|
||||||
issue {
|
issue {
|
||||||
...IssueSummaryFields
|
...IssueSummaryFields
|
||||||
|
followingTree
|
||||||
links {
|
links {
|
||||||
type
|
type
|
||||||
direction
|
direction
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import {
|
import {
|
||||||
ChangeDetectionStrategy,
|
ChangeDetectionStrategy,
|
||||||
Component,
|
Component,
|
||||||
|
DestroyRef,
|
||||||
effect,
|
effect,
|
||||||
inject,
|
inject,
|
||||||
|
signal,
|
||||||
untracked,
|
untracked,
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
PiEmptyStateComponent,
|
PiEmptyStateComponent,
|
||||||
@@ -17,7 +20,8 @@ import {
|
|||||||
ProjectFacade,
|
ProjectFacade,
|
||||||
provideEpicsFacade,
|
provideEpicsFacade,
|
||||||
} from '@task-stream/web/tracker/application';
|
} from '@task-stream/web/tracker/application';
|
||||||
import { Epic } from '@task-stream/web/tracker/domain';
|
import { ToastService } from '@task-stream/web/shared/ui';
|
||||||
|
import { Epic, IssuesGateway } from '@task-stream/web/tracker/domain';
|
||||||
import { statusLook, typeLook } from '../ui/copy';
|
import { statusLook, typeLook } from '../ui/copy';
|
||||||
|
|
||||||
/** /p/:key/epics — the big pieces of work and how far each has got. */
|
/** /p/:key/epics — the big pieces of work and how far each has got. */
|
||||||
@@ -69,6 +73,22 @@ import { statusLook, typeLook } from '../ui/copy';
|
|||||||
<span class="ts-pill" [style.--tone]="statusTone(epic)">{{
|
<span class="ts-pill" [style.--tone]="statusTone(epic)">{{
|
||||||
statusLabel(epic)
|
statusLabel(epic)
|
||||||
}}</span>
|
}}</span>
|
||||||
|
@if (project.myRole()) {
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="ts-epic__follow"
|
||||||
|
[class.is-on]="following(epic)"
|
||||||
|
[attr.aria-pressed]="following(epic)"
|
||||||
|
[attr.title]="
|
||||||
|
following(epic)
|
||||||
|
? 'Dejar de seguir esta épica y sus tickets'
|
||||||
|
: 'Avisarme de esta épica y de todos sus tickets, también los que se añadan'
|
||||||
|
"
|
||||||
|
(click)="toggleFollow(epic)"
|
||||||
|
>
|
||||||
|
{{ following(epic) ? 'Siguiendo todo' : 'Seguir todo' }}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</header>
|
</header>
|
||||||
<pi-progress-bar
|
<pi-progress-bar
|
||||||
[label]="summary(epic)"
|
[label]="summary(epic)"
|
||||||
@@ -86,6 +106,31 @@ import { statusLook, typeLook } from '../ui/copy';
|
|||||||
}
|
}
|
||||||
`,
|
`,
|
||||||
styles: `
|
styles: `
|
||||||
|
/* A small pill, not @pi/ui's sized button. */
|
||||||
|
.ts-epic__follow {
|
||||||
|
flex: none;
|
||||||
|
height: auto !important;
|
||||||
|
min-width: 0 !important;
|
||||||
|
padding: 0.1875rem 0.625rem !important;
|
||||||
|
border: 1px solid var(--ts-line-strong);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--ts-ink-soft);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
border-color: var(--ts-brand);
|
||||||
|
color: var(--ts-ink);
|
||||||
|
}
|
||||||
|
&.is-on {
|
||||||
|
border-color: transparent;
|
||||||
|
background: color-mix(in srgb, var(--ts-brand) 14%, transparent);
|
||||||
|
color: var(--ts-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
.ts-epics {
|
.ts-epics {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
@@ -120,7 +165,12 @@ import { statusLook, typeLook } from '../ui/copy';
|
|||||||
})
|
})
|
||||||
export class EpicsPage {
|
export class EpicsPage {
|
||||||
protected readonly facade = inject(EpicsFacade);
|
protected readonly facade = inject(EpicsFacade);
|
||||||
private readonly project = inject(ProjectFacade);
|
protected readonly project = inject(ProjectFacade);
|
||||||
|
private readonly issues = inject(IssuesGateway);
|
||||||
|
private readonly toast = inject(ToastService);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
/** Changed here since they loaded: epic id → following. */
|
||||||
|
private readonly changed = signal<ReadonlyMap<string, boolean>>(new Map());
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
@@ -129,6 +179,32 @@ export class EpicsPage {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
protected following(epic: Epic): boolean {
|
||||||
|
return this.changed().get(epic.issue.id) ?? epic.followingTree;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected toggleFollow(epic: Epic): void {
|
||||||
|
const on = !this.following(epic);
|
||||||
|
const set = (value: boolean) =>
|
||||||
|
this.changed.update((m) => new Map(m).set(epic.issue.id, value));
|
||||||
|
set(on);
|
||||||
|
this.issues
|
||||||
|
.followTree(epic.issue.id, on)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe({
|
||||||
|
next: () =>
|
||||||
|
this.toast.success(
|
||||||
|
on
|
||||||
|
? `Sigues ${epic.issue.key} y todo lo que cuelga de ella`
|
||||||
|
: `Ya no sigues ${epic.issue.key}`,
|
||||||
|
),
|
||||||
|
error: () => {
|
||||||
|
set(!on);
|
||||||
|
this.toast.error('No se pudo cambiar');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
protected summary(epic: Epic): string {
|
protected summary(epic: Epic): string {
|
||||||
const { finished, total, percent, storyPoints, finishedStoryPoints } =
|
const { finished, total, percent, storyPoints, finishedStoryPoints } =
|
||||||
epic.progress;
|
epic.progress;
|
||||||
|
|||||||
@@ -206,6 +206,28 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
(execute)="facade.setWatching(!facade.watching())"
|
(execute)="facade.setWatching(!facade.watching())"
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
|
@if (
|
||||||
|
project.myRole() &&
|
||||||
|
(facade.children().length || issue.followingTree)
|
||||||
|
) {
|
||||||
|
<pi-icon-button
|
||||||
|
icon="kanban"
|
||||||
|
color="grey"
|
||||||
|
appearance="outline"
|
||||||
|
[text]="
|
||||||
|
issue.followingTree
|
||||||
|
? 'Siguiendo todo · ' + facade.children().length
|
||||||
|
: 'Seguir todo'
|
||||||
|
"
|
||||||
|
[attr.aria-pressed]="issue.followingTree"
|
||||||
|
[attr.title]="
|
||||||
|
issue.followingTree
|
||||||
|
? 'Dejar de seguir lo que cuelga de este ticket'
|
||||||
|
: 'Avisarme de este ticket y de todo lo que cuelga de él, también lo que se añada'
|
||||||
|
"
|
||||||
|
(execute)="facade.setFollowingTree(!issue.followingTree)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
@if (project.myRole()) {
|
@if (project.myRole()) {
|
||||||
<pi-icon-button
|
<pi-icon-button
|
||||||
icon="pin"
|
icon="pin"
|
||||||
|
|||||||
Reference in New Issue
Block a user