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,6 +4,8 @@ export * from './lib/issue/issue';
export * from './lib/issue/issue.enums';
export * from './lib/issue/issue-attachment';
export * from './lib/issue/mentions';
export * from './lib/issue/issue-hierarchy';
export * from './lib/issue/issue-checklist';
export * from './lib/project/project-settings';
export * from './lib/comment/comment';
export * from './lib/release/release';

View File

@@ -26,3 +26,7 @@ export * from './issue-attachment-added.event';
export * from './issue-attachment-removed.event';
export * from './issue-watched.event';
export * from './issue-unwatched.event';
export * from './issue-parent-changed.event';
export * from './issue-checklist-item-added.event';
export * from './issue-checklist-item-updated.event';
export * from './issue-checklist-item-removed.event';

View File

@@ -0,0 +1,16 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { ChecklistItem } from '../issue/issue-checklist';
import { TrackerEvent } from './tracker.event';
/** A step was added to a ticket's checklist. */
@AutowiredEvent
export class IssueChecklistItemAddedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly item: ChecklistItem,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,16 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A step was removed from a ticket's checklist. */
@AutowiredEvent
export class IssueChecklistItemRemovedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly itemId: string,
readonly text: string,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,17 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { ChecklistItem } from '../issue/issue-checklist';
import { TrackerEvent } from './tracker.event';
/** A checklist step was reworded, ticked or unticked (only what changed). */
@AutowiredEvent
export class IssueChecklistItemUpdatedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly itemId: string,
readonly changes: Partial<Pick<ChecklistItem, 'text' | 'done'>>,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,17 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** A ticket was placed under another one (or taken out: `parentId` null). */
@AutowiredEvent
export class IssueParentChangedEvent extends TrackerEvent {
constructor(
actorId: string,
issueId: string,
projectId: string,
readonly parentId: string | null,
readonly parentKey: string | null,
readonly previousParentId: string | null,
) {
super(actorId, issueId, projectId);
}
}

View File

@@ -0,0 +1,22 @@
import { assertThat } from '@task-stream/api/shared/domain';
/** A step inside a ticket that does not deserve a ticket of its own. */
export interface ChecklistItem {
id: string;
text: string;
done: boolean;
}
export const MAX_CHECKLIST_ITEMS = 100;
export const MAX_CHECKLIST_TEXT = 300;
/** Trims and validates the wording of a checklist item. */
export function normalizeChecklistText(text: string): string {
const next = text.trim();
assertThat(next.length > 0, 'A checklist item needs some text');
assertThat(
next.length <= MAX_CHECKLIST_TEXT,
`A checklist item is at most ${MAX_CHECKLIST_TEXT} characters`,
);
return next;
}

View File

@@ -0,0 +1,42 @@
import { BusinessRuleError } from '@task-stream/api/shared/domain';
/**
* Levels below the top a ticket can sit: epic → ticket → subtask. Deeper
* trees are hard to read on a board and nobody plans with them.
*/
export const MAX_HIERARCHY_DEPTH = 2;
/** The would-be parent of a ticket, as the command handler found it. */
export interface ParentCandidate {
id: string;
key: string;
projectId: string;
/** Its own parent, grandparent… (closest first). */
ancestorIds: string[];
}
/**
* Checks that `childId` (of `childProjectId`, with descendants `childHeight`
* levels deep) may hang from `parent`.
*/
export function assertCanNest(
child: { id: string; key: string; projectId: string },
parent: ParentCandidate,
childHeight: number,
): void {
if (parent.id === child.id)
throw new BusinessRuleError(`${child.key} cannot be its own parent`);
if (parent.projectId !== child.projectId)
throw new BusinessRuleError(
`${parent.key} belongs to another project; a parent must be in the same project`,
);
if (parent.ancestorIds.includes(child.id))
throw new BusinessRuleError(
`${parent.key} already hangs from ${child.key}; that would make a loop`,
);
const depth = parent.ancestorIds.length + 1;
if (depth + childHeight > MAX_HIERARCHY_DEPTH)
throw new BusinessRuleError(
`Tickets nest at most ${MAX_HIERARCHY_DEPTH} levels (epic → ticket → subtask)`,
);
}

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 =

View File

