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

@@ -15,6 +15,19 @@ describe('Release', () => {
expect(release.status).toBe(ReleaseStatus.RELEASED);
});
it('takes notes before and after shipping, not once archived', () => {
const release = newRelease();
release.writeNotes('u', ' ## Novedades ');
release.ship('u', 0);
release.writeNotes('u', '## Novedades\n- Pago con tarjeta');
release.writeNotes('u', '## Novedades\n- Pago con tarjeta');
expect(replay(release, Release).rebuilt.notes).toBe(
'## Novedades\n- Pago con tarjeta',
);
release.archive('u');
expect(() => release.writeNotes('u', null)).toThrow(BusinessRuleError);
});
it('is locked once shipped, also after being rebuilt', () => {
const release = newRelease();
release.ship('u', 0);

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;
}