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

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