|
| 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