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

@@ -4,10 +4,12 @@ import {
ValidationError,
} from '@task-stream/api/shared/domain';
import { IssueCreatedEvent } from '../events/issue-created.event';
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueFactory } from '../factories/issue.factory';
import { replay } from '../testing';
import { Issue } from './issue';
import { ParentCandidate } from './issue-hierarchy';
import { mentionedUserIds, withoutMentionMarkup } from './mentions';
import { IssueLinkType, IssueStatus, IssueType } from './issue.enums';
@@ -223,6 +225,93 @@ describe('Issue', () => {
});
});
describe('hierarchy', () => {
const parent = (over: Partial<ParentCandidate> = {}): ParentCandidate => ({
id: 'epic-1',
key: 'TS-1',
projectId: 'p-1',
ancestorIds: [],
...over,
});
it('hangs from a parent, moves and leaves, and survives a replay', () => {
const issue = newIssue();
issue.setParent('u-pm', parent());
issue.setParent('u-pm', parent());
issue.setParent('u-pm', parent({ id: 'epic-2', key: 'TS-2' }));
issue.setParent('u-pm', null);
issue.setParent('u-pm', parent({ id: 'epic-3', key: 'TS-3' }));
const changes = issue
.getUncommittedEvents()
.filter((e) => e instanceof IssueParentChangedEvent);
expect(changes.map((e) => [e.parentId, e.previousParentId])).toEqual([
['epic-1', null],
['epic-2', 'epic-1'],
[null, 'epic-2'],
['epic-3', null],
]);
expect(replay(issue, Issue).rebuilt.parentId).toBe('epic-3');
});
it('refuses itself, another project, loops and a fourth level', () => {
const issue = newIssue();
expect(() => issue.setParent('u', parent({ id: issue.id }))).toThrow(
BusinessRuleError,
);
expect(() => issue.setParent('u', parent({ projectId: 'p-2' }))).toThrow(
/another project/,
);
expect(() =>
issue.setParent('u', parent({ ancestorIds: [issue.id] })),
).toThrow(/loop/);
// Under a ticket that hangs from an epic: it becomes a subtask.
expect(() =>
issue.setParent('u', parent({ ancestorIds: ['t-1'] })),
).not.toThrow();
expect(() =>
issue.setParent('u', parent({ id: 'x', ancestorIds: ['t-1', 'e-1'] })),
).toThrow(/at most 2 levels/);
// A ticket with subtasks cannot go below a ticket that has a parent.
expect(() =>
issue.setParent('u', parent({ id: 'y', ancestorIds: ['e-1'] }), 1),
).toThrow(/at most 2 levels/);
});
});
describe('checklist', () => {
it('adds, ticks, rewords and removes steps, and survives a replay', () => {
const issue = newIssue();
issue.addChecklistItem('u-dev', 'c-1', ' Write the migration ');
issue.addChecklistItem('u-dev', 'c-2', 'Update the docs');
issue.addChecklistItem('u-dev', 'c-1', 'Duplicate id is ignored');
issue.updateChecklistItem('u-dev', 'c-1', { done: true });
issue.updateChecklistItem('u-dev', 'c-1', {
done: true,
text: 'Write the migration',
});
issue.updateChecklistItem('u-dev', 'c-2', { text: 'Update the README' });
issue.removeChecklistItem('u-dev', 'c-2');
expect(issue.checklist).toEqual([
{ id: 'c-1', text: 'Write the migration', done: true },
]);
expect(replay(issue, Issue).rebuilt.checklist).toEqual(issue.checklist);
// created + 2 added + ticked + reworded + removed
expect(issue.getUncommittedEvents()).toHaveLength(6);
});
it('refuses empty steps and unknown items', () => {
const issue = newIssue();
expect(() => issue.addChecklistItem('u', 'c-1', ' ')).toThrow(
ValidationError,
);
expect(() => issue.removeChecklistItem('u', 'nope')).toThrow(
BusinessRuleError,
);
});
});
describe('mentions', () => {
it('reads who a text mentions, once each', () => {
const text =