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:
2026-10-08 13:52:43 +02:00
parent 75323115fb
commit d4da33d4e8
84 changed files with 4719 additions and 79 deletions

View File

@@ -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": {

View File

@@ -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

View File

@@ -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;

View File

@@ -28,6 +28,7 @@ describe('appRoutes', () => {
'', '',
'board', 'board',
'issues/:issueKey', 'issues/:issueKey',
'epics',
'releases', 'releases',
'dashboard', 'dashboard',
'analytics', 'analytics',

View File

@@ -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 |

View File

@@ -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:

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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();
}
}

View File

@@ -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,
]; ];

View File

@@ -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);

View File

@@ -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();

View File

@@ -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();
} }

View File

@@ -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();
}
}

View File

@@ -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();
}
}

View File

@@ -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';

View 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);
}
}

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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)

View File

@@ -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,

View File

@@ -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 };

View File

@@ -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);
}
}

View File

@@ -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,

View File

@@ -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);
}
}
}

View File

@@ -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);
} }
} }

View File

@@ -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,

View File

@@ -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>;

View File

@@ -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[]>;
} }

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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();
}
}

View File

@@ -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';

View File

@@ -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,
]; ];

View File

@@ -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,
}; };
} }
} }

View File

@@ -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;

View File

@@ -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,

View File

@@ -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. */

View File

@@ -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';

View File

@@ -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';

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View 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;
}

View 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)`,
);
}

View File

@@ -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 =

View File

@@ -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;
} }

View File

@@ -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;

View File

@@ -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()) {

View File

@@ -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 ?? [],
}; };
} }

View File

@@ -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 } },

View File

@@ -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,

View File

@@ -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()

View File

@@ -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)',
}) })

View File

@@ -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[];

View File

@@ -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)

View File

@@ -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;

View File

@@ -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({

View File

@@ -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>;

View File

@@ -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';

View 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>(),
},
});

View File

@@ -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>(),
}, },
}); });

View 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];
}

View File

@@ -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' });

View File

@@ -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;

View 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)),
}),
),
),
),
),
),
}),
),
);

View File

@@ -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(

View File

@@ -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';

View 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 });
});
});

View 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 };
}

View File

@@ -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;
}; };

View File

@@ -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;
} }

View File

@@ -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 {

View File

@@ -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,

View File

@@ -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

View File

@@ -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' } },
],
},
},
], ],
}, },
}, },

View File

@@ -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
}
}

View 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;
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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',

View File

@@ -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