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

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