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>
173 lines
5.2 KiB
TypeScript
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;
|
|
}
|
|
}
|