Files
jellytau/scripts/release-notes.ts
T
dtourolle 4e6ab017d4 docs: add mdBook docs-site, publish workflow, and release-notes tooling
Add a docs-site (mdBook) with a Gitea publish-docs workflow, a
release-notes generator script (release:notes) that turns a commit
range's TRACES into grouped notes, the background-audio feature spec,
and CLAUDE.md. Ignore docs-site build artifacts.
2026-07-22 21:51:56 +02:00

138 lines
4.3 KiB
TypeScript

#!/usr/bin/env bun
/**
* release-notes.ts — turn a commit range into capability-level release notes
* using the TRACES graph instead of raw commit subjects.
*
* Usage:
* bun run scripts/release-notes.ts [<range>]
* bun run scripts/release-notes.ts v0.0.15..HEAD
*
* With no argument it uses <latest tag>..HEAD (or the whole history if untagged).
*
* How it works:
* 1. `git diff --name-only <range>` → files the range changed.
* 2. Read each changed file's `TRACES:` comments → requirement IDs.
* 3. Resolve IDs to descriptions from docs/requirements.md.
* 4. Group: UR → Features, DR/IR → Improvements. Deduped, so many commits
* touching one requirement collapse to one line.
*
* This is a drafting aid for docs/release-checklist.md — review the output,
* it does not invent descriptions for untraced changes (those are listed
* separately so nothing is silently dropped).
*/
import { execSync } from "node:child_process";
import { readFileSync, existsSync } from "node:fs";
const TRACE_RE = /TRACES:\s*([^\n*]+)/g;
const ID_RE = /\b(UR|IR|DR|JA|UT|IT)-\d+\b/g;
const REQ_ROW_RE = /^\|\s*((?:UR|IR|DR|JA)-\d+)\s*\|\s*([^|]+?)\s*\|/;
function sh(cmd: string): string {
return execSync(cmd, { encoding: "utf8" }).trim();
}
function defaultRange(): string {
try {
const tag = sh("git describe --tags --abbrev=0");
return `${tag}..HEAD`;
} catch {
return ""; // no tags: fall through to whole-history diff
}
}
/** Map requirement ID → human description, parsed from docs/requirements.md. */
function loadRequirementDescriptions(): Map<string, string> {
const map = new Map<string, string>();
const text = readFileSync("docs/requirements.md", "utf8");
for (const line of text.split("\n")) {
const m = line.match(REQ_ROW_RE);
// First definition wins: the descriptive tables come before the later
// cross-reference tables, whose cells hold linked IDs (or "-"), not prose.
if (m && !map.has(m[1])) map.set(m[1], m[2].trim());
}
return map;
}
function changedFiles(range: string): string[] {
const cmd = range
? `git diff --name-only ${range}`
: "git ls-files"; // untagged repo: describe everything currently traced
return sh(cmd)
.split("\n")
.filter((f) => f && existsSync(f));
}
/** Collect requirement IDs referenced by TRACES comments in the given files. */
function idsFromFiles(files: string[]): Set<string> {
const ids = new Set<string>();
for (const file of files) {
let content: string;
try {
content = readFileSync(file, "utf8");
} catch {
continue;
}
for (const trace of content.matchAll(TRACE_RE)) {
for (const id of trace[1].matchAll(ID_RE)) ids.add(id[0]);
}
}
return ids;
}
function main() {
const range = process.argv[2] ?? defaultRange();
const descriptions = loadRequirementDescriptions();
const files = changedFiles(range);
const ids = idsFromFiles(files);
const features: string[] = []; // UR
const improvements: string[] = []; // DR / IR
const unknown: string[] = []; // traced but not in requirements.md
for (const id of [...ids].sort()) {
const desc = descriptions.get(id);
if (id.startsWith("UT") || id.startsWith("IT")) continue; // tests aren't notes
if (!desc) {
if (!id.startsWith("UT") && !id.startsWith("IT")) unknown.push(id);
continue;
}
const line = `- ${desc} (${id})`;
if (id.startsWith("UR")) features.push(line);
else improvements.push(line);
}
const header = range || "(entire history — no tags found)";
const out: string[] = [`## Release notes — ${header}`, ""];
if (features.length) out.push("### ✨ Features", ...features, "");
if (improvements.length) out.push("### 🚀 Improvements", ...improvements, "");
if (unknown.length)
out.push(
"### ⚠️ Traced IDs missing from requirements.md",
...unknown.map((id) => `- ${id}`),
"",
);
const untraced = files.filter((f) => {
try {
return !/TRACES:/.test(readFileSync(f, "utf8"));
} catch {
return false;
}
});
if (untraced.length)
out.push(
`### 📝 Changed files without TRACES (${untraced.length}) — review manually`,
...untraced.map((f) => `- ${f}`),
"",
);
if (!features.length && !improvements.length)
out.push("_No traced requirements in this range._", "");
console.log(out.join("\n"));
}
main();