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>
220 lines
6.6 KiB
TypeScript
220 lines
6.6 KiB
TypeScript
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,
|
|
];
|