Last active
April 28, 2026 13:57
-
-
Save lae/f72738a33cedaddaaf59239addab2b5b to your computer and use it in GitHub Desktop.
Browser userscript (FireMonkey, etc.) for fast-forward merge support on GitHub
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Fast-forward merge for GitHub | |
| // @namespace https://github.com/simnalamburt/ff-for-github | |
| // @version 0.2.0 | |
| // @description Shows whether or not a GitHub PR or branch can be fast-forward merged and lets you do it. | |
| // @match https://github.com/* | |
| // @run-at document-idle | |
| // @grant GM_xmlhttpRequest | |
| // @grant GM_getValue | |
| // @grant GM_setValue | |
| // @grant GM_registerMenuCommand | |
| // @connect api.github.com | |
| // @license MIT OR Apache-2.0 | |
| // ==/UserScript== | |
| // This script is primarily written via Claude to allow being ran on Firefox, and is based on simnalamburt/ff-for-github. | |
| (function () { | |
| "use strict"; | |
| const TOKEN_KEY = "ghff:github-personal-access-token"; | |
| const ROOT_ID = "ghff-root"; | |
| const STYLE_ID = "ghff-style"; | |
| const PAGE_CACHE_TTL_MS = 30000; | |
| const URL_CHECK_INTERVAL_MS = 750; | |
| const STREAM_REFRESH_MIN_INTERVAL_MS = 2000; | |
| const PR_PATH_PATTERN = /^\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/.*)?$/; | |
| const COMPARE_PATH_PATTERN = /^\/([^/]+)\/([^/]+)\/compare\/([^/]+)(?:\/.*)?$/; | |
| const CSS = ` | |
| #ghff-root > article { | |
| --ghff-border-color: var(--borderColor-default, #d0d7de); | |
| --ghff-bg-color: var(--bgColor-muted, #f6f8fa); | |
| --ghff-accent-color: var(--borderColor-muted, #656d76); | |
| border: 1px solid var(--ghff-border-color); | |
| color: var(--fgColor-default, #1f2328); | |
| } | |
| #ghff-root > article[data-tone="loading"] { | |
| --ghff-border-color: var(--borderColor-attention-emphasis, #9a6700); | |
| --ghff-bg-color: var(--bgColor-attention-muted, #fff8c5); | |
| --ghff-accent-color: var(--fgColor-attention, #9a6700); | |
| } | |
| #ghff-root > article[data-tone="success"] { | |
| --ghff-border-color: var(--borderColor-success-emphasis, #1a7f37); | |
| --ghff-bg-color: var(--bgColor-success-muted, #dafbe1); | |
| --ghff-accent-color: var(--fgColor-success, #1f883d); | |
| } | |
| #ghff-root > article[data-tone="muted"] { | |
| --ghff-border-color: var(--borderColor-muted, #656d76); | |
| --ghff-bg-color: var(--bgColor-muted, #f6f8fa); | |
| --ghff-accent-color: var(--fgColor-muted, #656d76); | |
| } | |
| #ghff-root > article[data-tone="error"] { | |
| --ghff-border-color: var(--borderColor-danger-emphasis, #cf222e); | |
| --ghff-bg-color: var(--bgColor-danger-muted, #ffebe9); | |
| --ghff-accent-color: var(--fgColor-danger, #d1242f); | |
| } | |
| #ghff-root .ghff-title { | |
| font-size: 16px; | |
| font-weight: 600; | |
| line-height: 1.4; | |
| } | |
| #ghff-root .ghff-detail { | |
| font-size: 12px; | |
| line-height: 1.5; | |
| margin-top: 8px; | |
| opacity: 0.78; | |
| } | |
| #ghff-root .ghff-detail--error { | |
| color: var(--fgColor-danger, #d1242f); | |
| opacity: 1; | |
| } | |
| #ghff-root button { | |
| appearance: none; | |
| border: 1px solid var(--button-default-borderColor-rest, rgba(31, 35, 40, 0.15)); | |
| border-radius: 6px; | |
| background: var(--button-primary-bgColor-rest, #1f883d); | |
| color: var(--button-primary-fgColor-rest, #ffffff); | |
| cursor: pointer; | |
| display: inline-block; | |
| font-size: 14px; | |
| font-weight: 600; | |
| line-height: 20px; | |
| padding: 6px 14px; | |
| } | |
| #ghff-root button:hover { | |
| background: var(--button-primary-bgColor-hover, #1a7f37); | |
| } | |
| #ghff-root button:disabled { | |
| cursor: default; | |
| opacity: 0.7; | |
| } | |
| #ghff-root > article[data-tone="error"] button { | |
| background: var(--button-danger-bgColor-rest, #cf222e); | |
| } | |
| #ghff-root > article[data-tone="error"] button:hover { | |
| background: var(--button-danger-bgColor-hover, #a40e26); | |
| } | |
| #ghff-root > .ghff-card { | |
| background: var(--ghff-bg-color); | |
| border-radius: 10px; | |
| margin-top: 16px; | |
| padding: 14px 16px; | |
| } | |
| #ghff-root > .ghff-card > button { | |
| margin-top: 12px; | |
| } | |
| #ghff-root > .ghff-compare-banner { | |
| background: var(--ghff-bg-color); | |
| border-radius: 12px; | |
| display: flex; | |
| gap: 16px; | |
| align-items: center; | |
| margin-bottom: 16px; | |
| padding: 14px 16px; | |
| position: relative; | |
| } | |
| #ghff-root > .ghff-compare-banner::before { | |
| background: var(--ghff-accent-color); | |
| border-radius: 999px; | |
| content: ""; | |
| align-self: stretch; | |
| flex: none; | |
| width: 6px; | |
| } | |
| #ghff-root > .ghff-compare-banner > .ghff-compare-banner__copy { | |
| flex: 1; | |
| min-width: 0; | |
| } | |
| #ghff-root > .ghff-compare-banner > .ghff-compare-banner__actions { | |
| display: flex; | |
| flex: none; | |
| justify-content: flex-end; | |
| } | |
| #ghff-root > .ghff-compare-banner > .ghff-compare-banner__actions > button { | |
| white-space: nowrap; | |
| } | |
| @media (max-width: 767px) { | |
| #ghff-root > .ghff-compare-banner { | |
| flex-wrap: wrap; | |
| gap: 12px; | |
| } | |
| #ghff-root > .ghff-compare-banner::before { | |
| height: 4px; | |
| width: 100%; | |
| } | |
| #ghff-root > .ghff-compare-banner > .ghff-compare-banner__actions { | |
| width: 100%; | |
| } | |
| } | |
| `; | |
| // --- Page state --- | |
| const pageState = { | |
| cache: new Map(), | |
| currentPath: "", | |
| optimisticStatuses: new Map(), | |
| pendingKey: null, | |
| requestId: 0, | |
| scheduled: false, | |
| }; | |
| const ui = { | |
| pageKind: null, | |
| state: { kind: "loading" }, | |
| mergeState: { kind: "idle" }, | |
| }; | |
| // --- Token storage --- | |
| function getToken() { | |
| const v = GM_getValue(TOKEN_KEY, ""); | |
| return typeof v === "string" ? v : ""; | |
| } | |
| function setToken(t) { | |
| GM_setValue(TOKEN_KEY, t); | |
| } | |
| function promptForToken() { | |
| const current = getToken(); | |
| const next = window.prompt( | |
| "Enter your GitHub personal access token (classic).\n\n" + | |
| "Classic: needs 'repo' scope to fast-forward merge.\n" + | |
| "Fine-grained: needs 'Contents: Read and write' on the target repos.\n\n" + | |
| "Leave blank to clear.", | |
| current, | |
| ); | |
| if (next === null) { return false; } | |
| setToken(next.trim()); | |
| return true; | |
| } | |
| GM_registerMenuCommand("Set GitHub token", () => { | |
| if (promptForToken()) { | |
| pageState.cache.clear(); | |
| void refresh({ bypassCache: true }); | |
| } | |
| }); | |
| GM_registerMenuCommand("Clear GitHub token", () => { | |
| if (window.confirm("Clear the saved GitHub token?")) { | |
| setToken(""); | |
| pageState.cache.clear(); | |
| void refresh({ bypassCache: true }); | |
| } | |
| }); | |
| // --- Style --- | |
| function ensureStyle() { | |
| if (document.getElementById(STYLE_ID)) { return; } | |
| const s = document.createElement("style"); | |
| s.id = STYLE_ID; | |
| s.textContent = CSS; | |
| (document.head || document.documentElement).appendChild(s); | |
| } | |
| const root = document.createElement("div"); | |
| root.id = ROOT_ID; | |
| // --- HTTP --- | |
| function gmFetch(url, options = {}) { | |
| return new Promise((resolve, reject) => { | |
| GM_xmlhttpRequest({ | |
| method: options.method || "GET", | |
| url, | |
| headers: options.headers || {}, | |
| data: options.body, | |
| onload: (response) => { | |
| let data = null; | |
| try { | |
| data = JSON.parse(response.responseText); | |
| } catch (_e) { | |
| data = null; | |
| } | |
| resolve({ | |
| ok: response.status >= 200 && response.status < 300, | |
| status: response.status, | |
| data, | |
| }); | |
| }, | |
| onerror: () => reject(new Error("Network error contacting api.github.com.")), | |
| ontimeout: () => reject(new Error("Timed out contacting api.github.com.")), | |
| }); | |
| }); | |
| } | |
| async function githubRequest(pathname, token, init = {}) { | |
| const headers = Object.assign( | |
| { | |
| Accept: "application/vnd.github+json", | |
| "Cache-Control": "no-cache", | |
| "X-GitHub-Api-Version": "2022-11-28", | |
| }, | |
| init.headers || {}, | |
| ); | |
| if (token) { headers.Authorization = `Bearer ${token}`; } | |
| const response = await gmFetch(`https://api.github.com${pathname}`, { | |
| method: init.method || "GET", | |
| headers, | |
| body: init.body, | |
| }); | |
| if (!response.ok) { | |
| const msg = (response.data && typeof response.data.message === "string") ? | |
| response.data.message : | |
| `GitHub API request failed with status ${response.status}.`; | |
| throw new Error(msg); | |
| } | |
| return response.data; | |
| } | |
| // --- Routing --- | |
| function parseCurrentRoute(pathname) { | |
| const prMatch = pathname.match(PR_PATH_PATTERN); | |
| if (prMatch) { | |
| const [, owner, repo, n] = prMatch; | |
| const pullNumber = Number(n); | |
| if (!Number.isSafeInteger(pullNumber) || pullNumber <= 0) { return null; } | |
| return { | |
| kind: "pull-request", | |
| pageKind: "pull-request", | |
| owner, | |
| repo, | |
| pullNumber, | |
| signature: `pull-request:${owner}/${repo}#${pullNumber}`, | |
| optimisticStatusAfterMerge: "closed", | |
| }; | |
| } | |
| const cmpMatch = pathname.match(COMPARE_PATH_PATTERN); | |
| if (!cmpMatch) { return null; } | |
| const spec = parseComparisonSpec(cmpMatch[3]); | |
| if (!spec) { return null; } | |
| const [, owner, repo] = cmpMatch; | |
| return { | |
| kind: "compare", | |
| pageKind: "compare", | |
| owner, | |
| repo, | |
| base: spec.base, | |
| head: spec.head, | |
| signature: `compare:${owner}/${repo}:${spec.base}...${spec.head}`, | |
| optimisticStatusAfterMerge: "up-to-date", | |
| }; | |
| } | |
| function parseComparisonSpec(spec) { | |
| let decoded; | |
| try { | |
| decoded = decodeURIComponent(spec); | |
| } catch (_e) { | |
| return null; | |
| } | |
| const sep = decoded.indexOf("..."); | |
| if (sep <= 0) { return null; } | |
| const base = decoded.slice(0, sep); | |
| const head = decoded.slice(sep + 3); | |
| if (!base || !head) { return null; } | |
| return { base, head }; | |
| } | |
| function findMountInstruction(locator) { | |
| if (locator.kind === "pull-request") { | |
| const target = document.querySelector("#partial-discussion-sidebar"); | |
| if (!target) { return null; } | |
| return { kind: "append", element: target }; | |
| } | |
| const commits = document.querySelector("#commits_bucket"); | |
| const summary = commits && commits.previousElementSibling; | |
| if (!(summary instanceof HTMLElement)) { return null; } | |
| return { kind: "before", element: summary }; | |
| } | |
| function ensureMounted(mount) { | |
| if (mount.kind === "append") { | |
| if (root.parentElement !== mount.element) { | |
| mount.element.insertAdjacentElement("beforeend", root); | |
| } | |
| return; | |
| } | |
| if (mount.element.previousElementSibling !== root) { | |
| mount.element.insertAdjacentElement("beforebegin", root); | |
| } | |
| } | |
| // --- API operations --- | |
| function encodeGitReference(ref) { | |
| return ref.split("/").map(encodeURIComponent).join("/"); | |
| } | |
| function encodeComparisonReference(ref) { | |
| return encodeURIComponent(ref); | |
| } | |
| function parseQualifiedReference(ref, defaultOwner) { | |
| const sep = ref.indexOf(":"); | |
| if (sep <= 0) { return { owner: defaultOwner, ref }; } | |
| return { owner: ref.slice(0, sep), ref: ref.slice(sep + 1) }; | |
| } | |
| function mapComparisonStatus(s) { | |
| switch (s) { | |
| case "ahead": | |
| return "ff-possible"; | |
| case "identical": | |
| return "up-to-date"; | |
| case "behind": | |
| return "base-ahead"; | |
| case "diverged": | |
| return "diverged"; | |
| default: | |
| return "unknown"; | |
| } | |
| } | |
| function assertAhead(cmp) { | |
| if (cmp.status === "identical") { | |
| throw new Error("The base branch is already up to date."); | |
| } | |
| if (cmp.status === "behind") { | |
| throw new Error("The base branch is already ahead of this comparison."); | |
| } | |
| if (cmp.status === "diverged") { | |
| throw new Error("Fast-forward merge is not possible because the branches have diverged."); | |
| } | |
| if (cmp.status !== "ahead") { | |
| throw new Error("GitHub did not return a comparison state this extension understands."); | |
| } | |
| } | |
| async function getStatusResult(locator) { | |
| const token = getToken(); | |
| const hasToken = token.trim() !== ""; | |
| if (locator.kind === "pull-request") { | |
| const pr = await githubRequest( | |
| `/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/pulls/${encodeURIComponent(String(locator.pullNumber))}`, | |
| token, | |
| ); | |
| const baseRef = (pr.base && pr.base.ref) || ""; | |
| const headSha = (pr.head && pr.head.sha) || ""; | |
| const isDraft = pr.draft === true; | |
| const state = pr.state || "open"; | |
| if (!baseRef || !headSha) { | |
| return { | |
| hasGitHubPersonalAccessToken: hasToken, | |
| status: state !== "open" ? "closed" : "unknown", | |
| aheadBy: 0, | |
| }; | |
| } | |
| const cmp = await githubRequest( | |
| `/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/compare/${encodeURIComponent(baseRef)}...${encodeURIComponent(headSha)}`, | |
| token, | |
| ); | |
| const aheadBy = cmp.ahead_by || 0; | |
| let status; | |
| if (state !== "open") { | |
| status = cmp.status === "ahead" ? "ff-possible-but-closed" : "closed"; | |
| } else { | |
| switch (cmp.status) { | |
| case "ahead": | |
| status = isDraft ? "ff-possible-but-draft" : "ff-possible"; | |
| break; | |
| case "identical": | |
| status = "up-to-date"; | |
| break; | |
| case "behind": | |
| status = "base-ahead"; | |
| break; | |
| case "diverged": | |
| status = "diverged"; | |
| break; | |
| default: | |
| status = "unknown"; | |
| } | |
| // GitHub computes a "mergeable_state" that bakes in branch protection, | |
| // required checks, and required reviews. "blocked" means a direct ref | |
| // update will fail; surface that up front instead of letting the user | |
| // click into an error. "unknown" / "clean" / "unstable" / "has_hooks" | |
| // all mean the merge is at least worth attempting. | |
| if (status === "ff-possible" && pr.mergeable_state === "blocked") { | |
| status = "ff-possible-but-blocked"; | |
| } | |
| } | |
| return { aheadBy, hasGitHubPersonalAccessToken: hasToken, status }; | |
| } | |
| const cmp = await githubRequest( | |
| `/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/compare/${encodeComparisonReference(locator.base)}...${encodeComparisonReference(locator.head)}`, | |
| token, | |
| ); | |
| let status = mapComparisonStatus(cmp.status); | |
| if (status === "ff-possible") { | |
| // No PR context here, so check branch protection directly on the base | |
| // branch. The /branches endpoint is readable by anyone with repo read | |
| // access (unlike /branches/{branch}/protection which needs admin), and | |
| // its "protected" flag captures whether a direct ref update will be | |
| // gated. We treat any failure of this lookup as best-effort and fall | |
| // through to the optimistic ff-possible state. | |
| try { | |
| const baseQual = parseQualifiedReference(locator.base, locator.owner); | |
| const branchInfo = await githubRequest( | |
| `/repos/${encodeURIComponent(baseQual.owner)}/${encodeURIComponent(locator.repo)}/branches/${encodeGitReference(baseQual.ref)}`, | |
| token, | |
| ); | |
| if (branchInfo.protected === true) { | |
| status = "ff-possible-but-blocked"; | |
| } | |
| } catch (_e) { | |
| // Best-effort; ignore (404 if branch missing, 401/403 if no auth). | |
| } | |
| } | |
| return { | |
| aheadBy: cmp.ahead_by || 0, | |
| hasGitHubPersonalAccessToken: hasToken, | |
| status, | |
| }; | |
| } | |
| async function performMerge(locator) { | |
| const token = getToken(); | |
| if (token.trim() === "") { throw new Error("No GitHub token is saved."); } | |
| if (locator.kind === "pull-request") { | |
| const pr = await githubRequest( | |
| `/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/pulls/${encodeURIComponent(String(locator.pullNumber))}`, | |
| token, | |
| ); | |
| const baseRef = (pr.base && pr.base.ref) || ""; | |
| const headSha = (pr.head && pr.head.sha) || ""; | |
| if (!baseRef || !headSha) { | |
| throw new Error("Could not determine the pull request branch heads."); | |
| } | |
| const cmp = await githubRequest( | |
| `/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/compare/${encodeURIComponent(baseRef)}...${encodeURIComponent(headSha)}`, | |
| token, | |
| ); | |
| assertAhead(cmp); | |
| await githubRequest( | |
| `/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/git/refs/heads/${encodeGitReference(baseRef)}`, | |
| token, | |
| { | |
| method: "PATCH", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ sha: headSha, force: false }), | |
| }, | |
| ); | |
| return; | |
| } | |
| const cmp = await githubRequest( | |
| `/repos/${encodeURIComponent(locator.owner)}/${encodeURIComponent(locator.repo)}/compare/${encodeComparisonReference(locator.base)}...${encodeComparisonReference(locator.head)}`, | |
| token, | |
| ); | |
| assertAhead(cmp); | |
| const baseQual = parseQualifiedReference(locator.base, locator.owner); | |
| const headQual = parseQualifiedReference(locator.head, locator.owner); | |
| const headCommit = await githubRequest( | |
| `/repos/${encodeURIComponent(headQual.owner)}/${encodeURIComponent(locator.repo)}/commits/${encodeURIComponent(headQual.ref)}`, | |
| token, | |
| ); | |
| const headSha = headCommit.sha || ""; | |
| if (!headSha) { throw new Error("Could not determine the comparison head commit."); } | |
| await githubRequest( | |
| `/repos/${encodeURIComponent(baseQual.owner)}/${encodeURIComponent(locator.repo)}/git/refs/heads/${encodeGitReference(baseQual.ref)}`, | |
| token, | |
| { | |
| method: "PATCH", | |
| headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ sha: headSha, force: false }), | |
| }, | |
| ); | |
| } | |
| // --- UI --- | |
| function getStatusPresentation(state) { | |
| if (state.kind === "loading") { | |
| return { tone: "loading", title: "Checking fast-forward status" }; | |
| } | |
| if (state.kind === "error") { | |
| return { tone: "error", title: "Fast-forward status unavailable", detail: state.message }; | |
| } | |
| const aheadDetail = (n) => `${n} commit${n === 1 ? "" : "s"} ahead`; | |
| const action = state.result.hasGitHubPersonalAccessToken ? "merge" : "open-options"; | |
| switch (state.result.status) { | |
| case "ff-possible": | |
| return { | |
| tone: "success", | |
| title: "Fast-forward merge possible", | |
| detail: aheadDetail(state.result.aheadBy), | |
| action, | |
| }; | |
| case "ff-possible-but-closed": | |
| return { | |
| tone: "error", | |
| title: "Fast-forward merge possible, but the pull request is not open", | |
| detail: aheadDetail(state.result.aheadBy), | |
| action, | |
| }; | |
| case "ff-possible-but-draft": | |
| return { | |
| tone: "error", | |
| title: "Fast-forward merge possible, but the pull request is a draft", | |
| detail: aheadDetail(state.result.aheadBy), | |
| action, | |
| }; | |
| case "ff-possible-but-blocked": | |
| return { | |
| tone: "error", | |
| title: "Fast-forward merge possible, but blocked by branch protection", | |
| detail: aheadDetail(state.result.aheadBy), | |
| }; | |
| case "up-to-date": | |
| return { tone: "muted", title: "Already up to date" }; | |
| case "base-ahead": | |
| case "diverged": | |
| return { tone: "muted", title: "Fast-forward merge not possible" }; | |
| case "closed": | |
| return { tone: "muted", title: "Pull request is not open" }; | |
| default: | |
| return { | |
| tone: "error", | |
| title: "Fast-forward status unavailable", | |
| detail: "GitHub did not return a comparison state this extension understands.", | |
| }; | |
| } | |
| } | |
| function render() { | |
| while (root.firstChild) { | |
| root.removeChild(root.firstChild); | |
| } | |
| if (!ui.pageKind) { return; } | |
| const presentation = getStatusPresentation(ui.state); | |
| const isCompare = ui.pageKind === "compare"; | |
| const article = document.createElement("article"); | |
| article.className = isCompare ? "ghff-compare-banner" : "ghff-card"; | |
| article.dataset.tone = presentation.tone; | |
| const copy = isCompare ? document.createElement("div") : article; | |
| if (isCompare) { copy.className = "ghff-compare-banner__copy"; } | |
| const title = document.createElement("div"); | |
| title.className = "ghff-title"; | |
| title.textContent = presentation.title; | |
| copy.appendChild(title); | |
| if (presentation.detail) { | |
| const detail = document.createElement("div"); | |
| detail.className = "ghff-detail"; | |
| detail.textContent = presentation.detail; | |
| copy.appendChild(detail); | |
| } | |
| if (ui.mergeState.kind === "error") { | |
| const err = document.createElement("div"); | |
| err.className = "ghff-detail ghff-detail--error"; | |
| err.textContent = ui.mergeState.message; | |
| copy.appendChild(err); | |
| } | |
| if (isCompare) { article.appendChild(copy); } | |
| if (presentation.action) { | |
| const button = document.createElement("button"); | |
| button.type = "button"; | |
| if (presentation.action === "merge") { | |
| button.textContent = | |
| ui.mergeState.kind === "submitting" ? "Fast-forwarding..." : "Fast-forward merge"; | |
| button.disabled = ui.mergeState.kind === "submitting"; | |
| button.addEventListener("click", () => { | |
| void fastForwardMerge(); | |
| }); | |
| } else { | |
| button.textContent = "Set up GitHub token"; | |
| button.addEventListener("click", () => { | |
| if (promptForToken()) { | |
| pageState.cache.clear(); | |
| void refresh({ bypassCache: true }); | |
| } | |
| }); | |
| } | |
| if (isCompare) { | |
| const actions = document.createElement("div"); | |
| actions.className = "ghff-compare-banner__actions"; | |
| actions.appendChild(button); | |
| article.appendChild(actions); | |
| } else { | |
| article.appendChild(button); | |
| } | |
| } | |
| root.appendChild(article); | |
| } | |
| function setPageKind(v) { | |
| ui.pageKind = v; | |
| render(); | |
| } | |
| function setState(v) { | |
| ui.state = v; | |
| render(); | |
| } | |
| function setMergeState(v) { | |
| ui.mergeState = v; | |
| render(); | |
| } | |
| // --- Refresh / merge --- | |
| async function refresh(options = {}) { | |
| if (pageState.scheduled) { return; } | |
| pageState.scheduled = true; | |
| await sleep(100); | |
| pageState.scheduled = false; | |
| const locator = parseCurrentRoute(location.pathname); | |
| if (!locator) { | |
| root.remove(); | |
| setPageKind(null); | |
| setMergeState({ kind: "idle" }); | |
| return; | |
| } | |
| const mount = findMountInstruction(locator); | |
| if (!mount) { | |
| root.remove(); | |
| return; | |
| } | |
| setPageKind(locator.pageKind); | |
| ensureMounted(mount); | |
| const cached = pageState.cache.get(locator.signature); | |
| if (!options.bypassCache && cached && Date.now() - cached.cachedAt < PAGE_CACHE_TTL_MS) { | |
| setMergeState({ kind: "idle" }); | |
| setState({ kind: "loaded", result: cached.result }); | |
| return; | |
| } | |
| if (!options.preserveState) { setState({ kind: "loading" }); } | |
| if (!options.bypassCache && pageState.pendingKey === locator.signature) { return; } | |
| pageState.pendingKey = locator.signature; | |
| const requestId = ++pageState.requestId; | |
| try { | |
| const result = await getStatusResult(locator); | |
| if (requestId !== pageState.requestId) { return; } | |
| const opt = pageState.optimisticStatuses.get(locator.signature); | |
| if (opt && opt.until > Date.now() && result.status !== opt.expectedStatus) { | |
| window.setTimeout(() => { | |
| void refresh({ bypassCache: true, preserveState: true }); | |
| }, 1000); | |
| return; | |
| } | |
| pageState.optimisticStatuses.delete(locator.signature); | |
| if (options.bypassCache) { | |
| pageState.cache.delete(locator.signature); | |
| } else { | |
| pageState.cache.set(locator.signature, { result, cachedAt: Date.now() }); | |
| } | |
| setMergeState({ kind: "idle" }); | |
| setState({ kind: "loaded", result }); | |
| } catch (error) { | |
| if (requestId !== pageState.requestId) { return; } | |
| if (options.preserveState) { return; } | |
| setState({ | |
| kind: "error", | |
| message: error instanceof Error ? error.message : String(error), | |
| }); | |
| } finally { | |
| if (pageState.pendingKey === locator.signature) { pageState.pendingKey = null; } | |
| } | |
| } | |
| async function fastForwardMerge() { | |
| const locator = parseCurrentRoute(location.pathname); | |
| if (!locator) { | |
| setMergeState({ kind: "error", message: "This is no longer a supported GitHub page." }); | |
| return; | |
| } | |
| setMergeState({ kind: "submitting" }); | |
| try { | |
| await performMerge(locator); | |
| const currentState = ui.state; | |
| const optimisticResult = { | |
| aheadBy: 0, | |
| hasGitHubPersonalAccessToken: (currentState.kind === "loaded") ? | |
| currentState.result.hasGitHubPersonalAccessToken : | |
| true, | |
| status: locator.optimisticStatusAfterMerge, | |
| }; | |
| pageState.optimisticStatuses.set(locator.signature, { | |
| expectedStatus: locator.optimisticStatusAfterMerge, | |
| until: Date.now() + 5000, | |
| }); | |
| pageState.cache.set(locator.signature, { | |
| result: optimisticResult, | |
| cachedAt: Date.now(), | |
| }); | |
| setMergeState({ kind: "idle" }); | |
| setState({ kind: "loaded", result: optimisticResult }); | |
| window.setTimeout(() => { | |
| pageState.cache.delete(locator.signature); | |
| void refresh({ bypassCache: true, preserveState: true }); | |
| }, 1500); | |
| } catch (error) { | |
| pageState.optimisticStatuses.delete(locator.signature); | |
| setMergeState({ | |
| kind: "error", | |
| message: error instanceof Error ? error.message : String(error), | |
| }); | |
| } | |
| } | |
| function sleep(ms) { | |
| return new Promise((resolve) => setTimeout(resolve, ms)); | |
| } | |
| // --- Bootstrap --- | |
| ensureStyle(); | |
| pageState.currentPath = location.pathname; | |
| void refresh(); | |
| window.addEventListener("load", () => { | |
| void refresh(); | |
| }); | |
| window.addEventListener("popstate", () => { | |
| void refresh(); | |
| }); | |
| document.addEventListener( | |
| "pjax:end", | |
| () => { | |
| void refresh(); | |
| }, | |
| true, | |
| ); | |
| document.addEventListener( | |
| "turbo:load", | |
| () => { | |
| void refresh(); | |
| }, | |
| true, | |
| ); | |
| document.addEventListener( | |
| "turbo:render", | |
| () => { | |
| void refresh(); | |
| }, | |
| true, | |
| ); | |
| // GitHub delivers many in-page updates (new commits, comments, status checks) | |
| // via Turbo Streams and Turbo Frames rather than full navigations. Listen to | |
| // those too, throttled so a flurry of comment activity doesn't translate | |
| // into a flurry of API calls. Important: invalidate the cache and let | |
| // refresh()'s pending-fetch dedup decide whether to actually launch a | |
| // request — bypassCache here would skip that dedup and let stream events | |
| // invalidate an in-flight bootstrap fetch, leaving the UI stuck on | |
| // "Checking fast-forward status" until events stop firing. | |
| let lastStreamRefreshAt = 0; | |
| const onStreamEvent = () => { | |
| const now = Date.now(); | |
| if (now - lastStreamRefreshAt < STREAM_REFRESH_MIN_INTERVAL_MS) { return; } | |
| lastStreamRefreshAt = now; | |
| const locator = parseCurrentRoute(location.pathname); | |
| if (locator) { pageState.cache.delete(locator.signature); } | |
| void refresh({ preserveState: true }); | |
| }; | |
| document.addEventListener("turbo:before-stream-render", onStreamEvent, true); | |
| document.addEventListener("turbo:frame-load", onStreamEvent, true); | |
| setInterval(() => { | |
| if (location.pathname !== pageState.currentPath) { | |
| pageState.currentPath = location.pathname; | |
| void refresh(); | |
| return; | |
| } | |
| const locator = parseCurrentRoute(location.pathname); | |
| if (!locator) { return; } | |
| // GitHub uses Turbo Streams (not full Turbo navigations) to swap parts | |
| // of the page after events like a new commit push. Stream renders don't | |
| // fire turbo:render, so we won't be told to re-mount or re-fetch — both | |
| // need to be backstopped by polling. | |
| if (!root.isConnected) { | |
| void refresh({ preserveState: true }); | |
| return; | |
| } | |
| const cached = pageState.cache.get(locator.signature); | |
| if (!cached || Date.now() - cached.cachedAt > PAGE_CACHE_TTL_MS) { | |
| // Same reasoning as onStreamEvent: stale cache will already cause a | |
| // fetch inside refresh(), so we don't need bypassCache — and not using | |
| // it lets pending-fetch dedup work, avoiding the stuck-on-loading race. | |
| void refresh({ preserveState: true }); | |
| } | |
| }, URL_CHECK_INTERVAL_MS); | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment