diff --git a/apps/api/openapi.json b/apps/api/openapi.json index d78d7a7..c14e7ac 100644 --- a/apps/api/openapi.json +++ b/apps/api/openapi.json @@ -1059,6 +1059,16 @@ "type": "boolean" } }, + { + "name": "sprintId", + "required": false, + "in": "query", + "description": "Only this sprint", + "schema": { + "format": "uuid", + "type": "string" + } + }, { "name": "perColumn", "required": false, @@ -2240,6 +2250,25 @@ "type": "string" } }, + { + "name": "sprintId", + "required": false, + "in": "query", + "description": "Only this sprint", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "noSprint", + "required": false, + "in": "query", + "description": "Only tickets in no sprint", + "schema": { + "type": "boolean" + } + }, { "name": "sort", "required": false, @@ -6272,6 +6301,1075 @@ "Saved filters" ] } + }, + "/api/projects/{project}/sprints": { + "get": { + "description": "The one under way, the planned ones, then the closed ones.", + "operationId": "SprintsController_list", + "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/SprintDto" + } + } + } + } + }, + "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": "A project's sprints", + "tags": [ + "Sprints" + ] + }, + "post": { + "operationId": "SprintsController_create", + "parameters": [ + { + "name": "project", + "required": true, + "in": "path", + "description": "Project id or key", + "schema": { + "example": "WEB", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateSprintDto" + } + } + } + }, + "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": "Plan a sprint (owners and admins)", + "tags": [ + "Sprints" + ] + } + }, + "/api/sprints/{sprint}": { + "get": { + "operationId": "SprintsController_findOne", + "parameters": [ + { + "name": "sprint", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SprintDto" + } + } + } + }, + "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": "A sprint", + "tags": [ + "Sprints" + ] + }, + "patch": { + "operationId": "SprintsController_update", + "parameters": [ + { + "name": "sprint", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateSprintDto" + } + } + } + }, + "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": "Rename, re-date or change the goal (not once closed)", + "tags": [ + "Sprints" + ] + }, + "delete": { + "description": "Its tickets go back to the backlog.", + "operationId": "SprintsController_delete", + "parameters": [ + { + "name": "sprint", + "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": "Delete a sprint that never started", + "tags": [ + "Sprints" + ] + } + }, + "/api/sprints/{sprint}/start": { + "post": { + "operationId": "SprintsController_start", + "parameters": [ + { + "name": "sprint", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "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": "Start a planned sprint (only one under way per project)", + "tags": [ + "Sprints" + ] + } + }, + "/api/sprints/{sprint}/close": { + "post": { + "description": "Unfinished tickets move to `carryOverTo` (another open sprint) or back to the backlog; finished ones stay as its record.", + "operationId": "SprintsController_close", + "parameters": [ + { + "name": "sprint", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CloseSprintDto" + } + } + } + }, + "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": "Close the sprint under way", + "tags": [ + "Sprints" + ] + } + }, + "/api/sprints/{sprint}/progress": { + "get": { + "operationId": "SprintsController_progress", + "parameters": [ + { + "name": "sprint", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SprintProgressDto" + } + } + } + }, + "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": "Finished tickets and points", + "tags": [ + "Sprints" + ] + } + }, + "/api/sprints/{sprint}/burndown": { + "get": { + "operationId": "SprintsController_burndown", + "parameters": [ + { + "name": "sprint", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SprintBurndownDto" + } + } + } + }, + "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": "Open tickets and points, day by day, against the ideal line", + "tags": [ + "Sprints" + ] + } + }, + "/api/sprints/{sprint}/issues": { + "get": { + "operationId": "SprintsController_issuesOf", + "parameters": [ + { + "name": "sprint", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "type": "string" + } + }, + { + "name": "limit", + "required": false, + "in": "query", + "schema": { + "minimum": 1, + "maximum": 200, + "default": 50, + "type": "number" + } + }, + { + "name": "offset", + "required": false, + "in": "query", + "schema": { + "minimum": 0, + "default": 0, + "type": "number" + } + } + ], + "responses": { + "200": { + "description": "", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssuePageDto" + } + } + } + }, + "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": "Tickets in the sprint, in backlog order", + "tags": [ + "Sprints" + ] + } + }, + "/api/issues/{issue}/sprint": { + "put": { + "operationId": "SprintsController_setIssueSprint", + "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/SetIssueSprintDto" + } + } + } + }, + "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": "Put the ticket in a sprint (null: the backlog)", + "tags": [ + "Sprints" + ] + } } }, "info": { @@ -7124,6 +8222,23 @@ "name" ] }, + "SprintRefDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "example": "Sprint 14" + } + }, + "required": [ + "id", + "name" + ] + }, "IssueLinkType": { "type": "string", "enum": [ @@ -7351,6 +8466,15 @@ } ] }, + "sprint": { + "nullable": true, + "type": "object", + "allOf": [ + { + "$ref": "#/components/schemas/SprintRefDto" + } + ] + }, "labels": { "type": "array", "items": { @@ -7442,6 +8566,7 @@ "reporter", "assignee", "release", + "sprint", "labels", "figmaUrl", "storyPoints", @@ -8774,6 +9899,247 @@ "required": [ "id" ] + }, + "SprintStatus": { + "type": "string", + "enum": [ + "PLANNED", + "ACTIVE", + "CLOSED" + ] + }, + "SprintDto": { + "type": "object", + "properties": { + "id": { + "type": "string", + "format": "uuid" + }, + "projectId": { + "type": "string", + "format": "uuid" + }, + "name": { + "type": "string", + "example": "Sprint 14" + }, + "goal": { + "type": "string", + "nullable": true + }, + "startDate": { + "type": "string", + "format": "date" + }, + "endDate": { + "type": "string", + "format": "date", + "description": "Last day, included" + }, + "status": { + "allOf": [ + { + "$ref": "#/components/schemas/SprintStatus" + } + ] + }, + "startedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "closedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "createdAt": { + "type": "string", + "format": "date-time" + }, + "version": { + "type": "number" + } + }, + "required": [ + "id", + "projectId", + "name", + "goal", + "startDate", + "endDate", + "status", + "startedAt", + "closedAt", + "createdAt", + "version" + ] + }, + "CreateSprintDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Sprint 14", + "maxLength": 80 + }, + "goal": { + "type": "string", + "nullable": true, + "maxLength": 500, + "example": "Pagos con tarjeta en producción" + }, + "startDate": { + "type": "string", + "format": "date", + "example": "2026-10-12" + }, + "endDate": { + "type": "string", + "format": "date", + "example": "2026-10-23", + "description": "Last day, included" + } + }, + "required": [ + "name", + "startDate", + "endDate" + ] + }, + "UpdateSprintDto": { + "type": "object", + "properties": { + "name": { + "type": "string", + "example": "Sprint 14", + "maxLength": 80 + }, + "goal": { + "type": "string", + "nullable": true, + "maxLength": 500, + "example": "Pagos con tarjeta en producción" + }, + "startDate": { + "type": "string", + "format": "date", + "example": "2026-10-12" + }, + "endDate": { + "type": "string", + "format": "date", + "example": "2026-10-23", + "description": "Last day, included" + } + } + }, + "CloseSprintDto": { + "type": "object", + "properties": { + "carryOverTo": { + "type": "string", + "nullable": true, + "format": "uuid", + "description": "Open sprint of the same project that takes the unfinished tickets; null or absent: back to the backlog" + } + } + }, + "SprintProgressDto": { + "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" + ] + }, + "SprintBurndownDto": { + "type": "object", + "properties": { + "sprintId": { + "type": "string", + "format": "uuid" + }, + "dates": { + "type": "array", + "items": { + "type": "string", + "format": "date" + } + }, + "scope": { + "nullable": true, + "description": "Tickets in the sprint (null: days to come)", + "type": "array", + "items": { + "type": "number" + } + }, + "remaining": { + "nullable": true, + "type": "array", + "items": { + "type": "number" + } + }, + "remainingPoints": { + "nullable": true, + "type": "array", + "items": { + "type": "number" + } + }, + "ideal": { + "type": "array", + "items": { + "type": "number" + } + } + }, + "required": [ + "sprintId", + "dates", + "scope", + "remaining", + "remainingPoints", + "ideal" + ] + }, + "SetIssueSprintDto": { + "type": "object", + "properties": { + "sprintId": { + "type": "string", + "nullable": true, + "format": "uuid", + "description": "Open sprint of the project, or null for the backlog" + } + }, + "required": [ + "sprintId" + ] } } } diff --git a/apps/api/schema.gql b/apps/api/schema.gql index c2f521f..8a0464e 100644 --- a/apps/api/schema.gql +++ b/apps/api/schema.gql @@ -176,6 +176,17 @@ input CreateReleaseInput { targetDate: String } +input CreateSprintInput { + """YYYY-MM-DD, included""" + endDate: String! + goal: String + name: String! + projectId: ID! + + """YYYY-MM-DD""" + startDate: String! +} + type CreatedResolved { created: Int! resolved: Int! @@ -249,6 +260,7 @@ type Issue { rank: String! release: ReleaseRef reporter: UserRef! + sprint: SprintRef """A status id of the project's settings""" status: String! @@ -278,6 +290,9 @@ input IssueFilterInput { categories: [StatusCategory!] labels: [String!] + """Only tickets in no sprint""" + noSprint: Boolean + """Only the tickets hanging from this one""" parentId: ID priorities: [IssuePriority!] @@ -287,6 +302,7 @@ input IssueFilterInput { """Matches key, title or description""" search: String sort: IssueSort = RECENT + sprintId: ID statuses: [String!] types: [String!] unassigned: Boolean @@ -388,10 +404,18 @@ type Mutation { """Move a ticket through the workflow (incl. QA)""" changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult! changeProjectMemberRole(input: ProjectMemberInput!): CommandResult! + + """ + Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null. + """ + closeSprint(carryOverToSprintId: ID, sprintId: ID!): CommandResult! createIssue(input: CreateIssueInput!): CommandResult! createProject(input: CreateProjectInput!): CommandResult! createRelease(input: CreateReleaseInput!): CommandResult! + """Plan a sprint (owners and admins)""" + createSprint(input: CreateSprintInput!): CommandResult! + """Author or project admin""" deleteComment(commentId: ID!): CommandResult! @@ -402,6 +426,9 @@ type Mutation { """Delete a view: whoever saved it, or an admin if it is shared""" deleteSavedFilter(id: ID!): Boolean! + + """Delete a sprint that never started; its tickets go back to the backlog""" + deleteSprint(sprintId: ID!): CommandResult! editComment(body: String!, commentId: ID!): CommandResult! """Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE""" @@ -435,6 +462,12 @@ type Mutation { """Put a ticket in a release (null removes it)""" setIssueRelease(issueId: ID!, releaseId: ID): CommandResult! + + """Put a ticket in a sprint (null: back to the backlog)""" + setIssueSprint(issueId: ID!, sprintId: ID): CommandResult! + + """Start a planned sprint (only one under way per project)""" + startSprint(sprintId: ID!): CommandResult! unlinkIssues(input: LinkIssuesInput!): CommandResult! """Stop following a ticket""" @@ -451,6 +484,7 @@ type Mutation { """ updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult! updateRelease(input: UpdateReleaseInput!): CommandResult! + updateSprint(input: UpdateSprintInput!): CommandResult! """ Follow a ticket: be told when it moves, is reassigned or gets a comment @@ -517,6 +551,7 @@ type Project { """Matches key, title or description""" search: String + sprintId: ID types: [String!] unassigned: Boolean ): [BoardColumn!]! @@ -668,6 +703,12 @@ type Query { """My views of the project's board, then the shared ones""" savedFilters(projectId: ID!): [SavedFilter!]! + sprint(id: ID!): Sprint! + + """ + A project's sprints: the one under way, the planned ones, then the closed ones + """ + sprints(projectId: ID!): [Sprint!]! """Planned releases of all my projects, soonest target date first""" upcomingReleases(limit: Int! = 10): [Release!]! @@ -784,6 +825,73 @@ input SavedFilterCriteriaInput { unassigned: Boolean } +"""A time-boxed iteration: the tickets the team commits to""" +type Sprint { + burndown: SprintBurndown! + closedAt: String + createdAt: String! + + """Last day (included), YYYY-MM-DD""" + endDate: String! + goal: String + id: ID! + + """Tickets in this sprint, in backlog order""" + issues(limit: Int! = 100, offset: Int! = 0): IssuePage! + name: String! + progress: SprintProgress! + projectId: ID! + + """First day, YYYY-MM-DD""" + startDate: String! + startedAt: String + status: SprintStatus! + version: Int! +} + +""" +Per day of the sprint: tickets in it, still open, and their points (null: days to come). `ideal` falls from the first day to zero. +""" +type SprintBurndown { + dates: [String!]! + ideal: [Float!]! + remaining: [Int]! + remainingPoints: [Int]! + scope: [Int]! + sprintId: ID! +} + +type SprintChange { + action: String! + actorId: ID! + + """Null: deleted""" + sprint: Sprint + sprintId: ID! + version: Int! +} + +type SprintProgress { + """Done or cancelled""" + finished: Int! + finishedStoryPoints: Int! + percent: Int! + storyPoints: Int! + total: Int! +} + +type SprintRef { + id: ID! + name: String! +} + +"""PLANNED, ACTIVE (one per project at a time) or CLOSED""" +enum SprintStatus { + ACTIVE + CLOSED + PLANNED +} + """ What a status means, whatever each project calls it. DONE and CANCELLED are finished. """ @@ -820,6 +928,7 @@ type Subscription { notificationAdded: Notification! projectChanged(projectId: ID!): ProjectChange! releaseChanged(projectId: ID!): ReleaseChange! + sprintChanged(projectId: ID!): SprintChange! } type Throughput { @@ -865,6 +974,14 @@ input UpdateReleaseInput { targetDate: String } +input UpdateSprintInput { + endDate: String + goal: String + name: String + sprintId: ID! + startDate: String +} + """User managed by the Pireon identity server""" type User { email: String! diff --git a/apps/mcp/src/app/api/taskstream.schema.ts b/apps/mcp/src/app/api/taskstream.schema.ts index f0e0dab..17bc158 100644 --- a/apps/mcp/src/app/api/taskstream.schema.ts +++ b/apps/mcp/src/app/api/taskstream.schema.ts @@ -909,6 +909,154 @@ export interface paths { patch?: never; trace?: never; }; + "/api/projects/{project}/sprints": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * A project's sprints + * @description The one under way, the planned ones, then the closed ones. + */ + get: operations["SprintsController_list"]; + put?: never; + /** Plan a sprint (owners and admins) */ + post: operations["SprintsController_create"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/sprints/{sprint}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** A sprint */ + get: operations["SprintsController_findOne"]; + put?: never; + post?: never; + /** + * Delete a sprint that never started + * @description Its tickets go back to the backlog. + */ + delete: operations["SprintsController_delete"]; + options?: never; + head?: never; + /** Rename, re-date or change the goal (not once closed) */ + patch: operations["SprintsController_update"]; + trace?: never; + }; + "/api/sprints/{sprint}/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Start a planned sprint (only one under way per project) */ + post: operations["SprintsController_start"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/sprints/{sprint}/close": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Close the sprint under way + * @description Unfinished tickets move to `carryOverTo` (another open sprint) or back to the backlog; finished ones stay as its record. + */ + post: operations["SprintsController_close"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/sprints/{sprint}/progress": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Finished tickets and points */ + get: operations["SprintsController_progress"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/sprints/{sprint}/burndown": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Open tickets and points, day by day, against the ideal line */ + get: operations["SprintsController_burndown"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/sprints/{sprint}/issues": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Tickets in the sprint, in backlog order */ + get: operations["SprintsController_issuesOf"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/issues/{issue}/sprint": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Put the ticket in a sprint (null: the backlog) */ + put: operations["SprintsController_setIssueSprint"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; } export type webhooks = Record; export interface components { @@ -1167,6 +1315,12 @@ export interface components { /** @example v1.4.0 */ name: string; }; + SprintRefDto: { + /** Format: uuid */ + id: string; + /** @example Sprint 14 */ + name: string; + }; /** @enum {string} */ IssueLinkType: "BLOCKS" | "DEPENDS_ON" | "TESTS" | "DUPLICATES" | "RELATES_TO"; /** @@ -1254,6 +1408,7 @@ export interface components { reporter: components["schemas"]["UserRefDto"]; assignee: components["schemas"]["UserRefDto"] | null; release: components["schemas"]["ReleaseRefDto"] | null; + sprint: components["schemas"]["SprintRefDto"] | null; labels: string[]; /** @description Figma frame */ figmaUrl: string | null; @@ -1707,6 +1862,98 @@ export interface components { /** Format: uuid */ id: string; }; + /** @enum {string} */ + SprintStatus: "PLANNED" | "ACTIVE" | "CLOSED"; + SprintDto: { + /** Format: uuid */ + id: string; + /** Format: uuid */ + projectId: string; + /** @example Sprint 14 */ + name: string; + goal: string | null; + /** Format: date */ + startDate: string; + /** + * Format: date + * @description Last day, included + */ + endDate: string; + status: components["schemas"]["SprintStatus"]; + /** Format: date-time */ + startedAt: string | null; + /** Format: date-time */ + closedAt: string | null; + /** Format: date-time */ + createdAt: string; + version: number; + }; + CreateSprintDto: { + /** @example Sprint 14 */ + name: string; + /** @example Pagos con tarjeta en producción */ + goal?: string | null; + /** + * Format: date + * @example 2026-10-12 + */ + startDate: string; + /** + * Format: date + * @description Last day, included + * @example 2026-10-23 + */ + endDate: string; + }; + UpdateSprintDto: { + /** @example Sprint 14 */ + name?: string; + /** @example Pagos con tarjeta en producción */ + goal?: string | null; + /** + * Format: date + * @example 2026-10-12 + */ + startDate?: string; + /** + * Format: date + * @description Last day, included + * @example 2026-10-23 + */ + endDate?: string; + }; + CloseSprintDto: { + /** + * Format: uuid + * @description Open sprint of the same project that takes the unfinished tickets; null or absent: back to the backlog + */ + carryOverTo?: string | null; + }; + SprintProgressDto: { + total: number; + /** @description Done or cancelled */ + finished: number; + percent: number; + storyPoints: number; + finishedStoryPoints: number; + }; + SprintBurndownDto: { + /** Format: uuid */ + sprintId: string; + dates: string[]; + /** @description Tickets in the sprint (null: days to come) */ + scope: number[] | null; + remaining: number[] | null; + remainingPoints: number[] | null; + ideal: number[]; + }; + SetIssueSprintDto: { + /** + * Format: uuid + * @description Open sprint of the project, or null for the backlog + */ + sprintId: string | null; + }; }; responses: never; parameters: never; @@ -2438,6 +2685,8 @@ export interface operations { search?: string; /** @description Only tickets without assignee */ unassigned?: boolean; + /** @description Only this sprint */ + sprintId?: string; perColumn?: number; }; header?: never; @@ -3152,6 +3401,10 @@ export interface operations { label?: string[]; /** @description Only the tickets hanging from this one (id or key) */ parent?: string; + /** @description Only this sprint */ + sprintId?: string; + /** @description Only tickets in no sprint */ + noSprint?: boolean; /** @description RECENT (default): last updated first. RANK: backlog order. */ sort?: components["schemas"]["IssueSort"]; /** @description Matches key, title or description */ @@ -6012,4 +6265,761 @@ export interface operations { }; }; }; + SprintsController_list: { + 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"]["SprintDto"][]; + }; + }; + /** @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"]; + }; + }; + }; + }; + SprintsController_create: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Project id or key */ + project: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CreateSprintDto"]; + }; + }; + 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"]; + }; + }; + }; + }; + SprintsController_findOne: { + parameters: { + query?: never; + header?: never; + path: { + sprint: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SprintDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + SprintsController_delete: { + parameters: { + query?: never; + header?: never; + path: { + sprint: 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"]; + }; + }; + }; + }; + SprintsController_update: { + parameters: { + query?: never; + header?: never; + path: { + sprint: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["UpdateSprintDto"]; + }; + }; + 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"]; + }; + }; + }; + }; + SprintsController_start: { + parameters: { + query?: never; + header?: never; + path: { + sprint: string; + }; + cookie?: never; + }; + requestBody?: never; + 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"]; + }; + }; + }; + }; + SprintsController_close: { + parameters: { + query?: never; + header?: never; + path: { + sprint: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["CloseSprintDto"]; + }; + }; + 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"]; + }; + }; + }; + }; + SprintsController_progress: { + parameters: { + query?: never; + header?: never; + path: { + sprint: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SprintProgressDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + SprintsController_burndown: { + parameters: { + query?: never; + header?: never; + path: { + sprint: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["SprintBurndownDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + SprintsController_issuesOf: { + parameters: { + query?: { + limit?: number; + offset?: number; + }; + header?: never; + path: { + sprint: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["IssuePageDto"]; + }; + }; + /** @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"]; + }; + }; + }; + }; + SprintsController_setIssueSprint: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Ticket id or key */ + issue: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetIssueSprintDto"]; + }; + }; + 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"]; + }; + }; + }; + }; } diff --git a/apps/web/src/app/app.routes.spec.ts b/apps/web/src/app/app.routes.spec.ts index 47b1960..dc1c631 100644 --- a/apps/web/src/app/app.routes.spec.ts +++ b/apps/web/src/app/app.routes.spec.ts @@ -29,6 +29,7 @@ describe('appRoutes', () => { 'board', 'issues/:issueKey', 'backlog', + 'sprints', 'epics', 'releases', 'dashboard', diff --git a/docs/01-vision-general.md b/docs/01-vision-general.md index 99a6f67..cf6c53a 100644 --- a/docs/01-vision-general.md +++ b/docs/01-vision-general.md @@ -15,6 +15,7 @@ | 👥 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 | | 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto | +| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar | | 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden | | 🧩 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 | diff --git a/docs/02-arquitectura.md b/docs/02-arquitectura.md index 909e439..b26a766 100644 --- a/docs/02-arquitectura.md +++ b/docs/02-arquitectura.md @@ -139,9 +139,10 @@ uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada | Saga | Evento | Comandos | | ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto | +| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket, `DeleteReleaseCommand` por cada release y `DeleteSprintCommand` por cada sprint 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) | +| `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog | | `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») | diff --git a/docs/03-api.md b/docs/03-api.md index 85cb667..e9cb5d9 100644 --- a/docs/03-api.md +++ b/docs/03-api.md @@ -157,6 +157,11 @@ stateDiagram-v2 encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden. +- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro. + Un sprint va de `PLANNED` a `ACTIVE` (uno solo en curso por proyecto) y a `CLOSED`; dura como mucho 90 días y un + sprint en curso conserva su fecha de inicio. Un sprint cerrado ya no acepta ni pierde tickets. Al cerrarlo se elige + adónde van los tickets sin terminar: a otro sprint abierto del proyecto o al backlog; los terminados se quedan como + registro. Solo se borran sprints que no han empezado (sus tickets vuelven al backlog). - **Vistas guardadas**: filtros del tablero con nombre. Las guardan los miembros del proyecto (los lectores, solo para sí); compartir una la hace visible a todo el proyecto. Borra una su autor, o un admin si es compartida; como mucho 50 por persona y proyecto. Son preferencias, no historia: viven en MongoDB (`saved_filters`), fuera del event store, @@ -192,12 +197,13 @@ 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`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `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`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` | +| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` | +| Sprint | `SprintCreatedEvent`, `SprintUpdatedEvent`, `SprintStartedEvent`, `SprintClosedEvent`, `SprintDeletedEvent` | +| 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. @@ -216,6 +222,7 @@ puras (`application/src/lib/analytics`): | Tiempo de entrega | De la creación hasta `DONE`; media y percentil 85 | | Throughput | Tickets resueltos por semana y por la persona que los tenía asignados | | QA | Salidas de un estado `QA`: a `DONE` cuenta como superado; a cualquier otro sitio salvo `CANCELLED`, como fallido | +| Burndown de un sprint | Por día del sprint: tickets en él (sin cancelados), cuántos siguen abiertos y sus puntos; línea ideal hasta cero | | Burnup de una release | Por día, desde que se creó hasta que se publica (o hoy, máx. 180 días): tickets en ella (sin cancelados) y hechos | Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa, @@ -240,6 +247,7 @@ que funcionan también sin token. | `publicProjects` | Proyectos públicos (sin token) | | `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` | | `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog | +| `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` | | `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto | | `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` | @@ -255,6 +263,7 @@ Todas devuelven `CommandResult { id, key, version }`. | Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` | | Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) | | Comentarios | `addComment`, `editComment`, `deleteComment` | +| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` | | Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` | | Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` | | Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` | @@ -267,6 +276,7 @@ Todas devuelven `CommandResult { id, key, version }`. | `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) | | `activityAdded(projectId)` | Hay una nueva entrada en el feed | | `releaseChanged(projectId)` | Cambia una release | +| `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) | | `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto | | `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno | @@ -459,6 +469,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`). | 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`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` | | Comentarios | `PATCH/DELETE /api/comments/{commentId}` | +| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` | | Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` | | Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` | | Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` | diff --git a/libs/api/tracker/application/src/index.ts b/libs/api/tracker/application/src/index.ts index 042a9ca..74caf05 100644 --- a/libs/api/tracker/application/src/index.ts +++ b/libs/api/tracker/application/src/index.ts @@ -10,5 +10,6 @@ export * from './lib/workflows.service'; export * from './lib/notifications.service'; export * from './lib/notifications'; export * from './lib/saved-filters.service'; +export * from './lib/sprints.service'; export * from './lib/saved-filters/saved-filters'; export * from './lib/tracker-application.module'; diff --git a/libs/api/tracker/application/src/lib/analytics/analytics.spec.ts b/libs/api/tracker/application/src/lib/analytics/analytics.spec.ts index 3891c75..63e97e1 100644 --- a/libs/api/tracker/application/src/lib/analytics/analytics.spec.ts +++ b/libs/api/tracker/application/src/lib/analytics/analytics.spec.ts @@ -4,10 +4,13 @@ import { ReleaseReadModel, ReleaseStatus, StatusCategory as C, + SprintReadModel, + SprintStatus, } from '@task-stream/api/tracker/domain'; import { computeProjectAnalytics, computeReleaseBurnup, + computeSprintBurndown, weekOf, } from './analytics'; @@ -182,3 +185,62 @@ describe('release burnup', () => { expect(at(burnup.done, '2026-10-14')).toBe(2); }); }); + +describe('sprint burndown', () => { + const sprint = { + id: 's1', + projectId: 'p', + startDate: '2026-10-12', + endDate: '2026-10-16', + status: SprintStatus.ACTIVE, + closedAt: null, + } as SprintReadModel; + const into = (issueId: string, at: string): IssueTimelineEntry => ({ + id: `${issueId}:${++seq}`, + issueId, + projectId: 'p', + at, + kind: 'sprint', + sprintId: 's1', + }); + // X (3 points) and Y (5) committed on Monday; Z (2) added on Tuesday. + // X done on Tuesday, Y cancelled on Wednesday. Today is Wednesday noon. + const issues = [ + { ...issue('x', '2026-10-01T09:00:00Z', 'DONE', C.DONE), storyPoints: 3 }, + { + ...issue('y', '2026-10-01T09:00:00Z', 'CANCELLED', C.CANCELLED), + storyPoints: 5, + }, + { ...issue('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO), storyPoints: 2 }, + ] as IssueReadModel[]; + const timeline = [ + step('x', '2026-10-01T09:00:00Z', 'TODO', C.TODO), + step('y', '2026-10-01T09:00:00Z', 'TODO', C.TODO), + step('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO), + into('x', '2026-10-12T08:00:00Z'), + into('y', '2026-10-12T08:00:00Z'), + into('z', '2026-10-13T10:00:00Z'), + step('x', '2026-10-13T16:00:00Z', 'DONE', C.DONE), + step('y', '2026-10-14T09:00:00Z', 'CANCELLED', C.CANCELLED), + ]; + + it('counts open tickets and points per day, and leaves the future blank', () => { + const burndown = computeSprintBurndown({ + sprint, + issues, + timeline, + now: new Date('2026-10-14T12:00:00Z'), + }); + expect(burndown.dates).toEqual([ + '2026-10-12', + '2026-10-13', + '2026-10-14', + '2026-10-15', + '2026-10-16', + ]); + expect(burndown.scope).toEqual([2, 3, 2, null, null]); + expect(burndown.remaining).toEqual([2, 2, 1, null, null]); + expect(burndown.remainingPoints).toEqual([8, 7, 2, null, null]); + expect(burndown.ideal).toEqual([2, 1.5, 1, 0.5, 0]); + }); +}); diff --git a/libs/api/tracker/application/src/lib/analytics/analytics.ts b/libs/api/tracker/application/src/lib/analytics/analytics.ts index fb07501..3ff487c 100644 --- a/libs/api/tracker/application/src/lib/analytics/analytics.ts +++ b/libs/api/tracker/application/src/lib/analytics/analytics.ts @@ -5,6 +5,8 @@ import { ProjectAnalyticsReadModel, ReleaseBurnupReadModel, ReleaseReadModel, + SprintBurndownReadModel, + SprintReadModel, StatusCategory, UserRefReadModel, } from '@task-stream/api/tracker/domain'; @@ -313,3 +315,85 @@ export function computeReleaseBurnup( done, }; } + +export interface SprintBurndownInput { + sprint: SprintReadModel; + issues: IssueReadModel[]; + timeline: IssueTimelineEntry[]; + now: Date; +} + +/** + * Per day of the sprint (start to end date, UTC): tickets in it, how many + * are still open and their points, and the ideal line. Points are today's + * estimates (their history is not kept). Days after today, or after the + * sprint closed, are null. + */ +export function computeSprintBurndown( + input: SprintBurndownInput, +): SprintBurndownReadModel { + const { sprint } = input; + const steps = stepsByIssue(input.issues, input.timeline); + const points = new Map(input.issues.map((i) => [i.id, i.storyPoints ?? 0])); + const membership = new Map< + string, + { at: number; sprintId: string | null }[] + >(); + for (const entry of input.timeline) { + if (entry.kind !== 'sprint') continue; + const list = membership.get(entry.issueId) ?? []; + list.push({ at: Date.parse(entry.at), sprintId: entry.sprintId ?? null }); + membership.set(entry.issueId, list); + } + for (const list of membership.values()) list.sort((a, b) => a.at - b.at); + + const start = Date.parse(`${sprint.startDate}T00:00:00Z`); + const end = Date.parse(`${sprint.endDate}T00:00:00Z`); + const last = Math.min( + sprint.closedAt ? Date.parse(sprint.closedAt) : input.now.getTime(), + end + DAY - 1, + ); + const dayEnds = days(start, end + DAY - 1); + const scope: (number | null)[] = []; + const remaining: (number | null)[] = []; + const remainingPoints: (number | null)[] = []; + for (const time of dayEnds) { + // The day under way counts up to now; the ones to come are unknown. + const at = Math.min(time, last); + if (time - DAY >= last) { + scope.push(null); + remaining.push(null); + remainingPoints.push(null); + continue; + } + let inScope = 0; + let open = 0; + let openPoints = 0; + for (const [issueId, list] of membership) { + const current = list.filter((m) => m.at <= at).at(-1); + if (current?.sprintId !== sprint.id) continue; + const step = stepAt(steps.get(issueId) ?? [], at); + if (!step || step.category === StatusCategory.CANCELLED) continue; + inScope++; + if (step.category !== StatusCategory.DONE) { + open++; + openPoints += points.get(issueId) ?? 0; + } + } + scope.push(inScope); + remaining.push(open); + remainingPoints.push(openPoints); + } + const first = remaining[0] ?? 0; + const span = Math.max(dayEnds.length - 1, 1); + return { + sprintId: sprint.id, + dates: dayEnds.map(dayOf), + scope, + remaining, + remainingPoints, + ideal: dayEnds.map( + (_, i) => Math.round((first * (span - i) * 100) / span) / 100, + ), + }; +} diff --git a/libs/api/tracker/application/src/lib/changes.service.ts b/libs/api/tracker/application/src/lib/changes.service.ts index 6e062b0..1e6b995 100644 --- a/libs/api/tracker/application/src/lib/changes.service.ts +++ b/libs/api/tracker/application/src/lib/changes.service.ts @@ -8,6 +8,7 @@ import { IssueChange, ProjectChange, ReleaseChange, + SprintChange, } from './ports/change-feed'; import { CanViewProjectQuery } from './queries/can-view-project.query'; import { GetIssueQuery } from './queries/get-issue.query'; @@ -52,6 +53,14 @@ export class ChangesService { return this.changeFeed.releases(projectId); } + async watchSprints( + viewerId: string, + projectId: string, + ): Promise> { + await this.assertCanView(viewerId, projectId); + return this.changeFeed.sprints(projectId); + } + async watchProject( viewerId: string, projectId: string, 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 c9c6f9e..631ff82 100644 --- a/libs/api/tracker/application/src/lib/commands/command-handlers.ts +++ b/libs/api/tracker/application/src/lib/commands/command-handlers.ts @@ -30,6 +30,7 @@ import { DetachFileCommandHandler } from './detach-file.command-handler'; import { WatchIssueCommandHandler } from './watch-issue.command-handler'; import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler'; import { RankIssueCommandHandler } from './rank-issue.command-handler'; +import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers'; import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler'; import { AddChecklistItemCommandHandler, @@ -71,6 +72,7 @@ export const COMMAND_HANDLERS = [ WatchIssueCommandHandler, SetIssueParentCommandHandler, RankIssueCommandHandler, + ...SPRINT_COMMAND_HANDLERS, AddChecklistItemCommandHandler, UpdateChecklistItemCommandHandler, RemoveChecklistItemCommandHandler, 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 2e68bb3..e2858b4 100644 --- a/libs/api/tracker/application/src/lib/commands/commands.spec.ts +++ b/libs/api/tracker/application/src/lib/commands/commands.spec.ts @@ -31,6 +31,13 @@ import { InMemoryTrackerStreams, } from '../../testing/in-memory'; import { TrackerStreamsRepository } from '../ports/tracker-streams.repository'; +import { FindSprintsRepository } from '../ports/find-sprints.repository'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { + CreateSprintCommand, + SetIssueSprintCommand, + StartSprintCommand, +} from './sprint.commands'; import { IssueTree } from './issue-tree'; import { SetIssueParentCommand } from './set-issue-parent.command'; import { RankIssueCommand } from './rank-issue.command'; @@ -63,6 +70,10 @@ import { WriteAccess } from './write-access'; async function setup() { const store = new InMemoryEventStore(); + const sprints = { + active: null as { id: string; name: string } | null, + findActive: async () => sprints.active, + }; const files = new InMemoryAttachmentStorage(); const users = new FakeUserDirectory() .add('owner') @@ -92,6 +103,20 @@ async function setup() { provide: TrackerStreamsRepository, useValue: new InMemoryTrackerStreams(store), }, + // Read-model lookups: the sprint under way is set by each test. + { provide: FindSprintsRepository, useValue: sprints }, + { + provide: FindIssuesRepository, + useValue: { + sprintProgress: async () => ({ + total: 0, + finished: 0, + percent: 0, + storyPoints: 0, + finishedStoryPoints: 0, + }), + }, + }, ], }).compile(); await moduleRef.init(); @@ -103,6 +128,7 @@ async function setup() { return { store, files, + sprints, run, rehydrator: moduleRef.get(AggregateRehydrator), }; @@ -405,6 +431,51 @@ describe('tracker commands (event sourced)', () => { ); }); + it('plans and starts sprints, one under way at a time, and fills them', async () => { + const { run, rehydrator, sprints } = await setup(); + const projectId = await projectWithTeam(run); + const plan = (name: string) => + run( + new CreateSprintCommand( + 'owner', + projectId, + name, + '2026-10-12', + '2026-10-23', + ), + ); + const first = await plan('Sprint 1'); + const second = await plan('Sprint 2'); + await expect( + run( + new CreateSprintCommand( + 'dev', + projectId, + 'X', + '2026-10-12', + '2026-10-23', + ), + ), + ).rejects.toThrow(ForbiddenError); + + await run(new StartSprintCommand('owner', first.id)); + sprints.active = { id: first.id, name: 'Sprint 1' }; + await expect( + run(new StartSprintCommand('owner', second.id)), + ).rejects.toThrow(/Sprint 1 is under way/); + + const issue = await run( + new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Login', {}), + ); + await run(new SetIssueSprintCommand('dev', issue.id, first.id)); + expect((await rehydrator.rehydrate(issue.id, Issue)).sprintId).toBe( + first.id, + ); + await expect( + run(new SetIssueSprintCommand('pm', issue.id, null)), + ).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/index.ts b/libs/api/tracker/application/src/lib/commands/index.ts index eeddea1..87aed4c 100644 --- a/libs/api/tracker/application/src/lib/commands/index.ts +++ b/libs/api/tracker/application/src/lib/commands/index.ts @@ -33,3 +33,4 @@ export * from './set-issue-parent.command'; export * from './detach-from-parent.command'; export * from './change-checklist.command'; export * from './rank-issue.command'; +export * from './sprint.commands'; diff --git a/libs/api/tracker/application/src/lib/commands/sprint.command-handlers.ts b/libs/api/tracker/application/src/lib/commands/sprint.command-handlers.ts new file mode 100644 index 0000000..689e838 --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/sprint.command-handlers.ts @@ -0,0 +1,219 @@ +import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs'; +import { + Issue, + Project, + Sprint, + SprintFactory, + SprintStatus, + StatusCategory, +} from '@task-stream/api/tracker/domain'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { FindSprintsRepository } from '../ports/find-sprints.repository'; +import { CommandResult, ack } from './command-result'; +import { + CarryOverIssueCommand, + CloseSprintCommand, + CreateSprintCommand, + DeleteSprintCommand, + SetIssueSprintCommand, + StartSprintCommand, + UpdateSprintCommand, +} from './sprint.commands'; +import { WriteAccess } from './write-access'; + +/* Sprints are planned and run by whoever manages the project; any member puts tickets in them. */ + +@CommandHandler(CreateSprintCommand) +export class CreateSprintCommandHandler + implements ICommandHandler +{ + constructor( + private readonly access: WriteAccess, + private readonly sprintFactory: SprintFactory, + private readonly eventPublisher: EventPublisher, + ) {} + + async execute(command: CreateSprintCommand): Promise { + const project = await this.access.project(command.projectId); + project.assertCanManage(command.actorId); + const sprint = this.sprintFactory.create(command.actorId, { + projectId: project.id, + name: command.name, + goal: command.goal, + startDate: command.startDate, + endDate: command.endDate, + }); + this.eventPublisher.mergeObjectContext(sprint); + await sprint.commitAndWait(); + return ack(sprint); + } +} + +/** The sprint, checking the actor manages its project. */ +async function managedSprint( + access: WriteAccess, + sprintId: string, + actorId: string, +): Promise { + const sprint = await access.sprint(sprintId); + (await access.project(sprint.projectId)).assertCanManage(actorId); + return sprint; +} + +@CommandHandler(UpdateSprintCommand) +export class UpdateSprintCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: UpdateSprintCommand): Promise { + const sprint = await managedSprint( + this.access, + command.sprintId, + command.actorId, + ); + sprint.update(command.actorId, command.changes); + await sprint.commitAndWait(); + return ack(sprint); + } +} + +@CommandHandler(StartSprintCommand) +export class StartSprintCommandHandler + implements ICommandHandler +{ + constructor( + private readonly access: WriteAccess, + private readonly findSprints: FindSprintsRepository, + ) {} + + async execute(command: StartSprintCommand): Promise { + const sprint = await managedSprint( + this.access, + command.sprintId, + command.actorId, + ); + const active = await this.findSprints.findActive(sprint.projectId); + sprint.start( + command.actorId, + active && active.id !== sprint.id ? active : null, + ); + await sprint.commitAndWait(); + return ack(sprint); + } +} + +@CommandHandler(CloseSprintCommand) +export class CloseSprintCommandHandler + implements ICommandHandler +{ + constructor( + private readonly access: WriteAccess, + private readonly findIssues: FindIssuesRepository, + ) {} + + async execute(command: CloseSprintCommand): Promise { + const sprint = await managedSprint( + this.access, + command.sprintId, + command.actorId, + ); + const target = command.carryOverTo + ? await this.access.sprint(command.carryOverTo) + : null; + const progress = await this.findIssues.sprintProgress(sprint.id); + sprint.close(command.actorId, target, progress.total - progress.finished); + await sprint.commitAndWait(); + return ack(sprint); + } +} + +@CommandHandler(DeleteSprintCommand) +export class DeleteSprintCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: DeleteSprintCommand): Promise { + if (command.cascade) { + // Saga: idempotent, the sprint may be gone already. + const sprint = await this.access.live(command.sprintId, Sprint); + if (!sprint) return; + sprint.delete(command.actorId, true); + await sprint.commitAndWait(); + return; + } + const sprint = await managedSprint( + this.access, + command.sprintId, + command.actorId, + ); + sprint.delete(command.actorId); + await sprint.commitAndWait(); + return ack(sprint); + } +} + +@CommandHandler(SetIssueSprintCommand) +export class SetIssueSprintCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: SetIssueSprintCommand): Promise { + const { issue, project } = await this.access.issueForContributor( + command.issueId, + command.actorId, + ); + // A closed sprint keeps its tickets: neither side may be closed. + if (issue.sprintId) await this.access.openSprintOf(project, issue.sprintId); + if (command.sprintId) + await this.access.openSprintOf(project, command.sprintId); + issue.setSprint(command.actorId, command.sprintId); + await issue.commitAndWait(); + return ack(issue); + } +} + +const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED]; + +@CommandHandler(CarryOverIssueCommand) +export class CarryOverIssueCommandHandler + implements ICommandHandler +{ + constructor(private readonly access: WriteAccess) {} + + async execute(command: CarryOverIssueCommand): Promise { + const issue = await this.access.live(command.issueId, Issue); + // Gone, or it left that sprint meanwhile: nothing to do. + if (!issue || issue.sprintId !== command.fromSprintId) return; + const project = await this.access.live(issue.projectId, Project); + if (!project) return; + if ( + command.onlyUnfinished && + FINISHED.includes(project.workflow.categoryOf(issue.status)) + ) + return; + if (command.toSprintId) { + const target = await this.access.live(command.toSprintId, Sprint); + // The target closed or vanished meanwhile: back to the backlog instead. + const open = + target && !target.deleted && target.status !== SprintStatus.CLOSED; + issue.setSprint(command.actorId, open ? command.toSprintId : null); + } else { + issue.setSprint(command.actorId, null); + } + await issue.commitAndWait(); + } +} + +export const SPRINT_COMMAND_HANDLERS = [ + CreateSprintCommandHandler, + UpdateSprintCommandHandler, + StartSprintCommandHandler, + CloseSprintCommandHandler, + DeleteSprintCommandHandler, + SetIssueSprintCommandHandler, + // Saga only. + CarryOverIssueCommandHandler, +]; diff --git a/libs/api/tracker/application/src/lib/commands/sprint.commands.ts b/libs/api/tracker/application/src/lib/commands/sprint.commands.ts new file mode 100644 index 0000000..e558cfd --- /dev/null +++ b/libs/api/tracker/application/src/lib/commands/sprint.commands.ts @@ -0,0 +1,85 @@ +import { Command } from '@nestjs/cqrs'; +import { SprintDetails } from '@task-stream/api/tracker/domain'; +import { CommandResult } from './command-result'; + +export class CreateSprintCommand extends Command { + constructor( + public readonly actorId: string, + public readonly projectId: string, + public readonly name: string, + public readonly startDate: string, + public readonly endDate: string, + public readonly goal: string | null = null, + ) { + super(); + } +} + +export class UpdateSprintCommand extends Command { + constructor( + public readonly actorId: string, + public readonly sprintId: string, + public readonly changes: SprintDetails, + ) { + super(); + } +} + +export class StartSprintCommand extends Command { + constructor( + public readonly actorId: string, + public readonly sprintId: string, + ) { + super(); + } +} + +/** Ends a sprint; unfinished tickets go to `carryOverTo` (null: backlog). */ +export class CloseSprintCommand extends Command { + constructor( + public readonly actorId: string, + public readonly sprintId: string, + public readonly carryOverTo: string | null, + ) { + super(); + } +} + +/** A planned sprint (people), or any sprint of a deleted project (`cascade`, saga). */ +export class DeleteSprintCommand extends Command { + constructor( + public readonly actorId: string, + public readonly sprintId: string, + public readonly cascade = false, + ) { + super(); + } +} + +/** Puts a ticket in a sprint (null: back to the backlog). */ +export class SetIssueSprintCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + public readonly sprintId: string | null, + ) { + super(); + } +} + +/** + * Saga only: a sprint closed (or was deleted), so a ticket of it moves to + * `toSprintId` (null: backlog). With `onlyUnfinished`, finished ones stay as + * the record of what the sprint achieved. + */ +export class CarryOverIssueCommand extends Command { + constructor( + public readonly actorId: string, + public readonly issueId: string, + public readonly fromSprintId: string, + public readonly toSprintId: string | null, + public readonly onlyUnfinished: boolean, + ) { + super(); + } +} diff --git a/libs/api/tracker/application/src/lib/commands/write-access.ts b/libs/api/tracker/application/src/lib/commands/write-access.ts index 4d8668e..21a9d56 100644 --- a/libs/api/tracker/application/src/lib/commands/write-access.ts +++ b/libs/api/tracker/application/src/lib/commands/write-access.ts @@ -10,6 +10,7 @@ import { Issue, Project, Release, + Sprint, } from '@task-stream/api/tracker/domain'; import { UserDirectory } from '../ports/user-directory'; @@ -38,6 +39,22 @@ export class WriteAccess { return this.load(id, Release); } + sprint(id: string): Promise { + return this.load(id, Sprint); + } + + /** Sprint of the same project that still takes tickets (not closed). */ + async openSprintOf(project: Project, sprintId: string): Promise { + const sprint = await this.sprint(sprintId); + if (sprint.projectId !== project.id) { + throw new BusinessRuleError( + `Sprint ${sprint.name} belongs to another project`, + ); + } + sprint.assertAcceptsIssues(); + return sprint; + } + comment(id: string): Promise { return this.load(id, Comment); } 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 534ae9b..2d9af91 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 @@ -28,6 +28,12 @@ import { ReleaseCreatedEvent, ReleaseShippedEvent, ReleaseUpdatedEvent, + IssueSprintChangedEvent, + SprintClosedEvent, + SprintCreatedEvent, + SprintDeletedEvent, + SprintStartedEvent, + SprintUpdatedEvent, TrackerEvent, } from '@task-stream/api/tracker/domain'; @@ -37,6 +43,8 @@ export interface SummaryContext { issueKey: string; projectKey: string; releaseName: string; + /** '' when unknown (e.g. the sprint was deleted). */ + sprintName: string; /** The project's workflow: names of statuses and types. */ workflow: ProjectWorkflow; } @@ -146,6 +154,12 @@ export function describeEvent( if (event instanceof IssueAttachmentRemovedEvent) return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`; + if (event instanceof IssueSprintChangedEvent) { + const sprint = ctx.sprintName ? `el sprint ${ctx.sprintName}` : 'un sprint'; + return event.sprintId + ? `${actor} metió ${issueKey} en ${sprint}` + : `${actor} sacó ${issueKey} de ${sprint}`; + } if (event instanceof IssueParentChangedEvent) { return event.parentKey ? `${actor} colocó ${issueKey} bajo ${event.parentKey}` @@ -178,5 +192,20 @@ export function describeEvent( return `${actor} publicó la release ${ctx.releaseName} 🚀`; if (event instanceof ReleaseArchivedEvent) return `${actor} archivó la release ${ctx.releaseName}`; + const sprint = ctx.sprintName || 'un sprint'; + if (event instanceof SprintCreatedEvent) + return `${actor} planificó el sprint ${event.sprint.name}`; + if (event instanceof SprintUpdatedEvent) + return `${actor} actualizó el sprint ${sprint}`; + if (event instanceof SprintStartedEvent) + return `${actor} empezó el sprint ${sprint} 🏁`; + if (event instanceof SprintClosedEvent) { + const left = event.unfinishedIssues + ? `; ${event.unfinishedIssues} ticket(s) sin terminar pasan ${event.carryOverTo ? 'al siguiente sprint' : 'al backlog'}` + : ''; + return `${actor} cerró el sprint ${sprint}${left}`; + } + if (event instanceof SprintDeletedEvent) + return `${actor} borró el sprint ${sprint}`; return `${actor}: ${event.constructor.name}`; } 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 4114fd0..a9a9b6c 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 @@ -23,6 +23,12 @@ import { ProjectMemberRoleChangedEvent, ProjectUpdatedEvent, ReleaseArchivedEvent, + IssueSprintChangedEvent, + SprintClosedEvent, + SprintCreatedEvent, + SprintDeletedEvent, + SprintStartedEvent, + SprintUpdatedEvent, ReleaseCreatedEvent, ReleaseShippedEvent, ReleaseUpdatedEvent, @@ -37,6 +43,7 @@ import { ChangeFeed } from '../ports/change-feed'; import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindReleasesRepository } from '../ports/find-releases.repository'; +import { FindSprintsRepository } from '../ports/find-sprints.repository'; import { UpsertMaterializedActivityRepository } from '../ports/upsert-materialized-activity.repository'; import { UserDirectory } from '../ports/user-directory'; import { describeEvent } from './activity-summary'; @@ -70,6 +77,12 @@ import { versionOf } from './read-model-notifier'; ReleaseUpdatedEvent, ReleaseShippedEvent, ReleaseArchivedEvent, + IssueSprintChangedEvent, + SprintCreatedEvent, + SprintUpdatedEvent, + SprintStartedEvent, + SprintClosedEvent, + SprintDeletedEvent, ) export class ActivityEventHandler implements IEventHandler { constructor( @@ -77,6 +90,7 @@ export class ActivityEventHandler implements IEventHandler { private readonly findProjects: FindProjectsRepository, private readonly findIssues: FindIssuesRepository, private readonly findReleases: FindReleasesRepository, + private readonly findSprints: FindSprintsRepository, private readonly users: UserDirectory, private readonly changeFeed: ChangeFeed, ) {} @@ -93,6 +107,7 @@ export class ActivityEventHandler implements IEventHandler { const release = releaseId ? await this.findReleases.findById(releaseId) : null; + const sprint = await this.sprintOf(event); const project = await this.findProjects.findById(event.projectId); // The project was deleted (cascade in progress): no feed to write to. if (!project) return; @@ -118,6 +133,7 @@ export class ActivityEventHandler implements IEventHandler { issueKey: issueKey ?? '', projectKey: project?.key ?? '', releaseName: release?.name ?? '', + sprintName: sprint, workflow: new ProjectWorkflow( project?.settings ?? DEFAULT_PROJECT_SETTINGS, ), @@ -129,6 +145,17 @@ export class ActivityEventHandler implements IEventHandler { } } + /** Name of the sprint the event is about ('' when it is gone). */ + private async sprintOf(event: TrackerEvent): Promise { + if (event instanceof SprintCreatedEvent) return event.sprint.name; + const id = event.constructor.name.startsWith('Sprint') + ? event.aggregateId + : event instanceof IssueSprintChangedEvent + ? (event.sprintId ?? event.previousSprintId) + : null; + return id ? ((await this.findSprints.findById(id))?.name ?? '') : ''; + } + private issueIdOf(event: TrackerEvent): string | null { if (event.constructor.name.startsWith('Issue')) return event.aggregateId; if (event instanceof CommentAddedEvent) return event.comment.issueId; 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 d86ea89..3959aad 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 @@ -31,6 +31,8 @@ import { IssueWatchersEventHandler } from './issue-watchers.event-handler'; import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler'; import { IssueChecklistEventHandler } from './issue-checklist.event-handler'; import { IssueRankedEventHandler } from './issue-ranked.event-handler'; +import { SprintEventHandler } from './sprint.event-handler'; +import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler'; /** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */ export const EVENT_HANDLERS = [ @@ -66,6 +68,8 @@ export const EVENT_HANDLERS = [ IssueParentChangedEventHandler, IssueChecklistEventHandler, IssueRankedEventHandler, + SprintEventHandler, + IssueSprintChangedEventHandler, ]; export { ReadModelNotifier }; 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 f41d4fb..8f4b73b 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 @@ -50,6 +50,7 @@ export class IssueCreatedEventHandler assignee: users.ofNullable(issue.assigneeId), release: release ? { id: release.id, name: release.name } : null, parent: null, + sprint: null, rank: implicitRank(issue.number), labels: issue.labels, figmaUrl: issue.figmaUrl, diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-sprint-changed.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-sprint-changed.event-handler.ts new file mode 100644 index 0000000..7a9511e --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-sprint-changed.event-handler.ts @@ -0,0 +1,31 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { IssueSprintChangedEvent } from '@task-stream/api/tracker/domain'; +import { FindSprintsRepository } from '../ports/find-sprints.repository'; +import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +@EventsHandler(IssueSprintChangedEvent) +export class IssueSprintChangedEventHandler + implements IEventHandler +{ + constructor( + private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository, + private readonly findSprints: FindSprintsRepository, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: IssueSprintChangedEvent) { + const sprint = event.sprintId + ? await this.findSprints.findById(event.sprintId) + : null; + await this.upsertMaterializedIssueRepository.upsert({ + id: event.aggregateId, + sprint: event.sprintId + ? { id: event.sprintId, name: sprint?.name ?? '' } + : null, + updatedAt: event.occurredAt, + version: versionOf(event), + }); + await this.notifier.issue(event.aggregateId, event); + } +} diff --git a/libs/api/tracker/application/src/lib/event-handlers/issue-timeline.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/issue-timeline.event-handler.ts index ebec13c..a6fad64 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/issue-timeline.event-handler.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/issue-timeline.event-handler.ts @@ -3,6 +3,7 @@ import { DEFAULT_PROJECT_SETTINGS, IssueCreatedEvent, IssueReleaseChangedEvent, + IssueSprintChangedEvent, IssueStatusChangedEvent, ProjectWorkflow, TrackerEvent, @@ -14,12 +15,13 @@ import { versionOf } from './read-model-notifier'; /** * The life of every ticket, step by step: where it went (status + category) - * and which release it joined. Statistics are computed from it. + * and which release and sprint it joined. Statistics are computed from it. */ @EventsHandler( IssueCreatedEvent, IssueStatusChangedEvent, IssueReleaseChangedEvent, + IssueSprintChangedEvent, ) export class IssueTimelineEventHandler implements IEventHandler { constructor( @@ -66,6 +68,14 @@ export class IssueTimelineEventHandler implements IEventHandler { kind: 'release', releaseId: event.releaseId, }); + return; + } + if (event instanceof IssueSprintChangedEvent) { + await this.timeline.record({ + ...base, + kind: 'sprint', + sprintId: event.sprintId, + }); } } diff --git a/libs/api/tracker/application/src/lib/event-handlers/read-model-notifier.ts b/libs/api/tracker/application/src/lib/event-handlers/read-model-notifier.ts index 55f2f7e..de2532f 100644 --- a/libs/api/tracker/application/src/lib/event-handlers/read-model-notifier.ts +++ b/libs/api/tracker/application/src/lib/event-handlers/read-model-notifier.ts @@ -5,6 +5,7 @@ import { FindCommentsRepository } from '../ports/find-comments.repository'; import { FindIssuesRepository } from '../ports/find-issues.repository'; import { FindProjectsRepository } from '../ports/find-projects.repository'; import { FindReleasesRepository } from '../ports/find-releases.repository'; +import { FindSprintsRepository } from '../ports/find-sprints.repository'; /** Position of the event in its stream (stamped by the event store). */ export function versionOf(event: TrackerEvent): number { @@ -23,6 +24,7 @@ export class ReadModelNotifier { private readonly findIssues: FindIssuesRepository, private readonly findComments: FindCommentsRepository, private readonly findReleases: FindReleasesRepository, + private readonly findSprints: FindSprintsRepository, ) {} async project( @@ -63,6 +65,16 @@ export class ReadModelNotifier { await this.changeFeed.publishRelease({ ...this.meta(event), release }); } + async sprint(sprintId: string, event: TrackerEvent): Promise { + const sprint = await this.findSprints.findById(sprintId); + await this.changeFeed.publishSprint({ + ...this.meta(event), + sprintId, + projectId: event.projectId, + sprint, + }); + } + private meta(event: TrackerEvent) { return { action: event.constructor.name, diff --git a/libs/api/tracker/application/src/lib/event-handlers/sprint.event-handler.ts b/libs/api/tracker/application/src/lib/event-handlers/sprint.event-handler.ts new file mode 100644 index 0000000..0b9f276 --- /dev/null +++ b/libs/api/tracker/application/src/lib/event-handlers/sprint.event-handler.ts @@ -0,0 +1,65 @@ +import { EventsHandler, IEventHandler } from '@nestjs/cqrs'; +import { + SprintClosedEvent, + SprintCreatedEvent, + SprintDeletedEvent, + SprintStartedEvent, + SprintStatus, + SprintUpdatedEvent, +} from '@task-stream/api/tracker/domain'; +import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository'; +import { UpsertMaterializedSprintRepository } from '../ports/upsert-materialized-sprint.repository'; +import { ReadModelNotifier, versionOf } from './read-model-notifier'; + +type SprintEvent = + | SprintCreatedEvent + | SprintUpdatedEvent + | SprintStartedEvent + | SprintClosedEvent + | SprintDeletedEvent; + +/** The sprints view (`sprints_view`), one document per sprint. */ +@EventsHandler( + SprintCreatedEvent, + SprintUpdatedEvent, + SprintStartedEvent, + SprintClosedEvent, + SprintDeletedEvent, +) +export class SprintEventHandler implements IEventHandler { + constructor( + private readonly sprints: UpsertMaterializedSprintRepository, + private readonly issues: UpsertMaterializedIssueRepository, + private readonly notifier: ReadModelNotifier, + ) {} + + async handle(event: SprintEvent) { + const version = versionOf(event); + const id = event.aggregateId; + if (event instanceof SprintCreatedEvent) { + await this.sprints.upsert({ ...event.sprint, version }); + } else if (event instanceof SprintUpdatedEvent) { + await this.sprints.upsert({ id, ...event.changes, version }); + // Tickets show its name. + if (event.changes.name) + await this.issues.renameSprint(id, event.changes.name); + } else if (event instanceof SprintStartedEvent) { + await this.sprints.upsert({ + id, + status: SprintStatus.ACTIVE, + startedAt: event.occurredAt, + version, + }); + } else if (event instanceof SprintClosedEvent) { + await this.sprints.upsert({ + id, + status: SprintStatus.CLOSED, + closedAt: event.occurredAt, + version, + }); + } else { + await this.sprints.remove(id); + } + await this.notifier.sprint(id, event); + } +} diff --git a/libs/api/tracker/application/src/lib/ports/change-feed.ts b/libs/api/tracker/application/src/lib/ports/change-feed.ts index 12523d9..de66e49 100644 --- a/libs/api/tracker/application/src/lib/ports/change-feed.ts +++ b/libs/api/tracker/application/src/lib/ports/change-feed.ts @@ -5,6 +5,7 @@ import { IssueReadModel, ProjectReadModel, ReleaseReadModel, + SprintReadModel, } from '@task-stream/api/tracker/domain'; /** Every realtime message says which event caused it and the resulting version. */ @@ -30,6 +31,13 @@ export interface ReleaseChange extends Change { release: ReleaseReadModel; } +export interface SprintChange extends Change { + /** Null when the sprint was deleted. */ + sprint: SprintReadModel | null; + sprintId: string; + projectId: string; +} + export interface ProjectChange extends Change { project: ProjectReadModel; } @@ -43,6 +51,7 @@ export abstract class ChangeFeed { abstract publishComment(change: CommentChange): Promise; abstract publishActivity(activity: ActivityReadModel): Promise; abstract publishRelease(change: ReleaseChange): Promise; + abstract publishSprint(change: SprintChange): Promise; /** @param notifyUserIds users whose project list changed (joined / left). */ abstract publishProject( change: ProjectChange, @@ -59,6 +68,7 @@ export abstract class ChangeFeed { projectId: string, ): AsyncIterableIterator; abstract releases(projectId: string): AsyncIterableIterator; + abstract sprints(projectId: string): AsyncIterableIterator; abstract project(projectId: string): AsyncIterableIterator; abstract userProjects(userId: string): AsyncIterableIterator; abstract notifications( 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 e2f4ad3..b38128a 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 @@ -4,6 +4,7 @@ import { IssueReadModel, ProjectStatsReadModel, ReleaseProgressReadModel, + SprintProgressReadModel, StatusCategory, } from '@task-stream/api/tracker/domain'; @@ -26,6 +27,9 @@ export interface IssueFilter { labels?: string[]; /** Only the tickets hanging from this one. */ parentId?: string; + sprintId?: string; + /** Only tickets in no sprint (wins over `sprintId`). */ + noSprint?: boolean; /** RECENT (default): last updated first. RANK: backlog order. */ sort?: IssueSort; search?: string; @@ -63,6 +67,7 @@ export abstract class FindIssuesRepository { abstract findByProject(projectId: string): Promise; /** Top-level tickets with others hanging from them, by key. */ abstract epics(projectId: string): Promise; + abstract sprintProgress(sprintId: string): Promise; abstract releaseProgress( releaseId: string, ): Promise; diff --git a/libs/api/tracker/application/src/lib/ports/find-sprints.repository.ts b/libs/api/tracker/application/src/lib/ports/find-sprints.repository.ts new file mode 100644 index 0000000..5482ad7 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/find-sprints.repository.ts @@ -0,0 +1,9 @@ +import { SprintReadModel } from '@task-stream/api/tracker/domain'; + +export abstract class FindSprintsRepository { + abstract findById(id: string): Promise; + /** Active first, then planned by start date, then closed (newest first). */ + abstract findByProject(projectId: string): Promise; + /** The sprint under way in the project, if any. */ + abstract findActive(projectId: string): Promise; +} diff --git a/libs/api/tracker/application/src/lib/ports/index.ts b/libs/api/tracker/application/src/lib/ports/index.ts index a5e2517..57ccfe6 100644 --- a/libs/api/tracker/application/src/lib/ports/index.ts +++ b/libs/api/tracker/application/src/lib/ports/index.ts @@ -21,3 +21,5 @@ export * from './attachment-storage'; export * from './notifications.repository'; export * from './notification-mailer'; export * from './saved-filters.repository'; +export * from './find-sprints.repository'; +export * from './upsert-materialized-sprint.repository'; 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 37df65b..14d7065 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 @@ -7,6 +7,9 @@ export abstract class TrackerStreamsRepository { abstract issueIdsOfProject(projectId: string): Promise; abstract releaseIdsOfProject(projectId: string): Promise; + abstract sprintIdsOfProject(projectId: string): Promise; + /** Tickets that were ever put in `sprintId` (they may have left since). */ + abstract issueIdsEverInSprint(sprintId: string): Promise; abstract commentIdsOfIssue(issueId: string): Promise; /** Tickets that were ever linked to `issueId` (they may have unlinked since). */ abstract issueIdsLinkingTo(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 16c1f51..bdee5d7 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 @@ -60,6 +60,8 @@ export abstract class UpsertMaterializedIssueRepository { ): Promise; /** The ticket was deleted: its view, its timeline and the links shown on other tickets go. */ abstract remove(issueId: string): Promise; + /** Keeps the denormalised sprint name of every issue in sync. */ + abstract renameSprint(sprintId: string, name: string): Promise; /** Keeps the denormalised release name of every issue in sync. */ abstract renameRelease(releaseId: string, name: string): Promise; } diff --git a/libs/api/tracker/application/src/lib/ports/upsert-materialized-sprint.repository.ts b/libs/api/tracker/application/src/lib/ports/upsert-materialized-sprint.repository.ts new file mode 100644 index 0000000..21f5860 --- /dev/null +++ b/libs/api/tracker/application/src/lib/ports/upsert-materialized-sprint.repository.ts @@ -0,0 +1,9 @@ +import { SprintReadModel } from '@task-stream/api/tracker/domain'; + +export abstract class UpsertMaterializedSprintRepository { + /** Applied only if `version` is newer than the stored one (idempotent). */ + abstract upsert( + sprint: Pick & Partial, + ): Promise; + abstract remove(sprintId: string): Promise; +} diff --git a/libs/api/tracker/application/src/lib/queries/index.ts b/libs/api/tracker/application/src/lib/queries/index.ts index 551d24c..2e151ee 100644 --- a/libs/api/tracker/application/src/lib/queries/index.ts +++ b/libs/api/tracker/application/src/lib/queries/index.ts @@ -18,3 +18,4 @@ export * from './search-my-issues.query'; export * from './get-public-projects.query'; export * from './get-attachment.query'; export * from './get-epics.query'; +export * from './sprint.queries'; 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 af2a621..f2e60ea 100644 --- a/libs/api/tracker/application/src/lib/queries/query-handlers.ts +++ b/libs/api/tracker/application/src/lib/queries/query-handlers.ts @@ -17,6 +17,7 @@ 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 { SPRINT_QUERY_HANDLERS } from './sprint.queries'; import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler'; export const QUERY_HANDLERS = [ @@ -38,6 +39,7 @@ export const QUERY_HANDLERS = [ GetUpcomingReleasesQueryHandler, SearchIssuesQueryHandler, GetEpicsQueryHandler, + ...SPRINT_QUERY_HANDLERS, SearchMyIssuesQueryHandler, GetAttachmentQueryHandler, ]; diff --git a/libs/api/tracker/application/src/lib/queries/sprint.queries.ts b/libs/api/tracker/application/src/lib/queries/sprint.queries.ts new file mode 100644 index 0000000..e0e8f2f --- /dev/null +++ b/libs/api/tracker/application/src/lib/queries/sprint.queries.ts @@ -0,0 +1,159 @@ +import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs'; +import { + SprintBurndownReadModel, + SprintProgressReadModel, + SprintReadModel, +} from '@task-stream/api/tracker/domain'; +import { computeSprintBurndown } from '../analytics/analytics'; +import { FindIssuesRepository } from '../ports/find-issues.repository'; +import { FindSprintsRepository } from '../ports/find-sprints.repository'; +import { FindTimelineRepository } from '../ports/find-timeline.repository'; +import { ReadAccess } from './read-access'; + +export class GetSprintsQuery extends Query { + constructor( + /** Null: anonymous (public projects only). */ + public readonly viewerId: string | null, + public readonly projectId: string, + ) { + super(); + } +} + +export class GetSprintQuery extends Query { + constructor( + public readonly viewerId: string | null, + public readonly sprintId: string, + ) { + super(); + } +} + +export class GetSprintProgressQuery extends Query { + constructor( + public readonly viewerId: string | null, + public readonly sprintId: string, + ) { + super(); + } +} + +export class GetSprintBurndownQuery extends Query { + constructor( + public readonly viewerId: string | null, + public readonly sprintId: string, + ) { + super(); + } +} + +/** The sprint, if the viewer may see its project (same error otherwise). */ +async function visibleSprint( + findSprints: FindSprintsRepository, + access: ReadAccess, + viewerId: string | null, + sprintId: string, +): Promise { + const sprint = access.assertFound( + await findSprints.findById(sprintId), + 'Sprint', + sprintId, + ); + await access.assertCanView(sprint.projectId, viewerId, { + entity: 'Sprint', + ref: sprintId, + }); + return sprint; +} + +@QueryHandler(GetSprintsQuery) +export class GetSprintsQueryHandler + implements IQueryHandler +{ + constructor( + private readonly findSprints: FindSprintsRepository, + private readonly access: ReadAccess, + ) {} + + async execute(query: GetSprintsQuery): Promise { + await this.access.assertCanView(query.projectId, query.viewerId); + return this.findSprints.findByProject(query.projectId); + } +} + +@QueryHandler(GetSprintQuery) +export class GetSprintQueryHandler + implements IQueryHandler +{ + constructor( + private readonly findSprints: FindSprintsRepository, + private readonly access: ReadAccess, + ) {} + + execute(query: GetSprintQuery): Promise { + return visibleSprint( + this.findSprints, + this.access, + query.viewerId, + query.sprintId, + ); + } +} + +@QueryHandler(GetSprintProgressQuery) +export class GetSprintProgressQueryHandler + implements IQueryHandler +{ + constructor( + private readonly findSprints: FindSprintsRepository, + private readonly findIssues: FindIssuesRepository, + private readonly access: ReadAccess, + ) {} + + async execute( + query: GetSprintProgressQuery, + ): Promise { + const sprint = await visibleSprint( + this.findSprints, + this.access, + query.viewerId, + query.sprintId, + ); + return this.findIssues.sprintProgress(sprint.id); + } +} + +@QueryHandler(GetSprintBurndownQuery) +export class GetSprintBurndownQueryHandler + implements IQueryHandler +{ + constructor( + private readonly findSprints: FindSprintsRepository, + private readonly findIssues: FindIssuesRepository, + private readonly findTimeline: FindTimelineRepository, + private readonly access: ReadAccess, + ) {} + + async execute( + query: GetSprintBurndownQuery, + ): Promise { + const sprint = await visibleSprint( + this.findSprints, + this.access, + query.viewerId, + query.sprintId, + ); + const [issues, timeline] = await Promise.all([ + this.findIssues.findByProject(sprint.projectId), + this.findTimeline.findByProject(sprint.projectId), + ]); + return computeSprintBurndown({ sprint, issues, timeline, now: new Date() }); + } +} + +export const SPRINT_QUERY_HANDLERS = [ + GetSprintsQueryHandler, + GetSprintQueryHandler, + GetSprintProgressQueryHandler, + GetSprintBurndownQueryHandler, +]; 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 04c7d6a..c4d9b3a 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 @@ -9,6 +9,7 @@ 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 { DeleteSprintCommand } from '../commands/sprint.commands'; import { PurgeCommentCommand } from '../commands/purge-comment.command'; import { TrackerStreamsRepository } from '../ports/tracker-streams.repository'; @@ -17,6 +18,7 @@ import { TrackerStreamsRepository } from '../ports/tracker-streams.repository'; * with its own "deleted" event): * * ProjectDeleted → DeleteIssue (each ticket) + DeleteRelease (each release) + * + DeleteSprint (each sprint, as a cascade) * IssueDeleted → PurgeComment (each comment) + DropLinksToIssue (tickets * of other projects that pointed to it) + DetachFromParent * (tickets that hung from it go back to the top) @@ -34,11 +36,14 @@ export class ProjectDeletionSaga { ofType(ProjectDeletedEvent), mergeMap((event) => from(this.contentsOf(event.projectId)).pipe( - mergeMap(({ issueIds, releaseIds }) => [ + mergeMap(({ issueIds, releaseIds, sprintIds }) => [ ...issueIds.map((id) => new DeleteIssueCommand(event.actorId, id)), ...releaseIds.map( (id) => new DeleteReleaseCommand(event.actorId, id), ), + ...sprintIds.map( + (id) => new DeleteSprintCommand(event.actorId, id, true), + ), ]), ), ), @@ -76,11 +81,12 @@ export class ProjectDeletionSaga { ); private async contentsOf(projectId: string) { - const [issueIds, releaseIds] = await Promise.all([ + const [issueIds, releaseIds, sprintIds] = await Promise.all([ this.streams.issueIdsOfProject(projectId), this.streams.releaseIdsOfProject(projectId), + this.streams.sprintIdsOfProject(projectId), ]); - return { issueIds, releaseIds }; + return { issueIds, releaseIds, sprintIds }; } private async dependentsOf(issueId: string) { 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 4b21e69..157123d 100644 --- a/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts +++ b/libs/api/tracker/application/src/lib/sagas/sagas.spec.ts @@ -47,6 +47,15 @@ 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 { + CloseSprintCommand, + CreateSprintCommand, + DeleteSprintCommand, + SetIssueSprintCommand, + StartSprintCommand, +} from '../commands/sprint.commands'; +import { FindSprintsRepository } from '../ports/find-sprints.repository'; +import { SprintCarryOverSaga } from './sprint-carry-over.saga'; import { FindIssuesRepository } from '../ports/find-issues.repository'; import { IssueNumbersRepository } from '../ports/issue-numbers.repository'; import { ProjectKeysRepository } from '../ports/project-keys.repository'; @@ -73,6 +82,7 @@ async function setup() { ProjectDeletionSaga, ReleaseArchivedSaga, UserDeactivatedSaga, + SprintCarryOverSaga, WriteAccess, IssueTree, AggregateRehydrator, @@ -90,11 +100,22 @@ async function setup() { provide: TrackerStreamsRepository, useValue: new InMemoryTrackerStreams(store), }, + { + provide: FindSprintsRepository, + useValue: { findActive: async () => null }, + }, { provide: FindIssuesRepository, useValue: { findOpenAssignedTo: async (userId: string) => (openAssigned.get(userId) ?? []).map((id) => ({ id })), + sprintProgress: async () => ({ + total: 2, + finished: 1, + percent: 50, + storyPoints: 0, + finishedStoryPoints: 0, + }), }, }, ], @@ -109,6 +130,7 @@ async function setup() { ...Object.values(moduleRef.get(ProjectDeletionSaga)), ...Object.values(moduleRef.get(ReleaseArchivedSaga)), ...Object.values(moduleRef.get(UserDeactivatedSaga)), + ...Object.values(moduleRef.get(SprintCarryOverSaga)), ].filter((value): value is ISaga => typeof value === 'function'); /** What the events bridge does: every stored event through every saga, until quiet. */ @@ -206,6 +228,41 @@ describe('sagas', () => { expect((await rehydrator.rehydrate(child, Issue)).parentId).toBeNull(); }); + it('closing a sprint carries its unfinished tickets over; deleting one empties it', async () => { + const { run, drain, rehydrator } = await setup(); + const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id; + const sprint = async (name: string) => + ( + await run( + new CreateSprintCommand( + 'owner', + web, + name, + '2026-10-12', + '2026-10-23', + ), + ) + ).id; + const [one, two] = [await sprint('One'), await sprint('Two')]; + const open = (await run(ticket(web, 'open'))).id; + const done = (await run(ticket(web, 'done'))).id; + for (const id of [open, done]) + await run(new SetIssueSprintCommand('owner', id, one)); + for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE']) + await run(new ChangeIssueStatusCommand('owner', done, status)); + + await run(new StartSprintCommand('owner', one)); + await run(new CloseSprintCommand('owner', one, two)); + await drain(); + + expect((await rehydrator.rehydrate(open, Issue)).sprintId).toBe(two); + expect((await rehydrator.rehydrate(done, Issue)).sprintId).toBe(one); + + await run(new DeleteSprintCommand('owner', two)); + await drain(); + expect((await rehydrator.rehydrate(open, Issue)).sprintId).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/sagas/sagas.ts b/libs/api/tracker/application/src/lib/sagas/sagas.ts index ac4a1ce..f213700 100644 --- a/libs/api/tracker/application/src/lib/sagas/sagas.ts +++ b/libs/api/tracker/application/src/lib/sagas/sagas.ts @@ -2,6 +2,7 @@ import { ProjectDeletionSaga } from './project-deletion.saga'; import { ReleaseArchivedSaga } from './release-archived.saga'; import { UserDeactivatedSaga } from './user-deactivated.saga'; import { CommentersFollowSaga } from './commenters-follow.saga'; +import { SprintCarryOverSaga } from './sprint-carry-over.saga'; /** Reactions to events that change other aggregates (run by the events bridge). */ export const SAGAS = [ @@ -9,4 +10,5 @@ export const SAGAS = [ ReleaseArchivedSaga, UserDeactivatedSaga, CommentersFollowSaga, + SprintCarryOverSaga, ]; diff --git a/libs/api/tracker/application/src/lib/sagas/sprint-carry-over.saga.ts b/libs/api/tracker/application/src/lib/sagas/sprint-carry-over.saga.ts new file mode 100644 index 0000000..3a7bb3d --- /dev/null +++ b/libs/api/tracker/application/src/lib/sagas/sprint-carry-over.saga.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; +import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs'; +import { + SprintClosedEvent, + SprintDeletedEvent, +} from '@task-stream/api/tracker/domain'; +import { from, mergeMap, Observable } from 'rxjs'; +import { CarryOverIssueCommand } from '../commands/sprint.commands'; +import { TrackerStreamsRepository } from '../ports/tracker-streams.repository'; + +/** + * When a sprint ends, what was left moves on: + * + * SprintClosed → CarryOverIssue for its unfinished tickets, to the sprint + * chosen when closing it (or back to the backlog) + * SprintDeleted → CarryOverIssue for all its tickets, back to the backlog + * + * Who was in the sprint comes from the event store. The moves are + * attributed to whoever closed or deleted the sprint. + */ +@Injectable() +export class SprintCarryOverSaga { + constructor(private readonly streams: TrackerStreamsRepository) {} + + @Saga() + sprintEnded = (events$: Observable): Observable => + events$.pipe( + ofType(SprintClosedEvent, SprintDeletedEvent), + mergeMap((event) => + from(this.streams.issueIdsEverInSprint(event.aggregateId)).pipe( + mergeMap((issueIds) => + issueIds.map( + (issueId) => + new CarryOverIssueCommand( + event.actorId, + issueId, + event.aggregateId, + event instanceof SprintClosedEvent ? event.carryOverTo : null, + event instanceof SprintClosedEvent, + ), + ), + ), + ), + ), + ); +} diff --git a/libs/api/tracker/application/src/lib/sprints.service.ts b/libs/api/tracker/application/src/lib/sprints.service.ts new file mode 100644 index 0000000..6391985 --- /dev/null +++ b/libs/api/tracker/application/src/lib/sprints.service.ts @@ -0,0 +1,122 @@ +import { Injectable } from '@nestjs/common'; +import { CommandBus, QueryBus } from '@nestjs/cqrs'; +import { + SprintBurndownReadModel, + SprintDetails, + SprintProgressReadModel, + SprintReadModel, +} from '@task-stream/api/tracker/domain'; +import { CommandResult } from './commands/command-result'; +import { + CloseSprintCommand, + CreateSprintCommand, + DeleteSprintCommand, + SetIssueSprintCommand, + StartSprintCommand, + UpdateSprintCommand, +} from './commands/sprint.commands'; +import { + GetSprintBurndownQuery, + GetSprintProgressQuery, + GetSprintQuery, + GetSprintsQuery, +} from './queries/sprint.queries'; + +export interface CreateSprintInput { + projectId: string; + name: string; + goal?: string | null; + startDate: string; + endDate: string; +} + +/** Sprints: plan them, run them, close them (and what was left moves on). */ +@Injectable() +export class SprintsService { + constructor( + private readonly commandBus: CommandBus, + private readonly queryBus: QueryBus, + ) {} + + list(viewerId: string | null, projectId: string): Promise { + return this.queryBus.execute(new GetSprintsQuery(viewerId, projectId)); + } + + findOne(viewerId: string | null, sprintId: string): Promise { + return this.queryBus.execute(new GetSprintQuery(viewerId, sprintId)); + } + + progress( + viewerId: string | null, + sprintId: string, + ): Promise { + return this.queryBus.execute( + new GetSprintProgressQuery(viewerId, sprintId), + ); + } + + burndown( + viewerId: string | null, + sprintId: string, + ): Promise { + return this.queryBus.execute( + new GetSprintBurndownQuery(viewerId, sprintId), + ); + } + + create(actorId: string, input: CreateSprintInput): Promise { + return this.commandBus.execute( + new CreateSprintCommand( + actorId, + input.projectId, + input.name, + input.startDate, + input.endDate, + input.goal ?? null, + ), + ); + } + + update( + actorId: string, + sprintId: string, + changes: SprintDetails, + ): Promise { + return this.commandBus.execute( + new UpdateSprintCommand(actorId, sprintId, changes), + ); + } + + start(actorId: string, sprintId: string): Promise { + return this.commandBus.execute(new StartSprintCommand(actorId, sprintId)); + } + + /** Unfinished tickets go to `carryOverTo` (null: the backlog). */ + close( + actorId: string, + sprintId: string, + carryOverTo: string | null, + ): Promise { + return this.commandBus.execute( + new CloseSprintCommand(actorId, sprintId, carryOverTo), + ); + } + + /** Planned sprints only; its tickets go back to the backlog. */ + delete(actorId: string, sprintId: string): Promise { + return this.commandBus.execute( + new DeleteSprintCommand(actorId, sprintId), + ) as Promise; + } + + /** Puts a ticket in a sprint (null: back to the backlog). */ + setIssueSprint( + actorId: string, + issueId: string, + sprintId: string | null, + ): Promise { + return this.commandBus.execute( + new SetIssueSprintCommand(actorId, issueId, sprintId), + ); + } +} 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 811b8ee..20bc3c9 100644 --- a/libs/api/tracker/application/src/lib/tracker-application.module.ts +++ b/libs/api/tracker/application/src/lib/tracker-application.module.ts @@ -31,6 +31,7 @@ import { import { SAVED_FILTERS } from './saved-filters/saved-filters'; import { SavedFiltersService } from './saved-filters.service'; +import { SprintsService } from './sprints.service'; /** Notifications: derived from events, kept and dismissed per person. */ const NOTIFICATIONS = [ @@ -51,6 +52,7 @@ const SERVICES = [ WorkflowsService, NotificationsService, SavedFiltersService, + SprintsService, ]; /** diff --git a/libs/api/tracker/application/src/testing/in-memory.ts b/libs/api/tracker/application/src/testing/in-memory.ts index b783d45..e79b397 100644 --- a/libs/api/tracker/application/src/testing/in-memory.ts +++ b/libs/api/tracker/application/src/testing/in-memory.ts @@ -243,6 +243,20 @@ export class InMemoryTrackerStreams extends TrackerStreamsRepository { ); } + async sprintIdsOfProject(projectId: string) { + return this.streams( + 'SprintCreatedEvent', + (d) => d['projectId'] === projectId, + ); + } + + async issueIdsEverInSprint(sprintId: string) { + return this.streams( + 'IssueSprintChangedEvent', + (d) => d['sprintId'] === sprintId, + ); + } + async commentIdsOfIssue(issueId: string) { return this.streams( 'CommentAddedEvent', diff --git a/libs/api/tracker/domain/src/index.ts b/libs/api/tracker/domain/src/index.ts index 94b1c8f..440b51c 100644 --- a/libs/api/tracker/domain/src/index.ts +++ b/libs/api/tracker/domain/src/index.ts @@ -11,6 +11,8 @@ export * from './lib/project/project-settings'; export * from './lib/comment/comment'; export * from './lib/release/release'; export * from './lib/release/release-status'; +export * from './lib/sprint/sprint'; +export * from './lib/sprint/sprint-status'; export * from './lib/events'; export * from './lib/factories'; export * from './lib/read-models'; diff --git a/libs/api/tracker/domain/src/lib/events/index.ts b/libs/api/tracker/domain/src/lib/events/index.ts index 293196e..530e0e9 100644 --- a/libs/api/tracker/domain/src/lib/events/index.ts +++ b/libs/api/tracker/domain/src/lib/events/index.ts @@ -31,3 +31,9 @@ export * from './issue-checklist-item-added.event'; export * from './issue-checklist-item-updated.event'; export * from './issue-checklist-item-removed.event'; export * from './issue-ranked.event'; +export * from './sprint-created.event'; +export * from './sprint-updated.event'; +export * from './sprint-started.event'; +export * from './sprint-closed.event'; +export * from './sprint-deleted.event'; +export * from './issue-sprint-changed.event'; diff --git a/libs/api/tracker/domain/src/lib/events/issue-sprint-changed.event.ts b/libs/api/tracker/domain/src/lib/events/issue-sprint-changed.event.ts new file mode 100644 index 0000000..6373a00 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/issue-sprint-changed.event.ts @@ -0,0 +1,16 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** A ticket was moved in or out of a sprint. */ +@AutowiredEvent +export class IssueSprintChangedEvent extends TrackerEvent { + constructor( + actorId: string, + issueId: string, + projectId: string, + readonly sprintId: string | null, + readonly previousSprintId: string | null, + ) { + super(actorId, issueId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/events/sprint-closed.event.ts b/libs/api/tracker/domain/src/lib/events/sprint-closed.event.ts new file mode 100644 index 0000000..342c540 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/sprint-closed.event.ts @@ -0,0 +1,19 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** + * A sprint ended. Its unfinished tickets move to `carryOverTo` (null: back + * to the backlog); the SprintCarryOverSaga moves them. + */ +@AutowiredEvent +export class SprintClosedEvent extends TrackerEvent { + constructor( + actorId: string, + sprintId: string, + projectId: string, + readonly carryOverTo: string | null, + readonly unfinishedIssues: number, + ) { + super(actorId, sprintId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/events/sprint-created.event.ts b/libs/api/tracker/domain/src/lib/events/sprint-created.event.ts new file mode 100644 index 0000000..8014499 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/sprint-created.event.ts @@ -0,0 +1,14 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import type { SprintProps } from '../sprint/sprint'; +import { TrackerEvent } from './tracker.event'; + +/** A sprint was planned. */ +@AutowiredEvent +export class SprintCreatedEvent extends TrackerEvent { + constructor( + actorId: string, + readonly sprint: SprintProps, + ) { + super(actorId, sprint.id, sprint.projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/events/sprint-deleted.event.ts b/libs/api/tracker/domain/src/lib/events/sprint-deleted.event.ts new file mode 100644 index 0000000..e19f0d4 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/sprint-deleted.event.ts @@ -0,0 +1,10 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** A sprint was deleted: its tickets go back to the backlog. */ +@AutowiredEvent +export class SprintDeletedEvent extends TrackerEvent { + constructor(actorId: string, sprintId: string, projectId: string) { + super(actorId, sprintId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/events/sprint-started.event.ts b/libs/api/tracker/domain/src/lib/events/sprint-started.event.ts new file mode 100644 index 0000000..70d6002 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/sprint-started.event.ts @@ -0,0 +1,10 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import { TrackerEvent } from './tracker.event'; + +/** A sprint got under way. */ +@AutowiredEvent +export class SprintStartedEvent extends TrackerEvent { + constructor(actorId: string, sprintId: string, projectId: string) { + super(actorId, sprintId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/events/sprint-updated.event.ts b/libs/api/tracker/domain/src/lib/events/sprint-updated.event.ts new file mode 100644 index 0000000..c167cb1 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/events/sprint-updated.event.ts @@ -0,0 +1,16 @@ +import { AutowiredEvent } from '@task-stream/api/shared/domain'; +import type { SprintDetails } from '../sprint/sprint'; +import { TrackerEvent } from './tracker.event'; + +/** A sprint was renamed, re-dated or got another goal (only what changed). */ +@AutowiredEvent +export class SprintUpdatedEvent extends TrackerEvent { + constructor( + actorId: string, + sprintId: string, + projectId: string, + readonly changes: SprintDetails, + ) { + super(actorId, sprintId, projectId); + } +} diff --git a/libs/api/tracker/domain/src/lib/factories/index.ts b/libs/api/tracker/domain/src/lib/factories/index.ts index 49a810e..3c8d390 100644 --- a/libs/api/tracker/domain/src/lib/factories/index.ts +++ b/libs/api/tracker/domain/src/lib/factories/index.ts @@ -2,13 +2,15 @@ import { CommentFactory } from './comment.factory'; import { IssueFactory } from './issue.factory'; import { ProjectFactory } from './project.factory'; import { ReleaseFactory } from './release.factory'; +import { SprintFactory } from './sprint.factory'; export * from './issue.factory'; -export { CommentFactory, ProjectFactory, ReleaseFactory }; +export { CommentFactory, ProjectFactory, ReleaseFactory, SprintFactory }; export const TRACKER_FACTORIES = [ ProjectFactory, IssueFactory, CommentFactory, ReleaseFactory, + SprintFactory, ]; diff --git a/libs/api/tracker/domain/src/lib/factories/sprint.factory.ts b/libs/api/tracker/domain/src/lib/factories/sprint.factory.ts new file mode 100644 index 0000000..9faba5a --- /dev/null +++ b/libs/api/tracker/domain/src/lib/factories/sprint.factory.ts @@ -0,0 +1,39 @@ +import { Injectable } from '@nestjs/common'; +import { newId } from '@task-stream/api/shared/domain'; +import { SprintCreatedEvent } from '../events/sprint-created.event'; +import { Sprint, SprintProps, normalizeSprintDetails } from '../sprint/sprint'; +import { SprintStatus } from '../sprint/sprint-status'; + +@Injectable() +export class SprintFactory { + create( + actorId: string, + props: { + projectId: string; + name: string; + goal?: string | null; + startDate: string; + endDate: string; + }, + ): Sprint { + const details = normalizeSprintDetails({ + name: props.name, + goal: props.goal ?? null, + startDate: props.startDate, + endDate: props.endDate, + }); + const data: SprintProps = { + id: newId(), + projectId: props.projectId, + ...details, + status: SprintStatus.PLANNED, + startedAt: null, + closedAt: null, + createdAt: new Date().toISOString(), + }; + const sprint = new Sprint(data.id); + Object.assign(sprint, { ...data }); + sprint.apply(new SprintCreatedEvent(actorId, data), { skipHandler: true }); + return sprint; + } +} diff --git a/libs/api/tracker/domain/src/lib/issue/issue.ts b/libs/api/tracker/domain/src/lib/issue/issue.ts index c26b955..f7763ba 100644 --- a/libs/api/tracker/domain/src/lib/issue/issue.ts +++ b/libs/api/tracker/domain/src/lib/issue/issue.ts @@ -16,6 +16,7 @@ import { IssueLinkedEvent } from '../events/issue-linked.event'; import { IssueParentChangedEvent } from '../events/issue-parent-changed.event'; import { IssueRankedEvent } from '../events/issue-ranked.event'; import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event'; +import { IssueSprintChangedEvent } from '../events/issue-sprint-changed.event'; import { IssueStatusChangedEvent } from '../events/issue-status-changed.event'; import { IssueUnlinkedEvent } from '../events/issue-unlinked.event'; import { IssueUpdatedEvent } from '../events/issue-updated.event'; @@ -112,6 +113,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { public checklist: ChecklistItem[] = []; /** Place in the backlog once someone moved it (see `backlogRank`). */ public rank: string | null = null; + public sprintId: string | null = null; public createdAt!: string; public updatedAt!: string; public deleted = false; @@ -246,6 +248,20 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { ); } + /** Moves the ticket in or out of a sprint (null: back to the backlog). */ + setSprint(actorId: string, sprintId: string | null): void { + if (this.sprintId === sprintId) return; + this.apply( + new IssueSprintChangedEvent( + actorId, + this.id, + this.projectId, + sprintId, + this.sprintId, + ), + ); + } + /** Records a file stored for this ticket (or one of its comments). */ attach(actorId: string, attachment: IssueAttachment): void { const next = normalizeAttachment(attachment); @@ -519,6 +535,11 @@ export class Issue extends VersionedAggregateRoot implements IssueProps { this.deleted = true; } + onIssueSprintChangedEvent(event: IssueSprintChangedEvent): void { + this.sprintId = event.sprintId; + this.updatedAt = event.occurredAt; + } + onIssueReleaseChangedEvent(event: IssueReleaseChangedEvent): void { this.releaseId = event.releaseId; this.updatedAt = event.occurredAt; diff --git a/libs/api/tracker/domain/src/lib/read-models/analytics.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/analytics.read-model.ts index b67cd5f..05b2a9b 100644 --- a/libs/api/tracker/domain/src/lib/read-models/analytics.read-model.ts +++ b/libs/api/tracker/domain/src/lib/read-models/analytics.read-model.ts @@ -12,12 +12,13 @@ export class IssueTimelineEntry { issueId!: string; projectId!: string; at!: string; - kind!: 'status' | 'release'; + kind!: 'status' | 'release' | 'sprint'; status?: string; category?: StatusCategory; /** Assignee when the status changed (throughput per person). */ assigneeId?: string | null; releaseId?: string | null; + sprintId?: string | null; } export class DurationStats { diff --git a/libs/api/tracker/domain/src/lib/read-models/index.ts b/libs/api/tracker/domain/src/lib/read-models/index.ts index 129e599..01a9130 100644 --- a/libs/api/tracker/domain/src/lib/read-models/index.ts +++ b/libs/api/tracker/domain/src/lib/read-models/index.ts @@ -8,3 +8,4 @@ export * from './project-stats.read-model'; export * from './analytics.read-model'; export * from './notification.read-model'; export * from './saved-filter.read-model'; +export * from './sprint.read-model'; 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 178037b..23e336b 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 @@ -70,6 +70,7 @@ export class IssueReadModel { reporter!: UserRefReadModel; assignee!: UserRefReadModel | null; release!: { id: string; name: string } | null; + sprint!: { id: string; name: string } | null; labels!: string[]; figmaUrl!: string | null; storyPoints!: number | null; diff --git a/libs/api/tracker/domain/src/lib/read-models/sprint.read-model.ts b/libs/api/tracker/domain/src/lib/read-models/sprint.read-model.ts new file mode 100644 index 0000000..91176e6 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/read-models/sprint.read-model.ts @@ -0,0 +1,40 @@ +import { SprintStatus } from '../sprint/sprint-status'; + +export class SprintReadModel { + id!: string; + projectId!: string; + name!: string; + goal!: string | null; + /** `YYYY-MM-DD`, both included. */ + startDate!: string; + endDate!: string; + status!: SprintStatus; + startedAt!: string | null; + closedAt!: string | null; + createdAt!: string; + version!: number; +} + +/** How far a sprint's tickets have got. */ +export class SprintProgressReadModel { + total!: number; + /** Done or cancelled. */ + finished!: number; + percent!: number; + storyPoints!: number; + finishedStoryPoints!: number; +} + +/** + * Per day of the sprint: tickets in it (cancelled ones aside), how many are + * still open and their points. Days still to come are null; `ideal` falls in + * a straight line from what was open on the first day to zero on the last. + */ +export class SprintBurndownReadModel { + sprintId!: string; + dates!: string[]; + scope!: (number | null)[]; + remaining!: (number | null)[]; + remainingPoints!: (number | null)[]; + ideal!: number[]; +} diff --git a/libs/api/tracker/domain/src/lib/sprint/sprint-status.ts b/libs/api/tracker/domain/src/lib/sprint/sprint-status.ts new file mode 100644 index 0000000..c1eeff2 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/sprint/sprint-status.ts @@ -0,0 +1,8 @@ +export enum SprintStatus { + /** Being prepared: tickets come and go. */ + PLANNED = 'PLANNED', + /** Under way (one per project at a time). */ + ACTIVE = 'ACTIVE', + /** Over: what was left went to another sprint or back to the backlog. */ + CLOSED = 'CLOSED', +} diff --git a/libs/api/tracker/domain/src/lib/sprint/sprint.spec.ts b/libs/api/tracker/domain/src/lib/sprint/sprint.spec.ts new file mode 100644 index 0000000..02e9377 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/sprint/sprint.spec.ts @@ -0,0 +1,79 @@ +import { + BusinessRuleError, + ValidationError, +} from '@task-stream/api/shared/domain'; +import { SprintClosedEvent } from '../events/sprint-closed.event'; +import { SprintFactory } from '../factories/sprint.factory'; +import { replay } from '../testing'; +import { Sprint } from './sprint'; +import { SprintStatus } from './sprint-status'; + +const factory = new SprintFactory(); +const newSprint = (name = 'Sprint 1', projectId = 'p-1') => + factory.create('u-pm', { + projectId, + name, + goal: ' Pagos con tarjeta ', + startDate: '2026-10-12', + endDate: '2026-10-23', + }); + +describe('Sprint', () => { + it('is planned with tidy details', () => { + const sprint = newSprint(); + expect(sprint.status).toBe(SprintStatus.PLANNED); + expect(sprint.goal).toBe('Pagos con tarjeta'); + }); + + it('refuses dates the wrong way round, too long or malformed', () => { + const create = (startDate: string, endDate: string) => + factory.create('u', { projectId: 'p', name: 'S', startDate, endDate }); + expect(() => create('2026-10-23', '2026-10-12')).toThrow(ValidationError); + expect(() => create('2026-01-01', '2026-12-31')).toThrow(/at most/); + expect(() => create('12/10/2026', '2026-10-23')).toThrow(ValidationError); + }); + + it('starts once, only when nothing else is under way, and closes', () => { + const sprint = newSprint(); + const next = newSprint('Sprint 2'); + expect(() => sprint.start('u', { name: 'Sprint 0' })).toThrow( + BusinessRuleError, + ); + sprint.start('u', null); + expect(() => sprint.start('u', null)).toThrow(BusinessRuleError); + expect(() => sprint.update('u', { startDate: '2026-10-13' })).toThrow( + /start date/, + ); + sprint.update('u', { endDate: '2026-10-26' }); + + sprint.close('u', next, 3); + const closed = sprint + .getUncommittedEvents() + .find((e) => e instanceof SprintClosedEvent); + expect(closed).toMatchObject({ carryOverTo: next.id, unfinishedIssues: 3 }); + const { rebuilt } = replay(sprint, Sprint); + expect(rebuilt.status).toBe(SprintStatus.CLOSED); + expect(rebuilt.endDate).toBe('2026-10-26'); + expect(() => rebuilt.assertAcceptsIssues()).toThrow(BusinessRuleError); + }); + + it('carries over only to another open sprint of the same project', () => { + const sprint = newSprint(); + sprint.start('u', null); + expect(() => sprint.close('u', sprint, 0)).toThrow(BusinessRuleError); + expect(() => sprint.close('u', newSprint('X', 'p-2'), 0)).toThrow( + BusinessRuleError, + ); + }); + + it('deletes planned sprints, and any on cascade', () => { + const planned = newSprint(); + planned.delete('u'); + expect(planned.deleted).toBe(true); + const active = newSprint(); + active.start('u', null); + expect(() => active.delete('u')).toThrow(BusinessRuleError); + active.delete('u', true); + expect(active.deleted).toBe(true); + }); +}); diff --git a/libs/api/tracker/domain/src/lib/sprint/sprint.ts b/libs/api/tracker/domain/src/lib/sprint/sprint.ts new file mode 100644 index 0000000..716e398 --- /dev/null +++ b/libs/api/tracker/domain/src/lib/sprint/sprint.ts @@ -0,0 +1,192 @@ +import { + BusinessRuleError, + VersionedAggregateRoot, + assertThat, +} from '@task-stream/api/shared/domain'; +import { SprintClosedEvent } from '../events/sprint-closed.event'; +import { SprintCreatedEvent } from '../events/sprint-created.event'; +import { SprintDeletedEvent } from '../events/sprint-deleted.event'; +import { SprintStartedEvent } from '../events/sprint-started.event'; +import { SprintUpdatedEvent } from '../events/sprint-updated.event'; +import { SprintStatus } from './sprint-status'; + +/** Data of a sprint (what SprintCreatedEvent carries). */ +export interface SprintProps { + id: string; + projectId: string; + name: string; + goal: string | null; + /** First and last day, `YYYY-MM-DD`. */ + startDate: string; + endDate: string; + status: SprintStatus; + startedAt: string | null; + closedAt: string | null; + createdAt: string; +} + +export type SprintDetails = Partial< + Pick +>; + +const DATE = /^\d{4}-\d{2}-\d{2}$/; +/** Long enough for any real iteration, short enough to catch a typo. */ +export const MAX_SPRINT_DAYS = 90; + +/** Checks the name and the dates of a sprint; returns them tidied. */ +export function normalizeSprintDetails( + details: Required> & + Pick, +): Required> { + const name = details.name.trim(); + assertThat( + name.length > 0 && name.length <= 80, + 'A sprint needs a name (up to 80 characters)', + ); + for (const date of [details.startDate, details.endDate]) + assertThat( + DATE.test(date) && !Number.isNaN(Date.parse(date)), + 'Sprint dates must be YYYY-MM-DD', + ); + const days = + (Date.parse(details.endDate) - Date.parse(details.startDate)) / 86_400_000; + assertThat(days >= 0, 'A sprint cannot end before it starts'); + assertThat( + days < MAX_SPRINT_DAYS, + `A sprint lasts at most ${MAX_SPRINT_DAYS} days`, + ); + return { + name, + goal: details.goal?.trim() || null, + startDate: details.startDate, + endDate: details.endDate, + }; +} + +/** A time-boxed iteration: the tickets the team commits to for a few weeks. */ +export class Sprint extends VersionedAggregateRoot implements SprintProps { + public projectId!: string; + public name!: string; + public goal: string | null = null; + public startDate!: string; + public endDate!: string; + public status: SprintStatus = SprintStatus.PLANNED; + public startedAt: string | null = null; + public closedAt: string | null = null; + public createdAt!: string; + public deleted = false; + + constructor(id: string) { + super(); + this.id = id; + } + + /** Closed sprints keep what they had: their record is fixed. */ + assertAcceptsIssues(): void { + if (this.status === SprintStatus.CLOSED) + throw new BusinessRuleError(`Sprint ${this.name} is closed`); + } + + update(actorId: string, changes: SprintDetails): void { + this.assertAcceptsIssues(); + const next = normalizeSprintDetails({ + name: changes.name ?? this.name, + goal: changes.goal !== undefined ? changes.goal : this.goal, + startDate: changes.startDate ?? this.startDate, + endDate: changes.endDate ?? this.endDate, + }); + if ( + this.status === SprintStatus.ACTIVE && + next.startDate !== this.startDate + ) + throw new BusinessRuleError('A sprint under way keeps its start date'); + const patch: SprintDetails = {}; + for (const field of ['name', 'goal', 'startDate', 'endDate'] as const) + if (next[field] !== this[field]) + (patch as Record)[field] = next[field]; + if (Object.keys(patch).length === 0) return; + this.apply(new SprintUpdatedEvent(actorId, this.id, this.projectId, patch)); + } + + /** `otherActive`: the sprint already under way in the project, if any. */ + start(actorId: string, otherActive: { name: string } | null): void { + if (this.status !== SprintStatus.PLANNED) + throw new BusinessRuleError(`Sprint ${this.name} is ${this.status}`); + if (otherActive) + throw new BusinessRuleError( + `Sprint ${otherActive.name} is under way; close it first`, + ); + this.apply(new SprintStartedEvent(actorId, this.id, this.projectId)); + } + + /** + * Ends the sprint. Its unfinished tickets go to `carryOverTo` (another + * open sprint of the project) or, with null, back to the backlog. + */ + close( + actorId: string, + carryOverTo: Sprint | null, + unfinishedIssues: number, + ): void { + if (this.status !== SprintStatus.ACTIVE) + throw new BusinessRuleError( + `Only a sprint under way can be closed; ${this.name} is ${this.status}`, + ); + if (carryOverTo) { + if ( + carryOverTo.id === this.id || + carryOverTo.projectId !== this.projectId + ) + throw new BusinessRuleError( + 'Unfinished tickets go to another sprint of the same project', + ); + carryOverTo.assertAcceptsIssues(); + } + this.apply( + new SprintClosedEvent( + actorId, + this.id, + this.projectId, + carryOverTo?.id ?? null, + unfinishedIssues, + ), + ); + } + + /** + * Removes a sprint that never started (its tickets go back to the + * backlog), or any sprint when its project is deleted (`cascade`). + */ + delete(actorId: string, cascade = false): void { + if (this.deleted) return; + if (!cascade && this.status !== SprintStatus.PLANNED) + throw new BusinessRuleError( + `Sprint ${this.name} is ${this.status}; only planned sprints can be deleted`, + ); + this.apply(new SprintDeletedEvent(actorId, this.id, this.projectId)); + } + + /* ---- State transitions (also replayed by loadFromHistory) ---- */ + + onSprintCreatedEvent({ sprint }: SprintCreatedEvent): void { + Object.assign(this, sprint); + } + + onSprintUpdatedEvent(event: SprintUpdatedEvent): void { + Object.assign(this, event.changes); + } + + onSprintStartedEvent(event: SprintStartedEvent): void { + this.status = SprintStatus.ACTIVE; + this.startedAt = event.occurredAt; + } + + onSprintClosedEvent(event: SprintClosedEvent): void { + this.status = SprintStatus.CLOSED; + this.closedAt = event.occurredAt; + } + + onSprintDeletedEvent(): void { + this.deleted = true; + } +} 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 d91eef8..cd47f37 100644 --- a/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts +++ b/libs/api/tracker/infrastructure/src/lib/mongo/find.repositories.ts @@ -24,6 +24,7 @@ import { ProjectRole, ProjectStatsReadModel, ReleaseProgressReadModel, + SprintProgressReadModel, ReleaseReadModel, ReleaseStatus, StatusCategory, @@ -281,6 +282,37 @@ export class MongoFindIssuesRepository extends FindIssuesRepository { }); } + async sprintProgress(sprintId: string): Promise { + const finished = { $in: ['$statusCategory', FINISHED] }; + const points = { $ifNull: ['$storyPoints', 0] }; + const [row] = await this.issues.aggregate<{ + total: number; + finished: number; + storyPoints: number; + finishedStoryPoints: number; + }>([ + { $match: { 'sprint.id': sprintId } }, + { + $group: { + _id: null, + total: { $sum: 1 }, + finished: { $sum: { $cond: [finished, 1, 0] } }, + storyPoints: { $sum: points }, + finishedStoryPoints: { $sum: { $cond: [finished, points, 0] } }, + }, + }, + ]); + const total = row?.total ?? 0; + return { + total, + finished: row?.finished ?? 0, + percent: + total === 0 ? 0 : Math.round(((row?.finished ?? 0) / total) * 100), + storyPoints: row?.storyPoints ?? 0, + finishedStoryPoints: row?.finishedStoryPoints ?? 0, + }; + } + async releaseProgress(releaseId: string): Promise { const rows = await this.issues.aggregate<{ _id: { status: string; category: StatusCategory }; @@ -322,6 +354,8 @@ function toMongoFilter(filter: IssueFilter): FilterQuery { 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.noSprint) query['sprint'] = null; + else if (filter.sprintId) query['sprint.id'] = filter.sprintId; 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 93e8e15..a4e85f2 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 @@ -8,6 +8,7 @@ import { ProjectReadModel, ReleaseReadModel, SavedFilterReadModel, + SprintReadModel, implicitRank, } from '@task-stream/api/tracker/domain'; import { Schema } from 'mongoose'; @@ -24,6 +25,7 @@ export type IssueDoc = Doc> & { }; export type CommentDoc = Doc; export type ReleaseDoc = Doc; +export type SprintDoc = Doc; export type ActivityDoc = Doc; export type TimelineDoc = Doc; export type NotificationDoc = Doc; @@ -43,6 +45,7 @@ export const ReadModels = { Notification: 'NotificationReadModel', NotificationSettings: 'NotificationSettings', SavedFilter: 'SavedFilter', + Sprint: 'SprintReadModel', } as const; const userRef = { _id: false, id: String, name: String }; @@ -79,6 +82,7 @@ export const IssueSchema = new Schema( reporter: userRef, assignee: userRef, release: { _id: false, id: String, name: String }, + sprint: { _id: false, id: String, name: String }, outwardLinks: [link], inwardLinks: [link], watchers: [userRef], @@ -108,6 +112,7 @@ IssueSchema.index({ 'watchers.id': 1 }); IssueSchema.index({ 'parent.id': 1 }); IssueSchema.index({ projectId: 1, rank: 1 }); IssueSchema.index({ 'release.id': 1 }); +IssueSchema.index({ 'sprint.id': 1 }); IssueSchema.index({ title: 'text', description: 'text', key: 'text' }); export const CommentSchema = new Schema( @@ -126,6 +131,16 @@ export const ReleaseSchema = new Schema( { ...loose, collection: 'releases_view' }, ); +export const SprintSchema = new Schema( + { + _id: String, + projectId: { type: String, index: true }, + status: String, + version: Number, + }, + { ...loose, collection: 'sprints_view' }, +); + export const ActivitySchema = new Schema( { _id: String, @@ -182,6 +197,7 @@ export const READ_MODEL_SCHEMAS = [ { name: ReadModels.Issue, schema: IssueSchema }, { name: ReadModels.Comment, schema: CommentSchema }, { name: ReadModels.Release, schema: ReleaseSchema }, + { name: ReadModels.Sprint, schema: SprintSchema }, { name: ReadModels.Activity, schema: ActivitySchema }, { name: ReadModels.Timeline, schema: TimelineSchema }, { name: ReadModels.Notification, schema: NotificationSchema }, @@ -207,6 +223,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel { watchers: rest.watchers ?? [], parent: rest.parent?.id ? rest.parent : null, rank: rest.rank ?? implicitRank(rest.number), + sprint: rest.sprint?.id ? rest.sprint : null, checklist: rest.checklist ?? [], }; } diff --git a/libs/api/tracker/infrastructure/src/lib/mongo/sprints.repositories.ts b/libs/api/tracker/infrastructure/src/lib/mongo/sprints.repositories.ts new file mode 100644 index 0000000..5fcccd4 --- /dev/null +++ b/libs/api/tracker/infrastructure/src/lib/mongo/sprints.repositories.ts @@ -0,0 +1,72 @@ +import { Injectable } from '@nestjs/common'; +import { InjectModel } from '@nestjs/mongoose'; +import { + FindSprintsRepository, + UpsertMaterializedSprintRepository, +} from '@task-stream/api/tracker/application'; +import { SprintReadModel, SprintStatus } from '@task-stream/api/tracker/domain'; +import { Model } from 'mongoose'; +import { ReadModels, SprintDoc, fromDoc } from './read-model.schemas'; +import { upsertIfNewer } from './upsert-if-newer'; + +const ORDER: Record = { + [SprintStatus.ACTIVE]: 0, + [SprintStatus.PLANNED]: 1, + [SprintStatus.CLOSED]: 2, +}; + +@Injectable() +export class MongoFindSprintsRepository extends FindSprintsRepository { + constructor( + @InjectModel(ReadModels.Sprint) private readonly sprints: Model, + ) { + super(); + } + + async findById(id: string): Promise { + const doc = await this.sprints.findById(id).lean(); + return doc ? fromDoc(doc) : null; + } + + async findByProject(projectId: string): Promise { + const docs = await this.sprints.find({ projectId }).lean(); + return docs + .map(fromDoc) + .sort( + (a, b) => + ORDER[a.status] - ORDER[b.status] || + (a.status === SprintStatus.CLOSED + ? b.startDate.localeCompare(a.startDate) + : a.startDate.localeCompare(b.startDate)) || + a.name.localeCompare(b.name), + ); + } + + async findActive(projectId: string): Promise { + const doc = await this.sprints + .findOne({ projectId, status: SprintStatus.ACTIVE }) + .lean(); + return doc ? fromDoc(doc) : null; + } +} + +@Injectable() +export class MongoUpsertMaterializedSprintRepository extends UpsertMaterializedSprintRepository { + constructor( + @InjectModel(ReadModels.Sprint) private readonly sprints: Model, + ) { + super(); + } + + async upsert({ + id, + version, + ...fields + }: Pick & Partial) { + await upsertIfNewer(this.sprints, id, version, fields); + } + + async remove(sprintId: string) { + await this.sprints.deleteOne({ _id: sprintId }); + } +} 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 8898c46..3482c9f 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 @@ -230,6 +230,13 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs if (ops.length) await this.issues.bulkWrite(ops); } + async renameSprint(sprintId: string, name: string) { + await this.issues.updateMany( + { 'sprint.id': sprintId }, + { $set: { 'sprint.name': name } }, + ); + } + async renameRelease(releaseId: string, name: string) { await this.issues.updateMany( { 'release.id': releaseId }, 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 7ede509..5946b23 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 @@ -5,7 +5,9 @@ import { IssueCreatedEvent, IssueLinkedEvent, IssueParentChangedEvent, + IssueSprintChangedEvent, ReleaseCreatedEvent, + SprintCreatedEvent, } from '@task-stream/api/tracker/domain'; import { DataSource } from 'typeorm'; @@ -32,6 +34,22 @@ export class PostgresTrackerStreamsRepository extends TrackerStreamsRepository { ); } + sprintIdsOfProject(projectId: string): Promise { + return this.streams( + SprintCreatedEvent.name, + `data->>'projectId'`, + projectId, + ); + } + + issueIdsEverInSprint(sprintId: string): Promise { + return this.streams( + IssueSprintChangedEvent.name, + `data->>'sprintId'`, + sprintId, + ); + } + commentIdsOfIssue(issueId: string): Promise { return this.streams( CommentAddedEvent.name, diff --git a/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts b/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts index 8d535c2..0c40710 100644 --- a/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts +++ b/libs/api/tracker/infrastructure/src/lib/realtime/redis-change-feed.ts @@ -6,6 +6,7 @@ import { IssueChange, ProjectChange, ReleaseChange, + SprintChange, } from '@task-stream/api/tracker/application'; import { ActivityReadModel, @@ -19,6 +20,7 @@ const Channels = { comment: (issueId: string) => `tracker.comment.${issueId}`, activity: (projectId: string) => `tracker.activity.${projectId}`, release: (projectId: string) => `tracker.release.${projectId}`, + sprint: (projectId: string) => `tracker.sprint.${projectId}`, project: (projectId: string) => `tracker.project.${projectId}`, userProjects: (userId: string) => `tracker.user-projects.${userId}`, notifications: (userId: string) => `tracker.notifications.${userId}`, @@ -49,6 +51,10 @@ export class RedisChangeFeed extends ChangeFeed { ); } + publishSprint(change: SprintChange): Promise { + return this.pubSub.publish(Channels.sprint(change.projectId), change); + } + async publishProject( change: ProjectChange, notifyUserIds: string[] = [], @@ -88,6 +94,10 @@ export class RedisChangeFeed extends ChangeFeed { return this.pubSub.asyncIterableIterator(Channels.release(projectId)); } + sprints(projectId: string): AsyncIterableIterator { + return this.pubSub.asyncIterableIterator(Channels.sprint(projectId)); + } + project(projectId: string): AsyncIterableIterator { return this.pubSub.asyncIterableIterator(Channels.project(projectId)); } diff --git a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts index 7c8d33a..797d4ee 100644 --- a/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts +++ b/libs/api/tracker/infrastructure/src/lib/tracker-infrastructure.module.ts @@ -13,6 +13,8 @@ import { FindIssuesRepository, FindProjectsRepository, FindReleasesRepository, + FindSprintsRepository, + UpsertMaterializedSprintRepository, FindTimelineRepository, IssueNumbersRepository, ProjectKeysRepository, @@ -58,6 +60,10 @@ import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.rep import { RedisChangeFeed } from './realtime/redis-change-feed'; import { MongoNotificationsRepository } from './mongo/notifications.repository'; import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository'; +import { + MongoFindSprintsRepository, + MongoUpsertMaterializedSprintRepository, +} from './mongo/sprints.repositories'; import { SmtpNotificationMailer } from './mail/smtp-notification.mailer'; import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage'; @@ -79,6 +85,11 @@ const PORTS = [ { provide: FindIssuesRepository, useClass: MongoFindIssuesRepository }, { provide: FindCommentsRepository, useClass: MongoFindCommentsRepository }, { provide: FindReleasesRepository, useClass: MongoFindReleasesRepository }, + { provide: FindSprintsRepository, useClass: MongoFindSprintsRepository }, + { + provide: UpsertMaterializedSprintRepository, + useClass: MongoUpsertMaterializedSprintRepository, + }, { provide: FindActivityRepository, useClass: MongoFindActivityRepository }, { provide: FindTimelineRepository, useClass: MongoFindTimelineRepository }, { diff --git a/libs/api/tracker/presenters/src/lib/graphql/enums.ts b/libs/api/tracker/presenters/src/lib/graphql/enums.ts index cae9e6d..81cbdc6 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/enums.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/enums.ts @@ -6,6 +6,7 @@ import { NotificationKind, ProjectRole, ReleaseStatus, + SprintStatus, StatusCategory, } from '@task-stream/api/tracker/domain'; @@ -23,6 +24,10 @@ registerEnumType(StatusCategory, { registerEnumType(IssueLinkType, { name: 'IssueLinkType' }); registerEnumType(ProjectRole, { name: 'ProjectRole' }); registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' }); +registerEnumType(SprintStatus, { + name: 'SprintStatus', + description: 'PLANNED, ACTIVE (one per project at a time) or CLOSED', +}); registerEnumType(LinkDirection, { name: 'LinkDirection' }); registerEnumType(IssueSort, { name: 'IssueSort', diff --git a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts index f192bae..967faaf 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/inputs.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/inputs.ts @@ -181,6 +181,41 @@ export class ChangeIssueStatusInput { reason?: string | null; } +const DAY = /^\d{4}-\d{2}-\d{2}$/; + +@InputType() +export class CreateSprintInput { + @Field(() => ID) @IsUUID() projectId!: string; + @Field() @IsString() @MinLength(1) @MaxLength(80) name!: string; + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + @MaxLength(500) + goal?: string | null; + @Field({ description: 'YYYY-MM-DD' }) @Matches(DAY) startDate!: string; + @Field({ description: 'YYYY-MM-DD, included' }) + @Matches(DAY) + endDate!: string; +} + +@InputType() +export class UpdateSprintInput { + @Field(() => ID) @IsUUID() sprintId!: string; + @Field({ nullable: true }) + @IsOptional() + @IsString() + @MinLength(1) + @MaxLength(80) + name?: string; + @Field(() => String, { nullable: true }) + @IsOptional() + @IsString() + @MaxLength(500) + goal?: string | null; + @Field({ nullable: true }) @IsOptional() @Matches(DAY) startDate?: string; + @Field({ nullable: true }) @IsOptional() @Matches(DAY) endDate?: string; +} + @InputType() export class SavedFilterCriteriaInput { @Field(() => [String], { nullable: true }) @@ -320,6 +355,14 @@ export class IssueFilterInput { @IsOptional() @IsUUID() parentId?: string; + @Field(() => ID, { nullable: true }) + @IsOptional() + @IsUUID() + sprintId?: string; + @Field({ nullable: true, description: 'Only tickets in no sprint' }) + @IsOptional() + @IsBoolean() + noSprint?: boolean; @Field(() => IssueSort, { nullable: true, defaultValue: IssueSort.RECENT }) @IsOptional() @IsEnum(IssueSort) diff --git a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts index 63d0a55..4a3fca1 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/project.resolver.ts @@ -125,6 +125,8 @@ export class ProjectResolver { search?: string | null, @Args('unassigned', { type: () => Boolean, nullable: true }) unassigned?: boolean | null, + @Args('sprintId', { type: () => ID, nullable: true }) + sprintId?: string | null, ) { return this.projects.board( viewerOf(me), @@ -138,6 +140,7 @@ export class ProjectResolver { labels: labels ?? undefined, search: search?.slice(0, 200) ?? undefined, unassigned: unassigned ?? undefined, + sprintId: sprintId ?? undefined, }, perColumn, ); diff --git a/libs/api/tracker/presenters/src/lib/graphql/sprint.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/sprint.resolver.ts new file mode 100644 index 0000000..acbaf65 --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/graphql/sprint.resolver.ts @@ -0,0 +1,170 @@ +import { + Args, + ID, + Int, + Mutation, + Parent, + Query, + ResolveField, + Resolver, +} from '@nestjs/graphql'; +import { User } from '@task-stream/api/identity/domain'; +import { + CurrentUser, + OptionalAuth, +} from '@task-stream/api/identity/presenters'; +import { + IssuesService, + IssueSort, + SprintsService, +} from '@task-stream/api/tracker/application'; +import { SprintReadModel } from '@task-stream/api/tracker/domain'; +import { viewerOf } from '../common/viewer'; +import { CreateSprintInput, UpdateSprintInput } from './inputs'; +import { + CommandResultGql, + IssuePageGql, + SprintBurndownGql, + SprintGql, + SprintProgressGql, +} from './types'; + +@Resolver(() => SprintGql) +export class SprintResolver { + constructor( + private readonly sprintsService: SprintsService, + private readonly issuesService: IssuesService, + ) {} + + @OptionalAuth() + @Query(() => [SprintGql], { + description: + "A project's sprints: the one under way, the planned ones, then the closed ones", + }) + sprints( + @CurrentUser() me: User | undefined, + @Args('projectId', { type: () => ID }) projectId: string, + ) { + return this.sprintsService.list(viewerOf(me), projectId); + } + + @OptionalAuth() + @Query(() => SprintGql) + sprint( + @CurrentUser() me: User | undefined, + @Args('id', { type: () => ID }) id: string, + ) { + return this.sprintsService.findOne(viewerOf(me), id); + } + + @ResolveField(() => SprintProgressGql) + progress( + @CurrentUser() me: User | undefined, + @Parent() sprint: SprintReadModel, + ) { + return this.sprintsService.progress(viewerOf(me), sprint.id); + } + + @ResolveField(() => SprintBurndownGql) + burndown( + @CurrentUser() me: User | undefined, + @Parent() sprint: SprintReadModel, + ) { + return this.sprintsService.burndown(viewerOf(me), sprint.id); + } + + @ResolveField(() => IssuePageGql, { + description: 'Tickets in this sprint, in backlog order', + }) + issues( + @CurrentUser() me: User | undefined, + @Parent() sprint: SprintReadModel, + @Args('limit', { type: () => Int, defaultValue: 100 }) limit: number, + @Args('offset', { type: () => Int, defaultValue: 0 }) offset: number, + ) { + return this.issuesService.search(viewerOf(me), { + projectId: sprint.projectId, + sprintId: sprint.id, + sort: IssueSort.RANK, + limit, + offset, + }); + } + + @Mutation(() => CommandResultGql, { + description: 'Plan a sprint (owners and admins)', + }) + createSprint( + @CurrentUser() me: User, + @Args('input') input: CreateSprintInput, + ) { + return this.sprintsService.create(me.id, input); + } + + @Mutation(() => CommandResultGql) + updateSprint( + @CurrentUser() me: User, + @Args('input') input: UpdateSprintInput, + ) { + const { sprintId, ...changes } = input; + return this.sprintsService.update( + me.id, + sprintId, + Object.fromEntries( + Object.entries(changes).filter( + ([field, value]) => value !== null || field === 'goal', + ), + ), + ); + } + + @Mutation(() => CommandResultGql, { + description: 'Start a planned sprint (only one under way per project)', + }) + startSprint( + @CurrentUser() me: User, + @Args('sprintId', { type: () => ID }) sprintId: string, + ) { + return this.sprintsService.start(me.id, sprintId); + } + + @Mutation(() => CommandResultGql, { + description: + 'Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null.', + }) + closeSprint( + @CurrentUser() me: User, + @Args('sprintId', { type: () => ID }) sprintId: string, + @Args('carryOverToSprintId', { type: () => ID, nullable: true }) + carryOverToSprintId: string | null, + ) { + return this.sprintsService.close( + me.id, + sprintId, + carryOverToSprintId ?? null, + ); + } + + @Mutation(() => CommandResultGql, { + description: + 'Delete a sprint that never started; its tickets go back to the backlog', + }) + deleteSprint( + @CurrentUser() me: User, + @Args('sprintId', { type: () => ID }) sprintId: string, + ) { + return this.sprintsService.delete(me.id, sprintId); + } + + @Mutation(() => CommandResultGql, { + description: 'Put a ticket in a sprint (null: back to the backlog)', + }) + setIssueSprint( + @CurrentUser() me: User, + @Args('issueId', { type: () => ID }) issueId: string, + @Args('sprintId', { type: () => ID, nullable: true }) + sprintId: string | null, + ) { + return this.sprintsService.setIssueSprint(me.id, issueId, sprintId ?? null); + } +} diff --git a/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts b/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts index a333683..30149c3 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/subscription.resolver.ts @@ -12,6 +12,7 @@ import { NotificationGql, ProjectChangeGql, ReleaseChangeGql, + SprintChangeGql, } from './types'; const asIs = { resolve: (payload: T) => payload }; @@ -66,6 +67,14 @@ export class SubscriptionResolver { return this.changes.watchReleases(me.id, projectId); } + @Subscription(() => SprintChangeGql, asIs) + sprintChanged( + @CurrentUser() me: User, + @Args('projectId', { type: () => ID }) projectId: string, + ) { + return this.changes.watchSprints(me.id, projectId); + } + @Subscription(() => ProjectChangeGql, asIs) projectChanged( @CurrentUser() me: User, diff --git a/libs/api/tracker/presenters/src/lib/graphql/types.ts b/libs/api/tracker/presenters/src/lib/graphql/types.ts index 7b5b99e..22e48e5 100644 --- a/libs/api/tracker/presenters/src/lib/graphql/types.ts +++ b/libs/api/tracker/presenters/src/lib/graphql/types.ts @@ -5,6 +5,7 @@ import { NotificationKind, ProjectRole, ReleaseStatus, + SprintStatus, StatusCategory, } from '@task-stream/api/tracker/domain'; import { LinkDirection } from './enums'; @@ -110,6 +111,12 @@ export class AttachmentGql { commentId!: string | null; } +@ObjectType('SprintRef') +export class SprintRefGql { + @Field(() => ID) id!: string; + @Field() name!: string; +} + @ObjectType('ReleaseRef') export class ReleaseRefGql { @Field(() => ID) id!: string; @@ -154,6 +161,7 @@ export class IssueGql { @Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null; @Field(() => ReleaseRefGql, { nullable: true }) release!: ReleaseRefGql | null; + @Field(() => SprintRefGql, { nullable: true }) sprint!: SprintRefGql | null; @Field(() => [String]) labels!: string[]; @Field(() => String, { nullable: true }) figmaUrl!: string | null; @Field(() => Int, { nullable: true }) storyPoints!: number | null; @@ -270,6 +278,56 @@ export class ReleaseGql { @Field(() => Int) version!: number; } +@ObjectType('Sprint', { + description: 'A time-boxed iteration: the tickets the team commits to', +}) +export class SprintGql { + @Field(() => ID) id!: string; + @Field(() => ID) projectId!: string; + @Field() name!: string; + @Field(() => String, { nullable: true }) goal!: string | null; + @Field({ description: 'First day, YYYY-MM-DD' }) startDate!: string; + @Field({ description: 'Last day (included), YYYY-MM-DD' }) endDate!: string; + @Field(() => SprintStatus) status!: SprintStatus; + @Field(() => String, { nullable: true }) startedAt!: string | null; + @Field(() => String, { nullable: true }) closedAt!: string | null; + @Field() createdAt!: string; + @Field(() => Int) version!: number; +} + +@ObjectType('SprintProgress') +export class SprintProgressGql { + @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('SprintBurndown', { + description: + 'Per day of the sprint: tickets in it, still open, and their points (null: days to come). `ideal` falls from the first day to zero.', +}) +export class SprintBurndownGql { + @Field(() => ID) sprintId!: string; + @Field(() => [String]) dates!: string[]; + @Field(() => [Int], { nullable: 'items' }) scope!: (number | null)[]; + @Field(() => [Int], { nullable: 'items' }) remaining!: (number | null)[]; + @Field(() => [Int], { nullable: 'items' }) + remainingPoints!: (number | null)[]; + @Field(() => [Float]) ideal!: number[]; +} + +@ObjectType('SprintChange') +export class SprintChangeGql { + @Field() action!: string; + @Field(() => Int) version!: number; + @Field(() => ID) actorId!: string; + @Field(() => ID) sprintId!: string; + @Field(() => SprintGql, { nullable: true, description: 'Null: deleted' }) + sprint!: SprintGql | null; +} + @ObjectType('CountByStatus') export class CountByStatusGql { @Field({ description: 'Status id' }) key!: string; 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 a2daa49..21b8216 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 @@ -285,6 +285,11 @@ export class BoardQueryDto { @IsBoolean() unassigned?: boolean; + @ApiPropertyOptional({ format: 'uuid', description: 'Only this sprint' }) + @IsOptional() + @IsUUID() + sprintId?: string; + @ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 }) @IsOptional() @Type(() => Number) @@ -451,6 +456,17 @@ export class IssueSearchQueryDto extends PageQueryDto { @IsString() parent?: string; + @ApiPropertyOptional({ format: 'uuid', description: 'Only this sprint' }) + @IsOptional() + @IsUUID() + sprintId?: string; + + @ApiPropertyOptional({ description: 'Only tickets in no sprint' }) + @IsOptional() + @Transform(toBoolean) + @IsBoolean() + noSprint?: boolean; + @ApiPropertyOptional({ enum: IssueSort, enumName: 'IssueSort', @@ -819,6 +835,66 @@ export class CreateReleaseDto { export class UpdateReleaseDto extends PartialType(CreateReleaseDto) {} +const DAY = /^\d{4}-\d{2}-\d{2}$/; + +export class CreateSprintDto { + @ApiProperty({ example: 'Sprint 14', maxLength: 80 }) + @IsString() + @MinLength(1) + @MaxLength(80) + name!: string; + + @ApiPropertyOptional({ + type: String, + nullable: true, + maxLength: 500, + example: 'Pagos con tarjeta en producción', + }) + @IsOptional() + @IsString() + @MaxLength(500) + goal?: string | null; + + @ApiProperty({ format: 'date', example: '2026-10-12' }) + @Matches(DAY) + startDate!: string; + + @ApiProperty({ + format: 'date', + example: '2026-10-23', + description: 'Last day, included', + }) + @Matches(DAY) + endDate!: string; +} + +export class UpdateSprintDto extends PartialType(CreateSprintDto) {} + +export class CloseSprintDto { + @ApiPropertyOptional({ + type: String, + nullable: true, + format: 'uuid', + description: + 'Open sprint of the same project that takes the unfinished tickets; null or absent: back to the backlog', + }) + @IsOptional() + @IsUUID() + carryOverTo?: string | null; +} + +export class SetIssueSprintDto { + @ApiProperty({ + type: String, + nullable: true, + format: 'uuid', + description: 'Open sprint of the project, or null for the backlog', + }) + @ValidateIf((dto: SetIssueSprintDto) => dto.sprintId !== null) + @IsUUID() + sprintId!: string | null; +} + export class PublishReleaseDto { @ApiPropertyOptional({ type: Boolean, 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 63f4090..18ee3a1 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 @@ -5,6 +5,7 @@ import { NotificationKind, ProjectRole, ReleaseStatus, + SprintStatus, SETTING_COLORS, StatusCategory, TYPE_ICONS, @@ -135,6 +136,11 @@ export class IssueParentDto { @ApiProperty({ example: 'Pago con tarjeta' }) title!: string; } +export class SprintRefDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ example: 'Sprint 14' }) name!: string; +} + export class IssueDto { @ApiProperty({ format: 'uuid' }) id!: string; @ApiProperty({ format: 'uuid' }) projectId!: string; @@ -163,6 +169,8 @@ export class IssueDto { assignee!: UserRefDto | null; @ApiProperty({ type: ReleaseRefDto, nullable: true }) release!: ReleaseRefDto | null; + @ApiProperty({ type: SprintRefDto, nullable: true }) + sprint!: SprintRefDto | null; @ApiProperty({ type: [String] }) labels!: string[]; @ApiProperty({ type: String, nullable: true, description: 'Figma frame' }) figmaUrl!: string | null; @@ -255,6 +263,50 @@ export class CommentPageDto { @ApiProperty() total!: number; } +export class SprintDto { + @ApiProperty({ format: 'uuid' }) id!: string; + @ApiProperty({ format: 'uuid' }) projectId!: string; + @ApiProperty({ example: 'Sprint 14' }) name!: string; + @ApiProperty({ type: String, nullable: true }) goal!: string | null; + @ApiProperty({ format: 'date' }) startDate!: string; + @ApiProperty({ format: 'date', description: 'Last day, included' }) + endDate!: string; + @ApiProperty({ enum: SprintStatus, enumName: 'SprintStatus' }) + status!: SprintStatus; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + startedAt!: string | null; + @ApiProperty({ type: String, nullable: true, format: 'date-time' }) + closedAt!: string | null; + @ApiProperty({ format: 'date-time' }) createdAt!: string; + @ApiProperty() version!: number; +} + +export class SprintProgressDto { + @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 SprintBurndownDto { + @ApiProperty({ format: 'uuid' }) sprintId!: string; + @ApiProperty({ type: [String], format: 'date' }) dates!: string[]; + @ApiProperty({ + type: [Number], + nullable: true, + description: 'Tickets in the sprint (null: days to come)', + }) + scope!: (number | null)[]; + @ApiProperty({ type: [Number], nullable: true }) remaining!: ( + | number + | null + )[]; + @ApiProperty({ type: [Number], nullable: true }) + remainingPoints!: (number | null)[]; + @ApiProperty({ type: [Number] }) ideal!: number[]; +} + export class ReleaseDto { @ApiProperty({ format: 'uuid' }) id!: string; @ApiProperty({ format: 'uuid' }) projectId!: string; 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 2842b96..d3c9311 100644 --- a/libs/api/tracker/presenters/src/lib/http/issues.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/issues.controller.ts @@ -157,6 +157,8 @@ export class IssuesController { unassigned: query.unassigned, releaseId: query.releaseId, labels: query.label, + sprintId: query.sprintId, + noSprint: query.noSprint, sort: query.sort, search: query.search, limit: query.limit, diff --git a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts index 572322d..6a5ce30 100644 --- a/libs/api/tracker/presenters/src/lib/http/projects.controller.ts +++ b/libs/api/tracker/presenters/src/lib/http/projects.controller.ts @@ -187,6 +187,7 @@ export class ProjectsController { labels: query.label, search: query.search, unassigned: query.unassigned, + sprintId: query.sprintId, }, query.perColumn, ); diff --git a/libs/api/tracker/presenters/src/lib/http/sprints.controller.ts b/libs/api/tracker/presenters/src/lib/http/sprints.controller.ts new file mode 100644 index 0000000..4908329 --- /dev/null +++ b/libs/api/tracker/presenters/src/lib/http/sprints.controller.ts @@ -0,0 +1,249 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + Put, + Query, +} from '@nestjs/common'; +import { + ApiCreatedResponse, + ApiOkResponse, + ApiOperation, + ApiParam, + ApiTags, +} from '@nestjs/swagger'; +import { User } from '@task-stream/api/identity/domain'; +import { + ApiOptionalPireonAuth, + ApiPireonAuth, + CurrentUser, +} from '@task-stream/api/identity/presenters'; +import { + ApiCommandErrors, + ApiReadErrors, +} from '@task-stream/api/shared/presenters'; +import { + IssuesService, + IssueSort, + SprintsService, +} from '@task-stream/api/tracker/application'; +import { viewerOf } from '../common/viewer'; +import { + CloseSprintDto, + CreateSprintDto, + PageQueryDto, + SetIssueSprintDto, + UpdateSprintDto, +} from './dto/requests.dto'; +import { + CommandResultDto, + IssuePageDto, + SprintBurndownDto, + SprintDto, + SprintProgressDto, +} from './dto/responses.dto'; +import { Refs } from './refs'; + +const PROJECT = ApiParam({ + name: 'project', + description: 'Project id or key', + example: 'WEB', +}); +const SPRINT = ApiParam({ name: 'sprint', format: 'uuid' }); + +/** REST adapter of the sprint use cases: plan, run and close iterations. */ +@ApiTags('Sprints') +@ApiPireonAuth() +@Controller() +export class SprintsController { + constructor( + private readonly sprints: SprintsService, + private readonly issues: IssuesService, + private readonly refs: Refs, + ) {} + + @Get('projects/:project/sprints') + @ApiOptionalPireonAuth() + @PROJECT + @ApiOperation({ + summary: "A project's sprints", + description: 'The one under way, the planned ones, then the closed ones.', + }) + @ApiOkResponse({ type: [SprintDto] }) + @ApiReadErrors() + async list( + @CurrentUser() me: User | undefined, + @Param('project') project: string, + ) { + return this.sprints.list( + viewerOf(me), + await this.refs.projectId(viewerOf(me), project), + ); + } + + @Post('projects/:project/sprints') + @PROJECT + @ApiOperation({ summary: 'Plan a sprint (owners and admins)' }) + @ApiCreatedResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async create( + @CurrentUser() me: User, + @Param('project') project: string, + @Body() body: CreateSprintDto, + ) { + return this.sprints.create(me.id, { + ...body, + projectId: await this.refs.projectId(me.id, project), + }); + } + + @Get('sprints/:sprint') + @ApiOptionalPireonAuth() + @SPRINT + @ApiOperation({ summary: 'A sprint' }) + @ApiOkResponse({ type: SprintDto }) + @ApiReadErrors() + findOne( + @CurrentUser() me: User | undefined, + @Param('sprint', ParseUUIDPipe) sprint: string, + ) { + return this.sprints.findOne(viewerOf(me), sprint); + } + + @Patch('sprints/:sprint') + @SPRINT + @ApiOperation({ + summary: 'Rename, re-date or change the goal (not once closed)', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + update( + @CurrentUser() me: User, + @Param('sprint', ParseUUIDPipe) sprint: string, + @Body() body: UpdateSprintDto, + ) { + return this.sprints.update(me.id, sprint, body); + } + + @Delete('sprints/:sprint') + @SPRINT + @ApiOperation({ + summary: 'Delete a sprint that never started', + description: 'Its tickets go back to the backlog.', + }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + delete( + @CurrentUser() me: User, + @Param('sprint', ParseUUIDPipe) sprint: string, + ) { + return this.sprints.delete(me.id, sprint); + } + + @Post('sprints/:sprint/start') + @SPRINT + @ApiOperation({ + summary: 'Start a planned sprint (only one under way per project)', + }) + @ApiCreatedResponse({ type: CommandResultDto }) + @ApiCommandErrors() + start( + @CurrentUser() me: User, + @Param('sprint', ParseUUIDPipe) sprint: string, + ) { + return this.sprints.start(me.id, sprint); + } + + @Post('sprints/:sprint/close') + @SPRINT + @ApiOperation({ + summary: 'Close the sprint under way', + description: + 'Unfinished tickets move to `carryOverTo` (another open sprint) or back to the backlog; finished ones stay as its record.', + }) + @ApiCreatedResponse({ type: CommandResultDto }) + @ApiCommandErrors() + close( + @CurrentUser() me: User, + @Param('sprint', ParseUUIDPipe) sprint: string, + @Body() body: CloseSprintDto, + ) { + return this.sprints.close(me.id, sprint, body.carryOverTo ?? null); + } + + @Get('sprints/:sprint/progress') + @ApiOptionalPireonAuth() + @SPRINT + @ApiOperation({ summary: 'Finished tickets and points' }) + @ApiOkResponse({ type: SprintProgressDto }) + @ApiReadErrors() + progress( + @CurrentUser() me: User | undefined, + @Param('sprint', ParseUUIDPipe) sprint: string, + ) { + return this.sprints.progress(viewerOf(me), sprint); + } + + @Get('sprints/:sprint/burndown') + @ApiOptionalPireonAuth() + @SPRINT + @ApiOperation({ + summary: 'Open tickets and points, day by day, against the ideal line', + }) + @ApiOkResponse({ type: SprintBurndownDto }) + @ApiReadErrors() + burndown( + @CurrentUser() me: User | undefined, + @Param('sprint', ParseUUIDPipe) sprint: string, + ) { + return this.sprints.burndown(viewerOf(me), sprint); + } + + @Get('sprints/:sprint/issues') + @ApiOptionalPireonAuth() + @SPRINT + @ApiOperation({ summary: 'Tickets in the sprint, in backlog order' }) + @ApiOkResponse({ type: IssuePageDto }) + @ApiReadErrors() + async issuesOf( + @CurrentUser() me: User | undefined, + @Param('sprint', ParseUUIDPipe) sprint: string, + @Query() query: PageQueryDto, + ) { + const found = await this.sprints.findOne(viewerOf(me), sprint); + const page = await this.issues.search(viewerOf(me), { + projectId: found.projectId, + sprintId: found.id, + sort: IssueSort.RANK, + limit: query.limit, + offset: query.offset, + }); + return { ...page, items: await this.refs.issueDtos(page.items) }; + } + + @Put('issues/:issue/sprint') + @ApiParam({ + name: 'issue', + description: 'Ticket id or key', + example: 'WEB-12', + }) + @ApiOperation({ summary: 'Put the ticket in a sprint (null: the backlog)' }) + @ApiOkResponse({ type: CommandResultDto }) + @ApiCommandErrors() + async setIssueSprint( + @CurrentUser() me: User, + @Param('issue') issue: string, + @Body() body: SetIssueSprintDto, + ) { + return this.sprints.setIssueSprint( + me.id, + await this.refs.issueId(me.id, issue), + body.sprintId, + ); + } +} diff --git a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts index 44e638d..b01b956 100644 --- a/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts +++ b/libs/api/tracker/presenters/src/lib/tracker-presenters.module.ts @@ -18,6 +18,8 @@ import { ReleasesController } from './http/releases.controller'; import { NotificationsController } from './http/notifications.controller'; import { SavedFiltersController } from './http/saved-filters.controller'; import { SavedFilterResolver } from './graphql/saved-filter.resolver'; +import { SprintResolver } from './graphql/sprint.resolver'; +import { SprintsController } from './http/sprints.controller'; /** * Driving adapters of the tracker context: GraphQL (queries, mutations, @@ -48,6 +50,7 @@ import { SavedFilterResolver } from './graphql/saved-filter.resolver'; ExploreController, NotificationsController, SavedFiltersController, + SprintsController, ], providers: [ Refs, @@ -58,6 +61,7 @@ import { SavedFilterResolver } from './graphql/saved-filter.resolver'; SubscriptionResolver, NotificationResolver, SavedFilterResolver, + SprintResolver, ], }) export class TrackerPresentersModule { 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 4de0017..47ab353 100644 --- a/libs/web/tracker/application/src/lib/events/issue.events.ts +++ b/libs/web/tracker/application/src/lib/events/issue.events.ts @@ -26,6 +26,10 @@ export const issuePageEvents = eventGroup({ targetKey: string; }>(), unlinkRequested: type(), + sprintChangeRequested: type<{ + issueId: string; + sprintId: string | null; + }>(), releaseChangeRequested: type<{ issueId: string; releaseId: string | null; diff --git a/libs/web/tracker/application/src/lib/events/project.events.ts b/libs/web/tracker/application/src/lib/events/project.events.ts index a392b0c..8551288 100644 --- a/libs/web/tracker/application/src/lib/events/project.events.ts +++ b/libs/web/tracker/application/src/lib/events/project.events.ts @@ -10,6 +10,10 @@ import { ProjectStats, Release, ReleaseBurnup, + CreateSprintInput, + Sprint, + SprintBurndown, + UpdateSprintInput, UserDirectoryEntry, } from '@task-stream/web/tracker/domain'; @@ -23,6 +27,15 @@ export const projectPageEvents = eventGroup({ releaseCreateRequested: type(), releasePublishRequested: type<{ releaseId: string; force: boolean }>(), releaseArchiveRequested: type<{ releaseId: string }>(), + sprintCreateRequested: type(), + sprintUpdateRequested: type(), + sprintStartRequested: type<{ sprintId: string }>(), + sprintCloseRequested: type<{ + sprintId: string; + carryOverTo: string | null; + }>(), + sprintDeleteRequested: type<{ sprintId: string }>(), + burndownRequested: type<{ sprintId: string }>(), userSearchRequested: type(), settingsSaveRequested: type<{ projectId: string; @@ -42,6 +55,8 @@ export const projectApiEvents = eventGroup({ loadFailed: type(), statsLoaded: type(), releasesLoaded: type(), + sprintsLoaded: type(), + burndownLoaded: type(), activityLoaded: type(), commandSucceeded: type(), commandFailed: type(), @@ -63,5 +78,6 @@ export const projectRealtimeEvents = eventGroup({ projectDeleted: type(), activityAdded: type(), releaseChanged: type(), + sprintChanged: type<{ sprintId: string }>(), }, }); 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 241e773..fa38508 100644 --- a/libs/web/tracker/application/src/lib/facades/facades.spec.ts +++ b/libs/web/tracker/application/src/lib/facades/facades.spec.ts @@ -13,6 +13,7 @@ import { ProjectsGateway, ReleasesGateway, SavedFiltersGateway, + SprintsGateway, TrackerRealtimeGateway, UsersGateway, } from '@task-stream/web/tracker/domain'; @@ -70,6 +71,14 @@ function gateways() { download: vi.fn(() => of(new Blob(['x']))), remove: vi.fn(() => of(ok)), }, + sprints: { + list: vi.fn(() => of([])), + burndown: vi.fn(() => NEVER), + create: vi.fn(() => of(ok)), + start: vi.fn(() => of(ok)), + close: vi.fn(() => of(ok)), + setIssueSprint: vi.fn(() => of(ok)), + }, views: { list: vi.fn(() => of([])), save: vi.fn(() => of('v1')), @@ -80,6 +89,7 @@ function gateways() { commentChanges: () => NEVER, activity: () => NEVER, releaseChanges: () => NEVER, + sprintChanges: () => NEVER, projectChanges: vi.fn((): unknown => NEVER), }, }; @@ -113,6 +123,7 @@ function setup() { { provide: UsersGateway, useValue: { search: () => of([]) } }, { provide: TrackerRealtimeGateway, useValue: fakes.realtime }, { provide: SavedFiltersGateway, useValue: fakes.views }, + { provide: SprintsGateway, useValue: fakes.sprints }, { provide: Notifications, useValue: { success: vi.fn(), error: vi.fn() }, @@ -219,6 +230,28 @@ describe('ProjectFacade', () => { expect(project.workflow().status('IN_REVIEW').name).toBe('In review'); }); + it('plans, starts and closes sprints of the open project', () => { + const { project, fakes } = setup(); + project.open('WEB'); + project.createSprint({ + name: 'Sprint 1', + startDate: '2026-10-12', + endDate: '2026-10-23', + }); + project.startSprint('s1'); + project.closeSprint('s1', null); + + expect(fakes.sprints.list).toHaveBeenCalledWith('p1'); + expect(fakes.sprints.create).toHaveBeenCalledWith({ + projectId: 'p1', + name: 'Sprint 1', + startDate: '2026-10-12', + endDate: '2026-10-23', + }); + expect(fakes.sprints.start).toHaveBeenCalledWith('s1'); + expect(fakes.sprints.close).toHaveBeenCalledWith('s1', null); + }); + it('makes the project public and deletes it, then goes home', () => { const { project, fakes, navigate, notifications } = setup(); project.open('WEB'); 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 0a60f39..7565362 100644 --- a/libs/web/tracker/application/src/lib/facades/issue.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/issue.facade.ts @@ -79,6 +79,13 @@ export class IssueFacade { ); } + /** null: back to the backlog. */ + setSprint(sprintId: string | null): void { + this.withIssue((issueId) => + this.dispatch.sprintChangeRequested({ issueId, sprintId }), + ); + } + setRelease(releaseId: string | null): void { this.withIssue((issueId) => this.dispatch.releaseChangeRequested({ issueId, releaseId }), diff --git a/libs/web/tracker/application/src/lib/facades/project.facade.ts b/libs/web/tracker/application/src/lib/facades/project.facade.ts index 4a4c6c6..5203c21 100644 --- a/libs/web/tracker/application/src/lib/facades/project.facade.ts +++ b/libs/web/tracker/application/src/lib/facades/project.facade.ts @@ -2,8 +2,11 @@ import { computed, inject, Injectable, Provider } from '@angular/core'; import { injectDispatch } from '@ngrx/signals/events'; import { CreateReleaseInput, + CreateSprintInput, ProjectRole, ProjectSettings, + SprintStatus, + UpdateSprintInput, } from '@task-stream/web/tracker/domain'; import { projectPageEvents } from '../events/project.events'; import { ProjectStore } from '../stores/project.store'; @@ -21,6 +24,20 @@ export class ProjectFacade { readonly members = this.store.members; readonly stats = this.store.stats; readonly releases = this.store.releases; + /** Under way, planned, then closed; with their progress. */ + readonly sprints = this.store.sprints; + /** The sprint under way, if any. */ + readonly activeSprint = computed( + () => + this.store.sprints().find((s) => s.status === SprintStatus.ACTIVE) ?? + null, + ); + /** Sprints that still take tickets (under way or planned). */ + readonly openSprints = computed(() => + this.store.sprints().filter((s) => s.status !== SprintStatus.CLOSED), + ); + /** Burndown of the sprint asked for with `loadBurndown`. */ + readonly burndown = this.store.burndown; readonly activity = this.store.activity; /** Pireon directory results for `searchUsers()`. */ readonly userResults = this.store.userResults; @@ -80,6 +97,33 @@ export class ProjectFacade { } /** `force` publishes even with unfinished tickets. */ + createSprint(sprint: Omit): void { + const projectId = this.projectId(); + if (projectId) + this.dispatch.sprintCreateRequested({ projectId, ...sprint }); + } + + updateSprint(changes: UpdateSprintInput): void { + this.dispatch.sprintUpdateRequested(changes); + } + + startSprint(sprintId: string): void { + this.dispatch.sprintStartRequested({ sprintId }); + } + + /** Unfinished tickets go to `carryOverTo` (null: back to the backlog). */ + closeSprint(sprintId: string, carryOverTo: string | null): void { + this.dispatch.sprintCloseRequested({ sprintId, carryOverTo }); + } + + deleteSprint(sprintId: string): void { + this.dispatch.sprintDeleteRequested({ sprintId }); + } + + loadBurndown(sprintId: string): void { + this.dispatch.burndownRequested({ sprintId }); + } + publishRelease(releaseId: string, force = false): void { this.dispatch.releasePublishRequested({ releaseId, force }); } 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 954a32d..34d0d08 100644 --- a/libs/web/tracker/application/src/lib/stores/issue.store.ts +++ b/libs/web/tracker/application/src/lib/stores/issue.store.ts @@ -19,6 +19,7 @@ import { Issue, IssuesGateway, IssueSummary, + SprintsGateway, TrackerRealtimeGateway, } from '@task-stream/web/tracker/domain'; import { @@ -122,6 +123,7 @@ export const IssueStore = signalStore( realtime = inject(TrackerRealtimeGateway), notifications = inject(Notifications), attachments = inject(AttachmentsGateway), + sprints = inject(SprintsGateway), ) => { /** One file after another: the ticket's version moves with each. */ const uploadAll = ( @@ -410,6 +412,20 @@ export const IssueStore = signalStore( ), ), ), + events + .on(issuePageEvents.sprintChangeRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + sprints.setIssueSprint(payload.issueId, payload.sprintId), + payload.sprintId + ? 'Metido en el sprint' + : 'De vuelta al backlog', + done, + failed, + ), + ), + ), events .on(issuePageEvents.releaseChangeRequested) .pipe( diff --git a/libs/web/tracker/application/src/lib/stores/project.store.ts b/libs/web/tracker/application/src/lib/stores/project.store.ts index ebdca62..9363e93 100644 --- a/libs/web/tracker/application/src/lib/stores/project.store.ts +++ b/libs/web/tracker/application/src/lib/stores/project.store.ts @@ -19,6 +19,9 @@ import { Release, ReleaseBurnup, ReleasesGateway, + Sprint, + SprintBurndown, + SprintsGateway, TrackerRealtimeGateway, UserDirectoryEntry, UsersGateway, @@ -50,6 +53,10 @@ interface ProjectState { project: Project | null; stats: ProjectStats | null; releases: Release[]; + /** Under way, planned, then closed; with their progress. */ + sprints: Sprint[]; + /** Burndown of the sprint asked for with `burndownRequested`. */ + burndown: SprintBurndown | null; activity: Activity[]; userResults: UserDirectoryEntry[]; analytics: ProjectAnalytics | null; @@ -71,6 +78,8 @@ export const ProjectStore = signalStore( project: null, stats: null, releases: [], + sprints: [], + burndown: null, activity: [], userResults: [], analytics: null, @@ -109,6 +118,8 @@ export const ProjectStore = signalStore( project: null, stats: null, releases: [], + sprints: [], + burndown: null, activity: [], analytics: null, analyticsError: null, @@ -153,6 +164,10 @@ export const ProjectStore = signalStore( error: payload, })), on(projectApiEvents.statsLoaded, ({ payload }) => ({ stats: payload })), + on(projectApiEvents.sprintsLoaded, ({ payload }) => ({ sprints: payload })), + on(projectApiEvents.burndownLoaded, ({ payload }) => ({ + burndown: payload, + })), on(projectApiEvents.releasesLoaded, ({ payload }) => ({ releases: payload, })), @@ -175,6 +190,7 @@ export const ProjectStore = signalStore( events = inject(Events), projects = inject(ProjectsGateway), releases = inject(ReleasesGateway), + sprints = inject(SprintsGateway), activity = inject(ActivityGateway), realtime = inject(TrackerRealtimeGateway), notifications = inject(Notifications), @@ -191,6 +207,23 @@ export const ProjectStore = signalStore( }), ); + const reloadSprints = (projectId: string) => + sprints.list(projectId).pipe( + mapResponse({ + next: (list) => projectApiEvents.sprintsLoaded(list), + error: (error) => + projectApiEvents.commandFailed(describeError(error)), + }), + ); + const loadBurndown = (sprintId: string) => + sprints.burndown(sprintId).pipe( + mapResponse({ + next: (burndown) => projectApiEvents.burndownLoaded(burndown), + error: (error) => + projectApiEvents.commandFailed(describeError(error)), + }), + ); + return { load$: events.on(projectPageEvents.opened).pipe( switchMap(({ payload }) => @@ -216,6 +249,7 @@ export const ProjectStore = signalStore( .stats(payload.id) .pipe(map((stats) => projectApiEvents.statsLoaded(stats))), reloadReleases(payload.id), + reloadSprints(payload.id), activity .list(payload.id) .pipe(map((list) => projectApiEvents.activityLoaded(list))), @@ -240,6 +274,9 @@ export const ProjectStore = signalStore( realtime .releaseChanges(payload.id) .pipe(map((r) => projectRealtimeEvents.releaseChanged(r))), + realtime + .sprintChanges(payload.id) + .pipe(map((c) => projectRealtimeEvents.sprintChanged(c))), ), ), ), @@ -267,6 +304,24 @@ export const ProjectStore = signalStore( return id ? reloadReleases(id) : []; }), ), + /** Progress and burndown follow the tickets: refetch when they or a sprint change. */ + refreshSprints$: merge( + events.on(projectRealtimeEvents.sprintChanged), + events + .on(projectRealtimeEvents.activityAdded) + .pipe(debounceTime(800)), + ).pipe( + switchMap(() => { + const id = store.project()?.id; + const shown = store.burndown()?.sprintId; + return id + ? merge(reloadSprints(id), shown ? loadBurndown(shown) : []) + : []; + }), + ), + burndown$: events + .on(projectPageEvents.burndownRequested) + .pipe(switchMap(({ payload }) => loadBurndown(payload.sprintId))), analytics$: events.on(projectPageEvents.analyticsRequested).pipe( switchMap(({ payload }) => projects.analytics(payload.projectId, payload.days).pipe( @@ -376,6 +431,68 @@ export const ProjectStore = signalStore( ), ), ), + events + .on(projectPageEvents.sprintCreateRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + sprints.create(payload), + `Sprint ${payload.name} planificado`, + projectApiEvents.commandSucceeded, + projectApiEvents.commandFailed, + ), + ), + ), + events + .on(projectPageEvents.sprintUpdateRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + sprints.update(payload), + 'Sprint actualizado', + projectApiEvents.commandSucceeded, + projectApiEvents.commandFailed, + ), + ), + ), + events + .on(projectPageEvents.sprintStartRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + sprints.start(payload.sprintId), + 'Sprint en marcha 🏁', + projectApiEvents.commandSucceeded, + projectApiEvents.commandFailed, + ), + ), + ), + events + .on(projectPageEvents.sprintCloseRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + sprints.close(payload.sprintId, payload.carryOverTo), + payload.carryOverTo + ? 'Sprint cerrado: lo pendiente pasa al siguiente' + : 'Sprint cerrado: lo pendiente vuelve al backlog', + projectApiEvents.commandSucceeded, + projectApiEvents.commandFailed, + ), + ), + ), + events + .on(projectPageEvents.sprintDeleteRequested) + .pipe( + mergeMap(({ payload }) => + runCommand( + sprints.remove(payload.sprintId), + 'Sprint eliminado', + projectApiEvents.commandSucceeded, + projectApiEvents.commandFailed, + ), + ), + ), events .on(projectPageEvents.releasePublishRequested) .pipe( diff --git a/libs/web/tracker/domain/src/lib/models/enums.ts b/libs/web/tracker/domain/src/lib/models/enums.ts index c99fd23..7a35265 100644 --- a/libs/web/tracker/domain/src/lib/models/enums.ts +++ b/libs/web/tracker/domain/src/lib/models/enums.ts @@ -39,6 +39,13 @@ export enum ReleaseStatus { ARCHIVED = 'ARCHIVED', } +export enum SprintStatus { + PLANNED = 'PLANNED', + /** Under way: one per project at a time. */ + ACTIVE = 'ACTIVE', + CLOSED = 'CLOSED', +} + /** Why someone is told. */ export enum NotificationKind { ASSIGNED = 'ASSIGNED', diff --git a/libs/web/tracker/domain/src/lib/models/inputs.ts b/libs/web/tracker/domain/src/lib/models/inputs.ts index 6692274..0513f16 100644 --- a/libs/web/tracker/domain/src/lib/models/inputs.ts +++ b/libs/web/tracker/domain/src/lib/models/inputs.ts @@ -86,4 +86,22 @@ export interface BoardFilter { labels?: string[]; /** Matches key, title or description. */ search?: string; + sprintId?: string; +} + +export interface CreateSprintInput { + projectId: string; + name: string; + goal?: string | null; + /** `YYYY-MM-DD`. */ + startDate: string; + endDate: string; +} + +export interface UpdateSprintInput { + sprintId: string; + name?: string; + goal?: string | null; + startDate?: string; + endDate?: string; } diff --git a/libs/web/tracker/domain/src/lib/models/models.ts b/libs/web/tracker/domain/src/lib/models/models.ts index d9a48c2..bbe788f 100644 --- a/libs/web/tracker/domain/src/lib/models/models.ts +++ b/libs/web/tracker/domain/src/lib/models/models.ts @@ -4,6 +4,7 @@ import { IssuePriority, ProjectRole, ReleaseStatus, + SprintStatus, StatusCategory, } from './enums'; import { ProjectSettings } from './workflow'; @@ -107,6 +108,7 @@ export interface IssueSummary { priority: IssuePriority; assignee: UserRef | null; release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; labels: string[]; /** The epic or ticket this one breaks down. */ parent: IssueParent | null; @@ -159,6 +161,40 @@ export interface Comment { editedAt: string | null; } +export interface SprintProgress { + total: number; + /** Done or cancelled. */ + finished: number; + percent: number; + storyPoints: number; + finishedStoryPoints: number; +} + +/** A time-boxed iteration of a project. */ +export interface Sprint { + id: string; + projectId: string; + name: string; + goal: string | null; + /** `YYYY-MM-DD`, both included. */ + startDate: string; + endDate: string; + status: SprintStatus; + startedAt: string | null; + closedAt: string | null; + progress: SprintProgress; +} + +/** Per day of a sprint; null for the days to come. */ +export interface SprintBurndown { + sprintId: string; + dates: string[]; + scope: (number | null)[]; + remaining: (number | null)[]; + remainingPoints: (number | null)[]; + ideal: number[]; +} + export interface Release { id: string; projectId: string; diff --git a/libs/web/tracker/domain/src/lib/ports/gateways.ts b/libs/web/tracker/domain/src/lib/ports/gateways.ts index 88428dc..caf78f8 100644 --- a/libs/web/tracker/domain/src/lib/ports/gateways.ts +++ b/libs/web/tracker/domain/src/lib/ports/gateways.ts @@ -6,6 +6,8 @@ import { Comment, Epic, SavedFilter, + Sprint, + SprintBurndown, SavedFilterCriteria, CommandResult, Issue, @@ -32,6 +34,8 @@ import { MyIssuesFilter, UpdateIssueInput, UpdateProjectInput, + CreateSprintInput, + UpdateSprintInput, } from '../models/inputs'; /* @@ -173,6 +177,25 @@ export abstract class ReleasesGateway { abstract burnup(releaseId: string): Observable; } +export abstract class SprintsGateway { + /** With their progress: under way, planned, then closed. */ + abstract list(projectId: string): Observable; + abstract burndown(sprintId: string): Observable; + abstract create(input: CreateSprintInput): Observable; + abstract update(input: UpdateSprintInput): Observable; + abstract start(sprintId: string): Observable; + /** Unfinished tickets go to `carryOverTo` (null: the backlog). */ + abstract close( + sprintId: string, + carryOverTo: string | null, + ): Observable; + abstract remove(sprintId: string): Observable; + abstract setIssueSprint( + issueId: string, + sprintId: string | null, + ): Observable; +} + export abstract class ActivityGateway { abstract list(projectId: string, issueId?: string): Observable; } @@ -219,6 +242,8 @@ export abstract class TrackerRealtimeGateway { abstract commentChanges(issueId: string): Observable; abstract activity(projectId: string): Observable; abstract releaseChanges(projectId: string): Observable; + /** Something about a sprint of the project changed (or it was deleted). */ + abstract sprintChanges(projectId: string): Observable<{ sprintId: string }>; abstract projectChanges(projectId: string): Observable; abstract myProjectsChanges(): Observable; abstract notifications(): Observable; 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 5f4fdfd..bbc180d 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 @@ -59,6 +59,7 @@ export class GraphqlIssuesGateway extends IssuesGateway { labels: filter.labels?.length ? filter.labels : null, search: filter.search?.trim() || null, unassigned: filter.unassigned ?? null, + sprintId: filter.sprintId ?? null, }) .pipe( map((d) => diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts index f1fecbb..5cc9f18 100644 --- a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-realtime.gateway.ts @@ -18,6 +18,7 @@ import { NotificationAddedDocument, ProjectChangedDocument, ReleaseChangedDocument, + SprintChangedDocument, } from '../graphql/generated'; import { GraphqlOperations } from './graphql-operations'; import { @@ -85,6 +86,14 @@ export class GraphqlRealtimeGateway extends TrackerRealtimeGateway { ); } + sprintChanges(projectId: string): Observable<{ sprintId: string }> { + return this.signedIn(() => + this.gql + .subscribe(SprintChangedDocument, { projectId }) + .pipe(map((d) => ({ sprintId: d.sprintChanged.sprintId }))), + ); + } + projectChanges(projectId: string): Observable { return this.signedIn(() => this.gql diff --git a/libs/web/tracker/infrastructure/src/lib/adapters/graphql-sprints.gateway.ts b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-sprints.gateway.ts new file mode 100644 index 0000000..c524a6e --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/adapters/graphql-sprints.gateway.ts @@ -0,0 +1,84 @@ +import { inject, Injectable } from '@angular/core'; +import { + CommandResult, + CreateSprintInput, + Sprint, + SprintBurndown, + SprintsGateway, + UpdateSprintInput, +} from '@task-stream/web/tracker/domain'; +import { map, Observable } from 'rxjs'; +import { + CloseSprintDocument, + CreateSprintDocument, + DeleteSprintDocument, + SetIssueSprintDocument, + SprintBurndownDocument, + SprintsDocument, + StartSprintDocument, + UpdateSprintDocument, +} from '../graphql/generated'; +import { GraphqlOperations } from './graphql-operations'; +import { toCommandResult } from './mappers'; + +@Injectable() +export class GraphqlSprintsGateway extends SprintsGateway { + private readonly gql = inject(GraphqlOperations); + + list(projectId: string): Observable { + return this.gql + .query(SprintsDocument, { projectId }) + .pipe(map((d) => d.sprints as Sprint[])); + } + + burndown(sprintId: string): Observable { + return this.gql + .query(SprintBurndownDocument, { id: sprintId }) + .pipe(map((d) => d.sprint.burndown)); + } + + create(input: CreateSprintInput): Observable { + return this.gql + .mutate(CreateSprintDocument, { input }) + .pipe(map((d) => toCommandResult(d.createSprint))); + } + + update(input: UpdateSprintInput): Observable { + return this.gql + .mutate(UpdateSprintDocument, { input }) + .pipe(map((d) => toCommandResult(d.updateSprint))); + } + + start(sprintId: string): Observable { + return this.gql + .mutate(StartSprintDocument, { sprintId }) + .pipe(map((d) => toCommandResult(d.startSprint))); + } + + close( + sprintId: string, + carryOverTo: string | null, + ): Observable { + return this.gql + .mutate(CloseSprintDocument, { + sprintId, + carryOverToSprintId: carryOverTo, + }) + .pipe(map((d) => toCommandResult(d.closeSprint))); + } + + remove(sprintId: string): Observable { + return this.gql + .mutate(DeleteSprintDocument, { sprintId }) + .pipe(map((d) => toCommandResult(d.deleteSprint))); + } + + setIssueSprint( + issueId: string, + sprintId: string | null, + ): Observable { + return this.gql + .mutate(SetIssueSprintDocument, { issueId, sprintId }) + .pipe(map((d) => toCommandResult(d.setIssueSprint))); + } +} diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql index 37da01e..2572800 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/fragments.graphql @@ -69,6 +69,10 @@ fragment IssueSummaryFields on Issue { id name } + sprint { + id + name + } parent { id key diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts index 2906bc7..0537503 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts +++ b/libs/web/tracker/infrastructure/src/lib/graphql/generated.ts @@ -63,11 +63,23 @@ export type CreateReleaseInput = { targetDate?: string | null | undefined; }; +export type CreateSprintInput = { + /** YYYY-MM-DD, included */ + endDate: string; + goal?: string | null | undefined; + name: string; + projectId: string; + /** YYYY-MM-DD */ + startDate: string; +}; + export type IssueFilterInput = { areas?: Array | null | undefined; assigneeId?: string | null | undefined; categories?: Array | null | undefined; labels?: Array | null | undefined; + /** Only tickets in no sprint */ + noSprint?: boolean | null | undefined; /** Only the tickets hanging from this one */ parentId?: string | null | undefined; priorities?: Array | null | undefined; @@ -76,6 +88,7 @@ export type IssueFilterInput = { /** Matches key, title or description */ search?: string | null | undefined; sort?: IssueSort | null | undefined; + sprintId?: string | null | undefined; statuses?: Array | null | undefined; types?: Array | null | undefined; unassigned?: boolean | null | undefined; @@ -166,6 +179,9 @@ export type SavedFilterCriteriaInput = { unassigned?: boolean | null | undefined; }; +/** PLANNED, ACTIVE (one per project at a time) or CLOSED */ +export type SprintStatus = 'ACTIVE' | 'CLOSED' | 'PLANNED'; + /** What a status means, whatever each project calls it. DONE and CANCELLED are finished. */ export type StatusCategory = | 'CANCELLED' @@ -208,6 +224,14 @@ export type UpdateProjectInput = { projectId: string; }; +export type UpdateSprintInput = { + endDate?: string | null | undefined; + goal?: string | null | undefined; + name?: string | null | undefined; + sprintId: string; + startDate?: string | null | undefined; +}; + export type WorkflowStatusInput = { archived?: boolean; category: StatusCategory; @@ -267,6 +291,7 @@ export type IssueSummaryFieldsFragment = { rank: string; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; }; @@ -311,6 +336,7 @@ export type IssueFieldsFragment = { }>; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; }; @@ -359,6 +385,7 @@ export type BoardQueryVariables = Exact<{ labels?: Array | string | null | undefined; search?: string | null | undefined; unassigned?: boolean | null | undefined; + sprintId?: string | null | undefined; }>; export type BoardQuery = { @@ -384,6 +411,7 @@ export type BoardQuery = { rank: string; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; }>; }>; @@ -436,6 +464,7 @@ export type IssueQuery = { }>; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; }; }; @@ -463,6 +492,7 @@ export type SearchIssuesQuery = { rank: string; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; }>; }; @@ -614,6 +644,7 @@ export type MyIssuesQuery = { rank: string; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; }>; }; @@ -642,6 +673,7 @@ export type IssueChildrenQuery = { rank: string; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; }>; }; @@ -670,6 +702,7 @@ export type EpicsQuery = { rank: string; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; }; progress: { @@ -740,6 +773,7 @@ export type BacklogQuery = { rank: string; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; }>; }; @@ -1218,6 +1252,126 @@ export type DeleteSavedFilterMutationVariables = Exact<{ export type DeleteSavedFilterMutation = { deleteSavedFilter: boolean }; +export type SprintFieldsFragment = { + id: string; + projectId: string; + name: string; + goal: string | null; + startDate: string; + endDate: string; + status: SprintStatus; + startedAt: string | null; + closedAt: string | null; + progress: { + total: number; + finished: number; + percent: number; + storyPoints: number; + finishedStoryPoints: number; + }; +}; + +export type SprintsQueryVariables = Exact<{ + projectId: string; +}>; + +export type SprintsQuery = { + sprints: Array<{ + id: string; + projectId: string; + name: string; + goal: string | null; + startDate: string; + endDate: string; + status: SprintStatus; + startedAt: string | null; + closedAt: string | null; + progress: { + total: number; + finished: number; + percent: number; + storyPoints: number; + finishedStoryPoints: number; + }; + }>; +}; + +export type SprintBurndownQueryVariables = Exact<{ + id: string; +}>; + +export type SprintBurndownQuery = { + sprint: { + id: string; + burndown: { + sprintId: string; + dates: Array; + scope: Array; + remaining: Array; + remainingPoints: Array; + ideal: Array; + }; + }; +}; + +export type CreateSprintMutationVariables = Exact<{ + input: CreateSprintInput; +}>; + +export type CreateSprintMutation = { + createSprint: { id: string; key: string | null; version: number }; +}; + +export type UpdateSprintMutationVariables = Exact<{ + input: UpdateSprintInput; +}>; + +export type UpdateSprintMutation = { + updateSprint: { id: string; key: string | null; version: number }; +}; + +export type StartSprintMutationVariables = Exact<{ + sprintId: string; +}>; + +export type StartSprintMutation = { + startSprint: { id: string; key: string | null; version: number }; +}; + +export type CloseSprintMutationVariables = Exact<{ + sprintId: string; + carryOverToSprintId?: string | null | undefined; +}>; + +export type CloseSprintMutation = { + closeSprint: { id: string; key: string | null; version: number }; +}; + +export type DeleteSprintMutationVariables = Exact<{ + sprintId: string; +}>; + +export type DeleteSprintMutation = { + deleteSprint: { id: string; key: string | null; version: number }; +}; + +export type SetIssueSprintMutationVariables = Exact<{ + issueId: string; + sprintId?: string | null | undefined; +}>; + +export type SetIssueSprintMutation = { + setIssueSprint: { id: string; key: string | null; version: number }; +}; + +export type SprintChangedSubscriptionVariables = Exact<{ + projectId: string; +}>; + +export type SprintChangedSubscription = { + sprintChanged: { action: string; sprintId: string }; +}; + export type IssueChangedSubscriptionVariables = Exact<{ projectId: string; }>; @@ -1244,6 +1398,7 @@ export type IssueChangedSubscription = { rank: string; assignee: { id: string; name: string } | null; release: { id: string; name: string } | null; + sprint: { id: string; name: string } | null; parent: { id: string; key: string; title: string } | null; }; }; @@ -1604,6 +1759,17 @@ export const IssueSummaryFieldsFragmentDoc = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + 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' }, @@ -1807,6 +1973,17 @@ export const IssueFieldsFragmentDoc = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + 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' }, @@ -2097,6 +2274,50 @@ export const SavedFilterFieldsFragmentDoc = { }, ], } as unknown as DocumentNode; +export const SprintFieldsFragmentDoc = { + kind: 'Document', + definitions: [ + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'SprintFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Sprint' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'goal' } }, + { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'endDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'startedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'closedAt' } }, + { + 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' }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; export const BoardDocument = { kind: 'Document', definitions: [ @@ -2207,6 +2428,14 @@ export const BoardDocument = { }, type: { kind: 'NamedType', name: { kind: 'Name', value: 'Boolean' } }, }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'sprintId' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, ], selectionSet: { kind: 'SelectionSet', @@ -2296,6 +2525,14 @@ export const BoardDocument = { name: { kind: 'Name', value: 'unassigned' }, }, }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'sprintId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'sprintId' }, + }, + }, { kind: 'Argument', name: { kind: 'Name', value: 'perColumn' }, @@ -2397,6 +2634,17 @@ export const BoardDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + 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' }, @@ -2529,6 +2777,17 @@ export const IssueDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + 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' }, @@ -2780,6 +3039,17 @@ export const SearchIssuesDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + 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' }, @@ -3996,6 +4266,17 @@ export const MyIssuesDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + 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' }, @@ -4142,6 +4423,17 @@ export const IssueChildrenDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + 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' }, @@ -4306,6 +4598,17 @@ export const EpicsDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + 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' }, @@ -4774,6 +5077,17 @@ export const BacklogDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + 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' }, @@ -7624,6 +7938,669 @@ export const DeleteSavedFilterDocument = { DeleteSavedFilterMutation, DeleteSavedFilterMutationVariables >; +export const SprintsDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'Sprints' }, + 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: 'sprints' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'projectId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'projectId' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'FragmentSpread', + name: { kind: 'Name', value: 'SprintFields' }, + }, + ], + }, + }, + ], + }, + }, + { + kind: 'FragmentDefinition', + name: { kind: 'Name', value: 'SprintFields' }, + typeCondition: { + kind: 'NamedType', + name: { kind: 'Name', value: 'Sprint' }, + }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { kind: 'Field', name: { kind: 'Name', value: 'projectId' } }, + { kind: 'Field', name: { kind: 'Name', value: 'name' } }, + { kind: 'Field', name: { kind: 'Name', value: 'goal' } }, + { kind: 'Field', name: { kind: 'Name', value: 'startDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'endDate' } }, + { kind: 'Field', name: { kind: 'Name', value: 'status' } }, + { kind: 'Field', name: { kind: 'Name', value: 'startedAt' } }, + { kind: 'Field', name: { kind: 'Name', value: 'closedAt' } }, + { + 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' }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const SprintBurndownDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'query', + name: { kind: 'Name', value: 'SprintBurndown' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { kind: 'Variable', name: { kind: 'Name', value: 'id' } }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'id' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'id' }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { kind: 'Field', name: { kind: 'Name', value: 'id' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'burndown' }, + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'sprintId' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'dates' } }, + { kind: 'Field', name: { kind: 'Name', value: 'scope' } }, + { + kind: 'Field', + name: { kind: 'Name', value: 'remaining' }, + }, + { + kind: 'Field', + name: { kind: 'Name', value: 'remainingPoints' }, + }, + { kind: 'Field', name: { kind: 'Name', value: 'ideal' } }, + ], + }, + }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode; +export const CreateSprintDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'CreateSprint' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'CreateSprintInput' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'createSprint' }, + 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< + CreateSprintMutation, + CreateSprintMutationVariables +>; +export const UpdateSprintDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'UpdateSprint' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'input' }, + }, + type: { + kind: 'NonNullType', + type: { + kind: 'NamedType', + name: { kind: 'Name', value: 'UpdateSprintInput' }, + }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'updateSprint' }, + 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< + UpdateSprintMutation, + UpdateSprintMutationVariables +>; +export const StartSprintDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'StartSprint' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'sprintId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'startSprint' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'sprintId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'sprintId' }, + }, + }, + ], + 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; +export const CloseSprintDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'CloseSprint' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'sprintId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'carryOverToSprintId' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'closeSprint' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'sprintId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'sprintId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'carryOverToSprintId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'carryOverToSprintId' }, + }, + }, + ], + 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; +export const DeleteSprintDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'DeleteSprint' }, + variableDefinitions: [ + { + kind: 'VariableDefinition', + variable: { + kind: 'Variable', + name: { kind: 'Name', value: 'sprintId' }, + }, + type: { + kind: 'NonNullType', + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'deleteSprint' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'sprintId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'sprintId' }, + }, + }, + ], + 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< + DeleteSprintMutation, + DeleteSprintMutationVariables +>; +export const SetIssueSprintDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'mutation', + name: { kind: 'Name', value: 'SetIssueSprint' }, + 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: 'sprintId' }, + }, + type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } }, + }, + ], + selectionSet: { + kind: 'SelectionSet', + selections: [ + { + kind: 'Field', + name: { kind: 'Name', value: 'setIssueSprint' }, + arguments: [ + { + kind: 'Argument', + name: { kind: 'Name', value: 'issueId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'issueId' }, + }, + }, + { + kind: 'Argument', + name: { kind: 'Name', value: 'sprintId' }, + value: { + kind: 'Variable', + name: { kind: 'Name', value: 'sprintId' }, + }, + }, + ], + 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< + SetIssueSprintMutation, + SetIssueSprintMutationVariables +>; +export const SprintChangedDocument = { + kind: 'Document', + definitions: [ + { + kind: 'OperationDefinition', + operation: 'subscription', + name: { kind: 'Name', value: 'SprintChanged' }, + 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: 'sprintChanged' }, + 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: 'action' } }, + { kind: 'Field', name: { kind: 'Name', value: 'sprintId' } }, + ], + }, + }, + ], + }, + }, + ], +} as unknown as DocumentNode< + SprintChangedSubscription, + SprintChangedSubscriptionVariables +>; export const IssueChangedDocument = { kind: 'Document', definitions: [ @@ -7747,6 +8724,17 @@ export const IssueChangedDocument = { ], }, }, + { + kind: 'Field', + name: { kind: 'Name', value: 'sprint' }, + 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' }, diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql index bbe2a39..46c0cba 100644 --- a/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql +++ b/libs/web/tracker/infrastructure/src/lib/graphql/issues.graphql @@ -8,6 +8,7 @@ query Board( $labels: [String!] $search: String $unassigned: Boolean + $sprintId: ID ) { project(idOrKey: $idOrKey) { id @@ -20,6 +21,7 @@ query Board( labels: $labels search: $search unassigned: $unassigned + sprintId: $sprintId perColumn: 100 ) { status diff --git a/libs/web/tracker/infrastructure/src/lib/graphql/sprints.graphql b/libs/web/tracker/infrastructure/src/lib/graphql/sprints.graphql new file mode 100644 index 0000000..8cab134 --- /dev/null +++ b/libs/web/tracker/infrastructure/src/lib/graphql/sprints.graphql @@ -0,0 +1,81 @@ +fragment SprintFields on Sprint { + id + projectId + name + goal + startDate + endDate + status + startedAt + closedAt + progress { + total + finished + percent + storyPoints + finishedStoryPoints + } +} + +query Sprints($projectId: ID!) { + sprints(projectId: $projectId) { + ...SprintFields + } +} + +query SprintBurndown($id: ID!) { + sprint(id: $id) { + id + burndown { + sprintId + dates + scope + remaining + remainingPoints + ideal + } + } +} + +mutation CreateSprint($input: CreateSprintInput!) { + createSprint(input: $input) { + ...CommandResultFields + } +} + +mutation UpdateSprint($input: UpdateSprintInput!) { + updateSprint(input: $input) { + ...CommandResultFields + } +} + +mutation StartSprint($sprintId: ID!) { + startSprint(sprintId: $sprintId) { + ...CommandResultFields + } +} + +mutation CloseSprint($sprintId: ID!, $carryOverToSprintId: ID) { + closeSprint(sprintId: $sprintId, carryOverToSprintId: $carryOverToSprintId) { + ...CommandResultFields + } +} + +mutation DeleteSprint($sprintId: ID!) { + deleteSprint(sprintId: $sprintId) { + ...CommandResultFields + } +} + +mutation SetIssueSprint($issueId: ID!, $sprintId: ID) { + setIssueSprint(issueId: $issueId, sprintId: $sprintId) { + ...CommandResultFields + } +} + +subscription SprintChanged($projectId: ID!) { + sprintChanged(projectId: $projectId) { + action + sprintId + } +} diff --git a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts index 53c7e99..540bbb1 100644 --- a/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts +++ b/libs/web/tracker/infrastructure/src/lib/tracker-infrastructure.providers.ts @@ -11,6 +11,7 @@ import { ProjectsGateway, ReleasesGateway, SavedFiltersGateway, + SprintsGateway, TrackerRealtimeGateway, UsersGateway, } from '@task-stream/web/tracker/domain'; @@ -26,6 +27,7 @@ import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway'; import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway'; import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway'; import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway'; +import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway'; /** Ports → GraphQL adapters (and REST for files). */ export function provideTrackerInfrastructure(): EnvironmentProviders { @@ -40,5 +42,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders { { provide: NotificationsGateway, useClass: GraphqlNotificationsGateway }, { provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway }, { provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway }, + { provide: SprintsGateway, useClass: GraphqlSprintsGateway }, ]); } diff --git a/libs/web/tracker/presenters/src/lib/pages/board.page.ts b/libs/web/tracker/presenters/src/lib/pages/board.page.ts index acac6af..8b30497 100644 --- a/libs/web/tracker/presenters/src/lib/pages/board.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/board.page.ts @@ -137,6 +137,12 @@ const NOBODY = '__nobody__'; [options]="priorityFilterOptions" [(value)]="priorityFilter" /> + (); /** `?assignee=` (from the tracking page's team list), bound by the router. */ readonly assignee = input(); + /** `?sprint=` (from the sprints page), bound by the router. */ + readonly sprint = input(); protected readonly board = inject(BoardFacade); protected readonly project = inject(ProjectFacade); @@ -353,6 +361,16 @@ export class BoardPage { protected readonly releaseFilter = signal(ALL); protected readonly area = signal(ALL); protected readonly typeFilter = signal(ALL); + protected readonly sprintFilter = signal(ALL); + protected readonly sprintFilterOptions = computed< + PiSelectFieldOption[] + >(() => [ + { value: ALL, label: 'Todos los sprints' }, + ...this.project.openSprints().map((s) => ({ + value: s.id, + label: s.status === 'ACTIVE' ? `${s.name} · en curso` : s.name, + })), + ]); protected readonly priorityFilter = signal(ALL); /** What is typed; `search` follows it once typing pauses. */ protected readonly searchInput = signal(''); @@ -364,6 +382,7 @@ export class BoardPage { !!( this.assigneeFilter() || this.releaseFilter() || + this.sprintFilter() || this.area() || this.typeFilter() || this.priorityFilter() || @@ -485,6 +504,10 @@ export class BoardPage { const assignee = this.assignee(); if (assignee) untracked(() => this.assigneeFilter.set(assignee)); }); + effect(() => { + const sprint = this.sprint(); + if (sprint) untracked(() => this.sprintFilter.set(sprint)); + }); effect((onCleanup) => { const text = this.searchInput().trim(); const timer = setTimeout(() => this.search.set(text), 300); @@ -505,6 +528,7 @@ export class BoardPage { priorities: priority ? [priority] : undefined, labels: this.labels().length ? this.labels() : undefined, search: this.search() || undefined, + sprintId: this.sprintFilter() || undefined, }; if (!projectId) return; untracked(() => @@ -569,6 +593,7 @@ export class BoardPage { filter.unassigned ? NOBODY : (filter.assigneeId ?? ALL), ); this.releaseFilter.set(filter.releaseId ?? ALL); + this.sprintFilter.set(ALL); this.area.set(filter.area ?? ALL); this.typeFilter.set(filter.types?.[0] ?? ALL); this.priorityFilter.set(filter.priorities?.[0] ?? ALL); @@ -581,6 +606,7 @@ export class BoardPage { for (const filter of [ this.assigneeFilter, this.releaseFilter, + this.sprintFilter, this.area, this.typeFilter, this.priorityFilter, 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 95a93f2..f2658be 100644 --- a/libs/web/tracker/presenters/src/lib/pages/issue.page.ts +++ b/libs/web/tracker/presenters/src/lib/pages/issue.page.ts @@ -594,6 +594,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024; > Release + + Sprint + ({ value: r.id, label: r.name })), ], ); + /** Open sprints, plus the ticket's own even if it closed since. */ + protected readonly sprintOptions = computed[]>( + () => { + const current = this.facade.issue()?.sprint; + const open = this.project.openSprints(); + return [ + { value: NONE, label: 'Backlog (sin sprint)' }, + ...open.map((s) => ({ + value: s.id, + label: s.status === 'ACTIVE' ? `${s.name} · en curso` : s.name, + })), + ...(current && !open.some((s) => s.id === current.id) + ? [{ value: current.id, label: `${current.name} · cerrado` }] + : []), + ]; + }, + ); + /** A closed sprint keeps its tickets. */ + protected readonly sprintLocked = computed(() => { + const current = this.facade.issue()?.sprint; + return ( + !!current && !this.project.openSprints().some((s) => s.id === current.id) + ); + }); protected readonly priorityOptions = options(PRIORITY); /** Active values, plus the ticket's own even if archived since. */ protected readonly typeOptions = computed[]>( 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 58258ef..0ae5382 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 @@ -86,6 +86,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';