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
14 changes: 8 additions & 6 deletions .claude/skills/records/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -193,15 +193,17 @@ node scripts/commitRecords.js --out page.html [--pr <number>] [--title <text>] \
- `--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 <file> --url <page>`,
scripts/recordMarkdown.js) with the page's link: after a push that keeps
the previous head, only the commits it added (`--since <previous head>`).
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>`).
- 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.
33 changes: 15 additions & 18 deletions .github/workflows/semantic_diff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -55,25 +56,23 @@ 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}")
head=$(git rev-parse --short=9 "pr-${PR_NUMBER}")
# 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
Expand All @@ -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
119 changes: 84 additions & 35 deletions scripts/commitRecords.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,8 @@
* --pr <number> and --title <text> head the page with the pull request.
* --markdown <file> also draws the records as the pull request's comment
* (scripts/recordMarkdown.js), linking the page at --url <url>. With
* --since <rev>, the comment draws only the commits <rev> lacks, and is
* not written when there are none.
* --previous <rev>, 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";
Expand All @@ -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<string, "new" | "edited">} */
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 {};
}
}

/**
Expand All @@ -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
Expand All @@ -78,7 +142,7 @@ for (let i = 0; i < args.length; i++) {
const outFile = options.out;
if (!outFile) {
console.error(
"usage: commitRecords.js --out <page.html> [--pr <number>] [--title <text>] [--markdown <comment.md> [--url <page url>] [--since <rev>]] <rev | base..tip>...",
"usage: commitRecords.js --out <page.html> [--pr <number>] [--title <text>] [--markdown <comment.md> [--url <page url>] [--previous <rev>]] <rev | base..tip>...",
);
process.exit(1);
}
Expand All @@ -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 = `<title>${esc(pageTitle(heading))}</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&family=STIX+Two+Text:ital,wght@0,400;0,500;1,400&display=swap">
Expand Down
95 changes: 63 additions & 32 deletions scripts/commitRecords.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ test("a minimal record draws its identity and subject", async () => {
has(html, "<h3>Fix a typo</h3>");
assert.match(
html,
/<div class="meta"><code>[0-9a-f]{9}<\/code> · A Person with B Person · [A-Z][a-z]{2} \d{2}, \d{4} · <a href="https:\/\/github.com\/rescript-lang\/rescript\/pull\/7">#7<\/a><\/div>/,
/<div class="meta"><code>[0-9a-f]{9}<\/code> · A Person \+ B Person · [A-Z][a-z]{2} \d{2}, \d{4}, \d{2}:\d{2} UTC · <a href="https:\/\/github.com\/rescript-lang\/rescript\/pull\/7">#7<\/a><\/div>/,
);
lacks(html, '<div class="below">');
lacks(html, "Signed-off-by");
Expand Down Expand Up @@ -457,7 +457,7 @@ test("Breaking and Review sections right above the sign-off are drawn", async ()
);
has(html, '<article class="card breaking"');
has(html, '<ul class="review"><li>Is -bs-x still read? No.</li></ul>');
has(html, "</code> · A Person with B Person · ");
has(html, "</code> · A Person + B Person · ");
lacks(html, "Signed-off-by");
});

Expand Down Expand Up @@ -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 <noreply@anthropic.com>\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, '<section class="block');
Expand Down Expand Up @@ -832,7 +833,7 @@ ${SIGNED}
has(html, '<span class="ln del" style="--r:2;--c:1;--i:0">');
});

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();
Expand All @@ -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<details`,
),
);
has(comment, "First change");
has(comment, "Second change");
lacks(comment, "First change");
const html = await fs.readFile(out, "utf8");
has(html, "First change");
has(html, "Second change");
await fs.rm(md);
run("HEAD");
await assert.rejects(fs.access(md));
});

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) =>
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, "<summary><b>Kept</b></summary>");
const marked = (/** @type {string} */ subject) =>
comment.split("<details").find(d => d.includes(`<b>${subject}</b>`)) ?? "";
lacks(marked("Kept"), "✨");
lacks(marked("Kept"), "✏️");
has(marked("Edited"), "<summary>✏️ <b>Edited</b></summary>");
has(marked("Edited"), " · edited</sub>");
has(marked("Added"), "<summary>✨ <b>Added</b></summary>");
has(marked("Added"), " · new</sub>");
});
Loading
Loading