Compare commits
24 Commits
80d9bee6e0
...
8c998d7423
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c998d7423 | |||
| feea58d0e8 | |||
| bec39da46d | |||
| 71b807b94b | |||
| d6a434ca1d | |||
| 4148335457 | |||
| 5863d773be | |||
| 8994590b54 | |||
| 1076205acc | |||
| d43fc620e9 | |||
| 046d77c813 | |||
| 24b37b6628 | |||
| 45a3dc1e16 | |||
| 09bd8c19a3 | |||
| 6aec774c92 | |||
| 50d3ffd1dd | |||
| d2e31789a7 | |||
| 298383f19f | |||
| 0606f53026 | |||
| d4da33d4e8 | |||
| 75323115fb | |||
| 88928b402f | |||
| efa06a0723 | |||
| 0b65c1c912 |
16
.env.example
16
.env.example
@@ -36,6 +36,22 @@ GRAPHQL_PLAYGROUND=true
|
||||
SWAGGER_ENABLED=true
|
||||
OPENAPI_FILE=apps/api/openapi.json
|
||||
|
||||
# Files attached to tickets: folder (a volume in Docker) and size limit per file.
|
||||
ATTACHMENTS_DIR=data/attachments
|
||||
ATTACHMENTS_MAX_MB=25
|
||||
|
||||
# Optional e-mail copies of notifications. Without SMTP_URL nothing is sent.
|
||||
SMTP_URL=
|
||||
MAIL_FROM=
|
||||
WEB_URL=http://localhost:4200
|
||||
|
||||
# Outgoing webhooks refuse hosts on this machine or its private network unless true.
|
||||
WEBHOOKS_ALLOW_PRIVATE=false
|
||||
|
||||
# Due-date reminders (hourly). The day changes at midnight in this time zone.
|
||||
REMINDERS_ENABLED=true
|
||||
REMINDERS_TIMEZONE=Europe/Madrid
|
||||
|
||||
# Docker builds only: token of Pireon's Nexus for @pi/ui (build secret, never stored in an image).
|
||||
# Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
|
||||
NEXUS_TOKEN=
|
||||
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -53,3 +53,6 @@ Thumbs.db
|
||||
__screenshots__/
|
||||
|
||||
vitest.config.*.timestamp*!apps/web/.env
|
||||
|
||||
# Attached files in development (ATTACHMENTS_DIR)
|
||||
/data
|
||||
|
||||
@@ -30,6 +30,9 @@ ENV NODE_ENV=production PORT=3000
|
||||
COPY --from=build-api /workspace/dist/apps/api/package.json /workspace/dist/apps/api/package-lock.json ./
|
||||
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund && npm cache clean --force
|
||||
COPY --from=build-api /workspace/dist/apps/api/main.js ./
|
||||
# Attached files (a volume in docker-compose), writable by the user that runs the API.
|
||||
RUN mkdir -p /data/attachments && chown node:node /data/attachments
|
||||
ENV ATTACHMENTS_DIR=/data/attachments
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,15 +3,10 @@
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"sourceRoot": "apps/api/src",
|
||||
"projectType": "application",
|
||||
"tags": [
|
||||
"scope:api",
|
||||
"type:app"
|
||||
],
|
||||
"tags": ["scope:api", "type:app"],
|
||||
"targets": {
|
||||
"prune-lockfile": {
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"dependsOn": ["build"],
|
||||
"cache": true,
|
||||
"executor": "@nx/js:prune-lockfile",
|
||||
"outputs": [
|
||||
@@ -23,32 +18,23 @@
|
||||
}
|
||||
},
|
||||
"copy-workspace-modules": {
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"dependsOn": ["build"],
|
||||
"cache": true,
|
||||
"outputs": [
|
||||
"{workspaceRoot}/dist/apps/api/workspace_modules"
|
||||
],
|
||||
"outputs": ["{workspaceRoot}/dist/apps/api/workspace_modules"],
|
||||
"executor": "@nx/js:copy-workspace-modules",
|
||||
"options": {
|
||||
"buildTarget": "build"
|
||||
}
|
||||
},
|
||||
"prune": {
|
||||
"dependsOn": [
|
||||
"prune-lockfile",
|
||||
"copy-workspace-modules"
|
||||
],
|
||||
"dependsOn": ["prune-lockfile", "copy-workspace-modules"],
|
||||
"executor": "nx:noop"
|
||||
},
|
||||
"serve": {
|
||||
"continuous": true,
|
||||
"executor": "@nx/js:node",
|
||||
"defaultConfiguration": "development",
|
||||
"dependsOn": [
|
||||
"build"
|
||||
],
|
||||
"dependsOn": ["build"],
|
||||
"options": {
|
||||
"buildTarget": "api:build",
|
||||
"runBuildTargetDependencies": false
|
||||
@@ -73,6 +59,13 @@
|
||||
"command": "jest --config apps/api/jest.e2e.config.cts --runInBand"
|
||||
},
|
||||
"cache": false
|
||||
},
|
||||
"contracts": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "node -r ./apps/api/scripts/register.cjs apps/api/scripts/contracts.ts"
|
||||
},
|
||||
"cache": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,6 +13,11 @@ type Activity {
|
||||
type: String!
|
||||
}
|
||||
|
||||
input AddChecklistItemInput {
|
||||
issueId: ID!
|
||||
text: String!
|
||||
}
|
||||
|
||||
type AreaOption {
|
||||
archived: Boolean!
|
||||
id: String!
|
||||
@@ -25,12 +30,134 @@ input AreaOptionInput {
|
||||
name: String!
|
||||
}
|
||||
|
||||
"""
|
||||
A file on a ticket. Download it from the REST API: GET /api/issues/{issueId}/attachments/{id}
|
||||
"""
|
||||
type Attachment {
|
||||
"""The comment it was attached to, if any"""
|
||||
commentId: ID
|
||||
fileName: String!
|
||||
id: ID!
|
||||
mimeType: String!
|
||||
|
||||
"""Bytes"""
|
||||
size: Int!
|
||||
uploadedAt: String!
|
||||
uploadedBy: UserRef!
|
||||
}
|
||||
|
||||
"""When this happens to a ticket, if it is like this, do that"""
|
||||
type Automation {
|
||||
action: AutomationActionSpec!
|
||||
active: Boolean!
|
||||
conditions: AutomationConditions!
|
||||
createdAt: String!
|
||||
createdBy: UserRef!
|
||||
id: ID!
|
||||
|
||||
"""Why the last run did nothing"""
|
||||
lastError: String
|
||||
lastRunAt: String
|
||||
name: String!
|
||||
projectId: ID!
|
||||
runs: Int!
|
||||
trigger: AutomationTriggerSpec!
|
||||
}
|
||||
|
||||
"""What a rule does to the ticket"""
|
||||
enum AutomationAction {
|
||||
ADD_COMMENT
|
||||
ADD_LABEL
|
||||
ADD_TO_ACTIVE_SPRINT
|
||||
ASSIGN
|
||||
ASSIGN_REPORTER
|
||||
SET_PRIORITY
|
||||
SET_STATUS
|
||||
}
|
||||
|
||||
input AutomationActionInput {
|
||||
kind: AutomationAction!
|
||||
value: String
|
||||
}
|
||||
|
||||
type AutomationActionSpec {
|
||||
kind: AutomationAction!
|
||||
|
||||
"""Status, member id, label, priority or comment text"""
|
||||
value: String
|
||||
}
|
||||
|
||||
type AutomationConditions {
|
||||
areas: [String!]
|
||||
label: String
|
||||
priorities: [IssuePriority!]
|
||||
types: [String!]
|
||||
}
|
||||
|
||||
input AutomationConditionsInput {
|
||||
areas: [String!]
|
||||
label: String
|
||||
priorities: [IssuePriority!]
|
||||
types: [String!]
|
||||
}
|
||||
|
||||
"""What starts a rule"""
|
||||
enum AutomationTrigger {
|
||||
COMMENT_ADDED
|
||||
ISSUE_ASSIGNED
|
||||
ISSUE_CREATED
|
||||
PULL_REQUEST_MERGED
|
||||
STATUS_CHANGED
|
||||
}
|
||||
|
||||
input AutomationTriggerInput {
|
||||
kind: AutomationTrigger!
|
||||
status: String
|
||||
}
|
||||
|
||||
type AutomationTriggerSpec {
|
||||
kind: AutomationTrigger!
|
||||
|
||||
"""STATUS_CHANGED: only into this status"""
|
||||
status: String
|
||||
}
|
||||
|
||||
type BoardColumn {
|
||||
issues: [Issue!]!
|
||||
status: String!
|
||||
total: Int!
|
||||
}
|
||||
|
||||
type BulkIssueResult {
|
||||
error: String
|
||||
issueId: ID!
|
||||
ok: Boolean!
|
||||
}
|
||||
|
||||
type BulkResult {
|
||||
results: [BulkIssueResult!]!
|
||||
updated: Int!
|
||||
}
|
||||
|
||||
input BulkUpdateIssuesInput {
|
||||
addLabels: [String!]
|
||||
assigneeId: ID
|
||||
issueIds: [ID!]!
|
||||
|
||||
"""Out of their release"""
|
||||
noRelease: Boolean
|
||||
|
||||
"""Back to the backlog"""
|
||||
noSprint: Boolean
|
||||
priority: IssuePriority
|
||||
releaseId: ID
|
||||
sprintId: ID
|
||||
status: String
|
||||
|
||||
"""Unassign everything"""
|
||||
unassign: Boolean
|
||||
}
|
||||
|
||||
input ChangeIssueStatusInput {
|
||||
issueId: ID!
|
||||
|
||||
@@ -41,6 +168,23 @@ input ChangeIssueStatusInput {
|
||||
status: String!
|
||||
}
|
||||
|
||||
"""A step inside a ticket that does not deserve a ticket of its own"""
|
||||
type ChecklistItem {
|
||||
done: Boolean!
|
||||
id: ID!
|
||||
text: String!
|
||||
}
|
||||
|
||||
"""How far the tickets hanging from one have got"""
|
||||
type ChildProgress {
|
||||
"""Done or cancelled"""
|
||||
finished: Int!
|
||||
finishedStoryPoints: Int!
|
||||
percent: Int!
|
||||
storyPoints: Int!
|
||||
total: Int!
|
||||
}
|
||||
|
||||
"""
|
||||
Writes are acknowledged immediately; the read model catches up asynchronously. Wait for a subscription event with version >= this version to see the change.
|
||||
"""
|
||||
@@ -95,6 +239,14 @@ type CountByStatus {
|
||||
key: String!
|
||||
}
|
||||
|
||||
input CreateAutomationInput {
|
||||
action: AutomationActionInput!
|
||||
conditions: AutomationConditionsInput
|
||||
name: String!
|
||||
projectId: ID!
|
||||
trigger: AutomationTriggerInput!
|
||||
}
|
||||
|
||||
input CreateIssueInput {
|
||||
"""An area id"""
|
||||
area: String
|
||||
@@ -105,9 +257,15 @@ input CreateIssueInput {
|
||||
"""Figma frame for UI changes"""
|
||||
figmaUrl: String
|
||||
labels: [String!]
|
||||
|
||||
"""Epic or ticket of the same project the new one breaks down"""
|
||||
parentId: ID
|
||||
priority: IssuePriority
|
||||
projectId: ID!
|
||||
releaseId: ID
|
||||
|
||||
"""When work should start (roadmap), YYYY-MM-DD"""
|
||||
startDate: String
|
||||
storyPoints: Int
|
||||
title: String!
|
||||
|
||||
@@ -135,6 +293,24 @@ input CreateReleaseInput {
|
||||
targetDate: String
|
||||
}
|
||||
|
||||
input CreateSprintInput {
|
||||
"""YYYY-MM-DD, included"""
|
||||
endDate: String!
|
||||
goal: String
|
||||
name: String!
|
||||
projectId: ID!
|
||||
|
||||
"""YYYY-MM-DD"""
|
||||
startDate: String!
|
||||
}
|
||||
|
||||
input CreateWebhookInput {
|
||||
"""Event names, family.* or *"""
|
||||
events: [String!]!
|
||||
projectId: ID!
|
||||
url: String!
|
||||
}
|
||||
|
||||
type CreatedResolved {
|
||||
created: Int!
|
||||
resolved: Int!
|
||||
@@ -148,6 +324,34 @@ type CumulativeFlow {
|
||||
series: [FlowSeries!]!
|
||||
}
|
||||
|
||||
"""A commit, branch or pull request that mentions the ticket"""
|
||||
type DevLink {
|
||||
at: String!
|
||||
author: String
|
||||
kind: DevLinkKind!
|
||||
|
||||
"""github, gitea or gitlab"""
|
||||
provider: String!
|
||||
|
||||
"""Short SHA, branch or PR number"""
|
||||
ref: String!
|
||||
state: DevLinkState
|
||||
title: String!
|
||||
url: String!
|
||||
}
|
||||
|
||||
enum DevLinkKind {
|
||||
BRANCH
|
||||
COMMIT
|
||||
PULL_REQUEST
|
||||
}
|
||||
|
||||
enum DevLinkState {
|
||||
CLOSED
|
||||
MERGED
|
||||
OPEN
|
||||
}
|
||||
|
||||
type DurationStats {
|
||||
averageDays: Float
|
||||
count: Int!
|
||||
@@ -156,11 +360,30 @@ type DurationStats {
|
||||
p85Days: Float
|
||||
}
|
||||
|
||||
"""A top-level ticket that groups others, with their progress"""
|
||||
type Epic {
|
||||
issue: Issue!
|
||||
progress: ChildProgress!
|
||||
}
|
||||
|
||||
type FlowSeries {
|
||||
counts: [Int!]!
|
||||
status: String!
|
||||
}
|
||||
|
||||
"""
|
||||
Webhook from GitHub, Gitea or GitLab: commits, branches and pull requests naming a ticket key are linked to it
|
||||
"""
|
||||
type GitIntegration {
|
||||
enabled: Boolean!
|
||||
|
||||
"""Webhook secret; only owners and admins see it"""
|
||||
secret: String
|
||||
|
||||
"""Path of the webhook on this API"""
|
||||
webhookPath: String!
|
||||
}
|
||||
|
||||
type Issue {
|
||||
"""Statuses this ticket can move to next (project's workflow)"""
|
||||
allowedTransitions: [String!]!
|
||||
@@ -168,10 +391,15 @@ type Issue {
|
||||
"""An area id"""
|
||||
area: String
|
||||
assignee: UserRef
|
||||
attachments: [Attachment!]!
|
||||
checklist: [ChecklistItem!]!
|
||||
commentCount: Int!
|
||||
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
|
||||
createdAt: String!
|
||||
description: String
|
||||
|
||||
"""Commits, branches and pull requests that mention it"""
|
||||
devLinks: [DevLink!]!
|
||||
dueDate: String
|
||||
figmaUrl: String
|
||||
|
||||
@@ -187,11 +415,23 @@ type Issue {
|
||||
linkedIssues: [Issue!]!
|
||||
links: [IssueLink!]!
|
||||
number: Int!
|
||||
|
||||
"""The epic or ticket this one breaks down"""
|
||||
parent: IssueParent
|
||||
priority: IssuePriority!
|
||||
projectId: ID!
|
||||
projectKey: String!
|
||||
|
||||
"""
|
||||
Place in the backlog and in its board column (ascending, compare as text)
|
||||
"""
|
||||
rank: String!
|
||||
release: ReleaseRef
|
||||
reporter: UserRef!
|
||||
sprint: SprintRef
|
||||
|
||||
"""When work should start (roadmap)"""
|
||||
startDate: String
|
||||
|
||||
"""A status id of the project's settings"""
|
||||
status: String!
|
||||
@@ -199,10 +439,16 @@ type Issue {
|
||||
storyPoints: Int
|
||||
title: String!
|
||||
|
||||
"""In the trash since then; null when it is not"""
|
||||
trashedAt: String
|
||||
|
||||
"""A ticket type id of the project's settings"""
|
||||
type: String!
|
||||
updatedAt: String!
|
||||
version: Int!
|
||||
|
||||
"""People following the ticket"""
|
||||
watchers: [UserRef!]!
|
||||
}
|
||||
|
||||
type IssueChange {
|
||||
@@ -216,13 +462,27 @@ input IssueFilterInput {
|
||||
areas: [String!]
|
||||
assigneeId: ID
|
||||
categories: [StatusCategory!]
|
||||
|
||||
"""Due on or after this day (YYYY-MM-DD)"""
|
||||
dueFrom: String
|
||||
|
||||
"""Due on or before this day (YYYY-MM-DD)"""
|
||||
dueTo: String
|
||||
labels: [String!]
|
||||
|
||||
"""Only tickets in no sprint"""
|
||||
noSprint: Boolean
|
||||
|
||||
"""Only the tickets hanging from this one"""
|
||||
parentId: ID
|
||||
priorities: [IssuePriority!]
|
||||
projectId: ID!
|
||||
releaseId: ID
|
||||
|
||||
"""Matches key, title or description"""
|
||||
search: String
|
||||
sort: IssueSort = RECENT
|
||||
sprintId: ID
|
||||
statuses: [String!]
|
||||
types: [String!]
|
||||
unassigned: Boolean
|
||||
@@ -249,6 +509,13 @@ type IssuePage {
|
||||
total: Int!
|
||||
}
|
||||
|
||||
"""The ticket another one hangs from"""
|
||||
type IssueParent {
|
||||
id: ID!
|
||||
key: String!
|
||||
title: String!
|
||||
}
|
||||
|
||||
enum IssuePriority {
|
||||
CRITICAL
|
||||
HIGH
|
||||
@@ -257,12 +524,33 @@ enum IssuePriority {
|
||||
MEDIUM
|
||||
}
|
||||
|
||||
"""RECENT: last updated first. RANK: backlog order."""
|
||||
enum IssueSort {
|
||||
RANK
|
||||
RECENT
|
||||
}
|
||||
|
||||
"""What a new ticket of the type starts with"""
|
||||
type IssueTemplate {
|
||||
"""Steps added to every new ticket"""
|
||||
checklist: [String!]!
|
||||
|
||||
"""Used when the ticket comes without a description"""
|
||||
description: String
|
||||
}
|
||||
|
||||
input IssueTemplateInput {
|
||||
checklist: [String!]! = []
|
||||
description: String
|
||||
}
|
||||
|
||||
type IssueTypeOption {
|
||||
archived: Boolean!
|
||||
color: String!
|
||||
icon: String!
|
||||
id: String!
|
||||
name: String!
|
||||
template: IssueTemplate
|
||||
}
|
||||
|
||||
input IssueTypeOptionInput {
|
||||
@@ -271,6 +559,7 @@ input IssueTypeOptionInput {
|
||||
icon: String!
|
||||
id: String!
|
||||
name: String!
|
||||
template: IssueTemplateInput
|
||||
}
|
||||
|
||||
enum LinkDirection {
|
||||
@@ -297,6 +586,8 @@ type Me {
|
||||
}
|
||||
|
||||
type Mutation {
|
||||
"""Add a step to a ticket's checklist"""
|
||||
addChecklistItem(input: AddChecklistItemInput!): CommandResult!
|
||||
addComment(body: String!, issueId: ID!): CommandResult!
|
||||
|
||||
"""Add a Pireon user to the project"""
|
||||
@@ -306,13 +597,33 @@ type Mutation {
|
||||
"""Assign (or unassign with null) a project member"""
|
||||
assignIssue(assigneeId: ID, issueId: ID!): CommandResult!
|
||||
|
||||
"""
|
||||
The same change on up to 100 tickets; each goes through the normal rules and may refuse on its own
|
||||
"""
|
||||
bulkUpdateIssues(input: BulkUpdateIssuesInput!): BulkResult!
|
||||
|
||||
"""Move a ticket through the workflow (incl. QA)"""
|
||||
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
|
||||
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
|
||||
|
||||
"""
|
||||
Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null.
|
||||
"""
|
||||
closeSprint(carryOverToSprintId: ID, sprintId: ID!): CommandResult!
|
||||
|
||||
"""
|
||||
Add a rule. It acts as TaskStream, within the workflow, and what it does triggers no other rule.
|
||||
"""
|
||||
createAutomation(input: CreateAutomationInput!): Automation!
|
||||
createIssue(input: CreateIssueInput!): CommandResult!
|
||||
createProject(input: CreateProjectInput!): CommandResult!
|
||||
createRelease(input: CreateReleaseInput!): CommandResult!
|
||||
|
||||
"""Plan a sprint (owners and admins)"""
|
||||
createSprint(input: CreateSprintInput!): CommandResult!
|
||||
createWebhook(input: CreateWebhookInput!): WebhookCreated!
|
||||
deleteAutomation(automationId: ID!): Boolean!
|
||||
|
||||
"""Author or project admin"""
|
||||
deleteComment(commentId: ID!): CommandResult!
|
||||
|
||||
@@ -320,21 +631,79 @@ type Mutation {
|
||||
Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key.
|
||||
"""
|
||||
deleteProject(confirmKey: String!, projectId: ID!): CommandResult!
|
||||
|
||||
"""Delete a view: whoever saved it, or an admin if it is shared"""
|
||||
deleteSavedFilter(id: ID!): Boolean!
|
||||
|
||||
"""Delete a sprint that never started; its tickets go back to the backlog"""
|
||||
deleteSprint(sprintId: ID!): CommandResult!
|
||||
deleteWebhook(webhookId: ID!): Boolean!
|
||||
disableGitIntegration(projectId: ID!): Boolean!
|
||||
editComment(body: String!, commentId: ID!): CommandResult!
|
||||
|
||||
"""Turn the Git webhook on, with a new secret (also to rotate it)"""
|
||||
enableGitIntegration(projectId: ID!): GitIntegration!
|
||||
|
||||
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
||||
linkIssues(input: LinkIssuesInput!): CommandResult!
|
||||
|
||||
"""Mark these as read, or all of them when `ids` is omitted"""
|
||||
markNotificationsRead(ids: [ID!]): Boolean!
|
||||
|
||||
"""
|
||||
Ship the release. Fails while it has unfinished tickets unless force = true.
|
||||
"""
|
||||
publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult!
|
||||
|
||||
"""Move a ticket in the backlog, between two others of the same project"""
|
||||
rankIssue(input: RankIssueInput!): CommandResult!
|
||||
|
||||
"""
|
||||
Remove a file from a ticket (whoever uploaded it, or an owner/admin). Upload with the REST API.
|
||||
"""
|
||||
removeAttachment(attachmentId: ID!, issueId: ID!): CommandResult!
|
||||
removeChecklistItem(issueId: ID!, itemId: ID!): CommandResult!
|
||||
removeProjectMember(projectId: ID!, userId: ID!): CommandResult!
|
||||
|
||||
"""Back from the trash"""
|
||||
restoreIssue(issueId: ID!): CommandResult!
|
||||
|
||||
"""Save the current board filters under a name (members of the project)"""
|
||||
saveFilter(input: SaveFilterInput!): ID!
|
||||
|
||||
"""
|
||||
Place a ticket under an epic or another ticket of the same project (null: back to the top). Three levels at most: epic → ticket → subtask.
|
||||
"""
|
||||
setIssueParent(issueId: ID!, parentId: ID): CommandResult!
|
||||
|
||||
"""Put a ticket in a release (null removes it)"""
|
||||
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
|
||||
|
||||
"""Put a ticket in a sprint (null: back to the backlog)"""
|
||||
setIssueSprint(issueId: ID!, sprintId: ID): CommandResult!
|
||||
|
||||
"""Start a planned sprint (only one under way per project)"""
|
||||
startSprint(sprintId: ID!): CommandResult!
|
||||
|
||||
"""Send a ping now and tell how it went"""
|
||||
testWebhook(webhookId: ID!): WebhookDelivery!
|
||||
|
||||
"""
|
||||
To the trash (whoever opened it, or an owner/admin): hidden everywhere, deleted for good after 30 days
|
||||
"""
|
||||
trashIssue(issueId: ID!): CommandResult!
|
||||
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
||||
|
||||
"""Stop following a ticket"""
|
||||
unwatchIssue(issueId: ID!): CommandResult!
|
||||
|
||||
"""Rename, pause or resume"""
|
||||
updateAutomation(active: Boolean, automationId: ID!, name: String): Automation!
|
||||
|
||||
"""Reword, tick or untick a checklist step"""
|
||||
updateChecklistItem(input: UpdateChecklistItemInput!): CommandResult!
|
||||
updateIssue(input: UpdateIssueInput!): CommandResult!
|
||||
updateNotificationSettings(email: Boolean!): NotificationSettings!
|
||||
updateProject(input: UpdateProjectInput!): CommandResult!
|
||||
|
||||
"""
|
||||
@@ -342,6 +711,60 @@ type Mutation {
|
||||
"""
|
||||
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
|
||||
updateRelease(input: UpdateReleaseInput!): CommandResult!
|
||||
updateSprint(input: UpdateSprintInput!): CommandResult!
|
||||
updateWebhook(input: UpdateWebhookInput!): Webhook!
|
||||
|
||||
"""
|
||||
Follow a ticket: be told when it moves, is reassigned or gets a comment
|
||||
"""
|
||||
watchIssue(issueId: ID!): CommandResult!
|
||||
|
||||
"""
|
||||
Write the release notes (Markdown; null removes them). Also after shipping.
|
||||
"""
|
||||
writeReleaseNotes(notes: String, releaseId: ID!): CommandResult!
|
||||
}
|
||||
|
||||
type MyNotifications {
|
||||
items: [Notification!]!
|
||||
|
||||
"""All my unread ones, not just these"""
|
||||
unread: Int!
|
||||
}
|
||||
|
||||
type Notification {
|
||||
actor: UserRef!
|
||||
createdAt: String!
|
||||
id: ID!
|
||||
issueId: ID
|
||||
issueKey: String
|
||||
kind: NotificationKind!
|
||||
projectId: ID!
|
||||
projectKey: String!
|
||||
|
||||
"""Null: unread"""
|
||||
readAt: String
|
||||
summary: String!
|
||||
}
|
||||
|
||||
"""Why someone is told"""
|
||||
enum NotificationKind {
|
||||
ADDED_TO_PROJECT
|
||||
ASSIGNED
|
||||
COMMENTED
|
||||
MENTIONED
|
||||
QA_FAILED
|
||||
REMINDER
|
||||
STATUS_CHANGED
|
||||
WATCHED
|
||||
}
|
||||
|
||||
type NotificationSettings {
|
||||
"""Copies by e-mail"""
|
||||
email: Boolean!
|
||||
|
||||
"""Whether this installation can send e-mail at all"""
|
||||
emailAvailable: Boolean!
|
||||
}
|
||||
|
||||
type Project {
|
||||
@@ -351,7 +774,20 @@ type Project {
|
||||
analytics(days: Int! = 90): ProjectAnalytics!
|
||||
|
||||
"""Kanban board, one column per status"""
|
||||
board(area: String, assigneeId: ID, perColumn: Int! = 50, releaseId: ID): [BoardColumn!]!
|
||||
board(
|
||||
area: String
|
||||
assigneeId: ID
|
||||
labels: [String!]
|
||||
perColumn: Int! = 50
|
||||
priorities: [IssuePriority!]
|
||||
releaseId: ID
|
||||
|
||||
"""Matches key, title or description"""
|
||||
search: String
|
||||
sprintId: ID
|
||||
types: [String!]
|
||||
unassigned: Boolean
|
||||
): [BoardColumn!]!
|
||||
createdAt: String!
|
||||
description: String
|
||||
id: ID!
|
||||
@@ -462,6 +898,13 @@ type Query {
|
||||
limit: Int! = 50
|
||||
projectId: ID!
|
||||
): [Activity!]!
|
||||
automations(projectId: ID!): [Automation!]!
|
||||
|
||||
"""
|
||||
A project's epics: top-level tickets that group others, with the progress of those
|
||||
"""
|
||||
epics(projectId: ID!): [Epic!]!
|
||||
gitIntegration(projectId: ID!): GitIntegration!
|
||||
issue(
|
||||
"""Issue id or key (e.g. "TS-42")"""
|
||||
idOrKey: String!
|
||||
@@ -474,10 +917,23 @@ type Query {
|
||||
"""
|
||||
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
|
||||
"""
|
||||
myIssues(assignedToMe: Boolean! = true, categories: [StatusCategory!], limit: Int! = 50, offset: Int! = 0, statuses: [String!]): IssuePage!
|
||||
myIssues(
|
||||
assignedToMe: Boolean! = true
|
||||
categories: [StatusCategory!]
|
||||
limit: Int! = 50
|
||||
offset: Int! = 0
|
||||
|
||||
"""Matches key, title or description (global search)"""
|
||||
search: String
|
||||
statuses: [String!]
|
||||
): IssuePage!
|
||||
|
||||
"""My notifications, newest first, and how many are unread"""
|
||||
myNotifications(limit: Int! = 30, unreadOnly: Boolean! = false): MyNotifications!
|
||||
|
||||
"""Projects where I am a member"""
|
||||
myProjects: [Project!]!
|
||||
notificationSettings: NotificationSettings!
|
||||
|
||||
"""A project of mine, or any public one (no sign-in needed)"""
|
||||
project(
|
||||
@@ -489,12 +945,37 @@ type Query {
|
||||
publicProjects: [Project!]!
|
||||
release(id: ID!): Release!
|
||||
|
||||
"""My views of the project's board, then the shared ones"""
|
||||
savedFilters(projectId: ID!): [SavedFilter!]!
|
||||
sprint(id: ID!): Sprint!
|
||||
|
||||
"""
|
||||
A project's sprints: the one under way, the planned ones, then the closed ones
|
||||
"""
|
||||
sprints(projectId: ID!): [Sprint!]!
|
||||
|
||||
"""A project's tickets in the trash, newest first"""
|
||||
trash(projectId: ID!): [Issue!]!
|
||||
|
||||
"""Planned releases of all my projects, soonest target date first"""
|
||||
upcomingReleases(limit: Int! = 10): [Release!]!
|
||||
user(id: String!): User
|
||||
|
||||
"""Search users by name or email (to assign / add members)"""
|
||||
users(limit: Int! = 20, search: String! = ""): [User!]!
|
||||
|
||||
"""Event names a webhook can subscribe to (also family.* and *)"""
|
||||
webhookEvents: [String!]!
|
||||
webhooks(projectId: ID!): [Webhook!]!
|
||||
}
|
||||
|
||||
input RankIssueInput {
|
||||
"""The ticket that ends up right above (null: the top)"""
|
||||
aboveIssueId: ID
|
||||
|
||||
"""The ticket that ends up right below (null: the bottom)"""
|
||||
belowIssueId: ID
|
||||
issueId: ID!
|
||||
}
|
||||
|
||||
type Release {
|
||||
@@ -507,6 +988,14 @@ type Release {
|
||||
"""Tickets included in this release"""
|
||||
issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
|
||||
name: String!
|
||||
|
||||
"""What shipped, told for people (Markdown)"""
|
||||
notes: String
|
||||
|
||||
"""
|
||||
A Markdown draft of the notes from its finished tickets, by type (not saved)
|
||||
"""
|
||||
notesDraft: String!
|
||||
progress: ReleaseProgress!
|
||||
projectId: ID!
|
||||
releasedAt: String
|
||||
@@ -550,6 +1039,118 @@ enum ReleaseStatus {
|
||||
RELEASED
|
||||
}
|
||||
|
||||
input SaveFilterInput {
|
||||
criteria: SavedFilterCriteriaInput!
|
||||
name: String!
|
||||
projectId: ID!
|
||||
|
||||
"""Visible to the whole project (members, not viewers, share)"""
|
||||
shared: Boolean! = false
|
||||
}
|
||||
|
||||
"""
|
||||
A named view of the board: private to whoever saved it, or shared with the project
|
||||
"""
|
||||
type SavedFilter {
|
||||
createdAt: String!
|
||||
criteria: SavedFilterCriteria!
|
||||
id: ID!
|
||||
name: String!
|
||||
owner: UserRef!
|
||||
projectId: ID!
|
||||
shared: Boolean!
|
||||
}
|
||||
|
||||
"""What a saved view narrows the board to; empty: everything"""
|
||||
type SavedFilterCriteria {
|
||||
areas: [String!]
|
||||
assigneeId: ID
|
||||
labels: [String!]
|
||||
priorities: [IssuePriority!]
|
||||
releaseId: ID
|
||||
search: String
|
||||
types: [String!]
|
||||
unassigned: Boolean
|
||||
}
|
||||
|
||||
input SavedFilterCriteriaInput {
|
||||
areas: [String!]
|
||||
assigneeId: ID
|
||||
labels: [String!]
|
||||
priorities: [IssuePriority!]
|
||||
releaseId: ID
|
||||
search: String
|
||||
types: [String!]
|
||||
unassigned: Boolean
|
||||
}
|
||||
|
||||
"""A time-boxed iteration: the tickets the team commits to"""
|
||||
type Sprint {
|
||||
burndown: SprintBurndown!
|
||||
closedAt: String
|
||||
createdAt: String!
|
||||
|
||||
"""Last day (included), YYYY-MM-DD"""
|
||||
endDate: String!
|
||||
goal: String
|
||||
id: ID!
|
||||
|
||||
"""Tickets in this sprint, in backlog order"""
|
||||
issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
|
||||
name: String!
|
||||
progress: SprintProgress!
|
||||
projectId: ID!
|
||||
|
||||
"""First day, YYYY-MM-DD"""
|
||||
startDate: String!
|
||||
startedAt: String
|
||||
status: SprintStatus!
|
||||
version: Int!
|
||||
}
|
||||
|
||||
"""
|
||||
Per day of the sprint: tickets in it, still open, and their points (null: days to come). `ideal` falls from the first day to zero.
|
||||
"""
|
||||
type SprintBurndown {
|
||||
dates: [String!]!
|
||||
ideal: [Float!]!
|
||||
remaining: [Int]!
|
||||
remainingPoints: [Int]!
|
||||
scope: [Int]!
|
||||
sprintId: ID!
|
||||
}
|
||||
|
||||
type SprintChange {
|
||||
action: String!
|
||||
actorId: ID!
|
||||
|
||||
"""Null: deleted"""
|
||||
sprint: Sprint
|
||||
sprintId: ID!
|
||||
version: Int!
|
||||
}
|
||||
|
||||
type SprintProgress {
|
||||
"""Done or cancelled"""
|
||||
finished: Int!
|
||||
finishedStoryPoints: Int!
|
||||
percent: Int!
|
||||
storyPoints: Int!
|
||||
total: Int!
|
||||
}
|
||||
|
||||
type SprintRef {
|
||||
id: ID!
|
||||
name: String!
|
||||
}
|
||||
|
||||
"""PLANNED, ACTIVE (one per project at a time) or CLOSED"""
|
||||
enum SprintStatus {
|
||||
ACTIVE
|
||||
CLOSED
|
||||
PLANNED
|
||||
}
|
||||
|
||||
"""
|
||||
What a status means, whatever each project calls it. DONE and CANCELLED are finished.
|
||||
"""
|
||||
@@ -581,8 +1182,12 @@ type Subscription {
|
||||
|
||||
"""Projects I am added to / removed from"""
|
||||
myProjectsChanged: ProjectChange!
|
||||
|
||||
"""My notifications, as they happen"""
|
||||
notificationAdded: Notification!
|
||||
projectChanged(projectId: ID!): ProjectChange!
|
||||
releaseChanged(projectId: ID!): ReleaseChange!
|
||||
sprintChanged(projectId: ID!): SprintChange!
|
||||
}
|
||||
|
||||
type Throughput {
|
||||
@@ -591,6 +1196,13 @@ type Throughput {
|
||||
week: String!
|
||||
}
|
||||
|
||||
input UpdateChecklistItemInput {
|
||||
done: Boolean
|
||||
issueId: ID!
|
||||
itemId: ID!
|
||||
text: String
|
||||
}
|
||||
|
||||
input UpdateIssueInput {
|
||||
"""An area id"""
|
||||
area: String
|
||||
@@ -602,6 +1214,9 @@ input UpdateIssueInput {
|
||||
issueId: ID!
|
||||
labels: [String!]
|
||||
priority: IssuePriority
|
||||
|
||||
"""When work should start (roadmap), YYYY-MM-DD"""
|
||||
startDate: String
|
||||
storyPoints: Int
|
||||
title: String
|
||||
type: String
|
||||
@@ -621,6 +1236,21 @@ input UpdateReleaseInput {
|
||||
targetDate: String
|
||||
}
|
||||
|
||||
input UpdateSprintInput {
|
||||
endDate: String
|
||||
goal: String
|
||||
name: String
|
||||
sprintId: ID!
|
||||
startDate: String
|
||||
}
|
||||
|
||||
input UpdateWebhookInput {
|
||||
active: Boolean
|
||||
events: [String!]
|
||||
url: String
|
||||
webhookId: ID!
|
||||
}
|
||||
|
||||
"""User managed by the Pireon identity server"""
|
||||
type User {
|
||||
email: String!
|
||||
@@ -634,6 +1264,39 @@ type UserRef {
|
||||
name: String!
|
||||
}
|
||||
|
||||
"""
|
||||
Where the project tells another system what happened (signed POST, HMAC-SHA256)
|
||||
"""
|
||||
type Webhook {
|
||||
active: Boolean!
|
||||
createdAt: String!
|
||||
createdBy: UserRef!
|
||||
|
||||
"""Event names, family.* or *"""
|
||||
events: [String!]!
|
||||
id: ID!
|
||||
lastDelivery: WebhookDelivery
|
||||
projectId: ID!
|
||||
url: String!
|
||||
}
|
||||
|
||||
type WebhookCreated {
|
||||
id: ID!
|
||||
|
||||
"""Signs every delivery. Shown only now: keep it."""
|
||||
secret: String!
|
||||
}
|
||||
|
||||
type WebhookDelivery {
|
||||
at: String!
|
||||
error: String
|
||||
event: String!
|
||||
ok: Boolean!
|
||||
|
||||
"""HTTP status"""
|
||||
status: Int
|
||||
}
|
||||
|
||||
type WorkflowStatus {
|
||||
"""No new tickets or transitions into it"""
|
||||
archived: Boolean!
|
||||
@@ -648,6 +1311,11 @@ type WorkflowStatus {
|
||||
|
||||
"""Entering it needs a reason"""
|
||||
requiresReason: Boolean!
|
||||
|
||||
"""
|
||||
Work-in-progress limit: most tickets the column should hold (a warning, not a lock)
|
||||
"""
|
||||
wipLimit: Int
|
||||
}
|
||||
|
||||
input WorkflowStatusInput {
|
||||
@@ -657,4 +1325,7 @@ input WorkflowStatusInput {
|
||||
id: String!
|
||||
name: String!
|
||||
requiresReason: Boolean! = false
|
||||
|
||||
"""Work-in-progress limit (null: none)"""
|
||||
wipLimit: Int
|
||||
}
|
||||
117
apps/api/scripts/contracts.ts
Normal file
117
apps/api/scripts/contracts.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* Writes apps/api/schema.gql and apps/api/openapi.json without starting the
|
||||
* API or touching a database: `npx nx run api:contracts`.
|
||||
*
|
||||
* The running API writes the same files on start in development; this is for
|
||||
* when no database is at hand — a CI job, a laptop without Docker running.
|
||||
*/
|
||||
import 'reflect-metadata';
|
||||
import { writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { InjectionToken, Module, Provider, Type } from '@nestjs/common';
|
||||
import { MODULE_METADATA } from '@nestjs/common/constants';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import {
|
||||
GRAPHQL_SDL_FILE_HEADER,
|
||||
GraphQLSchemaBuilderModule,
|
||||
GraphQLSchemaFactory,
|
||||
} from '@nestjs/graphql';
|
||||
import { IdentityPresentersModule } from '@task-stream/api/identity/presenters';
|
||||
import { TrackerPresentersModule } from '@task-stream/api/tracker/presenters';
|
||||
import { lexicographicSortSchema, printSchema } from 'graphql';
|
||||
import { AppModule } from '../src/app/app.module';
|
||||
import { buildOpenApiDocument } from '../src/app/openapi';
|
||||
|
||||
/** The GraphQL resolvers a presenters module declares. */
|
||||
function resolversOf(module: Type): Type[] {
|
||||
const providers: unknown[] =
|
||||
Reflect.getMetadata(MODULE_METADATA.PROVIDERS, module) ?? [];
|
||||
return providers.filter(
|
||||
(provider): provider is Type =>
|
||||
typeof provider === 'function' &&
|
||||
// `@Resolver()` without a type (subscriptions) sets no type, only a name.
|
||||
Reflect.getMetadataKeys(provider).some((key) =>
|
||||
String(key).startsWith('graphql:resolver'),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
async function graphqlSchema(): Promise<string> {
|
||||
const app = await NestFactory.create(GraphQLSchemaBuilderModule, {
|
||||
logger: false,
|
||||
});
|
||||
await app.init();
|
||||
const schema = await app
|
||||
.get(GraphQLSchemaFactory)
|
||||
.create([
|
||||
...resolversOf(IdentityPresentersModule),
|
||||
...resolversOf(TrackerPresentersModule),
|
||||
]);
|
||||
await app.close();
|
||||
// Same output as GraphQLModule's autoSchemaFile with sortSchema.
|
||||
return GRAPHQL_SDL_FILE_HEADER + printSchema(lexicographicSortSchema(schema));
|
||||
}
|
||||
|
||||
/** The REST controllers a module declares. */
|
||||
function controllersOf(module: Type): Type[] {
|
||||
return Reflect.getMetadata(MODULE_METADATA.CONTROLLERS, module) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty stand-ins for whatever the controllers' constructors ask for: Swagger
|
||||
* reads the controllers' metadata and never calls them.
|
||||
*/
|
||||
function standIns(controllers: Type[]): Provider[] {
|
||||
const tokens = new Set<unknown>();
|
||||
for (const controller of controllers) {
|
||||
const types: unknown[] =
|
||||
Reflect.getMetadata('design:paramtypes', controller) ?? [];
|
||||
const injected: { index: number; param: unknown }[] =
|
||||
Reflect.getMetadata('self:paramtypes', controller) ?? [];
|
||||
types.forEach((type, index) =>
|
||||
tokens.add(injected.find((i) => i.index === index)?.param ?? type),
|
||||
);
|
||||
}
|
||||
return [...tokens].map((token) => ({
|
||||
provide: token as InjectionToken,
|
||||
useValue: {},
|
||||
}));
|
||||
}
|
||||
|
||||
async function openApi(): Promise<string> {
|
||||
// Only the controllers, with stand-ins for their dependencies: nothing
|
||||
// connects to Postgres, Mongo, Redis or Pireon.
|
||||
const controllers = [
|
||||
...controllersOf(AppModule),
|
||||
...controllersOf(IdentityPresentersModule),
|
||||
...controllersOf(TrackerPresentersModule),
|
||||
];
|
||||
|
||||
@Module({ controllers, providers: standIns(controllers) })
|
||||
class ContractsModule {}
|
||||
|
||||
const app = await NestFactory.create(ContractsModule, { logger: false });
|
||||
app.setGlobalPrefix('api');
|
||||
const document = buildOpenApiDocument(app, {
|
||||
pireonBaseUrl: process.env['PIREON_BASE_URL'] || 'https://oauth.pireon.dev',
|
||||
scopes: (process.env['PIREON_SCOPES'] || 'users:read')
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean),
|
||||
passwordGrant: process.env['PIREON_PASSWORD_GRANT'] !== 'false',
|
||||
});
|
||||
return JSON.stringify(document, null, 2) + '\n';
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const root = process.cwd();
|
||||
writeFileSync(join(root, 'apps/api/schema.gql'), await graphqlSchema());
|
||||
writeFileSync(join(root, 'apps/api/openapi.json'), await openApi());
|
||||
}
|
||||
|
||||
main().then(
|
||||
() => process.exit(0),
|
||||
(error) => {
|
||||
console.error(error);
|
||||
process.exit(1);
|
||||
},
|
||||
);
|
||||
21
apps/api/scripts/register.cjs
Normal file
21
apps/api/scripts/register.cjs
Normal file
@@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Lets Node run the TypeScript sources of the monorepo directly: compiles
|
||||
* them on the fly (decorator metadata included, which Nest needs) and
|
||||
* resolves the `@task-stream/*` aliases of tsconfig.base.json.
|
||||
*/
|
||||
const { join } = require('node:path');
|
||||
const root = join(__dirname, '../../..');
|
||||
|
||||
require('ts-node').register({
|
||||
project: join(root, 'apps/api/tsconfig.app.json'),
|
||||
transpileOnly: true,
|
||||
compilerOptions: {
|
||||
module: 'commonjs',
|
||||
moduleResolution: 'node10',
|
||||
ignoreDeprecations: '6.0',
|
||||
},
|
||||
});
|
||||
require('tsconfig-paths').register({
|
||||
baseUrl: root,
|
||||
paths: require(join(root, 'tsconfig.base.json')).compilerOptions.paths,
|
||||
});
|
||||
@@ -42,4 +42,14 @@ export const configSchema = Joi.object({
|
||||
SWAGGER_ENABLED: Joi.boolean().default(true),
|
||||
// Where to write the OpenAPI document on startup ('' = don't), like the GraphQL SDL.
|
||||
OPENAPI_FILE: Joi.string().allow('').default('apps/api/openapi.json'),
|
||||
|
||||
// Attached files: where they are kept (a volume in Docker) and how big one can be.
|
||||
ATTACHMENTS_DIR: Joi.string().default('data/attachments'),
|
||||
ATTACHMENTS_MAX_MB: Joi.number().min(1).max(500).default(25),
|
||||
|
||||
// E-mail copies of notifications (optional): without SMTP_URL nothing is sent.
|
||||
SMTP_URL: Joi.string().allow('').optional(),
|
||||
MAIL_FROM: Joi.string().allow('').optional(),
|
||||
// Public address of the web app, for links in e-mails.
|
||||
WEB_URL: Joi.string().allow('').optional(),
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { INestApplication } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
||||
import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger';
|
||||
import {
|
||||
PIREON_BEARER,
|
||||
PIREON_OAUTH,
|
||||
@@ -35,19 +35,19 @@ show the previous version (or 404 right after a create).
|
||||
For live updates use the GraphQL subscriptions at \`/graphql\` (graphql-ws).
|
||||
`.trim();
|
||||
|
||||
/**
|
||||
* Builds the OpenAPI document from the REST controllers, serves Swagger UI and
|
||||
* (in development) writes it to OPENAPI_FILE so other apps can generate clients.
|
||||
*/
|
||||
export function setupOpenApi(app: INestApplication): void {
|
||||
const config = app.get(ConfigService);
|
||||
if (!config.get<boolean>('SWAGGER_ENABLED')) return;
|
||||
export interface OpenApiSettings {
|
||||
pireonBaseUrl: string;
|
||||
scopes: string[];
|
||||
passwordGrant: boolean;
|
||||
}
|
||||
|
||||
const pireon =
|
||||
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev';
|
||||
const scopes = (config.get<string>('PIREON_SCOPES') ?? '')
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean);
|
||||
/** The OpenAPI document of the REST controllers. Needs no database. */
|
||||
export function buildOpenApiDocument(
|
||||
app: INestApplication,
|
||||
settings: OpenApiSettings,
|
||||
): OpenAPIObject {
|
||||
const pireon = settings.pireonBaseUrl;
|
||||
const scopes = settings.scopes;
|
||||
const scopeDocs = Object.fromEntries(
|
||||
scopes.map((scope) => [
|
||||
scope,
|
||||
@@ -76,7 +76,7 @@ export function setupOpenApi(app: INestApplication): void {
|
||||
refreshUrl: tokenUrl,
|
||||
scopes: scopeDocs,
|
||||
},
|
||||
...(config.get<boolean>('PIREON_PASSWORD_GRANT')
|
||||
...(settings.passwordGrant
|
||||
? {
|
||||
password: {
|
||||
tokenUrl,
|
||||
@@ -125,6 +125,27 @@ export function setupOpenApi(app: INestApplication): void {
|
||||
}
|
||||
}
|
||||
|
||||
return document;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the OpenAPI document from the REST controllers, serves Swagger UI and
|
||||
* (in development) writes it to OPENAPI_FILE so other apps can generate clients.
|
||||
*/
|
||||
export function setupOpenApi(app: INestApplication): void {
|
||||
const config = app.get(ConfigService);
|
||||
if (!config.get<boolean>('SWAGGER_ENABLED')) return;
|
||||
|
||||
const scopes = (config.get<string>('PIREON_SCOPES') ?? '')
|
||||
.split(/[\s,]+/)
|
||||
.filter(Boolean);
|
||||
const document = buildOpenApiDocument(app, {
|
||||
pireonBaseUrl:
|
||||
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev',
|
||||
scopes,
|
||||
passwordGrant: !!config.get<boolean>('PIREON_PASSWORD_GRANT'),
|
||||
});
|
||||
|
||||
const file = config.get<string>('OPENAPI_FILE');
|
||||
if (file) {
|
||||
writeFileSync(
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@ server {
|
||||
root /usr/share/nginx/html;
|
||||
|
||||
location /api/ {
|
||||
# Attachments: the API takes files up to ATTACHMENTS_MAX_MB (25 MB by default).
|
||||
client_max_body_size 30m;
|
||||
proxy_pass http://task-stream-api:3000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
|
||||
@@ -28,6 +28,12 @@ describe('appRoutes', () => {
|
||||
'',
|
||||
'board',
|
||||
'issues/:issueKey',
|
||||
'backlog',
|
||||
'sprints',
|
||||
'calendar',
|
||||
'roadmap',
|
||||
'epics',
|
||||
'trash',
|
||||
'releases',
|
||||
'dashboard',
|
||||
'analytics',
|
||||
|
||||
@@ -11,6 +11,10 @@ import { filter, map } from 'rxjs';
|
||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
|
||||
import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||
import {
|
||||
CommandPalette,
|
||||
NotificationsBell,
|
||||
} from '@task-stream/web/tracker/presenters';
|
||||
|
||||
@Component({
|
||||
selector: 'ts-root',
|
||||
@@ -21,6 +25,8 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||
RouterLinkActive,
|
||||
AppShellComponent,
|
||||
UserMenuComponent,
|
||||
NotificationsBell,
|
||||
CommandPalette,
|
||||
],
|
||||
template: `
|
||||
<ts-app-shell>
|
||||
@@ -44,7 +50,12 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||
Explorar
|
||||
</a>
|
||||
@if (auth.isAuthenticated()) {
|
||||
<ts-user-menu shellUser />
|
||||
<!-- One projectable node per @if branch, or neither reaches the slot. -->
|
||||
<ng-container shellUser>
|
||||
<ts-command-palette />
|
||||
<ts-notifications-bell />
|
||||
<ts-user-menu />
|
||||
</ng-container>
|
||||
} @else if (auth.settled() && !onLogin()) {
|
||||
<a shellUser class="ts-nav-link ts-nav-link--cta" routerLink="/login"
|
||||
>Iniciar sesión</a
|
||||
|
||||
@@ -56,6 +56,20 @@ services:
|
||||
# document is still served at /api/docs-json.
|
||||
GRAPHQL_SCHEMA_FILE: ''
|
||||
OPENAPI_FILE: ''
|
||||
ATTACHMENTS_DIR: /data/attachments
|
||||
ATTACHMENTS_MAX_MB: ${ATTACHMENTS_MAX_MB:-25}
|
||||
# E-mail copies of notifications (optional).
|
||||
SMTP_URL: ${SMTP_URL:-}
|
||||
MAIL_FROM: ${MAIL_FROM:-}
|
||||
WEB_URL: ${WEB_URL:-}
|
||||
# Outgoing webhooks to private addresses (off: they could reach internal services).
|
||||
WEBHOOKS_ALLOW_PRIVATE: ${WEBHOOKS_ALLOW_PRIVATE:-false}
|
||||
# Due-date reminders, checked every hour.
|
||||
REMINDERS_ENABLED: ${REMINDERS_ENABLED:-true}
|
||||
REMINDERS_TIMEZONE: ${REMINDERS_TIMEZONE:-Europe/Madrid}
|
||||
volumes:
|
||||
# Files attached to tickets: lose this volume and the tickets point at nothing.
|
||||
- attachments-data:/data/attachments
|
||||
depends_on:
|
||||
postgres: { condition: service_healthy }
|
||||
mongo: { condition: service_healthy }
|
||||
@@ -125,6 +139,7 @@ services:
|
||||
retries: 10
|
||||
|
||||
volumes:
|
||||
attachments-data:
|
||||
postgres-data:
|
||||
mongo-data:
|
||||
redis-data:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
## Qué resuelve
|
||||
|
||||
| Necesidad | Cómo se resuelve |
|
||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
||||
| 🌍 Públicos | Un proyecto público se puede leer sin iniciar sesión (web, GraphQL y REST): tablero, tickets, comentarios, releases, actividad y estadísticas; «Explorar» los lista |
|
||||
| 🗑️ Borrar | El propietario borra un proyecto confirmando su clave; tickets, comentarios, releases y enlaces desde otros proyectos se borran en cascada (sagas) y la clave queda libre |
|
||||
@@ -12,9 +12,31 @@
|
||||
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
||||
| 🚀 Releases | Versiones a las que se asignan tickets; no se publican con tickets sin terminar (salvo forzando) |
|
||||
| 📝 Notas de versión | Borrador en Markdown con los tickets terminados por tipo, editable y copiable, también tras publicar |
|
||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket; el autor edita, el admin modera |
|
||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
|
||||
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
||||
| 🚦 WIP y carriles | Límite de trabajo en curso por estado (la columna se marca al pasarse) y tablero agrupable por persona, épica, prioridad o tipo |
|
||||
| 🎲 Estimación | Puntos por ticket (escala 0-1-2-3-5-8-13-21) en la ficha, las tarjetas, las columnas y el backlog; velocidad por sprint y aviso si un sprint planificado supera la media |
|
||||
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||
| 📅 Calendario | Los sprints mes a mes como franjas, con las releases y los vencimientos de cada día; se planifica un sprint pulsando su primer día y todo se descarga en .ics |
|
||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||
| 🗑️ Papelera | Borrar un ticket lo manda a la papelera (quien lo abrió o un admin); se recupera durante 30 días y luego se borra del todo |
|
||||
| ☑️ En bloque | Selecciona varios tickets del backlog y cámbiales a la vez estado, persona, sprint, prioridad o etiqueta |
|
||||
| ⌨️ Atajos y búsqueda | Ctrl/⌘ K o / busca tickets y proyectos de todos tus proyectos; `g b`, `g k`, `g s`, `g r` navegan, `c` crea, `e` edita, `?` los lista |
|
||||
| 🗺️ Hoja de ruta | Las épicas en el tiempo (fecha de inicio y límite), con los sprints y las releases como referencias |
|
||||
| 🌐 Web del colectivo | La página de cada proyecto en pireon.dev enseña su hoja de ruta, versiones, sprint y tickets para recién llegados (`good-first-issue`) |
|
||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
||||
| 🔔 Avisos | Campana con lo que te concierne (asignaciones, cambios de estado, QA fallido, comentarios), en vivo y opcionalmente por correo |
|
||||
| ⏰ Recordatorios | Aviso (y correo, si se quiere) el día antes, el mismo día y al vencer la fecha límite, a quien lo tiene y a quien lo sigue; las tarjetas marcan lo que vence |
|
||||
| 📎 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 |
|
||||
| 🔗 Relaciones | `BLOCKS`, `DEPENDS_ON`, `TESTS`, `DUPLICATES`, `RELATES_TO`, también entre proyectos (FE ↔ BE ↔ QA) |
|
||||
| 🌿 Git | Webhook para GitHub, Gitea o GitLab: commits, ramas y pull requests que nombran un ticket (WEB-12) aparecen en su ficha |
|
||||
| 🧾 Plantillas | Cada tipo de ticket puede traer una descripción inicial y una checklist que se aplican al crearlo |
|
||||
| 🤖 Automatizaciones | Reglas «cuando… si… entonces…»: al crear, mover, asignar, comentar o fusionar una PR, mover, asignar, etiquetar, priorizar, comentar o meter en el sprint |
|
||||
| 📡 Webhooks | Un POST firmado (HMAC-SHA256) a otro sistema cuando pasa algo: tickets, comentarios, releases, sprints; con prueba y último envío |
|
||||
| 📦 Importar/exportar | Todos los tickets a CSV; importar un CSV propio o una exportación de Jira, con vista previa de lo que se va a crear |
|
||||
| 🏠 Inicio | «Mi trabajo»: mis tickets en todos los proyectos, por categoría (QA fallidos primero), lo que espera QA, próximas releases e «Ir a…» por clave |
|
||||
| 📊 Seguimiento | Panel con contadores, flujo por estado, el equipo con sus tickets abiertos (enlaza a su tablero), feed de actividad e historial por ticket |
|
||||
| 📈 Estadísticas | Creados vs. resueltos, flujo acumulado, burnup de releases, tiempos de ciclo y entrega, tasa de fallo en QA y resueltos por persona |
|
||||
|
||||
@@ -138,10 +138,12 @@ evento, tras las proyecciones, pasa ese evento solo (`of(event)`) por cada saga
|
||||
uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada se pierde si la API se cae a mitad.
|
||||
|
||||
| Saga | Evento | Comandos |
|
||||
| --------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto |
|
||||
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario y `DropLinksToIssueCommand` en los tickets que lo enlazaban |
|
||||
| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket, `DeleteReleaseCommand` por cada release y `DeleteSprintCommand` por cada sprint del proyecto |
|
||||
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario, `DropLinksToIssueCommand` en los tickets que lo enlazaban y `DetachFromParentCommand` en los que colgaban de él |
|
||||
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
|
||||
| `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog |
|
||||
| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket |
|
||||
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
|
||||
|
||||
Reglas que cumplen:
|
||||
|
||||
157
docs/03-api.md
157
docs/03-api.md
@@ -142,8 +142,111 @@ stateDiagram-v2
|
||||
- **Relaciones**: nunca consigo mismo ni duplicadas; pueden cruzar proyectos si el actor puede ver el destino. Se
|
||||
muestran en los dos tickets (`OUTWARD` en el origen, `INWARD` en el destino).
|
||||
- **Releases**: solo las `PLANNED` aceptan o pierden tickets; publicar con tickets sin terminar exige `force: true`.
|
||||
Las notas de versión (`notes`, Markdown) se escriben también después de publicar (no una vez archivada);
|
||||
`notesDraft` / `GET /api/releases/{id}/notes/draft` propone un borrador con los tickets hechos agrupados por tipo
|
||||
(los de QA quedan fuera).
|
||||
- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto.
|
||||
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`.
|
||||
- **Adjuntos**: los suben los miembros (no los lectores), hasta `ATTACHMENTS_MAX_MB` (25 MB) por archivo y 50 por
|
||||
ticket; pueden ir en el ticket o bajo uno de sus comentarios (`commentId`). Quita uno quien lo subió o un admin.
|
||||
Los bytes se guardan primero y el evento después, así que nunca queda un adjunto apuntando a un archivo que no
|
||||
existe; si el evento no se guarda, el archivo se borra. Al borrar un ticket se borran sus archivos.
|
||||
- **Épicas y subtareas**: un ticket puede colgar de otro del mismo proyecto (`parent`), hasta tres niveles
|
||||
(épica → ticket → subtarea) y sin bucles; se comprueba sobre el event store, no sobre el read model. Una «épica» es
|
||||
cualquier ticket de primer nivel del que cuelgan otros (`epics(projectId)` da su progreso: terminados = hechos o
|
||||
cancelados, y puntos). Si se borra el padre, sus hijos vuelven al primer nivel.
|
||||
- **Orden del backlog**: cada ticket tiene un `rank` (texto que se compara carácter a carácter). Mover uno
|
||||
(`rankIssue`, `PUT /api/issues/{issue}/rank`) solo reescribe ese ticket: recibe un rango entre el de quien queda
|
||||
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
|
||||
recarga. Un ticket que nunca se movió ocupa su orden de creación (`IssueRankedEvent` no existe para él). Las
|
||||
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
|
||||
- **Integración con Git**: un owner o admin la activa en la configuración del proyecto y obtiene un secreto. El host
|
||||
llama a `POST /api/webhooks/git/{proyecto}` con los eventos _push_ y _pull/merge request_; GitHub y Gitea firman el
|
||||
cuerpo con el secreto (HMAC-SHA256) y GitLab lo envía como `X-Gitlab-Token`. Cada commit, rama o PR que nombre una
|
||||
clave del proyecto (`WEB-12`, también en minúsculas) queda en `devLinks` del ticket (`IssueDevLinkRecordedEvent`,
|
||||
actor `system`); el mismo enlace solo vuelve a contar si cambia (una PR fusionada). Proyecto desconocido,
|
||||
integración apagada o firma mala responden igual: 403. El secreto vive en MongoDB (`git_integrations`), no en el
|
||||
event store.
|
||||
- **Escaparate público**: `GET /api/explore/projects/{project}/showcase` resume un proyecto público para webs de
|
||||
fuera (la del colectivo lo enseña en la página de cada proyecto): épicas con fechas y progreso, las últimas
|
||||
versiones (con sus notas si ya salieron), el sprint en curso y los tickets abiertos con la etiqueta
|
||||
`good-first-issue`. Sin token, con `Cache-Control: public, max-age=300`; un proyecto privado responde 404.
|
||||
- **Papelera**: `trashIssue` / `DELETE /api/issues/{issue}` manda un ticket a la papelera (quien lo abrió o un owner/
|
||||
admin; `IssueTrashedEvent`): desaparece del tablero, las búsquedas, las estadísticas y el progreso, y no se puede
|
||||
cambiar hasta recuperarlo (`restoreIssue` / `POST …/restore`, `IssueRestoredEvent`). `trash(projectId)` /
|
||||
`GET /api/projects/{project}/trash` los lista. A los `TRASH_DAYS` días (30) se borra del todo, con la cascada de
|
||||
siempre: comentarios, enlaces y subtareas de vuelta al primer nivel.
|
||||
- **Edición en bloque**: `bulkUpdateIssues` / `POST /api/issues/bulk` aplica el mismo cambio (estado, persona o
|
||||
`unassign`, sprint o `noSprint`, release o `noRelease`, prioridad, etiquetas que añadir) a hasta 100 tickets. Cada
|
||||
uno pasa por los comandos normales (permisos, flujo, sprints y releases abiertos) y puede negarse sin parar al resto;
|
||||
la respuesta dice cuáles.
|
||||
- **Plantillas por tipo**: en la configuración, cada tipo de ticket puede llevar `template` con una descripción
|
||||
(hasta 5000 caracteres) y una checklist (hasta 20 pasos). Al crear un ticket de ese tipo, la descripción se usa si
|
||||
llega vacía y los pasos se añaden siempre (las importaciones no aplican plantillas).
|
||||
- **Automatizaciones**: owners y admins crean reglas (hasta 50 por proyecto, por GraphQL: `automations`,
|
||||
`createAutomation`, `updateAutomation`, `deleteAutomation`). Disparador: ticket creado, cambio de estado (opcionalmente
|
||||
a uno concreto), asignación, comentario o PR fusionada (integración Git). Condiciones: tipo, prioridad, área o
|
||||
etiqueta. Acción: mover a un estado, asignar (a alguien o a quien lo abrió), añadir una etiqueta, poner una
|
||||
prioridad, comentar o meter en el sprint en curso. Actúan como `system` («TaskStream») y respetando el flujo: si la
|
||||
transición no está permitida, la regla no hace nada y lo deja en `lastError`. Lo que hace una regla no dispara
|
||||
otras (no hay cadenas ni bucles) y cada regla corre una sola vez por evento aunque se reentregue
|
||||
(`automation_runs`). Viven en MongoDB (`automations`), fuera del event store.
|
||||
- **Webhooks salientes**: owners y admins dan de alta URLs (hasta 20 por proyecto) suscritas a eventos
|
||||
(`issue.created`, `issue.status_changed`, `comment.added`, `release.shipped`, `sprint.closed`… — la lista en
|
||||
`webhookEvents` / `GET /api/webhook-events`), a familias (`issue.*`) o a todo (`*`). Cada envío es un `POST` JSON con
|
||||
`event`, `deliveryId`, `occurredAt`, `actorId`, `project`, `issue` (si va de un ticket) y `data` (los campos del evento),
|
||||
y las cabeceras `X-TaskStream-Event`, `X-TaskStream-Delivery` y `X-TaskStream-Signature: sha256=<HMAC del cuerpo con el
|
||||
secreto>`. El secreto solo se muestra al crearlo. Se reintenta a los 2 y 10 s (no ante un 4xx), con 5 s de espera
|
||||
máxima; se guarda el último envío. Salen en segundo plano y no son duraderos: si la API se reinicia a mitad, ese
|
||||
envío se pierde. No llaman a direcciones privadas salvo con `WEBHOOKS_ALLOW_PRIVATE=true`.
|
||||
- **Importar y exportar**: `GET /api/projects/{project}/export.csv` da todos los tickets (UTF-8 con BOM; las celdas
|
||||
que una hoja de cálculo ejecutaría como fórmula empiezan por `'`). `POST /api/projects/{project}/import` (owners y
|
||||
admins, multipart `file`, hasta 5 MB y 2000 filas) lee ese mismo CSV o una exportación CSV de Jira: tipos, estados y
|
||||
personas se buscan por id o por nombre (los de Jira por su significado: _Story_ → Feature, _Highest_ → crítica,
|
||||
categoría _In Progress_ → primer estado en curso…), lo que no encaja se queda con un valor por defecto y un aviso, y
|
||||
los padres se reconstruyen al final. Con `dryRun=true` solo se cuenta lo que se crearía. Cada ticket importado es una
|
||||
creación normal (con sus eventos), en el estado que traía si está activo.
|
||||
- **Límite WIP**: cada estado puede tener `wipLimit` (1-999) en la configuración del proyecto. Es un aviso, no un
|
||||
bloqueo: la API acepta pasarse y el tablero marca la columna.
|
||||
- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
|
||||
Un sprint va de `PLANNED` a `ACTIVE` (uno solo en curso por proyecto) y a `CLOSED`; dura como mucho 90 días y un
|
||||
sprint en curso conserva su fecha de inicio. Un sprint cerrado ya no acepta ni pierde tickets. Al cerrarlo se elige
|
||||
adónde van los tickets sin terminar: a otro sprint abierto del proyecto o al backlog; los terminados se quedan como
|
||||
registro. Solo se borran sprints que no han empezado (sus tickets vuelven al backlog).
|
||||
- **Vistas guardadas**: filtros del tablero con nombre. Las guardan los miembros del proyecto (los lectores, solo para
|
||||
sí); compartir una la hace visible a todo el proyecto. Borra una su autor, o un admin si es compartida; como mucho
|
||||
50 por persona y proyecto. Son preferencias, no historia: viven en MongoDB (`saved_filters`), fuera del event store,
|
||||
y se borran con el proyecto.
|
||||
- **Checklist**: hasta 100 pasos de hasta 300 caracteres por ticket; los cambia quien puede editar el ticket.
|
||||
|
||||
### Notificaciones
|
||||
|
||||
Se derivan de los eventos (`NotificationsEventHandler`) y se guardan **por persona** en MongoDB: no son estado de
|
||||
ningún agregado, solo cada uno lee y descarta las suyas.
|
||||
|
||||
| Cuándo | A quién | Tipo |
|
||||
| ---------------------------------------- | ---------------------------------------- | ------------------ |
|
||||
| Se crea un ticket asignado o se asigna | La persona asignada | `ASSIGNED` |
|
||||
| Un ticket cambia de estado | Quien lo tiene asignado y quien lo abrió | `STATUS_CHANGED` |
|
||||
| Un ticket vuelve de QA sin pasar a hecho | Los mismos, con el motivo | `QA_FAILED` |
|
||||
| Alguien comenta | Quien lo tiene asignado y quien lo abrió | `COMMENTED` |
|
||||
| Te añaden a un proyecto | La persona añadida | `ADDED_TO_PROJECT` |
|
||||
|
||||
**Recordatorios** (`REMINDER`): cada hora la API mira los tickets sin terminar con fecha límite y avisa a quien lo
|
||||
tiene asignado y a quien lo sigue el día antes, el mismo día y una vez cuando ya ha vencido (el día cambia en
|
||||
`REMINDERS_TIMEZONE`). Cada aviso tiene id `reminder:<ticket>:<fecha>:<fase>:<persona>`, así que no se repite aunque
|
||||
varias instancias lo comprueben a la vez.
|
||||
|
||||
Nadie recibe aviso de lo que hizo él mismo, y cada persona recibe uno por evento: la primera razón de la tabla gana
|
||||
(una mención antes que «comentó en tu ticket», y eso antes que «un ticket que sigues»).
|
||||
|
||||
**Menciones**: se escriben `@[Nombre](id-de-usuario)` (la web las inserta al escribir `@`); el id dice a quién avisar y
|
||||
el nombre es lo que se ve, así que renombrar a alguien no rompe comentarios antiguos. Solo avisan a miembros del
|
||||
proyecto. **Seguir**: cualquier miembro, también los lectores, puede seguir un ticket (`watchIssue`/`unwatchIssue`,
|
||||
eventos `IssueWatchedEvent`/`IssueUnwatchedEvent`), y quien comenta pasa a seguirlo (`CommentersFollowSaga`). El id es
|
||||
`<agregado>:<versión>:<persona>`, así que reentregar un evento no duplica avisos. Llegan en vivo por la subscription
|
||||
`notificationAdded` y, si la instalación tiene `SMTP_URL`, también por correo a quien lo active (`email`, desactivado
|
||||
por defecto); un correo que falla se registra y no bloquea el stream.
|
||||
|
||||
### Eventos de dominio
|
||||
|
||||
@@ -151,11 +254,12 @@ Todo cambio es un evento guardado en el event store (stream = id del agregado).
|
||||
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
|
||||
|
||||
| Agregado | Eventos |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
|
||||
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueDeletedEvent` |
|
||||
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `IssueDevLinkRecordedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` |
|
||||
| Comment | `CommentAddedEvent`, `CommentEditedEvent`, `CommentDeletedEvent` |
|
||||
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` |
|
||||
| Sprint | `SprintCreatedEvent`, `SprintUpdatedEvent`, `SprintStartedEvent`, `SprintClosedEvent`, `SprintDeletedEvent` |
|
||||
| Release | `ReleaseCreatedEvent`, `ReleaseUpdatedEvent`, `ReleaseShippedEvent`, `ReleaseNotesWrittenEvent`, `ReleaseArchivedEvent`, `ReleaseDeletedEvent` |
|
||||
|
||||
`IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del
|
||||
cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado.
|
||||
@@ -174,6 +278,7 @@ puras (`application/src/lib/analytics`):
|
||||
| Tiempo de entrega | De la creación hasta `DONE`; media y percentil 85 |
|
||||
| Throughput | Tickets resueltos por semana y por la persona que los tenía asignados |
|
||||
| QA | Salidas de un estado `QA`: a `DONE` cuenta como superado; a cualquier otro sitio salvo `CANCELLED`, como fallido |
|
||||
| Burndown de un sprint | Por día del sprint: tickets en él (sin cancelados), cuántos siguen abiertos y sus puntos; línea ideal hasta cero |
|
||||
| Burnup de una release | Por día, desde que se creó hasta que se publica (o hoy, máx. 180 días): tickets en ella (sin cancelados) y hechos |
|
||||
|
||||
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa,
|
||||
@@ -188,16 +293,21 @@ que funcionan también sin token.
|
||||
### Queries
|
||||
|
||||
| Query | Devuelve |
|
||||
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `me` | El usuario autenticado, con sus roles en Pireon |
|
||||
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
||||
| `user(id)` | Un usuario |
|
||||
| `myProjects` | Proyectos de los que soy miembro |
|
||||
| `myIssues(assignedToMe, statuses, categories, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
|
||||
| `myIssues(assignedToMe, statuses, categories, search, limit, offset)` | «Mi trabajo»: tickets de todos mis proyectos, asignados a mí (por defecto) o de cualquiera (p. ej. `categories: [QA]`) |
|
||||
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
|
||||
| `publicProjects` | Proyectos públicos (sin token) |
|
||||
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
||||
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, texto) |
|
||||
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, unassigned, area, types, priorities, labels, search, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
||||
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, fecha límite entre `dueFrom` y `dueTo`, texto); `sort: RANK` para el orden del backlog |
|
||||
| `webhooks(projectId)`, `webhookEvents` | Webhooks salientes del proyecto (owners y admins) y los eventos a los que se pueden suscribir |
|
||||
| `gitIntegration(projectId)` | Si el webhook de Git está activo, su ruta y (para owners y admins) el secreto |
|
||||
| `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` |
|
||||
| `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto |
|
||||
| `epics(projectId)` | Épicas del proyecto (tickets de primer nivel de los que cuelgan otros) con el progreso de sus hijos |
|
||||
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
||||
| `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 |
|
||||
@@ -207,11 +317,16 @@ que funcionan también sin token.
|
||||
Todas devuelven `CommandResult { id, key, version }`.
|
||||
|
||||
| Ámbito | Mutations |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
|
||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
|
||||
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
||||
| Webhooks | `createWebhook` (devuelve el secreto), `updateWebhook`, `deleteWebhook`, `testWebhook` |
|
||||
| Git | `enableGitIntegration` (también para cambiar el secreto), `disableGitIntegration` |
|
||||
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
||||
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
|
||||
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `writeReleaseNotes`, `archiveRelease` |
|
||||
|
||||
### Subscriptions
|
||||
|
||||
@@ -221,6 +336,7 @@ Todas devuelven `CommandResult { id, key, version }`.
|
||||
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) |
|
||||
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
|
||||
| `releaseChanged(projectId)` | Cambia una release |
|
||||
| `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) |
|
||||
| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto |
|
||||
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
|
||||
|
||||
@@ -408,15 +524,28 @@ Lo que cada usuario puede ver o cambiar depende de su rol en cada proyecto, como
|
||||
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||
|
||||
| Recurso | Rutas |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
|
||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
|
||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
|
||||
| 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` |
|
||||
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
|
||||
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
|
||||
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
||||
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||
| Releases | `GET /api/me/releases` (próximas), `GET/POST /api/projects/{project}/releases`, `GET/PATCH /api/releases/{id}`, `PUT …/notes`, `GET …/notes/draft`, `…/progress`, `…/burnup`, `…/issues`, `POST …/publish`, `POST …/archive` |
|
||||
| Explorar | `GET /api/explore/projects` (proyectos públicos, sin token) |
|
||||
| Importar | `GET /api/projects/{project}/export.csv`, `POST /api/projects/{project}/import?dryRun=` (multipart `file`) |
|
||||
| Webhooks | `GET /api/webhook-events`, `GET/POST /api/projects/{project}/webhooks`, `PATCH/DELETE /api/outgoing-webhooks/{id}`, `POST …/test` |
|
||||
| Git | `GET/POST/DELETE /api/projects/{project}/git-integration`, `POST /api/webhooks/git/{project}` (lo llama el host de Git) |
|
||||
| Explorar | `GET /api/explore/projects/{project}/showcase` (sin token, proyectos públicos): hoja de ruta, versiones, sprint en curso y tickets `good-first-issue` |
|
||||
| Sistema | `GET /api/health`, `GET /api/auth/config`, `POST /api/auth/token`, `POST /api/webhooks/pireon` |
|
||||
|
||||
Los archivos van por REST porque GraphQL no está hecho para bytes; la lista de adjuntos de un ticket llega con el
|
||||
ticket (`attachments`, en GraphQL y en REST). La descarga sirve en línea solo imágenes, vídeo, audio, PDF y texto
|
||||
plano, y todo con `X-Content-Type-Options: nosniff` y una CSP `sandbox`: un HTML o un SVG subido se descarga,
|
||||
nunca se ejecuta en el origen de la API.
|
||||
|
||||
Los filtros de tickets aceptan `status` (ids del proyecto) y `category` (`?category=QA`), repetidos o separados por
|
||||
comas. Las escrituras responden `{ id, key?, version }` en cuanto el evento se guarda; las lecturas salen del read model, que
|
||||
se pone al día en milisegundos (un `GET` justo después puede devolver la versión anterior, o 404 tras crear). Para
|
||||
|
||||
@@ -32,6 +32,7 @@ la web y la API comparten origen igual que en Docker.
|
||||
| `npx nx e2e api` | Flujo completo de la API contra Docker, con Pireon simulado |
|
||||
| `npx nx run-many -t build` | Builds de la API y la web |
|
||||
| `npx nx run web:codegen` | Regenera los tipos GraphQL del cliente desde `apps/api/schema.gql` |
|
||||
| `npx nx run api:contracts` | Escribe `schema.gql` y `openapi.json` sin arrancar la API ni bases de datos |
|
||||
| `npx nx serve mcp` | Servidor MCP en `http://localhost:3010/mcp`, contra la API local ([MCP](09-mcp.md)) |
|
||||
| `npx nx run mcp:openapi-types` | Regenera los tipos REST del MCP desde `apps/api/openapi.json` |
|
||||
| `npx nx run api:prune` | Build de la API con `package.json` y lockfile podados (lo usa Docker) |
|
||||
@@ -70,7 +71,8 @@ Ejemplo: **marcar un ticket como bloqueado con un motivo**. El recorrido es siem
|
||||
por `ReadModelNotifier`. Añadir el evento al `ActivityEventHandler` y una frase en `activity-summary.ts`.
|
||||
- Si el read model cambia, añadir el campo a `IssueReadModel` (dominio) y al esquema Mongo (infraestructura).
|
||||
3. **Presenters** (`libs/api/tracker/presenters`): input y mutation `blockIssue` en `IssueResolver`, que llama al service.
|
||||
4. Arrancar la API para regenerar `apps/api/schema.gql`.
|
||||
4. Regenerar `apps/api/schema.gql` y `apps/api/openapi.json`: arrancando la API, o sin bases de datos con
|
||||
`npx nx run api:contracts`.
|
||||
|
||||
### Web
|
||||
|
||||
|
||||
@@ -71,7 +71,7 @@ donde los lee Compose.
|
||||
## 7.2 Variables de entorno de la API
|
||||
|
||||
| Variable | Por defecto | Descripción |
|
||||
| ------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------- |
|
||||
| ------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `PORT` | `3000` | Puerto HTTP |
|
||||
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
|
||||
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
|
||||
@@ -93,13 +93,23 @@ donde los lee Compose.
|
||||
| `GRAPHQL_SCHEMA_FILE` | `apps/api/schema.gql` | Dónde escribir el SDL (vacío = no escribir; así está en Docker) |
|
||||
| `SWAGGER_ENABLED` | `true` | API REST documentada: Swagger UI en `/api/docs`, OpenAPI en `/api/docs-json` |
|
||||
| `OPENAPI_FILE` | `apps/api/openapi.json` | Dónde escribir el documento OpenAPI al arrancar (vacío = no escribir; así en Docker) |
|
||||
| `ATTACHMENTS_DIR` | `data/attachments` | Carpeta de los adjuntos de los tickets; en Docker, el volumen `attachments-data` |
|
||||
| `SMTP_URL` | — | Servidor de correo para las copias de las notificaciones (`smtps://user:pass@host`); sin él no se envía nada |
|
||||
| `MAIL_FROM` | `TaskStream <no-reply@pireon.dev>` | Remitente de esos correos |
|
||||
| `WEB_URL` | — | Dirección pública de la web, para los enlaces de los correos |
|
||||
| `ATTACHMENTS_MAX_MB` | `25` | Tamaño máximo de un adjunto (nginx admite hasta 30 MB por petición) |
|
||||
| `WEBHOOKS_ALLOW_PRIVATE` | `false` | Deja que los webhooks salientes llamen a direcciones de esta máquina o de su red privada (apagado, un webhook podría alcanzar servicios internos) |
|
||||
| `REMINDERS_ENABLED` | `true` | Avisa cada hora de los tickets que vencen mañana, hoy o ya han vencido |
|
||||
| `REMINDERS_TIMEZONE` | `Europe/Madrid` | Zona horaria en la que cambia el día para los recordatorios |
|
||||
| `TRASH_DAYS` | `30` | Días que un ticket pasa en la papelera antes de borrarse del todo |
|
||||
|
||||
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
|
||||
|
||||
## 7.3 Escalado
|
||||
|
||||
- **API**: es _stateless_ (sesiones en Redis, subscriptions por Redis pub/sub), así que admite varias réplicas detrás
|
||||
del balanceador. El WebSocket necesita que el balanceador permita `Upgrade`.
|
||||
del balanceador. El WebSocket necesita que el balanceador permita `Upgrade`. La excepción son los **adjuntos**: se
|
||||
guardan en disco (`ATTACHMENTS_DIR`), así que varias réplicas tienen que compartir ese volumen.
|
||||
- **Relay**: cada instancia publica eventos al stream; `FOR UPDATE SKIP LOCKED` evita duplicados.
|
||||
- **Proyecciones**: el `EventsBridge` procesa los eventos en orden. Lo más sencillo es dejarlo activo en **una sola**
|
||||
instancia (`EVENTS_BRIDGE_ENABLED=true`) y desactivarlo en las demás. Varias instancias en el mismo consumer group se
|
||||
@@ -111,7 +121,8 @@ La web no tiene configuración de entorno: llama a la API en su mismo origen (`/
|
||||
- Registrar el cliente OAuth y las redirect URIs de producción en Pireon.
|
||||
- Servir por HTTPS (la web usa `wss://` automáticamente cuando la página va por HTTPS).
|
||||
- Revisar `GRAPHQL_PLAYGROUND` (introspección) según el entorno.
|
||||
- Copias de seguridad de PostgreSQL: es la fuente de verdad.
|
||||
- Copias de seguridad de PostgreSQL: es la fuente de verdad. Y del volumen de adjuntos (`attachments-data`): los
|
||||
eventos dicen qué archivos hay, pero los bytes solo están ahí.
|
||||
|
||||
## 7.5 Reconstruir el read model
|
||||
|
||||
|
||||
@@ -71,3 +71,16 @@ presentación se prueba sin plantillas. **Coste**: una clase más por store, cas
|
||||
|
||||
El `package.json` raíz incluye Angular y `@pi/ui` (del Nexus privado). La imagen de la API usa
|
||||
`apps/api/package.json`, así que ni lleva el frontend ni necesita el token del Nexus en ejecución.
|
||||
|
||||
### Adjuntos en disco, notificaciones fuera del event store
|
||||
|
||||
Los **adjuntos** se registran como eventos del ticket, pero los bytes van a un puerto `AttachmentStorage` (disco,
|
||||
`ATTACHMENTS_DIR`): un event store no es sitio para megabytes. Se escribe el archivo antes que el evento, y si el
|
||||
evento falla se borra el archivo, así que nunca hay un adjunto apuntando a nada. **Coste**: el volumen hay que
|
||||
copiarlo y compartirlo entre réplicas.
|
||||
|
||||
Las **notificaciones** sí salen de eventos, pero no son eventos: si un aviso está leído, o si alguien quiere copia por
|
||||
correo, solo le importa a esa persona y no necesita historia. Se guardan en MongoDB por destinatario y se marcan como
|
||||
leídas sin pasar por un agregado. Si se reconstruye el read model, se pueden regenerar desde los eventos (quedarían
|
||||
como no leídas). **Alternativa descartada**: un agregado «bandeja» por persona, con eventos `NotificationRead`; mucha
|
||||
ceremonia para un estado que nadie audita.
|
||||
|
||||
@@ -7,4 +7,20 @@ export * from './lib/comments.service';
|
||||
export * from './lib/releases.service';
|
||||
export * from './lib/changes.service';
|
||||
export * from './lib/workflows.service';
|
||||
export * from './lib/notifications.service';
|
||||
export * from './lib/notifications';
|
||||
export * from './lib/saved-filters.service';
|
||||
export * from './lib/sprints.service';
|
||||
export * from './lib/git.service';
|
||||
export * from './lib/webhooks.service';
|
||||
export * from './lib/transfer.service';
|
||||
export * from './lib/automations.service';
|
||||
export * from './lib/automations/automations';
|
||||
export * from './lib/automations/automation-rules';
|
||||
export * from './lib/transfer/import-export';
|
||||
export * from './lib/webhooks/webhooks';
|
||||
export * from './lib/webhooks/webhook-events';
|
||||
export * from './lib/git/git';
|
||||
export * from './lib/git/git-payloads';
|
||||
export * from './lib/saved-filters/saved-filters';
|
||||
export * from './lib/tracker-application.module';
|
||||
|
||||
@@ -4,10 +4,13 @@ import {
|
||||
ReleaseReadModel,
|
||||
ReleaseStatus,
|
||||
StatusCategory as C,
|
||||
SprintReadModel,
|
||||
SprintStatus,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
computeProjectAnalytics,
|
||||
computeReleaseBurnup,
|
||||
computeSprintBurndown,
|
||||
weekOf,
|
||||
} from './analytics';
|
||||
|
||||
@@ -182,3 +185,62 @@ describe('release burnup', () => {
|
||||
expect(at(burnup.done, '2026-10-14')).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sprint burndown', () => {
|
||||
const sprint = {
|
||||
id: 's1',
|
||||
projectId: 'p',
|
||||
startDate: '2026-10-12',
|
||||
endDate: '2026-10-16',
|
||||
status: SprintStatus.ACTIVE,
|
||||
closedAt: null,
|
||||
} as SprintReadModel;
|
||||
const into = (issueId: string, at: string): IssueTimelineEntry => ({
|
||||
id: `${issueId}:${++seq}`,
|
||||
issueId,
|
||||
projectId: 'p',
|
||||
at,
|
||||
kind: 'sprint',
|
||||
sprintId: 's1',
|
||||
});
|
||||
// X (3 points) and Y (5) committed on Monday; Z (2) added on Tuesday.
|
||||
// X done on Tuesday, Y cancelled on Wednesday. Today is Wednesday noon.
|
||||
const issues = [
|
||||
{ ...issue('x', '2026-10-01T09:00:00Z', 'DONE', C.DONE), storyPoints: 3 },
|
||||
{
|
||||
...issue('y', '2026-10-01T09:00:00Z', 'CANCELLED', C.CANCELLED),
|
||||
storyPoints: 5,
|
||||
},
|
||||
{ ...issue('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO), storyPoints: 2 },
|
||||
] as IssueReadModel[];
|
||||
const timeline = [
|
||||
step('x', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
|
||||
step('y', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
|
||||
step('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
|
||||
into('x', '2026-10-12T08:00:00Z'),
|
||||
into('y', '2026-10-12T08:00:00Z'),
|
||||
into('z', '2026-10-13T10:00:00Z'),
|
||||
step('x', '2026-10-13T16:00:00Z', 'DONE', C.DONE),
|
||||
step('y', '2026-10-14T09:00:00Z', 'CANCELLED', C.CANCELLED),
|
||||
];
|
||||
|
||||
it('counts open tickets and points per day, and leaves the future blank', () => {
|
||||
const burndown = computeSprintBurndown({
|
||||
sprint,
|
||||
issues,
|
||||
timeline,
|
||||
now: new Date('2026-10-14T12:00:00Z'),
|
||||
});
|
||||
expect(burndown.dates).toEqual([
|
||||
'2026-10-12',
|
||||
'2026-10-13',
|
||||
'2026-10-14',
|
||||
'2026-10-15',
|
||||
'2026-10-16',
|
||||
]);
|
||||
expect(burndown.scope).toEqual([2, 3, 2, null, null]);
|
||||
expect(burndown.remaining).toEqual([2, 2, 1, null, null]);
|
||||
expect(burndown.remainingPoints).toEqual([8, 7, 2, null, null]);
|
||||
expect(burndown.ideal).toEqual([2, 1.5, 1, 0.5, 0]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,6 +5,8 @@ import {
|
||||
ProjectAnalyticsReadModel,
|
||||
ReleaseBurnupReadModel,
|
||||
ReleaseReadModel,
|
||||
SprintBurndownReadModel,
|
||||
SprintReadModel,
|
||||
StatusCategory,
|
||||
UserRefReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
@@ -313,3 +315,85 @@ export function computeReleaseBurnup(
|
||||
done,
|
||||
};
|
||||
}
|
||||
|
||||
export interface SprintBurndownInput {
|
||||
sprint: SprintReadModel;
|
||||
issues: IssueReadModel[];
|
||||
timeline: IssueTimelineEntry[];
|
||||
now: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per day of the sprint (start to end date, UTC): tickets in it, how many
|
||||
* are still open and their points, and the ideal line. Points are today's
|
||||
* estimates (their history is not kept). Days after today, or after the
|
||||
* sprint closed, are null.
|
||||
*/
|
||||
export function computeSprintBurndown(
|
||||
input: SprintBurndownInput,
|
||||
): SprintBurndownReadModel {
|
||||
const { sprint } = input;
|
||||
const steps = stepsByIssue(input.issues, input.timeline);
|
||||
const points = new Map(input.issues.map((i) => [i.id, i.storyPoints ?? 0]));
|
||||
const membership = new Map<
|
||||
string,
|
||||
{ at: number; sprintId: string | null }[]
|
||||
>();
|
||||
for (const entry of input.timeline) {
|
||||
if (entry.kind !== 'sprint') continue;
|
||||
const list = membership.get(entry.issueId) ?? [];
|
||||
list.push({ at: Date.parse(entry.at), sprintId: entry.sprintId ?? null });
|
||||
membership.set(entry.issueId, list);
|
||||
}
|
||||
for (const list of membership.values()) list.sort((a, b) => a.at - b.at);
|
||||
|
||||
const start = Date.parse(`${sprint.startDate}T00:00:00Z`);
|
||||
const end = Date.parse(`${sprint.endDate}T00:00:00Z`);
|
||||
const last = Math.min(
|
||||
sprint.closedAt ? Date.parse(sprint.closedAt) : input.now.getTime(),
|
||||
end + DAY - 1,
|
||||
);
|
||||
const dayEnds = days(start, end + DAY - 1);
|
||||
const scope: (number | null)[] = [];
|
||||
const remaining: (number | null)[] = [];
|
||||
const remainingPoints: (number | null)[] = [];
|
||||
for (const time of dayEnds) {
|
||||
// The day under way counts up to now; the ones to come are unknown.
|
||||
const at = Math.min(time, last);
|
||||
if (time - DAY >= last) {
|
||||
scope.push(null);
|
||||
remaining.push(null);
|
||||
remainingPoints.push(null);
|
||||
continue;
|
||||
}
|
||||
let inScope = 0;
|
||||
let open = 0;
|
||||
let openPoints = 0;
|
||||
for (const [issueId, list] of membership) {
|
||||
const current = list.filter((m) => m.at <= at).at(-1);
|
||||
if (current?.sprintId !== sprint.id) continue;
|
||||
const step = stepAt(steps.get(issueId) ?? [], at);
|
||||
if (!step || step.category === StatusCategory.CANCELLED) continue;
|
||||
inScope++;
|
||||
if (step.category !== StatusCategory.DONE) {
|
||||
open++;
|
||||
openPoints += points.get(issueId) ?? 0;
|
||||
}
|
||||
}
|
||||
scope.push(inScope);
|
||||
remaining.push(open);
|
||||
remainingPoints.push(openPoints);
|
||||
}
|
||||
const first = remaining[0] ?? 0;
|
||||
const span = Math.max(dayEnds.length - 1, 1);
|
||||
return {
|
||||
sprintId: sprint.id,
|
||||
dates: dayEnds.map(dayOf),
|
||||
scope,
|
||||
remaining,
|
||||
remainingPoints,
|
||||
ideal: dayEnds.map(
|
||||
(_, i) => Math.round((first * (span - i) * 100) / span) / 100,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
49
libs/api/tracker/application/src/lib/automations.service.ts
Normal file
49
libs/api/tracker/application/src/lib/automations.service.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
AutomationInput,
|
||||
CreateAutomationCommand,
|
||||
DeleteAutomationCommand,
|
||||
GetAutomationsQuery,
|
||||
UpdateAutomationCommand,
|
||||
} from './automations/automations';
|
||||
import { Automation } from './ports/automations.repository';
|
||||
|
||||
/** The project's automation rules (owners and admins). */
|
||||
@Injectable()
|
||||
export class AutomationsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
list(viewerId: string, projectId: string): Promise<Automation[]> {
|
||||
return this.queryBus.execute(new GetAutomationsQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
create(
|
||||
actorId: string,
|
||||
projectId: string,
|
||||
rule: AutomationInput,
|
||||
): Promise<Automation> {
|
||||
return this.commandBus.execute(
|
||||
new CreateAutomationCommand(actorId, projectId, rule),
|
||||
);
|
||||
}
|
||||
|
||||
update(
|
||||
actorId: string,
|
||||
automationId: string,
|
||||
changes: { name?: string; active?: boolean },
|
||||
): Promise<Automation> {
|
||||
return this.commandBus.execute(
|
||||
new UpdateAutomationCommand(actorId, automationId, changes),
|
||||
);
|
||||
}
|
||||
|
||||
delete(actorId: string, automationId: string): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
new DeleteAutomationCommand(actorId, automationId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
defaultProjectSettings,
|
||||
DevLinkKind,
|
||||
DevLinkState,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssuePriority,
|
||||
IssueStatusChangedEvent,
|
||||
ProjectWorkflow,
|
||||
SYSTEM_ACTOR_ID,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
AutomationActionKind,
|
||||
AutomationTriggerKind,
|
||||
normalizeRule,
|
||||
ruleApplies,
|
||||
triggerOf,
|
||||
} from './automation-rules';
|
||||
|
||||
const workflow = new ProjectWorkflow(defaultProjectSettings());
|
||||
const bug = {
|
||||
type: 'BUG',
|
||||
priority: IssuePriority.HIGH,
|
||||
area: 'FRONTEND',
|
||||
labels: ['ui'],
|
||||
};
|
||||
|
||||
describe('automation rules', () => {
|
||||
it('reads triggers from events, merged pull requests included', () => {
|
||||
expect(
|
||||
triggerOf(
|
||||
new IssueStatusChangedEvent('u', 'i1', 'p', 'TODO', 'IN_QA', null),
|
||||
),
|
||||
).toEqual({
|
||||
kind: AutomationTriggerKind.STATUS_CHANGED,
|
||||
issueId: 'i1',
|
||||
status: 'IN_QA',
|
||||
});
|
||||
const merged = new IssueDevLinkRecordedEvent(SYSTEM_ACTOR_ID, 'i2', 'p', {
|
||||
kind: DevLinkKind.PULL_REQUEST,
|
||||
url: 'https://x/pr/1',
|
||||
title: 'x',
|
||||
ref: '#1',
|
||||
state: DevLinkState.MERGED,
|
||||
provider: 'github',
|
||||
author: null,
|
||||
at: '',
|
||||
});
|
||||
expect(triggerOf(merged)?.kind).toBe(
|
||||
AutomationTriggerKind.PULL_REQUEST_MERGED,
|
||||
);
|
||||
});
|
||||
|
||||
it('checks the trigger, its status and every condition', () => {
|
||||
const rule = {
|
||||
trigger: { kind: AutomationTriggerKind.STATUS_CHANGED, status: 'IN_QA' },
|
||||
conditions: { types: ['BUG'], label: 'ui' },
|
||||
};
|
||||
const into = (status: string) => ({
|
||||
kind: AutomationTriggerKind.STATUS_CHANGED,
|
||||
status,
|
||||
});
|
||||
expect(ruleApplies(rule, into('IN_QA'), bug)).toBe(true);
|
||||
expect(ruleApplies(rule, into('DONE'), bug)).toBe(false);
|
||||
expect(ruleApplies(rule, into('IN_QA'), { ...bug, labels: [] })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(ruleApplies(rule, into('IN_QA'), { ...bug, type: 'TASK' })).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it('refuses actions without a usable value', () => {
|
||||
const rule = (kind: AutomationActionKind, value: string | null) => ({
|
||||
name: 'R',
|
||||
trigger: { kind: AutomationTriggerKind.ISSUE_CREATED },
|
||||
conditions: {},
|
||||
action: { kind, value },
|
||||
});
|
||||
const member = (id: string) => id === 'u-ana';
|
||||
expect(
|
||||
normalizeRule(
|
||||
rule(AutomationActionKind.ADD_LABEL, ' Triage '),
|
||||
workflow,
|
||||
member,
|
||||
).action,
|
||||
).toEqual({
|
||||
kind: AutomationActionKind.ADD_LABEL,
|
||||
value: 'triage',
|
||||
});
|
||||
expect(() =>
|
||||
normalizeRule(
|
||||
rule(AutomationActionKind.SET_STATUS, 'NOPE'),
|
||||
workflow,
|
||||
member,
|
||||
),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
normalizeRule(
|
||||
rule(AutomationActionKind.ASSIGN, 'u-bob'),
|
||||
workflow,
|
||||
member,
|
||||
),
|
||||
).toThrow();
|
||||
expect(
|
||||
normalizeRule(
|
||||
rule(AutomationActionKind.ASSIGN_REPORTER, 'x'),
|
||||
workflow,
|
||||
member,
|
||||
).action.value,
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,187 @@
|
||||
import {
|
||||
CommentAddedEvent,
|
||||
DevLinkKind,
|
||||
DevLinkState,
|
||||
Issue,
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssuePriority,
|
||||
IssueStatusChangedEvent,
|
||||
ProjectWorkflow,
|
||||
TrackerEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { assertThat } from '@task-stream/api/shared/domain';
|
||||
|
||||
export enum AutomationTriggerKind {
|
||||
ISSUE_CREATED = 'ISSUE_CREATED',
|
||||
/** Optionally only into `status`. */
|
||||
STATUS_CHANGED = 'STATUS_CHANGED',
|
||||
ISSUE_ASSIGNED = 'ISSUE_ASSIGNED',
|
||||
COMMENT_ADDED = 'COMMENT_ADDED',
|
||||
PULL_REQUEST_MERGED = 'PULL_REQUEST_MERGED',
|
||||
}
|
||||
|
||||
export enum AutomationActionKind {
|
||||
/** `value`: a status id. */
|
||||
SET_STATUS = 'SET_STATUS',
|
||||
/** `value`: a member's id. */
|
||||
ASSIGN = 'ASSIGN',
|
||||
ASSIGN_REPORTER = 'ASSIGN_REPORTER',
|
||||
/** `value`: the label. */
|
||||
ADD_LABEL = 'ADD_LABEL',
|
||||
/** `value`: a priority. */
|
||||
SET_PRIORITY = 'SET_PRIORITY',
|
||||
/** `value`: the text. */
|
||||
ADD_COMMENT = 'ADD_COMMENT',
|
||||
ADD_TO_ACTIVE_SPRINT = 'ADD_TO_ACTIVE_SPRINT',
|
||||
}
|
||||
|
||||
export interface AutomationTrigger {
|
||||
kind: AutomationTriggerKind;
|
||||
/** STATUS_CHANGED only: the status entered (null: any). */
|
||||
status?: string | null;
|
||||
}
|
||||
|
||||
/** All of them must hold; an empty list means "any". */
|
||||
export interface AutomationConditions {
|
||||
types?: string[];
|
||||
priorities?: IssuePriority[];
|
||||
areas?: string[];
|
||||
/** The ticket carries this label. */
|
||||
label?: string | null;
|
||||
}
|
||||
|
||||
export interface AutomationAction {
|
||||
kind: AutomationActionKind;
|
||||
value?: string | null;
|
||||
}
|
||||
|
||||
/** What an event means for automations, if anything. */
|
||||
export function triggerOf(
|
||||
event: TrackerEvent,
|
||||
): { kind: AutomationTriggerKind; issueId: string; status?: string } | null {
|
||||
if (event instanceof IssueCreatedEvent)
|
||||
return {
|
||||
kind: AutomationTriggerKind.ISSUE_CREATED,
|
||||
issueId: event.aggregateId,
|
||||
};
|
||||
if (event instanceof IssueStatusChangedEvent)
|
||||
return {
|
||||
kind: AutomationTriggerKind.STATUS_CHANGED,
|
||||
issueId: event.aggregateId,
|
||||
status: event.to,
|
||||
};
|
||||
if (event instanceof IssueAssignedEvent && event.assigneeId)
|
||||
return {
|
||||
kind: AutomationTriggerKind.ISSUE_ASSIGNED,
|
||||
issueId: event.aggregateId,
|
||||
};
|
||||
if (event instanceof CommentAddedEvent)
|
||||
return {
|
||||
kind: AutomationTriggerKind.COMMENT_ADDED,
|
||||
issueId: event.comment.issueId,
|
||||
};
|
||||
if (
|
||||
event instanceof IssueDevLinkRecordedEvent &&
|
||||
event.link.kind === DevLinkKind.PULL_REQUEST &&
|
||||
event.link.state === DevLinkState.MERGED
|
||||
)
|
||||
return {
|
||||
kind: AutomationTriggerKind.PULL_REQUEST_MERGED,
|
||||
issueId: event.aggregateId,
|
||||
};
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Whether a rule wants this trigger on this ticket. */
|
||||
export function ruleApplies(
|
||||
rule: { trigger: AutomationTrigger; conditions: AutomationConditions },
|
||||
trigger: { kind: AutomationTriggerKind; status?: string },
|
||||
issue: Pick<Issue, 'type' | 'priority' | 'area' | 'labels'>,
|
||||
): boolean {
|
||||
if (rule.trigger.kind !== trigger.kind) return false;
|
||||
if (
|
||||
rule.trigger.kind === AutomationTriggerKind.STATUS_CHANGED &&
|
||||
rule.trigger.status &&
|
||||
rule.trigger.status !== trigger.status
|
||||
)
|
||||
return false;
|
||||
const { types, priorities, areas, label } = rule.conditions;
|
||||
if (types?.length && !types.includes(issue.type)) return false;
|
||||
if (priorities?.length && !priorities.includes(issue.priority)) return false;
|
||||
if (areas?.length && !(issue.area && areas.includes(issue.area)))
|
||||
return false;
|
||||
if (label && !issue.labels.includes(label.toLowerCase())) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Checks a rule against the project's workflow; returns it tidied. */
|
||||
export function normalizeRule(
|
||||
rule: {
|
||||
name: string;
|
||||
trigger: AutomationTrigger;
|
||||
conditions: AutomationConditions;
|
||||
action: AutomationAction;
|
||||
},
|
||||
workflow: ProjectWorkflow,
|
||||
isMember: (userId: string) => boolean,
|
||||
) {
|
||||
const name = rule.name.trim();
|
||||
assertThat(
|
||||
name.length > 0 && name.length <= 80,
|
||||
'A rule needs a name (up to 80 characters)',
|
||||
);
|
||||
assertThat(
|
||||
Object.values(AutomationTriggerKind).includes(rule.trigger.kind),
|
||||
'Unknown trigger',
|
||||
);
|
||||
const status = rule.trigger.status || null;
|
||||
if (status) assertThat(!!workflow.status(status), `Unknown status ${status}`);
|
||||
const { kind, value } = rule.action;
|
||||
assertThat(
|
||||
Object.values(AutomationActionKind).includes(kind),
|
||||
'Unknown action',
|
||||
);
|
||||
const needs: Partial<Record<AutomationActionKind, (v: string) => boolean>> = {
|
||||
[AutomationActionKind.SET_STATUS]: (v) => !!workflow.status(v),
|
||||
[AutomationActionKind.ASSIGN]: (v) => isMember(v),
|
||||
[AutomationActionKind.ADD_LABEL]: (v) =>
|
||||
v.trim().length > 0 && v.length <= 40,
|
||||
[AutomationActionKind.SET_PRIORITY]: (v) =>
|
||||
(Object.values(IssuePriority) as string[]).includes(v),
|
||||
[AutomationActionKind.ADD_COMMENT]: (v) =>
|
||||
v.trim().length > 0 && v.length <= 2000,
|
||||
};
|
||||
const check = needs[kind];
|
||||
if (check)
|
||||
assertThat(
|
||||
!!value && check(value),
|
||||
`The action ${kind} needs a valid value`,
|
||||
);
|
||||
const list = <T>(values?: T[] | null) =>
|
||||
values?.length ? [...new Set(values)] : undefined;
|
||||
return {
|
||||
name,
|
||||
trigger: {
|
||||
kind: rule.trigger.kind,
|
||||
...(rule.trigger.kind === AutomationTriggerKind.STATUS_CHANGED
|
||||
? { status }
|
||||
: {}),
|
||||
},
|
||||
conditions: {
|
||||
types: list(rule.conditions.types),
|
||||
priorities: list(rule.conditions.priorities),
|
||||
areas: list(rule.conditions.areas),
|
||||
label: rule.conditions.label?.trim().toLowerCase() || undefined,
|
||||
},
|
||||
action: {
|
||||
kind,
|
||||
value: check
|
||||
? kind === AutomationActionKind.ADD_LABEL
|
||||
? value!.trim().toLowerCase()
|
||||
: value
|
||||
: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
376
libs/api/tracker/application/src/lib/automations/automations.ts
Normal file
376
libs/api/tracker/application/src/lib/automations/automations.ts
Normal file
@@ -0,0 +1,376 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { Logger } from '@nestjs/common';
|
||||
import {
|
||||
Command,
|
||||
CommandBus,
|
||||
CommandHandler,
|
||||
EventPublisher,
|
||||
EventsHandler,
|
||||
ICommandHandler,
|
||||
IEventHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import {
|
||||
BusinessRuleError,
|
||||
NotFoundError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
CommentAddedEvent,
|
||||
CommentFactory,
|
||||
Issue,
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssuePriority,
|
||||
IssueStatusChangedEvent,
|
||||
Project,
|
||||
SYSTEM_ACTOR_ID,
|
||||
TrackerEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import {
|
||||
Automation,
|
||||
AutomationsRepository,
|
||||
} from '../ports/automations.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import {
|
||||
AutomationAction,
|
||||
AutomationActionKind,
|
||||
AutomationConditions,
|
||||
AutomationTrigger,
|
||||
normalizeRule,
|
||||
ruleApplies,
|
||||
triggerOf,
|
||||
} from './automation-rules';
|
||||
|
||||
/** A project keeps at most this many rules. */
|
||||
export const MAX_AUTOMATIONS = 50;
|
||||
|
||||
export interface AutomationInput {
|
||||
name: string;
|
||||
trigger: AutomationTrigger;
|
||||
conditions: AutomationConditions;
|
||||
action: AutomationAction;
|
||||
}
|
||||
|
||||
export class CreateAutomationCommand extends Command<Automation> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly rule: AutomationInput,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateAutomationCommand extends Command<Automation> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly automationId: string,
|
||||
public readonly changes: { name?: string; active?: boolean },
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class DeleteAutomationCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly automationId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Event handler only: one rule's action on one ticket, as TaskStream. */
|
||||
export class RunAutomationCommand extends Command<string | null> {
|
||||
constructor(
|
||||
public readonly automation: Automation,
|
||||
public readonly issueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetAutomationsQuery extends Query<Automation[]> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
async function managedProject(
|
||||
access: WriteAccess,
|
||||
projectId: string,
|
||||
actorId: string,
|
||||
): Promise<Project> {
|
||||
const project = await access.project(projectId);
|
||||
project.assertCanManage(actorId);
|
||||
return project;
|
||||
}
|
||||
|
||||
async function managedAutomation(
|
||||
automations: AutomationsRepository,
|
||||
access: WriteAccess,
|
||||
automationId: string,
|
||||
actorId: string,
|
||||
): Promise<Automation> {
|
||||
const automation = await automations.findById(automationId);
|
||||
if (!automation) throw new NotFoundError('Automation', automationId);
|
||||
await managedProject(access, automation.projectId, actorId);
|
||||
return automation;
|
||||
}
|
||||
|
||||
@CommandHandler(CreateAutomationCommand)
|
||||
export class CreateAutomationCommandHandler
|
||||
implements ICommandHandler<CreateAutomationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
private readonly users: UserDirectory,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateAutomationCommand): Promise<Automation> {
|
||||
const project = await managedProject(
|
||||
this.access,
|
||||
command.projectId,
|
||||
command.actorId,
|
||||
);
|
||||
if (
|
||||
(await this.automations.findByProject(project.id)).length >=
|
||||
MAX_AUTOMATIONS
|
||||
)
|
||||
throw new BusinessRuleError(
|
||||
`A project keeps at most ${MAX_AUTOMATIONS} rules`,
|
||||
);
|
||||
const rule = normalizeRule(command.rule, project.workflow, (id) =>
|
||||
project.isMember(id),
|
||||
);
|
||||
const users = await this.users.refs([command.actorId]);
|
||||
const automation: Automation = {
|
||||
id: randomUUID(),
|
||||
projectId: project.id,
|
||||
...rule,
|
||||
active: true,
|
||||
createdBy: users.of(command.actorId),
|
||||
createdAt: new Date().toISOString(),
|
||||
runs: 0,
|
||||
lastRunAt: null,
|
||||
lastError: null,
|
||||
};
|
||||
await this.automations.insert(automation);
|
||||
return automation;
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(UpdateAutomationCommand)
|
||||
export class UpdateAutomationCommandHandler
|
||||
implements ICommandHandler<UpdateAutomationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(command: UpdateAutomationCommand): Promise<Automation> {
|
||||
const automation = await managedAutomation(
|
||||
this.automations,
|
||||
this.access,
|
||||
command.automationId,
|
||||
command.actorId,
|
||||
);
|
||||
const changes: { name?: string; active?: boolean } = {};
|
||||
if (command.changes.name !== undefined) {
|
||||
const name = command.changes.name.trim();
|
||||
if (!name || name.length > 80)
|
||||
throw new BusinessRuleError(
|
||||
'A rule needs a name (up to 80 characters)',
|
||||
);
|
||||
changes.name = name;
|
||||
}
|
||||
if (command.changes.active !== undefined)
|
||||
changes.active = command.changes.active;
|
||||
await this.automations.update(automation.id, changes);
|
||||
return { ...automation, ...changes };
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(DeleteAutomationCommand)
|
||||
export class DeleteAutomationCommandHandler
|
||||
implements ICommandHandler<DeleteAutomationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(command: DeleteAutomationCommand): Promise<void> {
|
||||
const automation = await managedAutomation(
|
||||
this.automations,
|
||||
this.access,
|
||||
command.automationId,
|
||||
command.actorId,
|
||||
);
|
||||
await this.automations.delete(automation.id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Applies the action straight on the aggregates, as TaskStream (the system
|
||||
* actor): the rule was set up by someone who manages the project, and the
|
||||
* workflow still has the last word. Returns why nothing happened, if so.
|
||||
*/
|
||||
@CommandHandler(RunAutomationCommand)
|
||||
export class RunAutomationCommandHandler
|
||||
implements ICommandHandler<RunAutomationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly commentFactory: CommentFactory,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: RunAutomationCommand): Promise<string | null> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
const project = issue
|
||||
? await this.access.live(issue.projectId, Project)
|
||||
: null;
|
||||
if (!issue || !project) return 'The ticket is gone';
|
||||
const { kind, value } = command.automation.action;
|
||||
const actor = SYSTEM_ACTOR_ID;
|
||||
switch (kind) {
|
||||
case AutomationActionKind.SET_STATUS:
|
||||
if (!value || issue.status === value) return null;
|
||||
issue.changeStatus(actor, value, 'Automatización', project.workflow);
|
||||
break;
|
||||
case AutomationActionKind.ASSIGN:
|
||||
case AutomationActionKind.ASSIGN_REPORTER: {
|
||||
const assignee =
|
||||
kind === AutomationActionKind.ASSIGN
|
||||
? (value ?? null)
|
||||
: issue.reporterId;
|
||||
if (!assignee || !project.isMember(assignee))
|
||||
return 'That person is no longer a member';
|
||||
issue.assign(actor, assignee);
|
||||
break;
|
||||
}
|
||||
case AutomationActionKind.ADD_LABEL:
|
||||
if (!value || issue.labels.includes(value)) return null;
|
||||
issue.updateDetails(
|
||||
actor,
|
||||
{ labels: [...issue.labels, value] },
|
||||
project.workflow,
|
||||
);
|
||||
break;
|
||||
case AutomationActionKind.SET_PRIORITY:
|
||||
issue.updateDetails(
|
||||
actor,
|
||||
{ priority: value as IssuePriority },
|
||||
project.workflow,
|
||||
);
|
||||
break;
|
||||
case AutomationActionKind.ADD_TO_ACTIVE_SPRINT: {
|
||||
const active = await this.findSprints.findActive(project.id);
|
||||
if (!active) return 'No sprint is under way';
|
||||
issue.setSprint(actor, active.id);
|
||||
break;
|
||||
}
|
||||
case AutomationActionKind.ADD_COMMENT: {
|
||||
const comment = this.commentFactory.create({
|
||||
issueId: issue.id,
|
||||
projectId: issue.projectId,
|
||||
authorId: actor,
|
||||
body: value ?? '',
|
||||
});
|
||||
this.eventPublisher.mergeObjectContext(comment);
|
||||
await comment.commitAndWait();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
await issue.commitAndWait();
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetAutomationsQuery)
|
||||
export class GetAutomationsQueryHandler
|
||||
implements IQueryHandler<GetAutomationsQuery, Automation[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: GetAutomationsQuery): Promise<Automation[]> {
|
||||
await managedProject(this.access, query.projectId, query.viewerId);
|
||||
return this.automations.findByProject(query.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the project's rules on what people do. What rules themselves do (as
|
||||
* TaskStream) triggers nothing more: no chains, no loops. Each rule runs once
|
||||
* per event, also when the event is delivered again.
|
||||
*/
|
||||
@EventsHandler(
|
||||
IssueCreatedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueAssignedEvent,
|
||||
CommentAddedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
)
|
||||
export class AutomationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
private readonly logger = new Logger(AutomationsEventHandler.name);
|
||||
|
||||
constructor(
|
||||
private readonly automations: AutomationsRepository,
|
||||
private readonly access: WriteAccess,
|
||||
private readonly commandBus: CommandBus,
|
||||
) {}
|
||||
|
||||
async handle(event: TrackerEvent) {
|
||||
// Git reports come as the system too, and a merge is worth reacting to.
|
||||
if (
|
||||
event.actorId === SYSTEM_ACTOR_ID &&
|
||||
!(event instanceof IssueDevLinkRecordedEvent)
|
||||
)
|
||||
return;
|
||||
const trigger = triggerOf(event);
|
||||
if (!trigger) return;
|
||||
const rules = await this.automations.findActive(event.projectId);
|
||||
if (!rules.length) return;
|
||||
const issue = await this.access.live(trigger.issueId, Issue);
|
||||
if (!issue) return;
|
||||
for (const rule of rules) {
|
||||
if (!ruleApplies(rule, trigger, issue)) continue;
|
||||
const key = `${event.aggregateId}:${event.aggregateVersion ?? event.occurredAt}`;
|
||||
if (!(await this.automations.claimRun(rule.id, key))) continue;
|
||||
let error: string | null;
|
||||
try {
|
||||
error = await this.commandBus.execute(
|
||||
new RunAutomationCommand(rule, trigger.issueId),
|
||||
);
|
||||
} catch (failure) {
|
||||
error = failure instanceof Error ? failure.message : String(failure);
|
||||
this.logger.warn(`Rule ${rule.id} on ${trigger.issueId}: ${error}`);
|
||||
}
|
||||
await this.automations.recordRun(rule.id, error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const AUTOMATION_HANDLERS = [
|
||||
CreateAutomationCommandHandler,
|
||||
UpdateAutomationCommandHandler,
|
||||
DeleteAutomationCommandHandler,
|
||||
RunAutomationCommandHandler,
|
||||
GetAutomationsQueryHandler,
|
||||
AutomationsEventHandler,
|
||||
];
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
IssueChange,
|
||||
ProjectChange,
|
||||
ReleaseChange,
|
||||
SprintChange,
|
||||
} from './ports/change-feed';
|
||||
import { CanViewProjectQuery } from './queries/can-view-project.query';
|
||||
import { GetIssueQuery } from './queries/get-issue.query';
|
||||
@@ -52,6 +53,14 @@ export class ChangesService {
|
||||
return this.changeFeed.releases(projectId);
|
||||
}
|
||||
|
||||
async watchSprints(
|
||||
viewerId: string,
|
||||
projectId: string,
|
||||
): Promise<AsyncIterableIterator<SprintChange>> {
|
||||
await this.assertCanView(viewerId, projectId);
|
||||
return this.changeFeed.sprints(projectId);
|
||||
}
|
||||
|
||||
async watchProject(
|
||||
viewerId: string,
|
||||
projectId: string,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { BusinessRuleError } from '@task-stream/api/shared/domain';
|
||||
import { AttachmentStorage, attachmentKey } from '../ports/attachment-storage';
|
||||
import { AttachFileCommand } from './attach-file.command';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/**
|
||||
* Stores the bytes first and records them on the ticket after: a file with no
|
||||
* record is cleaned up here, never a record with no file.
|
||||
*/
|
||||
@CommandHandler(AttachFileCommand)
|
||||
export class AttachFileCommandHandler
|
||||
implements ICommandHandler<AttachFileCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly storage: AttachmentStorage,
|
||||
) {}
|
||||
|
||||
async execute(command: AttachFileCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
if (command.commentId) {
|
||||
const comment = await this.access.comment(command.commentId);
|
||||
if (comment.issueId !== issue.id) {
|
||||
throw new BusinessRuleError(
|
||||
`That comment belongs to another ticket, not ${issue.key}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const id = randomUUID();
|
||||
const key = attachmentKey(issue.projectId, issue.id, id);
|
||||
issue.attach(command.actorId, {
|
||||
id,
|
||||
fileName: command.file.fileName,
|
||||
mimeType: command.file.mimeType,
|
||||
size: command.file.content.length,
|
||||
uploadedBy: command.actorId,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
commentId: command.commentId,
|
||||
});
|
||||
|
||||
await this.storage.put(key, command.file.content);
|
||||
try {
|
||||
await issue.commitAndWait();
|
||||
} catch (error) {
|
||||
await this.storage.remove(key);
|
||||
throw error;
|
||||
}
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export interface IncomingFile {
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
content: Buffer;
|
||||
}
|
||||
|
||||
export class AttachFileCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly file: IncomingFile,
|
||||
/** Places it under one of the ticket's comments. */
|
||||
public readonly commentId: string | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
import {
|
||||
Command,
|
||||
CommandBus,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { assertThat } from '@task-stream/api/shared/domain';
|
||||
import { IssuePriority } from '@task-stream/api/tracker/domain';
|
||||
import { AssignIssueCommand } from './assign-issue.command';
|
||||
import { ChangeIssueStatusCommand } from './change-issue-status.command';
|
||||
import { SetIssueReleaseCommand } from './set-issue-release.command';
|
||||
import { SetIssueSprintCommand } from './sprint.commands';
|
||||
import { UpdateIssueCommand } from './update-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/** At most this many tickets per bulk change. */
|
||||
export const MAX_BULK = 100;
|
||||
|
||||
/** What to change on every ticket; absent fields stay as they are. */
|
||||
export interface BulkChange {
|
||||
status?: string;
|
||||
/** null unassigns. */
|
||||
assigneeId?: string | null;
|
||||
/** null: back to the backlog. */
|
||||
sprintId?: string | null;
|
||||
/** null: out of its release. */
|
||||
releaseId?: string | null;
|
||||
priority?: IssuePriority;
|
||||
/** Added to the ticket's labels. */
|
||||
addLabels?: string[];
|
||||
}
|
||||
|
||||
export interface BulkResult {
|
||||
updated: number;
|
||||
results: { issueId: string; ok: boolean; error: string | null }[];
|
||||
}
|
||||
|
||||
/**
|
||||
* The same change on several tickets. Each ticket goes through the normal
|
||||
* commands (permissions, workflow, open sprints and releases), one after
|
||||
* another; one refusing does not stop the rest.
|
||||
*/
|
||||
export class BulkUpdateIssuesCommand extends Command<BulkResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueIds: string[],
|
||||
public readonly change: BulkChange,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(BulkUpdateIssuesCommand)
|
||||
export class BulkUpdateIssuesCommandHandler
|
||||
implements ICommandHandler<BulkUpdateIssuesCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly access: WriteAccess,
|
||||
) {}
|
||||
|
||||
async execute(command: BulkUpdateIssuesCommand): Promise<BulkResult> {
|
||||
const ids = [...new Set(command.issueIds)];
|
||||
assertThat(ids.length > 0, 'Pick at least one ticket');
|
||||
assertThat(ids.length <= MAX_BULK, `At most ${MAX_BULK} tickets at once`);
|
||||
const change = command.change;
|
||||
assertThat(
|
||||
Object.values(change).some((v) => v !== undefined),
|
||||
'Nothing to change',
|
||||
);
|
||||
const actor = command.actorId;
|
||||
const result: BulkResult = { updated: 0, results: [] };
|
||||
for (const issueId of ids) {
|
||||
try {
|
||||
if (change.status !== undefined)
|
||||
await this.run(
|
||||
new ChangeIssueStatusCommand(actor, issueId, change.status),
|
||||
);
|
||||
if (change.assigneeId !== undefined)
|
||||
await this.run(
|
||||
new AssignIssueCommand(actor, issueId, change.assigneeId),
|
||||
);
|
||||
if (change.sprintId !== undefined)
|
||||
await this.run(
|
||||
new SetIssueSprintCommand(actor, issueId, change.sprintId),
|
||||
);
|
||||
if (change.releaseId !== undefined)
|
||||
await this.run(
|
||||
new SetIssueReleaseCommand(actor, issueId, change.releaseId),
|
||||
);
|
||||
const details: { priority?: IssuePriority; labels?: string[] } = {};
|
||||
if (change.priority) details.priority = change.priority;
|
||||
if (change.addLabels?.length) {
|
||||
const issue = await this.access.issue(issueId);
|
||||
details.labels = [...issue.labels, ...change.addLabels];
|
||||
}
|
||||
if (Object.keys(details).length)
|
||||
await this.run(new UpdateIssueCommand(actor, issueId, details));
|
||||
result.updated++;
|
||||
result.results.push({ issueId, ok: true, error: null });
|
||||
} catch (error) {
|
||||
result.results.push({
|
||||
issueId,
|
||||
ok: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private run(command: object): Promise<unknown> {
|
||||
return this.commandBus.execute(command as never);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
AddChecklistItemCommand,
|
||||
RemoveChecklistItemCommand,
|
||||
UpdateChecklistItemCommand,
|
||||
} from './change-checklist.command';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
type ChecklistCommand =
|
||||
| AddChecklistItemCommand
|
||||
| UpdateChecklistItemCommand
|
||||
| RemoveChecklistItemCommand;
|
||||
|
||||
/** Every change to a ticket's checklist: whoever can edit the ticket may. */
|
||||
@CommandHandler(AddChecklistItemCommand)
|
||||
export class AddChecklistItemCommandHandler
|
||||
implements ICommandHandler<AddChecklistItemCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
execute(command: AddChecklistItemCommand): Promise<CommandResult> {
|
||||
return change(this.access, command, (issue) =>
|
||||
issue.addChecklistItem(command.actorId, randomUUID(), command.text),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(UpdateChecklistItemCommand)
|
||||
export class UpdateChecklistItemCommandHandler
|
||||
implements ICommandHandler<UpdateChecklistItemCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
execute(command: UpdateChecklistItemCommand): Promise<CommandResult> {
|
||||
return change(this.access, command, (issue) =>
|
||||
issue.updateChecklistItem(
|
||||
command.actorId,
|
||||
command.itemId,
|
||||
command.changes,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(RemoveChecklistItemCommand)
|
||||
export class RemoveChecklistItemCommandHandler
|
||||
implements ICommandHandler<RemoveChecklistItemCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
execute(command: RemoveChecklistItemCommand): Promise<CommandResult> {
|
||||
return change(this.access, command, (issue) =>
|
||||
issue.removeChecklistItem(command.actorId, command.itemId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function change(
|
||||
access: WriteAccess,
|
||||
command: ChecklistCommand,
|
||||
apply: (issue: Awaited<ReturnType<WriteAccess['issue']>>) => void,
|
||||
): Promise<CommandResult> {
|
||||
const { issue } = await access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
apply(issue);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class AddChecklistItemCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly text: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Rewords, ticks or unticks a checklist step. */
|
||||
export class UpdateChecklistItemCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly itemId: string,
|
||||
public readonly changes: { text?: string; done?: boolean },
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoveChecklistItemCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly itemId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -25,6 +25,21 @@ import { PurgeCommentCommandHandler } from './purge-comment.command-handler';
|
||||
import { DropLinksToIssueCommandHandler } from './drop-links-to-issue.command-handler';
|
||||
import { DetachIssueFromReleaseCommandHandler } from './detach-issue-from-release.command-handler';
|
||||
import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
|
||||
import { AttachFileCommandHandler } from './attach-file.command-handler';
|
||||
import { DetachFileCommandHandler } from './detach-file.command-handler';
|
||||
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
|
||||
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
|
||||
import { RankIssueCommandHandler } from './rank-issue.command-handler';
|
||||
import { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler';
|
||||
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
|
||||
import { BulkUpdateIssuesCommandHandler } from './bulk-update-issues';
|
||||
import { TRASH_HANDLERS } from './trash';
|
||||
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
|
||||
import {
|
||||
AddChecklistItemCommandHandler,
|
||||
RemoveChecklistItemCommandHandler,
|
||||
UpdateChecklistItemCommandHandler,
|
||||
} from './change-checklist.command-handler';
|
||||
|
||||
export const COMMAND_HANDLERS = [
|
||||
CreateProjectCommandHandler,
|
||||
@@ -55,4 +70,18 @@ export const COMMAND_HANDLERS = [
|
||||
DropLinksToIssueCommandHandler,
|
||||
DetachIssueFromReleaseCommandHandler,
|
||||
UnassignIssueCommandHandler,
|
||||
AttachFileCommandHandler,
|
||||
DetachFileCommandHandler,
|
||||
WatchIssueCommandHandler,
|
||||
SetIssueParentCommandHandler,
|
||||
RankIssueCommandHandler,
|
||||
WriteReleaseNotesCommandHandler,
|
||||
...SPRINT_COMMAND_HANDLERS,
|
||||
BulkUpdateIssuesCommandHandler,
|
||||
...TRASH_HANDLERS,
|
||||
AddChecklistItemCommandHandler,
|
||||
UpdateChecklistItemCommandHandler,
|
||||
RemoveChecklistItemCommandHandler,
|
||||
// Saga only: the parent was deleted.
|
||||
DetachFromParentCommandHandler,
|
||||
];
|
||||
|
||||
@@ -12,29 +12,62 @@ import {
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
Comment,
|
||||
Issue,
|
||||
IssueStatus,
|
||||
Project,
|
||||
IssueType,
|
||||
ProjectRole,
|
||||
TRACKER_FACTORIES,
|
||||
defaultProjectSettings,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
FakeUserDirectory,
|
||||
InMemoryAttachmentStorage,
|
||||
InMemoryEventStore,
|
||||
InMemoryEventStorePublisher,
|
||||
InMemoryIssueNumbers,
|
||||
InMemoryProjectKeys,
|
||||
InMemoryReleaseNames,
|
||||
InMemoryReleaseIssues,
|
||||
InMemoryTrackerStreams,
|
||||
} from '../../testing/in-memory';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import {
|
||||
CreateSprintCommand,
|
||||
SetIssueSprintCommand,
|
||||
StartSprintCommand,
|
||||
} from './sprint.commands';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { GIT_HANDLERS, RecordGitMentionsCommand } from '../git/git';
|
||||
import { RunAutomationCommand } from '../automations/automations';
|
||||
import { RunAutomationCommandHandler } from '../automations/automations';
|
||||
import {
|
||||
AutomationActionKind,
|
||||
AutomationTriggerKind,
|
||||
} from '../automations/automation-rules';
|
||||
import { Automation } from '../ports/automations.repository';
|
||||
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
|
||||
import { DevLinkKind } from '@task-stream/api/tracker/domain';
|
||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||
import { RankIssueCommand } from './rank-issue.command';
|
||||
import {
|
||||
AddChecklistItemCommand,
|
||||
UpdateChecklistItemCommand,
|
||||
} from './change-checklist.command';
|
||||
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
|
||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
||||
import { ReleaseNamesRepository } from '../ports/release-names.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { AttachmentStorage } from '../ports/attachment-storage';
|
||||
import { AddCommentCommand } from './add-comment.command';
|
||||
import { AddProjectMemberCommand } from './add-project-member.command';
|
||||
import { AssignIssueCommand } from './assign-issue.command';
|
||||
import { AttachFileCommand } from './attach-file.command';
|
||||
import { DetachFileCommand } from './detach-file.command';
|
||||
import { WatchIssueCommand } from './watch-issue.command';
|
||||
import { ChangeIssueStatusCommand } from './change-issue-status.command';
|
||||
import { COMMAND_HANDLERS } from './command-handlers';
|
||||
import { CommandResult } from './command-result';
|
||||
@@ -45,9 +78,20 @@ import { DeleteCommentCommand } from './delete-comment.command';
|
||||
import { PublishReleaseCommand } from './publish-release.command';
|
||||
import { SetIssueReleaseCommand } from './set-issue-release.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
import { RestoreIssueCommand, TrashIssueCommand } from './trash';
|
||||
import { BulkResult, BulkUpdateIssuesCommand } from './bulk-update-issues';
|
||||
import { UpdateProjectSettingsCommand } from './update-project-settings.command';
|
||||
import { ReadAccess } from '../queries/read-access';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
|
||||
async function setup() {
|
||||
const store = new InMemoryEventStore();
|
||||
const keys = new Map<string, { id: string; projectId: string }>();
|
||||
const sprints = {
|
||||
active: null as { id: string; name: string } | null,
|
||||
findActive: async () => sprints.active,
|
||||
};
|
||||
const files = new InMemoryAttachmentStorage();
|
||||
const users = new FakeUserDirectory()
|
||||
.add('owner')
|
||||
.add('dev')
|
||||
@@ -58,10 +102,20 @@ async function setup() {
|
||||
imports: [CqrsModule.forRoot()],
|
||||
providers: [
|
||||
...COMMAND_HANDLERS,
|
||||
...GIT_HANDLERS,
|
||||
RunAutomationCommandHandler,
|
||||
...TRACKER_FACTORIES,
|
||||
WriteAccess,
|
||||
IssueTree,
|
||||
ReadAccess,
|
||||
{
|
||||
provide: FindProjectsRepository,
|
||||
useValue: { findRole: async () => null, findById: async () => null },
|
||||
},
|
||||
{ provide: GitIntegrationsRepository, useValue: {} },
|
||||
AggregateRehydrator,
|
||||
{ provide: EventStore, useValue: store },
|
||||
{ provide: AttachmentStorage, useValue: files },
|
||||
{ provide: UserDirectory, useValue: users },
|
||||
{ provide: ProjectKeysRepository, useClass: InMemoryProjectKeys },
|
||||
{ provide: IssueNumbersRepository, useClass: InMemoryIssueNumbers },
|
||||
@@ -70,6 +124,26 @@ async function setup() {
|
||||
provide: ReleaseIssuesRepository,
|
||||
useValue: new InMemoryReleaseIssues(store),
|
||||
},
|
||||
{
|
||||
provide: TrackerStreamsRepository,
|
||||
useValue: new InMemoryTrackerStreams(store),
|
||||
},
|
||||
// Read-model lookups: the sprint under way is set by each test.
|
||||
{ provide: FindSprintsRepository, useValue: sprints },
|
||||
{
|
||||
provide: FindIssuesRepository,
|
||||
useValue: {
|
||||
// Tickets by key, as the read model would answer (filled by tests).
|
||||
findByKey: async (key: string) => keys.get(key) ?? null,
|
||||
sprintProgress: async () => ({
|
||||
total: 0,
|
||||
finished: 0,
|
||||
percent: 0,
|
||||
storyPoints: 0,
|
||||
finishedStoryPoints: 0,
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
await moduleRef.init();
|
||||
@@ -78,7 +152,14 @@ async function setup() {
|
||||
const commandBus = moduleRef.get(CommandBus);
|
||||
const run = (command: object): Promise<CommandResult> =>
|
||||
commandBus.execute(command as never);
|
||||
return { store, run, rehydrator: moduleRef.get(AggregateRehydrator) };
|
||||
return {
|
||||
store,
|
||||
files,
|
||||
sprints,
|
||||
keys,
|
||||
run,
|
||||
rehydrator: moduleRef.get(AggregateRehydrator),
|
||||
};
|
||||
}
|
||||
|
||||
async function projectWithTeam(run: (c: object) => Promise<CommandResult>) {
|
||||
@@ -178,6 +259,432 @@ describe('tracker commands (event sourced)', () => {
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
});
|
||||
|
||||
describe('attachments', () => {
|
||||
const png = {
|
||||
fileName: 'captura.png',
|
||||
mimeType: 'image/png',
|
||||
content: Buffer.from('fake png'),
|
||||
};
|
||||
|
||||
it('stores the file and records it on the ticket', async () => {
|
||||
const { run, files, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
|
||||
);
|
||||
|
||||
await run(new AttachFileCommand('qa', issue.id, png));
|
||||
|
||||
const rebuilt = await rehydrator.rehydrate(issue.id, Issue);
|
||||
expect(rebuilt.attachments).toHaveLength(1);
|
||||
expect(rebuilt.attachments[0]).toMatchObject({
|
||||
fileName: 'captura.png',
|
||||
size: 8,
|
||||
uploadedBy: 'qa',
|
||||
});
|
||||
expect([...files.files.keys()]).toEqual([
|
||||
`${projectId}/${issue.id}/${rebuilt.attachments[0].id}`,
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps no file when the ticket cannot take it', async () => {
|
||||
const { run, files } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
run(new AttachFileCommand('pm', issue.id, png)),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
await expect(
|
||||
run(
|
||||
new AttachFileCommand('qa', issue.id, {
|
||||
...png,
|
||||
content: Buffer.alloc(0),
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow();
|
||||
expect(files.files.size).toBe(0);
|
||||
});
|
||||
|
||||
it('lets the uploader or an admin remove it, and deletes the bytes', async () => {
|
||||
const { run, files, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
|
||||
);
|
||||
await run(new AttachFileCommand('qa', issue.id, png));
|
||||
const [attachment] = (await rehydrator.rehydrate(issue.id, Issue))
|
||||
.attachments;
|
||||
|
||||
await expect(
|
||||
run(new DetachFileCommand('dev', issue.id, attachment.id)),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
await run(new DetachFileCommand('owner', issue.id, attachment.id));
|
||||
|
||||
expect(files.files.size).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('lets any member, viewers included, follow a ticket for themselves', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
|
||||
);
|
||||
|
||||
await run(new WatchIssueCommand('pm', issue.id, 'pm'));
|
||||
await run(new WatchIssueCommand('dev', issue.id, 'dev'));
|
||||
await run(new WatchIssueCommand('dev', issue.id, 'dev', false));
|
||||
|
||||
expect((await rehydrator.rehydrate(issue.id, Issue)).watchers).toEqual([
|
||||
'pm',
|
||||
]);
|
||||
await expect(
|
||||
run(new WatchIssueCommand('dev', issue.id, 'pm', false)),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
await expect(
|
||||
run(new WatchIssueCommand('gone', issue.id, 'gone')),
|
||||
).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('orders the backlog from the ranks in the event store', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const ids = [];
|
||||
for (const title of ['A', 'B', 'C'])
|
||||
ids.push(
|
||||
(
|
||||
await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, title, {}),
|
||||
)
|
||||
).id,
|
||||
);
|
||||
const [a, b, c] = ids;
|
||||
|
||||
// C to the top, then B between C and A: C, B, A.
|
||||
await run(new RankIssueCommand('dev', c, null, a));
|
||||
await run(new RankIssueCommand('dev', b, c, a));
|
||||
const ranks = await Promise.all(
|
||||
[c, b, a].map(
|
||||
async (id) => (await rehydrator.rehydrate(id, Issue)).backlogRank,
|
||||
),
|
||||
);
|
||||
expect([...ranks].sort()).toEqual(ranks);
|
||||
// Neighbours given the wrong way round: the order changed meanwhile.
|
||||
await expect(run(new RankIssueCommand('dev', a, b, c))).rejects.toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
await expect(run(new RankIssueCommand('pm', a, null, c))).rejects.toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
});
|
||||
|
||||
it('plans and starts sprints, one under way at a time, and fills them', async () => {
|
||||
const { run, rehydrator, sprints } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const plan = (name: string) =>
|
||||
run(
|
||||
new CreateSprintCommand(
|
||||
'owner',
|
||||
projectId,
|
||||
name,
|
||||
'2026-10-12',
|
||||
'2026-10-23',
|
||||
),
|
||||
);
|
||||
const first = await plan('Sprint 1');
|
||||
const second = await plan('Sprint 2');
|
||||
await expect(
|
||||
run(
|
||||
new CreateSprintCommand(
|
||||
'dev',
|
||||
projectId,
|
||||
'X',
|
||||
'2026-10-12',
|
||||
'2026-10-23',
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
|
||||
await run(new StartSprintCommand('owner', first.id));
|
||||
sprints.active = { id: first.id, name: 'Sprint 1' };
|
||||
await expect(
|
||||
run(new StartSprintCommand('owner', second.id)),
|
||||
).rejects.toThrow(/Sprint 1 is under way/);
|
||||
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Login', {}),
|
||||
);
|
||||
await run(new SetIssueSprintCommand('dev', issue.id, first.id));
|
||||
expect((await rehydrator.rehydrate(issue.id, Issue)).sprintId).toBe(
|
||||
first.id,
|
||||
);
|
||||
await expect(
|
||||
run(new SetIssueSprintCommand('pm', issue.id, null)),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('links commits and pull requests to the tickets they name', async () => {
|
||||
const { run, rehydrator, keys } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Pay', {}),
|
||||
);
|
||||
keys.set(issue.key ?? '', { id: issue.id, projectId });
|
||||
const commit = {
|
||||
text: `${issue.key}: card form (refs TS-999, OTHER-1)`,
|
||||
link: {
|
||||
kind: DevLinkKind.COMMIT,
|
||||
url: 'https://git.example.org/acme/web/commit/abc',
|
||||
title: 'card form',
|
||||
ref: 'abc',
|
||||
state: null,
|
||||
provider: 'gitea',
|
||||
author: 'Ana',
|
||||
at: '2026-10-08T10:00:00Z',
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
await run(new RecordGitMentionsCommand(projectId, [commit, commit])),
|
||||
).toBe(1);
|
||||
const { devLinks } = await rehydrator.rehydrate(issue.id, Issue);
|
||||
expect(devLinks.map((l) => l.ref)).toEqual(['abc']);
|
||||
});
|
||||
|
||||
it('runs automation actions as TaskStream, within the workflow', async () => {
|
||||
const { run, rehydrator, store } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.BUG, 'Crash', {}),
|
||||
);
|
||||
const rule = (kind: AutomationActionKind, value: string | null) =>
|
||||
({
|
||||
id: 'r1',
|
||||
projectId,
|
||||
name: 'R',
|
||||
active: true,
|
||||
trigger: { kind: AutomationTriggerKind.ISSUE_CREATED },
|
||||
conditions: {},
|
||||
action: { kind, value },
|
||||
}) as Automation;
|
||||
|
||||
await run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.SET_STATUS, 'TODO'),
|
||||
issue.id,
|
||||
),
|
||||
);
|
||||
await run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.ADD_LABEL, 'triage'),
|
||||
issue.id,
|
||||
),
|
||||
);
|
||||
await run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.ASSIGN_REPORTER, null),
|
||||
issue.id,
|
||||
),
|
||||
);
|
||||
await run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.ADD_COMMENT, 'Gracias'),
|
||||
issue.id,
|
||||
),
|
||||
);
|
||||
const rebuilt = await rehydrator.rehydrate(issue.id, Issue);
|
||||
expect([rebuilt.status, rebuilt.labels, rebuilt.assigneeId]).toEqual([
|
||||
'TODO',
|
||||
['triage'],
|
||||
'dev',
|
||||
]);
|
||||
expect(store.types()).toContain('CommentAddedEvent');
|
||||
// The workflow still decides: TODO cannot jump to DONE.
|
||||
await expect(
|
||||
run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.SET_STATUS, 'DONE'),
|
||||
issue.id,
|
||||
),
|
||||
),
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
expect(
|
||||
await run(
|
||||
new RunAutomationCommand(
|
||||
rule(AutomationActionKind.ADD_TO_ACTIVE_SPRINT, null),
|
||||
issue.id,
|
||||
),
|
||||
),
|
||||
).toBe('No sprint is under way');
|
||||
});
|
||||
|
||||
it("fills a new ticket with its type's template", async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const settings = defaultProjectSettings();
|
||||
settings.issueTypes[0].template = {
|
||||
description: '## Pasos para reproducir',
|
||||
checklist: ['Probar en Safari', 'Adjuntar captura'],
|
||||
};
|
||||
await run(new UpdateProjectSettingsCommand('owner', projectId, settings));
|
||||
const plain = await run(
|
||||
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
|
||||
);
|
||||
const written = await run(
|
||||
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash 2', {
|
||||
description: 'Ya lo explico yo',
|
||||
}),
|
||||
);
|
||||
|
||||
const first = await rehydrator.rehydrate(plain.id, Issue);
|
||||
expect(first.description).toBe('## Pasos para reproducir');
|
||||
expect(first.checklist.map((i) => i.text)).toEqual([
|
||||
'Probar en Safari',
|
||||
'Adjuntar captura',
|
||||
]);
|
||||
expect((await rehydrator.rehydrate(written.id, Issue)).description).toBe(
|
||||
'Ya lo explico yo',
|
||||
);
|
||||
});
|
||||
|
||||
it('changes several tickets at once and says which refused', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const ids: string[] = [];
|
||||
for (const title of ['A', 'B'])
|
||||
ids.push(
|
||||
(
|
||||
await run(
|
||||
new CreateIssueCommand('dev', projectId, IssueType.TASK, title, {}),
|
||||
)
|
||||
).id,
|
||||
);
|
||||
for (const status of ['TODO', 'IN_PROGRESS'])
|
||||
await run(new ChangeIssueStatusCommand('dev', ids[1], status));
|
||||
// A is in BACKLOG: the workflow does not let it jump to review.
|
||||
const result = (await run(
|
||||
new BulkUpdateIssuesCommand('dev', [...ids, ids[1]], {
|
||||
status: 'IN_REVIEW',
|
||||
assigneeId: 'qa',
|
||||
addLabels: ['sprint-14'],
|
||||
}),
|
||||
)) as unknown as BulkResult;
|
||||
|
||||
expect(result.updated).toBe(1);
|
||||
expect(result.results.map((r) => [r.issueId, r.ok])).toEqual([
|
||||
[ids[0], false],
|
||||
[ids[1], true],
|
||||
]);
|
||||
const b = await rehydrator.rehydrate(ids[1], Issue);
|
||||
expect([b.status, b.assigneeId, b.labels]).toEqual([
|
||||
'IN_REVIEW',
|
||||
'qa',
|
||||
['sprint-14'],
|
||||
]);
|
||||
await expect(
|
||||
run(new BulkUpdateIssuesCommand('dev', ids, {})),
|
||||
).rejects.toThrow(/Nothing to change/);
|
||||
});
|
||||
|
||||
it('sends tickets to the trash and back; trashed ones cannot change', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await run(
|
||||
new CreateIssueCommand('qa', projectId, IssueType.BUG, 'Crash', {}),
|
||||
);
|
||||
await expect(run(new TrashIssueCommand('dev', issue.id))).rejects.toThrow(
|
||||
ForbiddenError,
|
||||
);
|
||||
await run(new TrashIssueCommand('qa', issue.id));
|
||||
await expect(
|
||||
run(new AssignIssueCommand('owner', issue.id, 'dev')),
|
||||
).rejects.toThrow(/trash/);
|
||||
await run(new RestoreIssueCommand('owner', issue.id));
|
||||
await run(new AssignIssueCommand('owner', issue.id, 'dev'));
|
||||
expect((await rehydrator.rehydrate(issue.id, Issue)).trashedAt).toBeNull();
|
||||
});
|
||||
|
||||
it('does not let viewers modify tickets', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { IssueFactory } from '@task-stream/api/tracker/domain';
|
||||
import { IssueFactory, assertCanNest } from '@task-stream/api/tracker/domain';
|
||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { CreateIssueCommand } from './create-issue.command';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(CreateIssueCommand)
|
||||
@@ -14,6 +16,7 @@ export class CreateIssueCommandHandler
|
||||
private readonly issueFactory: IssueFactory,
|
||||
private readonly issueNumbers: IssueNumbersRepository,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
private readonly tree: IssueTree,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateIssueCommand): Promise<CommandResult> {
|
||||
@@ -27,10 +30,24 @@ export class CreateIssueCommandHandler
|
||||
this.access.assertAssignable(project, command.assigneeId);
|
||||
if (command.releaseId)
|
||||
await this.access.openReleaseOf(project, command.releaseId);
|
||||
const parent = command.parentId
|
||||
? await this.tree.candidate(command.parentId)
|
||||
: null;
|
||||
if (parent)
|
||||
assertCanNest(
|
||||
{ id: '', key: 'The new ticket', projectId: project.id },
|
||||
parent,
|
||||
0,
|
||||
);
|
||||
|
||||
const template = command.useTemplate
|
||||
? workflow.templateOf(command.type)
|
||||
: null;
|
||||
const issue = this.issueFactory.create(
|
||||
{
|
||||
...command.details,
|
||||
description:
|
||||
command.details.description?.trim() || template?.description || null,
|
||||
projectId: project.id,
|
||||
projectKey: project.key,
|
||||
number: await this.issueNumbers.next(project.id),
|
||||
@@ -38,10 +55,14 @@ export class CreateIssueCommandHandler
|
||||
title: command.title,
|
||||
reporterId: command.actorId,
|
||||
assigneeId: command.assigneeId,
|
||||
status: command.initialStatus,
|
||||
},
|
||||
workflow,
|
||||
);
|
||||
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
|
||||
if (parent) issue.setParent(command.actorId, parent);
|
||||
for (const step of template?.checklist ?? [])
|
||||
issue.addChecklistItem(command.actorId, randomUUID(), step);
|
||||
|
||||
this.eventPublisher.mergeObjectContext(issue);
|
||||
await issue.commitAndWait();
|
||||
|
||||
@@ -12,6 +12,12 @@ export class CreateIssueCommand extends Command<CommandResult> {
|
||||
public readonly details: Omit<IssueDetails, 'title' | 'type'>,
|
||||
public readonly assigneeId?: string | null,
|
||||
public readonly releaseId?: string | null,
|
||||
/** Epic or ticket the new one breaks down. */
|
||||
public readonly parentId?: string | null,
|
||||
/** Imports only: start in this (active) status instead of the first one. */
|
||||
public readonly initialStatus?: string | null,
|
||||
/** Fill in the type's template (imports bring their own content). */
|
||||
public readonly useTemplate = true,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ProjectRole } from '@task-stream/api/tracker/domain';
|
||||
import { AttachmentStorage, attachmentKey } from '../ports/attachment-storage';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { DetachFileCommand } from './detach-file.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/** Removes the record first; the bytes go once nothing points at them. */
|
||||
@CommandHandler(DetachFileCommand)
|
||||
export class DetachFileCommandHandler
|
||||
implements ICommandHandler<DetachFileCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly storage: AttachmentStorage,
|
||||
) {}
|
||||
|
||||
async execute(command: DetachFileCommand): Promise<CommandResult> {
|
||||
const { issue, project } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
const role = project.roleOf(command.actorId);
|
||||
issue.detach(
|
||||
command.actorId,
|
||||
command.attachmentId,
|
||||
role === ProjectRole.OWNER || role === ProjectRole.ADMIN,
|
||||
);
|
||||
await issue.commitAndWait();
|
||||
await this.storage.remove(
|
||||
attachmentKey(issue.projectId, issue.id, command.attachmentId),
|
||||
);
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class DetachFileCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly attachmentId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Issue, Project } from '@task-stream/api/tracker/domain';
|
||||
import { DetachFromParentCommand } from './detach-from-parent.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(DetachFromParentCommand)
|
||||
export class DetachFromParentCommandHandler
|
||||
implements ICommandHandler<DetachFromParentCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: DetachFromParentCommand): Promise<void> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
// Gone (or its whole project is going), or it moved elsewhere already.
|
||||
if (!issue || issue.parentId !== command.parentId) return;
|
||||
if (!(await this.access.live(issue.projectId, Project))) return;
|
||||
issue.setParent(command.actorId, null);
|
||||
await issue.commitAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: the parent of a ticket was deleted, so it goes back to the top. */
|
||||
export class DetachFromParentCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly parentId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -26,3 +26,14 @@ export * from './purge-comment.command';
|
||||
export * from './drop-links-to-issue.command';
|
||||
export * from './detach-issue-from-release.command';
|
||||
export * from './unassign-issue.command';
|
||||
export * from './attach-file.command';
|
||||
export * from './detach-file.command';
|
||||
export * from './watch-issue.command';
|
||||
export * from './set-issue-parent.command';
|
||||
export * from './detach-from-parent.command';
|
||||
export * from './change-checklist.command';
|
||||
export * from './rank-issue.command';
|
||||
export * from './sprint.commands';
|
||||
export * from './write-release-notes.command';
|
||||
export * from './bulk-update-issues';
|
||||
export * from './trash';
|
||||
|
||||
60
libs/api/tracker/application/src/lib/commands/issue-tree.ts
Normal file
60
libs/api/tracker/application/src/lib/commands/issue-tree.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Issue,
|
||||
MAX_HIERARCHY_DEPTH,
|
||||
ParentCandidate,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/**
|
||||
* Walks the epic → ticket → subtask tree on the write side (the event store),
|
||||
* so nesting rules never depend on a read model that may lag behind.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IssueTree {
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly streams: TrackerStreamsRepository,
|
||||
) {}
|
||||
|
||||
/** `parentId` as a would-be parent: with its ancestors, closest first. */
|
||||
async candidate(parentId: string): Promise<ParentCandidate> {
|
||||
const parent = await this.access.issue(parentId);
|
||||
const ancestorIds: string[] = [];
|
||||
let next = parent.parentId;
|
||||
// One more than allowed is enough to tell a tree is too deep (or loops).
|
||||
while (next && ancestorIds.length <= MAX_HIERARCHY_DEPTH) {
|
||||
ancestorIds.push(next);
|
||||
next = (await this.access.live(next, Issue))?.parentId ?? null;
|
||||
}
|
||||
return {
|
||||
id: parent.id,
|
||||
key: parent.key,
|
||||
projectId: parent.projectId,
|
||||
ancestorIds,
|
||||
};
|
||||
}
|
||||
|
||||
/** Tickets hanging from `issueId` right now. */
|
||||
async childrenOf(issueId: string): Promise<Issue[]> {
|
||||
const everUnder = await this.streams.issueIdsEverUnder(issueId);
|
||||
const issues = await Promise.all(
|
||||
everUnder.map((id) => this.access.live(id, Issue)),
|
||||
);
|
||||
return issues.filter(
|
||||
(issue): issue is Issue => !!issue && issue.parentId === issueId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Levels of tickets below `issueId` (0: none). */
|
||||
async heightOf(issueId: string, depth = 0): Promise<number> {
|
||||
if (depth > MAX_HIERARCHY_DEPTH) return 0;
|
||||
const children = await this.childrenOf(issueId);
|
||||
if (children.length === 0) return 0;
|
||||
const heights = await Promise.all(
|
||||
children.map((child) => this.heightOf(child.id, depth + 1)),
|
||||
);
|
||||
return 1 + Math.max(...heights);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { RankIssueCommand } from './rank-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(RankIssueCommand)
|
||||
export class RankIssueCommandHandler
|
||||
implements ICommandHandler<RankIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: RankIssueCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
// The neighbours' ranks come from their streams, not from the screen.
|
||||
const [above, below] = await Promise.all(
|
||||
[command.aboveIssueId, command.belowIssueId].map((id) =>
|
||||
id ? this.access.issue(id) : null,
|
||||
),
|
||||
);
|
||||
issue.moveBetween(command.actorId, above, below);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/**
|
||||
* Moves a ticket in the backlog, between the one that ends up above it and the
|
||||
* one that ends up below (null: the top or the bottom).
|
||||
*/
|
||||
export class RankIssueCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly aboveIssueId: string | null,
|
||||
public readonly belowIssueId: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(SetIssueParentCommand)
|
||||
export class SetIssueParentCommandHandler
|
||||
implements ICommandHandler<SetIssueParentCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly tree: IssueTree,
|
||||
) {}
|
||||
|
||||
async execute(command: SetIssueParentCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
if (command.parentId === null) {
|
||||
issue.setParent(command.actorId, null);
|
||||
} else if (command.parentId !== issue.parentId) {
|
||||
const [parent, height] = await Promise.all([
|
||||
this.tree.candidate(command.parentId),
|
||||
this.tree.heightOf(issue.id),
|
||||
]);
|
||||
issue.setParent(command.actorId, parent, height);
|
||||
}
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/** Places a ticket under an epic or another ticket (`parentId` null: out). */
|
||||
export class SetIssueParentCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly parentId: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
Issue,
|
||||
Project,
|
||||
Sprint,
|
||||
SprintFactory,
|
||||
SprintStatus,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import {
|
||||
CarryOverIssueCommand,
|
||||
CloseSprintCommand,
|
||||
CreateSprintCommand,
|
||||
DeleteSprintCommand,
|
||||
SetIssueSprintCommand,
|
||||
StartSprintCommand,
|
||||
UpdateSprintCommand,
|
||||
} from './sprint.commands';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/* Sprints are planned and run by whoever manages the project; any member puts tickets in them. */
|
||||
|
||||
@CommandHandler(CreateSprintCommand)
|
||||
export class CreateSprintCommandHandler
|
||||
implements ICommandHandler<CreateSprintCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly sprintFactory: SprintFactory,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateSprintCommand): Promise<CommandResult> {
|
||||
const project = await this.access.project(command.projectId);
|
||||
project.assertCanManage(command.actorId);
|
||||
const sprint = this.sprintFactory.create(command.actorId, {
|
||||
projectId: project.id,
|
||||
name: command.name,
|
||||
goal: command.goal,
|
||||
startDate: command.startDate,
|
||||
endDate: command.endDate,
|
||||
});
|
||||
this.eventPublisher.mergeObjectContext(sprint);
|
||||
await sprint.commitAndWait();
|
||||
return ack(sprint);
|
||||
}
|
||||
}
|
||||
|
||||
/** The sprint, checking the actor manages its project. */
|
||||
async function managedSprint(
|
||||
access: WriteAccess,
|
||||
sprintId: string,
|
||||
actorId: string,
|
||||
): Promise<Sprint> {
|
||||
const sprint = await access.sprint(sprintId);
|
||||
(await access.project(sprint.projectId)).assertCanManage(actorId);
|
||||
return sprint;
|
||||
}
|
||||
|
||||
@CommandHandler(UpdateSprintCommand)
|
||||
export class UpdateSprintCommandHandler
|
||||
implements ICommandHandler<UpdateSprintCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: UpdateSprintCommand): Promise<CommandResult> {
|
||||
const sprint = await managedSprint(
|
||||
this.access,
|
||||
command.sprintId,
|
||||
command.actorId,
|
||||
);
|
||||
sprint.update(command.actorId, command.changes);
|
||||
await sprint.commitAndWait();
|
||||
return ack(sprint);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(StartSprintCommand)
|
||||
export class StartSprintCommandHandler
|
||||
implements ICommandHandler<StartSprintCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: StartSprintCommand): Promise<CommandResult> {
|
||||
const sprint = await managedSprint(
|
||||
this.access,
|
||||
command.sprintId,
|
||||
command.actorId,
|
||||
);
|
||||
const active = await this.findSprints.findActive(sprint.projectId);
|
||||
sprint.start(
|
||||
command.actorId,
|
||||
active && active.id !== sprint.id ? active : null,
|
||||
);
|
||||
await sprint.commitAndWait();
|
||||
return ack(sprint);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(CloseSprintCommand)
|
||||
export class CloseSprintCommandHandler
|
||||
implements ICommandHandler<CloseSprintCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: CloseSprintCommand): Promise<CommandResult> {
|
||||
const sprint = await managedSprint(
|
||||
this.access,
|
||||
command.sprintId,
|
||||
command.actorId,
|
||||
);
|
||||
const target = command.carryOverTo
|
||||
? await this.access.sprint(command.carryOverTo)
|
||||
: null;
|
||||
const progress = await this.findIssues.sprintProgress(sprint.id);
|
||||
sprint.close(command.actorId, target, progress.total - progress.finished);
|
||||
await sprint.commitAndWait();
|
||||
return ack(sprint);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(DeleteSprintCommand)
|
||||
export class DeleteSprintCommandHandler
|
||||
implements ICommandHandler<DeleteSprintCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: DeleteSprintCommand): Promise<CommandResult | void> {
|
||||
if (command.cascade) {
|
||||
// Saga: idempotent, the sprint may be gone already.
|
||||
const sprint = await this.access.live(command.sprintId, Sprint);
|
||||
if (!sprint) return;
|
||||
sprint.delete(command.actorId, true);
|
||||
await sprint.commitAndWait();
|
||||
return;
|
||||
}
|
||||
const sprint = await managedSprint(
|
||||
this.access,
|
||||
command.sprintId,
|
||||
command.actorId,
|
||||
);
|
||||
sprint.delete(command.actorId);
|
||||
await sprint.commitAndWait();
|
||||
return ack(sprint);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(SetIssueSprintCommand)
|
||||
export class SetIssueSprintCommandHandler
|
||||
implements ICommandHandler<SetIssueSprintCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: SetIssueSprintCommand): Promise<CommandResult> {
|
||||
const { issue, project } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
// A closed sprint keeps its tickets: neither side may be closed.
|
||||
if (issue.sprintId) await this.access.openSprintOf(project, issue.sprintId);
|
||||
if (command.sprintId)
|
||||
await this.access.openSprintOf(project, command.sprintId);
|
||||
issue.setSprint(command.actorId, command.sprintId);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
|
||||
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||
|
||||
@CommandHandler(CarryOverIssueCommand)
|
||||
export class CarryOverIssueCommandHandler
|
||||
implements ICommandHandler<CarryOverIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: CarryOverIssueCommand): Promise<void> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
// Gone, or it left that sprint meanwhile: nothing to do.
|
||||
if (!issue || issue.sprintId !== command.fromSprintId) return;
|
||||
const project = await this.access.live(issue.projectId, Project);
|
||||
if (!project) return;
|
||||
if (
|
||||
command.onlyUnfinished &&
|
||||
FINISHED.includes(project.workflow.categoryOf(issue.status))
|
||||
)
|
||||
return;
|
||||
if (command.toSprintId) {
|
||||
const target = await this.access.live(command.toSprintId, Sprint);
|
||||
// The target closed or vanished meanwhile: back to the backlog instead.
|
||||
const open =
|
||||
target && !target.deleted && target.status !== SprintStatus.CLOSED;
|
||||
issue.setSprint(command.actorId, open ? command.toSprintId : null);
|
||||
} else {
|
||||
issue.setSprint(command.actorId, null);
|
||||
}
|
||||
await issue.commitAndWait();
|
||||
}
|
||||
}
|
||||
|
||||
export const SPRINT_COMMAND_HANDLERS = [
|
||||
CreateSprintCommandHandler,
|
||||
UpdateSprintCommandHandler,
|
||||
StartSprintCommandHandler,
|
||||
CloseSprintCommandHandler,
|
||||
DeleteSprintCommandHandler,
|
||||
SetIssueSprintCommandHandler,
|
||||
// Saga only.
|
||||
CarryOverIssueCommandHandler,
|
||||
];
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { SprintDetails } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class CreateSprintCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly name: string,
|
||||
public readonly startDate: string,
|
||||
public readonly endDate: string,
|
||||
public readonly goal: string | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateSprintCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly sprintId: string,
|
||||
public readonly changes: SprintDetails,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class StartSprintCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly sprintId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends a sprint; unfinished tickets go to `carryOverTo` (null: backlog). */
|
||||
export class CloseSprintCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly sprintId: string,
|
||||
public readonly carryOverTo: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** A planned sprint (people), or any sprint of a deleted project (`cascade`, saga). */
|
||||
export class DeleteSprintCommand extends Command<CommandResult | void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly sprintId: string,
|
||||
public readonly cascade = false,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts a ticket in a sprint (null: back to the backlog). */
|
||||
export class SetIssueSprintCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly sprintId: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saga only: a sprint closed (or was deleted), so a ticket of it moves to
|
||||
* `toSprintId` (null: backlog). With `onlyUnfinished`, finished ones stay as
|
||||
* the record of what the sprint achieved.
|
||||
*/
|
||||
export class CarryOverIssueCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly fromSprintId: string,
|
||||
public readonly toSprintId: string | null,
|
||||
public readonly onlyUnfinished: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
103
libs/api/tracker/application/src/lib/commands/trash.ts
Normal file
103
libs/api/tracker/application/src/lib/commands/trash.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
Command,
|
||||
CommandBus,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import { SYSTEM_ACTOR_ID } from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { DeleteIssueCommand } from './delete-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/** Days a ticket stays in the trash before it is deleted for good. */
|
||||
export const TRASH_DAYS = 30;
|
||||
|
||||
export class TrashIssueCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class RestoreIssueCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Scheduler only: deletes for good what went to the trash before `before`. */
|
||||
export class EmptyTrashCommand extends Command<number> {
|
||||
constructor(public readonly before: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(TrashIssueCommand)
|
||||
export class TrashIssueCommandHandler
|
||||
implements ICommandHandler<TrashIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: TrashIssueCommand): Promise<CommandResult> {
|
||||
const { issue, project } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
const role = project.roleOf(command.actorId);
|
||||
issue.trash(command.actorId, role === 'OWNER' || role === 'ADMIN');
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(RestoreIssueCommand)
|
||||
export class RestoreIssueCommandHandler
|
||||
implements ICommandHandler<RestoreIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: RestoreIssueCommand): Promise<CommandResult> {
|
||||
const issue = await this.access.issue(command.issueId);
|
||||
const project = await this.access.project(issue.projectId);
|
||||
project.assertCanContribute(command.actorId);
|
||||
const role = project.roleOf(command.actorId);
|
||||
issue.restore(command.actorId, role === 'OWNER' || role === 'ADMIN');
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Each deletion is the usual one: comments purged, links dropped and
|
||||
* subtasks back to the top, by the sagas.
|
||||
*/
|
||||
@CommandHandler(EmptyTrashCommand)
|
||||
export class EmptyTrashCommandHandler
|
||||
implements ICommandHandler<EmptyTrashCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly commandBus: CommandBus,
|
||||
) {}
|
||||
|
||||
async execute(command: EmptyTrashCommand): Promise<number> {
|
||||
const old = await this.findIssues.findTrashedBefore(command.before);
|
||||
for (const issue of old)
|
||||
await this.commandBus.execute(
|
||||
new DeleteIssueCommand(SYSTEM_ACTOR_ID, issue.id),
|
||||
);
|
||||
return old.length;
|
||||
}
|
||||
}
|
||||
|
||||
export const TRASH_HANDLERS = [
|
||||
TrashIssueCommandHandler,
|
||||
RestoreIssueCommandHandler,
|
||||
EmptyTrashCommandHandler,
|
||||
];
|
||||
@@ -0,0 +1,33 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WatchIssueCommand } from './watch-issue.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/**
|
||||
* Following is for members, viewers included: reading a ticket is enough to
|
||||
* want to hear about it. People only follow or unfollow for themselves.
|
||||
*/
|
||||
@CommandHandler(WatchIssueCommand)
|
||||
export class WatchIssueCommandHandler
|
||||
implements ICommandHandler<WatchIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: WatchIssueCommand): Promise<CommandResult> {
|
||||
const issue = await this.access.issue(command.issueId);
|
||||
const project = await this.access.project(issue.projectId);
|
||||
if (command.actorId !== command.userId) {
|
||||
throw new ForbiddenError('You can only follow tickets yourself');
|
||||
}
|
||||
if (!project.isMember(command.userId)) {
|
||||
throw new ForbiddenError(
|
||||
`You are not a member of project ${project.key}`,
|
||||
);
|
||||
}
|
||||
if (command.watching) issue.watch(command.actorId, command.userId);
|
||||
else issue.unwatch(command.actorId, command.userId);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/** `userId` starts (`watching`) or stops following a ticket. */
|
||||
export class WatchIssueCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly userId: string,
|
||||
public readonly watching = true,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Issue,
|
||||
Project,
|
||||
Release,
|
||||
Sprint,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
|
||||
@@ -38,6 +39,22 @@ export class WriteAccess {
|
||||
return this.load(id, Release);
|
||||
}
|
||||
|
||||
sprint(id: string): Promise<Sprint> {
|
||||
return this.load(id, Sprint);
|
||||
}
|
||||
|
||||
/** Sprint of the same project that still takes tickets (not closed). */
|
||||
async openSprintOf(project: Project, sprintId: string): Promise<Sprint> {
|
||||
const sprint = await this.sprint(sprintId);
|
||||
if (sprint.projectId !== project.id) {
|
||||
throw new BusinessRuleError(
|
||||
`Sprint ${sprint.name} belongs to another project`,
|
||||
);
|
||||
}
|
||||
sprint.assertAcceptsIssues();
|
||||
return sprint;
|
||||
}
|
||||
|
||||
comment(id: string): Promise<Comment> {
|
||||
return this.load(id, Comment);
|
||||
}
|
||||
@@ -50,6 +67,10 @@ export class WriteAccess {
|
||||
const issue = await this.issue(issueId);
|
||||
const project = await this.project(issue.projectId);
|
||||
project.assertCanContribute(actorId);
|
||||
if (issue.trashedAt)
|
||||
throw new BusinessRuleError(
|
||||
`${issue.key} is in the trash: restore it to change it`,
|
||||
);
|
||||
return { issue, project };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteReleaseNotesCommand } from './write-release-notes.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(WriteReleaseNotesCommand)
|
||||
export class WriteReleaseNotesCommandHandler
|
||||
implements ICommandHandler<WriteReleaseNotesCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: WriteReleaseNotesCommand): Promise<CommandResult> {
|
||||
const release = await this.access.release(command.releaseId);
|
||||
(await this.access.project(release.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
release.writeNotes(command.actorId, command.notes);
|
||||
await release.commitAndWait();
|
||||
return ack(release);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/** Writes (or with null, removes) a release's notes. */
|
||||
export class WriteReleaseNotesCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly releaseId: string,
|
||||
public readonly notes: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,15 @@ import {
|
||||
CommentDeletedEvent,
|
||||
CommentEditedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueAttachmentAddedEvent,
|
||||
IssueAttachmentRemovedEvent,
|
||||
IssueChecklistItemAddedEvent,
|
||||
IssueChecklistItemRemovedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueLinkedEvent,
|
||||
IssueLinkType,
|
||||
@@ -19,9 +28,16 @@ import {
|
||||
ProjectUpdatedEvent,
|
||||
ProjectWorkflow,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseNotesWrittenEvent,
|
||||
ReleaseCreatedEvent,
|
||||
ReleaseShippedEvent,
|
||||
ReleaseUpdatedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
SprintClosedEvent,
|
||||
SprintCreatedEvent,
|
||||
SprintDeletedEvent,
|
||||
SprintStartedEvent,
|
||||
SprintUpdatedEvent,
|
||||
TrackerEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
@@ -31,6 +47,8 @@ export interface SummaryContext {
|
||||
issueKey: string;
|
||||
projectKey: string;
|
||||
releaseName: string;
|
||||
/** '' when unknown (e.g. the sprint was deleted). */
|
||||
sprintName: string;
|
||||
/** The project's workflow: names of statuses and types. */
|
||||
workflow: ProjectWorkflow;
|
||||
}
|
||||
@@ -70,6 +88,7 @@ const FIELD: Record<string, string> = {
|
||||
labels: 'las etiquetas',
|
||||
figmaUrl: 'el enlace de Figma',
|
||||
storyPoints: 'los puntos',
|
||||
startDate: 'la fecha de inicio',
|
||||
dueDate: 'la fecha límite',
|
||||
};
|
||||
|
||||
@@ -135,6 +154,51 @@ export function describeEvent(
|
||||
: `${actor} sacó ${issueKey} de su release`;
|
||||
}
|
||||
|
||||
if (event instanceof IssueAttachmentAddedEvent)
|
||||
return `${actor} adjuntó ${event.attachment.fileName} a ${issueKey}`;
|
||||
if (event instanceof IssueAttachmentRemovedEvent)
|
||||
return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`;
|
||||
|
||||
if (event instanceof IssueSprintChangedEvent) {
|
||||
const sprint = ctx.sprintName ? `el sprint ${ctx.sprintName}` : 'un sprint';
|
||||
return event.sprintId
|
||||
? `${actor} metió ${issueKey} en ${sprint}`
|
||||
: `${actor} sacó ${issueKey} de ${sprint}`;
|
||||
}
|
||||
if (event instanceof IssueTrashedEvent)
|
||||
return `${actor} movió ${issueKey} a la papelera`;
|
||||
if (event instanceof IssueRestoredEvent)
|
||||
return `${actor} recuperó ${issueKey} de la papelera`;
|
||||
if (event instanceof IssueDevLinkRecordedEvent) {
|
||||
const { kind, ref, state, author } = event.link;
|
||||
const who = author ? ` (${author})` : '';
|
||||
if (kind === 'COMMIT') return `Commit ${ref} sobre ${issueKey}${who}`;
|
||||
if (kind === 'BRANCH') return `Rama ${ref} para ${issueKey}${who}`;
|
||||
const verb =
|
||||
state === 'MERGED'
|
||||
? 'fusionada'
|
||||
: state === 'CLOSED'
|
||||
? 'cerrada'
|
||||
: 'abierta';
|
||||
return `Pull request ${ref} ${verb} para ${issueKey}${who}`;
|
||||
}
|
||||
if (event instanceof IssueParentChangedEvent) {
|
||||
return event.parentKey
|
||||
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
|
||||
: `${actor} sacó ${issueKey} de su ticket padre`;
|
||||
}
|
||||
if (event instanceof IssueChecklistItemAddedEvent)
|
||||
return `${actor} añadió «${event.item.text}» a la lista de ${issueKey}`;
|
||||
if (event instanceof IssueChecklistItemUpdatedEvent) {
|
||||
if (event.changes.done === true)
|
||||
return `${actor} completó un paso de la lista de ${issueKey}`;
|
||||
if (event.changes.done === false)
|
||||
return `${actor} reabrió un paso de la lista de ${issueKey}`;
|
||||
return `${actor} cambió un paso de la lista de ${issueKey}`;
|
||||
}
|
||||
if (event instanceof IssueChecklistItemRemovedEvent)
|
||||
return `${actor} quitó «${event.text}» de la lista de ${issueKey}`;
|
||||
|
||||
if (event instanceof CommentAddedEvent)
|
||||
return `${actor} comentó en ${issueKey}`;
|
||||
if (event instanceof CommentEditedEvent)
|
||||
@@ -148,7 +212,26 @@ export function describeEvent(
|
||||
return `${actor} actualizó la release ${ctx.releaseName}`;
|
||||
if (event instanceof ReleaseShippedEvent)
|
||||
return `${actor} publicó la release ${ctx.releaseName} 🚀`;
|
||||
if (event instanceof ReleaseNotesWrittenEvent)
|
||||
return event.notes
|
||||
? `${actor} escribió las notas de la release ${ctx.releaseName}`
|
||||
: `${actor} quitó las notas de la release ${ctx.releaseName}`;
|
||||
if (event instanceof ReleaseArchivedEvent)
|
||||
return `${actor} archivó la release ${ctx.releaseName}`;
|
||||
const sprint = ctx.sprintName || 'un sprint';
|
||||
if (event instanceof SprintCreatedEvent)
|
||||
return `${actor} planificó el sprint ${event.sprint.name}`;
|
||||
if (event instanceof SprintUpdatedEvent)
|
||||
return `${actor} actualizó el sprint ${sprint}`;
|
||||
if (event instanceof SprintStartedEvent)
|
||||
return `${actor} empezó el sprint ${sprint} 🏁`;
|
||||
if (event instanceof SprintClosedEvent) {
|
||||
const left = event.unfinishedIssues
|
||||
? `; ${event.unfinishedIssues} ticket(s) sin terminar pasan ${event.carryOverTo ? 'al siguiente sprint' : 'al backlog'}`
|
||||
: '';
|
||||
return `${actor} cerró el sprint ${sprint}${left}`;
|
||||
}
|
||||
if (event instanceof SprintDeletedEvent)
|
||||
return `${actor} borró el sprint ${sprint}`;
|
||||
return `${actor}: ${event.constructor.name}`;
|
||||
}
|
||||
|
||||
@@ -5,6 +5,15 @@ import {
|
||||
CommentDeletedEvent,
|
||||
CommentEditedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueAttachmentAddedEvent,
|
||||
IssueAttachmentRemovedEvent,
|
||||
IssueChecklistItemAddedEvent,
|
||||
IssueChecklistItemRemovedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueLinkedEvent,
|
||||
IssueReleaseChangedEvent,
|
||||
@@ -17,6 +26,13 @@ import {
|
||||
ProjectMemberRoleChangedEvent,
|
||||
ProjectUpdatedEvent,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseNotesWrittenEvent,
|
||||
IssueSprintChangedEvent,
|
||||
SprintClosedEvent,
|
||||
SprintCreatedEvent,
|
||||
SprintDeletedEvent,
|
||||
SprintStartedEvent,
|
||||
SprintUpdatedEvent,
|
||||
ReleaseCreatedEvent,
|
||||
ReleaseShippedEvent,
|
||||
ReleaseUpdatedEvent,
|
||||
@@ -31,6 +47,7 @@ import { ChangeFeed } from '../ports/change-feed';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { UpsertMaterializedActivityRepository } from '../ports/upsert-materialized-activity.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { describeEvent } from './activity-summary';
|
||||
@@ -51,6 +68,15 @@ import { versionOf } from './read-model-notifier';
|
||||
IssueLinkedEvent,
|
||||
IssueUnlinkedEvent,
|
||||
IssueReleaseChangedEvent,
|
||||
IssueAttachmentAddedEvent,
|
||||
IssueAttachmentRemovedEvent,
|
||||
IssueParentChangedEvent,
|
||||
IssueDevLinkRecordedEvent,
|
||||
IssueTrashedEvent,
|
||||
IssueRestoredEvent,
|
||||
IssueChecklistItemAddedEvent,
|
||||
IssueChecklistItemUpdatedEvent,
|
||||
IssueChecklistItemRemovedEvent,
|
||||
CommentAddedEvent,
|
||||
CommentEditedEvent,
|
||||
CommentDeletedEvent,
|
||||
@@ -58,6 +84,13 @@ import { versionOf } from './read-model-notifier';
|
||||
ReleaseUpdatedEvent,
|
||||
ReleaseShippedEvent,
|
||||
ReleaseArchivedEvent,
|
||||
ReleaseNotesWrittenEvent,
|
||||
IssueSprintChangedEvent,
|
||||
SprintCreatedEvent,
|
||||
SprintUpdatedEvent,
|
||||
SprintStartedEvent,
|
||||
SprintClosedEvent,
|
||||
SprintDeletedEvent,
|
||||
)
|
||||
export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
constructor(
|
||||
@@ -65,6 +98,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly changeFeed: ChangeFeed,
|
||||
) {}
|
||||
@@ -81,6 +115,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
const release = releaseId
|
||||
? await this.findReleases.findById(releaseId)
|
||||
: null;
|
||||
const sprint = await this.sprintOf(event);
|
||||
const project = await this.findProjects.findById(event.projectId);
|
||||
// The project was deleted (cascade in progress): no feed to write to.
|
||||
if (!project) return;
|
||||
@@ -106,6 +141,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
issueKey: issueKey ?? '',
|
||||
projectKey: project?.key ?? '',
|
||||
releaseName: release?.name ?? '',
|
||||
sprintName: sprint,
|
||||
workflow: new ProjectWorkflow(
|
||||
project?.settings ?? DEFAULT_PROJECT_SETTINGS,
|
||||
),
|
||||
@@ -117,6 +153,17 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||
}
|
||||
}
|
||||
|
||||
/** Name of the sprint the event is about ('' when it is gone). */
|
||||
private async sprintOf(event: TrackerEvent): Promise<string> {
|
||||
if (event instanceof SprintCreatedEvent) return event.sprint.name;
|
||||
const id = event.constructor.name.startsWith('Sprint')
|
||||
? event.aggregateId
|
||||
: event instanceof IssueSprintChangedEvent
|
||||
? (event.sprintId ?? event.previousSprintId)
|
||||
: null;
|
||||
return id ? ((await this.findSprints.findById(id))?.name ?? '') : '';
|
||||
}
|
||||
|
||||
private issueIdOf(event: TrackerEvent): string | null {
|
||||
if (event.constructor.name.startsWith('Issue')) return event.aggregateId;
|
||||
if (event instanceof CommentAddedEvent) return event.comment.issueId;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueDeletedEvent } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
AttachmentStorage,
|
||||
issueAttachmentsPrefix,
|
||||
} from '../ports/attachment-storage';
|
||||
|
||||
/** A deleted ticket takes its files with it. */
|
||||
@EventsHandler(IssueDeletedEvent)
|
||||
export class AttachmentCleanupEventHandler
|
||||
implements IEventHandler<IssueDeletedEvent>
|
||||
{
|
||||
constructor(private readonly storage: AttachmentStorage) {}
|
||||
|
||||
async handle(event: IssueDeletedEvent) {
|
||||
await this.storage.removePrefix(
|
||||
issueAttachmentsPrefix(event.projectId, event.aggregateId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,18 @@ import { ProjectSettingsChangedEventHandler } from './project-settings-changed.e
|
||||
import { ProjectDeletedEventHandler } from './project-deleted.event-handler';
|
||||
import { IssueDeletedEventHandler } from './issue-deleted.event-handler';
|
||||
import { ReleaseDeletedEventHandler } from './release-deleted.event-handler';
|
||||
import { IssueAttachmentAddedEventHandler } from './issue-attachment-added.event-handler';
|
||||
import { IssueAttachmentRemovedEventHandler } from './issue-attachment-removed.event-handler';
|
||||
import { AttachmentCleanupEventHandler } from './attachment-cleanup.event-handler';
|
||||
import { IssueWatchersEventHandler } from './issue-watchers.event-handler';
|
||||
import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler';
|
||||
import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
|
||||
import { IssueRankedEventHandler } from './issue-ranked.event-handler';
|
||||
import { SprintEventHandler } from './sprint.event-handler';
|
||||
import { ReleaseNotesWrittenEventHandler } from './release-notes-written.event-handler';
|
||||
import { IssueDevLinkRecordedEventHandler } from './issue-dev-link-recorded.event-handler';
|
||||
import { IssueTrashEventHandler } from './issue-trash.event-handler';
|
||||
import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler';
|
||||
|
||||
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
||||
export const EVENT_HANDLERS = [
|
||||
@@ -52,6 +64,18 @@ export const EVENT_HANDLERS = [
|
||||
ReleaseCreatedEventHandler,
|
||||
ReleaseShippedEventHandler,
|
||||
ReleaseUpdatedEventHandler,
|
||||
IssueAttachmentAddedEventHandler,
|
||||
IssueAttachmentRemovedEventHandler,
|
||||
AttachmentCleanupEventHandler,
|
||||
IssueWatchersEventHandler,
|
||||
IssueParentChangedEventHandler,
|
||||
IssueChecklistEventHandler,
|
||||
IssueRankedEventHandler,
|
||||
SprintEventHandler,
|
||||
ReleaseNotesWrittenEventHandler,
|
||||
IssueDevLinkRecordedEventHandler,
|
||||
IssueTrashEventHandler,
|
||||
IssueSprintChangedEventHandler,
|
||||
];
|
||||
|
||||
export { ReadModelNotifier };
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueAttachmentAddedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueAttachmentAddedEvent)
|
||||
export class IssueAttachmentAddedEventHandler
|
||||
implements IEventHandler<IssueAttachmentAddedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueAttachmentAddedEvent) {
|
||||
const { attachment } = event;
|
||||
const users = await this.users.refs([attachment.uploadedBy]);
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.upsertMaterializedIssueRepository.addAttachment(
|
||||
event.aggregateId,
|
||||
{ ...attachment, uploadedBy: users.of(attachment.uploadedBy) },
|
||||
);
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueAttachmentRemovedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueAttachmentRemovedEvent)
|
||||
export class IssueAttachmentRemovedEventHandler
|
||||
implements IEventHandler<IssueAttachmentRemovedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueAttachmentRemovedEvent) {
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.upsertMaterializedIssueRepository.removeAttachment(
|
||||
event.aggregateId,
|
||||
event.attachmentId,
|
||||
);
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
IssueCreatedEvent,
|
||||
implicitRank,
|
||||
ProjectWorkflow,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
@@ -48,9 +49,13 @@ export class IssueCreatedEventHandler
|
||||
reporter: users.of(issue.reporterId),
|
||||
assignee: users.ofNullable(issue.assigneeId),
|
||||
release: release ? { id: release.id, name: release.name } : null,
|
||||
parent: null,
|
||||
sprint: null,
|
||||
rank: implicitRank(issue.number),
|
||||
labels: issue.labels,
|
||||
figmaUrl: issue.figmaUrl,
|
||||
storyPoints: issue.storyPoints,
|
||||
startDate: issue.startDate ?? null,
|
||||
dueDate: issue.dueDate,
|
||||
createdAt: issue.createdAt,
|
||||
updatedAt: issue.updatedAt,
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueDevLinkRecordedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueDevLinkRecordedEvent)
|
||||
export class IssueDevLinkRecordedEventHandler
|
||||
implements IEventHandler<IssueDevLinkRecordedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueDevLinkRecordedEvent) {
|
||||
// Code moving is not an edit of the ticket: `updatedAt` stays.
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.upsertMaterializedIssueRepository.putDevLink(
|
||||
event.aggregateId,
|
||||
event.link,
|
||||
);
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueRankedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueRankedEvent)
|
||||
export class IssueRankedEventHandler
|
||||
implements IEventHandler<IssueRankedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueRankedEvent) {
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
rank: event.rank,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueSprintChangedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueSprintChangedEvent)
|
||||
export class IssueSprintChangedEventHandler
|
||||
implements IEventHandler<IssueSprintChangedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueSprintChangedEvent) {
|
||||
const sprint = event.sprintId
|
||||
? await this.findSprints.findById(event.sprintId)
|
||||
: null;
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
sprint: event.sprintId
|
||||
? { id: event.sprintId, name: sprint?.name ?? '' }
|
||||
: null,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
IssueCreatedEvent,
|
||||
IssueReleaseChangedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
ProjectWorkflow,
|
||||
TrackerEvent,
|
||||
@@ -14,12 +15,13 @@ import { versionOf } from './read-model-notifier';
|
||||
|
||||
/**
|
||||
* The life of every ticket, step by step: where it went (status + category)
|
||||
* and which release it joined. Statistics are computed from it.
|
||||
* and which release and sprint it joined. Statistics are computed from it.
|
||||
*/
|
||||
@EventsHandler(
|
||||
IssueCreatedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
IssueReleaseChangedEvent,
|
||||
IssueSprintChangedEvent,
|
||||
)
|
||||
export class IssueTimelineEventHandler implements IEventHandler<TrackerEvent> {
|
||||
constructor(
|
||||
@@ -66,6 +68,14 @@ export class IssueTimelineEventHandler implements IEventHandler<TrackerEvent> {
|
||||
kind: 'release',
|
||||
releaseId: event.releaseId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (event instanceof IssueSprintChangedEvent) {
|
||||
await this.timeline.record({
|
||||
...base,
|
||||
kind: 'sprint',
|
||||
sprintId: event.sprintId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
IssueRestoredEvent,
|
||||
IssueTrashedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueTrashedEvent, IssueRestoredEvent)
|
||||
export class IssueTrashEventHandler
|
||||
implements IEventHandler<IssueTrashedEvent | IssueRestoredEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueTrashedEvent | IssueRestoredEvent) {
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
trashedAt: event instanceof IssueTrashedEvent ? event.occurredAt : null,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,12 @@ export class IssueUpdatedEventHandler
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
// Its subtasks show its title.
|
||||
if (event.changes.title !== undefined)
|
||||
await this.upsertMaterializedIssueRepository.renameParent(
|
||||
event.aggregateId,
|
||||
event.changes.title,
|
||||
);
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
IssueUnwatchedEvent,
|
||||
IssueWatchedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueWatchedEvent, IssueUnwatchedEvent)
|
||||
export class IssueWatchersEventHandler
|
||||
implements IEventHandler<IssueWatchedEvent | IssueUnwatchedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly users: UserDirectory,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueWatchedEvent | IssueUnwatchedEvent) {
|
||||
// Following is not a change to the ticket: `updatedAt` stays.
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
version: versionOf(event),
|
||||
});
|
||||
if (event instanceof IssueWatchedEvent) {
|
||||
const users = await this.users.refs([event.userId]);
|
||||
await this.upsertMaterializedIssueRepository.addWatcher(
|
||||
event.aggregateId,
|
||||
users.of(event.userId),
|
||||
);
|
||||
} else {
|
||||
await this.upsertMaterializedIssueRepository.removeWatcher(
|
||||
event.aggregateId,
|
||||
event.userId,
|
||||
);
|
||||
}
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { FindCommentsRepository } from '../ports/find-comments.repository';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
|
||||
/** Position of the event in its stream (stamped by the event store). */
|
||||
export function versionOf(event: TrackerEvent): number {
|
||||
@@ -23,6 +24,7 @@ export class ReadModelNotifier {
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findComments: FindCommentsRepository,
|
||||
private readonly findReleases: FindReleasesRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
) {}
|
||||
|
||||
async project(
|
||||
@@ -63,6 +65,16 @@ export class ReadModelNotifier {
|
||||
await this.changeFeed.publishRelease({ ...this.meta(event), release });
|
||||
}
|
||||
|
||||
async sprint(sprintId: string, event: TrackerEvent): Promise<void> {
|
||||
const sprint = await this.findSprints.findById(sprintId);
|
||||
await this.changeFeed.publishSprint({
|
||||
...this.meta(event),
|
||||
sprintId,
|
||||
projectId: event.projectId,
|
||||
sprint,
|
||||
});
|
||||
}
|
||||
|
||||
private meta(event: TrackerEvent) {
|
||||
return {
|
||||
action: event.constructor.name,
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { ReleaseNotesWrittenEvent } from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(ReleaseNotesWrittenEvent)
|
||||
export class ReleaseNotesWrittenEventHandler
|
||||
implements IEventHandler<ReleaseNotesWrittenEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: ReleaseNotesWrittenEvent) {
|
||||
await this.upsertMaterializedReleaseRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
notes: event.notes,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.release(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
SprintClosedEvent,
|
||||
SprintCreatedEvent,
|
||||
SprintDeletedEvent,
|
||||
SprintStartedEvent,
|
||||
SprintStatus,
|
||||
SprintUpdatedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { UpsertMaterializedSprintRepository } from '../ports/upsert-materialized-sprint.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
type SprintEvent =
|
||||
| SprintCreatedEvent
|
||||
| SprintUpdatedEvent
|
||||
| SprintStartedEvent
|
||||
| SprintClosedEvent
|
||||
| SprintDeletedEvent;
|
||||
|
||||
/** The sprints view (`sprints_view`), one document per sprint. */
|
||||
@EventsHandler(
|
||||
SprintCreatedEvent,
|
||||
SprintUpdatedEvent,
|
||||
SprintStartedEvent,
|
||||
SprintClosedEvent,
|
||||
SprintDeletedEvent,
|
||||
)
|
||||
export class SprintEventHandler implements IEventHandler<SprintEvent> {
|
||||
constructor(
|
||||
private readonly sprints: UpsertMaterializedSprintRepository,
|
||||
private readonly issues: UpsertMaterializedIssueRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: SprintEvent) {
|
||||
const version = versionOf(event);
|
||||
const id = event.aggregateId;
|
||||
if (event instanceof SprintCreatedEvent) {
|
||||
await this.sprints.upsert({ ...event.sprint, version });
|
||||
} else if (event instanceof SprintUpdatedEvent) {
|
||||
await this.sprints.upsert({ id, ...event.changes, version });
|
||||
// Tickets show its name.
|
||||
if (event.changes.name)
|
||||
await this.issues.renameSprint(id, event.changes.name);
|
||||
} else if (event instanceof SprintStartedEvent) {
|
||||
await this.sprints.upsert({
|
||||
id,
|
||||
status: SprintStatus.ACTIVE,
|
||||
startedAt: event.occurredAt,
|
||||
version,
|
||||
});
|
||||
} else if (event instanceof SprintClosedEvent) {
|
||||
await this.sprints.upsert({
|
||||
id,
|
||||
status: SprintStatus.CLOSED,
|
||||
closedAt: event.occurredAt,
|
||||
version,
|
||||
});
|
||||
} else {
|
||||
await this.sprints.remove(id);
|
||||
}
|
||||
await this.notifier.sprint(id, event);
|
||||
}
|
||||
}
|
||||
92
libs/api/tracker/application/src/lib/git.service.ts
Normal file
92
libs/api/tracker/application/src/lib/git.service.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { ForbiddenError } from '@task-stream/api/shared/domain';
|
||||
import {
|
||||
DisableGitIntegrationCommand,
|
||||
EnableGitIntegrationCommand,
|
||||
GetGitIntegrationQuery,
|
||||
GitIntegration,
|
||||
RecordGitMentionsCommand,
|
||||
} from './git/git';
|
||||
import {
|
||||
gitMentionsOf,
|
||||
gitProviderOf,
|
||||
verifyGitSignature,
|
||||
} from './git/git-payloads';
|
||||
import { FindProjectsRepository } from './ports/find-projects.repository';
|
||||
import { GitIntegrationsRepository } from './ports/git-integrations.repository';
|
||||
|
||||
export interface GitDelivery {
|
||||
/** Project id or key, from the webhook URL. */
|
||||
project: string;
|
||||
headers: Record<string, string | undefined>;
|
||||
rawBody: Buffer | undefined;
|
||||
body: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/** Links commits, branches and pull requests to the tickets they name. */
|
||||
@Injectable()
|
||||
export class GitService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly integrations: GitIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
integration(viewerId: string, projectId: string): Promise<GitIntegration> {
|
||||
return this.queryBus.execute(
|
||||
new GetGitIntegrationQuery(viewerId, projectId),
|
||||
);
|
||||
}
|
||||
|
||||
enable(actorId: string, projectId: string): Promise<GitIntegration> {
|
||||
return this.commandBus.execute(
|
||||
new EnableGitIntegrationCommand(actorId, projectId),
|
||||
);
|
||||
}
|
||||
|
||||
disable(actorId: string, projectId: string): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
new DisableGitIntegrationCommand(actorId, projectId),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* A webhook delivery. Unknown project, integration off or a bad signature
|
||||
* all look the same from outside (403): nothing to learn by probing.
|
||||
*/
|
||||
async receive(delivery: GitDelivery): Promise<{ recorded: number }> {
|
||||
const project =
|
||||
(await this.findProjects.findById(delivery.project)) ??
|
||||
(await this.findProjects.findByKey(delivery.project.toUpperCase()));
|
||||
const secret = project
|
||||
? await this.integrations.secretOf(project.id)
|
||||
: null;
|
||||
const source = gitProviderOf(delivery.headers);
|
||||
if (
|
||||
!project ||
|
||||
!secret ||
|
||||
!source ||
|
||||
!verifyGitSignature(
|
||||
source.provider,
|
||||
delivery.headers,
|
||||
delivery.rawBody,
|
||||
secret,
|
||||
)
|
||||
)
|
||||
throw new ForbiddenError(
|
||||
'Unknown project, integration off or bad signature',
|
||||
);
|
||||
const mentions = gitMentionsOf(
|
||||
source.provider,
|
||||
source.event,
|
||||
delivery.body,
|
||||
);
|
||||
if (!mentions.length) return { recorded: 0 };
|
||||
const recorded: number = await this.commandBus.execute(
|
||||
new RecordGitMentionsCommand(project.id, mentions),
|
||||
);
|
||||
return { recorded };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { createHmac } from 'node:crypto';
|
||||
import { DevLinkKind, DevLinkState } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
gitMentionsOf,
|
||||
gitProviderOf,
|
||||
verifyGitSignature,
|
||||
} from './git-payloads';
|
||||
|
||||
describe('git payloads', () => {
|
||||
const body = Buffer.from('{"a":1}');
|
||||
const hmac = createHmac('sha256', 's3cret').update(body).digest('hex');
|
||||
|
||||
it('tells the host apart and checks its signature', () => {
|
||||
expect(gitProviderOf({ 'x-gitea-event': 'push' })).toEqual({
|
||||
provider: 'gitea',
|
||||
event: 'push',
|
||||
});
|
||||
expect(gitProviderOf({})).toBeNull();
|
||||
expect(
|
||||
verifyGitSignature(
|
||||
'github',
|
||||
{ 'x-hub-signature-256': `sha256=${hmac}` },
|
||||
body,
|
||||
's3cret',
|
||||
),
|
||||
).toBe(true);
|
||||
expect(
|
||||
verifyGitSignature('gitea', { 'x-gitea-signature': hmac }, body, 'other'),
|
||||
).toBe(false);
|
||||
expect(
|
||||
verifyGitSignature(
|
||||
'gitlab',
|
||||
{ 'x-gitlab-token': 's3cret' },
|
||||
body,
|
||||
's3cret',
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('reads the commits and the branch of a push', () => {
|
||||
const mentions = gitMentionsOf('gitea', 'push', {
|
||||
ref: 'refs/heads/web-7-checkout',
|
||||
repository: { html_url: 'https://git.example.org/acme/web/' },
|
||||
pusher: { login: 'ana' },
|
||||
commits: [
|
||||
{
|
||||
id: 'abcdef1234567',
|
||||
message: 'WEB-7: card form\n\nlong text',
|
||||
url: 'https://git.example.org/acme/web/commit/abcdef1234567',
|
||||
author: { name: 'Ana' },
|
||||
timestamp: '2026-10-08T10:00:00Z',
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(mentions.map((m) => [m.link.kind, m.link.ref, m.text])).toEqual([
|
||||
[DevLinkKind.COMMIT, 'abcdef12', 'WEB-7: card form\n\nlong text'],
|
||||
[DevLinkKind.BRANCH, 'web-7-checkout', 'web-7-checkout'],
|
||||
]);
|
||||
expect(mentions[1].link.url).toBe(
|
||||
'https://git.example.org/acme/web/src/branch/web-7-checkout',
|
||||
);
|
||||
});
|
||||
|
||||
it('reads pull and merge requests with their state', () => {
|
||||
const [pr] = gitMentionsOf('github', 'pull_request', {
|
||||
pull_request: {
|
||||
html_url: 'https://github.com/acme/web/pull/12',
|
||||
number: 12,
|
||||
title: 'Pay by card',
|
||||
head: { ref: 'web-7' },
|
||||
state: 'closed',
|
||||
merged: true,
|
||||
},
|
||||
});
|
||||
expect(pr.link).toMatchObject({ ref: '#12', state: DevLinkState.MERGED });
|
||||
expect(pr.text).toContain('web-7');
|
||||
const [mr] = gitMentionsOf('gitlab', 'Merge Request Hook', {
|
||||
object_attributes: {
|
||||
url: 'https://gitlab.com/a/b/-/merge_requests/3',
|
||||
iid: 3,
|
||||
title: 'WEB-9',
|
||||
state: 'opened',
|
||||
},
|
||||
});
|
||||
expect(mr.link).toMatchObject({ ref: '!3', state: DevLinkState.OPEN });
|
||||
expect(gitMentionsOf('github', 'ping', {})).toEqual([]);
|
||||
});
|
||||
});
|
||||
178
libs/api/tracker/application/src/lib/git/git-payloads.ts
Normal file
178
libs/api/tracker/application/src/lib/git/git-payloads.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
import { createHmac, timingSafeEqual } from 'node:crypto';
|
||||
import {
|
||||
DevLink,
|
||||
DevLinkKind,
|
||||
DevLinkState,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
export type GitProvider = 'github' | 'gitea' | 'gitlab';
|
||||
|
||||
/** Code the host told us about, and the text where ticket keys may appear. */
|
||||
export interface GitMention {
|
||||
text: string;
|
||||
link: DevLink;
|
||||
}
|
||||
|
||||
type Json = Record<string, any>; // eslint-disable-line @typescript-eslint/no-explicit-any
|
||||
|
||||
/** Which host sent it, from its event header. */
|
||||
export function gitProviderOf(
|
||||
headers: Record<string, string | undefined>,
|
||||
): { provider: GitProvider; event: string } | null {
|
||||
if (headers['x-gitea-event'])
|
||||
return { provider: 'gitea', event: headers['x-gitea-event'] };
|
||||
if (headers['x-github-event'])
|
||||
return { provider: 'github', event: headers['x-github-event'] };
|
||||
if (headers['x-gitlab-event'])
|
||||
return { provider: 'gitlab', event: headers['x-gitlab-event'] };
|
||||
return null;
|
||||
}
|
||||
|
||||
const same = (a: string, b: string) =>
|
||||
a.length === b.length && timingSafeEqual(Buffer.from(a), Buffer.from(b));
|
||||
|
||||
/**
|
||||
* GitHub and Gitea sign the raw body (HMAC-SHA256, hex); GitLab sends the
|
||||
* secret itself as a token.
|
||||
*/
|
||||
export function verifyGitSignature(
|
||||
provider: GitProvider,
|
||||
headers: Record<string, string | undefined>,
|
||||
rawBody: Buffer | undefined,
|
||||
secret: string,
|
||||
): boolean {
|
||||
if (provider === 'gitlab')
|
||||
return (
|
||||
!!headers['x-gitlab-token'] && same(headers['x-gitlab-token'], secret)
|
||||
);
|
||||
const received = (
|
||||
provider === 'github'
|
||||
? headers['x-hub-signature-256']
|
||||
: headers['x-gitea-signature']
|
||||
)?.replace(/^sha256=/, '');
|
||||
if (!received || !rawBody) return false;
|
||||
return same(
|
||||
received,
|
||||
createHmac('sha256', secret).update(rawBody).digest('hex'),
|
||||
);
|
||||
}
|
||||
|
||||
const branchOf = (ref: unknown) =>
|
||||
typeof ref === 'string' && ref.startsWith('refs/heads/')
|
||||
? ref.slice('refs/heads/'.length)
|
||||
: null;
|
||||
|
||||
/** Commits of a push, and the branch they went to (all three hosts). */
|
||||
function fromPush(
|
||||
provider: GitProvider,
|
||||
body: Json,
|
||||
repoUrl: string,
|
||||
): GitMention[] {
|
||||
const mentions: GitMention[] = [];
|
||||
const branch = branchOf(body['ref']);
|
||||
for (const commit of (body['commits'] ?? []) as Json[]) {
|
||||
if (!commit?.['url'] || !commit['id']) continue;
|
||||
const message = String(commit['message'] ?? '');
|
||||
mentions.push({
|
||||
text: message,
|
||||
link: {
|
||||
kind: DevLinkKind.COMMIT,
|
||||
url: String(commit['url']),
|
||||
title: message,
|
||||
ref: String(commit['id']).slice(0, 8),
|
||||
state: null,
|
||||
provider,
|
||||
author: commit['author']?.['name'] ?? null,
|
||||
at: String(commit['timestamp'] ?? new Date().toISOString()),
|
||||
},
|
||||
});
|
||||
}
|
||||
if (branch && repoUrl) {
|
||||
const path =
|
||||
provider === 'gitlab'
|
||||
? '/-/tree/'
|
||||
: provider === 'gitea'
|
||||
? '/src/branch/'
|
||||
: '/tree/';
|
||||
mentions.push({
|
||||
text: branch,
|
||||
link: {
|
||||
kind: DevLinkKind.BRANCH,
|
||||
url: `${repoUrl}${path}${branch}`,
|
||||
title: branch,
|
||||
ref: branch,
|
||||
state: null,
|
||||
provider,
|
||||
author:
|
||||
body['pusher']?.['name'] ??
|
||||
body['pusher']?.['login'] ??
|
||||
body['user_name'] ??
|
||||
null,
|
||||
at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
return mentions;
|
||||
}
|
||||
|
||||
function prState(merged: boolean, state: string): DevLinkState {
|
||||
if (merged || state === 'merged') return DevLinkState.MERGED;
|
||||
return state === 'closed' ? DevLinkState.CLOSED : DevLinkState.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* What a webhook delivery is about: commits and branches of a push, or a
|
||||
* pull/merge request. Anything else (pings, tags, issues…) is nothing.
|
||||
*/
|
||||
export function gitMentionsOf(
|
||||
provider: GitProvider,
|
||||
event: string,
|
||||
body: Json,
|
||||
): GitMention[] {
|
||||
const repoUrl = String(
|
||||
body['repository']?.['html_url'] ?? body['project']?.['web_url'] ?? '',
|
||||
).replace(/\/$/, '');
|
||||
if (event === 'push' || event === 'Push Hook')
|
||||
return fromPush(provider, body, repoUrl);
|
||||
if (provider !== 'gitlab' && event === 'pull_request') {
|
||||
const pr = body['pull_request'] as Json | undefined;
|
||||
if (!pr?.['html_url']) return [];
|
||||
const title = String(pr['title'] ?? '');
|
||||
return [
|
||||
{
|
||||
text: `${title} ${pr['head']?.['ref'] ?? ''} ${pr['body'] ?? ''}`,
|
||||
link: {
|
||||
kind: DevLinkKind.PULL_REQUEST,
|
||||
url: String(pr['html_url']),
|
||||
title,
|
||||
ref: `#${pr['number'] ?? ''}`,
|
||||
state: prState(!!pr['merged'], String(pr['state'] ?? 'open')),
|
||||
provider,
|
||||
author: pr['user']?.['login'] ?? null,
|
||||
at: String(pr['updated_at'] ?? new Date().toISOString()),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
if (provider === 'gitlab' && event === 'Merge Request Hook') {
|
||||
const mr = body['object_attributes'] as Json | undefined;
|
||||
if (!mr?.['url']) return [];
|
||||
const title = String(mr['title'] ?? '');
|
||||
return [
|
||||
{
|
||||
text: `${title} ${mr['source_branch'] ?? ''} ${mr['description'] ?? ''}`,
|
||||
link: {
|
||||
kind: DevLinkKind.PULL_REQUEST,
|
||||
url: String(mr['url']),
|
||||
title,
|
||||
ref: `!${mr['iid'] ?? ''}`,
|
||||
state: prState(false, String(mr['state'] ?? 'opened')),
|
||||
provider,
|
||||
author: body['user']?.['username'] ?? null,
|
||||
at: String(mr['updated_at'] ?? new Date().toISOString()),
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
157
libs/api/tracker/application/src/lib/git/git.ts
Normal file
157
libs/api/tracker/application/src/lib/git/git.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import {
|
||||
Command,
|
||||
CommandHandler,
|
||||
ICommandHandler,
|
||||
IQueryHandler,
|
||||
Query,
|
||||
QueryHandler,
|
||||
} from '@nestjs/cqrs';
|
||||
import {
|
||||
Issue,
|
||||
ProjectRole,
|
||||
SYSTEM_ACTOR_ID,
|
||||
issueKeysIn,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { WriteAccess } from '../commands/write-access';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { GitIntegrationsRepository } from '../ports/git-integrations.repository';
|
||||
import { ReadAccess } from '../queries/read-access';
|
||||
import { GitMention } from './git-payloads';
|
||||
|
||||
export interface GitIntegration {
|
||||
enabled: boolean;
|
||||
/** Only for whoever manages the project. */
|
||||
secret: string | null;
|
||||
}
|
||||
|
||||
/** Turns the integration on with a new secret (or rotates it). */
|
||||
export class EnableGitIntegrationCommand extends Command<GitIntegration> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class DisableGitIntegrationCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Webhook only: code from a verified delivery, linked to the tickets it names. */
|
||||
export class RecordGitMentionsCommand extends Command<number> {
|
||||
constructor(
|
||||
public readonly projectId: string,
|
||||
public readonly mentions: GitMention[],
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetGitIntegrationQuery extends Query<GitIntegration> {
|
||||
constructor(
|
||||
public readonly viewerId: string,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(EnableGitIntegrationCommand)
|
||||
export class EnableGitIntegrationCommandHandler
|
||||
implements ICommandHandler<EnableGitIntegrationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly integrations: GitIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: EnableGitIntegrationCommand): Promise<GitIntegration> {
|
||||
(await this.access.project(command.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
const secret = randomBytes(24).toString('hex');
|
||||
await this.integrations.save(command.projectId, secret);
|
||||
return { enabled: true, secret };
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(DisableGitIntegrationCommand)
|
||||
export class DisableGitIntegrationCommandHandler
|
||||
implements ICommandHandler<DisableGitIntegrationCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly integrations: GitIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: DisableGitIntegrationCommand): Promise<void> {
|
||||
(await this.access.project(command.projectId)).assertCanManage(
|
||||
command.actorId,
|
||||
);
|
||||
await this.integrations.remove(command.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(RecordGitMentionsCommand)
|
||||
export class RecordGitMentionsCommandHandler
|
||||
implements ICommandHandler<RecordGitMentionsCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
) {}
|
||||
|
||||
/** How many tickets got something new. Unknown keys are ignored. */
|
||||
async execute(command: RecordGitMentionsCommand): Promise<number> {
|
||||
const project = await this.access.project(command.projectId);
|
||||
let recorded = 0;
|
||||
for (const mention of command.mentions) {
|
||||
for (const key of issueKeysIn(mention.text, project.key)) {
|
||||
const found = await this.findIssues.findByKey(key);
|
||||
if (!found || found.projectId !== project.id) continue;
|
||||
const issue = await this.access.live(found.id, Issue);
|
||||
if (!issue) continue;
|
||||
const before = issue.getUncommittedEvents().length;
|
||||
issue.recordDevLink(SYSTEM_ACTOR_ID, mention.link);
|
||||
if (issue.getUncommittedEvents().length === before) continue;
|
||||
await issue.commitAndWait();
|
||||
recorded++;
|
||||
}
|
||||
}
|
||||
return recorded;
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetGitIntegrationQuery)
|
||||
export class GetGitIntegrationQueryHandler
|
||||
implements IQueryHandler<GetGitIntegrationQuery, GitIntegration>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: ReadAccess,
|
||||
private readonly integrations: GitIntegrationsRepository,
|
||||
) {}
|
||||
|
||||
async execute(query: GetGitIntegrationQuery): Promise<GitIntegration> {
|
||||
const role = await this.access.assertCanView(
|
||||
query.projectId,
|
||||
query.viewerId,
|
||||
);
|
||||
const secret = await this.integrations.secretOf(query.projectId);
|
||||
const manages = role === ProjectRole.OWNER || role === ProjectRole.ADMIN;
|
||||
return { enabled: !!secret, secret: manages ? secret : null };
|
||||
}
|
||||
}
|
||||
|
||||
export const GIT_HANDLERS = [
|
||||
EnableGitIntegrationCommandHandler,
|
||||
DisableGitIntegrationCommandHandler,
|
||||
RecordGitMentionsCommandHandler,
|
||||
GetGitIntegrationQueryHandler,
|
||||
];
|
||||
@@ -2,11 +2,32 @@ import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
ActivityReadModel,
|
||||
EpicReadModel,
|
||||
IssueDetails,
|
||||
IssueLinkType,
|
||||
IssueReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { AssignIssueCommand } from './commands/assign-issue.command';
|
||||
import {
|
||||
AttachFileCommand,
|
||||
IncomingFile,
|
||||
} from './commands/attach-file.command';
|
||||
import { DetachFileCommand } from './commands/detach-file.command';
|
||||
import { WatchIssueCommand } from './commands/watch-issue.command';
|
||||
import { SetIssueParentCommand } from './commands/set-issue-parent.command';
|
||||
import { RankIssueCommand } from './commands/rank-issue.command';
|
||||
import { RestoreIssueCommand, TrashIssueCommand } from './commands/trash';
|
||||
import { GetTrashQuery } from './queries/get-trash.query';
|
||||
import {
|
||||
BulkChange,
|
||||
BulkResult,
|
||||
BulkUpdateIssuesCommand,
|
||||
} from './commands/bulk-update-issues';
|
||||
import {
|
||||
AddChecklistItemCommand,
|
||||
RemoveChecklistItemCommand,
|
||||
UpdateChecklistItemCommand,
|
||||
} from './commands/change-checklist.command';
|
||||
import { ChangeIssueStatusCommand } from './commands/change-issue-status.command';
|
||||
import { CommandResult } from './commands/command-result';
|
||||
import { CreateIssueCommand } from './commands/create-issue.command';
|
||||
@@ -16,9 +37,14 @@ import { UnlinkIssuesCommand } from './commands/unlink-issues.command';
|
||||
import { UpdateIssueCommand } from './commands/update-issue.command';
|
||||
import { IssueFilter, Page } from './ports/find-issues.repository';
|
||||
import { GetActivityQuery } from './queries/get-activity.query';
|
||||
import {
|
||||
AttachmentDownload,
|
||||
GetAttachmentQuery,
|
||||
} from './queries/get-attachment.query';
|
||||
import { GetIssueQuery } from './queries/get-issue.query';
|
||||
import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query';
|
||||
import { SearchIssuesQuery } from './queries/search-issues.query';
|
||||
import { GetEpicsQuery } from './queries/get-epics.query';
|
||||
import {
|
||||
MyIssuesFilter,
|
||||
SearchMyIssuesQuery,
|
||||
@@ -31,6 +57,8 @@ export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
|
||||
title: string;
|
||||
assigneeId?: string | null;
|
||||
releaseId?: string | null;
|
||||
/** Epic or ticket the new one breaks down. */
|
||||
parentId?: string | null;
|
||||
}
|
||||
|
||||
export interface IssueLinkInput {
|
||||
@@ -47,7 +75,15 @@ export class IssuesService {
|
||||
) {}
|
||||
|
||||
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(
|
||||
new CreateIssueCommand(
|
||||
actorId,
|
||||
@@ -57,6 +93,7 @@ export class IssuesService {
|
||||
details,
|
||||
assigneeId,
|
||||
releaseId,
|
||||
parentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -126,6 +163,50 @@ export class IssuesService {
|
||||
);
|
||||
}
|
||||
|
||||
/** Attaches a file to the ticket, or to one of its comments. */
|
||||
attach(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
file: IncomingFile,
|
||||
commentId: string | null = null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new AttachFileCommand(actorId, issueId, file, commentId),
|
||||
);
|
||||
}
|
||||
|
||||
detach(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
attachmentId: string,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new DetachFileCommand(actorId, issueId, attachmentId),
|
||||
);
|
||||
}
|
||||
|
||||
/** The actor follows (or stops following) the ticket. */
|
||||
watch(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
watching: boolean,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new WatchIssueCommand(actorId, issueId, actorId, watching),
|
||||
);
|
||||
}
|
||||
|
||||
/** A ticket's file, with its bytes, for whoever can read the ticket. */
|
||||
download(
|
||||
viewerId: string | null,
|
||||
issueId: string,
|
||||
attachmentId: string,
|
||||
): Promise<AttachmentDownload> {
|
||||
return this.queryBus.execute(
|
||||
new GetAttachmentQuery(viewerId, issueId, attachmentId),
|
||||
);
|
||||
}
|
||||
|
||||
findOne(viewerId: string | null, idOrKey: string): Promise<IssueReadModel> {
|
||||
return this.queryBus.execute(new GetIssueQuery(viewerId, idOrKey));
|
||||
}
|
||||
@@ -134,6 +215,91 @@ export class IssuesService {
|
||||
return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids));
|
||||
}
|
||||
|
||||
/** Places the ticket under `parentId` (null: back to the top). */
|
||||
setParent(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
parentId: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new SetIssueParentCommand(actorId, issueId, parentId),
|
||||
);
|
||||
}
|
||||
|
||||
/** Moves a ticket in the backlog between `above` and `below` (null: an end). */
|
||||
rank(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
above: string | null,
|
||||
below: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new RankIssueCommand(actorId, issueId, above, below),
|
||||
);
|
||||
}
|
||||
|
||||
/** The same change on several tickets; each one may succeed or not. */
|
||||
bulkUpdate(
|
||||
actorId: string,
|
||||
issueIds: string[],
|
||||
change: BulkChange,
|
||||
): Promise<BulkResult> {
|
||||
return this.commandBus.execute(
|
||||
new BulkUpdateIssuesCommand(actorId, issueIds, change),
|
||||
);
|
||||
}
|
||||
|
||||
/** To the trash (whoever opened it, or a manager); back with `restore`. */
|
||||
trash(actorId: string, issueId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(new TrashIssueCommand(actorId, issueId));
|
||||
}
|
||||
|
||||
restore(actorId: string, issueId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(new RestoreIssueCommand(actorId, issueId));
|
||||
}
|
||||
|
||||
trashed(
|
||||
viewerId: string | null,
|
||||
projectId: string,
|
||||
): Promise<IssueReadModel[]> {
|
||||
return this.queryBus.execute(new GetTrashQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
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(
|
||||
viewerId: string | null,
|
||||
filter: IssueFilter,
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import { NotificationReadModel } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
MarkNotificationsReadCommand,
|
||||
UpdateNotificationSettingsCommand,
|
||||
} from './notifications/notifications.commands';
|
||||
import {
|
||||
GetMyNotificationSettingsQuery,
|
||||
GetMyNotificationsQuery,
|
||||
MyNotificationSettings,
|
||||
MyNotifications,
|
||||
} from './notifications/notifications.queries';
|
||||
import { ChangeFeed } from './ports/change-feed';
|
||||
|
||||
/** The signed-in person's notifications: always their own, nobody else's. */
|
||||
@Injectable()
|
||||
export class NotificationsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
private readonly changeFeed: ChangeFeed,
|
||||
) {}
|
||||
|
||||
mine(
|
||||
userId: string,
|
||||
options: { unreadOnly?: boolean; limit?: number } = {},
|
||||
): Promise<MyNotifications> {
|
||||
return this.queryBus.execute(
|
||||
new GetMyNotificationsQuery(
|
||||
userId,
|
||||
options.unreadOnly ?? false,
|
||||
options.limit ?? 30,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/** `ids` omitted: everything. */
|
||||
markRead(userId: string, ids?: string[] | null): Promise<void> {
|
||||
return this.commandBus.execute(
|
||||
new MarkNotificationsReadCommand(userId, ids?.length ? ids : null),
|
||||
);
|
||||
}
|
||||
|
||||
settings(userId: string): Promise<MyNotificationSettings> {
|
||||
return this.queryBus.execute(new GetMyNotificationSettingsQuery(userId));
|
||||
}
|
||||
|
||||
async updateSettings(
|
||||
userId: string,
|
||||
changes: { email: boolean },
|
||||
): Promise<MyNotificationSettings> {
|
||||
await this.commandBus.execute(
|
||||
new UpdateNotificationSettingsCommand(userId, changes.email),
|
||||
);
|
||||
return this.settings(userId);
|
||||
}
|
||||
|
||||
/** New notifications for this person, as they happen. */
|
||||
watch(userId: string): AsyncIterableIterator<NotificationReadModel> {
|
||||
return this.changeFeed.notifications(userId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
export * from './notifier';
|
||||
export * from './notifications.queries';
|
||||
export * from './notifications.commands';
|
||||
export * from './reminders';
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { NotificationsRepository } from '../ports/notifications.repository';
|
||||
|
||||
/*
|
||||
* Per-person state, outside the event store: whether a notification was read
|
||||
* and whether to copy them by e-mail concern nobody else and need no history.
|
||||
*/
|
||||
|
||||
export class MarkNotificationsReadCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
/** Null: all of them. */
|
||||
public readonly ids: string[] | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateNotificationSettingsCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly email: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(MarkNotificationsReadCommand)
|
||||
export class MarkNotificationsReadCommandHandler
|
||||
implements ICommandHandler<MarkNotificationsReadCommand>
|
||||
{
|
||||
constructor(private readonly notifications: NotificationsRepository) {}
|
||||
|
||||
async execute(command: MarkNotificationsReadCommand): Promise<void> {
|
||||
await this.notifications.markRead(command.userId, command.ids);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(UpdateNotificationSettingsCommand)
|
||||
export class UpdateNotificationSettingsCommandHandler
|
||||
implements ICommandHandler<UpdateNotificationSettingsCommand>
|
||||
{
|
||||
constructor(private readonly notifications: NotificationsRepository) {}
|
||||
|
||||
async execute(command: UpdateNotificationSettingsCommand): Promise<void> {
|
||||
await this.notifications.saveSettings({
|
||||
userId: command.userId,
|
||||
email: command.email,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
CommentAddedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueCreatedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
mentionedUserIds,
|
||||
NotificationKind,
|
||||
ProjectMemberAddedEvent,
|
||||
StatusCategory,
|
||||
TrackerEvent,
|
||||
withoutMentionMarkup,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { NotificationDraft, Notifier } from './notifier';
|
||||
|
||||
/* Read by people, like the activity feed: Spanish. */
|
||||
|
||||
const excerpt = (text: string, max = 120) => {
|
||||
const flat = withoutMentionMarkup(text).replace(/\s+/g, ' ').trim();
|
||||
return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
|
||||
};
|
||||
|
||||
/**
|
||||
* Who is told what, for the events that concern someone in particular:
|
||||
* whoever is mentioned, whoever a ticket is assigned to, whoever reported or
|
||||
* works on it when it moves or gets a comment, whoever follows it, whoever
|
||||
* joins a project. The order is the order of importance: each person gets
|
||||
* one notification per event, for the first reason that applies.
|
||||
*/
|
||||
@EventsHandler(
|
||||
IssueCreatedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
CommentAddedEvent,
|
||||
ProjectMemberAddedEvent,
|
||||
)
|
||||
export class NotificationsEventHandler implements IEventHandler<TrackerEvent> {
|
||||
constructor(
|
||||
private readonly notifier: Notifier,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findProjects: FindProjectsRepository,
|
||||
private readonly users: UserDirectory,
|
||||
) {}
|
||||
|
||||
async handle(event: TrackerEvent): Promise<void> {
|
||||
const project = await this.findProjects.findById(event.projectId);
|
||||
if (!project) return;
|
||||
const actor = (await this.users.refs([event.actorId])).of(
|
||||
event.actorId,
|
||||
).name;
|
||||
|
||||
if (event instanceof ProjectMemberAddedEvent) {
|
||||
return this.notifier.deliver(
|
||||
event,
|
||||
{
|
||||
projectId: project.id,
|
||||
projectKey: project.key,
|
||||
issueId: null,
|
||||
issueKey: null,
|
||||
},
|
||||
[
|
||||
{
|
||||
userId: event.userId,
|
||||
kind: NotificationKind.ADDED_TO_PROJECT,
|
||||
summary: `${actor} te añadió al proyecto ${project.name} (${project.key})`,
|
||||
},
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
const issueId =
|
||||
event instanceof CommentAddedEvent
|
||||
? event.comment.issueId
|
||||
: event.aggregateId;
|
||||
const issue =
|
||||
event instanceof IssueCreatedEvent
|
||||
? event.issue
|
||||
: await this.findIssues.findById(issueId);
|
||||
if (!issue) return;
|
||||
const reporterId =
|
||||
'reporterId' in issue ? issue.reporterId : issue.reporter.id;
|
||||
const assigneeId =
|
||||
'assigneeId' in issue ? issue.assigneeId : (issue.assignee?.id ?? null);
|
||||
const ticket = `${issue.key}: ${issue.title}`;
|
||||
const watcherIds =
|
||||
'watchers' in issue ? issue.watchers.map((w) => w.id) : [];
|
||||
const memberIds = new Set(project.members.map((m) => m.user.id));
|
||||
|
||||
const drafts: NotificationDraft[] = [];
|
||||
if (event instanceof IssueCreatedEvent && assigneeId) {
|
||||
drafts.push({
|
||||
userId: assigneeId,
|
||||
kind: NotificationKind.ASSIGNED,
|
||||
summary: `${actor} te asignó ${ticket}`,
|
||||
});
|
||||
}
|
||||
if (event instanceof IssueAssignedEvent) {
|
||||
const assignee = event.assigneeId
|
||||
? (await this.users.refs([event.assigneeId])).of(event.assigneeId).name
|
||||
: null;
|
||||
if (event.assigneeId) {
|
||||
drafts.push({
|
||||
userId: event.assigneeId,
|
||||
kind: NotificationKind.ASSIGNED,
|
||||
summary: `${actor} te asignó ${ticket}`,
|
||||
});
|
||||
}
|
||||
for (const userId of watcherIds) {
|
||||
drafts.push({
|
||||
userId,
|
||||
kind: NotificationKind.WATCHED,
|
||||
summary: assignee
|
||||
? `${actor} asignó ${ticket} a ${assignee}`
|
||||
: `${actor} dejó sin asignar ${ticket}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
if (event instanceof IssueStatusChangedEvent) {
|
||||
const to = event.meta?.toName ?? event.to;
|
||||
// Back from QA without passing: the one move whoever did the work must hear about.
|
||||
const failedQa =
|
||||
event.meta?.fromCategory === StatusCategory.QA &&
|
||||
event.meta?.toCategory !== StatusCategory.QA &&
|
||||
event.meta?.toCategory !== StatusCategory.DONE;
|
||||
const reason = event.reason ? `: ${excerpt(event.reason)}` : '';
|
||||
const summary = failedQa
|
||||
? `${actor} devolvió ${ticket} desde QA${reason}`
|
||||
: `${actor} movió ${ticket} a ${to}${reason}`;
|
||||
for (const userId of [assigneeId, reporterId]) {
|
||||
if (!userId) continue;
|
||||
drafts.push({
|
||||
userId,
|
||||
kind: failedQa
|
||||
? NotificationKind.QA_FAILED
|
||||
: NotificationKind.STATUS_CHANGED,
|
||||
summary,
|
||||
});
|
||||
}
|
||||
for (const userId of watcherIds) {
|
||||
drafts.push({ userId, kind: NotificationKind.WATCHED, summary });
|
||||
}
|
||||
}
|
||||
if (event instanceof CommentAddedEvent) {
|
||||
const said = `«${excerpt(event.comment.body)}»`;
|
||||
// Only members: a mention cannot reach someone who cannot read the ticket.
|
||||
for (const userId of mentionedUserIds(event.comment.body)) {
|
||||
if (!memberIds.has(userId)) continue;
|
||||
drafts.push({
|
||||
userId,
|
||||
kind: NotificationKind.MENTIONED,
|
||||
summary: `${actor} te mencionó en ${issue.key}: ${said}`,
|
||||
});
|
||||
}
|
||||
for (const userId of [assigneeId, reporterId]) {
|
||||
if (!userId) continue;
|
||||
drafts.push({
|
||||
userId,
|
||||
kind: NotificationKind.COMMENTED,
|
||||
summary: `${actor} comentó en ${issue.key}: ${said}`,
|
||||
});
|
||||
}
|
||||
for (const userId of watcherIds) {
|
||||
drafts.push({
|
||||
userId,
|
||||
kind: NotificationKind.WATCHED,
|
||||
summary: `${actor} comentó en ${issue.key}, que sigues: ${said}`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await this.notifier.deliver(
|
||||
event,
|
||||
{
|
||||
projectId: project.id,
|
||||
projectKey: project.key,
|
||||
issueId: issue.id,
|
||||
issueKey: issue.key,
|
||||
},
|
||||
drafts,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
|
||||
import { NotificationReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { NotificationMailer } from '../ports/notification-mailer';
|
||||
import { NotificationsRepository } from '../ports/notifications.repository';
|
||||
|
||||
export interface MyNotifications {
|
||||
items: NotificationReadModel[];
|
||||
unread: number;
|
||||
}
|
||||
|
||||
export interface MyNotificationSettings {
|
||||
/** Copies by e-mail (the user's choice). */
|
||||
email: boolean;
|
||||
/** Whether this installation can send e-mail at all. */
|
||||
emailAvailable: boolean;
|
||||
}
|
||||
|
||||
export class GetMyNotificationsQuery extends Query<MyNotifications> {
|
||||
constructor(
|
||||
public readonly userId: string,
|
||||
public readonly unreadOnly: boolean,
|
||||
public readonly limit: number,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetMyNotificationSettingsQuery extends Query<MyNotificationSettings> {
|
||||
constructor(public readonly userId: string) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetMyNotificationsQuery)
|
||||
export class GetMyNotificationsQueryHandler
|
||||
implements IQueryHandler<GetMyNotificationsQuery, MyNotifications>
|
||||
{
|
||||
constructor(private readonly notifications: NotificationsRepository) {}
|
||||
|
||||
async execute(query: GetMyNotificationsQuery): Promise<MyNotifications> {
|
||||
const [items, unread] = await Promise.all([
|
||||
this.notifications.findForUser(query.userId, {
|
||||
unreadOnly: query.unreadOnly,
|
||||
limit: Math.min(Math.max(query.limit, 1), 100),
|
||||
}),
|
||||
this.notifications.countUnread(query.userId),
|
||||
]);
|
||||
return { items, unread };
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetMyNotificationSettingsQuery)
|
||||
export class GetMyNotificationSettingsQueryHandler
|
||||
implements
|
||||
IQueryHandler<GetMyNotificationSettingsQuery, MyNotificationSettings>
|
||||
{
|
||||
constructor(
|
||||
private readonly notifications: NotificationsRepository,
|
||||
private readonly mailer: NotificationMailer,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetMyNotificationSettingsQuery,
|
||||
): Promise<MyNotificationSettings> {
|
||||
const settings = await this.notifications.settings(query.userId);
|
||||
return {
|
||||
email: this.mailer.enabled && settings.email,
|
||||
emailAvailable: this.mailer.enabled,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import {
|
||||
CommentAddedEvent,
|
||||
IssueAssignedEvent,
|
||||
IssueStatusChangedEvent,
|
||||
NotificationKind,
|
||||
NotificationReadModel,
|
||||
NotificationSettingsReadModel,
|
||||
ProjectMemberAddedEvent,
|
||||
ProjectRole,
|
||||
StatusCategory,
|
||||
TrackerEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FakeUserDirectory } from '../../testing/in-memory';
|
||||
import { ChangeFeed } from '../ports/change-feed';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||
import { NotificationMailer } from '../ports/notification-mailer';
|
||||
import { NotificationsRepository } from '../ports/notifications.repository';
|
||||
import { NotificationsEventHandler } from './notifications.event-handler';
|
||||
import { Notifier } from './notifier';
|
||||
|
||||
class InMemoryNotifications extends NotificationsRepository {
|
||||
readonly all: NotificationReadModel[] = [];
|
||||
readonly emailOn = new Set<string>();
|
||||
async insert(n: NotificationReadModel) {
|
||||
if (this.all.some((x) => x.id === n.id)) return false;
|
||||
this.all.push(n);
|
||||
return true;
|
||||
}
|
||||
async findForUser(userId: string) {
|
||||
return this.all.filter((n) => n.userId === userId);
|
||||
}
|
||||
async countUnread(userId: string) {
|
||||
return this.all.filter((n) => n.userId === userId && !n.readAt).length;
|
||||
}
|
||||
async markRead() {
|
||||
return;
|
||||
}
|
||||
async settings(userId: string): Promise<NotificationSettingsReadModel> {
|
||||
return { userId, email: this.emailOn.has(userId) };
|
||||
}
|
||||
async saveSettings() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const at = <T extends TrackerEvent>(event: T, version = 2): T =>
|
||||
Object.assign(event, {
|
||||
aggregateVersion: version,
|
||||
occurredAt: '2026-10-08T10:00:00.000Z',
|
||||
});
|
||||
|
||||
function setup(mailEnabled = false) {
|
||||
const repo = new InMemoryNotifications();
|
||||
const published: NotificationReadModel[] = [];
|
||||
const mailed: string[] = [];
|
||||
const users = new FakeUserDirectory()
|
||||
.add('ana')
|
||||
.add('bob')
|
||||
.add('qa')
|
||||
.add('pm');
|
||||
const issue = {
|
||||
id: 'i-1',
|
||||
key: 'WEB-1',
|
||||
title: 'Login roto',
|
||||
reporter: { id: 'qa', name: 'QA' },
|
||||
assignee: { id: 'bob', name: 'Bob' },
|
||||
watchers: [
|
||||
{ id: 'pm', name: 'PM' },
|
||||
{ id: 'bob', name: 'Bob' },
|
||||
],
|
||||
};
|
||||
const notifier = new Notifier(
|
||||
repo,
|
||||
{
|
||||
publishNotification: async (n: NotificationReadModel) => {
|
||||
published.push(n);
|
||||
},
|
||||
} as unknown as ChangeFeed,
|
||||
{
|
||||
enabled: mailEnabled,
|
||||
send: async (to: { email: string }) => {
|
||||
mailed.push(to.email);
|
||||
},
|
||||
} as unknown as NotificationMailer,
|
||||
users,
|
||||
);
|
||||
const handler = new NotificationsEventHandler(
|
||||
notifier,
|
||||
{ findById: async () => issue } as unknown as FindIssuesRepository,
|
||||
{
|
||||
findById: async () => ({
|
||||
id: 'p-1',
|
||||
key: 'WEB',
|
||||
name: 'Web',
|
||||
members: ['ana', 'bob', 'qa', 'pm'].map((id) => ({
|
||||
user: { id, name: id },
|
||||
role: ProjectRole.MEMBER,
|
||||
})),
|
||||
}),
|
||||
} as unknown as FindProjectsRepository,
|
||||
users,
|
||||
);
|
||||
return { repo, published, mailed, handler };
|
||||
}
|
||||
|
||||
describe('notifications', () => {
|
||||
it('tells whoever a ticket is assigned to, not whoever assigned it', async () => {
|
||||
const { handler, repo, published } = setup();
|
||||
await handler.handle(
|
||||
at(new IssueAssignedEvent('ana', 'i-1', 'p-1', 'bob', null)),
|
||||
);
|
||||
await handler.handle(
|
||||
at(new IssueAssignedEvent('ana', 'i-1', 'p-1', 'ana', null), 3),
|
||||
);
|
||||
|
||||
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
|
||||
['bob', NotificationKind.ASSIGNED],
|
||||
// Bob follows the ticket too, but is told once: as the assignee.
|
||||
['pm', NotificationKind.WATCHED],
|
||||
['pm', NotificationKind.WATCHED],
|
||||
['bob', NotificationKind.WATCHED],
|
||||
]);
|
||||
expect(published).toHaveLength(4);
|
||||
expect(repo.all[0].summary).toContain('WEB-1');
|
||||
});
|
||||
|
||||
it('marks a ticket sent back from QA as such, for whoever works on it and reported it', async () => {
|
||||
const { handler, repo } = setup();
|
||||
await handler.handle(
|
||||
at(
|
||||
new IssueStatusChangedEvent(
|
||||
'ana',
|
||||
'i-1',
|
||||
'p-1',
|
||||
'IN_QA',
|
||||
'QA_FAILED',
|
||||
'Falla en Safari',
|
||||
{
|
||||
fromName: 'En QA',
|
||||
toName: 'QA fallido',
|
||||
fromCategory: StatusCategory.QA,
|
||||
toCategory: StatusCategory.IN_PROGRESS,
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
|
||||
['bob', NotificationKind.QA_FAILED],
|
||||
['qa', NotificationKind.QA_FAILED],
|
||||
['pm', NotificationKind.WATCHED],
|
||||
]);
|
||||
expect(repo.all[0].summary).toContain('Falla en Safari');
|
||||
});
|
||||
|
||||
it('tells about a comment everyone but its author, once even when redelivered', async () => {
|
||||
const { handler, repo } = setup();
|
||||
const comment = at(
|
||||
new CommentAddedEvent('bob', {
|
||||
id: 'c-1',
|
||||
issueId: 'i-1',
|
||||
projectId: 'p-1',
|
||||
authorId: 'bob',
|
||||
body: 'Ya está arreglado',
|
||||
deleted: false,
|
||||
createdAt: '2026-10-08T10:00:00.000Z',
|
||||
editedAt: null,
|
||||
}),
|
||||
1,
|
||||
);
|
||||
await handler.handle(comment);
|
||||
await handler.handle(comment);
|
||||
|
||||
expect(repo.all.map((n) => n.userId)).toEqual(['qa', 'pm']);
|
||||
});
|
||||
|
||||
it('tells a mentioned member first, and nobody outside the project', async () => {
|
||||
const { handler, repo } = setup();
|
||||
await handler.handle(
|
||||
at(
|
||||
new CommentAddedEvent('bob', {
|
||||
id: 'c-2',
|
||||
issueId: 'i-1',
|
||||
projectId: 'p-1',
|
||||
authorId: 'bob',
|
||||
body: '@[PM](pm) mira esto, y @[Fuera](stranger) también',
|
||||
deleted: false,
|
||||
createdAt: '2026-10-08T10:00:00.000Z',
|
||||
editedAt: null,
|
||||
}),
|
||||
1,
|
||||
),
|
||||
);
|
||||
|
||||
expect(repo.all.map((n) => [n.userId, n.kind])).toEqual([
|
||||
['pm', NotificationKind.MENTIONED],
|
||||
['qa', NotificationKind.COMMENTED],
|
||||
]);
|
||||
expect(repo.all[0].summary).toContain('«@PM mira esto');
|
||||
});
|
||||
|
||||
it('copies by e-mail only to whoever asked for it, when mail is configured', async () => {
|
||||
const { handler, repo, mailed } = setup(true);
|
||||
repo.emailOn.add('bob');
|
||||
await handler.handle(
|
||||
at(new ProjectMemberAddedEvent('ana', 'p-1', 'bob', ProjectRole.MEMBER)),
|
||||
);
|
||||
await handler.handle(
|
||||
at(
|
||||
new ProjectMemberAddedEvent('ana', 'p-1', 'qa', ProjectRole.MEMBER),
|
||||
3,
|
||||
),
|
||||
);
|
||||
|
||||
expect(mailed).toEqual(['bob@example.org']);
|
||||
expect(repo.all.map((n) => n.kind)).toEqual([
|
||||
NotificationKind.ADDED_TO_PROJECT,
|
||||
NotificationKind.ADDED_TO_PROJECT,
|
||||
]);
|
||||
});
|
||||
});
|
||||
118
libs/api/tracker/application/src/lib/notifications/notifier.ts
Normal file
118
libs/api/tracker/application/src/lib/notifications/notifier.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import {
|
||||
NotificationKind,
|
||||
NotificationReadModel,
|
||||
SYSTEM_ACTOR_ID,
|
||||
SYSTEM_ACTOR_NAME,
|
||||
TrackerEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { ChangeFeed } from '../ports/change-feed';
|
||||
import { NotificationMailer } from '../ports/notification-mailer';
|
||||
import { NotificationsRepository } from '../ports/notifications.repository';
|
||||
import { UserDirectory } from '../ports/user-directory';
|
||||
import { versionOf } from '../event-handlers/read-model-notifier';
|
||||
|
||||
export interface NotificationDraft {
|
||||
userId: string;
|
||||
kind: NotificationKind;
|
||||
summary: string;
|
||||
}
|
||||
|
||||
export interface NotificationContext {
|
||||
projectId: string;
|
||||
projectKey: string;
|
||||
issueId: string | null;
|
||||
issueKey: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delivers notifications: stored for the recipient, pushed live, and copied
|
||||
* by e-mail to whoever asked for it. Nobody is told about what they did
|
||||
* themselves, and each person is told once per event — the first reason wins.
|
||||
*/
|
||||
@Injectable()
|
||||
export class Notifier {
|
||||
private readonly logger = new Logger(Notifier.name);
|
||||
|
||||
constructor(
|
||||
private readonly notifications: NotificationsRepository,
|
||||
private readonly changeFeed: ChangeFeed,
|
||||
private readonly mailer: NotificationMailer,
|
||||
private readonly users: UserDirectory,
|
||||
) {}
|
||||
|
||||
async deliver(
|
||||
event: TrackerEvent,
|
||||
context: NotificationContext,
|
||||
drafts: NotificationDraft[],
|
||||
): Promise<void> {
|
||||
const seen = new Set<string>([event.actorId]);
|
||||
const wanted = drafts.filter((draft) => {
|
||||
if (seen.has(draft.userId)) return false;
|
||||
seen.add(draft.userId);
|
||||
return true;
|
||||
});
|
||||
if (!wanted.length) return;
|
||||
|
||||
const refs = await this.users.refs([event.actorId]);
|
||||
const actor =
|
||||
event.actorId === SYSTEM_ACTOR_ID
|
||||
? { id: SYSTEM_ACTOR_ID, name: SYSTEM_ACTOR_NAME }
|
||||
: refs.of(event.actorId);
|
||||
|
||||
await this.send(
|
||||
wanted.map((draft) => ({
|
||||
id: `${event.aggregateId}:${versionOf(event)}:${draft.userId}`,
|
||||
userId: draft.userId,
|
||||
kind: draft.kind,
|
||||
...context,
|
||||
actor,
|
||||
summary: draft.summary,
|
||||
readAt: null,
|
||||
createdAt: event.occurredAt,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stores, pushes and e-mails ready-made notifications. Their id makes them
|
||||
* idempotent: one that is already there is not sent again.
|
||||
*/
|
||||
async send(notifications: NotificationReadModel[]): Promise<void> {
|
||||
const delivered: NotificationReadModel[] = [];
|
||||
for (const notification of notifications) {
|
||||
// Delivered again (at-least-once): already told, nothing to repeat.
|
||||
if (!(await this.notifications.insert(notification))) continue;
|
||||
await this.changeFeed.publishNotification(notification);
|
||||
delivered.push(notification);
|
||||
}
|
||||
if (this.mailer.enabled && delivered.length) {
|
||||
await this.email(delivered);
|
||||
}
|
||||
}
|
||||
|
||||
/** A failed e-mail never blocks the event stream: it is logged and skipped. */
|
||||
private async email(notifications: NotificationReadModel[]): Promise<void> {
|
||||
const contacts = new Map(
|
||||
(await this.users.contacts(notifications.map((n) => n.userId))).map(
|
||||
(c) => [c.id, c],
|
||||
),
|
||||
);
|
||||
for (const notification of notifications) {
|
||||
const contact = contacts.get(notification.userId);
|
||||
if (!contact?.email) continue;
|
||||
if (!(await this.notifications.settings(notification.userId)).email)
|
||||
continue;
|
||||
try {
|
||||
await this.mailer.send(
|
||||
{ name: contact.name, email: contact.email },
|
||||
notification,
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`E-mail to ${notification.userId} failed: ${(error as Error).message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import {
|
||||
IssueReadModel,
|
||||
NotificationKind,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { dueStage, nextDay, remindersFor } from './reminders';
|
||||
|
||||
const issue = (id: string, dueDate: string, watchers: string[] = []) =>
|
||||
({
|
||||
id,
|
||||
key: `WEB-${id}`,
|
||||
title: `T${id}`,
|
||||
projectId: 'p',
|
||||
projectKey: 'WEB',
|
||||
dueDate,
|
||||
assignee: { id: 'ana', name: 'Ana' },
|
||||
watchers: watchers.map((w) => ({ id: w, name: w })),
|
||||
}) as IssueReadModel;
|
||||
|
||||
describe('due reminders', () => {
|
||||
it('tells the stage of a due date', () => {
|
||||
expect(nextDay('2026-12-31')).toBe('2027-01-01');
|
||||
expect(dueStage('2026-10-09', '2026-10-08')).toBe('tomorrow');
|
||||
expect(dueStage('2026-10-08', '2026-10-08')).toBe('today');
|
||||
expect(dueStage('2026-10-01', '2026-10-08')).toBe('overdue');
|
||||
expect(dueStage('2026-10-20', '2026-10-08')).toBeNull();
|
||||
});
|
||||
|
||||
it('reminds whoever has it and whoever follows it, once per stage', () => {
|
||||
const reminders = remindersFor(
|
||||
[issue('1', '2026-10-08', ['ana', 'bob']), issue('2', '2026-10-30')],
|
||||
'2026-10-08',
|
||||
'2026-10-08T07:00:00Z',
|
||||
);
|
||||
expect(reminders.map((r) => [r.userId, r.summary])).toEqual([
|
||||
['ana', 'WEB-1 vence hoy: T1'],
|
||||
['bob', 'WEB-1 vence hoy: T1'],
|
||||
]);
|
||||
expect(reminders[0]).toMatchObject({
|
||||
kind: NotificationKind.REMINDER,
|
||||
id: 'reminder:1:2026-10-08:today:ana',
|
||||
});
|
||||
});
|
||||
});
|
||||
111
libs/api/tracker/application/src/lib/notifications/reminders.ts
Normal file
111
libs/api/tracker/application/src/lib/notifications/reminders.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
import { Command, CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
IssueReadModel,
|
||||
NotificationKind,
|
||||
NotificationReadModel,
|
||||
SYSTEM_ACTOR_ID,
|
||||
SYSTEM_ACTOR_NAME,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { Notifier } from './notifier';
|
||||
|
||||
/** Where a due date stands, from the reminder's point of view. */
|
||||
export type DueStage = 'tomorrow' | 'today' | 'overdue';
|
||||
|
||||
const DAY = 86_400_000;
|
||||
|
||||
/** `YYYY-MM-DD` one day after `day`. */
|
||||
export function nextDay(day: string): string {
|
||||
return new Date(Date.parse(`${day}T00:00:00Z`) + DAY)
|
||||
.toISOString()
|
||||
.slice(0, 10);
|
||||
}
|
||||
|
||||
export function dueStage(dueDate: string, today: string): DueStage | null {
|
||||
const due = dueDate.slice(0, 10);
|
||||
if (due < today) return 'overdue';
|
||||
if (due === today) return 'today';
|
||||
return due === nextDay(today) ? 'tomorrow' : null;
|
||||
}
|
||||
|
||||
const day = (iso: string) =>
|
||||
new Date(`${iso.slice(0, 10)}T00:00:00Z`).toLocaleDateString('es', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
timeZone: 'UTC',
|
||||
});
|
||||
|
||||
/**
|
||||
* One reminder per ticket, due date and stage — the day before, on the day,
|
||||
* and once when it is overdue — for whoever has it and whoever follows it.
|
||||
* Ids make it idempotent: running it every hour, on every instance, is fine.
|
||||
*/
|
||||
export function remindersFor(
|
||||
issues: IssueReadModel[],
|
||||
today: string,
|
||||
now: string,
|
||||
): NotificationReadModel[] {
|
||||
const out: NotificationReadModel[] = [];
|
||||
for (const issue of issues) {
|
||||
if (!issue.dueDate) continue;
|
||||
const stage = dueStage(issue.dueDate, today);
|
||||
if (!stage) continue;
|
||||
const summary =
|
||||
stage === 'tomorrow'
|
||||
? `${issue.key} vence mañana: ${issue.title}`
|
||||
: stage === 'today'
|
||||
? `${issue.key} vence hoy: ${issue.title}`
|
||||
: `${issue.key} venció el ${day(issue.dueDate)} y sigue abierto: ${issue.title}`;
|
||||
const people = new Set(
|
||||
[issue.assignee?.id, ...(issue.watchers ?? []).map((w) => w.id)].filter(
|
||||
(id): id is string => !!id,
|
||||
),
|
||||
);
|
||||
for (const userId of people)
|
||||
out.push({
|
||||
id: `reminder:${issue.id}:${issue.dueDate.slice(0, 10)}:${stage}:${userId}`,
|
||||
userId,
|
||||
kind: NotificationKind.REMINDER,
|
||||
projectId: issue.projectId,
|
||||
projectKey: issue.projectKey,
|
||||
issueId: issue.id,
|
||||
issueKey: issue.key,
|
||||
actor: { id: SYSTEM_ACTOR_ID, name: SYSTEM_ACTOR_NAME },
|
||||
summary,
|
||||
readAt: null,
|
||||
createdAt: now,
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Scheduler only: tells people about tickets due tomorrow, today or overdue. */
|
||||
export class SendDueRemindersCommand extends Command<number> {
|
||||
constructor(
|
||||
/** `YYYY-MM-DD` in the installation's time zone. */
|
||||
public readonly today: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(SendDueRemindersCommand)
|
||||
export class SendDueRemindersCommandHandler
|
||||
implements ICommandHandler<SendDueRemindersCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly notifier: Notifier,
|
||||
) {}
|
||||
|
||||
async execute(command: SendDueRemindersCommand): Promise<number> {
|
||||
const due = await this.findIssues.findDueBy(nextDay(command.today));
|
||||
const reminders = remindersFor(
|
||||
due,
|
||||
command.today,
|
||||
new Date().toISOString(),
|
||||
);
|
||||
await this.notifier.send(reminders);
|
||||
return reminders.length;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Readable } from 'node:stream';
|
||||
|
||||
/** Where the bytes of attached files live (the tickets only record them). */
|
||||
export abstract class AttachmentStorage {
|
||||
abstract put(key: string, content: Buffer): Promise<void>;
|
||||
/** Fails with NotFoundError when there is nothing at `key`. */
|
||||
abstract read(key: string): Promise<Readable>;
|
||||
/** Idempotent: removing what is not there is fine. */
|
||||
abstract remove(key: string): Promise<void>;
|
||||
/** Everything under `prefix` (a ticket's files, when it is deleted). */
|
||||
abstract removePrefix(prefix: string): Promise<void>;
|
||||
}
|
||||
|
||||
/** One folder per project and ticket, so a ticket's files go together. */
|
||||
export const attachmentKey = (
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
attachmentId: string,
|
||||
): string => `${projectId}/${issueId}/${attachmentId}`;
|
||||
|
||||
export const issueAttachmentsPrefix = (
|
||||
projectId: string,
|
||||
issueId: string,
|
||||
): string => `${projectId}/${issueId}/`;
|
||||
@@ -0,0 +1,42 @@
|
||||
import { UserRefReadModel } from '@task-stream/api/tracker/domain';
|
||||
import {
|
||||
AutomationAction,
|
||||
AutomationConditions,
|
||||
AutomationTrigger,
|
||||
} from '../automations/automation-rules';
|
||||
|
||||
/** «When this happens to a ticket, if it is like this, do that.» */
|
||||
export interface Automation {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
active: boolean;
|
||||
trigger: AutomationTrigger;
|
||||
conditions: AutomationConditions;
|
||||
action: AutomationAction;
|
||||
createdBy: UserRefReadModel;
|
||||
createdAt: string;
|
||||
runs: number;
|
||||
lastRunAt: string | null;
|
||||
/** Why the last run did nothing (a move the workflow does not allow…). */
|
||||
lastError: string | null;
|
||||
}
|
||||
|
||||
/** Configuration, not history: kept outside the event store. */
|
||||
export abstract class AutomationsRepository {
|
||||
abstract findByProject(projectId: string): Promise<Automation[]>;
|
||||
abstract findActive(projectId: string): Promise<Automation[]>;
|
||||
abstract findById(id: string): Promise<Automation | null>;
|
||||
abstract insert(automation: Automation): Promise<void>;
|
||||
abstract update(
|
||||
id: string,
|
||||
changes: Partial<Pick<Automation, 'name' | 'active'>>,
|
||||
): Promise<void>;
|
||||
abstract delete(id: string): Promise<void>;
|
||||
/**
|
||||
* Takes the right to run `ruleId` for one event (false: it already ran, the
|
||||
* event was delivered again).
|
||||
*/
|
||||
abstract claimRun(ruleId: string, eventKey: string): Promise<boolean>;
|
||||
abstract recordRun(id: string, error: string | null): Promise<void>;
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import {
|
||||
ActivityReadModel,
|
||||
CommentReadModel,
|
||||
NotificationReadModel,
|
||||
IssueReadModel,
|
||||
ProjectReadModel,
|
||||
ReleaseReadModel,
|
||||
SprintReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Every realtime message says which event caused it and the resulting version. */
|
||||
@@ -29,6 +31,13 @@ export interface ReleaseChange extends Change {
|
||||
release: ReleaseReadModel;
|
||||
}
|
||||
|
||||
export interface SprintChange extends Change {
|
||||
/** Null when the sprint was deleted. */
|
||||
sprint: SprintReadModel | null;
|
||||
sprintId: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
export interface ProjectChange extends Change {
|
||||
project: ProjectReadModel;
|
||||
}
|
||||
@@ -42,18 +51,27 @@ export abstract class ChangeFeed {
|
||||
abstract publishComment(change: CommentChange): Promise<void>;
|
||||
abstract publishActivity(activity: ActivityReadModel): Promise<void>;
|
||||
abstract publishRelease(change: ReleaseChange): Promise<void>;
|
||||
abstract publishSprint(change: SprintChange): Promise<void>;
|
||||
/** @param notifyUserIds users whose project list changed (joined / left). */
|
||||
abstract publishProject(
|
||||
change: ProjectChange,
|
||||
notifyUserIds?: string[],
|
||||
): Promise<void>;
|
||||
|
||||
abstract publishNotification(
|
||||
notification: NotificationReadModel,
|
||||
): Promise<void>;
|
||||
|
||||
abstract issues(projectId: string): AsyncIterableIterator<IssueChange>;
|
||||
abstract comments(issueId: string): AsyncIterableIterator<CommentChange>;
|
||||
abstract activity(
|
||||
projectId: string,
|
||||
): AsyncIterableIterator<ActivityReadModel>;
|
||||
abstract releases(projectId: string): AsyncIterableIterator<ReleaseChange>;
|
||||
abstract sprints(projectId: string): AsyncIterableIterator<SprintChange>;
|
||||
abstract project(projectId: string): AsyncIterableIterator<ProjectChange>;
|
||||
abstract userProjects(userId: string): AsyncIterableIterator<ProjectChange>;
|
||||
abstract notifications(
|
||||
userId: string,
|
||||
): AsyncIterableIterator<NotificationReadModel>;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,18 @@
|
||||
import {
|
||||
EpicReadModel,
|
||||
IssuePriority,
|
||||
IssueReadModel,
|
||||
ProjectStatsReadModel,
|
||||
ReleaseProgressReadModel,
|
||||
SprintProgressReadModel,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
export enum IssueSort {
|
||||
RECENT = 'RECENT',
|
||||
RANK = 'RANK',
|
||||
}
|
||||
|
||||
export interface IssueFilter {
|
||||
projectId: string;
|
||||
/** Status, type and area ids of the project's settings. */
|
||||
@@ -18,6 +25,16 @@ export interface IssueFilter {
|
||||
unassigned?: boolean;
|
||||
releaseId?: string;
|
||||
labels?: string[];
|
||||
/** Only the tickets hanging from this one. */
|
||||
parentId?: string;
|
||||
sprintId?: string;
|
||||
/** Due between these days (`YYYY-MM-DD`, both included). */
|
||||
dueFrom?: string;
|
||||
dueTo?: string;
|
||||
/** Only tickets in no sprint (wins over `sprintId`). */
|
||||
noSprint?: boolean;
|
||||
/** RECENT (default): last updated first. RANK: backlog order. */
|
||||
sort?: IssueSort;
|
||||
search?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
@@ -29,6 +46,7 @@ export interface CrossProjectIssueFilter {
|
||||
assigneeId?: string;
|
||||
statuses?: string[];
|
||||
categories?: StatusCategory[];
|
||||
search?: string;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
@@ -49,8 +67,17 @@ export abstract class FindIssuesRepository {
|
||||
abstract stats(projectId: string): Promise<ProjectStatsReadModel>;
|
||||
/** Open tickets (TODO, IN_PROGRESS or QA category) assigned to someone, in any project. */
|
||||
abstract findOpenAssignedTo(userId: string): Promise<IssueReadModel[]>;
|
||||
/** Unfinished tickets with someone on them, due on or before `day` (YYYY-MM-DD). */
|
||||
abstract findDueBy(day: string): Promise<IssueReadModel[]>;
|
||||
/** The project's tickets in the trash, newest first. */
|
||||
abstract findTrashed(projectId: string): Promise<IssueReadModel[]>;
|
||||
/** Tickets that went to the trash before `before` (ISO), any project. */
|
||||
abstract findTrashedBefore(before: string): Promise<IssueReadModel[]>;
|
||||
/** Every ticket of a project (statistics). */
|
||||
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
|
||||
/** Top-level tickets with others hanging from them, by key. */
|
||||
abstract epics(projectId: string): Promise<EpicReadModel[]>;
|
||||
abstract sprintProgress(sprintId: string): Promise<SprintProgressReadModel>;
|
||||
abstract releaseProgress(
|
||||
releaseId: string,
|
||||
): Promise<ReleaseProgressReadModel>;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SprintReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export abstract class FindSprintsRepository {
|
||||
abstract findById(id: string): Promise<SprintReadModel | null>;
|
||||
/** Active first, then planned by start date, then closed (newest first). */
|
||||
abstract findByProject(projectId: string): Promise<SprintReadModel[]>;
|
||||
/** The sprint under way in the project, if any. */
|
||||
abstract findActive(projectId: string): Promise<SprintReadModel | null>;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
/**
|
||||
* Per-project secret that Git hosts sign their webhooks with. Configuration,
|
||||
* not history: kept outside the event store.
|
||||
*/
|
||||
export abstract class GitIntegrationsRepository {
|
||||
abstract secretOf(projectId: string): Promise<string | null>;
|
||||
abstract save(projectId: string, secret: string): Promise<void>;
|
||||
abstract remove(projectId: string): Promise<void>;
|
||||
}
|
||||
@@ -17,3 +17,12 @@ export * from './release-issues.repository';
|
||||
export * from './upsert-materialized-timeline.repository';
|
||||
export * from './find-timeline.repository';
|
||||
export * from './tracker-streams.repository';
|
||||
export * from './attachment-storage';
|
||||
export * from './notifications.repository';
|
||||
export * from './notification-mailer';
|
||||
export * from './saved-filters.repository';
|
||||
export * from './find-sprints.repository';
|
||||
export * from './upsert-materialized-sprint.repository';
|
||||
export * from './git-integrations.repository';
|
||||
export * from './outgoing-webhooks.repository';
|
||||
export * from './automations.repository';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NotificationReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export interface MailRecipient {
|
||||
name: string;
|
||||
email: string;
|
||||
}
|
||||
|
||||
/** E-mail copies of notifications, for whoever asked for them. */
|
||||
export abstract class NotificationMailer {
|
||||
/** False when no mail server is configured: nobody is offered e-mail. */
|
||||
abstract get enabled(): boolean;
|
||||
abstract send(
|
||||
to: MailRecipient,
|
||||
notification: NotificationReadModel,
|
||||
): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import {
|
||||
NotificationReadModel,
|
||||
NotificationSettingsReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
|
||||
/**
|
||||
* Notifications are per-person state, not part of any aggregate: they are
|
||||
* derived from events and read and dismissed by their recipient alone.
|
||||
*/
|
||||
export abstract class NotificationsRepository {
|
||||
/** False when it was already there (the event was delivered again). */
|
||||
abstract insert(notification: NotificationReadModel): Promise<boolean>;
|
||||
/** Newest first. */
|
||||
abstract findForUser(
|
||||
userId: string,
|
||||
options: { unreadOnly: boolean; limit: number },
|
||||
): Promise<NotificationReadModel[]>;
|
||||
abstract countUnread(userId: string): Promise<number>;
|
||||
/** `ids` null: all of them. */
|
||||
abstract markRead(userId: string, ids: string[] | null): Promise<void>;
|
||||
abstract settings(userId: string): Promise<NotificationSettingsReadModel>;
|
||||
abstract saveSettings(settings: NotificationSettingsReadModel): Promise<void>;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { UserRefReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export interface WebhookDelivery {
|
||||
at: string;
|
||||
event: string;
|
||||
/** HTTP status, null when it never answered. */
|
||||
status: number | null;
|
||||
ok: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
/** Where a project tells other systems what happened in it. */
|
||||
export interface OutgoingWebhook {
|
||||
id: string;
|
||||
projectId: string;
|
||||
url: string;
|
||||
/** Signs every delivery (HMAC-SHA256); shown once, when created. */
|
||||
secret: string;
|
||||
/** Event names, `issue.*` or `*`. */
|
||||
events: string[];
|
||||
active: boolean;
|
||||
createdBy: UserRefReadModel;
|
||||
createdAt: string;
|
||||
lastDelivery: WebhookDelivery | null;
|
||||
}
|
||||
|
||||
/** Configuration, not history: kept outside the event store. */
|
||||
export abstract class OutgoingWebhooksRepository {
|
||||
abstract findByProject(projectId: string): Promise<OutgoingWebhook[]>;
|
||||
abstract findActive(projectId: string): Promise<OutgoingWebhook[]>;
|
||||
abstract findById(id: string): Promise<OutgoingWebhook | null>;
|
||||
abstract insert(webhook: OutgoingWebhook): Promise<void>;
|
||||
abstract update(
|
||||
id: string,
|
||||
changes: Partial<Pick<OutgoingWebhook, 'url' | 'events' | 'active'>>,
|
||||
): Promise<void>;
|
||||
abstract delete(id: string): Promise<void>;
|
||||
abstract recordDelivery(id: string, delivery: WebhookDelivery): Promise<void>;
|
||||
}
|
||||
|
||||
/** Sends one delivery. Throws when the receiver cannot be reached. */
|
||||
export abstract class WebhookSender {
|
||||
abstract send(request: {
|
||||
url: string;
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
}): Promise<{ status: number }>;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SavedFilterReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
/** Saved board views: per person (or shared with a project), no history. */
|
||||
export abstract class SavedFiltersRepository {
|
||||
/** Whoever's own views of the project, then the shared ones, by name. */
|
||||
abstract findVisible(
|
||||
projectId: string,
|
||||
userId: string,
|
||||
): Promise<SavedFilterReadModel[]>;
|
||||
abstract findById(id: string): Promise<SavedFilterReadModel | null>;
|
||||
abstract countOwned(projectId: string, userId: string): Promise<number>;
|
||||
abstract insert(filter: SavedFilterReadModel): Promise<void>;
|
||||
abstract delete(id: string): Promise<void>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user