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:
@@ -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,
|
||||
];
|
||||
Reference in New Issue
Block a user