diff --git a/.claude/skills/records/SKILL.md b/.claude/skills/records/SKILL.md index 262fe4cc11..97ebeb363e 100644 --- a/.claude/skills/records/SKILL.md +++ b/.claude/skills/records/SKILL.md @@ -202,8 +202,9 @@ node scripts/commitRecords.js --out page.html [--pr ] [--title ] \ ## What the page shows The page is the heading, then a card per commit. A card shows the commit's -short sha, author and co-authors (an AI assistant by its short name, such -as Claude), date, the pull request a `(#1234)` at the end of the subject +short sha, author (in a pull request's page and comment, the GitHub login +linking the profile) and co-authors (an AI assistant by its short name, +such as Claude), date, the pull request a `(#1234)` at the end of the subject names, and the message drawn: the subject, the prose, each object block with its example and forms, the `Breaking:` sections as banners, and the `Review:` sections as a list. diff --git a/.github/workflows/semantic_diff.yml b/.github/workflows/semantic_diff.yml index 9090ffa519..a4c28f2632 100644 --- a/.github/workflows/semantic_diff.yml +++ b/.github/workflows/semantic_diff.yml @@ -56,6 +56,8 @@ jobs: persist-credentials: false - name: Render semantic diff + env: + GH_TOKEN: ${{ github.token }} run: | git fetch --no-tags --filter=blob:none origin "refs/pull/${PR_NUMBER}/head:refs/heads/pr-${PR_NUMBER}" base=$(git merge-base "$BASE_SHA" "pr-${PR_NUMBER}") @@ -72,7 +74,13 @@ jobs: previous=(--previous "$BEFORE_SHA") fi mkdir -p semantic-diff - node scripts/commitRecords.js --out semantic-diff/index.html --markdown semantic-diff/comment.md "${previous[@]}" --url "$url" --pr "$PR_NUMBER" --title "$PR_TITLE" "${base}..pr-${PR_NUMBER}" + # The authors' GitHub logins, which git doesn't know; the API links + # a commit to the account its email belongs to. Without them, the + # authors are drawn by name. + gh api --paginate "repos/${GITHUB_REPOSITORY}/pulls/${PR_NUMBER}/commits?per_page=100" \ + --jq '.[] | select(.author != null) | "\(.sha) \(.author.login)"' > semantic-diff/logins.txt || + : > semantic-diff/logins.txt + node scripts/commitRecords.js --out semantic-diff/index.html --markdown semantic-diff/comment.md "${previous[@]}" --logins semantic-diff/logins.txt --url "$url" --pr "$PR_NUMBER" --title "$PR_TITLE" "${base}..pr-${PR_NUMBER}" - name: Setup Rclone uses: cometkim/rclone-actions/setup-rclone@8d9c6b95341f7698609deee65c3d62af42893288 # main, 2025-06-18 diff --git a/scripts/commitRecords.js b/scripts/commitRecords.js index d6ac6eced5..016800b231 100644 --- a/scripts/commitRecords.js +++ b/scripts/commitRecords.js @@ -11,13 +11,15 @@ * node scripts/commitRecords.js --out review.html ..... * --pr and --title head the page with the pull request. * --markdown also draws the records as the pull request's comment - * (scripts/recordMarkdown.js), linking the page at --url . With + * (scripts/recordMarkdown.js), linking the page at --url . + * --logins , lines ` `, names the GitHub accounts of + * the commits' authors, drawn in place of their names. With * --previous , the pull request's head before the last push, the * comment marks the commits that push added or whose message it edited. */ import { execFileSync } from "node:child_process"; -import { writeFileSync } from "node:fs"; +import { readFileSync, writeFileSync } from "node:fs"; import { commentMarkdown } from "./recordMarkdown.js"; import { CSS, esc, pageBody, pageTitle, parseRecord } from "./recordRender.js"; @@ -57,6 +59,7 @@ function readRecord(rev) { const record = parseRecord({ sha: show("--format=%h", "--abbrev=9").trim(), author: show("--format=%an").trim(), + login: logins.get(show("--format=%H").trim()), date: utc(new Date(show("--format=%aI").trim())), message: show("--format=%B"), }); @@ -131,9 +134,15 @@ const options = {}; const targets = []; for (let i = 0; i < args.length; i++) { if ( - ["--out", "--pr", "--title", "--markdown", "--url", "--previous"].includes( - args[i], - ) && + [ + "--out", + "--pr", + "--title", + "--markdown", + "--url", + "--previous", + "--logins", + ].includes(args[i]) && i + 1 < args.length ) options[args[i].slice(2)] = args[++i]; @@ -142,10 +151,18 @@ for (let i = 0; i < args.length; i++) { const outFile = options.out; if (!outFile) { console.error( - "usage: commitRecords.js --out [--pr ] [--title ] [--markdown [--url ] [--previous ]] ...", + "usage: commitRecords.js --out [--pr ] [--title ] [--markdown [--url ] [--previous ]] [--logins ] ...", ); process.exit(1); } +/** The GitHub logins of the commits' authors, by full sha. */ +const logins = new Map( + (options.logins ? readFileSync(options.logins, "utf8") : "") + .split("\n") + .map(l => l.trim().split(/\s+/)) + .filter(f => f.length === 2) + .map(([sha, login]) => [sha, login]), +); const heading = { pr: (options.pr ?? "").replace(/\D/g, ""), title: options.title ?? "", diff --git a/scripts/commitRecords.test.js b/scripts/commitRecords.test.js index 702b4732b6..fe46b8f18b 100644 --- a/scripts/commitRecords.test.js +++ b/scripts/commitRecords.test.js @@ -866,6 +866,52 @@ test("--markdown draws every commit into the comment, with the page's link", asy has(comment, "Second change"); }); +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) => + 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, { "a.txt": "a\n" }, "base"); + await commit( + git, + dir, + {}, + `Known\n\nCo-Authored-By: Claude Opus 5.5 \n${SIGNED}`, + ); + const known = git("rev-parse", "HEAD"); + await commit(git, dir, {}, `Unknown\n\n${SIGNED}`); + await commit(git, dir, {}, `Odd login\n\n${SIGNED}`); + const odd = git("rev-parse", "HEAD"); + const logins = path.join(dir, "logins.txt"); + await fs.writeFile(logins, `${known} a-person\n${odd} bot[bot]\n`); + const out = path.join(dir, "page.html"); + const md = path.join(dir, "comment.md"); + const r = spawnSync( + "node", + [ + script, + ...["--out", out, "--markdown", md, "--logins", logins], + "HEAD~3..HEAD", + ], + { cwd: dir, encoding: "utf8" }, + ); + assert.equal(r.status, 0, r.stderr); + const html = await fs.readFile(out, "utf8"); + has( + html, + ' · a-person + Claude · ', + ); + const comment = await fs.readFile(md, "utf8"); + has(comment, " · [a-person](https://github.com/a-person) + Claude · "); + const card = (/** @type {string} */ subject) => + comment.split(" d.includes(`${subject}`)) ?? ""; + has(card("Unknown"), " · A Person · "); + has(card("Odd login"), " · A Person · "); + lacks(comment, "@a-person"); +}); + test("--previous marks the commits the last push added or edited", 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 41f047abd9..4886f2125a 100644 --- a/scripts/recordMarkdown.js +++ b/scripts/recordMarkdown.js @@ -406,7 +406,7 @@ export function commitMarkdown(r, full, change = "") { [...s.text, ...s.block.map(l => l.trim())].join(" ").trim(); const out = [ `
${change ? `${SIGNS[/** @type {keyof typeof SIGNS} */ (change)] ?? ""} ` : ""}${inlineHtml(subject)}`, - `${esc(r.sha)} · ${inline(byline(r))}${r.date ? ` · ${esc(r.date)}` : ""}${pr ? ` · #${pr}` : ""}${change ? ` · ${esc(change)}` : ""}`, + `${esc(r.sha)} · ${byline(r, inline, (login, url) => `[${login}](${url})`)}${r.date ? ` · ${esc(r.date)}` : ""}${pr ? ` · #${pr}` : ""}${change ? ` · ${esc(change)}` : ""}`, ...sectionGroups(r.sections.filter(s => !BELOW.has(s.name))).map(g => section(g.section, g.parts, full), ), diff --git a/scripts/recordRender.js b/scripts/recordRender.js index b011899fdc..a2ba19f245 100644 --- a/scripts/recordRender.js +++ b/scripts/recordRender.js @@ -66,7 +66,9 @@ export const PART = /^(?:Example|(Before|After) \(([a-z]+)\))$/; /** * @typedef {{ name: string, text: string[], block: string[] }} Section - * @typedef {{ sha: string, author: string, date: string, message: string }} CommitInfo + * @typedef {{ + * sha: string, author: string, login?: string, date: string, message: string + * }} CommitInfo * @typedef {CommitInfo & { * subject: string, sections: Section[], coauthors: string[] * }} CommitRecord @@ -192,10 +194,21 @@ export function parseRecord(commit) { } /** - * Who made a commit: its author and co-authors, as `A Person + Claude`. + * Who made a commit, as `A Person + Claude`: its author, by the GitHub login + * when known, and its co-authors, each name drawn by `name` and the login + * as a link to the profile by `profile` (a link, not an `@` mention, which + * would notify the user). * @param {CommitRecord} r + * @param {(name: string) => string} name + * @param {(login: string, url: string) => string} profile */ -export const byline = r => [r.author, ...r.coauthors].join(" + "); +export const byline = (r, name, profile) => + [ + r.login && /^[A-Za-z0-9-]+$/.test(r.login) + ? profile(r.login, `https://github.com/${r.login}`) + : name(r.author), + ...r.coauthors.map(name), + ].join(" + "); // --------------------------------------------------------------------------- // Code, highlighted by token @@ -1530,7 +1543,7 @@ function card(r) { [...s.text, ...s.block.map(l => l.trim())].join(" ").trim(); const out = [ `
`, - `
${esc(r.sha)} · ${esc(byline(r))}${r.date ? ` · ${esc(r.date)}` : ""}${ + `
${esc(r.sha)} · ${byline(r, esc, (login, url) => `${login}`)}${r.date ? ` · ${esc(r.date)}` : ""}${ pr ? ` · #${pr}` : "" }

${inline(subject)}

`, ];