From bc14dfc549388b3d5fb38fbcad9e320329bfb975 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Fri, 23 Oct 2026 16:33:03 +0000 Subject: [PATCH 1/2] List the pull request's changelog entries atop the comment The heading repeated the last commit's sha and a time. The comment now opens with a Changelog section listing the entries the pull request adds, each led by its category's emoji as in the changelog, and noting breaking commits without a breaking entry or the other way round; the Semantic Diff heading follows. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AzPS5F8RDSGZ6dhfK6NdZp Signed-off-by: Christoph Knittel --- .claude/skills/records/SKILL.md | 6 ++- scripts/commitRecords.js | 55 +++++++++++++++++++++++++- scripts/commitRecords.test.js | 63 +++++++++++++++++++++++++++--- scripts/recordMarkdown.js | 69 ++++++++++++++++++++++++--------- scripts/recordMarkdown.test.js | 35 +++++++++++++++-- 5 files changed, 199 insertions(+), 29 deletions(-) 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..fa935c7268 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 @@ -429,33 +445,50 @@ export function commitMarkdown(r, full, change = "") { * 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 && + (changelog.length + ? changelog + .map(e => unmentioned(e.text).replace(/^- /gm, `- ${e.emoji} `)) + .join("\n") + : "No changelog entry."), + 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..59d25d185a 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:`).", + ); +}); From 995e632aac40ad31f1ca5b7890ca4f6881965b96 Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Fri, 23 Oct 2026 16:43:19 +0000 Subject: [PATCH 2/2] Keep the changelog entries within the comment's budget The budget shortened only commits, so fragments of more than the budget would have pushed the comment past GitHub's limit (Codex). The changelog takes at most a quarter of the budget; entries past it are counted. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01AzPS5F8RDSGZ6dhfK6NdZp Signed-off-by: Christoph Knittel --- scripts/recordMarkdown.js | 33 +++++++++++++++++++++++++++------ scripts/recordMarkdown.test.js | 27 +++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 6 deletions(-) diff --git a/scripts/recordMarkdown.js b/scripts/recordMarkdown.js index fa935c7268..44b39ebf94 100644 --- a/scripts/recordMarkdown.js +++ b/scripts/recordMarkdown.js @@ -441,6 +441,32 @@ 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 @@ -469,12 +495,7 @@ export function commentMarkdown( const breakingEntry = changelog?.some(e => e.category === "breaking"); const head = [ changelog && "### Changelog", - changelog && - (changelog.length - ? changelog - .map(e => unmentioned(e.text).replace(/^- /gm, `- ${e.emoji} `)) - .join("\n") - : "No changelog entry."), + changelog && changelogList(changelog, budget / 4), changelog && breakingCommits && !breakingEntry && diff --git a/scripts/recordMarkdown.test.js b/scripts/recordMarkdown.test.js index 59d25d185a..5ab6ccdeb3 100644 --- a/scripts/recordMarkdown.test.js +++ b/scripts/recordMarkdown.test.js @@ -295,3 +295,30 @@ test("the heading lists the changelog entries and notes a breaking mismatch", () ":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/`."); +});