From d4da33d4e849894f21289048c9651bca8b9db9c1 Mon Sep 17 00:00:00 2001 From: Josep Piguillem Date: Thu, 8 Oct 2026 13:52:43 +0200 Subject: [PATCH] feat: epics, subtasks and checklists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tickets can hang from another one of the same project, three levels deep at most (epic → ticket → subtask). The ticket page lists its subtasks with their progress, creates new ones and moves the ticket under an epic; a new Épicas tab shows every epic and how far it has got. Each ticket also gets a checklist for the small steps that don't deserve a ticket. Co-Authored-By: Claude Opus 5.5 --- apps/api/openapi.json | 698 ++++++++++++++ apps/api/schema.gql | 68 ++ apps/mcp/src/app/api/taskstream.schema.ts | 503 ++++++++++ apps/web/src/app/app.routes.spec.ts | 1 + docs/01-vision-general.md | 1 + docs/02-arquitectura.md | 14 +- docs/03-api.md | 56 +- .../change-checklist.command-handler.ts | 72 ++ .../lib/commands/change-checklist.command.ts | 34 + .../src/lib/commands/command-handlers.ts | 13 + .../src/lib/commands/commands.spec.ts | 91 ++ .../commands/create-issue.command-handler.ts | 14 +- .../src/lib/commands/create-issue.command.ts | 2 + .../detach-from-parent.command-handler.ts | 20 + .../commands/detach-from-parent.command.ts | 12 + .../application/src/lib/commands/index.ts | 3 + .../src/lib/commands/issue-tree.ts | 60 ++ .../set-issue-parent.command-handler.ts | 33 + .../lib/commands/set-issue-parent.command.ts | 13 + .../lib/event-handlers/activity-summary.ts | 21 + .../event-handlers/activity.event-handler.ts | 8 + .../src/lib/event-handlers/event-handlers.ts | 4 + .../issue-checklist.event-handler.ts | 48 + .../issue-created.event-handler.ts | 1 + .../issue-parent-changed.event-handler.ts | 39 + .../issue-updated.event-handler.ts | 6 + .../application/src/lib/issues.service.ts | 67 +- .../src/lib/ports/find-issues.repository.ts | 5 + .../lib/ports/tracker-streams.repository.ts | 2 + .../upsert-materialized-issue.repository.ts | 16 +- .../lib/queries/get-epics.query-handler.ts | 20 + .../src/lib/queries/get-epics.query.ts | 13 + .../application/src/lib/queries/index.ts | 1 + .../src/lib/queries/query-handlers.ts | 2 + .../src/lib/sagas/project-deletion.saga.ts | 18 +- .../application/src/lib/sagas/sagas.spec.ts | 28 + .../src/lib/tracker-application.module.ts | 2 + .../application/src/testing/in-memory.ts | 7 + libs/api/tracker/domain/src/index.ts | 2 + .../tracker/domain/src/lib/events/index.ts | 4 + .../issue-checklist-item-added.event.ts | 16 + .../issue-checklist-item-removed.event.ts | 16 + .../issue-checklist-item-updated.event.ts | 17 + .../lib/events/issue-parent-changed.event.ts | 17 + .../domain/src/lib/issue/issue-checklist.ts | 22 + .../domain/src/lib/issue/issue-hierarchy.ts | 42 + .../domain/src/lib/issue/issue.spec.ts | 89 ++ .../api/tracker/domain/src/lib/issue/issue.ts | 124 +++ .../src/lib/read-models/issue.read-model.ts | 31 + .../src/lib/mongo/find.repositories.ts | 49 + .../src/lib/mongo/read-model.schemas.ts | 5 + .../mongo/upsert-materialized.repositories.ts | 40 + .../postgres/tracker-streams.repository.ts | 9 + .../presenters/src/lib/graphql/inputs.ts | 33 + .../src/lib/graphql/issue.resolver.ts | 65 ++ .../presenters/src/lib/graphql/types.ts | 42 + .../src/lib/http/dto/requests.dto.ts | 54 ++ .../src/lib/http/dto/responses.dto.ts | 32 + .../src/lib/http/issues.controller.ts | 114 ++- .../src/lib/graphql/generated.ts | 43 +- libs/web/tracker/application/src/index.ts | 1 + .../src/lib/events/epics.events.ts | 18 + .../src/lib/events/issue.events.ts | 21 + .../src/lib/facades/epics.facade.ts | 27 + .../src/lib/facades/facades.spec.ts | 49 +- .../src/lib/facades/issue.facade.ts | 46 + .../application/src/lib/stores/epics.store.ts | 66 ++ .../application/src/lib/stores/issue.store.ts | 128 ++- libs/web/tracker/domain/src/index.ts | 1 + .../domain/src/lib/models/hierarchy.spec.ts | 28 + .../domain/src/lib/models/hierarchy.ts | 32 + .../tracker/domain/src/lib/models/inputs.ts | 4 +- .../tracker/domain/src/lib/models/models.ts | 33 + .../tracker/domain/src/lib/ports/gateways.ts | 25 + .../lib/adapters/graphql-issues.gateway.ts | 62 ++ .../src/lib/graphql/fragments.graphql | 10 + .../src/lib/graphql/generated.ts | 857 ++++++++++++++++++ .../src/lib/graphql/issues.graphql | 47 + .../presenters/src/lib/pages/epics.page.ts | 147 +++ .../presenters/src/lib/pages/issue.page.scss | 86 ++ .../presenters/src/lib/pages/issue.page.ts | 216 +++++ .../src/lib/pages/project-shell.page.ts | 1 + .../presenters/src/lib/tracker.routes.ts | 6 + .../presenters/src/lib/ui/issue-card.ts | 5 + 84 files changed, 4719 insertions(+), 79 deletions(-) create mode 100644 libs/api/tracker/application/src/lib/commands/change-checklist.command-handler.ts create mode 100644 libs/api/tracker/application/src/lib/commands/change-checklist.command.ts create mode 100644 libs/api/tracker/application/src/lib/commands/detach-from-parent.command-handler.ts create mode 100644 libs/api/tracker/application/src/lib/commands/detach-from-parent.command.ts create mode 100644 libs/api/tracker/application/src/lib/commands/issue-tree.ts create mode 100644 libs/api/tracker/application/src/lib/commands/set-issue-parent.command-handler.ts create mode 100644 libs/api/tracker/application/src/lib/commands/set-issue-parent.command.ts create mode 100644 libs/api/tracker/application/src/lib/event-handlers/issue-checklist.event-handler.ts create mode 100644 libs/api/tracker/application/src/lib/event-handlers/issue-parent-changed.event-handler.ts create mode 100644 libs/api/tracker/application/src/lib/queries/get-epics.query-handler.ts create mode 100644 libs/api/tracker/application/src/lib/queries/get-epics.query.ts create mode 100644 libs/api/tracker/domain/src/lib/events/issue-checklist-item-added.event.ts create mode 100644 libs/api/tracker/domain/src/lib/events/issue-checklist-item-removed.event.ts create mode 100644 libs/api/tracker/domain/src/lib/events/issue-checklist-item-updated.event.ts create mode 100644 libs/api/tracker/domain/src/lib/events/issue-parent-changed.event.ts create mode 100644 libs/api/tracker/domain/src/lib/issue/issue-checklist.ts create mode 100644 libs/api/tracker/domain/src/lib/issue/issue-hierarchy.ts create mode 100644 libs/web/tracker/application/src/lib/events/epics.events.ts create mode 100644 libs/web/tracker/application/src/lib/facades/epics.facade.ts create mode 100644 libs/web/tracker/application/src/lib/stores/epics.store.ts create mode 100644 libs/web/tracker/domain/src/lib/models/hierarchy.spec.ts create mode 100644 libs/web/tracker/domain/src/lib/models/hierarchy.ts create mode 100644 libs/web/tracker/presenters/src/lib/pages/epics.page.ts diff --git a/apps/api/openapi.json b/apps/api/openapi.json index 38c5f69..cc49ed1 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -2178,6 +2178,16 @@ } } }, + { + "name": "parent", + "required": false, + "in": "query", + "description": "Only the tickets hanging from this one (id or key)", + "schema": { + "example": "WEB-3", + "type": "string" + } + }, { "name": "search", "required": false, @@ -2365,6 +2375,92 @@ ] } }, + "/api/projects/{project}/epics": { + "get": { + "description": "With the progress of the tickets hanging from each one (done or cancelled, and story points).", + "operationId": "IssuesController_epics", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EpicDto" + } + } + } + } + }, + "400": { + "description": "Invalid input", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "401": { + "description": "Missing or rejected Pireon token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "403": { + "description": "Not a member of the project (or not allowed)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + }, + "404": { + "description": "Not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ApiErrorDto" + } + } + } + } + }, + "security": [ + { + "pireon-oauth": [] + }, + { + "pireon": [] + }, + {} + ], + "summary": "Epics: top-level tickets that group others", + "tags": [ + "Issues" + ] + } + }, "/api/issues/{issue}": { "get": { "operationId": "IssuesController_findOne", @@ -2890,6 +2986,457 @@ ] } }, + "/api/issues/{issue}/parent": { + "put": { + "description": "Same project, three levels at most (epic → ticket → subtask), no loops. null takes it back to the top.", + "operationId": "IssuesController_setParent", + "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/SetIssueParentDto" + } + } + } + }, + "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": "Place the ticket under an epic or another ticket", + "tags": [ + "Issues" + ] + } + }, + "/api/issues/{issue}/checklist": { + "post": { + "operationId": "IssuesController_addChecklistItem", + "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/ChecklistItemBodyDto" + } + } + } + }, + "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": "Add a step to the ticket's checklist", + "tags": [ + "Issues" + ] + } + }, + "/api/issues/{issue}/checklist/{item}": { + "patch": { + "operationId": "IssuesController_updateChecklistItem", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + }, + { + "name": "item", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateChecklistItemDto" + } + } + } + }, + "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": "Reword, tick or untick a checklist step", + "tags": [ + "Issues" + ] + }, + "delete": { + "operationId": "IssuesController_removeChecklistItem", + "parameters": [ + { + "name": "issue", + "required": true, + "in": "path", + "description": "Ticket id or key", + "schema": { + "example": "WEB-12", + "type": "string" + } + }, + { + "name": "item", + "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": "Remove a checklist step", + "tags": [ + "Issues" + ] + } + }, "/api/issues/{issue}/links": { "post": { "description": "e.g. WEB-3 DEPENDS_ON API-7, WEB-9 TESTS WEB-3. May cross projects.", @@ -6213,6 +6760,49 @@ "commentId" ] }, + "IssueParentDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "key": { + "type": "string", + "example": "WEB-3" + }, + "title": { + "type": "string", + "example": "Pago con tarjeta" + } + }, + "required": [ + "id", + "key", + "title" + ] + }, + "ChecklistItemDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "text": { + "type": "string", + "example": "Actualizar el changelog" + }, + "done": { + "type": "boolean" + } + }, + "required": [ + "id", + "text", + "done" + ] + }, "IssueDto": { "type": "object", "properties": { @@ -6340,6 +6930,22 @@ "$ref": "#/components/schemas/UserRefDto" } }, + "parent": { + "nullable": true, + "description": "The epic or ticket this one breaks down", + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/IssueParentDto" + } + ] + }, + "checklist": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ChecklistItemDto" + } + }, "commentCount": { "type": "number" }, @@ -6379,6 +6985,8 @@ "links", "attachments", "watchers", + "parent", + "checklist", "commentCount", "createdAt", "updatedAt", @@ -6876,6 +7484,12 @@ "type": "string", "nullable": true, "format": "uuid" + }, + "parent": { + "type": "string", + "nullable": true, + "example": "WEB-3", + "description": "Epic or ticket of the same project the new one breaks down (id or key)" } }, "required": [ @@ -6883,6 +7497,51 @@ "title" ] }, + "ChildProgressDto": { + "type": "object", + "properties": { + "total": { + "type": "number" + }, + "finished": { + "type": "number", + "description": "Done or cancelled" + }, + "percent": { + "type": "number", + "minimum": 0, + "maximum": 100 + }, + "storyPoints": { + "type": "number" + }, + "finishedStoryPoints": { + "type": "number" + } + }, + "required": [ + "total", + "finished", + "percent", + "storyPoints", + "finishedStoryPoints" + ] + }, + "EpicDto": { + "type": "object", + "properties": { + "issue": { + "$ref": "#/components/schemas/IssueDto" + }, + "progress": { + "$ref": "#/components/schemas/ChildProgressDto" + } + }, + "required": [ + "issue", + "progress" + ] + }, "UpdateIssueDto": { "type": "object", "properties": { @@ -6983,6 +7642,45 @@ "releaseId" ] }, + "SetIssueParentDto": { + "type": "object", + "properties": { + "parent": { + "type": "string", + "nullable": true, + "example": "WEB-3", + "description": "Epic or ticket of the same project (id or key), or null to take it back to the top" + } + }, + "required": [ + "parent" + ] + }, + "ChecklistItemBodyDto": { + "type": "object", + "properties": { + "text": { + "type": "string", + "example": "Actualizar el changelog", + "maxLength": 300 + } + }, + "required": [ + "text" + ] + }, + "UpdateChecklistItemDto": { + "type": "object", + "properties": { + "text": { + "type": "string", + "maxLength": 300 + }, + "done": { + "type": "boolean" + } + } + }, "LinkIssueDto": { "type": "object", "properties": { diff --git a/apps/api/schema.gql b/apps/api/schema.gql index 9bb62b8..eea4104 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -13,6 +13,11 @@ type Activity { type: String! } +input AddChecklistItemInput { + issueId: ID! + text: String! +} + type AreaOption { archived: Boolean! id: String! @@ -57,6 +62,23 @@ input ChangeIssueStatusInput { status: String! } +"""A step inside a ticket that does not deserve a ticket of its own""" +type ChecklistItem { + done: Boolean! + id: ID! + text: String! +} + +"""How far the tickets hanging from one have got""" +type ChildProgress { + """Done or cancelled""" + finished: Int! + finishedStoryPoints: Int! + percent: Int! + storyPoints: Int! + total: Int! +} + """ Writes are acknowledged immediately; the read model catches up asynchronously. Wait for a subscription event with version >= this version to see the change. """ @@ -121,6 +143,9 @@ input CreateIssueInput { """Figma frame for UI changes""" figmaUrl: String labels: [String!] + + """Epic or ticket of the same project the new one breaks down""" + parentId: ID priority: IssuePriority projectId: ID! releaseId: ID @@ -172,6 +197,12 @@ type DurationStats { p85Days: Float } +"""A top-level ticket that groups others, with their progress""" +type Epic { + issue: Issue! + progress: ChildProgress! +} + type FlowSeries { counts: [Int!]! status: String! @@ -185,6 +216,7 @@ type Issue { area: String assignee: UserRef attachments: [Attachment!]! + checklist: [ChecklistItem!]! commentCount: Int! comments(limit: Int! = 50, offset: Int! = 0): CommentPage! createdAt: String! @@ -204,6 +236,9 @@ type Issue { linkedIssues: [Issue!]! links: [IssueLink!]! number: Int! + + """The epic or ticket this one breaks down""" + parent: IssueParent priority: IssuePriority! projectId: ID! projectKey: String! @@ -237,6 +272,9 @@ input IssueFilterInput { assigneeId: ID categories: [StatusCategory!] labels: [String!] + + """Only the tickets hanging from this one""" + parentId: ID priorities: [IssuePriority!] projectId: ID! releaseId: ID @@ -269,6 +307,13 @@ type IssuePage { total: Int! } +"""The ticket another one hangs from""" +type IssueParent { + id: ID! + key: String! + title: String! +} + enum IssuePriority { CRITICAL HIGH @@ -317,6 +362,8 @@ type Me { } type Mutation { + """Add a step to a ticket's checklist""" + addChecklistItem(input: AddChecklistItemInput!): CommandResult! addComment(body: String!, issueId: ID!): CommandResult! """Add a Pireon user to the project""" @@ -357,14 +404,23 @@ type Mutation { Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API. """ removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult! + removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult! removeProjectMember(projectId: ID!, userId: ID!): CommandResult! + """ + Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask. + """ + setIssueParent(issueId: ID!, parentId: ID): CommandResult! + """Put a ticket in a release (null removes it)""" setIssueRelease(issueId: ID!, releaseId: ID): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult! """Stop following a ticket""" unwatchIssue(issueId: ID!): CommandResult! + + """Reword, tick or untick a checklist step""" + updateChecklistItem(input: UpdateChecklistItemInput!): CommandResult! updateIssue(input: UpdateIssueInput!): CommandResult! updateNotificationSettings(email: Boolean!): NotificationSettings! updateProject(input: UpdateProjectInput!): CommandResult! @@ -541,6 +597,11 @@ type Query { limit: Int! = 50 projectId: ID! ): [Activity!]! + + """ + A project's epics: top-level tickets that group others, with the progress of those + """ + epics(projectId: ID!): [Epic!]! issue( """Issue id or key (e.g. "TS-42")""" idOrKey: String! @@ -677,6 +738,13 @@ type Throughput { week: String! } +input UpdateChecklistItemInput { + done: Boolean + issueId: ID! + itemId: ID! + text: String +} + input UpdateIssueInput { """An area id""" area: String diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index 2f18415..5ce13f6 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -363,6 +363,26 @@ export interface paths { patch?: never; trace?: never; }; + "/api/projects/{project}/epics": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Epics: top-level tickets that group others + * @description With the progress of the tickets hanging from each one (done or cancelled, and story points). + */ + get: operations["IssuesController_epics"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/issues/{issue}": { parameters: { query?: never; @@ -435,6 +455,61 @@ export interface paths { patch?: never; trace?: never; }; + "/api/issues/{issue}/parent": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** + * Place the ticket under an epic or another ticket + * @description Same project, three levels at most (epic → ticket → subtask), no loops. null takes it back to the top. + */ + put: operations["IssuesController_setParent"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/issues/{issue}/checklist": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Add a step to the ticket's checklist */ + post: operations["IssuesController_addChecklistItem"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/issues/{issue}/checklist/{item}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** Remove a checklist step */ + delete: operations["IssuesController_removeChecklistItem"]; + options?: never; + head?: never; + /** Reword, tick or untick a checklist step */ + patch: operations["IssuesController_updateChecklistItem"]; + trace?: never; + }; "/api/issues/{issue}/links": { parameters: { query?: never; @@ -1071,6 +1146,21 @@ export interface components { */ commentId: string | null; }; + IssueParentDto: { + /** Format: uuid */ + id: string; + /** @example WEB-3 */ + key: string; + /** @example Pago con tarjeta */ + title: string; + }; + ChecklistItemDto: { + /** Format: uuid */ + id: string; + /** @example Actualizar el changelog */ + text: string; + done: boolean; + }; IssueDto: { /** Format: uuid */ id: string; @@ -1116,6 +1206,9 @@ export interface components { attachments: components["schemas"]["AttachmentDto"][]; /** @description People following it */ watchers: components["schemas"]["UserRefDto"][]; + /** @description The epic or ticket this one breaks down */ + parent: components["schemas"]["IssueParentDto"] | null; + checklist: components["schemas"]["ChecklistItemDto"][]; commentCount: number; /** Format: date-time */ createdAt: string; @@ -1266,6 +1359,23 @@ export interface components { assigneeId?: string | null; /** Format: uuid */ releaseId?: string | null; + /** + * @description Epic or ticket of the same project the new one breaks down (id or key) + * @example WEB-3 + */ + parent?: string | null; + }; + ChildProgressDto: { + total: number; + /** @description Done or cancelled */ + finished: number; + percent: number; + storyPoints: number; + finishedStoryPoints: number; + }; + EpicDto: { + issue: components["schemas"]["IssueDto"]; + progress: components["schemas"]["ChildProgressDto"]; }; UpdateIssueDto: { description?: string | null; @@ -1308,6 +1418,21 @@ export interface components { */ releaseId: string | null; }; + SetIssueParentDto: { + /** + * @description Epic or ticket of the same project (id or key), or null to take it back to the top + * @example WEB-3 + */ + parent: string | null; + }; + ChecklistItemBodyDto: { + /** @example Actualizar el changelog */ + text: string; + }; + UpdateChecklistItemDto: { + text?: string; + done?: boolean; + }; LinkIssueDto: { type: components["schemas"]["IssueLinkType"]; /** @@ -2890,6 +3015,8 @@ export interface operations { unassigned?: boolean; releaseId?: string; label?: string[]; + /** @description Only the tickets hanging from this one (id or key) */ + parent?: string; /** @description Matches key, title or description */ search?: string; }; @@ -3028,6 +3155,64 @@ export interface operations { }; }; }; + IssuesController_epics: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["EpicDto"][]; + }; + }; + /** @description Invalid input */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Missing or rejected Pireon token */ + 401: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not a member of the project (or not allowed) */ + 403: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + /** @description Not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ApiErrorDto"]; + }; + }; + }; + }; IssuesController_findOne: { parameters: { query?: never; @@ -3406,6 +3591,324 @@ export interface operations { }; }; }; + IssuesController_setParent: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetIssueParentDto"]; + }; + }; + 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_addChecklistItem: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["ChecklistItemBodyDto"]; + }; + }; + 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_removeChecklistItem: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + item: 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_updateChecklistItem: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + item: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateChecklistItemDto"]; + }; + }; + 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_link: { parameters: { query?: never; diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 3062b4d..452d7af 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -28,6 +28,7 @@ describe('appRoutes', () => { '', 'board', 'issues/:issueKey', + 'epics', 'releases', 'dashboard', 'analytics', diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index b4936c4..71b45b1 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -14,6 +14,7 @@ | 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) | | 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona | | 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera | +| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños | | 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo | | 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo | | 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes | diff --git a/docs/02-arquitectura.md b/docs/02-arquitectura.md index 9ab4d64..909e439 100644 --- a/docs/02-arquitectura.md +++ b/docs/02-arquitectura.md @@ -137,13 +137,13 @@ nunca ve los eventos guardados (su publisher es el event store), las sagas las e evento, tras las proyecciones, pasa ese evento solo (`of(event)`) por cada saga y ejecuta los comandos que emite, uno a uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada se pierde si la API se cae a mitad. -| Saga | Evento | Comandos | -| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- | -| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto | -| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario y `DropLinksToIssueCommand` en los tickets que lo enlazaban | -| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) | -| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket | -| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») | +| Saga | Evento | Comandos | +| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto | +| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario, `DropLinksToIssueCommand` en los tickets que lo enlazaban y `DetachFromParentCommand` en los que colgaban de él | +| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) | +| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket | +| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») | Reglas que cumplen: diff --git a/docs/03-api.md b/docs/03-api.md index d918ed2..875b0ca 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -148,6 +148,11 @@ stateDiagram-v2 ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin. Los bytes se guardan primero y el evento después, así que nunca queda un adjunto apuntando a un archivo que no existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos. +- **Épicas y subtareas**: un ticket puede colgar de otro del mismo proyecto (`parent`), hasta tres niveles + (épica → ticket → subtarea) y sin bucles; se comprueba sobre el event store, no sobre el read model. Una «épica» es + cualquier ticket de primer nivel del que cuelgan otros (`epics(projectId)` da su progreso: terminados = hechos o + cancelados, y puntos). Si se borra el padre, sus hijos vuelven al primer nivel. +- **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket. ### Notificaciones @@ -178,12 +183,12 @@ por defecto); un correo que falla se registra y no bloquea el stream. Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`, `aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream). -| Agregado | Eventos | -| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` | -| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueDeletedEvent` | -| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | -| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` | +| Agregado | Eventos | +| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` | +| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` | +| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | +| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` | `IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado. @@ -225,7 +230,8 @@ que funcionan también sin token. | `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo | | `publicProjects` | Proyectos públicos (sin token) | | `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` | -| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) | +| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto) | +| `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos | | `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` | | `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` | | `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo | @@ -234,13 +240,13 @@ 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`, `removeAttachment` (subir: por REST) | -| Comentarios | `addComment`, `editComment`, `deleteComment` | -| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | -| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | +| Ámbito | Mutations | +| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | +| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) | +| Comentarios | `addComment`, `editComment`, `deleteComment` | +| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | +| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | ### Subscriptions @@ -436,17 +442,17 @@ 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`), `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) | -| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` | -| Comentarios | `PATCH/DELETE /api/comments/{commentId}` | -| 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}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | -| Explorar | `GET /api/explore/projects` (proyectos públicos, 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`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) | +| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` | +| Comentarios | `PATCH/DELETE /api/comments/{commentId}` | +| 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}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` | +| Explorar | `GET /api/explore/projects` (proyectos públicos, 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/libs/api/tracker/application/src/lib/commands/change-checklist.command-handler.ts b/libs/api/tracker/application/src/lib/commands/change-checklist.command-handler.ts new file mode 100644 index 0000000..fefba13 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/change-checklist.command-handler.ts @@ -0,0 +1,72 @@ +import { randomUUID } from 'node:crypto'; +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { + AddChecklistItemCommand, + RemoveChecklistItemCommand, + UpdateChecklistItemCommand, +} from './change-checklist.command'; +import { CommandResult, ack } from './command-result'; +import { WriteAccess } from './write-access'; + +type ChecklistCommand = + | AddChecklistItemCommand + | UpdateChecklistItemCommand + | RemoveChecklistItemCommand; + +/** Every change to a ticket's checklist: whoever can edit the ticket may. */ +@CommandHandler(AddChecklistItemCommand) +export class AddChecklistItemCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + execute(command: AddChecklistItemCommand): Promise { + return change(this.access, command, (issue) => + issue.addChecklistItem(command.actorId, randomUUID(), command.text), + ); + } +} + +@CommandHandler(UpdateChecklistItemCommand) +export class UpdateChecklistItemCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + execute(command: UpdateChecklistItemCommand): Promise { + return change(this.access, command, (issue) => + issue.updateChecklistItem( + command.actorId, + command.itemId, + command.changes, + ), + ); + } +} + +@CommandHandler(RemoveChecklistItemCommand) +export class RemoveChecklistItemCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + execute(command: RemoveChecklistItemCommand): Promise { + return change(this.access, command, (issue) => + issue.removeChecklistItem(command.actorId, command.itemId), + ); + } +} + +async function change( + access: WriteAccess, + command: ChecklistCommand, + apply: (issue: Awaited>) => void, +): Promise { + const { issue } = await access.issueForContributor( + command.issueId, + command.actorId, + ); + apply(issue); + await issue.commitAndWait(); + return ack(issue); +} diff --git a/libs/api/tracker/application/src/lib/commands/change-checklist.command.ts b/libs/api/tracker/application/src/lib/commands/change-checklist.command.ts new file mode 100644 index 0000000..431728c --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/change-checklist.command.ts @@ -0,0 +1,34 @@ +import { Command } from '@nestjs/cqrs'; +import { CommandResult } from './command-result'; + +export class AddChecklistItemCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + public readonly text: string, + ) { + super(); + } +} + +/** Rewords, ticks or unticks a checklist step. */ +export class UpdateChecklistItemCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + public readonly itemId: string, + public readonly changes: { text?: string; done?: boolean }, + ) { + super(); + } +} + +export class RemoveChecklistItemCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + public readonly itemId: string, + ) { + super(); + } +} 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 63a6c53..1195a17 100644 --- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts +++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts @@ -28,6 +28,13 @@ import { UnassignIssueCommandHandler } from './unassign-issue.command-handler'; import { AttachFileCommandHandler } from './attach-file.command-handler'; import { DetachFileCommandHandler } from './detach-file.command-handler'; import { WatchIssueCommandHandler } from './watch-issue.command-handler'; +import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler'; +import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler'; +import { + AddChecklistItemCommandHandler, + RemoveChecklistItemCommandHandler, + UpdateChecklistItemCommandHandler, +} from './change-checklist.command-handler'; export const COMMAND_HANDLERS = [ CreateProjectCommandHandler, @@ -61,4 +68,10 @@ export const COMMAND_HANDLERS = [ AttachFileCommandHandler, DetachFileCommandHandler, WatchIssueCommandHandler, + SetIssueParentCommandHandler, + AddChecklistItemCommandHandler, + UpdateChecklistItemCommandHandler, + RemoveChecklistItemCommandHandler, + // Saga only: the parent was deleted. + DetachFromParentCommandHandler, ]; 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 d3dfad9..09adc95 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -28,7 +28,15 @@ import { InMemoryProjectKeys, InMemoryReleaseNames, InMemoryReleaseIssues, + InMemoryTrackerStreams, } from '../../testing/in-memory'; +import { TrackerStreamsRepository } from '../ports/tracker-streams.repository'; +import { IssueTree } from './issue-tree'; +import { SetIssueParentCommand } from './set-issue-parent.command'; +import { + AddChecklistItemCommand, + UpdateChecklistItemCommand, +} from './change-checklist.command'; import { ReleaseIssuesRepository } from '../ports/release-issues.repository'; import { IssueNumbersRepository } from '../ports/issue-numbers.repository'; import { ProjectKeysRepository } from '../ports/project-keys.repository'; @@ -67,6 +75,7 @@ async function setup() { ...COMMAND_HANDLERS, ...TRACKER_FACTORIES, WriteAccess, + IssueTree, AggregateRehydrator, { provide: EventStore, useValue: store }, { provide: AttachmentStorage, useValue: files }, @@ -78,6 +87,10 @@ async function setup() { provide: ReleaseIssuesRepository, useValue: new InMemoryReleaseIssues(store), }, + { + provide: TrackerStreamsRepository, + useValue: new InMemoryTrackerStreams(store), + }, ], }).compile(); await moduleRef.init(); @@ -281,6 +294,84 @@ describe('tracker commands (event sourced)', () => { ).rejects.toThrow(ForbiddenError); }); + describe('epics, subtasks and checklists', () => { + const create = ( + run: (c: object) => Promise, + projectId: string, + title: string, + parentId?: string, + ) => + run( + new CreateIssueCommand( + 'dev', + projectId, + IssueType.TASK, + title, + {}, + null, + null, + parentId, + ), + ); + + it('nests tickets three levels deep at most, without loops', async () => { + const { run, rehydrator } = await setup(); + const projectId = await projectWithTeam(run); + const epic = await create(run, projectId, 'Checkout'); + const story = await create(run, projectId, 'Pay by card', epic.id); + const subtask = await create(run, projectId, 'Stripe form', story.id); + const loose = await create(run, projectId, 'Loose ticket'); + + expect((await rehydrator.rehydrate(subtask.id, Issue)).parentId).toBe( + story.id, + ); + // A fourth level, and a loop. + await expect( + create(run, projectId, 'Too deep', subtask.id), + ).rejects.toThrow(/at most 2 levels/); + await expect( + run(new SetIssueParentCommand('dev', epic.id, subtask.id)), + ).rejects.toThrow(BusinessRuleError); + // A ticket with subtasks cannot go under one that has a parent. + await run(new SetIssueParentCommand('dev', loose.id, epic.id)); + await expect( + run(new SetIssueParentCommand('dev', story.id, loose.id)), + ).rejects.toThrow(/at most 2 levels/); + // ...but it can move to another epic, and leave. + await run(new SetIssueParentCommand('dev', loose.id, null)); + await run(new SetIssueParentCommand('dev', story.id, loose.id)); + expect((await rehydrator.rehydrate(story.id, Issue)).parentId).toBe( + loose.id, + ); + await expect( + run(new SetIssueParentCommand('pm', story.id, null)), + ).rejects.toThrow(ForbiddenError); + }); + + it('keeps a checklist on the ticket', async () => { + const { run, rehydrator } = await setup(); + const projectId = await projectWithTeam(run); + const issue = await create(run, projectId, 'Release 1.0'); + await run(new AddChecklistItemCommand('dev', issue.id, 'Changelog')); + await run(new AddChecklistItemCommand('qa', issue.id, 'Smoke test')); + const [first] = (await rehydrator.rehydrate(issue.id, Issue)).checklist; + await run( + new UpdateChecklistItemCommand('qa', issue.id, first.id, { + done: true, + }), + ); + + const { checklist } = await rehydrator.rehydrate(issue.id, Issue); + expect(checklist.map((i) => [i.text, i.done])).toEqual([ + ['Changelog', true], + ['Smoke test', false], + ]); + await expect( + run(new AddChecklistItemCommand('pm', issue.id, 'Nope')), + ).rejects.toThrow(ForbiddenError); + }); + }); + it('does not let viewers modify tickets', async () => { const { run } = await setup(); const projectId = await projectWithTeam(run); diff --git a/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts b/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts index 3342781..ec62248 100644 --- a/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts +++ b/libs/api/tracker/application/src/lib/commands/create-issue.command-handler.ts @@ -1,8 +1,9 @@ import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; -import { IssueFactory } from '@task-stream/api/tracker/domain'; +import { IssueFactory, assertCanNest } from '@task-stream/api/tracker/domain'; import { IssueNumbersRepository } from '../ports/issue-numbers.repository'; import { CommandResult, ack } from './command-result'; import { CreateIssueCommand } from './create-issue.command'; +import { IssueTree } from './issue-tree'; import { WriteAccess } from './write-access'; @CommandHandler(CreateIssueCommand) @@ -14,6 +15,7 @@ export class CreateIssueCommandHandler private readonly issueFactory: IssueFactory, private readonly issueNumbers: IssueNumbersRepository, private readonly eventPublisher: EventPublisher, + private readonly tree: IssueTree, ) {} async execute(command: CreateIssueCommand): Promise { @@ -27,6 +29,15 @@ export class CreateIssueCommandHandler this.access.assertAssignable(project, command.assigneeId); if (command.releaseId) await this.access.openReleaseOf(project, command.releaseId); + const parent = command.parentId + ? await this.tree.candidate(command.parentId) + : null; + if (parent) + assertCanNest( + { id: '', key: 'The new ticket', projectId: project.id }, + parent, + 0, + ); const issue = this.issueFactory.create( { @@ -42,6 +53,7 @@ export class CreateIssueCommandHandler workflow, ); if (command.releaseId) issue.setRelease(command.actorId, command.releaseId); + if (parent) issue.setParent(command.actorId, parent); this.eventPublisher.mergeObjectContext(issue); await issue.commitAndWait(); diff --git a/libs/api/tracker/application/src/lib/commands/create-issue.command.ts b/libs/api/tracker/application/src/lib/commands/create-issue.command.ts index b60ccbe..83cb00c 100644 --- a/libs/api/tracker/application/src/lib/commands/create-issue.command.ts +++ b/libs/api/tracker/application/src/lib/commands/create-issue.command.ts @@ -12,6 +12,8 @@ export class CreateIssueCommand extends Command { public readonly details: Omit, public readonly assigneeId?: string | null, public readonly releaseId?: string | null, + /** Epic or ticket the new one breaks down. */ + public readonly parentId?: string | null, ) { super(); } diff --git a/libs/api/tracker/application/src/lib/commands/detach-from-parent.command-handler.ts b/libs/api/tracker/application/src/lib/commands/detach-from-parent.command-handler.ts new file mode 100644 index 0000000..ded0bf7 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/detach-from-parent.command-handler.ts @@ -0,0 +1,20 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { Issue, Project } from '@task-stream/api/tracker/domain'; +import { DetachFromParentCommand } from './detach-from-parent.command'; +import { WriteAccess } from './write-access'; + +@CommandHandler(DetachFromParentCommand) +export class DetachFromParentCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: DetachFromParentCommand): Promise { + const issue = await this.access.live(command.issueId, Issue); + // Gone (or its whole project is going), or it moved elsewhere already. + if (!issue || issue.parentId !== command.parentId) return; + if (!(await this.access.live(issue.projectId, Project))) return; + issue.setParent(command.actorId, null); + await issue.commitAndWait(); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/detach-from-parent.command.ts b/libs/api/tracker/application/src/lib/commands/detach-from-parent.command.ts new file mode 100644 index 0000000..4b40c86 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/detach-from-parent.command.ts @@ -0,0 +1,12 @@ +import { Command } from '@nestjs/cqrs'; + +/** Saga only: the parent of a ticket was deleted, so it goes back to the top. */ +export class DetachFromParentCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + public readonly parentId: string, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/index.ts b/libs/api/tracker/application/src/lib/commands/index.ts index 612b27e..ad977ee 100644 --- a/libs/api/tracker/application/src/lib/commands/index.ts +++ b/libs/api/tracker/application/src/lib/commands/index.ts @@ -29,3 +29,6 @@ export * from './unassign-issue.command'; export * from './attach-file.command'; export * from './detach-file.command'; export * from './watch-issue.command'; +export * from './set-issue-parent.command'; +export * from './detach-from-parent.command'; +export * from './change-checklist.command'; diff --git a/libs/api/tracker/application/src/lib/commands/issue-tree.ts b/libs/api/tracker/application/src/lib/commands/issue-tree.ts new file mode 100644 index 0000000..26caa5d --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/issue-tree.ts @@ -0,0 +1,60 @@ +import { Injectable } from '@nestjs/common'; +import { + Issue, + MAX_HIERARCHY_DEPTH, + ParentCandidate, +} from '@task-stream/api/tracker/domain'; +import { TrackerStreamsRepository } from '../ports/tracker-streams.repository'; +import { WriteAccess } from './write-access'; + +/** + * Walks the epic → ticket → subtask tree on the write side (the event store), + * so nesting rules never depend on a read model that may lag behind. + */ +@Injectable() +export class IssueTree { + constructor( + private readonly access: WriteAccess, + private readonly streams: TrackerStreamsRepository, + ) {} + + /** `parentId` as a would-be parent: with its ancestors, closest first. */ + async candidate(parentId: string): Promise { + const parent = await this.access.issue(parentId); + const ancestorIds: string[] = []; + let next = parent.parentId; + // One more than allowed is enough to tell a tree is too deep (or loops). + while (next && ancestorIds.length <= MAX_HIERARCHY_DEPTH) { + ancestorIds.push(next); + next = (await this.access.live(next, Issue))?.parentId ?? null; + } + return { + id: parent.id, + key: parent.key, + projectId: parent.projectId, + ancestorIds, + }; + } + + /** Tickets hanging from `issueId` right now. */ + async childrenOf(issueId: string): Promise { + const everUnder = await this.streams.issueIdsEverUnder(issueId); + const issues = await Promise.all( + everUnder.map((id) => this.access.live(id, Issue)), + ); + return issues.filter( + (issue): issue is Issue => !!issue && issue.parentId === issueId, + ); + } + + /** Levels of tickets below `issueId` (0: none). */ + async heightOf(issueId: string, depth = 0): Promise { + if (depth > MAX_HIERARCHY_DEPTH) return 0; + const children = await this.childrenOf(issueId); + if (children.length === 0) return 0; + const heights = await Promise.all( + children.map((child) => this.heightOf(child.id, depth + 1)), + ); + return 1 + Math.max(...heights); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/set-issue-parent.command-handler.ts b/libs/api/tracker/application/src/lib/commands/set-issue-parent.command-handler.ts new file mode 100644 index 0000000..d3da3f6 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/set-issue-parent.command-handler.ts @@ -0,0 +1,33 @@ +import { CommandHandler, ICommandHandler } from '@nestjs/cqrs'; +import { CommandResult, ack } from './command-result'; +import { IssueTree } from './issue-tree'; +import { SetIssueParentCommand } from './set-issue-parent.command'; +import { WriteAccess } from './write-access'; + +@CommandHandler(SetIssueParentCommand) +export class SetIssueParentCommandHandler + implements ICommandHandler +{ + constructor( + private readonly access: WriteAccess, + private readonly tree: IssueTree, + ) {} + + async execute(command: SetIssueParentCommand): Promise { + const { issue } = await this.access.issueForContributor( + command.issueId, + command.actorId, + ); + if (command.parentId === null) { + issue.setParent(command.actorId, null); + } else if (command.parentId !== issue.parentId) { + const [parent, height] = await Promise.all([ + this.tree.candidate(command.parentId), + this.tree.heightOf(issue.id), + ]); + issue.setParent(command.actorId, parent, height); + } + await issue.commitAndWait(); + return ack(issue); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/set-issue-parent.command.ts b/libs/api/tracker/application/src/lib/commands/set-issue-parent.command.ts new file mode 100644 index 0000000..3e2bfd7 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/set-issue-parent.command.ts @@ -0,0 +1,13 @@ +import { Command } from '@nestjs/cqrs'; +import { CommandResult } from './command-result'; + +/** Places a ticket under an epic or another ticket (`parentId` null: out). */ +export class SetIssueParentCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + public readonly parentId: string | null, + ) { + super(); + } +} 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 3f4e4b5..534ae9b 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 @@ -5,6 +5,10 @@ import { IssueAssignedEvent, IssueAttachmentAddedEvent, IssueAttachmentRemovedEvent, + IssueChecklistItemAddedEvent, + IssueChecklistItemRemovedEvent, + IssueChecklistItemUpdatedEvent, + IssueParentChangedEvent, IssueCreatedEvent, IssueLinkedEvent, IssueLinkType, @@ -142,6 +146,23 @@ export function describeEvent( if (event instanceof IssueAttachmentRemovedEvent) return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`; + if (event instanceof IssueParentChangedEvent) { + return event.parentKey + ? `${actor} colocó ${issueKey} bajo ${event.parentKey}` + : `${actor} sacó ${issueKey} de su ticket padre`; + } + if (event instanceof IssueChecklistItemAddedEvent) + return `${actor} añadió «${event.item.text}» a la lista de ${issueKey}`; + if (event instanceof IssueChecklistItemUpdatedEvent) { + if (event.changes.done === true) + return `${actor} completó un paso de la lista de ${issueKey}`; + if (event.changes.done === false) + return `${actor} reabrió un paso de la lista de ${issueKey}`; + return `${actor} cambió un paso de la lista de ${issueKey}`; + } + if (event instanceof IssueChecklistItemRemovedEvent) + return `${actor} quitó «${event.text}» de la lista de ${issueKey}`; + if (event instanceof CommentAddedEvent) return `${actor} comentó en ${issueKey}`; if (event instanceof CommentEditedEvent) 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 6fa3499..4114fd0 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 @@ -7,6 +7,10 @@ import { IssueAssignedEvent, IssueAttachmentAddedEvent, IssueAttachmentRemovedEvent, + IssueChecklistItemAddedEvent, + IssueChecklistItemRemovedEvent, + IssueChecklistItemUpdatedEvent, + IssueParentChangedEvent, IssueCreatedEvent, IssueLinkedEvent, IssueReleaseChangedEvent, @@ -55,6 +59,10 @@ import { versionOf } from './read-model-notifier'; IssueReleaseChangedEvent, IssueAttachmentAddedEvent, IssueAttachmentRemovedEvent, + IssueParentChangedEvent, + IssueChecklistItemAddedEvent, + IssueChecklistItemUpdatedEvent, + IssueChecklistItemRemovedEvent, CommentAddedEvent, CommentEditedEvent, CommentDeletedEvent, 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 6ebf4ed..9da65b2 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 @@ -28,6 +28,8 @@ import { IssueAttachmentAddedEventHandler } from './issue-attachment-added.event import { IssueAttachmentRemovedEventHandler } from './issue-attachment-removed.event-handler'; import { AttachmentCleanupEventHandler } from './attachment-cleanup.event-handler'; import { IssueWatchersEventHandler } from './issue-watchers.event-handler'; +import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler'; +import { IssueChecklistEventHandler } from './issue-checklist.event-handler'; /** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */ export const EVENT_HANDLERS = [ @@ -60,6 +62,8 @@ export const EVENT_HANDLERS = [ IssueAttachmentRemovedEventHandler, AttachmentCleanupEventHandler, IssueWatchersEventHandler, + IssueParentChangedEventHandler, + IssueChecklistEventHandler, ]; export { ReadModelNotifier }; diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-checklist.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-checklist.event-handler.ts new file mode 100644 index 0000000..808f8dc --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-checklist.event-handler.ts @@ -0,0 +1,48 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { + IssueChecklistItemAddedEvent, + IssueChecklistItemRemovedEvent, + IssueChecklistItemUpdatedEvent, +} from '@task-stream/api/tracker/domain'; +import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +type ChecklistEvent = + | IssueChecklistItemAddedEvent + | IssueChecklistItemUpdatedEvent + | IssueChecklistItemRemovedEvent; + +@EventsHandler( + IssueChecklistItemAddedEvent, + IssueChecklistItemUpdatedEvent, + IssueChecklistItemRemovedEvent, +) +export class IssueChecklistEventHandler + implements IEventHandler +{ + constructor( + private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: ChecklistEvent) { + const repository = this.upsertMaterializedIssueRepository; + await repository.upsert({ + id: event.aggregateId, + updatedAt: event.occurredAt, + version: versionOf(event), + }); + if (event instanceof IssueChecklistItemAddedEvent) { + await repository.addChecklistItem(event.aggregateId, event.item); + } else if (event instanceof IssueChecklistItemUpdatedEvent) { + await repository.updateChecklistItem( + event.aggregateId, + event.itemId, + event.changes, + ); + } else { + await repository.removeChecklistItem(event.aggregateId, event.itemId); + } + await this.notifier.issue(event.aggregateId, event); + } +} diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts index 1be9475..af91b39 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-created.event-handler.ts @@ -48,6 +48,7 @@ export class IssueCreatedEventHandler reporter: users.of(issue.reporterId), assignee: users.ofNullable(issue.assigneeId), release: release ? { id: release.id, name: release.name } : null, + parent: null, labels: issue.labels, figmaUrl: issue.figmaUrl, storyPoints: issue.storyPoints, diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-parent-changed.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-parent-changed.event-handler.ts new file mode 100644 index 0000000..eba8ed1 --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-parent-changed.event-handler.ts @@ -0,0 +1,39 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { IssueParentChangedEvent } from '@task-stream/api/tracker/domain'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +@EventsHandler(IssueParentChangedEvent) +export class IssueParentChangedEventHandler + implements IEventHandler +{ + constructor( + private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository, + private readonly findIssues: FindIssuesRepository, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: IssueParentChangedEvent) { + const parent = event.parentId + ? await this.findIssues.findById(event.parentId) + : null; + await this.upsertMaterializedIssueRepository.upsert({ + id: event.aggregateId, + parent: event.parentId + ? { + id: event.parentId, + key: parent?.key ?? event.parentKey ?? '', + title: parent?.title ?? '', + } + : null, + updatedAt: event.occurredAt, + version: versionOf(event), + }); + await this.notifier.issue(event.aggregateId, event); + // Both parents list their subtasks: they changed too. + for (const id of [event.parentId, event.previousParentId]) { + if (id) await this.notifier.issue(id, event); + } + } +} diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-updated.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-updated.event-handler.ts index b488adc..dceee4c 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/issue-updated.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-updated.event-handler.ts @@ -19,6 +19,12 @@ export class IssueUpdatedEventHandler updatedAt: event.occurredAt, version: versionOf(event), }); + // Its subtasks show its title. + if (event.changes.title !== undefined) + await this.upsertMaterializedIssueRepository.renameParent( + event.aggregateId, + event.changes.title, + ); 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 d00ba20..9ab4676 100644 --- a/libs/api/tracker/application/src/lib/issues.service.ts +++ b/libs/api/tracker/application/src/lib/issues.service.ts @@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common'; import { CommandBus, QueryBus } from '@nestjs/cqrs'; import { ActivityReadModel, + EpicReadModel, IssueDetails, IssueLinkType, IssueReadModel, @@ -13,6 +14,12 @@ import { } from './commands/attach-file.command'; import { DetachFileCommand } from './commands/detach-file.command'; import { WatchIssueCommand } from './commands/watch-issue.command'; +import { SetIssueParentCommand } from './commands/set-issue-parent.command'; +import { + AddChecklistItemCommand, + RemoveChecklistItemCommand, + UpdateChecklistItemCommand, +} from './commands/change-checklist.command'; import { ChangeIssueStatusCommand } from './commands/change-issue-status.command'; import { CommandResult } from './commands/command-result'; import { CreateIssueCommand } from './commands/create-issue.command'; @@ -29,6 +36,7 @@ import { import { GetIssueQuery } from './queries/get-issue.query'; import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query'; import { SearchIssuesQuery } from './queries/search-issues.query'; +import { GetEpicsQuery } from './queries/get-epics.query'; import { MyIssuesFilter, SearchMyIssuesQuery, @@ -41,6 +49,8 @@ export interface CreateIssueInput extends Omit { title: string; assigneeId?: string | null; releaseId?: string | null; + /** Epic or ticket the new one breaks down. */ + parentId?: string | null; } export interface IssueLinkInput { @@ -57,7 +67,15 @@ export class IssuesService { ) {} create(actorId: string, input: CreateIssueInput): Promise { - const { projectId, type, title, assigneeId, releaseId, ...details } = input; + const { + projectId, + type, + title, + assigneeId, + releaseId, + parentId, + ...details + } = input; return this.commandBus.execute( new CreateIssueCommand( actorId, @@ -67,6 +85,7 @@ export class IssuesService { details, assigneeId, releaseId, + parentId, ), ); } @@ -188,6 +207,52 @@ export class IssuesService { return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids)); } + /** Places the ticket under `parentId` (null: back to the top). */ + setParent( + actorId: string, + issueId: string, + parentId: string | null, + ): Promise { + return this.commandBus.execute( + new SetIssueParentCommand(actorId, issueId, parentId), + ); + } + + addChecklistItem( + actorId: string, + issueId: string, + text: string, + ): Promise { + return this.commandBus.execute( + new AddChecklistItemCommand(actorId, issueId, text), + ); + } + + updateChecklistItem( + actorId: string, + issueId: string, + itemId: string, + changes: { text?: string; done?: boolean }, + ): Promise { + return this.commandBus.execute( + new UpdateChecklistItemCommand(actorId, issueId, itemId, changes), + ); + } + + removeChecklistItem( + actorId: string, + issueId: string, + itemId: string, + ): Promise { + return this.commandBus.execute( + new RemoveChecklistItemCommand(actorId, issueId, itemId), + ); + } + + epics(viewerId: string | null, projectId: string): Promise { + return this.queryBus.execute(new GetEpicsQuery(viewerId, projectId)); + } + search( viewerId: string | null, filter: IssueFilter, 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 f4b1436..c8edb6b 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 @@ -1,4 +1,5 @@ import { + EpicReadModel, IssuePriority, IssueReadModel, ProjectStatsReadModel, @@ -18,6 +19,8 @@ export interface IssueFilter { unassigned?: boolean; releaseId?: string; labels?: string[]; + /** Only the tickets hanging from this one. */ + parentId?: string; search?: string; limit: number; offset: number; @@ -51,6 +54,8 @@ export abstract class FindIssuesRepository { abstract findOpenAssignedTo(userId: string): Promise; /** Every ticket of a project (statistics). */ abstract findByProject(projectId: string): Promise; + /** Top-level tickets with others hanging from them, by key. */ + abstract epics(projectId: string): Promise; abstract releaseProgress( releaseId: string, ): Promise; diff --git a/libs/api/tracker/application/src/lib/ports/tracker-streams.repository.ts b/libs/api/tracker/application/src/lib/ports/tracker-streams.repository.ts index d360def..37df65b 100644 --- a/libs/api/tracker/application/src/lib/ports/tracker-streams.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/tracker-streams.repository.ts @@ -10,4 +10,6 @@ export abstract class TrackerStreamsRepository { abstract commentIdsOfIssue(issueId: string): Promise; /** Tickets that were ever linked to `issueId` (they may have unlinked since). */ abstract issueIdsLinkingTo(issueId: string): Promise; + /** Tickets that were ever placed under `issueId` (they may have left since). */ + abstract issueIdsEverUnder(issueId: string): Promise; } diff --git a/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts b/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts index a8dd4fa..16c1f51 100644 --- a/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts +++ b/libs/api/tracker/application/src/lib/ports/upsert-materialized-issue.repository.ts @@ -1,5 +1,6 @@ import { AttachmentReadModel, + ChecklistItemReadModel, IssueLinkReadModel, IssueReadModel, StatusCategory, @@ -13,7 +14,7 @@ export abstract class UpsertMaterializedIssueRepository { Partial< Omit< IssueReadModel, - 'links' | 'commentCount' | 'attachments' | 'watchers' + 'links' | 'commentCount' | 'attachments' | 'watchers' | 'checklist' > >, ): Promise; @@ -35,6 +36,19 @@ export abstract class UpsertMaterializedIssueRepository { /** Adds a follower (idempotent). */ abstract addWatcher(issueId: string, user: UserRefReadModel): Promise; abstract removeWatcher(issueId: string, userId: string): Promise; + /** Adds a checklist step (idempotent). */ + abstract addChecklistItem( + issueId: string, + item: ChecklistItemReadModel, + ): Promise; + abstract updateChecklistItem( + issueId: string, + itemId: string, + changes: Partial>, + ): Promise; + abstract removeChecklistItem(issueId: string, itemId: string): Promise; + /** Keeps the denormalised parent title of a ticket's children in sync. */ + abstract renameParent(parentId: string, title: string): Promise; abstract setCommentCount( issueId: string, commentCount: number, diff --git a/libs/api/tracker/application/src/lib/queries/get-epics.query-handler.ts b/libs/api/tracker/application/src/lib/queries/get-epics.query-handler.ts new file mode 100644 index 0000000..5f3a54a --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-epics.query-handler.ts @@ -0,0 +1,20 @@ +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { EpicReadModel } from '@task-stream/api/tracker/domain'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { GetEpicsQuery } from './get-epics.query'; +import { ReadAccess } from './read-access'; + +@QueryHandler(GetEpicsQuery) +export class GetEpicsQueryHandler + implements IQueryHandler +{ + constructor( + private readonly findIssues: FindIssuesRepository, + private readonly access: ReadAccess, + ) {} + + async execute(query: GetEpicsQuery): Promise { + await this.access.assertCanView(query.projectId, query.viewerId); + return this.findIssues.epics(query.projectId); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/get-epics.query.ts b/libs/api/tracker/application/src/lib/queries/get-epics.query.ts new file mode 100644 index 0000000..bf1b6d1 --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/get-epics.query.ts @@ -0,0 +1,13 @@ +import { Query } from '@nestjs/cqrs'; +import { EpicReadModel } from '@task-stream/api/tracker/domain'; + +/** A project's epics: top-level tickets that group others, with progress. */ +export class GetEpicsQuery extends Query { + constructor( + /** Null: anonymous (public projects only). */ + public readonly viewerId: string | null, + public readonly projectId: string, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/queries/index.ts b/libs/api/tracker/application/src/lib/queries/index.ts index b01d451..551d24c 100644 --- a/libs/api/tracker/application/src/lib/queries/index.ts +++ b/libs/api/tracker/application/src/lib/queries/index.ts @@ -17,3 +17,4 @@ export * from './search-issues.query'; export * from './search-my-issues.query'; export * from './get-public-projects.query'; export * from './get-attachment.query'; +export * from './get-epics.query'; diff --git a/libs/api/tracker/application/src/lib/queries/query-handlers.ts b/libs/api/tracker/application/src/lib/queries/query-handlers.ts index 9986736..af2a621 100644 --- a/libs/api/tracker/application/src/lib/queries/query-handlers.ts +++ b/libs/api/tracker/application/src/lib/queries/query-handlers.ts @@ -16,6 +16,7 @@ import { GetReleaseProgressQueryHandler } from './get-release-progress.query-han import { GetReleasesQueryHandler } from './get-releases.query-handler'; import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler'; import { SearchIssuesQueryHandler } from './search-issues.query-handler'; +import { GetEpicsQueryHandler } from './get-epics.query-handler'; import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler'; export const QUERY_HANDLERS = [ @@ -36,6 +37,7 @@ export const QUERY_HANDLERS = [ GetReleasesQueryHandler, GetUpcomingReleasesQueryHandler, SearchIssuesQueryHandler, + GetEpicsQueryHandler, SearchMyIssuesQueryHandler, GetAttachmentQueryHandler, ]; diff --git a/libs/api/tracker/application/src/lib/sagas/project-deletion.saga.ts b/libs/api/tracker/application/src/lib/sagas/project-deletion.saga.ts index da73c67..04c7d6a 100644 --- a/libs/api/tracker/application/src/lib/sagas/project-deletion.saga.ts +++ b/libs/api/tracker/application/src/lib/sagas/project-deletion.saga.ts @@ -7,6 +7,7 @@ import { import { from, mergeMap, Observable } from 'rxjs'; import { DeleteIssueCommand } from '../commands/delete-issue.command'; import { DeleteReleaseCommand } from '../commands/delete-release.command'; +import { DetachFromParentCommand } from '../commands/detach-from-parent.command'; import { DropLinksToIssueCommand } from '../commands/drop-links-to-issue.command'; import { PurgeCommentCommand } from '../commands/purge-comment.command'; import { TrackerStreamsRepository } from '../ports/tracker-streams.repository'; @@ -17,7 +18,8 @@ import { TrackerStreamsRepository } from '../ports/tracker-streams.repository'; * * ProjectDeleted → DeleteIssue (each ticket) + DeleteRelease (each release) * IssueDeleted → PurgeComment (each comment) + DropLinksToIssue (tickets - * of other projects that pointed to it) + * of other projects that pointed to it) + DetachFromParent + * (tickets that hung from it go back to the top) * * Who to delete comes from the event store, not the read model. The changes * are attributed to whoever deleted the project. @@ -48,7 +50,7 @@ export class ProjectDeletionSaga { ofType(IssueDeletedEvent), mergeMap((event) => from(this.dependentsOf(event.aggregateId)).pipe( - mergeMap(({ commentIds, linkingIds }) => [ + mergeMap(({ commentIds, linkingIds, childIds }) => [ ...commentIds.map( (id) => new PurgeCommentCommand(event.actorId, id), ), @@ -60,6 +62,14 @@ export class ProjectDeletionSaga { event.aggregateId, ), ), + ...childIds.map( + (id) => + new DetachFromParentCommand( + event.actorId, + id, + event.aggregateId, + ), + ), ]), ), ), @@ -74,13 +84,15 @@ export class ProjectDeletionSaga { } private async dependentsOf(issueId: string) { - const [commentIds, linkingIds] = await Promise.all([ + const [commentIds, linkingIds, childIds] = await Promise.all([ this.streams.commentIdsOfIssue(issueId), this.streams.issueIdsLinkingTo(issueId), + this.streams.issueIdsEverUnder(issueId), ]); return { commentIds, linkingIds: linkingIds.filter((id) => id !== issueId), + childIds, }; } } diff --git a/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts b/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts index e14a2d2..4b21e69 100644 --- a/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts +++ b/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts @@ -39,12 +39,14 @@ import { ChangeIssueStatusCommand } from '../commands/change-issue-status.comman import { COMMAND_HANDLERS } from '../commands/command-handlers'; import { CommandResult } from '../commands/command-result'; import { CreateIssueCommand } from '../commands/create-issue.command'; +import { DeleteIssueCommand } from '../commands/delete-issue.command'; import { CreateProjectCommand } from '../commands/create-project.command'; import { CreateReleaseCommand } from '../commands/create-release.command'; import { DeleteProjectCommand } from '../commands/delete-project.command'; import { LinkIssuesCommand } from '../commands/link-issues.command'; import { AssignIssueCommand } from '../commands/assign-issue.command'; import { WriteAccess } from '../commands/write-access'; +import { IssueTree } from '../commands/issue-tree'; import { FindIssuesRepository } from '../ports/find-issues.repository'; import { IssueNumbersRepository } from '../ports/issue-numbers.repository'; import { ProjectKeysRepository } from '../ports/project-keys.repository'; @@ -72,6 +74,7 @@ async function setup() { ReleaseArchivedSaga, UserDeactivatedSaga, WriteAccess, + IssueTree, AggregateRehydrator, { provide: EventStore, useValue: store }, { provide: AttachmentStorage, useValue: files }, @@ -178,6 +181,31 @@ describe('sagas', () => { ).resolves.toMatchObject({ key: 'WEB' }); }); + it('a deleted ticket lets the tickets under it go back to the top', async () => { + const { run, drain, rehydrator } = await setup(); + const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id; + const epic = (await run(ticket(web, 'Epic'))).id; + const child = ( + await run( + new CreateIssueCommand( + 'owner', + web, + 'TASK', + 'child', + {}, + null, + null, + epic, + ), + ) + ).id; + + await run(new DeleteIssueCommand('owner', epic)); + await drain(); + + expect((await rehydrator.rehydrate(child, Issue)).parentId).toBeNull(); + }); + it('only the owner deletes a project', async () => { const { run } = await setup(); const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id; diff --git a/libs/api/tracker/application/src/lib/tracker-application.module.ts b/libs/api/tracker/application/src/lib/tracker-application.module.ts index 17a41ab..431df0d 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -4,6 +4,7 @@ import { TRACKER_FACTORIES } from '@task-stream/api/tracker/domain'; import { ChangesService } from './changes.service'; import { COMMAND_HANDLERS } from './commands/command-handlers'; import { WriteAccess } from './commands/write-access'; +import { IssueTree } from './commands/issue-tree'; import { CommentsService } from './comments.service'; import { EVENT_HANDLERS, @@ -59,6 +60,7 @@ const SERVICES = [ ...SERVICES, ...TRACKER_FACTORIES, WriteAccess, + IssueTree, ReadAccess, ReadModelNotifier, ...COMMAND_HANDLERS, diff --git a/libs/api/tracker/application/src/testing/in-memory.ts b/libs/api/tracker/application/src/testing/in-memory.ts index 628ec4c..b783d45 100644 --- a/libs/api/tracker/application/src/testing/in-memory.ts +++ b/libs/api/tracker/application/src/testing/in-memory.ts @@ -256,6 +256,13 @@ export class InMemoryTrackerStreams extends TrackerStreamsRepository { (d) => (d['link'] as { targetIssueId: string }).targetIssueId === issueId, ); } + + async issueIdsEverUnder(issueId: string) { + return this.streams( + 'IssueParentChangedEvent', + (d) => d['parentId'] === issueId, + ); + } } /** Attachment storage double: files in a map, keyed like on disk. */ diff --git a/libs/api/tracker/domain/src/index.ts b/libs/api/tracker/domain/src/index.ts index 5fd0438..90a6fa9 100644 --- a/libs/api/tracker/domain/src/index.ts +++ b/libs/api/tracker/domain/src/index.ts @@ -4,6 +4,8 @@ export * from './lib/issue/issue'; export * from './lib/issue/issue.enums'; export * from './lib/issue/issue-attachment'; export * from './lib/issue/mentions'; +export * from './lib/issue/issue-hierarchy'; +export * from './lib/issue/issue-checklist'; 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 caa17be..c77b216 100644 --- a/libs/api/tracker/domain/src/lib/events/index.ts +++ b/libs/api/tracker/domain/src/lib/events/index.ts @@ -26,3 +26,7 @@ export * from './issue-attachment-added.event'; export * from './issue-attachment-removed.event'; export * from './issue-watched.event'; export * from './issue-unwatched.event'; +export * from './issue-parent-changed.event'; +export * from './issue-checklist-item-added.event'; +export * from './issue-checklist-item-updated.event'; +export * from './issue-checklist-item-removed.event'; diff --git a/libs/api/tracker/domain/src/lib/events/issue-checklist-item-added.event.ts b/libs/api/tracker/domain/src/lib/events/issue-checklist-item-added.event.ts new file mode 100644 index 0000000..ecc89cd --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-checklist-item-added.event.ts @@ -0,0 +1,16 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { ChecklistItem } from '../issue/issue-checklist'; +import { TrackerEvent } from './tracker.event'; + +/** A step was added to a ticket's checklist. */ +@AutowiredEvent +export class IssueChecklistItemAddedEvent extends TrackerEvent { + constructor( + actorId: string, + issueId: string, + projectId: string, + readonly item: ChecklistItem, + ) { + super(actorId, issueId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/events/issue-checklist-item-removed.event.ts b/libs/api/tracker/domain/src/lib/events/issue-checklist-item-removed.event.ts new file mode 100644 index 0000000..6dcfbe2 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-checklist-item-removed.event.ts @@ -0,0 +1,16 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** A step was removed from a ticket's checklist. */ +@AutowiredEvent +export class IssueChecklistItemRemovedEvent extends TrackerEvent { + constructor( + actorId: string, + issueId: string, + projectId: string, + readonly itemId: string, + readonly text: string, + ) { + super(actorId, issueId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/events/issue-checklist-item-updated.event.ts b/libs/api/tracker/domain/src/lib/events/issue-checklist-item-updated.event.ts new file mode 100644 index 0000000..0bf403b --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-checklist-item-updated.event.ts @@ -0,0 +1,17 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { ChecklistItem } from '../issue/issue-checklist'; +import { TrackerEvent } from './tracker.event'; + +/** A checklist step was reworded, ticked or unticked (only what changed). */ +@AutowiredEvent +export class IssueChecklistItemUpdatedEvent extends TrackerEvent { + constructor( + actorId: string, + issueId: string, + projectId: string, + readonly itemId: string, + readonly changes: Partial>, + ) { + super(actorId, issueId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/events/issue-parent-changed.event.ts b/libs/api/tracker/domain/src/lib/events/issue-parent-changed.event.ts new file mode 100644 index 0000000..41b1a82 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-parent-changed.event.ts @@ -0,0 +1,17 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** A ticket was placed under another one (or taken out: `parentId` null). */ +@AutowiredEvent +export class IssueParentChangedEvent extends TrackerEvent { + constructor( + actorId: string, + issueId: string, + projectId: string, + readonly parentId: string | null, + readonly parentKey: string | null, + readonly previousParentId: string | null, + ) { + super(actorId, issueId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/issue/issue-checklist.ts b/libs/api/tracker/domain/src/lib/issue/issue-checklist.ts new file mode 100644 index 0000000..e015d67 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/issue/issue-checklist.ts @@ -0,0 +1,22 @@ +import { assertThat } from '@task-stream/api/shared/domain'; + +/** A step inside a ticket that does not deserve a ticket of its own. */ +export interface ChecklistItem { + id: string; + text: string; + done: boolean; +} + +export const MAX_CHECKLIST_ITEMS = 100; +export const MAX_CHECKLIST_TEXT = 300; + +/** Trims and validates the wording of a checklist item. */ +export function normalizeChecklistText(text: string): string { + const next = text.trim(); + assertThat(next.length > 0, 'A checklist item needs some text'); + assertThat( + next.length <= MAX_CHECKLIST_TEXT, + `A checklist item is at most ${MAX_CHECKLIST_TEXT} characters`, + ); + return next; +} diff --git a/libs/api/tracker/domain/src/lib/issue/issue-hierarchy.ts b/libs/api/tracker/domain/src/lib/issue/issue-hierarchy.ts new file mode 100644 index 0000000..744e6e2 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/issue/issue-hierarchy.ts @@ -0,0 +1,42 @@ +import { BusinessRuleError } from '@task-stream/api/shared/domain'; + +/** + * Levels below the top a ticket can sit: epic → ticket → subtask. Deeper + * trees are hard to read on a board and nobody plans with them. + */ +export const MAX_HIERARCHY_DEPTH = 2; + +/** The would-be parent of a ticket, as the command handler found it. */ +export interface ParentCandidate { + id: string; + key: string; + projectId: string; + /** Its own parent, grandparent… (closest first). */ + ancestorIds: string[]; +} + +/** + * Checks that `childId` (of `childProjectId`, with descendants `childHeight` + * levels deep) may hang from `parent`. + */ +export function assertCanNest( + child: { id: string; key: string; projectId: string }, + parent: ParentCandidate, + childHeight: number, +): void { + if (parent.id === child.id) + throw new BusinessRuleError(`${child.key} cannot be its own parent`); + if (parent.projectId !== child.projectId) + throw new BusinessRuleError( + `${parent.key} belongs to another project; a parent must be in the same project`, + ); + if (parent.ancestorIds.includes(child.id)) + throw new BusinessRuleError( + `${parent.key} already hangs from ${child.key}; that would make a loop`, + ); + const depth = parent.ancestorIds.length + 1; + if (depth + childHeight > MAX_HIERARCHY_DEPTH) + throw new BusinessRuleError( + `Tickets nest at most ${MAX_HIERARCHY_DEPTH} levels (epic → ticket → subtask)`, + ); +} diff --git a/libs/api/tracker/domain/src/lib/issue/issue.spec.ts b/libs/api/tracker/domain/src/lib/issue/issue.spec.ts index a7c8d63..49b173a 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.spec.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.spec.ts @@ -4,10 +4,12 @@ import { ValidationError, } from '@task-stream/api/shared/domain'; import { IssueCreatedEvent } from '../events/issue-created.event'; +import { IssueParentChangedEvent } from '../events/issue-parent-changed.event'; import { IssueStatusChangedEvent } from '../events/issue-status-changed.event'; import { IssueFactory } from '../factories/issue.factory'; import { replay } from '../testing'; import { Issue } from './issue'; +import { ParentCandidate } from './issue-hierarchy'; import { mentionedUserIds, withoutMentionMarkup } from './mentions'; import { IssueLinkType, IssueStatus, IssueType } from './issue.enums'; @@ -223,6 +225,93 @@ describe('Issue', () => { }); }); + describe('hierarchy', () => { + const parent = (over: Partial = {}): ParentCandidate => ({ + id: 'epic-1', + key: 'TS-1', + projectId: 'p-1', + ancestorIds: [], + ...over, + }); + + it('hangs from a parent, moves and leaves, and survives a replay', () => { + const issue = newIssue(); + issue.setParent('u-pm', parent()); + issue.setParent('u-pm', parent()); + issue.setParent('u-pm', parent({ id: 'epic-2', key: 'TS-2' })); + issue.setParent('u-pm', null); + issue.setParent('u-pm', parent({ id: 'epic-3', key: 'TS-3' })); + + const changes = issue + .getUncommittedEvents() + .filter((e) => e instanceof IssueParentChangedEvent); + expect(changes.map((e) => [e.parentId, e.previousParentId])).toEqual([ + ['epic-1', null], + ['epic-2', 'epic-1'], + [null, 'epic-2'], + ['epic-3', null], + ]); + expect(replay(issue, Issue).rebuilt.parentId).toBe('epic-3'); + }); + + it('refuses itself, another project, loops and a fourth level', () => { + const issue = newIssue(); + expect(() => issue.setParent('u', parent({ id: issue.id }))).toThrow( + BusinessRuleError, + ); + expect(() => issue.setParent('u', parent({ projectId: 'p-2' }))).toThrow( + /another project/, + ); + expect(() => + issue.setParent('u', parent({ ancestorIds: [issue.id] })), + ).toThrow(/loop/); + // Under a ticket that hangs from an epic: it becomes a subtask. + expect(() => + issue.setParent('u', parent({ ancestorIds: ['t-1'] })), + ).not.toThrow(); + expect(() => + issue.setParent('u', parent({ id: 'x', ancestorIds: ['t-1', 'e-1'] })), + ).toThrow(/at most 2 levels/); + // A ticket with subtasks cannot go below a ticket that has a parent. + expect(() => + issue.setParent('u', parent({ id: 'y', ancestorIds: ['e-1'] }), 1), + ).toThrow(/at most 2 levels/); + }); + }); + + describe('checklist', () => { + it('adds, ticks, rewords and removes steps, and survives a replay', () => { + const issue = newIssue(); + issue.addChecklistItem('u-dev', 'c-1', ' Write the migration '); + issue.addChecklistItem('u-dev', 'c-2', 'Update the docs'); + issue.addChecklistItem('u-dev', 'c-1', 'Duplicate id is ignored'); + issue.updateChecklistItem('u-dev', 'c-1', { done: true }); + issue.updateChecklistItem('u-dev', 'c-1', { + done: true, + text: 'Write the migration', + }); + issue.updateChecklistItem('u-dev', 'c-2', { text: 'Update the README' }); + issue.removeChecklistItem('u-dev', 'c-2'); + + expect(issue.checklist).toEqual([ + { id: 'c-1', text: 'Write the migration', done: true }, + ]); + expect(replay(issue, Issue).rebuilt.checklist).toEqual(issue.checklist); + // created + 2 added + ticked + reworded + removed + expect(issue.getUncommittedEvents()).toHaveLength(6); + }); + + it('refuses empty steps and unknown items', () => { + const issue = newIssue(); + expect(() => issue.addChecklistItem('u', 'c-1', ' ')).toThrow( + ValidationError, + ); + expect(() => issue.removeChecklistItem('u', 'nope')).toThrow( + BusinessRuleError, + ); + }); + }); + describe('mentions', () => { it('reads who a text mentions, once each', () => { const text = diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts index 5f7920c..859e6a0 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.ts @@ -7,9 +7,13 @@ import { import { IssueAssignedEvent } from '../events/issue-assigned.event'; import { IssueAttachmentAddedEvent } from '../events/issue-attachment-added.event'; import { IssueAttachmentRemovedEvent } from '../events/issue-attachment-removed.event'; +import { IssueChecklistItemAddedEvent } from '../events/issue-checklist-item-added.event'; +import { IssueChecklistItemRemovedEvent } from '../events/issue-checklist-item-removed.event'; +import { IssueChecklistItemUpdatedEvent } from '../events/issue-checklist-item-updated.event'; import { IssueCreatedEvent } from '../events/issue-created.event'; import { IssueDeletedEvent } from '../events/issue-deleted.event'; import { IssueLinkedEvent } from '../events/issue-linked.event'; +import { IssueParentChangedEvent } from '../events/issue-parent-changed.event'; import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event'; import { IssueStatusChangedEvent } from '../events/issue-status-changed.event'; import { IssueUnlinkedEvent } from '../events/issue-unlinked.event'; @@ -22,6 +26,12 @@ import { MAX_ATTACHMENTS_PER_ISSUE, normalizeAttachment, } from './issue-attachment'; +import { + ChecklistItem, + MAX_CHECKLIST_ITEMS, + normalizeChecklistText, +} from './issue-checklist'; +import { ParentCandidate, assertCanNest } from './issue-hierarchy'; import { IssueLinkType, IssuePriority } from './issue.enums'; export interface IssueLink { @@ -95,6 +105,9 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { public attachments: IssueAttachment[] = []; /** People who asked to be told about this ticket. */ public watchers: string[] = []; + /** The epic or ticket this one breaks down. */ + public parentId: string | null = null; + public checklist: ChecklistItem[] = []; public createdAt!: string; public updatedAt!: string; public deleted = false; @@ -280,6 +293,91 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { ); } + /** + * Places the ticket under `parent` (null takes it out). `childHeight` is + * how many levels of tickets already hang from this one. + */ + setParent( + actorId: string, + parent: ParentCandidate | null, + childHeight = 0, + ): void { + if ((parent?.id ?? null) === this.parentId) return; + if (parent) assertCanNest(this, parent, childHeight); + this.apply( + new IssueParentChangedEvent( + actorId, + this.id, + this.projectId, + parent?.id ?? null, + parent?.key ?? null, + this.parentId, + ), + ); + } + + /** Adds a step to the checklist; `id` is generated by the caller. */ + addChecklistItem(actorId: string, id: string, text: string): void { + if (this.checklist.some((i) => i.id === id)) return; + if (this.checklist.length >= MAX_CHECKLIST_ITEMS) { + throw new BusinessRuleError( + `${this.key} already has ${MAX_CHECKLIST_ITEMS} checklist items`, + ); + } + this.apply( + new IssueChecklistItemAddedEvent(actorId, this.id, this.projectId, { + id, + text: normalizeChecklistText(text), + done: false, + }), + ); + } + + /** Rewords, ticks or unticks a step. Nothing changes, nothing happens. */ + updateChecklistItem( + actorId: string, + itemId: string, + changes: { text?: string; done?: boolean }, + ): void { + const item = this.checklistItem(itemId); + const patch: { text?: string; done?: boolean } = {}; + if (changes.text !== undefined) { + const text = normalizeChecklistText(changes.text); + if (text !== item.text) patch.text = text; + } + if (changes.done !== undefined && changes.done !== item.done) + patch.done = changes.done; + if (Object.keys(patch).length === 0) return; + this.apply( + new IssueChecklistItemUpdatedEvent( + actorId, + this.id, + this.projectId, + itemId, + patch, + ), + ); + } + + removeChecklistItem(actorId: string, itemId: string): void { + const item = this.checklistItem(itemId); + this.apply( + new IssueChecklistItemRemovedEvent( + actorId, + this.id, + this.projectId, + item.id, + item.text, + ), + ); + } + + private checklistItem(itemId: string): ChecklistItem { + const item = this.checklist.find((i) => i.id === itemId); + if (!item) throw new BusinessRuleError('Checklist item not found'); + return item; + } + /** Deletes the ticket (its project was deleted). Idempotent. */ delete(actorId: string): void { if (this.deleted) return; @@ -360,6 +458,32 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { this.watchers = this.watchers.filter((id) => id !== event.userId); } + onIssueParentChangedEvent(event: IssueParentChangedEvent): void { + this.parentId = event.parentId; + this.updatedAt = event.occurredAt; + } + + onIssueChecklistItemAddedEvent(event: IssueChecklistItemAddedEvent): void { + this.checklist = [...this.checklist, { ...event.item }]; + this.updatedAt = event.occurredAt; + } + + onIssueChecklistItemUpdatedEvent( + event: IssueChecklistItemUpdatedEvent, + ): void { + this.checklist = this.checklist.map((i) => + i.id === event.itemId ? { ...i, ...event.changes } : i, + ); + this.updatedAt = event.occurredAt; + } + + onIssueChecklistItemRemovedEvent( + event: IssueChecklistItemRemovedEvent, + ): void { + this.checklist = this.checklist.filter((i) => i.id !== event.itemId); + this.updatedAt = event.occurredAt; + } + onIssueDeletedEvent(): void { this.deleted = true; } 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 1dd3f2c..8ff415b 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 @@ -23,6 +23,35 @@ export class AttachmentReadModel { commentId!: string | null; } +export class ChecklistItemReadModel { + id!: string; + text!: string; + done!: boolean; +} + +/** The ticket another one hangs from. */ +export class IssueParentReadModel { + id!: string; + key!: string; + title!: string; +} + +/** How far the tickets hanging from one have got. */ +export class ChildProgressReadModel { + total!: number; + /** Done or cancelled. */ + finished!: number; + percent!: number; + storyPoints!: number; + finishedStoryPoints!: number; +} + +/** A top-level ticket that groups others, with their progress. */ +export class EpicReadModel { + issue!: IssueReadModel; + progress!: ChildProgressReadModel; +} + export class IssueReadModel { id!: string; projectId!: string; @@ -49,6 +78,8 @@ export class IssueReadModel { attachments!: AttachmentReadModel[]; /** People following the ticket. */ watchers!: UserRefReadModel[]; + parent!: IssueParentReadModel | null; + checklist!: ChecklistItemReadModel[]; commentCount!: number; createdAt!: string; updatedAt!: string; diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts index 863ac1b..6c083b1 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -14,6 +14,7 @@ import { import { ActivityReadModel, CommentReadModel, + EpicReadModel, CountBy, defaultProjectSettings, IssueReadModel, @@ -228,6 +229,53 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { return docs.map(issueFromDoc); } + async epics(projectId: string): Promise { + const finished = { $in: ['$statusCategory', FINISHED] }; + const points = { $ifNull: ['$storyPoints', 0] }; + const rows = await this.issues.aggregate<{ + _id: string; + total: number; + finished: number; + storyPoints: number; + finishedStoryPoints: number; + }>([ + { $match: { projectId, 'parent.id': { $type: 'string' } } }, + { + $group: { + _id: '$parent.id', + total: { $sum: 1 }, + finished: { $sum: { $cond: [finished, 1, 0] } }, + storyPoints: { $sum: points }, + finishedStoryPoints: { $sum: { $cond: [finished, points, 0] } }, + }, + }, + ]); + if (rows.length === 0) return []; + const progress = new Map(rows.map((row) => [row._id, row])); + // Only the top of each tree: a ticket with subtasks under an epic is not one. + const docs = await this.issues + .find({ _id: { $in: [...progress.keys()] }, 'parent.id': null }) + .sort({ number: 1 }) + .lean(); + return docs.map((doc) => { + const { _id: _ignored, ...row } = progress.get(doc._id) ?? { + _id: doc._id, + total: 0, + finished: 0, + storyPoints: 0, + finishedStoryPoints: 0, + }; + return { + issue: issueFromDoc(doc), + progress: { + ...row, + percent: + row.total === 0 ? 0 : Math.round((row.finished / row.total) * 100), + }, + }; + }); + } + async releaseProgress(releaseId: string): Promise { const rows = await this.issues.aggregate<{ _id: { status: string; category: StatusCategory }; @@ -268,6 +316,7 @@ function toMongoFilter(filter: IssueFilter): FilterQuery { if (filter.labels?.length) query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) }; if (filter.releaseId) query['release.id'] = filter.releaseId; + if (filter.parentId) query['parent.id'] = filter.parentId; if (filter.unassigned) query['assignee'] = null; else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId; if (filter.search?.trim()) { 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 f7f80c4..e5989df 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 @@ -78,6 +78,8 @@ export const IssueSchema = new Schema( outwardLinks: [link], inwardLinks: [link], watchers: [userRef], + parent: { _id: false, id: String, key: String, title: String }, + checklist: [{ _id: false, id: String, text: String, done: Boolean }], attachments: [ { _id: false, @@ -99,6 +101,7 @@ IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 }); IssueSchema.index({ projectId: 1, statusCategory: 1 }); IssueSchema.index({ 'assignee.id': 1 }); IssueSchema.index({ 'watchers.id': 1 }); +IssueSchema.index({ 'parent.id': 1 }); IssueSchema.index({ 'release.id': 1 }); IssueSchema.index({ title: 'text', description: 'text', key: 'text' }); @@ -182,5 +185,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel { // Tickets projected before attachments existed have none. attachments: rest.attachments ?? [], watchers: rest.watchers ?? [], + parent: rest.parent?.id ? rest.parent : null, + checklist: rest.checklist ?? [], }; } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts index 95e912d..740ee7f 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/upsert-materialized.repositories.ts @@ -11,6 +11,7 @@ import { import { ActivityReadModel, AttachmentReadModel, + ChecklistItemReadModel, CommentReadModel, IssueLinkReadModel, IssueReadModel, @@ -130,6 +131,45 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs ); } + async addChecklistItem(issueId: string, item: ChecklistItemReadModel) { + await this.issues.updateOne( + { _id: issueId, 'checklist.id': { $ne: item.id } }, + { $push: { checklist: item } }, + ); + } + + async updateChecklistItem( + issueId: string, + itemId: string, + changes: Partial>, + ) { + const set = Object.fromEntries( + Object.entries(changes).map(([field, value]) => [ + `checklist.$.${field}`, + value, + ]), + ); + if (Object.keys(set).length === 0) return; + await this.issues.updateOne( + { _id: issueId, 'checklist.id': itemId }, + { $set: set }, + ); + } + + async removeChecklistItem(issueId: string, itemId: string) { + await this.issues.updateOne( + { _id: issueId }, + { $pull: { checklist: { id: itemId } } }, + ); + } + + async renameParent(parentId: string, title: string) { + await this.issues.updateMany( + { 'parent.id': parentId }, + { $set: { 'parent.title': title } }, + ); + } + async addAttachment(issueId: string, attachment: AttachmentReadModel) { await this.issues.updateOne( { _id: issueId, 'attachments.id': { $ne: attachment.id } }, diff --git a/libs/api/tracker/infrastructure/src/lib/postgres/tracker-streams.repository.ts b/libs/api/tracker/infrastructure/src/lib/postgres/tracker-streams.repository.ts index a50a0fd..7ede509 100644 --- a/libs/api/tracker/infrastructure/src/lib/postgres/tracker-streams.repository.ts +++ b/libs/api/tracker/infrastructure/src/lib/postgres/tracker-streams.repository.ts @@ -4,6 +4,7 @@ import { CommentAddedEvent, IssueCreatedEvent, IssueLinkedEvent, + IssueParentChangedEvent, ReleaseCreatedEvent, } from '@task-stream/api/tracker/domain'; import { DataSource } from 'typeorm'; @@ -47,6 +48,14 @@ export class PostgresTrackerStreamsRepository extends TrackerStreamsRepository { ); } + issueIdsEverUnder(issueId: string): Promise { + return this.streams( + IssueParentChangedEvent.name, + `data->>'parentId'`, + issueId, + ); + } + /** `field` is one of the fixed JSON paths above, never user input. */ private async streams( type: string, diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index 879dd4f..fbfcdb0 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -140,6 +140,13 @@ export class CreateIssueInput extends IssueFieldsInput { @Field(() => ID, { nullable: true }) @IsOptional() @IsUUID() releaseId?: | string | null; + @Field(() => ID, { + nullable: true, + description: 'Epic or ticket of the same project the new one breaks down', + }) + @IsOptional() + @IsUUID() + parentId?: string | null; } @InputType() @@ -173,6 +180,25 @@ export class ChangeIssueStatusInput { reason?: string | null; } +@InputType() +export class AddChecklistItemInput { + @Field(() => ID) @IsUUID() issueId!: string; + @Field() @IsString() @MinLength(1) @MaxLength(300) text!: string; +} + +@InputType() +export class UpdateChecklistItemInput { + @Field(() => ID) @IsUUID() issueId!: string; + @Field(() => ID) @IsUUID() itemId!: string; + @Field({ nullable: true }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(300) + text?: string; + @Field({ nullable: true }) @IsOptional() @IsBoolean() done?: boolean; +} + @InputType() export class LinkIssuesInput { @Field(() => ID) @IsUUID() issueId!: string; @@ -216,6 +242,13 @@ export class IssueFilterInput { @IsOptional() @IsString({ each: true }) labels?: string[]; + @Field(() => ID, { + nullable: true, + description: 'Only the tickets hanging from this one', + }) + @IsOptional() + @IsUUID() + parentId?: string; @Field({ nullable: true, description: 'Matches key, title or description' }) @IsOptional() @IsString() 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 fac82f9..4552f71 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/issue.resolver.ts @@ -23,10 +23,12 @@ import { StatusCategory, } from '@task-stream/api/tracker/domain'; import { + AddChecklistItemInput, ChangeIssueStatusInput, CreateIssueInput, IssueFilterInput, LinkIssuesInput, + UpdateChecklistItemInput, UpdateIssueInput, } from './inputs'; import { withoutNulls } from '../common/issue-input'; @@ -35,6 +37,7 @@ import { ActivityGql, CommandResultGql, CommentPageGql, + EpicGql, IssueGql, IssuePageGql, } from './types'; @@ -72,6 +75,18 @@ export class IssueResolver { }); } + @OptionalAuth() + @Query(() => [EpicGql], { + description: + "A project's epics: top-level tickets that group others, with the progress of those", + }) + epics( + @CurrentUser() me: User | undefined, + @Args('projectId', { type: () => ID }) projectId: string, + ) { + return this.issuesService.epics(viewerOf(me), projectId); + } + @Query(() => IssuePageGql, { description: 'Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])', @@ -238,6 +253,56 @@ export class IssueResolver { return this.issuesService.unlink(me.id, input); } + @Mutation(() => CommandResultGql, { + description: + 'Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask.', + }) + setIssueParent( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + @Args('parentId', { type: () => ID, nullable: true }) + parentId: string | null, + ) { + return this.issuesService.setParent(me.id, issueId, parentId ?? null); + } + + @Mutation(() => CommandResultGql, { + description: "Add a step to a ticket's checklist", + }) + addChecklistItem( + @CurrentUser() me: User, + @Args('input') input: AddChecklistItemInput, + ) { + return this.issuesService.addChecklistItem( + me.id, + input.issueId, + input.text, + ); + } + + @Mutation(() => CommandResultGql, { + description: 'Reword, tick or untick a checklist step', + }) + updateChecklistItem( + @CurrentUser() me: User, + @Args('input') input: UpdateChecklistItemInput, + ) { + const { issueId, itemId, text, done } = input; + return this.issuesService.updateChecklistItem(me.id, issueId, itemId, { + ...(text != null && { text }), + ...(done != null && { done }), + }); + } + + @Mutation(() => CommandResultGql) + removeChecklistItem( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + @Args('itemId', { type: () => ID }) itemId: string, + ) { + return this.issuesService.removeChecklistItem(me.id, issueId, itemId); + } + @Mutation(() => CommandResultGql, { description: 'Put a ticket in a release (null removes it)', }) diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 593ba85..ed49cd0 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -116,6 +116,23 @@ export class ReleaseRefGql { @Field() name!: string; } +@ObjectType('ChecklistItem', { + description: + 'A step inside a ticket that does not deserve a ticket of its own', +}) +export class ChecklistItemGql { + @Field(() => ID) id!: string; + @Field() text!: string; + @Field() done!: boolean; +} + +@ObjectType('IssueParent', { description: 'The ticket another one hangs from' }) +export class IssueParentGql { + @Field(() => ID) id!: string; + @Field() key!: string; + @Field() title!: string; +} + @ObjectType('Issue') export class IssueGql { @Field(() => ID) id!: string; @@ -145,12 +162,37 @@ export class IssueGql { @Field(() => [AttachmentGql]) attachments!: AttachmentGql[]; @Field(() => [UserRefGql], { description: 'People following the ticket' }) watchers!: UserRefGql[]; + @Field(() => IssueParentGql, { + nullable: true, + description: 'The epic or ticket this one breaks down', + }) + parent!: IssueParentGql | null; + @Field(() => [ChecklistItemGql]) checklist!: ChecklistItemGql[]; @Field(() => Int) commentCount!: number; @Field() createdAt!: string; @Field() updatedAt!: string; @Field(() => Int) version!: number; } +@ObjectType('ChildProgress', { + description: 'How far the tickets hanging from one have got', +}) +export class ChildProgressGql { + @Field(() => Int) total!: number; + @Field(() => Int, { description: 'Done or cancelled' }) finished!: number; + @Field(() => Int) percent!: number; + @Field(() => Int) storyPoints!: number; + @Field(() => Int) finishedStoryPoints!: number; +} + +@ObjectType('Epic', { + description: 'A top-level ticket that groups others, with their progress', +}) +export class EpicGql { + @Field(() => IssueGql) issue!: IssueGql; + @Field(() => ChildProgressGql) progress!: ChildProgressGql; +} + @ObjectType('IssuePage') export class IssuePageGql { @Field(() => [IssueGql]) items!: IssueGql[]; 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 40d519a..12e933f 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 @@ -408,6 +408,14 @@ export class IssueSearchQueryDto extends PageQueryDto { @IsString({ each: true }) label?: string[]; + @ApiPropertyOptional({ + description: 'Only the tickets hanging from this one (id or key)', + example: 'WEB-3', + }) + @IsOptional() + @IsString() + parent?: string; + @ApiPropertyOptional({ description: 'Matches key, title or description' }) @IsOptional() @IsString() @@ -501,6 +509,17 @@ export class CreateIssueDto extends IssueFieldsDto { @IsOptional() @IsString() releaseId?: string | null; + + @ApiPropertyOptional({ + type: String, + nullable: true, + example: 'WEB-3', + description: + 'Epic or ticket of the same project the new one breaks down (id or key)', + }) + @IsOptional() + @IsString() + parent?: string | null; } export class UpdateIssueDto extends IssueFieldsDto { @@ -560,6 +579,41 @@ export class SetIssueReleaseDto { releaseId!: string | null; } +export class SetIssueParentDto { + @ApiProperty({ + type: String, + nullable: true, + example: 'WEB-3', + description: + 'Epic or ticket of the same project (id or key), or null to take it back to the top', + }) + @ValidateIf((dto: SetIssueParentDto) => dto.parent !== null) + @IsString() + parent!: string | null; +} + +export class ChecklistItemBodyDto { + @ApiProperty({ example: 'Actualizar el changelog', maxLength: 300 }) + @IsString() + @MinLength(1) + @MaxLength(300) + text!: string; +} + +export class UpdateChecklistItemDto { + @ApiPropertyOptional({ maxLength: 300 }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(300) + text?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsBoolean() + done?: boolean; +} + export class LinkIssueDto { @ApiProperty({ enum: IssueLinkType, enumName: 'IssueLinkType' }) @IsEnum(IssueLinkType) 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 234fb85..ec7b904 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 @@ -123,6 +123,18 @@ export class AttachmentDto { commentId!: string | null; } +export class ChecklistItemDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ example: 'Actualizar el changelog' }) text!: string; + @ApiProperty() done!: boolean; +} + +export class IssueParentDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ example: 'WEB-3' }) key!: string; + @ApiProperty({ example: 'Pago con tarjeta' }) title!: string; +} + export class IssueDto { @ApiProperty({ format: 'uuid' }) id!: string; @ApiProperty({ format: 'uuid' }) projectId!: string; @@ -161,6 +173,13 @@ export class IssueDto { @ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[]; @ApiProperty({ type: [UserRefDto], description: 'People following it' }) watchers!: UserRefDto[]; + @ApiProperty({ + type: IssueParentDto, + nullable: true, + description: 'The epic or ticket this one breaks down', + }) + parent!: IssueParentDto | null; + @ApiProperty({ type: [ChecklistItemDto] }) checklist!: ChecklistItemDto[]; @ApiProperty() commentCount!: number; @ApiProperty({ format: 'date-time' }) createdAt!: string; @ApiProperty({ format: 'date-time' }) updatedAt!: string; @@ -233,6 +252,19 @@ export class CountByAssigneeDto { @ApiProperty() count!: number; } +export class ChildProgressDto { + @ApiProperty() total!: number; + @ApiProperty({ description: 'Done or cancelled' }) finished!: number; + @ApiProperty({ minimum: 0, maximum: 100 }) percent!: number; + @ApiProperty() storyPoints!: number; + @ApiProperty() finishedStoryPoints!: number; +} + +export class EpicDto { + @ApiProperty({ type: IssueDto }) issue!: IssueDto; + @ApiProperty({ type: ChildProgressDto }) progress!: ChildProgressDto; +} + export class ReleaseProgressDto { @ApiProperty() total!: number; @ApiProperty() finished!: number; diff --git a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts index a257b08..f068aeb 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -4,6 +4,7 @@ import { Delete, Get, Param, + ParseUUIDPipe, Patch, Post, Put, @@ -35,6 +36,7 @@ import { withoutNulls } from '../common/issue-input'; import { AssignIssueDto, ChangeStatusDto, + ChecklistItemBodyDto, CommentBodyDto, CreateIssueDto, IssueSearchQueryDto, @@ -42,13 +44,16 @@ import { LinkTypeParamDto, MyIssuesQueryDto, PageQueryDto, + SetIssueParentDto, SetIssueReleaseDto, + UpdateChecklistItemDto, UpdateIssueDto, } from './dto/requests.dto'; import { ActivityDto, CommandResultDto, CommentPageDto, + EpicDto, IssueDto, IssuePageDto, } from './dto/responses.dto'; @@ -139,6 +144,9 @@ export class IssuesController { ) { const page = await this.issues.search(viewerOf(me), { projectId: await this.refs.projectId(viewerOf(me), project), + parentId: query.parent + ? await this.refs.issueId(viewerOf(me), query.parent) + : undefined, statuses: query.status, categories: query.category, types: query.type, @@ -169,12 +177,40 @@ export class IssuesController { @Param('project') project: string, @Body() body: CreateIssueDto, ) { + const { parent, ...fields } = body; return this.issues.create(me.id, { - ...withoutNulls(body), + ...withoutNulls(fields), projectId: await this.refs.projectId(me.id, project), + parentId: parent ? await this.refs.issueId(me.id, parent) : null, }); } + @Get('projects/:project/epics') + @ApiOptionalPireonAuth() + @PROJECT + @ApiOperation({ + summary: 'Epics: top-level tickets that group others', + description: + 'With the progress of the tickets hanging from each one (done or cancelled, and story points).', + }) + @ApiOkResponse({ type: [EpicDto] }) + @ApiReadErrors() + async epics( + @CurrentUser() me: User | undefined, + @Param('project') project: string, + ) { + const epics = await this.issues.epics( + viewerOf(me), + await this.refs.projectId(viewerOf(me), project), + ); + return Promise.all( + epics.map(async (epic) => ({ + ...epic, + issue: await this.refs.issueDto(epic.issue), + })), + ); + } + @Get('issues/:issue') @ApiOptionalPireonAuth() @ISSUE @@ -266,6 +302,82 @@ export class IssuesController { ); } + @Put('issues/:issue/parent') + @ISSUE + @ApiOperation({ + summary: 'Place the ticket under an epic or another ticket', + description: + 'Same project, three levels at most (epic → ticket → subtask), no loops. null takes it back to the top.', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async setParent( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Body() body: SetIssueParentDto, + ) { + return this.issues.setParent( + me.id, + await this.refs.issueId(me.id, issue), + body.parent ? await this.refs.issueId(me.id, body.parent) : null, + ); + } + + @Post('issues/:issue/checklist') + @ISSUE + @ApiOperation({ summary: "Add a step to the ticket's checklist" }) + @ApiCreatedResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async addChecklistItem( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Body() body: ChecklistItemBodyDto, + ) { + return this.issues.addChecklistItem( + me.id, + await this.refs.issueId(me.id, issue), + body.text, + ); + } + + @Patch('issues/:issue/checklist/:item') + @ISSUE + @ApiParam({ name: 'item', format: 'uuid' }) + @ApiOperation({ summary: 'Reword, tick or untick a checklist step' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async updateChecklistItem( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Param('item', ParseUUIDPipe) item: string, + @Body() body: UpdateChecklistItemDto, + ) { + return this.issues.updateChecklistItem( + me.id, + await this.refs.issueId(me.id, issue), + item, + body, + ); + } + + @Delete('issues/:issue/checklist/:item') + @ISSUE + @ApiParam({ name: 'item', format: 'uuid' }) + @ApiOperation({ summary: 'Remove a checklist step' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async removeChecklistItem( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Param('item', ParseUUIDPipe) item: string, + ) { + return this.issues.removeChecklistItem( + me.id, + await this.refs.issueId(me.id, issue), + item, + ); + } + @Post('issues/:issue/links') @ISSUE @ApiOperation({ diff --git a/libs/web/identity/infrastructure/src/lib/graphql/generated.ts b/libs/web/identity/infrastructure/src/lib/graphql/generated.ts index 221741f..837b0dc 100644 --- a/libs/web/identity/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/identity/infrastructure/src/lib/graphql/generated.ts @@ -1,43 +1,12 @@ /** Internal type. DO NOT USE DIRECTLY. */ type Exact = { [K in keyof T]: T[K] }; /** Internal type. DO NOT USE DIRECTLY. */ -export type Incremental = - | T - | { - [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never; - }; +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; -export type MeQueryVariables = Exact<{ [key: string]: never }>; +export type MeQueryVariables = Exact<{ [key: string]: never; }>; -export type MeQuery = { - me: { id: string; name: string; email: string; appRoles: Array }; -}; -export const MeDocument = { - kind: 'Document', - definitions: [ - { - kind: 'OperationDefinition', - operation: 'query', - name: { kind: 'Name', value: 'Me' }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { - kind: 'Field', - name: { kind: 'Name', value: 'me' }, - selectionSet: { - kind: 'SelectionSet', - selections: [ - { kind: 'Field', name: { kind: 'Name', value: 'id' } }, - { kind: 'Field', name: { kind: 'Name', value: 'name' } }, - { kind: 'Field', name: { kind: 'Name', value: 'email' } }, - { kind: 'Field', name: { kind: 'Name', value: 'appRoles' } }, - ], - }, - }, - ], - }, - }, - ], -} as unknown as DocumentNode; +export type MeQuery = { me: { id: string, name: string, email: string, appRoles: Array } }; + + +export const MeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"appRoles"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/libs/web/tracker/application/src/index.ts b/libs/web/tracker/application/src/index.ts index f5fc012..2026c27 100644 --- a/libs/web/tracker/application/src/index.ts +++ b/libs/web/tracker/application/src/index.ts @@ -6,3 +6,4 @@ export * from './lib/facades/issue.facade'; export * from './lib/facades/home.facade'; export * from './lib/facades/explore.facade'; export * from './lib/facades/inbox.facade'; +export * from './lib/facades/epics.facade'; diff --git a/libs/web/tracker/application/src/lib/events/epics.events.ts b/libs/web/tracker/application/src/lib/events/epics.events.ts new file mode 100644 index 0000000..76b6bd2 --- /dev/null +++ b/libs/web/tracker/application/src/lib/events/epics.events.ts @@ -0,0 +1,18 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; +import { Epic } from '@task-stream/web/tracker/domain'; + +export const epicsPageEvents = eventGroup({ + source: 'Epics Page', + events: { + opened: type<{ projectId: string }>(), + }, +}); + +export const epicsApiEvents = eventGroup({ + source: 'Epics API', + events: { + loaded: type(), + 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 b2a6d6b..4de0017 100644 --- a/libs/web/tracker/application/src/lib/events/issue.events.ts +++ b/libs/web/tracker/application/src/lib/events/issue.events.ts @@ -6,6 +6,7 @@ import { Comment, CommentChanged, Issue, + IssueSummary, LinkIssuesInput, UpdateIssueInput, } from '@task-stream/web/tracker/domain'; @@ -36,6 +37,21 @@ export const issuePageEvents = eventGroup({ files?: File[]; }>(), watchRequested: type<{ issueId: string; watching: boolean }>(), + /** Place the ticket under another one by its key (e.g. WEB-3); null: out. */ + parentRequested: type<{ issueId: string; parentKey: string | null }>(), + subtaskCreateRequested: type<{ + projectId: string; + parentId: string; + type: string; + title: string; + }>(), + checklistAddRequested: type<{ issueId: string; text: string }>(), + checklistUpdateRequested: type<{ + issueId: string; + itemId: string; + changes: { text?: string; done?: boolean }; + }>(), + checklistRemoveRequested: type<{ issueId: string; itemId: string }>(), attachRequested: type<{ issueId: string; files: File[]; @@ -57,7 +73,10 @@ export const issueApiEvents = eventGroup({ loadFailed: type(), commentsLoaded: type(), historyLoaded: type(), + childrenLoaded: type(), commandSucceeded: type(), + /** A checklist step was saved (no toast for something so small). */ + checklistSaved: type(), commandFailed: type(), }, }); @@ -67,6 +86,8 @@ export const issueRealtimeEvents = eventGroup({ events: { /** Something about this ticket changed: refetch it. */ issueTouched: type<{ issueId: string; version: number }>(), + /** A ticket under this one changed, arrived or left: reload them. */ + childrenTouched: type<{ issueId: string }>(), commentChanged: type(), }, }); diff --git a/libs/web/tracker/application/src/lib/facades/epics.facade.ts b/libs/web/tracker/application/src/lib/facades/epics.facade.ts new file mode 100644 index 0000000..4f90474 --- /dev/null +++ b/libs/web/tracker/application/src/lib/facades/epics.facade.ts @@ -0,0 +1,27 @@ +import { inject, Injectable, Provider } from '@angular/core'; +import { injectDispatch } from '@ngrx/signals/events'; +import { epicsPageEvents } from '../events/epics.events'; +import { EpicsStore } from '../stores/epics.store'; + +/** "Épicas": top-level tickets that group others, with their progress. */ +@Injectable() +export class EpicsFacade { + private readonly store = inject(EpicsStore); + private readonly dispatch = injectDispatch(epicsPageEvents); + + /** By key. */ + readonly epics = this.store.epics; + /** False until the first load: show skeletons, not empty states. */ + readonly loaded = this.store.loaded; + readonly error = this.store.error; + + /** Loads the project's epics and keeps them up to date. */ + open(projectId: string): void { + this.dispatch.opened({ projectId }); + } +} + +/** Store + facade of the epics view, for its route component. */ +export function provideEpicsFacade(): Provider[] { + return [EpicsStore, EpicsFacade]; +} diff --git a/libs/web/tracker/application/src/lib/facades/facades.spec.ts b/libs/web/tracker/application/src/lib/facades/facades.spec.ts index b001d6e..7095419 100644 --- a/libs/web/tracker/application/src/lib/facades/facades.spec.ts +++ b/libs/web/tracker/application/src/lib/facades/facades.spec.ts @@ -28,7 +28,13 @@ import { ProjectFacade, provideProjectFacade } from './project.facade'; const ok = { id: 'x', key: null, aggregateVersion: 1 }; const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project; -const issue = { id: 'i1', key: 'WEB-1', links: [] }; +const issue = { + id: 'i1', + key: 'WEB-1', + projectId: 'p1', + links: [], + checklist: [], +}; const analytics = { from: 'a', to: 'b', qa: { failureRate: 0.25 } }; /** Every command succeeds, every query answers once, nothing arrives in real time. */ @@ -48,6 +54,11 @@ function gateways() { issue: vi.fn(() => of(issue)), changeStatus: vi.fn(() => of(ok)), watch: vi.fn(() => of(ok)), + children: vi.fn(() => of([])), + create: vi.fn(() => of(ok)), + setParent: vi.fn(() => of(ok)), + addChecklistItem: vi.fn(() => of(ok)), + updateChecklistItem: vi.fn(() => of(ok)), }, list: { list: vi.fn(() => of([])), @@ -238,6 +249,42 @@ describe('IssueFacade', () => { expect(fakes.issues.watch).toHaveBeenCalledWith('i1', true); }); + it('breaks the ticket down: subtasks, parent and checklist', () => { + const { issue, fakes } = setup(); + // The parent is looked up by its key first. + fakes.issues.issue.mockImplementation(((key: string) => + of({ + id: key === 'WEB-1' ? 'i1' : 'e1', + key, + projectId: 'p1', + links: [], + checklist: [], + })) as never); + + issue.open('WEB-1'); + expect(fakes.issues.children).toHaveBeenCalledWith('p1', 'i1'); + issue.addSubtask(' Formulario ', 'TASK'); + issue.setParent('web-9'); + issue.addChecklistItem('Changelog'); + issue.updateChecklistItem('c1', { done: true }); + + expect(fakes.issues.create).toHaveBeenCalledWith({ + projectId: 'p1', + parentId: 'i1', + type: 'TASK', + title: 'Formulario', + }); + expect(fakes.issues.issue).toHaveBeenCalledWith('WEB-9'); + expect(fakes.issues.setParent).toHaveBeenCalledWith('i1', 'e1'); + expect(fakes.issues.addChecklistItem).toHaveBeenCalledWith( + 'i1', + 'Changelog', + ); + expect(fakes.issues.updateChecklistItem).toHaveBeenCalledWith('i1', 'c1', { + done: true, + }); + }); + it('uploads files one by one, and under a new comment when sent with it', () => { const { issue, fakes } = setup(); const a = new File(['a'], 'a.png', { type: 'image/png' }); 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 bb77ca0..0a60f39 100644 --- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts @@ -26,6 +26,11 @@ export class IssueFacade { readonly history = this.store.history; readonly outwardLinks = this.store.outwardLinks; readonly inwardLinks = this.store.inwardLinks; + /** Tickets hanging from the open one, and how far they have got. */ + readonly children = this.store.children; + readonly childProgress = this.store.childProgress; + /** Ticked steps of the open ticket's checklist. */ + readonly checklistProgress = this.store.checklistProgress; readonly loading = this.store.loading; readonly error = this.store.error; /** Signed-in user's id (authors can edit their comments). */ @@ -94,6 +99,47 @@ export class IssueFacade { ); } + /** Places the open ticket under another one by its key; null: back to the top. */ + setParent(parentKey: string | null): void { + this.withIssue((issueId) => + this.dispatch.parentRequested({ issueId, parentKey }), + ); + } + + /** Creates a ticket under the open one. */ + addSubtask(title: string, type: string): void { + const issue = this.store.issue(); + if (!issue || !title.trim()) return; + this.dispatch.subtaskCreateRequested({ + projectId: issue.projectId, + parentId: issue.id, + type, + title: title.trim(), + }); + } + + addChecklistItem(text: string): void { + if (!text.trim()) return; + this.withIssue((issueId) => + this.dispatch.checklistAddRequested({ issueId, text: text.trim() }), + ); + } + + updateChecklistItem( + itemId: string, + changes: { text?: string; done?: boolean }, + ): void { + this.withIssue((issueId) => + this.dispatch.checklistUpdateRequested({ issueId, itemId, changes }), + ); + } + + removeChecklistItem(itemId: string): void { + this.withIssue((issueId) => + this.dispatch.checklistRemoveRequested({ issueId, itemId }), + ); + } + /** Attaches files to the ticket (or under one of its comments). */ attach(files: File[], commentId: string | null = null): void { if (!files.length) return; diff --git a/libs/web/tracker/application/src/lib/stores/epics.store.ts b/libs/web/tracker/application/src/lib/stores/epics.store.ts new file mode 100644 index 0000000..87b8bd5 --- /dev/null +++ b/libs/web/tracker/application/src/lib/stores/epics.store.ts @@ -0,0 +1,66 @@ +import { inject } from '@angular/core'; +import { mapResponse } from '@ngrx/operators'; +import { signalStore, withState } from '@ngrx/signals'; +import { + Events, + on, + withEventHandlers, + withReducer, +} from '@ngrx/signals/events'; +import { + Epic, + IssuesGateway, + TrackerRealtimeGateway, +} from '@task-stream/web/tracker/domain'; +import { debounceTime, startWith, switchMap } from 'rxjs'; +import { epicsApiEvents, epicsPageEvents } from '../events/epics.events'; +import { describeError } from '../notifications'; + +interface EpicsState { + epics: Epic[]; + loaded: boolean; + error: string | null; +} + +/** A project's epics and their progress, live. Scoped to the epics view. */ +export const EpicsStore = signalStore( + withState({ epics: [], loaded: false, error: null }), + withReducer( + on(epicsPageEvents.opened, () => ({ loaded: false, error: null })), + on(epicsApiEvents.loaded, ({ payload }) => ({ + epics: payload, + loaded: true, + })), + on(epicsApiEvents.loadFailed, ({ payload }) => ({ + loaded: true, + error: payload, + })), + ), + withEventHandlers( + ( + _store, + events = inject(Events), + issues = inject(IssuesGateway), + realtime = inject(TrackerRealtimeGateway), + ) => ({ + // Any ticket of the project may move an epic's progress: reload, calmly. + load$: events.on(epicsPageEvents.opened).pipe( + switchMap(({ payload }) => + realtime.issueChanges(payload.projectId).pipe( + debounceTime(500), + startWith(null), + switchMap(() => + issues.epics(payload.projectId).pipe( + mapResponse({ + next: (list) => epicsApiEvents.loaded(list), + error: (error) => + epicsApiEvents.loadFailed(describeError(error)), + }), + ), + ), + ), + ), + ), + }), + ), +); 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 f208c2d..954a32d 100644 --- a/libs/web/tracker/application/src/lib/stores/issue.store.ts +++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts @@ -14,8 +14,11 @@ import { AttachmentsGateway, Comment, CommentsGateway, + checklistProgress, + childProgress, Issue, IssuesGateway, + IssueSummary, TrackerRealtimeGateway, } from '@task-stream/web/tracker/domain'; import { @@ -45,6 +48,8 @@ interface IssueState { issue: Issue | null; comments: Comment[]; history: Activity[]; + /** Tickets hanging from this one. */ + children: IssueSummary[]; loading: boolean; error: string | null; } @@ -55,10 +60,15 @@ export const IssueStore = signalStore( issue: null, comments: [], history: [], + children: [], loading: false, error: null, }), - withComputed(({ issue }, auth = inject(AuthFacade)) => ({ + withComputed(({ issue, children }, auth = inject(AuthFacade)) => ({ + childProgress: computed(() => childProgress(children())), + checklistProgress: computed(() => + checklistProgress(issue()?.checklist ?? []), + ), me: computed(() => auth.user()?.id ?? null), watching: computed( () => @@ -79,6 +89,7 @@ export const IssueStore = signalStore( issue: null, comments: [], history: [], + children: [], })), on(issueApiEvents.loaded, ({ payload }) => ({ issue: payload, @@ -90,6 +101,7 @@ export const IssueStore = signalStore( })), on(issueApiEvents.commentsLoaded, ({ payload }) => ({ comments: payload })), on(issueApiEvents.historyLoaded, ({ payload }) => ({ history: payload })), + on(issueApiEvents.childrenLoaded, ({ payload }) => ({ children: payload })), on(issueRealtimeEvents.commentChanged, ({ payload }, state) => { const others = state.comments.filter((c) => c.id !== payload.commentId); if (!payload.comment) return { comments: others }; @@ -181,6 +193,44 @@ export const IssueStore = signalStore( ), ), ), + children$: merge( + loaded$.pipe( + map(({ payload }) => payload), + filter( + (issue, index) => index === 0 || issue.id !== store.issue()?.id, + ), + ), + events.on(issueRealtimeEvents.childrenTouched).pipe( + debounceTime(200), + map(() => store.issue()), + filter((issue): issue is Issue => !!issue), + ), + ).pipe( + switchMap((issue) => + issues + .children(issue.projectId, issue.id) + .pipe(map((list) => issueApiEvents.childrenLoaded(list))), + ), + ), + /** A ticket under this one changed, or one moved in or out. */ + liveChildren$: events.on(issuePageEvents.opened).pipe( + switchMap(() => loaded$.pipe(map(({ payload }) => payload))), + filter( + (issue, index) => index === 0 || issue.id !== store.issue()?.id, + ), + switchMap((issue) => + realtime.issueChanges(issue.projectId).pipe( + filter( + (change) => + change.issue.parent?.id === issue.id || + store.children().some((c) => c.id === change.issue.id), + ), + map(() => + issueRealtimeEvents.childrenTouched({ issueId: issue.id }), + ), + ), + ), + ), refetch$: events.on(issueRealtimeEvents.issueTouched).pipe( debounceTime(150), switchMap(({ payload }) => fetchIssue(payload.issueId)), @@ -242,6 +292,82 @@ export const IssueStore = signalStore( ), ), ), + events + .on(issuePageEvents.parentRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + payload.parentKey + ? issues + .issue(payload.parentKey.trim().toUpperCase()) + .pipe( + switchMap((parent) => + issues.setParent(payload.issueId, parent.id), + ), + ) + : issues.setParent(payload.issueId, null), + payload.parentKey + ? `Ahora cuelga de ${payload.parentKey.trim().toUpperCase()}` + : 'Ya no cuelga de ningún ticket', + done, + failed, + ), + ), + ), + events + .on(issuePageEvents.subtaskCreateRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + issues.create(payload), + 'Subtarea creada', + done, + failed, + ), + ), + ), + events + .on(issuePageEvents.checklistAddRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + issues.addChecklistItem(payload.issueId, payload.text), + 'Paso añadido', + done, + failed, + ), + ), + ), + events.on(issuePageEvents.checklistUpdateRequested).pipe( + mergeMap(({ payload }) => + issues + .updateChecklistItem( + payload.issueId, + payload.itemId, + payload.changes, + ) + .pipe( + // Ticking a step is not worth a toast; only failures are. + mapResponse({ + next: () => issueApiEvents.checklistSaved(), + error: (error) => + issueApiEvents.commandFailed(describeError(error)), + }), + ), + ), + ), + events + .on(issuePageEvents.checklistRemoveRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + issues.removeChecklistItem(payload.issueId, payload.itemId), + 'Paso eliminado', + done, + failed, + ), + ), + ), events .on(issuePageEvents.linkRequested) .pipe( diff --git a/libs/web/tracker/domain/src/index.ts b/libs/web/tracker/domain/src/index.ts index eeba170..133382a 100644 --- a/libs/web/tracker/domain/src/index.ts +++ b/libs/web/tracker/domain/src/index.ts @@ -4,3 +4,4 @@ export * from './lib/models/inputs'; export * from './lib/models/workflow'; export * from './lib/ports/gateways'; export * from './lib/models/mentions'; +export * from './lib/models/hierarchy'; diff --git a/libs/web/tracker/domain/src/lib/models/hierarchy.spec.ts b/libs/web/tracker/domain/src/lib/models/hierarchy.spec.ts new file mode 100644 index 0000000..1464615 --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/hierarchy.spec.ts @@ -0,0 +1,28 @@ +import { StatusCategory } from './enums'; +import { checklistProgress, childProgress } from './hierarchy'; +import { IssueSummary } from './models'; + +const child = (statusCategory: StatusCategory) => + ({ statusCategory }) as IssueSummary; + +describe('hierarchy', () => { + it('counts done and cancelled subtasks as finished', () => { + expect( + childProgress([ + child(StatusCategory.DONE), + child(StatusCategory.CANCELLED), + child(StatusCategory.IN_PROGRESS), + ]), + ).toMatchObject({ total: 3, finished: 2, percent: 67 }); + expect(childProgress([]).percent).toBe(0); + }); + + it('counts ticked checklist steps', () => { + expect( + checklistProgress([ + { id: 'a', text: 'a', done: true }, + { id: 'b', text: 'b', done: false }, + ]), + ).toEqual({ done: 1, total: 2 }); + }); +}); diff --git a/libs/web/tracker/domain/src/lib/models/hierarchy.ts b/libs/web/tracker/domain/src/lib/models/hierarchy.ts new file mode 100644 index 0000000..605a8f2 --- /dev/null +++ b/libs/web/tracker/domain/src/lib/models/hierarchy.ts @@ -0,0 +1,32 @@ +import { ChecklistItem, ChildProgress, IssueSummary } from './models'; +import { StatusCategory } from './enums'; + +const FINISHED: StatusCategory[] = [ + StatusCategory.DONE, + StatusCategory.CANCELLED, +]; + +/** Progress of the tickets hanging from one (story points are not loaded here). */ +export function childProgress(children: IssueSummary[]): ChildProgress { + const finished = children.filter((c) => + FINISHED.includes(c.statusCategory), + ).length; + return { + total: children.length, + finished, + percent: + children.length === 0 + ? 0 + : Math.round((finished / children.length) * 100), + storyPoints: 0, + finishedStoryPoints: 0, + }; +} + +/** "3/5": ticked steps of a checklist. */ +export function checklistProgress(items: ChecklistItem[]): { + done: number; + total: number; +} { + return { done: items.filter((i) => i.done).length, total: items.length }; +} diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts index 090f5d3..a991bf0 100644 --- a/libs/web/tracker/domain/src/lib/models/inputs.ts +++ b/libs/web/tracker/domain/src/lib/models/inputs.ts @@ -30,10 +30,12 @@ export interface CreateIssueInput { figmaUrl?: string | null; assigneeId?: string | null; releaseId?: string | null; + /** Epic or ticket the new one breaks down. */ + parentId?: string | null; } export type UpdateIssueInput = Partial< - Omit + Omit > & { issueId: string; }; diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index 36675fd..1c3f46b 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -40,6 +40,36 @@ export interface IssueLink { issueKey: string; } +/** The ticket another one hangs from. */ +export interface IssueParent { + id: string; + key: string; + title: string; +} + +/** A step inside a ticket that does not deserve a ticket of its own. */ +export interface ChecklistItem { + id: string; + text: string; + done: boolean; +} + +/** How far the tickets hanging from one have got. */ +export interface ChildProgress { + total: number; + /** Done or cancelled. */ + finished: number; + percent: number; + storyPoints: number; + finishedStoryPoints: number; +} + +/** A top-level ticket that groups others. */ +export interface Epic { + issue: IssueSummary; + progress: ChildProgress; +} + /** What boards and lists show. */ export interface IssueSummary { id: string; @@ -55,6 +85,8 @@ export interface IssueSummary { assignee: UserRef | null; release: { id: string; name: string } | null; labels: string[]; + /** The epic or ticket this one breaks down. */ + parent: IssueParent | null; commentCount: number; updatedAt: string; version: number; @@ -88,6 +120,7 @@ export interface Issue extends IssueSummary { attachments: Attachment[]; /** People following the ticket. */ watchers: UserRef[]; + checklist: ChecklistItem[]; allowedTransitions: string[]; createdAt: string; } diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 561ea7d..8c57fd0 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -4,6 +4,7 @@ import { Attachment, BoardColumn, Comment, + Epic, CommandResult, Issue, IssuePage, @@ -89,6 +90,30 @@ export abstract class IssuesGateway { ): Observable; /** The signed-in person follows (or stops following) the ticket. */ abstract watch(issueId: string, watching: boolean): Observable; + /** Tickets hanging from `issueId`. */ + abstract children( + projectId: string, + issueId: string, + ): Observable; + /** Places the ticket under `parentId` (null: back to the top). */ + abstract setParent( + issueId: string, + parentId: string | null, + ): Observable; + abstract epics(projectId: string): Observable; + abstract addChecklistItem( + issueId: string, + text: string, + ): Observable; + abstract updateChecklistItem( + issueId: string, + itemId: string, + changes: { text?: string; done?: boolean }, + ): Observable; + abstract removeChecklistItem( + issueId: string, + itemId: string, + ): Observable; } export abstract class CommentsGateway { 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 6d946e2..2835a0a 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 @@ -5,6 +5,7 @@ import { ChangeStatusInput, CommandResult, CreateIssueInput, + Epic, Issue, IssuePage, IssuesGateway, @@ -15,18 +16,24 @@ import { } from '@task-stream/web/tracker/domain'; import { map, Observable } from 'rxjs'; import { + AddChecklistItemDocument, AssignIssueDocument, BoardDocument, ChangeIssueStatusDocument, CreateIssueDocument, + EpicsDocument, + IssueChildrenDocument, IssueDocument, LinkIssuesDocument, MyIssuesDocument, + RemoveChecklistItemDocument, SearchIssuesDocument, + SetIssueParentDocument, SetIssueReleaseDocument, UnlinkIssuesDocument, UnwatchIssueDocument, WatchIssueDocument, + UpdateChecklistItemDocument, UpdateIssueDocument, } from '../graphql/generated'; import { GraphqlOperations } from './graphql-operations'; @@ -131,6 +138,61 @@ export class GraphqlIssuesGateway extends IssuesGateway { .pipe(map((d) => toCommandResult(d.unwatchIssue))); } + children(projectId: string, issueId: string): Observable { + return this.gql + .query(IssueChildrenDocument, { + filter: { projectId, parentId: issueId }, + }) + .pipe(map((d) => d.issues.items.map(toIssueSummary))); + } + + setParent( + issueId: string, + parentId: string | null, + ): Observable { + return this.gql + .mutate(SetIssueParentDocument, { issueId, parentId }) + .pipe(map((d) => toCommandResult(d.setIssueParent))); + } + + epics(projectId: string): Observable { + return this.gql.query(EpicsDocument, { projectId }).pipe( + map((d) => + d.epics.map((epic) => ({ + issue: toIssueSummary(epic.issue), + progress: { ...epic.progress }, + })), + ), + ); + } + + addChecklistItem(issueId: string, text: string): Observable { + return this.gql + .mutate(AddChecklistItemDocument, { input: { issueId, text } }) + .pipe(map((d) => toCommandResult(d.addChecklistItem))); + } + + updateChecklistItem( + issueId: string, + itemId: string, + changes: { text?: string; done?: boolean }, + ): Observable { + return this.gql + .mutate(UpdateChecklistItemDocument, { + input: { issueId, itemId, ...changes }, + }) + .pipe(map((d) => toCommandResult(d.updateChecklistItem))); + } + + removeChecklistItem( + issueId: string, + itemId: string, + ): Observable { + return this.gql + .mutate(RemoveChecklistItemDocument, { issueId, itemId }) + .pipe(map((d) => toCommandResult(d.removeChecklistItem))); + } + setRelease( issueId: string, releaseId: string | null, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index 3e11b54..90c907d 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -69,6 +69,11 @@ fragment IssueSummaryFields on Issue { id name } + parent { + id + key + title + } } fragment IssueFields on Issue { @@ -91,6 +96,11 @@ fragment IssueFields on Issue { watchers { ...UserRefFields } + checklist { + id + text + done + } attachments { id fileName diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 1d9ec3f..b128741 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -7,6 +7,11 @@ export type Incremental = [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never; }; import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +export type AddChecklistItemInput = { + issueId: string; + text: string; +}; + export type AreaOptionInput = { archived?: boolean; id: string; @@ -30,6 +35,8 @@ export type CreateIssueInput = { /** Figma frame for UI changes */ figmaUrl?: string | null | undefined; labels?: Array | null | undefined; + /** Epic or ticket of the same project the new one breaks down */ + parentId?: string | null | undefined; priority?: IssuePriority | null | undefined; projectId: string; releaseId?: string | null | undefined; @@ -61,6 +68,8 @@ export type IssueFilterInput = { assigneeId?: string | null | undefined; categories?: Array | null | undefined; labels?: Array | null | undefined; + /** Only the tickets hanging from this one */ + parentId?: string | null | undefined; priorities?: Array | null | undefined; projectId: string; releaseId?: string | null | undefined; @@ -139,6 +148,13 @@ export type StatusTransitionsInput = { to: Array; }; +export type UpdateChecklistItemInput = { + done?: boolean | null | undefined; + issueId: string; + itemId: string; + text?: string | null | undefined; +}; + export type UpdateIssueInput = { /** An area id */ area?: string | null | undefined; @@ -219,6 +235,7 @@ export type IssueSummaryFieldsFragment = { version: number; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + parent: { id: string; key: string; title: string } | null; }; export type IssueFieldsFragment = { @@ -249,6 +266,7 @@ export type IssueFieldsFragment = { issueKey: string; }>; watchers: Array<{ id: string; name: string }>; + checklist: Array<{ id: string; text: string; done: boolean }>; attachments: Array<{ id: string; fileName: string; @@ -260,6 +278,7 @@ export type IssueFieldsFragment = { }>; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + parent: { id: string; key: string; title: string } | null; }; export type CommentFieldsFragment = { @@ -326,6 +345,7 @@ export type BoardQuery = { version: number; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + parent: { id: string; key: string; title: string } | null; }>; }>; }; @@ -364,6 +384,7 @@ export type IssueQuery = { issueKey: string; }>; watchers: Array<{ id: string; name: string }>; + checklist: Array<{ id: string; text: string; done: boolean }>; attachments: Array<{ id: string; fileName: string; @@ -375,6 +396,7 @@ export type IssueQuery = { }>; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + parent: { id: string; key: string; title: string } | null; }; }; @@ -400,6 +422,7 @@ export type SearchIssuesQuery = { version: number; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + parent: { id: string; key: string; title: string } | null; }>; }; }; @@ -549,10 +572,106 @@ export type MyIssuesQuery = { version: number; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + parent: { id: string; key: string; title: string } | null; }>; }; }; +export type IssueChildrenQueryVariables = Exact<{ + filter: IssueFilterInput; +}>; + +export type IssueChildrenQuery = { + issues: { + items: Array<{ + id: string; + projectId: string; + key: string; + type: string; + area: string | null; + title: string; + status: string; + statusCategory: StatusCategory; + priority: IssuePriority; + labels: Array; + commentCount: number; + updatedAt: string; + version: number; + assignee: { id: string; name: string } | null; + release: { id: string; name: string } | null; + parent: { id: string; key: string; title: string } | null; + }>; + }; +}; + +export type EpicsQueryVariables = Exact<{ + projectId: string; +}>; + +export type EpicsQuery = { + epics: Array<{ + issue: { + id: string; + projectId: string; + key: string; + type: string; + area: string | null; + title: string; + status: string; + statusCategory: StatusCategory; + priority: IssuePriority; + labels: Array; + commentCount: number; + updatedAt: string; + version: number; + assignee: { id: string; name: string } | null; + release: { id: string; name: string } | null; + parent: { id: string; key: string; title: string } | null; + }; + progress: { + total: number; + finished: number; + percent: number; + storyPoints: number; + finishedStoryPoints: number; + }; + }>; +}; + +export type SetIssueParentMutationVariables = Exact<{ + issueId: string; + parentId?: string | null | undefined; +}>; + +export type SetIssueParentMutation = { + setIssueParent: { id: string; key: string | null; version: number }; +}; + +export type AddChecklistItemMutationVariables = Exact<{ + input: AddChecklistItemInput; +}>; + +export type AddChecklistItemMutation = { + addChecklistItem: { id: string; key: string | null; version: number }; +}; + +export type UpdateChecklistItemMutationVariables = Exact<{ + input: UpdateChecklistItemInput; +}>; + +export type UpdateChecklistItemMutation = { + updateChecklistItem: { id: string; key: string | null; version: number }; +}; + +export type RemoveChecklistItemMutationVariables = Exact<{ + issueId: string; + itemId: string; +}>; + +export type RemoveChecklistItemMutation = { + removeChecklistItem: { id: string; key: string | null; version: number }; +}; + export type NotificationFieldsFragment = { id: string; kind: NotificationKind; @@ -987,6 +1106,7 @@ export type IssueChangedSubscription = { version: number; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + parent: { id: string; key: string; title: string } | null; }; }; }; @@ -1346,6 +1466,18 @@ export const IssueSummaryFieldsFragmentDoc = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'parent' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, ], }, }, @@ -1431,6 +1563,18 @@ export const IssueFieldsFragmentDoc = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'checklist' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'text' } }, + { kind: 'Field', name: { kind: 'Name', value: 'done' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'attachments' }, @@ -1524,6 +1668,18 @@ export const IssueFieldsFragmentDoc = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'parent' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, ], }, }, @@ -1926,6 +2082,18 @@ export const BoardDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'parent' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, ], }, }, @@ -2045,6 +2213,18 @@ export const IssueDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'parent' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, ], }, }, @@ -2110,6 +2290,18 @@ export const IssueDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'checklist' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'text' } }, + { kind: 'Field', name: { kind: 'Name', value: 'done' } }, + ], + }, + }, { kind: 'Field', name: { kind: 'Name', value: 'attachments' }, @@ -2271,6 +2463,18 @@ export const SearchIssuesDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'parent' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, ], }, }, @@ -3474,11 +3678,652 @@ export const MyIssuesDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'parent' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, ], }, }, ], } as unknown as DocumentNode; +export const IssueChildrenDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'IssueChildren' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'filter' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'IssueFilterInput' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'issues' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'filter' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'filter' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'limit' }, + value: { kind: 'IntValue', value: '200' }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'items' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Issue' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, + { kind: 'Field', name: { kind: 'Name', value: 'priority' } }, + { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, + { kind: 'Field', name: { kind: 'Name', value: 'commentCount' } }, + { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'assignee' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'release' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'parent' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const EpicsDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'Epics' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'epics' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'projectId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'issue' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'progress' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'total' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'finished' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'percent' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'storyPoints' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'finishedStoryPoints' }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'UserRefFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UserRef' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'IssueSummaryFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Issue' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'type' } }, + { kind: 'Field', name: { kind: 'Name', value: 'area' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } }, + { kind: 'Field', name: { kind: 'Name', value: 'priority' } }, + { kind: 'Field', name: { kind: 'Name', value: 'labels' } }, + { kind: 'Field', name: { kind: 'Name', value: 'commentCount' } }, + { kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'version' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'assignee' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'UserRefFields' }, + }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'release' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + ], + }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'parent' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const SetIssueParentDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'SetIssueParent' }, + 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: 'parentId' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'setIssueParent' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'parentId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'parentId' }, + }, + }, + ], + 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< + SetIssueParentMutation, + SetIssueParentMutationVariables +>; +export const AddChecklistItemDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'AddChecklistItem' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'AddChecklistItemInput' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'addChecklistItem' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'input' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + }, + ], + 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< + AddChecklistItemMutation, + AddChecklistItemMutationVariables +>; +export const UpdateChecklistItemDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'UpdateChecklistItem' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UpdateChecklistItemInput' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'updateChecklistItem' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'input' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + }, + ], + 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< + UpdateChecklistItemMutation, + UpdateChecklistItemMutationVariables +>; +export const RemoveChecklistItemDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'RemoveChecklistItem' }, + 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: 'itemId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'removeChecklistItem' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'itemId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'itemId' }, + }, + }, + ], + 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< + RemoveChecklistItemMutation, + RemoveChecklistItemMutationVariables +>; export const MyNotificationsDocument = { kind: 'Document', definitions: [ @@ -6163,6 +7008,18 @@ export const IssueChangedDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'parent' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'key' } }, + { kind: 'Field', name: { kind: 'Name', value: 'title' } }, + ], + }, + }, ], }, }, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index 62559a7..e5c11ab 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -129,3 +129,50 @@ query MyIssues( } } } + +query IssueChildren($filter: IssueFilterInput!) { + issues(filter: $filter, limit: 200) { + items { + ...IssueSummaryFields + } + } +} + +query Epics($projectId: ID!) { + epics(projectId: $projectId) { + issue { + ...IssueSummaryFields + } + progress { + total + finished + percent + storyPoints + finishedStoryPoints + } + } +} + +mutation SetIssueParent($issueId: ID!, $parentId: ID) { + setIssueParent(issueId: $issueId, parentId: $parentId) { + ...CommandResultFields + } +} + +mutation AddChecklistItem($input: AddChecklistItemInput!) { + addChecklistItem(input: $input) { + ...CommandResultFields + } +} + +mutation UpdateChecklistItem($input: UpdateChecklistItemInput!) { + updateChecklistItem(input: $input) { + ...CommandResultFields + } +} + +mutation RemoveChecklistItem($issueId: ID!, $itemId: ID!) { + removeChecklistItem(issueId: $issueId, itemId: $itemId) { + ...CommandResultFields + } +} diff --git a/libs/web/tracker/presenters/src/lib/pages/epics.page.ts b/libs/web/tracker/presenters/src/lib/pages/epics.page.ts new file mode 100644 index 0000000..3529e05 --- /dev/null +++ b/libs/web/tracker/presenters/src/lib/pages/epics.page.ts @@ -0,0 +1,147 @@ +import { + ChangeDetectionStrategy, + Component, + effect, + inject, + untracked, +} from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { + PiEmptyStateComponent, + PiNoteComponent, + PiProgressBarComponent, + PiSkeletonComponent, +} from '@pi/ui'; +import { + EpicsFacade, + ProjectFacade, + provideEpicsFacade, +} from '@task-stream/web/tracker/application'; +import { Epic } from '@task-stream/web/tracker/domain'; +import { statusLook, typeLook } from '../ui/copy'; + +/** /p/:key/epics — the big pieces of work and how far each has got. */ +@Component({ + selector: 'ts-epics-page', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + RouterLink, + PiEmptyStateComponent, + PiNoteComponent, + PiProgressBarComponent, + PiSkeletonComponent, + ], + providers: [provideEpicsFacade()], + template: ` +
+

+ Una épica es un ticket que agrupa a otros. Cuelga tickets de ella desde + su ficha («Subtareas») y aquí verás cuánto falta. +

+
+ + @if (facade.error(); as error) { + + } @else if (!facade.loaded()) { + + } @else if (facade.epics().length) { + + } @else { + + } + `, + styles: ` + .ts-epics { + display: grid; + gap: 0.75rem; + margin: 0; + padding: 0; + list-style: none; + } + .ts-epic { + display: grid; + gap: 0.75rem; + padding: 1rem 1.125rem; + border-left: 3px solid var(--tone, var(--ts-brand)); + } + .ts-epic__head { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto; + gap: 0.625rem; + align-items: center; + } + .ts-epic__title { + overflow: hidden; + color: inherit; + font-weight: 600; + text-decoration: none; + text-overflow: ellipsis; + white-space: nowrap; + &:hover { + color: var(--ts-brand); + } + } + `, +}) +export class EpicsPage { + protected readonly facade = inject(EpicsFacade); + private readonly project = inject(ProjectFacade); + + constructor() { + effect(() => { + const projectId = this.project.project()?.id; + if (projectId) untracked(() => this.facade.open(projectId)); + }); + } + + protected summary(epic: Epic): string { + const { finished, total, percent, storyPoints, finishedStoryPoints } = + epic.progress; + const points = storyPoints + ? ` · ${finishedStoryPoints}/${storyPoints} puntos` + : ''; + return `${percent}% · ${finished} de ${total} tickets terminados${points}`; + } + + protected typeTone = (epic: Epic) => + typeLook(this.project.workflow(), epic.issue.type).tone; + protected statusTone = (epic: Epic) => + statusLook(this.project.workflow(), epic.issue.status).tone; + protected statusLabel = (epic: Epic) => + statusLook(this.project.workflow(), epic.issue.status).label; +} 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 df0777e..c61ba09 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.scss +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.scss @@ -192,6 +192,92 @@ a.ts-key { } } +.ts-inline-form--two { + grid-template-columns: minmax(0, 1fr) auto; +} + +// Breakdown: the ticket it hangs from, its checklist and its subtasks. +.ts-parent { + display: inline-flex; + align-items: center; + gap: 0.375rem; + min-width: 0; + color: var(--ts-muted); + font-size: 0.8125rem; + text-decoration: none; + + &:hover { + color: var(--ts-brand); + } +} + +.ts-parent__title { + overflow: hidden; + max-width: 18rem; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ts-checklist, +.ts-children { + display: grid; + gap: 0.25rem; + margin: 0; + padding: 0; + list-style: none; +} + +.ts-checklist__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + + &.is-done { + color: var(--ts-muted); + text-decoration: line-through; + } +} + +.ts-children { + margin-top: 0.75rem; +} + +.ts-child { + display: grid; + grid-template-columns: auto minmax(0, 1fr) auto auto; + gap: 0.625rem; + align-items: center; + padding: 0.375rem 0; + border-top: 1px solid var(--ts-line); + + .ts-comment__avatar { + width: 1.5rem; + height: 1.5rem; + font-size: 0.625rem; + } +} + +.ts-child__title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ts-parent-form { + display: flex; + align-items: center; + gap: 0.75rem; + + .ts-inline-form { + flex: 1; + } + + > .ts-muted { + margin-top: 0.875rem; + } +} + // Comments: a thread with initials on a rail. .ts-comment { display: grid; 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 65d8d79..95a93f2 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -14,11 +14,13 @@ import { form, FormField, required } from '@angular/forms/signals'; import { RouterLink } from '@angular/router'; import { PiActionButtonComponent, + PiCheckboxComponent, PiFileUploadComponent, PiIconButtonComponent, PiIconComponent, PiLabelDirective, PiNoteComponent, + PiProgressBarComponent, PiSelectFieldComponent, PiSkeletonComponent, PiTextareaComponent, @@ -68,11 +70,13 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; FormField, AttachmentItem, PiActionButtonComponent, + PiCheckboxComponent, PiFileUploadComponent, PiIconComponent, PiIconButtonComponent, PiLabelDirective, PiNoteComponent, + PiProgressBarComponent, PiSelectFieldComponent, PiSkeletonComponent, PiTextFieldComponent, @@ -96,6 +100,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; {{ status().label }} + @if (issue.parent; as parent) { + + + {{ parent.key }} + {{ parent.title }} + + }

{{ issue.title }}

@@ -161,6 +176,168 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; } +
+

+ Checklist + @if (issue.checklist.length) { + {{ facade.checklistProgress().done }}/{{ + facade.checklistProgress().total + }} + } +

+ @if (issue.checklist.length) { +
    + @for (item of issue.checklist; track item.id) { +
  • + + @if (project.canContribute()) { + + } +
  • + } +
+ } @else if (!project.canContribute()) { +

Sin pasos.

+ } + @if (project.canContribute()) { +
+ + + + } +
+ +
+

+ Subtareas + @if (facade.children().length) { + {{ facade.childProgress().finished }}/{{ + facade.childProgress().total + }} + } +

+ @if (facade.children().length) { + +
    + @for (child of facade.children(); track child.id) { +
  • + {{ + child.key + }} + {{ child.title }} + {{ statusLabel(child.status) }} + @if (child.assignee) { + {{ initialsOf(child.assignee.name) }} + } +
  • + } +
+ } @else if (!project.canContribute()) { +

Sin subtareas.

+ } + @if (project.canContribute()) { +
+ + + + +
+ @if (issue.parent; as parent) { + Cuelga de + {{ + parent.key + }} + + } @else { +
+ + + + } +
+ } +
+

Adjuntos @@ -639,6 +816,23 @@ export class IssuePage { ) .slice(0, 6); }); + protected readonly checklistText = signal(''); + protected readonly subtaskTitle = signal(''); + protected readonly subtaskType = signal(null); + protected readonly parentKey = signal(''); + /** Active types; subtasks start as tasks when the project has them. */ + protected readonly subtaskTypeOptions = computed< + PiSelectFieldOption[] + >(() => + this.project + .workflow() + .settings.issueTypes.filter((t) => !t.archived) + .map((t) => ({ value: t.id, label: t.name })), + ); + protected readonly defaultSubtaskType = computed(() => { + const ids = this.subtaskTypeOptions().map((o) => o.value); + return ids.includes('TASK') ? 'TASK' : (ids[0] ?? ''); + }); protected readonly pendingFiles = signal([]); protected readonly maxFileBytes = MAX_FILE_BYTES; @@ -687,6 +881,28 @@ export class IssuePage { ? LINK[link.type].outward : LINK[link.type].inward; + protected addChecklistItem(): void { + const text = this.checklistText().trim(); + if (!text) return; + this.facade.addChecklistItem(text); + this.checklistText.set(''); + } + + protected addSubtask(): void { + const title = this.subtaskTitle().trim(); + const type = this.subtaskType() ?? this.defaultSubtaskType(); + if (!title || !type) return; + this.facade.addSubtask(title, type); + this.subtaskTitle.set(''); + } + + protected setParent(): void { + const key = this.parentKey().trim(); + if (!key) return; + this.facade.setParent(key); + this.parentKey.set(''); + } + protected move(to: string): void { if (this.project.workflow().requiresReason(to)) { this.pendingStatus = to; diff --git a/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts b/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts index e9cd003..3d57c27 100644 --- a/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/project-shell.page.ts @@ -85,6 +85,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy'; }