Skip to content

Commit bc14dfc

Browse files
cknittclaude
andcommitted
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzPS5F8RDSGZ6dhfK6NdZp Signed-off-by: Christoph Knittel <ck@cca.io>
1 parent 8b14387 commit bc14dfc

5 files changed

Lines changed: 199 additions & 29 deletions

File tree

‎.claude/skills/records/SKILL.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -195,7 +195,11 @@ node scripts/commitRecords.js --out page.html [--pr <number>] [--title <text>] \
195195
and comments the records as Markdown (`--markdown <file> --url <page>`,
196196
scripts/recordMarkdown.js) with the page's link, one comment that each
197197
push updates to hold every commit, marking those the push added or
198-
edited (`--previous <previous head>`).
198+
edited (`--previous <previous head>`). The comment first lists, under
199+
its own heading, the changelog entries the pull request adds, which
200+
describe it as a whole, while a `Breaking:` section describes its
201+
commit; it notes breaking commits without a breaking entry, and the
202+
other way round.
199203
- In prose, `#1234` and full pull request and issue URLs link as `#1234`,
200204
and the 9-character short sha of a commit on the page links to its card.
201205

‎scripts/commitRecords.js‎

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,12 @@
2020

2121
import { execFileSync } from "node:child_process";
2222
import { readFileSync, writeFileSync } from "node:fs";
23+
import { categories, parseFragment } from "./changelog.js";
2324
import { commentMarkdown } from "./recordMarkdown.js";
2425
import { CSS, esc, pageBody, pageTitle, parseRecord } from "./recordRender.js";
2526

2627
/** @import { CommitRecord, Group } from "./recordRender.js" */
28+
/** @import { ChangelogEntry } from "./recordMarkdown.js" */
2729

2830
/** @param {string[]} args */
2931
const git = args =>
@@ -33,6 +35,56 @@ const git = args =>
3335
stdio: ["ignore", "pipe", "ignore"],
3436
});
3537

38+
/**
39+
* The changelog entries a range adds or changes: the fragments under
40+
* `changes/` that differ between its ends, as the tip has them, breaking
41+
* changes first and then in the changelog's order. A fragment that doesn't
42+
* parse is drawn by its name; the changelog check reports why.
43+
* @param {string} base
44+
* @param {string} tip
45+
* @returns {ChangelogEntry[]}
46+
*/
47+
function changelog(base, tip) {
48+
const order = Object.keys(categories);
49+
return git([
50+
"diff",
51+
"--name-only",
52+
"--no-renames",
53+
"--diff-filter=AM",
54+
base,
55+
tip,
56+
"--",
57+
"changes/",
58+
])
59+
.split("\n")
60+
.filter(f => /^changes\/[^/]+\.md$/.test(f) && f !== "changes/README.md")
61+
.map(f => {
62+
const name = f.slice("changes/".length);
63+
try {
64+
const { category, text } = parseFragment(
65+
name,
66+
git(["show", `${tip}:${f}`]),
67+
);
68+
const emoji =
69+
/:\w+:/.exec(
70+
categories[/** @type {keyof typeof categories} */ (category)],
71+
)?.[0] ?? "";
72+
return { category, emoji, text };
73+
} catch {
74+
return {
75+
category: "",
76+
emoji: ":grey_question:",
77+
text: `- \`${name}\``,
78+
};
79+
}
80+
})
81+
.sort((a, b) => {
82+
const rank = (/** @type {ChangelogEntry} */ e) =>
83+
e.category ? order.indexOf(e.category) : order.length;
84+
return rank(a) - rank(b);
85+
});
86+
}
87+
3688
const MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" ");
3789

