feat: sprints with burndown and carry-over
Plan sprints with dates and a goal, run one at a time per project, and put tickets in them from the ticket page. The Sprints tab follows the one under way with its progress and a burndown in tickets or points against the ideal line. Closing a sprint sends what was left to the next one or back to the backlog; the board can be filtered by sprint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -176,6 +176,17 @@ 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!
|
||||||
|
}
|
||||||
|
|
||||||
type CreatedResolved {
|
type CreatedResolved {
|
||||||
created: Int!
|
created: Int!
|
||||||
resolved: Int!
|
resolved: Int!
|
||||||
@@ -249,6 +260,7 @@ type Issue {
|
|||||||
rank: String!
|
rank: String!
|
||||||
release: ReleaseRef
|
release: ReleaseRef
|
||||||
reporter: UserRef!
|
reporter: UserRef!
|
||||||
|
sprint: SprintRef
|
||||||
|
|
||||||
"""A status id of the project's settings"""
|
"""A status id of the project's settings"""
|
||||||
status: String!
|
status: String!
|
||||||
@@ -278,6 +290,9 @@ input IssueFilterInput {
|
|||||||
categories: [StatusCategory!]
|
categories: [StatusCategory!]
|
||||||
labels: [String!]
|
labels: [String!]
|
||||||
|
|
||||||
|
"""Only tickets in no sprint"""
|
||||||
|
noSprint: Boolean
|
||||||
|
|
||||||
"""Only the tickets hanging from this one"""
|
"""Only the tickets hanging from this one"""
|
||||||
parentId: ID
|
parentId: ID
|
||||||
priorities: [IssuePriority!]
|
priorities: [IssuePriority!]
|
||||||
@@ -287,6 +302,7 @@ input IssueFilterInput {
|
|||||||
"""Matches key, title or description"""
|
"""Matches key, title or description"""
|
||||||
search: String
|
search: String
|
||||||
sort: IssueSort = RECENT
|
sort: IssueSort = RECENT
|
||||||
|
sprintId: ID
|
||||||
statuses: [String!]
|
statuses: [String!]
|
||||||
types: [String!]
|
types: [String!]
|
||||||
unassigned: Boolean
|
unassigned: Boolean
|
||||||
@@ -388,10 +404,18 @@ type Mutation {
|
|||||||
"""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!
|
||||||
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!
|
||||||
|
|
||||||
"""Author or project admin"""
|
"""Author or project admin"""
|
||||||
deleteComment(commentId: ID!): CommandResult!
|
deleteComment(commentId: ID!): CommandResult!
|
||||||
|
|
||||||
@@ -402,6 +426,9 @@ type Mutation {
|
|||||||
|
|
||||||
"""Delete a view: whoever saved it, or an admin if it is shared"""
|
"""Delete a view: whoever saved it, or an admin if it is shared"""
|
||||||
deleteSavedFilter(id: ID!): Boolean!
|
deleteSavedFilter(id: ID!): Boolean!
|
||||||
|
|
||||||
|
"""Delete a sprint that never started; its tickets go back to the backlog"""
|
||||||
|
deleteSprint(sprintId: ID!): CommandResult!
|
||||||
editComment(body: String!, commentId: ID!): CommandResult!
|
editComment(body: String!, commentId: ID!): CommandResult!
|
||||||
|
|
||||||
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
"""Relate tickets, e.g. FE DEPENDS_ON BE, QA TESTS FE"""
|
||||||
@@ -435,6 +462,12 @@ type Mutation {
|
|||||||
|
|
||||||
"""Put a ticket in a release (null removes it)"""
|
"""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!
|
||||||
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
unlinkIssues(input: LinkIssuesInput!): CommandResult!
|
||||||
|
|
||||||
"""Stop following a ticket"""
|
"""Stop following a ticket"""
|
||||||
@@ -451,6 +484,7 @@ 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!
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Follow a ticket: be told when it moves, is reassigned or gets a comment
|
Follow a ticket: be told when it moves, is reassigned or gets a comment
|
||||||
@@ -517,6 +551,7 @@ type Project {
|
|||||||
|
|
||||||
"""Matches key, title or description"""
|
"""Matches key, title or description"""
|
||||||
search: String
|
search: String
|
||||||
|
sprintId: ID
|
||||||
types: [String!]
|
types: [String!]
|
||||||
unassigned: Boolean
|
unassigned: Boolean
|
||||||
): [BoardColumn!]!
|
): [BoardColumn!]!
|
||||||
@@ -668,6 +703,12 @@ type Query {
|
|||||||
|
|
||||||
"""My views of the project's board, then the shared ones"""
|
"""My views of the project's board, then the shared ones"""
|
||||||
savedFilters(projectId: ID!): [SavedFilter!]!
|
savedFilters(projectId: ID!): [SavedFilter!]!
|
||||||
|
sprint(id: ID!): Sprint!
|
||||||
|
|
||||||
|
"""
|
||||||
|
A project's sprints: the one under way, the planned ones, then the closed ones
|
||||||
|
"""
|
||||||
|
sprints(projectId: ID!): [Sprint!]!
|
||||||
|
|
||||||
"""Planned releases of all my projects, soonest target date first"""
|
"""Planned releases of all my projects, soonest target date first"""
|
||||||
upcomingReleases(limit: Int! = 10): [Release!]!
|
upcomingReleases(limit: Int! = 10): [Release!]!
|
||||||
@@ -784,6 +825,73 @@ input SavedFilterCriteriaInput {
|
|||||||
unassigned: Boolean
|
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.
|
||||||
"""
|
"""
|
||||||
@@ -820,6 +928,7 @@ type Subscription {
|
|||||||
notificationAdded: Notification!
|
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 {
|
||||||
@@ -865,6 +974,14 @@ input UpdateReleaseInput {
|
|||||||
targetDate: String
|
targetDate: String
|
||||||
}
|
}
|
||||||
|
|
||||||
|
input UpdateSprintInput {
|
||||||
|
endDate: String
|
||||||
|
goal: String
|
||||||
|
name: String
|
||||||
|
sprintId: ID!
|
||||||
|
startDate: String
|
||||||
|
}
|
||||||
|
|
||||||
"""User managed by the Pireon identity server"""
|
"""User managed by the Pireon identity server"""
|
||||||
type User {
|
type User {
|
||||||
email: String!
|
email: String!
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -29,6 +29,7 @@ describe('appRoutes', () => {
|
|||||||
'board',
|
'board',
|
||||||
'issues/:issueKey',
|
'issues/:issueKey',
|
||||||
'backlog',
|
'backlog',
|
||||||
|
'sprints',
|
||||||
'epics',
|
'epics',
|
||||||
'releases',
|
'releases',
|
||||||
'dashboard',
|
'dashboard',
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
| 👥 Asignaciones | Solo a miembros del proyecto; panel de carga por persona |
|
||||||
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
|
| 💬 Comentarios | Decisiones y explicaciones dentro del ticket, con `@menciones`; el autor edita, el admin modera |
|
||||||
| 🔎 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 |
|
| 🔎 Vistas | El tablero se filtra por texto, persona (o sin asignar), tipo, prioridad, área y release; los filtros se guardan como vistas propias o compartidas con el proyecto |
|
||||||
|
| 🏃 Sprints | Iteraciones con fechas y objetivo, una en curso por proyecto, con burndown (tickets o puntos) y traspaso de lo pendiente al cerrar |
|
||||||
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
| 📋 Backlog | Lo pendiente de empezar, en el orden en que se va a coger; se reordena arrastrando y el tablero sigue ese orden |
|
||||||
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
| 🧩 Desglose | Épicas → tickets → subtareas (tres niveles), con su progreso; y una checklist dentro de cada ticket para los pasos pequeños |
|
||||||
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
| 👀 Seguir | Cualquier miembro sigue los tickets que le interesan; quien comenta, los sigue solo |
|
||||||
|
|||||||
@@ -139,9 +139,10 @@ uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada
|
|||||||
|
|
||||||
| 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, `DropLinksToIssueCommand` en los tickets que lo enlazaban y `DetachFromParentCommand` en los que colgaban de él |
|
| | `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 |
|
| `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») |
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,11 @@ stateDiagram-v2
|
|||||||
encima y el de quien queda debajo, leídos del event store; si entretanto el orden cambió, la API lo rechaza y la web
|
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
|
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.
|
columnas del tablero y `issues(filter: { sort: RANK })` siguen este orden.
|
||||||
|
- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
|
||||||
|
Un sprint va de `PLANNED` a `ACTIVE` (uno solo en curso por proyecto) y a `CLOSED`; dura como mucho 90 días y un
|
||||||
|
sprint en curso conserva su fecha de inicio. Un sprint cerrado ya no acepta ni pierde tickets. Al cerrarlo se elige
|
||||||
|
adónde van los tickets sin terminar: a otro sprint abierto del proyecto o al backlog; los terminados se quedan como
|
||||||
|
registro. Solo se borran sprints que no han empezado (sus tickets vuelven al backlog).
|
||||||
- **Vistas guardadas**: filtros del tablero con nombre. Las guardan los miembros del proyecto (los lectores, solo para
|
- **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
|
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,
|
50 por persona y proyecto. Son preferencias, no historia: viven en MongoDB (`saved_filters`), fuera del event store,
|
||||||
@@ -192,12 +197,13 @@ por defecto); un correo que falla se registra y no bloquea el stream.
|
|||||||
Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`,
|
Todo cambio es un evento guardado en el event store (stream = id del agregado). Llevan `actorId`, `occurredAt`,
|
||||||
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
|
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
|
||||||
|
|
||||||
| Agregado | Eventos |
|
| Agregado | Eventos |
|
||||||
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
|
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
|
||||||
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueChecklistItemAddedEvent`, `IssueChecklistItemUpdatedEvent`, `IssueChecklistItemRemovedEvent`, `IssueDeletedEvent` |
|
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `IssueAttachmentAddedEvent`, `IssueAttachmentRemovedEvent`, `IssueWatchedEvent`, `IssueUnwatchedEvent`, `IssueParentChangedEvent`, `IssueRankedEvent`, `IssueSprintChangedEvent`, `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`, `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.
|
||||||
@@ -216,6 +222,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,
|
||||||
@@ -240,6 +247,7 @@ que funcionan también sin token.
|
|||||||
| `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, unassigned, area, types, priorities, labels, search, 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, ticket padre, texto); `sort: RANK` para el orden del backlog |
|
| `issues(filter, limit, offset)` | Búsqueda de tickets (estado, tipo, área, prioridad, persona, release, etiquetas, ticket padre, texto); `sort: RANK` para el orden del backlog |
|
||||||
|
| `sprints(projectId)`, `sprint(id)` | Sprints del proyecto (en curso, planificados, cerrados). Campos calculados: `progress`, `burndown`, `issues` |
|
||||||
| `savedFilters(projectId)` | Mis vistas guardadas del tablero y las compartidas con el proyecto |
|
| `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 |
|
| `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` |
|
||||||
@@ -255,6 +263,7 @@ Todas devuelven `CommandResult { id, key, version }`.
|
|||||||
| 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`, `setIssueParent`, `rankIssue`, `addChecklistItem`, `updateChecklistItem`, `removeChecklistItem`, `watchIssue`, `unwatchIssue`, `removeAttachment` (subir: por REST) |
|
| 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` |
|
||||||
|
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
|
||||||
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
|
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
|
||||||
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
|
||||||
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `archiveRelease` |
|
||||||
@@ -267,6 +276,7 @@ Todas devuelven `CommandResult { id, key, version }`.
|
|||||||
| `commentChanged(issueId)` | Se añade, edita o borra un comentario (`comment` es `null` al borrar) |
|
| `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 |
|
||||||
|
|
||||||
@@ -459,6 +469,7 @@ Proyectos y tickets se pueden indicar por **id o por clave** (`WEB`, `WEB-12`).
|
|||||||
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
| Proyectos | `GET/POST /api/projects`, `GET/PATCH/DELETE /api/projects/{project}` (`DELETE …?confirm=CLAVE`), `PUT …/settings`, `…/board`, `…/epics`, `…/stats`, `…/analytics?days=`, `…/activity`, `…/members` (POST, PATCH, DELETE) |
|
||||||
| Tickets | `GET /api/me/issues` (mi trabajo), `GET/POST /api/projects/{project}/issues`, `GET/PATCH /api/issues/{issue}`, `PUT …/status`, `PUT …/assignee`, `PUT …/release`, `PUT/DELETE …/watch`, `PUT …/parent`, `PUT …/rank`, `POST …/checklist`, `PATCH/DELETE …/checklist/{item}`, `POST/DELETE …/links`, `GET/POST …/comments`, `GET …/history` |
|
| 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}` |
|
||||||
|
| 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}` |
|
| 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` |
|
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
|
||||||
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |
|
||||||
|
|||||||
@@ -10,5 +10,6 @@ export * from './lib/workflows.service';
|
|||||||
export * from './lib/notifications.service';
|
export * from './lib/notifications.service';
|
||||||
export * from './lib/notifications';
|
export * from './lib/notifications';
|
||||||
export * from './lib/saved-filters.service';
|
export * from './lib/saved-filters.service';
|
||||||
|
export * from './lib/sprints.service';
|
||||||
export * from './lib/saved-filters/saved-filters';
|
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,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { DetachFileCommandHandler } from './detach-file.command-handler';
|
|||||||
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
|
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
|
||||||
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
|
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
|
||||||
import { RankIssueCommandHandler } from './rank-issue.command-handler';
|
import { RankIssueCommandHandler } from './rank-issue.command-handler';
|
||||||
|
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
|
||||||
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
|
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
|
||||||
import {
|
import {
|
||||||
AddChecklistItemCommandHandler,
|
AddChecklistItemCommandHandler,
|
||||||
@@ -71,6 +72,7 @@ export const COMMAND_HANDLERS = [
|
|||||||
WatchIssueCommandHandler,
|
WatchIssueCommandHandler,
|
||||||
SetIssueParentCommandHandler,
|
SetIssueParentCommandHandler,
|
||||||
RankIssueCommandHandler,
|
RankIssueCommandHandler,
|
||||||
|
...SPRINT_COMMAND_HANDLERS,
|
||||||
AddChecklistItemCommandHandler,
|
AddChecklistItemCommandHandler,
|
||||||
UpdateChecklistItemCommandHandler,
|
UpdateChecklistItemCommandHandler,
|
||||||
RemoveChecklistItemCommandHandler,
|
RemoveChecklistItemCommandHandler,
|
||||||
|
|||||||
@@ -31,6 +31,13 @@ import {
|
|||||||
InMemoryTrackerStreams,
|
InMemoryTrackerStreams,
|
||||||
} from '../../testing/in-memory';
|
} from '../../testing/in-memory';
|
||||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
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 { IssueTree } from './issue-tree';
|
||||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||||
import { RankIssueCommand } from './rank-issue.command';
|
import { RankIssueCommand } from './rank-issue.command';
|
||||||
@@ -63,6 +70,10 @@ import { WriteAccess } from './write-access';
|
|||||||
|
|
||||||
async function setup() {
|
async function setup() {
|
||||||
const store = new InMemoryEventStore();
|
const store = new InMemoryEventStore();
|
||||||
|
const sprints = {
|
||||||
|
active: null as { id: string; name: string } | null,
|
||||||
|
findActive: async () => sprints.active,
|
||||||
|
};
|
||||||
const files = new InMemoryAttachmentStorage();
|
const files = new InMemoryAttachmentStorage();
|
||||||
const users = new FakeUserDirectory()
|
const users = new FakeUserDirectory()
|
||||||
.add('owner')
|
.add('owner')
|
||||||
@@ -92,6 +103,20 @@ async function setup() {
|
|||||||
provide: TrackerStreamsRepository,
|
provide: TrackerStreamsRepository,
|
||||||
useValue: new InMemoryTrackerStreams(store),
|
useValue: new InMemoryTrackerStreams(store),
|
||||||
},
|
},
|
||||||
|
// Read-model lookups: the sprint under way is set by each test.
|
||||||
|
{ provide: FindSprintsRepository, useValue: sprints },
|
||||||
|
{
|
||||||
|
provide: FindIssuesRepository,
|
||||||
|
useValue: {
|
||||||
|
sprintProgress: async () => ({
|
||||||
|
total: 0,
|
||||||
|
finished: 0,
|
||||||
|
percent: 0,
|
||||||
|
storyPoints: 0,
|
||||||
|
finishedStoryPoints: 0,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
}).compile();
|
}).compile();
|
||||||
await moduleRef.init();
|
await moduleRef.init();
|
||||||
@@ -103,6 +128,7 @@ async function setup() {
|
|||||||
return {
|
return {
|
||||||
store,
|
store,
|
||||||
files,
|
files,
|
||||||
|
sprints,
|
||||||
run,
|
run,
|
||||||
rehydrator: moduleRef.get(AggregateRehydrator),
|
rehydrator: moduleRef.get(AggregateRehydrator),
|
||||||
};
|
};
|
||||||
@@ -405,6 +431,51 @@ describe('tracker commands (event sourced)', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('plans and starts sprints, one under way at a time, and fills them', async () => {
|
||||||
|
const { run, rehydrator, sprints } = await setup();
|
||||||
|
const projectId = await projectWithTeam(run);
|
||||||
|
const plan = (name: string) =>
|
||||||
|
run(
|
||||||
|
new CreateSprintCommand(
|
||||||
|
'owner',
|
||||||
|
projectId,
|
||||||
|
name,
|
||||||
|
'2026-10-12',
|
||||||
|
'2026-10-23',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const first = await plan('Sprint 1');
|
||||||
|
const second = await plan('Sprint 2');
|
||||||
|
await expect(
|
||||||
|
run(
|
||||||
|
new CreateSprintCommand(
|
||||||
|
'dev',
|
||||||
|
projectId,
|
||||||
|
'X',
|
||||||
|
'2026-10-12',
|
||||||
|
'2026-10-23',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
).rejects.toThrow(ForbiddenError);
|
||||||
|
|
||||||
|
await run(new StartSprintCommand('owner', first.id));
|
||||||
|
sprints.active = { id: first.id, name: 'Sprint 1' };
|
||||||
|
await expect(
|
||||||
|
run(new StartSprintCommand('owner', second.id)),
|
||||||
|
).rejects.toThrow(/Sprint 1 is under way/);
|
||||||
|
|
||||||
|
const issue = await run(
|
||||||
|
new CreateIssueCommand('dev', projectId, IssueType.TASK, 'Login', {}),
|
||||||
|
);
|
||||||
|
await run(new SetIssueSprintCommand('dev', issue.id, first.id));
|
||||||
|
expect((await rehydrator.rehydrate(issue.id, Issue)).sprintId).toBe(
|
||||||
|
first.id,
|
||||||
|
);
|
||||||
|
await expect(
|
||||||
|
run(new SetIssueSprintCommand('pm', issue.id, null)),
|
||||||
|
).rejects.toThrow(ForbiddenError);
|
||||||
|
});
|
||||||
|
|
||||||
it('does not let viewers modify tickets', async () => {
|
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);
|
||||||
|
|||||||
@@ -33,3 +33,4 @@ export * from './set-issue-parent.command';
|
|||||||
export * from './detach-from-parent.command';
|
export * from './detach-from-parent.command';
|
||||||
export * from './change-checklist.command';
|
export * from './change-checklist.command';
|
||||||
export * from './rank-issue.command';
|
export * from './rank-issue.command';
|
||||||
|
export * from './sprint.commands';
|
||||||
|
|||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,12 @@ import {
|
|||||||
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';
|
||||||
|
|
||||||
@@ -37,6 +43,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;
|
||||||
}
|
}
|
||||||
@@ -146,6 +154,12 @@ export function describeEvent(
|
|||||||
if (event instanceof IssueAttachmentRemovedEvent)
|
if (event instanceof IssueAttachmentRemovedEvent)
|
||||||
return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`;
|
return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`;
|
||||||
|
|
||||||
|
if (event instanceof IssueSprintChangedEvent) {
|
||||||
|
const sprint = ctx.sprintName ? `el sprint ${ctx.sprintName}` : 'un sprint';
|
||||||
|
return event.sprintId
|
||||||
|
? `${actor} metió ${issueKey} en ${sprint}`
|
||||||
|
: `${actor} sacó ${issueKey} de ${sprint}`;
|
||||||
|
}
|
||||||
if (event instanceof IssueParentChangedEvent) {
|
if (event instanceof IssueParentChangedEvent) {
|
||||||
return event.parentKey
|
return event.parentKey
|
||||||
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
|
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
|
||||||
@@ -178,5 +192,20 @@ export function describeEvent(
|
|||||||
return `${actor} publicó la release ${ctx.releaseName} 🚀`;
|
return `${actor} publicó 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}`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -23,6 +23,12 @@ import {
|
|||||||
ProjectMemberRoleChangedEvent,
|
ProjectMemberRoleChangedEvent,
|
||||||
ProjectUpdatedEvent,
|
ProjectUpdatedEvent,
|
||||||
ReleaseArchivedEvent,
|
ReleaseArchivedEvent,
|
||||||
|
IssueSprintChangedEvent,
|
||||||
|
SprintClosedEvent,
|
||||||
|
SprintCreatedEvent,
|
||||||
|
SprintDeletedEvent,
|
||||||
|
SprintStartedEvent,
|
||||||
|
SprintUpdatedEvent,
|
||||||
ReleaseCreatedEvent,
|
ReleaseCreatedEvent,
|
||||||
ReleaseShippedEvent,
|
ReleaseShippedEvent,
|
||||||
ReleaseUpdatedEvent,
|
ReleaseUpdatedEvent,
|
||||||
@@ -37,6 +43,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';
|
||||||
@@ -70,6 +77,12 @@ import { versionOf } from './read-model-notifier';
|
|||||||
ReleaseUpdatedEvent,
|
ReleaseUpdatedEvent,
|
||||||
ReleaseShippedEvent,
|
ReleaseShippedEvent,
|
||||||
ReleaseArchivedEvent,
|
ReleaseArchivedEvent,
|
||||||
|
IssueSprintChangedEvent,
|
||||||
|
SprintCreatedEvent,
|
||||||
|
SprintUpdatedEvent,
|
||||||
|
SprintStartedEvent,
|
||||||
|
SprintClosedEvent,
|
||||||
|
SprintDeletedEvent,
|
||||||
)
|
)
|
||||||
export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
|
||||||
constructor(
|
constructor(
|
||||||
@@ -77,6 +90,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,
|
||||||
) {}
|
) {}
|
||||||
@@ -93,6 +107,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;
|
||||||
@@ -118,6 +133,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,
|
||||||
),
|
),
|
||||||
@@ -129,6 +145,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;
|
||||||
|
|||||||
@@ -31,6 +31,8 @@ import { IssueWatchersEventHandler } from './issue-watchers.event-handler';
|
|||||||
import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler';
|
import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler';
|
||||||
import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
|
import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
|
||||||
import { IssueRankedEventHandler } from './issue-ranked.event-handler';
|
import { IssueRankedEventHandler } from './issue-ranked.event-handler';
|
||||||
|
import { SprintEventHandler } from './sprint.event-handler';
|
||||||
|
import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler';
|
||||||
|
|
||||||
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
|
||||||
export const EVENT_HANDLERS = [
|
export const EVENT_HANDLERS = [
|
||||||
@@ -66,6 +68,8 @@ export const EVENT_HANDLERS = [
|
|||||||
IssueParentChangedEventHandler,
|
IssueParentChangedEventHandler,
|
||||||
IssueChecklistEventHandler,
|
IssueChecklistEventHandler,
|
||||||
IssueRankedEventHandler,
|
IssueRankedEventHandler,
|
||||||
|
SprintEventHandler,
|
||||||
|
IssueSprintChangedEventHandler,
|
||||||
];
|
];
|
||||||
|
|
||||||
export { ReadModelNotifier };
|
export { ReadModelNotifier };
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ export class IssueCreatedEventHandler
|
|||||||
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,
|
parent: null,
|
||||||
|
sprint: null,
|
||||||
rank: implicitRank(issue.number),
|
rank: implicitRank(issue.number),
|
||||||
labels: issue.labels,
|
labels: issue.labels,
|
||||||
figmaUrl: issue.figmaUrl,
|
figmaUrl: issue.figmaUrl,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
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. */
|
||||||
@@ -30,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;
|
||||||
}
|
}
|
||||||
@@ -43,6 +51,7 @@ 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,
|
||||||
@@ -59,6 +68,7 @@ export abstract class ChangeFeed {
|
|||||||
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(
|
abstract notifications(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
IssueReadModel,
|
IssueReadModel,
|
||||||
ProjectStatsReadModel,
|
ProjectStatsReadModel,
|
||||||
ReleaseProgressReadModel,
|
ReleaseProgressReadModel,
|
||||||
|
SprintProgressReadModel,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
@@ -26,6 +27,9 @@ export interface IssueFilter {
|
|||||||
labels?: string[];
|
labels?: string[];
|
||||||
/** Only the tickets hanging from this one. */
|
/** Only the tickets hanging from this one. */
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
|
sprintId?: string;
|
||||||
|
/** Only tickets in no sprint (wins over `sprintId`). */
|
||||||
|
noSprint?: boolean;
|
||||||
/** RECENT (default): last updated first. RANK: backlog order. */
|
/** RECENT (default): last updated first. RANK: backlog order. */
|
||||||
sort?: IssueSort;
|
sort?: IssueSort;
|
||||||
search?: string;
|
search?: string;
|
||||||
@@ -63,6 +67,7 @@ export abstract class FindIssuesRepository {
|
|||||||
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
|
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
|
||||||
/** Top-level tickets with others hanging from them, by key. */
|
/** Top-level tickets with others hanging from them, by key. */
|
||||||
abstract epics(projectId: string): Promise<EpicReadModel[]>;
|
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>;
|
||||||
|
}
|
||||||
@@ -21,3 +21,5 @@ export * from './attachment-storage';
|
|||||||
export * from './notifications.repository';
|
export * from './notifications.repository';
|
||||||
export * from './notification-mailer';
|
export * from './notification-mailer';
|
||||||
export * from './saved-filters.repository';
|
export * from './saved-filters.repository';
|
||||||
|
export * from './find-sprints.repository';
|
||||||
|
export * from './upsert-materialized-sprint.repository';
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
export abstract class TrackerStreamsRepository {
|
export abstract class TrackerStreamsRepository {
|
||||||
abstract issueIdsOfProject(projectId: string): Promise<string[]>;
|
abstract issueIdsOfProject(projectId: string): Promise<string[]>;
|
||||||
abstract releaseIdsOfProject(projectId: string): Promise<string[]>;
|
abstract releaseIdsOfProject(projectId: string): Promise<string[]>;
|
||||||
|
abstract sprintIdsOfProject(projectId: string): Promise<string[]>;
|
||||||
|
/** Tickets that were ever put in `sprintId` (they may have left since). */
|
||||||
|
abstract issueIdsEverInSprint(sprintId: string): Promise<string[]>;
|
||||||
abstract commentIdsOfIssue(issueId: string): Promise<string[]>;
|
abstract commentIdsOfIssue(issueId: string): Promise<string[]>;
|
||||||
/** Tickets that were ever linked to `issueId` (they may have unlinked since). */
|
/** Tickets that were ever linked to `issueId` (they may have unlinked since). */
|
||||||
abstract issueIdsLinkingTo(issueId: string): Promise<string[]>;
|
abstract issueIdsLinkingTo(issueId: string): Promise<string[]>;
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ export abstract class UpsertMaterializedIssueRepository {
|
|||||||
): Promise<void>;
|
): Promise<void>;
|
||||||
/** The ticket was deleted: its view, its timeline and the links shown on other tickets go. */
|
/** The ticket was deleted: its view, its timeline and the links shown on other tickets go. */
|
||||||
abstract remove(issueId: string): Promise<void>;
|
abstract remove(issueId: string): Promise<void>;
|
||||||
|
/** Keeps the denormalised sprint name of every issue in sync. */
|
||||||
|
abstract renameSprint(sprintId: string, name: string): Promise<void>;
|
||||||
/** Keeps the denormalised release name of every issue in sync. */
|
/** Keeps the denormalised release name of every issue in sync. */
|
||||||
abstract renameRelease(releaseId: string, name: string): Promise<void>;
|
abstract renameRelease(releaseId: string, name: string): Promise<void>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { SprintReadModel } from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
|
export abstract class UpsertMaterializedSprintRepository {
|
||||||
|
/** Applied only if `version` is newer than the stored one (idempotent). */
|
||||||
|
abstract upsert(
|
||||||
|
sprint: Pick<SprintReadModel, 'id' | 'version'> & Partial<SprintReadModel>,
|
||||||
|
): Promise<void>;
|
||||||
|
abstract remove(sprintId: string): Promise<void>;
|
||||||
|
}
|
||||||
@@ -18,3 +18,4 @@ export * from './search-my-issues.query';
|
|||||||
export * from './get-public-projects.query';
|
export * from './get-public-projects.query';
|
||||||
export * from './get-attachment.query';
|
export * from './get-attachment.query';
|
||||||
export * from './get-epics.query';
|
export * from './get-epics.query';
|
||||||
|
export * from './sprint.queries';
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import { GetReleasesQueryHandler } from './get-releases.query-handler';
|
|||||||
import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler';
|
import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler';
|
||||||
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
|
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
|
||||||
import { GetEpicsQueryHandler } from './get-epics.query-handler';
|
import { GetEpicsQueryHandler } from './get-epics.query-handler';
|
||||||
|
import { SPRINT_QUERY_HANDLERS } from './sprint.queries';
|
||||||
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
|
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
|
||||||
|
|
||||||
export const QUERY_HANDLERS = [
|
export const QUERY_HANDLERS = [
|
||||||
@@ -38,6 +39,7 @@ export const QUERY_HANDLERS = [
|
|||||||
GetUpcomingReleasesQueryHandler,
|
GetUpcomingReleasesQueryHandler,
|
||||||
SearchIssuesQueryHandler,
|
SearchIssuesQueryHandler,
|
||||||
GetEpicsQueryHandler,
|
GetEpicsQueryHandler,
|
||||||
|
...SPRINT_QUERY_HANDLERS,
|
||||||
SearchMyIssuesQueryHandler,
|
SearchMyIssuesQueryHandler,
|
||||||
GetAttachmentQueryHandler,
|
GetAttachmentQueryHandler,
|
||||||
];
|
];
|
||||||
|
|||||||
159
libs/api/tracker/application/src/lib/queries/sprint.queries.ts
Normal file
159
libs/api/tracker/application/src/lib/queries/sprint.queries.ts
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
SprintBurndownReadModel,
|
||||||
|
SprintProgressReadModel,
|
||||||
|
SprintReadModel,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { computeSprintBurndown } from '../analytics/analytics';
|
||||||
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
|
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||||
|
import { FindTimelineRepository } from '../ports/find-timeline.repository';
|
||||||
|
import { ReadAccess } from './read-access';
|
||||||
|
|
||||||
|
export class GetSprintsQuery extends Query<SprintReadModel[]> {
|
||||||
|
constructor(
|
||||||
|
/** Null: anonymous (public projects only). */
|
||||||
|
public readonly viewerId: string | null,
|
||||||
|
public readonly projectId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetSprintQuery extends Query<SprintReadModel> {
|
||||||
|
constructor(
|
||||||
|
public readonly viewerId: string | null,
|
||||||
|
public readonly sprintId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetSprintProgressQuery extends Query<SprintProgressReadModel> {
|
||||||
|
constructor(
|
||||||
|
public readonly viewerId: string | null,
|
||||||
|
public readonly sprintId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GetSprintBurndownQuery extends Query<SprintBurndownReadModel> {
|
||||||
|
constructor(
|
||||||
|
public readonly viewerId: string | null,
|
||||||
|
public readonly sprintId: string,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The sprint, if the viewer may see its project (same error otherwise). */
|
||||||
|
async function visibleSprint(
|
||||||
|
findSprints: FindSprintsRepository,
|
||||||
|
access: ReadAccess,
|
||||||
|
viewerId: string | null,
|
||||||
|
sprintId: string,
|
||||||
|
): Promise<SprintReadModel> {
|
||||||
|
const sprint = access.assertFound(
|
||||||
|
await findSprints.findById(sprintId),
|
||||||
|
'Sprint',
|
||||||
|
sprintId,
|
||||||
|
);
|
||||||
|
await access.assertCanView(sprint.projectId, viewerId, {
|
||||||
|
entity: 'Sprint',
|
||||||
|
ref: sprintId,
|
||||||
|
});
|
||||||
|
return sprint;
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(GetSprintsQuery)
|
||||||
|
export class GetSprintsQueryHandler
|
||||||
|
implements IQueryHandler<GetSprintsQuery, SprintReadModel[]>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly findSprints: FindSprintsRepository,
|
||||||
|
private readonly access: ReadAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(query: GetSprintsQuery): Promise<SprintReadModel[]> {
|
||||||
|
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||||
|
return this.findSprints.findByProject(query.projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(GetSprintQuery)
|
||||||
|
export class GetSprintQueryHandler
|
||||||
|
implements IQueryHandler<GetSprintQuery, SprintReadModel>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly findSprints: FindSprintsRepository,
|
||||||
|
private readonly access: ReadAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
execute(query: GetSprintQuery): Promise<SprintReadModel> {
|
||||||
|
return visibleSprint(
|
||||||
|
this.findSprints,
|
||||||
|
this.access,
|
||||||
|
query.viewerId,
|
||||||
|
query.sprintId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(GetSprintProgressQuery)
|
||||||
|
export class GetSprintProgressQueryHandler
|
||||||
|
implements IQueryHandler<GetSprintProgressQuery, SprintProgressReadModel>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly findSprints: FindSprintsRepository,
|
||||||
|
private readonly findIssues: FindIssuesRepository,
|
||||||
|
private readonly access: ReadAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
query: GetSprintProgressQuery,
|
||||||
|
): Promise<SprintProgressReadModel> {
|
||||||
|
const sprint = await visibleSprint(
|
||||||
|
this.findSprints,
|
||||||
|
this.access,
|
||||||
|
query.viewerId,
|
||||||
|
query.sprintId,
|
||||||
|
);
|
||||||
|
return this.findIssues.sprintProgress(sprint.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@QueryHandler(GetSprintBurndownQuery)
|
||||||
|
export class GetSprintBurndownQueryHandler
|
||||||
|
implements IQueryHandler<GetSprintBurndownQuery, SprintBurndownReadModel>
|
||||||
|
{
|
||||||
|
constructor(
|
||||||
|
private readonly findSprints: FindSprintsRepository,
|
||||||
|
private readonly findIssues: FindIssuesRepository,
|
||||||
|
private readonly findTimeline: FindTimelineRepository,
|
||||||
|
private readonly access: ReadAccess,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async execute(
|
||||||
|
query: GetSprintBurndownQuery,
|
||||||
|
): Promise<SprintBurndownReadModel> {
|
||||||
|
const sprint = await visibleSprint(
|
||||||
|
this.findSprints,
|
||||||
|
this.access,
|
||||||
|
query.viewerId,
|
||||||
|
query.sprintId,
|
||||||
|
);
|
||||||
|
const [issues, timeline] = await Promise.all([
|
||||||
|
this.findIssues.findByProject(sprint.projectId),
|
||||||
|
this.findTimeline.findByProject(sprint.projectId),
|
||||||
|
]);
|
||||||
|
return computeSprintBurndown({ sprint, issues, timeline, now: new Date() });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SPRINT_QUERY_HANDLERS = [
|
||||||
|
GetSprintsQueryHandler,
|
||||||
|
GetSprintQueryHandler,
|
||||||
|
GetSprintProgressQueryHandler,
|
||||||
|
GetSprintBurndownQueryHandler,
|
||||||
|
];
|
||||||
@@ -9,6 +9,7 @@ import { DeleteIssueCommand } from '../commands/delete-issue.command';
|
|||||||
import { DeleteReleaseCommand } from '../commands/delete-release.command';
|
import { DeleteReleaseCommand } from '../commands/delete-release.command';
|
||||||
import { DetachFromParentCommand } from '../commands/detach-from-parent.command';
|
import { DetachFromParentCommand } from '../commands/detach-from-parent.command';
|
||||||
import { DropLinksToIssueCommand } from '../commands/drop-links-to-issue.command';
|
import { DropLinksToIssueCommand } from '../commands/drop-links-to-issue.command';
|
||||||
|
import { DeleteSprintCommand } from '../commands/sprint.commands';
|
||||||
import { PurgeCommentCommand } from '../commands/purge-comment.command';
|
import { PurgeCommentCommand } from '../commands/purge-comment.command';
|
||||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||||
|
|
||||||
@@ -17,6 +18,7 @@ import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
|||||||
* with its own "deleted" event):
|
* with its own "deleted" event):
|
||||||
*
|
*
|
||||||
* ProjectDeleted → DeleteIssue (each ticket) + DeleteRelease (each release)
|
* ProjectDeleted → DeleteIssue (each ticket) + DeleteRelease (each release)
|
||||||
|
* + DeleteSprint (each sprint, as a cascade)
|
||||||
* IssueDeleted → PurgeComment (each comment) + DropLinksToIssue (tickets
|
* IssueDeleted → PurgeComment (each comment) + DropLinksToIssue (tickets
|
||||||
* of other projects that pointed to it) + DetachFromParent
|
* of other projects that pointed to it) + DetachFromParent
|
||||||
* (tickets that hung from it go back to the top)
|
* (tickets that hung from it go back to the top)
|
||||||
@@ -34,11 +36,14 @@ export class ProjectDeletionSaga {
|
|||||||
ofType(ProjectDeletedEvent),
|
ofType(ProjectDeletedEvent),
|
||||||
mergeMap((event) =>
|
mergeMap((event) =>
|
||||||
from(this.contentsOf(event.projectId)).pipe(
|
from(this.contentsOf(event.projectId)).pipe(
|
||||||
mergeMap(({ issueIds, releaseIds }) => [
|
mergeMap(({ issueIds, releaseIds, sprintIds }) => [
|
||||||
...issueIds.map((id) => new DeleteIssueCommand(event.actorId, id)),
|
...issueIds.map((id) => new DeleteIssueCommand(event.actorId, id)),
|
||||||
...releaseIds.map(
|
...releaseIds.map(
|
||||||
(id) => new DeleteReleaseCommand(event.actorId, id),
|
(id) => new DeleteReleaseCommand(event.actorId, id),
|
||||||
),
|
),
|
||||||
|
...sprintIds.map(
|
||||||
|
(id) => new DeleteSprintCommand(event.actorId, id, true),
|
||||||
|
),
|
||||||
]),
|
]),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -76,11 +81,12 @@ export class ProjectDeletionSaga {
|
|||||||
);
|
);
|
||||||
|
|
||||||
private async contentsOf(projectId: string) {
|
private async contentsOf(projectId: string) {
|
||||||
const [issueIds, releaseIds] = await Promise.all([
|
const [issueIds, releaseIds, sprintIds] = await Promise.all([
|
||||||
this.streams.issueIdsOfProject(projectId),
|
this.streams.issueIdsOfProject(projectId),
|
||||||
this.streams.releaseIdsOfProject(projectId),
|
this.streams.releaseIdsOfProject(projectId),
|
||||||
|
this.streams.sprintIdsOfProject(projectId),
|
||||||
]);
|
]);
|
||||||
return { issueIds, releaseIds };
|
return { issueIds, releaseIds, sprintIds };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async dependentsOf(issueId: string) {
|
private async dependentsOf(issueId: string) {
|
||||||
|
|||||||
@@ -47,6 +47,15 @@ import { LinkIssuesCommand } from '../commands/link-issues.command';
|
|||||||
import { AssignIssueCommand } from '../commands/assign-issue.command';
|
import { AssignIssueCommand } from '../commands/assign-issue.command';
|
||||||
import { WriteAccess } from '../commands/write-access';
|
import { WriteAccess } from '../commands/write-access';
|
||||||
import { IssueTree } from '../commands/issue-tree';
|
import { IssueTree } from '../commands/issue-tree';
|
||||||
|
import {
|
||||||
|
CloseSprintCommand,
|
||||||
|
CreateSprintCommand,
|
||||||
|
DeleteSprintCommand,
|
||||||
|
SetIssueSprintCommand,
|
||||||
|
StartSprintCommand,
|
||||||
|
} from '../commands/sprint.commands';
|
||||||
|
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||||
|
import { SprintCarryOverSaga } from './sprint-carry-over.saga';
|
||||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||||
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
||||||
@@ -73,6 +82,7 @@ async function setup() {
|
|||||||
ProjectDeletionSaga,
|
ProjectDeletionSaga,
|
||||||
ReleaseArchivedSaga,
|
ReleaseArchivedSaga,
|
||||||
UserDeactivatedSaga,
|
UserDeactivatedSaga,
|
||||||
|
SprintCarryOverSaga,
|
||||||
WriteAccess,
|
WriteAccess,
|
||||||
IssueTree,
|
IssueTree,
|
||||||
AggregateRehydrator,
|
AggregateRehydrator,
|
||||||
@@ -90,11 +100,22 @@ async function setup() {
|
|||||||
provide: TrackerStreamsRepository,
|
provide: TrackerStreamsRepository,
|
||||||
useValue: new InMemoryTrackerStreams(store),
|
useValue: new InMemoryTrackerStreams(store),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: FindSprintsRepository,
|
||||||
|
useValue: { findActive: async () => null },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: FindIssuesRepository,
|
provide: FindIssuesRepository,
|
||||||
useValue: {
|
useValue: {
|
||||||
findOpenAssignedTo: async (userId: string) =>
|
findOpenAssignedTo: async (userId: string) =>
|
||||||
(openAssigned.get(userId) ?? []).map((id) => ({ id })),
|
(openAssigned.get(userId) ?? []).map((id) => ({ id })),
|
||||||
|
sprintProgress: async () => ({
|
||||||
|
total: 2,
|
||||||
|
finished: 1,
|
||||||
|
percent: 50,
|
||||||
|
storyPoints: 0,
|
||||||
|
finishedStoryPoints: 0,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -109,6 +130,7 @@ async function setup() {
|
|||||||
...Object.values(moduleRef.get(ProjectDeletionSaga)),
|
...Object.values(moduleRef.get(ProjectDeletionSaga)),
|
||||||
...Object.values(moduleRef.get(ReleaseArchivedSaga)),
|
...Object.values(moduleRef.get(ReleaseArchivedSaga)),
|
||||||
...Object.values(moduleRef.get(UserDeactivatedSaga)),
|
...Object.values(moduleRef.get(UserDeactivatedSaga)),
|
||||||
|
...Object.values(moduleRef.get(SprintCarryOverSaga)),
|
||||||
].filter((value): value is ISaga => typeof value === 'function');
|
].filter((value): value is ISaga => typeof value === 'function');
|
||||||
|
|
||||||
/** What the events bridge does: every stored event through every saga, until quiet. */
|
/** What the events bridge does: every stored event through every saga, until quiet. */
|
||||||
@@ -206,6 +228,41 @@ describe('sagas', () => {
|
|||||||
expect((await rehydrator.rehydrate(child, Issue)).parentId).toBeNull();
|
expect((await rehydrator.rehydrate(child, Issue)).parentId).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('closing a sprint carries its unfinished tickets over; deleting one empties it', async () => {
|
||||||
|
const { run, drain, rehydrator } = await setup();
|
||||||
|
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||||
|
const sprint = async (name: string) =>
|
||||||
|
(
|
||||||
|
await run(
|
||||||
|
new CreateSprintCommand(
|
||||||
|
'owner',
|
||||||
|
web,
|
||||||
|
name,
|
||||||
|
'2026-10-12',
|
||||||
|
'2026-10-23',
|
||||||
|
),
|
||||||
|
)
|
||||||
|
).id;
|
||||||
|
const [one, two] = [await sprint('One'), await sprint('Two')];
|
||||||
|
const open = (await run(ticket(web, 'open'))).id;
|
||||||
|
const done = (await run(ticket(web, 'done'))).id;
|
||||||
|
for (const id of [open, done])
|
||||||
|
await run(new SetIssueSprintCommand('owner', id, one));
|
||||||
|
for (const status of ['TODO', 'IN_PROGRESS', 'IN_REVIEW', 'DONE'])
|
||||||
|
await run(new ChangeIssueStatusCommand('owner', done, status));
|
||||||
|
|
||||||
|
await run(new StartSprintCommand('owner', one));
|
||||||
|
await run(new CloseSprintCommand('owner', one, two));
|
||||||
|
await drain();
|
||||||
|
|
||||||
|
expect((await rehydrator.rehydrate(open, Issue)).sprintId).toBe(two);
|
||||||
|
expect((await rehydrator.rehydrate(done, Issue)).sprintId).toBe(one);
|
||||||
|
|
||||||
|
await run(new DeleteSprintCommand('owner', two));
|
||||||
|
await drain();
|
||||||
|
expect((await rehydrator.rehydrate(open, Issue)).sprintId).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it('only the owner deletes a project', async () => {
|
it('only the owner deletes a project', async () => {
|
||||||
const { run } = await setup();
|
const { run } = await setup();
|
||||||
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ProjectDeletionSaga } from './project-deletion.saga';
|
|||||||
import { ReleaseArchivedSaga } from './release-archived.saga';
|
import { ReleaseArchivedSaga } from './release-archived.saga';
|
||||||
import { UserDeactivatedSaga } from './user-deactivated.saga';
|
import { UserDeactivatedSaga } from './user-deactivated.saga';
|
||||||
import { CommentersFollowSaga } from './commenters-follow.saga';
|
import { CommentersFollowSaga } from './commenters-follow.saga';
|
||||||
|
import { SprintCarryOverSaga } from './sprint-carry-over.saga';
|
||||||
|
|
||||||
/** Reactions to events that change other aggregates (run by the events bridge). */
|
/** Reactions to events that change other aggregates (run by the events bridge). */
|
||||||
export const SAGAS = [
|
export const SAGAS = [
|
||||||
@@ -9,4 +10,5 @@ export const SAGAS = [
|
|||||||
ReleaseArchivedSaga,
|
ReleaseArchivedSaga,
|
||||||
UserDeactivatedSaga,
|
UserDeactivatedSaga,
|
||||||
CommentersFollowSaga,
|
CommentersFollowSaga,
|
||||||
|
SprintCarryOverSaga,
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
SprintClosedEvent,
|
||||||
|
SprintDeletedEvent,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { from, mergeMap, Observable } from 'rxjs';
|
||||||
|
import { CarryOverIssueCommand } from '../commands/sprint.commands';
|
||||||
|
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When a sprint ends, what was left moves on:
|
||||||
|
*
|
||||||
|
* SprintClosed → CarryOverIssue for its unfinished tickets, to the sprint
|
||||||
|
* chosen when closing it (or back to the backlog)
|
||||||
|
* SprintDeleted → CarryOverIssue for all its tickets, back to the backlog
|
||||||
|
*
|
||||||
|
* Who was in the sprint comes from the event store. The moves are
|
||||||
|
* attributed to whoever closed or deleted the sprint.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SprintCarryOverSaga {
|
||||||
|
constructor(private readonly streams: TrackerStreamsRepository) {}
|
||||||
|
|
||||||
|
@Saga()
|
||||||
|
sprintEnded = (events$: Observable<IEvent>): Observable<ICommand> =>
|
||||||
|
events$.pipe(
|
||||||
|
ofType(SprintClosedEvent, SprintDeletedEvent),
|
||||||
|
mergeMap((event) =>
|
||||||
|
from(this.streams.issueIdsEverInSprint(event.aggregateId)).pipe(
|
||||||
|
mergeMap((issueIds) =>
|
||||||
|
issueIds.map(
|
||||||
|
(issueId) =>
|
||||||
|
new CarryOverIssueCommand(
|
||||||
|
event.actorId,
|
||||||
|
issueId,
|
||||||
|
event.aggregateId,
|
||||||
|
event instanceof SprintClosedEvent ? event.carryOverTo : null,
|
||||||
|
event instanceof SprintClosedEvent,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
122
libs/api/tracker/application/src/lib/sprints.service.ts
Normal file
122
libs/api/tracker/application/src/lib/sprints.service.ts
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||||
|
import {
|
||||||
|
SprintBurndownReadModel,
|
||||||
|
SprintDetails,
|
||||||
|
SprintProgressReadModel,
|
||||||
|
SprintReadModel,
|
||||||
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
import { CommandResult } from './commands/command-result';
|
||||||
|
import {
|
||||||
|
CloseSprintCommand,
|
||||||
|
CreateSprintCommand,
|
||||||
|
DeleteSprintCommand,
|
||||||
|
SetIssueSprintCommand,
|
||||||
|
StartSprintCommand,
|
||||||
|
UpdateSprintCommand,
|
||||||
|
} from './commands/sprint.commands';
|
||||||
|
import {
|
||||||
|
GetSprintBurndownQuery,
|
||||||
|
GetSprintProgressQuery,
|
||||||
|
GetSprintQuery,
|
||||||
|
GetSprintsQuery,
|
||||||
|
} from './queries/sprint.queries';
|
||||||
|
|
||||||
|
export interface CreateSprintInput {
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
goal?: string | null;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sprints: plan them, run them, close them (and what was left moves on). */
|
||||||
|
@Injectable()
|
||||||
|
export class SprintsService {
|
||||||
|
constructor(
|
||||||
|
private readonly commandBus: CommandBus,
|
||||||
|
private readonly queryBus: QueryBus,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
list(viewerId: string | null, projectId: string): Promise<SprintReadModel[]> {
|
||||||
|
return this.queryBus.execute(new GetSprintsQuery(viewerId, projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
findOne(viewerId: string | null, sprintId: string): Promise<SprintReadModel> {
|
||||||
|
return this.queryBus.execute(new GetSprintQuery(viewerId, sprintId));
|
||||||
|
}
|
||||||
|
|
||||||
|
progress(
|
||||||
|
viewerId: string | null,
|
||||||
|
sprintId: string,
|
||||||
|
): Promise<SprintProgressReadModel> {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new GetSprintProgressQuery(viewerId, sprintId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
burndown(
|
||||||
|
viewerId: string | null,
|
||||||
|
sprintId: string,
|
||||||
|
): Promise<SprintBurndownReadModel> {
|
||||||
|
return this.queryBus.execute(
|
||||||
|
new GetSprintBurndownQuery(viewerId, sprintId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
create(actorId: string, input: CreateSprintInput): Promise<CommandResult> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new CreateSprintCommand(
|
||||||
|
actorId,
|
||||||
|
input.projectId,
|
||||||
|
input.name,
|
||||||
|
input.startDate,
|
||||||
|
input.endDate,
|
||||||
|
input.goal ?? null,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(
|
||||||
|
actorId: string,
|
||||||
|
sprintId: string,
|
||||||
|
changes: SprintDetails,
|
||||||
|
): Promise<CommandResult> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new UpdateSprintCommand(actorId, sprintId, changes),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
start(actorId: string, sprintId: string): Promise<CommandResult> {
|
||||||
|
return this.commandBus.execute(new StartSprintCommand(actorId, sprintId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unfinished tickets go to `carryOverTo` (null: the backlog). */
|
||||||
|
close(
|
||||||
|
actorId: string,
|
||||||
|
sprintId: string,
|
||||||
|
carryOverTo: string | null,
|
||||||
|
): Promise<CommandResult> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new CloseSprintCommand(actorId, sprintId, carryOverTo),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Planned sprints only; its tickets go back to the backlog. */
|
||||||
|
delete(actorId: string, sprintId: string): Promise<CommandResult> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new DeleteSprintCommand(actorId, sprintId),
|
||||||
|
) as Promise<CommandResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Puts a ticket in a sprint (null: back to the backlog). */
|
||||||
|
setIssueSprint(
|
||||||
|
actorId: string,
|
||||||
|
issueId: string,
|
||||||
|
sprintId: string | null,
|
||||||
|
): Promise<CommandResult> {
|
||||||
|
return this.commandBus.execute(
|
||||||
|
new SetIssueSprintCommand(actorId, issueId, sprintId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,6 +31,7 @@ import {
|
|||||||
|
|
||||||
import { SAVED_FILTERS } from './saved-filters/saved-filters';
|
import { SAVED_FILTERS } from './saved-filters/saved-filters';
|
||||||
import { SavedFiltersService } from './saved-filters.service';
|
import { SavedFiltersService } from './saved-filters.service';
|
||||||
|
import { SprintsService } from './sprints.service';
|
||||||
|
|
||||||
/** Notifications: derived from events, kept and dismissed per person. */
|
/** Notifications: derived from events, kept and dismissed per person. */
|
||||||
const NOTIFICATIONS = [
|
const NOTIFICATIONS = [
|
||||||
@@ -51,6 +52,7 @@ const SERVICES = [
|
|||||||
WorkflowsService,
|
WorkflowsService,
|
||||||
NotificationsService,
|
NotificationsService,
|
||||||
SavedFiltersService,
|
SavedFiltersService,
|
||||||
|
SprintsService,
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -243,6 +243,20 @@ export class InMemoryTrackerStreams extends TrackerStreamsRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sprintIdsOfProject(projectId: string) {
|
||||||
|
return this.streams(
|
||||||
|
'SprintCreatedEvent',
|
||||||
|
(d) => d['projectId'] === projectId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async issueIdsEverInSprint(sprintId: string) {
|
||||||
|
return this.streams(
|
||||||
|
'IssueSprintChangedEvent',
|
||||||
|
(d) => d['sprintId'] === sprintId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async commentIdsOfIssue(issueId: string) {
|
async commentIdsOfIssue(issueId: string) {
|
||||||
return this.streams(
|
return this.streams(
|
||||||
'CommentAddedEvent',
|
'CommentAddedEvent',
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ export * from './lib/project/project-settings';
|
|||||||
export * from './lib/comment/comment';
|
export * from './lib/comment/comment';
|
||||||
export * from './lib/release/release';
|
export * from './lib/release/release';
|
||||||
export * from './lib/release/release-status';
|
export * from './lib/release/release-status';
|
||||||
|
export * from './lib/sprint/sprint';
|
||||||
|
export * from './lib/sprint/sprint-status';
|
||||||
export * from './lib/events';
|
export * from './lib/events';
|
||||||
export * from './lib/factories';
|
export * from './lib/factories';
|
||||||
export * from './lib/read-models';
|
export * from './lib/read-models';
|
||||||
|
|||||||
@@ -31,3 +31,9 @@ export * from './issue-checklist-item-added.event';
|
|||||||
export * from './issue-checklist-item-updated.event';
|
export * from './issue-checklist-item-updated.event';
|
||||||
export * from './issue-checklist-item-removed.event';
|
export * from './issue-checklist-item-removed.event';
|
||||||
export * from './issue-ranked.event';
|
export * from './issue-ranked.event';
|
||||||
|
export * from './sprint-created.event';
|
||||||
|
export * from './sprint-updated.event';
|
||||||
|
export * from './sprint-started.event';
|
||||||
|
export * from './sprint-closed.event';
|
||||||
|
export * from './sprint-deleted.event';
|
||||||
|
export * from './issue-sprint-changed.event';
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||||
|
import { TrackerEvent } from './tracker.event';
|
||||||
|
|
||||||
|
/** A ticket was moved in or out of a sprint. */
|
||||||
|
@AutowiredEvent
|
||||||
|
export class IssueSprintChangedEvent extends TrackerEvent {
|
||||||
|
constructor(
|
||||||
|
actorId: string,
|
||||||
|
issueId: string,
|
||||||
|
projectId: string,
|
||||||
|
readonly sprintId: string | null,
|
||||||
|
readonly previousSprintId: string | null,
|
||||||
|
) {
|
||||||
|
super(actorId, issueId, projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||||
|
import { TrackerEvent } from './tracker.event';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A sprint ended. Its unfinished tickets move to `carryOverTo` (null: back
|
||||||
|
* to the backlog); the SprintCarryOverSaga moves them.
|
||||||
|
*/
|
||||||
|
@AutowiredEvent
|
||||||
|
export class SprintClosedEvent extends TrackerEvent {
|
||||||
|
constructor(
|
||||||
|
actorId: string,
|
||||||
|
sprintId: string,
|
||||||
|
projectId: string,
|
||||||
|
readonly carryOverTo: string | null,
|
||||||
|
readonly unfinishedIssues: number,
|
||||||
|
) {
|
||||||
|
super(actorId, sprintId, projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||||
|
import type { SprintProps } from '../sprint/sprint';
|
||||||
|
import { TrackerEvent } from './tracker.event';
|
||||||
|
|
||||||
|
/** A sprint was planned. */
|
||||||
|
@AutowiredEvent
|
||||||
|
export class SprintCreatedEvent extends TrackerEvent {
|
||||||
|
constructor(
|
||||||
|
actorId: string,
|
||||||
|
readonly sprint: SprintProps,
|
||||||
|
) {
|
||||||
|
super(actorId, sprint.id, sprint.projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||||
|
import { TrackerEvent } from './tracker.event';
|
||||||
|
|
||||||
|
/** A sprint was deleted: its tickets go back to the backlog. */
|
||||||
|
@AutowiredEvent
|
||||||
|
export class SprintDeletedEvent extends TrackerEvent {
|
||||||
|
constructor(actorId: string, sprintId: string, projectId: string) {
|
||||||
|
super(actorId, sprintId, projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||||
|
import { TrackerEvent } from './tracker.event';
|
||||||
|
|
||||||
|
/** A sprint got under way. */
|
||||||
|
@AutowiredEvent
|
||||||
|
export class SprintStartedEvent extends TrackerEvent {
|
||||||
|
constructor(actorId: string, sprintId: string, projectId: string) {
|
||||||
|
super(actorId, sprintId, projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||||
|
import type { SprintDetails } from '../sprint/sprint';
|
||||||
|
import { TrackerEvent } from './tracker.event';
|
||||||
|
|
||||||
|
/** A sprint was renamed, re-dated or got another goal (only what changed). */
|
||||||
|
@AutowiredEvent
|
||||||
|
export class SprintUpdatedEvent extends TrackerEvent {
|
||||||
|
constructor(
|
||||||
|
actorId: string,
|
||||||
|
sprintId: string,
|
||||||
|
projectId: string,
|
||||||
|
readonly changes: SprintDetails,
|
||||||
|
) {
|
||||||
|
super(actorId, sprintId, projectId);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,15 @@ import { CommentFactory } from './comment.factory';
|
|||||||
import { IssueFactory } from './issue.factory';
|
import { IssueFactory } from './issue.factory';
|
||||||
import { ProjectFactory } from './project.factory';
|
import { ProjectFactory } from './project.factory';
|
||||||
import { ReleaseFactory } from './release.factory';
|
import { ReleaseFactory } from './release.factory';
|
||||||
|
import { SprintFactory } from './sprint.factory';
|
||||||
|
|
||||||
export * from './issue.factory';
|
export * from './issue.factory';
|
||||||
export { CommentFactory, ProjectFactory, ReleaseFactory };
|
export { CommentFactory, ProjectFactory, ReleaseFactory, SprintFactory };
|
||||||
|
|
||||||
export const TRACKER_FACTORIES = [
|
export const TRACKER_FACTORIES = [
|
||||||
ProjectFactory,
|
ProjectFactory,
|
||||||
IssueFactory,
|
IssueFactory,
|
||||||
CommentFactory,
|
CommentFactory,
|
||||||
ReleaseFactory,
|
ReleaseFactory,
|
||||||
|
SprintFactory,
|
||||||
];
|
];
|
||||||
|
|||||||
39
libs/api/tracker/domain/src/lib/factories/sprint.factory.ts
Normal file
39
libs/api/tracker/domain/src/lib/factories/sprint.factory.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { newId } from '@task-stream/api/shared/domain';
|
||||||
|
import { SprintCreatedEvent } from '../events/sprint-created.event';
|
||||||
|
import { Sprint, SprintProps, normalizeSprintDetails } from '../sprint/sprint';
|
||||||
|
import { SprintStatus } from '../sprint/sprint-status';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SprintFactory {
|
||||||
|
create(
|
||||||
|
actorId: string,
|
||||||
|
props: {
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
goal?: string | null;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
},
|
||||||
|
): Sprint {
|
||||||
|
const details = normalizeSprintDetails({
|
||||||
|
name: props.name,
|
||||||
|
goal: props.goal ?? null,
|
||||||
|
startDate: props.startDate,
|
||||||
|
endDate: props.endDate,
|
||||||
|
});
|
||||||
|
const data: SprintProps = {
|
||||||
|
id: newId(),
|
||||||
|
projectId: props.projectId,
|
||||||
|
...details,
|
||||||
|
status: SprintStatus.PLANNED,
|
||||||
|
startedAt: null,
|
||||||
|
closedAt: null,
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
const sprint = new Sprint(data.id);
|
||||||
|
Object.assign(sprint, { ...data });
|
||||||
|
sprint.apply(new SprintCreatedEvent(actorId, data), { skipHandler: true });
|
||||||
|
return sprint;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import { IssueLinkedEvent } from '../events/issue-linked.event';
|
|||||||
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
|
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
|
||||||
import { IssueRankedEvent } from '../events/issue-ranked.event';
|
import { IssueRankedEvent } from '../events/issue-ranked.event';
|
||||||
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
|
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
|
||||||
|
import { IssueSprintChangedEvent } from '../events/issue-sprint-changed.event';
|
||||||
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
|
||||||
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
|
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
|
||||||
import { IssueUpdatedEvent } from '../events/issue-updated.event';
|
import { IssueUpdatedEvent } from '../events/issue-updated.event';
|
||||||
@@ -112,6 +113,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
|||||||
public checklist: ChecklistItem[] = [];
|
public checklist: ChecklistItem[] = [];
|
||||||
/** Place in the backlog once someone moved it (see `backlogRank`). */
|
/** Place in the backlog once someone moved it (see `backlogRank`). */
|
||||||
public rank: string | null = null;
|
public rank: string | null = null;
|
||||||
|
public sprintId: string | null = null;
|
||||||
public createdAt!: string;
|
public createdAt!: string;
|
||||||
public updatedAt!: string;
|
public updatedAt!: string;
|
||||||
public deleted = false;
|
public deleted = false;
|
||||||
@@ -246,6 +248,20 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Moves the ticket in or out of a sprint (null: back to the backlog). */
|
||||||
|
setSprint(actorId: string, sprintId: string | null): void {
|
||||||
|
if (this.sprintId === sprintId) return;
|
||||||
|
this.apply(
|
||||||
|
new IssueSprintChangedEvent(
|
||||||
|
actorId,
|
||||||
|
this.id,
|
||||||
|
this.projectId,
|
||||||
|
sprintId,
|
||||||
|
this.sprintId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Records a file stored for this ticket (or one of its comments). */
|
/** Records a file stored for this ticket (or one of its comments). */
|
||||||
attach(actorId: string, attachment: IssueAttachment): void {
|
attach(actorId: string, attachment: IssueAttachment): void {
|
||||||
const next = normalizeAttachment(attachment);
|
const next = normalizeAttachment(attachment);
|
||||||
@@ -519,6 +535,11 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
|
|||||||
this.deleted = true;
|
this.deleted = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onIssueSprintChangedEvent(event: IssueSprintChangedEvent): void {
|
||||||
|
this.sprintId = event.sprintId;
|
||||||
|
this.updatedAt = event.occurredAt;
|
||||||
|
}
|
||||||
|
|
||||||
onIssueReleaseChangedEvent(event: IssueReleaseChangedEvent): void {
|
onIssueReleaseChangedEvent(event: IssueReleaseChangedEvent): void {
|
||||||
this.releaseId = event.releaseId;
|
this.releaseId = event.releaseId;
|
||||||
this.updatedAt = event.occurredAt;
|
this.updatedAt = event.occurredAt;
|
||||||
|
|||||||
@@ -12,12 +12,13 @@ export class IssueTimelineEntry {
|
|||||||
issueId!: string;
|
issueId!: string;
|
||||||
projectId!: string;
|
projectId!: string;
|
||||||
at!: string;
|
at!: string;
|
||||||
kind!: 'status' | 'release';
|
kind!: 'status' | 'release' | 'sprint';
|
||||||
status?: string;
|
status?: string;
|
||||||
category?: StatusCategory;
|
category?: StatusCategory;
|
||||||
/** Assignee when the status changed (throughput per person). */
|
/** Assignee when the status changed (throughput per person). */
|
||||||
assigneeId?: string | null;
|
assigneeId?: string | null;
|
||||||
releaseId?: string | null;
|
releaseId?: string | null;
|
||||||
|
sprintId?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class DurationStats {
|
export class DurationStats {
|
||||||
|
|||||||
@@ -8,3 +8,4 @@ export * from './project-stats.read-model';
|
|||||||
export * from './analytics.read-model';
|
export * from './analytics.read-model';
|
||||||
export * from './notification.read-model';
|
export * from './notification.read-model';
|
||||||
export * from './saved-filter.read-model';
|
export * from './saved-filter.read-model';
|
||||||
|
export * from './sprint.read-model';
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export class IssueReadModel {
|
|||||||
reporter!: UserRefReadModel;
|
reporter!: UserRefReadModel;
|
||||||
assignee!: UserRefReadModel | null;
|
assignee!: UserRefReadModel | null;
|
||||||
release!: { id: string; name: string } | null;
|
release!: { id: string; name: string } | null;
|
||||||
|
sprint!: { id: string; name: string } | null;
|
||||||
labels!: string[];
|
labels!: string[];
|
||||||
figmaUrl!: string | null;
|
figmaUrl!: string | null;
|
||||||
storyPoints!: number | null;
|
storyPoints!: number | null;
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { SprintStatus } from '../sprint/sprint-status';
|
||||||
|
|
||||||
|
export class SprintReadModel {
|
||||||
|
id!: string;
|
||||||
|
projectId!: string;
|
||||||
|
name!: string;
|
||||||
|
goal!: string | null;
|
||||||
|
/** `YYYY-MM-DD`, both included. */
|
||||||
|
startDate!: string;
|
||||||
|
endDate!: string;
|
||||||
|
status!: SprintStatus;
|
||||||
|
startedAt!: string | null;
|
||||||
|
closedAt!: string | null;
|
||||||
|
createdAt!: string;
|
||||||
|
version!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** How far a sprint's tickets have got. */
|
||||||
|
export class SprintProgressReadModel {
|
||||||
|
total!: number;
|
||||||
|
/** Done or cancelled. */
|
||||||
|
finished!: number;
|
||||||
|
percent!: number;
|
||||||
|
storyPoints!: number;
|
||||||
|
finishedStoryPoints!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per day of the sprint: tickets in it (cancelled ones aside), how many are
|
||||||
|
* still open and their points. Days still to come are null; `ideal` falls in
|
||||||
|
* a straight line from what was open on the first day to zero on the last.
|
||||||
|
*/
|
||||||
|
export class SprintBurndownReadModel {
|
||||||
|
sprintId!: string;
|
||||||
|
dates!: string[];
|
||||||
|
scope!: (number | null)[];
|
||||||
|
remaining!: (number | null)[];
|
||||||
|
remainingPoints!: (number | null)[];
|
||||||
|
ideal!: number[];
|
||||||
|
}
|
||||||
8
libs/api/tracker/domain/src/lib/sprint/sprint-status.ts
Normal file
8
libs/api/tracker/domain/src/lib/sprint/sprint-status.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
export enum SprintStatus {
|
||||||
|
/** Being prepared: tickets come and go. */
|
||||||
|
PLANNED = 'PLANNED',
|
||||||
|
/** Under way (one per project at a time). */
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
/** Over: what was left went to another sprint or back to the backlog. */
|
||||||
|
CLOSED = 'CLOSED',
|
||||||
|
}
|
||||||
79
libs/api/tracker/domain/src/lib/sprint/sprint.spec.ts
Normal file
79
libs/api/tracker/domain/src/lib/sprint/sprint.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
import {
|
||||||
|
BusinessRuleError,
|
||||||
|
ValidationError,
|
||||||
|
} from '@task-stream/api/shared/domain';
|
||||||
|
import { SprintClosedEvent } from '../events/sprint-closed.event';
|
||||||
|
import { SprintFactory } from '../factories/sprint.factory';
|
||||||
|
import { replay } from '../testing';
|
||||||
|
import { Sprint } from './sprint';
|
||||||
|
import { SprintStatus } from './sprint-status';
|
||||||
|
|
||||||
|
const factory = new SprintFactory();
|
||||||
|
const newSprint = (name = 'Sprint 1', projectId = 'p-1') =>
|
||||||
|
factory.create('u-pm', {
|
||||||
|
projectId,
|
||||||
|
name,
|
||||||
|
goal: ' Pagos con tarjeta ',
|
||||||
|
startDate: '2026-10-12',
|
||||||
|
endDate: '2026-10-23',
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('Sprint', () => {
|
||||||
|
it('is planned with tidy details', () => {
|
||||||
|
const sprint = newSprint();
|
||||||
|
expect(sprint.status).toBe(SprintStatus.PLANNED);
|
||||||
|
expect(sprint.goal).toBe('Pagos con tarjeta');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses dates the wrong way round, too long or malformed', () => {
|
||||||
|
const create = (startDate: string, endDate: string) =>
|
||||||
|
factory.create('u', { projectId: 'p', name: 'S', startDate, endDate });
|
||||||
|
expect(() => create('2026-10-23', '2026-10-12')).toThrow(ValidationError);
|
||||||
|
expect(() => create('2026-01-01', '2026-12-31')).toThrow(/at most/);
|
||||||
|
expect(() => create('12/10/2026', '2026-10-23')).toThrow(ValidationError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('starts once, only when nothing else is under way, and closes', () => {
|
||||||
|
const sprint = newSprint();
|
||||||
|
const next = newSprint('Sprint 2');
|
||||||
|
expect(() => sprint.start('u', { name: 'Sprint 0' })).toThrow(
|
||||||
|
BusinessRuleError,
|
||||||
|
);
|
||||||
|
sprint.start('u', null);
|
||||||
|
expect(() => sprint.start('u', null)).toThrow(BusinessRuleError);
|
||||||
|
expect(() => sprint.update('u', { startDate: '2026-10-13' })).toThrow(
|
||||||
|
/start date/,
|
||||||
|
);
|
||||||
|
sprint.update('u', { endDate: '2026-10-26' });
|
||||||
|
|
||||||
|
sprint.close('u', next, 3);
|
||||||
|
const closed = sprint
|
||||||
|
.getUncommittedEvents()
|
||||||
|
.find((e) => e instanceof SprintClosedEvent);
|
||||||
|
expect(closed).toMatchObject({ carryOverTo: next.id, unfinishedIssues: 3 });
|
||||||
|
const { rebuilt } = replay(sprint, Sprint);
|
||||||
|
expect(rebuilt.status).toBe(SprintStatus.CLOSED);
|
||||||
|
expect(rebuilt.endDate).toBe('2026-10-26');
|
||||||
|
expect(() => rebuilt.assertAcceptsIssues()).toThrow(BusinessRuleError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('carries over only to another open sprint of the same project', () => {
|
||||||
|
const sprint = newSprint();
|
||||||
|
sprint.start('u', null);
|
||||||
|
expect(() => sprint.close('u', sprint, 0)).toThrow(BusinessRuleError);
|
||||||
|
expect(() => sprint.close('u', newSprint('X', 'p-2'), 0)).toThrow(
|
||||||
|
BusinessRuleError,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes planned sprints, and any on cascade', () => {
|
||||||
|
const planned = newSprint();
|
||||||
|
planned.delete('u');
|
||||||
|
expect(planned.deleted).toBe(true);
|
||||||
|
const active = newSprint();
|
||||||
|
active.start('u', null);
|
||||||
|
expect(() => active.delete('u')).toThrow(BusinessRuleError);
|
||||||
|
active.delete('u', true);
|
||||||
|
expect(active.deleted).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
192
libs/api/tracker/domain/src/lib/sprint/sprint.ts
Normal file
192
libs/api/tracker/domain/src/lib/sprint/sprint.ts
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import {
|
||||||
|
BusinessRuleError,
|
||||||
|
VersionedAggregateRoot,
|
||||||
|
assertThat,
|
||||||
|
} from '@task-stream/api/shared/domain';
|
||||||
|
import { SprintClosedEvent } from '../events/sprint-closed.event';
|
||||||
|
import { SprintCreatedEvent } from '../events/sprint-created.event';
|
||||||
|
import { SprintDeletedEvent } from '../events/sprint-deleted.event';
|
||||||
|
import { SprintStartedEvent } from '../events/sprint-started.event';
|
||||||
|
import { SprintUpdatedEvent } from '../events/sprint-updated.event';
|
||||||
|
import { SprintStatus } from './sprint-status';
|
||||||
|
|
||||||
|
/** Data of a sprint (what SprintCreatedEvent carries). */
|
||||||
|
export interface SprintProps {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
goal: string | null;
|
||||||
|
/** First and last day, `YYYY-MM-DD`. */
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
status: SprintStatus;
|
||||||
|
startedAt: string | null;
|
||||||
|
closedAt: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SprintDetails = Partial<
|
||||||
|
Pick<SprintProps, 'name' | 'goal' | 'startDate' | 'endDate'>
|
||||||
|
>;
|
||||||
|
|
||||||
|
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
/** Long enough for any real iteration, short enough to catch a typo. */
|
||||||
|
export const MAX_SPRINT_DAYS = 90;
|
||||||
|
|
||||||
|
/** Checks the name and the dates of a sprint; returns them tidied. */
|
||||||
|
export function normalizeSprintDetails(
|
||||||
|
details: Required<Pick<SprintProps, 'name' | 'startDate' | 'endDate'>> &
|
||||||
|
Pick<SprintProps, 'goal'>,
|
||||||
|
): Required<Pick<SprintProps, 'name' | 'startDate' | 'endDate' | 'goal'>> {
|
||||||
|
const name = details.name.trim();
|
||||||
|
assertThat(
|
||||||
|
name.length > 0 && name.length <= 80,
|
||||||
|
'A sprint needs a name (up to 80 characters)',
|
||||||
|
);
|
||||||
|
for (const date of [details.startDate, details.endDate])
|
||||||
|
assertThat(
|
||||||
|
DATE.test(date) && !Number.isNaN(Date.parse(date)),
|
||||||
|
'Sprint dates must be YYYY-MM-DD',
|
||||||
|
);
|
||||||
|
const days =
|
||||||
|
(Date.parse(details.endDate) - Date.parse(details.startDate)) / 86_400_000;
|
||||||
|
assertThat(days >= 0, 'A sprint cannot end before it starts');
|
||||||
|
assertThat(
|
||||||
|
days < MAX_SPRINT_DAYS,
|
||||||
|
`A sprint lasts at most ${MAX_SPRINT_DAYS} days`,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
goal: details.goal?.trim() || null,
|
||||||
|
startDate: details.startDate,
|
||||||
|
endDate: details.endDate,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A time-boxed iteration: the tickets the team commits to for a few weeks. */
|
||||||
|
export class Sprint extends VersionedAggregateRoot implements SprintProps {
|
||||||
|
public projectId!: string;
|
||||||
|
public name!: string;
|
||||||
|
public goal: string | null = null;
|
||||||
|
public startDate!: string;
|
||||||
|
public endDate!: string;
|
||||||
|
public status: SprintStatus = SprintStatus.PLANNED;
|
||||||
|
public startedAt: string | null = null;
|
||||||
|
public closedAt: string | null = null;
|
||||||
|
public createdAt!: string;
|
||||||
|
public deleted = false;
|
||||||
|
|
||||||
|
constructor(id: string) {
|
||||||
|
super();
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Closed sprints keep what they had: their record is fixed. */
|
||||||
|
assertAcceptsIssues(): void {
|
||||||
|
if (this.status === SprintStatus.CLOSED)
|
||||||
|
throw new BusinessRuleError(`Sprint ${this.name} is closed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
update(actorId: string, changes: SprintDetails): void {
|
||||||
|
this.assertAcceptsIssues();
|
||||||
|
const next = normalizeSprintDetails({
|
||||||
|
name: changes.name ?? this.name,
|
||||||
|
goal: changes.goal !== undefined ? changes.goal : this.goal,
|
||||||
|
startDate: changes.startDate ?? this.startDate,
|
||||||
|
endDate: changes.endDate ?? this.endDate,
|
||||||
|
});
|
||||||
|
if (
|
||||||
|
this.status === SprintStatus.ACTIVE &&
|
||||||
|
next.startDate !== this.startDate
|
||||||
|
)
|
||||||
|
throw new BusinessRuleError('A sprint under way keeps its start date');
|
||||||
|
const patch: SprintDetails = {};
|
||||||
|
for (const field of ['name', 'goal', 'startDate', 'endDate'] as const)
|
||||||
|
if (next[field] !== this[field])
|
||||||
|
(patch as Record<string, unknown>)[field] = next[field];
|
||||||
|
if (Object.keys(patch).length === 0) return;
|
||||||
|
this.apply(new SprintUpdatedEvent(actorId, this.id, this.projectId, patch));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** `otherActive`: the sprint already under way in the project, if any. */
|
||||||
|
start(actorId: string, otherActive: { name: string } | null): void {
|
||||||
|
if (this.status !== SprintStatus.PLANNED)
|
||||||
|
throw new BusinessRuleError(`Sprint ${this.name} is ${this.status}`);
|
||||||
|
if (otherActive)
|
||||||
|
throw new BusinessRuleError(
|
||||||
|
`Sprint ${otherActive.name} is under way; close it first`,
|
||||||
|
);
|
||||||
|
this.apply(new SprintStartedEvent(actorId, this.id, this.projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ends the sprint. Its unfinished tickets go to `carryOverTo` (another
|
||||||
|
* open sprint of the project) or, with null, back to the backlog.
|
||||||
|
*/
|
||||||
|
close(
|
||||||
|
actorId: string,
|
||||||
|
carryOverTo: Sprint | null,
|
||||||
|
unfinishedIssues: number,
|
||||||
|
): void {
|
||||||
|
if (this.status !== SprintStatus.ACTIVE)
|
||||||
|
throw new BusinessRuleError(
|
||||||
|
`Only a sprint under way can be closed; ${this.name} is ${this.status}`,
|
||||||
|
);
|
||||||
|
if (carryOverTo) {
|
||||||
|
if (
|
||||||
|
carryOverTo.id === this.id ||
|
||||||
|
carryOverTo.projectId !== this.projectId
|
||||||
|
)
|
||||||
|
throw new BusinessRuleError(
|
||||||
|
'Unfinished tickets go to another sprint of the same project',
|
||||||
|
);
|
||||||
|
carryOverTo.assertAcceptsIssues();
|
||||||
|
}
|
||||||
|
this.apply(
|
||||||
|
new SprintClosedEvent(
|
||||||
|
actorId,
|
||||||
|
this.id,
|
||||||
|
this.projectId,
|
||||||
|
carryOverTo?.id ?? null,
|
||||||
|
unfinishedIssues,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a sprint that never started (its tickets go back to the
|
||||||
|
* backlog), or any sprint when its project is deleted (`cascade`).
|
||||||
|
*/
|
||||||
|
delete(actorId: string, cascade = false): void {
|
||||||
|
if (this.deleted) return;
|
||||||
|
if (!cascade && this.status !== SprintStatus.PLANNED)
|
||||||
|
throw new BusinessRuleError(
|
||||||
|
`Sprint ${this.name} is ${this.status}; only planned sprints can be deleted`,
|
||||||
|
);
|
||||||
|
this.apply(new SprintDeletedEvent(actorId, this.id, this.projectId));
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- State transitions (also replayed by loadFromHistory) ---- */
|
||||||
|
|
||||||
|
onSprintCreatedEvent({ sprint }: SprintCreatedEvent): void {
|
||||||
|
Object.assign(this, sprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSprintUpdatedEvent(event: SprintUpdatedEvent): void {
|
||||||
|
Object.assign(this, event.changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
onSprintStartedEvent(event: SprintStartedEvent): void {
|
||||||
|
this.status = SprintStatus.ACTIVE;
|
||||||
|
this.startedAt = event.occurredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSprintClosedEvent(event: SprintClosedEvent): void {
|
||||||
|
this.status = SprintStatus.CLOSED;
|
||||||
|
this.closedAt = event.occurredAt;
|
||||||
|
}
|
||||||
|
|
||||||
|
onSprintDeletedEvent(): void {
|
||||||
|
this.deleted = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
ProjectRole,
|
ProjectRole,
|
||||||
ProjectStatsReadModel,
|
ProjectStatsReadModel,
|
||||||
ReleaseProgressReadModel,
|
ReleaseProgressReadModel,
|
||||||
|
SprintProgressReadModel,
|
||||||
ReleaseReadModel,
|
ReleaseReadModel,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
@@ -281,6 +282,37 @@ export class MongoFindIssuesRepository extends FindIssuesRepository {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async sprintProgress(sprintId: string): Promise<SprintProgressReadModel> {
|
||||||
|
const finished = { $in: ['$statusCategory', FINISHED] };
|
||||||
|
const points = { $ifNull: ['$storyPoints', 0] };
|
||||||
|
const [row] = await this.issues.aggregate<{
|
||||||
|
total: number;
|
||||||
|
finished: number;
|
||||||
|
storyPoints: number;
|
||||||
|
finishedStoryPoints: number;
|
||||||
|
}>([
|
||||||
|
{ $match: { 'sprint.id': sprintId } },
|
||||||
|
{
|
||||||
|
$group: {
|
||||||
|
_id: null,
|
||||||
|
total: { $sum: 1 },
|
||||||
|
finished: { $sum: { $cond: [finished, 1, 0] } },
|
||||||
|
storyPoints: { $sum: points },
|
||||||
|
finishedStoryPoints: { $sum: { $cond: [finished, points, 0] } },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const total = row?.total ?? 0;
|
||||||
|
return {
|
||||||
|
total,
|
||||||
|
finished: row?.finished ?? 0,
|
||||||
|
percent:
|
||||||
|
total === 0 ? 0 : Math.round(((row?.finished ?? 0) / total) * 100),
|
||||||
|
storyPoints: row?.storyPoints ?? 0,
|
||||||
|
finishedStoryPoints: row?.finishedStoryPoints ?? 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async releaseProgress(releaseId: string): Promise<ReleaseProgressReadModel> {
|
async releaseProgress(releaseId: string): Promise<ReleaseProgressReadModel> {
|
||||||
const rows = await this.issues.aggregate<{
|
const rows = await this.issues.aggregate<{
|
||||||
_id: { status: string; category: StatusCategory };
|
_id: { status: string; category: StatusCategory };
|
||||||
@@ -322,6 +354,8 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
|
|||||||
query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) };
|
query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) };
|
||||||
if (filter.releaseId) query['release.id'] = filter.releaseId;
|
if (filter.releaseId) query['release.id'] = filter.releaseId;
|
||||||
if (filter.parentId) query['parent.id'] = filter.parentId;
|
if (filter.parentId) query['parent.id'] = filter.parentId;
|
||||||
|
if (filter.noSprint) query['sprint'] = null;
|
||||||
|
else if (filter.sprintId) query['sprint.id'] = filter.sprintId;
|
||||||
if (filter.unassigned) query['assignee'] = null;
|
if (filter.unassigned) query['assignee'] = null;
|
||||||
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
|
||||||
if (filter.search?.trim()) {
|
if (filter.search?.trim()) {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
ProjectReadModel,
|
ProjectReadModel,
|
||||||
ReleaseReadModel,
|
ReleaseReadModel,
|
||||||
SavedFilterReadModel,
|
SavedFilterReadModel,
|
||||||
|
SprintReadModel,
|
||||||
implicitRank,
|
implicitRank,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import { Schema } from 'mongoose';
|
import { Schema } from 'mongoose';
|
||||||
@@ -24,6 +25,7 @@ export type IssueDoc = Doc<Omit<IssueReadModel, 'links'>> & {
|
|||||||
};
|
};
|
||||||
export type CommentDoc = Doc<CommentReadModel>;
|
export type CommentDoc = Doc<CommentReadModel>;
|
||||||
export type ReleaseDoc = Doc<ReleaseReadModel>;
|
export type ReleaseDoc = Doc<ReleaseReadModel>;
|
||||||
|
export type SprintDoc = Doc<SprintReadModel>;
|
||||||
export type ActivityDoc = Doc<ActivityReadModel>;
|
export type ActivityDoc = Doc<ActivityReadModel>;
|
||||||
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
export type TimelineDoc = Doc<IssueTimelineEntry>;
|
||||||
export type NotificationDoc = Doc<NotificationReadModel>;
|
export type NotificationDoc = Doc<NotificationReadModel>;
|
||||||
@@ -43,6 +45,7 @@ export const ReadModels = {
|
|||||||
Notification: 'NotificationReadModel',
|
Notification: 'NotificationReadModel',
|
||||||
NotificationSettings: 'NotificationSettings',
|
NotificationSettings: 'NotificationSettings',
|
||||||
SavedFilter: 'SavedFilter',
|
SavedFilter: 'SavedFilter',
|
||||||
|
Sprint: 'SprintReadModel',
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const userRef = { _id: false, id: String, name: String };
|
const userRef = { _id: false, id: String, name: String };
|
||||||
@@ -79,6 +82,7 @@ export const IssueSchema = new Schema<IssueDoc>(
|
|||||||
reporter: userRef,
|
reporter: userRef,
|
||||||
assignee: userRef,
|
assignee: userRef,
|
||||||
release: { _id: false, id: String, name: String },
|
release: { _id: false, id: String, name: String },
|
||||||
|
sprint: { _id: false, id: String, name: String },
|
||||||
outwardLinks: [link],
|
outwardLinks: [link],
|
||||||
inwardLinks: [link],
|
inwardLinks: [link],
|
||||||
watchers: [userRef],
|
watchers: [userRef],
|
||||||
@@ -108,6 +112,7 @@ IssueSchema.index({ 'watchers.id': 1 });
|
|||||||
IssueSchema.index({ 'parent.id': 1 });
|
IssueSchema.index({ 'parent.id': 1 });
|
||||||
IssueSchema.index({ projectId: 1, rank: 1 });
|
IssueSchema.index({ projectId: 1, rank: 1 });
|
||||||
IssueSchema.index({ 'release.id': 1 });
|
IssueSchema.index({ 'release.id': 1 });
|
||||||
|
IssueSchema.index({ 'sprint.id': 1 });
|
||||||
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
|
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
|
||||||
|
|
||||||
export const CommentSchema = new Schema<CommentDoc>(
|
export const CommentSchema = new Schema<CommentDoc>(
|
||||||
@@ -126,6 +131,16 @@ export const ReleaseSchema = new Schema<ReleaseDoc>(
|
|||||||
{ ...loose, collection: 'releases_view' },
|
{ ...loose, collection: 'releases_view' },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const SprintSchema = new Schema<SprintDoc>(
|
||||||
|
{
|
||||||
|
_id: String,
|
||||||
|
projectId: { type: String, index: true },
|
||||||
|
status: String,
|
||||||
|
version: Number,
|
||||||
|
},
|
||||||
|
{ ...loose, collection: 'sprints_view' },
|
||||||
|
);
|
||||||
|
|
||||||
export const ActivitySchema = new Schema<ActivityDoc>(
|
export const ActivitySchema = new Schema<ActivityDoc>(
|
||||||
{
|
{
|
||||||
_id: String,
|
_id: String,
|
||||||
@@ -182,6 +197,7 @@ export const READ_MODEL_SCHEMAS = [
|
|||||||
{ name: ReadModels.Issue, schema: IssueSchema },
|
{ name: ReadModels.Issue, schema: IssueSchema },
|
||||||
{ name: ReadModels.Comment, schema: CommentSchema },
|
{ name: ReadModels.Comment, schema: CommentSchema },
|
||||||
{ name: ReadModels.Release, schema: ReleaseSchema },
|
{ name: ReadModels.Release, schema: ReleaseSchema },
|
||||||
|
{ name: ReadModels.Sprint, schema: SprintSchema },
|
||||||
{ name: ReadModels.Activity, schema: ActivitySchema },
|
{ name: ReadModels.Activity, schema: ActivitySchema },
|
||||||
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
{ name: ReadModels.Timeline, schema: TimelineSchema },
|
||||||
{ name: ReadModels.Notification, schema: NotificationSchema },
|
{ name: ReadModels.Notification, schema: NotificationSchema },
|
||||||
@@ -207,6 +223,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
|
|||||||
watchers: rest.watchers ?? [],
|
watchers: rest.watchers ?? [],
|
||||||
parent: rest.parent?.id ? rest.parent : null,
|
parent: rest.parent?.id ? rest.parent : null,
|
||||||
rank: rest.rank ?? implicitRank(rest.number),
|
rank: rest.rank ?? implicitRank(rest.number),
|
||||||
|
sprint: rest.sprint?.id ? rest.sprint : null,
|
||||||
checklist: rest.checklist ?? [],
|
checklist: rest.checklist ?? [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectModel } from '@nestjs/mongoose';
|
||||||
|
import {
|
||||||
|
FindSprintsRepository,
|
||||||
|
UpsertMaterializedSprintRepository,
|
||||||
|
} from '@task-stream/api/tracker/application';
|
||||||
|
import { SprintReadModel, SprintStatus } from '@task-stream/api/tracker/domain';
|
||||||
|
import { Model } from 'mongoose';
|
||||||
|
import { ReadModels, SprintDoc, fromDoc } from './read-model.schemas';
|
||||||
|
import { upsertIfNewer } from './upsert-if-newer';
|
||||||
|
|
||||||
|
const ORDER: Record<SprintStatus, number> = {
|
||||||
|
[SprintStatus.ACTIVE]: 0,
|
||||||
|
[SprintStatus.PLANNED]: 1,
|
||||||
|
[SprintStatus.CLOSED]: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MongoFindSprintsRepository extends FindSprintsRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(ReadModels.Sprint) private readonly sprints: Model<SprintDoc>,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findById(id: string): Promise<SprintReadModel | null> {
|
||||||
|
const doc = await this.sprints.findById(id).lean<SprintDoc>();
|
||||||
|
return doc ? fromDoc(doc) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async findByProject(projectId: string): Promise<SprintReadModel[]> {
|
||||||
|
const docs = await this.sprints.find({ projectId }).lean<SprintDoc[]>();
|
||||||
|
return docs
|
||||||
|
.map(fromDoc)
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
ORDER[a.status] - ORDER[b.status] ||
|
||||||
|
(a.status === SprintStatus.CLOSED
|
||||||
|
? b.startDate.localeCompare(a.startDate)
|
||||||
|
: a.startDate.localeCompare(b.startDate)) ||
|
||||||
|
a.name.localeCompare(b.name),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async findActive(projectId: string): Promise<SprintReadModel | null> {
|
||||||
|
const doc = await this.sprints
|
||||||
|
.findOne({ projectId, status: SprintStatus.ACTIVE })
|
||||||
|
.lean<SprintDoc>();
|
||||||
|
return doc ? fromDoc(doc) : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class MongoUpsertMaterializedSprintRepository extends UpsertMaterializedSprintRepository {
|
||||||
|
constructor(
|
||||||
|
@InjectModel(ReadModels.Sprint) private readonly sprints: Model<SprintDoc>,
|
||||||
|
) {
|
||||||
|
super();
|
||||||
|
}
|
||||||
|
|
||||||
|
async upsert({
|
||||||
|
id,
|
||||||
|
version,
|
||||||
|
...fields
|
||||||
|
}: Pick<SprintReadModel, 'id' | 'version'> & Partial<SprintReadModel>) {
|
||||||
|
await upsertIfNewer(this.sprints, id, version, fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(sprintId: string) {
|
||||||
|
await this.sprints.deleteOne({ _id: sprintId });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -230,6 +230,13 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
|||||||
if (ops.length) await this.issues.bulkWrite(ops);
|
if (ops.length) await this.issues.bulkWrite(ops);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async renameSprint(sprintId: string, name: string) {
|
||||||
|
await this.issues.updateMany(
|
||||||
|
{ 'sprint.id': sprintId },
|
||||||
|
{ $set: { 'sprint.name': name } },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async renameRelease(releaseId: string, name: string) {
|
async renameRelease(releaseId: string, name: string) {
|
||||||
await this.issues.updateMany(
|
await this.issues.updateMany(
|
||||||
{ 'release.id': releaseId },
|
{ 'release.id': releaseId },
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ import {
|
|||||||
IssueCreatedEvent,
|
IssueCreatedEvent,
|
||||||
IssueLinkedEvent,
|
IssueLinkedEvent,
|
||||||
IssueParentChangedEvent,
|
IssueParentChangedEvent,
|
||||||
|
IssueSprintChangedEvent,
|
||||||
ReleaseCreatedEvent,
|
ReleaseCreatedEvent,
|
||||||
|
SprintCreatedEvent,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import { DataSource } from 'typeorm';
|
import { DataSource } from 'typeorm';
|
||||||
|
|
||||||
@@ -32,6 +34,22 @@ export class PostgresTrackerStreamsRepository extends TrackerStreamsRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sprintIdsOfProject(projectId: string): Promise<string[]> {
|
||||||
|
return this.streams(
|
||||||
|
SprintCreatedEvent.name,
|
||||||
|
`data->>'projectId'`,
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
issueIdsEverInSprint(sprintId: string): Promise<string[]> {
|
||||||
|
return this.streams(
|
||||||
|
IssueSprintChangedEvent.name,
|
||||||
|
`data->>'sprintId'`,
|
||||||
|
sprintId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
commentIdsOfIssue(issueId: string): Promise<string[]> {
|
commentIdsOfIssue(issueId: string): Promise<string[]> {
|
||||||
return this.streams(
|
return this.streams(
|
||||||
CommentAddedEvent.name,
|
CommentAddedEvent.name,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
IssueChange,
|
IssueChange,
|
||||||
ProjectChange,
|
ProjectChange,
|
||||||
ReleaseChange,
|
ReleaseChange,
|
||||||
|
SprintChange,
|
||||||
} from '@task-stream/api/tracker/application';
|
} from '@task-stream/api/tracker/application';
|
||||||
import {
|
import {
|
||||||
ActivityReadModel,
|
ActivityReadModel,
|
||||||
@@ -19,6 +20,7 @@ const Channels = {
|
|||||||
comment: (issueId: string) => `tracker.comment.${issueId}`,
|
comment: (issueId: string) => `tracker.comment.${issueId}`,
|
||||||
activity: (projectId: string) => `tracker.activity.${projectId}`,
|
activity: (projectId: string) => `tracker.activity.${projectId}`,
|
||||||
release: (projectId: string) => `tracker.release.${projectId}`,
|
release: (projectId: string) => `tracker.release.${projectId}`,
|
||||||
|
sprint: (projectId: string) => `tracker.sprint.${projectId}`,
|
||||||
project: (projectId: string) => `tracker.project.${projectId}`,
|
project: (projectId: string) => `tracker.project.${projectId}`,
|
||||||
userProjects: (userId: string) => `tracker.user-projects.${userId}`,
|
userProjects: (userId: string) => `tracker.user-projects.${userId}`,
|
||||||
notifications: (userId: string) => `tracker.notifications.${userId}`,
|
notifications: (userId: string) => `tracker.notifications.${userId}`,
|
||||||
@@ -49,6 +51,10 @@ export class RedisChangeFeed extends ChangeFeed {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
publishSprint(change: SprintChange): Promise<void> {
|
||||||
|
return this.pubSub.publish(Channels.sprint(change.projectId), change);
|
||||||
|
}
|
||||||
|
|
||||||
async publishProject(
|
async publishProject(
|
||||||
change: ProjectChange,
|
change: ProjectChange,
|
||||||
notifyUserIds: string[] = [],
|
notifyUserIds: string[] = [],
|
||||||
@@ -88,6 +94,10 @@ export class RedisChangeFeed extends ChangeFeed {
|
|||||||
return this.pubSub.asyncIterableIterator(Channels.release(projectId));
|
return this.pubSub.asyncIterableIterator(Channels.release(projectId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sprints(projectId: string): AsyncIterableIterator<SprintChange> {
|
||||||
|
return this.pubSub.asyncIterableIterator(Channels.sprint(projectId));
|
||||||
|
}
|
||||||
|
|
||||||
project(projectId: string): AsyncIterableIterator<ProjectChange> {
|
project(projectId: string): AsyncIterableIterator<ProjectChange> {
|
||||||
return this.pubSub.asyncIterableIterator(Channels.project(projectId));
|
return this.pubSub.asyncIterableIterator(Channels.project(projectId));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
FindIssuesRepository,
|
FindIssuesRepository,
|
||||||
FindProjectsRepository,
|
FindProjectsRepository,
|
||||||
FindReleasesRepository,
|
FindReleasesRepository,
|
||||||
|
FindSprintsRepository,
|
||||||
|
UpsertMaterializedSprintRepository,
|
||||||
FindTimelineRepository,
|
FindTimelineRepository,
|
||||||
IssueNumbersRepository,
|
IssueNumbersRepository,
|
||||||
ProjectKeysRepository,
|
ProjectKeysRepository,
|
||||||
@@ -58,6 +60,10 @@ import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.rep
|
|||||||
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
import { RedisChangeFeed } from './realtime/redis-change-feed';
|
||||||
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
import { MongoNotificationsRepository } from './mongo/notifications.repository';
|
||||||
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
|
||||||
|
import {
|
||||||
|
MongoFindSprintsRepository,
|
||||||
|
MongoUpsertMaterializedSprintRepository,
|
||||||
|
} from './mongo/sprints.repositories';
|
||||||
import { SmtpNotificationMailer } from './mail/smtp-notification.mailer';
|
import { SmtpNotificationMailer } from './mail/smtp-notification.mailer';
|
||||||
import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage';
|
import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage';
|
||||||
|
|
||||||
@@ -79,6 +85,11 @@ const PORTS = [
|
|||||||
{ provide: FindIssuesRepository, useClass: MongoFindIssuesRepository },
|
{ provide: FindIssuesRepository, useClass: MongoFindIssuesRepository },
|
||||||
{ provide: FindCommentsRepository, useClass: MongoFindCommentsRepository },
|
{ provide: FindCommentsRepository, useClass: MongoFindCommentsRepository },
|
||||||
{ provide: FindReleasesRepository, useClass: MongoFindReleasesRepository },
|
{ provide: FindReleasesRepository, useClass: MongoFindReleasesRepository },
|
||||||
|
{ provide: FindSprintsRepository, useClass: MongoFindSprintsRepository },
|
||||||
|
{
|
||||||
|
provide: UpsertMaterializedSprintRepository,
|
||||||
|
useClass: MongoUpsertMaterializedSprintRepository,
|
||||||
|
},
|
||||||
{ provide: FindActivityRepository, useClass: MongoFindActivityRepository },
|
{ provide: FindActivityRepository, useClass: MongoFindActivityRepository },
|
||||||
{ provide: FindTimelineRepository, useClass: MongoFindTimelineRepository },
|
{ provide: FindTimelineRepository, useClass: MongoFindTimelineRepository },
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
NotificationKind,
|
NotificationKind,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
|
SprintStatus,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
|
|
||||||
@@ -23,6 +24,10 @@ registerEnumType(StatusCategory, {
|
|||||||
registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
|
registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
|
||||||
registerEnumType(ProjectRole, { name: 'ProjectRole' });
|
registerEnumType(ProjectRole, { name: 'ProjectRole' });
|
||||||
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });
|
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });
|
||||||
|
registerEnumType(SprintStatus, {
|
||||||
|
name: 'SprintStatus',
|
||||||
|
description: 'PLANNED, ACTIVE (one per project at a time) or CLOSED',
|
||||||
|
});
|
||||||
registerEnumType(LinkDirection, { name: 'LinkDirection' });
|
registerEnumType(LinkDirection, { name: 'LinkDirection' });
|
||||||
registerEnumType(IssueSort, {
|
registerEnumType(IssueSort, {
|
||||||
name: 'IssueSort',
|
name: 'IssueSort',
|
||||||
|
|||||||
@@ -181,6 +181,41 @@ export class ChangeIssueStatusInput {
|
|||||||
reason?: string | null;
|
reason?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class CreateSprintInput {
|
||||||
|
@Field(() => ID) @IsUUID() projectId!: string;
|
||||||
|
@Field() @IsString() @MinLength(1) @MaxLength(80) name!: string;
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
goal?: string | null;
|
||||||
|
@Field({ description: 'YYYY-MM-DD' }) @Matches(DAY) startDate!: string;
|
||||||
|
@Field({ description: 'YYYY-MM-DD, included' })
|
||||||
|
@Matches(DAY)
|
||||||
|
endDate!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
@InputType()
|
||||||
|
export class UpdateSprintInput {
|
||||||
|
@Field(() => ID) @IsUUID() sprintId!: string;
|
||||||
|
@Field({ nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(80)
|
||||||
|
name?: string;
|
||||||
|
@Field(() => String, { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
goal?: string | null;
|
||||||
|
@Field({ nullable: true }) @IsOptional() @Matches(DAY) startDate?: string;
|
||||||
|
@Field({ nullable: true }) @IsOptional() @Matches(DAY) endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@InputType()
|
@InputType()
|
||||||
export class SavedFilterCriteriaInput {
|
export class SavedFilterCriteriaInput {
|
||||||
@Field(() => [String], { nullable: true })
|
@Field(() => [String], { nullable: true })
|
||||||
@@ -320,6 +355,14 @@ export class IssueFilterInput {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsUUID()
|
@IsUUID()
|
||||||
parentId?: string;
|
parentId?: string;
|
||||||
|
@Field(() => ID, { nullable: true })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
sprintId?: string;
|
||||||
|
@Field({ nullable: true, description: 'Only tickets in no sprint' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
noSprint?: boolean;
|
||||||
@Field(() => IssueSort, { nullable: true, defaultValue: IssueSort.RECENT })
|
@Field(() => IssueSort, { nullable: true, defaultValue: IssueSort.RECENT })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsEnum(IssueSort)
|
@IsEnum(IssueSort)
|
||||||
|
|||||||
@@ -125,6 +125,8 @@ export class ProjectResolver {
|
|||||||
search?: string | null,
|
search?: string | null,
|
||||||
@Args('unassigned', { type: () => Boolean, nullable: true })
|
@Args('unassigned', { type: () => Boolean, nullable: true })
|
||||||
unassigned?: boolean | null,
|
unassigned?: boolean | null,
|
||||||
|
@Args('sprintId', { type: () => ID, nullable: true })
|
||||||
|
sprintId?: string | null,
|
||||||
) {
|
) {
|
||||||
return this.projects.board(
|
return this.projects.board(
|
||||||
viewerOf(me),
|
viewerOf(me),
|
||||||
@@ -138,6 +140,7 @@ export class ProjectResolver {
|
|||||||
labels: labels ?? undefined,
|
labels: labels ?? undefined,
|
||||||
search: search?.slice(0, 200) ?? undefined,
|
search: search?.slice(0, 200) ?? undefined,
|
||||||
unassigned: unassigned ?? undefined,
|
unassigned: unassigned ?? undefined,
|
||||||
|
sprintId: sprintId ?? undefined,
|
||||||
},
|
},
|
||||||
perColumn,
|
perColumn,
|
||||||
);
|
);
|
||||||
|
|||||||
170
libs/api/tracker/presenters/src/lib/graphql/sprint.resolver.ts
Normal file
170
libs/api/tracker/presenters/src/lib/graphql/sprint.resolver.ts
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
import {
|
||||||
|
Args,
|
||||||
|
ID,
|
||||||
|
Int,
|
||||||
|
Mutation,
|
||||||
|
Parent,
|
||||||
|
Query,
|
||||||
|
ResolveField,
|
||||||
|
Resolver,
|
||||||
|
} from '@nestjs/graphql';
|
||||||
|
import { User } from '@task-stream/api/identity/domain';
|
||||||
|
import {
|
||||||
|
CurrentUser,
|
||||||
|
OptionalAuth,
|
||||||
|
} from '@task-stream/api/identity/presenters';
|
||||||
|
import {
|
||||||
|
IssuesService,
|
||||||
|
IssueSort,
|
||||||
|
SprintsService,
|
||||||
|
} from '@task-stream/api/tracker/application';
|
||||||
|
import { SprintReadModel } from '@task-stream/api/tracker/domain';
|
||||||
|
import { viewerOf } from '../common/viewer';
|
||||||
|
import { CreateSprintInput, UpdateSprintInput } from './inputs';
|
||||||
|
import {
|
||||||
|
CommandResultGql,
|
||||||
|
IssuePageGql,
|
||||||
|
SprintBurndownGql,
|
||||||
|
SprintGql,
|
||||||
|
SprintProgressGql,
|
||||||
|
} from './types';
|
||||||
|
|
||||||
|
@Resolver(() => SprintGql)
|
||||||
|
export class SprintResolver {
|
||||||
|
constructor(
|
||||||
|
private readonly sprintsService: SprintsService,
|
||||||
|
private readonly issuesService: IssuesService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@OptionalAuth()
|
||||||
|
@Query(() => [SprintGql], {
|
||||||
|
description:
|
||||||
|
"A project's sprints: the one under way, the planned ones, then the closed ones",
|
||||||
|
})
|
||||||
|
sprints(
|
||||||
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Args('projectId', { type: () => ID }) projectId: string,
|
||||||
|
) {
|
||||||
|
return this.sprintsService.list(viewerOf(me), projectId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptionalAuth()
|
||||||
|
@Query(() => SprintGql)
|
||||||
|
sprint(
|
||||||
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Args('id', { type: () => ID }) id: string,
|
||||||
|
) {
|
||||||
|
return this.sprintsService.findOne(viewerOf(me), id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => SprintProgressGql)
|
||||||
|
progress(
|
||||||
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Parent() sprint: SprintReadModel,
|
||||||
|
) {
|
||||||
|
return this.sprintsService.progress(viewerOf(me), sprint.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => SprintBurndownGql)
|
||||||
|
burndown(
|
||||||
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Parent() sprint: SprintReadModel,
|
||||||
|
) {
|
||||||
|
return this.sprintsService.burndown(viewerOf(me), sprint.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@ResolveField(() => IssuePageGql, {
|
||||||
|
description: 'Tickets in this sprint, in backlog order',
|
||||||
|
})
|
||||||
|
issues(
|
||||||
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Parent() sprint: SprintReadModel,
|
||||||
|
@Args('limit', { type: () => Int, defaultValue: 100 }) limit: number,
|
||||||
|
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
|
||||||
|
) {
|
||||||
|
return this.issuesService.search(viewerOf(me), {
|
||||||
|
projectId: sprint.projectId,
|
||||||
|
sprintId: sprint.id,
|
||||||
|
sort: IssueSort.RANK,
|
||||||
|
limit,
|
||||||
|
offset,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => CommandResultGql, {
|
||||||
|
description: 'Plan a sprint (owners and admins)',
|
||||||
|
})
|
||||||
|
createSprint(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('input') input: CreateSprintInput,
|
||||||
|
) {
|
||||||
|
return this.sprintsService.create(me.id, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => CommandResultGql)
|
||||||
|
updateSprint(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('input') input: UpdateSprintInput,
|
||||||
|
) {
|
||||||
|
const { sprintId, ...changes } = input;
|
||||||
|
return this.sprintsService.update(
|
||||||
|
me.id,
|
||||||
|
sprintId,
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(changes).filter(
|
||||||
|
([field, value]) => value !== null || field === 'goal',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => CommandResultGql, {
|
||||||
|
description: 'Start a planned sprint (only one under way per project)',
|
||||||
|
})
|
||||||
|
startSprint(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('sprintId', { type: () => ID }) sprintId: string,
|
||||||
|
) {
|
||||||
|
return this.sprintsService.start(me.id, sprintId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => CommandResultGql, {
|
||||||
|
description:
|
||||||
|
'Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null.',
|
||||||
|
})
|
||||||
|
closeSprint(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('sprintId', { type: () => ID }) sprintId: string,
|
||||||
|
@Args('carryOverToSprintId', { type: () => ID, nullable: true })
|
||||||
|
carryOverToSprintId: string | null,
|
||||||
|
) {
|
||||||
|
return this.sprintsService.close(
|
||||||
|
me.id,
|
||||||
|
sprintId,
|
||||||
|
carryOverToSprintId ?? null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => CommandResultGql, {
|
||||||
|
description:
|
||||||
|
'Delete a sprint that never started; its tickets go back to the backlog',
|
||||||
|
})
|
||||||
|
deleteSprint(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('sprintId', { type: () => ID }) sprintId: string,
|
||||||
|
) {
|
||||||
|
return this.sprintsService.delete(me.id, sprintId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Mutation(() => CommandResultGql, {
|
||||||
|
description: 'Put a ticket in a sprint (null: back to the backlog)',
|
||||||
|
})
|
||||||
|
setIssueSprint(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('issueId', { type: () => ID }) issueId: string,
|
||||||
|
@Args('sprintId', { type: () => ID, nullable: true })
|
||||||
|
sprintId: string | null,
|
||||||
|
) {
|
||||||
|
return this.sprintsService.setIssueSprint(me.id, issueId, sprintId ?? null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
NotificationGql,
|
NotificationGql,
|
||||||
ProjectChangeGql,
|
ProjectChangeGql,
|
||||||
ReleaseChangeGql,
|
ReleaseChangeGql,
|
||||||
|
SprintChangeGql,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
const asIs = { resolve: <T>(payload: T) => payload };
|
const asIs = { resolve: <T>(payload: T) => payload };
|
||||||
@@ -66,6 +67,14 @@ export class SubscriptionResolver {
|
|||||||
return this.changes.watchReleases(me.id, projectId);
|
return this.changes.watchReleases(me.id, projectId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Subscription(() => SprintChangeGql, asIs)
|
||||||
|
sprintChanged(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Args('projectId', { type: () => ID }) projectId: string,
|
||||||
|
) {
|
||||||
|
return this.changes.watchSprints(me.id, projectId);
|
||||||
|
}
|
||||||
|
|
||||||
@Subscription(() => ProjectChangeGql, asIs)
|
@Subscription(() => ProjectChangeGql, asIs)
|
||||||
projectChanged(
|
projectChanged(
|
||||||
@CurrentUser() me: User,
|
@CurrentUser() me: User,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
NotificationKind,
|
NotificationKind,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
|
SprintStatus,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
} from '@task-stream/api/tracker/domain';
|
} from '@task-stream/api/tracker/domain';
|
||||||
import { LinkDirection } from './enums';
|
import { LinkDirection } from './enums';
|
||||||
@@ -110,6 +111,12 @@ export class AttachmentGql {
|
|||||||
commentId!: string | null;
|
commentId!: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType('SprintRef')
|
||||||
|
export class SprintRefGql {
|
||||||
|
@Field(() => ID) id!: string;
|
||||||
|
@Field() name!: string;
|
||||||
|
}
|
||||||
|
|
||||||
@ObjectType('ReleaseRef')
|
@ObjectType('ReleaseRef')
|
||||||
export class ReleaseRefGql {
|
export class ReleaseRefGql {
|
||||||
@Field(() => ID) id!: string;
|
@Field(() => ID) id!: string;
|
||||||
@@ -154,6 +161,7 @@ export class IssueGql {
|
|||||||
@Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null;
|
@Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null;
|
||||||
@Field(() => ReleaseRefGql, { nullable: true })
|
@Field(() => ReleaseRefGql, { nullable: true })
|
||||||
release!: ReleaseRefGql | null;
|
release!: ReleaseRefGql | null;
|
||||||
|
@Field(() => SprintRefGql, { nullable: true }) sprint!: SprintRefGql | null;
|
||||||
@Field(() => [String]) labels!: string[];
|
@Field(() => [String]) labels!: string[];
|
||||||
@Field(() => String, { nullable: true }) figmaUrl!: string | null;
|
@Field(() => String, { nullable: true }) figmaUrl!: string | null;
|
||||||
@Field(() => Int, { nullable: true }) storyPoints!: number | null;
|
@Field(() => Int, { nullable: true }) storyPoints!: number | null;
|
||||||
@@ -270,6 +278,56 @@ export class ReleaseGql {
|
|||||||
@Field(() => Int) version!: number;
|
@Field(() => Int) version!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ObjectType('Sprint', {
|
||||||
|
description: 'A time-boxed iteration: the tickets the team commits to',
|
||||||
|
})
|
||||||
|
export class SprintGql {
|
||||||
|
@Field(() => ID) id!: string;
|
||||||
|
@Field(() => ID) projectId!: string;
|
||||||
|
@Field() name!: string;
|
||||||
|
@Field(() => String, { nullable: true }) goal!: string | null;
|
||||||
|
@Field({ description: 'First day, YYYY-MM-DD' }) startDate!: string;
|
||||||
|
@Field({ description: 'Last day (included), YYYY-MM-DD' }) endDate!: string;
|
||||||
|
@Field(() => SprintStatus) status!: SprintStatus;
|
||||||
|
@Field(() => String, { nullable: true }) startedAt!: string | null;
|
||||||
|
@Field(() => String, { nullable: true }) closedAt!: string | null;
|
||||||
|
@Field() createdAt!: string;
|
||||||
|
@Field(() => Int) version!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('SprintProgress')
|
||||||
|
export class SprintProgressGql {
|
||||||
|
@Field(() => Int) total!: number;
|
||||||
|
@Field(() => Int, { description: 'Done or cancelled' }) finished!: number;
|
||||||
|
@Field(() => Int) percent!: number;
|
||||||
|
@Field(() => Int) storyPoints!: number;
|
||||||
|
@Field(() => Int) finishedStoryPoints!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('SprintBurndown', {
|
||||||
|
description:
|
||||||
|
'Per day of the sprint: tickets in it, still open, and their points (null: days to come). `ideal` falls from the first day to zero.',
|
||||||
|
})
|
||||||
|
export class SprintBurndownGql {
|
||||||
|
@Field(() => ID) sprintId!: string;
|
||||||
|
@Field(() => [String]) dates!: string[];
|
||||||
|
@Field(() => [Int], { nullable: 'items' }) scope!: (number | null)[];
|
||||||
|
@Field(() => [Int], { nullable: 'items' }) remaining!: (number | null)[];
|
||||||
|
@Field(() => [Int], { nullable: 'items' })
|
||||||
|
remainingPoints!: (number | null)[];
|
||||||
|
@Field(() => [Float]) ideal!: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
@ObjectType('SprintChange')
|
||||||
|
export class SprintChangeGql {
|
||||||
|
@Field() action!: string;
|
||||||
|
@Field(() => Int) version!: number;
|
||||||
|
@Field(() => ID) actorId!: string;
|
||||||
|
@Field(() => ID) sprintId!: string;
|
||||||
|
@Field(() => SprintGql, { nullable: true, description: 'Null: deleted' })
|
||||||
|
sprint!: SprintGql | null;
|
||||||
|
}
|
||||||
|
|
||||||
@ObjectType('CountByStatus')
|
@ObjectType('CountByStatus')
|
||||||
export class CountByStatusGql {
|
export class CountByStatusGql {
|
||||||
@Field({ description: 'Status id' }) key!: string;
|
@Field({ description: 'Status id' }) key!: string;
|
||||||
|
|||||||
@@ -285,6 +285,11 @@ export class BoardQueryDto {
|
|||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
unassigned?: boolean;
|
unassigned?: boolean;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', description: 'Only this sprint' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
sprintId?: string;
|
||||||
|
|
||||||
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
|
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@Type(() => Number)
|
@Type(() => Number)
|
||||||
@@ -451,6 +456,17 @@ export class IssueSearchQueryDto extends PageQueryDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
parent?: string;
|
parent?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ format: 'uuid', description: 'Only this sprint' })
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
sprintId?: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({ description: 'Only tickets in no sprint' })
|
||||||
|
@IsOptional()
|
||||||
|
@Transform(toBoolean)
|
||||||
|
@IsBoolean()
|
||||||
|
noSprint?: boolean;
|
||||||
|
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
enum: IssueSort,
|
enum: IssueSort,
|
||||||
enumName: 'IssueSort',
|
enumName: 'IssueSort',
|
||||||
@@ -819,6 +835,66 @@ export class CreateReleaseDto {
|
|||||||
|
|
||||||
export class UpdateReleaseDto extends PartialType(CreateReleaseDto) {}
|
export class UpdateReleaseDto extends PartialType(CreateReleaseDto) {}
|
||||||
|
|
||||||
|
const DAY = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
export class CreateSprintDto {
|
||||||
|
@ApiProperty({ example: 'Sprint 14', maxLength: 80 })
|
||||||
|
@IsString()
|
||||||
|
@MinLength(1)
|
||||||
|
@MaxLength(80)
|
||||||
|
name!: string;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
maxLength: 500,
|
||||||
|
example: 'Pagos con tarjeta en producción',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(500)
|
||||||
|
goal?: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ format: 'date', example: '2026-10-12' })
|
||||||
|
@Matches(DAY)
|
||||||
|
startDate!: string;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
format: 'date',
|
||||||
|
example: '2026-10-23',
|
||||||
|
description: 'Last day, included',
|
||||||
|
})
|
||||||
|
@Matches(DAY)
|
||||||
|
endDate!: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateSprintDto extends PartialType(CreateSprintDto) {}
|
||||||
|
|
||||||
|
export class CloseSprintDto {
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
format: 'uuid',
|
||||||
|
description:
|
||||||
|
'Open sprint of the same project that takes the unfinished tickets; null or absent: back to the backlog',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsUUID()
|
||||||
|
carryOverTo?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SetIssueSprintDto {
|
||||||
|
@ApiProperty({
|
||||||
|
type: String,
|
||||||
|
nullable: true,
|
||||||
|
format: 'uuid',
|
||||||
|
description: 'Open sprint of the project, or null for the backlog',
|
||||||
|
})
|
||||||
|
@ValidateIf((dto: SetIssueSprintDto) => dto.sprintId !== null)
|
||||||
|
@IsUUID()
|
||||||
|
sprintId!: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
export class PublishReleaseDto {
|
export class PublishReleaseDto {
|
||||||
@ApiPropertyOptional({
|
@ApiPropertyOptional({
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
NotificationKind,
|
NotificationKind,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
|
SprintStatus,
|
||||||
SETTING_COLORS,
|
SETTING_COLORS,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
TYPE_ICONS,
|
TYPE_ICONS,
|
||||||
@@ -135,6 +136,11 @@ export class IssueParentDto {
|
|||||||
@ApiProperty({ example: 'Pago con tarjeta' }) title!: string;
|
@ApiProperty({ example: 'Pago con tarjeta' }) title!: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SprintRefDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||||
|
@ApiProperty({ example: 'Sprint 14' }) name!: string;
|
||||||
|
}
|
||||||
|
|
||||||
export class IssueDto {
|
export class IssueDto {
|
||||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||||
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||||
@@ -163,6 +169,8 @@ export class IssueDto {
|
|||||||
assignee!: UserRefDto | null;
|
assignee!: UserRefDto | null;
|
||||||
@ApiProperty({ type: ReleaseRefDto, nullable: true })
|
@ApiProperty({ type: ReleaseRefDto, nullable: true })
|
||||||
release!: ReleaseRefDto | null;
|
release!: ReleaseRefDto | null;
|
||||||
|
@ApiProperty({ type: SprintRefDto, nullable: true })
|
||||||
|
sprint!: SprintRefDto | null;
|
||||||
@ApiProperty({ type: [String] }) labels!: string[];
|
@ApiProperty({ type: [String] }) labels!: string[];
|
||||||
@ApiProperty({ type: String, nullable: true, description: 'Figma frame' })
|
@ApiProperty({ type: String, nullable: true, description: 'Figma frame' })
|
||||||
figmaUrl!: string | null;
|
figmaUrl!: string | null;
|
||||||
@@ -255,6 +263,50 @@ export class CommentPageDto {
|
|||||||
@ApiProperty() total!: number;
|
@ApiProperty() total!: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export class SprintDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||||
|
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||||
|
@ApiProperty({ example: 'Sprint 14' }) name!: string;
|
||||||
|
@ApiProperty({ type: String, nullable: true }) goal!: string | null;
|
||||||
|
@ApiProperty({ format: 'date' }) startDate!: string;
|
||||||
|
@ApiProperty({ format: 'date', description: 'Last day, included' })
|
||||||
|
endDate!: string;
|
||||||
|
@ApiProperty({ enum: SprintStatus, enumName: 'SprintStatus' })
|
||||||
|
status!: SprintStatus;
|
||||||
|
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||||
|
startedAt!: string | null;
|
||||||
|
@ApiProperty({ type: String, nullable: true, format: 'date-time' })
|
||||||
|
closedAt!: string | null;
|
||||||
|
@ApiProperty({ format: 'date-time' }) createdAt!: string;
|
||||||
|
@ApiProperty() version!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SprintProgressDto {
|
||||||
|
@ApiProperty() total!: number;
|
||||||
|
@ApiProperty({ description: 'Done or cancelled' }) finished!: number;
|
||||||
|
@ApiProperty({ minimum: 0, maximum: 100 }) percent!: number;
|
||||||
|
@ApiProperty() storyPoints!: number;
|
||||||
|
@ApiProperty() finishedStoryPoints!: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SprintBurndownDto {
|
||||||
|
@ApiProperty({ format: 'uuid' }) sprintId!: string;
|
||||||
|
@ApiProperty({ type: [String], format: 'date' }) dates!: string[];
|
||||||
|
@ApiProperty({
|
||||||
|
type: [Number],
|
||||||
|
nullable: true,
|
||||||
|
description: 'Tickets in the sprint (null: days to come)',
|
||||||
|
})
|
||||||
|
scope!: (number | null)[];
|
||||||
|
@ApiProperty({ type: [Number], nullable: true }) remaining!: (
|
||||||
|
| number
|
||||||
|
| null
|
||||||
|
)[];
|
||||||
|
@ApiProperty({ type: [Number], nullable: true })
|
||||||
|
remainingPoints!: (number | null)[];
|
||||||
|
@ApiProperty({ type: [Number] }) ideal!: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export class ReleaseDto {
|
export class ReleaseDto {
|
||||||
@ApiProperty({ format: 'uuid' }) id!: string;
|
@ApiProperty({ format: 'uuid' }) id!: string;
|
||||||
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
@ApiProperty({ format: 'uuid' }) projectId!: string;
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ export class IssuesController {
|
|||||||
unassigned: query.unassigned,
|
unassigned: query.unassigned,
|
||||||
releaseId: query.releaseId,
|
releaseId: query.releaseId,
|
||||||
labels: query.label,
|
labels: query.label,
|
||||||
|
sprintId: query.sprintId,
|
||||||
|
noSprint: query.noSprint,
|
||||||
sort: query.sort,
|
sort: query.sort,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
limit: query.limit,
|
limit: query.limit,
|
||||||
|
|||||||
@@ -187,6 +187,7 @@ export class ProjectsController {
|
|||||||
labels: query.label,
|
labels: query.label,
|
||||||
search: query.search,
|
search: query.search,
|
||||||
unassigned: query.unassigned,
|
unassigned: query.unassigned,
|
||||||
|
sprintId: query.sprintId,
|
||||||
},
|
},
|
||||||
query.perColumn,
|
query.perColumn,
|
||||||
);
|
);
|
||||||
|
|||||||
249
libs/api/tracker/presenters/src/lib/http/sprints.controller.ts
Normal file
249
libs/api/tracker/presenters/src/lib/http/sprints.controller.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
import {
|
||||||
|
Body,
|
||||||
|
Controller,
|
||||||
|
Delete,
|
||||||
|
Get,
|
||||||
|
Param,
|
||||||
|
ParseUUIDPipe,
|
||||||
|
Patch,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Query,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import {
|
||||||
|
ApiCreatedResponse,
|
||||||
|
ApiOkResponse,
|
||||||
|
ApiOperation,
|
||||||
|
ApiParam,
|
||||||
|
ApiTags,
|
||||||
|
} from '@nestjs/swagger';
|
||||||
|
import { User } from '@task-stream/api/identity/domain';
|
||||||
|
import {
|
||||||
|
ApiOptionalPireonAuth,
|
||||||
|
ApiPireonAuth,
|
||||||
|
CurrentUser,
|
||||||
|
} from '@task-stream/api/identity/presenters';
|
||||||
|
import {
|
||||||
|
ApiCommandErrors,
|
||||||
|
ApiReadErrors,
|
||||||
|
} from '@task-stream/api/shared/presenters';
|
||||||
|
import {
|
||||||
|
IssuesService,
|
||||||
|
IssueSort,
|
||||||
|
SprintsService,
|
||||||
|
} from '@task-stream/api/tracker/application';
|
||||||
|
import { viewerOf } from '../common/viewer';
|
||||||
|
import {
|
||||||
|
CloseSprintDto,
|
||||||
|
CreateSprintDto,
|
||||||
|
PageQueryDto,
|
||||||
|
SetIssueSprintDto,
|
||||||
|
UpdateSprintDto,
|
||||||
|
} from './dto/requests.dto';
|
||||||
|
import {
|
||||||
|
CommandResultDto,
|
||||||
|
IssuePageDto,
|
||||||
|
SprintBurndownDto,
|
||||||
|
SprintDto,
|
||||||
|
SprintProgressDto,
|
||||||
|
} from './dto/responses.dto';
|
||||||
|
import { Refs } from './refs';
|
||||||
|
|
||||||
|
const PROJECT = ApiParam({
|
||||||
|
name: 'project',
|
||||||
|
description: 'Project id or key',
|
||||||
|
example: 'WEB',
|
||||||
|
});
|
||||||
|
const SPRINT = ApiParam({ name: 'sprint', format: 'uuid' });
|
||||||
|
|
||||||
|
/** REST adapter of the sprint use cases: plan, run and close iterations. */
|
||||||
|
@ApiTags('Sprints')
|
||||||
|
@ApiPireonAuth()
|
||||||
|
@Controller()
|
||||||
|
export class SprintsController {
|
||||||
|
constructor(
|
||||||
|
private readonly sprints: SprintsService,
|
||||||
|
private readonly issues: IssuesService,
|
||||||
|
private readonly refs: Refs,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get('projects/:project/sprints')
|
||||||
|
@ApiOptionalPireonAuth()
|
||||||
|
@PROJECT
|
||||||
|
@ApiOperation({
|
||||||
|
summary: "A project's sprints",
|
||||||
|
description: 'The one under way, the planned ones, then the closed ones.',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: [SprintDto] })
|
||||||
|
@ApiReadErrors()
|
||||||
|
async list(
|
||||||
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Param('project') project: string,
|
||||||
|
) {
|
||||||
|
return this.sprints.list(
|
||||||
|
viewerOf(me),
|
||||||
|
await this.refs.projectId(viewerOf(me), project),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('projects/:project/sprints')
|
||||||
|
@PROJECT
|
||||||
|
@ApiOperation({ summary: 'Plan a sprint (owners and admins)' })
|
||||||
|
@ApiCreatedResponse({ type: CommandResultDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async create(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('project') project: string,
|
||||||
|
@Body() body: CreateSprintDto,
|
||||||
|
) {
|
||||||
|
return this.sprints.create(me.id, {
|
||||||
|
...body,
|
||||||
|
projectId: await this.refs.projectId(me.id, project),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('sprints/:sprint')
|
||||||
|
@ApiOptionalPireonAuth()
|
||||||
|
@SPRINT
|
||||||
|
@ApiOperation({ summary: 'A sprint' })
|
||||||
|
@ApiOkResponse({ type: SprintDto })
|
||||||
|
@ApiReadErrors()
|
||||||
|
findOne(
|
||||||
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||||
|
) {
|
||||||
|
return this.sprints.findOne(viewerOf(me), sprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Patch('sprints/:sprint')
|
||||||
|
@SPRINT
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Rename, re-date or change the goal (not once closed)',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: CommandResultDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
update(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||||
|
@Body() body: UpdateSprintDto,
|
||||||
|
) {
|
||||||
|
return this.sprints.update(me.id, sprint, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete('sprints/:sprint')
|
||||||
|
@SPRINT
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Delete a sprint that never started',
|
||||||
|
description: 'Its tickets go back to the backlog.',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: CommandResultDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
delete(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||||
|
) {
|
||||||
|
return this.sprints.delete(me.id, sprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sprints/:sprint/start')
|
||||||
|
@SPRINT
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Start a planned sprint (only one under way per project)',
|
||||||
|
})
|
||||||
|
@ApiCreatedResponse({ type: CommandResultDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
start(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||||
|
) {
|
||||||
|
return this.sprints.start(me.id, sprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post('sprints/:sprint/close')
|
||||||
|
@SPRINT
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Close the sprint under way',
|
||||||
|
description:
|
||||||
|
'Unfinished tickets move to `carryOverTo` (another open sprint) or back to the backlog; finished ones stay as its record.',
|
||||||
|
})
|
||||||
|
@ApiCreatedResponse({ type: CommandResultDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
close(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||||
|
@Body() body: CloseSprintDto,
|
||||||
|
) {
|
||||||
|
return this.sprints.close(me.id, sprint, body.carryOverTo ?? null);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('sprints/:sprint/progress')
|
||||||
|
@ApiOptionalPireonAuth()
|
||||||
|
@SPRINT
|
||||||
|
@ApiOperation({ summary: 'Finished tickets and points' })
|
||||||
|
@ApiOkResponse({ type: SprintProgressDto })
|
||||||
|
@ApiReadErrors()
|
||||||
|
progress(
|
||||||
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||||
|
) {
|
||||||
|
return this.sprints.progress(viewerOf(me), sprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('sprints/:sprint/burndown')
|
||||||
|
@ApiOptionalPireonAuth()
|
||||||
|
@SPRINT
|
||||||
|
@ApiOperation({
|
||||||
|
summary: 'Open tickets and points, day by day, against the ideal line',
|
||||||
|
})
|
||||||
|
@ApiOkResponse({ type: SprintBurndownDto })
|
||||||
|
@ApiReadErrors()
|
||||||
|
burndown(
|
||||||
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||||
|
) {
|
||||||
|
return this.sprints.burndown(viewerOf(me), sprint);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('sprints/:sprint/issues')
|
||||||
|
@ApiOptionalPireonAuth()
|
||||||
|
@SPRINT
|
||||||
|
@ApiOperation({ summary: 'Tickets in the sprint, in backlog order' })
|
||||||
|
@ApiOkResponse({ type: IssuePageDto })
|
||||||
|
@ApiReadErrors()
|
||||||
|
async issuesOf(
|
||||||
|
@CurrentUser() me: User | undefined,
|
||||||
|
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||||
|
@Query() query: PageQueryDto,
|
||||||
|
) {
|
||||||
|
const found = await this.sprints.findOne(viewerOf(me), sprint);
|
||||||
|
const page = await this.issues.search(viewerOf(me), {
|
||||||
|
projectId: found.projectId,
|
||||||
|
sprintId: found.id,
|
||||||
|
sort: IssueSort.RANK,
|
||||||
|
limit: query.limit,
|
||||||
|
offset: query.offset,
|
||||||
|
});
|
||||||
|
return { ...page, items: await this.refs.issueDtos(page.items) };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('issues/:issue/sprint')
|
||||||
|
@ApiParam({
|
||||||
|
name: 'issue',
|
||||||
|
description: 'Ticket id or key',
|
||||||
|
example: 'WEB-12',
|
||||||
|
})
|
||||||
|
@ApiOperation({ summary: 'Put the ticket in a sprint (null: the backlog)' })
|
||||||
|
@ApiOkResponse({ type: CommandResultDto })
|
||||||
|
@ApiCommandErrors()
|
||||||
|
async setIssueSprint(
|
||||||
|
@CurrentUser() me: User,
|
||||||
|
@Param('issue') issue: string,
|
||||||
|
@Body() body: SetIssueSprintDto,
|
||||||
|
) {
|
||||||
|
return this.sprints.setIssueSprint(
|
||||||
|
me.id,
|
||||||
|
await this.refs.issueId(me.id, issue),
|
||||||
|
body.sprintId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,8 @@ import { ReleasesController } from './http/releases.controller';
|
|||||||
import { NotificationsController } from './http/notifications.controller';
|
import { NotificationsController } from './http/notifications.controller';
|
||||||
import { SavedFiltersController } from './http/saved-filters.controller';
|
import { SavedFiltersController } from './http/saved-filters.controller';
|
||||||
import { SavedFilterResolver } from './graphql/saved-filter.resolver';
|
import { SavedFilterResolver } from './graphql/saved-filter.resolver';
|
||||||
|
import { SprintResolver } from './graphql/sprint.resolver';
|
||||||
|
import { SprintsController } from './http/sprints.controller';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
* Driving adapters of the tracker context: GraphQL (queries, mutations,
|
||||||
@@ -48,6 +50,7 @@ import { SavedFilterResolver } from './graphql/saved-filter.resolver';
|
|||||||
ExploreController,
|
ExploreController,
|
||||||
NotificationsController,
|
NotificationsController,
|
||||||
SavedFiltersController,
|
SavedFiltersController,
|
||||||
|
SprintsController,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
Refs,
|
Refs,
|
||||||
@@ -58,6 +61,7 @@ import { SavedFilterResolver } from './graphql/saved-filter.resolver';
|
|||||||
SubscriptionResolver,
|
SubscriptionResolver,
|
||||||
NotificationResolver,
|
NotificationResolver,
|
||||||
SavedFilterResolver,
|
SavedFilterResolver,
|
||||||
|
SprintResolver,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TrackerPresentersModule {
|
export class TrackerPresentersModule {
|
||||||
|
|||||||
@@ -26,6 +26,10 @@ export const issuePageEvents = eventGroup({
|
|||||||
targetKey: string;
|
targetKey: string;
|
||||||
}>(),
|
}>(),
|
||||||
unlinkRequested: type<LinkIssuesInput>(),
|
unlinkRequested: type<LinkIssuesInput>(),
|
||||||
|
sprintChangeRequested: type<{
|
||||||
|
issueId: string;
|
||||||
|
sprintId: string | null;
|
||||||
|
}>(),
|
||||||
releaseChangeRequested: type<{
|
releaseChangeRequested: type<{
|
||||||
issueId: string;
|
issueId: string;
|
||||||
releaseId: string | null;
|
releaseId: string | null;
|
||||||
|
|||||||
@@ -10,6 +10,10 @@ import {
|
|||||||
ProjectStats,
|
ProjectStats,
|
||||||
Release,
|
Release,
|
||||||
ReleaseBurnup,
|
ReleaseBurnup,
|
||||||
|
CreateSprintInput,
|
||||||
|
Sprint,
|
||||||
|
SprintBurndown,
|
||||||
|
UpdateSprintInput,
|
||||||
UserDirectoryEntry,
|
UserDirectoryEntry,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
|
|
||||||
@@ -23,6 +27,15 @@ export const projectPageEvents = eventGroup({
|
|||||||
releaseCreateRequested: type<CreateReleaseInput>(),
|
releaseCreateRequested: type<CreateReleaseInput>(),
|
||||||
releasePublishRequested: type<{ releaseId: string; force: boolean }>(),
|
releasePublishRequested: type<{ releaseId: string; force: boolean }>(),
|
||||||
releaseArchiveRequested: type<{ releaseId: string }>(),
|
releaseArchiveRequested: type<{ releaseId: string }>(),
|
||||||
|
sprintCreateRequested: type<CreateSprintInput>(),
|
||||||
|
sprintUpdateRequested: type<UpdateSprintInput>(),
|
||||||
|
sprintStartRequested: type<{ sprintId: string }>(),
|
||||||
|
sprintCloseRequested: type<{
|
||||||
|
sprintId: string;
|
||||||
|
carryOverTo: string | null;
|
||||||
|
}>(),
|
||||||
|
sprintDeleteRequested: type<{ sprintId: string }>(),
|
||||||
|
burndownRequested: type<{ sprintId: string }>(),
|
||||||
userSearchRequested: type<string>(),
|
userSearchRequested: type<string>(),
|
||||||
settingsSaveRequested: type<{
|
settingsSaveRequested: type<{
|
||||||
projectId: string;
|
projectId: string;
|
||||||
@@ -42,6 +55,8 @@ export const projectApiEvents = eventGroup({
|
|||||||
loadFailed: type<string>(),
|
loadFailed: type<string>(),
|
||||||
statsLoaded: type<ProjectStats>(),
|
statsLoaded: type<ProjectStats>(),
|
||||||
releasesLoaded: type<Release[]>(),
|
releasesLoaded: type<Release[]>(),
|
||||||
|
sprintsLoaded: type<Sprint[]>(),
|
||||||
|
burndownLoaded: type<SprintBurndown>(),
|
||||||
activityLoaded: type<Activity[]>(),
|
activityLoaded: type<Activity[]>(),
|
||||||
commandSucceeded: type<string>(),
|
commandSucceeded: type<string>(),
|
||||||
commandFailed: type<string>(),
|
commandFailed: type<string>(),
|
||||||
@@ -63,5 +78,6 @@ export const projectRealtimeEvents = eventGroup({
|
|||||||
projectDeleted: type<Project>(),
|
projectDeleted: type<Project>(),
|
||||||
activityAdded: type<Activity>(),
|
activityAdded: type<Activity>(),
|
||||||
releaseChanged: type<Release>(),
|
releaseChanged: type<Release>(),
|
||||||
|
sprintChanged: type<{ sprintId: string }>(),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ProjectsGateway,
|
ProjectsGateway,
|
||||||
ReleasesGateway,
|
ReleasesGateway,
|
||||||
SavedFiltersGateway,
|
SavedFiltersGateway,
|
||||||
|
SprintsGateway,
|
||||||
TrackerRealtimeGateway,
|
TrackerRealtimeGateway,
|
||||||
UsersGateway,
|
UsersGateway,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
@@ -70,6 +71,14 @@ function gateways() {
|
|||||||
download: vi.fn(() => of(new Blob(['x']))),
|
download: vi.fn(() => of(new Blob(['x']))),
|
||||||
remove: vi.fn(() => of(ok)),
|
remove: vi.fn(() => of(ok)),
|
||||||
},
|
},
|
||||||
|
sprints: {
|
||||||
|
list: vi.fn(() => of([])),
|
||||||
|
burndown: vi.fn(() => NEVER),
|
||||||
|
create: vi.fn(() => of(ok)),
|
||||||
|
start: vi.fn(() => of(ok)),
|
||||||
|
close: vi.fn(() => of(ok)),
|
||||||
|
setIssueSprint: vi.fn(() => of(ok)),
|
||||||
|
},
|
||||||
views: {
|
views: {
|
||||||
list: vi.fn(() => of([])),
|
list: vi.fn(() => of([])),
|
||||||
save: vi.fn(() => of('v1')),
|
save: vi.fn(() => of('v1')),
|
||||||
@@ -80,6 +89,7 @@ function gateways() {
|
|||||||
commentChanges: () => NEVER,
|
commentChanges: () => NEVER,
|
||||||
activity: () => NEVER,
|
activity: () => NEVER,
|
||||||
releaseChanges: () => NEVER,
|
releaseChanges: () => NEVER,
|
||||||
|
sprintChanges: () => NEVER,
|
||||||
projectChanges: vi.fn((): unknown => NEVER),
|
projectChanges: vi.fn((): unknown => NEVER),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -113,6 +123,7 @@ function setup() {
|
|||||||
{ provide: UsersGateway, useValue: { search: () => of([]) } },
|
{ provide: UsersGateway, useValue: { search: () => of([]) } },
|
||||||
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime },
|
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime },
|
||||||
{ provide: SavedFiltersGateway, useValue: fakes.views },
|
{ provide: SavedFiltersGateway, useValue: fakes.views },
|
||||||
|
{ provide: SprintsGateway, useValue: fakes.sprints },
|
||||||
{
|
{
|
||||||
provide: Notifications,
|
provide: Notifications,
|
||||||
useValue: { success: vi.fn(), error: vi.fn() },
|
useValue: { success: vi.fn(), error: vi.fn() },
|
||||||
@@ -219,6 +230,28 @@ describe('ProjectFacade', () => {
|
|||||||
expect(project.workflow().status('IN_REVIEW').name).toBe('In review');
|
expect(project.workflow().status('IN_REVIEW').name).toBe('In review');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('plans, starts and closes sprints of the open project', () => {
|
||||||
|
const { project, fakes } = setup();
|
||||||
|
project.open('WEB');
|
||||||
|
project.createSprint({
|
||||||
|
name: 'Sprint 1',
|
||||||
|
startDate: '2026-10-12',
|
||||||
|
endDate: '2026-10-23',
|
||||||
|
});
|
||||||
|
project.startSprint('s1');
|
||||||
|
project.closeSprint('s1', null);
|
||||||
|
|
||||||
|
expect(fakes.sprints.list).toHaveBeenCalledWith('p1');
|
||||||
|
expect(fakes.sprints.create).toHaveBeenCalledWith({
|
||||||
|
projectId: 'p1',
|
||||||
|
name: 'Sprint 1',
|
||||||
|
startDate: '2026-10-12',
|
||||||
|
endDate: '2026-10-23',
|
||||||
|
});
|
||||||
|
expect(fakes.sprints.start).toHaveBeenCalledWith('s1');
|
||||||
|
expect(fakes.sprints.close).toHaveBeenCalledWith('s1', null);
|
||||||
|
});
|
||||||
|
|
||||||
it('makes the project public and deletes it, then goes home', () => {
|
it('makes the project public and deletes it, then goes home', () => {
|
||||||
const { project, fakes, navigate, notifications } = setup();
|
const { project, fakes, navigate, notifications } = setup();
|
||||||
project.open('WEB');
|
project.open('WEB');
|
||||||
|
|||||||
@@ -79,6 +79,13 @@ export class IssueFacade {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** null: back to the backlog. */
|
||||||
|
setSprint(sprintId: string | null): void {
|
||||||
|
this.withIssue((issueId) =>
|
||||||
|
this.dispatch.sprintChangeRequested({ issueId, sprintId }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
setRelease(releaseId: string | null): void {
|
setRelease(releaseId: string | null): void {
|
||||||
this.withIssue((issueId) =>
|
this.withIssue((issueId) =>
|
||||||
this.dispatch.releaseChangeRequested({ issueId, releaseId }),
|
this.dispatch.releaseChangeRequested({ issueId, releaseId }),
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import { computed, inject, Injectable, Provider } from '@angular/core';
|
|||||||
import { injectDispatch } from '@ngrx/signals/events';
|
import { injectDispatch } from '@ngrx/signals/events';
|
||||||
import {
|
import {
|
||||||
CreateReleaseInput,
|
CreateReleaseInput,
|
||||||
|
CreateSprintInput,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
ProjectSettings,
|
ProjectSettings,
|
||||||
|
SprintStatus,
|
||||||
|
UpdateSprintInput,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import { projectPageEvents } from '../events/project.events';
|
import { projectPageEvents } from '../events/project.events';
|
||||||
import { ProjectStore } from '../stores/project.store';
|
import { ProjectStore } from '../stores/project.store';
|
||||||
@@ -21,6 +24,20 @@ export class ProjectFacade {
|
|||||||
readonly members = this.store.members;
|
readonly members = this.store.members;
|
||||||
readonly stats = this.store.stats;
|
readonly stats = this.store.stats;
|
||||||
readonly releases = this.store.releases;
|
readonly releases = this.store.releases;
|
||||||
|
/** Under way, planned, then closed; with their progress. */
|
||||||
|
readonly sprints = this.store.sprints;
|
||||||
|
/** The sprint under way, if any. */
|
||||||
|
readonly activeSprint = computed(
|
||||||
|
() =>
|
||||||
|
this.store.sprints().find((s) => s.status === SprintStatus.ACTIVE) ??
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
/** Sprints that still take tickets (under way or planned). */
|
||||||
|
readonly openSprints = computed(() =>
|
||||||
|
this.store.sprints().filter((s) => s.status !== SprintStatus.CLOSED),
|
||||||
|
);
|
||||||
|
/** Burndown of the sprint asked for with `loadBurndown`. */
|
||||||
|
readonly burndown = this.store.burndown;
|
||||||
readonly activity = this.store.activity;
|
readonly activity = this.store.activity;
|
||||||
/** Pireon directory results for `searchUsers()`. */
|
/** Pireon directory results for `searchUsers()`. */
|
||||||
readonly userResults = this.store.userResults;
|
readonly userResults = this.store.userResults;
|
||||||
@@ -80,6 +97,33 @@ export class ProjectFacade {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/** `force` publishes even with unfinished tickets. */
|
/** `force` publishes even with unfinished tickets. */
|
||||||
|
createSprint(sprint: Omit<CreateSprintInput, 'projectId'>): void {
|
||||||
|
const projectId = this.projectId();
|
||||||
|
if (projectId)
|
||||||
|
this.dispatch.sprintCreateRequested({ projectId, ...sprint });
|
||||||
|
}
|
||||||
|
|
||||||
|
updateSprint(changes: UpdateSprintInput): void {
|
||||||
|
this.dispatch.sprintUpdateRequested(changes);
|
||||||
|
}
|
||||||
|
|
||||||
|
startSprint(sprintId: string): void {
|
||||||
|
this.dispatch.sprintStartRequested({ sprintId });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unfinished tickets go to `carryOverTo` (null: back to the backlog). */
|
||||||
|
closeSprint(sprintId: string, carryOverTo: string | null): void {
|
||||||
|
this.dispatch.sprintCloseRequested({ sprintId, carryOverTo });
|
||||||
|
}
|
||||||
|
|
||||||
|
deleteSprint(sprintId: string): void {
|
||||||
|
this.dispatch.sprintDeleteRequested({ sprintId });
|
||||||
|
}
|
||||||
|
|
||||||
|
loadBurndown(sprintId: string): void {
|
||||||
|
this.dispatch.burndownRequested({ sprintId });
|
||||||
|
}
|
||||||
|
|
||||||
publishRelease(releaseId: string, force = false): void {
|
publishRelease(releaseId: string, force = false): void {
|
||||||
this.dispatch.releasePublishRequested({ releaseId, force });
|
this.dispatch.releasePublishRequested({ releaseId, force });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Issue,
|
Issue,
|
||||||
IssuesGateway,
|
IssuesGateway,
|
||||||
IssueSummary,
|
IssueSummary,
|
||||||
|
SprintsGateway,
|
||||||
TrackerRealtimeGateway,
|
TrackerRealtimeGateway,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
import {
|
import {
|
||||||
@@ -122,6 +123,7 @@ export const IssueStore = signalStore(
|
|||||||
realtime = inject(TrackerRealtimeGateway),
|
realtime = inject(TrackerRealtimeGateway),
|
||||||
notifications = inject(Notifications),
|
notifications = inject(Notifications),
|
||||||
attachments = inject(AttachmentsGateway),
|
attachments = inject(AttachmentsGateway),
|
||||||
|
sprints = inject(SprintsGateway),
|
||||||
) => {
|
) => {
|
||||||
/** One file after another: the ticket's version moves with each. */
|
/** One file after another: the ticket's version moves with each. */
|
||||||
const uploadAll = (
|
const uploadAll = (
|
||||||
@@ -410,6 +412,20 @@ export const IssueStore = signalStore(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
events
|
||||||
|
.on(issuePageEvents.sprintChangeRequested)
|
||||||
|
.pipe(
|
||||||
|
mergeMap(({ payload }) =>
|
||||||
|
runCommand(
|
||||||
|
sprints.setIssueSprint(payload.issueId, payload.sprintId),
|
||||||
|
payload.sprintId
|
||||||
|
? 'Metido en el sprint'
|
||||||
|
: 'De vuelta al backlog',
|
||||||
|
done,
|
||||||
|
failed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
events
|
events
|
||||||
.on(issuePageEvents.releaseChangeRequested)
|
.on(issuePageEvents.releaseChangeRequested)
|
||||||
.pipe(
|
.pipe(
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ import {
|
|||||||
Release,
|
Release,
|
||||||
ReleaseBurnup,
|
ReleaseBurnup,
|
||||||
ReleasesGateway,
|
ReleasesGateway,
|
||||||
|
Sprint,
|
||||||
|
SprintBurndown,
|
||||||
|
SprintsGateway,
|
||||||
TrackerRealtimeGateway,
|
TrackerRealtimeGateway,
|
||||||
UserDirectoryEntry,
|
UserDirectoryEntry,
|
||||||
UsersGateway,
|
UsersGateway,
|
||||||
@@ -50,6 +53,10 @@ interface ProjectState {
|
|||||||
project: Project | null;
|
project: Project | null;
|
||||||
stats: ProjectStats | null;
|
stats: ProjectStats | null;
|
||||||
releases: Release[];
|
releases: Release[];
|
||||||
|
/** Under way, planned, then closed; with their progress. */
|
||||||
|
sprints: Sprint[];
|
||||||
|
/** Burndown of the sprint asked for with `burndownRequested`. */
|
||||||
|
burndown: SprintBurndown | null;
|
||||||
activity: Activity[];
|
activity: Activity[];
|
||||||
userResults: UserDirectoryEntry[];
|
userResults: UserDirectoryEntry[];
|
||||||
analytics: ProjectAnalytics | null;
|
analytics: ProjectAnalytics | null;
|
||||||
@@ -71,6 +78,8 @@ export const ProjectStore = signalStore(
|
|||||||
project: null,
|
project: null,
|
||||||
stats: null,
|
stats: null,
|
||||||
releases: [],
|
releases: [],
|
||||||
|
sprints: [],
|
||||||
|
burndown: null,
|
||||||
activity: [],
|
activity: [],
|
||||||
userResults: [],
|
userResults: [],
|
||||||
analytics: null,
|
analytics: null,
|
||||||
@@ -109,6 +118,8 @@ export const ProjectStore = signalStore(
|
|||||||
project: null,
|
project: null,
|
||||||
stats: null,
|
stats: null,
|
||||||
releases: [],
|
releases: [],
|
||||||
|
sprints: [],
|
||||||
|
burndown: null,
|
||||||
activity: [],
|
activity: [],
|
||||||
analytics: null,
|
analytics: null,
|
||||||
analyticsError: null,
|
analyticsError: null,
|
||||||
@@ -153,6 +164,10 @@ export const ProjectStore = signalStore(
|
|||||||
error: payload,
|
error: payload,
|
||||||
})),
|
})),
|
||||||
on(projectApiEvents.statsLoaded, ({ payload }) => ({ stats: payload })),
|
on(projectApiEvents.statsLoaded, ({ payload }) => ({ stats: payload })),
|
||||||
|
on(projectApiEvents.sprintsLoaded, ({ payload }) => ({ sprints: payload })),
|
||||||
|
on(projectApiEvents.burndownLoaded, ({ payload }) => ({
|
||||||
|
burndown: payload,
|
||||||
|
})),
|
||||||
on(projectApiEvents.releasesLoaded, ({ payload }) => ({
|
on(projectApiEvents.releasesLoaded, ({ payload }) => ({
|
||||||
releases: payload,
|
releases: payload,
|
||||||
})),
|
})),
|
||||||
@@ -175,6 +190,7 @@ export const ProjectStore = signalStore(
|
|||||||
events = inject(Events),
|
events = inject(Events),
|
||||||
projects = inject(ProjectsGateway),
|
projects = inject(ProjectsGateway),
|
||||||
releases = inject(ReleasesGateway),
|
releases = inject(ReleasesGateway),
|
||||||
|
sprints = inject(SprintsGateway),
|
||||||
activity = inject(ActivityGateway),
|
activity = inject(ActivityGateway),
|
||||||
realtime = inject(TrackerRealtimeGateway),
|
realtime = inject(TrackerRealtimeGateway),
|
||||||
notifications = inject(Notifications),
|
notifications = inject(Notifications),
|
||||||
@@ -191,6 +207,23 @@ export const ProjectStore = signalStore(
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const reloadSprints = (projectId: string) =>
|
||||||
|
sprints.list(projectId).pipe(
|
||||||
|
mapResponse({
|
||||||
|
next: (list) => projectApiEvents.sprintsLoaded(list),
|
||||||
|
error: (error) =>
|
||||||
|
projectApiEvents.commandFailed(describeError(error)),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const loadBurndown = (sprintId: string) =>
|
||||||
|
sprints.burndown(sprintId).pipe(
|
||||||
|
mapResponse({
|
||||||
|
next: (burndown) => projectApiEvents.burndownLoaded(burndown),
|
||||||
|
error: (error) =>
|
||||||
|
projectApiEvents.commandFailed(describeError(error)),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
load$: events.on(projectPageEvents.opened).pipe(
|
load$: events.on(projectPageEvents.opened).pipe(
|
||||||
switchMap(({ payload }) =>
|
switchMap(({ payload }) =>
|
||||||
@@ -216,6 +249,7 @@ export const ProjectStore = signalStore(
|
|||||||
.stats(payload.id)
|
.stats(payload.id)
|
||||||
.pipe(map((stats) => projectApiEvents.statsLoaded(stats))),
|
.pipe(map((stats) => projectApiEvents.statsLoaded(stats))),
|
||||||
reloadReleases(payload.id),
|
reloadReleases(payload.id),
|
||||||
|
reloadSprints(payload.id),
|
||||||
activity
|
activity
|
||||||
.list(payload.id)
|
.list(payload.id)
|
||||||
.pipe(map((list) => projectApiEvents.activityLoaded(list))),
|
.pipe(map((list) => projectApiEvents.activityLoaded(list))),
|
||||||
@@ -240,6 +274,9 @@ export const ProjectStore = signalStore(
|
|||||||
realtime
|
realtime
|
||||||
.releaseChanges(payload.id)
|
.releaseChanges(payload.id)
|
||||||
.pipe(map((r) => projectRealtimeEvents.releaseChanged(r))),
|
.pipe(map((r) => projectRealtimeEvents.releaseChanged(r))),
|
||||||
|
realtime
|
||||||
|
.sprintChanges(payload.id)
|
||||||
|
.pipe(map((c) => projectRealtimeEvents.sprintChanged(c))),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -267,6 +304,24 @@ export const ProjectStore = signalStore(
|
|||||||
return id ? reloadReleases(id) : [];
|
return id ? reloadReleases(id) : [];
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
/** Progress and burndown follow the tickets: refetch when they or a sprint change. */
|
||||||
|
refreshSprints$: merge(
|
||||||
|
events.on(projectRealtimeEvents.sprintChanged),
|
||||||
|
events
|
||||||
|
.on(projectRealtimeEvents.activityAdded)
|
||||||
|
.pipe(debounceTime(800)),
|
||||||
|
).pipe(
|
||||||
|
switchMap(() => {
|
||||||
|
const id = store.project()?.id;
|
||||||
|
const shown = store.burndown()?.sprintId;
|
||||||
|
return id
|
||||||
|
? merge(reloadSprints(id), shown ? loadBurndown(shown) : [])
|
||||||
|
: [];
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
burndown$: events
|
||||||
|
.on(projectPageEvents.burndownRequested)
|
||||||
|
.pipe(switchMap(({ payload }) => loadBurndown(payload.sprintId))),
|
||||||
analytics$: events.on(projectPageEvents.analyticsRequested).pipe(
|
analytics$: events.on(projectPageEvents.analyticsRequested).pipe(
|
||||||
switchMap(({ payload }) =>
|
switchMap(({ payload }) =>
|
||||||
projects.analytics(payload.projectId, payload.days).pipe(
|
projects.analytics(payload.projectId, payload.days).pipe(
|
||||||
@@ -376,6 +431,68 @@ export const ProjectStore = signalStore(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
events
|
||||||
|
.on(projectPageEvents.sprintCreateRequested)
|
||||||
|
.pipe(
|
||||||
|
mergeMap(({ payload }) =>
|
||||||
|
runCommand(
|
||||||
|
sprints.create(payload),
|
||||||
|
`Sprint ${payload.name} planificado`,
|
||||||
|
projectApiEvents.commandSucceeded,
|
||||||
|
projectApiEvents.commandFailed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
events
|
||||||
|
.on(projectPageEvents.sprintUpdateRequested)
|
||||||
|
.pipe(
|
||||||
|
mergeMap(({ payload }) =>
|
||||||
|
runCommand(
|
||||||
|
sprints.update(payload),
|
||||||
|
'Sprint actualizado',
|
||||||
|
projectApiEvents.commandSucceeded,
|
||||||
|
projectApiEvents.commandFailed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
events
|
||||||
|
.on(projectPageEvents.sprintStartRequested)
|
||||||
|
.pipe(
|
||||||
|
mergeMap(({ payload }) =>
|
||||||
|
runCommand(
|
||||||
|
sprints.start(payload.sprintId),
|
||||||
|
'Sprint en marcha 🏁',
|
||||||
|
projectApiEvents.commandSucceeded,
|
||||||
|
projectApiEvents.commandFailed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
events
|
||||||
|
.on(projectPageEvents.sprintCloseRequested)
|
||||||
|
.pipe(
|
||||||
|
mergeMap(({ payload }) =>
|
||||||
|
runCommand(
|
||||||
|
sprints.close(payload.sprintId, payload.carryOverTo),
|
||||||
|
payload.carryOverTo
|
||||||
|
? 'Sprint cerrado: lo pendiente pasa al siguiente'
|
||||||
|
: 'Sprint cerrado: lo pendiente vuelve al backlog',
|
||||||
|
projectApiEvents.commandSucceeded,
|
||||||
|
projectApiEvents.commandFailed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
events
|
||||||
|
.on(projectPageEvents.sprintDeleteRequested)
|
||||||
|
.pipe(
|
||||||
|
mergeMap(({ payload }) =>
|
||||||
|
runCommand(
|
||||||
|
sprints.remove(payload.sprintId),
|
||||||
|
'Sprint eliminado',
|
||||||
|
projectApiEvents.commandSucceeded,
|
||||||
|
projectApiEvents.commandFailed,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
events
|
events
|
||||||
.on(projectPageEvents.releasePublishRequested)
|
.on(projectPageEvents.releasePublishRequested)
|
||||||
.pipe(
|
.pipe(
|
||||||
|
|||||||
@@ -39,6 +39,13 @@ export enum ReleaseStatus {
|
|||||||
ARCHIVED = 'ARCHIVED',
|
ARCHIVED = 'ARCHIVED',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export enum SprintStatus {
|
||||||
|
PLANNED = 'PLANNED',
|
||||||
|
/** Under way: one per project at a time. */
|
||||||
|
ACTIVE = 'ACTIVE',
|
||||||
|
CLOSED = 'CLOSED',
|
||||||
|
}
|
||||||
|
|
||||||
/** Why someone is told. */
|
/** Why someone is told. */
|
||||||
export enum NotificationKind {
|
export enum NotificationKind {
|
||||||
ASSIGNED = 'ASSIGNED',
|
ASSIGNED = 'ASSIGNED',
|
||||||
|
|||||||
@@ -86,4 +86,22 @@ export interface BoardFilter {
|
|||||||
labels?: string[];
|
labels?: string[];
|
||||||
/** Matches key, title or description. */
|
/** Matches key, title or description. */
|
||||||
search?: string;
|
search?: string;
|
||||||
|
sprintId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSprintInput {
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
goal?: string | null;
|
||||||
|
/** `YYYY-MM-DD`. */
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSprintInput {
|
||||||
|
sprintId: string;
|
||||||
|
name?: string;
|
||||||
|
goal?: string | null;
|
||||||
|
startDate?: string;
|
||||||
|
endDate?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
IssuePriority,
|
IssuePriority,
|
||||||
ProjectRole,
|
ProjectRole,
|
||||||
ReleaseStatus,
|
ReleaseStatus,
|
||||||
|
SprintStatus,
|
||||||
StatusCategory,
|
StatusCategory,
|
||||||
} from './enums';
|
} from './enums';
|
||||||
import { ProjectSettings } from './workflow';
|
import { ProjectSettings } from './workflow';
|
||||||
@@ -107,6 +108,7 @@ export interface IssueSummary {
|
|||||||
priority: IssuePriority;
|
priority: IssuePriority;
|
||||||
assignee: UserRef | null;
|
assignee: UserRef | null;
|
||||||
release: { id: string; name: string } | null;
|
release: { id: string; name: string } | null;
|
||||||
|
sprint: { id: string; name: string } | null;
|
||||||
labels: string[];
|
labels: string[];
|
||||||
/** The epic or ticket this one breaks down. */
|
/** The epic or ticket this one breaks down. */
|
||||||
parent: IssueParent | null;
|
parent: IssueParent | null;
|
||||||
@@ -159,6 +161,40 @@ export interface Comment {
|
|||||||
editedAt: string | null;
|
editedAt: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SprintProgress {
|
||||||
|
total: number;
|
||||||
|
/** Done or cancelled. */
|
||||||
|
finished: number;
|
||||||
|
percent: number;
|
||||||
|
storyPoints: number;
|
||||||
|
finishedStoryPoints: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A time-boxed iteration of a project. */
|
||||||
|
export interface Sprint {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
name: string;
|
||||||
|
goal: string | null;
|
||||||
|
/** `YYYY-MM-DD`, both included. */
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
status: SprintStatus;
|
||||||
|
startedAt: string | null;
|
||||||
|
closedAt: string | null;
|
||||||
|
progress: SprintProgress;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per day of a sprint; null for the days to come. */
|
||||||
|
export interface SprintBurndown {
|
||||||
|
sprintId: string;
|
||||||
|
dates: string[];
|
||||||
|
scope: (number | null)[];
|
||||||
|
remaining: (number | null)[];
|
||||||
|
remainingPoints: (number | null)[];
|
||||||
|
ideal: number[];
|
||||||
|
}
|
||||||
|
|
||||||
export interface Release {
|
export interface Release {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import {
|
|||||||
Comment,
|
Comment,
|
||||||
Epic,
|
Epic,
|
||||||
SavedFilter,
|
SavedFilter,
|
||||||
|
Sprint,
|
||||||
|
SprintBurndown,
|
||||||
SavedFilterCriteria,
|
SavedFilterCriteria,
|
||||||
CommandResult,
|
CommandResult,
|
||||||
Issue,
|
Issue,
|
||||||
@@ -32,6 +34,8 @@ import {
|
|||||||
MyIssuesFilter,
|
MyIssuesFilter,
|
||||||
UpdateIssueInput,
|
UpdateIssueInput,
|
||||||
UpdateProjectInput,
|
UpdateProjectInput,
|
||||||
|
CreateSprintInput,
|
||||||
|
UpdateSprintInput,
|
||||||
} from '../models/inputs';
|
} from '../models/inputs';
|
||||||
|
|
||||||
/*
|
/*
|
||||||
@@ -173,6 +177,25 @@ export abstract class ReleasesGateway {
|
|||||||
abstract burnup(releaseId: string): Observable<ReleaseBurnup>;
|
abstract burnup(releaseId: string): Observable<ReleaseBurnup>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export abstract class SprintsGateway {
|
||||||
|
/** With their progress: under way, planned, then closed. */
|
||||||
|
abstract list(projectId: string): Observable<Sprint[]>;
|
||||||
|
abstract burndown(sprintId: string): Observable<SprintBurndown>;
|
||||||
|
abstract create(input: CreateSprintInput): Observable<CommandResult>;
|
||||||
|
abstract update(input: UpdateSprintInput): Observable<CommandResult>;
|
||||||
|
abstract start(sprintId: string): Observable<CommandResult>;
|
||||||
|
/** Unfinished tickets go to `carryOverTo` (null: the backlog). */
|
||||||
|
abstract close(
|
||||||
|
sprintId: string,
|
||||||
|
carryOverTo: string | null,
|
||||||
|
): Observable<CommandResult>;
|
||||||
|
abstract remove(sprintId: string): Observable<CommandResult>;
|
||||||
|
abstract setIssueSprint(
|
||||||
|
issueId: string,
|
||||||
|
sprintId: string | null,
|
||||||
|
): Observable<CommandResult>;
|
||||||
|
}
|
||||||
|
|
||||||
export abstract class ActivityGateway {
|
export abstract class ActivityGateway {
|
||||||
abstract list(projectId: string, issueId?: string): Observable<Activity[]>;
|
abstract list(projectId: string, issueId?: string): Observable<Activity[]>;
|
||||||
}
|
}
|
||||||
@@ -219,6 +242,8 @@ export abstract class TrackerRealtimeGateway {
|
|||||||
abstract commentChanges(issueId: string): Observable<CommentChanged>;
|
abstract commentChanges(issueId: string): Observable<CommentChanged>;
|
||||||
abstract activity(projectId: string): Observable<Activity>;
|
abstract activity(projectId: string): Observable<Activity>;
|
||||||
abstract releaseChanges(projectId: string): Observable<Release>;
|
abstract releaseChanges(projectId: string): Observable<Release>;
|
||||||
|
/** Something about a sprint of the project changed (or it was deleted). */
|
||||||
|
abstract sprintChanges(projectId: string): Observable<{ sprintId: string }>;
|
||||||
abstract projectChanges(projectId: string): Observable<ProjectChanged>;
|
abstract projectChanges(projectId: string): Observable<ProjectChanged>;
|
||||||
abstract myProjectsChanges(): Observable<ProjectChanged>;
|
abstract myProjectsChanges(): Observable<ProjectChanged>;
|
||||||
abstract notifications(): Observable<Notification>;
|
abstract notifications(): Observable<Notification>;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
|
|||||||
labels: filter.labels?.length ? filter.labels : null,
|
labels: filter.labels?.length ? filter.labels : null,
|
||||||
search: filter.search?.trim() || null,
|
search: filter.search?.trim() || null,
|
||||||
unassigned: filter.unassigned ?? null,
|
unassigned: filter.unassigned ?? null,
|
||||||
|
sprintId: filter.sprintId ?? null,
|
||||||
})
|
})
|
||||||
.pipe(
|
.pipe(
|
||||||
map((d) =>
|
map((d) =>
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
NotificationAddedDocument,
|
NotificationAddedDocument,
|
||||||
ProjectChangedDocument,
|
ProjectChangedDocument,
|
||||||
ReleaseChangedDocument,
|
ReleaseChangedDocument,
|
||||||
|
SprintChangedDocument,
|
||||||
} from '../graphql/generated';
|
} from '../graphql/generated';
|
||||||
import { GraphqlOperations } from './graphql-operations';
|
import { GraphqlOperations } from './graphql-operations';
|
||||||
import {
|
import {
|
||||||
@@ -85,6 +86,14 @@ export class GraphqlRealtimeGateway extends TrackerRealtimeGateway {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sprintChanges(projectId: string): Observable<{ sprintId: string }> {
|
||||||
|
return this.signedIn(() =>
|
||||||
|
this.gql
|
||||||
|
.subscribe(SprintChangedDocument, { projectId })
|
||||||
|
.pipe(map((d) => ({ sprintId: d.sprintChanged.sprintId }))),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
projectChanges(projectId: string): Observable<ProjectChanged> {
|
projectChanges(projectId: string): Observable<ProjectChanged> {
|
||||||
return this.signedIn(() =>
|
return this.signedIn(() =>
|
||||||
this.gql
|
this.gql
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import { inject, Injectable } from '@angular/core';
|
||||||
|
import {
|
||||||
|
CommandResult,
|
||||||
|
CreateSprintInput,
|
||||||
|
Sprint,
|
||||||
|
SprintBurndown,
|
||||||
|
SprintsGateway,
|
||||||
|
UpdateSprintInput,
|
||||||
|
} from '@task-stream/web/tracker/domain';
|
||||||
|
import { map, Observable } from 'rxjs';
|
||||||
|
import {
|
||||||
|
CloseSprintDocument,
|
||||||
|
CreateSprintDocument,
|
||||||
|
DeleteSprintDocument,
|
||||||
|
SetIssueSprintDocument,
|
||||||
|
SprintBurndownDocument,
|
||||||
|
SprintsDocument,
|
||||||
|
StartSprintDocument,
|
||||||
|
UpdateSprintDocument,
|
||||||
|
} from '../graphql/generated';
|
||||||
|
import { GraphqlOperations } from './graphql-operations';
|
||||||
|
import { toCommandResult } from './mappers';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class GraphqlSprintsGateway extends SprintsGateway {
|
||||||
|
private readonly gql = inject(GraphqlOperations);
|
||||||
|
|
||||||
|
list(projectId: string): Observable<Sprint[]> {
|
||||||
|
return this.gql
|
||||||
|
.query(SprintsDocument, { projectId })
|
||||||
|
.pipe(map((d) => d.sprints as Sprint[]));
|
||||||
|
}
|
||||||
|
|
||||||
|
burndown(sprintId: string): Observable<SprintBurndown> {
|
||||||
|
return this.gql
|
||||||
|
.query(SprintBurndownDocument, { id: sprintId })
|
||||||
|
.pipe(map((d) => d.sprint.burndown));
|
||||||
|
}
|
||||||
|
|
||||||
|
create(input: CreateSprintInput): Observable<CommandResult> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(CreateSprintDocument, { input })
|
||||||
|
.pipe(map((d) => toCommandResult(d.createSprint)));
|
||||||
|
}
|
||||||
|
|
||||||
|
update(input: UpdateSprintInput): Observable<CommandResult> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(UpdateSprintDocument, { input })
|
||||||
|
.pipe(map((d) => toCommandResult(d.updateSprint)));
|
||||||
|
}
|
||||||
|
|
||||||
|
start(sprintId: string): Observable<CommandResult> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(StartSprintDocument, { sprintId })
|
||||||
|
.pipe(map((d) => toCommandResult(d.startSprint)));
|
||||||
|
}
|
||||||
|
|
||||||
|
close(
|
||||||
|
sprintId: string,
|
||||||
|
carryOverTo: string | null,
|
||||||
|
): Observable<CommandResult> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(CloseSprintDocument, {
|
||||||
|
sprintId,
|
||||||
|
carryOverToSprintId: carryOverTo,
|
||||||
|
})
|
||||||
|
.pipe(map((d) => toCommandResult(d.closeSprint)));
|
||||||
|
}
|
||||||
|
|
||||||
|
remove(sprintId: string): Observable<CommandResult> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(DeleteSprintDocument, { sprintId })
|
||||||
|
.pipe(map((d) => toCommandResult(d.deleteSprint)));
|
||||||
|
}
|
||||||
|
|
||||||
|
setIssueSprint(
|
||||||
|
issueId: string,
|
||||||
|
sprintId: string | null,
|
||||||
|
): Observable<CommandResult> {
|
||||||
|
return this.gql
|
||||||
|
.mutate(SetIssueSprintDocument, { issueId, sprintId })
|
||||||
|
.pipe(map((d) => toCommandResult(d.setIssueSprint)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -69,6 +69,10 @@ fragment IssueSummaryFields on Issue {
|
|||||||
id
|
id
|
||||||
name
|
name
|
||||||
}
|
}
|
||||||
|
sprint {
|
||||||
|
id
|
||||||
|
name
|
||||||
|
}
|
||||||
parent {
|
parent {
|
||||||
id
|
id
|
||||||
key
|
key
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -8,6 +8,7 @@ query Board(
|
|||||||
$labels: [String!]
|
$labels: [String!]
|
||||||
$search: String
|
$search: String
|
||||||
$unassigned: Boolean
|
$unassigned: Boolean
|
||||||
|
$sprintId: ID
|
||||||
) {
|
) {
|
||||||
project(idOrKey: $idOrKey) {
|
project(idOrKey: $idOrKey) {
|
||||||
id
|
id
|
||||||
@@ -20,6 +21,7 @@ query Board(
|
|||||||
labels: $labels
|
labels: $labels
|
||||||
search: $search
|
search: $search
|
||||||
unassigned: $unassigned
|
unassigned: $unassigned
|
||||||
|
sprintId: $sprintId
|
||||||
perColumn: 100
|
perColumn: 100
|
||||||
) {
|
) {
|
||||||
status
|
status
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
fragment SprintFields on Sprint {
|
||||||
|
id
|
||||||
|
projectId
|
||||||
|
name
|
||||||
|
goal
|
||||||
|
startDate
|
||||||
|
endDate
|
||||||
|
status
|
||||||
|
startedAt
|
||||||
|
closedAt
|
||||||
|
progress {
|
||||||
|
total
|
||||||
|
finished
|
||||||
|
percent
|
||||||
|
storyPoints
|
||||||
|
finishedStoryPoints
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query Sprints($projectId: ID!) {
|
||||||
|
sprints(projectId: $projectId) {
|
||||||
|
...SprintFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
query SprintBurndown($id: ID!) {
|
||||||
|
sprint(id: $id) {
|
||||||
|
id
|
||||||
|
burndown {
|
||||||
|
sprintId
|
||||||
|
dates
|
||||||
|
scope
|
||||||
|
remaining
|
||||||
|
remainingPoints
|
||||||
|
ideal
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation CreateSprint($input: CreateSprintInput!) {
|
||||||
|
createSprint(input: $input) {
|
||||||
|
...CommandResultFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation UpdateSprint($input: UpdateSprintInput!) {
|
||||||
|
updateSprint(input: $input) {
|
||||||
|
...CommandResultFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation StartSprint($sprintId: ID!) {
|
||||||
|
startSprint(sprintId: $sprintId) {
|
||||||
|
...CommandResultFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation CloseSprint($sprintId: ID!, $carryOverToSprintId: ID) {
|
||||||
|
closeSprint(sprintId: $sprintId, carryOverToSprintId: $carryOverToSprintId) {
|
||||||
|
...CommandResultFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation DeleteSprint($sprintId: ID!) {
|
||||||
|
deleteSprint(sprintId: $sprintId) {
|
||||||
|
...CommandResultFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mutation SetIssueSprint($issueId: ID!, $sprintId: ID) {
|
||||||
|
setIssueSprint(issueId: $issueId, sprintId: $sprintId) {
|
||||||
|
...CommandResultFields
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
subscription SprintChanged($projectId: ID!) {
|
||||||
|
sprintChanged(projectId: $projectId) {
|
||||||
|
action
|
||||||
|
sprintId
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
ProjectsGateway,
|
ProjectsGateway,
|
||||||
ReleasesGateway,
|
ReleasesGateway,
|
||||||
SavedFiltersGateway,
|
SavedFiltersGateway,
|
||||||
|
SprintsGateway,
|
||||||
TrackerRealtimeGateway,
|
TrackerRealtimeGateway,
|
||||||
UsersGateway,
|
UsersGateway,
|
||||||
} from '@task-stream/web/tracker/domain';
|
} from '@task-stream/web/tracker/domain';
|
||||||
@@ -26,6 +27,7 @@ import { HttpAttachmentsGateway } from './adapters/http-attachments.gateway';
|
|||||||
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
|
import { GraphqlProjectsGateway } from './adapters/graphql-projects.gateway';
|
||||||
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
|
import { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
|
||||||
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
|
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
|
||||||
|
import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway';
|
||||||
|
|
||||||
/** Ports → GraphQL adapters (and REST for files). */
|
/** Ports → GraphQL adapters (and REST for files). */
|
||||||
export function provideTrackerInfrastructure(): EnvironmentProviders {
|
export function provideTrackerInfrastructure(): EnvironmentProviders {
|
||||||
@@ -40,5 +42,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
|
|||||||
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
|
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
|
||||||
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
|
||||||
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
|
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
|
||||||
|
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,6 +137,12 @@ const NOBODY = '__nobody__';
|
|||||||
[options]="priorityFilterOptions"
|
[options]="priorityFilterOptions"
|
||||||
[(value)]="priorityFilter"
|
[(value)]="priorityFilter"
|
||||||
/>
|
/>
|
||||||
|
<pi-select-field
|
||||||
|
class="ts-filter"
|
||||||
|
placeholder="Sprint"
|
||||||
|
[options]="sprintFilterOptions()"
|
||||||
|
[(value)]="sprintFilter"
|
||||||
|
/>
|
||||||
<pi-select-field
|
<pi-select-field
|
||||||
class="ts-filter"
|
class="ts-filter"
|
||||||
placeholder="Release"
|
placeholder="Release"
|
||||||
@@ -335,6 +341,8 @@ export class BoardPage {
|
|||||||
readonly release = input<string | undefined>();
|
readonly release = input<string | undefined>();
|
||||||
/** `?assignee=` (from the tracking page's team list), bound by the router. */
|
/** `?assignee=` (from the tracking page's team list), bound by the router. */
|
||||||
readonly assignee = input<string | undefined>();
|
readonly assignee = input<string | undefined>();
|
||||||
|
/** `?sprint=` (from the sprints page), bound by the router. */
|
||||||
|
readonly sprint = input<string | undefined>();
|
||||||
|
|
||||||
protected readonly board = inject(BoardFacade);
|
protected readonly board = inject(BoardFacade);
|
||||||
protected readonly project = inject(ProjectFacade);
|
protected readonly project = inject(ProjectFacade);
|
||||||
@@ -353,6 +361,16 @@ export class BoardPage {
|
|||||||
protected readonly releaseFilter = signal<string | null>(ALL);
|
protected readonly releaseFilter = signal<string | null>(ALL);
|
||||||
protected readonly area = signal<string | null>(ALL);
|
protected readonly area = signal<string | null>(ALL);
|
||||||
protected readonly typeFilter = signal<string | null>(ALL);
|
protected readonly typeFilter = signal<string | null>(ALL);
|
||||||
|
protected readonly sprintFilter = signal<string | null>(ALL);
|
||||||
|
protected readonly sprintFilterOptions = computed<
|
||||||
|
PiSelectFieldOption<string>[]
|
||||||
|
>(() => [
|
||||||
|
{ value: ALL, label: 'Todos los sprints' },
|
||||||
|
...this.project.openSprints().map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.status === 'ACTIVE' ? `${s.name} · en curso` : s.name,
|
||||||
|
})),
|
||||||
|
]);
|
||||||
protected readonly priorityFilter = signal<string | null>(ALL);
|
protected readonly priorityFilter = signal<string | null>(ALL);
|
||||||
/** What is typed; `search` follows it once typing pauses. */
|
/** What is typed; `search` follows it once typing pauses. */
|
||||||
protected readonly searchInput = signal('');
|
protected readonly searchInput = signal('');
|
||||||
@@ -364,6 +382,7 @@ export class BoardPage {
|
|||||||
!!(
|
!!(
|
||||||
this.assigneeFilter() ||
|
this.assigneeFilter() ||
|
||||||
this.releaseFilter() ||
|
this.releaseFilter() ||
|
||||||
|
this.sprintFilter() ||
|
||||||
this.area() ||
|
this.area() ||
|
||||||
this.typeFilter() ||
|
this.typeFilter() ||
|
||||||
this.priorityFilter() ||
|
this.priorityFilter() ||
|
||||||
@@ -485,6 +504,10 @@ export class BoardPage {
|
|||||||
const assignee = this.assignee();
|
const assignee = this.assignee();
|
||||||
if (assignee) untracked(() => this.assigneeFilter.set(assignee));
|
if (assignee) untracked(() => this.assigneeFilter.set(assignee));
|
||||||
});
|
});
|
||||||
|
effect(() => {
|
||||||
|
const sprint = this.sprint();
|
||||||
|
if (sprint) untracked(() => this.sprintFilter.set(sprint));
|
||||||
|
});
|
||||||
effect((onCleanup) => {
|
effect((onCleanup) => {
|
||||||
const text = this.searchInput().trim();
|
const text = this.searchInput().trim();
|
||||||
const timer = setTimeout(() => this.search.set(text), 300);
|
const timer = setTimeout(() => this.search.set(text), 300);
|
||||||
@@ -505,6 +528,7 @@ export class BoardPage {
|
|||||||
priorities: priority ? [priority] : undefined,
|
priorities: priority ? [priority] : undefined,
|
||||||
labels: this.labels().length ? this.labels() : undefined,
|
labels: this.labels().length ? this.labels() : undefined,
|
||||||
search: this.search() || undefined,
|
search: this.search() || undefined,
|
||||||
|
sprintId: this.sprintFilter() || undefined,
|
||||||
};
|
};
|
||||||
if (!projectId) return;
|
if (!projectId) return;
|
||||||
untracked(() =>
|
untracked(() =>
|
||||||
@@ -569,6 +593,7 @@ export class BoardPage {
|
|||||||
filter.unassigned ? NOBODY : (filter.assigneeId ?? ALL),
|
filter.unassigned ? NOBODY : (filter.assigneeId ?? ALL),
|
||||||
);
|
);
|
||||||
this.releaseFilter.set(filter.releaseId ?? ALL);
|
this.releaseFilter.set(filter.releaseId ?? ALL);
|
||||||
|
this.sprintFilter.set(ALL);
|
||||||
this.area.set(filter.area ?? ALL);
|
this.area.set(filter.area ?? ALL);
|
||||||
this.typeFilter.set(filter.types?.[0] ?? ALL);
|
this.typeFilter.set(filter.types?.[0] ?? ALL);
|
||||||
this.priorityFilter.set(filter.priorities?.[0] ?? ALL);
|
this.priorityFilter.set(filter.priorities?.[0] ?? ALL);
|
||||||
@@ -581,6 +606,7 @@ export class BoardPage {
|
|||||||
for (const filter of [
|
for (const filter of [
|
||||||
this.assigneeFilter,
|
this.assigneeFilter,
|
||||||
this.releaseFilter,
|
this.releaseFilter,
|
||||||
|
this.sprintFilter,
|
||||||
this.area,
|
this.area,
|
||||||
this.typeFilter,
|
this.typeFilter,
|
||||||
this.priorityFilter,
|
this.priorityFilter,
|
||||||
|
|||||||
@@ -594,6 +594,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
|
|||||||
>
|
>
|
||||||
<pi-label>Release</pi-label>
|
<pi-label>Release</pi-label>
|
||||||
</pi-select-field>
|
</pi-select-field>
|
||||||
|
<pi-select-field
|
||||||
|
[options]="sprintOptions()"
|
||||||
|
[disabled]="!project.canContribute() || sprintLocked()"
|
||||||
|
[value]="issue.sprint?.id ?? ''"
|
||||||
|
(valueChange)="facade.setSprint($event || null)"
|
||||||
|
>
|
||||||
|
<pi-label>Sprint</pi-label>
|
||||||
|
</pi-select-field>
|
||||||
<pi-select-field
|
<pi-select-field
|
||||||
[options]="typeOptions()"
|
[options]="typeOptions()"
|
||||||
[disabled]="!project.canContribute()"
|
[disabled]="!project.canContribute()"
|
||||||
@@ -768,6 +776,30 @@ export class IssuePage {
|
|||||||
.map((r) => ({ value: r.id, label: r.name })),
|
.map((r) => ({ value: r.id, label: r.name })),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
/** Open sprints, plus the ticket's own even if it closed since. */
|
||||||
|
protected readonly sprintOptions = computed<PiSelectFieldOption<string>[]>(
|
||||||
|
() => {
|
||||||
|
const current = this.facade.issue()?.sprint;
|
||||||
|
const open = this.project.openSprints();
|
||||||
|
return [
|
||||||
|
{ value: NONE, label: 'Backlog (sin sprint)' },
|
||||||
|
...open.map((s) => ({
|
||||||
|
value: s.id,
|
||||||
|
label: s.status === 'ACTIVE' ? `${s.name} · en curso` : s.name,
|
||||||
|
})),
|
||||||
|
...(current && !open.some((s) => s.id === current.id)
|
||||||
|
? [{ value: current.id, label: `${current.name} · cerrado` }]
|
||||||
|
: []),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
);
|
||||||
|
/** A closed sprint keeps its tickets. */
|
||||||
|
protected readonly sprintLocked = computed(() => {
|
||||||
|
const current = this.facade.issue()?.sprint;
|
||||||
|
return (
|
||||||
|
!!current && !this.project.openSprints().some((s) => s.id === current.id)
|
||||||
|
);
|
||||||
|
});
|
||||||
protected readonly priorityOptions = options(PRIORITY);
|
protected readonly priorityOptions = options(PRIORITY);
|
||||||
/** Active values, plus the ticket's own even if archived since. */
|
/** Active values, plus the ticket's own even if archived since. */
|
||||||
protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>(
|
protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user