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

@@ -30,6 +30,7 @@ import { DetachFileCommandHandler } from './detach-file.command-handler';
import { WatchIssueCommandHandler } from './watch-issue.command-handler';
import { SetIssueParentCommandHandler } from './set-issue-parent.command-handler';
import { RankIssueCommandHandler } from './rank-issue.command-handler';
import { WriteReleaseNotesCommandHandler } from './write-release-notes.command-handler';
import { SPRINT_COMMAND_HANDLERS } from './sprint.command-handlers';
import { DetachFromParentCommandHandler } from './detach-from-parent.command-handler';
import {
@@ -72,6 +73,7 @@ export const COMMAND_HANDLERS = [
WatchIssueCommandHandler,
SetIssueParentCommandHandler,
RankIssueCommandHandler,
WriteReleaseNotesCommandHandler,
...SPRINT_COMMAND_HANDLERS,
AddChecklistItemCommandHandler,
UpdateChecklistItemCommandHandler,

View File

@@ -34,3 +34,4 @@ export * from './detach-from-parent.command';
export * from './change-checklist.command';
export * from './rank-issue.command';
export * from './sprint.commands';
export * from './write-release-notes.command';

View File

@@ -0,0 +1,21 @@
import { CommandHandler, ICommandHandler } from '@nestjs/cqrs';
import { CommandResult, ack } from './command-result';
import { WriteReleaseNotesCommand } from './write-release-notes.command';
import { WriteAccess } from './write-access';
@CommandHandler(WriteReleaseNotesCommand)
export class WriteReleaseNotesCommandHandler
implements ICommandHandler<WriteReleaseNotesCommand>
{
constructor(private readonly access: WriteAccess) {}
async execute(command: WriteReleaseNotesCommand): Promise<CommandResult> {
const release = await this.access.release(command.releaseId);
(await this.access.project(release.projectId)).assertCanManage(
command.actorId,
);
release.writeNotes(command.actorId, command.notes);
await release.commitAndWait();
return ack(release);
}
}

View File

@@ -0,0 +1,13 @@
import { Command } from '@nestjs/cqrs';
import { CommandResult } from './command-result';
/** Writes (or with null, removes) a release's notes. */
export class WriteReleaseNotesCommand extends Command<CommandResult> {
constructor(
public readonly actorId: string,
public readonly releaseId: string,
public readonly notes: string | null,
) {
super();
}
}

View File

@@ -25,6 +25,7 @@ import {
ProjectUpdatedEvent,
ProjectWorkflow,
ReleaseArchivedEvent,
ReleaseNotesWrittenEvent,
ReleaseCreatedEvent,
ReleaseShippedEvent,
ReleaseUpdatedEvent,
@@ -191,6 +192,10 @@ export function describeEvent(
return `${actor} actualizó la release ${ctx.releaseName}`;
if (event instanceof ReleaseShippedEvent)
return `${actor} publicó la release ${ctx.releaseName} 🚀`;
if (event instanceof ReleaseNotesWrittenEvent)
return event.notes
? `${actor} escribió las notas de la release ${ctx.releaseName}`
: `${actor} quitó las notas de la release ${ctx.releaseName}`;
if (event instanceof ReleaseArchivedEvent)
return `${actor} archivó la release ${ctx.releaseName}`;
const sprint = ctx.sprintName || 'un sprint';

View File

@@ -23,6 +23,7 @@ import {
ProjectMemberRoleChangedEvent,
ProjectUpdatedEvent,
ReleaseArchivedEvent,
ReleaseNotesWrittenEvent,
IssueSprintChangedEvent,
SprintClosedEvent,
SprintCreatedEvent,
@@ -77,6 +78,7 @@ import { versionOf } from './read-model-notifier';
ReleaseUpdatedEvent,
ReleaseShippedEvent,
ReleaseArchivedEvent,
ReleaseNotesWrittenEvent,
IssueSprintChangedEvent,
SprintCreatedEvent,
SprintUpdatedEvent,

View File

@@ -32,6 +32,7 @@ import { IssueParentChangedEventHandler } from './issue-parent-changed.event-han
import { IssueChecklistEventHandler } from './issue-checklist.event-handler';
import { IssueRankedEventHandler } from './issue-ranked.event-handler';
import { SprintEventHandler } from './sprint.event-handler';
import { ReleaseNotesWrittenEventHandler } from './release-notes-written.event-handler';
import { IssueSprintChangedEventHandler } from './issue-sprint-changed.event-handler';
/** Read-model projections (MongoDB) + realtime, fed by the EventsBridge. */
@@ -69,6 +70,7 @@ export const EVENT_HANDLERS = [
IssueChecklistEventHandler,
IssueRankedEventHandler,
SprintEventHandler,
ReleaseNotesWrittenEventHandler,
IssueSprintChangedEventHandler,
];

View File

@@ -0,0 +1,23 @@
import { EventsHandler, IEventHandler } from '@nestjs/cqrs';
import { ReleaseNotesWrittenEvent } from '@task-stream/api/tracker/domain';
import { UpsertMaterializedReleaseRepository } from '../ports/upsert-materialized-release.repository';
import { ReadModelNotifier, versionOf } from './read-model-notifier';
@EventsHandler(ReleaseNotesWrittenEvent)
export class ReleaseNotesWrittenEventHandler
implements IEventHandler<ReleaseNotesWrittenEvent>
{
constructor(
private readonly upsertMaterializedReleaseRepository: UpsertMaterializedReleaseRepository,
private readonly notifier: ReadModelNotifier,
) {}
async handle(event: ReleaseNotesWrittenEvent) {
await this.upsertMaterializedReleaseRepository.upsert({
id: event.aggregateId,
notes: event.notes,
version: versionOf(event),
});
await this.notifier.release(event.aggregateId, event);
}
}

View File

@@ -0,0 +1,60 @@
import { IQueryHandler, Query, QueryHandler } from '@nestjs/cqrs';
import {
DEFAULT_PROJECT_SETTINGS,
ProjectWorkflow,
} from '@task-stream/api/tracker/domain';
import { FindIssuesRepository } from '../ports/find-issues.repository';
import { FindProjectsRepository } from '../ports/find-projects.repository';
import { FindReleasesRepository } from '../ports/find-releases.repository';
import { draftReleaseNotes } from '../releases/release-notes';
import { ReadAccess } from './read-access';
/** A Markdown draft of a release's notes, from its finished tickets. */
export class GetReleaseNotesDraftQuery extends Query<string> {
constructor(
public readonly viewerId: string | null,
public readonly releaseId: string,
) {
super();
}
}
@QueryHandler(GetReleaseNotesDraftQuery)
export class GetReleaseNotesDraftQueryHandler
implements IQueryHandler<GetReleaseNotesDraftQuery, string>
{
constructor(
private readonly findReleases: FindReleasesRepository,
private readonly findIssues: FindIssuesRepository,
private readonly findProjects: FindProjectsRepository,
private readonly access: ReadAccess,
) {}
async execute(query: GetReleaseNotesDraftQuery): Promise<string> {
const release = this.access.assertFound(
await this.findReleases.findById(query.releaseId),
'Release',
query.releaseId,
);
await this.access.assertCanView(release.projectId, query.viewerId, {
entity: 'Release',
ref: query.releaseId,
});
const [page, project] = await Promise.all([
this.findIssues.search({
projectId: release.projectId,
releaseId: release.id,
limit: 500,
offset: 0,
}),
this.findProjects.findById(release.projectId),
]);
return draftReleaseNotes({
release,
issues: page.items,
workflow: new ProjectWorkflow(
project?.settings ?? DEFAULT_PROJECT_SETTINGS,
),
});
}
}

View File

@@ -19,3 +19,4 @@ export * from './get-public-projects.query';
export * from './get-attachment.query';
export * from './get-epics.query';
export * from './sprint.queries';
export * from './get-release-notes-draft.query';

View File

@@ -18,6 +18,7 @@ import { GetUpcomingReleasesQueryHandler } from './get-upcoming-releases.query-h
import { SearchIssuesQueryHandler } from './search-issues.query-handler';
import { GetEpicsQueryHandler } from './get-epics.query-handler';
import { SPRINT_QUERY_HANDLERS } from './sprint.queries';
import { GetReleaseNotesDraftQueryHandler } from './get-release-notes-draft.query';
import { SearchMyIssuesQueryHandler } from './search-my-issues.query-handler';
export const QUERY_HANDLERS = [
@@ -40,6 +41,7 @@ export const QUERY_HANDLERS = [
SearchIssuesQueryHandler,
GetEpicsQueryHandler,
...SPRINT_QUERY_HANDLERS,
GetReleaseNotesDraftQueryHandler,
SearchMyIssuesQueryHandler,
GetAttachmentQueryHandler,
];

View File

@@ -10,6 +10,8 @@ import { CommandResult } from './commands/command-result';
import { CreateReleaseCommand } from './commands/create-release.command';
import { PublishReleaseCommand } from './commands/publish-release.command';
import { UpdateReleaseCommand } from './commands/update-release.command';
import { WriteReleaseNotesCommand } from './commands/write-release-notes.command';
import { GetReleaseNotesDraftQuery } from './queries/get-release-notes-draft.query';
import { GetReleaseProgressQuery } from './queries/get-release-progress.query';
import { GetReleaseQuery } from './queries/get-release.query';
import { GetUpcomingReleasesQuery } from './queries/get-upcoming-releases.query';
@@ -82,6 +84,24 @@ export class ReleasesService {
return this.queryBus.execute(new GetUpcomingReleasesQuery(viewerId, limit));
}
/** Writes the notes (null removes them); also after the release ships. */
writeNotes(
actorId: string,
releaseId: string,
notes: string | null,
): Promise<CommandResult> {
return this.commandBus.execute(
new WriteReleaseNotesCommand(actorId, releaseId, notes),
);
}
/** A Markdown draft from the release's finished tickets (not saved). */
notesDraft(viewerId: string | null, releaseId: string): Promise<string> {
return this.queryBus.execute(
new GetReleaseNotesDraftQuery(viewerId, releaseId),
);
}
progress(
viewerId: string | null,
releaseId: string,

View File

@@ -0,0 +1,58 @@
import {
defaultProjectSettings,
IssueReadModel,
ProjectWorkflow,
ReleaseReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
import { draftReleaseNotes } from './release-notes';
const issue = (
number: number,
type: string,
title: string,
statusCategory = StatusCategory.DONE,
) =>
({
number,
key: `WEB-${number}`,
type,
title,
statusCategory,
}) as IssueReadModel;
describe('release notes draft', () => {
it('lists finished tickets by type, news first, QA left out', () => {
const notes = draftReleaseNotes({
release: {
name: 'v1.4.0',
description: 'Pagos.',
targetDate: '2026-11-14',
releasedAt: null,
} as ReleaseReadModel,
issues: [
issue(3, 'BUG', 'El botón de pagar no responde en Safari'),
issue(1, 'FEATURE', 'Pago con tarjeta'),
issue(2, 'QA', 'Plan de pruebas de pagos'),
issue(4, 'FEATURE', 'Recibo por correo', StatusCategory.IN_PROGRESS),
],
workflow: new ProjectWorkflow(defaultProjectSettings()),
});
expect(notes).toBe(
[
'## v1.4.0 — 14 de noviembre de 2026',
'',
'Pagos.',
'',
'### Correcciones',
'',
'- El botón de pagar no responde en Safari (WEB-3)',
'',
'### Novedades',
'',
'- Pago con tarjeta (WEB-1)',
'',
].join('\n'),
);
});
});

View File

@@ -0,0 +1,59 @@
import {
IssueReadModel,
ProjectWorkflow,
ReleaseReadModel,
StatusCategory,
} from '@task-stream/api/tracker/domain';
/** How the default types are told in release notes; others use their name. */
const HEADING: Record<string, string> = {
FEATURE: 'Novedades',
UI_CHANGE: 'Cambios en la interfaz',
BUG: 'Correcciones',
TASK: 'Mejoras internas',
};
/** Verification work is not news for whoever reads the notes. */
const LEFT_OUT = new Set(['QA']);
/**
* A first draft of a release's notes, in Markdown: its finished tickets
* grouped by type (in the project's order of types), newest version header
* first. Someone edits it before publishing it; it is never saved by itself.
*/
export function draftReleaseNotes(input: {
release: ReleaseReadModel;
issues: IssueReadModel[];
workflow: ProjectWorkflow;
}): string {
const { release, issues, workflow } = input;
const done = issues.filter(
(i) => i.statusCategory === StatusCategory.DONE && !LEFT_OUT.has(i.type),
);
const day = release.releasedAt ?? release.targetDate;
const date = day
? ` — ${new Date(day).toLocaleDateString('es', {
day: 'numeric',
month: 'long',
year: 'numeric',
timeZone: 'UTC',
})}`
: '';
const lines = [`## ${release.name}${date}`];
if (release.description) lines.push('', release.description);
const order = workflow.settings.issueTypes.map((t) => t.id);
const types = [...new Set(done.map((i) => i.type))].sort(
(a, b) =>
(order.indexOf(a) + 1 || order.length + 1) -
(order.indexOf(b) + 1 || order.length + 1),
);
for (const type of types) {
lines.push('', `### ${HEADING[type] ?? workflow.typeName(type)}`, '');
for (const issue of done
.filter((i) => i.type === type)
.sort((a, b) => a.number - b.number))
lines.push(`- ${issue.title} (${issue.key})`);
}
if (!done.length)
lines.push('', '_Aún no hay tickets terminados en esta release._');
return lines.join('\n') + '\n';
}