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:
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
22
libs/api/tracker/domain/src/lib/issue/issue-checklist.ts
Normal file
22
libs/api/tracker/domain/src/lib/issue/issue-checklist.ts
Normal 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;
|
||||
}
|
||||
42
libs/api/tracker/domain/src/lib/issue/issue-hierarchy.ts
Normal file
42
libs/api/tracker/domain/src/lib/issue/issue-hierarchy.ts
Normal 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)`,
|
||||
);
|
||||
}
|
||||
@@ -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 =
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user