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:
2026-10-08 14:59:01 +02:00
parent d2e31789a7
commit 50d3ffd1dd
104 changed files with 7209 additions and 33 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -176,6 +176,17 @@ input CreateReleaseInput {
targetDate: String
}
input CreateSprintInput {
"""YYYY-MM-DD, included"""
endDate: String!
goal: String
name: String!
projectId: ID!
"""YYYY-MM-DD"""
startDate: String!
}
type CreatedResolved {
created: Int!
resolved: Int!
@@ -249,6 +260,7 @@ type Issue {
rank: String!
release: ReleaseRef
reporter: UserRef!
sprint: SprintRef
"""A status id of the project's settings"""
status: String!
@@ -278,6 +290,9 @@ input IssueFilterInput {
categories: [StatusCategory!]
labels: [String!]
"""Only tickets in no sprint"""
noSprint: Boolean
"""Only the tickets hanging from this one"""
parentId: ID
priorities: [IssuePriority!]
@@ -287,6 +302,7 @@ input IssueFilterInput {
"""Matches key, title or description"""
search: String
sort: IssueSort = RECENT
sprintId: ID
statuses: [String!]
types: [String!]
unassigned: Boolean
@@ -388,10 +404,18 @@ type Mutation {
"""Move a ticket through the workflow (incl. QA)"""
changeIssueStatus(input: ChangeIssueStatusInput!): CommandResult!
changeProjectMemberRole(input: ProjectMemberInput!): CommandResult!
"""
Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null.
"""
closeSprint(carryOverToSprintId: ID, sprintId: ID!): CommandResult!
createIssue(input: CreateIssueInput!): CommandResult!
createProject(input: CreateProjectInput!): CommandResult!
createRelease(input: CreateReleaseInput!): CommandResult!
"""Plan a sprint (owners and admins)"""
createSprint(input: CreateSprintInput!): CommandResult!
"""Author or project admin"""
deleteComment(commentId: ID!): CommandResult!
@@ -402,6 +426,9 @@ type Mutation {
"""Delete a view: whoever saved it, or an admin if it is shared"""
deleteSavedFilter(id: ID!): Boolean!
"""Delete a sprint that never started; its tickets go back to the backlog"""
deleteSprint(sprintId: ID!): CommandResult!
editComment(body: String!, commentId: ID!): CommandResult!
"""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)"""
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!
"""Stop following a ticket"""
@@ -451,6 +484,7 @@ type Mutation {
"""
updateProjectSettings(projectId: ID!, settings: ProjectSettingsInput!): CommandResult!
updateRelease(input: UpdateReleaseInput!): CommandResult!
updateSprint(input: UpdateSprintInput!): CommandResult!
"""
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"""
search: String
sprintId: ID
types: [String!]
unassigned: Boolean
): [BoardColumn!]!
@@ -668,6 +703,12 @@ type Query {
"""My views of the project's board, then the shared ones"""
savedFilters(projectId: ID!): [SavedFilter!]!
sprint(id: ID!): Sprint!
"""
A project's sprints: the one under way, the planned ones, then the closed ones
"""
sprints(projectId: ID!): [Sprint!]!
"""Planned releases of all my projects, soonest target date first"""
upcomingReleases(limit: Int! = 10): [Release!]!
@@ -784,6 +825,73 @@ input SavedFilterCriteriaInput {
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.
"""
@@ -820,6 +928,7 @@ type Subscription {
notificationAdded: Notification!
projectChanged(projectId: ID!): ProjectChange!
releaseChanged(projectId: ID!): ReleaseChange!
sprintChanged(projectId: ID!): SprintChange!
}
type Throughput {
@@ -865,6 +974,14 @@ input UpdateReleaseInput {
targetDate: String
}
input UpdateSprintInput {
endDate: String
goal: String
name: String
sprintId: ID!
startDate: String
}
"""User managed by the Pireon identity server"""
type User {
email: String!

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -15,6 +15,7 @@
| 👥 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 |
| 🔎 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 |
| 🧩 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 |

View File

@@ -139,9 +139,10 @@ uno y esperando. Así heredan las garantías del bridge: orden, reintento y nada
| 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 |
| `ReleaseArchivedSaga` | `ReleaseArchivedEvent` | `DetachIssueFromReleaseCommand` para sus tickets sin terminar (los terminados se quedan) |
| `SprintCarryOverSaga` | `SprintClosedEvent` / `SprintDeletedEvent` | `CarryOverIssueCommand`: lo pendiente pasa al sprint elegido o al backlog; al borrar, todos vuelven al backlog |
| `CommentersFollowSaga` | `CommentAddedEvent` | `WatchIssueCommand`: quien comenta pasa a seguir el ticket |
| `UserDeactivatedSaga` | `UserDeactivatedEvent` (contexto identity) | `UnassignIssueCommand` para sus tickets abiertos en todos los proyectos, como actor `system` («TaskStream») |

View File

@@ -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
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.
- **Sprints**: los planifica y gestiona quien administra el proyecto (owner o admin); mete tickets cualquier miembro.
Un sprint va de `PLANNED` a `ACTIVE` (uno solo en curso por proyecto) y a `CLOSED`; dura como mucho 90 días y un
sprint en curso conserva su fecha de inicio. Un sprint cerrado ya no acepta ni pierde tickets. Al cerrarlo se elige
adónde van los tickets sin terminar: a otro sprint abierto del proyecto o al backlog; los terminados se quedan como
registro. Solo se borran sprints que no han empezado (sus tickets vuelven al backlog).
- **Vistas guardadas**: filtros del tablero con nombre. Las guardan los miembros del proyecto (los lectores, solo para
sí); compartir una la hace visible a todo el proyecto. Borra una su autor, o un admin si es compartida; como mucho
50 por persona y proyecto. Son preferencias, no historia: viven en MongoDB (`saved_filters`), fuera del event store,
@@ -193,10 +198,11 @@ Todo cambio es un evento guardado en el event store (stream = id del agregado).
`aggregateId`, `projectId` y `aggregateVersion` (la posición en el stream).
| Agregado | Eventos |
| -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| -------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Project | `ProjectCreatedEvent`, `ProjectUpdatedEvent`, `ProjectSettingsChangedEvent`, `ProjectMemberAddedEvent`, `ProjectMemberRoleChangedEvent`, `ProjectMemberRemovedEvent`, `ProjectDeletedEvent` |
| Issue | `IssueCreatedEvent`, `IssueUpdatedEvent`, `IssueStatusChangedEvent`, `IssueAssignedEvent`, `IssueLinkedEvent`, `IssueUnlinkedEvent`, `IssueReleaseChangedEvent`, `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` |
| 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
@@ -216,6 +222,7 @@ puras (`application/src/lib/analytics`):
| Tiempo de entrega | De la creación hasta `DONE`; media y percentil 85 |
| Throughput | Tickets resueltos por semana y por la persona que los tenía asignados |
| QA | Salidas de un estado `QA`: a `DONE` cuenta como superado; a cualquier otro sitio salvo `CANCELLED`, como fallido |
| Burndown de un sprint | Por día del sprint: tickets en él (sin cancelados), cuántos siguen abiertos y sus puntos; línea ideal hasta cero |
| Burnup de una release | Por día, desde que se creó hasta que se publica (o hoy, máx. 180 días): tickets en ella (sin cancelados) y hechos |
Los tickets anteriores a esta proyección solo tienen su creación y su estado actual; para tener su historia completa,
@@ -240,6 +247,7 @@ que funcionan también 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` |
| `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 |
| `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` |
@@ -255,6 +263,7 @@ Todas devuelven `CommandResult { id, key, version }`.
| 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) |
| Comentarios | `addComment`, `editComment`, `deleteComment` |
| Sprints | `createSprint`, `updateSprint`, `startSprint`, `closeSprint(carryOverToSprintId)`, `deleteSprint`, `setIssueSprint` |
| Vistas | `saveFilter(input)` (devuelve el id), `deleteSavedFilter(id)` |
| Avisos | `markNotificationsRead(ids)`, `updateNotificationSettings(email)` |
| Releases | `createRelease`, `updateRelease`, `publishRelease(force)`, `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) |
| `activityAdded(projectId)` | Hay una nueva entrada en el feed |
| `releaseChanged(projectId)` | Cambia una release |
| `sprintChanged(projectId)` | Se planifica, cambia, empieza, cierra o borra un sprint (`sprint` es `null` al borrar) |
| `projectChanged(projectId)` | Cambian los datos, la configuración o los miembros del proyecto |
| `myProjectsChanged` | Me añaden a un proyecto o me quitan de uno |
@@ -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) |
| 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}` |
| Sprints | `GET/POST /api/projects/{project}/sprints`, `GET/PATCH/DELETE /api/sprints/{sprint}`, `POST …/start`, `POST …/close`, `GET …/progress`, `…/burndown`, `…/issues`, `PUT /api/issues/{issue}/sprint` |
| Vistas | `GET/POST /api/projects/{project}/filters`, `DELETE /api/filters/{id}` |
| Avisos | `GET /api/me/notifications`, `POST /api/me/notifications/read`, `GET/PUT /api/me/notification-settings` |
| Adjuntos | `POST /api/issues/{issue}/attachments` (multipart: `file` y opcional `commentId`), `GET/DELETE /api/issues/{issue}/attachments/{id}` |

View File

@@ -10,5 +10,6 @@ export * from './lib/workflows.service';
export * from './lib/notifications.service';
export * from './lib/notifications';
export * from './lib/saved-filters.service';
export * from './lib/sprints.service';
export * from './lib/saved-filters/saved-filters';
export * from './lib/tracker-application.module';

View File

@@ -4,10 +4,13 @@ import {
ReleaseReadModel,
ReleaseStatus,
StatusCategory as C,
SprintReadModel,
SprintStatus,
} from '@task-stream/api/tracker/domain';
import {
computeProjectAnalytics,
computeReleaseBurnup,
computeSprintBurndown,
weekOf,
} from './analytics';
@@ -182,3 +185,62 @@ describe('release burnup', () => {
expect(at(burnup.done, '2026-10-14')).toBe(2);
});
});
describe('sprint burndown', () => {
const sprint = {
id: 's1',
projectId: 'p',
startDate: '2026-10-12',
endDate: '2026-10-16',
status: SprintStatus.ACTIVE,
closedAt: null,
} as SprintReadModel;
const into = (issueId: string, at: string): IssueTimelineEntry => ({
id: `${issueId}:${++seq}`,
issueId,
projectId: 'p',
at,
kind: 'sprint',
sprintId: 's1',
});
// X (3 points) and Y (5) committed on Monday; Z (2) added on Tuesday.
// X done on Tuesday, Y cancelled on Wednesday. Today is Wednesday noon.
const issues = [
{ ...issue('x', '2026-10-01T09:00:00Z', 'DONE', C.DONE), storyPoints: 3 },
{
...issue('y', '2026-10-01T09:00:00Z', 'CANCELLED', C.CANCELLED),
storyPoints: 5,
},
{ ...issue('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO), storyPoints: 2 },
] as IssueReadModel[];
const timeline = [
step('x', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
step('y', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
step('z', '2026-10-01T09:00:00Z', 'TODO', C.TODO),
into('x', '2026-10-12T08:00:00Z'),
into('y', '2026-10-12T08:00:00Z'),
into('z', '2026-10-13T10:00:00Z'),
step('x', '2026-10-13T16:00:00Z', 'DONE', C.DONE),
step('y', '2026-10-14T09:00:00Z', 'CANCELLED', C.CANCELLED),
];
it('counts open tickets and points per day, and leaves the future blank', () => {
const burndown = computeSprintBurndown({
sprint,
issues,
timeline,
now: new Date('2026-10-14T12:00:00Z'),
});
expect(burndown.dates).toEqual([
'2026-10-12',
'2026-10-13',
'2026-10-14',
'2026-10-15',
'2026-10-16',
]);
expect(burndown.scope).toEqual([2, 3, 2, null, null]);
expect(burndown.remaining).toEqual([2, 2, 1, null, null]);
expect(burndown.remainingPoints).toEqual([8, 7, 2, null, null]);
expect(burndown.ideal).toEqual([2, 1.5, 1, 0.5, 0]);
});
});

View File

@@ -5,6 +5,8 @@ import {
ProjectAnalyticsReadModel,
ReleaseBurnupReadModel,
ReleaseReadModel,
SprintBurndownReadModel,
SprintReadModel,
StatusCategory,
UserRefReadModel,
} from '@task-stream/api/tracker/domain';
@@ -313,3 +315,85 @@ export function computeReleaseBurnup(
done,
};
}
export interface SprintBurndownInput {
sprint: SprintReadModel;
issues: IssueReadModel[];
timeline: IssueTimelineEntry[];
now: Date;
}
/**
* Per day of the sprint (start to end date, UTC): tickets in it, how many
* are still open and their points, and the ideal line. Points are today's
* estimates (their history is not kept). Days after today, or after the
* sprint closed, are null.
*/
export function computeSprintBurndown(
input: SprintBurndownInput,
): SprintBurndownReadModel {
const { sprint } = input;
const steps = stepsByIssue(input.issues, input.timeline);
const points = new Map(input.issues.map((i) => [i.id, i.storyPoints ?? 0]));
const membership = new Map<
string,
{ at: number; sprintId: string | null }[]
>();
for (const entry of input.timeline) {
if (entry.kind !== 'sprint') continue;
const list = membership.get(entry.issueId) ?? [];
list.push({ at: Date.parse(entry.at), sprintId: entry.sprintId ?? null });
membership.set(entry.issueId, list);
}
for (const list of membership.values()) list.sort((a, b) => a.at - b.at);
const start = Date.parse(`${sprint.startDate}T00:00:00Z`);
const end = Date.parse(`${sprint.endDate}T00:00:00Z`);
const last = Math.min(
sprint.closedAt ? Date.parse(sprint.closedAt) : input.now.getTime(),
end + DAY - 1,
);
const dayEnds = days(start, end + DAY - 1);
const scope: (number | null)[] = [];
const remaining: (number | null)[] = [];
const remainingPoints: (number | null)[] = [];
for (const time of dayEnds) {
// The day under way counts up to now; the ones to come are unknown.
const at = Math.min(time, last);
if (time - DAY >= last) {
scope.push(null);
remaining.push(null);
remainingPoints.push(null);
continue;
}
let inScope = 0;
let open = 0;
let openPoints = 0;
for (const [issueId, list] of membership) {
const current = list.filter((m) => m.at <= at).at(-1);
if (current?.sprintId !== sprint.id) continue;
const step = stepAt(steps.get(issueId) ?? [], at);
if (!step || step.category === StatusCategory.CANCELLED) continue;
inScope++;
if (step.category !== StatusCategory.DONE) {
open++;
openPoints += points.get(issueId) ?? 0;
}
}
scope.push(inScope);
remaining.push(open);
remainingPoints.push(openPoints);
}
const first = remaining[0] ?? 0;
const span = Math.max(dayEnds.length - 1, 1);
return {
sprintId: sprint.id,
dates: dayEnds.map(dayOf),
scope,
remaining,
remainingPoints,
ideal: dayEnds.map(
(_, i) => Math.round((first * (span - i) * 100) / span) / 100,
),
};
}

View File

@@ -8,6 +8,7 @@ import {
IssueChange,
ProjectChange,
ReleaseChange,
SprintChange,
} from './ports/change-feed';
import { CanViewProjectQuery } from './queries/can-view-project.query';
import { GetIssueQuery } from './queries/get-issue.query';
@@ -52,6 +53,14 @@ export class ChangesService {
return this.changeFeed.releases(projectId);
}
async watchSprints(
viewerId: string,
projectId: string,
): Promise<AsyncIterableIterator<SprintChange>> {
await this.assertCanView(viewerId, projectId);
return this.changeFeed.sprints(projectId);
}
async watchProject(
viewerId: string,
projectId: string,

View File

@@ -30,6 +30,7 @@ import { DetachFileCommandHandler } from './detach-file.command-handler';
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
import { RankIssueCommandHandler } from './rank-issue.command-handler';
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
import {
AddChecklistItemCommandHandler,
@@ -71,6 +72,7 @@ export const COMMAND_HANDLERS = [
WatchIssueCommandHandler,
SetIssueParentCommandHandler,
RankIssueCommandHandler,
...SPRINT_COMMAND_HANDLERS,
AddChecklistItemCommandHandler,
UpdateChecklistItemCommandHandler,
RemoveChecklistItemCommandHandler,

View File

@@ -31,6 +31,13 @@ import {
InMemoryTrackerStreams,
} from '../../testing/in-memory';
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import {
CreateSprintCommand,
SetIssueSprintCommand,
StartSprintCommand,
} from './sprint.commands';
import { IssueTree } from './issue-tree';
import { SetIssueParentCommand } from './set-issue-parent.command';
import { RankIssueCommand } from './rank-issue.command';
@@ -63,6 +70,10 @@ import { WriteAccess } from './write-access';
async function setup() {
const store = new InMemoryEventStore();
const sprints = {
active: null as { id: string; name: string } | null,
findActive: async () => sprints.active,
};
const files = new InMemoryAttachmentStorage();
const users = new FakeUserDirectory()
.add('owner')
@@ -92,6 +103,20 @@ async function setup() {
provide: TrackerStreamsRepository,
useValue: new InMemoryTrackerStreams(store),
},
// Read-model lookups: the sprint under way is set by each test.
{ provide: FindSprintsRepository, useValue: sprints },
{
provide: FindIssuesRepository,
useValue: {
sprintProgress: async () => ({
total: 0,
finished: 0,
percent: 0,
storyPoints: 0,
finishedStoryPoints: 0,
}),
},
},
],
}).compile();
await moduleRef.init();
@@ -103,6 +128,7 @@ async function setup() {
return {
store,
files,
sprints,
run,
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 () => {
const { run } = await setup();
const projectId = await projectWithTeam(run);

View File

@@ -33,3 +33,4 @@ export * from './set-issue-parent.command';
export * from './detach-from-parent.command';
export * from './change-checklist.command';
export * from './rank-issue.command';
export * from './sprint.commands';

View File

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

View File

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

View File

@@ -10,6 +10,7 @@ import {
Issue,
Project,
Release,
Sprint,
} from '@task-stream/api/tracker/domain';
import { UserDirectory } from '../ports/user-directory';
@@ -38,6 +39,22 @@ export class WriteAccess {
return this.load(id, Release);
}
sprint(id: string): Promise<Sprint> {
return this.load(id, Sprint);
}
/** Sprint of the same project that still takes tickets (not closed). */
async openSprintOf(project: Project, sprintId: string): Promise<Sprint> {
const sprint = await this.sprint(sprintId);
if (sprint.projectId !== project.id) {
throw new BusinessRuleError(
`Sprint ${sprint.name} belongs to another project`,
);
}
sprint.assertAcceptsIssues();
return sprint;
}
comment(id: string): Promise<Comment> {
return this.load(id, Comment);
}

View File

@@ -28,6 +28,12 @@ import {
ReleaseCreatedEvent,
ReleaseShippedEvent,
ReleaseUpdatedEvent,
IssueSprintChangedEvent,
SprintClosedEvent,
SprintCreatedEvent,
SprintDeletedEvent,
SprintStartedEvent,
SprintUpdatedEvent,
TrackerEvent,
} from '@task-stream/api/tracker/domain';
@@ -37,6 +43,8 @@ export interface SummaryContext {
issueKey: string;
projectKey: string;
releaseName: string;
/** '' when unknown (e.g. the sprint was deleted). */
sprintName: string;
/** The project's workflow: names of statuses and types. */
workflow: ProjectWorkflow;
}
@@ -146,6 +154,12 @@ export function describeEvent(
if (event instanceof IssueAttachmentRemovedEvent)
return `${actor} quitó el adjunto ${event.fileName} de ${issueKey}`;
if (event instanceof IssueSprintChangedEvent) {
const sprint = ctx.sprintName ? `el sprint ${ctx.sprintName}` : 'un sprint';
return event.sprintId
? `${actor} metió ${issueKey} en ${sprint}`
: `${actor} sacó ${issueKey} de ${sprint}`;
}
if (event instanceof IssueParentChangedEvent) {
return event.parentKey
? `${actor} colocó ${issueKey} bajo ${event.parentKey}`
@@ -178,5 +192,20 @@ export function describeEvent(
return `${actor} publicó la release ${ctx.releaseName} 🚀`;
if (event instanceof ReleaseArchivedEvent)
return `${actor} archivó la release ${ctx.releaseName}`;
const sprint = ctx.sprintName || 'un sprint';
if (event instanceof SprintCreatedEvent)
return `${actor} planificó el sprint ${event.sprint.name}`;
if (event instanceof SprintUpdatedEvent)
return `${actor} actualizó el sprint ${sprint}`;
if (event instanceof SprintStartedEvent)
return `${actor} empezó el sprint ${sprint} 🏁`;
if (event instanceof SprintClosedEvent) {
const left = event.unfinishedIssues
? `; ${event.unfinishedIssues} ticket(s) sin terminar pasan ${event.carryOverTo ? 'al siguiente sprint' : 'al backlog'}`
: '';
return `${actor} cerró el sprint ${sprint}${left}`;
}
if (event instanceof SprintDeletedEvent)
return `${actor} borró el sprint ${sprint}`;
return `${actor}: ${event.constructor.name}`;
}

View File

@@ -23,6 +23,12 @@ import {
ProjectMemberRoleChangedEvent,
ProjectUpdatedEvent,
ReleaseArchivedEvent,
IssueSprintChangedEvent,
SprintClosedEvent,
SprintCreatedEvent,
SprintDeletedEvent,
SprintStartedEvent,
SprintUpdatedEvent,
ReleaseCreatedEvent,
ReleaseShippedEvent,
ReleaseUpdatedEvent,
@@ -37,6 +43,7 @@ import { ChangeFeed } from '../ports/change-feed';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
import { UpsertMaterializedActivityRepository } from '../ports/upsert-materialized-activity.repository';
import { UserDirectory } from '../ports/user-directory';
import { describeEvent } from './activity-summary';
@@ -70,6 +77,12 @@ import { versionOf } from './read-model-notifier';
ReleaseUpdatedEvent,
ReleaseShippedEvent,
ReleaseArchivedEvent,
IssueSprintChangedEvent,
SprintCreatedEvent,
SprintUpdatedEvent,
SprintStartedEvent,
SprintClosedEvent,
SprintDeletedEvent,
)
export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
constructor(
@@ -77,6 +90,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
private readonly findProjects: FindProjectsRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findReleases: FindReleasesRepository,
private readonly findSprints: FindSprintsRepository,
private readonly users: UserDirectory,
private readonly changeFeed: ChangeFeed,
) {}
@@ -93,6 +107,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
const release = releaseId
? await this.findReleases.findById(releaseId)
: null;
const sprint = await this.sprintOf(event);
const project = await this.findProjects.findById(event.projectId);
// The project was deleted (cascade in progress): no feed to write to.
if (!project) return;
@@ -118,6 +133,7 @@ export class ActivityEventHandler implements IEventHandler<TrackerEvent> {
issueKey: issueKey ?? '',
projectKey: project?.key ?? '',
releaseName: release?.name ?? '',
sprintName: sprint,
workflow: new ProjectWorkflow(
project?.settings ?? DEFAULT_PROJECT_SETTINGS,
),
@@ -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 {
if (event.constructor.name.startsWith('Issue')) return event.aggregateId;
if (event instanceof CommentAddedEvent) return event.comment.issueId;

View File

@@ -31,6 +31,8 @@ import { IssueWatchersEventHandler } from './issue-watchers.event-handler';
import { IssueParentChangedEventHandler } from './issue-parent-changed.event-handler';
import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
import { IssueRankedEventHandler } from './issue-ranked.event-handler';
import { SprintEventHandler } from './sprint.event-handler';
import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler';
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
export const EVENT_HANDLERS = [
@@ -66,6 +68,8 @@ export const EVENT_HANDLERS = [
IssueParentChangedEventHandler,
IssueChecklistEventHandler,
IssueRankedEventHandler,
SprintEventHandler,
IssueSprintChangedEventHandler,
];
export { ReadModelNotifier };

View File

@@ -50,6 +50,7 @@ export class IssueCreatedEventHandler
assignee: users.ofNullable(issue.assigneeId),
release: release ? { id: release.id, name: release.name } : null,
parent: null,
sprint: null,
rank: implicitRank(issue.number),
labels: issue.labels,
figmaUrl: issue.figmaUrl,

View File

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

View File

@@ -3,6 +3,7 @@ import {
DEFAULT_PROJECT_SETTINGS,
IssueCreatedEvent,
IssueReleaseChangedEvent,
IssueSprintChangedEvent,
IssueStatusChangedEvent,
ProjectWorkflow,
TrackerEvent,
@@ -14,12 +15,13 @@ import { versionOf } from './read-model-notifier';
/**
* The life of every ticket, step by step: where it went (status + category)
* and which release it joined. Statistics are computed from it.
* and which release and sprint it joined. Statistics are computed from it.
*/
@EventsHandler(
IssueCreatedEvent,
IssueStatusChangedEvent,
IssueReleaseChangedEvent,
IssueSprintChangedEvent,
)
export class IssueTimelineEventHandler implements IEventHandler<TrackerEvent> {
constructor(
@@ -66,6 +68,14 @@ export class IssueTimelineEventHandler implements IEventHandler<TrackerEvent> {
kind: 'release',
releaseId: event.releaseId,
});
return;
}
if (event instanceof IssueSprintChangedEvent) {
await this.timeline.record({
...base,
kind: 'sprint',
sprintId: event.sprintId,
});
}
}

View File

@@ -5,6 +5,7 @@ import { FindCommentsRepository } from '../ports/find-comments.repository';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { FindSprintsRepository } from '../ports/find-sprints.repository';
/** Position of the event in its stream (stamped by the event store). */
export function versionOf(event: TrackerEvent): number {
@@ -23,6 +24,7 @@ export class ReadModelNotifier {
private readonly findIssues: FindIssuesRepository,
private readonly findComments: FindCommentsRepository,
private readonly findReleases: FindReleasesRepository,
private readonly findSprints: FindSprintsRepository,
) {}
async project(
@@ -63,6 +65,16 @@ export class ReadModelNotifier {
await this.changeFeed.publishRelease({ ...this.meta(event), release });
}
async sprint(sprintId: string, event: TrackerEvent): Promise<void> {
const sprint = await this.findSprints.findById(sprintId);
await this.changeFeed.publishSprint({
...this.meta(event),
sprintId,
projectId: event.projectId,
sprint,
});
}
private meta(event: TrackerEvent) {
return {
action: event.constructor.name,

View File

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

View File

@@ -5,6 +5,7 @@ import {
IssueReadModel,
ProjectReadModel,
ReleaseReadModel,
SprintReadModel,
} from '@task-stream/api/tracker/domain';
/** Every realtime message says which event caused it and the resulting version. */
@@ -30,6 +31,13 @@ export interface ReleaseChange extends Change {
release: ReleaseReadModel;
}
export interface SprintChange extends Change {
/** Null when the sprint was deleted. */
sprint: SprintReadModel | null;
sprintId: string;
projectId: string;
}
export interface ProjectChange extends Change {
project: ProjectReadModel;
}
@@ -43,6 +51,7 @@ export abstract class ChangeFeed {
abstract publishComment(change: CommentChange): Promise<void>;
abstract publishActivity(activity: ActivityReadModel): Promise<void>;
abstract publishRelease(change: ReleaseChange): Promise<void>;
abstract publishSprint(change: SprintChange): Promise<void>;
/** @param notifyUserIds users whose project list changed (joined / left). */
abstract publishProject(
change: ProjectChange,
@@ -59,6 +68,7 @@ export abstract class ChangeFeed {
projectId: string,
): AsyncIterableIterator<ActivityReadModel>;
abstract releases(projectId: string): AsyncIterableIterator<ReleaseChange>;
abstract sprints(projectId: string): AsyncIterableIterator<SprintChange>;
abstract project(projectId: string): AsyncIterableIterator<ProjectChange>;
abstract userProjects(userId: string): AsyncIterableIterator<ProjectChange>;
abstract notifications(

View File

@@ -4,6 +4,7 @@ import {
IssueReadModel,
ProjectStatsReadModel,
ReleaseProgressReadModel,
SprintProgressReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
@@ -26,6 +27,9 @@ export interface IssueFilter {
labels?: string[];
/** Only the tickets hanging from this one. */
parentId?: string;
sprintId?: string;
/** Only tickets in no sprint (wins over `sprintId`). */
noSprint?: boolean;
/** RECENT (default): last updated first. RANK: backlog order. */
sort?: IssueSort;
search?: string;
@@ -63,6 +67,7 @@ export abstract class FindIssuesRepository {
abstract findByProject(projectId: string): Promise<IssueReadModel[]>;
/** Top-level tickets with others hanging from them, by key. */
abstract epics(projectId: string): Promise<EpicReadModel[]>;
abstract sprintProgress(sprintId: string): Promise<SprintProgressReadModel>;
abstract releaseProgress(
releaseId: string,
): Promise<ReleaseProgressReadModel>;

View File

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

View File

@@ -21,3 +21,5 @@ export * from './attachment-storage';
export * from './notifications.repository';
export * from './notification-mailer';
export * from './saved-filters.repository';
export * from './find-sprints.repository';
export * from './upsert-materialized-sprint.repository';

View File

@@ -7,6 +7,9 @@
export abstract class TrackerStreamsRepository {
abstract issueIdsOfProject(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[]>;
/** Tickets that were ever linked to `issueId` (they may have unlinked since). */
abstract issueIdsLinkingTo(issueId: string): Promise<string[]>;

View File

@@ -60,6 +60,8 @@ export abstract class UpsertMaterializedIssueRepository {
): Promise<void>;
/** The ticket was deleted: its view, its timeline and the links shown on other tickets go. */
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. */
abstract renameRelease(releaseId: string, name: string): Promise<void>;
}

View File

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

View File

@@ -18,3 +18,4 @@ export * from './search-my-issues.query';
export * from './get-public-projects.query';
export * from './get-attachment.query';
export * from './get-epics.query';
export * from './sprint.queries';

View File

@@ -17,6 +17,7 @@ import { GetReleasesQueryHandler } from './get-releases.query-handler';
import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler';
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
import { GetEpicsQueryHandler } from './get-epics.query-handler';
import { SPRINT_QUERY_HANDLERS } from './sprint.queries';
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
export const QUERY_HANDLERS = [
@@ -38,6 +39,7 @@ export const QUERY_HANDLERS = [
GetUpcomingReleasesQueryHandler,
SearchIssuesQueryHandler,
GetEpicsQueryHandler,
...SPRINT_QUERY_HANDLERS,
SearchMyIssuesQueryHandler,
GetAttachmentQueryHandler,
];

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

View File

@@ -9,6 +9,7 @@ import { DeleteIssueCommand } from '../commands/delete-issue.command';
import { DeleteReleaseCommand } from '../commands/delete-release.command';
import { DetachFromParentCommand } from '../commands/detach-from-parent.command';
import { DropLinksToIssueCommand } from '../commands/drop-links-to-issue.command';
import { DeleteSprintCommand } from '../commands/sprint.commands';
import { PurgeCommentCommand } from '../commands/purge-comment.command';
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
@@ -17,6 +18,7 @@ import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
* with its own "deleted" event):
*
* ProjectDeleted → DeleteIssue (each ticket) + DeleteRelease (each release)
* + DeleteSprint (each sprint, as a cascade)
* IssueDeleted → PurgeComment (each comment) + DropLinksToIssue (tickets
* of other projects that pointed to it) + DetachFromParent
* (tickets that hung from it go back to the top)
@@ -34,11 +36,14 @@ export class ProjectDeletionSaga {
ofType(ProjectDeletedEvent),
mergeMap((event) =>
from(this.contentsOf(event.projectId)).pipe(
mergeMap(({ issueIds, releaseIds }) => [
mergeMap(({ issueIds, releaseIds, sprintIds }) => [
...issueIds.map((id) => new DeleteIssueCommand(event.actorId, id)),
...releaseIds.map(
(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) {
const [issueIds, releaseIds] = await Promise.all([
const [issueIds, releaseIds, sprintIds] = await Promise.all([
this.streams.issueIdsOfProject(projectId),
this.streams.releaseIdsOfProject(projectId),
this.streams.sprintIdsOfProject(projectId),
]);
return { issueIds, releaseIds };
return { issueIds, releaseIds, sprintIds };
}
private async dependentsOf(issueId: string) {

View File

@@ -47,6 +47,15 @@ import { LinkIssuesCommand } from '../commands/link-issues.command';
import { AssignIssueCommand } from '../commands/assign-issue.command';
import { WriteAccess } from '../commands/write-access';
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 { IssueNumbersRepository } from '../ports/issue-numbers.repository';
import { ProjectKeysRepository } from '../ports/project-keys.repository';
@@ -73,6 +82,7 @@ async function setup() {
ProjectDeletionSaga,
ReleaseArchivedSaga,
UserDeactivatedSaga,
SprintCarryOverSaga,
WriteAccess,
IssueTree,
AggregateRehydrator,
@@ -90,11 +100,22 @@ async function setup() {
provide: TrackerStreamsRepository,
useValue: new InMemoryTrackerStreams(store),
},
{
provide: FindSprintsRepository,
useValue: { findActive: async () => null },
},
{
provide: FindIssuesRepository,
useValue: {
findOpenAssignedTo: async (userId: string) =>
(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(ReleaseArchivedSaga)),
...Object.values(moduleRef.get(UserDeactivatedSaga)),
...Object.values(moduleRef.get(SprintCarryOverSaga)),
].filter((value): value is ISaga => typeof value === 'function');
/** 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();
});
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 () => {
const { run } = await setup();
const web = (await run(new CreateProjectCommand('owner', 'WEB', 'Web'))).id;

View File

@@ -2,6 +2,7 @@ import { ProjectDeletionSaga } from './project-deletion.saga';
import { ReleaseArchivedSaga } from './release-archived.saga';
import { UserDeactivatedSaga } from './user-deactivated.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). */
export const SAGAS = [
@@ -9,4 +10,5 @@ export const SAGAS = [
ReleaseArchivedSaga,
UserDeactivatedSaga,
CommentersFollowSaga,
SprintCarryOverSaga,
];

View File

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

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

View File

@@ -31,6 +31,7 @@ import {
import { SAVED_FILTERS } from './saved-filters/saved-filters';
import { SavedFiltersService } from './saved-filters.service';
import { SprintsService } from './sprints.service';
/** Notifications: derived from events, kept and dismissed per person. */
const NOTIFICATIONS = [
@@ -51,6 +52,7 @@ const SERVICES = [
WorkflowsService,
NotificationsService,
SavedFiltersService,
SprintsService,
];
/**

View File

@@ -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) {
return this.streams(
'CommentAddedEvent',

View File

@@ -11,6 +11,8 @@ export * from './lib/project/project-settings';
export * from './lib/comment/comment';
export * from './lib/release/release';
export * from './lib/release/release-status';
export * from './lib/sprint/sprint';
export * from './lib/sprint/sprint-status';
export * from './lib/events';
export * from './lib/factories';
export * from './lib/read-models';

View File

@@ -31,3 +31,9 @@ export * from './issue-checklist-item-added.event';
export * from './issue-checklist-item-updated.event';
export * from './issue-checklist-item-removed.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';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,13 +2,15 @@ import { CommentFactory } from './comment.factory';
import { IssueFactory } from './issue.factory';
import { ProjectFactory } from './project.factory';
import { ReleaseFactory } from './release.factory';
import { SprintFactory } from './sprint.factory';
export * from './issue.factory';
export { CommentFactory, ProjectFactory, ReleaseFactory };
export { CommentFactory, ProjectFactory, ReleaseFactory, SprintFactory };
export const TRACKER_FACTORIES = [
ProjectFactory,
IssueFactory,
CommentFactory,
ReleaseFactory,
SprintFactory,
];

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

View File

@@ -16,6 +16,7 @@ import { IssueLinkedEvent } from '../events/issue-linked.event';
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
import { IssueRankedEvent } from '../events/issue-ranked.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 { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
import { IssueUpdatedEvent } from '../events/issue-updated.event';
@@ -112,6 +113,7 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public checklist: ChecklistItem[] = [];
/** Place in the backlog once someone moved it (see `backlogRank`). */
public rank: string | null = null;
public sprintId: string | null = null;
public createdAt!: string;
public updatedAt!: string;
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). */
attach(actorId: string, attachment: IssueAttachment): void {
const next = normalizeAttachment(attachment);
@@ -519,6 +535,11 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
this.deleted = true;
}
onIssueSprintChangedEvent(event: IssueSprintChangedEvent): void {
this.sprintId = event.sprintId;
this.updatedAt = event.occurredAt;
}
onIssueReleaseChangedEvent(event: IssueReleaseChangedEvent): void {
this.releaseId = event.releaseId;
this.updatedAt = event.occurredAt;

View File

@@ -12,12 +12,13 @@ export class IssueTimelineEntry {
issueId!: string;
projectId!: string;
at!: string;
kind!: 'status' | 'release';
kind!: 'status' | 'release' | 'sprint';
status?: string;
category?: StatusCategory;
/** Assignee when the status changed (throughput per person). */
assigneeId?: string | null;
releaseId?: string | null;
sprintId?: string | null;
}
export class DurationStats {

View File

@@ -8,3 +8,4 @@ export * from './project-stats.read-model';
export * from './analytics.read-model';
export * from './notification.read-model';
export * from './saved-filter.read-model';
export * from './sprint.read-model';

View File

@@ -70,6 +70,7 @@ export class IssueReadModel {
reporter!: UserRefReadModel;
assignee!: UserRefReadModel | null;
release!: { id: string; name: string } | null;
sprint!: { id: string; name: string } | null;
labels!: string[];
figmaUrl!: string | null;
storyPoints!: number | null;

View File

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

View 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',
}

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

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

View File

@@ -24,6 +24,7 @@ import {
ProjectRole,
ProjectStatsReadModel,
ReleaseProgressReadModel,
SprintProgressReadModel,
ReleaseReadModel,
ReleaseStatus,
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> {
const rows = await this.issues.aggregate<{
_id: { status: string; category: StatusCategory };
@@ -322,6 +354,8 @@ function toMongoFilter(filter: IssueFilter): FilterQuery<IssueDoc> {
query.labels = { $all: filter.labels.map((l) => l.toLowerCase()) };
if (filter.releaseId) query['release.id'] = filter.releaseId;
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;
else if (filter.assigneeId) query['assignee.id'] = filter.assigneeId;
if (filter.search?.trim()) {

View File

@@ -8,6 +8,7 @@ import {
ProjectReadModel,
ReleaseReadModel,
SavedFilterReadModel,
SprintReadModel,
implicitRank,
} from '@task-stream/api/tracker/domain';
import { Schema } from 'mongoose';
@@ -24,6 +25,7 @@ export type IssueDoc = Doc<Omit<IssueReadModel, 'links'>> & {
};
export type CommentDoc = Doc<CommentReadModel>;
export type ReleaseDoc = Doc<ReleaseReadModel>;
export type SprintDoc = Doc<SprintReadModel>;
export type ActivityDoc = Doc<ActivityReadModel>;
export type TimelineDoc = Doc<IssueTimelineEntry>;
export type NotificationDoc = Doc<NotificationReadModel>;
@@ -43,6 +45,7 @@ export const ReadModels = {
Notification: 'NotificationReadModel',
NotificationSettings: 'NotificationSettings',
SavedFilter: 'SavedFilter',
Sprint: 'SprintReadModel',
} as const;
const userRef = { _id: false, id: String, name: String };
@@ -79,6 +82,7 @@ export const IssueSchema = new Schema<IssueDoc>(
reporter: userRef,
assignee: userRef,
release: { _id: false, id: String, name: String },
sprint: { _id: false, id: String, name: String },
outwardLinks: [link],
inwardLinks: [link],
watchers: [userRef],
@@ -108,6 +112,7 @@ IssueSchema.index({ 'watchers.id': 1 });
IssueSchema.index({ 'parent.id': 1 });
IssueSchema.index({ projectId: 1, rank: 1 });
IssueSchema.index({ 'release.id': 1 });
IssueSchema.index({ 'sprint.id': 1 });
IssueSchema.index({ title: 'text', description: 'text', key: 'text' });
export const CommentSchema = new Schema<CommentDoc>(
@@ -126,6 +131,16 @@ export const ReleaseSchema = new Schema<ReleaseDoc>(
{ ...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>(
{
_id: String,
@@ -182,6 +197,7 @@ export const READ_MODEL_SCHEMAS = [
{ name: ReadModels.Issue, schema: IssueSchema },
{ name: ReadModels.Comment, schema: CommentSchema },
{ name: ReadModels.Release, schema: ReleaseSchema },
{ name: ReadModels.Sprint, schema: SprintSchema },
{ name: ReadModels.Activity, schema: ActivitySchema },
{ name: ReadModels.Timeline, schema: TimelineSchema },
{ name: ReadModels.Notification, schema: NotificationSchema },
@@ -207,6 +223,7 @@ export function issueFromDoc(doc: IssueDoc): IssueReadModel {
watchers: rest.watchers ?? [],
parent: rest.parent?.id ? rest.parent : null,
rank: rest.rank ?? implicitRank(rest.number),
sprint: rest.sprint?.id ? rest.sprint : null,
checklist: rest.checklist ?? [],
};
}

View File

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

View File

@@ -230,6 +230,13 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
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) {
await this.issues.updateMany(
{ 'release.id': releaseId },

View File

@@ -5,7 +5,9 @@ import {
IssueCreatedEvent,
IssueLinkedEvent,
IssueParentChangedEvent,
IssueSprintChangedEvent,
ReleaseCreatedEvent,
SprintCreatedEvent,
} from '@task-stream/api/tracker/domain';
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[]> {
return this.streams(
CommentAddedEvent.name,

View File

@@ -6,6 +6,7 @@ import {
IssueChange,
ProjectChange,
ReleaseChange,
SprintChange,
} from '@task-stream/api/tracker/application';
import {
ActivityReadModel,
@@ -19,6 +20,7 @@ const Channels = {
comment: (issueId: string) => `tracker.comment.${issueId}`,
activity: (projectId: string) => `tracker.activity.${projectId}`,
release: (projectId: string) => `tracker.release.${projectId}`,
sprint: (projectId: string) => `tracker.sprint.${projectId}`,
project: (projectId: string) => `tracker.project.${projectId}`,
userProjects: (userId: string) => `tracker.user-projects.${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(
change: ProjectChange,
notifyUserIds: string[] = [],
@@ -88,6 +94,10 @@ export class RedisChangeFeed extends ChangeFeed {
return this.pubSub.asyncIterableIterator(Channels.release(projectId));
}
sprints(projectId: string): AsyncIterableIterator<SprintChange> {
return this.pubSub.asyncIterableIterator(Channels.sprint(projectId));
}
project(projectId: string): AsyncIterableIterator<ProjectChange> {
return this.pubSub.asyncIterableIterator(Channels.project(projectId));
}

View File

@@ -13,6 +13,8 @@ import {
FindIssuesRepository,
FindProjectsRepository,
FindReleasesRepository,
FindSprintsRepository,
UpsertMaterializedSprintRepository,
FindTimelineRepository,
IssueNumbersRepository,
ProjectKeysRepository,
@@ -58,6 +60,10 @@ import { PostgresTrackerStreamsRepository } from './postgres/tracker-streams.rep
import { RedisChangeFeed } from './realtime/redis-change-feed';
import { MongoNotificationsRepository } from './mongo/notifications.repository';
import { MongoSavedFiltersRepository } from './mongo/saved-filters.repository';
import {
MongoFindSprintsRepository,
MongoUpsertMaterializedSprintRepository,
} from './mongo/sprints.repositories';
import { SmtpNotificationMailer } from './mail/smtp-notification.mailer';
import { FileSystemAttachmentStorage } from './storage/file-system-attachment.storage';
@@ -79,6 +85,11 @@ const PORTS = [
{ provide: FindIssuesRepository, useClass: MongoFindIssuesRepository },
{ provide: FindCommentsRepository, useClass: MongoFindCommentsRepository },
{ provide: FindReleasesRepository, useClass: MongoFindReleasesRepository },
{ provide: FindSprintsRepository, useClass: MongoFindSprintsRepository },
{
provide: UpsertMaterializedSprintRepository,
useClass: MongoUpsertMaterializedSprintRepository,
},
{ provide: FindActivityRepository, useClass: MongoFindActivityRepository },
{ provide: FindTimelineRepository, useClass: MongoFindTimelineRepository },
{

View File

@@ -6,6 +6,7 @@ import {
NotificationKind,
ProjectRole,
ReleaseStatus,
SprintStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain';
@@ -23,6 +24,10 @@ registerEnumType(StatusCategory, {
registerEnumType(IssueLinkType, { name: 'IssueLinkType' });
registerEnumType(ProjectRole, { name: 'ProjectRole' });
registerEnumType(ReleaseStatus, { name: 'ReleaseStatus' });
registerEnumType(SprintStatus, {
name: 'SprintStatus',
description: 'PLANNED, ACTIVE (one per project at a time) or CLOSED',
});
registerEnumType(LinkDirection, { name: 'LinkDirection' });
registerEnumType(IssueSort, {
name: 'IssueSort',

View File

@@ -181,6 +181,41 @@ export class ChangeIssueStatusInput {
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()
export class SavedFilterCriteriaInput {
@Field(() => [String], { nullable: true })
@@ -320,6 +355,14 @@ export class IssueFilterInput {
@IsOptional()
@IsUUID()
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 })
@IsOptional()
@IsEnum(IssueSort)

View File

@@ -125,6 +125,8 @@ export class ProjectResolver {
search?: string | null,
@Args('unassigned', { type: () => Boolean, nullable: true })
unassigned?: boolean | null,
@Args('sprintId', { type: () => ID, nullable: true })
sprintId?: string | null,
) {
return this.projects.board(
viewerOf(me),
@@ -138,6 +140,7 @@ export class ProjectResolver {
labels: labels ?? undefined,
search: search?.slice(0, 200) ?? undefined,
unassigned: unassigned ?? undefined,
sprintId: sprintId ?? undefined,
},
perColumn,
);

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

View File

@@ -12,6 +12,7 @@ import {
NotificationGql,
ProjectChangeGql,
ReleaseChangeGql,
SprintChangeGql,
} from './types';
const asIs = { resolve: <T>(payload: T) => payload };
@@ -66,6 +67,14 @@ export class SubscriptionResolver {
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)
projectChanged(
@CurrentUser() me: User,

View File

@@ -5,6 +5,7 @@ import {
NotificationKind,
ProjectRole,
ReleaseStatus,
SprintStatus,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { LinkDirection } from './enums';
@@ -110,6 +111,12 @@ export class AttachmentGql {
commentId!: string | null;
}
@ObjectType('SprintRef')
export class SprintRefGql {
@Field(() => ID) id!: string;
@Field() name!: string;
}
@ObjectType('ReleaseRef')
export class ReleaseRefGql {
@Field(() => ID) id!: string;
@@ -154,6 +161,7 @@ export class IssueGql {
@Field(() => UserRefGql, { nullable: true }) assignee!: UserRefGql | null;
@Field(() => ReleaseRefGql, { nullable: true })
release!: ReleaseRefGql | null;
@Field(() => SprintRefGql, { nullable: true }) sprint!: SprintRefGql | null;
@Field(() => [String]) labels!: string[];
@Field(() => String, { nullable: true }) figmaUrl!: string | null;
@Field(() => Int, { nullable: true }) storyPoints!: number | null;
@@ -270,6 +278,56 @@ export class ReleaseGql {
@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')
export class CountByStatusGql {
@Field({ description: 'Status id' }) key!: string;

View File

@@ -285,6 +285,11 @@ export class BoardQueryDto {
@IsBoolean()
unassigned?: boolean;
@ApiPropertyOptional({ format: 'uuid', description: 'Only this sprint' })
@IsOptional()
@IsUUID()
sprintId?: string;
@ApiPropertyOptional({ type: Number, default: 50, minimum: 1, maximum: 200 })
@IsOptional()
@Type(() => Number)
@@ -451,6 +456,17 @@ export class IssueSearchQueryDto extends PageQueryDto {
@IsString()
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({
enum: IssueSort,
enumName: 'IssueSort',
@@ -819,6 +835,66 @@ export class 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 {
@ApiPropertyOptional({
type: Boolean,

View File

@@ -5,6 +5,7 @@ import {
NotificationKind,
ProjectRole,
ReleaseStatus,
SprintStatus,
SETTING_COLORS,
StatusCategory,
TYPE_ICONS,
@@ -135,6 +136,11 @@ export class IssueParentDto {
@ApiProperty({ example: 'Pago con tarjeta' }) title!: string;
}
export class SprintRefDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ example: 'Sprint 14' }) name!: string;
}
export class IssueDto {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ format: 'uuid' }) projectId!: string;
@@ -163,6 +169,8 @@ export class IssueDto {
assignee!: UserRefDto | null;
@ApiProperty({ type: ReleaseRefDto, nullable: true })
release!: ReleaseRefDto | null;
@ApiProperty({ type: SprintRefDto, nullable: true })
sprint!: SprintRefDto | null;
@ApiProperty({ type: [String] }) labels!: string[];
@ApiProperty({ type: String, nullable: true, description: 'Figma frame' })
figmaUrl!: string | null;
@@ -255,6 +263,50 @@ export class CommentPageDto {
@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 {
@ApiProperty({ format: 'uuid' }) id!: string;
@ApiProperty({ format: 'uuid' }) projectId!: string;

View File

@@ -157,6 +157,8 @@ export class IssuesController {
unassigned: query.unassigned,
releaseId: query.releaseId,
labels: query.label,
sprintId: query.sprintId,
noSprint: query.noSprint,
sort: query.sort,
search: query.search,
limit: query.limit,

View File

@@ -187,6 +187,7 @@ export class ProjectsController {
labels: query.label,
search: query.search,
unassigned: query.unassigned,
sprintId: query.sprintId,
},
query.perColumn,
);

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

View File

@@ -18,6 +18,8 @@ import { ReleasesController } from './http/releases.controller';
import { NotificationsController } from './http/notifications.controller';
import { SavedFiltersController } from './http/saved-filters.controller';
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,
@@ -48,6 +50,7 @@ import { SavedFilterResolver } from './graphql/saved-filter.resolver';
ExploreController,
NotificationsController,
SavedFiltersController,
SprintsController,
],
providers: [
Refs,
@@ -58,6 +61,7 @@ import { SavedFilterResolver } from './graphql/saved-filter.resolver';
SubscriptionResolver,
NotificationResolver,
SavedFilterResolver,
SprintResolver,
],
})
export class TrackerPresentersModule {

View File

@@ -26,6 +26,10 @@ export const issuePageEvents = eventGroup({
targetKey: string;
}>(),
unlinkRequested: type<LinkIssuesInput>(),
sprintChangeRequested: type<{
issueId: string;
sprintId: string | null;
}>(),
releaseChangeRequested: type<{
issueId: string;
releaseId: string | null;

View File

@@ -10,6 +10,10 @@ import {
ProjectStats,
Release,
ReleaseBurnup,
CreateSprintInput,
Sprint,
SprintBurndown,
UpdateSprintInput,
UserDirectoryEntry,
} from '@task-stream/web/tracker/domain';
@@ -23,6 +27,15 @@ export const projectPageEvents = eventGroup({
releaseCreateRequested: type<CreateReleaseInput>(),
releasePublishRequested: type<{ releaseId: string; force: boolean }>(),
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>(),
settingsSaveRequested: type<{
projectId: string;
@@ -42,6 +55,8 @@ export const projectApiEvents = eventGroup({
loadFailed: type<string>(),
statsLoaded: type<ProjectStats>(),
releasesLoaded: type<Release[]>(),
sprintsLoaded: type<Sprint[]>(),
burndownLoaded: type<SprintBurndown>(),
activityLoaded: type<Activity[]>(),
commandSucceeded: type<string>(),
commandFailed: type<string>(),
@@ -63,5 +78,6 @@ export const projectRealtimeEvents = eventGroup({
projectDeleted: type<Project>(),
activityAdded: type<Activity>(),
releaseChanged: type<Release>(),
sprintChanged: type<{ sprintId: string }>(),
},
});

View File

@@ -13,6 +13,7 @@ import {
ProjectsGateway,
ReleasesGateway,
SavedFiltersGateway,
SprintsGateway,
TrackerRealtimeGateway,
UsersGateway,
} from '@task-stream/web/tracker/domain';
@@ -70,6 +71,14 @@ function gateways() {
download: vi.fn(() => of(new Blob(['x']))),
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: {
list: vi.fn(() => of([])),
save: vi.fn(() => of('v1')),
@@ -80,6 +89,7 @@ function gateways() {
commentChanges: () => NEVER,
activity: () => NEVER,
releaseChanges: () => NEVER,
sprintChanges: () => NEVER,
projectChanges: vi.fn((): unknown => NEVER),
},
};
@@ -113,6 +123,7 @@ function setup() {
{ provide: UsersGateway, useValue: { search: () => of([]) } },
{ provide: TrackerRealtimeGateway, useValue: fakes.realtime },
{ provide: SavedFiltersGateway, useValue: fakes.views },
{ provide: SprintsGateway, useValue: fakes.sprints },
{
provide: Notifications,
useValue: { success: vi.fn(), error: vi.fn() },
@@ -219,6 +230,28 @@ describe('ProjectFacade', () => {
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', () => {
const { project, fakes, navigate, notifications } = setup();
project.open('WEB');

View File

@@ -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 {
this.withIssue((issueId) =>
this.dispatch.releaseChangeRequested({ issueId, releaseId }),

View File

@@ -2,8 +2,11 @@ import { computed, inject, Injectable, Provider } from '@angular/core';
import { injectDispatch } from '@ngrx/signals/events';
import {
CreateReleaseInput,
CreateSprintInput,
ProjectRole,
ProjectSettings,
SprintStatus,
UpdateSprintInput,
} from '@task-stream/web/tracker/domain';
import { projectPageEvents } from '../events/project.events';
import { ProjectStore } from '../stores/project.store';
@@ -21,6 +24,20 @@ export class ProjectFacade {
readonly members = this.store.members;
readonly stats = this.store.stats;
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;
/** Pireon directory results for `searchUsers()`. */
readonly userResults = this.store.userResults;
@@ -80,6 +97,33 @@ export class ProjectFacade {
}
/** `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 {
this.dispatch.releasePublishRequested({ releaseId, force });
}

View File

@@ -19,6 +19,7 @@ import {
Issue,
IssuesGateway,
IssueSummary,
SprintsGateway,
TrackerRealtimeGateway,
} from '@task-stream/web/tracker/domain';
import {
@@ -122,6 +123,7 @@ export const IssueStore = signalStore(
realtime = inject(TrackerRealtimeGateway),
notifications = inject(Notifications),
attachments = inject(AttachmentsGateway),
sprints = inject(SprintsGateway),
) => {
/** One file after another: the ticket's version moves with each. */
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
.on(issuePageEvents.releaseChangeRequested)
.pipe(

View File

@@ -19,6 +19,9 @@ import {
Release,
ReleaseBurnup,
ReleasesGateway,
Sprint,
SprintBurndown,
SprintsGateway,
TrackerRealtimeGateway,
UserDirectoryEntry,
UsersGateway,
@@ -50,6 +53,10 @@ interface ProjectState {
project: Project | null;
stats: ProjectStats | null;
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[];
userResults: UserDirectoryEntry[];
analytics: ProjectAnalytics | null;
@@ -71,6 +78,8 @@ export const ProjectStore = signalStore(
project: null,
stats: null,
releases: [],
sprints: [],
burndown: null,
activity: [],
userResults: [],
analytics: null,
@@ -109,6 +118,8 @@ export const ProjectStore = signalStore(
project: null,
stats: null,
releases: [],
sprints: [],
burndown: null,
activity: [],
analytics: null,
analyticsError: null,
@@ -153,6 +164,10 @@ export const ProjectStore = signalStore(
error: payload,
})),
on(projectApiEvents.statsLoaded, ({ payload }) => ({ stats: payload })),
on(projectApiEvents.sprintsLoaded, ({ payload }) => ({ sprints: payload })),
on(projectApiEvents.burndownLoaded, ({ payload }) => ({
burndown: payload,
})),
on(projectApiEvents.releasesLoaded, ({ payload }) => ({
releases: payload,
})),
@@ -175,6 +190,7 @@ export const ProjectStore = signalStore(
events = inject(Events),
projects = inject(ProjectsGateway),
releases = inject(ReleasesGateway),
sprints = inject(SprintsGateway),
activity = inject(ActivityGateway),
realtime = inject(TrackerRealtimeGateway),
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 {
load$: events.on(projectPageEvents.opened).pipe(
switchMap(({ payload }) =>
@@ -216,6 +249,7 @@ export const ProjectStore = signalStore(
.stats(payload.id)
.pipe(map((stats) => projectApiEvents.statsLoaded(stats))),
reloadReleases(payload.id),
reloadSprints(payload.id),
activity
.list(payload.id)
.pipe(map((list) => projectApiEvents.activityLoaded(list))),
@@ -240,6 +274,9 @@ export const ProjectStore = signalStore(
realtime
.releaseChanges(payload.id)
.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) : [];
}),
),
/** 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(
switchMap(({ payload }) =>
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
.on(projectPageEvents.releasePublishRequested)
.pipe(

View File

@@ -39,6 +39,13 @@ export enum ReleaseStatus {
ARCHIVED = 'ARCHIVED',
}
export enum SprintStatus {
PLANNED = 'PLANNED',
/** Under way: one per project at a time. */
ACTIVE = 'ACTIVE',
CLOSED = 'CLOSED',
}
/** Why someone is told. */
export enum NotificationKind {
ASSIGNED = 'ASSIGNED',

View File

@@ -86,4 +86,22 @@ export interface BoardFilter {
labels?: string[];
/** Matches key, title or description. */
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;
}

View File

@@ -4,6 +4,7 @@ import {
IssuePriority,
ProjectRole,
ReleaseStatus,
SprintStatus,
StatusCategory,
} from './enums';
import { ProjectSettings } from './workflow';
@@ -107,6 +108,7 @@ export interface IssueSummary {
priority: IssuePriority;
assignee: UserRef | null;
release: { id: string; name: string } | null;
sprint: { id: string; name: string } | null;
labels: string[];
/** The epic or ticket this one breaks down. */
parent: IssueParent | null;
@@ -159,6 +161,40 @@ export interface Comment {
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 {
id: string;
projectId: string;

View File

@@ -6,6 +6,8 @@ import {
Comment,
Epic,
SavedFilter,
Sprint,
SprintBurndown,
SavedFilterCriteria,
CommandResult,
Issue,
@@ -32,6 +34,8 @@ import {
MyIssuesFilter,
UpdateIssueInput,
UpdateProjectInput,
CreateSprintInput,
UpdateSprintInput,
} from '../models/inputs';
/*
@@ -173,6 +177,25 @@ export abstract class ReleasesGateway {
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 {
abstract list(projectId: string, issueId?: string): Observable<Activity[]>;
}
@@ -219,6 +242,8 @@ export abstract class TrackerRealtimeGateway {
abstract commentChanges(issueId: string): Observable<CommentChanged>;
abstract activity(projectId: string): Observable<Activity>;
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 myProjectsChanges(): Observable<ProjectChanged>;
abstract notifications(): Observable<Notification>;

View File

@@ -59,6 +59,7 @@ export class GraphqlIssuesGateway extends IssuesGateway {
labels: filter.labels?.length ? filter.labels : null,
search: filter.search?.trim() || null,
unassigned: filter.unassigned ?? null,
sprintId: filter.sprintId ?? null,
})
.pipe(
map((d) =>

View File

@@ -18,6 +18,7 @@ import {
NotificationAddedDocument,
ProjectChangedDocument,
ReleaseChangedDocument,
SprintChangedDocument,
} from '../graphql/generated';
import { GraphqlOperations } from './graphql-operations';
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> {
return this.signedIn(() =>
this.gql

View File

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

View File

@@ -69,6 +69,10 @@ fragment IssueSummaryFields on Issue {
id
name
}
sprint {
id
name
}
parent {
id
key

File diff suppressed because it is too large Load Diff

View File

@@ -8,6 +8,7 @@ query Board(
$labels: [String!]
$search: String
$unassigned: Boolean
$sprintId: ID
) {
project(idOrKey: $idOrKey) {
id
@@ -20,6 +21,7 @@ query Board(
labels: $labels
search: $search
unassigned: $unassigned
sprintId: $sprintId
perColumn: 100
) {
status

View File

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

View File

@@ -11,6 +11,7 @@ import {
ProjectsGateway,
ReleasesGateway,
SavedFiltersGateway,
SprintsGateway,
TrackerRealtimeGateway,
UsersGateway,
} 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 { GraphqlRealtimeGateway } from './adapters/graphql-realtime.gateway';
import { GraphqlSavedFiltersGateway } from './adapters/graphql-saved-filters.gateway';
import { GraphqlSprintsGateway } from './adapters/graphql-sprints.gateway';
/** Ports → GraphQL adapters (and REST for files). */
export function provideTrackerInfrastructure(): EnvironmentProviders {
@@ -40,5 +42,6 @@ export function provideTrackerInfrastructure(): EnvironmentProviders {
{ provide: NotificationsGateway, useClass: GraphqlNotificationsGateway },
{ provide: TrackerRealtimeGateway, useClass: GraphqlRealtimeGateway },
{ provide: SavedFiltersGateway, useClass: GraphqlSavedFiltersGateway },
{ provide: SprintsGateway, useClass: GraphqlSprintsGateway },
]);
}

View File

@@ -137,6 +137,12 @@ const NOBODY = '__nobody__';
[options]="priorityFilterOptions"
[(value)]="priorityFilter"
/>
<pi-select-field
class="ts-filter"
placeholder="Sprint"
[options]="sprintFilterOptions()"
[(value)]="sprintFilter"
/>
<pi-select-field
class="ts-filter"
placeholder="Release"
@@ -335,6 +341,8 @@ export class BoardPage {
readonly release = input<string | undefined>();
/** `?assignee=` (from the tracking page's team list), bound by the router. */
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 project = inject(ProjectFacade);
@@ -353,6 +361,16 @@ export class BoardPage {
protected readonly releaseFilter = signal<string | null>(ALL);
protected readonly area = 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);
/** What is typed; `search` follows it once typing pauses. */
protected readonly searchInput = signal('');
@@ -364,6 +382,7 @@ export class BoardPage {
!!(
this.assigneeFilter() ||
this.releaseFilter() ||
this.sprintFilter() ||
this.area() ||
this.typeFilter() ||
this.priorityFilter() ||
@@ -485,6 +504,10 @@ export class BoardPage {
const assignee = this.assignee();
if (assignee) untracked(() => this.assigneeFilter.set(assignee));
});
effect(() => {
const sprint = this.sprint();
if (sprint) untracked(() => this.sprintFilter.set(sprint));
});
effect((onCleanup) => {
const text = this.searchInput().trim();
const timer = setTimeout(() => this.search.set(text), 300);
@@ -505,6 +528,7 @@ export class BoardPage {
priorities: priority ? [priority] : undefined,
labels: this.labels().length ? this.labels() : undefined,
search: this.search() || undefined,
sprintId: this.sprintFilter() || undefined,
};
if (!projectId) return;
untracked(() =>
@@ -569,6 +593,7 @@ export class BoardPage {
filter.unassigned ? NOBODY : (filter.assigneeId ?? ALL),
);
this.releaseFilter.set(filter.releaseId ?? ALL);
this.sprintFilter.set(ALL);
this.area.set(filter.area ?? ALL);
this.typeFilter.set(filter.types?.[0] ?? ALL);
this.priorityFilter.set(filter.priorities?.[0] ?? ALL);
@@ -581,6 +606,7 @@ export class BoardPage {
for (const filter of [
this.assigneeFilter,
this.releaseFilter,
this.sprintFilter,
this.area,
this.typeFilter,
this.priorityFilter,

View File

@@ -594,6 +594,14 @@ const MAX_FILE_BYTES = 25 * 1024 * 1024;
>
<pi-label>Release</pi-label>
</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
[options]="typeOptions()"
[disabled]="!project.canContribute()"
@@ -768,6 +776,30 @@ export class IssuePage {
.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);
/** Active values, plus the ticket's own even if archived since. */
protected readonly typeOptions = computed<PiSelectFieldOption<string>[]>(

Some files were not shown because too many files have changed in this diff Show More