3890
/** A time as `Oct 23, 2026, 14:41 UTC`, as the cards and the comment print it. */
@@ -176,8 +228,7 @@ if (options.markdown) {
176228
options.markdown,
177229
commentMarkdown(groups, {
178230
url: options.url,
179-
head: git(["rev-parse", "--short=9", tip ?? base]).trim(),
180-
updated: utc(new Date()),
231+
...(tip ? { changelog: changelog(base, tip) } : {}),
181232
...(options.previous && tip
182233
? marks(records, base, options.previous)
183234
: {}),

‎scripts/commitRecords.test.js‎

Lines changed: 58 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -855,17 +855,70 @@ test("--markdown draws every commit into the comment, with the page's link", asy
855855
);
856856
assert.equal(r.status, 0, r.stderr);
857857
const comment = await fs.readFile(md, "utf8");
858-
const tip = git("rev-parse", "--short=9", "HEAD");
859-
assert.match(
858+
has(
860859
comment,
861-
new RegExp(
862-
`^### \\[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<details`,
863-
),
860+
"### Changelog\n\nNo changelog entry.\n\n### [Semantic Diff](https://cdn.example/pr-1/index.html)\n\n2 commits · [Full page](https://cdn.example/pr-1/index.html)\n\n<details",
864861
);
865862
has(comment, "First change");
866863
has(comment, "Second change");
867864
});
868865

866+
test("the comment lists the changelog entries a range adds, breaking first", async () => {
867+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "commit-records-"));
868+
const git = (/** @type {string[]} */ ...a) =>
869+
execFileSync("git", a, { cwd: dir, encoding: "utf8" }).trim();
870+
git("init", "-q", "-b", "main");
871+
git("config", "user.name", "A Person");
872+
git("config", "user.email", "a@example.com");
873+
await commit(
874+
git,
875+
dir,
876+
{ "changes/README.md": "readme\n", "changes/old.fix.md": "- Old.\n" },
877+
"base",
878+
);
879+
const base = git("rev-parse", "HEAD");
880+
await commit(
881+
git,
882+
dir,
883+
{
884+
"changes/watch.fix.md": "- Recover the watcher.\n",
885+
"changes/gone.breaking.md": "- Drop -x.\n",
886+
"changes/Bad Name.md": "- ?\n",
887+
"changes/README.md": "readme, edited\n",
888+
},
889+
`Fix and break\n\n${SIGNED}`,
890+
);
891+
// An entry added and then removed again is no entry of the range.
892+
await commit(
893+
git,
894+
dir,
895+
{ "changes/tmp.polish.md": "- Tmp.\n" },
896+
`Add\n\n${SIGNED}`,
897+
);
898+
git("rm", "-q", "changes/tmp.polish.md");
899+
git("commit", "-q", "-m", `Remove\n\n${SIGNED}`);
900+
const md = path.join(dir, "comment.md");
901+
const r = spawnSync(
902+
"node",
903+
[
904+
script,
905+
...["--out", path.join(dir, "page.html"), "--markdown", md],
906+
`${base}..HEAD`,
907+
],
908+
{ cwd: dir, encoding: "utf8" },
909+
);
910+
assert.equal(r.status, 0, r.stderr);
911+
const comment = await fs.readFile(md, "utf8");
912+
has(
913+
comment,
914+
"### Changelog\n\n- :boom: Drop -x.\n- :bug: Recover the watcher.\n- :grey_question: `Bad Name.md`\n\n",
915+
);
916+
lacks(comment, "Old.");
917+
lacks(comment, "Tmp.");
918+
lacks(comment, "readme");
919+
has(comment, "but no commit says what it breaks");
920+
});
921+
869922
test("--logins draws an author by the GitHub login, linked, others by name", async () => {
870923
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "commit-records-"));
871924
const git = (/** @type {string[]} */ ...a) =>

‎scripts/recordMarkdown.js‎

