diff --git a/.claude/skills/records/SKILL.md b/.claude/skills/records/SKILL.md index d500dbc35b..8d5848f0c3 100644 --- a/.claude/skills/records/SKILL.md +++ b/.claude/skills/records/SKILL.md @@ -195,6 +195,11 @@ node scripts/commitRecords.js --out page.html [--pr ] [--title ] \ 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 `). +- The viewer draws records in the browser from GitHub's API, with the same + renderer: `https://cdn.rescript-lang.org/semantic-diff/view/index.html` + with `?pr=`, `?range=...` or `?commit=[,]`, + at most 250 commits (`scripts/recordViewer.js`, + `.github/workflows/semantic_diff_viewer.yml`). - 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. diff --git a/.github/workflows/semantic_diff_viewer.yml b/.github/workflows/semantic_diff_viewer.yml new file mode 100644 index 0000000000..61d558c719 --- /dev/null +++ b/.github/workflows/semantic_diff_viewer.yml @@ -0,0 +1,78 @@ +name: Semantic Diff Viewer + +# Publishes the Semantic Diff viewer (scripts/recordViewer.html), which draws +# the commit records of any pull request, range or commit in the browser, +# reading them from GitHub's API: +# +# https://cdn.rescript-lang.org/semantic-diff/view/index.html?pr= +# +# A push to master publishes it there. A pull request from a branch of this +# repository that changes it publishes its own version under +# semantic-diff/view/pr-/ and comments that address, so the change +# can be tried before it lands; a fork's pull request publishes nothing. + +on: + push: + branches: [master] + paths: &viewer + - scripts/recordViewer.html + - scripts/recordViewer.js + - scripts/recordRender.js + - .github/workflows/semantic_diff_viewer.yml + pull_request: + paths: *viewer + workflow_dispatch: + +concurrency: + group: semantic-diff-viewer-${{ github.event.pull_request.number || 'master' }} + cancel-in-progress: true + +jobs: + publish: + if: >- + github.event_name != 'pull_request' || + github.event.pull_request.head.repo.full_name == github.repository + runs-on: ubuntu-26.04 + permissions: + contents: read + pull-requests: write + env: + DIR: semantic-diff/view${{ github.event_name == 'pull_request' && format('/pr-{0}', github.event.pull_request.number) || '' }} + steps: + - name: Checkout + uses: actions/checkout@v7 + with: + persist-credentials: false + + - name: Setup Rclone + uses: cometkim/rclone-actions/setup-rclone@8d9c6b95341f7698609deee65c3d62af42893288 # main, 2025-06-18 + + - name: Configure Rclone remote + uses: cometkim/rclone-actions/configure-remote/s3-provider@8d9c6b95341f7698609deee65c3d62af42893288 # main, 2025-06-18 + with: + name: rescript + provider: Cloudflare + endpoint: https://${{ vars.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com + access-key-id: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }} + secret-access-key: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }} + acl: private + + # The page and its modules are revalidated within five minutes, so a + # new version reaches readers soon after it is published. + - name: Upload viewer + run: | + upload() { + rclone copyto --s3-no-check-bucket --header-upload "Cache-Control: max-age=300" "scripts/$1" "rescript:cdn-assets/${DIR}/$2" + } + upload recordViewer.html index.html + upload recordViewer.js recordViewer.js + upload recordRender.js recordRender.js + + - name: Comment viewer address + if: github.event_name == 'pull_request' + uses: thollander/actions-comment-pull-request@v3 + with: + pr-number: ${{ github.event.pull_request.number }} + comment-tag: semantic-diff-viewer + message: | + Semantic Diff viewer of this pull request: https://cdn.rescript-lang.org/${{ env.DIR }}/index.html?pr=${{ github.event.pull_request.number }} diff --git a/package.json b/package.json index 09612a20c3..995f58652d 100644 --- a/package.json +++ b/package.json @@ -63,7 +63,7 @@ "changelog:release": "node scripts/changelog.js release", "changelog:open": "node scripts/changelog.js open", "test-changelog": "node --test scripts/changelog.test.js", - "test-commit-records": "node --test scripts/commitRecords.test.js scripts/recordMarkdown.test.js" + "test-commit-records": "node --test scripts/commitRecords.test.js scripts/recordMarkdown.test.js scripts/recordViewer.test.js" }, "files": [ "CHANGELOG.md", diff --git a/scripts/recordRender.js b/scripts/recordRender.js index 37a647ae9f..675592019b 100644 --- a/scripts/recordRender.js +++ b/scripts/recordRender.js @@ -1,8 +1,8 @@ // @ts-check /* Commit records, parsed from a commit message and drawn as HTML. The - * caller (commitRecords.js) reads each commit's identity and message from - * git. The + * caller reads each commit's identity and message: commitRecords.js from + * git, recordViewer.js in the browser from GitHub's API. The * format is the `records` skill's (.claude/skills/records/SKILL.md): a * subject, a `Why:` section, object blocks such as * `AST modified (raw extensions):`, each followed by an optional `Example:` diff --git a/scripts/recordViewer.html b/scripts/recordViewer.html new file mode 100644 index 0000000000..b586dd7b6f --- /dev/null +++ b/scripts/recordViewer.html @@ -0,0 +1,21 @@ + + + + + + Semantic Diff + + + + + +

