Files
task-stream/libs/api/tracker/domain/src/lib/release/release.ts
Josep Piguillem 24b37b6628 feat: release notes
Each release can carry notes in Markdown, written before or after it
ships. A draft is generated from its finished tickets grouped by type, to
edit and copy from the releases page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 15:19:23 +02:00

173 lines
5.2 KiB
TypeScript

import {
BusinessRuleError,
VersionedAggregateRoot,
assertThat,
} from '@task-stream/api/shared/domain';
import { ReleaseArchivedEvent } from '../events/release-archived.event';
import { ReleaseCreatedEvent } from '../events/release-created.event';
import { ReleaseDeletedEvent } from '../events/release-deleted.event';
import { ReleaseNotesWrittenEvent } from '../events/release-notes-written.event';
import { ReleaseShippedEvent } from '../events/release-shipped.event';
import { ReleaseUpdatedEvent } from '../events/release-updated.event';
import { ReleaseStatus } from './release-status';
/** Data of a release (what ReleaseCreatedEvent carries). */
export interface ReleaseProps {
id: string;
projectId: string;
name: string;
description: string | null;
targetDate: string | null;
status: ReleaseStatus;
releasedAt: string | null;
createdAt: string;
}
export const MAX_RELEASE_NOTES = 20_000;
/** A version of the product: controls which tickets ship together. */
export class Release extends VersionedAggregateRoot implements ReleaseProps {
public projectId!: string;
public name!: string;
public description: string | null = null;
public targetDate: string | null = null;
public status: ReleaseStatus = ReleaseStatus.PLANNED;
public releasedAt: string | null = null;
/** What shipped, told for people (Markdown). */
public notes: string | null = null;
public createdAt!: string;
public deleted = false;
constructor(id: string) {
super();
this.id = id;
}
/** Only planned releases can receive or lose tickets. */
assertAcceptsIssues(): void {
if (this.status !== ReleaseStatus.PLANNED) {
throw new BusinessRuleError(
`Release ${this.name} is ${this.status} and is locked`,
);
}
}
update(
actorId: string,
changes: {
name?: string;
description?: string | null;
targetDate?: string | null;
},
): void {
this.assertAcceptsIssues();
const patch: {
name?: string;
description?: string | null;
targetDate?: string | null;
} = {};
if (changes.name !== undefined) {
const name = changes.name.trim();
assertThat(name.length > 0, 'Release name is required');
if (name !== this.name) patch.name = name;
}
if (changes.description !== undefined) {
const description = changes.description?.trim() || null;
if (description !== this.description) patch.description = description;
}
if (
changes.targetDate !== undefined &&
changes.targetDate !== this.targetDate
) {
if (changes.targetDate) {
assertThat(
!Number.isNaN(Date.parse(changes.targetDate)),
'targetDate must be an ISO date',
);
}
patch.targetDate = changes.targetDate;
}
if (Object.keys(patch).length === 0) return;
this.apply(
new ReleaseUpdatedEvent(actorId, this.id, this.projectId, patch),
);
}
/**
* Ships the release. Unfinished tickets block it unless forced, so nothing
* goes out unnoticed. (Not called `publish`: that name belongs to AggregateRoot.)
*/
ship(actorId: string, unfinishedIssues: number, force = false): void {
this.assertAcceptsIssues();
if (unfinishedIssues > 0 && !force) {
throw new BusinessRuleError(
`Release ${this.name} still has ${unfinishedIssues} unfinished ticket(s); move them out or publish with force`,
);
}
this.apply(
new ReleaseShippedEvent(
actorId,
this.id,
this.projectId,
unfinishedIssues,
),
);
}
/**
* Writes the notes. Unlike the rest of the release they can be written
* after it ships: that is usually when they are needed. Not once archived.
*/
writeNotes(actorId: string, notes: string | null): void {
if (this.status === ReleaseStatus.ARCHIVED)
throw new BusinessRuleError(`Release ${this.name} is archived`);
const next = notes?.trim() || null;
assertThat(
(next?.length ?? 0) <= MAX_RELEASE_NOTES,
`Release notes are at most ${MAX_RELEASE_NOTES} characters`,
);
if (next === this.notes) return;
this.apply(
new ReleaseNotesWrittenEvent(actorId, this.id, this.projectId, next),
);
}
archive(actorId: string): void {
if (this.status === ReleaseStatus.ARCHIVED) return;
this.apply(new ReleaseArchivedEvent(actorId, this.id, this.projectId));
}
/** Deletes the release (its project was deleted). Idempotent. */
delete(actorId: string): void {
if (this.deleted) return;
this.apply(new ReleaseDeletedEvent(actorId, this.id, this.projectId));
}
/* ---- State transitions (also replayed by loadFromHistory) ---- */
onReleaseCreatedEvent({ release }: ReleaseCreatedEvent): void {
Object.assign(this, release);
}
onReleaseUpdatedEvent(event: ReleaseUpdatedEvent): void {
Object.assign(this, event.changes);
}
onReleaseShippedEvent(event: ReleaseShippedEvent): void {
this.status = ReleaseStatus.RELEASED;
this.releasedAt = event.occurredAt;
}
onReleaseNotesWrittenEvent(event: ReleaseNotesWrittenEvent): void {
this.notes = event.notes;
}
onReleaseArchivedEvent(): void {
this.status = ReleaseStatus.ARCHIVED;
}
onReleaseDeletedEvent(): void {
this.deleted = true;
}
}