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:
2026-10-08 13:52:43 +02:00
parent 75323115fb
commit d4da33d4e8
84 changed files with 4719 additions and 79 deletions

View File

@@ -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);