diff --git a/.claude/skills/records/SKILL.md b/.claude/skills/records/SKILL.md index d500dbc35b..262fe4cc11 100644 --- a/.claude/skills/records/SKILL.md +++ b/.claude/skills/records/SKILL.md @@ -193,15 +193,17 @@ node scripts/commitRecords.js --out page.html [--pr ] [--title ] \ - `--pr` and `--title` head the page with the pull request. `.github/workflows/semantic_diff.yml` renders each pull request's range and comments the records as Markdown (`--markdown --url `, - scripts/recordMarkdown.js) with the page's link: after a push that keeps - the previous head, only the commits it added (`--since `). + 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 `). - 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. ## 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, 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. +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 +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 33cb31b9d6..9090ffa519 100644 --- a/.github/workflows/semantic_diff.yml +++ b/.github/workflows/semantic_diff.yml @@ -2,9 +2,10 @@ name: Semantic Diff # Renders the commit records of each pull request (scripts/commitRecords.js) # as a review page on the CDN, and comments them on the pull request as -# Markdown (scripts/recordMarkdown.js) with the page's link. The page holds -# every commit; the comment holds the commits a push added, or every commit -# when the pull request is opened or reopened or its branch is force-pushed. +# Markdown (scripts/recordMarkdown.js) with the page's link. A pull request +# has one such comment, which each push updates in place to hold every +# commit, so the pull request shows its current records in one place; the +# comment marks the commits the last push added or edited. # # A pull request from a branch of this repository runs on pull_request, with # the workflow and renderer of its merge into the base branch: GitHub takes @@ -55,7 +56,6 @@ jobs: persist-credentials: false - name: Render semantic diff - id: render 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}") @@ -63,17 +63,16 @@ jobs: # The query string names the rendered commit, so each push gets a # URL that no cache has seen. url="https://cdn.rescript-lang.org/semantic-diff/pr-${PR_NUMBER}/index.html?v=${head}" - # A push that keeps the previous head adds commits on top of it: - # the comment holds those. After a force push the previous head is - # no ancestor (nor, from a fork, fetched), and the comment holds - # every commit. - since=() - if [ -n "$BEFORE_SHA" ] && git merge-base --is-ancestor "$BEFORE_SHA" "pr-${PR_NUMBER}" 2>/dev/null; then - since=(--since "$BEFORE_SHA") + # The comment marks what the push changed against the head before + # it. A force push leaves that head on no branch, but GitHub still + # serves it by its sha; when it can't be fetched, nothing is marked. + previous=() + if [ -n "$BEFORE_SHA" ] && { git cat-file -e "${BEFORE_SHA}^{commit}" 2>/dev/null || + git fetch --no-tags --filter=blob:none origin "$BEFORE_SHA" 2>/dev/null; }; then + previous=(--previous "$BEFORE_SHA") fi mkdir -p semantic-diff - node scripts/commitRecords.js --out semantic-diff/index.html --markdown semantic-diff/comment.md "${since[@]}" --url "$url" --pr "$PR_NUMBER" --title "$PR_TITLE" "${base}..pr-${PR_NUMBER}" - echo "head=${head}" >> "$GITHUB_OUTPUT" + 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}" - name: Setup Rclone uses: cometkim/rclone-actions/setup-rclone@8d9c6b95341f7698609deee65c3d62af42893288 # main, 2025-06-18 @@ -91,14 +90,12 @@ jobs: - name: Upload semantic diff run: rclone copyto --s3-no-check-bucket semantic-diff/index.html "rescript:cdn-assets/semantic-diff/pr-${PR_NUMBER}/index.html" - # A push that adds only merge commits writes no comment. - name: Comment semantic diff - if: hashFiles('semantic-diff/comment.md') != '' uses: thollander/actions-comment-pull-request@v3 with: pr-number: ${{ github.event.pull_request.number }} - # Each push gets its own comment, so the comments of earlier pushes - # stay; a re-run of the same head updates its comment. - comment-tag: semantic-diff-${{ steps.render.outputs.head }} + # The pull request's one comment, edited in place on each push; + # edits notify no one. + comment-tag: semantic-diff mode: upsert file-path: semantic-diff/comment.md diff --git a/scripts/commitRecords.js b/scripts/commitRecords.js index c719c8e717..d6ac6eced5 100644 --- a/scripts/commitRecords.js +++ b/scripts/commitRecords.js @@ -12,8 +12,8 @@ * --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 - * --since , the comment draws only the commits lacks, and is - * not written when there are none. + * --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"; @@ -31,20 +31,84 @@ const git = args => stdio: ["ignore", "pipe", "ignore"], }); +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. */ +const utc = (/** @type {Date} */ d) => { + const two = (/** @type {number} */ n) => String(n).padStart(2, "0"); + return `${MONTHS[d.getUTCMonth()]} ${two(d.getUTCDate())}, ${d.getUTCFullYear()}, ${two(d.getUTCHours())}:${two(d.getUTCMinutes())} UTC`; +}; + +/** A commit's message, trimmed. @param {string} rev */ +const message = rev => git(["show", "-s", "--format=%B", rev]).trim(); + +/** The messages of the records read, by short hash. */ +const messages = new Map(); + /** - * The record of one commit: its short hash, author, date and message. + * The record of one commit: its short hash, author, author date (in UTC, + * to the minute) and message. * @param {string} rev * @returns {CommitRecord} */ function readRecord(rev) { const show = (/** @type {string[]} */ ...format) => git(["show", "-s", ...format, rev]); - return parseRecord({ + const record = parseRecord({ sha: show("--format=%h", "--abbrev=9").trim(), author: show("--format=%an").trim(), - date: show("--format=%ad", "--date=format:%b %d, %Y").trim(), + date: utc(new Date(show("--format=%aI").trim())), message: show("--format=%B"), }); + messages.set(record.sha, message(rev)); + return record; +} + +/** + * What the last push changed, against the head before it: the records it + * added (`new`) or whose message it edited under the same subject + * (`edited`), by short hash. The previous commits are those of `previous` + * that the range's base lacks. + * @param {CommitRecord[]} records + * @param {string} base + * @param {string} previous + */ +function lastPush(records, base, previous) { + const before = git([ + "rev-list", + "--no-merges", + previous, + `^${git(["merge-base", base, previous]).trim()}`, + ]) + .split("\n") + .filter(Boolean) + .map(message); + const subject = (/** @type {string} */ m) => m.split("\n")[0]; + const kept = new Set(before); + const subjects = new Set(before.map(subject)); + /** @type {Map} */ + const changes = new Map(); + for (const r of records) { + const m = messages.get(r.sha) ?? ""; + if (!kept.has(m)) + changes.set(r.sha, subjects.has(subject(m)) ? "edited" : "new"); + } + return changes; +} + +/** + * The comment's marks of the last push, or none when the previous head + * can't be read, so that the comment is still drawn. + * @param {CommitRecord[]} records + * @param {string} base + * @param {string} previous + */ +function marks(records, base, previous) { + try { + return { changes: lastPush(records, base, previous) }; + } catch { + return {}; + } } /** @@ -67,7 +131,7 @@ const options = {}; const targets = []; for (let i = 0; i < args.length; i++) { if ( - ["--out", "--pr", "--title", "--markdown", "--url", "--since"].includes( + ["--out", "--pr", "--title", "--markdown", "--url", "--previous"].includes( args[i], ) && i + 1 < args.length @@ -78,7 +142,7 @@ for (let i = 0; i < args.length; i++) { const outFile = options.out; if (!outFile) { console.error( - "usage: commitRecords.js --out [--pr ] [--title ] [--markdown [--url ] [--since ]] ...", + "usage: commitRecords.js --out [--pr ] [--title ] [--markdown [--url ] [--previous ]] ...", ); process.exit(1); } @@ -89,35 +153,20 @@ const heading = { const groups = targets.map(group); const body = pageBody(groups, heading); if (options.markdown) { - const since = options.since; - // The commits of the targets that `since` does not reach. - const fresh = new Set( - since - ? targets.flatMap(t => - git(["rev-list", "--abbrev-commit", "--abbrev=9", t, `^${since}`]) - .split("\n") - .filter(Boolean), - ) - : [], + const [base, tip] = targets[targets.length - 1].split(".."); + const records = groups.flatMap(g => g.records); + writeFileSync( + options.markdown, + commentMarkdown(groups, { + url: options.url, + head: git(["rev-parse", "--short=9", tip ?? base]).trim(), + updated: utc(new Date()), + ...(options.previous && tip + ? marks(records, base, options.previous) + : {}), + }), ); - const shown = since - ? groups.map(g => ({ - ...g, - records: g.records.filter(r => fresh.has(r.sha)), - })) - : groups; - if (shown.some(g => g.records.length)) { - writeFileSync( - options.markdown, - commentMarkdown(shown, { - url: options.url, - total: since - ? groups.reduce((n, g) => n + g.records.length, 0) - : undefined, - }), - ); - console.log(options.markdown); - } + console.log(options.markdown); } const head = `${esc(pageTitle(heading))} diff --git a/scripts/commitRecords.test.js b/scripts/commitRecords.test.js index 1f0cff5f57..702b4732b6 100644 --- a/scripts/commitRecords.test.js +++ b/scripts/commitRecords.test.js @@ -132,7 +132,7 @@ test("a minimal record draws its identity and subject", async () => { has(html, "

Fix a typo

"); assert.match( html, - /
[0-9a-f]{9}<\/code> · A Person with B Person · [A-Z][a-z]{2} \d{2}, \d{4} · #7<\/a><\/div>/, + /
[0-9a-f]{9}<\/code> · A Person \+ B Person · [A-Z][a-z]{2} \d{2}, \d{4}, \d{2}:\d{2} UTC · #7<\/a><\/div>/, ); lacks(html, '
'); lacks(html, "Signed-off-by"); @@ -457,7 +457,7 @@ test("Breaking and Review sections right above the sign-off are drawn", async () ); has(html, '
  • Is -bs-x still read? No.
  • '); - has(html, "
    · A Person with B Person · "); + has(html, "
    · A Person + B Person · "); lacks(html, "Signed-off-by"); }); @@ -504,7 +504,8 @@ test("trailers of any key end the message and are not drawn", async () => { const html = await renderOne( `Fix a thing\n\nCo-Authored-By: Claude Opus 5.5 \nClaude-Session: https://claude.ai/code/session_x\n${SIGNED}\n`, ); - has(html, " · A Person with Claude Opus 5.5 · "); + has(html, " · A Person + Claude · "); + lacks(html, "Opus"); lacks(html, "Claude-Session"); lacks(html, "session_x"); lacks(html, '
    '); }); -test("--since leaves the comment the commits a push added, the page every commit", async () => { +test("--markdown draws every commit into the comment, with the page's link", 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(); @@ -841,36 +842,66 @@ test("--since leaves the comment the commits a push added, the page every commit git("config", "user.email", "a@example.com"); await commit(git, dir, { "a.txt": "a\n" }, "base"); await commit(git, dir, {}, `First change\n\n${SIGNED}`); - const before = git("rev-parse", "HEAD"); await commit(git, dir, {}, `Second change\n\n${SIGNED}`); - const out = path.join(dir, "page.html"); const md = path.join(dir, "comment.md"); - const run = (/** @type {string} */ since) => { - const r = spawnSync( - "node", - [ - script, - "--out", - out, - "--markdown", - md, - "--since", - since, - "HEAD~2..HEAD", - ], - { cwd: dir, encoding: "utf8" }, - ); - assert.equal(r.status, 0, r.stderr); - }; - run(before); + const r = spawnSync( + "node", + [ + script, + ...["--out", path.join(dir, "page.html"), "--markdown", md], + ...["--url", "https://cdn.example/pr-1/index.html", "HEAD~2..HEAD"], + ], + { cwd: dir, encoding: "utf8" }, + ); + assert.equal(r.status, 0, r.stderr); const comment = await fs.readFile(md, "utf8"); - has(comment, "1 new commit (2 total)"); + const tip = git("rev-parse", "--short=9", "HEAD"); + assert.match( + 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, { "a.txt": "a\n" }, "base"); + const base = git("rev-parse", "HEAD"); + await commit(git, dir, {}, `Kept\n\n${SIGNED}`); + await commit(git, dir, {}, `Edited\n\nFirst words.\n\n${SIGNED}`); + await commit(git, dir, {}, `Dropped\n\n${SIGNED}`); + const previous = git("rev-parse", "HEAD"); + // A force push: the last commit dropped, the one below reworded, one added. + git("reset", "-q", "--hard", "HEAD~2"); + await commit(git, dir, {}, `Edited\n\nOther words.\n\n${SIGNED}`); + await commit(git, dir, {}, `Added\n\n${SIGNED}`); + const md = path.join(dir, "comment.md"); + const r = spawnSync( + "node", + [ + script, + ...["--out", path.join(dir, "page.html"), "--markdown", md], + ...["--previous", previous, `${base}..HEAD`], + ], + { cwd: dir, encoding: "utf8" }, + ); + assert.equal(r.status, 0, r.stderr); + const comment = await fs.readFile(md, "utf8"); + has(comment, "Kept"); + const marked = (/** @type {string} */ subject) => + comment.split(" d.includes(`${subject}`)) ?? ""; + lacks(marked("Kept"), "✨"); + lacks(marked("Kept"), "✏️"); + has(marked("Edited"), "✏️ Edited"); + has(marked("Edited"), " · edited"); + has(marked("Added"), "✨ Added"); + has(marked("Added"), " · new"); }); diff --git a/scripts/recordMarkdown.js b/scripts/recordMarkdown.js index 25cd4f8fab..41f047abd9 100644 --- a/scripts/recordMarkdown.js +++ b/scripts/recordMarkdown.js @@ -14,6 +14,7 @@ import { atomDiff, BELOW, BLOCK, + byline, cellsOf, columnWidths, dedent, @@ -386,20 +387,26 @@ function section(s, parts, full) { return out.join("\n\n"); } +/** The sign before a commit's subject that marks what the last push did. */ +const SIGNS = { new: "✨", edited: "✏️" }; + /** * A commit as a collapsible block: its subject, identity and message drawn; - * without `full`, its forms are left to the page. + * without `full`, its forms are left to the page. A `change` (`new` or + * `edited`), what the last push did to it, puts a sign before the subject + * and the word in the identity line. * @param {CommitRecord} r * @param {boolean} full + * @param {string} [change] */ -export function commitMarkdown(r, full) { +export function commitMarkdown(r, full, change = "") { const pr = /\(#(\d+)\)\s*$/.exec(r.subject)?.[1] ?? ""; const subject = r.subject.replace(/\s*\(#\d+\)\s*$/, ""); const joined = (/** @type {Section} */ s) => [...s.text, ...s.block.map(l => l.trim())].join(" ").trim(); const out = [ - `
    ${inlineHtml(subject)}`, - `${esc(r.sha)} · ${inline(r.author)}${r.coauthors.length ? ` with ${inline(r.coauthors.join(", "))}` : ""}${r.date ? ` · ${esc(r.date)}` : ""}${pr ? ` · #${pr}` : ""}`, + `
    ${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)}` : ""}`, ...sectionGroups(r.sections.filter(s => !BELOW.has(s.name))).map(g => section(g.section, g.parts, full), ), @@ -419,29 +426,45 @@ export function commitMarkdown(r, full) { } /** - * The comment: a heading linking the 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. - * With `total`, the commits are the new ones a push added to a pull request - * of `total` commits. + * 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. * @param {Group[]} groups - * @param {{ url?: string, budget?: number, total?: number }} [options] + * @param {{ url?: string, budget?: number, head?: string, updated?: string, changes?: Map }} [options] */ export function commentMarkdown( groups, - { url = "", budget = BUDGET, total } = {}, + { + url = "", + budget = BUDGET, + head: tip = "", + updated = "", + changes = new Map(), + } = {}, ) { const records = groups.flatMap(g => g.records); const n = records.length; - const head = `### Semantic Diff\n\n${n}${total === undefined ? "" : " new"} commit${n === 1 ? "" : "s"}${total === undefined || total === n ? "" : ` (${total} total)`}${url ? ` · [Full page](${url})` : ""}`; - const drawn = records.map(r => commitMarkdown(r, true)); + const head = `### ${url ? `[Semantic Diff](${url})` : "Semantic Diff"}\n\n${[ + `${n} commit${n === 1 ? "" : "s"}`, + tip, + updated, + url && `[Full page](${url})`, + ] + .filter(Boolean) + .join(" · ")}`; + 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); const order = drawn .map((d, k) => ({ k, length: d.length })) .sort((x, y) => y.length - x.length); for (const { k } of order) { if (size() <= budget) break; - drawn[k] = commitMarkdown(records[k], false); + drawn[k] = commitMarkdown(records[k], false, change(records[k])); } let left = 0; while (drawn.length && size() > budget) { diff --git a/scripts/recordMarkdown.test.js b/scripts/recordMarkdown.test.js index bbfe695a9a..aa7b9d049c 100644 --- a/scripts/recordMarkdown.test.js +++ b/scripts/recordMarkdown.test.js @@ -9,7 +9,7 @@ const SIGNED = "Signed-off-by: A Person "; /** * The comment drawn from commit messages, one commit each. * @param {string[]} messages - * @param {{ url?: string, budget?: number }} [options] + * @param {Parameters[1]} [options] */ const comment = (messages, options) => commentMarkdown( @@ -65,17 +65,21 @@ Co-Authored-By: B Person ${SIGNED} `, ], - { url: "https://cdn.example/pr-12/index.html?v=1" }, + { + url: "https://cdn.example/pr-12/index.html?v=1", + head: "aaaaaaaa0", + updated: "Oct 23, 2026, 11:48 UTC", + }, ); has( md, - "### Semantic Diff\n\n1 commit · [Full page](https://cdn.example/pr-12/index.html?v=1)", + "### [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\nRepresent raw extensions as their own node", ); - has(md, "aaaaaaaa0 · A Person with B Person · Oct 22, 2026 · #12"); + has(md, "aaaaaaaa0 · A Person + B Person · Oct 22, 2026 · #12"); has(md, "> **Why**\n>\n> A raw extension carries JavaScript."); has(md, "**AST modified** (raw extensions)"); has(md, "```rescript\nlet f = %raw(`(a) => a`)\n```"); @@ -232,3 +236,33 @@ ${SIGNED} assert.ok(tiny.length <= 500, `${tiny.length}`); has(tiny, "more commit"); }); + +test("the commits the last push added or edited carry a dot and the word", () => { + const message = (/** @type {string} */ subject) => + `${subject}\n\n${SIGNED}\n`; + const md = comment([message("Kept"), message("Edited"), message("Added")], { + changes: new Map([ + ["aaaaaaaa1", "edited"], + ["aaaaaaaa2", "new"], + ]), + }); + has( + md, + "Kept\n\naaaaaaaa0 · A Person · Oct 22, 2026", + ); + has( + md, + "✏️ Edited\n\naaaaaaaa1 · A Person · Oct 22, 2026 · edited", + ); + has( + md, + "✨ Added\n\naaaaaaaa2 · A Person · Oct 22, 2026 · new", + ); +}); + +test("an assistant co-author is drawn by its short name, others as written", () => { + const md = comment([ + `Fix it\n\nCo-Authored-By: Claude Sonnet 5.5 \nCo-Authored-By: Claude Person \nCo-Authored-By: No Email\n${SIGNED}\n`, + ]); + has(md, "· A Person + Claude + Claude Person + No Email ·"); +}); diff --git a/scripts/recordRender.js b/scripts/recordRender.js index 37a647ae9f..b011899fdc 100644 --- a/scripts/recordRender.js +++ b/scripts/recordRender.js @@ -166,9 +166,16 @@ function parseMessage(text) { return { subject: lines[0], sections, trailers: lines.slice(end) }; } +/** + * AI assistants by the email their `Co-Authored-By:` trailers give, and the + * short name drawn for them: the name in the trailer changes with the model + * ("Claude Opus 5.5"), the email does not. + */ +const ASSISTANTS = new Map([["noreply@anthropic.com", "Claude"]]); + /** * A commit's record: its identity and its message, parsed; the co-authors - * are the names its `Co-Authored-By:` trailers give. + * are the names its `Co-Authored-By:` trailers give, an assistant's short. * @param {CommitInfo} commit * @returns {CommitRecord} */ @@ -176,10 +183,20 @@ export function parseRecord(commit) { const { subject, sections, trailers } = parseMessage(commit.message); const coauthors = trailers .filter(l => /^Co-Authored-By:/i.test(l)) - .map(l => l.replace(/^Co-Authored-By:\s*/i, "").replace(/\s*<[^>]*>/, "")); + .map(l => { + const [, name, email = ""] = + /^Co-Authored-By:\s*(.*?)\s*(?:<([^>]*)>)?\s*$/i.exec(l) ?? []; + return ASSISTANTS.get(email.toLowerCase()) ?? name; + }); return { ...commit, subject, sections, coauthors: [...new Set(coauthors)] }; } +/** + * Who made a commit: its author and co-authors, as `A Person + Claude`. + * @param {CommitRecord} r + */ +export const byline = r => [r.author, ...r.coauthors].join(" + "); + // --------------------------------------------------------------------------- // Code, highlighted by token @@ -1513,7 +1530,7 @@ function card(r) { [...s.text, ...s.block.map(l => l.trim())].join(" ").trim(); const out = [ `