Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .claude/skills/records/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,7 +195,11 @@ node scripts/commitRecords.js --out page.html [--pr <number>] [--title <text>] \
and comments the records as Markdown (`--markdown <file> --url <page>`,
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 <previous head>`).
edited (`--previous <previous head>`). 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.

Expand Down
55 changes: 53 additions & 2 deletions scripts/commitRecords.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 =>
Expand All @@ -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. */
Expand Down Expand Up @@ -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)
: {}),
Expand Down
63 changes: 58 additions & 5 deletions scripts/commitRecords.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<details`,
),
"### 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",
);
has(comment, "First change");
has(comment, "Second change");
});

test("the comment lists the changelog entries a range adds, breaking first", async () => {
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) =>
Expand Down
90 changes: 72 additions & 18 deletions scripts/recordMarkdown.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

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

/**
* Markdown kept as written, but for an `@name` outside backticks, drawn as
* code so that it notifies no one.
* @param {string} s
*/
export const unmentioned = s =>
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
Expand Down Expand Up @@ -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<sub>${left}${drawn.length ? " more" : ""} changelog entr${left === 1 ? "y" : "ies"} under \`changes/\`.</sub>`,
);
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<string, string> }} [options]
* @param {{ url?: string, budget?: number, changes?: Map<string, string>, 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 &&
`<sub>:boom: ${breakingCommits === 1 ? "A commit says" : `${breakingCommits} commits say`} what it breaks (\`Breaking:\`), but no changelog entry is a breaking change.</sub>`,
breakingEntry &&
!breakingCommits &&
"<sub>:boom: A changelog entry is a breaking change, but no commit says what it breaks (`Breaking:`).</sub>",
`### ${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);
Expand Down
62 changes: 59 additions & 3 deletions scripts/recordMarkdown.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<details",
"### [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",
);
has(
md,
Expand Down Expand Up @@ -266,3 +264,61 @@ test("an assistant co-author is drawn by its short name, others as written", ()
]);
has(md, "· A Person + Claude + Claude Person + No Email ·");
});

test("the heading lists the changelog entries and notes a breaking mismatch", () => {
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<details",
);
has(
comment([plain], { changelog: [] }),
"### Changelog\n\nNo changelog entry.\n\n### Semantic Diff\n\n1 commit\n\n",
);
assert.ok(comment([plain]).startsWith("### Semantic Diff\n\n1 commit\n\n"));
has(
comment([breaking], { changelog: [fix] }),
"<sub>:boom: A commit says what it breaks (`Breaking:`), but no changelog entry is a breaking change.</sub>\n\n### Semantic Diff",
);
const boom = { category: "breaking", emoji: ":boom:", text: "- -x is gone." };
const both = comment([breaking], { changelog: [boom] });
assert.ok(!both.includes("<sub>:boom:"));
has(
comment([plain], { changelog: [boom] }),
"<sub>:boom: A changelog entry is a breaking change, but no commit says what it breaks (`Breaking:`).</sub>",
);
});

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,
"<sub>17 more changelog entries under `changes/`.</sub>\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, "<sub>1 changelog entry under `changes/`.</sub>");
});
Loading