From 8b1438789f468d7c97ac0647b42e7b09f07bf46a Mon Sep 17 00:00:00 2001 From: Christoph Knittel Date: Fri, 23 Oct 2026 16:14:21 +0000 Subject: [PATCH] Draw a pull request's commit authors by their GitHub login A login is shorter than a name and leads to the person. The workflow asks GitHub's API for the account each commit's email belongs to, and the page and comment link the login to the profile (a link, not a mention, which would notify); a commit without an account keeps the author's name. 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 | 5 ++-- .github/workflows/semantic_diff.yml | 10 ++++++- scripts/commitRecords.js | 29 ++++++++++++++---- scripts/commitRecords.test.js | 46 +++++++++++++++++++++++++++++ scripts/recordMarkdown.js | 2 +- scripts/recordRender.js | 21 ++++++++++--- 6 files changed, 99 insertions(+), 14 deletions(-) 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)}

`, ];