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

@@ -37,3 +37,4 @@ export * from './sprint-started.event';
export * from './sprint-closed.event';
export * from './sprint-deleted.event';
export * from './issue-sprint-changed.event';
export * from './release-notes-written.event';

View File

@@ -0,0 +1,15 @@
import { AutowiredEvent } from '@task-stream/api/shared/domain';
import { TrackerEvent } from './tracker.event';
/** The release notes were written or rewritten (null: removed). */
@AutowiredEvent
export class ReleaseNotesWrittenEvent extends TrackerEvent {
constructor(
actorId: string,
releaseId: string,
projectId: string,
readonly notes: string | null,
) {
super(actorId, releaseId, projectId);
}
}

View File

@@ -8,6 +8,8 @@ export class ReleaseReadModel {
targetDate!: string | null;
status!: ReleaseStatus;
releasedAt!: string | null;
/** Markdown, for people. */
notes!: string | null;
createdAt!: string;
version!: number;
}

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