diff --git a/.claude/skills/records/SKILL.md b/.claude/skills/records/SKILL.md index 97ebeb363e..cebb83d86d 100644 --- a/.claude/skills/records/SKILL.md +++ b/.claude/skills/records/SKILL.md @@ -195,7 +195,11 @@ node scripts/commitRecords.js --out page.html [--pr ] [--title ] \ and comments the records as Markdown (`--markdown --url `, scripts/recordMarkdown.js) with the page's link, one comment that each push updates to hold every commit, marking those the push added or - edited (`--previous `). + edited (`--previous `). The comment first lists, under + its own heading, the changelog entries the pull request adds, which + describe it as a whole, while a `Breaking:` section describes its + commit; it notes breaking commits without a breaking entry, and the + other way round. - In prose, `#1234` and full pull request and issue URLs link as `#1234`, and the 9-character short sha of a commit on the page links to its card. diff --git a/scripts/commitRecords.js b/scripts/commitRecords.js index 016800b231..6ac055c354 100644 --- a/scripts/commitRecords.js +++ b/scripts/commitRecords.js @@ -20,10 +20,12 @@ import { execFileSync } from "node:child_process"; import { readFileSync, writeFileSync } from "node:fs"; +import { categories, parseFragment } from "./changelog.js"; import { commentMarkdown } from "./recordMarkdown.js"; import { CSS, esc, pageBody, pageTitle, parseRecord } from "./recordRender.js"; /** @import { CommitRecord, Group } from "./recordRender.js" */ +/** @import { ChangelogEntry } from "./recordMarkdown.js" */ /** @param {string[]} args */ const git = args => @@ -33,6 +35,56 @@ const git = args => stdio: ["ignore", "pipe", "ignore"], }); +/** + * The changelog entries a range adds or changes: the fragments under + * `changes/` that differ between its ends, as the tip has them, breaking + * changes first and then in the changelog's order. A fragment that doesn't + * parse is drawn by its name; the changelog check reports why. + * @param {string} base + * @param {string} tip + * @returns {ChangelogEntry[]} + */ +function changelog(base, tip) { + const order = Object.keys(categories); + return git([ + "diff", + "--name-only", + "--no-renames", + "--diff-filter=AM", + base, + tip, + "--", + "changes/", + ]) + .split("\n") + .filter(f => /^changes\/[^/]+\.md$/.test(f) && f !== "changes/README.md") + .map(f => { + const name = f.slice("changes/".length); + try { + const { category, text } = parseFragment( + name, + git(["show", `${tip}:${f}`]), + ); + const emoji = + /:\w+:/.exec( + categories[/** @type {keyof typeof categories} */ (category)], + )?.[0] ?? ""; + return { category, emoji, text }; + } catch { + return { + category: "", + emoji: ":grey_question:", + text: `- \`${name}\``, + }; + } + }) + .sort((a, b) => { + const rank = (/** @type {ChangelogEntry} */ e) => + e.category ? order.indexOf(e.category) : order.length; + return rank(a) - rank(b); + }); +} + const MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" "); /** A time as `Oct 23, 2026, 14:41 UTC`, as the cards and the comment print it. */ @@ -176,8 +228,7 @@ if (options.markdown) { options.markdown, commentMarkdown(groups, { url: options.url, - head: git(["rev-parse", "--short=9", tip ?? base]).trim(), - updated: utc(new Date()), + ...(tip ? { changelog: changelog(base, tip) } : {}), ...(options.previous && tip ? marks(records, base, options.previous) : {}), diff --git a/scripts/commitRecords.test.js b/scripts/commitRecords.test.js index fe46b8f18b..72160b78fa 100644 --- a/scripts/commitRecords.test.js +++ b/scripts/commitRecords.test.js @@ -855,17 +855,70 @@ test("--markdown draws every commit into the comment, with the page's link", asy ); assert.equal(r.status, 0, r.stderr); const comment = await fs.readFile(md, "utf8"); - const tip = git("rev-parse", "--short=9", "HEAD"); - assert.match( + has( comment, - new RegExp( - `^### \\[Semantic Diff\\]\\(https://cdn\\.example/pr-1/index\\.html\\)\\n\\n2 commits · ${tip} · [A-Z][a-z]{2} \\d\\d, \\d{4}, \\d\\d:\\d\\d UTC · \\[Full page\\]\\(https://cdn\\.example/pr-1/index\\.html\\)\\n\\n { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), "commit-records-")); + const git = (/** @type {string[]} */ ...a) => + execFileSync("git", a, { cwd: dir, encoding: "utf8" }).trim(); + git("init", "-q", "-b", "main"); + git("config", "user.name", "A Person"); + git("config", "user.email", "a@example.com"); + await commit( + git, + dir, + { "changes/README.md": "readme\n", "changes/old.fix.md": "- Old.\n" }, + "base", + ); + const base = git("rev-parse", "HEAD"); + await commit( + git, + dir, + { + "changes/watch.fix.md": "- Recover the watcher.\n", + "changes/gone.breaking.md": "- Drop -x.\n", + "changes/Bad Name.md": "- ?\n", + "changes/README.md": "readme, edited\n", + }, + `Fix and break\n\n${SIGNED}`, + ); + // An entry added and then removed again is no entry of the range. + await commit( + git, + dir, + { "changes/tmp.polish.md": "- Tmp.\n" }, + `Add\n\n${SIGNED}`, + ); + git("rm", "-q", "changes/tmp.polish.md"); + git("commit", "-q", "-m", `Remove\n\n${SIGNED}`); + const md = path.join(dir, "comment.md"); + const r = spawnSync( + "node", + [ + script, + ...["--out", path.join(dir, "page.html"), "--markdown", md], + `${base}..HEAD`, + ], + { cwd: dir, encoding: "utf8" }, + ); + assert.equal(r.status, 0, r.stderr); + const comment = await fs.readFile(md, "utf8"); + has( + comment, + "### Changelog\n\n- :boom: Drop -x.\n- :bug: Recover the watcher.\n- :grey_question: `Bad Name.md`\n\n", + ); + lacks(comment, "Old."); + lacks(comment, "Tmp."); + lacks(comment, "readme"); + has(comment, "but no commit says what it breaks"); +}); + test("--logins draws an author by the GitHub login, linked, others by name", async () => { const dir = await fs.mkdtemp(path.join(os.tmpdir(), "commit-records-")); const git = (/** @type {string[]} */ ...a) => diff --git a/scripts/recordMarkdown.js b/scripts/recordMarkdown.js index 4886f2125a..44b39ebf94 100644 --- a/scripts/recordMarkdown.js +++ b/scripts/recordMarkdown.js @@ -36,6 +36,12 @@ import { /** @import { CommitRecord, Group, Section, FormEntry } from "./recordRender.js" */ +/** + * A changelog fragment the pull request adds: its category, the category's + * emoji as the changelog heads it (`:boom:`), and its Markdown bullets. + * @typedef {{ category: string, emoji: string, text: string }} ChangelogEntry + */ + /** The characters a comment holds at most, with room for the action's tag. */ export const BUDGET = 60000; @@ -52,6 +58,16 @@ export const BUDGET = 60000; const spans = s => s.split(/(`[^`]+`|(? + spans(s) + .map((part, i) => (i % 2 && !part.startsWith("`") ? `\`${part}\`` : part)) + .join(""); + /** * Prose as Markdown: code spans kept, attributes drawn as code, the * characters Markdown or GitHub would read as markup escaped. `#1234` and @@ -425,37 +441,75 @@ export function commitMarkdown(r, full, change = "") { return out.filter(Boolean).join("\n\n"); } +/** + * The changelog entries as a list, each bullet led by its entry's emoji, in + * at most `room` characters: the comment shortens commits to fit its + * budget but not the changelog, so entries past the room are counted, not + * drawn. + * @param {ChangelogEntry[]} changelog + * @param {number} room + */ +function changelogList(changelog, room) { + if (!changelog.length) return "No changelog entry."; + const drawn = []; + let size = 0; + for (const e of changelog) { + const lines = unmentioned(e.text).replace(/^- /gm, `- ${e.emoji} `); + size += lines.length + 1; + if (size > room) break; + drawn.push(lines); + } + const left = changelog.length - drawn.length; + if (left) + drawn.push( + `\n${left}${drawn.length ? " more" : ""} changelog entr${left === 1 ? "y" : "ies"} under \`changes/\`.`, + ); + return drawn.join("\n"); +} + /** * The comment: a heading linking the page (`url`), in its title and as "Full * page", then each commit. Past the budget, the commits with the most * characters are drawn without their forms, and if that is not enough, the - * last commits are left to the page. The heading names the commit drawn up - * to (`head`, which GitHub links) and when (`updated`), since the comment is - * edited in place on each push; the commits the last push added or edited - * (`changes`) are marked. + * last commits are left to the page. The commits the last push added or + * edited (`changes`) are marked. + * + * The changelog entries the pull request adds (`changelog`, each fragment's + * bullets led by its category's emoji) describe it as a whole, as its squash + * commit will; a commit's `Breaking:` sections describe that commit. They + * come first, under their own heading, which also notes a pull request + * whose commits break something while no entry is a breaking change, or + * the other way round. * @param {Group[]} groups - * @param {{ url?: string, budget?: number, head?: string, updated?: string, changes?: Map }} [options] + * @param {{ url?: string, budget?: number, changes?: Map, changelog?: ChangelogEntry[] }} [options] */ export function commentMarkdown( groups, - { - url = "", - budget = BUDGET, - head: tip = "", - updated = "", - changes = new Map(), - } = {}, + { url = "", budget = BUDGET, changes = new Map(), changelog } = {}, ) { const records = groups.flatMap(g => g.records); const n = records.length; - const head = `### ${url ? `[Semantic Diff](${url})` : "Semantic Diff"}\n\n${[ - `${n} commit${n === 1 ? "" : "s"}`, - tip, - updated, - url && `[Full page](${url})`, + const breakingCommits = records.filter(r => + r.sections.some(s => s.name === "Breaking"), + ).length; + const breakingEntry = changelog?.some(e => e.category === "breaking"); + const head = [ + changelog && "### Changelog", + changelog && changelogList(changelog, budget / 4), + changelog && + breakingCommits && + !breakingEntry && + `:boom: ${breakingCommits === 1 ? "A commit says" : `${breakingCommits} commits say`} what it breaks (\`Breaking:\`), but no changelog entry is a breaking change.`, + breakingEntry && + !breakingCommits && + ":boom: A changelog entry is a breaking change, but no commit says what it breaks (`Breaking:`).", + `### ${url ? `[Semantic Diff](${url})` : "Semantic Diff"}`, + [`${n} commit${n === 1 ? "" : "s"}`, url && `[Full page](${url})`] + .filter(Boolean) + .join(" · "), ] .filter(Boolean) - .join(" · ")}`; + .join("\n\n"); const change = (/** @type {CommitRecord} */ r) => changes.get(r.sha) ?? ""; const drawn = records.map(r => commitMarkdown(r, true, change(r))); const size = () => drawn.reduce((t, d) => t + d.length + 2, head.length); diff --git a/scripts/recordMarkdown.test.js b/scripts/recordMarkdown.test.js index aa7b9d049c..5ab6ccdeb3 100644 --- a/scripts/recordMarkdown.test.js +++ b/scripts/recordMarkdown.test.js @@ -67,13 +67,11 @@ ${SIGNED} ], { url: "https://cdn.example/pr-12/index.html?v=1", - head: "aaaaaaaa0", - updated: "Oct 23, 2026, 11:48 UTC", }, ); has( md, - "### [Semantic Diff](https://cdn.example/pr-12/index.html?v=1)\n\n1 commit · aaaaaaaa0 · Oct 23, 2026, 11:48 UTC · [Full page](https://cdn.example/pr-12/index.html?v=1)\n\n { + const breaking = `Drop a flag\n\nBreaking: -x is gone.\n\n${SIGNED}\n`; + const plain = `Fix a typo\n\n${SIGNED}\n`; + const fix = { + category: "fix", + emoji: ":bug:", + text: "- Recover @someone's `@raw` build.\n- Keep **watching**,\n after errors.", + }; + const md = comment([plain], { changelog: [fix] }); + has( + md, + "### Changelog\n\n- :bug: Recover `@someone`'s `@raw` build.\n- :bug: Keep **watching**,\n after errors.\n\n### Semantic Diff\n\n1 commit\n\n:boom: A commit says what it breaks (`Breaking:`), but no changelog entry is a breaking change.\n\n### Semantic Diff", + ); + const boom = { category: "breaking", emoji: ":boom:", text: "- -x is gone." }; + const both = comment([breaking], { changelog: [boom] }); + assert.ok(!both.includes(":boom:")); + has( + comment([plain], { changelog: [boom] }), + ":boom: A changelog entry is a breaking change, but no commit says what it breaks (`Breaking:`).", + ); +}); + +test("past a quarter of the budget, changelog entries are counted, not drawn", () => { + const entry = (/** @type {number} */ k) => ({ + category: "fix", + emoji: ":bug:", + text: `- Fix ${k}: ${"x".repeat(300)}`, + }); + const md = comment([`Fix\n\n${SIGNED}\n`], { + budget: 4000, + changelog: Array.from({ length: 20 }, (_, k) => entry(k)), + }); + assert.ok(md.length <= 4000, `${md.length}`); + has(md, "- :bug: Fix 0:"); + assert.ok(!md.includes("Fix 3:")); + has( + md, + "17 more changelog entries under `changes/`.\n\n### Semantic Diff", + ); + const huge = comment([`Fix\n\n${SIGNED}\n`], { + budget: 4000, + changelog: [ + { category: "fix", emoji: ":bug:", text: `- ${"y".repeat(5000)}` }, + ], + }); + assert.ok(huge.length <= 4000, `${huge.length}`); + has(huge, "1 changelog entry under `changes/`."); +});