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>
This commit is contained in:
2026-10-08 15:19:23 +02:00
parent 45a3dc1e16
commit 24b37b6628
40 changed files with 1251 additions and 31 deletions

View File

@@ -6,6 +6,7 @@ import {
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';
@@ -22,6 +23,8 @@ export interface ReleaseProps {
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;
@@ -30,6 +33,8 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps {
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;
@@ -109,6 +114,24 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps {
);
}
/**
* 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));
@@ -135,6 +158,10 @@ export class Release extends VersionedAggregateRoot implements ReleaseProps {
this.releasedAt = event.occurredAt;
}
onReleaseNotesWrittenEvent(event: ReleaseNotesWrittenEvent): void {
this.notes = event.notes;
}
onReleaseArchivedEvent(): void {
this.status = ReleaseStatus.ARCHIVED;
}