From 93d028e870a0724de800293d915e36312487a70b Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Tue, 20 Oct 2026 20:38:30 +0200 Subject: [PATCH] feat: ask a teammate to review a ticket MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit «Pedir revisión» on a ticket asks a member (with an optional note): they are notified and see it under «Te piden revisión» in my work until they answer «Todo bien» or «Pedir cambios» (with what to change) from the ticket's Revisiones box; whoever asked hears the answer and can withdraw an open request. Events of the ticket (requested, answered, cancelled), so they show in its history and the activity feed. Viewers can be asked and answer; not oneself, nor twice the same person with one pending. API: requestReview / answerReview / cancelReview, Issue.reviews, myIssues(reviewing); POST /api/issues/{issue}/reviews, POST …/reviews/{id}/answer, DELETE …/reviews/{id}. MCP: request_review, answer_review, my_issues reviewing. Co-Authored-By: Claude Opus 5.5 --- apps/api/openapi.json | 458 ++++++++++++++++- apps/api/schema.gql | 39 ++ apps/mcp/src/app/api/taskstream.schema.ts | 327 +++++++++++- apps/mcp/src/app/tools/issues.resolver.ts | 72 +++ docs/03-api.md | 68 +-- docs/04-cliente-web.md | 38 +- docs/09-mcp.md | 4 +- libs/api/tracker/application/src/index.ts | 1 + .../src/lib/commands/command-handlers.ts | 2 + .../src/lib/commands/commands.spec.ts | 74 +++ .../application/src/lib/commands/reviews.ts | 132 +++++ .../lib/event-handlers/activity-summary.ts | 12 + .../event-handlers/activity.event-handler.ts | 7 + .../src/lib/event-handlers/event-handlers.ts | 2 + .../issue-reviews.event-handler.ts | 74 +++ .../application/src/lib/issues.service.ts | 41 ++ .../notifications.event-handler.ts | 28 ++ .../src/lib/ports/find-issues.repository.ts | 4 + .../queries/search-my-issues.query-handler.ts | 1 + .../src/lib/queries/search-my-issues.query.ts | 2 + libs/api/tracker/domain/src/index.ts | 1 + .../tracker/domain/src/lib/events/index.ts | 1 + .../src/lib/events/issue-review.events.ts | 46 ++ .../api/tracker/domain/src/lib/issue/issue.ts | 137 +++++ .../tracker/domain/src/lib/issue/review.ts | 23 + .../src/lib/read-models/issue.read-model.ts | 16 + .../read-models/notification.read-model.ts | 4 + .../src/lib/mongo/find.repositories.ts | 8 + .../src/lib/mongo/read-model.schemas.ts | 1 + .../presenters/src/lib/graphql/enums.ts | 5 + .../src/lib/graphql/issue.resolver.ts | 55 ++ .../presenters/src/lib/graphql/types.ts | 23 + .../src/lib/http/dto/requests.dto.ts | 33 ++ .../src/lib/http/dto/responses.dto.ts | 24 + .../src/lib/http/issues.controller.ts | 67 +++ .../application/src/lib/events/home.events.ts | 1 + .../src/lib/events/issue.events.ts | 13 + .../src/lib/facades/home.facade.ts | 2 + .../src/lib/facades/issue.facade.ts | 34 ++ .../application/src/lib/stores/home.store.ts | 10 + .../application/src/lib/stores/issue.store.ts | 48 ++ .../tracker/domain/src/lib/models/enums.ts | 8 + .../tracker/domain/src/lib/models/inputs.ts | 2 + .../tracker/domain/src/lib/models/models.ts | 16 + .../tracker/domain/src/lib/ports/gateways.ts | 19 +- .../lib/adapters/graphql-issues.gateway.ts | 37 ++ .../src/lib/graphql/fragments.graphql | 14 + .../src/lib/graphql/generated.ts | 472 ++++++++++++++++++ .../src/lib/graphql/issues.graphql | 30 ++ .../presenters/src/lib/pages/issue.page.scss | 89 ++++ .../presenters/src/lib/pages/issue.page.ts | 238 +++++++++ .../presenters/src/lib/pages/my-work.ts | 25 + .../src/lib/ui/notifications-bell.ts | 2 + 53 files changed, 2836 insertions(+), 54 deletions(-) create mode 100644 libs/api/tracker/application/src/lib/commands/reviews.ts create mode 100644 libs/api/tracker/application/src/lib/event-handlers/issue-reviews.event-handler.ts create mode 100644 libs/api/tracker/domain/src/lib/events/issue-review.events.ts create mode 100644 libs/api/tracker/domain/src/lib/issue/review.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 7d71148..12e7d1b 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -3341,6 +3341,348 @@ ] } }, + "/api/issues/{issue}/reviews": { + "post": { + "description": "Whoever can change the ticket asks; the reviewer is told and sees it in GET /api/me/issues?reviewing=true until they answer.", + "operationId": "IssuesController_requestReview", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RequestReviewDto" + } + } + } + }, + "responses": { + "201": { + "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": "Ask a member to review the ticket", + "tags": [ + "Issues" + ] + } + }, + "/api/issues/{issue}/reviews/{review}/answer": { + "post": { + "operationId": "IssuesController_answerReview", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + }, + { + "name": "review", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AnswerReviewDto" + } + } + } + }, + "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": "Answer a review I was asked for (all good, or changes)", + "tags": [ + "Issues" + ] + } + }, + "/api/issues/{issue}/reviews/{review}": { + "delete": { + "operationId": "IssuesController_cancelReview", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + }, + { + "name": "review", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "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": "Withdraw a review request I made", + "tags": [ + "Issues" + ] + } + }, "/api/issues/{issue}/follow-tree": { "put": { "description": "Told of its changes and of every ticket hanging from it, at any depth, also the ones added later. Private to me.", @@ -3774,6 +4116,15 @@ "type": "boolean" } }, + { + "name": "reviewing", + "required": false, + "in": "query", + "description": "Only the tickets I was asked to review and have not answered", + "schema": { + "type": "boolean" + } + }, { "name": "status", "required": false, @@ -14605,6 +14956,65 @@ "commentId" ] }, + "ReviewVerdict": { + "type": "string", + "enum": [ + "APPROVED", + "CHANGES" + ], + "description": "Null while waiting for an answer" + }, + "IssueReviewDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "requester": { + "$ref": "#/components/schemas/UserRefDto" + }, + "reviewer": { + "$ref": "#/components/schemas/UserRefDto" + }, + "note": { + "type": "string", + "nullable": true + }, + "requestedAt": { + "type": "string", + "format": "date-time" + }, + "verdict": { + "nullable": true, + "description": "Null while waiting for an answer", + "allOf": [ + { + "$ref": "#/components/schemas/ReviewVerdict" + } + ] + }, + "answer": { + "type": "string", + "nullable": true + }, + "answeredAt": { + "type": "string", + "nullable": true, + "format": "date-time" + } + }, + "required": [ + "id", + "requester", + "reviewer", + "note", + "requestedAt", + "verdict", + "answer", + "answeredAt" + ] + }, "IssueParentDto": { "type": "object", "properties": { @@ -14880,6 +15290,13 @@ "$ref": "#/components/schemas/UserRefDto" } }, + "reviews": { + "description": "Reviews asked, open and answered, oldest first", + "type": "array", + "items": { + "$ref": "#/components/schemas/IssueReviewDto" + } + }, "parent": { "nullable": true, "description": "The epic or ticket this one breaks down", @@ -14971,6 +15388,7 @@ "links", "attachments", "watchers", + "reviews", "parent", "blockedBy", "votes", @@ -16724,6 +17142,42 @@ "at" ] }, + "RequestReviewDto": { + "type": "object", + "properties": { + "reviewer": { + "type": "string", + "description": "Reviewer's user id (a member of the project)" + }, + "note": { + "type": "string", + "maxLength": 1000, + "description": "What to look at" + } + }, + "required": [ + "reviewer" + ] + }, + "AnswerReviewDto": { + "type": "object", + "properties": { + "verdict": { + "allOf": [ + { + "$ref": "#/components/schemas/ReviewVerdict" + } + ] + }, + "answer": { + "type": "string", + "maxLength": 1000 + } + }, + "required": [ + "verdict" + ] + }, "IssuePageDto": { "type": "object", "properties": { @@ -17986,7 +18440,9 @@ "PROPOSED", "WATCHED", "ADDED_TO_PROJECT", - "REMINDER" + "REMINDER", + "REVIEW_REQUESTED", + "REVIEW_ANSWERED" ] }, "NotificationDto": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index d679253..27720e4 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -725,6 +725,9 @@ type Issue { rank: String! release: ReleaseRef reporter: UserRef! + + """Reviews asked, open and answered, oldest first""" + reviews: [IssueReview!]! sprint: SprintRef """When work should start (roadmap)""" @@ -855,6 +858,20 @@ type IssueReminder { note: String } +"""A review someone asked on the ticket""" +type IssueReview { + answer: String + answeredAt: String + id: ID! + note: String + requestedAt: String! + requester: UserRef! + reviewer: UserRef! + + """Null while waiting for an answer""" + verdict: ReviewVerdict +} + """RECENT: last updated first. RANK: backlog order.""" enum IssueSort { RANK @@ -960,6 +977,9 @@ type Mutation { """Add tickets to «Mi día» (10 at most)""" addToMyDay(day: String!, issueIds: [ID!]!): Boolean! + """Answer a review I was asked for: all good, or changes""" + answerReview(answer: String, issueId: ID!, reviewId: ID!, verdict: ReviewVerdict!): CommandResult! + """ Archive the project (owner only): everything stays readable, nothing changes until it is unarchived """ @@ -980,6 +1000,9 @@ type Mutation { """ bulkUpdateIssues(input: BulkUpdateIssuesInput!): BulkResult! + """Withdraw a review request I made (or, as admin, any)""" + cancelReview(issueId: ID!, reviewId: ID!): CommandResult! + """Move a ticket through the workflow (incl. QA)""" changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult! changeProjectMemberRole(input: ProjectMemberInput!): CommandResult! @@ -1083,6 +1106,11 @@ type Mutation { removeGitRepository(projectId: ID!, repositoryId: ID!): Boolean! removeProjectMember(projectId: ID!, userId: ID!): CommandResult! + """ + Ask a member to review a ticket (with an optional note): they are told and see it in their work + """ + requestReview(issueId: ID!, note: String, reviewerId: ID!): CommandResult! + """ Settle a conversation (a top-level comment and its replies) or reopen it (resolved: false). A conclusion is added as a reply marked as a decision. Whoever can change the ticket """ @@ -1311,6 +1339,8 @@ enum NotificationKind { QA_FAILED REMINDER REPLIED + REVIEW_ANSWERED + REVIEW_REQUESTED STATUS_CHANGED WATCHED } @@ -1679,6 +1709,9 @@ type Query { """Only these of my projects""" projectIds: [ID!] + """Only the tickets I was asked to review (unanswered)""" + reviewing: Boolean! = false + """Matches key, title or description (global search)""" search: String statuses: [String!] @@ -1841,6 +1874,12 @@ type ReminderOnIssue { reminder: IssueReminder! } +"""APPROVED: all good. CHANGES: something to change""" +enum ReviewVerdict { + APPROVED + CHANGES +} + """Tickets to look at now (worst first)""" type Risks { blocked: [InsightIssue!]! diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 129cdbf..a599b84 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -578,6 +578,60 @@ export interface paths { patch?: never; trace?: never; }; + "/api/issues/{issue}/reviews": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Ask a member to review the ticket + * @description Whoever can change the ticket asks; the reviewer is told and sees it in GET /api/me/issues?reviewing=true until they answer. + */ + post: operations["IssuesController_requestReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/issues/{issue}/reviews/{review}/answer": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Answer a review I was asked for (all good, or changes) */ + post: operations["IssuesController_answerReview"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/issues/{issue}/reviews/{review}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Withdraw a review request I made */ + delete: operations["IssuesController_cancelReview"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/issues/{issue}/follow-tree": { parameters: { query?: never; @@ -2421,6 +2475,25 @@ export interface components { */ commentId: string | null; }; + /** + * @description Null while waiting for an answer + * @enum {string} + */ + ReviewVerdict: "APPROVED" | "CHANGES"; + IssueReviewDto: { + /** Format: uuid */ + id: string; + requester: components["schemas"]["UserRefDto"]; + reviewer: components["schemas"]["UserRefDto"]; + note: string | null; + /** Format: date-time */ + requestedAt: string; + /** @description Null while waiting for an answer */ + verdict: components["schemas"]["ReviewVerdict"] | null; + answer: string | null; + /** Format: date-time */ + answeredAt: string | null; + }; IssueParentDto: { /** Format: uuid */ id: string; @@ -2520,6 +2593,8 @@ export interface components { attachments: components["schemas"]["AttachmentDto"][]; /** @description People following it */ watchers: components["schemas"]["UserRefDto"][]; + /** @description Reviews asked, open and answered, oldest first */ + reviews: components["schemas"]["IssueReviewDto"][]; /** @description The epic or ticket this one breaks down */ parent: components["schemas"]["IssueParentDto"] | null; /** @description Open tickets it waits for: the ones that block it and the ones it depends on */ @@ -3017,6 +3092,16 @@ export interface components { /** @description What to remember */ note?: string; }; + RequestReviewDto: { + /** @description Reviewer's user id (a member of the project) */ + reviewer: string; + /** @description What to look at */ + note?: string; + }; + AnswerReviewDto: { + verdict: components["schemas"]["ReviewVerdict"]; + answer?: string; + }; IssuePageDto: { items: components["schemas"]["IssueDto"][]; /** @description Total matches (for pagination) */ @@ -3493,7 +3578,7 @@ export interface components { path: string | null; }; /** @enum {string} */ - NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "REPLIED" | "PROPOSED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER"; + NotificationKind: "ASSIGNED" | "STATUS_CHANGED" | "QA_FAILED" | "COMMENTED" | "MENTIONED" | "REPLIED" | "PROPOSED" | "WATCHED" | "ADDED_TO_PROJECT" | "REMINDER" | "REVIEW_REQUESTED" | "REVIEW_ANSWERED"; NotificationDto: { id: string; kind: components["schemas"]["NotificationKind"]; @@ -6278,6 +6363,244 @@ export interface operations { }; }; }; + IssuesController_requestReview: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RequestReviewDto"]; + }; + }; + responses: { + 201: { + 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"]; + }; + }; + }; + }; + IssuesController_answerReview: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + review: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["AnswerReviewDto"]; + }; + }; + 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"]; + }; + }; + }; + }; + IssuesController_cancelReview: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + review: 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"]; + }; + }; + }; + }; IssuesController_followTree: { parameters: { query?: never; @@ -6587,6 +6910,8 @@ export interface operations { assignedToMe?: boolean; /** @description Only the tickets I follow (with assignedToMe=false, whoever they are assigned to) */ watching?: boolean; + /** @description Only the tickets I was asked to review and have not answered */ + reviewing?: boolean; /** @description Status ids. Repeat the parameter or separate with commas */ status?: string[]; /** @description Whatever each project calls its statuses */ diff --git a/apps/mcp/src/app/tools/issues.resolver.ts b/apps/mcp/src/app/tools/issues.resolver.ts index fbdc916..9591fd0 100644 --- a/apps/mcp/src/app/tools/issues.resolver.ts +++ b/apps/mcp/src/app/tools/issues.resolver.ts @@ -89,6 +89,12 @@ export class IssuesResolver { .describe( 'Only the tickets the user follows (use with assignedToMe false)', ), + reviewing: z + .boolean() + .default(false) + .describe( + 'Only the tickets the user was asked to review and has not answered (use with assignedToMe false)', + ), categories: z .array(CATEGORY) .optional() @@ -110,6 +116,7 @@ export class IssuesResolver { params: { assignedToMe: boolean; watching: boolean; + reviewing: boolean; categories?: z.infer[]; priorities?: z.infer[]; bugsOnly?: boolean; @@ -125,6 +132,7 @@ export class IssuesResolver { query: { assignedToMe: params.assignedToMe, watching: params.watching, + reviewing: params.reviewing, category: params.categories ?? ['TODO', 'IN_PROGRESS', 'QA'], priority: params.priorities, bugsOnly: params.bugsOnly, @@ -332,6 +340,14 @@ export class IssuesResolver { url: d.url, })), watchers: issue.watchers.map((w) => w.name), + reviews: issue.reviews.map((r) => ({ + id: r.id, + requester: r.requester.name, + reviewer: r.reviewer.name, + note: r.note, + verdict: r.verdict, + answer: r.answer, + })), attachments: issue.attachments.length, trashedAt: issue.trashedAt, createdAt: issue.createdAt, @@ -783,6 +799,62 @@ export class IssuesResolver { ); } + @Tool({ + name: 'request_review', + description: + 'Ask a member of the project to review a ticket (with an optional note): they are told and see it in my_issues with reviewing. reviewer is a user id (get_project lists members).', + paramsSchema: z.object({ + issue: issueRef, + reviewer: z.string().min(1), + note: z.string().max(1000).optional(), + }), + annotations: WRITE, + }) + requestReview( + params: { issue: string; reviewer: string; note?: string }, + ctx: McpContext, + ) { + return toolResult(() => + this.api.data( + this.api.as(ctx).POST('/api/issues/{issue}/reviews', { + params: { path: { issue: params.issue } }, + body: { reviewer: params.reviewer, note: params.note }, + }), + ), + ); + } + + @Tool({ + name: 'answer_review', + description: + 'Answer a review the user was asked for: APPROVED (all good) or CHANGES, with an optional answer. Review ids come with get_issue.', + paramsSchema: z.object({ + issue: issueRef, + reviewId: z.string().min(1), + verdict: z.enum(['APPROVED', 'CHANGES']), + answer: z.string().max(1000).optional(), + }), + annotations: WRITE, + }) + answerReview( + params: { + issue: string; + reviewId: string; + verdict: 'APPROVED' | 'CHANGES'; + answer?: string; + }, + ctx: McpContext, + ) { + return toolResult(() => + this.api.data( + this.api.as(ctx).POST('/api/issues/{issue}/reviews/{review}/answer', { + params: { path: { issue: params.issue, review: params.reviewId } }, + body: { verdict: params.verdict, answer: params.answer }, + }), + ), + ); + } + @Tool({ name: 'my_day', description: diff --git a/docs/03-api.md b/docs/03-api.md index aef7f16..8fb8f62 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -425,7 +425,13 @@ eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien lo abre, quien lo ti sigue un ticket (`WATCHED`) sabe cuándo cambia de estado, se asigna, se comenta, cambia su prioridad o su fecha límite, o entra o sale de un sprint; las ediciones menores (título, etiquetas…) no avisan. «Los que sigo»: `watching` en `myIssues` / `GET /api/me/issues` y en la búsqueda de un proyecto (`issues(filter: { watching: true })`, -`?watching=true`; sin sesión, ninguno). **Mi día**: los tickets que cada persona elige para hoy (`myDay(day)`, `addToMyDay(day, issueIds)`, +`?watching=true`; sin sesión, ninguno). **Revisiones**: quien puede cambiar un ticket pide a un miembro que lo revise, con nota opcional +(`requestReview(issueId, reviewerId, note)`, `POST /api/issues/{issue}/reviews`; evento `IssueReviewRequestedEvent`). +Esa persona, aunque sea lectora, responde «todo bien» o «cambios» con su respuesta (`answerReview`, `POST +…/reviews/{id}/answer`, `IssueReviewAnsweredEvent`); quien la pidió (o un admin) puede retirarla (`cancelReview`, `DELETE +…/reviews/{id}`, `IssueReviewCancelledEvent`). No a uno mismo ni dos veces a la misma persona con una pendiente. Avisos +`REVIEW_REQUESTED` y `REVIEW_ANSWERED`; `Issue.reviews`; «Te piden revisión» es `myIssues(reviewing: true)` / +`GET /api/me/issues?reviewing=true`. **Mi día**: los tickets que cada persona elige para hoy (`myDay(day)`, `addToMyDay(day, issueIds)`, `removeFromMyDay(day, issueId)`; `GET /api/me/day?day=`, `PUT/DELETE /api/me/day/{issue}?day=`), hasta 10. `day` es hoy en la zona horaria de quien pregunta (`YYYY-MM-DD`, a dos días como mucho de la del servidor). La respuesta trae también lo que quedó sin terminar el último día anterior (si fue en la última semana), para pasarlo a hoy. Privado y sin @@ -553,17 +559,17 @@ que funcionan también sin token. Todas devuelven `CommandResult { id, key, version }`. -| Ámbito | Mutations | -| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | -| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `clearReminder`, `addToMyDay`, `removeFromMyDay` (devuelven `true`), `setReminder`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` | -| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` | -| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | -| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | -| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` | -| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») | -| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | -| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` | +| Ámbito | Mutations | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | +| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `requestReview`, `answerReview`, `cancelReview`, `pinIssue`, `unpinIssue`, `followTree`, `unfollowTree`, `clearReminder`, `addToMyDay`, `removeFromMyDay` (devuelven `true`), `setReminder`, `removeAttachment` (subir: por REST), `duplicateIssue`, `voteIssue` | +| Comentarios | `addComment(replyTo)`, `editComment`, `deleteComment`, `reactToComment`, `markDecision(commentId, on)`, `resolveThread(commentId, resolved, conclusion)`, `typing(issueId, threadId)`; consulta `decisions(projectId, search)` | +| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` | +| Git | `addGitRepository` (devuelve su secreto), `rotateGitRepositorySecret`, `removeGitRepository` | +| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` | +| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)`, `GET/POST /api/me/views`, `DELETE /api/me/views/{id}` (mis vistas de «Mi trabajo») | +| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | +| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` | ### Subscriptions @@ -761,25 +767,25 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). -| Recurso | Rutas | -| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | -| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) | -| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT/DELETE …/follow-tree`, `PUT/DELETE …/reminder`, `GET /api/me/reminders`, `GET /api/me/day`, `PUT/DELETE /api/me/day/{issue}`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` | -| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` | -| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | -| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | -| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | -| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | -| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | -| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | -| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) | -| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | -| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | -| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | -| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` | -| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) | -| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | +| Recurso | Rutas | +| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` | +| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `POST …/archive`, `POST …/unarchive`, `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/insights`, `…/activity`, `…/members` (POST, PATCH, DELETE) | +| Tickets | `GET /api/me/home` (portada), `GET /api/me/portfolio` (cartera), `GET /api/me/issues` (mi trabajo; filtros `project`, `priority`, `bugsOnly`, `overdue`), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `POST …/reviews`, `POST …/reviews/{id}/answer`, `DELETE …/reviews/{id}`, `PUT/DELETE …/follow-tree`, `PUT/DELETE …/reminder`, `GET /api/me/reminders`, `GET /api/me/day`, `PUT/DELETE /api/me/day/{issue}`, `GET /api/me/pins` y `PUT/DELETE …/pin` (fijados), `PUT/DELETE …/presence`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history`, `POST /api/issues/{issue}/duplicate`, `PUT/DELETE …/vote` | +| Comentarios | `PATCH/DELETE /api/comments/{commentId}`, `POST …/reactions`, `PUT/DELETE …/decision`, `PUT/DELETE …/resolution`; `GET /api/issues/{issue}/decisions`, `GET /api/projects/{project}/decisions`, `GET /api/me/decisions` | +| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | +| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | +| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | +| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | +| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | +| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) | +| Compartir | `GET/PUT/DELETE /api/projects/{project}/share` (enlace para clientes), `GET /api/shared/dashboards/{token}` (lo que enseña, sin token) | +| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) | +| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` | +| Git | `GET /api/projects/{project}/git-integration`, `POST …/git-integration/repositories`, `POST …/repositories/{id}/secret`, `DELETE …/repositories/{id}`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) | +| Alertas | `GET/PUT/DELETE /api/projects/{project}/alert-integration`, `POST …/alert-integration/token`, `POST /api/webhooks/alerts/{project}/{token}` | +| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta con votos, versiones, sprint en curso, tickets `good-first-issue` y propuestas abiertas; `POST …/proposals` (sin token) | +| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` | Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto diff --git a/docs/04-cliente-web.md b/docs/04-cliente-web.md index 53c4c2b..f7b88ce 100644 --- a/docs/04-cliente-web.md +++ b/docs/04-cliente-web.md @@ -300,25 +300,25 @@ visualización de la plataforma: ## 4.6 Rutas -| Ruta | Página | -| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `/login` | Inicio de sesión (solo sin sesión) | -| `/auth/callback` | Retorno de Pireon (PKCE) | -| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» | -| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos | -| `/explore` | Proyectos públicos (sin sesión) | -| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre | -| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy | -| `/p/:key/releases` | Releases | -| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) | -| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea | -| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo | -| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) | -| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar | -| `/p/:key/activity` | Actividad en vivo | -| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» | -| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» | -| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos | +| Ruta | Página | +| -------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `/login` | Inicio de sesión (solo sin sesión) | +| `/auth/callback` | Retorno de Pireon (PKCE) | +| `/` | Inicio: saludo según la hora con el resumen del día, «Ir a…» (`WEB-12`), primeros pasos, mis proyectos con su actividad (lo primero, para llegar antes), «Mi día» (`ts-my-day`: los tickets que elijo para hoy, tachados al terminar, mis abiertos para añadir y lo que quedó del último día), mis fijados (`ts-pinned`, una franja que se quita con ×; solo si hay), cuatro cifras (`ts-home-kpis`: abiertos míos, esperando QA, terminados por mí y por el equipo esta semana), la semana (`ts-week-agenda`), mis vistas guardadas y dos columnas: mis tickets y lo último en mis proyectos; sprints en curso, esperando QA, que sigo, próximas releases, quién está ahora y «Retomar» | +| `/portfolio` | Cartera (con sesión): una tarjeta por proyecto activo con lo abierto y en riesgo, el sprint en curso (¿llega?), las próximas releases con su previsión y las épicas abiertas; en directo: se vuelve a leer sola cuando cambia un ticket, sprint o release de esos proyectos | +| `/explore` | Proyectos públicos (sin sesión) | +| `/p/:key/board` | Tablero kanban (`?release=` y `?assignee=` preseleccionan los filtros). Quien puede contribuir cambia prioridad, estimación y persona desde la propia tarjeta (selectores nativos sobre sus distintivos; el título es el enlace). Mover una tarjeta o cambiar su persona ofrece «Deshacer» unos segundos (o Ctrl/⌘ Z): la orden contraria, que el flujo puede rechazar. Con el teclado: Tab entra en las tarjetas, las flechas se mueven entre ellas, Shift+←/→ pasa la tarjeta de columna (mismas reglas que arrastrar), `p`/`e`/`a` abren su prioridad, estimación o persona y Enter la abre | +| `/p/:key/issues/:issueKey` | Ticket. Encima del título, quién más lo está viendo o editando ahora (`PresenceFacade`: late cada 10 s; editar es tener abierto el formulario del ticket o de un comentario, o estar escribiendo uno). «Fijar» lo deja a mano en el inicio y en la paleta de búsqueda. Arriba, sus decisiones (comentarios marcados con la bandera por quien puede cambiar el ticket); `?comment=` lleva a un comentario y lo señala. Cambiar el estado o la persona y mandarlo a la papelera (sin confirmación) ofrecen «Deshacer». Cada conversación con respuestas se resuelve («✓ Resolver», con una conclusión opcional que queda como decisión) y se pliega en una línea; la cabecera cuenta las abiertas y terminar el ticket con alguna pregunta antes. Se ve quién está escribiendo en cada conversación («Ana está escribiendo…», `PresenceFacade.typists`), y en las respuestas Enter envía (Shift+Enter, otra línea). Si tiene subtareas, «Seguir todo» avisa de ella y de todo lo que cuelga, también lo que se añada (también desde Épicas). «Recordármelo» pone un aviso solo para mí (dentro de 1 hora, esta tarde, mañana o el lunes a las 9, en una semana o cuando diga), con nota opcional. «☀ Mi día» lo añade a los de hoy. «Pedir revisión» a alguien del equipo: lo ve en «Mi trabajo» («Te piden revisión») y responde «Todo bien» o «Pedir cambios» desde el recuadro Revisiones | +| `/p/:key/releases` | Releases | +| `/p/:key/report/:kind/:id` | Informe para imprimir de un sprint (`sprint`) o una release (`release`): datos, totales, notas y tickets por etapa. «Imprimir o guardar en PDF» abre el diálogo del navegador; al imprimir se quitan la cabecera, las pestañas y los fondos (`styles.scss`) | +| `/p/:key/roadmap` | Hoja de ruta: épicas en el tiempo, con sprints y releases; flechas «bloquea» / «depende de» entre épicas y aviso si una empieza antes de que acabe la que la bloquea | +| `/p/:key/dashboard` | Seguimiento: cifras, flujo por estado, lo que ha cambiado desde tu última visita (guardada en este navegador), sprint en curso, tickets en riesgo, previsión de las próximas releases, antigüedad de lo que está en curso, cuellos de botella, salud del backlog (cada número abre la lista filtrada: `?missing=estimate`, `area`, `assignee` o `?staleDays=90`), calidad (bugs por prioridad y por semana, QA por área, bugs tras cada release), abiertos por un campo de lista a elegir, el recorrido de un ticket (dónde se va el tiempo de creado a hecho y qué parte es trabajo), la actividad de 12 semanas en un mapa de calor (pulsar un día abre lo que pasó, ticket a ticket), el embudo de propuestas del público (si las hay), equipo con sus puntos abiertos y tipos. Se recarga solo con cada cambio. «Modo pantalla» lo pone a pantalla completa para una tele del equipo: un bloque cada vez, ampliado, que cambia cada 15 s (← → para moverse, espacio para pausar, Esc para salir), con la hora y en directo | +| `/p/:key/analytics` | Estadísticas: creados vs. resueltos, flujo acumulado, burnup de una release, tiempos de ciclo y entrega, QA y resueltos por persona (30 días a 1 año) | +| `/p/:key/settings` | Configuración (propietario y admins; pestaña oculta para el resto), con un menú de secciones (`?section=`): General (visibilidad, propuestas, panel para clientes), Flujo de trabajo (estados, transiciones, definición de «Hecho»), Tickets (tipos, áreas, campos), Equipos, Automatizaciones, Integraciones (Git, alertas → bugs con su URL secreta y cómo conectar Sentry, Uptime Kuma o Grafana, webhooks), Importar y exportar y Zona peligrosa; archivar en lugar de borrar, y un punto en el menú donde hay cambios sin guardar | +| `/p/:key/activity` | Actividad en vivo | +| `/p/:key/propose` | Formulario público para reportar un error o proponer una idea sin cuenta (si el proyecto lo tiene abierto); llegan como tickets con la etiqueta «propuesta» | +| `/share/:token` | Panel para clientes, sin sesión: los bloques que eligió el proyecto (flujo, sprint, releases, épicas, actividad), sin enlaces hacia dentro de la aplicación; se vuelve a leer cada minuto. Si el enlace se renovó o retiró, «Este enlace ya no funciona» | +| `/p/:key/members` | Miembros, con su rol (propietario, admin, miembro, lector o invitado). Un invitado de un proyecto privado solo tiene Tablero, Lista y Miembros, ve solo sus tickets y abre tickets sin asignarlos ni planificarlos | Solo `/` está protegida por `authGuard` (redirige a `/login?returnUrl=…`); `/login` y `/auth/callback` son para quien no tiene sesión, y el resto se puede abrir sin ella si el proyecto es público. Los parámetros de ruta llegan como inputs de diff --git a/docs/09-mcp.md b/docs/09-mcp.md index c2a12bf..ce7c3cd 100644 --- a/docs/09-mcp.md +++ b/docs/09-mcp.md @@ -52,7 +52,7 @@ flowchart LR | | `get_my_week` | Mi semana: lo mío que vence en 7 días, fin de sprints y releases; lo terminado por mí y por mis equipos, racha; sprints en curso y qué podría coger | | | `get_project_insights` | El panel: sprint, riesgo, antigüedad, cuellos de botella, releases, equipo, backlog, calidad, campos, actividad por día, recorrido de un ticket, propuestas y, con `since`, los cambios | | | `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 | +| 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; `reviewing`: los que me piden revisar | | | `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, decisiones y comentarios | | | `create_issue` | Crear un ticket (también colgado de una épica) | @@ -70,6 +70,8 @@ flowchart LR | | `resolve_thread` | Resolver una conversación (con conclusión opcional, que queda como decisión) o reabrirla | | | `remind_me` | Recordármelo: un aviso solo para mí sobre un ticket, a una fecha y hora, con nota (o quitarlo) | | | `my_day` | Mi día: los tickets elegidos para hoy (y lo que quedó del último día); añadir o quitar uno | +| | `request_review` | Pedir a alguien del proyecto que revise un ticket | +| | `answer_review` | Responder una revisión: todo bien o cambios | | | `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 | diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index 150e750..eeb19be 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -49,6 +49,7 @@ export * from './lib/issue-reminders.service'; export * from './lib/my-day/my-day'; export * from './lib/ports/my-days.repository'; export * from './lib/my-day.service'; +export * from './lib/commands/reviews'; export * from './lib/tracker-application.module'; export * from './lib/ports/presence.store'; export * from './lib/presence.service'; diff --git a/libs/api/tracker/application/src/lib/commands/command-handlers.ts b/libs/api/tracker/application/src/lib/commands/command-handlers.ts index 3ac462e..54593ff 100644 --- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts +++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts @@ -1,6 +1,7 @@ import { ReactToCommentCommandHandler } from './react-to-comment.command-handler'; import { MarkDecisionCommandHandler } from './mark-decision.command-handler'; import { ResolveThreadCommandHandler } from './resolve-thread.command-handler'; +import { REVIEW_HANDLERS } from './reviews'; import { VoteIssueCommandHandler } from './vote-issue.command-handler'; import { CreateProjectCommandHandler } from './create-project.command-handler'; import { UpdateProjectCommandHandler } from './update-project.command-handler'; @@ -50,6 +51,7 @@ export const COMMAND_HANDLERS = [ ReactToCommentCommandHandler, MarkDecisionCommandHandler, ResolveThreadCommandHandler, + ...REVIEW_HANDLERS, 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 8f53d00..526350a 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -19,6 +19,7 @@ import { Project, IssuePriority, IssueType, + ReviewVerdict, ProjectRole, TRACKER_FACTORIES, defaultProjectSettings, @@ -79,6 +80,11 @@ import { AddCommentCommand } from './add-comment.command'; import { ReactToCommentCommand } from './react-to-comment.command'; import { MarkDecisionCommand } from './mark-decision.command'; import { ResolveThreadCommand } from './resolve-thread.command'; +import { + AnswerReviewCommand, + CancelReviewCommand, + RequestReviewCommand, +} from './reviews'; import { UpdateProjectCommand } from './update-project.command'; import { VoteIssueCommand } from './vote-issue.command'; import { AddProjectMemberCommand } from './add-project-member.command'; @@ -944,6 +950,74 @@ describe('tracker commands (event sourced)', () => { ); }); + it('asks a teammate to review a ticket, who answers it', async () => { + const { run, rehydrator } = await setup(); + const projectId = await projectWithTeam(run); + const issue = await run( + new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Login', {}), + ); + await run( + new RequestReviewCommand('dev', issue.id, 'qa', ' Mira el móvil '), + ); + // Not oneself, not twice the same open one, not outsiders. + await expect( + run(new RequestReviewCommand('dev', issue.id, 'dev')), + ).rejects.toThrow(ValidationError); + await expect( + run(new RequestReviewCommand('dev', issue.id, 'qa')), + ).rejects.toThrow(BusinessRuleError); + await expect( + run(new RequestReviewCommand('dev', issue.id, 'stranger')), + ).rejects.toThrow(BusinessRuleError); + // A viewer can be asked and answer, but not ask. + await run(new RequestReviewCommand('dev', issue.id, 'pm')); + await expect( + run(new RequestReviewCommand('pm', issue.id, 'qa')), + ).rejects.toThrow(ForbiddenError); + + let reviews = (await rehydrator.rehydrate(issue.id, Issue)).reviews; + expect(reviews.map((r) => [r.reviewerId, r.note, r.verdict])).toEqual([ + ['qa', 'Mira el móvil', null], + ['pm', null, null], + ]); + const [forQa, forPm] = reviews; + await expect( + run( + new AnswerReviewCommand( + 'dev', + issue.id, + forQa.id, + ReviewVerdict.APPROVED, + ), + ), + ).rejects.toThrow(ForbiddenError); + await run( + new AnswerReviewCommand( + 'qa', + issue.id, + forQa.id, + ReviewVerdict.CHANGES, + 'Falla en iOS', + ), + ); + await expect( + run( + new AnswerReviewCommand( + 'qa', + issue.id, + forQa.id, + ReviewVerdict.APPROVED, + ), + ), + ).rejects.toThrow(BusinessRuleError); + await run(new CancelReviewCommand('dev', issue.id, forPm.id)); + + reviews = (await rehydrator.rehydrate(issue.id, Issue)).reviews; + expect(reviews.map((r) => [r.reviewerId, r.verdict, r.answer])).toEqual([ + ['qa', ReviewVerdict.CHANGES, 'Falla en iOS'], + ]); + }); + it('settles a conversation, keeping its conclusion as a decision', async () => { const { run, rehydrator, store } = await setup(); const projectId = await projectWithTeam(run); diff --git a/libs/api/tracker/application/src/lib/commands/reviews.ts b/libs/api/tracker/application/src/lib/commands/reviews.ts new file mode 100644 index 0000000..0339889 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/reviews.ts @@ -0,0 +1,132 @@ +import { randomUUID } from 'node:crypto'; +import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { + BusinessRuleError, + ForbiddenError, +} from '@task-stream/api/shared/domain'; +import { ProjectRole, ReviewVerdict } from '@task-stream/api/tracker/domain'; +import { CommandResult, ack } from './command-result'; +import { WriteAccess } from './write-access'; + +/* + * «Ana, can you review this?»: a request on a ticket that the reviewer + * answers (all good, or changes wanted). Events of the ticket, so they show + * in its history and in the activity feed. + */ + +export class RequestReviewCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + public readonly reviewerId: string, + public readonly note: string | null = null, + ) { + super(); + } +} + +export class AnswerReviewCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + public readonly reviewId: string, + public readonly verdict: ReviewVerdict, + public readonly answer: string | null = null, + ) { + super(); + } +} + +export class CancelReviewCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + public readonly reviewId: string, + ) { + super(); + } +} + +/** The team asks for reviews: whoever can change the ticket. */ +@CommandHandler(RequestReviewCommand) +export class RequestReviewCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: RequestReviewCommand): Promise { + const { issue, project } = await this.access.issueForContributor( + command.issueId, + command.actorId, + ); + // Someone who reads the whole project: a guest would not see the ticket. + const role = project.roleOf(command.reviewerId); + if (!role || (role === ProjectRole.GUEST && !project.isPublic)) + throw new BusinessRuleError( + `Only members of ${project.key} can be asked to review`, + ); + issue.requestReview( + command.actorId, + randomUUID(), + command.reviewerId, + command.note, + ); + await issue.commitAndWait(); + return ack(issue); + } +} + +/** The person asked answers; reading the ticket is enough (viewers too). */ +@CommandHandler(AnswerReviewCommand) +export class AnswerReviewCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: AnswerReviewCommand): Promise { + const issue = await this.access.issue(command.issueId); + const project = await this.access.project(issue.projectId); + if (!project.isMember(command.actorId)) + throw new ForbiddenError( + `You are not a member of project ${project.key}`, + ); + issue.answerReview( + command.actorId, + command.reviewId, + command.verdict, + command.answer, + ); + await issue.commitAndWait(); + return ack(issue); + } +} + +@CommandHandler(CancelReviewCommand) +export class CancelReviewCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: CancelReviewCommand): Promise { + const issue = await this.access.issue(command.issueId); + const project = await this.access.project(issue.projectId); + if (!project.isMember(command.actorId)) + throw new ForbiddenError( + `You are not a member of project ${project.key}`, + ); + const role = project.roleOf(command.actorId); + issue.cancelReview( + command.actorId, + command.reviewId, + role === ProjectRole.OWNER || role === ProjectRole.ADMIN, + ); + await issue.commitAndWait(); + return ack(issue); + } +} + +export const REVIEW_HANDLERS = [ + RequestReviewCommandHandler, + AnswerReviewCommandHandler, + CancelReviewCommandHandler, +]; diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts index bc78b73..ef92be2 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity-summary.ts @@ -9,6 +9,10 @@ import { IssueChecklistItemRemovedEvent, IssueChecklistItemUpdatedEvent, IssueParentChangedEvent, + IssueReviewAnsweredEvent, + IssueReviewCancelledEvent, + IssueReviewRequestedEvent, + ReviewVerdict, IssueDevLinkRecordedEvent, IssueTrashedEvent, IssueRestoredEvent, @@ -191,6 +195,14 @@ export function describeEvent( : 'abierta'; return `Pull request ${ref} ${verb} para ${issueKey}${who}`; } + if (event instanceof IssueReviewRequestedEvent) + return `${actor} pidió a ${ctx.user(event.reviewerId)} que revise ${issueKey}`; + if (event instanceof IssueReviewAnsweredEvent) + return event.verdict === ReviewVerdict.APPROVED + ? `${actor} dio por bueno ${issueKey} al revisarlo` + : `${actor} pidió cambios en ${issueKey} al revisarlo`; + if (event instanceof IssueReviewCancelledEvent) + return `${actor} retiró una petición de revisión de ${issueKey}`; if (event instanceof IssueParentChangedEvent) { return event.parentKey ? `${actor} colocó ${issueKey} bajo ${event.parentKey}` diff --git a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts index 57045af..15bbeaa 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/activity.event-handler.ts @@ -11,6 +11,9 @@ import { IssueChecklistItemRemovedEvent, IssueChecklistItemUpdatedEvent, IssueParentChangedEvent, + IssueReviewAnsweredEvent, + IssueReviewCancelledEvent, + IssueReviewRequestedEvent, IssueDevLinkRecordedEvent, IssueTrashedEvent, IssueRestoredEvent, @@ -75,6 +78,9 @@ import { versionOf } from './read-model-notifier'; IssueAttachmentAddedEvent, IssueAttachmentRemovedEvent, IssueParentChangedEvent, + IssueReviewRequestedEvent, + IssueReviewAnsweredEvent, + IssueReviewCancelledEvent, IssueDevLinkRecordedEvent, IssueTrashedEvent, IssueRestoredEvent, @@ -127,6 +133,7 @@ export class ActivityEventHandler implements IEventHandler { event.actorId, (event as { userId?: string }).userId, (event as { assigneeId?: string }).assigneeId, + (event as { reviewerId?: string }).reviewerId, ]; const users = await this.users.refs(mentioned); const actorOf = (id: string) => 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 6d09952..8310d9e 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 @@ -5,6 +5,7 @@ import { CommentEditedEventHandler } from './comment-edited.event-handler'; import { CommentReactedEventHandler } from './comment-reacted.event-handler'; import { CommentDecisionMarkedEventHandler } from './comment-decision-marked.event-handler'; import { CommentThreadResolvedEventHandler } from './comment-thread-resolved.event-handler'; +import { IssueReviewsEventHandler } from './issue-reviews.event-handler'; import { IssueVotedEventHandler } from './issue-voted.event-handler'; import { IssueAssignedEventHandler } from './issue-assigned.event-handler'; import { IssueCreatedEventHandler } from './issue-created.event-handler'; @@ -54,6 +55,7 @@ export const EVENT_HANDLERS = [ CommentReactedEventHandler, CommentDecisionMarkedEventHandler, CommentThreadResolvedEventHandler, + IssueReviewsEventHandler, IssueVotedEventHandler, IssueAssignedEventHandler, IssueCreatedEventHandler, diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-reviews.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-reviews.event-handler.ts new file mode 100644 index 0000000..f2faaca --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-reviews.event-handler.ts @@ -0,0 +1,74 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { + IssueReviewAnsweredEvent, + IssueReviewCancelledEvent, + IssueReviewRequestedEvent, +} from '@task-stream/api/tracker/domain'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; +import { UserDirectory } from '../ports/user-directory'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +type ReviewEvent = + | IssueReviewRequestedEvent + | IssueReviewAnsweredEvent + | IssueReviewCancelledEvent; + +/** The ticket's review requests, as its page and «my work» show them. */ +@EventsHandler( + IssueReviewRequestedEvent, + IssueReviewAnsweredEvent, + IssueReviewCancelledEvent, +) +export class IssueReviewsEventHandler implements IEventHandler { + constructor( + private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository, + private readonly findIssues: FindIssuesRepository, + private readonly users: UserDirectory, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: ReviewEvent) { + const issue = await this.findIssues.findById(event.aggregateId); + if (!issue) return; + // Events of a stream come in order: the list read is the one before. + let reviews = issue.reviews ?? []; + if (event instanceof IssueReviewRequestedEvent) { + if (reviews.some((r) => r.id === event.reviewId)) return; + const refs = await this.users.refs([event.actorId, event.reviewerId]); + reviews = [ + ...reviews, + { + id: event.reviewId, + requester: refs.of(event.actorId), + reviewer: refs.of(event.reviewerId), + note: event.note, + requestedAt: event.occurredAt, + verdict: null, + answer: null, + answeredAt: null, + }, + ]; + } else if (event instanceof IssueReviewAnsweredEvent) { + reviews = reviews.map((r) => + r.id === event.reviewId + ? { + ...r, + verdict: event.verdict, + answer: event.answer, + answeredAt: event.occurredAt, + } + : r, + ); + } else { + reviews = reviews.filter((r) => r.id !== event.reviewId); + } + // A review is not a change to the ticket itself: `updatedAt` stays. + await this.upsertMaterializedIssueRepository.upsert({ + id: event.aggregateId, + reviews, + version: versionOf(event), + }); + await this.notifier.issue(event.aggregateId, event); + } +} diff --git a/libs/api/tracker/application/src/lib/issues.service.ts b/libs/api/tracker/application/src/lib/issues.service.ts index c1a5b15..fd7ead4 100644 --- a/libs/api/tracker/application/src/lib/issues.service.ts +++ b/libs/api/tracker/application/src/lib/issues.service.ts @@ -9,6 +9,7 @@ import { IssueLinkType, IssuePriority, IssueReadModel, + ReviewVerdict, SYSTEM_ACTOR_ID, } from '@task-stream/api/tracker/domain'; import { BulkChange, BulkResult, MAX_BULK } from './bulk-change'; @@ -21,6 +22,11 @@ import { } from './commands/attach-file.command'; import { DetachFileCommand } from './commands/detach-file.command'; import { WatchIssueCommand } from './commands/watch-issue.command'; +import { + AnswerReviewCommand, + CancelReviewCommand, + RequestReviewCommand, +} from './commands/reviews'; import { SetIssueParentCommand } from './commands/set-issue-parent.command'; import { RankIssueCommand } from './commands/rank-issue.command'; import { DeleteIssueCommand } from './commands/delete-issue.command'; @@ -212,6 +218,41 @@ export class IssuesService { ); } + /** Asks `reviewerId` (a member) to review the ticket. */ + requestReview( + actorId: string, + issueId: string, + reviewerId: string, + note: string | null, + ): Promise { + return this.commands.execute( + new RequestReviewCommand(actorId, issueId, reviewerId, note), + ); + } + + /** The person asked answers: all good, or changes wanted. */ + answerReview( + actorId: string, + issueId: string, + reviewId: string, + verdict: ReviewVerdict, + answer: string | null, + ): Promise { + return this.commands.execute( + new AnswerReviewCommand(actorId, issueId, reviewId, verdict, answer), + ); + } + + cancelReview( + actorId: string, + issueId: string, + reviewId: string, + ): Promise { + return this.commands.execute( + new CancelReviewCommand(actorId, issueId, reviewId), + ); + } + /** A ticket's file, with its bytes, for whoever can read the ticket. */ download( viewerId: string | null, diff --git a/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts index 8ffbf8b..711a189 100644 --- a/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts +++ b/libs/api/tracker/application/src/lib/notifications/notifications.event-handler.ts @@ -5,6 +5,9 @@ import { IssueCreatedEvent, IssueParentChangedEvent, IssuePriority, + IssueReviewAnsweredEvent, + IssueReviewRequestedEvent, + ReviewVerdict, IssueSprintChangedEvent, IssueStatusChangedEvent, IssueUpdatedEvent, @@ -69,6 +72,8 @@ const excerpt = (text: string, max = 120) => { IssueUpdatedEvent, IssueSprintChangedEvent, IssueParentChangedEvent, + IssueReviewRequestedEvent, + IssueReviewAnsweredEvent, CommentAddedEvent, ProjectMemberAddedEvent, ) @@ -161,6 +166,29 @@ export class NotificationsEventHandler implements IEventHandler { }); } } + if (event instanceof IssueReviewRequestedEvent) { + const note = event.note ? `: «${excerpt(event.note)}»` : ''; + drafts.push({ + userId: event.reviewerId, + kind: NotificationKind.REVIEW_REQUESTED, + summary: `${actor} te pide que revises ${ticket}${note}`, + }); + } + if (event instanceof IssueReviewAnsweredEvent) { + const review = ('reviews' in issue ? (issue.reviews ?? []) : []).find( + (r) => r.id === event.reviewId, + ); + const answer = event.answer ? `: «${excerpt(event.answer)}»` : ''; + if (review) + drafts.push({ + userId: review.requester.id, + kind: NotificationKind.REVIEW_ANSWERED, + summary: + event.verdict === ReviewVerdict.APPROVED + ? `${actor} revisó ${ticket} y lo da por bueno${answer}` + : `${actor} revisó ${ticket} y pide cambios${answer}`, + }); + } // A ticket new under a followed one (created there or moved in): the // only way to hear of it. if (event instanceof IssueParentChangedEvent && event.parentId) { diff --git a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts index 927d68c..6c5b99f 100644 --- a/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/find-issues.repository.ts @@ -47,6 +47,8 @@ export interface IssueFilter { watching?: boolean; /** Only the ones this person follows. */ watcherId?: string; + /** Only the ones this person was asked to review and has not answered. */ + reviewerId?: string; /** Only the ones this person opened, has or follows (a guest's view). */ participantId?: string; /** RECENT (default): last updated first. RANK: backlog order. */ @@ -62,6 +64,8 @@ export interface CrossProjectIssueFilter { assigneeId?: string; /** Only the ones this person follows. */ watcherId?: string; + /** Only the ones this person was asked to review and has not answered. */ + reviewerId?: string; statuses?: string[]; categories?: StatusCategory[]; priorities?: IssuePriority[]; diff --git a/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts b/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts index ddd77bf..d8a4c38 100644 --- a/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts +++ b/libs/api/tracker/application/src/lib/queries/search-my-issues.query-handler.ts @@ -56,6 +56,7 @@ export class SearchMyIssuesQueryHandler : undefined, assigneeId: query.filter.assignedToMe ? query.viewerId : undefined, watcherId: query.filter.watching ? query.viewerId : undefined, + reviewerId: query.filter.reviewing ? query.viewerId : undefined, statuses: query.filter.statuses, categories: filter.overdue ? (filter.categories?.length ? filter.categories : open).filter((c) => diff --git a/libs/api/tracker/application/src/lib/queries/search-my-issues.query.ts b/libs/api/tracker/application/src/lib/queries/search-my-issues.query.ts index 04238a7..b4369f1 100644 --- a/libs/api/tracker/application/src/lib/queries/search-my-issues.query.ts +++ b/libs/api/tracker/application/src/lib/queries/search-my-issues.query.ts @@ -11,6 +11,8 @@ export interface MyIssuesFilter { assignedToMe: boolean; /** Only the tickets the viewer follows. */ watching?: boolean; + /** Only the tickets the viewer was asked to review (unanswered). */ + reviewing?: boolean; statuses?: string[]; /** e.g. TODO + IN_PROGRESS + QA = still open, whatever the project calls them. */ categories?: StatusCategory[]; diff --git a/libs/api/tracker/domain/src/index.ts b/libs/api/tracker/domain/src/index.ts index 8e3c881..ae3ea99 100644 --- a/libs/api/tracker/domain/src/index.ts +++ b/libs/api/tracker/domain/src/index.ts @@ -8,6 +8,7 @@ export * from './lib/issue/issue-hierarchy'; export * from './lib/issue/issue-checklist'; export * from './lib/issue/issue-rank'; export * from './lib/issue/dev-link'; +export * from './lib/issue/review'; export * from './lib/project/project-settings'; export * from './lib/comment/comment'; export * from './lib/release/release'; diff --git a/libs/api/tracker/domain/src/lib/events/index.ts b/libs/api/tracker/domain/src/lib/events/index.ts index 3d5de21..1474898 100644 --- a/libs/api/tracker/domain/src/lib/events/index.ts +++ b/libs/api/tracker/domain/src/lib/events/index.ts @@ -17,6 +17,7 @@ export * from './comment-edited.event'; export * from './comment-reacted.event'; export * from './comment-decision-marked.event'; export * from './comment-thread-resolved.event'; +export * from './issue-review.events'; export * from './comment-deleted.event'; export * from './release-created.event'; export * from './release-updated.event'; diff --git a/libs/api/tracker/domain/src/lib/events/issue-review.events.ts b/libs/api/tracker/domain/src/lib/events/issue-review.events.ts new file mode 100644 index 0000000..47c0b84 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-review.events.ts @@ -0,0 +1,46 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { ReviewVerdict } from '../issue/review'; +import { TrackerEvent } from './tracker.event'; + +/** Someone asked someone else to review a ticket. */ +@AutowiredEvent +export class IssueReviewRequestedEvent extends TrackerEvent { + constructor( + actorId: string, + issueId: string, + projectId: string, + readonly reviewId: string, + readonly reviewerId: string, + readonly note: string | null, + ) { + super(actorId, issueId, projectId); + } +} + +/** The reviewer answered: all good, or changes wanted. */ +@AutowiredEvent +export class IssueReviewAnsweredEvent extends TrackerEvent { + constructor( + actorId: string, + issueId: string, + projectId: string, + readonly reviewId: string, + readonly verdict: ReviewVerdict, + readonly answer: string | null, + ) { + super(actorId, issueId, projectId); + } +} + +/** A review request was withdrawn before it was answered. */ +@AutowiredEvent +export class IssueReviewCancelledEvent extends TrackerEvent { + constructor( + actorId: string, + issueId: string, + projectId: string, + readonly reviewId: string, + ) { + super(actorId, issueId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts index be172ff..222bff3 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.ts @@ -1,6 +1,7 @@ import { BusinessRuleError, ForbiddenError, + NotFoundError, VersionedAggregateRoot, assertThat, } from '@task-stream/api/shared/domain'; @@ -41,6 +42,17 @@ import { ParentCandidate, assertCanNest } from './issue-hierarchy'; import { DevLink, MAX_DEV_LINKS, normalizeDevLink } from './dev-link'; import { implicitRank, rankBetween } from './issue-rank'; import { IssueLinkType, IssuePriority } from './issue.enums'; +import { + IssueReview, + MAX_OPEN_REVIEWS, + MAX_REVIEW_NOTE, + ReviewVerdict, +} from './review'; +import { + IssueReviewAnsweredEvent, + IssueReviewCancelledEvent, + IssueReviewRequestedEvent, +} from '../events/issue-review.events'; export interface IssueLink { type: IssueLinkType; @@ -136,6 +148,8 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { public attachments: IssueAttachment[] = []; /** People who asked to be told about this ticket. */ public watchers: string[] = []; + /** Asked reviews, open and answered, oldest first. */ + public reviews: IssueReview[] = []; /** People who want it done (a public roadmap's way to prioritise). */ public voterIds: string[] = []; /** The epic or ticket this one breaks down. */ @@ -404,6 +418,96 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { ); } + /** + * Asks `reviewerId` to review it. Not oneself, and not twice to the same + * person while the first is unanswered. Who may be asked is the caller's + * check (a member of the team). + */ + requestReview( + actorId: string, + reviewId: string, + reviewerId: string, + note: string | null, + ): void { + if (this.trashedAt) + throw new BusinessRuleError( + `${this.key} is in the trash: restore it to change it`, + ); + assertThat(reviewerId !== actorId, 'Ask someone else to review it'); + const text = note?.trim() || null; + assertThat( + !text || text.length <= MAX_REVIEW_NOTE, + `A review request takes ${MAX_REVIEW_NOTE} characters at most`, + ); + const open = this.reviews.filter((r) => !r.verdict); + if (open.some((r) => r.reviewerId === reviewerId)) + throw new BusinessRuleError( + 'That person already has an unanswered review request on this ticket', + ); + assertThat( + open.length < MAX_OPEN_REVIEWS, + `A ticket takes ${MAX_OPEN_REVIEWS} open review requests at most`, + ); + this.apply( + new IssueReviewRequestedEvent( + actorId, + this.id, + this.projectId, + reviewId, + reviewerId, + text, + ), + ); + } + + /** The reviewer answers an open request of theirs. */ + answerReview( + actorId: string, + reviewId: string, + verdict: ReviewVerdict, + answer: string | null, + ): void { + const review = this.openReview(reviewId); + if (review.reviewerId !== actorId) + throw new ForbiddenError('Only the person asked can answer the review'); + const text = answer?.trim() || null; + assertThat( + !text || text.length <= MAX_REVIEW_NOTE, + `An answer takes ${MAX_REVIEW_NOTE} characters at most`, + ); + this.apply( + new IssueReviewAnsweredEvent( + actorId, + this.id, + this.projectId, + reviewId, + verdict, + text, + ), + ); + } + + /** + * Withdraws an open request: whoever asked, or (`canModerate`) whoever + * manages the project. + */ + cancelReview(actorId: string, reviewId: string, canModerate: boolean): void { + const review = this.openReview(reviewId); + if (review.requesterId !== actorId && !canModerate) + throw new ForbiddenError('Only whoever asked can withdraw the request'); + this.apply( + new IssueReviewCancelledEvent(actorId, this.id, this.projectId, reviewId), + ); + } + + private openReview(reviewId: string): IssueReview { + const review = this.reviews.find((r) => r.id === reviewId); + if (!review) throw new NotFoundError('Review', reviewId); + if (review.verdict) + throw new BusinessRuleError('That review was already answered'); + return review; + } + /** `userId` starts following the ticket. Idempotent. */ watch(actorId: string, userId: string): void { if (this.watchers.includes(userId)) return; @@ -625,6 +729,39 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { this.voterIds = event.voterIds; } + onIssueReviewRequestedEvent(event: IssueReviewRequestedEvent): void { + this.reviews = [ + ...(this.reviews ?? []), + { + id: event.reviewId, + requesterId: event.actorId, + reviewerId: event.reviewerId, + note: event.note, + requestedAt: event.occurredAt, + verdict: null, + answer: null, + answeredAt: null, + }, + ]; + } + + onIssueReviewAnsweredEvent(event: IssueReviewAnsweredEvent): void { + this.reviews = this.reviews.map((r) => + r.id === event.reviewId + ? { + ...r, + verdict: event.verdict, + answer: event.answer, + answeredAt: event.occurredAt, + } + : r, + ); + } + + onIssueReviewCancelledEvent(event: IssueReviewCancelledEvent): void { + this.reviews = this.reviews.filter((r) => r.id !== event.reviewId); + } + onIssueWatchedEvent(event: IssueWatchedEvent): void { this.watchers = [...this.watchers, event.userId]; } diff --git a/libs/api/tracker/domain/src/lib/issue/review.ts b/libs/api/tracker/domain/src/lib/issue/review.ts new file mode 100644 index 0000000..cd0aea0 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/issue/review.ts @@ -0,0 +1,23 @@ +/** What a reviewer said. */ +export enum ReviewVerdict { + APPROVED = 'APPROVED', + CHANGES = 'CHANGES', +} + +/** «Ana, can you review this?» on a ticket. */ +export interface IssueReview { + id: string; + requesterId: string; + reviewerId: string; + /** What to look at, in a line or two. */ + note: string | null; + requestedAt: string; + /** Null while waiting for an answer. */ + verdict: ReviewVerdict | null; + answer: string | null; + answeredAt: string | null; +} + +export const MAX_REVIEW_NOTE = 1000; +/** Open requests a ticket takes at once. */ +export const MAX_OPEN_REVIEWS = 10; diff --git a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts index 76b6763..98dca40 100644 --- a/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/issue.read-model.ts @@ -1,3 +1,4 @@ +import { ReviewVerdict } from '../issue/review'; import { DevLink } from '../issue/dev-link'; import { IssueLinkType, IssuePriority } from '../issue/issue.enums'; import { StatusCategory } from '../project/project-settings'; @@ -85,6 +86,8 @@ export class IssueReadModel { attachments!: AttachmentReadModel[]; /** People following the ticket. */ watchers!: UserRefReadModel[]; + /** Asked reviews, open and answered, oldest first. */ + reviews!: IssueReviewReadModel[]; /** How many want it done. */ votes!: number; /** Who voted: to tell someone whether they did; never shown as a list. */ @@ -103,3 +106,16 @@ export class IssueReadModel { /** Position of the last projected event. */ version!: number; } + +/** A review asked on a ticket, as the read side shows it. */ +export class IssueReviewReadModel { + id!: string; + requester!: UserRefReadModel; + reviewer!: UserRefReadModel; + note!: string | null; + requestedAt!: string; + /** Null while waiting for an answer. */ + verdict!: ReviewVerdict | null; + answer!: string | null; + answeredAt!: string | null; +} diff --git a/libs/api/tracker/domain/src/lib/read-models/notification.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/notification.read-model.ts index e08a14e..d4f286b 100644 --- a/libs/api/tracker/domain/src/lib/read-models/notification.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/notification.read-model.ts @@ -22,6 +22,10 @@ export enum NotificationKind { ADDED_TO_PROJECT = 'ADDED_TO_PROJECT', /** A ticket of yours is due soon or has been still for too long. */ REMINDER = 'REMINDER', + /** Someone asked you to review a ticket. */ + REVIEW_REQUESTED = 'REVIEW_REQUESTED', + /** The review you asked for was answered. */ + REVIEW_ANSWERED = 'REVIEW_ANSWERED', } /** 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 ed2353e..bc440b0 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -176,6 +176,10 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { }; if (filter.assigneeId) query['assignee.id'] = filter.assigneeId; if (filter.watcherId) query['watchers.id'] = filter.watcherId; + if (filter.reviewerId) + query['reviews'] = { + $elemMatch: { 'reviewer.id': filter.reviewerId, verdict: null }, + }; if (filter.statuses?.length) query['status'] = { $in: filter.statuses }; if (filter.categories?.length) query['statusCategory'] = { $in: filter.categories }; @@ -497,6 +501,10 @@ function toMongoFilter(filter: IssueFilter): FilterQuery { if (filter.unassigned) query['assignee'] = null; else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId; if (filter.watcherId) query['watchers.id'] = filter.watcherId; + if (filter.reviewerId) + query['reviews'] = { + $elemMatch: { 'reviewer.id': filter.reviewerId, verdict: null }, + }; if (filter.participantId) query.$and = [ ...(query.$and ?? []), 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 5680165..35b8200 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 @@ -524,6 +524,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel { // Tickets projected before attachments existed have none. attachments: rest.attachments ?? [], watchers: rest.watchers ?? [], + reviews: rest.reviews ?? [], votes: rest.votes ?? 0, voterIds: rest.voterIds ?? [], parent: rest.parent?.id ? rest.parent : null, diff --git a/libs/api/tracker/presenters/src/lib/graphql/enums.ts b/libs/api/tracker/presenters/src/lib/graphql/enums.ts index af53657..e82469e 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/enums.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/enums.ts @@ -15,6 +15,7 @@ import { StatusCategory, DevLinkKind, DevLinkState, + ReviewVerdict, } from '@task-stream/api/tracker/domain'; export enum LinkDirection { @@ -23,6 +24,10 @@ export enum LinkDirection { } registerEnumType(IssuePriority, { name: 'IssuePriority' }); +registerEnumType(ReviewVerdict, { + name: 'ReviewVerdict', + description: 'APPROVED: all good. CHANGES: something to change', +}); registerEnumType(CustomFieldType, { name: 'CustomFieldType', description: 'What a custom field holds (DATE: YYYY-MM-DD)', 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 616654b..9f5fb12 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -27,6 +27,7 @@ import { import { IssuePriority, IssueReadModel, + ReviewVerdict, StatusCategory, } from '@task-stream/api/tracker/domain'; import { @@ -168,6 +169,11 @@ export class IssueResolver { description: 'Only the tickets I follow', }) watching: boolean, + @Args('reviewing', { + defaultValue: false, + description: 'Only the tickets I was asked to review (unanswered)', + }) + reviewing: boolean, @Args('statuses', { type: () => [String], nullable: true }) statuses: string[] | undefined, @Args('categories', { type: () => [StatusCategory], nullable: true }) @@ -204,6 +210,7 @@ export class IssueResolver { return this.issuesService.mine(me.id, { assignedToMe, watching, + reviewing, statuses: statuses ?? undefined, categories: categories ?? undefined, search: search ?? undefined, @@ -508,6 +515,54 @@ export class IssueResolver { return true; } + @Mutation(() => CommandResultGql, { + description: + 'Ask a member to review a ticket (with an optional note): they are told and see it in their work', + }) + requestReview( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + @Args('reviewerId', { type: () => ID }) reviewerId: string, + @Args('note', { nullable: true }) note: string | null, + ) { + return this.issuesService.requestReview( + me.id, + issueId, + reviewerId, + note ?? null, + ); + } + + @Mutation(() => CommandResultGql, { + description: 'Answer a review I was asked for: all good, or changes', + }) + answerReview( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + @Args('reviewId', { type: () => ID }) reviewId: string, + @Args('verdict', { type: () => ReviewVerdict }) verdict: ReviewVerdict, + @Args('answer', { nullable: true }) answer: string | null, + ) { + return this.issuesService.answerReview( + me.id, + issueId, + reviewId, + verdict, + answer ?? null, + ); + } + + @Mutation(() => CommandResultGql, { + description: 'Withdraw a review request I made (or, as admin, any)', + }) + cancelReview( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + @Args('reviewId', { type: () => ID }) reviewId: string, + ) { + return this.issuesService.cancelReview(me.id, issueId, reviewId); + } + @Mutation(() => CommandResultGql, { description: 'Stop following a ticket' }) unwatchIssue( @CurrentUser() me: User, diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 8bf2cfe..5000f57 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -10,6 +10,7 @@ import { StatusCategory, DevLinkKind, DevLinkState, + ReviewVerdict, } from '@task-stream/api/tracker/domain'; import { AutomationActionKind, @@ -371,6 +372,24 @@ export class IssueParentGql { @Field() title!: string; } +@ObjectType('IssueReview', { + description: 'A review someone asked on the ticket', +}) +export class IssueReviewGql { + @Field(() => ID) id!: string; + @Field(() => UserRefGql) requester!: UserRefGql; + @Field(() => UserRefGql) reviewer!: UserRefGql; + @Field(() => String, { nullable: true }) note!: string | null; + @Field() requestedAt!: string; + @Field(() => ReviewVerdict, { + nullable: true, + description: 'Null while waiting for an answer', + }) + verdict!: ReviewVerdict | null; + @Field(() => String, { nullable: true }) answer!: string | null; + @Field(() => String, { nullable: true }) answeredAt!: string | null; +} + @ObjectType('Issue') export class IssueGql { @Field(() => ID) id!: string; @@ -408,6 +427,10 @@ export class IssueGql { @Field(() => [AttachmentGql]) attachments!: AttachmentGql[]; @Field(() => [UserRefGql], { description: 'People following the ticket' }) watchers!: UserRefGql[]; + @Field(() => [IssueReviewGql], { + description: 'Reviews asked, open and answered, oldest first', + }) + reviews!: IssueReviewGql[]; @Field(() => IssueParentGql, { nullable: true, description: 'The epic or ticket this one breaks down', 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 0fbf706..d5a61b7 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 @@ -10,6 +10,7 @@ import { IssuePriority, ProjectRole, REACTIONS, + ReviewVerdict, SETTING_COLOR_PATTERN, StatusCategory, TYPE_ICONS, @@ -555,6 +556,14 @@ export class MyIssuesQueryDto extends PageQueryDto { @IsBoolean() watching?: boolean; + @ApiPropertyOptional({ + description: 'Only the tickets I was asked to review and have not answered', + }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + reviewing?: boolean; + @ApiPropertyOptional({ type: [String], description: 'Status ids. Repeat the parameter or separate with commas', @@ -1552,6 +1561,30 @@ export class PushEndpointDto { endpoint!: string; } +export class RequestReviewDto { + @ApiProperty({ description: "Reviewer's user id (a member of the project)" }) + @IsString() + reviewer!: string; + + @ApiPropertyOptional({ maxLength: 1000, description: 'What to look at' }) + @IsOptional() + @IsString() + @MaxLength(1000) + note?: string; +} + +export class AnswerReviewDto { + @ApiProperty({ enum: ReviewVerdict, enumName: 'ReviewVerdict' }) + @IsEnum(ReviewVerdict) + verdict!: ReviewVerdict; + + @ApiPropertyOptional({ maxLength: 1000 }) + @IsOptional() + @IsString() + @MaxLength(1000) + answer?: string; +} + export class MyDayQueryDto { @ApiProperty({ example: '2026-10-20', description: 'Today, in my time zone' }) @Matches(/^\d{4}-\d{2}-\d{2}$/) 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 0f49bf5..128506f 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 @@ -3,6 +3,7 @@ import { CustomFieldType, IssueLinkType, IssuePriority, + ReviewVerdict, NotificationKind, ProjectRole, ReleaseStatus, @@ -481,6 +482,11 @@ export class IssueDto { @ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[]; @ApiProperty({ type: [UserRefDto], description: 'People following it' }) watchers!: UserRefDto[]; + @ApiProperty({ + type: () => [IssueReviewDto], + description: 'Reviews asked, open and answered, oldest first', + }) + reviews!: IssueReviewDto[]; @ApiProperty({ type: IssueParentDto, nullable: true, @@ -1439,3 +1445,21 @@ export class MyDayDto { @ApiProperty({ type: MyDayLeftoverDto, nullable: true }) leftover!: MyDayLeftoverDto | null; } + +export class IssueReviewDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ type: UserRefDto }) requester!: UserRefDto; + @ApiProperty({ type: UserRefDto }) reviewer!: UserRefDto; + @ApiProperty({ type: String, nullable: true }) note!: string | null; + @ApiProperty({ format: 'date-time' }) requestedAt!: string; + @ApiProperty({ + enum: ReviewVerdict, + enumName: 'ReviewVerdict', + nullable: true, + description: 'Null while waiting for an answer', + }) + verdict!: ReviewVerdict | null; + @ApiProperty({ type: String, nullable: true }) answer!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + answeredAt!: string | null; +} 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 5003a77..02070ef 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -50,6 +50,8 @@ import { CreateIssueDto, DecisionsQueryDto, SetReminderDto, + RequestReviewDto, + AnswerReviewDto, MyDayQueryDto, IssueSearchQueryDto, PresenceBodyDto, @@ -316,6 +318,70 @@ export class IssuesController { await this.reminders.clear(me.id, await this.refs.issueId(me.id, issue)); } + @Post('issues/:issue/reviews') + @ISSUE + @ApiOperation({ + summary: 'Ask a member to review the ticket', + description: + 'Whoever can change the ticket asks; the reviewer is told and sees it in GET /api/me/issues?reviewing=true until they answer.', + }) + @ApiCreatedResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async requestReview( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Body() body: RequestReviewDto, + ) { + return this.issues.requestReview( + me.id, + await this.refs.issueId(me.id, issue), + body.reviewer, + body.note ?? null, + ); + } + + @Post('issues/:issue/reviews/:review/answer') + @HttpCode(200) + @ISSUE + @ApiParam({ name: 'review', format: 'uuid' }) + @ApiOperation({ + summary: 'Answer a review I was asked for (all good, or changes)', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async answerReview( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Param('review') review: string, + @Body() body: AnswerReviewDto, + ) { + return this.issues.answerReview( + me.id, + await this.refs.issueId(me.id, issue), + review, + body.verdict, + body.answer ?? null, + ); + } + + @Delete('issues/:issue/reviews/:review') + @ISSUE + @ApiParam({ name: 'review', format: 'uuid' }) + @ApiOperation({ summary: 'Withdraw a review request I made' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async cancelReview( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Param('review') review: string, + ) { + return this.issues.cancelReview( + me.id, + await this.refs.issueId(me.id, issue), + review, + ); + } + @Put('issues/:issue/follow-tree') @HttpCode(204) @ISSUE @@ -390,6 +456,7 @@ export class IssuesController { const page = await this.issues.mine(me.id, { assignedToMe: query.assignedToMe, watching: query.watching, + reviewing: query.reviewing, statuses: query.status, categories: query.category, search: query.search, diff --git a/libs/web/tracker/application/src/lib/events/home.events.ts b/libs/web/tracker/application/src/lib/events/home.events.ts index 08d16db..0ed21f3 100644 --- a/libs/web/tracker/application/src/lib/events/home.events.ts +++ b/libs/web/tracker/application/src/lib/events/home.events.ts @@ -17,6 +17,7 @@ export const homeApiEvents = eventGroup({ mine: IssuePage; awaitingQa: IssuePage; following: IssuePage; + reviewing: IssuePage; releases: Release[]; }>(), loadFailed: type(), 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 5188cb4..f89e8b1 100644 --- a/libs/web/tracker/application/src/lib/events/issue.events.ts +++ b/libs/web/tracker/application/src/lib/events/issue.events.ts @@ -9,6 +9,7 @@ import { IssueSummary, LinkIssuesInput, UpdateIssueInput, + ReviewVerdict, } from '@task-stream/web/tracker/domain'; export const issuePageEvents = eventGroup({ @@ -50,6 +51,18 @@ export const issuePageEvents = eventGroup({ watchRequested: type<{ issueId: string; watching: boolean }>(), pinRequested: type<{ issueId: string; pinned: boolean }>(), followTreeRequested: type<{ issueId: string; on: boolean }>(), + reviewRequested: type<{ + issueId: string; + reviewerId: string; + note: string | null; + }>(), + reviewAnswerRequested: type<{ + issueId: string; + reviewId: string; + verdict: ReviewVerdict; + answer: string | null; + }>(), + reviewCancelRequested: type<{ issueId: string; reviewId: string }>(), reminderRequested: type<{ issueId: string; /** null: drop it. */ diff --git a/libs/web/tracker/application/src/lib/facades/home.facade.ts b/libs/web/tracker/application/src/lib/facades/home.facade.ts index a02ed4d..619d245 100644 --- a/libs/web/tracker/application/src/lib/facades/home.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/home.facade.ts @@ -21,6 +21,8 @@ export class HomeFacade { /** Open tickets I follow, whoever works on them. */ readonly following = this.store.following; readonly followingTotal = this.store.followingTotal; + /** Tickets I was asked to review and have not answered. */ + readonly reviewing = this.store.reviewing; /** Planned releases of my projects, soonest first. */ readonly releases = this.store.releases; /** False until the first load: show skeletons, not empty states. */ 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 a389145..97e299d 100644 --- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts @@ -8,6 +8,7 @@ import { IssueLinkType, IssueSummary, UpdateIssueInput, + ReviewVerdict, } from '@task-stream/web/tracker/domain'; import { map, Observable } from 'rxjs'; import { issuePageEvents } from '../events/issue.events'; @@ -120,6 +121,39 @@ export class IssueFacade { ); } + /** Asks a member to review the open ticket. */ + requestReview(reviewerId: string, note: string | null = null): void { + this.withIssue((issueId) => + this.dispatch.reviewRequested({ + issueId, + reviewerId, + note: note?.trim() || null, + }), + ); + } + + /** Answers a review I was asked for. */ + answerReview( + reviewId: string, + verdict: ReviewVerdict, + answer: string | null = null, + ): void { + this.withIssue((issueId) => + this.dispatch.reviewAnswerRequested({ + issueId, + reviewId, + verdict, + answer: answer?.trim() || null, + }), + ); + } + + cancelReview(reviewId: string): void { + this.withIssue((issueId) => + this.dispatch.reviewCancelRequested({ issueId, reviewId }), + ); + } + /** Remind me of the ticket then (null: drop it); one per ticket. */ setReminder(at: string | null, note: string | null = null): void { this.withIssue((issueId) => diff --git a/libs/web/tracker/application/src/lib/stores/home.store.ts b/libs/web/tracker/application/src/lib/stores/home.store.ts index 7056558..f455180 100644 --- a/libs/web/tracker/application/src/lib/stores/home.store.ts +++ b/libs/web/tracker/application/src/lib/stores/home.store.ts @@ -34,6 +34,8 @@ interface HomeState { awaitingQaTotal: number; following: IssueSummary[]; followingTotal: number; + /** Tickets I was asked to review and have not answered. */ + reviewing: IssueSummary[]; releases: Release[]; loaded: boolean; loading: boolean; @@ -54,6 +56,7 @@ export const HomeStore = signalStore( awaitingQaTotal: 0, following: [], followingTotal: 0, + reviewing: [], releases: [], loaded: false, loading: false, @@ -88,6 +91,7 @@ export const HomeStore = signalStore( awaitingQaTotal: payload.awaitingQa.total, following: payload.following.items, followingTotal: payload.following.total, + reviewing: payload.reviewing.items, releases: payload.releases, loaded: true, loading: false, @@ -123,6 +127,12 @@ export const HomeStore = signalStore( categories: [...OPEN_CATEGORIES], limit: 20, }), + reviewing: issues.mine({ + assignedToMe: false, + reviewing: true, + categories: [...OPEN_CATEGORIES, StatusCategory.DONE], + limit: 20, + }), releases: releases.upcoming(5), }).pipe( mapResponse({ 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 f3603ee..01de7d6 100644 --- a/libs/web/tracker/application/src/lib/stores/issue.store.ts +++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts @@ -21,6 +21,7 @@ import { IssuesGateway, IssueSummary, SprintsGateway, + ReviewVerdict, TrackerRealtimeGateway, whenLabel, } from '@task-stream/web/tracker/domain'; @@ -380,6 +381,53 @@ export const IssueStore = signalStore( ), ), ), + events + .on(issuePageEvents.reviewRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + issues.requestReview( + payload.issueId, + payload.reviewerId, + payload.note, + ), + 'Revisión pedida', + done, + failed, + ), + ), + ), + events + .on(issuePageEvents.reviewAnswerRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + issues.answerReview( + payload.issueId, + payload.reviewId, + payload.verdict, + payload.answer, + ), + payload.verdict === ReviewVerdict.APPROVED + ? 'Revisado: todo bien' + : 'Revisado: pides cambios', + done, + failed, + ), + ), + ), + events + .on(issuePageEvents.reviewCancelRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + issues.cancelReview(payload.issueId, payload.reviewId), + 'Petición de revisión retirada', + done, + failed, + ), + ), + ), events .on(issuePageEvents.reminderRequested) .pipe( diff --git a/libs/web/tracker/domain/src/lib/models/enums.ts b/libs/web/tracker/domain/src/lib/models/enums.ts index 2b0997b..b1c04a8 100644 --- a/libs/web/tracker/domain/src/lib/models/enums.ts +++ b/libs/web/tracker/domain/src/lib/models/enums.ts @@ -70,7 +70,15 @@ export enum NotificationKind { WATCHED = 'WATCHED', ADDED_TO_PROJECT = 'ADDED_TO_PROJECT', REMINDER = 'REMINDER', + REVIEW_REQUESTED = 'REVIEW_REQUESTED', + REVIEW_ANSWERED = 'REVIEW_ANSWERED', } /** The reactions a comment takes, in the order they are offered (GitHub's). */ export const REACTIONS = ['👍', '👎', '😄', '🎉', '😕', '❤️', '🚀', '👀']; + +/** What a reviewer said. */ +export enum ReviewVerdict { + APPROVED = 'APPROVED', + CHANGES = 'CHANGES', +} diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts index b7ba1c1..b58b9f1 100644 --- a/libs/web/tracker/domain/src/lib/models/inputs.ts +++ b/libs/web/tracker/domain/src/lib/models/inputs.ts @@ -121,6 +121,8 @@ export interface MyIssuesFilter { assignedToMe: boolean; /** Only the tickets I follow. */ watching?: boolean; + /** Only the tickets I was asked to review (unanswered). */ + reviewing?: boolean; categories?: StatusCategory[]; /** Matches key, title or description. */ search?: string; diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 0ee1d5a..f4650a3 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -2,6 +2,7 @@ import { NotificationKind, IssueLinkType, IssuePriority, + ReviewVerdict, ProjectRole, ReleaseStatus, SprintStatus, @@ -192,6 +193,8 @@ export interface Issue extends IssueSummary { attachments: Attachment[]; /** People following the ticket. */ watchers: UserRef[]; + /** Reviews asked, open and answered, oldest first. */ + reviews: IssueReview[]; /** Whether I keep it pinned (home page and search). */ pinned: boolean; /** Whether I follow it with everything under it (an epic). */ @@ -208,6 +211,19 @@ export interface Issue extends IssueSummary { } /** Someone else on a ticket's page right now. */ +/** A review someone asked on a ticket. */ +export interface IssueReview { + id: string; + requester: UserRef; + reviewer: UserRef; + note: string | null; + requestedAt: string; + /** Null while waiting for an answer. */ + verdict: ReviewVerdict | null; + answer: string | null; + answeredAt: string | null; +} + /** My own reminder on a ticket. */ export interface IssueReminder { /** ISO date-time. */ diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 653685c..beb502d 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -1,4 +1,4 @@ -import { IssuePriority } from '../models/enums'; +import { IssuePriority, ReviewVerdict } from '../models/enums'; import { MyDay } from '../models/my-day'; import { Home } from '../models/home'; import { PortfolioProject, ProjectInsights } from '../models/insights'; @@ -143,6 +143,23 @@ export abstract class IssuesGateway { issueId: string, releaseId: string | null, ): Observable; + /** Asks a member to review the ticket. */ + abstract requestReview( + issueId: string, + reviewerId: string, + note: string | null, + ): Observable; + /** Answers a review I was asked for. */ + abstract answerReview( + issueId: string, + reviewId: string, + verdict: ReviewVerdict, + answer: string | null, + ): Observable; + abstract cancelReview( + issueId: string, + reviewId: string, + ): Observable; /** The signed-in person follows (or stops following) the ticket. */ abstract watch(issueId: string, watching: boolean): Observable; /** My pinned tickets, most recently pinned first. */ 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 4875d25..6b31113 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 @@ -18,6 +18,7 @@ import { LinkIssuesInput, MyDay, MyIssuesFilter, + ReviewVerdict, mergeBulkResults, UpdateIssueInput, } from '@task-stream/web/tracker/domain'; @@ -51,6 +52,9 @@ import { UnwatchIssueDocument, VoteIssueDocument, WatchIssueDocument, + RequestReviewDocument, + AnswerReviewDocument, + CancelReviewDocument, MyPinsDocument, FollowTreeDocument, SetReminderDocument, @@ -115,6 +119,7 @@ export class GraphqlIssuesGateway extends IssuesGateway { .query(MyIssuesDocument, { assignedToMe: filter.assignedToMe, watching: filter.watching ?? false, + reviewing: filter.reviewing ?? false, categories: filter.categories as never, limit: filter.limit, search: filter.search?.trim() || null, @@ -281,6 +286,38 @@ export class GraphqlIssuesGateway extends IssuesGateway { return done$.pipe(map(() => undefined)); } + requestReview( + issueId: string, + reviewerId: string, + note: string | null, + ): Observable { + return this.gql + .mutate(RequestReviewDocument, { issueId, reviewerId, note }) + .pipe(map((d) => toCommandResult(d.requestReview))); + } + + answerReview( + issueId: string, + reviewId: string, + verdict: ReviewVerdict, + answer: string | null, + ): Observable { + return this.gql + .mutate(AnswerReviewDocument, { + issueId, + reviewId, + verdict: verdict as never, + answer, + }) + .pipe(map((d) => toCommandResult(d.answerReview))); + } + + cancelReview(issueId: string, reviewId: string): Observable { + return this.gql + .mutate(CancelReviewDocument, { issueId, reviewId }) + .pipe(map((d) => toCommandResult(d.cancelReview))); + } + watch(issueId: string, watching: boolean): Observable { return watching ? this.gql diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index 24a2506..a95fe9c 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -126,6 +126,20 @@ fragment IssueFields on Issue { at note } + reviews { + id + requester { + ...UserRefFields + } + reviewer { + ...UserRefFields + } + note + requestedAt + verdict + answer + answeredAt + } decisions { ...CommentFields } diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 6b82ba3..086aeea 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -269,6 +269,8 @@ export type NotificationKind = | 'QA_FAILED' | 'REMINDER' | 'REPLIED' + | 'REVIEW_ANSWERED' + | 'REVIEW_REQUESTED' | 'STATUS_CHANGED' | 'WATCHED'; @@ -312,6 +314,9 @@ export type RankIssueInput = { export type ReleaseStatus = 'ARCHIVED' | 'PLANNED' | 'RELEASED'; +/** APPROVED: all good. CHANGES: something to change */ +export type ReviewVerdict = 'APPROVED' | 'CHANGES'; + export type SaveFilterInput = { criteria: SavedFilterCriteriaInput; name: string; @@ -715,6 +720,16 @@ export type IssueFieldsFragment = { dueDate: string | null; trashedAt: string | null; myReminder: { at: string; note: string | null } | null; + reviews: Array<{ + id: string; + note: string | null; + requestedAt: string; + verdict: ReviewVerdict | null; + answer: string | null; + answeredAt: string | null; + requester: { id: string; name: string }; + reviewer: { id: string; name: string }; + }>; decisions: Array<{ id: string; issueId: string; @@ -962,6 +977,16 @@ export type IssueQuery = { dueDate: string | null; trashedAt: string | null; myReminder: { at: string; note: string | null } | null; + reviews: Array<{ + id: string; + note: string | null; + requestedAt: string; + verdict: ReviewVerdict | null; + answer: string | null; + answeredAt: string | null; + requester: { id: string; name: string }; + reviewer: { id: string; name: string }; + }>; decisions: Array<{ id: string; issueId: string; @@ -1105,6 +1130,36 @@ export type UnlinkIssuesMutation = { unlinkIssues: { id: string; key: string | null; version: number }; }; +export type RequestReviewMutationVariables = Exact<{ + issueId: string; + reviewerId: string; + note?: string | null | undefined; +}>; + +export type RequestReviewMutation = { + requestReview: { id: string; key: string | null; version: number }; +}; + +export type AnswerReviewMutationVariables = Exact<{ + issueId: string; + reviewId: string; + verdict: ReviewVerdict; + answer?: string | null | undefined; +}>; + +export type AnswerReviewMutation = { + answerReview: { id: string; key: string | null; version: number }; +}; + +export type CancelReviewMutationVariables = Exact<{ + issueId: string; + reviewId: string; +}>; + +export type CancelReviewMutation = { + cancelReview: { id: string; key: string | null; version: number }; +}; + export type WatchIssueMutationVariables = Exact<{ issueId: string; }>; @@ -1347,6 +1402,7 @@ export type DeleteCommentMutation = { export type MyIssuesQueryVariables = Exact<{ assignedToMe: boolean; watching?: boolean | null | undefined; + reviewing?: boolean | null | undefined; categories?: Array | StatusCategory | null | undefined; limit: number; search?: string | null | undefined; @@ -3948,6 +4004,47 @@ export const IssueFieldsFragmentDoc = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'reviews' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'requester' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'reviewer' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'note' } }, + { kind: 'Field', name: { kind: 'Name', value: 'requestedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'verdict' } }, + { kind: 'Field', name: { kind: 'Name', value: 'answer' } }, + { kind: 'Field', name: { kind: 'Name', value: 'answeredAt' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'decisions' }, @@ -6560,6 +6657,47 @@ export const IssueDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'reviews' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'requester' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'reviewer' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'note' } }, + { kind: 'Field', name: { kind: 'Name', value: 'requestedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'verdict' } }, + { kind: 'Field', name: { kind: 'Name', value: 'answer' } }, + { kind: 'Field', name: { kind: 'Name', value: 'answeredAt' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'decisions' }, @@ -7308,6 +7446,324 @@ export const UnlinkIssuesDocument = { UnlinkIssuesMutation, UnlinkIssuesMutationVariables >; +export const RequestReviewDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'RequestReview' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'reviewerId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'note' } }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'requestReview' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'reviewerId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'reviewerId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'note' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'note' }, + }, + }, + ], + 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< + RequestReviewMutation, + RequestReviewMutationVariables +>; +export const AnswerReviewDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'AnswerReview' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'reviewId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'verdict' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'ReviewVerdict' }, + }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'answer' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'String' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'answerReview' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'reviewId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'reviewId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'verdict' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'verdict' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'answer' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'answer' }, + }, + }, + ], + 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< + AnswerReviewMutation, + AnswerReviewMutationVariables +>; +export const CancelReviewDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'CancelReview' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'reviewId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'cancelReview' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'reviewId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'reviewId' }, + }, + }, + ], + 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< + CancelReviewMutation, + CancelReviewMutationVariables +>; export const WatchIssueDocument = { kind: 'Document', definitions: [ @@ -8781,6 +9237,14 @@ export const MyIssuesDocument = { }, type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } }, }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'reviewing' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } }, + }, { kind: 'VariableDefinition', variable: { @@ -8888,6 +9352,14 @@ export const MyIssuesDocument = { name: { kind: 'Name', value: 'watching' }, }, }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'reviewing' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'reviewing' }, + }, + }, { kind: 'Argument', name: { kind: 'Name', value: 'categories' }, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index 643bb73..4801e9c 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -83,6 +83,34 @@ mutation UnlinkIssues($input: LinkIssuesInput!) { } } +mutation RequestReview($issueId: ID!, $reviewerId: ID!, $note: String) { + requestReview(issueId: $issueId, reviewerId: $reviewerId, note: $note) { + ...CommandResultFields + } +} + +mutation AnswerReview( + $issueId: ID! + $reviewId: ID! + $verdict: ReviewVerdict! + $answer: String +) { + answerReview( + issueId: $issueId + reviewId: $reviewId + verdict: $verdict + answer: $answer + ) { + ...CommandResultFields + } +} + +mutation CancelReview($issueId: ID!, $reviewId: ID!) { + cancelReview(issueId: $issueId, reviewId: $reviewId) { + ...CommandResultFields + } +} + mutation WatchIssue($issueId: ID!) { watchIssue(issueId: $issueId) { ...CommandResultFields @@ -188,6 +216,7 @@ mutation DeleteComment($commentId: ID!) { query MyIssues( $assignedToMe: Boolean! $watching: Boolean + $reviewing: Boolean $categories: [StatusCategory!] $limit: Int! $search: String @@ -199,6 +228,7 @@ query MyIssues( myIssues( assignedToMe: $assignedToMe watching: $watching + reviewing: $reviewing categories: $categories limit: $limit search: $search 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 f0dbfa2..a50cbda 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss @@ -837,3 +837,92 @@ a.ts-key { color: var(--ts-ink); } } + +/* Reviews asked on the ticket. */ +.ts-reviews { + ul { + display: grid; + gap: 0.625rem; + margin: 0; + padding: 0; + list-style: none; + } + + li { + padding: 0.625rem 0.875rem; + border-left: 3px solid var(--ts-c-amber); + border-radius: 0 0.5rem 0.5rem 0; + background: color-mix(in srgb, var(--ts-c-amber) 8%, var(--ts-surface)); + + &.is-approved { + border-left-color: var(--ts-c-green); + background: color-mix(in srgb, var(--ts-c-green) 7%, var(--ts-surface)); + } + + &.is-changes { + border-left-color: var(--ts-c-red); + background: color-mix(in srgb, var(--ts-c-red) 6%, var(--ts-surface)); + } + } + + p { + margin: 0; + font-size: 0.875rem; + } +} + +.ts-reviews__note { + margin-top: 0.25rem !important; + color: var(--ts-ink-soft); +} + +.ts-reviews__actions { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem 0.75rem; + margin-top: 0.5rem; + + &:empty { + display: none; + } +} + +/* Small buttons, not @pi/ui's sized ones. */ +.ts-reviews__button { + height: auto !important; + min-width: 0 !important; + padding: 0.25rem 0.75rem !important; + border: 1px solid var(--ts-line-strong) !important; + border-radius: 999px !important; + background: var(--ts-surface) !important; + color: var(--ts-ink); + font: inherit; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; + + &.is-ok { + border-color: transparent !important; + background: var(--ts-c-green) !important; + color: #fff; + } + + &:hover, + &:focus-visible { + filter: brightness(1.05); + } +} + +.ts-reviews__link { + height: auto !important; + min-width: 0 !important; + padding: 0 !important; + border: 0; + background: none !important; + color: var(--ts-brand); + font: inherit; + font-size: 0.8125rem; + font-weight: 600; + cursor: pointer; +} 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 922b6dd..c03a31e 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -52,11 +52,13 @@ import { IssuePriority, isTyping, POINT_SCALE, + ProjectRole, IssuesGateway, localDay, ReleaseStatus, ReminderChoice, reminderChoices, + ReviewVerdict, StatusCategory, whenLabel, } from '@task-stream/web/tracker/domain'; @@ -211,6 +213,16 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; (execute)="facade.setWatching(!facade.watching())" /> } + @if (project.canContribute()) { + + } @if (project.myRole() && inMyDay() !== null) { } + @if (issue.reviews.length) { +
+

