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
|
SWAGGER_ENABLED=true
|
||||||
OPENAPI_FILE=apps/api/openapi.json
|
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).
|
# 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-)
|
# Locally: export NEXUS_TOKEN=$(grep '^//nexus.pireon.dev/repository/npm-releases/:_authToken=' ~/.npmrc | cut -d= -f2-)
|
||||||
NEXUS_TOKEN=
|
NEXUS_TOKEN=
|
||||||
|
|||||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -53,3 +53,6 @@ Thumbs.db
|
|||||||
__screenshots__/
|
__screenshots__/
|
||||||
|
|
||||||
vitest.config.*.timestamp*!apps/web/.env
|
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 ./
|
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
|
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 ./
|
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
|
USER node
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
HEALTHCHECK --interval=10s --timeout=3s --start-period=20s --retries=5 \
|
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",
|
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||||
"sourceRoot": "apps/api/src",
|
"sourceRoot": "apps/api/src",
|
||||||
"projectType": "application",
|
"projectType": "application",
|
||||||
"tags": [
|
"tags": ["scope:api", "type:app"],
|
||||||
"scope:api",
|
|
||||||
"type:app"
|
|
||||||
],
|
|
||||||
"targets": {
|
"targets": {
|
||||||
"prune-lockfile": {
|
"prune-lockfile": {
|
||||||
"dependsOn": [
|
"dependsOn": ["build"],
|
||||||
"build"
|
|
||||||
],
|
|
||||||
"cache": true,
|
"cache": true,
|
||||||
"executor": "@nx/js:prune-lockfile",
|
"executor": "@nx/js:prune-lockfile",
|
||||||
"outputs": [
|
"outputs": [
|
||||||
@@ -23,32 +18,23 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"copy-workspace-modules": {
|
"copy-workspace-modules": {
|
||||||
"dependsOn": [
|
"dependsOn": ["build"],
|
||||||
"build"
|
|
||||||
],
|
|
||||||
"cache": true,
|
"cache": true,
|
||||||
"outputs": [
|
"outputs": ["{workspaceRoot}/dist/apps/api/workspace_modules"],
|
||||||
"{workspaceRoot}/dist/apps/api/workspace_modules"
|
|
||||||
],
|
|
||||||
"executor": "@nx/js:copy-workspace-modules",
|
"executor": "@nx/js:copy-workspace-modules",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "build"
|
"buildTarget": "build"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"prune": {
|
"prune": {
|
||||||
"dependsOn": [
|
"dependsOn": ["prune-lockfile", "copy-workspace-modules"],
|
||||||
"prune-lockfile",
|
|
||||||
"copy-workspace-modules"
|
|
||||||
],
|
|
||||||
"executor": "nx:noop"
|
"executor": "nx:noop"
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"continuous": true,
|
"continuous": true,
|
||||||
"executor": "@nx/js:node",
|
"executor": "@nx/js:node",
|
||||||
"defaultConfiguration": "development",
|
"defaultConfiguration": "development",
|
||||||
"dependsOn": [
|
"dependsOn": ["build"],
|
||||||
"build"
|
|
||||||
],
|
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "api:build",
|
"buildTarget": "api:build",
|
||||||
"runBuildTargetDependencies": false
|
"runBuildTargetDependencies": false
|
||||||
@@ -73,6 +59,13 @@
|
|||||||
"command": "jest --config apps/api/jest.e2e.config.cts --runInBand"
|
"command": "jest --config apps/api/jest.e2e.config.cts --runInBand"
|
||||||
},
|
},
|
||||||
"cache": false
|
"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!
|
type: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input AddChecklistItemInput {
|
||||||
|
issueId: ID!
|
||||||
|
text: String!
|
||||||
|
}
|
||||||
|
|
||||||
type AreaOption {
|
type AreaOption {
|
||||||
archived: Boolean!
|
archived: Boolean!
|
||||||
id: String!
|
id: String!
|
||||||
@@ -25,12 +30,134 @@ input AreaOptionInput {
|
|||||||
name: String!
|
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 {
|
type BoardColumn {
|
||||||
issues: [Issue!]!
|
issues: [Issue!]!
|
||||||
status: String!
|
status: String!
|
||||||
total: Int!
|
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 {
|
input ChangeIssueStatusInput {
|
||||||
issueId: ID!
|
issueId: ID!
|
||||||
|
|
||||||
@@ -41,6 +168,23 @@ input ChangeIssueStatusInput {
|
|||||||
status: String!
|
status: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""A step inside a ticket that does not deserve a ticket of its own"""
|
||||||
|
type ChecklistItem {
|
||||||
|
done: Boolean!
|
||||||
|
id: ID!
|
||||||
|
text: String!
|
||||||
|
}
|
||||||
|
|
||||||
|
"""How far the tickets hanging from one have got"""
|
||||||
|
type ChildProgress {
|
||||||
|
"""Done or cancelled"""
|
||||||
|
finished: Int!
|
||||||
|
finishedStoryPoints: Int!
|
||||||
|
percent: Int!
|
||||||
|
storyPoints: Int!
|
||||||
|
total: Int!
|
||||||
|
}
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Writes are acknowledged immediately; the read model catches up asynchronously. Wait for a subscription event with version >= this version to see the change.
|
Writes are acknowledged immediately; the read model catches up asynchronously. Wait for a subscription event with version >= this version to see the change.
|
||||||
"""
|
"""
|
||||||
@@ -95,6 +239,14 @@ type CountByStatus {
|
|||||||
key: String!
|
key: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input CreateAutomationInput {
|
||||||
|
action: AutomationActionInput!
|
||||||
|
conditions: AutomationConditionsInput
|
||||||
|
name: String!
|
||||||
|
projectId: ID!
|
||||||
|
trigger: AutomationTriggerInput!
|
||||||
|
}
|
||||||
|
|
||||||
input CreateIssueInput {
|
input CreateIssueInput {
|
||||||
"""An area id"""
|
"""An area id"""
|
||||||
area: String
|
area: String
|
||||||
@@ -105,9 +257,15 @@ input CreateIssueInput {
|
|||||||
"""Figma frame for UI changes"""
|
"""Figma frame for UI changes"""
|
||||||
figmaUrl: String
|
figmaUrl: String
|
||||||
labels: [String!]
|
labels: [String!]
|
||||||
|
|
||||||
|
"""Epic or ticket of the same project the new one breaks down"""
|
||||||
|
parentId: ID
|
||||||
priority: IssuePriority
|
priority: IssuePriority
|
||||||
projectId: ID!
|
projectId: ID!
|
||||||
releaseId: ID
|
releaseId: ID
|
||||||
|
|
||||||
|
"""When work should start (roadmap), YYYY-MM-DD"""
|
||||||
|
startDate: String
|
||||||
storyPoints: Int
|
storyPoints: Int
|
||||||
title: String!
|
title: String!
|
||||||
|
|
||||||
@@ -135,6 +293,24 @@ input CreateReleaseInput {
|
|||||||
targetDate: String
|
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 {
|
type CreatedResolved {
|
||||||
created: Int!
|
created: Int!
|
||||||
resolved: Int!
|
resolved: Int!
|
||||||
@@ -148,6 +324,34 @@ type CumulativeFlow {
|
|||||||
series: [FlowSeries!]!
|
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 {
|
type DurationStats {
|
||||||
averageDays: Float
|
averageDays: Float
|
||||||
count: Int!
|
count: Int!
|
||||||
@@ -156,11 +360,30 @@ type DurationStats {
|
|||||||
p85Days: Float
|
p85Days: Float
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""A top-level ticket that groups others, with their progress"""
|
||||||
|
type Epic {
|
||||||
|
issue: Issue!
|
||||||
|
progress: ChildProgress!
|
||||||
|
}
|
||||||
|
|
||||||
type FlowSeries {
|
type FlowSeries {
|
||||||
counts: [Int!]!
|
counts: [Int!]!
|
||||||
status: String!
|
status: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""
|
||||||
|
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 {
|
type Issue {
|
||||||
"""Statuses this ticket can move to next (project's workflow)"""
|
"""Statuses this ticket can move to next (project's workflow)"""
|
||||||
allowedTransitions: [String!]!
|
allowedTransitions: [String!]!
|
||||||
@@ -168,10 +391,15 @@ type Issue {
|
|||||||
"""An area id"""
|
"""An area id"""
|
||||||
area: String
|
area: String
|
||||||
assignee: UserRef
|
assignee: UserRef
|
||||||
|
attachments: [Attachment!]!
|
||||||
|
checklist: [ChecklistItem!]!
|
||||||
commentCount: Int!
|
commentCount: Int!
|
||||||
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
|
comments(limit: Int! = 50, offset: Int! = 0): CommentPage!
|
||||||
createdAt: String!
|
createdAt: String!
|
||||||
description: String
|
description: String
|
||||||
|
|
||||||
|
"""Commits, branches and pull requests that mention it"""
|
||||||
|
devLinks: [DevLink!]!
|
||||||
dueDate: String
|
dueDate: String
|
||||||
figmaUrl: String
|
figmaUrl: String
|
||||||
|
|
||||||
@@ -187,11 +415,23 @@ type Issue {
|
|||||||
linkedIssues: [Issue!]!
|
linkedIssues: [Issue!]!
|
||||||
links: [IssueLink!]!
|
links: [IssueLink!]!
|
||||||
number: Int!
|
number: Int!
|
||||||
|
|
||||||
|
"""The epic or ticket this one breaks down"""
|
||||||
|
parent: IssueParent
|
||||||
priority: IssuePriority!
|
priority: IssuePriority!
|
||||||
projectId: ID!
|
projectId: ID!
|
||||||
projectKey: String!
|
projectKey: String!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Place in the backlog and in its board column (ascending, compare as text)
|
||||||
|
"""
|
||||||
|
rank: String!
|
||||||
release: ReleaseRef
|
release: ReleaseRef
|
||||||
reporter: UserRef!
|
reporter: UserRef!
|
||||||
|
sprint: SprintRef
|
||||||
|
|
||||||
|
"""When work should start (roadmap)"""
|
||||||
|
startDate: String
|
||||||
|
|
||||||
"""A status id of the project's settings"""
|
"""A status id of the project's settings"""
|
||||||
status: String!
|
status: String!
|
||||||
@@ -199,10 +439,16 @@ type Issue {
|
|||||||
storyPoints: Int
|
storyPoints: Int
|
||||||
title: String!
|
title: String!
|
||||||
|
|
||||||
|
"""In the trash since then; null when it is not"""
|
||||||
|
trashedAt: String
|
||||||
|
|
||||||
"""A ticket type id of the project's settings"""
|
"""A ticket type id of the project's settings"""
|
||||||
type: String!
|
type: String!
|
||||||
updatedAt: String!
|
updatedAt: String!
|
||||||
version: Int!
|
version: Int!
|
||||||
|
|
||||||
|
"""People following the ticket"""
|
||||||
|
watchers: [UserRef!]!
|
||||||
}
|
}
|
||||||
|
|
||||||
type IssueChange {
|
type IssueChange {
|
||||||
@@ -216,13 +462,27 @@ input IssueFilterInput {
|
|||||||
areas: [String!]
|
areas: [String!]
|
||||||
assigneeId: ID
|
assigneeId: ID
|
||||||
categories: [StatusCategory!]
|
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!]
|
labels: [String!]
|
||||||
|
|
||||||
|
"""Only tickets in no sprint"""
|
||||||
|
noSprint: Boolean
|
||||||
|
|
||||||
|
"""Only the tickets hanging from this one"""
|
||||||
|
parentId: ID
|
||||||
priorities: [IssuePriority!]
|
priorities: [IssuePriority!]
|
||||||
projectId: ID!
|
projectId: ID!
|
||||||
releaseId: ID
|
releaseId: ID
|
||||||
|
|
||||||
"""Matches key, title or description"""
|
"""Matches key, title or description"""
|
||||||
search: String
|
search: String
|
||||||
|
sort: IssueSort = RECENT
|
||||||
|
sprintId: ID
|
||||||
statuses: [String!]
|
statuses: [String!]
|
||||||
types: [String!]
|
types: [String!]
|
||||||
unassigned: Boolean
|
unassigned: Boolean
|
||||||
@@ -249,6 +509,13 @@ type IssuePage {
|
|||||||
total: Int!
|
total: Int!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
"""The ticket another one hangs from"""
|
||||||
|
type IssueParent {
|
||||||
|
id: ID!
|
||||||
|
key: String!
|
||||||
|
title: String!
|
||||||
|
}
|
||||||
|
|
||||||
enum IssuePriority {
|
enum IssuePriority {
|
||||||
CRITICAL
|
CRITICAL
|
||||||
HIGH
|
HIGH
|
||||||
@@ -257,12 +524,33 @@ enum IssuePriority {
|
|||||||
MEDIUM
|
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 {
|
type IssueTypeOption {
|
||||||
archived: Boolean!
|
archived: Boolean!
|
||||||
color: String!
|
color: String!
|
||||||
icon: String!
|
icon: String!
|
||||||
id: String!
|
id: String!
|
||||||
name: String!
|
name: String!
|
||||||
|
template: IssueTemplate
|
||||||
}
|
}
|
||||||
|
|
||||||
input IssueTypeOptionInput {
|
input IssueTypeOptionInput {
|
||||||
@@ -271,6 +559,7 @@ input IssueTypeOptionInput {
|
|||||||
icon: String!
|
icon: String!
|
||||||
id: String!
|
id: String!
|
||||||
name: String!
|
name: String!
|
||||||
|
template: IssueTemplateInput
|
||||||
}
|
}
|
||||||
|
|
||||||
enum LinkDirection {
|
enum LinkDirection {
|
||||||
@@ -297,6 +586,8 @@ type Me {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type Mutation {
|
type Mutation {
|
||||||
|
"""Add a step to a ticket's checklist"""
|
||||||
|
addChecklistItem(input: AddChecklistItemInput!): CommandResult!
|
||||||
addComment(body: String!, issueId: ID!): CommandResult!
|
addComment(body: String!, issueId: ID!): CommandResult!
|
||||||
|
|
||||||
"""Add a Pireon user to the project"""
|
"""Add a Pireon user to the project"""
|
||||||
@@ -306,13 +597,33 @@ type Mutation {
|
|||||||
"""Assign (or unassign with null) a project member"""
|
"""Assign (or unassign with null) a project member"""
|
||||||
assignIssue(assigneeId: ID, issueId: ID!): CommandResult!
|
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)"""
|
"""Move a ticket through the workflow (incl. QA)"""
|
||||||
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
|
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
|
||||||
changeProjectMemberRole(input: ProjectMemberInput!): 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!
|
createIssue(input: CreateIssueInput!): CommandResult!
|
||||||
createProject(input: CreateProjectInput!): CommandResult!
|
createProject(input: CreateProjectInput!): CommandResult!
|
||||||
createRelease(input: CreateReleaseInput!): 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"""
|
"""Author or project admin"""
|
||||||
deleteComment(commentId: ID!): CommandResult!
|
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.
|
Delete the project (owner only), with its tickets, comments and releases. `confirmKey` must be the project key.
|
||||||
"""
|
"""
|
||||||
deleteProject(confirmKey: String!, projectId: ID!): CommandResult!
|
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!
|
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"""
|
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
||||||
linkIssues(input: LinkIssuesInput!): CommandResult!
|
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.
|
Ship the release. Fails while it has unfinished tickets unless force = true.
|
||||||
"""
|
"""
|
||||||
publishRelease(force: Boolean! = false, releaseId: ID!): CommandResult!
|
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!
|
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)"""
|
"""Put a ticket in a release (null removes it)"""
|
||||||
setIssueRelease(issueId: ID!, releaseId: ID): CommandResult!
|
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!
|
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!
|
updateIssue(input: UpdateIssueInput!): CommandResult!
|
||||||
|
updateNotificationSettings(email: Boolean!): NotificationSettings!
|
||||||
updateProject(input: UpdateProjectInput!): CommandResult!
|
updateProject(input: UpdateProjectInput!): CommandResult!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
@@ -342,6 +711,60 @@ type Mutation {
|
|||||||
"""
|
"""
|
||||||
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
|
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
|
||||||
updateRelease(input: UpdateReleaseInput!): 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 {
|
type Project {
|
||||||
@@ -351,7 +774,20 @@ type Project {
|
|||||||
analytics(days: Int! = 90): ProjectAnalytics!
|
analytics(days: Int! = 90): ProjectAnalytics!
|
||||||
|
|
||||||
"""Kanban board, one column per status"""
|
"""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!
|
createdAt: String!
|
||||||
description: String
|
description: String
|
||||||
id: ID!
|
id: ID!
|
||||||
@@ -462,6 +898,13 @@ type Query {
|
|||||||
limit: Int! = 50
|
limit: Int! = 50
|
||||||
projectId: ID!
|
projectId: ID!
|
||||||
): [Activity!]!
|
): [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(
|
||||||
"""Issue id or key (e.g. "TS-42")"""
|
"""Issue id or key (e.g. "TS-42")"""
|
||||||
idOrKey: String!
|
idOrKey: String!
|
||||||
@@ -474,10 +917,23 @@ type Query {
|
|||||||
"""
|
"""
|
||||||
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
|
Tickets across all my projects: assigned to me (default) or any (e.g. categories [QA])
|
||||||
"""
|
"""
|
||||||
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"""
|
"""Projects where I am a member"""
|
||||||
myProjects: [Project!]!
|
myProjects: [Project!]!
|
||||||
|
notificationSettings: NotificationSettings!
|
||||||
|
|
||||||
"""A project of mine, or any public one (no sign-in needed)"""
|
"""A project of mine, or any public one (no sign-in needed)"""
|
||||||
project(
|
project(
|
||||||
@@ -489,12 +945,37 @@ type Query {
|
|||||||
publicProjects: [Project!]!
|
publicProjects: [Project!]!
|
||||||
release(id: ID!): Release!
|
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"""
|
"""Planned releases of all my projects, soonest target date first"""
|
||||||
upcomingReleases(limit: Int! = 10): [Release!]!
|
upcomingReleases(limit: Int! = 10): [Release!]!
|
||||||
user(id: String!): User
|
user(id: String!): User
|
||||||
|
|
||||||
"""Search users by name or email (to assign / add members)"""
|
"""Search users by name or email (to assign / add members)"""
|
||||||
users(limit: Int! = 20, search: String! = ""): [User!]!
|
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 {
|
type Release {
|
||||||
@@ -507,6 +988,14 @@ type Release {
|
|||||||
"""Tickets included in this release"""
|
"""Tickets included in this release"""
|
||||||
issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
|
issues(limit: Int! = 100, offset: Int! = 0): IssuePage!
|
||||||
name: String!
|
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!
|
progress: ReleaseProgress!
|
||||||
projectId: ID!
|
projectId: ID!
|
||||||
releasedAt: String
|
releasedAt: String
|
||||||
@@ -550,6 +1039,118 @@ enum ReleaseStatus {
|
|||||||
RELEASED
|
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.
|
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"""
|
"""Projects I am added to / removed from"""
|
||||||
myProjectsChanged: ProjectChange!
|
myProjectsChanged: ProjectChange!
|
||||||
|
|
||||||
|
"""My notifications, as they happen"""
|
||||||
|
notificationAdded: Notification!
|
||||||
projectChanged(projectId: ID!): ProjectChange!
|
projectChanged(projectId: ID!): ProjectChange!
|
||||||
releaseChanged(projectId: ID!): ReleaseChange!
|
releaseChanged(projectId: ID!): ReleaseChange!
|
||||||
|
sprintChanged(projectId: ID!): SprintChange!
|
||||||
}
|
}
|
||||||
|
|
||||||
type Throughput {
|
type Throughput {
|
||||||
@@ -591,6 +1196,13 @@ type Throughput {
|
|||||||
week: String!
|
week: String!
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input UpdateChecklistItemInput {
|
||||||
|
done: Boolean
|
||||||
|
issueId: ID!
|
||||||
|
itemId: ID!
|
||||||
|
text: String
|
||||||
|
}
|
||||||
|
|
||||||
input UpdateIssueInput {
|
input UpdateIssueInput {
|
||||||
"""An area id"""
|
"""An area id"""
|
||||||
area: String
|
area: String
|
||||||
@@ -602,6 +1214,9 @@ input UpdateIssueInput {
|
|||||||
issueId: ID!
|
issueId: ID!
|
||||||
labels: [String!]
|
labels: [String!]
|
||||||
priority: IssuePriority
|
priority: IssuePriority
|
||||||
|
|
||||||
|
"""When work should start (roadmap), YYYY-MM-DD"""
|
||||||
|
startDate: String
|
||||||
storyPoints: Int
|
storyPoints: Int
|
||||||
title: String
|
title: String
|
||||||
type: String
|
type: String
|
||||||
@@ -621,6 +1236,21 @@ input UpdateReleaseInput {
|
|||||||
targetDate: String
|
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"""
|
"""User managed by the Pireon identity server"""
|
||||||
type User {
|
type User {
|
||||||
email: String!
|
email: String!
|
||||||
@@ -634,6 +1264,39 @@ type UserRef {
|
|||||||
name: String!
|
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 {
|
type WorkflowStatus {
|
||||||
"""No new tickets or transitions into it"""
|
"""No new tickets or transitions into it"""
|
||||||
archived: Boolean!
|
archived: Boolean!
|
||||||
@@ -648,6 +1311,11 @@ type WorkflowStatus {
|
|||||||
|
|
||||||
"""Entering it needs a reason"""
|
"""Entering it needs a reason"""
|
||||||
requiresReason: Boolean!
|
requiresReason: Boolean!
|
||||||
|
|
||||||
|
"""
|
||||||
|
Work-in-progress limit: most tickets the column should hold (a warning, not a lock)
|
||||||
|
"""
|
||||||
|
wipLimit: Int
|
||||||
}
|
}
|
||||||
|
|
||||||
input WorkflowStatusInput {
|
input WorkflowStatusInput {
|
||||||
@@ -657,4 +1325,7 @@ input WorkflowStatusInput {
|
|||||||
id: String!
|
id: String!
|
||||||
name: String!
|
name: String!
|
||||||
requiresReason: Boolean! = false
|
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),
|
SWAGGER_ENABLED: Joi.boolean().default(true),
|
||||||
// Where to write the OpenAPI document on startup ('' = don't), like the GraphQL SDL.
|
// Where to write the OpenAPI document on startup ('' = don't), like the GraphQL SDL.
|
||||||
OPENAPI_FILE: Joi.string().allow('').default('apps/api/openapi.json'),
|
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 { INestApplication } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
|
import { DocumentBuilder, OpenAPIObject, SwaggerModule } from '@nestjs/swagger';
|
||||||
import {
|
import {
|
||||||
PIREON_BEARER,
|
PIREON_BEARER,
|
||||||
PIREON_OAUTH,
|
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).
|
For live updates use the GraphQL subscriptions at \`/graphql\` (graphql-ws).
|
||||||
`.trim();
|
`.trim();
|
||||||
|
|
||||||
/**
|
export interface OpenApiSettings {
|
||||||
* Builds the OpenAPI document from the REST controllers, serves Swagger UI and
|
pireonBaseUrl: string;
|
||||||
* (in development) writes it to OPENAPI_FILE so other apps can generate clients.
|
scopes: string[];
|
||||||
*/
|
passwordGrant: boolean;
|
||||||
export function setupOpenApi(app: INestApplication): void {
|
}
|
||||||
const config = app.get(ConfigService);
|
|
||||||
if (!config.get<boolean>('SWAGGER_ENABLED')) return;
|
|
||||||
|
|
||||||
const pireon =
|
/** The OpenAPI document of the REST controllers. Needs no database. */
|
||||||
config.get<string>('PIREON_BASE_URL') ?? 'https://oauth.pireon.dev';
|
export function buildOpenApiDocument(
|
||||||
const scopes = (config.get<string>('PIREON_SCOPES') ?? '')
|
app: INestApplication,
|
||||||
.split(/[\s,]+/)
|
settings: OpenApiSettings,
|
||||||
.filter(Boolean);
|
): OpenAPIObject {
|
||||||
|
const pireon = settings.pireonBaseUrl;
|
||||||
|
const scopes = settings.scopes;
|
||||||
const scopeDocs = Object.fromEntries(
|
const scopeDocs = Object.fromEntries(
|
||||||
scopes.map((scope) => [
|
scopes.map((scope) => [
|
||||||
scope,
|
scope,
|
||||||
@@ -76,7 +76,7 @@ export function setupOpenApi(app: INestApplication): void {
|
|||||||
refreshUrl: tokenUrl,
|
refreshUrl: tokenUrl,
|
||||||
scopes: scopeDocs,
|
scopes: scopeDocs,
|
||||||
},
|
},
|
||||||
...(config.get<boolean>('PIREON_PASSWORD_GRANT')
|
...(settings.passwordGrant
|
||||||
? {
|
? {
|
||||||
password: {
|
password: {
|
||||||
tokenUrl,
|
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');
|
const file = config.get<string>('OPENAPI_FILE');
|
||||||
if (file) {
|
if (file) {
|
||||||
writeFileSync(
|
writeFileSync(
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,8 @@ server {
|
|||||||
root /usr/share/nginx/html;
|
root /usr/share/nginx/html;
|
||||||
|
|
||||||
location /api/ {
|
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_pass http://task-stream-api:3000;
|
||||||
proxy_set_header Host $host;
|
proxy_set_header Host $host;
|
||||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ describe('appRoutes', () => {
|
|||||||
'',
|
'',
|
||||||
'board',
|
'board',
|
||||||
'issues/:issueKey',
|
'issues/:issueKey',
|
||||||
|
'backlog',
|
||||||
|
'sprints',
|
||||||
|
'calendar',
|
||||||
|
'roadmap',
|
||||||
|
'epics',
|
||||||
|
'trash',
|
||||||
'releases',
|
'releases',
|
||||||
'dashboard',
|
'dashboard',
|
||||||
'analytics',
|
'analytics',
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ import { filter, map } from 'rxjs';
|
|||||||
import { AuthFacade } from '@task-stream/web/identity/application';
|
import { AuthFacade } from '@task-stream/web/identity/application';
|
||||||
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
|
import { UserMenuComponent } from '@task-stream/web/identity/presenters';
|
||||||
import { AppShellComponent } from '@task-stream/web/shared/ui';
|
import { AppShellComponent } from '@task-stream/web/shared/ui';
|
||||||
|
import {
|
||||||
|
CommandPalette,
|
||||||
|
NotificationsBell,
|
||||||
|
} from '@task-stream/web/tracker/presenters';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'ts-root',
|
selector: 'ts-root',
|
||||||
@@ -21,6 +25,8 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
|||||||
RouterLinkActive,
|
RouterLinkActive,
|
||||||
AppShellComponent,
|
AppShellComponent,
|
||||||
UserMenuComponent,
|
UserMenuComponent,
|
||||||
|
NotificationsBell,
|
||||||
|
CommandPalette,
|
||||||
],
|
],
|
||||||
template: `
|
template: `
|
||||||
<ts-app-shell>
|
<ts-app-shell>
|
||||||
@@ -44,7 +50,12 @@ import { AppShellComponent } from '@task-stream/web/shared/ui';
|
|||||||
Explorar
|
Explorar
|
||||||
</a>
|
</a>
|
||||||
@if (auth.isAuthenticated()) {
|
@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()) {
|
} @else if (auth.settled() && !onLogin()) {
|
||||||
<a shellUser class="ts-nav-link ts-nav-link--cta" routerLink="/login"
|
<a shellUser class="ts-nav-link ts-nav-link--cta" routerLink="/login"
|
||||||
>Iniciar sesión</a
|
>Iniciar sesión</a
|
||||||
|
|||||||
@@ -56,6 +56,20 @@ services:
|
|||||||
# document is still served at /api/docs-json.
|
# document is still served at /api/docs-json.
|
||||||
GRAPHQL_SCHEMA_FILE: ''
|
GRAPHQL_SCHEMA_FILE: ''
|
||||||
OPENAPI_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:
|
depends_on:
|
||||||
postgres: { condition: service_healthy }
|
postgres: { condition: service_healthy }
|
||||||
mongo: { condition: service_healthy }
|
mongo: { condition: service_healthy }
|
||||||
@@ -125,6 +139,7 @@ services:
|
|||||||
retries: 10
|
retries: 10
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
|
attachments-data:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
mongo-data:
|
mongo-data:
|
||||||
redis-data:
|
redis-data:
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
## Qué resuelve
|
## Qué resuelve
|
||||||
|
|
||||||
| Necesidad | Cómo se resuelve |
|
| Necesidad | Cómo se resuelve |
|
||||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| 🐛 Bugs | Tickets de tipo `BUG` con prioridad, área y etiquetas |
|
| 🐛 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 |
|
| 🌍 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 |
|
| 🗑️ 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 |
|
| ⚙️ Features | Tipo `FEATURE` siguiendo el workflow completo |
|
||||||
| 🧪 QA | Por defecto, `READY_FOR_QA → IN_QA → DONE / QA_FAILED`; fallar QA exige un motivo |
|
| 🧪 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) |
|
| 🚀 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 |
|
| 👥 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) |
|
| 🔗 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 |
|
| 🏠 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 |
|
| 📊 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 |
|
| 📈 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.
|
uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada se pierde si la API se cae a mitad.
|
||||||
|
|
||||||
| Saga | Evento | Comandos |
|
| Saga | Evento | Comandos |
|
||||||
| --------------------- | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | ------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket y `DeleteReleaseCommand` por cada release del proyecto |
|
| `ProjectDeletionSaga` | `ProjectDeletedEvent` | `DeleteIssueCommand` por cada ticket, `DeleteReleaseCommand` por cada release y `DeleteSprintCommand` por cada sprint del proyecto |
|
||||||
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario y `DropLinksToIssueCommand` en los tickets que lo enlazaban |
|
| | `IssueDeletedEvent` | `PurgeCommentCommand` por cada comentario, `DropLinksToIssueCommand` en los tickets que lo enlazaban y `DetachFromParentCommand` en los que colgaban de él |
|
||||||
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
|
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
|
||||||
|
| `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») |
|
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |
|
||||||
|
|
||||||
Reglas que cumplen:
|
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
|
- **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).
|
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`.
|
- **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.
|
- **Comentarios**: solo el autor edita; borra el autor o un admin del proyecto.
|
||||||
- **Cambios de UI**: `figmaUrl` debe ser un enlace `https://…figma.com/…`.
|
- **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
|
### 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).
|
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
|
||||||
|
|
||||||
| Agregado | Eventos |
|
| Agregado | Eventos |
|
||||||
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
|
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
|
||||||
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `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` |
|
| 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
|
`IssueStatusChangedEvent` lleva además `meta` con el nombre y la categoría de los dos estados en el momento del
|
||||||
cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado.
|
cambio, para que el historial y las estadísticas no cambien si luego se renombra o recategoriza un estado.
|
||||||
@@ -174,6 +278,7 @@ puras (`application/src/lib/analytics`):
|
|||||||
| Tiempo de entrega | De la creación hasta `DONE`; media y percentil 85 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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,
|
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
|
### Queries
|
||||||
|
|
||||||
| Query | Devuelve |
|
| Query | Devuelve |
|
||||||
| ------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `me` | El usuario autenticado, con sus roles en Pireon |
|
| `me` | El usuario autenticado, con sus roles en Pireon |
|
||||||
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
| `users(search, limit)` | Usuarios de Pireon (para asignar o añadir miembros) |
|
||||||
| `user(id)` | Un usuario |
|
| `user(id)` | Un usuario |
|
||||||
| `myProjects` | Proyectos de los que soy miembro |
|
| `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 |
|
| `upcomingReleases(limit)` | Releases planificadas de mis proyectos, por fecha objetivo |
|
||||||
| `publicProjects` | Proyectos públicos (sin token) |
|
| `publicProjects` | Proyectos públicos (sin token) |
|
||||||
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, area, perColumn)`, `stats`, `analytics(days)`, `releases` |
|
| `project(idOrKey)` | Un proyecto (mío, o público) con su `settings`. Campos calculados: `board(releaseId, assigneeId, 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, texto) |
|
| `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` |
|
| `issue(idOrKey)` | Un ticket. Campos calculados: `allowedTransitions`, `linkedIssues`, `comments`, `history` |
|
||||||
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
|
| `release(id)` | Una release. Campos calculados: `progress`, `burnup`, `issues` |
|
||||||
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
|
| `activity(projectId, issueId?, limit, before?)` | Feed de actividad, del más reciente al más antiguo |
|
||||||
@@ -207,11 +317,16 @@ que funcionan también sin token.
|
|||||||
Todas devuelven `CommandResult { id, key, version }`.
|
Todas devuelven `CommandResult { id, key, version }`.
|
||||||
|
|
||||||
| Ámbito | Mutations |
|
| Ámbito | Mutations |
|
||||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
| ----------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
| Proyectos | `createProject`, `updateProject`, `updateProjectSettings`, `deleteProject(confirmKey)`, `addProjectMember`, `changeProjectMemberRole`, `removeProjectMember` |
|
||||||
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease` |
|
| Tickets | `createIssue`, `updateIssue`, `changeIssueStatus`, `assignIssue`, `linkIssues`, `unlinkIssues`, `setIssueRelease`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
|
||||||
| Comentarios | `addComment`, `editComment`, `deleteComment` |
|
| 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
|
### 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) |
|
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) |
|
||||||
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
|
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
|
||||||
| `releaseChanged(projectId)` | Cambia una release |
|
| `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 |
|
| `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 |
|
| `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`).
|
Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
||||||
|
|
||||||
| Recurso | Rutas |
|
| Recurso | Rutas |
|
||||||
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
| Usuarios | `GET /api/users/me`, `GET /api/users?search=`, `GET /api/users/{id}` |
|
||||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||||
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
|
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
|
||||||
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
|
| Comentarios | `PATCH/DELETE /api/comments/{commentId}` |
|
||||||
| 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) |
|
| 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` |
|
| 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
|
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
|
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
|
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 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-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 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 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 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) |
|
| `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`.
|
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).
|
- 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.
|
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
|
### Web
|
||||||
|
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ donde los lee Compose.
|
|||||||
## 7.2 Variables de entorno de la API
|
## 7.2 Variables de entorno de la API
|
||||||
|
|
||||||
| Variable | Por defecto | Descripción |
|
| Variable | Por defecto | Descripción |
|
||||||
| ------------------------------------------- | -------------------------- | ------------------------------------------------------------------------------------- |
|
| ------------------------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `PORT` | `3000` | Puerto HTTP |
|
| `PORT` | `3000` | Puerto HTTP |
|
||||||
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
|
| `NODE_ENV` | `development` | En `production` no se devuelven stacktraces |
|
||||||
| `POSTGRES_URL` | — (obligatoria) | Event store y lookups |
|
| `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) |
|
| `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` |
|
| `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) |
|
| `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`).
|
La web no tiene configuración de entorno: llama a la API en su mismo origen (`/api`, `/graphql`).
|
||||||
|
|
||||||
## 7.3 Escalado
|
## 7.3 Escalado
|
||||||
|
|
||||||
- **API**: es _stateless_ (sesiones en Redis, subscriptions por Redis pub/sub), así que admite varias réplicas detrás
|
- **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.
|
- **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**
|
- **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
|
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.
|
- 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).
|
- 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.
|
- 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
|
## 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
|
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.
|
`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/releases.service';
|
||||||
export * from './lib/changes.service';
|
export * from './lib/changes.service';
|
||||||
export * from './lib/workflows.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';
|
export * from './lib/tracker-application.module';
|
||||||
|
|||||||
@@ -4,10 +4,13 @@ import {
|
|||||||
ReleaseReadModel,
|
ReleaseReadModel,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
StatusCategory as C,
|
StatusCategory as C,
|
||||||
|
SprintReadModel,
|
||||||
|
SprintStatus,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import {
|
import {
|
||||||
computeProjectAnalytics,
|
computeProjectAnalytics,
|
||||||
computeReleaseBurnup,
|
computeReleaseBurnup,
|
||||||
|
computeSprintBurndown,
|
||||||
weekOf,
|
weekOf,
|
||||||
} from './analytics';
|
} from './analytics';
|
||||||
|
|
||||||
@@ -182,3 +185,62 @@ describe('release burnup', () => {
|
|||||||
expect(at(burnup.done, '2026-10-14')).toBe(2);
|
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,
|
ProjectAnalyticsReadModel,
|
||||||
ReleaseBurnupReadModel,
|
ReleaseBurnupReadModel,
|
||||||
ReleaseReadModel,
|
ReleaseReadModel,
|
||||||
|
SprintBurndownReadModel,
|
||||||
|
SprintReadModel,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
UserRefReadModel,
|
UserRefReadModel,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
@@ -313,3 +315,85 @@ export function computeReleaseBurnup(
|
|||||||
done,
|
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,
|
IssueChange,
|
||||||
ProjectChange,
|
ProjectChange,
|
||||||
ReleaseChange,
|
ReleaseChange,
|
||||||
|
SprintChange,
|
||||||
} from './ports/change-feed';
|
} from './ports/change-feed';
|
||||||
import { CanViewProjectQuery } from './queries/can-view-project.query';
|
import { CanViewProjectQuery } from './queries/can-view-project.query';
|
||||||
import { GetIssueQuery } from './queries/get-issue.query';
|
import { GetIssueQuery } from './queries/get-issue.query';
|
||||||
@@ -52,6 +53,14 @@ export class ChangesService {
|
|||||||
return this.changeFeed.releases(projectId);
|
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(
|
async watchProject(
|
||||||
viewerId: string,
|
viewerId: string,
|
||||||
projectId: 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 { DropLinksToIssueCommandHandler } from './drop-links-to-issue.command-handler';
|
||||||
import { DetachIssueFromReleaseCommandHandler } from './detach-issue-from-release.command-handler';
|
import { DetachIssueFromReleaseCommandHandler } from './detach-issue-from-release.command-handler';
|
||||||
import { UnassignIssueCommandHandler } from './unassign-issue.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 = [
|
export const COMMAND_HANDLERS = [
|
||||||
CreateProjectCommandHandler,
|
CreateProjectCommandHandler,
|
||||||
@@ -55,4 +70,18 @@ export const COMMAND_HANDLERS = [
|
|||||||
DropLinksToIssueCommandHandler,
|
DropLinksToIssueCommandHandler,
|
||||||
DetachIssueFromReleaseCommandHandler,
|
DetachIssueFromReleaseCommandHandler,
|
||||||
UnassignIssueCommandHandler,
|
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';
|
} from '@task-stream/api/shared/domain';
|
||||||
import {
|
import {
|
||||||
Comment,
|
Comment,
|
||||||
|
Issue,
|
||||||
IssueStatus,
|
IssueStatus,
|
||||||
Project,
|
Project,
|
||||||
IssueType,
|
IssueType,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
TRACKER_FACTORIES,
|
TRACKER_FACTORIES,
|
||||||
|
defaultProjectSettings,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import {
|
import {
|
||||||
FakeUserDirectory,
|
FakeUserDirectory,
|
||||||
|
InMemoryAttachmentStorage,
|
||||||
InMemoryEventStore,
|
InMemoryEventStore,
|
||||||
InMemoryEventStorePublisher,
|
InMemoryEventStorePublisher,
|
||||||
InMemoryIssueNumbers,
|
InMemoryIssueNumbers,
|
||||||
InMemoryProjectKeys,
|
InMemoryProjectKeys,
|
||||||
InMemoryReleaseNames,
|
InMemoryReleaseNames,
|
||||||
InMemoryReleaseIssues,
|
InMemoryReleaseIssues,
|
||||||
|
InMemoryTrackerStreams,
|
||||||
} from '../../testing/in-memory';
|
} 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 { ReleaseIssuesRepository } from '../ports/release-issues.repository';
|
||||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||||
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
||||||
import { ReleaseNamesRepository } from '../ports/release-names.repository';
|
import { ReleaseNamesRepository } from '../ports/release-names.repository';
|
||||||
import { UserDirectory } from '../ports/user-directory';
|
import { UserDirectory } from '../ports/user-directory';
|
||||||
|
import { AttachmentStorage } from '../ports/attachment-storage';
|
||||||
import { AddCommentCommand } from './add-comment.command';
|
import { AddCommentCommand } from './add-comment.command';
|
||||||
import { AddProjectMemberCommand } from './add-project-member.command';
|
import { AddProjectMemberCommand } from './add-project-member.command';
|
||||||
import { AssignIssueCommand } from './assign-issue.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 { ChangeIssueStatusCommand } from './change-issue-status.command';
|
||||||
import { COMMAND_HANDLERS } from './command-handlers';
|
import { COMMAND_HANDLERS } from './command-handlers';
|
||||||
import { CommandResult } from './command-result';
|
import { CommandResult } from './command-result';
|
||||||
@@ -45,9 +78,20 @@ import { DeleteCommentCommand } from './delete-comment.command';
|
|||||||
import { PublishReleaseCommand } from './publish-release.command';
|
import { PublishReleaseCommand } from './publish-release.command';
|
||||||
import { SetIssueReleaseCommand } from './set-issue-release.command';
|
import { SetIssueReleaseCommand } from './set-issue-release.command';
|
||||||
import { WriteAccess } from './write-access';
|
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() {
|
async function setup() {
|
||||||
const store = new InMemoryEventStore();
|
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()
|
const users = new FakeUserDirectory()
|
||||||
.add('owner')
|
.add('owner')
|
||||||
.add('dev')
|
.add('dev')
|
||||||
@@ -58,10 +102,20 @@ async function setup() {
|
|||||||
imports: [CqrsModule.forRoot()],
|
imports: [CqrsModule.forRoot()],
|
||||||
providers: [
|
providers: [
|
||||||
...COMMAND_HANDLERS,
|
...COMMAND_HANDLERS,
|
||||||
|
...GIT_HANDLERS,
|
||||||
|
RunAutomationCommandHandler,
|
||||||
...TRACKER_FACTORIES,
|
...TRACKER_FACTORIES,
|
||||||
WriteAccess,
|
WriteAccess,
|
||||||
|
IssueTree,
|
||||||
|
ReadAccess,
|
||||||
|
{
|
||||||
|
provide: FindProjectsRepository,
|
||||||
|
useValue: { findRole: async () => null, findById: async () => null },
|
||||||
|
},
|
||||||
|
{ provide: GitIntegrationsRepository, useValue: {} },
|
||||||
AggregateRehydrator,
|
AggregateRehydrator,
|
||||||
{ provide: EventStore, useValue: store },
|
{ provide: EventStore, useValue: store },
|
||||||
|
{ provide: AttachmentStorage, useValue: files },
|
||||||
{ provide: UserDirectory, useValue: users },
|
{ provide: UserDirectory, useValue: users },
|
||||||
{ provide: ProjectKeysRepository, useClass: InMemoryProjectKeys },
|
{ provide: ProjectKeysRepository, useClass: InMemoryProjectKeys },
|
||||||
{ provide: IssueNumbersRepository, useClass: InMemoryIssueNumbers },
|
{ provide: IssueNumbersRepository, useClass: InMemoryIssueNumbers },
|
||||||
@@ -70,6 +124,26 @@ async function setup() {
|
|||||||
provide: ReleaseIssuesRepository,
|
provide: ReleaseIssuesRepository,
|
||||||
useValue: new InMemoryReleaseIssues(store),
|
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();
|
}).compile();
|
||||||
await moduleRef.init();
|
await moduleRef.init();
|
||||||
@@ -78,7 +152,14 @@ async function setup() {
|
|||||||
const commandBus = moduleRef.get(CommandBus);
|
const commandBus = moduleRef.get(CommandBus);
|
||||||
const run = (command: object): Promise<CommandResult> =>
|
const run = (command: object): Promise<CommandResult> =>
|
||||||
commandBus.execute(command as never);
|
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>) {
|
async function projectWithTeam(run: (c: object) => Promise<CommandResult>) {
|
||||||
@@ -178,6 +259,432 @@ describe('tracker commands (event sourced)', () => {
|
|||||||
).rejects.toThrow(BusinessRuleError);
|
).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 () => {
|
it('does not let viewers modify tickets', async () => {
|
||||||
const { run } = await setup();
|
const { run } = await setup();
|
||||||
const projectId = await projectWithTeam(run);
|
const projectId = await projectWithTeam(run);
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
|
import { randomUUID } from 'node:crypto';
|
||||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||||
import { IssueFactory } from '@task-stream/api/tracker/domain';
|
import { IssueFactory, assertCanNest } from '@task-stream/api/tracker/domain';
|
||||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||||
import { CommandResult, ack } from './command-result';
|
import { CommandResult, ack } from './command-result';
|
||||||
import { CreateIssueCommand } from './create-issue.command';
|
import { CreateIssueCommand } from './create-issue.command';
|
||||||
|
import { IssueTree } from './issue-tree';
|
||||||
import { WriteAccess } from './write-access';
|
import { WriteAccess } from './write-access';
|
||||||
|
|
||||||
@CommandHandler(CreateIssueCommand)
|
@CommandHandler(CreateIssueCommand)
|
||||||
@@ -14,6 +16,7 @@ export class CreateIssueCommandHandler
|
|||||||
private readonly issueFactory: IssueFactory,
|
private readonly issueFactory: IssueFactory,
|
||||||
private readonly issueNumbers: IssueNumbersRepository,
|
private readonly issueNumbers: IssueNumbersRepository,
|
||||||
private readonly eventPublisher: EventPublisher,
|
private readonly eventPublisher: EventPublisher,
|
||||||
|
private readonly tree: IssueTree,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async execute(command: CreateIssueCommand): Promise<CommandResult> {
|
async execute(command: CreateIssueCommand): Promise<CommandResult> {
|
||||||
@@ -27,10 +30,24 @@ export class CreateIssueCommandHandler
|
|||||||
this.access.assertAssignable(project, command.assigneeId);
|
this.access.assertAssignable(project, command.assigneeId);
|
||||||
if (command.releaseId)
|
if (command.releaseId)
|
||||||
await this.access.openReleaseOf(project, command.releaseId);
|
await this.access.openReleaseOf(project, command.releaseId);
|
||||||
|
const parent = command.parentId
|
||||||
|
? await this.tree.candidate(command.parentId)
|
||||||
|
: null;
|
||||||
|
if (parent)
|
||||||
|
assertCanNest(
|
||||||
|
{ id: '', key: 'The new ticket', projectId: project.id },
|
||||||
|
parent,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
|
const template = command.useTemplate
|
||||||
|
? workflow.templateOf(command.type)
|
||||||
|
: null;
|
||||||
const issue = this.issueFactory.create(
|
const issue = this.issueFactory.create(
|
||||||
{
|
{
|
||||||
...command.details,
|
...command.details,
|
||||||
|
description:
|
||||||
|
command.details.description?.trim() || template?.description || null,
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
projectKey: project.key,
|
projectKey: project.key,
|
||||||
number: await this.issueNumbers.next(project.id),
|
number: await this.issueNumbers.next(project.id),
|
||||||
@@ -38,10 +55,14 @@ export class CreateIssueCommandHandler
|
|||||||
title: command.title,
|
title: command.title,
|
||||||
reporterId: command.actorId,
|
reporterId: command.actorId,
|
||||||
assigneeId: command.assigneeId,
|
assigneeId: command.assigneeId,
|
||||||
|
status: command.initialStatus,
|
||||||
},
|
},
|
||||||
workflow,
|
workflow,
|
||||||
);
|
);
|
||||||
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
|
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
|
||||||
|
if (parent) issue.setParent(command.actorId, parent);
|
||||||
|
for (const step of template?.checklist ?? [])
|
||||||
|
issue.addChecklistItem(command.actorId, randomUUID(), step);
|
||||||
|
|
||||||
this.eventPublisher.mergeObjectContext(issue);
|
this.eventPublisher.mergeObjectContext(issue);
|
||||||
await issue.commitAndWait();
|
await issue.commitAndWait();
|
||||||
|
|||||||
@@ -12,6 +12,12 @@ export class CreateIssueCommand extends Command<CommandResult> {
|
|||||||
public readonly details: Omit<IssueDetails, 'title' | 'type'>,
|
public readonly details: Omit<IssueDetails, 'title' | 'type'>,
|
||||||
public readonly assigneeId?: string | null,
|
public readonly assigneeId?: string | null,
|
||||||
public readonly releaseId?: string | null,
|
public readonly releaseId?: string | null,
|
||||||
|
/** Epic or ticket the new one breaks down. */
|
||||||
|
public readonly parentId?: string | null,
|
||||||
|
/** 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();
|
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 './drop-links-to-issue.command';
|
||||||
export * from './detach-issue-from-release.command';
|
export * from './detach-issue-from-release.command';
|
||||||
export * from './unassign-issue.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,
|
Issue,
|
||||||
Project,
|
Project,
|
||||||
Release,
|
Release,
|
||||||
|
Sprint,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import { UserDirectory } from '../ports/user-directory';
|
import { UserDirectory } from '../ports/user-directory';
|
||||||
|
|
||||||
@@ -38,6 +39,22 @@ export class WriteAccess {
|
|||||||
return this.load(id, Release);
|
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> {
|
comment(id: string): Promise<Comment> {
|
||||||
return this.load(id, Comment);
|
return this.load(id, Comment);
|
||||||
}
|
}
|
||||||
@@ -50,6 +67,10 @@ export class WriteAccess {
|
|||||||
const issue = await this.issue(issueId);
|
const issue = await this.issue(issueId);
|
||||||
const project = await this.project(issue.projectId);
|
const project = await this.project(issue.projectId);
|
||||||
project.assertCanContribute(actorId);
|
project.assertCanContribute(actorId);
|
||||||
|
if (issue.trashedAt)
|
||||||
|
throw new BusinessRuleError(
|
||||||
|
`${issue.key} is in the trash: restore it to change it`,
|
||||||
|
);
|
||||||
return { issue, project };
|
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,
|
CommentDeletedEvent,
|
||||||
CommentEditedEvent,
|
CommentEditedEvent,
|
||||||
IssueAssignedEvent,
|
IssueAssignedEvent,
|
||||||
|
IssueAttachmentAddedEvent,
|
||||||
|
IssueAttachmentRemovedEvent,
|
||||||
|
IssueChecklistItemAddedEvent,
|
||||||
|
IssueChecklistItemRemovedEvent,
|
||||||
|
IssueChecklistItemUpdatedEvent,
|
||||||
|
IssueParentChangedEvent,
|
||||||
|
IssueDevLinkRecordedEvent,
|
||||||
|
IssueTrashedEvent,
|
||||||
|
IssueRestoredEvent,
|
||||||
IssueCreatedEvent,
|
IssueCreatedEvent,
|
||||||
IssueLinkedEvent,
|
IssueLinkedEvent,
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
@@ -19,9 +28,16 @@ import {
|
|||||||
ProjectUpdatedEvent,
|
ProjectUpdatedEvent,
|
||||||
ProjectWorkflow,
|
ProjectWorkflow,
|
||||||
ReleaseArchivedEvent,
|
ReleaseArchivedEvent,
|
||||||
|
ReleaseNotesWrittenEvent,
|
||||||
ReleaseCreatedEvent,
|
ReleaseCreatedEvent,
|
||||||
ReleaseShippedEvent,
|
ReleaseShippedEvent,
|
||||||
ReleaseUpdatedEvent,
|
ReleaseUpdatedEvent,
|
||||||
|
IssueSprintChangedEvent,
|
||||||
|
SprintClosedEvent,
|
||||||
|
SprintCreatedEvent,
|
||||||
|
SprintDeletedEvent,
|
||||||
|
SprintStartedEvent,
|
||||||
|
SprintUpdatedEvent,
|
||||||
TrackerEvent,
|
TrackerEvent,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
@@ -31,6 +47,8 @@ export interface SummaryContext {
|
|||||||
issueKey: string;
|
issueKey: string;
|
||||||
projectKey: string;
|
projectKey: string;
|
||||||
releaseName: string;
|
releaseName: string;
|
||||||
|
/** '' when unknown (e.g. the sprint was deleted). */
|
||||||
|
sprintName: string;
|
||||||
/** The project's workflow: names of statuses and types. */
|
/** The project's workflow: names of statuses and types. */
|
||||||
workflow: ProjectWorkflow;
|
workflow: ProjectWorkflow;
|
||||||
}
|
}
|
||||||
@@ -70,6 +88,7 @@ const FIELD: Record<string, string> = {
|
|||||||
labels: 'las etiquetas',
|
labels: 'las etiquetas',
|
||||||
figmaUrl: 'el enlace de Figma',
|
figmaUrl: 'el enlace de Figma',
|
||||||
storyPoints: 'los puntos',
|
storyPoints: 'los puntos',
|
||||||
|
startDate: 'la fecha de inicio',
|
||||||
dueDate: 'la fecha límite',
|
dueDate: 'la fecha límite',
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -135,6 +154,51 @@ export function describeEvent(
|
|||||||
: `${actor} sacó ${issueKey} de su release`;
|
: `${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)
|
if (event instanceof CommentAddedEvent)
|
||||||
return `${actor} comentó en ${issueKey}`;
|
return `${actor} comentó en ${issueKey}`;
|
||||||
if (event instanceof CommentEditedEvent)
|
if (event instanceof CommentEditedEvent)
|
||||||
@@ -148,7 +212,26 @@ export function describeEvent(
|
|||||||
return `${actor} actualizó la release ${ctx.releaseName}`;
|
return `${actor} actualizó la release ${ctx.releaseName}`;
|
||||||
if (event instanceof ReleaseShippedEvent)
|
if (event instanceof ReleaseShippedEvent)
|
||||||
return `${actor} publicó la release ${ctx.releaseName} 🚀`;
|
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)
|
if (event instanceof ReleaseArchivedEvent)
|
||||||
return `${actor} archivó la release ${ctx.releaseName}`;
|
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}`;
|
return `${actor}: ${event.constructor.name}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,15 @@ import {
|
|||||||
CommentDeletedEvent,
|
CommentDeletedEvent,
|
||||||
CommentEditedEvent,
|
CommentEditedEvent,
|
||||||
IssueAssignedEvent,
|
IssueAssignedEvent,
|
||||||
|
IssueAttachmentAddedEvent,
|
||||||
|
IssueAttachmentRemovedEvent,
|
||||||
|
IssueChecklistItemAddedEvent,
|
||||||
|
IssueChecklistItemRemovedEvent,
|
||||||
|
IssueChecklistItemUpdatedEvent,
|
||||||
|
IssueParentChangedEvent,
|
||||||
|
IssueDevLinkRecordedEvent,
|
||||||
|
IssueTrashedEvent,
|
||||||
|
IssueRestoredEvent,
|
||||||
IssueCreatedEvent,
|
IssueCreatedEvent,
|
||||||
IssueLinkedEvent,
|
IssueLinkedEvent,
|
||||||
IssueReleaseChangedEvent,
|
IssueReleaseChangedEvent,
|
||||||
@@ -17,6 +26,13 @@ import {
|
|||||||
ProjectMemberRoleChangedEvent,
|
ProjectMemberRoleChangedEvent,
|
||||||
ProjectUpdatedEvent,
|
ProjectUpdatedEvent,
|
||||||
ReleaseArchivedEvent,
|
ReleaseArchivedEvent,
|
||||||
|
ReleaseNotesWrittenEvent,
|
||||||
|
IssueSprintChangedEvent,
|
||||||
|
SprintClosedEvent,
|
||||||
|
SprintCreatedEvent,
|
||||||
|
SprintDeletedEvent,
|
||||||
|
SprintStartedEvent,
|
||||||
|
SprintUpdatedEvent,
|
||||||
ReleaseCreatedEvent,
|
ReleaseCreatedEvent,
|
||||||
ReleaseShippedEvent,
|
ReleaseShippedEvent,
|
||||||
ReleaseUpdatedEvent,
|
ReleaseUpdatedEvent,
|
||||||
@@ -31,6 +47,7 @@ import { ChangeFeed } from '../ports/change-feed';
|
|||||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
import { FindReleasesRepository } from '../ports/find-releases.repository';
|
||||||
|
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||||
import { UpsertMaterializedActivityRepository } from '../ports/upsert-materialized-activity.repository';
|
import { UpsertMaterializedActivityRepository } from '../ports/upsert-materialized-activity.repository';
|
||||||
import { UserDirectory } from '../ports/user-directory';
|
import { UserDirectory } from '../ports/user-directory';
|
||||||
import { describeEvent } from './activity-summary';
|
import { describeEvent } from './activity-summary';
|
||||||
@@ -51,6 +68,15 @@ import { versionOf } from './read-model-notifier';
|
|||||||
IssueLinkedEvent,
|
IssueLinkedEvent,
|
||||||
IssueUnlinkedEvent,
|
IssueUnlinkedEvent,
|
||||||
IssueReleaseChangedEvent,
|
IssueReleaseChangedEvent,
|
||||||
|
IssueAttachmentAddedEvent,
|
||||||
|
IssueAttachmentRemovedEvent,
|
||||||
|
IssueParentChangedEvent,
|
||||||
|
IssueDevLinkRecordedEvent,
|
||||||
|
IssueTrashedEvent,
|
||||||
|
IssueRestoredEvent,
|
||||||
|
IssueChecklistItemAddedEvent,
|
||||||
|
IssueChecklistItemUpdatedEvent,
|
||||||
|
IssueChecklistItemRemovedEvent,
|
||||||
CommentAddedEvent,
|
CommentAddedEvent,
|
||||||
CommentEditedEvent,
|
CommentEditedEvent,
|
||||||
CommentDeletedEvent,
|
CommentDeletedEvent,
|
||||||
@@ -58,6 +84,13 @@ import { versionOf } from './read-model-notifier';
|
|||||||
ReleaseUpdatedEvent,
|
ReleaseUpdatedEvent,
|
||||||
ReleaseShippedEvent,
|
ReleaseShippedEvent,
|
||||||
ReleaseArchivedEvent,
|
ReleaseArchivedEvent,
|
||||||
|
ReleaseNotesWrittenEvent,
|
||||||
|
IssueSprintChangedEvent,
|
||||||
|
SprintCreatedEvent,
|
||||||
|
SprintUpdatedEvent,
|
||||||
|
SprintStartedEvent,
|
||||||
|
SprintClosedEvent,
|
||||||
|
SprintDeletedEvent,
|
||||||
)
|
)
|
||||||
export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -65,6 +98,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
private readonly findProjects: FindProjectsRepository,
|
private readonly findProjects: FindProjectsRepository,
|
||||||
private readonly findIssues: FindIssuesRepository,
|
private readonly findIssues: FindIssuesRepository,
|
||||||
private readonly findReleases: FindReleasesRepository,
|
private readonly findReleases: FindReleasesRepository,
|
||||||
|
private readonly findSprints: FindSprintsRepository,
|
||||||
private readonly users: UserDirectory,
|
private readonly users: UserDirectory,
|
||||||
private readonly changeFeed: ChangeFeed,
|
private readonly changeFeed: ChangeFeed,
|
||||||
) {}
|
) {}
|
||||||
@@ -81,6 +115,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
const release = releaseId
|
const release = releaseId
|
||||||
? await this.findReleases.findById(releaseId)
|
? await this.findReleases.findById(releaseId)
|
||||||
: null;
|
: null;
|
||||||
|
const sprint = await this.sprintOf(event);
|
||||||
const project = await this.findProjects.findById(event.projectId);
|
const project = await this.findProjects.findById(event.projectId);
|
||||||
// The project was deleted (cascade in progress): no feed to write to.
|
// The project was deleted (cascade in progress): no feed to write to.
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
@@ -106,6 +141,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
issueKey: issueKey ?? '',
|
issueKey: issueKey ?? '',
|
||||||
projectKey: project?.key ?? '',
|
projectKey: project?.key ?? '',
|
||||||
releaseName: release?.name ?? '',
|
releaseName: release?.name ?? '',
|
||||||
|
sprintName: sprint,
|
||||||
workflow: new ProjectWorkflow(
|
workflow: new ProjectWorkflow(
|
||||||
project?.settings ?? DEFAULT_PROJECT_SETTINGS,
|
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 {
|
private issueIdOf(event: TrackerEvent): string | null {
|
||||||
if (event.constructor.name.startsWith('Issue')) return event.aggregateId;
|
if (event.constructor.name.startsWith('Issue')) return event.aggregateId;
|
||||||
if (event instanceof CommentAddedEvent) return event.comment.issueId;
|
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 { ProjectDeletedEventHandler } from './project-deleted.event-handler';
|
||||||
import { IssueDeletedEventHandler } from './issue-deleted.event-handler';
|
import { IssueDeletedEventHandler } from './issue-deleted.event-handler';
|
||||||
import { ReleaseDeletedEventHandler } from './release-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. */
|
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
||||||
export const EVENT_HANDLERS = [
|
export const EVENT_HANDLERS = [
|
||||||
@@ -52,6 +64,18 @@ export const EVENT_HANDLERS = [
|
|||||||
ReleaseCreatedEventHandler,
|
ReleaseCreatedEventHandler,
|
||||||
ReleaseShippedEventHandler,
|
ReleaseShippedEventHandler,
|
||||||
ReleaseUpdatedEventHandler,
|
ReleaseUpdatedEventHandler,
|
||||||
|
IssueAttachmentAddedEventHandler,
|
||||||
|
IssueAttachmentRemovedEventHandler,
|
||||||
|
AttachmentCleanupEventHandler,
|
||||||
|
IssueWatchersEventHandler,
|
||||||
|
IssueParentChangedEventHandler,
|
||||||
|
IssueChecklistEventHandler,
|
||||||
|
IssueRankedEventHandler,
|
||||||
|
SprintEventHandler,
|
||||||
|
ReleaseNotesWrittenEventHandler,
|
||||||
|
IssueDevLinkRecordedEventHandler,
|
||||||
|
IssueTrashEventHandler,
|
||||||
|
IssueSprintChangedEventHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { ReadModelNotifier };
|
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 {
|
import {
|
||||||
DEFAULT_PROJECT_SETTINGS,
|
DEFAULT_PROJECT_SETTINGS,
|
||||||
IssueCreatedEvent,
|
IssueCreatedEvent,
|
||||||
|
implicitRank,
|
||||||
ProjectWorkflow,
|
ProjectWorkflow,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
@@ -48,9 +49,13 @@ export class IssueCreatedEventHandler
|
|||||||
reporter: users.of(issue.reporterId),
|
reporter: users.of(issue.reporterId),
|
||||||
assignee: users.ofNullable(issue.assigneeId),
|
assignee: users.ofNullable(issue.assigneeId),
|
||||||
release: release ? { id: release.id, name: release.name } : null,
|
release: release ? { id: release.id, name: release.name } : null,
|
||||||
|
parent: null,
|
||||||
|
sprint: null,
|
||||||
|
rank: implicitRank(issue.number),
|
||||||
labels: issue.labels,
|
labels: issue.labels,
|
||||||
figmaUrl: issue.figmaUrl,
|
figmaUrl: issue.figmaUrl,
|
||||||
storyPoints: issue.storyPoints,
|
storyPoints: issue.storyPoints,
|
||||||
|
startDate: issue.startDate ?? null,
|
||||||
dueDate: issue.dueDate,
|
dueDate: issue.dueDate,
|
||||||
createdAt: issue.createdAt,
|
createdAt: issue.createdAt,
|
||||||
updatedAt: issue.updatedAt,
|
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,
|
DEFAULT_PROJECT_SETTINGS,
|
||||||
IssueCreatedEvent,
|
IssueCreatedEvent,
|
||||||
IssueReleaseChangedEvent,
|
IssueReleaseChangedEvent,
|
||||||
|
IssueSprintChangedEvent,
|
||||||
IssueStatusChangedEvent,
|
IssueStatusChangedEvent,
|
||||||
ProjectWorkflow,
|
ProjectWorkflow,
|
||||||
TrackerEvent,
|
TrackerEvent,
|
||||||
@@ -14,12 +15,13 @@ import { versionOf } from './read-model-notifier';
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* The life of every ticket, step by step: where it went (status + category)
|
* 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(
|
@EventsHandler(
|
||||||
IssueCreatedEvent,
|
IssueCreatedEvent,
|
||||||
IssueStatusChangedEvent,
|
IssueStatusChangedEvent,
|
||||||
IssueReleaseChangedEvent,
|
IssueReleaseChangedEvent,
|
||||||
|
IssueSprintChangedEvent,
|
||||||
)
|
)
|
||||||
export class IssueTimelineEventHandler implements IEventHandler<TrackerEvent> {
|
export class IssueTimelineEventHandler implements IEventHandler<TrackerEvent> {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -66,6 +68,14 @@ export class IssueTimelineEventHandler implements IEventHandler<TrackerEvent> {
|
|||||||
kind: 'release',
|
kind: 'release',
|
||||||
releaseId: event.releaseId,
|
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,
|
updatedAt: event.occurredAt,
|
||||||
version: versionOf(event),
|
version: versionOf(event),
|
||||||
});
|
});
|
||||||
|
// Its subtasks show its title.
|
||||||
|
if (event.changes.title !== undefined)
|
||||||
|
await this.upsertMaterializedIssueRepository.renameParent(
|
||||||
|
event.aggregateId,
|
||||||
|
event.changes.title,
|
||||||
|
);
|
||||||
await this.notifier.issue(event.aggregateId, event);
|
await this.notifier.issue(event.aggregateId, event);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
import { FindProjectsRepository } from '../ports/find-projects.repository';
|
||||||
import { FindReleasesRepository } from '../ports/find-releases.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). */
|
/** Position of the event in its stream (stamped by the event store). */
|
||||||
export function versionOf(event: TrackerEvent): number {
|
export function versionOf(event: TrackerEvent): number {
|
||||||
@@ -23,6 +24,7 @@ export class ReadModelNotifier {
|
|||||||
private readonly findIssues: FindIssuesRepository,
|
private readonly findIssues: FindIssuesRepository,
|
||||||
private readonly findComments: FindCommentsRepository,
|
private readonly findComments: FindCommentsRepository,
|
||||||
private readonly findReleases: FindReleasesRepository,
|
private readonly findReleases: FindReleasesRepository,
|
||||||
|
private readonly findSprints: FindSprintsRepository,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async project(
|
async project(
|
||||||
@@ -63,6 +65,16 @@ export class ReadModelNotifier {
|
|||||||
await this.changeFeed.publishRelease({ ...this.meta(event), release });
|
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) {
|
private meta(event: TrackerEvent) {
|
||||||
return {
|
return {
|
||||||
action: event.constructor.name,
|
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 { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||||
import {
|
import {
|
||||||
ActivityReadModel,
|
ActivityReadModel,
|
||||||
|
EpicReadModel,
|
||||||
IssueDetails,
|
IssueDetails,
|
||||||
IssueLinkType,
|
IssueLinkType,
|
||||||
IssueReadModel,
|
IssueReadModel,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import { AssignIssueCommand } from './commands/assign-issue.command';
|
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 { ChangeIssueStatusCommand } from './commands/change-issue-status.command';
|
||||||
import { CommandResult } from './commands/command-result';
|
import { CommandResult } from './commands/command-result';
|
||||||
import { CreateIssueCommand } from './commands/create-issue.command';
|
import { CreateIssueCommand } from './commands/create-issue.command';
|
||||||
@@ -16,9 +37,14 @@ import { UnlinkIssuesCommand } from './commands/unlink-issues.command';
|
|||||||
import { UpdateIssueCommand } from './commands/update-issue.command';
|
import { UpdateIssueCommand } from './commands/update-issue.command';
|
||||||
import { IssueFilter, Page } from './ports/find-issues.repository';
|
import { IssueFilter, Page } from './ports/find-issues.repository';
|
||||||
import { GetActivityQuery } from './queries/get-activity.query';
|
import { GetActivityQuery } from './queries/get-activity.query';
|
||||||
|
import {
|
||||||
|
AttachmentDownload,
|
||||||
|
GetAttachmentQuery,
|
||||||
|
} from './queries/get-attachment.query';
|
||||||
import { GetIssueQuery } from './queries/get-issue.query';
|
import { GetIssueQuery } from './queries/get-issue.query';
|
||||||
import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query';
|
import { GetIssuesByIdsQuery } from './queries/get-issues-by-ids.query';
|
||||||
import { SearchIssuesQuery } from './queries/search-issues.query';
|
import { SearchIssuesQuery } from './queries/search-issues.query';
|
||||||
|
import { GetEpicsQuery } from './queries/get-epics.query';
|
||||||
import {
|
import {
|
||||||
MyIssuesFilter,
|
MyIssuesFilter,
|
||||||
SearchMyIssuesQuery,
|
SearchMyIssuesQuery,
|
||||||
@@ -31,6 +57,8 @@ export interface CreateIssueInput extends Omit<IssueDetails, 'title' | 'type'> {
|
|||||||
title: string;
|
title: string;
|
||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
releaseId?: string | null;
|
releaseId?: string | null;
|
||||||
|
/** Epic or ticket the new one breaks down. */
|
||||||
|
parentId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IssueLinkInput {
|
export interface IssueLinkInput {
|
||||||
@@ -47,7 +75,15 @@ export class IssuesService {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
create(actorId: string, input: CreateIssueInput): Promise<CommandResult> {
|
create(actorId: string, input: CreateIssueInput): Promise<CommandResult> {
|
||||||
const { projectId, type, title, assigneeId, releaseId, ...details } = input;
|
const {
|
||||||
|
projectId,
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
assigneeId,
|
||||||
|
releaseId,
|
||||||
|
parentId,
|
||||||
|
...details
|
||||||
|
} = input;
|
||||||
return this.commandBus.execute(
|
return this.commandBus.execute(
|
||||||
new CreateIssueCommand(
|
new CreateIssueCommand(
|
||||||
actorId,
|
actorId,
|
||||||
@@ -57,6 +93,7 @@ export class IssuesService {
|
|||||||
details,
|
details,
|
||||||
assigneeId,
|
assigneeId,
|
||||||
releaseId,
|
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> {
|
findOne(viewerId: string | null, idOrKey: string): Promise<IssueReadModel> {
|
||||||
return this.queryBus.execute(new GetIssueQuery(viewerId, idOrKey));
|
return this.queryBus.execute(new GetIssueQuery(viewerId, idOrKey));
|
||||||
}
|
}
|
||||||
@@ -134,6 +215,91 @@ export class IssuesService {
|
|||||||
return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids));
|
return this.queryBus.execute(new GetIssuesByIdsQuery(viewerId, ids));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Places the ticket under `parentId` (null: back to the top). */
|
||||||
|
setParent(
|
||||||
|
actorId: string,
|
||||||
|
issueId: string,
|
||||||
|
parentId: string | null,
|
||||||
|
): Promise<CommandResult> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new SetIssueParentCommand(actorId, issueId, parentId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 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(
|
search(
|
||||||
viewerId: string | null,
|
viewerId: string | null,
|
||||||
filter: IssueFilter,
|
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 {
|
import {
|
||||||
ActivityReadModel,
|
ActivityReadModel,
|
||||||
CommentReadModel,
|
CommentReadModel,
|
||||||
|
NotificationReadModel,
|
||||||
IssueReadModel,
|
IssueReadModel,
|
||||||
ProjectReadModel,
|
ProjectReadModel,
|
||||||
ReleaseReadModel,
|
ReleaseReadModel,
|
||||||
|
SprintReadModel,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
/** Every realtime message says which event caused it and the resulting version. */
|
/** Every realtime message says which event caused it and the resulting version. */
|
||||||
@@ -29,6 +31,13 @@ export interface ReleaseChange extends Change {
|
|||||||
release: ReleaseReadModel;
|
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 {
|
export interface ProjectChange extends Change {
|
||||||
project: ProjectReadModel;
|
project: ProjectReadModel;
|
||||||
}
|
}
|
||||||
@@ -42,18 +51,27 @@ export abstract class ChangeFeed {
|
|||||||
abstract publishComment(change: CommentChange): Promise<void>;
|
abstract publishComment(change: CommentChange): Promise<void>;
|
||||||
abstract publishActivity(activity: ActivityReadModel): Promise<void>;
|
abstract publishActivity(activity: ActivityReadModel): Promise<void>;
|
||||||
abstract publishRelease(change: ReleaseChange): Promise<void>;
|
abstract publishRelease(change: ReleaseChange): Promise<void>;
|
||||||
|
abstract publishSprint(change: SprintChange): Promise<void>;
|
||||||
/** @param notifyUserIds users whose project list changed (joined / left). */
|
/** @param notifyUserIds users whose project list changed (joined / left). */
|
||||||
abstract publishProject(
|
abstract publishProject(
|
||||||
change: ProjectChange,
|
change: ProjectChange,
|
||||||
notifyUserIds?: string[],
|
notifyUserIds?: string[],
|
||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
|
|
||||||
|
abstract publishNotification(
|
||||||
|
notification: NotificationReadModel,
|
||||||
|
): Promise<void>;
|
||||||
|
|
||||||
abstract issues(projectId: string): AsyncIterableIterator<IssueChange>;
|
abstract issues(projectId: string): AsyncIterableIterator<IssueChange>;
|
||||||
abstract comments(issueId: string): AsyncIterableIterator<CommentChange>;
|
abstract comments(issueId: string): AsyncIterableIterator<CommentChange>;
|
||||||
abstract activity(
|
abstract activity(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
): AsyncIterableIterator<ActivityReadModel>;
|
): AsyncIterableIterator<ActivityReadModel>;
|
||||||
abstract releases(projectId: string): AsyncIterableIterator<ReleaseChange>;
|
abstract releases(projectId: string): AsyncIterableIterator<ReleaseChange>;
|
||||||
|
abstract sprints(projectId: string): AsyncIterableIterator<SprintChange>;
|
||||||
abstract project(projectId: string): AsyncIterableIterator<ProjectChange>;
|
abstract project(projectId: string): AsyncIterableIterator<ProjectChange>;
|
||||||
abstract userProjects(userId: string): AsyncIterableIterator<ProjectChange>;
|
abstract userProjects(userId: string): AsyncIterableIterator<ProjectChange>;
|
||||||
|
abstract notifications(
|
||||||
|
userId: string,
|
||||||
|
): AsyncIterableIterator<NotificationReadModel>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
import {
|
import {
|
||||||
|
EpicReadModel,
|
||||||
IssuePriority,
|
IssuePriority,
|
||||||
IssueReadModel,
|
IssueReadModel,
|
||||||
ProjectStatsReadModel,
|
ProjectStatsReadModel,
|
||||||
ReleaseProgressReadModel,
|
ReleaseProgressReadModel,
|
||||||
|
SprintProgressReadModel,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
|
export enum IssueSort {
|
||||||
|
RECENT = 'RECENT',
|
||||||
|
RANK = 'RANK',
|
||||||
|
}
|
||||||
|
|
||||||
export interface IssueFilter {
|
export interface IssueFilter {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
/** Status, type and area ids of the project's settings. */
|
/** Status, type and area ids of the project's settings. */
|
||||||
@@ -18,6 +25,16 @@ export interface IssueFilter {
|
|||||||
unassigned?: boolean;
|
unassigned?: boolean;
|
||||||
releaseId?: string;
|
releaseId?: string;
|
||||||
labels?: 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;
|
search?: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
@@ -29,6 +46,7 @@ export interface CrossProjectIssueFilter {
|
|||||||
assigneeId?: string;
|
assigneeId?: string;
|
||||||
statuses?: string[];
|
statuses?: string[];
|
||||||
categories?: StatusCategory[];
|
categories?: StatusCategory[];
|
||||||
|
search?: string;
|
||||||
limit: number;
|
limit: number;
|
||||||
offset: number;
|
offset: number;
|
||||||
}
|
}
|
||||||
@@ -49,8 +67,17 @@ export abstract class FindIssuesRepository {
|
|||||||
abstract stats(projectId: string): Promise<ProjectStatsReadModel>;
|
abstract stats(projectId: string): Promise<ProjectStatsReadModel>;
|
||||||
/** Open tickets (TODO, IN_PROGRESS or QA category) assigned to someone, in any project. */
|
/** Open tickets (TODO, IN_PROGRESS or QA category) assigned to someone, in any project. */
|
||||||
abstract findOpenAssignedTo(userId: string): Promise<IssueReadModel[]>;
|
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). */
|
/** Every ticket of a project (statistics). */
|
||||||
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
|
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
|
||||||
|
/** Top-level tickets with others hanging from them, by key. */
|
||||||
|
abstract epics(projectId: string): Promise<EpicReadModel[]>;
|
||||||
|
abstract sprintProgress(sprintId: string): Promise<SprintProgressReadModel>;
|
||||||
abstract releaseProgress(
|
abstract releaseProgress(
|
||||||
releaseId: string,
|
releaseId: string,
|
||||||
): Promise<ReleaseProgressReadModel>;
|
): 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 './upsert-materialized-timeline.repository';
|
||||||
export * from './find-timeline.repository';
|
export * from './find-timeline.repository';
|
||||||
export * from './tracker-streams.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