Skip to content

Commit 8fd1816

Browse files
cknittclaude
andcommitted
Draw any pull request's records in the browser
A Semantic Diff page exists only for a pull request the workflow rendered, and shows its latest push. The viewer reads a pull request's, a range's or commits' records from GitHub's API and draws them with the page's renderer, at most 250 commits; a workflow publishes it to the CDN from master, and from a pull request that changes it under its own address. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AzPS5F8RDSGZ6dhfK6NdZp Signed-off-by: Christoph Knittel <ck@cca.io>
1 parent 0326cb5 commit 8fd1816

7 files changed

Lines changed: 488 additions & 3 deletions

File tree

‎.claude/skills/records/SKILL.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,11 @@ node scripts/commitRecords.js --out page.html [--pr <number>] [--title <text>] \
195195
and comments the records as Markdown (`--markdown <file> --url <page>`,
196196
scripts/recordMarkdown.js) with the page's link: after a push that keeps
197197
the previous head, only the commits it added (`--since <previous head>`).
198+
- The viewer draws records in the browser from GitHub's API, with the same
199+
renderer: `https://cdn.rescript-lang.org/semantic-diff/view/index.html`
200+
with `?pr=<number>`, `?range=<base>...<tip>` or `?commit=<sha>[,<sha>]`,
201+
at most 250 commits (`scripts/recordViewer.js`,
202+
`.github/workflows/semantic_diff_viewer.yml`).
198203
- In prose, `#1234` and full pull request and issue URLs link as `#1234`,
199204
and the 9-character short sha of a commit on the page links to its card.
200205

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
name: Semantic Diff Viewer
2+
3+
# Publishes the Semantic Diff viewer (scripts/recordViewer.html), which draws
4+
# the commit records of any pull request, range or commit in the browser,
5+
# reading them from GitHub's API:
6+
#
7+
# https://cdn.rescript-lang.org/semantic-diff/view/index.html?pr=<number>
8+
#
9+
# A push to master publishes it there. A pull request from a branch of this
10+
# repository that changes it publishes its own version under
11+
# semantic-diff/view/pr-<number>/ and comments that address, so the change
12+
# can be tried before it lands; a fork's pull request publishes nothing.
13+
14+
on:
15+
push:
16+
branches: [master]
17+
paths: &viewer
18+
- scripts/recordViewer.html
19+
- scripts/recordViewer.js
20+
- scripts/recordRender.js
21+
- .github/workflows/semantic_diff_viewer.yml
22+
pull_request:
23+
paths: *viewer
24+
workflow_dispatch:
25+
26+
concurrency:
27+
group: semantic-diff-viewer-${{ github.event.pull_request.number || 'master' }}
28+
cancel-in-progress: true
29+
30+
jobs:
31+
publish:
32+
if: >-
33+
github.event_name != 'pull_request' ||
34+
github.event.pull_request.head.repo.full_name == github.repository
35+
runs-on: ubuntu-26.04
36+
permissions:
37+
contents: read
38+
pull-requests: write
39+
env:
40+
DIR: semantic-diff/view${{ github.event_name == 'pull_request' && format('/pr-{0}', github.event.pull_request.number) || '' }}
41+
steps:
42+
- name: Checkout
43+
uses: actions/checkout@v7
44+
with:
45+
persist-credentials: false
46+
47+
- name: Setup Rclone
48+
uses: cometkim/rclone-actions/setup-rclone@8d9c6b95341f7698609deee65c3d62af42893288 # main, 2025-06-18
49+
50+
- name: Configure Rclone remote
51+
uses: cometkim/rclone-actions/configure-remote/s3-provider@8d9c6b95341f7698609deee65c3d62af42893288 # main, 2025-06-18
52+
with:
53+
name: rescript
54+
provider: Cloudflare
55+
endpoint: https://${{ vars.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com
56+
access-key-id: ${{ secrets.CLOUDFLARE_R2_ACCESS_KEY_ID }}
57+
secret-access-key: ${{ secrets.CLOUDFLARE_R2_SECRET_ACCESS_KEY }}
58+
acl: private
59+
60+
# The page and its modules are revalidated within five minutes, so a
61+
# new version reaches readers soon after it is published.
62+
- name: Upload viewer
63+
run: |
64+
upload() {
65+
rclone copyto --s3-no-check-bucket --header-upload "Cache-Control: max-age=300" "scripts/$1" "rescript:cdn-assets/${DIR}/$2"
66+
}
67+
upload recordViewer.html index.html
68+
upload recordViewer.js recordViewer.js
69+
upload recordRender.js recordRender.js
70+
71+
- name: Comment viewer address
72+
if: github.event_name == 'pull_request'
73+
uses: thollander/actions-comment-pull-request@v3
74+
with:
75+
pr-number: ${{ github.event.pull_request.number }}
76+
comment-tag: semantic-diff-viewer
77+
message: |
78+
Semantic Diff viewer of this pull request: https://cdn.rescript-lang.org/${{ env.DIR }}/index.html?pr=${{ github.event.pull_request.number }}

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@
6363
"changelog:release": "node scripts/changelog.js release",
6464
"changelog:open": "node scripts/changelog.js open",
6565
"test-changelog": "node --test scripts/changelog.test.js",
66-
"test-commit-records": "node --test scripts/commitRecords.test.js scripts/recordMarkdown.test.js"
66+
"test-commit-records": "node --test scripts/commitRecords.test.js scripts/recordMarkdown.test.js scripts/recordViewer.test.js"
6767
},
6868
"files": [
6969
"CHANGELOG.md",

‎scripts/recordRender.js‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
// @ts-check
22

33
/* Commit records, parsed from a commit message and drawn as HTML. The
4-
* caller (commitRecords.js) reads each commit's identity and message from
5-
* git. The
4+
* caller reads each commit's identity and message: commitRecords.js from
5+
* git, recordViewer.js in the browser from GitHub's API. The
66
* format is the `records` skill's (.claude/skills/records/SKILL.md): a
77
* subject, a `Why:` section, object blocks such as
88
* `AST modified (raw extensions):`, each followed by an optional `Example:`

‎scripts/recordViewer.html‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<!doctype html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="utf-8" />
5+
<meta name="viewport" content="width=device-width, initial-scale=1" />
6+
<title>Semantic Diff</title>
7+
<link
8+
rel="stylesheet"
9+
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"
10+
/>
11+
<!-- Draws the records the address names (recordViewer.js). -->
12+
<script type="module">
13+
import { show } from "./recordViewer.js";
14+
15+
show();
16+
</script>
17+
</head>
18+
<body>
19+
<main><header><p>Loading…</p></header></main>
20+
</body>
21+
</html>

‎scripts/recordViewer.js‎

Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// @ts-check
2+
3+
/* The Semantic Diff viewer: draws the commit records of a pull request, a
4+
* range or commits of this repository in the browser, reading them from
5+
* GitHub's REST API and drawing them with the page's renderer
6+
* (recordRender.js). recordViewer.html is the page;
7+
* .github/workflows/semantic_diff_viewer.yml publishes both to the CDN.
8+
*
9+
* ?pr=8958 the pull request's commits
10+
* ?pr=8958&range=a...b the commits of a range, headed by the pull request
11+
* ?range=a...b the commits b has and a lacks
12+
* ?commit=a1b2c3d,e4f5a6b commits, one card each
13+
*
14+
* Without a token, GitHub answers 60 requests an hour for each address; a
15+
* pull request takes two, and one more for each 100 commits past the first.
16+
* A pull request or range holds at most 250 commits (GitHub lists no more
17+
* of a pull request's), and a query names at most 20 commits, so a page
18+
* takes at most 4 requests or 20.
19+
*/
20+
21+
import { CSS, esc, pageBody, pageTitle, parseRecord } from "./recordRender.js";
22+
23+
/** @import { CommitRecord, Group } from "./recordRender.js" */
24+
25+
/**
26+
* A GitHub API request: the path under the repository, answered as JSON.
27+
* @typedef {(path: string) => Promise<any>} FetchJson
28+
*/
29+
30+
/**
31+
* A commit as GitHub's API returns it, the fields the viewer reads.
32+
* @typedef {{
33+
* sha: string,
34+
* parents: unknown[],
35+
* commit: { message: string, author: { name: string, date: string } },
36+
* }} ApiCommit
37+
*/
38+
39+
const REPO = "rescript-lang/rescript";
40+
const API = `https://api.github.com/repos/${REPO}`;
41+
const MONTHS = "Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec".split(" ");
42+
const PAGE = 100;
43+
/** The commits a pull request or range holds at most. */
44+
export const MAX_COMMITS = 250;
45+
/** The commits a query names at most. */
46+
export const MAX_NAMED = 20;
47+
48+
/** A commit's sha, full or abbreviated. */
49+
const SHA = /^[0-9a-f]{4,40}$/i;
50+
const USAGE =
51+
"Name what to draw: ?pr=<number>, ?range=<base>...<tip> or ?commit=<sha>[,<sha>].";
52+
53+
/**
54+
* A range `base..tip` or `base...tip` of shas, tags or branch names: words
55+
* joined by single dots or slashes, so a range names no other API path.
56+
*/
57+
const RANGE = /^(\w[\w-]*(?:[./]\w[\w-]*)*)\.{2,3}(\w[\w-]*(?:[./]\w[\w-]*)*)$/;
58+
59+
/**
60+
* A commit's record, its date as `git log --date=format:%b %d, %Y` prints
61+
* it, in UTC.
62+
* @param {ApiCommit} c
63+
* @returns {CommitRecord}
64+
*/
65+
export function recordOf(c) {
66+
const d = new Date(c.commit.author.date);
67+
return parseRecord({
68+
sha: c.sha.slice(0, 9),
69+
author: c.commit.author.name,
70+
date: `${MONTHS[d.getUTCMonth()]} ${String(d.getUTCDate()).padStart(2, "0")}, ${d.getUTCFullYear()}`,
71+
message: c.commit.message,
72+
});
73+
}
74+
75+
/**
76+
* The commits a paginated listing holds, page after page until one comes
77+
* back short. A listing answers either an array or `{ commits }`.
78+
* @param {FetchJson} fetchJson
79+
* @param {string} path
80+
* @returns {Promise<ApiCommit[]>}
81+
*/
82+
async function listing(fetchJson, path) {
83+
/** @type {ApiCommit[]} */
84+
const all = [];
85+
for (let page = 1; ; page++) {
86+
const answer = await fetchJson(
87+
`${path}${path.includes("?") ? "&" : "?"}per_page=${PAGE}&page=${page}`,
88+
);
89+
/** @type {ApiCommit[]} */
90+
const commits = Array.isArray(answer) ? answer : answer.commits;
91+
tooMany(answer.total_commits);
92+
all.push(...commits);
93+
if (commits.length < PAGE) return all;
94+
}
95+
}
96+
97+
/**
98+
* Refuses a pull request or range of more commits than the viewer draws.
99+
* @param {number | undefined} count
100+
*/
101+
function tooMany(count) {
102+
if (count !== undefined && count > MAX_COMMITS)
103+
throw new Error(
104+
`It holds ${count} commits; the viewer draws at most ${MAX_COMMITS}.`,
105+
);
106+
}
107+
108+
/**
109+
* Records of the commits, merge commits left out as `git rev-list
110+
* --no-merges` leaves them.
111+
* @param {ApiCommit[]} commits
112+
*/
113+
const records = commits =>
114+
commits.filter(c => c.parents.length < 2).map(recordOf);
115+
116+
/**
117+
* The page the query asks for: its heading and its groups of records.
118+
* @param {URLSearchParams} query
119+
* @param {FetchJson} fetchJson
120+
* @returns {Promise<{ heading: { pr?: string, title?: string }, groups: Group[] }>}
121+
*/
122+
export async function load(query, fetchJson) {
123+
const pr = query.get("pr") ?? "";
124+
const range = query.get("range") ?? "";
125+
const commits = (query.get("commit") ?? "").split(",").filter(Boolean);
126+
if (pr && !/^\d+$/.test(pr)) throw new Error(`Not a pull request: ${pr}`);
127+
const ends = range ? RANGE.exec(range) : null;
128+
if (range && !ends) throw new Error(`Not a range: ${range}`);
129+
const bad = commits.find(c => !SHA.test(c));
130+
if (bad) throw new Error(`Not a commit: ${bad}`);
131+
if (commits.length > MAX_NAMED)
132+
throw new Error(
133+
`The query names ${commits.length} commits; the viewer draws at most ${MAX_NAMED}.`,
134+
);
135+
if (!pr && !ends && !commits.length) throw new Error(USAGE);
136+
137+
/** @type {Group[]} */
138+
const groups = [];
139+
/** @type {{ pr?: string, title?: string }} */
140+
let heading = {};
141+
if (pr) {
142+
const pull = await fetchJson(`/pulls/${pr}`);
143+
heading = { pr, title: pull.title };
144+
if (!ends) {
145+
tooMany(pull.commits);
146+
groups.push({
147+
range: `${pull.base.ref}..${pull.head.ref}`,
148+
records: records(await listing(fetchJson, `/pulls/${pr}/commits`)),
149+
});
150+
}
151+
}
152+
if (ends) {
153+
const [, base, tip] = ends;
154+
groups.push({
155+
range: `${base}..${tip}`,
156+
records: records(await listing(fetchJson, `/compare/${base}...${tip}`)),
157+
});
158+
}
159+
for (const sha of commits)
160+
groups.push({ records: [recordOf(await fetchJson(`/commits/${sha}`))] });
161+
return { heading, groups };
162+
}
163+
164+
/**
165+
* A request to GitHub's API, its failures as errors a reader can act on.
166+
* @type {FetchJson}
167+
*/
168+
async function fetchJson(path) {
169+
const response = await fetch(`${API}${path}`, {
170+
headers: { Accept: "application/vnd.github+json" },
171+
});
172+
if (response.ok) return response.json();
173+
if (response.headers.get("x-ratelimit-remaining") === "0") {
174+
const reset = new Date(
175+
Number(response.headers.get("x-ratelimit-reset")) * 1000,
176+
);
177+
throw new Error(
178+
`GitHub's API allows this address no more requests until ${reset.toLocaleTimeString()}.`,
179+
);
180+
}
181+
throw new Error(
182+
response.status === 404
183+
? `GitHub found nothing at ${REPO}${path.replace(/\?.*/, "")}.`
184+
: `GitHub answered ${response.status} ${response.statusText}.`,
185+
);
186+
}
187+
188+
/**
189+
* Draws the page the address asks for into the document, or why it can't.
190+
*/
191+
export async function show() {
192+
const style = document.createElement("style");
193+
style.textContent = CSS;
194+
document.head.append(style);
195+
try {
196+
const page = await load(new URLSearchParams(location.search), fetchJson);
197+
document.title = pageTitle(page.heading);
198+
document.body.innerHTML = pageBody(page.groups, page.heading);
199+
if (location.hash)
200+
document.getElementById(location.hash.slice(1))?.scrollIntoView();
201+
} catch (e) {
202+
document.body.innerHTML = `<main><header><h1 class="brand">Semantic <span>Diff</span></h1><p class="lede">${esc(e instanceof Error ? e.message : String(e))}</p></header></main>`;
203+
}
204+
}

0 commit comments

Comments
 (0)