Revisiones

+
    + @for (review of issue.reviews; track review.id) { +
  • + @if (!review.verdict) { +

    + {{ review.requester.name }} pidió a + {{ + review.reviewer.id === facade.me() + ? 'ti' + : review.reviewer.name + }} + que lo revise + · {{ ago(review.requestedAt) }} +

    + @if (review.note) { +

    «{{ review.note }}»

    + } +
    + @if (review.reviewer.id === facade.me()) { + + + } + @if ( + review.requester.id === facade.me() || + project.canManage() + ) { + + } +
    + } @else { +

    + + {{ review.reviewer.name }} + {{ + review.verdict === approved + ? 'lo dio por bueno' + : 'pide cambios' + }} + · + {{ ago(review.answeredAt ?? review.requestedAt) }} · + lo pidió {{ review.requester.name }} +

    + @if (review.answer) { +

    «{{ review.answer }}»

    + } + } +
  • + } +
+
+ } + @if (issue.decisions.length) {
} + +
+

+ Le llegará un aviso y lo verá en «Mi trabajo» hasta que responda. +

+ + + + +
+ + +
+ + + +
+
>('reasonForm'); private readonly reminderForm = viewChild.required>('reminderForm'); + private readonly reviewRequestForm = + viewChild.required>('reviewRequestForm'); + private readonly reviewAnswerForm = + viewChild.required>('reviewAnswerForm'); + protected readonly approved = ReviewVerdict.APPROVED; + protected readonly changes = ReviewVerdict.CHANGES; + /** The review being asked: to whom, and what to look at. */ + protected readonly reviewer = signal(''); + protected readonly reviewNote = signal(''); + /** The review being answered, and the answer. */ + protected readonly answering = signal<{ + reviewId: string; + verdict: ReviewVerdict; + } | null>(null); + protected readonly reviewAnswer = signal(''); + /** Who can be asked: the members, not me. */ + protected readonly reviewers = computed(() => + this.project + .members() + .filter( + (m) => m.user.id !== this.facade.me() && m.role !== ProjectRole.GUEST, + ) + .map((m) => ({ id: m.user.id, name: m.user.name })), + ); /** The reminder being written: `datetime-local` value and note. */ protected readonly reminderAt = signal(''); protected readonly reminderNote = signal(''); @@ -1792,6 +1992,44 @@ export class IssuePage { }); } + protected openReviewRequest(): void { + this.reviewer.set(''); + this.reviewNote.set(''); + this.drawer.open('Pedir revisión', this.reviewRequestForm()); + } + + protected sendReviewRequest(): void { + if (!this.reviewer()) return; + this.facade.requestReview(this.reviewer(), this.reviewNote()); + this.drawer.closeAll(); + } + + protected openReviewAnswer(reviewId: string, verdict: ReviewVerdict): void { + this.answering.set({ reviewId, verdict }); + this.reviewAnswer.set(''); + this.drawer.open( + verdict === ReviewVerdict.APPROVED ? 'Todo bien' : 'Pedir cambios', + this.reviewAnswerForm(), + ); + } + + protected sendReviewAnswer(): void { + const answering = this.answering(); + if (!answering) return; + if ( + answering.verdict === ReviewVerdict.CHANGES && + !this.reviewAnswer().trim() + ) + return; + this.facade.answerReview( + answering.reviewId, + answering.verdict, + this.reviewAnswer(), + ); + this.answering.set(null); + this.drawer.closeAll(); + } + protected openReminder(): void { const current = this.facade.issue()?.myReminder; this.reminderChoices.set(reminderChoices()); diff --git a/libs/web/tracker/presenters/src/lib/pages/my-work.ts b/libs/web/tracker/presenters/src/lib/pages/my-work.ts index adc1dcc..4b1aef3 100644 --- a/libs/web/tracker/presenters/src/lib/pages/my-work.ts +++ b/libs/web/tracker/presenters/src/lib/pages/my-work.ts @@ -145,6 +145,31 @@ const VISIBLE = 12; } } @else { + @if (home.reviewing().length) { +
+
+

Te piden revisión

+ {{ home.reviewing().length }} +
+ +
+ }

Esperando QA

diff --git a/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts b/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts index 652ff2a..3aed4e3 100644 --- a/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts +++ b/libs/web/tracker/presenters/src/lib/ui/notifications-bell.ts @@ -32,6 +32,8 @@ const ICON: Record = { [NotificationKind.WATCHED]: 'focus', [NotificationKind.ADDED_TO_PROJECT]: 'briefcase', [NotificationKind.REMINDER]: 'calendar', + [NotificationKind.REVIEW_REQUESTED]: 'eye', + [NotificationKind.REVIEW_ANSWERED]: 'check', }; /**