feat: epics, subtasks and checklists
Tickets can hang from another one of the same project, three levels deep at most (epic → ticket → subtask). The ticket page lists its subtasks with their progress, creates new ones and moves the ticket under an epic; a new Épicas tab shows every epic and how far it has got. Each ticket also gets a checklist for the small steps that don't deserve a ticket. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,72 @@
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import {
|
||||
AddChecklistItemCommand,
|
||||
RemoveChecklistItemCommand,
|
||||
UpdateChecklistItemCommand,
|
||||
} from './change-checklist.command';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
type ChecklistCommand =
|
||||
| AddChecklistItemCommand
|
||||
| UpdateChecklistItemCommand
|
||||
| RemoveChecklistItemCommand;
|
||||
|
||||
/** Every change to a ticket's checklist: whoever can edit the ticket may. */
|
||||
@CommandHandler(AddChecklistItemCommand)
|
||||
export class AddChecklistItemCommandHandler
|
||||
implements ICommandHandler<AddChecklistItemCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
execute(command: AddChecklistItemCommand): Promise<CommandResult> {
|
||||
return change(this.access, command, (issue) =>
|
||||
issue.addChecklistItem(command.actorId, randomUUID(), command.text),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(UpdateChecklistItemCommand)
|
||||
export class UpdateChecklistItemCommandHandler
|
||||
implements ICommandHandler<UpdateChecklistItemCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
execute(command: UpdateChecklistItemCommand): Promise<CommandResult> {
|
||||
return change(this.access, command, (issue) =>
|
||||
issue.updateChecklistItem(
|
||||
command.actorId,
|
||||
command.itemId,
|
||||
command.changes,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@CommandHandler(RemoveChecklistItemCommand)
|
||||
export class RemoveChecklistItemCommandHandler
|
||||
implements ICommandHandler<RemoveChecklistItemCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
execute(command: RemoveChecklistItemCommand): Promise<CommandResult> {
|
||||
return change(this.access, command, (issue) =>
|
||||
issue.removeChecklistItem(command.actorId, command.itemId),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function change(
|
||||
access: WriteAccess,
|
||||
command: ChecklistCommand,
|
||||
apply: (issue: Awaited<ReturnType<WriteAccess['issue']>>) => void,
|
||||
): Promise<CommandResult> {
|
||||
const { issue } = await access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
apply(issue);
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
export class AddChecklistItemCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly text: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
/** Rewords, ticks or unticks a checklist step. */
|
||||
export class UpdateChecklistItemCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly itemId: string,
|
||||
public readonly changes: { text?: string; done?: boolean },
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
|
||||
export class RemoveChecklistItemCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly itemId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -28,6 +28,13 @@ import { UnassignIssueCommandHandler } from './unassign-issue.command-handler';
|
||||
import { AttachFileCommandHandler } from './attach-file.command-handler';
|
||||
import { DetachFileCommandHandler } from './detach-file.command-handler';
|
||||
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
|
||||
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
|
||||
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
|
||||
import {
|
||||
AddChecklistItemCommandHandler,
|
||||
RemoveChecklistItemCommandHandler,
|
||||
UpdateChecklistItemCommandHandler,
|
||||
} from './change-checklist.command-handler';
|
||||
|
||||
export const COMMAND_HANDLERS = [
|
||||
CreateProjectCommandHandler,
|
||||
@@ -61,4 +68,10 @@ export const COMMAND_HANDLERS = [
|
||||
AttachFileCommandHandler,
|
||||
DetachFileCommandHandler,
|
||||
WatchIssueCommandHandler,
|
||||
SetIssueParentCommandHandler,
|
||||
AddChecklistItemCommandHandler,
|
||||
UpdateChecklistItemCommandHandler,
|
||||
RemoveChecklistItemCommandHandler,
|
||||
// Saga only: the parent was deleted.
|
||||
DetachFromParentCommandHandler,
|
||||
];
|
||||
|
||||
@@ -28,7 +28,15 @@ import {
|
||||
InMemoryProjectKeys,
|
||||
InMemoryReleaseNames,
|
||||
InMemoryReleaseIssues,
|
||||
InMemoryTrackerStreams,
|
||||
} from '../../testing/in-memory';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||
import {
|
||||
AddChecklistItemCommand,
|
||||
UpdateChecklistItemCommand,
|
||||
} from './change-checklist.command';
|
||||
import { ReleaseIssuesRepository } from '../ports/release-issues.repository';
|
||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||
import { ProjectKeysRepository } from '../ports/project-keys.repository';
|
||||
@@ -67,6 +75,7 @@ async function setup() {
|
||||
...COMMAND_HANDLERS,
|
||||
...TRACKER_FACTORIES,
|
||||
WriteAccess,
|
||||
IssueTree,
|
||||
AggregateRehydrator,
|
||||
{ provide: EventStore, useValue: store },
|
||||
{ provide: AttachmentStorage, useValue: files },
|
||||
@@ -78,6 +87,10 @@ async function setup() {
|
||||
provide: ReleaseIssuesRepository,
|
||||
useValue: new InMemoryReleaseIssues(store),
|
||||
},
|
||||
{
|
||||
provide: TrackerStreamsRepository,
|
||||
useValue: new InMemoryTrackerStreams(store),
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
await moduleRef.init();
|
||||
@@ -281,6 +294,84 @@ describe('tracker commands (event sourced)', () => {
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
describe('epics, subtasks and checklists', () => {
|
||||
const create = (
|
||||
run: (c: object) => Promise<CommandResult>,
|
||||
projectId: string,
|
||||
title: string,
|
||||
parentId?: string,
|
||||
) =>
|
||||
run(
|
||||
new CreateIssueCommand(
|
||||
'dev',
|
||||
projectId,
|
||||
IssueType.TASK,
|
||||
title,
|
||||
{},
|
||||
null,
|
||||
null,
|
||||
parentId,
|
||||
),
|
||||
);
|
||||
|
||||
it('nests tickets three levels deep at most, without loops', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const epic = await create(run, projectId, 'Checkout');
|
||||
const story = await create(run, projectId, 'Pay by card', epic.id);
|
||||
const subtask = await create(run, projectId, 'Stripe form', story.id);
|
||||
const loose = await create(run, projectId, 'Loose ticket');
|
||||
|
||||
expect((await rehydrator.rehydrate(subtask.id, Issue)).parentId).toBe(
|
||||
story.id,
|
||||
);
|
||||
// A fourth level, and a loop.
|
||||
await expect(
|
||||
create(run, projectId, 'Too deep', subtask.id),
|
||||
).rejects.toThrow(/at most 2 levels/);
|
||||
await expect(
|
||||
run(new SetIssueParentCommand('dev', epic.id, subtask.id)),
|
||||
).rejects.toThrow(BusinessRuleError);
|
||||
// A ticket with subtasks cannot go under one that has a parent.
|
||||
await run(new SetIssueParentCommand('dev', loose.id, epic.id));
|
||||
await expect(
|
||||
run(new SetIssueParentCommand('dev', story.id, loose.id)),
|
||||
).rejects.toThrow(/at most 2 levels/);
|
||||
// ...but it can move to another epic, and leave.
|
||||
await run(new SetIssueParentCommand('dev', loose.id, null));
|
||||
await run(new SetIssueParentCommand('dev', story.id, loose.id));
|
||||
expect((await rehydrator.rehydrate(story.id, Issue)).parentId).toBe(
|
||||
loose.id,
|
||||
);
|
||||
await expect(
|
||||
run(new SetIssueParentCommand('pm', story.id, null)),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
|
||||
it('keeps a checklist on the ticket', async () => {
|
||||
const { run, rehydrator } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
const issue = await create(run, projectId, 'Release 1.0');
|
||||
await run(new AddChecklistItemCommand('dev', issue.id, 'Changelog'));
|
||||
await run(new AddChecklistItemCommand('qa', issue.id, 'Smoke test'));
|
||||
const [first] = (await rehydrator.rehydrate(issue.id, Issue)).checklist;
|
||||
await run(
|
||||
new UpdateChecklistItemCommand('qa', issue.id, first.id, {
|
||||
done: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const { checklist } = await rehydrator.rehydrate(issue.id, Issue);
|
||||
expect(checklist.map((i) => [i.text, i.done])).toEqual([
|
||||
['Changelog', true],
|
||||
['Smoke test', false],
|
||||
]);
|
||||
await expect(
|
||||
run(new AddChecklistItemCommand('pm', issue.id, 'Nope')),
|
||||
).rejects.toThrow(ForbiddenError);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not let viewers modify tickets', async () => {
|
||||
const { run } = await setup();
|
||||
const projectId = await projectWithTeam(run);
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import { CommandHandler, EventPublisher, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { IssueFactory } from '@task-stream/api/tracker/domain';
|
||||
import { IssueFactory, assertCanNest } from '@task-stream/api/tracker/domain';
|
||||
import { IssueNumbersRepository } from '../ports/issue-numbers.repository';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { CreateIssueCommand } from './create-issue.command';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(CreateIssueCommand)
|
||||
@@ -14,6 +15,7 @@ export class CreateIssueCommandHandler
|
||||
private readonly issueFactory: IssueFactory,
|
||||
private readonly issueNumbers: IssueNumbersRepository,
|
||||
private readonly eventPublisher: EventPublisher,
|
||||
private readonly tree: IssueTree,
|
||||
) {}
|
||||
|
||||
async execute(command: CreateIssueCommand): Promise<CommandResult> {
|
||||
@@ -27,6 +29,15 @@ export class CreateIssueCommandHandler
|
||||
this.access.assertAssignable(project, command.assigneeId);
|
||||
if (command.releaseId)
|
||||
await this.access.openReleaseOf(project, command.releaseId);
|
||||
const parent = command.parentId
|
||||
? await this.tree.candidate(command.parentId)
|
||||
: null;
|
||||
if (parent)
|
||||
assertCanNest(
|
||||
{ id: '', key: 'The new ticket', projectId: project.id },
|
||||
parent,
|
||||
0,
|
||||
);
|
||||
|
||||
const issue = this.issueFactory.create(
|
||||
{
|
||||
@@ -42,6 +53,7 @@ export class CreateIssueCommandHandler
|
||||
workflow,
|
||||
);
|
||||
if (command.releaseId) issue.setRelease(command.actorId, command.releaseId);
|
||||
if (parent) issue.setParent(command.actorId, parent);
|
||||
|
||||
this.eventPublisher.mergeObjectContext(issue);
|
||||
await issue.commitAndWait();
|
||||
|
||||
@@ -12,6 +12,8 @@ export class CreateIssueCommand extends Command<CommandResult> {
|
||||
public readonly details: Omit<IssueDetails, 'title' | 'type'>,
|
||||
public readonly assigneeId?: string | null,
|
||||
public readonly releaseId?: string | null,
|
||||
/** Epic or ticket the new one breaks down. */
|
||||
public readonly parentId?: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { Issue, Project } from '@task-stream/api/tracker/domain';
|
||||
import { DetachFromParentCommand } from './detach-from-parent.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(DetachFromParentCommand)
|
||||
export class DetachFromParentCommandHandler
|
||||
implements ICommandHandler<DetachFromParentCommand>
|
||||
{
|
||||
constructor(private readonly access: WriteAccess) {}
|
||||
|
||||
async execute(command: DetachFromParentCommand): Promise<void> {
|
||||
const issue = await this.access.live(command.issueId, Issue);
|
||||
// Gone (or its whole project is going), or it moved elsewhere already.
|
||||
if (!issue || issue.parentId !== command.parentId) return;
|
||||
if (!(await this.access.live(issue.projectId, Project))) return;
|
||||
issue.setParent(command.actorId, null);
|
||||
await issue.commitAndWait();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
|
||||
/** Saga only: the parent of a ticket was deleted, so it goes back to the top. */
|
||||
export class DetachFromParentCommand extends Command<void> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly parentId: string,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
@@ -29,3 +29,6 @@ export * from './unassign-issue.command';
|
||||
export * from './attach-file.command';
|
||||
export * from './detach-file.command';
|
||||
export * from './watch-issue.command';
|
||||
export * from './set-issue-parent.command';
|
||||
export * from './detach-from-parent.command';
|
||||
export * from './change-checklist.command';
|
||||
|
||||
60
libs/api/tracker/application/src/lib/commands/issue-tree.ts
Normal file
60
libs/api/tracker/application/src/lib/commands/issue-tree.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import {
|
||||
Issue,
|
||||
MAX_HIERARCHY_DEPTH,
|
||||
ParentCandidate,
|
||||
} from '@task-stream/api/tracker/domain';
|
||||
import { TrackerStreamsRepository } from '../ports/tracker-streams.repository';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
/**
|
||||
* Walks the epic → ticket → subtask tree on the write side (the event store),
|
||||
* so nesting rules never depend on a read model that may lag behind.
|
||||
*/
|
||||
@Injectable()
|
||||
export class IssueTree {
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly streams: TrackerStreamsRepository,
|
||||
) {}
|
||||
|
||||
/** `parentId` as a would-be parent: with its ancestors, closest first. */
|
||||
async candidate(parentId: string): Promise<ParentCandidate> {
|
||||
const parent = await this.access.issue(parentId);
|
||||
const ancestorIds: string[] = [];
|
||||
let next = parent.parentId;
|
||||
// One more than allowed is enough to tell a tree is too deep (or loops).
|
||||
while (next && ancestorIds.length <= MAX_HIERARCHY_DEPTH) {
|
||||
ancestorIds.push(next);
|
||||
next = (await this.access.live(next, Issue))?.parentId ?? null;
|
||||
}
|
||||
return {
|
||||
id: parent.id,
|
||||
key: parent.key,
|
||||
projectId: parent.projectId,
|
||||
ancestorIds,
|
||||
};
|
||||
}
|
||||
|
||||
/** Tickets hanging from `issueId` right now. */
|
||||
async childrenOf(issueId: string): Promise<Issue[]> {
|
||||
const everUnder = await this.streams.issueIdsEverUnder(issueId);
|
||||
const issues = await Promise.all(
|
||||
everUnder.map((id) => this.access.live(id, Issue)),
|
||||
);
|
||||
return issues.filter(
|
||||
(issue): issue is Issue => !!issue && issue.parentId === issueId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Levels of tickets below `issueId` (0: none). */
|
||||
async heightOf(issueId: string, depth = 0): Promise<number> {
|
||||
if (depth > MAX_HIERARCHY_DEPTH) return 0;
|
||||
const children = await this.childrenOf(issueId);
|
||||
if (children.length === 0) return 0;
|
||||
const heights = await Promise.all(
|
||||
children.map((child) => this.heightOf(child.id, depth + 1)),
|
||||
);
|
||||
return 1 + Math.max(...heights);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
|
||||
import { CommandResult, ack } from './command-result';
|
||||
import { IssueTree } from './issue-tree';
|
||||
import { SetIssueParentCommand } from './set-issue-parent.command';
|
||||
import { WriteAccess } from './write-access';
|
||||
|
||||
@CommandHandler(SetIssueParentCommand)
|
||||
export class SetIssueParentCommandHandler
|
||||
implements ICommandHandler<SetIssueParentCommand>
|
||||
{
|
||||
constructor(
|
||||
private readonly access: WriteAccess,
|
||||
private readonly tree: IssueTree,
|
||||
) {}
|
||||
|
||||
async execute(command: SetIssueParentCommand): Promise<CommandResult> {
|
||||
const { issue } = await this.access.issueForContributor(
|
||||
command.issueId,
|
||||
command.actorId,
|
||||
);
|
||||
if (command.parentId === null) {
|
||||
issue.setParent(command.actorId, null);
|
||||
} else if (command.parentId !== issue.parentId) {
|
||||
const [parent, height] = await Promise.all([
|
||||
this.tree.candidate(command.parentId),
|
||||
this.tree.heightOf(issue.id),
|
||||
]);
|
||||
issue.setParent(command.actorId, parent, height);
|
||||
}
|
||||
await issue.commitAndWait();
|
||||
return ack(issue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Command } from '@nestjs/cqrs';
|
||||
import { CommandResult } from './command-result';
|
||||
|
||||
/** Places a ticket under an epic or another ticket (`parentId` null: out). */
|
||||
export class SetIssueParentCommand extends Command<CommandResult> {
|
||||
constructor(
|
||||
public readonly actorId: string,
|
||||
public readonly issueId: string,
|
||||
public readonly parentId: string | null,
|
||||
) {
|
||||
super();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user