Skip to content

Commit c129a4a

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, with the authors' GitHub logins and the dates in UTC as the cards print them; 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 Claude-Session: https://claude.ai/code/session_01Kr4cjoUi2f5VpMvE6wT9bo Signed-off-by: Christoph Knittel <ck@cca.io>
1 parent 799a82f commit c129a4a

7 files changed

Lines changed: 500 additions & 3 deletions

File tree

‎.agents/skills/records/SKILL.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,11 @@ node scripts/commitRecords.js --out page.html [--pr <number>] [--title <text>] \
200200
describe it as a whole, while a `Breaking:` section describes its
201201
commit; it notes breaking commits without a breaking entry, and the
202202
other way round.
203+
- The viewer draws records in the browser from GitHub's API, with the same
204+
renderer: `https://cdn.rescript-lang.org/semantic-diff/view/index.html`
205+
with `?pr=<number>`, `?range=<base>...<tip>` or `?commit=<sha>[,<sha>]`,
206+
at most 250 commits (`scripts/recordViewer.js`,
207+
`.github/workflows/semantic_diff_viewer.yml`).
203208
- In prose, `#1234` and full pull request and issue URLs link as `#1234`,
204209
and the 9-character short sha of a commit on the page links to its card.
205210

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

0 commit comments

Comments
 (0)