Lines changed: 51 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,12 @@ import {
3636

3737
/** @import { CommitRecord, Group, Section, FormEntry } from "./recordRender.js" */
3838

39+
/**
40+
* A changelog fragment the pull request adds: its category, the category's
41+
* emoji as the changelog heads it (`:boom:`), and its Markdown bullets.
42+
* @typedef {{ category: string, emoji: string, text: string }} ChangelogEntry
43+
*/
44+
3945
/** The characters a comment holds at most, with room for the action's tag. */
4046
export const BUDGET = 60000;
4147

@@ -52,6 +58,16 @@ export const BUDGET = 60000;
5258
const spans = s =>
5359
s.split(/(`[^`]+`|(?<![\w@`])@{1,2}\w[\w.]*(?:\([^()`\s]*\))?)/);
5460

61+
/**
62+
* Markdown kept as written, but for an `@name` outside backticks, drawn as
63+
* code so that it notifies no one.
64+
* @param {string} s
65+
*/
66+
export const unmentioned = s =>
67+
spans(s)
68+
.map((part, i) => (i % 2 && !part.startsWith("`") ? `\`${part}\`` : part))
69+
.join("");
70+
5571
/**
5672
* Prose as Markdown: code spans kept, attributes drawn as code, the
5773
* characters Markdown or GitHub would read as markup escaped. `#1234` and
@@ -429,33 +445,50 @@ export function commitMarkdown(r, full, change = "") {
429445
* The comment: a heading linking the page (`url`), in its title and as "Full
430446
* page", then each commit. Past the budget, the commits with the most
431447
* characters are drawn without their forms, and if that is not enough, the
432-
* last commits are left to the page. The heading names the commit drawn up
433-
* to (`head`, which GitHub links) and when (`updated`), since the comment is
434-
* edited in place on each push; the commits the last push added or edited
435-
* (`changes`) are marked.
448+
* last commits are left to the page. The commits the last push added or
449+
* edited (`changes`) are marked.
450+
*
451+
* The changelog entries the pull request adds (`changelog`, each fragment's
452+
* bullets led by its category's emoji) describe it as a whole, as its squash
453+
* commit will; a commit's `Breaking:` sections describe that commit. They
454+
* come first, under their own heading, which also notes a pull request
455+
* whose commits break something while no entry is a breaking change, or
456+
* the other way round.
436457
* @param {Group[]} groups
437-
* @param {{ url?: string, budget?: number, head?: string, updated?: string, changes?: Map<string, string> }} [options]
458+
* @param {{ url?: string, budget?: number, changes?: Map<string, string>, changelog?: ChangelogEntry[] }} [options]
438459
*/
439460
export function commentMarkdown(
440461
groups,
441-
{
442-
url = "",
443-
budget = BUDGET,
444-
head: tip = "",
445-
updated = "",
446-
changes = new Map(),
447-
} = {},
462+
{ url = "", budget = BUDGET, changes = new Map(), changelog } = {},
448463
) {
449464
const records = groups.flatMap(g => g.records);
450465
const n = records.length;
451-
const head = `### ${url ? `[Semantic Diff](${url})` : "Semantic Diff"}\n\n${[
452-
`${n} commit${n === 1 ? "" : "s"}`,
453-
tip,
454-
updated,
455-
url && `[Full page](${url})`,
466+
const breakingCommits = records.filter(r =>
467+
r.sections.some(s => s.name === "Breaking"),
468+
).length;
469+
const breakingEntry = changelog?.some(e => e.category === "breaking");
470+
const head = [
471+
changelog && "### Changelog",
472+
changelog &&
473+
(changelog.length
474+
? changelog
475+
.map(e => unmentioned(e.text).replace(/^- /gm, `- ${e.emoji} `))
476+
.join("\n")
477+
: "No changelog entry."),
478+
changelog &&
479+
breakingCommits &&
480+
!breakingEntry &&
481+
`<sub>:boom: ${breakingCommits === 1 ? "A commit says" : `${breakingCommits} commits say`} what it breaks (\`Breaking:\`), but no changelog entry is a breaking change.</sub>`,
482+
breakingEntry &&
483+
!breakingCommits &&
484+
"<sub>:boom: A changelog entry is a breaking change, but no commit says what it breaks (`Breaking:`).</sub>",
485+
`### ${url ? `[Semantic Diff](${url})` : "Semantic Diff"}`,
486+
[`${n} commit${n === 1 ? "" : "s"}`, url && `[Full page](${url})`]
487+
.filter(Boolean)
488+
.join(" · "),
456489
]
457490
.filter(Boolean)
458-
.join(" · ")}`;
491+
.join("\n\n");
459492
const change = (/** @type {CommitRecord} */ r) => changes.get(r.sha) ?? "";
460493
const drawn = records.map(r => commitMarkdown(r, true, change(r)));
461494
const size = () => drawn.reduce((t, d) => t + d.length + 2, head.length);

‎scripts/recordMarkdown.test.js‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -67,13 +67,11 @@ ${SIGNED}
6767
],
6868
{
6969
url: "https://cdn.example/pr-12/index.html?v=1",
70-
head: "aaaaaaaa0",
71-
updated: "Oct 23, 2026, 11:48 UTC",
7270
},
7371
);
7472
has(
7573
md,
76-
"### [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<details",
74+
"### [Semantic Diff](https://cdn.example/pr-12/index.html?v=1)\n\n1 commit · [Full page](https://cdn.example/pr-12/index.html?v=1)\n\n<details",
7775
);
7876
has(
7977
md,
@@ -266,3 +264,34 @@ test("an assistant co-author is drawn by its short name, others as written", ()
266264
]);
267265
has(md, "· A Person + Claude + Claude Person + No Email ·");
268266
});
267+
268+
test("the heading lists the changelog entries and notes a breaking mismatch", () => {
269+
const breaking = `Drop a flag\n\nBreaking: -x is gone.\n\n${SIGNED}\n`;
270+
const plain = `Fix a typo\n\n${SIGNED}\n`;
271+
const fix = {
272+
category: "fix",
273+
emoji: ":bug:",
274+
text: "- Recover @someone's `@raw` build.\n- Keep **watching**,\n after errors.",
275+
};
276+
const md = comment([plain], { changelog: [fix] });
277+
has(
278+
md,
279+
"### 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<details",
280+
);
281+
has(
282+
comment([plain], { changelog: [] }),
283+
"### Changelog\n\nNo changelog entry.\n\n### Semantic Diff\n\n1 commit\n\n",
284+
);
285+
assert.ok(comment([plain]).startsWith("### Semantic Diff\n\n1 commit\n\n"));
286+
has(
287+
comment([breaking], { changelog: [fix] }),
288+
"<sub>:boom: A commit says what it breaks (`Breaking:`), but no changelog entry is a breaking change.</sub>\n\n### Semantic Diff",
289+
);
290+
const boom = { category: "breaking", emoji: ":boom:", text: "- -x is gone." };
291+
const both = comment([breaking], { changelog: [boom] });
292+
assert.ok(!both.includes("<sub>:boom:"));
293+
has(
294+
comment([plain], { changelog: [boom] }),
295+
"<sub>:boom: A changelog entry is a breaking change, but no commit says what it breaks (`Breaking:`).</sub>",
296+
);
297+
});

0 commit comments

Comments
 (0)