From 70e4fbd39994f5e99aa99d28f5e60d81f4e4bd3b Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Sat, 17 Oct 2026 11:32:07 +0200 Subject: [PATCH] feat: comments marked as decisions, on top of the ticket and searchable Whoever can change the ticket flags a comment as a decision (new event CommentDecisionMarkedEvent on the comment). The ticket page lists its decisions above the description, with a link to the comment in the conversation (`?comment=` scrolls to it and points at it), and the search palette finds them by their text across my projects. API: markDecision / PUT-DELETE /api/comments/{id}/decision; Issue.decisions and GET /api/issues/{issue}/decisions; decisions(projectId?, search) and GET /api/projects/{project}/decisions, GET /api/me/decisions. MCP: mark_decision, search_decisions, and get_issue includes them. Co-Authored-By: Claude Opus 5.5 --- apps/api/openapi.json | 518 +++++++++++++++ apps/api/schema.gql | 26 + apps/mcp/src/app/api/taskstream.schema.ts | 411 ++++++++++++ apps/mcp/src/app/tools/issues.resolver.ts | 76 ++- docs/01-vision-general.md | 82 +-- docs/03-api.md | 10 +- docs/04-cliente-web.md | 2 +- docs/09-mcp.md | 4 +- .../src/lib/commands/command-handlers.ts | 2 + .../src/lib/commands/commands.spec.ts | 27 + .../commands/mark-decision.command-handler.ts | 20 + .../src/lib/commands/mark-decision.command.ts | 13 + .../application/src/lib/comments.service.ts | 39 +- .../comment-added.event-handler.ts | 3 + .../comment-decision-marked.event-handler.ts | 30 + .../src/lib/event-handlers/event-handlers.ts | 2 + .../src/lib/ports/find-comments.repository.ts | 10 + .../src/lib/queries/decisions.query.ts | 112 ++++ .../src/lib/queries/query-handlers.ts | 6 + .../tracker/domain/src/lib/comment/comment.ts | 25 + .../events/comment-decision-marked.event.ts | 17 + .../tracker/domain/src/lib/events/index.ts | 1 + .../src/lib/read-models/comment.read-model.ts | 11 + .../src/lib/mongo/find.repositories.ts | 25 + .../src/lib/mongo/read-model.schemas.ts | 4 + .../src/lib/graphql/comment.resolver.ts | 41 +- .../src/lib/graphql/issue.resolver.ts | 11 + .../presenters/src/lib/graphql/types.ts | 18 + .../src/lib/http/comments.controller.ts | 24 + .../src/lib/http/dto/requests.dto.ts | 16 + .../src/lib/http/dto/responses.dto.ts | 18 + .../src/lib/http/issues.controller.ts | 54 ++ .../src/lib/events/issue.events.ts | 1 + .../src/lib/facades/issue.facade.ts | 5 + .../application/src/lib/stores/issue.store.ts | 14 + .../tracker/domain/src/lib/models/models.ts | 12 + .../tracker/domain/src/lib/ports/gateways.ts | 14 + .../graphql-collaboration.gateways.ts | 7 + .../lib/adapters/graphql-issues.gateway.ts | 15 + .../src/lib/graphql/fragments.graphql | 7 + .../src/lib/graphql/generated.ts | 589 +++++++++++++++++- .../src/lib/graphql/issues.graphql | 19 + .../presenters/src/lib/pages/issue.page.scss | 103 +++ .../presenters/src/lib/pages/issue.page.ts | 93 ++- .../presenters/src/lib/ui/command-palette.ts | 62 +- 45 files changed, 2515 insertions(+), 84 deletions(-) create mode 100644 libs/api/tracker/application/src/lib/commands/mark-decision.command-handler.ts create mode 100644 libs/api/tracker/application/src/lib/commands/mark-decision.command.ts create mode 100644 libs/api/tracker/application/src/lib/event-handlers/comment-decision-marked.event-handler.ts create mode 100644 libs/api/tracker/application/src/lib/queries/decisions.query.ts create mode 100644 libs/api/tracker/domain/src/lib/events/comment-decision-marked.event.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 9ba892c..9c134aa 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -5908,6 +5908,290 @@ ] } }, + "/api/issues/{issue}/decisions": { + "get": { + "operationId": "IssuesController_issueDecisions", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/CommentDto" + } + } + } + } + }, + "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" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + }, + {} + ], + "summary": "Comments of the ticket marked as decisions (oldest first)", + "tags": [ + "Issues" + ] + } + }, + "/api/projects/{project}/decisions": { + "get": { + "operationId": "IssuesController_projectDecisions", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + }, + { + "name": "search", + "required": false, + "in": "query", + "description": "Words in the decision", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 100, + "default": 20, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecisionDto" + } + } + } + } + }, + "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" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + }, + {} + ], + "summary": "Search a project's decisions (most recent first)", + "tags": [ + "Issues" + ] + } + }, + "/api/me/decisions": { + "get": { + "operationId": "IssuesController_myDecisions", + "parameters": [ + { + "name": "search", + "required": false, + "in": "query", + "description": "Words in the decision", + "schema": { + "type": "string" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 100, + "default": 20, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/DecisionDto" + } + } + } + } + }, + "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" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + } + ], + "summary": "Search the decisions of all my projects (most recent first)", + "tags": [ + "Issues" + ] + } + }, "/api/issues/{issue}/history": { "get": { "operationId": "IssuesController_history", @@ -6632,6 +6916,203 @@ ] } }, + "/api/comments/{commentId}/decision": { + "put": { + "description": "Whoever can change the ticket. Decisions are shown above the ticket and searchable (GET …/decisions).", + "operationId": "CommentsController_markDecision", + "parameters": [ + { + "name": "commentId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "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": "Mark a comment as a decision of its ticket", + "tags": [ + "Comments" + ] + }, + "delete": { + "operationId": "CommentsController_unmarkDecision", + "parameters": [ + { + "name": "commentId", + "required": true, + "in": "path", + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CommandResultDto" + } + } + } + }, + "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": "It is no longer a decision", + "tags": [ + "Comments" + ] + } + }, "/api/me/releases": { "get": { "operationId": "ReleasesController_upcoming", @@ -15237,6 +15718,25 @@ "$ref": "#/components/schemas/CommentReactionDto" } }, + "decision": { + "type": "boolean", + "description": "Marked as a decision of the ticket (shown above it)" + }, + "decidedBy": { + "nullable": true, + "description": "Who marked it as a decision", + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/UserRefDto" + } + ] + }, + "decidedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, "createdAt": { "type": "string", "format": "date-time" @@ -15258,6 +15758,9 @@ "replyTo", "deleted", "reactions", + "decision", + "decidedBy", + "decidedAt", "createdAt", "editedAt", "version" @@ -15281,6 +15784,21 @@ "total" ] }, + "DecisionDto": { + "type": "object", + "properties": { + "comment": { + "$ref": "#/components/schemas/CommentDto" + }, + "issue": { + "$ref": "#/components/schemas/IssueParentDto" + } + }, + "required": [ + "comment", + "issue" + ] + }, "AddCommentDto": { "type": "object", "properties": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 3b3c256..50b94fe 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -270,6 +270,13 @@ type Comment { """Empty on a deleted comment kept for its replies""" body: String! createdAt: String! + decidedAt: String + + """Who marked it as a decision""" + decidedBy: UserRef + + """Marked as a decision of the ticket (shown above it)""" + decision: Boolean! """Deleted, but kept (without its text) while it has replies""" deleted: Boolean! @@ -475,6 +482,12 @@ type DashboardShare { token: String! } +"""A comment marked as a decision, with its ticket""" +type Decision { + comment: Comment! + issue: IssueParent! +} + """A commit, branch or pull request that mentions the ticket""" type DevLink { at: String! @@ -640,6 +653,9 @@ type Issue { """Values of the project's custom fields (only those set)""" customFields: [CustomFieldValue!]! + + """Its comments marked as decisions, oldest first""" + decisions: [Comment!]! description: String """Commits, branches and pull requests that mention it""" @@ -973,6 +989,11 @@ type Mutation { """Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE""" linkIssues(input: LinkIssuesInput!): CommandResult! + """ + Mark a comment as a decision of its ticket, or unmark it (on: false). Whoever can change the ticket + """ + markDecision(commentId: ID!, on: Boolean! = true): CommandResult! + """Mark these as read, or all of them when `ids` is omitted""" markNotificationsRead(ids: [ID!]): Boolean! @@ -1491,6 +1512,11 @@ type Query { """The project's link for clients, if any (owners and admins)""" dashboardShare(projectId: ID!): DashboardShare + """ + Decisions by their text, most recent first: of a project, or of all my projects without one + """ + decisions(limit: Int! = 20, projectId: ID, search: String): [Decision!]! + """ A project's epics: top-level tickets that group others, with the progress of those """ diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 3d9779e..ea93372 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -885,6 +885,57 @@ export interface paths { patch?: never; trace?: never; }; + "/api/issues/{issue}/decisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Comments of the ticket marked as decisions (oldest first) */ + get: operations["IssuesController_issueDecisions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/projects/{project}/decisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Search a project's decisions (most recent first) */ + get: operations["IssuesController_projectDecisions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/me/decisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Search the decisions of all my projects (most recent first) */ + get: operations["IssuesController_myDecisions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/issues/{issue}/history": { parameters: { query?: never; @@ -978,6 +1029,27 @@ export interface paths { patch?: never; trace?: never; }; + "/api/comments/{commentId}/decision": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Mark a comment as a decision of its ticket + * @description Whoever can change the ticket. Decisions are shown above the ticket and searchable (GET …/decisions). + */ + put: operations["CommentsController_markDecision"]; + post?: never; + /** It is no longer a decision */ + delete: operations["CommentsController_unmarkDecision"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/me/releases": { parameters: { query?: never; @@ -2929,6 +3001,12 @@ export interface components { deleted: boolean; /** @description In the order they are offered; only the ones somebody chose */ reactions: components["schemas"]["CommentReactionDto"][]; + /** @description Marked as a decision of the ticket (shown above it) */ + decision: boolean; + /** @description Who marked it as a decision */ + decidedBy: components["schemas"]["UserRefDto"] | null; + /** Format: date-time */ + decidedAt: string | null; /** Format: date-time */ createdAt: string; /** Format: date-time */ @@ -2939,6 +3017,10 @@ export interface components { items: components["schemas"]["CommentDto"][]; total: number; }; + DecisionDto: { + comment: components["schemas"]["CommentDto"]; + issue: components["schemas"]["IssueParentDto"]; + }; AddCommentDto: { /** @example Reproducido en Safari 17. */ body: string; @@ -7578,6 +7660,185 @@ export interface operations { }; }; }; + IssuesController_issueDecisions: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommentDto"][]; + }; + }; + /** @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"]; + }; + }; + }; + }; + IssuesController_projectDecisions: { + parameters: { + query?: { + /** @description Words in the decision */ + search?: string; + limit?: number; + }; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DecisionDto"][]; + }; + }; + /** @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"]; + }; + }; + }; + }; + IssuesController_myDecisions: { + parameters: { + query?: { + /** @description Words in the decision */ + search?: string; + limit?: number; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DecisionDto"][]; + }; + }; + /** @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"]; + }; + }; + }; + }; IssuesController_history: { parameters: { query?: { @@ -8087,6 +8348,156 @@ export interface operations { }; }; }; + CommentsController_markDecision: { + parameters: { + query?: never; + header?: never; + path: { + commentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + CommentsController_unmarkDecision: { + parameters: { + query?: never; + header?: never; + path: { + commentId: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["CommandResultDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; ReleasesController_upcoming: { parameters: { query?: { diff --git a/apps/mcp/src/app/tools/issues.resolver.ts b/apps/mcp/src/app/tools/issues.resolver.ts index c8b8dc7..d6f2d07 100644 --- a/apps/mcp/src/app/tools/issues.resolver.ts +++ b/apps/mcp/src/app/tools/issues.resolver.ts @@ -254,7 +254,7 @@ export class IssuesResolver { @Tool({ name: 'get_issue', description: - 'A ticket in full: description, parent, checklist (with item ids), links, commits and pull requests, followers, allowed next statuses, and optionally its comments and history.', + 'A ticket in full: description, parent, checklist (with item ids), links, commits and pull requests, followers, allowed next statuses, the comments marked as decisions, and optionally its comments and history.', paramsSchema: z.object({ issue: issueRef, withComments: z.boolean().default(true), @@ -289,8 +289,20 @@ export class IssuesResolver { emoji: r.emoji, by: r.users.map((u) => u.name), })), + decision: c.decision || undefined, })) : undefined; + const decisions = ( + await this.api.data( + client.GET('/api/issues/{issue}/decisions', { params: { path } }), + ) + ).map((c) => ({ + commentId: c.id, + by: c.author.name, + at: c.createdAt, + markedBy: c.decidedBy?.name ?? null, + body: c.body, + })); const history = params.withHistory ? ( await this.api.data( @@ -322,6 +334,7 @@ export class IssuesResolver { attachments: issue.attachments.length, trashedAt: issue.trashedAt, createdAt: issue.createdAt, + decisions, comments, history, }; @@ -734,6 +747,67 @@ export class IssuesResolver { ); } + @Tool({ + name: 'mark_decision', + description: + 'Mark a comment as a decision of its ticket (what was agreed), or unmark it (on: false). Whoever can change the ticket. Comment ids come with get_issue.', + paramsSchema: z.object({ + commentId: z.string().min(1), + on: z.boolean().default(true), + }), + annotations: { ...WRITE, idempotentHint: true }, + }) + markDecision( + { commentId, on }: { commentId: string; on: boolean }, + ctx: McpContext, + ) { + const client = this.api.as(ctx); + const options = { params: { path: { commentId } } }; + return toolResult(() => + this.api.data( + on + ? client.PUT('/api/comments/{commentId}/decision', options) + : client.DELETE('/api/comments/{commentId}/decision', options), + ), + ); + } + + @Tool({ + name: 'search_decisions', + description: + 'What was decided, by words in the decision: comments marked as decisions, most recent first, with their ticket. Of one project, or of all my projects without one.', + paramsSchema: z.object({ + project: projectRef.optional(), + search: z.string().max(200).optional(), + limit: limit(100, 20), + }), + annotations: READ, + }) + searchDecisions( + params: { project?: string; search?: string; limit: number }, + ctx: McpContext, + ) { + return toolResult(async () => { + const client = this.api.as(ctx); + const query = { search: params.search, limit: params.limit }; + const found = await this.api.data( + params.project + ? client.GET('/api/projects/{project}/decisions', { + params: { path: { project: params.project }, query }, + }) + : client.GET('/api/me/decisions', { params: { query } }), + ); + return found.map((d) => ({ + issue: d.issue.key, + title: d.issue.title, + commentId: d.comment.id, + by: d.comment.author.name, + at: d.comment.createdAt, + body: d.comment.body, + })); + }); + } + @Tool({ name: 'vote_issue', description: diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index ed706b1..4e78645 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -2,47 +2,47 @@ ## Qué resuelve -| Necesidad | Cómo se resuelve | -| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | -| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista | -| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre | -| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra | -| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | -| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | -| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | -| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | -| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar | -| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | -| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…) y `@menciones`; el autor edita, el admin modera | -| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | -| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar | -| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo | -| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media | -| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | -| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics | -| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | -| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo | -| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | -| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista | -| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras | -| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere | -| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) | -| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | -| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo | -| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo | -| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence | -| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes | -| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» | -| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha | -| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo | -| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint | -| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío | -| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear | -| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | -| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | -| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | -| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | +| Necesidad | Cómo se resuelve | +| --------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas | +| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista | +| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre | +| 🧩 Configuración | Cada proyecto tiene sus estados (con categoría, color y motivo obligatorio), transiciones, tipos de ticket y áreas; lo usado se archiva, no se borra | +| 🎨 Cambios de UI | Tipo `UI_CHANGE` con enlace al frame de Figma (validado contra `figma.com`) | +| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo | +| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo | +| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | +| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar | +| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | +| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con formato (editor visual, guardado en Markdown), capturas pegadas, respuestas, reacciones (👍 🎉…), decisiones (un comentario marcado sale encima del ticket y se encuentra al buscar) y `@menciones`; el autor edita, el admin modera | +| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | +| 📋 Lista | Todos los tickets en filas: ordenar por cualquier columna, filtrar, cambiar estado, prioridad o persona sin abrirlos, editar en bloque y duplicar | +| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo | +| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media | +| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | +| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics | +| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | +| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo | +| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta | +| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets, decisiones y proyectos de todos tus proyectos (antes de escribir, tus tickets fijados); `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista | +| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias y flechas entre las que dependen unas de otras | +| 🗳️ Propuestas y votos | Un proyecto público puede abrir un formulario para que cualquiera, sin cuenta, reporte un error o proponga una idea; quien tenga sesión vota lo que quiere | +| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) | +| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | +| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan y los ve juntos en «Que sigo»; quien lo abre, lo tiene asignado o comenta, lo sigue solo | +| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios, respuestas), en vivo y opcionalmente por correo | +| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence | +| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes | +| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA); lo que espera a otro ticket abierto sale marcado como «Bloqueado» | +| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha | +| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo | +| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint | +| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío | +| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear | +| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave | +| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket | +| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona | +| ⚡ Tiempo real | Tablero, comentarios, actividad, releases y membresías se actualizan solos (GraphQL subscriptions) | Los **usuarios no se gestionan aquí**: vienen del servidor de identidad **Pireon** ([Swagger](https://oauth.pireon.dev/api/documentation)). TaskStream gestiona proyectos, membresías y roles por diff --git a/docs/03-api.md b/docs/03-api.md index 6427fc4..0435867 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -238,6 +238,12 @@ stateDiagram-v2 - **Reacciones**: 👍 👎 😄 🎉 😕 ❤️ 🚀 👀 en los comentarios (`reactToComment(commentId, emoji, on)`, `POST /api/comments/{id}/reactions`); los miembros, también los lectores. Cada comentario da `reactions` con quién eligió cada una. No generan actividad ni avisos. +- **Decisiones**: quien puede cambiar el ticket marca un comentario como decisión (`markDecision(commentId, on)`, + `PUT/DELETE /api/comments/{id}/decision`; evento `CommentDecisionMarkedEvent`). El comentario da `decision`, + `decidedBy` y `decidedAt`; el ticket, `decisions` (más antiguas primero, `GET /api/issues/{issue}/decisions`), que la + web muestra encima de la descripción. Se buscan por su texto: `decisions(projectId?, search, limit)`, + `GET /api/projects/{project}/decisions` y, de todos mis proyectos, `GET /api/me/decisions` (más recientes primero; sin + las de la papelera y, para un invitado, solo las de sus tickets). - **Bloqueos**: un ticket espera a otro si ese otro lo bloquea (`BLOCKS`) o si él depende de ese otro (`DEPENDS_ON`), mientras el otro no esté hecho ni cancelado. `blockedBy` lo calcula al leer (los enlaces no guardan el estado del otro ticket), con una sola consulta por tablero o página. @@ -515,7 +521,7 @@ Todas devuelven `CommandResult { id, key, version }`. | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | 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` | +| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`; 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` | @@ -723,7 +729,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). | 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` | +| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`; `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` | diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index cabcae1..af2d217 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -305,7 +305,7 @@ visualización de la plataforma: | `/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) | -| `/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 | +| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=` lleva a un comentario y lo señala | | `/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 | diff --git a/docs/09-mcp.md b/docs/09-mcp.md index aba488a..111adce 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -54,7 +54,7 @@ flowchart LR | | `get_project_activity` | Actividad reciente, o la de un día (`day`) | | Tickets | `my_issues` | Mi trabajo en todos los proyectos (o de cualquiera, por categoría; o los que sigo); también por prioridad, solo bugs o vencidos | | | `search_issues` | Buscar por texto y filtros (épica, sprint, fechas, campos…); el backlog: `noSprint` y orden `RANK` | -| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, comentarios | +| | `get_issue` | Detalle: épica, lista de comprobación, relaciones, commits, seguidores, transiciones, decisiones y comentarios | | | `create_issue` | Crear un ticket (también colgado de una épica) | | | `update_issue` | Cambiar título, descripción, tipo, área, prioridad, etiquetas, fechas, campos personalizados… | | | `change_issue_status` | Mover de estado (con motivo cuando el estado lo pide) | @@ -66,6 +66,8 @@ flowchart LR | | `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 | +| | `mark_decision` | Marcar un comentario como decisión del ticket, o quitarla | +| | `search_decisions` | Qué se decidió: decisiones por su texto, de un proyecto o de todos los míos | | | `add_checklist_item` / `update_checklist_item` / `remove_checklist_item` | La lista de comprobación: añadir, marcar o reescribir, quitar | | Organización | `list_epics` | Épicas con el progreso de lo que cuelga de cada una | | | `set_issue_parent` | Colgar un ticket de una épica u otro ticket, o soltarlo | diff --git a/libs/api/tracker/application/src/lib/commands/command-handlers.ts b/libs/api/tracker/application/src/lib/commands/command-handlers.ts index 550e908..4c995e6 100644 --- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts +++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts @@ -1,4 +1,5 @@ import { ReactToCommentCommandHandler } from './react-to-comment.command-handler'; +import { MarkDecisionCommandHandler } from './mark-decision.command-handler'; import { VoteIssueCommandHandler } from './vote-issue.command-handler'; import { CreateProjectCommandHandler } from './create-project.command-handler'; import { UpdateProjectCommandHandler } from './update-project.command-handler'; @@ -46,6 +47,7 @@ import { export const COMMAND_HANDLERS = [ VoteIssueCommandHandler, ReactToCommentCommandHandler, + MarkDecisionCommandHandler, CreateProjectCommandHandler, UpdateProjectCommandHandler, UpdateProjectSettingsCommandHandler, diff --git a/libs/api/tracker/application/src/lib/commands/commands.spec.ts b/libs/api/tracker/application/src/lib/commands/commands.spec.ts index 545031e..37c1388 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -77,6 +77,7 @@ import { UserDirectory } from '../ports/user-directory'; import { AttachmentStorage } from '../ports/attachment-storage'; import { AddCommentCommand } from './add-comment.command'; import { ReactToCommentCommand } from './react-to-comment.command'; +import { MarkDecisionCommand } from './mark-decision.command'; import { UpdateProjectCommand } from './update-project.command'; import { VoteIssueCommand } from './vote-issue.command'; import { AddProjectMemberCommand } from './add-project-member.command'; @@ -916,6 +917,32 @@ describe('tracker commands (event sourced)', () => { ).rejects.toThrow(ForbiddenError); }); + it('lets the team mark a comment as a decision, and only the team', async () => { + const { run, rehydrator } = await setup(); + const projectId = await projectWithTeam(run); + const issue = await run( + new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Caché', {}), + ); + const comment = await run( + new AddCommentCommand('qa', issue.id, 'Usamos Redis'), + ); + await run(new MarkDecisionCommand('dev', comment.id, true)); + // Twice is still once. + const again = await run(new MarkDecisionCommand('dev', comment.id, true)); + expect(again.version).toBe(2); + expect((await rehydrator.rehydrate(comment.id, Comment)).decision).toBe( + true, + ); + // A viewer reads the decisions but does not make them. + await expect( + run(new MarkDecisionCommand('pm', comment.id, false)), + ).rejects.toThrow(ForbiddenError); + await run(new MarkDecisionCommand('owner', comment.id, false)); + expect((await rehydrator.rehydrate(comment.id, Comment)).decision).toBe( + false, + ); + }); + it('takes votes from members, and from anyone once it is public', async () => { const { run, rehydrator } = await setup(); const projectId = await projectWithTeam(run); diff --git a/libs/api/tracker/application/src/lib/commands/mark-decision.command-handler.ts b/libs/api/tracker/application/src/lib/commands/mark-decision.command-handler.ts new file mode 100644 index 0000000..7c9ce88 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/mark-decision.command-handler.ts @@ -0,0 +1,20 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { CommandResult, ack } from './command-result'; +import { MarkDecisionCommand } from './mark-decision.command'; +import { WriteAccess } from './write-access'; + +/** The team decides: whoever can change the ticket marks its decisions. */ +@CommandHandler(MarkDecisionCommand) +export class MarkDecisionCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: MarkDecisionCommand): Promise { + const comment = await this.access.comment(command.commentId); + await this.access.issueForContributor(comment.issueId, command.actorId); + comment.markDecision(command.actorId, command.on); + await comment.commitAndWait(); + return ack(comment); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/mark-decision.command.ts b/libs/api/tracker/application/src/lib/commands/mark-decision.command.ts new file mode 100644 index 0000000..bd0eab4 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/mark-decision.command.ts @@ -0,0 +1,13 @@ +import { Command } from '@nestjs/cqrs'; +import { CommandResult } from './command-result'; + +/** Marks a comment as a decision of its ticket (`on`) or unmarks it. */ +export class MarkDecisionCommand extends Command { + constructor( + public readonly actorId: string, + public readonly commentId: string, + public readonly on: boolean, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/comments.service.ts b/libs/api/tracker/application/src/lib/comments.service.ts index a71ddfc..86c3989 100644 --- a/libs/api/tracker/application/src/lib/comments.service.ts +++ b/libs/api/tracker/application/src/lib/comments.service.ts @@ -1,14 +1,22 @@ import { CommandRunner } from '@task-stream/api/shared/application'; import { Injectable } from '@nestjs/common'; import { QueryBus } from '@nestjs/cqrs'; -import { CommentReadModel } from '@task-stream/api/tracker/domain'; +import { + CommentReadModel, + DecisionReadModel, +} from '@task-stream/api/tracker/domain'; import { AddCommentCommand } from './commands/add-comment.command'; import { CommandResult } from './commands/command-result'; import { DeleteCommentCommand } from './commands/delete-comment.command'; import { EditCommentCommand } from './commands/edit-comment.command'; import { ReactToCommentCommand } from './commands/react-to-comment.command'; +import { MarkDecisionCommand } from './commands/mark-decision.command'; import { Page } from './ports/find-issues.repository'; import { GetCommentsQuery } from './queries/get-comments.query'; +import { + GetIssueDecisionsQuery, + SearchDecisionsQuery, +} from './queries/decisions.query'; @Injectable() export class CommentsService { @@ -50,6 +58,35 @@ export class CommentsService { ); } + /** Marks it as a decision of its ticket (`on`) or unmarks it: the team does. */ + markDecision( + actorId: string, + commentId: string, + on: boolean, + ): Promise { + return this.commands.execute( + new MarkDecisionCommand(actorId, commentId, on), + ); + } + + /** A ticket's decisions, oldest first. */ + decisions( + viewerId: string | null, + issueId: string, + ): Promise { + return this.queryBus.execute(new GetIssueDecisionsQuery(viewerId, issueId)); + } + + /** Decisions by their text: of a project, or of all mine without one. */ + searchDecisions( + viewerId: string | null, + filter: { projectId?: string; search?: string; limit?: number }, + ): Promise { + return this.queryBus.execute( + new SearchDecisionsQuery(viewerId, { limit: 20, ...filter }), + ); + } + delete(actorId: string, commentId: string): Promise { return this.commands.execute(new DeleteCommentCommand(actorId, commentId)); } diff --git a/libs/api/tracker/application/src/lib/event-handlers/comment-added.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/comment-added.event-handler.ts index 12edf48..9037b82 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/comment-added.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/comment-added.event-handler.ts @@ -30,6 +30,9 @@ export class CommentAddedEventHandler replyTo: comment.replyTo ?? null, deleted: false, reactions: [], + decision: false, + decidedBy: null, + decidedAt: null, createdAt: comment.createdAt, editedAt: null, version: versionOf(event), diff --git a/libs/api/tracker/application/src/lib/event-handlers/comment-decision-marked.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/comment-decision-marked.event-handler.ts new file mode 100644 index 0000000..24cdbb7 --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/comment-decision-marked.event-handler.ts @@ -0,0 +1,30 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { CommentDecisionMarkedEvent } from '@task-stream/api/tracker/domain'; +import { UpsertMaterializedCommentRepository } from '../ports/upsert-materialized-comment.repository'; +import { UserDirectory } from '../ports/user-directory'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +@EventsHandler(CommentDecisionMarkedEvent) +export class CommentDecisionMarkedEventHandler + implements IEventHandler +{ + constructor( + private readonly upsertMaterializedCommentRepository: UpsertMaterializedCommentRepository, + private readonly users: UserDirectory, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: CommentDecisionMarkedEvent) { + const refs = await this.users.refs([event.actorId]); + await this.upsertMaterializedCommentRepository.upsert({ + id: event.aggregateId, + decision: event.decision, + decidedBy: event.decision ? refs.of(event.actorId) : null, + decidedAt: event.decision ? event.occurredAt : null, + version: versionOf(event), + }); + await this.notifier.comment(event.aggregateId, event.issueId, event); + // The ticket shows its decisions on top: its page reloads them. + await this.notifier.issue(event.issueId, event); + } +} diff --git a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts index 47b1bbc..fcc4742 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/event-handlers.ts @@ -3,6 +3,7 @@ import { CommentAddedEventHandler } from './comment-added.event-handler'; import { CommentDeletedEventHandler } from './comment-deleted.event-handler'; import { CommentEditedEventHandler } from './comment-edited.event-handler'; import { CommentReactedEventHandler } from './comment-reacted.event-handler'; +import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler'; import { IssueVotedEventHandler } from './issue-voted.event-handler'; import { IssueAssignedEventHandler } from './issue-assigned.event-handler'; import { IssueCreatedEventHandler } from './issue-created.event-handler'; @@ -50,6 +51,7 @@ export const EVENT_HANDLERS = [ CommentDeletedEventHandler, CommentEditedEventHandler, CommentReactedEventHandler, + CommentDecisionMarkedEventHandler, IssueVotedEventHandler, IssueAssignedEventHandler, IssueCreatedEventHandler, diff --git a/libs/api/tracker/application/src/lib/ports/find-comments.repository.ts b/libs/api/tracker/application/src/lib/ports/find-comments.repository.ts index 1d4928f..b91d746 100644 --- a/libs/api/tracker/application/src/lib/ports/find-comments.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-comments.repository.ts @@ -11,4 +11,14 @@ export abstract class FindCommentsRepository { /** Comments still standing (deleted ones kept for their replies do not count). */ abstract countByIssue(issueId: string): Promise; abstract countReplies(commentId: string): Promise; + /** + * Comments marked as decisions (not deleted): of one ticket, or of some + * projects, oldest first for a ticket and most recent first otherwise. + */ + abstract findDecisions(filter: { + issueId?: string; + projectIds?: string[]; + search?: string; + limit: number; + }): Promise; } diff --git a/libs/api/tracker/application/src/lib/queries/decisions.query.ts b/libs/api/tracker/application/src/lib/queries/decisions.query.ts new file mode 100644 index 0000000..cd7f399 --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/decisions.query.ts @@ -0,0 +1,112 @@ +import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs'; +import { + CommentReadModel, + DecisionReadModel, +} from '@task-stream/api/tracker/domain'; +import { FindCommentsRepository } from '../ports/find-comments.repository'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { FindProjectsRepository } from '../ports/find-projects.repository'; +import { ReadAccess } from './read-access'; + +/** The decisions of a ticket, oldest first (shown above its conversation). */ +export class GetIssueDecisionsQuery extends Query { + constructor( + /** Null: anonymous (public projects only). */ + public readonly viewerId: string | null, + public readonly issueId: string, + ) { + super(); + } +} + +/** + * Decisions by their text, most recently decided first: of one project, or + * (without one) of every project the viewer belongs to. + */ +export class SearchDecisionsQuery extends Query { + constructor( + public readonly viewerId: string | null, + public readonly filter: { + projectId?: string; + search?: string; + limit: number; + }, + ) { + super(); + } +} + +@QueryHandler(GetIssueDecisionsQuery) +export class GetIssueDecisionsQueryHandler + implements IQueryHandler +{ + constructor( + private readonly findIssues: FindIssuesRepository, + private readonly findComments: FindCommentsRepository, + private readonly access: ReadAccess, + ) {} + + async execute(query: GetIssueDecisionsQuery): Promise { + const issue = this.access.assertFound( + await this.findIssues.findById(query.issueId), + 'Issue', + query.issueId, + ); + await this.access.assertCanViewIssue(issue, query.viewerId); + return this.findComments.findDecisions({ issueId: issue.id, limit: 50 }); + } +} + +@QueryHandler(SearchDecisionsQuery) +export class SearchDecisionsQueryHandler + implements IQueryHandler +{ + constructor( + private readonly findProjects: FindProjectsRepository, + private readonly findIssues: FindIssuesRepository, + private readonly findComments: FindCommentsRepository, + private readonly access: ReadAccess, + ) {} + + async execute(query: SearchDecisionsQuery): Promise { + const { filter, viewerId } = query; + let projectIds: string[]; + if (filter.projectId) { + await this.access.assertCanView(filter.projectId, viewerId); + projectIds = [filter.projectId]; + } else { + if (!viewerId) return []; + projectIds = (await this.findProjects.findByMember(viewerId)).map( + (p) => p.id, + ); + } + if (projectIds.length === 0) return []; + const limit = Math.min(filter.limit, 100); + // Read a few more: some may sit in the trash or, for a guest, in + // tickets they do not take part in. + const comments = await this.findComments.findDecisions({ + projectIds, + search: filter.search?.trim().slice(0, 200) || undefined, + limit: limit * 2, + }); + const issues = new Map( + ( + await this.findIssues.findByIds([ + ...new Set(comments.map((c) => c.issueId)), + ]) + ).map((issue) => [issue.id, issue]), + ); + const found: DecisionReadModel[] = []; + for (const comment of comments) { + const issue = issues.get(comment.issueId); + if (!issue || issue.trashedAt) continue; + if (!(await this.access.canViewIssue(issue, viewerId))) continue; + found.push({ + comment, + issue: { id: issue.id, key: issue.key, title: issue.title }, + }); + if (found.length === limit) break; + } + return found; + } +} diff --git a/libs/api/tracker/application/src/lib/queries/query-handlers.ts b/libs/api/tracker/application/src/lib/queries/query-handlers.ts index 1816f67..4c5cb64 100644 --- a/libs/api/tracker/application/src/lib/queries/query-handlers.ts +++ b/libs/api/tracker/application/src/lib/queries/query-handlers.ts @@ -26,6 +26,10 @@ import { GetReleaseNotesDraftQueryHandler } from './get-release-notes-draft.quer import { GetTrashQueryHandler } from './get-trash.query'; import { GetShowcaseQueryHandler } from './get-showcase.query'; import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler'; +import { + GetIssueDecisionsQueryHandler, + SearchDecisionsQueryHandler, +} from './decisions.query'; export const QUERY_HANDLERS = [ GetPublicProjectsQueryHandler, @@ -33,6 +37,8 @@ export const QUERY_HANDLERS = [ GetActivityQueryHandler, GetBoardQueryHandler, GetCommentsQueryHandler, + GetIssueDecisionsQueryHandler, + SearchDecisionsQueryHandler, GetIssueQueryHandler, GetIssuesByIdsQueryHandler, GetMyProjectsQueryHandler, diff --git a/libs/api/tracker/domain/src/lib/comment/comment.ts b/libs/api/tracker/domain/src/lib/comment/comment.ts index 928ff08..708678b 100644 --- a/libs/api/tracker/domain/src/lib/comment/comment.ts +++ b/libs/api/tracker/domain/src/lib/comment/comment.ts @@ -5,6 +5,7 @@ import { assertThat, } from '@task-stream/api/shared/domain'; import { CommentAddedEvent } from '../events/comment-added.event'; +import { CommentDecisionMarkedEvent } from '../events/comment-decision-marked.event'; import { CommentDeletedEvent } from '../events/comment-deleted.event'; import { CommentEditedEvent } from '../events/comment-edited.event'; import { CommentReactedEvent } from '../events/comment-reacted.event'; @@ -41,6 +42,8 @@ export class Comment extends VersionedAggregateRoot implements CommentProps { public replyTo: string | null = null; /** Who chose each emoji (only the ones somebody chose). */ public reactions: Record = {}; + /** What was decided on the ticket: shown above it and searchable. */ + public decision = false; public deleted = false; public createdAt!: string; public editedAt: string | null = null; @@ -93,6 +96,24 @@ export class Comment extends VersionedAggregateRoot implements CommentProps { ); } + /** + * Marks it as a decision of the ticket (`on`) or unmarks it; repeating it + * changes nothing. Who may do it (the team) is the caller's check. + */ + markDecision(actorId: string, on: boolean): void { + this.assertAlive(); + if (on === this.decision) return; + this.apply( + new CommentDecisionMarkedEvent( + actorId, + this.id, + this.projectId, + this.issueId, + on, + ), + ); + } + /** @param canModerate whether the actor manages the project (may delete others' comments). */ delete(actorId: string, canModerate: boolean): void { this.assertAlive(); @@ -121,6 +142,10 @@ export class Comment extends VersionedAggregateRoot implements CommentProps { this.reactions = event.reactions; } + onCommentDecisionMarkedEvent(event: CommentDecisionMarkedEvent): void { + this.decision = event.decision; + } + onCommentDeletedEvent(): void { this.deleted = true; } diff --git a/libs/api/tracker/domain/src/lib/events/comment-decision-marked.event.ts b/libs/api/tracker/domain/src/lib/events/comment-decision-marked.event.ts new file mode 100644 index 0000000..110fe88 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/comment-decision-marked.event.ts @@ -0,0 +1,17 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** A comment was marked as a decision of the ticket, or unmarked. */ +@AutowiredEvent +export class CommentDecisionMarkedEvent extends TrackerEvent { + constructor( + actorId: string, + commentId: string, + projectId: string, + readonly issueId: string, + /** False: it is no longer a decision. */ + readonly decision: boolean, + ) { + super(actorId, commentId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/events/index.ts b/libs/api/tracker/domain/src/lib/events/index.ts index 6625255..e6793ff 100644 --- a/libs/api/tracker/domain/src/lib/events/index.ts +++ b/libs/api/tracker/domain/src/lib/events/index.ts @@ -15,6 +15,7 @@ export * from './issue-release-changed.event'; export * from './comment-added.event'; export * from './comment-edited.event'; export * from './comment-reacted.event'; +export * from './comment-decision-marked.event'; export * from './comment-deleted.event'; export * from './release-created.event'; export * from './release-updated.event'; diff --git a/libs/api/tracker/domain/src/lib/read-models/comment.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/comment.read-model.ts index 1670ff6..270de9e 100644 --- a/libs/api/tracker/domain/src/lib/read-models/comment.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/comment.read-model.ts @@ -21,7 +21,18 @@ export class CommentReadModel { deleted!: boolean; /** In the order they are offered; only the ones somebody chose. */ reactions!: CommentReactionReadModel[]; + /** Marked as a decision of the ticket. */ + decision!: boolean; + /** Who marked it and when (null when it is not a decision). */ + decidedBy!: UserRefReadModel | null; + decidedAt!: string | null; createdAt!: string; editedAt!: string | null; version!: number; } + +/** A decision with the ticket it belongs to, for searching across tickets. */ +export class DecisionReadModel { + comment!: CommentReadModel; + issue!: { id: string; key: string; title: string }; +} diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index 5ccb3bf..a484419 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -51,6 +51,9 @@ const commentFromDoc = (doc: CommentDoc): CommentReadModel => ({ replyTo: doc.replyTo ?? null, deleted: doc.deleted ?? false, reactions: doc.reactions ?? [], + decision: doc.decision ?? false, + decidedBy: doc.decidedBy ?? null, + decidedAt: doc.decidedAt ?? null, }); const escapeRegex = (text: string) => @@ -545,6 +548,28 @@ export class MongoFindCommentsRepository extends FindCommentsRepository { countReplies(commentId: string): Promise { return this.comments.countDocuments({ replyTo: commentId }); } + + async findDecisions(filter: { + issueId?: string; + projectIds?: string[]; + search?: string; + limit: number; + }): Promise { + const query: Record = { + decision: true, + deleted: { $ne: true }, + }; + if (filter.issueId) query['issueId'] = filter.issueId; + if (filter.projectIds) query['projectId'] = { $in: filter.projectIds }; + if (filter.search) + query['body'] = { $regex: escapeRegex(filter.search), $options: 'i' }; + const docs = await this.comments + .find(query) + .sort(filter.issueId ? { createdAt: 1 } : { decidedAt: -1 }) + .limit(filter.limit) + .lean(); + return docs.map(commentFromDoc); + } } @Injectable() diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts index e0047db..343b458 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/read-model.schemas.ts @@ -239,6 +239,10 @@ class CommentDocument { } export const CommentSchema = SchemaFactory.createForClass(CommentDocument); CommentSchema.index({ issueId: 1, createdAt: 1 }); +CommentSchema.index( + { projectId: 1, decidedAt: -1 }, + { partialFilterExpression: { decision: true } }, +); @Schema({ ...LOOSE, collection: 'releases_view' }) class ReleaseDocument { diff --git a/libs/api/tracker/presenters/src/lib/graphql/comment.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/comment.resolver.ts index fa1c5c3..120ab5a 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/comment.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/comment.resolver.ts @@ -1,8 +1,12 @@ -import { Args, ID, Mutation, Resolver } from '@nestjs/graphql'; +import { Args, ID, Int, Mutation, Query, Resolver } from '@nestjs/graphql'; import { User } from '@task-stream/api/identity/domain'; -import { CurrentUser } from '@task-stream/api/identity/presenters'; +import { + CurrentUser, + OptionalAuth, +} from '@task-stream/api/identity/presenters'; import { CommentsService } from '@task-stream/api/tracker/application'; -import { CommandResultGql, CommentGql } from './types'; +import { viewerOf } from '../common/viewer'; +import { CommandResultGql, CommentGql, DecisionGql } from './types'; @Resolver(() => CommentGql) export class CommentResolver { @@ -43,6 +47,37 @@ export class CommentResolver { return this.comments.react(me.id, commentId, emoji, on); } + @Mutation(() => CommandResultGql, { + description: + 'Mark a comment as a decision of its ticket, or unmark it (on: false). Whoever can change the ticket', + }) + markDecision( + @CurrentUser() me: User, + @Args('commentId', { type: () => ID }) commentId: string, + @Args('on', { defaultValue: true }) on: boolean, + ) { + return this.comments.markDecision(me.id, commentId, on); + } + + @OptionalAuth() + @Query(() => [DecisionGql], { + description: + 'Decisions by their text, most recent first: of a project, or of all my projects without one', + }) + decisions( + @CurrentUser() me: User | undefined, + @Args('projectId', { type: () => ID, nullable: true }) + projectId: string | null, + @Args('search', { nullable: true }) search: string | null, + @Args('limit', { type: () => Int, defaultValue: 20 }) limit: number, + ) { + return this.comments.searchDecisions(viewerOf(me), { + projectId: projectId ?? undefined, + search: search ?? undefined, + limit, + }); + } + @Mutation(() => CommandResultGql, { description: 'Author or project admin' }) deleteComment( @CurrentUser() me: User, diff --git a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts index 329a13e..df993b1 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -44,6 +44,7 @@ import { ActivityGql, BulkResultGql, CommandResultGql, + CommentGql, CommentPageGql, EpicGql, CustomFieldValueGql, @@ -247,6 +248,16 @@ export class IssueResolver { return !!me && issue.voterIds.includes(me.id); } + @ResolveField(() => [CommentGql], { + description: 'Its comments marked as decisions, oldest first', + }) + decisions( + @CurrentUser() me: User | undefined, + @Parent() issue: IssueReadModel, + ) { + return this.commentsService.decisions(viewerOf(me), issue.id); + } + @ResolveField(() => Boolean, { description: 'Whether I pinned it (false without a token)', }) diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index caba088..75db909 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -548,6 +548,16 @@ export class CommentGql { description: 'In the order they are offered; only the ones somebody chose', }) reactions!: CommentReactionGql[]; + @Field({ + description: 'Marked as a decision of the ticket (shown above it)', + }) + decision!: boolean; + @Field(() => UserRefGql, { + nullable: true, + description: 'Who marked it as a decision', + }) + decidedBy!: UserRefGql | null; + @Field(() => String, { nullable: true }) decidedAt!: string | null; @Field() createdAt!: string; @Field(() => String, { nullable: true }) editedAt!: string | null; @Field(() => Int) version!: number; @@ -559,6 +569,14 @@ export class CommentPageGql { @Field(() => Int) total!: number; } +@ObjectType('Decision', { + description: 'A comment marked as a decision, with its ticket', +}) +export class DecisionGql { + @Field(() => CommentGql) comment!: CommentGql; + @Field(() => IssueParentGql) issue!: IssueParentGql; +} + @ObjectType('Release') export class ReleaseGql { @Field(() => ID) id!: string; diff --git a/libs/api/tracker/presenters/src/lib/http/comments.controller.ts b/libs/api/tracker/presenters/src/lib/http/comments.controller.ts index e8f5ee7..7eba227 100644 --- a/libs/api/tracker/presenters/src/lib/http/comments.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/comments.controller.ts @@ -6,6 +6,7 @@ import { Param, Patch, Post, + Put, } from '@nestjs/common'; import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger'; import { User } from '@task-stream/api/identity/domain'; @@ -54,6 +55,29 @@ export class CommentsController { return this.comments.react(me.id, commentId, body.emoji, body.on); } + @Put(':commentId/decision') + @ApiOperation({ + summary: 'Mark a comment as a decision of its ticket', + description: + 'Whoever can change the ticket. Decisions are shown above the ticket and searchable (GET …/decisions).', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + markDecision(@CurrentUser() me: User, @Param('commentId') commentId: string) { + return this.comments.markDecision(me.id, commentId, true); + } + + @Delete(':commentId/decision') + @ApiOperation({ summary: 'It is no longer a decision' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + unmarkDecision( + @CurrentUser() me: User, + @Param('commentId') commentId: string, + ) { + return this.comments.markDecision(me.id, commentId, false); + } + @Delete(':commentId') @ApiOperation({ summary: 'Delete a comment (author or project admin)' }) @ApiOkResponse({ type: CommandResultDto }) diff --git a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts index 94809e6..7b68f3a 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/requests.dto.ts @@ -501,6 +501,22 @@ export class PageQueryDto { offset = 0; } +export class DecisionsQueryDto { + @ApiPropertyOptional({ description: 'Words in the decision' }) + @IsOptional() + @IsString() + @MaxLength(200) + search?: string; + + @ApiPropertyOptional({ type: Number, default: 20, minimum: 1, maximum: 100 }) + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + limit = 20; +} + // ---- Issues ----------------------------------------------------------------- export class MyIssuesQueryDto extends PageQueryDto { diff --git a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts index 9615a55..3c5c75e 100644 --- a/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts +++ b/libs/api/tracker/presenters/src/lib/http/dto/responses.dto.ts @@ -625,12 +625,30 @@ export class CommentDto { description: 'In the order they are offered; only the ones somebody chose', }) reactions!: CommentReactionDto[]; + @ApiProperty({ + description: 'Marked as a decision of the ticket (shown above it)', + }) + decision!: boolean; + @ApiProperty({ + type: UserRefDto, + nullable: true, + description: 'Who marked it as a decision', + }) + decidedBy!: UserRefDto | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + decidedAt!: string | null; @ApiProperty({ format: 'date-time' }) createdAt!: string; @ApiProperty({ type: String, nullable: true, format: 'date-time' }) editedAt!: string | null; @ApiProperty() version!: number; } +/** A comment marked as a decision, with its ticket. */ +export class DecisionDto { + @ApiProperty({ type: CommentDto }) comment!: CommentDto; + @ApiProperty({ type: IssueParentDto }) issue!: IssueParentDto; +} + export class CommentPageDto { @ApiProperty({ type: [CommentDto] }) items!: CommentDto[]; @ApiProperty() total!: number; diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index 2b99552..f34e4ff 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -45,6 +45,7 @@ import { ChecklistItemBodyDto, AddCommentDto, CreateIssueDto, + DecisionsQueryDto, IssueSearchQueryDto, PresenceBodyDto, LinkIssueDto, @@ -61,7 +62,9 @@ import { ActivityDto, BulkResultDto, CommandResultDto, + CommentDto, CommentPageDto, + DecisionDto, EpicDto, IssueDto, IssuePageDto, @@ -707,6 +710,57 @@ export class IssuesController { ); } + @Get('issues/:issue/decisions') + @ApiOptionalPireonAuth() + @ISSUE + @ApiOperation({ + summary: 'Comments of the ticket marked as decisions (oldest first)', + }) + @ApiOkResponse({ type: [CommentDto] }) + @ApiReadErrors() + async issueDecisions( + @CurrentUser() me: User | undefined, + @Param('issue') issue: string, + ) { + return this.comments.decisions( + viewerOf(me), + await this.refs.issueId(viewerOf(me), issue), + ); + } + + @Get('projects/:project/decisions') + @ApiOptionalPireonAuth() + @PROJECT + @ApiOperation({ + summary: "Search a project's decisions (most recent first)", + }) + @ApiOkResponse({ type: [DecisionDto] }) + @ApiReadErrors() + async projectDecisions( + @CurrentUser() me: User | undefined, + @Param('project') project: string, + @Query() query: DecisionsQueryDto, + ) { + return this.comments.searchDecisions(viewerOf(me), { + projectId: await this.refs.projectId(viewerOf(me), project), + search: query.search, + limit: query.limit, + }); + } + + @Get('me/decisions') + @ApiOperation({ + summary: 'Search the decisions of all my projects (most recent first)', + }) + @ApiOkResponse({ type: [DecisionDto] }) + @ApiReadErrors() + myDecisions(@CurrentUser() me: User, @Query() query: DecisionsQueryDto) { + return this.comments.searchDecisions(me.id, { + search: query.search, + limit: query.limit, + }); + } + @Post('issues/:issue/comments') @ISSUE @ApiOperation({ diff --git a/libs/web/tracker/application/src/lib/events/issue.events.ts b/libs/web/tracker/application/src/lib/events/issue.events.ts index e6b67be..89e4ba7 100644 --- a/libs/web/tracker/application/src/lib/events/issue.events.ts +++ b/libs/web/tracker/application/src/lib/events/issue.events.ts @@ -71,6 +71,7 @@ export const issuePageEvents = eventGroup({ }>(), commentEditRequested: type<{ commentId: string; body: string }>(), reactRequested: type<{ commentId: string; emoji: string; on: boolean }>(), + decisionRequested: type<{ commentId: string; on: boolean }>(), voteRequested: type<{ issueId: string; on: boolean }>(), /** An image pasted into a text was uploaded (it joins the ticket's files). */ imagePasted: type<{ issueId: string }>(), diff --git a/libs/web/tracker/application/src/lib/facades/issue.facade.ts b/libs/web/tracker/application/src/lib/facades/issue.facade.ts index b22a2b6..026c355 100644 --- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts @@ -231,6 +231,11 @@ export class IssueFacade { this.dispatch.reactRequested({ commentId, emoji, on }); } + /** Marks a comment as a decision of the ticket (`on`) or unmarks it. */ + markDecision(commentId: string, on: boolean): void { + this.dispatch.decisionRequested({ commentId, on }); + } + /** A copy of what the open ticket says; answers its key. */ duplicate(): Observable { const issueId = this.store.issue()?.id ?? ''; diff --git a/libs/web/tracker/application/src/lib/stores/issue.store.ts b/libs/web/tracker/application/src/lib/stores/issue.store.ts index 2bab5de..339ad5c 100644 --- a/libs/web/tracker/application/src/lib/stores/issue.store.ts +++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts @@ -423,6 +423,20 @@ export const IssueStore = signalStore( ), ), ), + events + .on(issuePageEvents.decisionRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + comments.markDecision(payload.commentId, payload.on), + payload.on + ? 'Marcado como decisión: sale arriba del ticket' + : 'Ya no es una decisión', + done, + failed, + ), + ), + ), events.on(issuePageEvents.reactRequested).pipe( mergeMap(({ payload }) => comments.react(payload.commentId, payload.emoji, payload.on).pipe( diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index fa099fb..c0accb9 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -192,6 +192,8 @@ export interface Issue extends IssueSummary { watchers: UserRef[]; /** Whether I keep it pinned (home page and search). */ pinned: boolean; + /** Its comments marked as decisions, oldest first. */ + decisions: Comment[]; checklist: ChecklistItem[]; /** Commits, branches and pull requests that mention it. */ devLinks: DevLink[]; @@ -326,10 +328,20 @@ export interface Comment { deleted: boolean; /** 👍… and who chose each (only the ones somebody chose). */ reactions: { emoji: string; users: UserRef[] }[]; + /** Marked as a decision of the ticket: shown above it and searchable. */ + decision: boolean; + /** Who marked it as a decision (null when it is not one). */ + decidedBy: UserRef | null; createdAt: string; editedAt: string | null; } +/** A decision found by its text, with its ticket. */ +export interface Decision { + comment: Comment; + issue: { id: string; key: string; title: string }; +} + export interface SprintProgress { total: number; /** Done or cancelled. */ diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 1612591..cb350a4 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -16,6 +16,7 @@ import { PushDeviceSubscription, CommandResult, Comment, + Decision, Epic, GitIntegration, GitRepository, @@ -144,6 +145,14 @@ export abstract class IssuesGateway { abstract pins(): Observable; /** Pins the ticket for me (or unpins it): private, at most 20. */ abstract pin(issueId: string, pinned: boolean): Observable; + /** + * Decisions (comments marked as such) of all my projects by their text, + * most recent first: here and not in CommentsGateway, for the palette. + */ + abstract searchDecisions( + search: string, + limit: number, + ): Observable; /** Tickets hanging from `issueId`. */ abstract children( projectId: string, @@ -289,6 +298,11 @@ export abstract class CommentsGateway { on: boolean, ): Observable; abstract remove(commentId: string): Observable; + /** Marks it as a decision of its ticket (`on`) or unmarks it. */ + abstract markDecision( + commentId: string, + on: boolean, + ): Observable; } /** A ticket's files. Uploads and downloads go through the REST API. */ diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts index 017fdd1..a3b2a3a 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-collaboration.gateways.ts @@ -23,6 +23,7 @@ import { EditCommentDocument, IssueCommentsDocument, ReactToCommentDocument, + MarkDecisionDocument, PublishReleaseDocument, ReleaseBurnupDocument, ReleaseNotesDraftDocument, @@ -75,6 +76,12 @@ export class GraphqlCommentsGateway extends CommentsGateway { .mutate(DeleteCommentDocument, { commentId }) .pipe(map((d) => toCommandResult(d.deleteComment))); } + + markDecision(commentId: string, on: boolean): Observable { + return this.gql + .mutate(MarkDecisionDocument, { commentId, on }) + .pipe(map((d) => toCommandResult(d.markDecision))); + } } @Injectable() diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts index d655381..5d91ddd 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-issues.gateway.ts @@ -7,6 +7,7 @@ import { ChangeStatusInput, CommandResult, CreateIssueInput, + Decision, Epic, Issue, IssuePage, @@ -48,6 +49,7 @@ import { VoteIssueDocument, WatchIssueDocument, MyPinsDocument, + SearchDecisionsDocument, PinIssueDocument, UpdateChecklistItemDocument, UpdateIssueDocument, @@ -57,6 +59,7 @@ import { toCommandResult, toIssue, toIssueLink, + toComment, toIssueSummary, } from './mappers'; @@ -201,6 +204,18 @@ export class GraphqlIssuesGateway extends IssuesGateway { .pipe(map((d) => d.myPins.map(toIssueSummary))); } + searchDecisions(search: string, limit: number): Observable { + return this.gql + .query(SearchDecisionsDocument, { search: search.trim() || null, limit }) + .pipe( + map((d) => + d.decisions.map((found) => ({ + comment: toComment(found.comment), + issue: found.issue, + })), + ), + ); + } pin(issueId: string, pinned: boolean): Observable { const done$: Observable = pinned ? this.gql.mutate(PinIssueDocument, { issueId }) diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index 028d8aa..bde6786 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -121,6 +121,9 @@ fragment IssueSummaryFields on Issue { fragment IssueFields on Issue { ...IssueSummaryFields pinned + decisions { + ...CommentFields + } description figmaUrl createdAt @@ -178,6 +181,10 @@ fragment CommentFields on Comment { ...UserRefFields } } + decision + decidedBy { + ...UserRefFields + } createdAt editedAt author { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 7c8370c..a47fe06 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -650,6 +650,22 @@ export type IssueFieldsFragment = { startDate: string | null; dueDate: string | null; trashedAt: string | null; + decisions: Array<{ + id: string; + issueId: string; + body: string; + replyTo: string | null; + deleted: boolean; + decision: boolean; + createdAt: string; + editedAt: string | null; + reactions: Array<{ + emoji: string; + users: Array<{ id: string; name: string }>; + }>; + decidedBy: { id: string; name: string } | null; + author: { id: string; name: string }; + }>; reporter: { id: string; name: string }; links: Array<{ type: IssueLinkType; @@ -693,12 +709,14 @@ export type CommentFieldsFragment = { body: string; replyTo: string | null; deleted: boolean; + decision: boolean; createdAt: string; editedAt: string | null; reactions: Array<{ emoji: string; users: Array<{ id: string; name: string }>; }>; + decidedBy: { id: string; name: string } | null; author: { id: string; name: string }; }; @@ -873,6 +891,22 @@ export type IssueQuery = { startDate: string | null; dueDate: string | null; trashedAt: string | null; + decisions: Array<{ + id: string; + issueId: string; + body: string; + replyTo: string | null; + deleted: boolean; + decision: boolean; + createdAt: string; + editedAt: string | null; + reactions: Array<{ + emoji: string; + users: Array<{ id: string; name: string }>; + }>; + decidedBy: { id: string; name: string } | null; + author: { id: string; name: string }; + }>; reporter: { id: string; name: string }; links: Array<{ type: IssueLinkType; @@ -1083,12 +1117,14 @@ export type IssueCommentsQuery = { body: string; replyTo: string | null; deleted: boolean; + decision: boolean; createdAt: string; editedAt: string | null; reactions: Array<{ emoji: string; users: Array<{ id: string; name: string }>; }>; + decidedBy: { id: string; name: string } | null; author: { id: string; name: string }; }>; }; @@ -1490,6 +1526,42 @@ export type ReactToCommentMutation = { reactToComment: { id: string; key: string | null; version: number }; }; +export type MarkDecisionMutationVariables = Exact<{ + commentId: string; + on: boolean; +}>; + +export type MarkDecisionMutation = { + markDecision: { id: string; key: string | null; version: number }; +}; + +export type SearchDecisionsQueryVariables = Exact<{ + search?: string | null | undefined; + limit: number; +}>; + +export type SearchDecisionsQuery = { + decisions: Array<{ + comment: { + id: string; + issueId: string; + body: string; + replyTo: string | null; + deleted: boolean; + decision: boolean; + createdAt: string; + editedAt: string | null; + reactions: Array<{ + emoji: string; + users: Array<{ id: string; name: string }>; + }>; + decidedBy: { id: string; name: string } | null; + author: { id: string; name: string }; + }; + issue: { id: string; key: string; title: string }; + }>; +}; + export type DuplicateIssueMutationVariables = Exact<{ issueId: string; }>; @@ -2780,12 +2852,14 @@ export type CommentChangedSubscription = { body: string; replyTo: string | null; deleted: boolean; + decision: boolean; createdAt: string; editedAt: string | null; reactions: Array<{ emoji: string; users: Array<{ id: string; name: string }>; }>; + decidedBy: { id: string; name: string } | null; author: { id: string; name: string }; } | null; }; @@ -3498,6 +3572,96 @@ export const IssueSummaryFieldsFragmentDoc = { }, ], } as unknown as DocumentNode; +export const CommentFieldsFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'CommentFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Comment' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'issueId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'body' } }, + { kind: 'Field', name: { kind: 'Name', value: 'replyTo' } }, + { kind: 'Field', name: { kind: 'Name', value: 'deleted' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'reactions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'emoji' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'users' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'decision' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'decidedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'editedAt' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'author' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const IssueFieldsFragmentDoc = { kind: 'Document', definitions: [ @@ -3516,6 +3680,19 @@ export const IssueFieldsFragmentDoc = { name: { kind: 'Name', value: 'IssueSummaryFields' }, }, { kind: 'Field', name: { kind: 'Name', value: 'pinned' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'decisions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'CommentFields' }, + }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, @@ -3742,11 +3919,6 @@ export const IssueFieldsFragmentDoc = { ], }, }, - ], -} as unknown as DocumentNode; -export const CommentFieldsFragmentDoc = { - kind: 'Document', - definitions: [ { kind: 'FragmentDefinition', name: { kind: 'Name', value: 'CommentFields' }, @@ -3785,6 +3957,20 @@ export const CommentFieldsFragmentDoc = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'decision' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'decidedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'editedAt' } }, { @@ -3803,23 +3989,8 @@ export const CommentFieldsFragmentDoc = { ], }, }, - { - kind: 'FragmentDefinition', - name: { kind: 'Name', value: 'UserRefFields' }, - typeCondition: { - kind: 'NamedType', - name: { kind: 'Name', value: 'UserRef' }, - }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, - ], - }, - }, ], -} as unknown as DocumentNode; +} as unknown as DocumentNode; export const ReleaseFieldsFragmentDoc = { kind: 'Document', definitions: [ @@ -5716,6 +5887,76 @@ export const IssueDocument = { ], }, }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'CommentFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Comment' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'issueId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'body' } }, + { kind: 'Field', name: { kind: 'Name', value: 'replyTo' } }, + { kind: 'Field', name: { kind: 'Name', value: 'deleted' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'reactions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'emoji' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'users' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'decision' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'decidedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'editedAt' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'author' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, { kind: 'FragmentDefinition', name: { kind: 'Name', value: 'IssueFields' }, @@ -5731,6 +5972,19 @@ export const IssueDocument = { name: { kind: 'Name', value: 'IssueSummaryFields' }, }, { kind: 'Field', name: { kind: 'Name', value: 'pinned' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'decisions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'CommentFields' }, + }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'description' } }, { kind: 'Field', name: { kind: 'Name', value: 'figmaUrl' } }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, @@ -7051,6 +7305,20 @@ export const IssueCommentsDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'decision' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'decidedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'editedAt' } }, { @@ -9539,6 +9807,271 @@ export const ReactToCommentDocument = { ReactToCommentMutation, ReactToCommentMutationVariables >; +export const MarkDecisionDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'MarkDecision' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'commentId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'on' } }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Boolean' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'markDecision' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'commentId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'commentId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'on' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'on' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'CommandResultFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'CommandResultFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'CommandResult' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + ], + }, + }, + ], +} as unknown as DocumentNode< + MarkDecisionMutation, + MarkDecisionMutationVariables +>; +export const SearchDecisionsDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'SearchDecisions' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'search' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'limit' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'Int' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'decisions' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'search' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'search' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'limit' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'limit' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'comment' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'CommentFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'issue' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'CommentFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Comment' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'issueId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'body' } }, + { kind: 'Field', name: { kind: 'Name', value: 'replyTo' } }, + { kind: 'Field', name: { kind: 'Name', value: 'deleted' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'reactions' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'emoji' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'users' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'decision' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'decidedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'editedAt' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'author' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + SearchDecisionsQuery, + SearchDecisionsQueryVariables +>; export const DuplicateIssueDocument = { kind: 'Document', definitions: [ @@ -16294,6 +16827,20 @@ export const CommentChangedDocument = { ], }, }, + { kind: 'Field', name: { kind: 'Name', value: 'decision' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'decidedBy' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'createdAt' } }, { kind: 'Field', name: { kind: 'Name', value: 'editedAt' } }, { diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index 3cee1b3..b76c104 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -293,6 +293,25 @@ mutation ReactToComment($commentId: ID!, $emoji: String!, $on: Boolean!) { } } +mutation MarkDecision($commentId: ID!, $on: Boolean!) { + markDecision(commentId: $commentId, on: $on) { + ...CommandResultFields + } +} + +query SearchDecisions($search: String, $limit: Int!) { + decisions(search: $search, limit: $limit) { + comment { + ...CommentFields + } + issue { + id + key + title + } + } +} + mutation DuplicateIssue($issueId: ID!) { duplicateIssue(issueId: $issueId) { ...CommandResultFields diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss index f5db3c3..912510c 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss @@ -331,6 +331,109 @@ a.ts-key { } } +/* A decision: marked on its comment, and listed above the ticket. */ +.ts-comment.is-decision { + margin-inline: -0.75rem; + padding-inline: 0.75rem; + border-left: 3px solid var(--ts-c-green); + background: color-mix(in srgb, var(--ts-c-green) 6%, transparent); +} + +.ts-comment.is-pointed { + animation: ts-pointed 2.4s ease-out; +} + +@keyframes ts-pointed { + 0%, + 40% { + box-shadow: 0 0 0 3px color-mix(in srgb, var(--ts-brand) 45%, transparent); + } + 100% { + box-shadow: 0 0 0 3px transparent; + } +} + +.ts-comment__decision { + padding: 0.0625rem 0.5rem; + border-radius: 999px; + background: color-mix(in srgb, var(--ts-c-green) 16%, transparent); + color: var(--ts-c-green); + font-size: 0.6875rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +/* A plain icon button, not @pi/ui's sized one. */ +.ts-comment__flag { + display: inline-grid; + place-items: center; + width: 2rem !important; + min-width: 0 !important; + height: 2rem !important; + padding: 0 !important; + border: 0; + border-radius: 0.5rem; + background: transparent; + color: var(--ts-muted); + cursor: pointer; + + &:hover, + &:focus-visible { + background: var(--ts-sunken); + color: var(--ts-ink); + } + + &.is-on { + color: var(--ts-c-green); + } +} + +.ts-decisions { + ol { + display: grid; + gap: 0.875rem; + margin: 0; + padding: 0; + list-style: none; + } + + li { + padding: 0.75rem 1rem; + border-left: 3px solid var(--ts-c-green); + border-radius: 0 0.5rem 0.5rem 0; + background: color-mix(in srgb, var(--ts-c-green) 7%, var(--ts-surface)); + } + + .ts-section pi-icon { + color: var(--ts-c-green); + } +} + +.ts-decisions__meta { + margin: 0.375rem 0 0; + color: var(--ts-muted); + font-size: 0.8125rem; +} + +/* Looks like a link: not @pi/ui's sized button. */ +.ts-decisions__go { + display: inline !important; + min-width: 0 !important; + height: auto !important; + padding: 0 !important; + border: 0; + background: none !important; + color: var(--ts-brand); + font: inherit; + cursor: pointer; + + &:hover, + &:focus-visible { + text-decoration: underline; + } +} + .ts-comment__head { display: flex; align-items: center; diff --git a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts index 630113c..b8a3897 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -259,6 +259,47 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; } + @if (issue.decisions.length) { +
+

+ + Decisiones + {{ issue.decisions.length }} +

+
    + @for (decision of issue.decisions; track decision.id) { +
  1. + +

    + {{ decision.author.name }} · + {{ ago(decision.createdAt) }} + @if (decision.decidedBy; as by) { + @if (by.id !== decision.author.id) { + · marcada por {{ by.name }} + } + } + · + +

    +
  2. + } +
+
+ } +

Descripción

@if (issue.description) { @@ -615,7 +656,11 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; {{ commentCount() }} -
+
@@ -628,7 +673,34 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; >{{ ago(comment.createdAt) }}{{ comment.editedAt ? ' · editado' : '' }} + @if (comment.decision && !comment.deleted) { + Decisión + } + @if (!comment.deleted && project.canContribute()) { + + + } @if ( !comment.deleted && comment.author.id === facade.me() ) { @@ -1028,6 +1100,8 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; export class IssuePage { /** Route param (`withComponentInputBinding`). */ readonly issueKey = input.required(); + /** `?comment=` (query param): the comment to bring into view. */ + readonly comment = input(); protected readonly facade = inject(IssueFacade); protected readonly project = inject(ProjectFacade); @@ -1334,12 +1408,29 @@ export class IssuePage { }); }); }); + effect(() => { + const id = this.comment(); + // Once its comment has arrived (they load after the ticket). + if (id && this.facade.comments().some((c) => c.id === id)) + untracked(() => setTimeout(() => this.goToComment(id))); + }); effect(() => { const editing = this.editingInDrawer() || !!this.commentBody().trim(); untracked(() => this.presence.setEditing(editing)); }); } + /** Scrolls to a comment of the conversation and points at it. */ + protected goToComment(id: string): void { + const element = document.getElementById(`comment-${id}`); + if (!element) return; + element.scrollIntoView({ behavior: 'smooth', block: 'center' }); + element.classList.remove('is-pointed'); + // Restart the animation when it is pointed at again. + void element.offsetWidth; + element.classList.add('is-pointed'); + } + /** While a form is open in the drawer, I am editing. */ private trackEditing(ref: { afterClosed(): { subscribe(f: () => void): unknown }; diff --git a/libs/web/tracker/presenters/src/lib/ui/command-palette.ts b/libs/web/tracker/presenters/src/lib/ui/command-palette.ts index 88fd8ee..12962b7 100644 --- a/libs/web/tracker/presenters/src/lib/ui/command-palette.ts +++ b/libs/web/tracker/presenters/src/lib/ui/command-palette.ts @@ -14,6 +14,7 @@ import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop'; import { Router } from '@angular/router'; import { ProjectsFacade } from '@task-stream/web/tracker/application'; import { + Decision, GO_TO, isTyping, IssuesGateway, @@ -23,6 +24,7 @@ import { } from '@task-stream/web/tracker/domain'; import { catchError, + combineLatest, debounceTime, distinctUntilChanged, map, @@ -39,9 +41,28 @@ const issueResult = (i: IssueSummary): Result => ({ url: `/p/${i.key.slice(0, i.key.lastIndexOf('-'))}/issues/${i.key}`, }); +/** A decision as a result: opens its ticket at the comment. */ +const decisionResult = ({ comment, issue }: Decision): Result => ({ + id: `d:${comment.id}`, + kind: 'decision', + label: `${issue.key} · ${excerpt(comment.body)}`, + detail: comment.author.name, + url: `/p/${issue.key.slice(0, issue.key.lastIndexOf('-'))}/issues/${issue.key}?comment=${comment.id}`, +}); + +/** The first words of a comment, without Markdown marks. */ +function excerpt(body: string): string { + const text = body + .replace(/!?\[([^\]]*)\]\([^)]*\)/g, '$1') + .replace(/[*_`~#>]+/g, '') + .replace(/\s+/g, ' ') + .trim(); + return text.length > 90 ? `${text.slice(0, 89)}…` : text; +} + interface Result { id: string; - kind: 'project' | 'issue'; + kind: 'project' | 'issue' | 'decision'; label: string; detail: string; url: string; @@ -83,7 +104,7 @@ interface Result { #search class="ts-palette__input" type="search" - placeholder="Busca un ticket (WEB-12, título…) o un proyecto" + placeholder="Busca un ticket (WEB-12, título…), una decisión o un proyecto" role="combobox" aria-controls="ts-palette-results" [attr.aria-expanded]="results().length > 0" @@ -113,9 +134,11 @@ interface Result { {{ result.kind === 'project' ? 'Proyecto' - : text().trim().length < 2 - ? 'Fijado' - : 'Ticket' + : result.kind === 'decision' + ? 'Decisión' + : text().trim().length < 2 + ? 'Fijado' + : 'Ticket' }} {{ result.label }} {{ result.detail }} @@ -268,6 +291,7 @@ export class CommandPalette { private goTimer: ReturnType | null = null; private readonly foundIssues = signal([]); + private readonly foundDecisions = signal([]); /** Mine, shown before typing; read again each time it opens. */ protected readonly pins = signal([]); protected readonly results = computed(() => { @@ -288,7 +312,11 @@ export class CommandPalette { detail: p.key, url: `/p/${p.key}/board`, })); - return [...projects, ...this.foundIssues().map(issueResult)]; + return [ + ...projects, + ...this.foundIssues().map(issueResult), + ...this.foundDecisions().map(decisionResult), + ]; }); constructor() { @@ -299,15 +327,25 @@ export class CommandPalette { distinctUntilChanged(), switchMap((search) => search.length < 2 - ? of([]) - : this.issues.mine({ assignedToMe: false, search, limit: 8 }).pipe( - map((page) => page.items), - catchError(() => of([])), - ), + ? of<[IssueSummary[], Decision[]]>([[], []]) + : combineLatest([ + this.issues + .mine({ assignedToMe: false, search, limit: 8 }) + .pipe( + map((page) => page.items), + catchError(() => of([])), + ), + this.issues + .searchDecisions(search, 4) + .pipe(catchError(() => of([]))), + ]), ), takeUntilDestroyed(this.destroyRef), ) - .subscribe((found) => this.foundIssues.set(found)); + .subscribe((found) => { + this.foundIssues.set(found[0]); + this.foundDecisions.set(found[1]); + }); effect(() => { if (this.mode() === 'search') this.searchInput()?.nativeElement.focus(); });