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',
|
||||
|
||||
Reference in New Issue
Block a user