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:
@@ -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';
|
||||
|
||||
@@ -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]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,219 @@
|
||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
Issue,
|
||||
Project,
|
||||
Sprint,
|
||||
SprintFactory,
|
||||
SprintStatus,
|
||||
StatusCategory,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import {
|
||||
CarryOverIssueCommand,
|
||||
CloseSprintCommand,
|
||||
CreateSprintCommand,
|
||||
DeleteSprintCommand,
|
||||
SetIssueSprintCommand,
|
||||
StartSprintCommand,
|
||||
UpdateSprintCommand,
|
||||
} from './sprint.commands';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/* Sprints are planned and run by whoever manages the project; any member puts tickets in them. */
|
||||
|
||||
@CommandHandler(CreateSprintCommand)
|
||||
export class CreateSprintCommandHandler
|
||||
implements ICommandHandler<CreateSprintCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly sprintFactory: SprintFactory,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateSprintCommand): Promise<CommandResult> {
|
||||
const project = await this.access.project(command.projectId);
|
||||
project.assertCanManage(command.actorId);
|
||||
const sprint = this.sprintFactory.create(command.actorId, {
|
||||
projectId: project.id,
|
||||
name: command.name,
|
||||
goal: command.goal,
|
||||
startDate: command.startDate,
|
||||
endDate: command.endDate,
|
||||
});
|
||||
this.eventPublisher.mergeObjectContext(sprint);
|
||||
await sprint.commitAndWait();
|
||||
return ack(sprint);
|
||||
}
|
||||
}
|
||||
|
||||
/** The sprint, checking the actor manages its project. */
|
||||
async function managedSprint(
|
||||
access: WriteAccess,
|
||||
sprintId: string,
|
||||
actorId: string,
|
||||
): Promise<Sprint> {
|
||||
const sprint = await access.sprint(sprintId);
|
||||
(await access.project(sprint.projectId)).assertCanManage(actorId);
|
||||
return sprint;
|
||||
}
|
||||
|
||||
@CommandHandler(UpdateSprintCommand)
|
||||
export class UpdateSprintCommandHandler
|
||||
implements ICommandHandler<UpdateSprintCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: UpdateSprintCommand): Promise<CommandResult> {
|
||||
const sprint = await managedSprint(
|
||||
this.access,
|
||||
command.sprintId,
|
||||
command.actorId,
|
||||
);
|
||||
sprint.update(command.actorId, command.changes);
|
||||
await sprint.commitAndWait();
|
||||
return ack(sprint);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(StartSprintCommand)
|
||||
export class StartSprintCommandHandler
|
||||
implements ICommandHandler<StartSprintCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: StartSprintCommand): Promise<CommandResult> {
|
||||
const sprint = await managedSprint(
|
||||
this.access,
|
||||
command.sprintId,
|
||||
command.actorId,
|
||||
);
|
||||
const active = await this.findSprints.findActive(sprint.projectId);
|
||||
sprint.start(
|
||||
command.actorId,
|
||||
active && active.id !== sprint.id ? active : null,
|
||||
);
|
||||
await sprint.commitAndWait();
|
||||
return ack(sprint);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(CloseSprintCommand)
|
||||
export class CloseSprintCommandHandler
|
||||
implements ICommandHandler<CloseSprintCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
) {}
|
||||
|
||||
async execute(command: CloseSprintCommand): Promise<CommandResult> {
|
||||
const sprint = await managedSprint(
|
||||
this.access,
|
||||
command.sprintId,
|
||||
command.actorId,
|
||||
);
|
||||
const target = command.carryOverTo
|
||||
? await this.access.sprint(command.carryOverTo)
|
||||
: null;
|
||||
const progress = await this.findIssues.sprintProgress(sprint.id);
|
||||
sprint.close(command.actorId, target, progress.total - progress.finished);
|
||||
await sprint.commitAndWait();
|
||||
return ack(sprint);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(DeleteSprintCommand)
|
||||
export class DeleteSprintCommandHandler
|
||||
implements ICommandHandler<DeleteSprintCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: DeleteSprintCommand): Promise<CommandResult | void> {
|
||||
if (command.cascade) {
|
||||
// Saga: idempotent, the sprint may be gone already.
|
||||
const sprint = await this.access.live(command.sprintId, Sprint);
|
||||
if (!sprint) return;
|
||||
sprint.delete(command.actorId, true);
|
||||
await sprint.commitAndWait();
|
||||
return;
|
||||
}
|
||||
const sprint = await managedSprint(
|
||||
this.access,
|
||||
command.sprintId,
|
||||
command.actorId,
|
||||
);
|
||||
sprint.delete(command.actorId);
|
||||
await sprint.commitAndWait();
|
||||
return ack(sprint);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(SetIssueSprintCommand)
|
||||
export class SetIssueSprintCommandHandler
|
||||
implements ICommandHandler<SetIssueSprintCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: SetIssueSprintCommand): Promise<CommandResult> {
|
||||
const { issue, project } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
// A closed sprint keeps its tickets: neither side may be closed.
|
||||
if (issue.sprintId) await this.access.openSprintOf(project, issue.sprintId);
|
||||
if (command.sprintId)
|
||||
await this.access.openSprintOf(project, command.sprintId);
|
||||
issue.setSprint(command.actorId, command.sprintId);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
|
||||
const FINISHED = [StatusCategory.DONE, StatusCategory.CANCELLED];
|
||||
|
||||
@CommandHandler(CarryOverIssueCommand)
|
||||
export class CarryOverIssueCommandHandler
|
||||
implements ICommandHandler<CarryOverIssueCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: CarryOverIssueCommand): Promise<void> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
// Gone, or it left that sprint meanwhile: nothing to do.
|
||||
if (!issue || issue.sprintId !== command.fromSprintId) return;
|
||||
const project = await this.access.live(issue.projectId, Project);
|
||||
if (!project) return;
|
||||
if (
|
||||
command.onlyUnfinished &&
|
||||
FINISHED.includes(project.workflow.categoryOf(issue.status))
|
||||
)
|
||||
return;
|
||||
if (command.toSprintId) {
|
||||
const target = await this.access.live(command.toSprintId, Sprint);
|
||||
// The target closed or vanished meanwhile: back to the backlog instead.
|
||||
const open =
|
||||
target && !target.deleted && target.status !== SprintStatus.CLOSED;
|
||||
issue.setSprint(command.actorId, open ? command.toSprintId : null);
|
||||
} else {
|
||||
issue.setSprint(command.actorId, null);
|
||||
}
|
||||
await issue.commitAndWait();
|
||||
}
|
||||
}
|
||||
|
||||
export const SPRINT_COMMAND_HANDLERS = [
|
||||
CreateSprintCommandHandler,
|
||||
UpdateSprintCommandHandler,
|
||||
StartSprintCommandHandler,
|
||||
CloseSprintCommandHandler,
|
||||
DeleteSprintCommandHandler,
|
||||
SetIssueSprintCommandHandler,
|
||||
// Saga only.
|
||||
CarryOverIssueCommandHandler,
|
||||
];
|
||||
@@ -0,0 +1,85 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { SprintDetails } from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class CreateSprintCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly projectId: string,
|
||||
public readonly name: string,
|
||||
public readonly startDate: string,
|
||||
public readonly endDate: string,
|
||||
public readonly goal: string | null = null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class UpdateSprintCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly sprintId: string,
|
||||
public readonly changes: SprintDetails,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class StartSprintCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly sprintId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Ends a sprint; unfinished tickets go to `carryOverTo` (null: backlog). */
|
||||
export class CloseSprintCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly sprintId: string,
|
||||
public readonly carryOverTo: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** A planned sprint (people), or any sprint of a deleted project (`cascade`, saga). */
|
||||
export class DeleteSprintCommand extends Command<CommandResult | void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly sprintId: string,
|
||||
public readonly cascade = false,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Puts a ticket in a sprint (null: back to the backlog). */
|
||||
export class SetIssueSprintCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly sprintId: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Saga only: a sprint closed (or was deleted), so a ticket of it moves to
|
||||
* `toSprintId` (null: backlog). With `onlyUnfinished`, finished ones stay as
|
||||
* the record of what the sprint achieved.
|
||||
*/
|
||||
export class CarryOverIssueCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly fromSprintId: string,
|
||||
public readonly toSprintId: string | null,
|
||||
public readonly onlyUnfinished: boolean,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
Issue,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import { IssueSprintChangedEvent } from '@task-stream/api/tracker/domain';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
@EventsHandler(IssueSprintChangedEvent)
|
||||
export class IssueSprintChangedEventHandler
|
||||
implements IEventHandler<IssueSprintChangedEvent>
|
||||
{
|
||||
constructor(
|
||||
private readonly upsertMaterializedIssueRepository: UpsertMaterializedIssueRepository,
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: IssueSprintChangedEvent) {
|
||||
const sprint = event.sprintId
|
||||
? await this.findSprints.findById(event.sprintId)
|
||||
: null;
|
||||
await this.upsertMaterializedIssueRepository.upsert({
|
||||
id: event.aggregateId,
|
||||
sprint: event.sprintId
|
||||
? { id: event.sprintId, name: sprint?.name ?? '' }
|
||||
: null,
|
||||
updatedAt: event.occurredAt,
|
||||
version: versionOf(event),
|
||||
});
|
||||
await this.notifier.issue(event.aggregateId, event);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
DEFAULT_PROJECT_SETTINGS,
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
SprintClosedEvent,
|
||||
SprintCreatedEvent,
|
||||
SprintDeletedEvent,
|
||||
SprintStartedEvent,
|
||||
SprintStatus,
|
||||
SprintUpdatedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { UpsertMaterializedIssueRepository } from '../ports/upsert-materialized-issue.repository';
|
||||
import { UpsertMaterializedSprintRepository } from '../ports/upsert-materialized-sprint.repository';
|
||||
import { ReadModelNotifier, versionOf } from './read-model-notifier';
|
||||
|
||||
type SprintEvent =
|
||||
| SprintCreatedEvent
|
||||
| SprintUpdatedEvent
|
||||
| SprintStartedEvent
|
||||
| SprintClosedEvent
|
||||
| SprintDeletedEvent;
|
||||
|
||||
/** The sprints view (`sprints_view`), one document per sprint. */
|
||||
@EventsHandler(
|
||||
SprintCreatedEvent,
|
||||
SprintUpdatedEvent,
|
||||
SprintStartedEvent,
|
||||
SprintClosedEvent,
|
||||
SprintDeletedEvent,
|
||||
)
|
||||
export class SprintEventHandler implements IEventHandler<SprintEvent> {
|
||||
constructor(
|
||||
private readonly sprints: UpsertMaterializedSprintRepository,
|
||||
private readonly issues: UpsertMaterializedIssueRepository,
|
||||
private readonly notifier: ReadModelNotifier,
|
||||
) {}
|
||||
|
||||
async handle(event: SprintEvent) {
|
||||
const version = versionOf(event);
|
||||
const id = event.aggregateId;
|
||||
if (event instanceof SprintCreatedEvent) {
|
||||
await this.sprints.upsert({ ...event.sprint, version });
|
||||
} else if (event instanceof SprintUpdatedEvent) {
|
||||
await this.sprints.upsert({ id, ...event.changes, version });
|
||||
// Tickets show its name.
|
||||
if (event.changes.name)
|
||||
await this.issues.renameSprint(id, event.changes.name);
|
||||
} else if (event instanceof SprintStartedEvent) {
|
||||
await this.sprints.upsert({
|
||||
id,
|
||||
status: SprintStatus.ACTIVE,
|
||||
startedAt: event.occurredAt,
|
||||
version,
|
||||
});
|
||||
} else if (event instanceof SprintClosedEvent) {
|
||||
await this.sprints.upsert({
|
||||
id,
|
||||
status: SprintStatus.CLOSED,
|
||||
closedAt: event.occurredAt,
|
||||
version,
|
||||
});
|
||||
} else {
|
||||
await this.sprints.remove(id);
|
||||
}
|
||||
await this.notifier.sprint(id, event);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
IssueReadModel,
|
||||
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(
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SprintReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export abstract class FindSprintsRepository {
|
||||
abstract findById(id: string): Promise<SprintReadModel | null>;
|
||||
/** Active first, then planned by start date, then closed (newest first). */
|
||||
abstract findByProject(projectId: string): Promise<SprintReadModel[]>;
|
||||
/** The sprint under way in the project, if any. */
|
||||
abstract findActive(projectId: string): Promise<SprintReadModel | null>;
|
||||
}
|
||||
@@ -21,3 +21,5 @@ export * from './attachment-storage';
|
||||
export * from './notifications.repository';
|
||||
export * from './notification-mailer';
|
||||
export * from './saved-filters.repository';
|
||||
export * from './find-sprints.repository';
|
||||
export * from './upsert-materialized-sprint.repository';
|
||||
|
||||
@@ -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[]>;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SprintReadModel } from '@task-stream/api/tracker/domain';
|
||||
|
||||
export abstract class UpsertMaterializedSprintRepository {
|
||||
/** Applied only if `version` is newer than the stored one (idempotent). */
|
||||
abstract upsert(
|
||||
sprint: Pick<SprintReadModel, 'id' | 'version'> & Partial<SprintReadModel>,
|
||||
): Promise<void>;
|
||||
abstract remove(sprintId: string): Promise<void>;
|
||||
}
|
||||
@@ -18,3 +18,4 @@ export * from './search-my-issues.query';
|
||||
export * from './get-public-projects.query';
|
||||
export * from './get-attachment.query';
|
||||
export * from './get-epics.query';
|
||||
export * from './sprint.queries';
|
||||
|
||||
@@ -17,6 +17,7 @@ import { GetReleasesQueryHandler } from './get-releases.query-handler';
|
||||
import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-handler';
|
||||
import { 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,
|
||||
];
|
||||
|
||||
159
libs/api/tracker/application/src/lib/queries/sprint.queries.ts
Normal file
159
libs/api/tracker/application/src/lib/queries/sprint.queries.ts
Normal file
@@ -0,0 +1,159 @@
|
||||
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
SprintBurndownReadModel,
|
||||
SprintProgressReadModel,
|
||||
SprintReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { computeSprintBurndown } from '../analytics/analytics';
|
||||
import { FindIssuesRepository } from '../ports/find-issues.repository';
|
||||
import { FindSprintsRepository } from '../ports/find-sprints.repository';
|
||||
import { FindTimelineRepository } from '../ports/find-timeline.repository';
|
||||
import { ReadAccess } from './read-access';
|
||||
|
||||
export class GetSprintsQuery extends Query<SprintReadModel[]> {
|
||||
constructor(
|
||||
/** Null: anonymous (public projects only). */
|
||||
public readonly viewerId: string | null,
|
||||
public readonly projectId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetSprintQuery extends Query<SprintReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string | null,
|
||||
public readonly sprintId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetSprintProgressQuery extends Query<SprintProgressReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string | null,
|
||||
public readonly sprintId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class GetSprintBurndownQuery extends Query<SprintBurndownReadModel> {
|
||||
constructor(
|
||||
public readonly viewerId: string | null,
|
||||
public readonly sprintId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** The sprint, if the viewer may see its project (same error otherwise). */
|
||||
async function visibleSprint(
|
||||
findSprints: FindSprintsRepository,
|
||||
access: ReadAccess,
|
||||
viewerId: string | null,
|
||||
sprintId: string,
|
||||
): Promise<SprintReadModel> {
|
||||
const sprint = access.assertFound(
|
||||
await findSprints.findById(sprintId),
|
||||
'Sprint',
|
||||
sprintId,
|
||||
);
|
||||
await access.assertCanView(sprint.projectId, viewerId, {
|
||||
entity: 'Sprint',
|
||||
ref: sprintId,
|
||||
});
|
||||
return sprint;
|
||||
}
|
||||
|
||||
@QueryHandler(GetSprintsQuery)
|
||||
export class GetSprintsQueryHandler
|
||||
implements IQueryHandler<GetSprintsQuery, SprintReadModel[]>
|
||||
{
|
||||
constructor(
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(query: GetSprintsQuery): Promise<SprintReadModel[]> {
|
||||
await this.access.assertCanView(query.projectId, query.viewerId);
|
||||
return this.findSprints.findByProject(query.projectId);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetSprintQuery)
|
||||
export class GetSprintQueryHandler
|
||||
implements IQueryHandler<GetSprintQuery, SprintReadModel>
|
||||
{
|
||||
constructor(
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
execute(query: GetSprintQuery): Promise<SprintReadModel> {
|
||||
return visibleSprint(
|
||||
this.findSprints,
|
||||
this.access,
|
||||
query.viewerId,
|
||||
query.sprintId,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetSprintProgressQuery)
|
||||
export class GetSprintProgressQueryHandler
|
||||
implements IQueryHandler<GetSprintProgressQuery, SprintProgressReadModel>
|
||||
{
|
||||
constructor(
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetSprintProgressQuery,
|
||||
): Promise<SprintProgressReadModel> {
|
||||
const sprint = await visibleSprint(
|
||||
this.findSprints,
|
||||
this.access,
|
||||
query.viewerId,
|
||||
query.sprintId,
|
||||
);
|
||||
return this.findIssues.sprintProgress(sprint.id);
|
||||
}
|
||||
}
|
||||
|
||||
@QueryHandler(GetSprintBurndownQuery)
|
||||
export class GetSprintBurndownQueryHandler
|
||||
implements IQueryHandler<GetSprintBurndownQuery, SprintBurndownReadModel>
|
||||
{
|
||||
constructor(
|
||||
private readonly findSprints: FindSprintsRepository,
|
||||
private readonly findIssues: FindIssuesRepository,
|
||||
private readonly findTimeline: FindTimelineRepository,
|
||||
private readonly access: ReadAccess,
|
||||
) {}
|
||||
|
||||
async execute(
|
||||
query: GetSprintBurndownQuery,
|
||||
): Promise<SprintBurndownReadModel> {
|
||||
const sprint = await visibleSprint(
|
||||
this.findSprints,
|
||||
this.access,
|
||||
query.viewerId,
|
||||
query.sprintId,
|
||||
);
|
||||
const [issues, timeline] = await Promise.all([
|
||||
this.findIssues.findByProject(sprint.projectId),
|
||||
this.findTimeline.findByProject(sprint.projectId),
|
||||
]);
|
||||
return computeSprintBurndown({ sprint, issues, timeline, now: new Date() });
|
||||
}
|
||||
}
|
||||
|
||||
export const SPRINT_QUERY_HANDLERS = [
|
||||
GetSprintsQueryHandler,
|
||||
GetSprintQueryHandler,
|
||||
GetSprintProgressQueryHandler,
|
||||
GetSprintBurndownQueryHandler,
|
||||
];
|
||||
@@ -9,6 +9,7 @@ import { DeleteIssueCommand } from '../commands/delete-issue.command';
|
||||
import { DeleteReleaseCommand } from '../commands/delete-release.command';
|
||||
import { 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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ICommand, IEvent, ofType, Saga } from '@nestjs/cqrs';
|
||||
import {
|
||||
SprintClosedEvent,
|
||||
SprintDeletedEvent,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { from, mergeMap, Observable } from 'rxjs';
|
||||
import { CarryOverIssueCommand } from '../commands/sprint.commands';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
|
||||
/**
|
||||
* When a sprint ends, what was left moves on:
|
||||
*
|
||||
* SprintClosed → CarryOverIssue for its unfinished tickets, to the sprint
|
||||
* chosen when closing it (or back to the backlog)
|
||||
* SprintDeleted → CarryOverIssue for all its tickets, back to the backlog
|
||||
*
|
||||
* Who was in the sprint comes from the event store. The moves are
|
||||
* attributed to whoever closed or deleted the sprint.
|
||||
*/
|
||||
@Injectable()
|
||||
export class SprintCarryOverSaga {
|
||||
constructor(private readonly streams: TrackerStreamsRepository) {}
|
||||
|
||||
@Saga()
|
||||
sprintEnded = (events$: Observable<IEvent>): Observable<ICommand> =>
|
||||
events$.pipe(
|
||||
ofType(SprintClosedEvent, SprintDeletedEvent),
|
||||
mergeMap((event) =>
|
||||
from(this.streams.issueIdsEverInSprint(event.aggregateId)).pipe(
|
||||
mergeMap((issueIds) =>
|
||||
issueIds.map(
|
||||
(issueId) =>
|
||||
new CarryOverIssueCommand(
|
||||
event.actorId,
|
||||
issueId,
|
||||
event.aggregateId,
|
||||
event instanceof SprintClosedEvent ? event.carryOverTo : null,
|
||||
event instanceof SprintClosedEvent,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
122
libs/api/tracker/application/src/lib/sprints.service.ts
Normal file
122
libs/api/tracker/application/src/lib/sprints.service.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { CommandBus, QueryBus } from '@nestjs/cqrs';
|
||||
import {
|
||||
SprintBurndownReadModel,
|
||||
SprintDetails,
|
||||
SprintProgressReadModel,
|
||||
SprintReadModel,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { CommandResult } from './commands/command-result';
|
||||
import {
|
||||
CloseSprintCommand,
|
||||
CreateSprintCommand,
|
||||
DeleteSprintCommand,
|
||||
SetIssueSprintCommand,
|
||||
StartSprintCommand,
|
||||
UpdateSprintCommand,
|
||||
} from './commands/sprint.commands';
|
||||
import {
|
||||
GetSprintBurndownQuery,
|
||||
GetSprintProgressQuery,
|
||||
GetSprintQuery,
|
||||
GetSprintsQuery,
|
||||
} from './queries/sprint.queries';
|
||||
|
||||
export interface CreateSprintInput {
|
||||
projectId: string;
|
||||
name: string;
|
||||
goal?: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
/** Sprints: plan them, run them, close them (and what was left moves on). */
|
||||
@Injectable()
|
||||
export class SprintsService {
|
||||
constructor(
|
||||
private readonly commandBus: CommandBus,
|
||||
private readonly queryBus: QueryBus,
|
||||
) {}
|
||||
|
||||
list(viewerId: string | null, projectId: string): Promise<SprintReadModel[]> {
|
||||
return this.queryBus.execute(new GetSprintsQuery(viewerId, projectId));
|
||||
}
|
||||
|
||||
findOne(viewerId: string | null, sprintId: string): Promise<SprintReadModel> {
|
||||
return this.queryBus.execute(new GetSprintQuery(viewerId, sprintId));
|
||||
}
|
||||
|
||||
progress(
|
||||
viewerId: string | null,
|
||||
sprintId: string,
|
||||
): Promise<SprintProgressReadModel> {
|
||||
return this.queryBus.execute(
|
||||
new GetSprintProgressQuery(viewerId, sprintId),
|
||||
);
|
||||
}
|
||||
|
||||
burndown(
|
||||
viewerId: string | null,
|
||||
sprintId: string,
|
||||
): Promise<SprintBurndownReadModel> {
|
||||
return this.queryBus.execute(
|
||||
new GetSprintBurndownQuery(viewerId, sprintId),
|
||||
);
|
||||
}
|
||||
|
||||
create(actorId: string, input: CreateSprintInput): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new CreateSprintCommand(
|
||||
actorId,
|
||||
input.projectId,
|
||||
input.name,
|
||||
input.startDate,
|
||||
input.endDate,
|
||||
input.goal ?? null,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
update(
|
||||
actorId: string,
|
||||
sprintId: string,
|
||||
changes: SprintDetails,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new UpdateSprintCommand(actorId, sprintId, changes),
|
||||
);
|
||||
}
|
||||
|
||||
start(actorId: string, sprintId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(new StartSprintCommand(actorId, sprintId));
|
||||
}
|
||||
|
||||
/** Unfinished tickets go to `carryOverTo` (null: the backlog). */
|
||||
close(
|
||||
actorId: string,
|
||||
sprintId: string,
|
||||
carryOverTo: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new CloseSprintCommand(actorId, sprintId, carryOverTo),
|
||||
);
|
||||
}
|
||||
|
||||
/** Planned sprints only; its tickets go back to the backlog. */
|
||||
delete(actorId: string, sprintId: string): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new DeleteSprintCommand(actorId, sprintId),
|
||||
) as Promise<CommandResult>;
|
||||
}
|
||||
|
||||
/** Puts a ticket in a sprint (null: back to the backlog). */
|
||||
setIssueSprint(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
sprintId: string | null,
|
||||
): Promise<CommandResult> {
|
||||
return this.commandBus.execute(
|
||||
new SetIssueSprintCommand(actorId, issueId, sprintId),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ import {
|
||||
|
||||
import { SAVED_FILTERS } from './saved-filters/saved-filters';
|
||||
import { 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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A ticket was moved in or out of a sprint. */
|
||||
@AutowiredEvent
|
||||
export class IssueSprintChangedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
issueId: string,
|
||||
projectId: string,
|
||||
readonly sprintId: string | null,
|
||||
readonly previousSprintId: string | null,
|
||||
) {
|
||||
super(actorId, issueId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/**
|
||||
* A sprint ended. Its unfinished tickets move to `carryOverTo` (null: back
|
||||
* to the backlog); the SprintCarryOverSaga moves them.
|
||||
*/
|
||||
@AutowiredEvent
|
||||
export class SprintClosedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
sprintId: string,
|
||||
projectId: string,
|
||||
readonly carryOverTo: string | null,
|
||||
readonly unfinishedIssues: number,
|
||||
) {
|
||||
super(actorId, sprintId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import type { SprintProps } from '../sprint/sprint';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A sprint was planned. */
|
||||
@AutowiredEvent
|
||||
export class SprintCreatedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
readonly sprint: SprintProps,
|
||||
) {
|
||||
super(actorId, sprint.id, sprint.projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A sprint was deleted: its tickets go back to the backlog. */
|
||||
@AutowiredEvent
|
||||
export class SprintDeletedEvent extends TrackerEvent {
|
||||
constructor(actorId: string, sprintId: string, projectId: string) {
|
||||
super(actorId, sprintId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A sprint got under way. */
|
||||
@AutowiredEvent
|
||||
export class SprintStartedEvent extends TrackerEvent {
|
||||
constructor(actorId: string, sprintId: string, projectId: string) {
|
||||
super(actorId, sprintId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { AutowiredEvent } from '@task-stream/api/shared/domain';
|
||||
import type { SprintDetails } from '../sprint/sprint';
|
||||
import { TrackerEvent } from './tracker.event';
|
||||
|
||||
/** A sprint was renamed, re-dated or got another goal (only what changed). */
|
||||
@AutowiredEvent
|
||||
export class SprintUpdatedEvent extends TrackerEvent {
|
||||
constructor(
|
||||
actorId: string,
|
||||
sprintId: string,
|
||||
projectId: string,
|
||||
readonly changes: SprintDetails,
|
||||
) {
|
||||
super(actorId, sprintId, projectId);
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,15 @@ import { CommentFactory } from './comment.factory';
|
||||
import { IssueFactory } from './issue.factory';
|
||||
import { 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,
|
||||
];
|
||||
|
||||
39
libs/api/tracker/domain/src/lib/factories/sprint.factory.ts
Normal file
39
libs/api/tracker/domain/src/lib/factories/sprint.factory.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { newId } from '@task-stream/api/shared/domain';
|
||||
import { SprintCreatedEvent } from '../events/sprint-created.event';
|
||||
import { Sprint, SprintProps, normalizeSprintDetails } from '../sprint/sprint';
|
||||
import { SprintStatus } from '../sprint/sprint-status';
|
||||
|
||||
@Injectable()
|
||||
export class SprintFactory {
|
||||
create(
|
||||
actorId: string,
|
||||
props: {
|
||||
projectId: string;
|
||||
name: string;
|
||||
goal?: string | null;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
},
|
||||
): Sprint {
|
||||
const details = normalizeSprintDetails({
|
||||
name: props.name,
|
||||
goal: props.goal ?? null,
|
||||
startDate: props.startDate,
|
||||
endDate: props.endDate,
|
||||
});
|
||||
const data: SprintProps = {
|
||||
id: newId(),
|
||||
projectId: props.projectId,
|
||||
...details,
|
||||
status: SprintStatus.PLANNED,
|
||||
startedAt: null,
|
||||
closedAt: null,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
const sprint = new Sprint(data.id);
|
||||
Object.assign(sprint, { ...data });
|
||||
sprint.apply(new SprintCreatedEvent(actorId, data), { skipHandler: true });
|
||||
return sprint;
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { IssueLinkedEvent } from '../events/issue-linked.event';
|
||||
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
|
||||
import { 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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { SprintStatus } from '../sprint/sprint-status';
|
||||
|
||||
export class SprintReadModel {
|
||||
id!: string;
|
||||
projectId!: string;
|
||||
name!: string;
|
||||
goal!: string | null;
|
||||
/** `YYYY-MM-DD`, both included. */
|
||||
startDate!: string;
|
||||
endDate!: string;
|
||||
status!: SprintStatus;
|
||||
startedAt!: string | null;
|
||||
closedAt!: string | null;
|
||||
createdAt!: string;
|
||||
version!: number;
|
||||
}
|
||||
|
||||
/** How far a sprint's tickets have got. */
|
||||
export class SprintProgressReadModel {
|
||||
total!: number;
|
||||
/** Done or cancelled. */
|
||||
finished!: number;
|
||||
percent!: number;
|
||||
storyPoints!: number;
|
||||
finishedStoryPoints!: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per day of the sprint: tickets in it (cancelled ones aside), how many are
|
||||
* still open and their points. Days still to come are null; `ideal` falls in
|
||||
* a straight line from what was open on the first day to zero on the last.
|
||||
*/
|
||||
export class SprintBurndownReadModel {
|
||||
sprintId!: string;
|
||||
dates!: string[];
|
||||
scope!: (number | null)[];
|
||||
remaining!: (number | null)[];
|
||||
remainingPoints!: (number | null)[];
|
||||
ideal!: number[];
|
||||
}
|
||||
8
libs/api/tracker/domain/src/lib/sprint/sprint-status.ts
Normal file
8
libs/api/tracker/domain/src/lib/sprint/sprint-status.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export enum SprintStatus {
|
||||
/** Being prepared: tickets come and go. */
|
||||
PLANNED = 'PLANNED',
|
||||
/** Under way (one per project at a time). */
|
||||
ACTIVE = 'ACTIVE',
|
||||
/** Over: what was left went to another sprint or back to the backlog. */
|
||||
CLOSED = 'CLOSED',
|
||||
}
|
||||
79
libs/api/tracker/domain/src/lib/sprint/sprint.spec.ts
Normal file
79
libs/api/tracker/domain/src/lib/sprint/sprint.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import {
|
||||
BusinessRuleError,
|
||||
ValidationError,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { SprintClosedEvent } from '../events/sprint-closed.event';
|
||||
import { SprintFactory } from '../factories/sprint.factory';
|
||||
import { replay } from '../testing';
|
||||
import { Sprint } from './sprint';
|
||||
import { SprintStatus } from './sprint-status';
|
||||
|
||||
const factory = new SprintFactory();
|
||||
const newSprint = (name = 'Sprint 1', projectId = 'p-1') =>
|
||||
factory.create('u-pm', {
|
||||
projectId,
|
||||
name,
|
||||
goal: ' Pagos con tarjeta ',
|
||||
startDate: '2026-10-12',
|
||||
endDate: '2026-10-23',
|
||||
});
|
||||
|
||||
describe('Sprint', () => {
|
||||
it('is planned with tidy details', () => {
|
||||
const sprint = newSprint();
|
||||
expect(sprint.status).toBe(SprintStatus.PLANNED);
|
||||
expect(sprint.goal).toBe('Pagos con tarjeta');
|
||||
});
|
||||
|
||||
it('refuses dates the wrong way round, too long or malformed', () => {
|
||||
const create = (startDate: string, endDate: string) =>
|
||||
factory.create('u', { projectId: 'p', name: 'S', startDate, endDate });
|
||||
expect(() => create('2026-10-23', '2026-10-12')).toThrow(ValidationError);
|
||||
expect(() => create('2026-01-01', '2026-12-31')).toThrow(/at most/);
|
||||
expect(() => create('12/10/2026', '2026-10-23')).toThrow(ValidationError);
|
||||
});
|
||||
|
||||
it('starts once, only when nothing else is under way, and closes', () => {
|
||||
const sprint = newSprint();
|
||||
const next = newSprint('Sprint 2');
|
||||
expect(() => sprint.start('u', { name: 'Sprint 0' })).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
sprint.start('u', null);
|
||||
expect(() => sprint.start('u', null)).toThrow(BusinessRuleError);
|
||||
expect(() => sprint.update('u', { startDate: '2026-10-13' })).toThrow(
|
||||
/start date/,
|
||||
);
|
||||
sprint.update('u', { endDate: '2026-10-26' });
|
||||
|
||||
sprint.close('u', next, 3);
|
||||
const closed = sprint
|
||||
.getUncommittedEvents()
|
||||
.find((e) => e instanceof SprintClosedEvent);
|
||||
expect(closed).toMatchObject({ carryOverTo: next.id, unfinishedIssues: 3 });
|
||||
const { rebuilt } = replay(sprint, Sprint);
|
||||
expect(rebuilt.status).toBe(SprintStatus.CLOSED);
|
||||
expect(rebuilt.endDate).toBe('2026-10-26');
|
||||
expect(() => rebuilt.assertAcceptsIssues()).toThrow(BusinessRuleError);
|
||||
});
|
||||
|
||||
it('carries over only to another open sprint of the same project', () => {
|
||||
const sprint = newSprint();
|
||||
sprint.start('u', null);
|
||||
expect(() => sprint.close('u', sprint, 0)).toThrow(BusinessRuleError);
|
||||
expect(() => sprint.close('u', newSprint('X', 'p-2'), 0)).toThrow(
|
||||
BusinessRuleError,
|
||||
);
|
||||
});
|
||||
|
||||
it('deletes planned sprints, and any on cascade', () => {
|
||||
const planned = newSprint();
|
||||
planned.delete('u');
|
||||
expect(planned.deleted).toBe(true);
|
||||
const active = newSprint();
|
||||
active.start('u', null);
|
||||
expect(() => active.delete('u')).toThrow(BusinessRuleError);
|
||||
active.delete('u', true);
|
||||
expect(active.deleted).toBe(true);
|
||||
});
|
||||
});
|
||||
192
libs/api/tracker/domain/src/lib/sprint/sprint.ts
Normal file
192
libs/api/tracker/domain/src/lib/sprint/sprint.ts
Normal file
@@ -0,0 +1,192 @@
|
||||
import {
|
||||
BusinessRuleError,
|
||||
VersionedAggregateRoot,
|
||||
assertThat,
|
||||
} from '@task-stream/api/shared/domain';
|
||||
import { SprintClosedEvent } from '../events/sprint-closed.event';
|
||||
import { SprintCreatedEvent } from '../events/sprint-created.event';
|
||||
import { SprintDeletedEvent } from '../events/sprint-deleted.event';
|
||||
import { SprintStartedEvent } from '../events/sprint-started.event';
|
||||
import { SprintUpdatedEvent } from '../events/sprint-updated.event';
|
||||
import { SprintStatus } from './sprint-status';
|
||||
|
||||
/** Data of a sprint (what SprintCreatedEvent carries). */
|
||||
export interface SprintProps {
|
||||
id: string;
|
||||
projectId: string;
|
||||
name: string;
|
||||
goal: string | null;
|
||||
/** First and last day, `YYYY-MM-DD`. */
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
status: SprintStatus;
|
||||
startedAt: string | null;
|
||||
closedAt: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type SprintDetails = Partial<
|
||||
Pick<SprintProps, 'name' | 'goal' | 'startDate' | 'endDate'>
|
||||
>;
|
||||
|
||||
const DATE = /^\d{4}-\d{2}-\d{2}$/;
|
||||
/** Long enough for any real iteration, short enough to catch a typo. */
|
||||
export const MAX_SPRINT_DAYS = 90;
|
||||
|
||||
/** Checks the name and the dates of a sprint; returns them tidied. */
|
||||
export function normalizeSprintDetails(
|
||||
details: Required<Pick<SprintProps, 'name' | 'startDate' | 'endDate'>> &
|
||||
Pick<SprintProps, 'goal'>,
|
||||
): Required<Pick<SprintProps, 'name' | 'startDate' | 'endDate' | 'goal'>> {
|
||||
const name = details.name.trim();
|
||||
assertThat(
|
||||
name.length > 0 && name.length <= 80,
|
||||
'A sprint needs a name (up to 80 characters)',
|
||||
);
|
||||
for (const date of [details.startDate, details.endDate])
|
||||
assertThat(
|
||||
DATE.test(date) && !Number.isNaN(Date.parse(date)),
|
||||
'Sprint dates must be YYYY-MM-DD',
|
||||
);
|
||||
const days =
|
||||
(Date.parse(details.endDate) - Date.parse(details.startDate)) / 86_400_000;
|
||||
assertThat(days >= 0, 'A sprint cannot end before it starts');
|
||||
assertThat(
|
||||
days < MAX_SPRINT_DAYS,
|
||||
`A sprint lasts at most ${MAX_SPRINT_DAYS} days`,
|
||||
);
|
||||
return {
|
||||
name,
|
||||
goal: details.goal?.trim() || null,
|
||||
startDate: details.startDate,
|
||||
endDate: details.endDate,
|
||||
};
|
||||
}
|
||||
|
||||
/** A time-boxed iteration: the tickets the team commits to for a few weeks. */
|
||||
export class Sprint extends VersionedAggregateRoot implements SprintProps {
|
||||
public projectId!: string;
|
||||
public name!: string;
|
||||
public goal: string | null = null;
|
||||
public startDate!: string;
|
||||
public endDate!: string;
|
||||
public status: SprintStatus = SprintStatus.PLANNED;
|
||||
public startedAt: string | null = null;
|
||||
public closedAt: string | null = null;
|
||||
public createdAt!: string;
|
||||
public deleted = false;
|
||||
|
||||
constructor(id: string) {
|
||||
super();
|
||||
this.id = id;
|
||||
}
|
||||
|
||||
/** Closed sprints keep what they had: their record is fixed. */
|
||||
assertAcceptsIssues(): void {
|
||||
if (this.status === SprintStatus.CLOSED)
|
||||
throw new BusinessRuleError(`Sprint ${this.name} is closed`);
|
||||
}
|
||||
|
||||
update(actorId: string, changes: SprintDetails): void {
|
||||
this.assertAcceptsIssues();
|
||||
const next = normalizeSprintDetails({
|
||||
name: changes.name ?? this.name,
|
||||
goal: changes.goal !== undefined ? changes.goal : this.goal,
|
||||
startDate: changes.startDate ?? this.startDate,
|
||||
endDate: changes.endDate ?? this.endDate,
|
||||
});
|
||||
if (
|
||||
this.status === SprintStatus.ACTIVE &&
|
||||
next.startDate !== this.startDate
|
||||
)
|
||||
throw new BusinessRuleError('A sprint under way keeps its start date');
|
||||
const patch: SprintDetails = {};
|
||||
for (const field of ['name', 'goal', 'startDate', 'endDate'] as const)
|
||||
if (next[field] !== this[field])
|
||||
(patch as Record<string, unknown>)[field] = next[field];
|
||||
if (Object.keys(patch).length === 0) return;
|
||||
this.apply(new SprintUpdatedEvent(actorId, this.id, this.projectId, patch));
|
||||
}
|
||||
|
||||
/** `otherActive`: the sprint already under way in the project, if any. */
|
||||
start(actorId: string, otherActive: { name: string } | null): void {
|
||||
if (this.status !== SprintStatus.PLANNED)
|
||||
throw new BusinessRuleError(`Sprint ${this.name} is ${this.status}`);
|
||||
if (otherActive)
|
||||
throw new BusinessRuleError(
|
||||
`Sprint ${otherActive.name} is under way; close it first`,
|
||||
);
|
||||
this.apply(new SprintStartedEvent(actorId, this.id, this.projectId));
|
||||
}
|
||||
|
||||
/**
|
||||
* Ends the sprint. Its unfinished tickets go to `carryOverTo` (another
|
||||
* open sprint of the project) or, with null, back to the backlog.
|
||||
*/
|
||||
close(
|
||||
actorId: string,
|
||||
carryOverTo: Sprint | null,
|
||||
unfinishedIssues: number,
|
||||
): void {
|
||||
if (this.status !== SprintStatus.ACTIVE)
|
||||
throw new BusinessRuleError(
|
||||
`Only a sprint under way can be closed; ${this.name} is ${this.status}`,
|
||||
);
|
||||
if (carryOverTo) {
|
||||
if (
|
||||
carryOverTo.id === this.id ||
|
||||
carryOverTo.projectId !== this.projectId
|
||||
)
|
||||
throw new BusinessRuleError(
|
||||
'Unfinished tickets go to another sprint of the same project',
|
||||
);
|
||||
carryOverTo.assertAcceptsIssues();
|
||||
}
|
||||
this.apply(
|
||||
new SprintClosedEvent(
|
||||
actorId,
|
||||
this.id,
|
||||
this.projectId,
|
||||
carryOverTo?.id ?? null,
|
||||
unfinishedIssues,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes a sprint that never started (its tickets go back to the
|
||||
* backlog), or any sprint when its project is deleted (`cascade`).
|
||||
*/
|
||||
delete(actorId: string, cascade = false): void {
|
||||
if (this.deleted) return;
|
||||
if (!cascade && this.status !== SprintStatus.PLANNED)
|
||||
throw new BusinessRuleError(
|
||||
`Sprint ${this.name} is ${this.status}; only planned sprints can be deleted`,
|
||||
);
|
||||
this.apply(new SprintDeletedEvent(actorId, this.id, this.projectId));
|
||||
}
|
||||
|
||||
/* ---- State transitions (also replayed by loadFromHistory) ---- */
|
||||
|
||||
onSprintCreatedEvent({ sprint }: SprintCreatedEvent): void {
|
||||
Object.assign(this, sprint);
|
||||
}
|
||||
|
||||
onSprintUpdatedEvent(event: SprintUpdatedEvent): void {
|
||||
Object.assign(this, event.changes);
|
||||
}
|
||||
|
||||
onSprintStartedEvent(event: SprintStartedEvent): void {
|
||||
this.status = SprintStatus.ACTIVE;
|
||||
this.startedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onSprintClosedEvent(event: SprintClosedEvent): void {
|
||||
this.status = SprintStatus.CLOSED;
|
||||
this.closedAt = event.occurredAt;
|
||||
}
|
||||
|
||||
onSprintDeletedEvent(): void {
|
||||
this.deleted = true;
|
||||
}
|
||||
}
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
ProjectRole,
|
||||
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()) {
|
||||
|
||||
@@ -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 ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import {
|
||||
FindSprintsRepository,
|
||||
UpsertMaterializedSprintRepository,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { SprintReadModel, SprintStatus } from '@task-stream/api/tracker/domain';
|
||||
import { Model } from 'mongoose';
|
||||
import { ReadModels, SprintDoc, fromDoc } from './read-model.schemas';
|
||||
import { upsertIfNewer } from './upsert-if-newer';
|
||||
|
||||
const ORDER: Record<SprintStatus, number> = {
|
||||
[SprintStatus.ACTIVE]: 0,
|
||||
[SprintStatus.PLANNED]: 1,
|
||||
[SprintStatus.CLOSED]: 2,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class MongoFindSprintsRepository extends FindSprintsRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.Sprint) private readonly sprints: Model<SprintDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async findById(id: string): Promise<SprintReadModel | null> {
|
||||
const doc = await this.sprints.findById(id).lean<SprintDoc>();
|
||||
return doc ? fromDoc(doc) : null;
|
||||
}
|
||||
|
||||
async findByProject(projectId: string): Promise<SprintReadModel[]> {
|
||||
const docs = await this.sprints.find({ projectId }).lean<SprintDoc[]>();
|
||||
return docs
|
||||
.map(fromDoc)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
ORDER[a.status] - ORDER[b.status] ||
|
||||
(a.status === SprintStatus.CLOSED
|
||||
? b.startDate.localeCompare(a.startDate)
|
||||
: a.startDate.localeCompare(b.startDate)) ||
|
||||
a.name.localeCompare(b.name),
|
||||
);
|
||||
}
|
||||
|
||||
async findActive(projectId: string): Promise<SprintReadModel | null> {
|
||||
const doc = await this.sprints
|
||||
.findOne({ projectId, status: SprintStatus.ACTIVE })
|
||||
.lean<SprintDoc>();
|
||||
return doc ? fromDoc(doc) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class MongoUpsertMaterializedSprintRepository extends UpsertMaterializedSprintRepository {
|
||||
constructor(
|
||||
@InjectModel(ReadModels.Sprint) private readonly sprints: Model<SprintDoc>,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
async upsert({
|
||||
id,
|
||||
version,
|
||||
...fields
|
||||
}: Pick<SprintReadModel, 'id' | 'version'> & Partial<SprintReadModel>) {
|
||||
await upsertIfNewer(this.sprints, id, version, fields);
|
||||
}
|
||||
|
||||
async remove(sprintId: string) {
|
||||
await this.sprints.deleteOne({ _id: sprintId });
|
||||
}
|
||||
}
|
||||
@@ -230,6 +230,13 @@ export class MongoUpsertMaterializedIssueRepository extends UpsertMaterializedIs
|
||||
if (ops.length) await this.issues.bulkWrite(ops);
|
||||
}
|
||||
|
||||
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 },
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
{
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
170
libs/api/tracker/presenters/src/lib/graphql/sprint.resolver.ts
Normal file
170
libs/api/tracker/presenters/src/lib/graphql/sprint.resolver.ts
Normal file
@@ -0,0 +1,170 @@
|
||||
import {
|
||||
Args,
|
||||
ID,
|
||||
Int,
|
||||
Mutation,
|
||||
Parent,
|
||||
Query,
|
||||
ResolveField,
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
CurrentUser,
|
||||
OptionalAuth,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
IssuesService,
|
||||
IssueSort,
|
||||
SprintsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { SprintReadModel } from '@task-stream/api/tracker/domain';
|
||||
import { viewerOf } from '../common/viewer';
|
||||
import { CreateSprintInput, UpdateSprintInput } from './inputs';
|
||||
import {
|
||||
CommandResultGql,
|
||||
IssuePageGql,
|
||||
SprintBurndownGql,
|
||||
SprintGql,
|
||||
SprintProgressGql,
|
||||
} from './types';
|
||||
|
||||
@Resolver(() => SprintGql)
|
||||
export class SprintResolver {
|
||||
constructor(
|
||||
private readonly sprintsService: SprintsService,
|
||||
private readonly issuesService: IssuesService,
|
||||
) {}
|
||||
|
||||
@OptionalAuth()
|
||||
@Query(() => [SprintGql], {
|
||||
description:
|
||||
"A project's sprints: the one under way, the planned ones, then the closed ones",
|
||||
})
|
||||
sprints(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Args('projectId', { type: () => ID }) projectId: string,
|
||||
) {
|
||||
return this.sprintsService.list(viewerOf(me), projectId);
|
||||
}
|
||||
|
||||
@OptionalAuth()
|
||||
@Query(() => SprintGql)
|
||||
sprint(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Args('id', { type: () => ID }) id: string,
|
||||
) {
|
||||
return this.sprintsService.findOne(viewerOf(me), id);
|
||||
}
|
||||
|
||||
@ResolveField(() => SprintProgressGql)
|
||||
progress(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Parent() sprint: SprintReadModel,
|
||||
) {
|
||||
return this.sprintsService.progress(viewerOf(me), sprint.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => SprintBurndownGql)
|
||||
burndown(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Parent() sprint: SprintReadModel,
|
||||
) {
|
||||
return this.sprintsService.burndown(viewerOf(me), sprint.id);
|
||||
}
|
||||
|
||||
@ResolveField(() => IssuePageGql, {
|
||||
description: 'Tickets in this sprint, in backlog order',
|
||||
})
|
||||
issues(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Parent() sprint: SprintReadModel,
|
||||
@Args('limit', { type: () => Int, defaultValue: 100 }) limit: number,
|
||||
@Args('offset', { type: () => Int, defaultValue: 0 }) offset: number,
|
||||
) {
|
||||
return this.issuesService.search(viewerOf(me), {
|
||||
projectId: sprint.projectId,
|
||||
sprintId: sprint.id,
|
||||
sort: IssueSort.RANK,
|
||||
limit,
|
||||
offset,
|
||||
});
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Plan a sprint (owners and admins)',
|
||||
})
|
||||
createSprint(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: CreateSprintInput,
|
||||
) {
|
||||
return this.sprintsService.create(me.id, input);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql)
|
||||
updateSprint(
|
||||
@CurrentUser() me: User,
|
||||
@Args('input') input: UpdateSprintInput,
|
||||
) {
|
||||
const { sprintId, ...changes } = input;
|
||||
return this.sprintsService.update(
|
||||
me.id,
|
||||
sprintId,
|
||||
Object.fromEntries(
|
||||
Object.entries(changes).filter(
|
||||
([field, value]) => value !== null || field === 'goal',
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Start a planned sprint (only one under way per project)',
|
||||
})
|
||||
startSprint(
|
||||
@CurrentUser() me: User,
|
||||
@Args('sprintId', { type: () => ID }) sprintId: string,
|
||||
) {
|
||||
return this.sprintsService.start(me.id, sprintId);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Close the sprint under way. Unfinished tickets move to carryOverToSprintId, or back to the backlog when it is null.',
|
||||
})
|
||||
closeSprint(
|
||||
@CurrentUser() me: User,
|
||||
@Args('sprintId', { type: () => ID }) sprintId: string,
|
||||
@Args('carryOverToSprintId', { type: () => ID, nullable: true })
|
||||
carryOverToSprintId: string | null,
|
||||
) {
|
||||
return this.sprintsService.close(
|
||||
me.id,
|
||||
sprintId,
|
||||
carryOverToSprintId ?? null,
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description:
|
||||
'Delete a sprint that never started; its tickets go back to the backlog',
|
||||
})
|
||||
deleteSprint(
|
||||
@CurrentUser() me: User,
|
||||
@Args('sprintId', { type: () => ID }) sprintId: string,
|
||||
) {
|
||||
return this.sprintsService.delete(me.id, sprintId);
|
||||
}
|
||||
|
||||
@Mutation(() => CommandResultGql, {
|
||||
description: 'Put a ticket in a sprint (null: back to the backlog)',
|
||||
})
|
||||
setIssueSprint(
|
||||
@CurrentUser() me: User,
|
||||
@Args('issueId', { type: () => ID }) issueId: string,
|
||||
@Args('sprintId', { type: () => ID, nullable: true })
|
||||
sprintId: string | null,
|
||||
) {
|
||||
return this.sprintsService.setIssueSprint(me.id, issueId, sprintId ?? null);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
NotificationGql,
|
||||
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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -187,6 +187,7 @@ export class ProjectsController {
|
||||
labels: query.label,
|
||||
search: query.search,
|
||||
unassigned: query.unassigned,
|
||||
sprintId: query.sprintId,
|
||||
},
|
||||
query.perColumn,
|
||||
);
|
||||
|
||||
249
libs/api/tracker/presenters/src/lib/http/sprints.controller.ts
Normal file
249
libs/api/tracker/presenters/src/lib/http/sprints.controller.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseUUIDPipe,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
ApiCreatedResponse,
|
||||
ApiOkResponse,
|
||||
ApiOperation,
|
||||
ApiParam,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { User } from '@task-stream/api/identity/domain';
|
||||
import {
|
||||
ApiOptionalPireonAuth,
|
||||
ApiPireonAuth,
|
||||
CurrentUser,
|
||||
} from '@task-stream/api/identity/presenters';
|
||||
import {
|
||||
ApiCommandErrors,
|
||||
ApiReadErrors,
|
||||
} from '@task-stream/api/shared/presenters';
|
||||
import {
|
||||
IssuesService,
|
||||
IssueSort,
|
||||
SprintsService,
|
||||
} from '@task-stream/api/tracker/application';
|
||||
import { viewerOf } from '../common/viewer';
|
||||
import {
|
||||
CloseSprintDto,
|
||||
CreateSprintDto,
|
||||
PageQueryDto,
|
||||
SetIssueSprintDto,
|
||||
UpdateSprintDto,
|
||||
} from './dto/requests.dto';
|
||||
import {
|
||||
CommandResultDto,
|
||||
IssuePageDto,
|
||||
SprintBurndownDto,
|
||||
SprintDto,
|
||||
SprintProgressDto,
|
||||
} from './dto/responses.dto';
|
||||
import { Refs } from './refs';
|
||||
|
||||
const PROJECT = ApiParam({
|
||||
name: 'project',
|
||||
description: 'Project id or key',
|
||||
example: 'WEB',
|
||||
});
|
||||
const SPRINT = ApiParam({ name: 'sprint', format: 'uuid' });
|
||||
|
||||
/** REST adapter of the sprint use cases: plan, run and close iterations. */
|
||||
@ApiTags('Sprints')
|
||||
@ApiPireonAuth()
|
||||
@Controller()
|
||||
export class SprintsController {
|
||||
constructor(
|
||||
private readonly sprints: SprintsService,
|
||||
private readonly issues: IssuesService,
|
||||
private readonly refs: Refs,
|
||||
) {}
|
||||
|
||||
@Get('projects/:project/sprints')
|
||||
@ApiOptionalPireonAuth()
|
||||
@PROJECT
|
||||
@ApiOperation({
|
||||
summary: "A project's sprints",
|
||||
description: 'The one under way, the planned ones, then the closed ones.',
|
||||
})
|
||||
@ApiOkResponse({ type: [SprintDto] })
|
||||
@ApiReadErrors()
|
||||
async list(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('project') project: string,
|
||||
) {
|
||||
return this.sprints.list(
|
||||
viewerOf(me),
|
||||
await this.refs.projectId(viewerOf(me), project),
|
||||
);
|
||||
}
|
||||
|
||||
@Post('projects/:project/sprints')
|
||||
@PROJECT
|
||||
@ApiOperation({ summary: 'Plan a sprint (owners and admins)' })
|
||||
@ApiCreatedResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async create(
|
||||
@CurrentUser() me: User,
|
||||
@Param('project') project: string,
|
||||
@Body() body: CreateSprintDto,
|
||||
) {
|
||||
return this.sprints.create(me.id, {
|
||||
...body,
|
||||
projectId: await this.refs.projectId(me.id, project),
|
||||
});
|
||||
}
|
||||
|
||||
@Get('sprints/:sprint')
|
||||
@ApiOptionalPireonAuth()
|
||||
@SPRINT
|
||||
@ApiOperation({ summary: 'A sprint' })
|
||||
@ApiOkResponse({ type: SprintDto })
|
||||
@ApiReadErrors()
|
||||
findOne(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
) {
|
||||
return this.sprints.findOne(viewerOf(me), sprint);
|
||||
}
|
||||
|
||||
@Patch('sprints/:sprint')
|
||||
@SPRINT
|
||||
@ApiOperation({
|
||||
summary: 'Rename, re-date or change the goal (not once closed)',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
update(
|
||||
@CurrentUser() me: User,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
@Body() body: UpdateSprintDto,
|
||||
) {
|
||||
return this.sprints.update(me.id, sprint, body);
|
||||
}
|
||||
|
||||
@Delete('sprints/:sprint')
|
||||
@SPRINT
|
||||
@ApiOperation({
|
||||
summary: 'Delete a sprint that never started',
|
||||
description: 'Its tickets go back to the backlog.',
|
||||
})
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
delete(
|
||||
@CurrentUser() me: User,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
) {
|
||||
return this.sprints.delete(me.id, sprint);
|
||||
}
|
||||
|
||||
@Post('sprints/:sprint/start')
|
||||
@SPRINT
|
||||
@ApiOperation({
|
||||
summary: 'Start a planned sprint (only one under way per project)',
|
||||
})
|
||||
@ApiCreatedResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
start(
|
||||
@CurrentUser() me: User,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
) {
|
||||
return this.sprints.start(me.id, sprint);
|
||||
}
|
||||
|
||||
@Post('sprints/:sprint/close')
|
||||
@SPRINT
|
||||
@ApiOperation({
|
||||
summary: 'Close the sprint under way',
|
||||
description:
|
||||
'Unfinished tickets move to `carryOverTo` (another open sprint) or back to the backlog; finished ones stay as its record.',
|
||||
})
|
||||
@ApiCreatedResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
close(
|
||||
@CurrentUser() me: User,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
@Body() body: CloseSprintDto,
|
||||
) {
|
||||
return this.sprints.close(me.id, sprint, body.carryOverTo ?? null);
|
||||
}
|
||||
|
||||
@Get('sprints/:sprint/progress')
|
||||
@ApiOptionalPireonAuth()
|
||||
@SPRINT
|
||||
@ApiOperation({ summary: 'Finished tickets and points' })
|
||||
@ApiOkResponse({ type: SprintProgressDto })
|
||||
@ApiReadErrors()
|
||||
progress(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
) {
|
||||
return this.sprints.progress(viewerOf(me), sprint);
|
||||
}
|
||||
|
||||
@Get('sprints/:sprint/burndown')
|
||||
@ApiOptionalPireonAuth()
|
||||
@SPRINT
|
||||
@ApiOperation({
|
||||
summary: 'Open tickets and points, day by day, against the ideal line',
|
||||
})
|
||||
@ApiOkResponse({ type: SprintBurndownDto })
|
||||
@ApiReadErrors()
|
||||
burndown(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
) {
|
||||
return this.sprints.burndown(viewerOf(me), sprint);
|
||||
}
|
||||
|
||||
@Get('sprints/:sprint/issues')
|
||||
@ApiOptionalPireonAuth()
|
||||
@SPRINT
|
||||
@ApiOperation({ summary: 'Tickets in the sprint, in backlog order' })
|
||||
@ApiOkResponse({ type: IssuePageDto })
|
||||
@ApiReadErrors()
|
||||
async issuesOf(
|
||||
@CurrentUser() me: User | undefined,
|
||||
@Param('sprint', ParseUUIDPipe) sprint: string,
|
||||
@Query() query: PageQueryDto,
|
||||
) {
|
||||
const found = await this.sprints.findOne(viewerOf(me), sprint);
|
||||
const page = await this.issues.search(viewerOf(me), {
|
||||
projectId: found.projectId,
|
||||
sprintId: found.id,
|
||||
sort: IssueSort.RANK,
|
||||
limit: query.limit,
|
||||
offset: query.offset,
|
||||
});
|
||||
return { ...page, items: await this.refs.issueDtos(page.items) };
|
||||
}
|
||||
|
||||
@Put('issues/:issue/sprint')
|
||||
@ApiParam({
|
||||
name: 'issue',
|
||||
description: 'Ticket id or key',
|
||||
example: 'WEB-12',
|
||||
})
|
||||
@ApiOperation({ summary: 'Put the ticket in a sprint (null: the backlog)' })
|
||||
@ApiOkResponse({ type: CommandResultDto })
|
||||
@ApiCommandErrors()
|
||||
async setIssueSprint(
|
||||
@CurrentUser() me: User,
|
||||
@Param('issue') issue: string,
|
||||
@Body() body: SetIssueSprintDto,
|
||||
) {
|
||||
return this.sprints.setIssueSprint(
|
||||
me.id,
|
||||
await this.refs.issueId(me.id, issue),
|
||||
body.sprintId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ import { ReleasesController } from './http/releases.controller';
|
||||
import { NotificationsController } from './http/notifications.controller';
|
||||
import { 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 {
|
||||
|
||||
Reference in New Issue
Block a user