@@ -7,9 +7,13 @@ import {
import { IssueAssignedEvent } from '../events/issue-assigned.event';
import { IssueAttachmentAddedEvent } from '../events/issue-attachment-added.event';
import { IssueAttachmentRemovedEvent } from '../events/issue-attachment-removed.event';
import { IssueChecklistItemAddedEvent } from '../events/issue-checklist-item-added.event';
import { IssueChecklistItemRemovedEvent } from '../events/issue-checklist-item-removed.event';
import { IssueChecklistItemUpdatedEvent } from '../events/issue-checklist-item-updated.event';
import { IssueCreatedEvent } from '../events/issue-created.event';
import { IssueDeletedEvent } from '../events/issue-deleted.event';
import { IssueLinkedEvent } from '../events/issue-linked.event';
import { IssueParentChangedEvent } from '../events/issue-parent-changed.event';
import { IssueReleaseChangedEvent } from '../events/issue-release-changed.event';
import { IssueStatusChangedEvent } from '../events/issue-status-changed.event';
import { IssueUnlinkedEvent } from '../events/issue-unlinked.event';
@@ -22,6 +26,12 @@ import {
MAX_ATTACHMENTS_PER_ISSUE,
normalizeAttachment,
} from './issue-attachment';
import {
ChecklistItem,
MAX_CHECKLIST_ITEMS,
normalizeChecklistText,
} from './issue-checklist';
import { ParentCandidate, assertCanNest } from './issue-hierarchy';
import { IssueLinkType, IssuePriority } from './issue.enums';
export interface IssueLink {
@@ -95,6 +105,9 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
public attachments: IssueAttachment[] = [];
/** People who asked to be told about this ticket. */
public watchers: string[] = [];
/** The epic or ticket this one breaks down. */
public parentId: string | null = null;
public checklist: ChecklistItem[] = [];
public createdAt!: string;
public updatedAt!: string;
public deleted = false;
@@ -280,6 +293,91 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
);
}
/**
* Places the ticket under `parent` (null takes it out). `childHeight` is
* how many levels of tickets already hang from this one.
*/
setParent(
actorId: string,
parent: ParentCandidate | null,
childHeight = 0,
): void {
if ((parent?.id ?? null) === this.parentId) return;
if (parent) assertCanNest(this, parent, childHeight);
this.apply(
new IssueParentChangedEvent(
actorId,
this.id,
this.projectId,
parent?.id ?? null,
parent?.key ?? null,
this.parentId,
),
);
}
/** Adds a step to the checklist; `id` is generated by the caller. */
addChecklistItem(actorId: string, id: string, text: string): void {
if (this.checklist.some((i) => i.id === id)) return;
if (this.checklist.length >= MAX_CHECKLIST_ITEMS) {
throw new BusinessRuleError(
`${this.key} already has ${MAX_CHECKLIST_ITEMS} checklist items`,
);
}
this.apply(
new IssueChecklistItemAddedEvent(actorId, this.id, this.projectId, {
id,
text: normalizeChecklistText(text),
done: false,
}),
);
}
/** Rewords, ticks or unticks a step. Nothing changes, nothing happens. */
updateChecklistItem(
actorId: string,
itemId: string,
changes: { text?: string; done?: boolean },
): void {
const item = this.checklistItem(itemId);
const patch: { text?: string; done?: boolean } = {};
if (changes.text !== undefined) {
const text = normalizeChecklistText(changes.text);
if (text !== item.text) patch.text = text;
}
if (changes.done !== undefined && changes.done !== item.done)
patch.done = changes.done;
if (Object.keys(patch).length === 0) return;
this.apply(
new IssueChecklistItemUpdatedEvent(
actorId,
this.id,
this.projectId,
itemId,
patch,
),
);
}
removeChecklistItem(actorId: string, itemId: string): void {
const item = this.checklistItem(itemId);
this.apply(
new IssueChecklistItemRemovedEvent(
actorId,
this.id,
this.projectId,
item.id,
item.text,
),
);
}
private checklistItem(itemId: string): ChecklistItem {
const item = this.checklist.find((i) => i.id === itemId);
if (!item) throw new BusinessRuleError('Checklist item not found');
return item;
}
/** Deletes the ticket (its project was deleted). Idempotent. */
delete(actorId: string): void {
if (this.deleted) return;
@@ -360,6 +458,32 @@ export class Issue extends VersionedAggregateRoot implements IssueProps {
this.watchers = this.watchers.filter((id) => id !== event.userId);
}
onIssueParentChangedEvent(event: IssueParentChangedEvent): void {
this.parentId = event.parentId;
this.updatedAt = event.occurredAt;
}
onIssueChecklistItemAddedEvent(event: IssueChecklistItemAddedEvent): void {
this.checklist = [...this.checklist, { ...event.item }];
this.updatedAt = event.occurredAt;
}
onIssueChecklistItemUpdatedEvent(
event: IssueChecklistItemUpdatedEvent,
): void {
this.checklist = this.checklist.map((i) =>
i.id === event.itemId ? { ...i, ...event.changes } : i,
);
this.updatedAt = event.occurredAt;
}
onIssueChecklistItemRemovedEvent(
event: IssueChecklistItemRemovedEvent,
): void {
this.checklist = this.checklist.filter((i) => i.id !== event.itemId);
this.updatedAt = event.occurredAt;
}
onIssueDeletedEvent(): void {
this.deleted = true;
}

View File

@@ -23,6 +23,35 @@ export class AttachmentReadModel {
commentId!: string | null;
}
export class ChecklistItemReadModel {
id!: string;
text!: string;
done!: boolean;
}
/** The ticket another one hangs from. */
export class IssueParentReadModel {
id!: string;
key!: string;
title!: string;
}
/** How far the tickets hanging from one have got. */
export class ChildProgressReadModel {
total!: number;
/** Done or cancelled. */
finished!: number;
percent!: number;
storyPoints!: number;
finishedStoryPoints!: number;
}
/** A top-level ticket that groups others, with their progress. */
export class EpicReadModel {
issue!: IssueReadModel;
progress!: ChildProgressReadModel;
}
export class IssueReadModel {
id!: string;
projectId!: string;
@@ -49,6 +78,8 @@ export class IssueReadModel {
attachments!: AttachmentReadModel[];
/** People following the ticket. */
watchers!: UserRefReadModel[];
parent!: IssueParentReadModel | null;
checklist!: ChecklistItemReadModel[];
commentCount!: number;
createdAt!: string;
updatedAt!: string;