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:
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user