Loading…

+ + diff --git a/scripts/recordViewer.js b/scripts/recordViewer.js new file mode 100644 index 0000000000..fc41027ee3 --- /dev/null +++ b/scripts/recordViewer.js @@ -0,0 +1,204 @@ +// @ts-check + +/* The Semantic Diff viewer: draws the commit records of a pull request, a + * range or commits of this repository in the browser, reading them from + * GitHub's REST API and drawing them with the page's renderer + * (recordRender.js). recordViewer.html is the page; + * .github/workflows/semantic_diff_viewer.yml publishes both to the CDN. + * + * ?pr=8958 the pull request's commits + * ?pr=8958&range=a...b the commits of a range, headed by the pull request + * ?range=a...b the commits b has and a lacks + * ?commit=a1b2c3d,e4f5a6b commits, one card each + * + * Without a token, GitHub answers 60 requests an hour for each address; a + * pull request takes two, and one more for each 100 commits past the first. + * A pull request or range holds at most 250 commits (GitHub lists no more + * of a pull request's), and a query names at most 20 commits, so a page + * takes at most 4 requests or 20. + */ + +import { CSS, esc, pageBody, pageTitle, parseRecord } from "./recordRender.js"; + +/** @import { CommitRecord, Group } from "./recordRender.js" */ + +/** + * A GitHub API request: the path under the repository, answered as JSON. + * @typedef {(path: string) => Promise} FetchJson + */ + +/** + * A commit as GitHub's API returns it, the fields the viewer reads. + * @typedef {{ + * sha: string, + * parents: unknown[], + * commit: { message: string, author: { name: string, date: string } }, + * }} ApiCommit + */ + +const REPO = "rescript-lang/rescript"; +const API = `https://api.github.com/repos/${REPO}`; +const MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" "); +const PAGE = 100; +/** The commits a pull request or range holds at most. */ +export const MAX_COMMITS = 250; +/** The commits a query names at most. */ +export const MAX_NAMED = 20; + +/** A commit's sha, full or abbreviated. */ +const SHA = /^[0-9a-f]{4,40}$/i; +const USAGE = + "Name what to draw: ?pr=, ?range=... or ?commit=[,]."; + +/** + * A range `base..tip` or `base...tip` of shas, tags or branch names: words + * joined by single dots or slashes, so a range names no other API path. + */ +const RANGE = /^(\w[\w-]*(?:[./]\w[\w-]*)*)\.{2,3}(\w[\w-]*(?:[./]\w[\w-]*)*)$/; + +/** + * A commit's record, its date as `git log --date=format:%b %d, %Y` prints + * it, in UTC. + * @param {ApiCommit} c + * @returns {CommitRecord} + */ +export function recordOf(c) { + const d = new Date(c.commit.author.date); + return parseRecord({ + sha: c.sha.slice(0, 9), + author: c.commit.author.name, + date: `${MONTHS[d.getUTCMonth()]} ${String(d.getUTCDate()).padStart(2, "0")}, ${d.getUTCFullYear()}`, + message: c.commit.message, + }); +} + +/** + * The commits a paginated listing holds, page after page until one comes + * back short. A listing answers either an array or `{ commits }`. + * @param {FetchJson} fetchJson + * @param {string} path + * @returns {Promise} + */ +async function listing(fetchJson, path) { + /** @type {ApiCommit[]} */ + const all = []; + for (let page = 1; ; page++) { + const answer = await fetchJson( + `${path}${path.includes("?") ? "&" : "?"}per_page=${PAGE}&page=${page}`, + ); + /** @type {ApiCommit[]} */ + const commits = Array.isArray(answer) ? answer : answer.commits; + tooMany(answer.total_commits); + all.push(...commits); + if (commits.length < PAGE) return all; + } +} + +/** + * Refuses a pull request or range of more commits than the viewer draws. + * @param {number | undefined} count + */ +function tooMany(count) { + if (count !== undefined && count > MAX_COMMITS) + throw new Error( + `It holds ${count} commits; the viewer draws at most ${MAX_COMMITS}.`, + ); +} + +/** + * Records of the commits, merge commits left out as `git rev-list + * --no-merges` leaves them. + * @param {ApiCommit[]} commits + */ +const records = commits => + commits.filter(c => c.parents.length < 2).map(recordOf); + +/** + * The page the query asks for: its heading and its groups of records. + * @param {URLSearchParams} query + * @param {FetchJson} fetchJson + * @returns {Promise<{ heading: { pr?: string, title?: string }, groups: Group[] }>} + */ +export async function load(query, fetchJson) { + const pr = query.get("pr") ?? ""; + const range = query.get("range") ?? ""; + const commits = (query.get("commit") ?? "").split(",").filter(Boolean); + if (pr && !/^\d+$/.test(pr)) throw new Error(`Not a pull request: ${pr}`); + const ends = range ? RANGE.exec(range) : null; + if (range && !ends) throw new Error(`Not a range: ${range}`); + const bad = commits.find(c => !SHA.test(c)); + if (bad) throw new Error(`Not a commit: ${bad}`); + if (commits.length > MAX_NAMED) + throw new Error( + `The query names ${commits.length} commits; the viewer draws at most ${MAX_NAMED}.`, + ); + if (!pr && !ends && !commits.length) throw new Error(USAGE); + + /** @type {Group[]} */ + const groups = []; + /** @type {{ pr?: string, title?: string }} */ + let heading = {}; + if (pr) { + const pull = await fetchJson(`/pulls/${pr}`); + heading = { pr, title: pull.title }; + if (!ends) { + tooMany(pull.commits); + groups.push({ + range: `${pull.base.ref}..${pull.head.ref}`, + records: records(await listing(fetchJson, `/pulls/${pr}/commits`)), + }); + } + } + if (ends) { + const [, base, tip] = ends; + groups.push({ + range: `${base}..${tip}`, + records: records(await listing(fetchJson, `/compare/${base}...${tip}`)), + }); + } + for (const sha of commits) + groups.push({ records: [recordOf(await fetchJson(`/commits/${sha}`))] }); + return { heading, groups }; +} + +/** + * A request to GitHub's API, its failures as errors a reader can act on. + * @type {FetchJson} + */ +async function fetchJson(path) { + const response = await fetch(`${API}${path}`, { + headers: { Accept: "application/vnd.github+json" }, + }); + if (response.ok) return response.json(); + if (response.headers.get("x-ratelimit-remaining") === "0") { + const reset = new Date( + Number(response.headers.get("x-ratelimit-reset")) * 1000, + ); + throw new Error( + `GitHub's API allows this address no more requests until ${reset.toLocaleTimeString()}.`, + ); + } + throw new Error( + response.status === 404 + ? `GitHub found nothing at ${REPO}${path.replace(/\?.*/, "")}.` + : `GitHub answered ${response.status} ${response.statusText}.`, + ); +} + +/** + * Draws the page the address asks for into the document, or why it can't. + */ +export async function show() { + const style = document.createElement("style"); + style.textContent = CSS; + document.head.append(style); + try { + const page = await load(new URLSearchParams(location.search), fetchJson); + document.title = pageTitle(page.heading); + document.body.innerHTML = pageBody(page.groups, page.heading); + if (location.hash) + document.getElementById(location.hash.slice(1))?.scrollIntoView(); + } catch (e) { + document.body.innerHTML = `

