feat: epics, subtasks and checklists
Tickets can hang from another one of the same project, three levels deep at most (epic → ticket → subtask). The ticket page lists its subtasks with their progress, creates new ones and moves the ticket under an epic; a new Épicas tab shows every epic and how far it has got. Each ticket also gets a checklist for the small steps that don't deserve a ticket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2178,6 +2178,16 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "parent",
|
||||||
|
"required": false,
|
||||||
|
"in": "query",
|
||||||
|
"description": "Only the tickets hanging from this one (id or key)",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB-3",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "search",
|
"name": "search",
|
||||||
"required": false,
|
"required": false,
|
||||||
@@ -2365,6 +2375,92 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/projects/{project}/epics": {
|
||||||
|
"get": {
|
||||||
|
"description": "With the progress of the tickets hanging from each one (done or cancelled, and story points).",
|
||||||
|
"operationId": "IssuesController_epics",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "project",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Project id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/EpicDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or rejected Pireon token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not a member of the project (or not allowed)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
},
|
||||||
|
{}
|
||||||
|
],
|
||||||
|
"summary": "Epics: top-level tickets that group others",
|
||||||
|
"tags": [
|
||||||
|
"Issues"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/issues/{issue}": {
|
"/api/issues/{issue}": {
|
||||||
"get": {
|
"get": {
|
||||||
"operationId": "IssuesController_findOne",
|
"operationId": "IssuesController_findOne",
|
||||||
@@ -2890,6 +2986,457 @@
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/issues/{issue}/parent": {
|
||||||
|
"put": {
|
||||||
|
"description": "Same project, three levels at most (epic → ticket → subtask), no loops. null takes it back to the top.",
|
||||||
|
"operationId": "IssuesController_setParent",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "issue",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Ticket id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB-12",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/SetIssueParentDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CommandResultDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or rejected Pireon token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not a member of the project (or not allowed)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Someone changed it at the same time: retry",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Place the ticket under an epic or another ticket",
|
||||||
|
"tags": [
|
||||||
|
"Issues"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/issues/{issue}/checklist": {
|
||||||
|
"post": {
|
||||||
|
"operationId": "IssuesController_addChecklistItem",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "issue",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Ticket id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB-12",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ChecklistItemBodyDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"201": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CommandResultDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or rejected Pireon token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not a member of the project (or not allowed)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Someone changed it at the same time: retry",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Add a step to the ticket's checklist",
|
||||||
|
"tags": [
|
||||||
|
"Issues"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/issues/{issue}/checklist/{item}": {
|
||||||
|
"patch": {
|
||||||
|
"operationId": "IssuesController_updateChecklistItem",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "issue",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Ticket id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB-12",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "item",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/UpdateChecklistItemDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CommandResultDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or rejected Pireon token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not a member of the project (or not allowed)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Someone changed it at the same time: retry",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Reword, tick or untick a checklist step",
|
||||||
|
"tags": [
|
||||||
|
"Issues"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"operationId": "IssuesController_removeChecklistItem",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"name": "issue",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"description": "Ticket id or key",
|
||||||
|
"schema": {
|
||||||
|
"example": "WEB-12",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "item",
|
||||||
|
"required": true,
|
||||||
|
"in": "path",
|
||||||
|
"schema": {
|
||||||
|
"format": "uuid",
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/CommandResultDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid input",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Missing or rejected Pireon token",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Not a member of the project (or not allowed)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not found",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"409": {
|
||||||
|
"description": "Someone changed it at the same time: retry",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"422": {
|
||||||
|
"description": "Business rule (e.g. a transition the workflow forbids)",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/components/schemas/ApiErrorDto"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"pireon-oauth": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"pireon": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"summary": "Remove a checklist step",
|
||||||
|
"tags": [
|
||||||
|
"Issues"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/issues/{issue}/links": {
|
"/api/issues/{issue}/links": {
|
||||||
"post": {
|
"post": {
|
||||||
"description": "e.g. WEB-3 DEPENDS_ON API-7, WEB-9 TESTS WEB-3. May cross projects.",
|
"description": "e.g. WEB-3 DEPENDS_ON API-7, WEB-9 TESTS WEB-3. May cross projects.",
|
||||||
@@ -6213,6 +6760,49 @@
|
|||||||
"commentId"
|
"commentId"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"IssueParentDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"key": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "WEB-3"
|
||||||
|
},
|
||||||
|
"title": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Pago con tarjeta"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"key",
|
||||||
|
"title"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ChecklistItemDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Actualizar el changelog"
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"text",
|
||||||
|
"done"
|
||||||
|
]
|
||||||
|
},
|
||||||
"IssueDto": {
|
"IssueDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -6340,6 +6930,22 @@
|
|||||||
"$ref": "#/components/schemas/UserRefDto"
|
"$ref": "#/components/schemas/UserRefDto"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"parent": {
|
||||||
|
"nullable": true,
|
||||||
|
"description": "The epic or ticket this one breaks down",
|
||||||
|
"type": "object",
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/components/schemas/IssueParentDto"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"checklist": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/components/schemas/ChecklistItemDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
"commentCount": {
|
"commentCount": {
|
||||||
"type": "number"
|
"type": "number"
|
||||||
},
|
},
|
||||||
@@ -6379,6 +6985,8 @@
|
|||||||
"links",
|
"links",
|
||||||
"attachments",
|
"attachments",
|
||||||
"watchers",
|
"watchers",
|
||||||
|
"parent",
|
||||||
|
"checklist",
|
||||||
"commentCount",
|
"commentCount",
|
||||||
"createdAt",
|
"createdAt",
|
||||||
"updatedAt",
|
"updatedAt",
|
||||||
@@ -6876,6 +7484,12 @@
|
|||||||
"type": "string",
|
"type": "string",
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"format": "uuid"
|
"format": "uuid"
|
||||||
|
},
|
||||||
|
"parent": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"example": "WEB-3",
|
||||||
|
"description": "Epic or ticket of the same project the new one breaks down (id or key)"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"required": [
|
"required": [
|
||||||
@@ -6883,6 +7497,51 @@
|
|||||||
"title"
|
"title"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"ChildProgressDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"total": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"finished": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Done or cancelled"
|
||||||
|
},
|
||||||
|
"percent": {
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 0,
|
||||||
|
"maximum": 100
|
||||||
|
},
|
||||||
|
"storyPoints": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"finishedStoryPoints": {
|
||||||
|
"type": "number"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"total",
|
||||||
|
"finished",
|
||||||
|
"percent",
|
||||||
|
"storyPoints",
|
||||||
|
"finishedStoryPoints"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"EpicDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"issue": {
|
||||||
|
"$ref": "#/components/schemas/IssueDto"
|
||||||
|
},
|
||||||
|
"progress": {
|
||||||
|
"$ref": "#/components/schemas/ChildProgressDto"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"issue",
|
||||||
|
"progress"
|
||||||
|
]
|
||||||
|
},
|
||||||
"UpdateIssueDto": {
|
"UpdateIssueDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -6983,6 +7642,45 @@
|
|||||||
"releaseId"
|
"releaseId"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"SetIssueParentDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"parent": {
|
||||||
|
"type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"example": "WEB-3",
|
||||||
|
"description": "Epic or ticket of the same project (id or key), or null to take it back to the top"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"parent"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ChecklistItemBodyDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"example": "Actualizar el changelog",
|
||||||
|
"maxLength": 300
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"UpdateChecklistItemDto": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"text": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 300
|
||||||
|
},
|
||||||
|
"done": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"LinkIssueDto": {
|
"LinkIssueDto": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -13,6 +13,11 @@ type Activity {
|
|||||||
type: String!
|
type: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input AddChecklistItemInput {
|
||||||
|
issueId: ID!
|
||||||
|
text: String!
|
||||||
|
}
|
||||||
|
|
||||||
type AreaOption {
|
type AreaOption {
|
||||||
archived: Boolean!
|
archived: Boolean!
|
||||||
id: String!
|
id: String!
|
||||||
@@ -57,6 +62,23 @@ input ChangeIssueStatusInput {
|
|||||||
status: String!
|
status: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""A step inside a ticket that does not deserve a ticket of its own"""
|
||||||
|
type ChecklistItem {
|
||||||
|
done: Boolean!
|
||||||
|
id: ID!
|
||||||
|
text: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
"""How far the tickets hanging from one have got"""
|
||||||
|
type ChildProgress {
|
||||||
|
"""Done or cancelled"""
|
||||||
|
finished: Int!
|
||||||
|
finishedStoryPoints: Int!
|
||||||
|
percent: Int!
|
||||||
|
storyPoints: Int!
|
||||||
|
total: Int!
|
||||||
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Writes are acknowledged immediately; the read model catches up asynchronously. Wait for a subscription event with version >= this version to see the change.
|
Writes are acknowledged immediately; the read model catches up asynchronously. Wait for a subscription event with version >= this version to see the change.
|
||||||
"""
|
"""
|
||||||
@@ -121,6 +143,9 @@ input CreateIssueInput {
|
|||||||
"""Figma frame for UI changes"""
|
"""Figma frame for UI changes"""
|
||||||
figmaUrl: String
|
figmaUrl: String
|
||||||
labels: [String!]
|
labels: [String!]
|
||||||
|
|
||||||
|
"""Epic or ticket of the same project the new one breaks down"""
|
||||||
|
parentId: ID
|
||||||
priority: IssuePriority
|
priority: IssuePriority
|
||||||
projectId: ID!
|
projectId: ID!
|
||||||
releaseId: ID
|
releaseId: ID
|
||||||
@@ -172,6 +197,12 @@ type DurationStats {
|
|||||||
p85Days: Float
|
p85Days: Float
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""A top-level ticket that groups others, with their progress"""
|
||||||
|
type Epic {
|
||||||
|
issue: Issue!
|
||||||
|
progress: ChildProgress!
|
||||||
|
}
|
||||||
|
|
||||||
type FlowSeries {
|
type FlowSeries {
|
||||||
counts: [Int!]!
|
counts: [Int!]!
|
||||||
status: String!
|
status: String!
|
||||||
@@ -185,6 +216,7 @@ type Issue {
|
|||||||
area: String
|
area: String
|
||||||
assignee: UserRef
|
assignee: UserRef
|
||||||
attachments: [Attachment!]!
|
attachments: [Attachment!]!
|
||||||
|
checklist: [ChecklistItem!]!
|
||||||
commentCount: Int!
|
commentCount: Int!
|
||||||
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
|
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
|
||||||
createdAt: String!
|
createdAt: String!
|
||||||
@@ -204,6 +236,9 @@ type Issue {
|
|||||||
linkedIssues: [Issue!]!
|
linkedIssues: [Issue!]!
|
||||||
links: [IssueLink!]!
|
links: [IssueLink!]!
|
||||||
number: Int!
|
number: Int!
|
||||||
|
|
||||||
|
"""The epic or ticket this one breaks down"""
|
||||||
|
parent: IssueParent
|
||||||
priority: IssuePriority!
|
priority: IssuePriority!
|
||||||
projectId: ID!
|
projectId: ID!
|
||||||
projectKey: String!
|
projectKey: String!
|
||||||
@@ -237,6 +272,9 @@ input IssueFilterInput {
|
|||||||
assigneeId: ID
|
assigneeId: ID
|
||||||
categories: [StatusCategory!]
|
categories: [StatusCategory!]
|
||||||
labels: [String!]
|
labels: [String!]
|
||||||
|
|
||||||
|
"""Only the tickets hanging from this one"""
|
||||||
|
parentId: ID
|
||||||
priorities: [IssuePriority!]
|
priorities: [IssuePriority!]
|
||||||
projectId: ID!
|
projectId: ID!
|
||||||
releaseId: ID
|
releaseId: ID
|
||||||
@@ -269,6 +307,13 @@ type IssuePage {
|
|||||||
total: Int!
|
total: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""The ticket another one hangs from"""
|
||||||
|
type IssueParent {
|
||||||
|
id: ID!
|
||||||
|
key: String!
|
||||||
|
title: String!
|
||||||
|
}
|
||||||
|
|
||||||
enum IssuePriority {
|
enum IssuePriority {
|
||||||
CRITICAL
|
CRITICAL
|
||||||
HIGH
|
HIGH
|
||||||
@@ -317,6 +362,8 @@ type Me {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
|
"""Add a step to a ticket's checklist"""
|
||||||
|
addChecklistItem(input: AddChecklistItemInput!): CommandResult!
|
||||||
addComment(body: String!, issueId: ID!): CommandResult!
|
addComment(body: String!, issueId: ID!): CommandResult!
|
||||||
|
|
||||||
"""Add a Pireon user to the project"""
|
"""Add a Pireon user to the project"""
|
||||||
@@ -357,14 +404,23 @@ type Mutation {
|
|||||||
Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.
|
Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.
|
||||||
"""
|
"""
|
||||||
removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult!
|
removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult!
|
||||||
|
removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult!
|
||||||
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask.
|
||||||
|
"""
|
||||||
|
setIssueParent(issueId: ID!, parentId: ID): CommandResult!
|
||||||
|
|
||||||
"""Put a ticket in a release (null removes it)"""
|
"""Put a ticket in a release (null removes it)"""
|
||||||
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
|
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
|
||||||
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
||||||
|
|
||||||
"""Stop following a ticket"""
|
"""Stop following a ticket"""
|
||||||
unwatchIssue(issueId: ID!): CommandResult!
|
unwatchIssue(issueId: ID!): CommandResult!
|
||||||
|
|
||||||
|
"""Reword, tick or untick a checklist step"""
|
||||||
|
updateChecklistItem(input: UpdateChecklistItemInput!): CommandResult!
|
||||||
updateIssue(input: UpdateIssueInput!): CommandResult!
|
updateIssue(input: UpdateIssueInput!): CommandResult!
|
||||||
updateNotificationSettings(email: Boolean!): NotificationSettings!
|
updateNotificationSettings(email: Boolean!): NotificationSettings!
|
||||||
updateProject(input: UpdateProjectInput!): CommandResult!
|
updateProject(input: UpdateProjectInput!): CommandResult!
|
||||||
@@ -541,6 +597,11 @@ type Query {
|
|||||||
limit: Int! = 50
|
limit: Int! = 50
|
||||||
projectId: ID!
|
projectId: ID!
|
||||||
): [Activity!]!
|
): [Activity!]!
|
||||||
|
|
||||||
|
"""
|
||||||
|
A project's epics: top-level tickets that group others, with the progress of those
|
||||||
|
"""
|
||||||
|
epics(projectId: ID!): [Epic!]!
|
||||||
issue(
|
issue(
|
||||||
"""Issue id or key (e.g. "TS-42")"""
|
"""Issue id or key (e.g. "TS-42")"""
|
||||||
idOrKey: String!
|
idOrKey: String!
|
||||||
@@ -677,6 +738,13 @@ type Throughput {
|
|||||||
week: String!
|
week: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input UpdateChecklistItemInput {
|
||||||
|
done: Boolean
|
||||||
|
issueId: ID!
|
||||||
|
itemId: ID!
|
||||||
|
text: String
|
||||||
|
}
|
||||||
|
|
||||||
input UpdateIssueInput {
|
input UpdateIssueInput {
|
||||||
"""An area id"""
|
"""An area id"""
|
||||||
area: String
|
area: String
|
||||||
|
|||||||
@@ -363,6 +363,26 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/projects/{project}/epics": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* Epics: top-level tickets that group others
|
||||||
|
* @description With the progress of the tickets hanging from each one (done or cancelled, and story points).
|
||||||
|
*/
|
||||||
|
get: operations["IssuesController_epics"];
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/issues/{issue}": {
|
"/api/issues/{issue}": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -435,6 +455,61 @@ export interface paths {
|
|||||||
patch?: never;
|
patch?: never;
|
||||||
trace?: never;
|
trace?: never;
|
||||||
};
|
};
|
||||||
|
"/api/issues/{issue}/parent": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
/**
|
||||||
|
* Place the ticket under an epic or another ticket
|
||||||
|
* @description Same project, three levels at most (epic → ticket → subtask), no loops. null takes it back to the top.
|
||||||
|
*/
|
||||||
|
put: operations["IssuesController_setParent"];
|
||||||
|
post?: never;
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/issues/{issue}/checklist": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
/** Add a step to the ticket's checklist */
|
||||||
|
post: operations["IssuesController_addChecklistItem"];
|
||||||
|
delete?: never;
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
patch?: never;
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
|
"/api/issues/{issue}/checklist/{item}": {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path?: never;
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
get?: never;
|
||||||
|
put?: never;
|
||||||
|
post?: never;
|
||||||
|
/** Remove a checklist step */
|
||||||
|
delete: operations["IssuesController_removeChecklistItem"];
|
||||||
|
options?: never;
|
||||||
|
head?: never;
|
||||||
|
/** Reword, tick or untick a checklist step */
|
||||||
|
patch: operations["IssuesController_updateChecklistItem"];
|
||||||
|
trace?: never;
|
||||||
|
};
|
||||||
"/api/issues/{issue}/links": {
|
"/api/issues/{issue}/links": {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -1071,6 +1146,21 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
commentId: string | null;
|
commentId: string | null;
|
||||||
};
|
};
|
||||||
|
IssueParentDto: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** @example WEB-3 */
|
||||||
|
key: string;
|
||||||
|
/** @example Pago con tarjeta */
|
||||||
|
title: string;
|
||||||
|
};
|
||||||
|
ChecklistItemDto: {
|
||||||
|
/** Format: uuid */
|
||||||
|
id: string;
|
||||||
|
/** @example Actualizar el changelog */
|
||||||
|
text: string;
|
||||||
|
done: boolean;
|
||||||
|
};
|
||||||
IssueDto: {
|
IssueDto: {
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
id: string;
|
id: string;
|
||||||
@@ -1116,6 +1206,9 @@ export interface components {
|
|||||||
attachments: components["schemas"]["AttachmentDto"][];
|
attachments: components["schemas"]["AttachmentDto"][];
|
||||||
/** @description People following it */
|
/** @description People following it */
|
||||||
watchers: components["schemas"]["UserRefDto"][];
|
watchers: components["schemas"]["UserRefDto"][];
|
||||||
|
/** @description The epic or ticket this one breaks down */
|
||||||
|
parent: components["schemas"]["IssueParentDto"] | null;
|
||||||
|
checklist: components["schemas"]["ChecklistItemDto"][];
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
/** Format: date-time */
|
/** Format: date-time */
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -1266,6 +1359,23 @@ export interface components {
|
|||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
/** Format: uuid */
|
/** Format: uuid */
|
||||||
releaseId?: string | null;
|
releaseId?: string | null;
|
||||||
|
/**
|
||||||
|
* @description Epic or ticket of the same project the new one breaks down (id or key)
|
||||||
|
* @example WEB-3
|
||||||
|
*/
|
||||||
|
parent?: string | null;
|
||||||
|
};
|
||||||
|
ChildProgressDto: {
|
||||||
|
total: number;
|
||||||
|
/** @description Done or cancelled */
|
||||||
|
finished: number;
|
||||||
|
percent: number;
|
||||||
|
storyPoints: number;
|
||||||
|
finishedStoryPoints: number;
|
||||||
|
};
|
||||||
|
EpicDto: {
|
||||||
|
issue: components["schemas"]["IssueDto"];
|
||||||
|
progress: components["schemas"]["ChildProgressDto"];
|
||||||
};
|
};
|
||||||
UpdateIssueDto: {
|
UpdateIssueDto: {
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
@@ -1308,6 +1418,21 @@ export interface components {
|
|||||||
*/
|
*/
|
||||||
releaseId: string | null;
|
releaseId: string | null;
|
||||||
};
|
};
|
||||||
|
SetIssueParentDto: {
|
||||||
|
/**
|
||||||
|
* @description Epic or ticket of the same project (id or key), or null to take it back to the top
|
||||||
|
* @example WEB-3
|
||||||
|
*/
|
||||||
|
parent: string | null;
|
||||||
|
};
|
||||||
|
ChecklistItemBodyDto: {
|
||||||
|
/** @example Actualizar el changelog */
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
UpdateChecklistItemDto: {
|
||||||
|
text?: string;
|
||||||
|
done?: boolean;
|
||||||
|
};
|
||||||
LinkIssueDto: {
|
LinkIssueDto: {
|
||||||
type: components["schemas"]["IssueLinkType"];
|
type: components["schemas"]["IssueLinkType"];
|
||||||
/**
|
/**
|
||||||
@@ -2890,6 +3015,8 @@ export interface operations {
|
|||||||
unassigned?: boolean;
|
unassigned?: boolean;
|
||||||
releaseId?: string;
|
releaseId?: string;
|
||||||
label?: string[];
|
label?: string[];
|
||||||
|
/** @description Only the tickets hanging from this one (id or key) */
|
||||||
|
parent?: string;
|
||||||
/** @description Matches key, title or description */
|
/** @description Matches key, title or description */
|
||||||
search?: string;
|
search?: string;
|
||||||
};
|
};
|
||||||
@@ -3028,6 +3155,64 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
IssuesController_epics: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Project id or key */
|
||||||
|
project: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["EpicDto"][];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
IssuesController_findOne: {
|
IssuesController_findOne: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
@@ -3406,6 +3591,324 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
IssuesController_setParent: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Ticket id or key */
|
||||||
|
issue: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["SetIssueParentDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CommandResultDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Someone changed it at the same time: retry */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
IssuesController_addChecklistItem: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Ticket id or key */
|
||||||
|
issue: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ChecklistItemBodyDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
201: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CommandResultDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Someone changed it at the same time: retry */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
IssuesController_removeChecklistItem: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Ticket id or key */
|
||||||
|
issue: string;
|
||||||
|
item: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody?: never;
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CommandResultDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Someone changed it at the same time: retry */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
IssuesController_updateChecklistItem: {
|
||||||
|
parameters: {
|
||||||
|
query?: never;
|
||||||
|
header?: never;
|
||||||
|
path: {
|
||||||
|
/** @description Ticket id or key */
|
||||||
|
issue: string;
|
||||||
|
item: string;
|
||||||
|
};
|
||||||
|
cookie?: never;
|
||||||
|
};
|
||||||
|
requestBody: {
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["UpdateChecklistItemDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
responses: {
|
||||||
|
200: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["CommandResultDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Invalid input */
|
||||||
|
400: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Missing or rejected Pireon token */
|
||||||
|
401: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not a member of the project (or not allowed) */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Not found */
|
||||||
|
404: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Someone changed it at the same time: retry */
|
||||||
|
409: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
/** @description Business rule (e.g. a transition the workflow forbids) */
|
||||||
|
422: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content: {
|
||||||
|
"application/json": components["schemas"]["ApiErrorDto"];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
IssuesController_link: {
|
IssuesController_link: {
|
||||||
parameters: {
|
parameters: {
|
||||||
query?: never;
|
query?: never;
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ describe('appRoutes', () => {
|
|||||||
'',
|
'',
|
||||||
'board',
|
'board',
|
||||||
'issues/:issueKey',
|
'issues/:issueKey',
|
||||||
|
'epics',
|
||||||
'releases',
|
'releases',
|
||||||
'dashboard',
|
'dashboard',
|
||||||
'analytics',
|
'analytics',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
| 👥 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 |
|
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
|
||||||
|
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
||||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
|
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
|
||||||
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
| 📎 Adjuntos | Capturas, logs y vídeos en el ticket o bajo un comentario; arrastrar, elegir o pegar con Ctrl+V; miniaturas de las imágenes |
|
||||||
|
|||||||
@@ -137,13 +137,13 @@ nunca ve los eventos guardados (su publisher es el event store), las sagas las e
|
|||||||
evento, tras las proyecciones, pasa ese evento solo (`of(event)`) por cada saga y ejecuta los comandos que emite, uno a
|
evento, tras las proyecciones, pasa ese evento solo (`of(event)`) por cada saga y ejecuta los comandos que emite, uno a
|
||||||
uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada se pierde si la API se cae a mitad.
|
uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada se pierde si la API se cae a mitad.
|
||||||
|
|
||||||
| Saga | Evento | Comandos |
|
| Saga | Evento | Comandos |
|
||||||
| ---------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto |
|
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto |
|
||||||
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario y `DropLinksToIssueCommand` en los tickets que lo enlazaban |
|
| | `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) |
|
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
|
||||||
| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket |
|
| `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») |
|
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
|
||||||
|
|
||||||
Reglas que cumplen:
|
Reglas que cumplen:
|
||||||
|
|
||||||
|
|||||||
@@ -148,6 +148,11 @@ stateDiagram-v2
|
|||||||
ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin.
|
ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin.
|
||||||
Los bytes se guardan primero y el evento después, así que nunca queda un adjunto apuntando a un archivo que no
|
Los bytes se guardan primero y el evento después, así que nunca queda un adjunto apuntando a un archivo que no
|
||||||
existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos.
|
existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos.
|
||||||
|
- **Épicas y subtareas**: un ticket puede colgar de otro del mismo proyecto (`parent`), hasta tres niveles
|
||||||
|
(épica → ticket → subtarea) y sin bucles; se comprueba sobre el event store, no sobre el read model. Una «épica» es
|
||||||
|
cualquier ticket de primer nivel del que cuelgan otros (`epics(projectId)` da su progreso: terminados = hechos o
|
||||||
|
cancelados, y puntos). Si se borra el padre, sus hijos vuelven al primer nivel.
|
||||||
|
- **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket.
|
||||||
|
|
||||||
### Notificaciones
|
### Notificaciones
|
||||||
|
|
||||||
@@ -178,12 +183,12 @@ por defecto); un correo que falla se registra y no bloquea el stream.
|
|||||||
Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`,
|
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).
|
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
|
||||||
|
|
||||||
| Agregado | Eventos |
|
| Agregado | Eventos |
|
||||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
|
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
|
||||||
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueDeletedEvent` |
|
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` |
|
||||||
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
|
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
|
||||||
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` |
|
| 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
|
`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.
|
cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado.
|
||||||
@@ -225,7 +230,8 @@ que funcionan también sin token.
|
|||||||
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
|
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
|
||||||
| `publicProjects` | Proyectos públicos (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, area, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
||||||
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
|
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto) |
|
||||||
|
| `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos |
|
||||||
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
||||||
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
|
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
|
||||||
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
|
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
|
||||||
@@ -234,13 +240,13 @@ que funcionan también sin token.
|
|||||||
|
|
||||||
Todas devuelven `CommandResult { id, key, version }`.
|
Todas devuelven `CommandResult { id, key, version }`.
|
||||||
|
|
||||||
| Ámbito | Mutations |
|
| Ámbito | Mutations |
|
||||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `removeAttachment` (subir: por REST) |
|
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
|
||||||
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
||||||
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
||||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
||||||
|
|
||||||
### Subscriptions
|
### Subscriptions
|
||||||
|
|
||||||
@@ -436,17 +442,17 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
|
|||||||
|
|
||||||
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||||
|
|
||||||
| Recurso | Rutas |
|
| Recurso | Rutas |
|
||||||
| ----------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
| 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`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
|
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
|
||||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
|
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
|
||||||
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
| 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}` |
|
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||||
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||||
|
|
||||||
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
||||||
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
|
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
|
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
AddChecklistItemCommand,
|
||||||
|
RemoveChecklistItemCommand,
|
||||||
|
UpdateChecklistItemCommand,
|
||||||
|
} from './change-checklist.command';
|
||||||
|
import { CommandResult, ack } from './command-result';
|
||||||
|
import { WriteAccess } from './write-access';
|
||||||
|
|
||||||
|
type ChecklistCommand =
|
||||||
|
| AddChecklistItemCommand
|
||||||
|
| UpdateChecklistItemCommand
|
||||||
|
| RemoveChecklistItemCommand;
|
||||||
|
|
||||||
|
/** Every change to a ticket's checklist: whoever can edit the ticket may. */
|
||||||
|
@CommandHandler(AddChecklistItemCommand)
|
||||||
|
export class AddChecklistItemCommandHandler
|
||||||
|
implements ICommandHandler<AddChecklistItemCommand>
|
||||||
|
{
|
||||||
|
constructor(private readonly access: WriteAccess) {}
|
||||||
|
|
||||||
|
execute(command: AddChecklistItemCommand): Promise<CommandResult> {
|
||||||
|
return change(this.access, command, (issue) =>
|
||||||
|
issue.addChecklistItem(command.actorId, randomUUID(), command.text),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(UpdateChecklistItemCommand)
|
||||||
|
export class UpdateChecklistItemCommandHandler
|
||||||
|
implements ICommandHandler<UpdateChecklistItemCommand>
|
||||||
|
{
|
||||||
|
constructor(private readonly access: WriteAccess) {}
|
||||||
|
|
||||||
|
execute(command: UpdateChecklistItemCommand): Promise<CommandResult> {
|
||||||
|
return change(this.access, command, (issue) =>
|
||||||
|
issue.updateChecklistItem(
|
||||||
|
command.actorId,
|
||||||
|
command.itemId,
|
||||||
|
command.changes,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@CommandHandler(RemoveChecklistItemCommand)
|
||||||
|
export class RemoveChecklistItemCommandHandler
|
||||||
|
implements ICommandHandler<RemoveChecklistItemCommand>
|
||||||
|
{
|
||||||
|
constructor(private readonly access: WriteAccess) {}
|
||||||
|
|
||||||
|
execute(command: RemoveChecklistItemCommand): Promise<CommandResult> {
|
||||||
|
return change(this.access, command, (issue) =>
|
||||||
|
issue.removeChecklistItem(command.actorId, command.itemId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function change(
|
||||||
|
access: WriteAccess,
|
||||||
|
command: ChecklistCommand,
|
||||||
|
apply: (issue: Awaited<ReturnType<WriteAccess['issue']>>) => void,
|
||||||
|
): Promise<CommandResult> {
|
||||||
|
const { issue } = await access.issueForContributor(
|
||||||
|
command.issueId,
|
||||||
|
command.actorId,
|
||||||
|
);
|
||||||
|
apply(issue);
|
||||||
|
await issue.commitAndWait();
|
||||||
|
return ack(issue);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { Command } from '@nestjs/cqrs';
|
||||||
|
import { CommandResult } from './command-result';
|
||||||
|
|
||||||
|
export class AddChecklistItemCommand extends Command<CommandResult> {
|
||||||
|
constructor(
|
||||||
|
public readonly actorId: string,
|
||||||
|
public readonly issueId: string,
|
||||||
|
public readonly text: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewords, ticks or unticks a checklist step. */
|
||||||
|
export class UpdateChecklistItemCommand extends Command<CommandResult> {
|
||||||
|
constructor(
|
||||||
|
public readonly actorId: string,
|
||||||
|
public readonly issueId: string,
|
||||||
|
public readonly itemId: string,
|
||||||
|
public readonly changes: { text?: string; done?: boolean },
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class RemoveChecklistItemCommand extends Command<CommandResult> {
|
||||||
|
constructor(
|
||||||
|
public readonly actorId: string,
|
||||||
|
public readonly issueId: string,
|
||||||
|
public readonly itemId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,13 @@ import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
|
|||||||
import { AttachFileCommandHandler } from './attach-file.command-handler';
|
import { AttachFileCommandHandler } from './attach-file.command-handler';
|
||||||
import { DetachFileCommandHandler } from './detach-file.command-handler';
|
import { DetachFileCommandHandler } from './detach-file.command-handler';
|
||||||
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
|
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
|
||||||
|
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
|
||||||
|
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
|
||||||
|
import {
|
||||||
|
AddChecklistItemCommandHandler,
|
||||||
|
RemoveChecklistItemCommandHandler,
|
||||||
|
UpdateChecklistItemCommandHandler,
|
||||||
|
} from './change-checklist.command-handler';
|
||||||
|
|
||||||
export const COMMAND_HANDLERS = [
|
export const COMMAND_HANDLERS = [
|
||||||
CreateProjectCommandHandler,
|
CreateProjectCommandHandler,
|
||||||
@@ -61,4 +68,10 @@ export const COMMAND_HANDLERS = [
|
|||||||
AttachFileCommandHandler,
|
AttachFileCommandHandler,
|
||||||
DetachFileCommandHandler,
|
DetachFileCommandHandler,
|
||||||
WatchIssueCommandHandler,
|
WatchIssueCommandHandler,
|
||||||
|
SetIssueParentCommandHandler,
|
||||||
|
AddChecklistItemCommandHandler,
|
||||||
|
UpdateChecklistItemCommandHandler,
|
||||||
|
RemoveChecklistItemCommandHandler,
|
||||||
|
// Saga only: the parent was deleted.
|
||||||
|
DetachFromParentCommandHandler,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -28,7 +28,15 @@ import {
|
|||||||
InMemoryProjectKeys,
|
InMemoryProjectKeys,
|
||||||
InMemoryReleaseNames,
|
InMemoryReleaseNames,
|
||||||
InMemoryReleaseIssues,
|
InMemoryReleaseIssues,
|
||||||
|
InMemoryTrackerStreams,
|
||||||
} from '../../testing/in-memory';
|
} from '../../testing/in-memory';
|
||||||
|
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||||
|
import { IssueTree } from './issue-tree';
|
||||||
|
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||||
|
import {
|
||||||
|
AddChecklistItemCommand,
|
||||||
|
UpdateChecklistItemCommand,
|
||||||
|
} from './change-checklist.command';
|
||||||
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
|
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
|
||||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||||
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
||||||
@@ -67,6 +75,7 @@ async function setup() {
|
|||||||
...COMMAND_HANDLERS,
|
...COMMAND_HANDLERS,
|
||||||
...TRACKER_FACTORIES,
|
...TRACKER_FACTORIES,
|
||||||
WriteAccess,
|
WriteAccess,
|
||||||
|
IssueTree,
|
||||||
AggregateRehydrator,
|
AggregateRehydrator,
|
||||||
{ provide: EventStore, useValue: store },
|
{ provide: EventStore, useValue: store },
|
||||||
{ provide: AttachmentStorage, useValue: files },
|
{ provide: AttachmentStorage, useValue: files },
|
||||||
@@ -78,6 +87,10 @@ async function setup() {
|
|||||||
provide: ReleaseIssuesRepository,
|
provide: ReleaseIssuesRepository,
|
||||||
useValue: new InMemoryReleaseIssues(store),
|
useValue: new InMemoryReleaseIssues(store),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: TrackerStreamsRepository,
|
||||||
|
useValue: new InMemoryTrackerStreams(store),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
await moduleRef.init();
|
await moduleRef.init();
|
||||||
@@ -281,6 +294,84 @@ describe('tracker commands (event sourced)', () => {
|
|||||||
).rejects.toThrow(ForbiddenError);
|
).rejects.toThrow(ForbiddenError);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('epics, subtasks and checklists', () => {
|
||||||
|
const create = (
|
||||||
|
run: (c: object) => Promise<CommandResult>,
|
||||||
|
projectId: string,
|
||||||
|
title: string,
|
||||||
|
parentId?: string,
|
||||||
|
) =>
|
||||||
|
run(
|
||||||
|
new CreateIssueCommand(
|
||||||
|
'dev',
|
||||||
|
projectId,
|
||||||
|
IssueType.TASK,
|
||||||
|
title,
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
parentId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
it('nests tickets three levels deep at most, without loops', async () => {
|
||||||
|
const { run, rehydrator } = await setup();
|
||||||
|
const projectId = await projectWithTeam(run);
|
||||||
|
const epic = await create(run, projectId, 'Checkout');
|
||||||
|
const story = await create(run, projectId, 'Pay by card', epic.id);
|
||||||
|
const subtask = await create(run, projectId, 'Stripe form', story.id);
|
||||||
|
const loose = await create(run, projectId, 'Loose ticket');
|
||||||
|
|
||||||
|
expect((await rehydrator.rehydrate(subtask.id, Issue)).parentId).toBe(
|
||||||
|
story.id,
|
||||||
|
);
|
||||||
|
// A fourth level, and a loop.
|
||||||
|
await expect(
|
||||||
|
create(run, projectId, 'Too deep', subtask.id),
|
||||||
|
).rejects.toThrow(/at most 2 levels/);
|
||||||
|
await expect(
|
||||||
|
run(new SetIssueParentCommand('dev', epic.id, subtask.id)),
|
||||||
|
).rejects.toThrow(BusinessRuleError);
|
||||||
|
// A ticket with subtasks cannot go under one that has a parent.
|
||||||
|
await run(new SetIssueParentCommand('dev', loose.id, epic.id));
|
||||||
|
await expect(
|
||||||
|
run(new SetIssueParentCommand('dev', story.id, loose.id)),
|
||||||
|
).rejects.toThrow(/at most 2 levels/);
|
||||||
|
// ...but it can move to another epic, and leave.
|
||||||
|
await run(new SetIssueParentCommand('dev', loose.id, null));
|
||||||
|
await run(new SetIssueParentCommand('dev', story.id, loose.id));
|
||||||
|
expect((await rehydrator.rehydrate(story.id, Issue)).parentId).toBe(
|
||||||
|
loose.id,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
run(new SetIssueParentCommand('pm', story.id, null)),
|
||||||
|
).rejects.toThrow(ForbiddenError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a checklist on the ticket', async () => {
|
||||||
|
const { run, rehydrator } = await setup();
|
||||||
|
const projectId = await projectWithTeam(run);
|
||||||
|
const issue = await create(run, projectId, 'Release 1.0');
|
||||||
|
await run(new AddChecklistItemCommand('dev', issue.id, 'Changelog'));
|
||||||
|
await run(new AddChecklistItemCommand('qa', issue.id, 'Smoke test'));
|
||||||
|
const [first] = (await rehydrator.rehydrate(issue.id, Issue)).checklist;
|
||||||
|
await run(
|
||||||
|
new UpdateChecklistItemCommand('qa', issue.id, first.id, {
|
||||||
|
done: true,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
const { checklist } = await rehydrator.rehydrate(issue.id, Issue);
|
||||||
|
expect(checklist.map((i) => [i.text, i.done])).toEqual([
|
||||||
|
['Changelog', true],
|
||||||
|
['Smoke test', false],
|
||||||
|
]);
|
||||||
|
await expect(
|
||||||
|
run(new AddChecklistItemCommand('pm', issue.id, 'Nope')),
|
||||||
|
).rejects.toThrow(ForbiddenError);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('does not let viewers modify tickets', async () => {
|
it('does not let viewers modify tickets', async () => {
|
||||||
const { run } = await setup();
|
const { run } = await setup();
|
||||||
const projectId = await projectWithTeam(run);
|
const projectId = await projectWithTeam(run);
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { IssueFactory } from '@task-stream/api/tracker/domain';
|
import { IssueFactory, assertCanNest } from '@task-stream/api/tracker/domain';
|
||||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||||
import { CommandResult, ack } from './command-result';
|
import { CommandResult, ack } from './command-result';
|
||||||
import { CreateIssueCommand } from './create-issue.command';
|
import { CreateIssueCommand } from './create-issue.command';
|
||||||
|
import { IssueTree } from './issue-tree';
|
||||||
import { WriteAccess } from './write-access';
|
import { WriteAccess } from './write-access';
|
||||||
|
|
||||||
@CommandHandler(CreateIssueCommand)
|
@CommandHandler(CreateIssueCommand)
|
||||||
@@ -14,6 +15,7 @@ export class CreateIssueCommandHandler
|
|||||||
private readonly issueFactory: IssueFactory,
|
private readonly issueFactory: IssueFactory,
|
||||||
private readonly issueNumbers: IssueNumbersRepository,
|
private readonly issueNumbers: IssueNumbersRepository,
|
||||||
private readonly eventPublisher: EventPublisher,
|
private readonly eventPublisher: EventPublisher,
|
||||||
|
private readonly tree: IssueTree,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: CreateIssueCommand): Promise<CommandResult> {
|
async execute(command: CreateIssueCommand): Promise<CommandResult> {
|
||||||
@@ -27,6 +29,15 @@ export class CreateIssueCommandHandler
|
|||||||
this.access.assertAssignable(project, command.assigneeId);
|
this.access.assertAssignable(project, command.assigneeId);
|
||||||
if (command.releaseId)
|
if (command.releaseId)
|
||||||
await this.access.openReleaseOf(project, command.releaseId);
|
await this.access.openReleaseOf(project, command.releaseId);
|
||||||
|
const parent = command.parentId
|
||||||
|
? await this.tree.candidate(command.parentId)
|
||||||
|
: null;
|
||||||
|
if (parent)
|
||||||
|
assertCanNest(
|
||||||
|
{ id: '', key: 'The new ticket', projectId: project.id },
|
||||||
|
parent,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
const issue = this.issueFactory.create(
|
const issue = this.issueFactory.create(
|
||||||
{
|
{
|
||||||
@@ -42,6 +53,7 @@ export class CreateIssueCommandHandler
|
|||||||
workflow,
|
workflow,
|
||||||
);
|
);
|
||||||
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
|
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
|
||||||
|
if (parent) issue.setParent(command.actorId, parent);
|
||||||
|
|
||||||
this.eventPublisher.mergeObjectContext(issue);
|
this.eventPublisher.mergeObjectContext(issue);
|
||||||
await issue.commitAndWait();
|
await issue.commitAndWait();
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ export class CreateIssueCommand extends Command<CommandResult> {
|
|||||||
public readonly details: Omit<IssueDetails, 'title' | 'type'>,
|
public readonly details: Omit<IssueDetails, 'title' | 'type'>,
|
||||||
public readonly assigneeId?: string | null,
|
public readonly assigneeId?: string | null,
|
||||||
public readonly releaseId?: string | null,
|
public readonly releaseId?: string | null,
|
||||||
|
/** Epic or ticket the new one breaks down. */
|
||||||
|
public readonly parentId?: string | null,
|
||||||
) {
|
) {
|
||||||
super();
|
super();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { Issue, Project } from '@task-stream/api/tracker/domain';
|
||||||
|
import { DetachFromParentCommand } from './detach-from-parent.command';
|
||||||
|
import { WriteAccess } from './write-access';
|
||||||
|
|
||||||
|
@CommandHandler(DetachFromParentCommand)
|
||||||
|
export class DetachFromParentCommandHandler
|
||||||
|
implements ICommandHandler<DetachFromParentCommand>
|
||||||
|
{
|
||||||
|
constructor(private readonly access: WriteAccess) {}
|
||||||
|
|
||||||
|
async execute(command: DetachFromParentCommand): Promise<void> {
|
||||||
|
const issue = await this.access.live(command.issueId, Issue);
|
||||||
|
// Gone (or its whole project is going), or it moved elsewhere already.
|
||||||
|
if (!issue || issue.parentId !== command.parentId) return;
|
||||||
|
if (!(await this.access.live(issue.projectId, Project))) return;
|
||||||
|
issue.setParent(command.actorId, null);
|
||||||
|
await issue.commitAndWait();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import { Command } from '@nestjs/cqrs';
|
||||||
|
|
||||||
|
/** Saga only: the parent of a ticket was deleted, so it goes back to the top. */
|
||||||
|
export class DetachFromParentCommand extends Command<void> {
|
||||||
|
constructor(
|
||||||
|
public readonly actorId: string,
|
||||||
|
public readonly issueId: string,
|
||||||
|
public readonly parentId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -29,3 +29,6 @@ export * from './unassign-issue.command';
|
|||||||
export * from './attach-file.command';
|
export * from './attach-file.command';
|
||||||
export * from './detach-file.command';
|
export * from './detach-file.command';
|
||||||
export * from './watch-issue.command';
|
export * from './watch-issue.command';
|
||||||
|
export * from './set-issue-parent.command';
|
||||||
|
export * from './detach-from-parent.command';
|
||||||
|
export * from './change-checklist.command';
|
||||||
|
|||||||
60
libs/api/tracker/application/src/lib/commands/issue-tree.ts
Normal file
60
libs/api/tracker/application/src/lib/commands/issue-tree.ts
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
Issue,
|
||||||
|
MAX_HIERARCHY_DEPTH,
|
||||||
|
ParentCandidate,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||||
|
import { WriteAccess } from './write-access';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walks the epic → ticket → subtask tree on the write side (the event store),
|
||||||
|
* so nesting rules never depend on a read model that may lag behind.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class IssueTree {
|
||||||
|
constructor(
|
||||||
|
private readonly access: WriteAccess,
|
||||||
|
private readonly streams: TrackerStreamsRepository,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** `parentId` as a would-be parent: with its ancestors, closest first. */
|
||||||
|
async candidate(parentId: string): Promise<ParentCandidate> {
|
||||||
|
const parent = await this.access.issue(parentId);
|
||||||
|
const ancestorIds: string[] = [];
|
||||||
|
let next = parent.parentId;
|
||||||
|
// One more than allowed is enough to tell a tree is too deep (or loops).
|
||||||
|
while (next && ancestorIds.length <= MAX_HIERARCHY_DEPTH) {
|
||||||
|
ancestorIds.push(next);
|
||||||
|
next = (await this.access.live(next, Issue))?.parentId ?? null;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
id: parent.id,
|
||||||
|
key: parent.key,
|
||||||
|
projectId: parent.projectId,
|
||||||
|
ancestorIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tickets hanging from `issueId` right now. */
|
||||||
|
async childrenOf(issueId: string): Promise<Issue[]> {
|
||||||
|
const everUnder = await this.streams.issueIdsEverUnder(issueId);
|
||||||
|
const issues = await Promise.all(
|
||||||
|
everUnder.map((id) => this.access.live(id, Issue)),
|
||||||
|
);
|
||||||
|
return issues.filter(
|
||||||
|
(issue): issue is Issue => !!issue && issue.parentId === issueId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Levels of tickets below `issueId` (0: none). */
|
||||||
|
async heightOf(issueId: string, depth = 0): Promise<number> {
|
||||||
|
if (depth > MAX_HIERARCHY_DEPTH) return 0;
|
||||||
|
const children = await this.childrenOf(issueId);
|
||||||
|
if (children.length === 0) return 0;
|
||||||
|
const heights = await Promise.all(
|
||||||
|
children.map((child) => this.heightOf(child.id, depth + 1)),
|
||||||
|
);
|
||||||
|
return 1 + Math.max(...heights);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||||
|
import { CommandResult, ack } from './command-result';
|
||||||
|
import { IssueTree } from './issue-tree';
|
||||||
|
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||||
|
import { WriteAccess } from './write-access';
|
||||||
|
|
||||||
|
@CommandHandler(SetIssueParentCommand)
|
||||||
|
export class SetIssueParentCommandHandler
|
||||||
|
implements ICommandHandler<SetIssueParentCommand>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly access: WriteAccess,
|
||||||
|
private readonly tree: IssueTree,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(command: SetIssueParentCommand): Promise<CommandResult> {
|
||||||
|
const { issue } = await this.access.issueForContributor(
|
||||||
|
command.issueId,
|
||||||
|
command.actorId,
|
||||||
|
);
|
||||||
|
if (command.parentId === null) {
|
||||||
|
issue.setParent(command.actorId, null);
|
||||||
|
} else if (command.parentId !== issue.parentId) {
|
||||||
|
const [parent, height] = await Promise.all([
|
||||||
|
this.tree.candidate(command.parentId),
|
||||||
|
this.tree.heightOf(issue.id),
|
||||||
|
]);
|
||||||
|
issue.setParent(command.actorId, parent, height);
|
||||||
|
}
|
||||||
|
await issue.commitAndWait();
|
||||||
|
return ack(issue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Command } from '@nestjs/cqrs';
|
||||||
|
import { CommandResult } from './command-result';
|
||||||
|
|
||||||
|
/** Places a ticket under an epic or another ticket (`parentId` null: out). */
|
||||||
|
export class SetIssueParentCommand extends Command<CommandResult> {
|
||||||
|
constructor(
|
||||||
|
public readonly actorId: string,
|
||||||
|
public readonly issueId: string,
|
||||||
|
public readonly parentId: string | null,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,10 @@ import {
|
|||||||
IssueAssignedEvent,
|
IssueAssignedEvent,
|
||||||
IssueAttachmentAddedEvent,
|
IssueAttachmentAddedEvent,
|
||||||
IssueAttachmentRemovedEvent,
|
IssueAttachmentRemovedEvent,
|
||||||
|
IssueChecklistItemAddedEvent,
|
||||||
|
IssueChecklistItemRemovedEvent,
|
||||||
|
IssueChecklistItemUpdatedEvent,
|
||||||
|
IssueParentChangedEvent,
|
||||||
IssueCreatedEvent,
|
IssueCreatedEvent,
|
||||||
IssueLinkedEvent,
|
IssueLinkedEvent,
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
@@ -142,6 +146,23 @@ export function describeEvent(
|
|||||||
if (event instanceof IssueAttachmentRemovedEvent)
|
if (event instanceof IssueAttachmentRemovedEvent)
|
||||||
return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`;
|
return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`;
|
||||||
|
|
||||||
|
if (event instanceof IssueParentChangedEvent) {
|
||||||
|
return event.parentKey
|
||||||
|
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
|
||||||
|
: `${actor} sacó ${issueKey} de su ticket padre`;
|
||||||
|
}
|
||||||
|
if (event instanceof IssueChecklistItemAddedEvent)
|
||||||
|
return `${actor} añadió «${event.item.text}» a la lista de ${issueKey}`;
|
||||||
|
if (event instanceof IssueChecklistItemUpdatedEvent) {
|
||||||
|
if (event.changes.done === true)
|
||||||
|
return `${actor} completó un paso de la lista de ${issueKey}`;
|
||||||
|
if (event.changes.done === false)
|
||||||
|
return `${actor} reabrió un paso de la lista de ${issueKey}`;
|
||||||
|
return `${actor} cambió un paso de la lista de ${issueKey}`;
|
||||||
|
}
|
||||||
|
if (event instanceof IssueChecklistItemRemovedEvent)
|
||||||
|
return `${actor} quitó «${event.text}» de la lista de ${issueKey}`;
|
||||||
|
|
||||||
if (event instanceof CommentAddedEvent)
|
if (event instanceof CommentAddedEvent)
|
||||||
return `${actor} comentó en ${issueKey}`;
|
return `${actor} comentó en ${issueKey}`;
|
||||||
if (event instanceof CommentEditedEvent)
|
if (event instanceof CommentEditedEvent)
|
||||||
|
|||||||
@@ -7,6 +7,10 @@ import {
|
|||||||
IssueAssignedEvent,
|
IssueAssignedEvent,
|
||||||
IssueAttachmentAddedEvent,
|
IssueAttachmentAddedEvent,
|
||||||
IssueAttachmentRemovedEvent,
|
IssueAttachmentRemovedEvent,
|
||||||
|
IssueChecklistItemAddedEvent,
|
||||||
|
IssueChecklistItemRemovedEvent,
|
||||||
|
IssueChecklistItemUpdatedEvent,
|
||||||
|
IssueParentChangedEvent,
|
||||||
IssueCreatedEvent,
|
IssueCreatedEvent,
|
||||||
IssueLinkedEvent,
|
IssueLinkedEvent,
|
||||||
IssueReleaseChangedEvent,
|
IssueReleaseChangedEvent,
|
||||||
@@ -55,6 +59,10 @@ import { versionOf } from './read-model-notifier';
|
|||||||
IssueReleaseChangedEvent,
|
IssueReleaseChangedEvent,
|
||||||
IssueAttachmentAddedEvent,
|
IssueAttachmentAddedEvent,
|
||||||
IssueAttachmentRemovedEvent,
|
IssueAttachmentRemovedEvent,
|
||||||
|
IssueParentChangedEvent,
|
||||||
|
IssueChecklistItemAddedEvent,
|
||||||
|
IssueChecklistItemUpdatedEvent,
|
||||||
|
IssueChecklistItemRemovedEvent,
|
||||||
CommentAddedEvent,
|
CommentAddedEvent,
|
||||||
CommentEditedEvent,
|
CommentEditedEvent,
|
||||||
CommentDeletedEvent,
|
CommentDeletedEvent,
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ import { IssueAttachmentAddedEventHandler } from './issue-attachment-added.event
|
|||||||
import { IssueAttachmentRemovedEventHandler } from './issue-attachment-removed.event-handler';
|
import { IssueAttachmentRemovedEventHandler } from './issue-attachment-removed.event-handler';
|
||||||
import { AttachmentCleanupEventHandler } from './attachment-cleanup.event-handler';
|
import { AttachmentCleanupEventHandler } from './attachment-cleanup.event-handler';
|
||||||
import { IssueWatchersEventHandler } from './issue-watchers.event-handler';
|
import { IssueWatchersEventHandler } from './issue-watchers.event-handler';
|
||||||
|
import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler';
|
||||||
|
import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
|
||||||
|
|
||||||
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
||||||
export const EVENT_HANDLERS = [
|
export const EVENT_HANDLERS = [
|
||||||
@@ -60,6 +62,8 @@ export const EVENT_HANDLERS = [
|
|||||||
IssueAttachmentRemovedEventHandler,
|
IssueAttachmentRemovedEventHandler,
|
||||||
AttachmentCleanupEventHandler,
|
AttachmentCleanupEventHandler,
|
||||||
IssueWatchersEventHandler,
|
IssueWatchersEventHandler,
|
||||||
|
IssueParentChangedEventHandler,
|
||||||
|
IssueChecklistEventHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { ReadModelNotifier };
|
export { ReadModelNotifier };
|
||||||
|
|||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
IssueChecklistItemAddedEvent,
|
||||||
|
IssueChecklistItemRemovedEvent,
|
||||||
|
IssueChecklistItemUpdatedEvent,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||||
|
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||||
|
|
||||||
|
type ChecklistEvent =
|
||||||
|
| IssueChecklistItemAddedEvent
|
||||||
|
| IssueChecklistItemUpdatedEvent
|
||||||
|
| IssueChecklistItemRemovedEvent;
|
||||||
|
|
||||||
|
@EventsHandler(
|
||||||
|
IssueChecklistItemAddedEvent,
|
||||||
|
IssueChecklistItemUpdatedEvent,
|
||||||
|
IssueChecklistItemRemovedEvent,
|
||||||
|
)
|
||||||
|
export class IssueChecklistEventHandler
|
||||||
|
implements IEventHandler<ChecklistEvent>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||||
|
private readonly notifier: ReadModelNotifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(event: ChecklistEvent) {
|
||||||
|
const repository = this.upsertMaterializedIssueRepository;
|
||||||
|
await repository.upsert({
|
||||||
|
id: event.aggregateId,
|
||||||
|
updatedAt: event.occurredAt,
|
||||||
|
version: versionOf(event),
|
||||||
|
});
|
||||||
|
if (event instanceof IssueChecklistItemAddedEvent) {
|
||||||
|
await repository.addChecklistItem(event.aggregateId, event.item);
|
||||||
|
} else if (event instanceof IssueChecklistItemUpdatedEvent) {
|
||||||
|
await repository.updateChecklistItem(
|
||||||
|
event.aggregateId,
|
||||||
|
event.itemId,
|
||||||
|
event.changes,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await repository.removeChecklistItem(event.aggregateId, event.itemId);
|
||||||
|
}
|
||||||
|
await this.notifier.issue(event.aggregateId, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,6 +48,7 @@ export class IssueCreatedEventHandler
|
|||||||
reporter: users.of(issue.reporterId),
|
reporter: users.of(issue.reporterId),
|
||||||
assignee: users.ofNullable(issue.assigneeId),
|
assignee: users.ofNullable(issue.assigneeId),
|
||||||
release: release ? { id: release.id, name: release.name } : null,
|
release: release ? { id: release.id, name: release.name } : null,
|
||||||
|
parent: null,
|
||||||
labels: issue.labels,
|
labels: issue.labels,
|
||||||
figmaUrl: issue.figmaUrl,
|
figmaUrl: issue.figmaUrl,
|
||||||
storyPoints: issue.storyPoints,
|
storyPoints: issue.storyPoints,
|
||||||
|
|||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||||
|
import { IssueParentChangedEvent } from '@task-stream/api/tracker/domain';
|
||||||
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
|
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||||
|
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||||
|
|
||||||
|
@EventsHandler(IssueParentChangedEvent)
|
||||||
|
export class IssueParentChangedEventHandler
|
||||||
|
implements IEventHandler<IssueParentChangedEvent>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||||
|
private readonly findIssues: FindIssuesRepository,
|
||||||
|
private readonly notifier: ReadModelNotifier,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async handle(event: IssueParentChangedEvent) {
|
||||||
|
const parent = event.parentId
|
||||||
|
? await this.findIssues.findById(event.parentId)
|
||||||
|
: null;
|
||||||
|
await this.upsertMaterializedIssueRepository.upsert({
|
||||||
|
id: event.aggregateId,
|
||||||
|
parent: event.parentId
|
||||||
|
? {
|
||||||
|
id: event.parentId,
|
||||||
|
key: parent?.key ?? event.parentKey ?? '',
|
||||||
|
title: parent?.title ?? '',
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
updatedAt: event.occurredAt,
|
||||||
|
version: versionOf(event),
|
||||||
|
});
|
||||||
|
await this.notifier.issue(event.aggregateId, event);
|
||||||
|
// Both parents list their subtasks: they changed too.
|
||||||
|
for (const id of [event.parentId, event.previousParentId]) {
|
||||||
|
if (id) await this.notifier.issue(id, event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -19,6 +19,12 @@ export class IssueUpdatedEventHandler
|
|||||||
updatedAt: event.occurredAt,
|
updatedAt: event.occurredAt,
|
||||||
version: versionOf(event),
|
version: versionOf(event),
|
||||||
});
|
});
|
||||||
|
// Its subtasks show its title.
|
||||||
|
if (event.changes.title !== undefined)
|
||||||
|
await this.upsertMaterializedIssueRepository.renameParent(
|
||||||
|
event.aggregateId,
|
||||||
|
event.changes.title,
|
||||||
|
);
|
||||||
await this.notifier.issue(event.aggregateId, event);
|
await this.notifier.issue(event.aggregateId, event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||||
import {
|
import {
|
||||||
ActivityReadModel,
|
ActivityReadModel,
|
||||||
|
EpicReadModel,
|
||||||
IssueDetails,
|
IssueDetails,
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssueReadModel,
|
IssueReadModel,
|
||||||
@@ -13,6 +14,12 @@ import {
|
|||||||
} from './commands/attach-file.command';
|
} from './commands/attach-file.command';
|
||||||
import { DetachFileCommand } from './commands/detach-file.command';
|
import { DetachFileCommand } from './commands/detach-file.command';
|
||||||
import { WatchIssueCommand } from './commands/watch-issue.command';
|
import { WatchIssueCommand } from './commands/watch-issue.command';
|
||||||
|
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
|
||||||
|
import {
|
||||||
|
AddChecklistItemCommand,
|
||||||
|
RemoveChecklistItemCommand,
|
||||||
|
UpdateChecklistItemCommand,
|
||||||
|
} from './commands/change-checklist.command';
|
||||||
import { ChangeIssueStatusCommand } from './commands/change-issue-status.command';
|
import { ChangeIssueStatusCommand } from './commands/change-issue-status.command';
|
||||||
import { CommandResult } from './commands/command-result';
|
import { CommandResult } from './commands/command-result';
|
||||||
import { CreateIssueCommand } from './commands/create-issue.command';
|
import { CreateIssueCommand } from './commands/create-issue.command';
|
||||||
@@ -29,6 +36,7 @@ import {
|
|||||||
import { GetIssueQuery } from './queries/get-issue.query';
|
import { GetIssueQuery } from './queries/get-issue.query';
|
||||||
import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query';
|
import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query';
|
||||||
import { SearchIssuesQuery } from './queries/search-issues.query';
|
import { SearchIssuesQuery } from './queries/search-issues.query';
|
||||||
|
import { GetEpicsQuery } from './queries/get-epics.query';
|
||||||
import {
|
import {
|
||||||
MyIssuesFilter,
|
MyIssuesFilter,
|
||||||
SearchMyIssuesQuery,
|
SearchMyIssuesQuery,
|
||||||
@@ -41,6 +49,8 @@ export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
|
|||||||
title: string;
|
title: string;
|
||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
releaseId?: string | null;
|
releaseId?: string | null;
|
||||||
|
/** Epic or ticket the new one breaks down. */
|
||||||
|
parentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IssueLinkInput {
|
export interface IssueLinkInput {
|
||||||
@@ -57,7 +67,15 @@ export class IssuesService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
create(actorId: string, input: CreateIssueInput): Promise<CommandResult> {
|
create(actorId: string, input: CreateIssueInput): Promise<CommandResult> {
|
||||||
const { projectId, type, title, assigneeId, releaseId, ...details } = input;
|
const {
|
||||||
|
projectId,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
assigneeId,
|
||||||
|
releaseId,
|
||||||
|
parentId,
|
||||||
|
...details
|
||||||
|
} = input;
|
||||||
return this.commandBus.execute(
|
return this.commandBus.execute(
|
||||||
new CreateIssueCommand(
|
new CreateIssueCommand(
|
||||||
actorId,
|
actorId,
|
||||||
@@ -67,6 +85,7 @@ export class IssuesService {
|
|||||||
details,
|
details,
|
||||||
assigneeId,
|
assigneeId,
|
||||||
releaseId,
|
releaseId,
|
||||||
|
parentId,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -188,6 +207,52 @@ export class IssuesService {
|
|||||||
return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids));
|
return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Places the ticket under `parentId` (null: back to the top). */
|
||||||
|
setParent(
|
||||||
|
actorId: string,
|
||||||
|
issueId: string,
|
||||||
|
parentId: string | null,
|
||||||
|
): Promise<CommandResult> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new SetIssueParentCommand(actorId, issueId, parentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addChecklistItem(
|
||||||
|
actorId: string,
|
||||||
|
issueId: string,
|
||||||
|
text: string,
|
||||||
|
): Promise<CommandResult> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new AddChecklistItemCommand(actorId, issueId, text),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateChecklistItem(
|
||||||
|
actorId: string,
|
||||||
|
issueId: string,
|
||||||
|
itemId: string,
|
||||||
|
changes: { text?: string; done?: boolean },
|
||||||
|
): Promise<CommandResult> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new UpdateChecklistItemCommand(actorId, issueId, itemId, changes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChecklistItem(
|
||||||
|
actorId: string,
|
||||||
|
issueId: string,
|
||||||
|
itemId: string,
|
||||||
|
): Promise<CommandResult> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new RemoveChecklistItemCommand(actorId, issueId, itemId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
epics(viewerId: string | null, projectId: string): Promise<EpicReadModel[]> {
|
||||||
|
return this.queryBus.execute(new GetEpicsQuery(viewerId, projectId));
|
||||||
|
}
|
||||||
|
|
||||||
search(
|
search(
|
||||||
viewerId: string | null,
|
viewerId: string | null,
|
||||||
filter: IssueFilter,
|
filter: IssueFilter,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
EpicReadModel,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
IssueReadModel,
|
IssueReadModel,
|
||||||
ProjectStatsReadModel,
|
ProjectStatsReadModel,
|
||||||
@@ -18,6 +19,8 @@ export interface IssueFilter {
|
|||||||
unassigned?: boolean;
|
unassigned?: boolean;
|
||||||
releaseId?: string;
|
releaseId?: string;
|
||||||
labels?: string[];
|
labels?: string[];
|
||||||
|
/** Only the tickets hanging from this one. */
|
||||||
|
parentId?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
@@ -51,6 +54,8 @@ export abstract class FindIssuesRepository {
|
|||||||
abstract findOpenAssignedTo(userId: string): Promise<IssueReadModel[]>;
|
abstract findOpenAssignedTo(userId: string): Promise<IssueReadModel[]>;
|
||||||
/** Every ticket of a project (statistics). */
|
/** Every ticket of a project (statistics). */
|
||||||
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
|
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
|
||||||
|
/** Top-level tickets with others hanging from them, by key. */
|
||||||
|
abstract epics(projectId: string): Promise<EpicReadModel[]>;
|
||||||
abstract releaseProgress(
|
abstract releaseProgress(
|
||||||
releaseId: string,
|
releaseId: string,
|
||||||
): Promise<ReleaseProgressReadModel>;
|
): Promise<ReleaseProgressReadModel>;
|
||||||
|
|||||||
@@ -10,4 +10,6 @@ export abstract class TrackerStreamsRepository {
|
|||||||
abstract commentIdsOfIssue(issueId: string): Promise<string[]>;
|
abstract commentIdsOfIssue(issueId: string): Promise<string[]>;
|
||||||
/** Tickets that were ever linked to `issueId` (they may have unlinked since). */
|
/** Tickets that were ever linked to `issueId` (they may have unlinked since). */
|
||||||
abstract issueIdsLinkingTo(issueId: string): Promise<string[]>;
|
abstract issueIdsLinkingTo(issueId: string): Promise<string[]>;
|
||||||
|
/** Tickets that were ever placed under `issueId` (they may have left since). */
|
||||||
|
abstract issueIdsEverUnder(issueId: string): Promise<string[]>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AttachmentReadModel,
|
AttachmentReadModel,
|
||||||
|
ChecklistItemReadModel,
|
||||||
IssueLinkReadModel,
|
IssueLinkReadModel,
|
||||||
IssueReadModel,
|
IssueReadModel,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
@@ -13,7 +14,7 @@ export abstract class UpsertMaterializedIssueRepository {
|
|||||||
Partial<
|
Partial<
|
||||||
Omit<
|
Omit<
|
||||||
IssueReadModel,
|
IssueReadModel,
|
||||||
'links' | 'commentCount' | 'attachments' | 'watchers'
|
'links' | 'commentCount' | 'attachments' | 'watchers' | 'checklist'
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
@@ -35,6 +36,19 @@ export abstract class UpsertMaterializedIssueRepository {
|
|||||||
/** Adds a follower (idempotent). */
|
/** Adds a follower (idempotent). */
|
||||||
abstract addWatcher(issueId: string, user: UserRefReadModel): Promise<void>;
|
abstract addWatcher(issueId: string, user: UserRefReadModel): Promise<void>;
|
||||||
abstract removeWatcher(issueId: string, userId: string): Promise<void>;
|
abstract removeWatcher(issueId: string, userId: string): Promise<void>;
|
||||||
|
/** Adds a checklist step (idempotent). */
|
||||||
|
abstract addChecklistItem(
|
||||||
|
issueId: string,
|
||||||
|
item: ChecklistItemReadModel,
|
||||||
|
): Promise<void>;
|
||||||
|
abstract updateChecklistItem(
|
||||||
|
issueId: string,
|
||||||
|
itemId: string,
|
||||||
|
changes: Partial<Pick<ChecklistItemReadModel, 'text' | 'done'>>,
|
||||||
|
): Promise<void>;
|
||||||
|
abstract removeChecklistItem(issueId: string, itemId: string): Promise<void>;
|
||||||
|
/** Keeps the denormalised parent title of a ticket's children in sync. */
|
||||||
|
abstract renameParent(parentId: string, title: string): Promise<void>;
|
||||||
abstract setCommentCount(
|
abstract setCommentCount(
|
||||||
issueId: string,
|
issueId: string,
|
||||||
commentCount: number,
|
commentCount: number,
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import { IQueryHandler, QueryHandler } from '@nestjs/cqrs';
|
||||||
|
import { EpicReadModel } from '@task-stream/api/tracker/domain';
|
||||||
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
|
import { GetEpicsQuery } from './get-epics.query';
|
||||||
|
import { ReadAccess } from './read-access';
|
||||||
|
|
||||||
|
@QueryHandler(GetEpicsQuery)
|
||||||
|
export class GetEpicsQueryHandler
|
||||||
|
implements IQueryHandler<GetEpicsQuery, EpicReadModel[]>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly findIssues: FindIssuesRepository,
|
||||||
|
private readonly access: ReadAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: GetEpicsQuery): Promise<EpicReadModel[]> {
|
||||||
|
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||||
|
return this.findIssues.epics(query.projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { Query } from '@nestjs/cqrs';
|
||||||
|
import { EpicReadModel } from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
|
/** A project's epics: top-level tickets that group others, with progress. */
|
||||||
|
export class GetEpicsQuery extends Query<EpicReadModel[]> {
|
||||||
|
constructor(
|
||||||
|
/** Null: anonymous (public projects only). */
|
||||||
|
public readonly viewerId: string | null,
|
||||||
|
public readonly projectId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,3 +17,4 @@ export * from './search-issues.query';
|
|||||||
export * from './search-my-issues.query';
|
export * from './search-my-issues.query';
|
||||||
export * from './get-public-projects.query';
|
export * from './get-public-projects.query';
|
||||||
export * from './get-attachment.query';
|
export * from './get-attachment.query';
|
||||||
|
export * from './get-epics.query';
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ import { GetReleaseProgressQueryHandler } from './get-release-progress.query-han
|
|||||||
import { GetReleasesQueryHandler } from './get-releases.query-handler';
|
import { GetReleasesQueryHandler } from './get-releases.query-handler';
|
||||||
import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler';
|
import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler';
|
||||||
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
|
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
|
||||||
|
import { GetEpicsQueryHandler } from './get-epics.query-handler';
|
||||||
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
|
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
|
||||||
|
|
||||||
export const QUERY_HANDLERS = [
|
export const QUERY_HANDLERS = [
|
||||||
@@ -36,6 +37,7 @@ export const QUERY_HANDLERS = [
|
|||||||
GetReleasesQueryHandler,
|
GetReleasesQueryHandler,
|
||||||
GetUpcomingReleasesQueryHandler,
|
GetUpcomingReleasesQueryHandler,
|
||||||
SearchIssuesQueryHandler,
|
SearchIssuesQueryHandler,
|
||||||
|
GetEpicsQueryHandler,
|
||||||
SearchMyIssuesQueryHandler,
|
SearchMyIssuesQueryHandler,
|
||||||
GetAttachmentQueryHandler,
|
GetAttachmentQueryHandler,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { from, mergeMap, Observable } from 'rxjs';
|
import { from, mergeMap, Observable } from 'rxjs';
|
||||||
import { DeleteIssueCommand } from '../commands/delete-issue.command';
|
import { DeleteIssueCommand } from '../commands/delete-issue.command';
|
||||||
import { DeleteReleaseCommand } from '../commands/delete-release.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 { DropLinksToIssueCommand } from '../commands/drop-links-to-issue.command';
|
||||||
import { PurgeCommentCommand } from '../commands/purge-comment.command';
|
import { PurgeCommentCommand } from '../commands/purge-comment.command';
|
||||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||||
@@ -17,7 +18,8 @@ import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
|||||||
*
|
*
|
||||||
* ProjectDeleted → DeleteIssue (each ticket) + DeleteRelease (each release)
|
* ProjectDeleted → DeleteIssue (each ticket) + DeleteRelease (each release)
|
||||||
* IssueDeleted → PurgeComment (each comment) + DropLinksToIssue (tickets
|
* IssueDeleted → PurgeComment (each comment) + DropLinksToIssue (tickets
|
||||||
* of other projects that pointed to it)
|
* of other projects that pointed to it) + DetachFromParent
|
||||||
|
* (tickets that hung from it go back to the top)
|
||||||
*
|
*
|
||||||
* Who to delete comes from the event store, not the read model. The changes
|
* Who to delete comes from the event store, not the read model. The changes
|
||||||
* are attributed to whoever deleted the project.
|
* are attributed to whoever deleted the project.
|
||||||
@@ -48,7 +50,7 @@ export class ProjectDeletionSaga {
|
|||||||
ofType(IssueDeletedEvent),
|
ofType(IssueDeletedEvent),
|
||||||
mergeMap((event) =>
|
mergeMap((event) =>
|
||||||
from(this.dependentsOf(event.aggregateId)).pipe(
|
from(this.dependentsOf(event.aggregateId)).pipe(
|
||||||
mergeMap(({ commentIds, linkingIds }) => [
|
mergeMap(({ commentIds, linkingIds, childIds }) => [
|
||||||
...commentIds.map(
|
...commentIds.map(
|
||||||
(id) => new PurgeCommentCommand(event.actorId, id),
|
(id) => new PurgeCommentCommand(event.actorId, id),
|
||||||
),
|
),
|
||||||
@@ -60,6 +62,14 @@ export class ProjectDeletionSaga {
|
|||||||
event.aggregateId,
|
event.aggregateId,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
...childIds.map(
|
||||||
|
(id) =>
|
||||||
|
new DetachFromParentCommand(
|
||||||
|
event.actorId,
|
||||||
|
id,
|
||||||
|
event.aggregateId,
|
||||||
|
),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -74,13 +84,15 @@ export class ProjectDeletionSaga {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async dependentsOf(issueId: string) {
|
private async dependentsOf(issueId: string) {
|
||||||
const [commentIds, linkingIds] = await Promise.all([
|
const [commentIds, linkingIds, childIds] = await Promise.all([
|
||||||
this.streams.commentIdsOfIssue(issueId),
|
this.streams.commentIdsOfIssue(issueId),
|
||||||
this.streams.issueIdsLinkingTo(issueId),
|
this.streams.issueIdsLinkingTo(issueId),
|
||||||
|
this.streams.issueIdsEverUnder(issueId),
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
commentIds,
|
commentIds,
|
||||||
linkingIds: linkingIds.filter((id) => id !== issueId),
|
linkingIds: linkingIds.filter((id) => id !== issueId),
|
||||||
|
childIds,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,12 +39,14 @@ import { ChangeIssueStatusCommand } from '../commands/change-issue-status.comman
|
|||||||
import { COMMAND_HANDLERS } from '../commands/command-handlers';
|
import { COMMAND_HANDLERS } from '../commands/command-handlers';
|
||||||
import { CommandResult } from '../commands/command-result';
|
import { CommandResult } from '../commands/command-result';
|
||||||
import { CreateIssueCommand } from '../commands/create-issue.command';
|
import { CreateIssueCommand } from '../commands/create-issue.command';
|
||||||
|
import { DeleteIssueCommand } from '../commands/delete-issue.command';
|
||||||
import { CreateProjectCommand } from '../commands/create-project.command';
|
import { CreateProjectCommand } from '../commands/create-project.command';
|
||||||
import { CreateReleaseCommand } from '../commands/create-release.command';
|
import { CreateReleaseCommand } from '../commands/create-release.command';
|
||||||
import { DeleteProjectCommand } from '../commands/delete-project.command';
|
import { DeleteProjectCommand } from '../commands/delete-project.command';
|
||||||
import { LinkIssuesCommand } from '../commands/link-issues.command';
|
import { LinkIssuesCommand } from '../commands/link-issues.command';
|
||||||
import { AssignIssueCommand } from '../commands/assign-issue.command';
|
import { AssignIssueCommand } from '../commands/assign-issue.command';
|
||||||
import { WriteAccess } from '../commands/write-access';
|
import { WriteAccess } from '../commands/write-access';
|
||||||
|
import { IssueTree } from '../commands/issue-tree';
|
||||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||||
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
||||||
@@ -72,6 +74,7 @@ async function setup() {
|
|||||||
ReleaseArchivedSaga,
|
ReleaseArchivedSaga,
|
||||||
UserDeactivatedSaga,
|
UserDeactivatedSaga,
|
||||||
WriteAccess,
|
WriteAccess,
|
||||||
|
IssueTree,
|
||||||
AggregateRehydrator,
|
AggregateRehydrator,
|
||||||
{ provide: EventStore, useValue: store },
|
{ provide: EventStore, useValue: store },
|
||||||
{ provide: AttachmentStorage, useValue: files },
|
{ provide: AttachmentStorage, useValue: files },
|
||||||
@@ -178,6 +181,31 @@ describe('sagas', () => {
|
|||||||
).resolves.toMatchObject({ key: 'WEB' });
|
).resolves.toMatchObject({ key: 'WEB' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('a deleted ticket lets the tickets under it go back to the top', async () => {
|
||||||
|
const { run, drain, rehydrator } = await setup();
|
||||||
|
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||||
|
const epic = (await run(ticket(web, 'Epic'))).id;
|
||||||
|
const child = (
|
||||||
|
await run(
|
||||||
|
new CreateIssueCommand(
|
||||||
|
'owner',
|
||||||
|
web,
|
||||||
|
'TASK',
|
||||||
|
'child',
|
||||||
|
{},
|
||||||
|
null,
|
||||||
|
null,
|
||||||
|
epic,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).id;
|
||||||
|
|
||||||
|
await run(new DeleteIssueCommand('owner', epic));
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect((await rehydrator.rehydrate(child, Issue)).parentId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('only the owner deletes a project', async () => {
|
it('only the owner deletes a project', async () => {
|
||||||
const { run } = await setup();
|
const { run } = await setup();
|
||||||
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { TRACKER_FACTORIES } from '@task-stream/api/tracker/domain';
|
|||||||
import { ChangesService } from './changes.service';
|
import { ChangesService } from './changes.service';
|
||||||
import { COMMAND_HANDLERS } from './commands/command-handlers';
|
import { COMMAND_HANDLERS } from './commands/command-handlers';
|
||||||
import { WriteAccess } from './commands/write-access';
|
import { WriteAccess } from './commands/write-access';
|
||||||
|
import { IssueTree } from './commands/issue-tree';
|
||||||
import { CommentsService } from './comments.service';
|
import { CommentsService } from './comments.service';
|
||||||
import {
|
import {
|
||||||
EVENT_HANDLERS,
|
EVENT_HANDLERS,
|
||||||
@@ -59,6 +60,7 @@ const SERVICES = [
|
|||||||
...SERVICES,
|
...SERVICES,
|
||||||
...TRACKER_FACTORIES,
|
...TRACKER_FACTORIES,
|
||||||
WriteAccess,
|
WriteAccess,
|
||||||
|
IssueTree,
|
||||||
ReadAccess,
|
ReadAccess,
|
||||||
ReadModelNotifier,
|
ReadModelNotifier,
|
||||||
...COMMAND_HANDLERS,
|
...COMMAND_HANDLERS,
|
||||||
|
|||||||
@@ -256,6 +256,13 @@ export class InMemoryTrackerStreams extends TrackerStreamsRepository {
|
|||||||
(d) => (d['link'] as { targetIssueId: string }).targetIssueId === issueId,
|
(d) => (d['link'] as { targetIssueId: string }).targetIssueId === issueId,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async issueIdsEverUnder(issueId: string) {
|
||||||
|
return this.streams(
|
||||||
|
'IssueParentChangedEvent',
|
||||||
|
(d) => d['parentId'] === issueId,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Attachment storage double: files in a map, keyed like on disk. */
|
/** Attachment storage double: files in a map, keyed like on disk. */
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ export * from './lib/issue/issue';
|
|||||||
export * from './lib/issue/issue.enums';
|
export * from './lib/issue/issue.enums';
|
||||||
export * from './lib/issue/issue-attachment';
|
export * from './lib/issue/issue-attachment';
|
||||||
export * from './lib/issue/mentions';
|
export * from './lib/issue/mentions';
|
||||||
|
export * from './lib/issue/issue-hierarchy';
|
||||||
|
export * from './lib/issue/issue-checklist';
|
||||||
export * from './lib/project/project-settings';
|
export * from './lib/project/project-settings';
|
||||||
export * from './lib/comment/comment';
|
export * from './lib/comment/comment';
|
||||||
export * from './lib/release/release';
|
export * from './lib/release/release';
|
||||||
|
|||||||
@@ -26,3 +26,7 @@ export * from './issue-attachment-added.event';
|
|||||||
export * from './issue-attachment-removed.event';
|
export * from './issue-attachment-removed.event';
|
||||||
export * from './issue-watched.event';
|
export * from './issue-watched.event';
|
||||||
export * from './issue-unwatched.event';
|
export * from './issue-unwatched.event';
|
||||||
|
export * from './issue-parent-changed.event';
|
||||||
|
export * from './issue-checklist-item-added.event';
|
||||||
|
export * from './issue-checklist-item-updated.event';
|
||||||
|
export * from './issue-checklist-item-removed.event';
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||||
|
import { ChecklistItem } from '../issue/issue-checklist';
|
||||||
|
import { TrackerEvent } from './tracker.event';
|
||||||
|
|
||||||
|
/** A step was added to a ticket's checklist. */
|
||||||
|
@AutowiredEvent
|
||||||
|
export class IssueChecklistItemAddedEvent extends TrackerEvent {
|
||||||
|
constructor(
|
||||||
|
actorId: string,
|
||||||
|
issueId: string,
|
||||||
|
projectId: string,
|
||||||
|
readonly item: ChecklistItem,
|
||||||
|
) {
|
||||||
|
super(actorId, issueId, projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||||
|
import { TrackerEvent } from './tracker.event';
|
||||||
|
|
||||||
|
/** A step was removed from a ticket's checklist. */
|
||||||
|
@AutowiredEvent
|
||||||
|
export class IssueChecklistItemRemovedEvent extends TrackerEvent {
|
||||||
|
constructor(
|
||||||
|
actorId: string,
|
||||||
|
issueId: string,
|
||||||
|
projectId: string,
|
||||||
|
readonly itemId: string,
|
||||||
|
readonly text: string,
|
||||||
|
) {
|
||||||
|
super(actorId, issueId, projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||||
|
import { ChecklistItem } from '../issue/issue-checklist';
|
||||||
|
import { TrackerEvent } from './tracker.event';
|
||||||
|
|
||||||
|
/** A checklist step was reworded, ticked or unticked (only what changed). */
|
||||||
|
@AutowiredEvent
|
||||||
|
export class IssueChecklistItemUpdatedEvent extends TrackerEvent {
|
||||||
|
constructor(
|
||||||
|
actorId: string,
|
||||||
|
issueId: string,
|
||||||
|
projectId: string,
|
||||||
|
readonly itemId: string,
|
||||||
|
readonly changes: Partial<Pick<ChecklistItem, 'text' | 'done'>>,
|
||||||
|
) {
|
||||||
|
super(actorId, issueId, projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||||
|
import { TrackerEvent } from './tracker.event';
|
||||||
|
|
||||||
|
/** A ticket was placed under another one (or taken out: `parentId` null). */
|
||||||
|
@AutowiredEvent
|
||||||
|
export class IssueParentChangedEvent extends TrackerEvent {
|
||||||
|
constructor(
|
||||||
|
actorId: string,
|
||||||
|
issueId: string,
|
||||||
|
projectId: string,
|
||||||
|
readonly parentId: string | null,
|
||||||
|
readonly parentKey: string | null,
|
||||||
|
readonly previousParentId: string | null,
|
||||||
|
) {
|
||||||
|
super(actorId, issueId, projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
22
libs/api/tracker/domain/src/lib/issue/issue-checklist.ts
Normal file
22
libs/api/tracker/domain/src/lib/issue/issue-checklist.ts
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import { assertThat } from '@task-stream/api/shared/domain';
|
||||||
|
|
||||||
|
/** A step inside a ticket that does not deserve a ticket of its own. */
|
||||||
|
export interface ChecklistItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MAX_CHECKLIST_ITEMS = 100;
|
||||||
|
export const MAX_CHECKLIST_TEXT = 300;
|
||||||
|
|
||||||
|
/** Trims and validates the wording of a checklist item. */
|
||||||
|
export function normalizeChecklistText(text: string): string {
|
||||||
|
const next = text.trim();
|
||||||
|
assertThat(next.length > 0, 'A checklist item needs some text');
|
||||||
|
assertThat(
|
||||||
|
next.length <= MAX_CHECKLIST_TEXT,
|
||||||
|
`A checklist item is at most ${MAX_CHECKLIST_TEXT} characters`,
|
||||||
|
);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
42
libs/api/tracker/domain/src/lib/issue/issue-hierarchy.ts
Normal file
42
libs/api/tracker/domain/src/lib/issue/issue-hierarchy.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Levels below the top a ticket can sit: epic → ticket → subtask. Deeper
|
||||||
|
* trees are hard to read on a board and nobody plans with them.
|
||||||
|
*/
|
||||||
|
export const MAX_HIERARCHY_DEPTH = 2;
|
||||||
|
|
||||||
|
/** The would-be parent of a ticket, as the command handler found it. */
|
||||||
|
export interface ParentCandidate {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
projectId: string;
|
||||||
|
/** Its own parent, grandparent… (closest first). */
|
||||||
|
ancestorIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks that `childId` (of `childProjectId`, with descendants `childHeight`
|
||||||
|
* levels deep) may hang from `parent`.
|
||||||
|
*/
|
||||||
|
export function assertCanNest(
|
||||||
|
child: { id: string; key: string; projectId: string },
|
||||||
|
parent: ParentCandidate,
|
||||||
|
childHeight: number,
|
||||||
|
): void {
|
||||||
|
if (parent.id === child.id)
|
||||||
|
throw new BusinessRuleError(`${child.key} cannot be its own parent`);
|
||||||
|
if (parent.projectId !== child.projectId)
|
||||||
|
throw new BusinessRuleError(
|
||||||
|
`${parent.key} belongs to another project; a parent must be in the same project`,
|
||||||
|
);
|
||||||
|
if (parent.ancestorIds.includes(child.id))
|
||||||
|
throw new BusinessRuleError(
|
||||||
|
`${parent.key} already hangs from ${child.key}; that would make a loop`,
|
||||||
|
);
|
||||||
|
const depth = parent.ancestorIds.length + 1;
|
||||||
|
if (depth + childHeight > MAX_HIERARCHY_DEPTH)
|
||||||
|
throw new BusinessRuleError(
|
||||||
|
`Tickets nest at most ${MAX_HIERARCHY_DEPTH} levels (epic → ticket → subtask)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -4,10 +4,12 @@ import {
|
|||||||
ValidationError,
|
ValidationError,
|
||||||
} from '@task-stream/api/shared/domain';
|
} from '@task-stream/api/shared/domain';
|
||||||
import { IssueCreatedEvent } from '../events/issue-created.event';
|
import { IssueCreatedEvent } from '../events/issue-created.event';
|
||||||
|
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
|
||||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||||
import { IssueFactory } from '../factories/issue.factory';
|
import { IssueFactory } from '../factories/issue.factory';
|
||||||
import { replay } from '../testing';
|
import { replay } from '../testing';
|
||||||
import { Issue } from './issue';
|
import { Issue } from './issue';
|
||||||
|
import { ParentCandidate } from './issue-hierarchy';
|
||||||
import { mentionedUserIds, withoutMentionMarkup } from './mentions';
|
import { mentionedUserIds, withoutMentionMarkup } from './mentions';
|
||||||
import { IssueLinkType, IssueStatus, IssueType } from './issue.enums';
|
import { IssueLinkType, IssueStatus, IssueType } from './issue.enums';
|
||||||
|
|
||||||
@@ -223,6 +225,93 @@ describe('Issue', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('hierarchy', () => {
|
||||||
|
const parent = (over: Partial<ParentCandidate> = {}): ParentCandidate => ({
|
||||||
|
id: 'epic-1',
|
||||||
|
key: 'TS-1',
|
||||||
|
projectId: 'p-1',
|
||||||
|
ancestorIds: [],
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hangs from a parent, moves and leaves, and survives a replay', () => {
|
||||||
|
const issue = newIssue();
|
||||||
|
issue.setParent('u-pm', parent());
|
||||||
|
issue.setParent('u-pm', parent());
|
||||||
|
issue.setParent('u-pm', parent({ id: 'epic-2', key: 'TS-2' }));
|
||||||
|
issue.setParent('u-pm', null);
|
||||||
|
issue.setParent('u-pm', parent({ id: 'epic-3', key: 'TS-3' }));
|
||||||
|
|
||||||
|
const changes = issue
|
||||||
|
.getUncommittedEvents()
|
||||||
|
.filter((e) => e instanceof IssueParentChangedEvent);
|
||||||
|
expect(changes.map((e) => [e.parentId, e.previousParentId])).toEqual([
|
||||||
|
['epic-1', null],
|
||||||
|
['epic-2', 'epic-1'],
|
||||||
|
[null, 'epic-2'],
|
||||||
|
['epic-3', null],
|
||||||
|
]);
|
||||||
|
expect(replay(issue, Issue).rebuilt.parentId).toBe('epic-3');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses itself, another project, loops and a fourth level', () => {
|
||||||
|
const issue = newIssue();
|
||||||
|
expect(() => issue.setParent('u', parent({ id: issue.id }))).toThrow(
|
||||||
|
BusinessRuleError,
|
||||||
|
);
|
||||||
|
expect(() => issue.setParent('u', parent({ projectId: 'p-2' }))).toThrow(
|
||||||
|
/another project/,
|
||||||
|
);
|
||||||
|
expect(() =>
|
||||||
|
issue.setParent('u', parent({ ancestorIds: [issue.id] })),
|
||||||
|
).toThrow(/loop/);
|
||||||
|
// Under a ticket that hangs from an epic: it becomes a subtask.
|
||||||
|
expect(() =>
|
||||||
|
issue.setParent('u', parent({ ancestorIds: ['t-1'] })),
|
||||||
|
).not.toThrow();
|
||||||
|
expect(() =>
|
||||||
|
issue.setParent('u', parent({ id: 'x', ancestorIds: ['t-1', 'e-1'] })),
|
||||||
|
).toThrow(/at most 2 levels/);
|
||||||
|
// A ticket with subtasks cannot go below a ticket that has a parent.
|
||||||
|
expect(() =>
|
||||||
|
issue.setParent('u', parent({ id: 'y', ancestorIds: ['e-1'] }), 1),
|
||||||
|
).toThrow(/at most 2 levels/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('checklist', () => {
|
||||||
|
it('adds, ticks, rewords and removes steps, and survives a replay', () => {
|
||||||
|
const issue = newIssue();
|
||||||
|
issue.addChecklistItem('u-dev', 'c-1', ' Write the migration ');
|
||||||
|
issue.addChecklistItem('u-dev', 'c-2', 'Update the docs');
|
||||||
|
issue.addChecklistItem('u-dev', 'c-1', 'Duplicate id is ignored');
|
||||||
|
issue.updateChecklistItem('u-dev', 'c-1', { done: true });
|
||||||
|
issue.updateChecklistItem('u-dev', 'c-1', {
|
||||||
|
done: true,
|
||||||
|
text: 'Write the migration',
|
||||||
|
});
|
||||||
|
issue.updateChecklistItem('u-dev', 'c-2', { text: 'Update the README' });
|
||||||
|
issue.removeChecklistItem('u-dev', 'c-2');
|
||||||
|
|
||||||
|
expect(issue.checklist).toEqual([
|
||||||
|
{ id: 'c-1', text: 'Write the migration', done: true },
|
||||||
|
]);
|
||||||
|
expect(replay(issue, Issue).rebuilt.checklist).toEqual(issue.checklist);
|
||||||
|
// created + 2 added + ticked + reworded + removed
|
||||||
|
expect(issue.getUncommittedEvents()).toHaveLength(6);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses empty steps and unknown items', () => {
|
||||||
|
const issue = newIssue();
|
||||||
|
expect(() => issue.addChecklistItem('u', 'c-1', ' ')).toThrow(
|
||||||
|
ValidationError,
|
||||||
|
);
|
||||||
|
expect(() => issue.removeChecklistItem('u', 'nope')).toThrow(
|
||||||
|
BusinessRuleError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('mentions', () => {
|
describe('mentions', () => {
|
||||||
it('reads who a text mentions, once each', () => {
|
it('reads who a text mentions, once each', () => {
|
||||||
const text =
|
const text =
|
||||||
|
|||||||
@@ -7,9 +7,13 @@ import {
|
|||||||
import { IssueAssignedEvent } from '../events/issue-assigned.event';
|
import { IssueAssignedEvent } from '../events/issue-assigned.event';
|
||||||
import { IssueAttachmentAddedEvent } from '../events/issue-attachment-added.event';
|
import { IssueAttachmentAddedEvent } from '../events/issue-attachment-added.event';
|
||||||
import { IssueAttachmentRemovedEvent } from '../events/issue-attachment-removed.event';
|
import { IssueAttachmentRemovedEvent } from '../events/issue-attachment-removed.event';
|
||||||
|
import { IssueChecklistItemAddedEvent } from '../events/issue-checklist-item-added.event';
|
||||||
|
import { IssueChecklistItemRemovedEvent } from '../events/issue-checklist-item-removed.event';
|
||||||
|
import { IssueChecklistItemUpdatedEvent } from '../events/issue-checklist-item-updated.event';
|
||||||
import { IssueCreatedEvent } from '../events/issue-created.event';
|
import { IssueCreatedEvent } from '../events/issue-created.event';
|
||||||
import { IssueDeletedEvent } from '../events/issue-deleted.event';
|
import { IssueDeletedEvent } from '../events/issue-deleted.event';
|
||||||
import { IssueLinkedEvent } from '../events/issue-linked.event';
|
import { IssueLinkedEvent } from '../events/issue-linked.event';
|
||||||
|
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
|
||||||
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
|
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
|
||||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||||
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
|
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
|
||||||
@@ -22,6 +26,12 @@ import {
|
|||||||
MAX_ATTACHMENTS_PER_ISSUE,
|
MAX_ATTACHMENTS_PER_ISSUE,
|
||||||
normalizeAttachment,
|
normalizeAttachment,
|
||||||
} from './issue-attachment';
|
} from './issue-attachment';
|
||||||
|
import {
|
||||||
|
ChecklistItem,
|
||||||
|
MAX_CHECKLIST_ITEMS,
|
||||||
|
normalizeChecklistText,
|
||||||
|
} from './issue-checklist';
|
||||||
|
import { ParentCandidate, assertCanNest } from './issue-hierarchy';
|
||||||
import { IssueLinkType, IssuePriority } from './issue.enums';
|
import { IssueLinkType, IssuePriority } from './issue.enums';
|
||||||
|
|
||||||
export interface IssueLink {
|
export interface IssueLink {
|
||||||
@@ -95,6 +105,9 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
|||||||
public attachments: IssueAttachment[] = [];
|
public attachments: IssueAttachment[] = [];
|
||||||
/** People who asked to be told about this ticket. */
|
/** People who asked to be told about this ticket. */
|
||||||
public watchers: string[] = [];
|
public watchers: string[] = [];
|
||||||
|
/** The epic or ticket this one breaks down. */
|
||||||
|
public parentId: string | null = null;
|
||||||
|
public checklist: ChecklistItem[] = [];
|
||||||
public createdAt!: string;
|
public createdAt!: string;
|
||||||
public updatedAt!: string;
|
public updatedAt!: string;
|
||||||
public deleted = false;
|
public deleted = false;
|
||||||
@@ -280,6 +293,91 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Places the ticket under `parent` (null takes it out). `childHeight` is
|
||||||
|
* how many levels of tickets already hang from this one.
|
||||||
|
*/
|
||||||
|
setParent(
|
||||||
|
actorId: string,
|
||||||
|
parent: ParentCandidate | null,
|
||||||
|
childHeight = 0,
|
||||||
|
): void {
|
||||||
|
if ((parent?.id ?? null) === this.parentId) return;
|
||||||
|
if (parent) assertCanNest(this, parent, childHeight);
|
||||||
|
this.apply(
|
||||||
|
new IssueParentChangedEvent(
|
||||||
|
actorId,
|
||||||
|
this.id,
|
||||||
|
this.projectId,
|
||||||
|
parent?.id ?? null,
|
||||||
|
parent?.key ?? null,
|
||||||
|
this.parentId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Adds a step to the checklist; `id` is generated by the caller. */
|
||||||
|
addChecklistItem(actorId: string, id: string, text: string): void {
|
||||||
|
if (this.checklist.some((i) => i.id === id)) return;
|
||||||
|
if (this.checklist.length >= MAX_CHECKLIST_ITEMS) {
|
||||||
|
throw new BusinessRuleError(
|
||||||
|
`${this.key} already has ${MAX_CHECKLIST_ITEMS} checklist items`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.apply(
|
||||||
|
new IssueChecklistItemAddedEvent(actorId, this.id, this.projectId, {
|
||||||
|
id,
|
||||||
|
text: normalizeChecklistText(text),
|
||||||
|
done: false,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rewords, ticks or unticks a step. Nothing changes, nothing happens. */
|
||||||
|
updateChecklistItem(
|
||||||
|
actorId: string,
|
||||||
|
itemId: string,
|
||||||
|
changes: { text?: string; done?: boolean },
|
||||||
|
): void {
|
||||||
|
const item = this.checklistItem(itemId);
|
||||||
|
const patch: { text?: string; done?: boolean } = {};
|
||||||
|
if (changes.text !== undefined) {
|
||||||
|
const text = normalizeChecklistText(changes.text);
|
||||||
|
if (text !== item.text) patch.text = text;
|
||||||
|
}
|
||||||
|
if (changes.done !== undefined && changes.done !== item.done)
|
||||||
|
patch.done = changes.done;
|
||||||
|
if (Object.keys(patch).length === 0) return;
|
||||||
|
this.apply(
|
||||||
|
new IssueChecklistItemUpdatedEvent(
|
||||||
|
actorId,
|
||||||
|
this.id,
|
||||||
|
this.projectId,
|
||||||
|
itemId,
|
||||||
|
patch,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChecklistItem(actorId: string, itemId: string): void {
|
||||||
|
const item = this.checklistItem(itemId);
|
||||||
|
this.apply(
|
||||||
|
new IssueChecklistItemRemovedEvent(
|
||||||
|
actorId,
|
||||||
|
this.id,
|
||||||
|
this.projectId,
|
||||||
|
item.id,
|
||||||
|
item.text,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private checklistItem(itemId: string): ChecklistItem {
|
||||||
|
const item = this.checklist.find((i) => i.id === itemId);
|
||||||
|
if (!item) throw new BusinessRuleError('Checklist item not found');
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
/** Deletes the ticket (its project was deleted). Idempotent. */
|
/** Deletes the ticket (its project was deleted). Idempotent. */
|
||||||
delete(actorId: string): void {
|
delete(actorId: string): void {
|
||||||
if (this.deleted) return;
|
if (this.deleted) return;
|
||||||
@@ -360,6 +458,32 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
|||||||
this.watchers = this.watchers.filter((id) => id !== event.userId);
|
this.watchers = this.watchers.filter((id) => id !== event.userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onIssueParentChangedEvent(event: IssueParentChangedEvent): void {
|
||||||
|
this.parentId = event.parentId;
|
||||||
|
this.updatedAt = event.occurredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
onIssueChecklistItemAddedEvent(event: IssueChecklistItemAddedEvent): void {
|
||||||
|
this.checklist = [...this.checklist, { ...event.item }];
|
||||||
|
this.updatedAt = event.occurredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
onIssueChecklistItemUpdatedEvent(
|
||||||
|
event: IssueChecklistItemUpdatedEvent,
|
||||||
|
): void {
|
||||||
|
this.checklist = this.checklist.map((i) =>
|
||||||
|
i.id === event.itemId ? { ...i, ...event.changes } : i,
|
||||||
|
);
|
||||||
|
this.updatedAt = event.occurredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
onIssueChecklistItemRemovedEvent(
|
||||||
|
event: IssueChecklistItemRemovedEvent,
|
||||||
|
): void {
|
||||||
|
this.checklist = this.checklist.filter((i) => i.id !== event.itemId);
|
||||||
|
this.updatedAt = event.occurredAt;
|
||||||
|
}
|
||||||
|
|
||||||
onIssueDeletedEvent(): void {
|
onIssueDeletedEvent(): void {
|
||||||
this.deleted = true;
|
this.deleted = true;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,35 @@ export class AttachmentReadModel {
|
|||||||
commentId!: string | null;
|
commentId!: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ChecklistItemReadModel {
|
||||||
|
id!: string;
|
||||||
|
text!: string;
|
||||||
|
done!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The ticket another one hangs from. */
|
||||||
|
export class IssueParentReadModel {
|
||||||
|
id!: string;
|
||||||
|
key!: string;
|
||||||
|
title!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far the tickets hanging from one have got. */
|
||||||
|
export class ChildProgressReadModel {
|
||||||
|
total!: number;
|
||||||
|
/** Done or cancelled. */
|
||||||
|
finished!: number;
|
||||||
|
percent!: number;
|
||||||
|
storyPoints!: number;
|
||||||
|
finishedStoryPoints!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A top-level ticket that groups others, with their progress. */
|
||||||
|
export class EpicReadModel {
|
||||||
|
issue!: IssueReadModel;
|
||||||
|
progress!: ChildProgressReadModel;
|
||||||
|
}
|
||||||
|
|
||||||
export class IssueReadModel {
|
export class IssueReadModel {
|
||||||
id!: string;
|
id!: string;
|
||||||
projectId!: string;
|
projectId!: string;
|
||||||
@@ -49,6 +78,8 @@ export class IssueReadModel {
|
|||||||
attachments!: AttachmentReadModel[];
|
attachments!: AttachmentReadModel[];
|
||||||
/** People following the ticket. */
|
/** People following the ticket. */
|
||||||
watchers!: UserRefReadModel[];
|
watchers!: UserRefReadModel[];
|
||||||
|
parent!: IssueParentReadModel | null;
|
||||||
|
checklist!: ChecklistItemReadModel[];
|
||||||
commentCount!: number;
|
commentCount!: number;
|
||||||
createdAt!: string;
|
createdAt!: string;
|
||||||
updatedAt!: string;
|
updatedAt!: string;
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ActivityReadModel,
|
ActivityReadModel,
|
||||||
CommentReadModel,
|
CommentReadModel,
|
||||||
|
EpicReadModel,
|
||||||
CountBy,
|
CountBy,
|
||||||
defaultProjectSettings,
|
defaultProjectSettings,
|
||||||
IssueReadModel,
|
IssueReadModel,
|
||||||
@@ -228,6 +229,53 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
|||||||
return docs.map(issueFromDoc);
|
return docs.map(issueFromDoc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async epics(projectId: string): Promise<EpicReadModel[]> {
|
||||||
|
const finished = { $in: ['$statusCategory', FINISHED] };
|
||||||
|
const points = { $ifNull: ['$storyPoints', 0] };
|
||||||
|
const rows = await this.issues.aggregate<{
|
||||||
|
_id: string;
|
||||||
|
total: number;
|
||||||
|
finished: number;
|
||||||
|
storyPoints: number;
|
||||||
|
finishedStoryPoints: number;
|
||||||
|
}>([
|
||||||
|
{ $match: { projectId, 'parent.id': { $type: 'string' } } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: '$parent.id',
|
||||||
|
total: { $sum: 1 },
|
||||||
|
finished: { $sum: { $cond: [finished, 1, 0] } },
|
||||||
|
storyPoints: { $sum: points },
|
||||||
|
finishedStoryPoints: { $sum: { $cond: [finished, points, 0] } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
if (rows.length === 0) return [];
|
||||||
|
const progress = new Map(rows.map((row) => [row._id, row]));
|
||||||
|
// Only the top of each tree: a ticket with subtasks under an epic is not one.
|
||||||
|
const docs = await this.issues
|
||||||
|
.find({ _id: { $in: [...progress.keys()] }, 'parent.id': null })
|
||||||
|
.sort({ number: 1 })
|
||||||
|
.lean<IssueDoc[]>();
|
||||||
|
return docs.map((doc) => {
|
||||||
|
const { _id: _ignored, ...row } = progress.get(doc._id) ?? {
|
||||||
|
_id: doc._id,
|
||||||
|
total: 0,
|
||||||
|
finished: 0,
|
||||||
|
storyPoints: 0,
|
||||||
|
finishedStoryPoints: 0,
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
issue: issueFromDoc(doc),
|
||||||
|
progress: {
|
||||||
|
...row,
|
||||||
|
percent:
|
||||||
|
row.total === 0 ? 0 : Math.round((row.finished / row.total) * 100),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async releaseProgress(releaseId: string): Promise<ReleaseProgressReadModel> {
|
async releaseProgress(releaseId: string): Promise<ReleaseProgressReadModel> {
|
||||||
const rows = await this.issues.aggregate<{
|
const rows = await this.issues.aggregate<{
|
||||||
_id: { status: string; category: StatusCategory };
|
_id: { status: string; category: StatusCategory };
|
||||||
@@ -268,6 +316,7 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
|||||||
if (filter.labels?.length)
|
if (filter.labels?.length)
|
||||||
query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) };
|
query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) };
|
||||||
if (filter.releaseId) query['release.id'] = filter.releaseId;
|
if (filter.releaseId) query['release.id'] = filter.releaseId;
|
||||||
|
if (filter.parentId) query['parent.id'] = filter.parentId;
|
||||||
if (filter.unassigned) query['assignee'] = null;
|
if (filter.unassigned) query['assignee'] = null;
|
||||||
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
||||||
if (filter.search?.trim()) {
|
if (filter.search?.trim()) {
|
||||||
|
|||||||
@@ -78,6 +78,8 @@ export const IssueSchema = new Schema<IssueDoc>(
|
|||||||
outwardLinks: [link],
|
outwardLinks: [link],
|
||||||
inwardLinks: [link],
|
inwardLinks: [link],
|
||||||
watchers: [userRef],
|
watchers: [userRef],
|
||||||
|
parent: { _id: false, id: String, key: String, title: String },
|
||||||
|
checklist: [{ _id: false, id: String, text: String, done: Boolean }],
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
_id: false,
|
_id: false,
|
||||||
@@ -99,6 +101,7 @@ IssueSchema.index({ projectId: 1, status: 1, updatedAt: -1 });
|
|||||||
IssueSchema.index({ projectId: 1, statusCategory: 1 });
|
IssueSchema.index({ projectId: 1, statusCategory: 1 });
|
||||||
IssueSchema.index({ 'assignee.id': 1 });
|
IssueSchema.index({ 'assignee.id': 1 });
|
||||||
IssueSchema.index({ 'watchers.id': 1 });
|
IssueSchema.index({ 'watchers.id': 1 });
|
||||||
|
IssueSchema.index({ 'parent.id': 1 });
|
||||||
IssueSchema.index({ 'release.id': 1 });
|
IssueSchema.index({ 'release.id': 1 });
|
||||||
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
|
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
|
||||||
|
|
||||||
@@ -182,5 +185,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
|||||||
// Tickets projected before attachments existed have none.
|
// Tickets projected before attachments existed have none.
|
||||||
attachments: rest.attachments ?? [],
|
attachments: rest.attachments ?? [],
|
||||||
watchers: rest.watchers ?? [],
|
watchers: rest.watchers ?? [],
|
||||||
|
parent: rest.parent?.id ? rest.parent : null,
|
||||||
|
checklist: rest.checklist ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import {
|
import {
|
||||||
ActivityReadModel,
|
ActivityReadModel,
|
||||||
AttachmentReadModel,
|
AttachmentReadModel,
|
||||||
|
ChecklistItemReadModel,
|
||||||
CommentReadModel,
|
CommentReadModel,
|
||||||
IssueLinkReadModel,
|
IssueLinkReadModel,
|
||||||
IssueReadModel,
|
IssueReadModel,
|
||||||
@@ -130,6 +131,45 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async addChecklistItem(issueId: string, item: ChecklistItemReadModel) {
|
||||||
|
await this.issues.updateOne(
|
||||||
|
{ _id: issueId, 'checklist.id': { $ne: item.id } },
|
||||||
|
{ $push: { checklist: item } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async updateChecklistItem(
|
||||||
|
issueId: string,
|
||||||
|
itemId: string,
|
||||||
|
changes: Partial<Pick<ChecklistItemReadModel, 'text' | 'done'>>,
|
||||||
|
) {
|
||||||
|
const set = Object.fromEntries(
|
||||||
|
Object.entries(changes).map(([field, value]) => [
|
||||||
|
`checklist.$.${field}`,
|
||||||
|
value,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
if (Object.keys(set).length === 0) return;
|
||||||
|
await this.issues.updateOne(
|
||||||
|
{ _id: issueId, 'checklist.id': itemId },
|
||||||
|
{ $set: set },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async removeChecklistItem(issueId: string, itemId: string) {
|
||||||
|
await this.issues.updateOne(
|
||||||
|
{ _id: issueId },
|
||||||
|
{ $pull: { checklist: { id: itemId } } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async renameParent(parentId: string, title: string) {
|
||||||
|
await this.issues.updateMany(
|
||||||
|
{ 'parent.id': parentId },
|
||||||
|
{ $set: { 'parent.title': title } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async addAttachment(issueId: string, attachment: AttachmentReadModel) {
|
async addAttachment(issueId: string, attachment: AttachmentReadModel) {
|
||||||
await this.issues.updateOne(
|
await this.issues.updateOne(
|
||||||
{ _id: issueId, 'attachments.id': { $ne: attachment.id } },
|
{ _id: issueId, 'attachments.id': { $ne: attachment.id } },
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
CommentAddedEvent,
|
CommentAddedEvent,
|
||||||
IssueCreatedEvent,
|
IssueCreatedEvent,
|
||||||
IssueLinkedEvent,
|
IssueLinkedEvent,
|
||||||
|
IssueParentChangedEvent,
|
||||||
ReleaseCreatedEvent,
|
ReleaseCreatedEvent,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
@@ -47,6 +48,14 @@ export class PostgresTrackerStreamsRepository extends TrackerStreamsRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
issueIdsEverUnder(issueId: string): Promise<string[]> {
|
||||||
|
return this.streams(
|
||||||
|
IssueParentChangedEvent.name,
|
||||||
|
`data->>'parentId'`,
|
||||||
|
issueId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** `field` is one of the fixed JSON paths above, never user input. */
|
/** `field` is one of the fixed JSON paths above, never user input. */
|
||||||
private async streams(
|
private async streams(
|
||||||
type: string,
|
type: string,
|
||||||
|
|||||||
@@ -140,6 +140,13 @@ export class CreateIssueInput extends IssueFieldsInput {
|
|||||||
@Field(() => ID, { nullable: true }) @IsOptional() @IsUUID() releaseId?:
|
@Field(() => ID, { nullable: true }) @IsOptional() @IsUUID() releaseId?:
|
||||||
| string
|
| string
|
||||||
| null;
|
| null;
|
||||||
|
@Field(() => ID, {
|
||||||
|
nullable: true,
|
||||||
|
description: 'Epic or ticket of the same project the new one breaks down',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
parentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
@@ -173,6 +180,25 @@ export class ChangeIssueStatusInput {
|
|||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class AddChecklistItemInput {
|
||||||
|
@Field(() => ID) @IsUUID() issueId!: string;
|
||||||
|
@Field() @IsString() @MinLength(1) @MaxLength(300) text!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class UpdateChecklistItemInput {
|
||||||
|
@Field(() => ID) @IsUUID() issueId!: string;
|
||||||
|
@Field(() => ID) @IsUUID() itemId!: string;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(300)
|
||||||
|
text?: string;
|
||||||
|
@Field({ nullable: true }) @IsOptional() @IsBoolean() done?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
export class LinkIssuesInput {
|
export class LinkIssuesInput {
|
||||||
@Field(() => ID) @IsUUID() issueId!: string;
|
@Field(() => ID) @IsUUID() issueId!: string;
|
||||||
@@ -216,6 +242,13 @@ export class IssueFilterInput {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
labels?: string[];
|
labels?: string[];
|
||||||
|
@Field(() => ID, {
|
||||||
|
nullable: true,
|
||||||
|
description: 'Only the tickets hanging from this one',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
parentId?: string;
|
||||||
@Field({ nullable: true, description: 'Matches key, title or description' })
|
@Field({ nullable: true, description: 'Matches key, title or description' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ import {
|
|||||||
StatusCategory,
|
StatusCategory,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import {
|
import {
|
||||||
|
AddChecklistItemInput,
|
||||||
ChangeIssueStatusInput,
|
ChangeIssueStatusInput,
|
||||||
CreateIssueInput,
|
CreateIssueInput,
|
||||||
IssueFilterInput,
|
IssueFilterInput,
|
||||||
LinkIssuesInput,
|
LinkIssuesInput,
|
||||||
|
UpdateChecklistItemInput,
|
||||||
UpdateIssueInput,
|
UpdateIssueInput,
|
||||||
} from './inputs';
|
} from './inputs';
|
||||||
import { withoutNulls } from '../common/issue-input';
|
import { withoutNulls } from '../common/issue-input';
|
||||||
@@ -35,6 +37,7 @@ import {
|
|||||||
ActivityGql,
|
ActivityGql,
|
||||||
CommandResultGql,
|
CommandResultGql,
|
||||||
CommentPageGql,
|
CommentPageGql,
|
||||||
|
EpicGql,
|
||||||
IssueGql,
|
IssueGql,
|
||||||
IssuePageGql,
|
IssuePageGql,
|
||||||
} from './types';
|
} from './types';
|
||||||
@@ -72,6 +75,18 @@ export class IssueResolver {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptionalAuth()
|
||||||
|
@Query(() => [EpicGql], {
|
||||||
|
description:
|
||||||
|
"A project's epics: top-level tickets that group others, with the progress of those",
|
||||||
|
})
|
||||||
|
epics(
|
||||||
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Args('projectId', { type: () => ID }) projectId: string,
|
||||||
|
) {
|
||||||
|
return this.issuesService.epics(viewerOf(me), projectId);
|
||||||
|
}
|
||||||
|
|
||||||
@Query(() => IssuePageGql, {
|
@Query(() => IssuePageGql, {
|
||||||
description:
|
description:
|
||||||
'Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])',
|
'Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])',
|
||||||
@@ -238,6 +253,56 @@ export class IssueResolver {
|
|||||||
return this.issuesService.unlink(me.id, input);
|
return this.issuesService.unlink(me.id, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Mutation(() => CommandResultGql, {
|
||||||
|
description:
|
||||||
|
'Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask.',
|
||||||
|
})
|
||||||
|
setIssueParent(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('issueId', { type: () => ID }) issueId: string,
|
||||||
|
@Args('parentId', { type: () => ID, nullable: true })
|
||||||
|
parentId: string | null,
|
||||||
|
) {
|
||||||
|
return this.issuesService.setParent(me.id, issueId, parentId ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => CommandResultGql, {
|
||||||
|
description: "Add a step to a ticket's checklist",
|
||||||
|
})
|
||||||
|
addChecklistItem(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('input') input: AddChecklistItemInput,
|
||||||
|
) {
|
||||||
|
return this.issuesService.addChecklistItem(
|
||||||
|
me.id,
|
||||||
|
input.issueId,
|
||||||
|
input.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => CommandResultGql, {
|
||||||
|
description: 'Reword, tick or untick a checklist step',
|
||||||
|
})
|
||||||
|
updateChecklistItem(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('input') input: UpdateChecklistItemInput,
|
||||||
|
) {
|
||||||
|
const { issueId, itemId, text, done } = input;
|
||||||
|
return this.issuesService.updateChecklistItem(me.id, issueId, itemId, {
|
||||||
|
...(text != null && { text }),
|
||||||
|
...(done != null && { done }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => CommandResultGql)
|
||||||
|
removeChecklistItem(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('issueId', { type: () => ID }) issueId: string,
|
||||||
|
@Args('itemId', { type: () => ID }) itemId: string,
|
||||||
|
) {
|
||||||
|
return this.issuesService.removeChecklistItem(me.id, issueId, itemId);
|
||||||
|
}
|
||||||
|
|
||||||
@Mutation(() => CommandResultGql, {
|
@Mutation(() => CommandResultGql, {
|
||||||
description: 'Put a ticket in a release (null removes it)',
|
description: 'Put a ticket in a release (null removes it)',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -116,6 +116,23 @@ export class ReleaseRefGql {
|
|||||||
@Field() name!: string;
|
@Field() name!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType('ChecklistItem', {
|
||||||
|
description:
|
||||||
|
'A step inside a ticket that does not deserve a ticket of its own',
|
||||||
|
})
|
||||||
|
export class ChecklistItemGql {
|
||||||
|
@Field(() => ID) id!: string;
|
||||||
|
@Field() text!: string;
|
||||||
|
@Field() done!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('IssueParent', { description: 'The ticket another one hangs from' })
|
||||||
|
export class IssueParentGql {
|
||||||
|
@Field(() => ID) id!: string;
|
||||||
|
@Field() key!: string;
|
||||||
|
@Field() title!: string;
|
||||||
|
}
|
||||||
|
|
||||||
@ObjectType('Issue')
|
@ObjectType('Issue')
|
||||||
export class IssueGql {
|
export class IssueGql {
|
||||||
@Field(() => ID) id!: string;
|
@Field(() => ID) id!: string;
|
||||||
@@ -145,12 +162,37 @@ export class IssueGql {
|
|||||||
@Field(() => [AttachmentGql]) attachments!: AttachmentGql[];
|
@Field(() => [AttachmentGql]) attachments!: AttachmentGql[];
|
||||||
@Field(() => [UserRefGql], { description: 'People following the ticket' })
|
@Field(() => [UserRefGql], { description: 'People following the ticket' })
|
||||||
watchers!: UserRefGql[];
|
watchers!: UserRefGql[];
|
||||||
|
@Field(() => IssueParentGql, {
|
||||||
|
nullable: true,
|
||||||
|
description: 'The epic or ticket this one breaks down',
|
||||||
|
})
|
||||||
|
parent!: IssueParentGql | null;
|
||||||
|
@Field(() => [ChecklistItemGql]) checklist!: ChecklistItemGql[];
|
||||||
@Field(() => Int) commentCount!: number;
|
@Field(() => Int) commentCount!: number;
|
||||||
@Field() createdAt!: string;
|
@Field() createdAt!: string;
|
||||||
@Field() updatedAt!: string;
|
@Field() updatedAt!: string;
|
||||||
@Field(() => Int) version!: number;
|
@Field(() => Int) version!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType('ChildProgress', {
|
||||||
|
description: 'How far the tickets hanging from one have got',
|
||||||
|
})
|
||||||
|
export class ChildProgressGql {
|
||||||
|
@Field(() => Int) total!: number;
|
||||||
|
@Field(() => Int, { description: 'Done or cancelled' }) finished!: number;
|
||||||
|
@Field(() => Int) percent!: number;
|
||||||
|
@Field(() => Int) storyPoints!: number;
|
||||||
|
@Field(() => Int) finishedStoryPoints!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('Epic', {
|
||||||
|
description: 'A top-level ticket that groups others, with their progress',
|
||||||
|
})
|
||||||
|
export class EpicGql {
|
||||||
|
@Field(() => IssueGql) issue!: IssueGql;
|
||||||
|
@Field(() => ChildProgressGql) progress!: ChildProgressGql;
|
||||||
|
}
|
||||||
|
|
||||||
@ObjectType('IssuePage')
|
@ObjectType('IssuePage')
|
||||||
export class IssuePageGql {
|
export class IssuePageGql {
|
||||||
@Field(() => [IssueGql]) items!: IssueGql[];
|
@Field(() => [IssueGql]) items!: IssueGql[];
|
||||||
|
|||||||
@@ -408,6 +408,14 @@ export class IssueSearchQueryDto extends PageQueryDto {
|
|||||||
@IsString({ each: true })
|
@IsString({ each: true })
|
||||||
label?: string[];
|
label?: string[];
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Only the tickets hanging from this one (id or key)',
|
||||||
|
example: 'WEB-3',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
parent?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ description: 'Matches key, title or description' })
|
@ApiPropertyOptional({ description: 'Matches key, title or description' })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@@ -501,6 +509,17 @@ export class CreateIssueDto extends IssueFieldsDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
releaseId?: string | null;
|
releaseId?: string | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
example: 'WEB-3',
|
||||||
|
description:
|
||||||
|
'Epic or ticket of the same project the new one breaks down (id or key)',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
parent?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdateIssueDto extends IssueFieldsDto {
|
export class UpdateIssueDto extends IssueFieldsDto {
|
||||||
@@ -560,6 +579,41 @@ export class SetIssueReleaseDto {
|
|||||||
releaseId!: string | null;
|
releaseId!: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SetIssueParentDto {
|
||||||
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
example: 'WEB-3',
|
||||||
|
description:
|
||||||
|
'Epic or ticket of the same project (id or key), or null to take it back to the top',
|
||||||
|
})
|
||||||
|
@ValidateIf((dto: SetIssueParentDto) => dto.parent !== null)
|
||||||
|
@IsString()
|
||||||
|
parent!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ChecklistItemBodyDto {
|
||||||
|
@ApiProperty({ example: 'Actualizar el changelog', maxLength: 300 })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(300)
|
||||||
|
text!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateChecklistItemDto {
|
||||||
|
@ApiPropertyOptional({ maxLength: 300 })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(300)
|
||||||
|
text?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional()
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
done?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export class LinkIssueDto {
|
export class LinkIssueDto {
|
||||||
@ApiProperty({ enum: IssueLinkType, enumName: 'IssueLinkType' })
|
@ApiProperty({ enum: IssueLinkType, enumName: 'IssueLinkType' })
|
||||||
@IsEnum(IssueLinkType)
|
@IsEnum(IssueLinkType)
|
||||||
|
|||||||
@@ -123,6 +123,18 @@ export class AttachmentDto {
|
|||||||
commentId!: string | null;
|
commentId!: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ChecklistItemDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||||
|
@ApiProperty({ example: 'Actualizar el changelog' }) text!: string;
|
||||||
|
@ApiProperty() done!: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class IssueParentDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||||
|
@ApiProperty({ example: 'WEB-3' }) key!: string;
|
||||||
|
@ApiProperty({ example: 'Pago con tarjeta' }) title!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class IssueDto {
|
export class IssueDto {
|
||||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||||
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||||
@@ -161,6 +173,13 @@ export class IssueDto {
|
|||||||
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
|
@ApiProperty({ type: [AttachmentDto] }) attachments!: AttachmentDto[];
|
||||||
@ApiProperty({ type: [UserRefDto], description: 'People following it' })
|
@ApiProperty({ type: [UserRefDto], description: 'People following it' })
|
||||||
watchers!: UserRefDto[];
|
watchers!: UserRefDto[];
|
||||||
|
@ApiProperty({
|
||||||
|
type: IssueParentDto,
|
||||||
|
nullable: true,
|
||||||
|
description: 'The epic or ticket this one breaks down',
|
||||||
|
})
|
||||||
|
parent!: IssueParentDto | null;
|
||||||
|
@ApiProperty({ type: [ChecklistItemDto] }) checklist!: ChecklistItemDto[];
|
||||||
@ApiProperty() commentCount!: number;
|
@ApiProperty() commentCount!: number;
|
||||||
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||||
@ApiProperty({ format: 'date-time' }) updatedAt!: string;
|
@ApiProperty({ format: 'date-time' }) updatedAt!: string;
|
||||||
@@ -233,6 +252,19 @@ export class CountByAssigneeDto {
|
|||||||
@ApiProperty() count!: number;
|
@ApiProperty() count!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class ChildProgressDto {
|
||||||
|
@ApiProperty() total!: number;
|
||||||
|
@ApiProperty({ description: 'Done or cancelled' }) finished!: number;
|
||||||
|
@ApiProperty({ minimum: 0, maximum: 100 }) percent!: number;
|
||||||
|
@ApiProperty() storyPoints!: number;
|
||||||
|
@ApiProperty() finishedStoryPoints!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class EpicDto {
|
||||||
|
@ApiProperty({ type: IssueDto }) issue!: IssueDto;
|
||||||
|
@ApiProperty({ type: ChildProgressDto }) progress!: ChildProgressDto;
|
||||||
|
}
|
||||||
|
|
||||||
export class ReleaseProgressDto {
|
export class ReleaseProgressDto {
|
||||||
@ApiProperty() total!: number;
|
@ApiProperty() total!: number;
|
||||||
@ApiProperty() finished!: number;
|
@ApiProperty() finished!: number;
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Delete,
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Put,
|
Put,
|
||||||
@@ -35,6 +36,7 @@ import { withoutNulls } from '../common/issue-input';
|
|||||||
import {
|
import {
|
||||||
AssignIssueDto,
|
AssignIssueDto,
|
||||||
ChangeStatusDto,
|
ChangeStatusDto,
|
||||||
|
ChecklistItemBodyDto,
|
||||||
CommentBodyDto,
|
CommentBodyDto,
|
||||||
CreateIssueDto,
|
CreateIssueDto,
|
||||||
IssueSearchQueryDto,
|
IssueSearchQueryDto,
|
||||||
@@ -42,13 +44,16 @@ import {
|
|||||||
LinkTypeParamDto,
|
LinkTypeParamDto,
|
||||||
MyIssuesQueryDto,
|
MyIssuesQueryDto,
|
||||||
PageQueryDto,
|
PageQueryDto,
|
||||||
|
SetIssueParentDto,
|
||||||
SetIssueReleaseDto,
|
SetIssueReleaseDto,
|
||||||
|
UpdateChecklistItemDto,
|
||||||
UpdateIssueDto,
|
UpdateIssueDto,
|
||||||
} from './dto/requests.dto';
|
} from './dto/requests.dto';
|
||||||
import {
|
import {
|
||||||
ActivityDto,
|
ActivityDto,
|
||||||
CommandResultDto,
|
CommandResultDto,
|
||||||
CommentPageDto,
|
CommentPageDto,
|
||||||
|
EpicDto,
|
||||||
IssueDto,
|
IssueDto,
|
||||||
IssuePageDto,
|
IssuePageDto,
|
||||||
} from './dto/responses.dto';
|
} from './dto/responses.dto';
|
||||||
@@ -139,6 +144,9 @@ export class IssuesController {
|
|||||||
) {
|
) {
|
||||||
const page = await this.issues.search(viewerOf(me), {
|
const page = await this.issues.search(viewerOf(me), {
|
||||||
projectId: await this.refs.projectId(viewerOf(me), project),
|
projectId: await this.refs.projectId(viewerOf(me), project),
|
||||||
|
parentId: query.parent
|
||||||
|
? await this.refs.issueId(viewerOf(me), query.parent)
|
||||||
|
: undefined,
|
||||||
statuses: query.status,
|
statuses: query.status,
|
||||||
categories: query.category,
|
categories: query.category,
|
||||||
types: query.type,
|
types: query.type,
|
||||||
@@ -169,12 +177,40 @@ export class IssuesController {
|
|||||||
@Param('project') project: string,
|
@Param('project') project: string,
|
||||||
@Body() body: CreateIssueDto,
|
@Body() body: CreateIssueDto,
|
||||||
) {
|
) {
|
||||||
|
const { parent, ...fields } = body;
|
||||||
return this.issues.create(me.id, {
|
return this.issues.create(me.id, {
|
||||||
...withoutNulls(body),
|
...withoutNulls(fields),
|
||||||
projectId: await this.refs.projectId(me.id, project),
|
projectId: await this.refs.projectId(me.id, project),
|
||||||
|
parentId: parent ? await this.refs.issueId(me.id, parent) : null,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('projects/:project/epics')
|
||||||
|
@ApiOptionalPireonAuth()
|
||||||
|
@PROJECT
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Epics: top-level tickets that group others',
|
||||||
|
description:
|
||||||
|
'With the progress of the tickets hanging from each one (done or cancelled, and story points).',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: [EpicDto] })
|
||||||
|
@ApiReadErrors()
|
||||||
|
async epics(
|
||||||
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Param('project') project: string,
|
||||||
|
) {
|
||||||
|
const epics = await this.issues.epics(
|
||||||
|
viewerOf(me),
|
||||||
|
await this.refs.projectId(viewerOf(me), project),
|
||||||
|
);
|
||||||
|
return Promise.all(
|
||||||
|
epics.map(async (epic) => ({
|
||||||
|
...epic,
|
||||||
|
issue: await this.refs.issueDto(epic.issue),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Get('issues/:issue')
|
@Get('issues/:issue')
|
||||||
@ApiOptionalPireonAuth()
|
@ApiOptionalPireonAuth()
|
||||||
@ISSUE
|
@ISSUE
|
||||||
@@ -266,6 +302,82 @@ export class IssuesController {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('issues/:issue/parent')
|
||||||
|
@ISSUE
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Place the ticket under an epic or another ticket',
|
||||||
|
description:
|
||||||
|
'Same project, three levels at most (epic → ticket → subtask), no loops. null takes it back to the top.',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: CommandResultDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async setParent(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('issue') issue: string,
|
||||||
|
@Body() body: SetIssueParentDto,
|
||||||
|
) {
|
||||||
|
return this.issues.setParent(
|
||||||
|
me.id,
|
||||||
|
await this.refs.issueId(me.id, issue),
|
||||||
|
body.parent ? await this.refs.issueId(me.id, body.parent) : null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('issues/:issue/checklist')
|
||||||
|
@ISSUE
|
||||||
|
@ApiOperation({ summary: "Add a step to the ticket's checklist" })
|
||||||
|
@ApiCreatedResponse({ type: CommandResultDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async addChecklistItem(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('issue') issue: string,
|
||||||
|
@Body() body: ChecklistItemBodyDto,
|
||||||
|
) {
|
||||||
|
return this.issues.addChecklistItem(
|
||||||
|
me.id,
|
||||||
|
await this.refs.issueId(me.id, issue),
|
||||||
|
body.text,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('issues/:issue/checklist/:item')
|
||||||
|
@ISSUE
|
||||||
|
@ApiParam({ name: 'item', format: 'uuid' })
|
||||||
|
@ApiOperation({ summary: 'Reword, tick or untick a checklist step' })
|
||||||
|
@ApiOkResponse({ type: CommandResultDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async updateChecklistItem(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('issue') issue: string,
|
||||||
|
@Param('item', ParseUUIDPipe) item: string,
|
||||||
|
@Body() body: UpdateChecklistItemDto,
|
||||||
|
) {
|
||||||
|
return this.issues.updateChecklistItem(
|
||||||
|
me.id,
|
||||||
|
await this.refs.issueId(me.id, issue),
|
||||||
|
item,
|
||||||
|
body,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('issues/:issue/checklist/:item')
|
||||||
|
@ISSUE
|
||||||
|
@ApiParam({ name: 'item', format: 'uuid' })
|
||||||
|
@ApiOperation({ summary: 'Remove a checklist step' })
|
||||||
|
@ApiOkResponse({ type: CommandResultDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async removeChecklistItem(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('issue') issue: string,
|
||||||
|
@Param('item', ParseUUIDPipe) item: string,
|
||||||
|
) {
|
||||||
|
return this.issues.removeChecklistItem(
|
||||||
|
me.id,
|
||||||
|
await this.refs.issueId(me.id, issue),
|
||||||
|
item,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
@Post('issues/:issue/links')
|
@Post('issues/:issue/links')
|
||||||
@ISSUE
|
@ISSUE
|
||||||
@ApiOperation({
|
@ApiOperation({
|
||||||
|
|||||||
@@ -1,43 +1,12 @@
|
|||||||
/** Internal type. DO NOT USE DIRECTLY. */
|
/** Internal type. DO NOT USE DIRECTLY. */
|
||||||
type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
|
type Exact<T extends { [key: string]: unknown }> = { [K in keyof T]: T[K] };
|
||||||
/** Internal type. DO NOT USE DIRECTLY. */
|
/** Internal type. DO NOT USE DIRECTLY. */
|
||||||
export type Incremental<T> =
|
export type Incremental<T> = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never };
|
||||||
| T
|
|
||||||
| {
|
|
||||||
[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never;
|
|
||||||
};
|
|
||||||
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||||
export type MeQueryVariables = Exact<{ [key: string]: never }>;
|
export type MeQueryVariables = Exact<{ [key: string]: never; }>;
|
||||||
|
|
||||||
export type MeQuery = {
|
|
||||||
me: { id: string; name: string; email: string; appRoles: Array<string> };
|
|
||||||
};
|
|
||||||
|
|
||||||
export const MeDocument = {
|
export type MeQuery = { me: { id: string, name: string, email: string, appRoles: Array<string> } };
|
||||||
kind: 'Document',
|
|
||||||
definitions: [
|
|
||||||
{
|
export const MeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"Me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"me"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"appRoles"}}]}}]}}]} as unknown as DocumentNode<MeQuery, MeQueryVariables>;
|
||||||
kind: 'OperationDefinition',
|
|
||||||
operation: 'query',
|
|
||||||
name: { kind: 'Name', value: 'Me' },
|
|
||||||
selectionSet: {
|
|
||||||
kind: 'SelectionSet',
|
|
||||||
selections: [
|
|
||||||
{
|
|
||||||
kind: 'Field',
|
|
||||||
name: { kind: 'Name', value: 'me' },
|
|
||||||
selectionSet: {
|
|
||||||
kind: 'SelectionSet',
|
|
||||||
selections: [
|
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'email' } },
|
|
||||||
{ kind: 'Field', name: { kind: 'Name', value: 'appRoles' } },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
} as unknown as DocumentNode<MeQuery, MeQueryVariables>;
|
|
||||||
@@ -6,3 +6,4 @@ export * from './lib/facades/issue.facade';
|
|||||||
export * from './lib/facades/home.facade';
|
export * from './lib/facades/home.facade';
|
||||||
export * from './lib/facades/explore.facade';
|
export * from './lib/facades/explore.facade';
|
||||||
export * from './lib/facades/inbox.facade';
|
export * from './lib/facades/inbox.facade';
|
||||||
|
export * from './lib/facades/epics.facade';
|
||||||
|
|||||||
18
libs/web/tracker/application/src/lib/events/epics.events.ts
Normal file
18
libs/web/tracker/application/src/lib/events/epics.events.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { type } from '@ngrx/signals';
|
||||||
|
import { eventGroup } from '@ngrx/signals/events';
|
||||||
|
import { Epic } from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
|
export const epicsPageEvents = eventGroup({
|
||||||
|
source: 'Epics Page',
|
||||||
|
events: {
|
||||||
|
opened: type<{ projectId: string }>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
export const epicsApiEvents = eventGroup({
|
||||||
|
source: 'Epics API',
|
||||||
|
events: {
|
||||||
|
loaded: type<Epic[]>(),
|
||||||
|
loadFailed: type<string>(),
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Comment,
|
Comment,
|
||||||
CommentChanged,
|
CommentChanged,
|
||||||
Issue,
|
Issue,
|
||||||
|
IssueSummary,
|
||||||
LinkIssuesInput,
|
LinkIssuesInput,
|
||||||
UpdateIssueInput,
|
UpdateIssueInput,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
@@ -36,6 +37,21 @@ export const issuePageEvents = eventGroup({
|
|||||||
files?: File[];
|
files?: File[];
|
||||||
}>(),
|
}>(),
|
||||||
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
watchRequested: type<{ issueId: string; watching: boolean }>(),
|
||||||
|
/** Place the ticket under another one by its key (e.g. WEB-3); null: out. */
|
||||||
|
parentRequested: type<{ issueId: string; parentKey: string | null }>(),
|
||||||
|
subtaskCreateRequested: type<{
|
||||||
|
projectId: string;
|
||||||
|
parentId: string;
|
||||||
|
type: string;
|
||||||
|
title: string;
|
||||||
|
}>(),
|
||||||
|
checklistAddRequested: type<{ issueId: string; text: string }>(),
|
||||||
|
checklistUpdateRequested: type<{
|
||||||
|
issueId: string;
|
||||||
|
itemId: string;
|
||||||
|
changes: { text?: string; done?: boolean };
|
||||||
|
}>(),
|
||||||
|
checklistRemoveRequested: type<{ issueId: string; itemId: string }>(),
|
||||||
attachRequested: type<{
|
attachRequested: type<{
|
||||||
issueId: string;
|
issueId: string;
|
||||||
files: File[];
|
files: File[];
|
||||||
@@ -57,7 +73,10 @@ export const issueApiEvents = eventGroup({
|
|||||||
loadFailed: type<string>(),
|
loadFailed: type<string>(),
|
||||||
commentsLoaded: type<Comment[]>(),
|
commentsLoaded: type<Comment[]>(),
|
||||||
historyLoaded: type<Activity[]>(),
|
historyLoaded: type<Activity[]>(),
|
||||||
|
childrenLoaded: type<IssueSummary[]>(),
|
||||||
commandSucceeded: type<string>(),
|
commandSucceeded: type<string>(),
|
||||||
|
/** A checklist step was saved (no toast for something so small). */
|
||||||
|
checklistSaved: type<void>(),
|
||||||
commandFailed: type<string>(),
|
commandFailed: type<string>(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -67,6 +86,8 @@ export const issueRealtimeEvents = eventGroup({
|
|||||||
events: {
|
events: {
|
||||||
/** Something about this ticket changed: refetch it. */
|
/** Something about this ticket changed: refetch it. */
|
||||||
issueTouched: type<{ issueId: string; version: number }>(),
|
issueTouched: type<{ issueId: string; version: number }>(),
|
||||||
|
/** A ticket under this one changed, arrived or left: reload them. */
|
||||||
|
childrenTouched: type<{ issueId: string }>(),
|
||||||
commentChanged: type<CommentChanged>(),
|
commentChanged: type<CommentChanged>(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
27
libs/web/tracker/application/src/lib/facades/epics.facade.ts
Normal file
27
libs/web/tracker/application/src/lib/facades/epics.facade.ts
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
import { inject, Injectable, Provider } from '@angular/core';
|
||||||
|
import { injectDispatch } from '@ngrx/signals/events';
|
||||||
|
import { epicsPageEvents } from '../events/epics.events';
|
||||||
|
import { EpicsStore } from '../stores/epics.store';
|
||||||
|
|
||||||
|
/** "Épicas": top-level tickets that group others, with their progress. */
|
||||||
|
@Injectable()
|
||||||
|
export class EpicsFacade {
|
||||||
|
private readonly store = inject(EpicsStore);
|
||||||
|
private readonly dispatch = injectDispatch(epicsPageEvents);
|
||||||
|
|
||||||
|
/** By key. */
|
||||||
|
readonly epics = this.store.epics;
|
||||||
|
/** False until the first load: show skeletons, not empty states. */
|
||||||
|
readonly loaded = this.store.loaded;
|
||||||
|
readonly error = this.store.error;
|
||||||
|
|
||||||
|
/** Loads the project's epics and keeps them up to date. */
|
||||||
|
open(projectId: string): void {
|
||||||
|
this.dispatch.opened({ projectId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Store + facade of the epics view, for its route component. */
|
||||||
|
export function provideEpicsFacade(): Provider[] {
|
||||||
|
return [EpicsStore, EpicsFacade];
|
||||||
|
}
|
||||||
@@ -28,7 +28,13 @@ import { ProjectFacade, provideProjectFacade } from './project.facade';
|
|||||||
|
|
||||||
const ok = { id: 'x', key: null, aggregateVersion: 1 };
|
const ok = { id: 'x', key: null, aggregateVersion: 1 };
|
||||||
const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project;
|
const project = { id: 'p1', key: 'WEB', members: [] } as unknown as Project;
|
||||||
const issue = { id: 'i1', key: 'WEB-1', links: [] };
|
const issue = {
|
||||||
|
id: 'i1',
|
||||||
|
key: 'WEB-1',
|
||||||
|
projectId: 'p1',
|
||||||
|
links: [],
|
||||||
|
checklist: [],
|
||||||
|
};
|
||||||
const analytics = { from: 'a', to: 'b', qa: { failureRate: 0.25 } };
|
const analytics = { from: 'a', to: 'b', qa: { failureRate: 0.25 } };
|
||||||
|
|
||||||
/** Every command succeeds, every query answers once, nothing arrives in real time. */
|
/** Every command succeeds, every query answers once, nothing arrives in real time. */
|
||||||
@@ -48,6 +54,11 @@ function gateways() {
|
|||||||
issue: vi.fn(() => of(issue)),
|
issue: vi.fn(() => of(issue)),
|
||||||
changeStatus: vi.fn(() => of(ok)),
|
changeStatus: vi.fn(() => of(ok)),
|
||||||
watch: vi.fn(() => of(ok)),
|
watch: vi.fn(() => of(ok)),
|
||||||
|
children: vi.fn(() => of([])),
|
||||||
|
create: vi.fn(() => of(ok)),
|
||||||
|
setParent: vi.fn(() => of(ok)),
|
||||||
|
addChecklistItem: vi.fn(() => of(ok)),
|
||||||
|
updateChecklistItem: vi.fn(() => of(ok)),
|
||||||
},
|
},
|
||||||
list: {
|
list: {
|
||||||
list: vi.fn(() => of([])),
|
list: vi.fn(() => of([])),
|
||||||
@@ -238,6 +249,42 @@ describe('IssueFacade', () => {
|
|||||||
expect(fakes.issues.watch).toHaveBeenCalledWith('i1', true);
|
expect(fakes.issues.watch).toHaveBeenCalledWith('i1', true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('breaks the ticket down: subtasks, parent and checklist', () => {
|
||||||
|
const { issue, fakes } = setup();
|
||||||
|
// The parent is looked up by its key first.
|
||||||
|
fakes.issues.issue.mockImplementation(((key: string) =>
|
||||||
|
of({
|
||||||
|
id: key === 'WEB-1' ? 'i1' : 'e1',
|
||||||
|
key,
|
||||||
|
projectId: 'p1',
|
||||||
|
links: [],
|
||||||
|
checklist: [],
|
||||||
|
})) as never);
|
||||||
|
|
||||||
|
issue.open('WEB-1');
|
||||||
|
expect(fakes.issues.children).toHaveBeenCalledWith('p1', 'i1');
|
||||||
|
issue.addSubtask(' Formulario ', 'TASK');
|
||||||
|
issue.setParent('web-9');
|
||||||
|
issue.addChecklistItem('Changelog');
|
||||||
|
issue.updateChecklistItem('c1', { done: true });
|
||||||
|
|
||||||
|
expect(fakes.issues.create).toHaveBeenCalledWith({
|
||||||
|
projectId: 'p1',
|
||||||
|
parentId: 'i1',
|
||||||
|
type: 'TASK',
|
||||||
|
title: 'Formulario',
|
||||||
|
});
|
||||||
|
expect(fakes.issues.issue).toHaveBeenCalledWith('WEB-9');
|
||||||
|
expect(fakes.issues.setParent).toHaveBeenCalledWith('i1', 'e1');
|
||||||
|
expect(fakes.issues.addChecklistItem).toHaveBeenCalledWith(
|
||||||
|
'i1',
|
||||||
|
'Changelog',
|
||||||
|
);
|
||||||
|
expect(fakes.issues.updateChecklistItem).toHaveBeenCalledWith('i1', 'c1', {
|
||||||
|
done: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('uploads files one by one, and under a new comment when sent with it', () => {
|
it('uploads files one by one, and under a new comment when sent with it', () => {
|
||||||
const { issue, fakes } = setup();
|
const { issue, fakes } = setup();
|
||||||
const a = new File(['a'], 'a.png', { type: 'image/png' });
|
const a = new File(['a'], 'a.png', { type: 'image/png' });
|
||||||
|
|||||||
@@ -26,6 +26,11 @@ export class IssueFacade {
|
|||||||
readonly history = this.store.history;
|
readonly history = this.store.history;
|
||||||
readonly outwardLinks = this.store.outwardLinks;
|
readonly outwardLinks = this.store.outwardLinks;
|
||||||
readonly inwardLinks = this.store.inwardLinks;
|
readonly inwardLinks = this.store.inwardLinks;
|
||||||
|
/** Tickets hanging from the open one, and how far they have got. */
|
||||||
|
readonly children = this.store.children;
|
||||||
|
readonly childProgress = this.store.childProgress;
|
||||||
|
/** Ticked steps of the open ticket's checklist. */
|
||||||
|
readonly checklistProgress = this.store.checklistProgress;
|
||||||
readonly loading = this.store.loading;
|
readonly loading = this.store.loading;
|
||||||
readonly error = this.store.error;
|
readonly error = this.store.error;
|
||||||
/** Signed-in user's id (authors can edit their comments). */
|
/** Signed-in user's id (authors can edit their comments). */
|
||||||
@@ -94,6 +99,47 @@ export class IssueFacade {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Places the open ticket under another one by its key; null: back to the top. */
|
||||||
|
setParent(parentKey: string | null): void {
|
||||||
|
this.withIssue((issueId) =>
|
||||||
|
this.dispatch.parentRequested({ issueId, parentKey }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Creates a ticket under the open one. */
|
||||||
|
addSubtask(title: string, type: string): void {
|
||||||
|
const issue = this.store.issue();
|
||||||
|
if (!issue || !title.trim()) return;
|
||||||
|
this.dispatch.subtaskCreateRequested({
|
||||||
|
projectId: issue.projectId,
|
||||||
|
parentId: issue.id,
|
||||||
|
type,
|
||||||
|
title: title.trim(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addChecklistItem(text: string): void {
|
||||||
|
if (!text.trim()) return;
|
||||||
|
this.withIssue((issueId) =>
|
||||||
|
this.dispatch.checklistAddRequested({ issueId, text: text.trim() }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
updateChecklistItem(
|
||||||
|
itemId: string,
|
||||||
|
changes: { text?: string; done?: boolean },
|
||||||
|
): void {
|
||||||
|
this.withIssue((issueId) =>
|
||||||
|
this.dispatch.checklistUpdateRequested({ issueId, itemId, changes }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChecklistItem(itemId: string): void {
|
||||||
|
this.withIssue((issueId) =>
|
||||||
|
this.dispatch.checklistRemoveRequested({ issueId, itemId }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Attaches files to the ticket (or under one of its comments). */
|
/** Attaches files to the ticket (or under one of its comments). */
|
||||||
attach(files: File[], commentId: string | null = null): void {
|
attach(files: File[], commentId: string | null = null): void {
|
||||||
if (!files.length) return;
|
if (!files.length) return;
|
||||||
|
|||||||
66
libs/web/tracker/application/src/lib/stores/epics.store.ts
Normal file
66
libs/web/tracker/application/src/lib/stores/epics.store.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import { inject } from '@angular/core';
|
||||||
|
import { mapResponse } from '@ngrx/operators';
|
||||||
|
import { signalStore, withState } from '@ngrx/signals';
|
||||||
|
import {
|
||||||
|
Events,
|
||||||
|
on,
|
||||||
|
withEventHandlers,
|
||||||
|
withReducer,
|
||||||
|
} from '@ngrx/signals/events';
|
||||||
|
import {
|
||||||
|
Epic,
|
||||||
|
IssuesGateway,
|
||||||
|
TrackerRealtimeGateway,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
|
import { debounceTime, startWith, switchMap } from 'rxjs';
|
||||||
|
import { epicsApiEvents, epicsPageEvents } from '../events/epics.events';
|
||||||
|
import { describeError } from '../notifications';
|
||||||
|
|
||||||
|
interface EpicsState {
|
||||||
|
epics: Epic[];
|
||||||
|
loaded: boolean;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A project's epics and their progress, live. Scoped to the epics view. */
|
||||||
|
export const EpicsStore = signalStore(
|
||||||
|
withState<EpicsState>({ epics: [], loaded: false, error: null }),
|
||||||
|
withReducer(
|
||||||
|
on(epicsPageEvents.opened, () => ({ loaded: false, error: null })),
|
||||||
|
on(epicsApiEvents.loaded, ({ payload }) => ({
|
||||||
|
epics: payload,
|
||||||
|
loaded: true,
|
||||||
|
})),
|
||||||
|
on(epicsApiEvents.loadFailed, ({ payload }) => ({
|
||||||
|
loaded: true,
|
||||||
|
error: payload,
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
withEventHandlers(
|
||||||
|
(
|
||||||
|
_store,
|
||||||
|
events = inject(Events),
|
||||||
|
issues = inject(IssuesGateway),
|
||||||
|
realtime = inject(TrackerRealtimeGateway),
|
||||||
|
) => ({
|
||||||
|
// Any ticket of the project may move an epic's progress: reload, calmly.
|
||||||
|
load$: events.on(epicsPageEvents.opened).pipe(
|
||||||
|
switchMap(({ payload }) =>
|
||||||
|
realtime.issueChanges(payload.projectId).pipe(
|
||||||
|
debounceTime(500),
|
||||||
|
startWith(null),
|
||||||
|
switchMap(() =>
|
||||||
|
issues.epics(payload.projectId).pipe(
|
||||||
|
mapResponse({
|
||||||
|
next: (list) => epicsApiEvents.loaded(list),
|
||||||
|
error: (error) =>
|
||||||
|
epicsApiEvents.loadFailed(describeError(error)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
@@ -14,8 +14,11 @@ import {
|
|||||||
AttachmentsGateway,
|
AttachmentsGateway,
|
||||||
Comment,
|
Comment,
|
||||||
CommentsGateway,
|
CommentsGateway,
|
||||||
|
checklistProgress,
|
||||||
|
childProgress,
|
||||||
Issue,
|
Issue,
|
||||||
IssuesGateway,
|
IssuesGateway,
|
||||||
|
IssueSummary,
|
||||||
TrackerRealtimeGateway,
|
TrackerRealtimeGateway,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import {
|
import {
|
||||||
@@ -45,6 +48,8 @@ interface IssueState {
|
|||||||
issue: Issue | null;
|
issue: Issue | null;
|
||||||
comments: Comment[];
|
comments: Comment[];
|
||||||
history: Activity[];
|
history: Activity[];
|
||||||
|
/** Tickets hanging from this one. */
|
||||||
|
children: IssueSummary[];
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
error: string | null;
|
error: string | null;
|
||||||
}
|
}
|
||||||
@@ -55,10 +60,15 @@ export const IssueStore = signalStore(
|
|||||||
issue: null,
|
issue: null,
|
||||||
comments: [],
|
comments: [],
|
||||||
history: [],
|
history: [],
|
||||||
|
children: [],
|
||||||
loading: false,
|
loading: false,
|
||||||
error: null,
|
error: null,
|
||||||
}),
|
}),
|
||||||
withComputed(({ issue }, auth = inject(AuthFacade)) => ({
|
withComputed(({ issue, children }, auth = inject(AuthFacade)) => ({
|
||||||
|
childProgress: computed(() => childProgress(children())),
|
||||||
|
checklistProgress: computed(() =>
|
||||||
|
checklistProgress(issue()?.checklist ?? []),
|
||||||
|
),
|
||||||
me: computed(() => auth.user()?.id ?? null),
|
me: computed(() => auth.user()?.id ?? null),
|
||||||
watching: computed(
|
watching: computed(
|
||||||
() =>
|
() =>
|
||||||
@@ -79,6 +89,7 @@ export const IssueStore = signalStore(
|
|||||||
issue: null,
|
issue: null,
|
||||||
comments: [],
|
comments: [],
|
||||||
history: [],
|
history: [],
|
||||||
|
children: [],
|
||||||
})),
|
})),
|
||||||
on(issueApiEvents.loaded, ({ payload }) => ({
|
on(issueApiEvents.loaded, ({ payload }) => ({
|
||||||
issue: payload,
|
issue: payload,
|
||||||
@@ -90,6 +101,7 @@ export const IssueStore = signalStore(
|
|||||||
})),
|
})),
|
||||||
on(issueApiEvents.commentsLoaded, ({ payload }) => ({ comments: payload })),
|
on(issueApiEvents.commentsLoaded, ({ payload }) => ({ comments: payload })),
|
||||||
on(issueApiEvents.historyLoaded, ({ payload }) => ({ history: payload })),
|
on(issueApiEvents.historyLoaded, ({ payload }) => ({ history: payload })),
|
||||||
|
on(issueApiEvents.childrenLoaded, ({ payload }) => ({ children: payload })),
|
||||||
on(issueRealtimeEvents.commentChanged, ({ payload }, state) => {
|
on(issueRealtimeEvents.commentChanged, ({ payload }, state) => {
|
||||||
const others = state.comments.filter((c) => c.id !== payload.commentId);
|
const others = state.comments.filter((c) => c.id !== payload.commentId);
|
||||||
if (!payload.comment) return { comments: others };
|
if (!payload.comment) return { comments: others };
|
||||||
@@ -181,6 +193,44 @@ export const IssueStore = signalStore(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
children$: merge(
|
||||||
|
loaded$.pipe(
|
||||||
|
map(({ payload }) => payload),
|
||||||
|
filter(
|
||||||
|
(issue, index) => index === 0 || issue.id !== store.issue()?.id,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
events.on(issueRealtimeEvents.childrenTouched).pipe(
|
||||||
|
debounceTime(200),
|
||||||
|
map(() => store.issue()),
|
||||||
|
filter((issue): issue is Issue => !!issue),
|
||||||
|
),
|
||||||
|
).pipe(
|
||||||
|
switchMap((issue) =>
|
||||||
|
issues
|
||||||
|
.children(issue.projectId, issue.id)
|
||||||
|
.pipe(map((list) => issueApiEvents.childrenLoaded(list))),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
/** A ticket under this one changed, or one moved in or out. */
|
||||||
|
liveChildren$: events.on(issuePageEvents.opened).pipe(
|
||||||
|
switchMap(() => loaded$.pipe(map(({ payload }) => payload))),
|
||||||
|
filter(
|
||||||
|
(issue, index) => index === 0 || issue.id !== store.issue()?.id,
|
||||||
|
),
|
||||||
|
switchMap((issue) =>
|
||||||
|
realtime.issueChanges(issue.projectId).pipe(
|
||||||
|
filter(
|
||||||
|
(change) =>
|
||||||
|
change.issue.parent?.id === issue.id ||
|
||||||
|
store.children().some((c) => c.id === change.issue.id),
|
||||||
|
),
|
||||||
|
map(() =>
|
||||||
|
issueRealtimeEvents.childrenTouched({ issueId: issue.id }),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
refetch$: events.on(issueRealtimeEvents.issueTouched).pipe(
|
refetch$: events.on(issueRealtimeEvents.issueTouched).pipe(
|
||||||
debounceTime(150),
|
debounceTime(150),
|
||||||
switchMap(({ payload }) => fetchIssue(payload.issueId)),
|
switchMap(({ payload }) => fetchIssue(payload.issueId)),
|
||||||
@@ -242,6 +292,82 @@ export const IssueStore = signalStore(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
events
|
||||||
|
.on(issuePageEvents.parentRequested)
|
||||||
|
.pipe(
|
||||||
|
mergeMap(({ payload }) =>
|
||||||
|
runCommand(
|
||||||
|
payload.parentKey
|
||||||
|
? issues
|
||||||
|
.issue(payload.parentKey.trim().toUpperCase())
|
||||||
|
.pipe(
|
||||||
|
switchMap((parent) =>
|
||||||
|
issues.setParent(payload.issueId, parent.id),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: issues.setParent(payload.issueId, null),
|
||||||
|
payload.parentKey
|
||||||
|
? `Ahora cuelga de ${payload.parentKey.trim().toUpperCase()}`
|
||||||
|
: 'Ya no cuelga de ningún ticket',
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
events
|
||||||
|
.on(issuePageEvents.subtaskCreateRequested)
|
||||||
|
.pipe(
|
||||||
|
mergeMap(({ payload }) =>
|
||||||
|
runCommand(
|
||||||
|
issues.create(payload),
|
||||||
|
'Subtarea creada',
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
events
|
||||||
|
.on(issuePageEvents.checklistAddRequested)
|
||||||
|
.pipe(
|
||||||
|
mergeMap(({ payload }) =>
|
||||||
|
runCommand(
|
||||||
|
issues.addChecklistItem(payload.issueId, payload.text),
|
||||||
|
'Paso añadido',
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
events.on(issuePageEvents.checklistUpdateRequested).pipe(
|
||||||
|
mergeMap(({ payload }) =>
|
||||||
|
issues
|
||||||
|
.updateChecklistItem(
|
||||||
|
payload.issueId,
|
||||||
|
payload.itemId,
|
||||||
|
payload.changes,
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
// Ticking a step is not worth a toast; only failures are.
|
||||||
|
mapResponse({
|
||||||
|
next: () => issueApiEvents.checklistSaved(),
|
||||||
|
error: (error) =>
|
||||||
|
issueApiEvents.commandFailed(describeError(error)),
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
events
|
||||||
|
.on(issuePageEvents.checklistRemoveRequested)
|
||||||
|
.pipe(
|
||||||
|
mergeMap(({ payload }) =>
|
||||||
|
runCommand(
|
||||||
|
issues.removeChecklistItem(payload.issueId, payload.itemId),
|
||||||
|
'Paso eliminado',
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
events
|
events
|
||||||
.on(issuePageEvents.linkRequested)
|
.on(issuePageEvents.linkRequested)
|
||||||
.pipe(
|
.pipe(
|
||||||
|
|||||||
@@ -4,3 +4,4 @@ export * from './lib/models/inputs';
|
|||||||
export * from './lib/models/workflow';
|
export * from './lib/models/workflow';
|
||||||
export * from './lib/ports/gateways';
|
export * from './lib/ports/gateways';
|
||||||
export * from './lib/models/mentions';
|
export * from './lib/models/mentions';
|
||||||
|
export * from './lib/models/hierarchy';
|
||||||
|
|||||||
28
libs/web/tracker/domain/src/lib/models/hierarchy.spec.ts
Normal file
28
libs/web/tracker/domain/src/lib/models/hierarchy.spec.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { StatusCategory } from './enums';
|
||||||
|
import { checklistProgress, childProgress } from './hierarchy';
|
||||||
|
import { IssueSummary } from './models';
|
||||||
|
|
||||||
|
const child = (statusCategory: StatusCategory) =>
|
||||||
|
({ statusCategory }) as IssueSummary;
|
||||||
|
|
||||||
|
describe('hierarchy', () => {
|
||||||
|
it('counts done and cancelled subtasks as finished', () => {
|
||||||
|
expect(
|
||||||
|
childProgress([
|
||||||
|
child(StatusCategory.DONE),
|
||||||
|
child(StatusCategory.CANCELLED),
|
||||||
|
child(StatusCategory.IN_PROGRESS),
|
||||||
|
]),
|
||||||
|
).toMatchObject({ total: 3, finished: 2, percent: 67 });
|
||||||
|
expect(childProgress([]).percent).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('counts ticked checklist steps', () => {
|
||||||
|
expect(
|
||||||
|
checklistProgress([
|
||||||
|
{ id: 'a', text: 'a', done: true },
|
||||||
|
{ id: 'b', text: 'b', done: false },
|
||||||
|
]),
|
||||||
|
).toEqual({ done: 1, total: 2 });
|
||||||
|
});
|
||||||
|
});
|
||||||
32
libs/web/tracker/domain/src/lib/models/hierarchy.ts
Normal file
32
libs/web/tracker/domain/src/lib/models/hierarchy.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { ChecklistItem, ChildProgress, IssueSummary } from './models';
|
||||||
|
import { StatusCategory } from './enums';
|
||||||
|
|
||||||
|
const FINISHED: StatusCategory[] = [
|
||||||
|
StatusCategory.DONE,
|
||||||
|
StatusCategory.CANCELLED,
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Progress of the tickets hanging from one (story points are not loaded here). */
|
||||||
|
export function childProgress(children: IssueSummary[]): ChildProgress {
|
||||||
|
const finished = children.filter((c) =>
|
||||||
|
FINISHED.includes(c.statusCategory),
|
||||||
|
).length;
|
||||||
|
return {
|
||||||
|
total: children.length,
|
||||||
|
finished,
|
||||||
|
percent:
|
||||||
|
children.length === 0
|
||||||
|
? 0
|
||||||
|
: Math.round((finished / children.length) * 100),
|
||||||
|
storyPoints: 0,
|
||||||
|
finishedStoryPoints: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "3/5": ticked steps of a checklist. */
|
||||||
|
export function checklistProgress(items: ChecklistItem[]): {
|
||||||
|
done: number;
|
||||||
|
total: number;
|
||||||
|
} {
|
||||||
|
return { done: items.filter((i) => i.done).length, total: items.length };
|
||||||
|
}
|
||||||
@@ -30,10 +30,12 @@ export interface CreateIssueInput {
|
|||||||
figmaUrl?: string | null;
|
figmaUrl?: string | null;
|
||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
releaseId?: string | null;
|
releaseId?: string | null;
|
||||||
|
/** Epic or ticket the new one breaks down. */
|
||||||
|
parentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UpdateIssueInput = Partial<
|
export type UpdateIssueInput = Partial<
|
||||||
Omit<CreateIssueInput, 'projectId' | 'assigneeId' | 'releaseId'>
|
Omit<CreateIssueInput, 'projectId' | 'assigneeId' | 'releaseId' | 'parentId'>
|
||||||
> & {
|
> & {
|
||||||
issueId: string;
|
issueId: string;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,6 +40,36 @@ export interface IssueLink {
|
|||||||
issueKey: string;
|
issueKey: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The ticket another one hangs from. */
|
||||||
|
export interface IssueParent {
|
||||||
|
id: string;
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A step inside a ticket that does not deserve a ticket of its own. */
|
||||||
|
export interface ChecklistItem {
|
||||||
|
id: string;
|
||||||
|
text: string;
|
||||||
|
done: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far the tickets hanging from one have got. */
|
||||||
|
export interface ChildProgress {
|
||||||
|
total: number;
|
||||||
|
/** Done or cancelled. */
|
||||||
|
finished: number;
|
||||||
|
percent: number;
|
||||||
|
storyPoints: number;
|
||||||
|
finishedStoryPoints: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A top-level ticket that groups others. */
|
||||||
|
export interface Epic {
|
||||||
|
issue: IssueSummary;
|
||||||
|
progress: ChildProgress;
|
||||||
|
}
|
||||||
|
|
||||||
/** What boards and lists show. */
|
/** What boards and lists show. */
|
||||||
export interface IssueSummary {
|
export interface IssueSummary {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -55,6 +85,8 @@ export interface IssueSummary {
|
|||||||
assignee: UserRef | null;
|
assignee: UserRef | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
labels: string[];
|
labels: string[];
|
||||||
|
/** The epic or ticket this one breaks down. */
|
||||||
|
parent: IssueParent | null;
|
||||||
commentCount: number;
|
commentCount: number;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
version: number;
|
version: number;
|
||||||
@@ -88,6 +120,7 @@ export interface Issue extends IssueSummary {
|
|||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
/** People following the ticket. */
|
/** People following the ticket. */
|
||||||
watchers: UserRef[];
|
watchers: UserRef[];
|
||||||
|
checklist: ChecklistItem[];
|
||||||
allowedTransitions: string[];
|
allowedTransitions: string[];
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
Attachment,
|
Attachment,
|
||||||
BoardColumn,
|
BoardColumn,
|
||||||
Comment,
|
Comment,
|
||||||
|
Epic,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
Issue,
|
Issue,
|
||||||
IssuePage,
|
IssuePage,
|
||||||
@@ -89,6 +90,30 @@ export abstract class IssuesGateway {
|
|||||||
): Observable<CommandResult>;
|
): Observable<CommandResult>;
|
||||||
/** The signed-in person follows (or stops following) the ticket. */
|
/** The signed-in person follows (or stops following) the ticket. */
|
||||||
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
|
abstract watch(issueId: string, watching: boolean): Observable<CommandResult>;
|
||||||
|
/** Tickets hanging from `issueId`. */
|
||||||
|
abstract children(
|
||||||
|
projectId: string,
|
||||||
|
issueId: string,
|
||||||
|
): Observable<IssueSummary[]>;
|
||||||
|
/** Places the ticket under `parentId` (null: back to the top). */
|
||||||
|
abstract setParent(
|
||||||
|
issueId: string,
|
||||||
|
parentId: string | null,
|
||||||
|
): Observable<CommandResult>;
|
||||||
|
abstract epics(projectId: string): Observable<Epic[]>;
|
||||||
|
abstract addChecklistItem(
|
||||||
|
issueId: string,
|
||||||
|
text: string,
|
||||||
|
): Observable<CommandResult>;
|
||||||
|
abstract updateChecklistItem(
|
||||||
|
issueId: string,
|
||||||
|
itemId: string,
|
||||||
|
changes: { text?: string; done?: boolean },
|
||||||
|
): Observable<CommandResult>;
|
||||||
|
abstract removeChecklistItem(
|
||||||
|
issueId: string,
|
||||||
|
itemId: string,
|
||||||
|
): Observable<CommandResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export abstract class CommentsGateway {
|
export abstract class CommentsGateway {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
ChangeStatusInput,
|
ChangeStatusInput,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
CreateIssueInput,
|
CreateIssueInput,
|
||||||
|
Epic,
|
||||||
Issue,
|
Issue,
|
||||||
IssuePage,
|
IssuePage,
|
||||||
IssuesGateway,
|
IssuesGateway,
|
||||||
@@ -15,18 +16,24 @@ import {
|
|||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import { map, Observable } from 'rxjs';
|
import { map, Observable } from 'rxjs';
|
||||||
import {
|
import {
|
||||||
|
AddChecklistItemDocument,
|
||||||
AssignIssueDocument,
|
AssignIssueDocument,
|
||||||
BoardDocument,
|
BoardDocument,
|
||||||
ChangeIssueStatusDocument,
|
ChangeIssueStatusDocument,
|
||||||
CreateIssueDocument,
|
CreateIssueDocument,
|
||||||
|
EpicsDocument,
|
||||||
|
IssueChildrenDocument,
|
||||||
IssueDocument,
|
IssueDocument,
|
||||||
LinkIssuesDocument,
|
LinkIssuesDocument,
|
||||||
MyIssuesDocument,
|
MyIssuesDocument,
|
||||||
|
RemoveChecklistItemDocument,
|
||||||
SearchIssuesDocument,
|
SearchIssuesDocument,
|
||||||
|
SetIssueParentDocument,
|
||||||
SetIssueReleaseDocument,
|
SetIssueReleaseDocument,
|
||||||
UnlinkIssuesDocument,
|
UnlinkIssuesDocument,
|
||||||
UnwatchIssueDocument,
|
UnwatchIssueDocument,
|
||||||
WatchIssueDocument,
|
WatchIssueDocument,
|
||||||
|
UpdateChecklistItemDocument,
|
||||||
UpdateIssueDocument,
|
UpdateIssueDocument,
|
||||||
} from '../graphql/generated';
|
} from '../graphql/generated';
|
||||||
import { GraphqlOperations } from './graphql-operations';
|
import { GraphqlOperations } from './graphql-operations';
|
||||||
@@ -131,6 +138,61 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
|||||||
.pipe(map((d) => toCommandResult(d.unwatchIssue)));
|
.pipe(map((d) => toCommandResult(d.unwatchIssue)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
children(projectId: string, issueId: string): Observable<IssueSummary[]> {
|
||||||
|
return this.gql
|
||||||
|
.query(IssueChildrenDocument, {
|
||||||
|
filter: { projectId, parentId: issueId },
|
||||||
|
})
|
||||||
|
.pipe(map((d) => d.issues.items.map(toIssueSummary)));
|
||||||
|
}
|
||||||
|
|
||||||
|
setParent(
|
||||||
|
issueId: string,
|
||||||
|
parentId: string | null,
|
||||||
|
): Observable<CommandResult> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(SetIssueParentDocument, { issueId, parentId })
|
||||||
|
.pipe(map((d) => toCommandResult(d.setIssueParent)));
|
||||||
|
}
|
||||||
|
|
||||||
|
epics(projectId: string): Observable<Epic[]> {
|
||||||
|
return this.gql.query(EpicsDocument, { projectId }).pipe(
|
||||||
|
map((d) =>
|
||||||
|
d.epics.map((epic) => ({
|
||||||
|
issue: toIssueSummary(epic.issue),
|
||||||
|
progress: { ...epic.progress },
|
||||||
|
})),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
addChecklistItem(issueId: string, text: string): Observable<CommandResult> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(AddChecklistItemDocument, { input: { issueId, text } })
|
||||||
|
.pipe(map((d) => toCommandResult(d.addChecklistItem)));
|
||||||
|
}
|
||||||
|
|
||||||
|
updateChecklistItem(
|
||||||
|
issueId: string,
|
||||||
|
itemId: string,
|
||||||
|
changes: { text?: string; done?: boolean },
|
||||||
|
): Observable<CommandResult> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(UpdateChecklistItemDocument, {
|
||||||
|
input: { issueId, itemId, ...changes },
|
||||||
|
})
|
||||||
|
.pipe(map((d) => toCommandResult(d.updateChecklistItem)));
|
||||||
|
}
|
||||||
|
|
||||||
|
removeChecklistItem(
|
||||||
|
issueId: string,
|
||||||
|
itemId: string,
|
||||||
|
): Observable<CommandResult> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(RemoveChecklistItemDocument, { issueId, itemId })
|
||||||
|
.pipe(map((d) => toCommandResult(d.removeChecklistItem)));
|
||||||
|
}
|
||||||
|
|
||||||
setRelease(
|
setRelease(
|
||||||
issueId: string,
|
issueId: string,
|
||||||
releaseId: string | null,
|
releaseId: string | null,
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ fragment IssueSummaryFields on Issue {
|
|||||||
id
|
id
|
||||||
name
|
name
|
||||||
}
|
}
|
||||||
|
parent {
|
||||||
|
id
|
||||||
|
key
|
||||||
|
title
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fragment IssueFields on Issue {
|
fragment IssueFields on Issue {
|
||||||
@@ -91,6 +96,11 @@ fragment IssueFields on Issue {
|
|||||||
watchers {
|
watchers {
|
||||||
...UserRefFields
|
...UserRefFields
|
||||||
}
|
}
|
||||||
|
checklist {
|
||||||
|
id
|
||||||
|
text
|
||||||
|
done
|
||||||
|
}
|
||||||
attachments {
|
attachments {
|
||||||
id
|
id
|
||||||
fileName
|
fileName
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ export type Incremental<T> =
|
|||||||
[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never;
|
[P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never;
|
||||||
};
|
};
|
||||||
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core';
|
||||||
|
export type AddChecklistItemInput = {
|
||||||
|
issueId: string;
|
||||||
|
text: string;
|
||||||
|
};
|
||||||
|
|
||||||
export type AreaOptionInput = {
|
export type AreaOptionInput = {
|
||||||
archived?: boolean;
|
archived?: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
@@ -30,6 +35,8 @@ export type CreateIssueInput = {
|
|||||||
/** Figma frame for UI changes */
|
/** Figma frame for UI changes */
|
||||||
figmaUrl?: string | null | undefined;
|
figmaUrl?: string | null | undefined;
|
||||||
labels?: Array<string> | null | undefined;
|
labels?: Array<string> | null | undefined;
|
||||||
|
/** Epic or ticket of the same project the new one breaks down */
|
||||||
|
parentId?: string | null | undefined;
|
||||||
priority?: IssuePriority | null | undefined;
|
priority?: IssuePriority | null | undefined;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
releaseId?: string | null | undefined;
|
releaseId?: string | null | undefined;
|
||||||
@@ -61,6 +68,8 @@ export type IssueFilterInput = {
|
|||||||
assigneeId?: string | null | undefined;
|
assigneeId?: string | null | undefined;
|
||||||
categories?: Array<StatusCategory> | null | undefined;
|
categories?: Array<StatusCategory> | null | undefined;
|
||||||
labels?: Array<string> | null | undefined;
|
labels?: Array<string> | null | undefined;
|
||||||
|
/** Only the tickets hanging from this one */
|
||||||
|
parentId?: string | null | undefined;
|
||||||
priorities?: Array<IssuePriority> | null | undefined;
|
priorities?: Array<IssuePriority> | null | undefined;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
releaseId?: string | null | undefined;
|
releaseId?: string | null | undefined;
|
||||||
@@ -139,6 +148,13 @@ export type StatusTransitionsInput = {
|
|||||||
to: Array<string>;
|
to: Array<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type UpdateChecklistItemInput = {
|
||||||
|
done?: boolean | null | undefined;
|
||||||
|
issueId: string;
|
||||||
|
itemId: string;
|
||||||
|
text?: string | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
export type UpdateIssueInput = {
|
export type UpdateIssueInput = {
|
||||||
/** An area id */
|
/** An area id */
|
||||||
area?: string | null | undefined;
|
area?: string | null | undefined;
|
||||||
@@ -219,6 +235,7 @@ export type IssueSummaryFieldsFragment = {
|
|||||||
version: number;
|
version: number;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
|
parent: { id: string; key: string; title: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IssueFieldsFragment = {
|
export type IssueFieldsFragment = {
|
||||||
@@ -249,6 +266,7 @@ export type IssueFieldsFragment = {
|
|||||||
issueKey: string;
|
issueKey: string;
|
||||||
}>;
|
}>;
|
||||||
watchers: Array<{ id: string; name: string }>;
|
watchers: Array<{ id: string; name: string }>;
|
||||||
|
checklist: Array<{ id: string; text: string; done: boolean }>;
|
||||||
attachments: Array<{
|
attachments: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -260,6 +278,7 @@ export type IssueFieldsFragment = {
|
|||||||
}>;
|
}>;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
|
parent: { id: string; key: string; title: string } | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type CommentFieldsFragment = {
|
export type CommentFieldsFragment = {
|
||||||
@@ -326,6 +345,7 @@ export type BoardQuery = {
|
|||||||
version: number;
|
version: number;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
|
parent: { id: string; key: string; title: string } | null;
|
||||||
}>;
|
}>;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
@@ -364,6 +384,7 @@ export type IssueQuery = {
|
|||||||
issueKey: string;
|
issueKey: string;
|
||||||
}>;
|
}>;
|
||||||
watchers: Array<{ id: string; name: string }>;
|
watchers: Array<{ id: string; name: string }>;
|
||||||
|
checklist: Array<{ id: string; text: string; done: boolean }>;
|
||||||
attachments: Array<{
|
attachments: Array<{
|
||||||
id: string;
|
id: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
@@ -375,6 +396,7 @@ export type IssueQuery = {
|
|||||||
}>;
|
}>;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
|
parent: { id: string; key: string; title: string } | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -400,6 +422,7 @@ export type SearchIssuesQuery = {
|
|||||||
version: number;
|
version: number;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
|
parent: { id: string; key: string; title: string } | null;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -549,10 +572,106 @@ export type MyIssuesQuery = {
|
|||||||
version: number;
|
version: number;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
|
parent: { id: string; key: string; title: string } | null;
|
||||||
}>;
|
}>;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type IssueChildrenQueryVariables = Exact<{
|
||||||
|
filter: IssueFilterInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type IssueChildrenQuery = {
|
||||||
|
issues: {
|
||||||
|
items: Array<{
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
area: string | null;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
statusCategory: StatusCategory;
|
||||||
|
priority: IssuePriority;
|
||||||
|
labels: Array<string>;
|
||||||
|
commentCount: number;
|
||||||
|
updatedAt: string;
|
||||||
|
version: number;
|
||||||
|
assignee: { id: string; name: string } | null;
|
||||||
|
release: { id: string; name: string } | null;
|
||||||
|
parent: { id: string; key: string; title: string } | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EpicsQueryVariables = Exact<{
|
||||||
|
projectId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type EpicsQuery = {
|
||||||
|
epics: Array<{
|
||||||
|
issue: {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
key: string;
|
||||||
|
type: string;
|
||||||
|
area: string | null;
|
||||||
|
title: string;
|
||||||
|
status: string;
|
||||||
|
statusCategory: StatusCategory;
|
||||||
|
priority: IssuePriority;
|
||||||
|
labels: Array<string>;
|
||||||
|
commentCount: number;
|
||||||
|
updatedAt: string;
|
||||||
|
version: number;
|
||||||
|
assignee: { id: string; name: string } | null;
|
||||||
|
release: { id: string; name: string } | null;
|
||||||
|
parent: { id: string; key: string; title: string } | null;
|
||||||
|
};
|
||||||
|
progress: {
|
||||||
|
total: number;
|
||||||
|
finished: number;
|
||||||
|
percent: number;
|
||||||
|
storyPoints: number;
|
||||||
|
finishedStoryPoints: number;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SetIssueParentMutationVariables = Exact<{
|
||||||
|
issueId: string;
|
||||||
|
parentId?: string | null | undefined;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type SetIssueParentMutation = {
|
||||||
|
setIssueParent: { id: string; key: string | null; version: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type AddChecklistItemMutationVariables = Exact<{
|
||||||
|
input: AddChecklistItemInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type AddChecklistItemMutation = {
|
||||||
|
addChecklistItem: { id: string; key: string | null; version: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdateChecklistItemMutationVariables = Exact<{
|
||||||
|
input: UpdateChecklistItemInput;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type UpdateChecklistItemMutation = {
|
||||||
|
updateChecklistItem: { id: string; key: string | null; version: number };
|
||||||
|
};
|
||||||
|
|
||||||
|
export type RemoveChecklistItemMutationVariables = Exact<{
|
||||||
|
issueId: string;
|
||||||
|
itemId: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
export type RemoveChecklistItemMutation = {
|
||||||
|
removeChecklistItem: { id: string; key: string | null; version: number };
|
||||||
|
};
|
||||||
|
|
||||||
export type NotificationFieldsFragment = {
|
export type NotificationFieldsFragment = {
|
||||||
id: string;
|
id: string;
|
||||||
kind: NotificationKind;
|
kind: NotificationKind;
|
||||||
@@ -987,6 +1106,7 @@ export type IssueChangedSubscription = {
|
|||||||
version: number;
|
version: number;
|
||||||
assignee: { id: string; name: string } | null;
|
assignee: { id: string; name: string } | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
|
parent: { id: string; key: string; title: string } | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -1346,6 +1466,18 @@ export const IssueSummaryFieldsFragmentDoc = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'parent' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1431,6 +1563,18 @@ export const IssueFieldsFragmentDoc = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'checklist' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'done' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'attachments' },
|
name: { kind: 'Name', value: 'attachments' },
|
||||||
@@ -1524,6 +1668,18 @@ export const IssueFieldsFragmentDoc = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'parent' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -1926,6 +2082,18 @@ export const BoardDocument = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'parent' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2045,6 +2213,18 @@ export const IssueDocument = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'parent' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -2110,6 +2290,18 @@ export const IssueDocument = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'checklist' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'text' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'done' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
kind: 'Field',
|
kind: 'Field',
|
||||||
name: { kind: 'Name', value: 'attachments' },
|
name: { kind: 'Name', value: 'attachments' },
|
||||||
@@ -2271,6 +2463,18 @@ export const SearchIssuesDocument = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'parent' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -3474,11 +3678,652 @@ export const MyIssuesDocument = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'parent' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
} as unknown as DocumentNode<MyIssuesQuery, MyIssuesQueryVariables>;
|
} as unknown as DocumentNode<MyIssuesQuery, MyIssuesQueryVariables>;
|
||||||
|
export const IssueChildrenDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'query',
|
||||||
|
name: { kind: 'Name', value: 'IssueChildren' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'filter' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'IssueFilterInput' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'issues' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'filter' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'filter' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'limit' },
|
||||||
|
value: { kind: 'IntValue', value: '200' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'items' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'UserRef' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'Issue' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'commentCount' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'assignee' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'release' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'parent' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<IssueChildrenQuery, IssueChildrenQueryVariables>;
|
||||||
|
export const EpicsDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'query',
|
||||||
|
name: { kind: 'Name', value: 'Epics' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'epics' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'projectId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'issue' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'progress' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'total' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'finished' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'percent' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'storyPoints' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'finishedStoryPoints' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'UserRef' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'IssueSummaryFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'Issue' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'projectId' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'type' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'area' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'status' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'statusCategory' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'priority' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'labels' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'commentCount' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'updatedAt' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'assignee' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'UserRefFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'release' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'name' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'parent' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<EpicsQuery, EpicsQueryVariables>;
|
||||||
|
export const SetIssueParentDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'SetIssueParent' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'parentId' },
|
||||||
|
},
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'setIssueParent' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'parentId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'parentId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'CommandResult' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
SetIssueParentMutation,
|
||||||
|
SetIssueParentMutationVariables
|
||||||
|
>;
|
||||||
|
export const AddChecklistItemDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'AddChecklistItem' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'AddChecklistItemInput' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'addChecklistItem' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'CommandResult' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
AddChecklistItemMutation,
|
||||||
|
AddChecklistItemMutationVariables
|
||||||
|
>;
|
||||||
|
export const UpdateChecklistItemDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'UpdateChecklistItem' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'UpdateChecklistItemInput' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'updateChecklistItem' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'input' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'CommandResult' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
UpdateChecklistItemMutation,
|
||||||
|
UpdateChecklistItemMutationVariables
|
||||||
|
>;
|
||||||
|
export const RemoveChecklistItemDocument = {
|
||||||
|
kind: 'Document',
|
||||||
|
definitions: [
|
||||||
|
{
|
||||||
|
kind: 'OperationDefinition',
|
||||||
|
operation: 'mutation',
|
||||||
|
name: { kind: 'Name', value: 'RemoveChecklistItem' },
|
||||||
|
variableDefinitions: [
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'VariableDefinition',
|
||||||
|
variable: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'itemId' },
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
kind: 'NonNullType',
|
||||||
|
type: { kind: 'NamedType', name: { kind: 'Name', value: 'ID' } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'removeChecklistItem' },
|
||||||
|
arguments: [
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'issueId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'Argument',
|
||||||
|
name: { kind: 'Name', value: 'itemId' },
|
||||||
|
value: {
|
||||||
|
kind: 'Variable',
|
||||||
|
name: { kind: 'Name', value: 'itemId' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{
|
||||||
|
kind: 'FragmentSpread',
|
||||||
|
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
kind: 'FragmentDefinition',
|
||||||
|
name: { kind: 'Name', value: 'CommandResultFields' },
|
||||||
|
typeCondition: {
|
||||||
|
kind: 'NamedType',
|
||||||
|
name: { kind: 'Name', value: 'CommandResult' },
|
||||||
|
},
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'version' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as unknown as DocumentNode<
|
||||||
|
RemoveChecklistItemMutation,
|
||||||
|
RemoveChecklistItemMutationVariables
|
||||||
|
>;
|
||||||
export const MyNotificationsDocument = {
|
export const MyNotificationsDocument = {
|
||||||
kind: 'Document',
|
kind: 'Document',
|
||||||
definitions: [
|
definitions: [
|
||||||
@@ -6163,6 +7008,18 @@ export const IssueChangedDocument = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
kind: 'Field',
|
||||||
|
name: { kind: 'Name', value: 'parent' },
|
||||||
|
selectionSet: {
|
||||||
|
kind: 'SelectionSet',
|
||||||
|
selections: [
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'id' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'key' } },
|
||||||
|
{ kind: 'Field', name: { kind: 'Name', value: 'title' } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -129,3 +129,50 @@ query MyIssues(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
query IssueChildren($filter: IssueFilterInput!) {
|
||||||
|
issues(filter: $filter, limit: 200) {
|
||||||
|
items {
|
||||||
|
...IssueSummaryFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query Epics($projectId: ID!) {
|
||||||
|
epics(projectId: $projectId) {
|
||||||
|
issue {
|
||||||
|
...IssueSummaryFields
|
||||||
|
}
|
||||||
|
progress {
|
||||||
|
total
|
||||||
|
finished
|
||||||
|
percent
|
||||||
|
storyPoints
|
||||||
|
finishedStoryPoints
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation SetIssueParent($issueId: ID!, $parentId: ID) {
|
||||||
|
setIssueParent(issueId: $issueId, parentId: $parentId) {
|
||||||
|
...CommandResultFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation AddChecklistItem($input: AddChecklistItemInput!) {
|
||||||
|
addChecklistItem(input: $input) {
|
||||||
|
...CommandResultFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation UpdateChecklistItem($input: UpdateChecklistItemInput!) {
|
||||||
|
updateChecklistItem(input: $input) {
|
||||||
|
...CommandResultFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation RemoveChecklistItem($issueId: ID!, $itemId: ID!) {
|
||||||
|
removeChecklistItem(issueId: $issueId, itemId: $itemId) {
|
||||||
|
...CommandResultFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
147
libs/web/tracker/presenters/src/lib/pages/epics.page.ts
Normal file
147
libs/web/tracker/presenters/src/lib/pages/epics.page.ts
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
effect,
|
||||||
|
inject,
|
||||||
|
untracked,
|
||||||
|
} from '@angular/core';
|
||||||
|
import { RouterLink } from '@angular/router';
|
||||||
|
import {
|
||||||
|
PiEmptyStateComponent,
|
||||||
|
PiNoteComponent,
|
||||||
|
PiProgressBarComponent,
|
||||||
|
PiSkeletonComponent,
|
||||||
|
} from '@pi/ui';
|
||||||
|
import {
|
||||||
|
EpicsFacade,
|
||||||
|
ProjectFacade,
|
||||||
|
provideEpicsFacade,
|
||||||
|
} from '@task-stream/web/tracker/application';
|
||||||
|
import { Epic } from '@task-stream/web/tracker/domain';
|
||||||
|
import { statusLook, typeLook } from '../ui/copy';
|
||||||
|
|
||||||
|
/** /p/:key/epics — the big pieces of work and how far each has got. */
|
||||||
|
@Component({
|
||||||
|
selector: 'ts-epics-page',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
imports: [
|
||||||
|
RouterLink,
|
||||||
|
PiEmptyStateComponent,
|
||||||
|
PiNoteComponent,
|
||||||
|
PiProgressBarComponent,
|
||||||
|
PiSkeletonComponent,
|
||||||
|
],
|
||||||
|
providers: [provideEpicsFacade()],
|
||||||
|
template: `
|
||||||
|
<div class="ts-section-header">
|
||||||
|
<p class="ts-muted">
|
||||||
|
Una épica es un ticket que agrupa a otros. Cuelga tickets de ella desde
|
||||||
|
su ficha («Subtareas») y aquí verás cuánto falta.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (facade.error(); as error) {
|
||||||
|
<pi-note
|
||||||
|
gap="xs"
|
||||||
|
status="error"
|
||||||
|
appearance="fill"
|
||||||
|
type="status"
|
||||||
|
title="No se pudieron cargar las épicas"
|
||||||
|
[text]="error"
|
||||||
|
/>
|
||||||
|
} @else if (!facade.loaded()) {
|
||||||
|
<pi-skeleton shape="text" [lines]="6" />
|
||||||
|
} @else if (facade.epics().length) {
|
||||||
|
<ul class="ts-epics">
|
||||||
|
@for (epic of facade.epics(); track epic.issue.id) {
|
||||||
|
<li class="ts-epic ts-panel" [style.--tone]="typeTone(epic)">
|
||||||
|
<header class="ts-epic__head">
|
||||||
|
<a
|
||||||
|
class="ts-key"
|
||||||
|
[routerLink]="['..', 'issues', epic.issue.key]"
|
||||||
|
>{{ epic.issue.key }}</a
|
||||||
|
>
|
||||||
|
<a
|
||||||
|
class="ts-epic__title"
|
||||||
|
[routerLink]="['..', 'issues', epic.issue.key]"
|
||||||
|
>{{ epic.issue.title }}</a
|
||||||
|
>
|
||||||
|
<span class="ts-pill" [style.--tone]="statusTone(epic)">{{
|
||||||
|
statusLabel(epic)
|
||||||
|
}}</span>
|
||||||
|
</header>
|
||||||
|
<pi-progress-bar
|
||||||
|
[label]="summary(epic)"
|
||||||
|
[value]="epic.progress.percent"
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else {
|
||||||
|
<pi-empty-state
|
||||||
|
icon="flag"
|
||||||
|
title="Aún no hay épicas"
|
||||||
|
description="Abre un ticket grande y crea sus subtareas desde «Subtareas»: aparecerá aquí con su progreso."
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
styles: `
|
||||||
|
.ts-epics {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
.ts-epic {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1rem 1.125rem;
|
||||||
|
border-left: 3px solid var(--tone, var(--ts-brand));
|
||||||
|
}
|
||||||
|
.ts-epic__head {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||||
|
gap: 0.625rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.ts-epic__title {
|
||||||
|
overflow: hidden;
|
||||||
|
color: inherit;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
&:hover {
|
||||||
|
color: var(--ts-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
`,
|
||||||
|
})
|
||||||
|
export class EpicsPage {
|
||||||
|
protected readonly facade = inject(EpicsFacade);
|
||||||
|
private readonly project = inject(ProjectFacade);
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const projectId = this.project.project()?.id;
|
||||||
|
if (projectId) untracked(() => this.facade.open(projectId));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
protected summary(epic: Epic): string {
|
||||||
|
const { finished, total, percent, storyPoints, finishedStoryPoints } =
|
||||||
|
epic.progress;
|
||||||
|
const points = storyPoints
|
||||||
|
? ` · ${finishedStoryPoints}/${storyPoints} puntos`
|
||||||
|
: '';
|
||||||
|
return `${percent}% · ${finished} de ${total} tickets terminados${points}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
protected typeTone = (epic: Epic) =>
|
||||||
|
typeLook(this.project.workflow(), epic.issue.type).tone;
|
||||||
|
protected statusTone = (epic: Epic) =>
|
||||||
|
statusLook(this.project.workflow(), epic.issue.status).tone;
|
||||||
|
protected statusLabel = (epic: Epic) =>
|
||||||
|
statusLook(this.project.workflow(), epic.issue.status).label;
|
||||||
|
}
|
||||||
@@ -192,6 +192,92 @@ a.ts-key {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ts-inline-form--two {
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breakdown: the ticket it hangs from, its checklist and its subtasks.
|
||||||
|
.ts-parent {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.375rem;
|
||||||
|
min-width: 0;
|
||||||
|
color: var(--ts-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
text-decoration: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--ts-brand);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-parent__title {
|
||||||
|
overflow: hidden;
|
||||||
|
max-width: 18rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-checklist,
|
||||||
|
.ts-children {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-checklist__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
&.is-done {
|
||||||
|
color: var(--ts-muted);
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-children {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-child {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: auto minmax(0, 1fr) auto auto;
|
||||||
|
gap: 0.625rem;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.375rem 0;
|
||||||
|
border-top: 1px solid var(--ts-line);
|
||||||
|
|
||||||
|
.ts-comment__avatar {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
font-size: 0.625rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-child__title {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ts-parent-form {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
.ts-inline-form {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
> .ts-muted {
|
||||||
|
margin-top: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Comments: a thread with initials on a rail.
|
// Comments: a thread with initials on a rail.
|
||||||
.ts-comment {
|
.ts-comment {
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -14,11 +14,13 @@ import { form, FormField, required } from '@angular/forms/signals';
|
|||||||
import { RouterLink } from '@angular/router';
|
import { RouterLink } from '@angular/router';
|
||||||
import {
|
import {
|
||||||
PiActionButtonComponent,
|
PiActionButtonComponent,
|
||||||
|
PiCheckboxComponent,
|
||||||
PiFileUploadComponent,
|
PiFileUploadComponent,
|
||||||
PiIconButtonComponent,
|
PiIconButtonComponent,
|
||||||
PiIconComponent,
|
PiIconComponent,
|
||||||
PiLabelDirective,
|
PiLabelDirective,
|
||||||
PiNoteComponent,
|
PiNoteComponent,
|
||||||
|
PiProgressBarComponent,
|
||||||
PiSelectFieldComponent,
|
PiSelectFieldComponent,
|
||||||
PiSkeletonComponent,
|
PiSkeletonComponent,
|
||||||
PiTextareaComponent,
|
PiTextareaComponent,
|
||||||
@@ -68,11 +70,13 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
FormField,
|
FormField,
|
||||||
AttachmentItem,
|
AttachmentItem,
|
||||||
PiActionButtonComponent,
|
PiActionButtonComponent,
|
||||||
|
PiCheckboxComponent,
|
||||||
PiFileUploadComponent,
|
PiFileUploadComponent,
|
||||||
PiIconComponent,
|
PiIconComponent,
|
||||||
PiIconButtonComponent,
|
PiIconButtonComponent,
|
||||||
PiLabelDirective,
|
PiLabelDirective,
|
||||||
PiNoteComponent,
|
PiNoteComponent,
|
||||||
|
PiProgressBarComponent,
|
||||||
PiSelectFieldComponent,
|
PiSelectFieldComponent,
|
||||||
PiSkeletonComponent,
|
PiSkeletonComponent,
|
||||||
PiTextFieldComponent,
|
PiTextFieldComponent,
|
||||||
@@ -96,6 +100,17 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
<span class="ts-pill" [style.--tone]="status().tone">{{
|
<span class="ts-pill" [style.--tone]="status().tone">{{
|
||||||
status().label
|
status().label
|
||||||
}}</span>
|
}}</span>
|
||||||
|
@if (issue.parent; as parent) {
|
||||||
|
<a
|
||||||
|
class="ts-parent"
|
||||||
|
[routerLink]="['..', parent.key]"
|
||||||
|
[title]="'Cuelga de ' + parent.key"
|
||||||
|
>
|
||||||
|
<span aria-hidden="true">↑</span>
|
||||||
|
<span class="ts-key">{{ parent.key }}</span>
|
||||||
|
<span class="ts-parent__title">{{ parent.title }}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="ts-issue__title-row">
|
<div class="ts-issue__title-row">
|
||||||
<h1>{{ issue.title }}</h1>
|
<h1>{{ issue.title }}</h1>
|
||||||
@@ -161,6 +176,168 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
}
|
}
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
|
<article class="ts-block">
|
||||||
|
<h2 class="ts-section">
|
||||||
|
Checklist
|
||||||
|
@if (issue.checklist.length) {
|
||||||
|
<span class="ts-mono"
|
||||||
|
>{{ facade.checklistProgress().done }}/{{
|
||||||
|
facade.checklistProgress().total
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</h2>
|
||||||
|
@if (issue.checklist.length) {
|
||||||
|
<ul class="ts-checklist">
|
||||||
|
@for (item of issue.checklist; track item.id) {
|
||||||
|
<li class="ts-checklist__item" [class.is-done]="item.done">
|
||||||
|
<pi-checkbox
|
||||||
|
[label]="item.text"
|
||||||
|
[checked]="item.done"
|
||||||
|
[disabled]="!project.canContribute()"
|
||||||
|
(checkedChange)="
|
||||||
|
facade.updateChecklistItem(item.id, { done: $event })
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
@if (project.canContribute()) {
|
||||||
|
<pi-icon-button
|
||||||
|
icon="close"
|
||||||
|
color="grey"
|
||||||
|
appearance="ghost"
|
||||||
|
[attr.aria-label]="'Quitar «' + item.text + '»'"
|
||||||
|
(execute)="facade.removeChecklistItem(item.id)"
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else if (!project.canContribute()) {
|
||||||
|
<p class="ts-muted">Sin pasos.</p>
|
||||||
|
}
|
||||||
|
@if (project.canContribute()) {
|
||||||
|
<form
|
||||||
|
class="ts-inline-form ts-inline-form--two"
|
||||||
|
(submit)="$event.preventDefault(); addChecklistItem()"
|
||||||
|
novalidate
|
||||||
|
>
|
||||||
|
<pi-text-field
|
||||||
|
placeholder="Nuevo paso, p. ej. Actualizar el changelog"
|
||||||
|
[(value)]="checklistText"
|
||||||
|
/>
|
||||||
|
<pi-action-button
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
text="Añadir"
|
||||||
|
[disabled]="!checklistText().trim()"
|
||||||
|
(execute)="addChecklistItem()"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="ts-block">
|
||||||
|
<h2 class="ts-section">
|
||||||
|
Subtareas
|
||||||
|
@if (facade.children().length) {
|
||||||
|
<span class="ts-mono"
|
||||||
|
>{{ facade.childProgress().finished }}/{{
|
||||||
|
facade.childProgress().total
|
||||||
|
}}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</h2>
|
||||||
|
@if (facade.children().length) {
|
||||||
|
<pi-progress-bar
|
||||||
|
[label]="facade.childProgress().percent + '% terminado'"
|
||||||
|
[value]="facade.childProgress().percent"
|
||||||
|
/>
|
||||||
|
<ul class="ts-children">
|
||||||
|
@for (child of facade.children(); track child.id) {
|
||||||
|
<li class="ts-child">
|
||||||
|
<a class="ts-key" [routerLink]="['..', child.key]">{{
|
||||||
|
child.key
|
||||||
|
}}</a>
|
||||||
|
<span class="ts-child__title">{{ child.title }}</span>
|
||||||
|
<span
|
||||||
|
class="ts-pill"
|
||||||
|
[style.--tone]="statusTone(child.status)"
|
||||||
|
>{{ statusLabel(child.status) }}</span
|
||||||
|
>
|
||||||
|
@if (child.assignee) {
|
||||||
|
<span
|
||||||
|
class="ts-comment__avatar"
|
||||||
|
[title]="child.assignee.name"
|
||||||
|
>{{ initialsOf(child.assignee.name) }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
} @else if (!project.canContribute()) {
|
||||||
|
<p class="ts-muted">Sin subtareas.</p>
|
||||||
|
}
|
||||||
|
@if (project.canContribute()) {
|
||||||
|
<form
|
||||||
|
class="ts-inline-form"
|
||||||
|
(submit)="$event.preventDefault(); addSubtask()"
|
||||||
|
novalidate
|
||||||
|
>
|
||||||
|
<pi-select-field
|
||||||
|
[options]="subtaskTypeOptions()"
|
||||||
|
[value]="subtaskType() ?? defaultSubtaskType()"
|
||||||
|
(valueChange)="subtaskType.set($event)"
|
||||||
|
/>
|
||||||
|
<pi-text-field
|
||||||
|
placeholder="Título de la subtarea"
|
||||||
|
[(value)]="subtaskTitle"
|
||||||
|
/>
|
||||||
|
<pi-action-button
|
||||||
|
size="small"
|
||||||
|
color="primary"
|
||||||
|
text="Crear"
|
||||||
|
[disabled]="!subtaskTitle().trim()"
|
||||||
|
(execute)="addSubtask()"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
<div class="ts-parent-form">
|
||||||
|
@if (issue.parent; as parent) {
|
||||||
|
<span class="ts-muted"
|
||||||
|
>Cuelga de
|
||||||
|
<a class="ts-key" [routerLink]="['..', parent.key]">{{
|
||||||
|
parent.key
|
||||||
|
}}</a></span
|
||||||
|
>
|
||||||
|
<pi-action-button
|
||||||
|
size="small"
|
||||||
|
color="grey"
|
||||||
|
appearance="outline"
|
||||||
|
text="Sacar"
|
||||||
|
(execute)="facade.setParent(null)"
|
||||||
|
/>
|
||||||
|
} @else {
|
||||||
|
<form
|
||||||
|
class="ts-inline-form ts-inline-form--two"
|
||||||
|
(submit)="$event.preventDefault(); setParent()"
|
||||||
|
novalidate
|
||||||
|
>
|
||||||
|
<pi-text-field
|
||||||
|
placeholder="Colgar de… (clave de la épica, p. ej. WEB-3)"
|
||||||
|
[(value)]="parentKey"
|
||||||
|
/>
|
||||||
|
<pi-action-button
|
||||||
|
size="small"
|
||||||
|
color="grey"
|
||||||
|
appearance="outline"
|
||||||
|
text="Colgar"
|
||||||
|
[disabled]="!parentKey().trim()"
|
||||||
|
(execute)="setParent()"
|
||||||
|
/>
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</article>
|
||||||
|
|
||||||
<article class="ts-block">
|
<article class="ts-block">
|
||||||
<h2 class="ts-section">
|
<h2 class="ts-section">
|
||||||
Adjuntos
|
Adjuntos
|
||||||
@@ -639,6 +816,23 @@ export class IssuePage {
|
|||||||
)
|
)
|
||||||
.slice(0, 6);
|
.slice(0, 6);
|
||||||
});
|
});
|
||||||
|
protected readonly checklistText = signal('');
|
||||||
|
protected readonly subtaskTitle = signal('');
|
||||||
|
protected readonly subtaskType = signal<string | null>(null);
|
||||||
|
protected readonly parentKey = signal('');
|
||||||
|
/** Active types; subtasks start as tasks when the project has them. */
|
||||||
|
protected readonly subtaskTypeOptions = computed<
|
||||||
|
PiSelectFieldOption<string>[]
|
||||||
|
>(() =>
|
||||||
|
this.project
|
||||||
|
.workflow()
|
||||||
|
.settings.issueTypes.filter((t) => !t.archived)
|
||||||
|
.map((t) => ({ value: t.id, label: t.name })),
|
||||||
|
);
|
||||||
|
protected readonly defaultSubtaskType = computed(() => {
|
||||||
|
const ids = this.subtaskTypeOptions().map((o) => o.value);
|
||||||
|
return ids.includes('TASK') ? 'TASK' : (ids[0] ?? '');
|
||||||
|
});
|
||||||
protected readonly pendingFiles = signal<File[]>([]);
|
protected readonly pendingFiles = signal<File[]>([]);
|
||||||
protected readonly maxFileBytes = MAX_FILE_BYTES;
|
protected readonly maxFileBytes = MAX_FILE_BYTES;
|
||||||
|
|
||||||
@@ -687,6 +881,28 @@ export class IssuePage {
|
|||||||
? LINK[link.type].outward
|
? LINK[link.type].outward
|
||||||
: LINK[link.type].inward;
|
: LINK[link.type].inward;
|
||||||
|
|
||||||
|
protected addChecklistItem(): void {
|
||||||
|
const text = this.checklistText().trim();
|
||||||
|
if (!text) return;
|
||||||
|
this.facade.addChecklistItem(text);
|
||||||
|
this.checklistText.set('');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected addSubtask(): void {
|
||||||
|
const title = this.subtaskTitle().trim();
|
||||||
|
const type = this.subtaskType() ?? this.defaultSubtaskType();
|
||||||
|
if (!title || !type) return;
|
||||||
|
this.facade.addSubtask(title, type);
|
||||||
|
this.subtaskTitle.set('');
|
||||||
|
}
|
||||||
|
|
||||||
|
protected setParent(): void {
|
||||||
|
const key = this.parentKey().trim();
|
||||||
|
if (!key) return;
|
||||||
|
this.facade.setParent(key);
|
||||||
|
this.parentKey.set('');
|
||||||
|
}
|
||||||
|
|
||||||
protected move(to: string): void {
|
protected move(to: string): void {
|
||||||
if (this.project.workflow().requiresReason(to)) {
|
if (this.project.workflow().requiresReason(to)) {
|
||||||
this.pendingStatus = to;
|
this.pendingStatus = to;
|
||||||
|
|||||||
@@ -85,6 +85,7 @@ import { projectTone, PUBLIC_TONE, ROLE } from '../ui/copy';
|
|||||||
}
|
}
|
||||||
<nav class="ts-tabs" aria-label="Secciones del proyecto">
|
<nav class="ts-tabs" aria-label="Secciones del proyecto">
|
||||||
<a routerLink="board" routerLinkActive="is-active">Tablero</a>
|
<a routerLink="board" routerLinkActive="is-active">Tablero</a>
|
||||||
|
<a routerLink="epics" routerLinkActive="is-active">Épicas</a>
|
||||||
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
|
<a routerLink="releases" routerLinkActive="is-active">Releases</a>
|
||||||
<a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a>
|
<a routerLink="dashboard" routerLinkActive="is-active">Seguimiento</a>
|
||||||
<a routerLink="analytics" routerLinkActive="is-active">Estadísticas</a>
|
<a routerLink="analytics" routerLinkActive="is-active">Estadísticas</a>
|
||||||
|
|||||||
@@ -38,6 +38,12 @@ export const trackerRoutes: Routes = [
|
|||||||
loadComponent: () =>
|
loadComponent: () =>
|
||||||
import('./pages/issue.page').then((m) => m.IssuePage),
|
import('./pages/issue.page').then((m) => m.IssuePage),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'epics',
|
||||||
|
title: 'Épicas · TaskStream',
|
||||||
|
loadComponent: () =>
|
||||||
|
import('./pages/epics.page').then((m) => m.EpicsPage),
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'releases',
|
path: 'releases',
|
||||||
title: 'Releases · TaskStream',
|
title: 'Releases · TaskStream',
|
||||||
|
|||||||
@@ -40,6 +40,11 @@ import { initials, PRIORITY, PRIORITY_LEVEL, typeLook } from './copy';
|
|||||||
>
|
>
|
||||||
<i></i><i></i><i></i><i></i>
|
<i></i><i></i><i></i><i></i>
|
||||||
</span>
|
</span>
|
||||||
|
@if (issue().parent; as parent) {
|
||||||
|
<span class="ts-card__meta" [attr.title]="'Cuelga de ' + parent.title"
|
||||||
|
>↑ {{ parent.key }}</span
|
||||||
|
>
|
||||||
|
}
|
||||||
@if (issue().release; as release) {
|
@if (issue().release; as release) {
|
||||||
<span class="ts-card__meta" title="Release"
|
<span class="ts-card__meta" title="Release"
|
||||||
>◆ {{ release.name }}</span
|
>◆ {{ release.name }}</span
|
||||||
|
|||||||
Reference in New Issue
Block a user