Semantic Diff

${esc(e instanceof Error ? e.message : String(e))}

`; + } +} diff --git a/scripts/recordViewer.test.js b/scripts/recordViewer.test.js new file mode 100644 index 0000000000..0aaa8632a5 --- /dev/null +++ b/scripts/recordViewer.test.js @@ -0,0 +1,177 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { pageBody } from "./recordRender.js"; +import { load, MAX_COMMITS, MAX_NAMED, recordOf } from "./recordViewer.js"; + +const SIGNED = "Signed-off-by: A Person "; + +/** + * A commit as GitHub's API returns it. + * @param {string} sha + * @param {string} message + * @param {number} [parents] + */ +const apiCommit = (sha, message, parents = 1) => ({ + sha: sha.padEnd(40, "0"), + parents: Array.from({ length: parents }, () => ({})), + commit: { + message, + author: { name: "A Person", date: "2026-10-02T23:30:00Z" }, + }, +}); + +/** + * A fake API answering the paths it is given, and the paths asked. + * @param {Record} answers + */ +function api(answers) { + /** @type {string[]} */ + const asked = []; + /** @param {string} path */ + const fetchJson = async path => { + asked.push(path); + if (!(path in answers)) throw new Error(`unexpected ${path}`); + return answers[path]; + }; + return { asked, fetchJson }; +} + +const query = (/** @type {string} */ s) => new URLSearchParams(s); + +test("a commit's record has its short sha, author and date as git prints them", () => { + const r = recordOf( + apiCommit("abcdef1234", `Fix it\n\nIt broke.\n\n${SIGNED}`), + ); + assert.equal(r.sha, "abcdef123"); + assert.equal(r.author, "A Person"); + assert.equal(r.date, "Oct 02, 2026"); + assert.equal(r.subject, "Fix it"); +}); + +test("a pull request is its title and its commits, merges left out", async () => { + const { asked, fetchJson } = api({ + "/pulls/12": { + title: "Keep node", + base: { ref: "master" }, + head: { ref: "feature" }, + }, + "/pulls/12/commits?per_page=100&page=1": [ + apiCommit("a1", `First\n\n${SIGNED}`), + apiCommit("b2", "Merge master", 2), + apiCommit("c3", `Second\n\n${SIGNED}`), + ], + }); + const page = await load(query("pr=12"), fetchJson); + assert.deepEqual(page.heading, { pr: "12", title: "Keep node" }); + assert.equal(page.groups.length, 1); + assert.equal(page.groups[0].range, "master..feature"); + assert.deepEqual( + page.groups[0].records.map(r => r.subject), + ["First", "Second"], + ); + assert.equal(asked.length, 2); + const html = pageBody(page.groups, page.heading); + assert.ok(html.includes("Keep <the> node")); + assert.ok(html.includes('id="c-c30000000"')); +}); + +test("a listing is read page after page until one comes back short", async () => { + const full = Array.from({ length: 100 }, (_, k) => + apiCommit(`${k}`.padStart(4, "a"), `Commit ${k}\n\n${SIGNED}`), + ); + const { asked, fetchJson } = api({ + "/compare/master...f00d?per_page=100&page=1": { commits: full }, + "/compare/master...f00d?per_page=100&page=2": { + commits: [apiCommit("beef", `Last\n\n${SIGNED}`)], + }, + }); + const page = await load(query("range=master..f00d"), fetchJson); + assert.ok( + await load( + query("range=v12.0.0...feature/x-y"), + api({ + "/compare/v12.0.0...feature/x-y?per_page=100&page=1": { commits: [] }, + }).fetchJson, + ), + ); + assert.equal(page.groups[0].range, "master..f00d"); + assert.equal(page.groups[0].records.length, 101); + assert.equal(asked.length, 2); +}); + +test("a range under a pull request takes the pull request's heading only", async () => { + const { fetchJson } = api({ + "/pulls/12": { title: "T", base: { ref: "master" }, head: { ref: "x" } }, + "/compare/a1...b2?per_page=100&page=1": { + commits: [apiCommit("b2", `Old head\n\n${SIGNED}`)], + }, + }); + const page = await load(query("pr=12&range=a1...b2"), fetchJson); + assert.equal(page.heading.title, "T"); + assert.deepEqual( + page.groups.map(g => g.range), + ["a1..b2"], + ); +}); + +test("commits are a card each", async () => { + const { fetchJson } = api({ + "/commits/a1b2": apiCommit("a1b2", `One\n\n${SIGNED}`), + "/commits/c3d4": apiCommit("c3d4", `Two\n\n${SIGNED}`), + }); + const page = await load(query("commit=a1b2,c3d4"), fetchJson); + assert.deepEqual( + page.groups.map(g => [g.range, g.records[0].subject]), + [ + [undefined, "One"], + [undefined, "Two"], + ], + ); +}); + +test("a query naming nothing or something malformed asks nothing of GitHub", async () => { + const { asked, fetchJson } = api({}); + for (const [q, message] of [ + ["", /Name what to draw/], + ["pr=12a", /Not a pull request: 12a/], + ["range=a;b", /Not a range: a;b/], + ["range=../x", /Not a range/], + ["range=a/../../b...c", /Not a range/], + ["range=a..b..c", /Not a range/], + ["commit=xyz", /Not a commit: xyz/], + ]) + await assert.rejects(load(query(`${q}`), fetchJson), message); + assert.deepEqual(asked, []); +}); + +test("a pull request or range of too many commits is refused before its listing", async () => { + const { asked, fetchJson } = api({ + "/pulls/12": { + title: "T", + commits: MAX_COMMITS + 1, + base: { ref: "master" }, + head: { ref: "x" }, + }, + "/compare/v11...master?per_page=100&page=1": { + total_commits: 4000, + commits: [], + }, + }); + await assert.rejects(load(query("pr=12"), fetchJson), /holds 251 commits/); + await assert.rejects( + load(query("range=v11...master"), fetchJson), + /holds 4000 commits; the viewer draws at most 250/, + ); + assert.deepEqual(asked, [ + "/pulls/12", + "/compare/v11...master?per_page=100&page=1", + ]); + const named = Array.from({ length: MAX_NAMED + 1 }, (_, k) => + `${k}`.padStart(4, "a"), + ); + await assert.rejects( + load(query(`commit=${named.join(",")}`), fetchJson), + /names 21 commits/, + ); +});