From 62ef76f440be2556b66b94e1b6f72b7835a48854 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 12:27:53 +0000 Subject: [PATCH 1/7] Only the PR header badge decides merged/closed state (v1.2.1) On the files page the quick-approve button appears, then vanishes a few seconds later once the conversation-page fetch resolves: detectPrStateInDoc took any state badge in the document, so a "Merged"/"Closed" badge on a timeline cross-reference or linked issue was read as the PR's own state. Now the first badge that reads as a PR state decides, and an open/draft header ends the scan. The state log also names the badge it matched. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MM2QDa5SMgF9CRr94AZzyW --- github-pr-approve-helper.user.js | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/github-pr-approve-helper.user.js b/github-pr-approve-helper.user.js index 1f1b13e..dc4b48c 100644 --- a/github-pr-approve-helper.user.js +++ b/github-pr-approve-helper.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name GitHub PR Approve Helper // @namespace https://github.com/MishaKav/userscripts/github-pr-approve-helper -// @version 1.2.0 +// @version 1.2.1 // @description A userscript that auto-fills the review comment with LGTM when you select Approve in the GitHub pull request review dialog // @author Misha Kav // @copyright 2026, Misha Kav @@ -19,7 +19,7 @@ 'use strict'; // keep in sync with @version above, shown in the logs and the badge - const VERSION = '1.2.0'; + const VERSION = '1.2.1'; // automatically select the approve option when the review dialog opens const AUTO_SELECT_APPROVE = true; @@ -541,6 +541,11 @@ // page/fetch detection below recognizes the approval instead const approvedPrs = new Set(); + // the pr states a header badge can show: data-status "pullMerged" / + // "pullClosed" / "pullOpened" / "pullDraft" in the react header, the + // badge text or a "Status: Merged" title on the classic one + const PR_BADGE_STATES = ['merged', 'closed', 'open', 'draft']; + const escapeRegExp = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const getMyLogin = () => @@ -551,17 +556,30 @@ // the sidebar/timeline. null when the document shows neither // returns { state, via } or null; `via` names the signal for the log const detectPrStateInDoc = (doc, me) => { + // only the pr header badge counts, and it comes first in the document. + // timeline cross-references ("this was referenced by #250") and linked + // issues render their own "Merged"/"Closed" badges further down, which + // must not hide the button on an open pr - so the first badge that + // reads as a pr state decides, and an "open"/"draft" header ends the scan for (const badge of doc.querySelectorAll(SELECTORS.STATE_BADGE)) { const status = (badge.getAttribute('data-status') ?? '').toLowerCase(); const text = badge.textContent.trim().toLowerCase(); const title = (badge.getAttribute('title') ?? '').toLowerCase(); - if (status.includes('merged') || text === 'merged' || title === 'status: merged') { - return { state: 'merged', via: 'state badge' }; + const state = PR_BADGE_STATES.find( + (name) => + status.includes(name) || text === name || title === `status: ${name}`, + ); + + if (!state) { + continue; } - if (status.includes('closed') || text === 'closed' || title === 'status: closed') { - return { state: 'closed', via: 'state badge' }; + + const via = `state badge "${status || title || text}"`; + if (state === 'merged' || state === 'closed') { + return { state, via }; } + break; // open or draft header - the pr is open, ignore later badges } if (me) { From 238e017423e9fd427c82397da09ddf3471a0ea58 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 12:42:53 +0000 Subject: [PATCH 2/7] Approve a whole GitHub stack from the quick-approve menu (v1.3.0) The plain click still approves only the current PR. On a PR that is part of a stack (the N/M badge next to the state label), the right-click menu gains "Approve whole stack", which approves every PR bottom to top with the default comment, skipping merged/closed/own/already-approved ones and reporting the tally on the button. Stack members are read from the page's embedded JSON payload (graphql shape: stack.entries[].pullRequest.number), falling back to the stack map popover opened through the badge. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MM2QDa5SMgF9CRr94AZzyW --- github-pr-approve-helper.user.js | 255 +++++++++++++++++++++++++++++-- 1 file changed, 241 insertions(+), 14 deletions(-) diff --git a/github-pr-approve-helper.user.js b/github-pr-approve-helper.user.js index dc4b48c..7134456 100644 --- a/github-pr-approve-helper.user.js +++ b/github-pr-approve-helper.user.js @@ -1,8 +1,8 @@ // ==UserScript== // @name GitHub PR Approve Helper // @namespace https://github.com/MishaKav/userscripts/github-pr-approve-helper -// @version 1.2.1 -// @description A userscript that auto-fills the review comment with LGTM when you select Approve in the GitHub pull request review dialog +// @version 1.3.0 +// @description Auto-fills the review comment with LGTM on Approve, adds a quick-approve button and can approve a whole stack of PRs // @author Misha Kav // @copyright 2026, Misha Kav // @match https://github.com/linear-b/* @@ -19,7 +19,7 @@ 'use strict'; // keep in sync with @version above, shown in the logs and the badge - const VERSION = '1.2.1'; + const VERSION = '1.3.0'; // automatically select the approve option when the review dialog opens const AUTO_SELECT_APPROVE = true; @@ -53,6 +53,11 @@ // comment and approve the PR without opening the review dialog const SHOW_QUICK_APPROVE = true; + // on a PR that is part of a github stack, offer "approve whole stack" in + // the right-click menu of the quick-approve button. the plain click still + // approves only the current PR - the stack run is always a manual pick + const STACK_APPROVE = true; + const DROPDOWN_ID = 'gpah-comment-select'; const BUTTON_ID = 'gpah-quick-approve'; const MENU_ID = 'gpah-quick-approve-menu'; @@ -702,6 +707,156 @@ return 'open'; // fail open while the fetch resolves }; + // ===== STACKS ===== + + // the stack badge in the pr header ("2/3" with a layers icon, next to the + // state label) - it opens the stack map popover. its text gives the + // position and size without opening anything + const getStackBadge = () => { + for (const el of document.querySelectorAll('button, summary, a')) { + const match = el.textContent.trim().match(/^(\d+)\s*\/\s*(\d+)$/); + // must sit next to the pr state label, so a "1/2" elsewhere on the + // page (a checks counter, pagination) is never taken for the badge + const nearStateLabel = el.parentElement + ?.closest('*') + ?.parentElement?.querySelector(SELECTORS.STATE_BADGE); + if (match && el.querySelector('svg') && nearStateLabel) { + return { el, position: Number(match[1]), size: Number(match[2]) }; + } + } + return null; + }; + + // walk the json payloads github embeds in the page for the pr's stack: + // graphql-shaped `stack: { number, size, entries: [{ position, + // pullRequest: { number } }] }`, tolerant to flattened entries or an + // `edges/nodes` connection. returns [{ number, position }] or [] + const collectStackFromPayload = (doc, pr) => { + const entries = new Map(); // pr number -> position + + const collectEntries = (node, position, depth) => { + if (!node || typeof node !== 'object' || depth > 8) { + return; + } + if (Array.isArray(node)) { + node.forEach((item) => collectEntries(item, position, depth + 1)); + return; + } + const ownPosition = Number.isInteger(node.position) ? node.position : position; + const isPr = + Number.isInteger(node.number) && + !('size' in node) && + !('entries' in node) && + (typeof node.title === 'string' || + 'headRefName' in node || + 'headRef' in node || + Number.isInteger(ownPosition)); + if (isPr) { + entries.set(node.number, ownPosition ?? entries.get(node.number) ?? null); + } + for (const value of Object.values(node)) { + collectEntries(value, ownPosition, depth + 1); + } + }; + + // find every object that sits under a key named like "stack" + const findStacks = (node, depth) => { + if (!node || typeof node !== 'object' || depth > 12) { + return; + } + for (const [key, value] of Object.entries(node)) { + if (/stack/i.test(key) && value && typeof value === 'object') { + collectEntries(value, undefined, 0); + } + findStacks(value, depth + 1); + } + }; + + for (const script of doc.querySelectorAll('script[type="application/json"]')) { + if (/stack/i.test(script.textContent)) { + findStacks(safeJsonParse(script.textContent), 0); + } + } + + // a real stack of this pr contains this pr + if (!entries.has(Number(pr.number))) { + return []; + } + return [...entries].map(([number, position]) => ({ number, position })); + }; + + // fallback: read the stack map popover ("Stack #257" listing "#255 · + // branch" rows). opens it through the badge when closed, and closes it + // again with escape + const collectStackFromPopover = async (pr, badge) => { + const prefix = `/${pr.owner}/${pr.repo}/pull/`; + const findPopover = () => + [...document.querySelectorAll('[role="dialog"], [data-component="Popover"], [class*="Overlay" i], dialog, div')].find( + (el) => + /^\s*Stack #\d+/.test(el.textContent) && + el.textContent.length < 5000 && + el.querySelector(`a[href*="${prefix}"]`), + ); + + let popover = findPopover(); + let opened = false; + if (!popover && badge) { + badge.el.click(); + opened = true; + popover = await waitFor(findPopover, 3000); + } + if (!popover) { + return []; + } + + const numbers = []; + for (const link of popover.querySelectorAll(`a[href*="${prefix}"]`)) { + const number = Number(link.getAttribute('href').split(prefix)[1]?.match(/^\d+/)?.[0]); + if (number && !numbers.includes(number)) { + numbers.push(number); + } + } + + if (opened) { + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + popover.querySelector('button[aria-label*="close" i]')?.click(); + } + + // the popover lists top of the stack first - flip to bottom-first + return numbers.reverse().map((number, index) => ({ number, position: index + 1 })); + }; + + // the prs of this pr's stack, bottom (closest to the trunk) first + const findStackPrs = async (pr, badge) => { + let entries = collectStackFromPayload(document, pr); + let via = 'page payload'; + + if (entries.length < 2) { + entries = await collectStackFromPopover(pr, badge); + via = 'stack popover'; + } + + entries.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + console.log( + `[GitHub PR Approve Helper] stack of ${prKey(pr)}: ${ + entries.map((e) => `#${e.number}`).join(', ') || 'not found' + } (via ${via})`, + ); + return entries.map((entry) => ({ ...pr, number: String(entry.number) })); + }; + + // state of another pr of the stack, from its conversation page - so the + // stack run skips merged/closed/own/already-approved prs instead of + // failing on them + const fetchPrState = async (pr) => { + const response = await fetch(prPagePath(pr), { credentials: 'include' }); + if (!response.ok) { + throw new Error(`pr page failed to load (${response.status})`); + } + const doc = new DOMParser().parseFromString(await response.text(), 'text/html'); + return detectPrStateInDoc(doc, getMyLogin())?.state ?? 'open'; + }; + // per-pr dismissal of the indicator, so a click hides it until the next // navigation to a different pr let indicatorDismissedFor = null; @@ -748,7 +903,51 @@ const closeQuickApproveMenu = () => document.getElementById(MENU_ID)?.remove(); - const onQuickApprove = async (comment) => { + // approve the whole stack, bottom to top. the current pr may fall back + // to driving the ui; the others are approved directly or reported + const approveStack = async (comment, pr, button) => { + const prs = await findStackPrs(pr, getStackBadge()); + if (prs.length < 2) { + throw new Error('could not read the stack - see the "stack of" console line'); + } + + const results = { approved: [], skipped: [], failed: [] }; + + for (const [index, member] of prs.entries()) { + setButtonState(button, `⏳ Approving ${index + 1}/${prs.length}…`, '#9a6700', 'busy'); + const key = prKey(member); + const isCurrent = member.number === pr.number; + + try { + const state = isCurrent ? 'open' : await fetchPrState(member); + if (state !== 'open') { + results.skipped.push(`${key} (${state})`); + continue; + } + + try { + await submitApproval(comment, member); + } catch (directError) { + if (!isCurrent) { + throw directError; + } + console.log( + `[GitHub PR Approve Helper] direct approve failed (${directError.message}), driving the ui instead`, + ); + await submitViaUi(comment, member); + } + approvedPrs.add(key); + results.approved.push(key); + } catch (error) { + results.failed.push(`${key} (${error.message})`); + } + } + + console.log(`[GitHub PR Approve Helper] stack run: ${JSON.stringify(results)}`); + return results; + }; + + const onQuickApprove = async (comment, { stack = false } = {}) => { closeQuickApproveMenu(); const button = document.getElementById(BUTTON_ID); const pr = parsePrPath(); @@ -768,17 +967,33 @@ setButtonState(button, '⏳ Approving…', '#9a6700', 'busy'); try { - try { - await submitApproval(comment, pr); - } catch (directError) { - console.log( - `[GitHub PR Approve Helper] direct approve failed (${directError.message}), driving the ui instead`, + if (stack) { + const { approved, skipped, failed } = await approveStack(comment, pr, button); + const total = approved.length + skipped.length + failed.length; + if (failed.length) { + throw new Error( + `approved ${approved.length}/${total} of the stack, failed: ${failed.join('; ')}`, + ); + } + setButtonState( + button, + `🎉 Stack approved ${approved.length}/${total}${skipped.length ? ` (${skipped.length} skipped)` : ''}`, + '#1f883d', + 'done', ); - await submitViaUi(comment, pr); + } else { + try { + await submitApproval(comment, pr); + } catch (directError) { + console.log( + `[GitHub PR Approve Helper] direct approve failed (${directError.message}), driving the ui instead`, + ); + await submitViaUi(comment, pr); + } + approvedPrs.add(prKey(pr)); + setButtonState(button, '🎉 Approved', '#1f883d', 'done'); } - approvedPrs.add(prKey(pr)); - setButtonState(button, '🎉 Approved', '#1f883d', 'done'); - console.log(`[GitHub PR Approve Helper] approved ${prKey(pr)}: "${comment}"`); + console.log(`[GitHub PR Approve Helper] approved ${prKey(pr)}${stack ? ' (stack)' : ''}: "${comment}"`); setTimeout(() => { button.remove(); scheduleScan(); // hands over to the "already approved" indicator @@ -840,6 +1055,18 @@ for (const comment of comments) { menu.appendChild(createMenuRow(`✅ ${comment}`, () => onQuickApprove(comment))); } + const badge = STACK_APPROVE && getStackBadge(); + if (badge) { + const divider = document.createElement('div'); + divider.style.cssText = 'border-top: 1px solid #d0d7de; margin: 4px 0'; + menu.appendChild(divider); + menu.appendChild( + createMenuRow(`🥞 Approve whole stack (${badge.size} PRs) with ${DEFAULT_COMMENT}`, () => + onQuickApprove(DEFAULT_COMMENT, { stack: true }), + ), + ); + } + menu.appendChild(createMenuRow('Cancel', closeQuickApproveMenu, true)); document.body.appendChild(menu); @@ -888,7 +1115,7 @@ button.id = BUTTON_ID; button.type = 'button'; button.textContent = '✅ Quick approve'; - button.title = `Approve Helper v${VERSION} - click: approve with ${DEFAULT_COMMENT} · right-click: choose text`; + button.title = `Approve Helper v${VERSION} - click: approve with ${DEFAULT_COMMENT} · right-click: choose text or approve the whole stack`; button.style.cssText = [ 'position: fixed', 'bottom: 16px', From b2f3280ceebcad9ff20c02f10e63f97ad15d889c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 12:55:07 +0000 Subject: [PATCH 3/7] Never cache the live page's PR state (v1.3.1) Jumping between PRs through the stack popover is a soft navigation: the url already names the next PR while the previous PR's sidebar is still in the dom, so the live detection read the old "approved" and cached it on the new PR. The fetched conversation page is now the only cached source; the live page is an uncached hint, used only once the tab title names the current PR. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MM2QDa5SMgF9CRr94AZzyW --- github-pr-approve-helper.user.js | 38 ++++++++++++++------------------ 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/github-pr-approve-helper.user.js b/github-pr-approve-helper.user.js index 7134456..fa3f767 100644 --- a/github-pr-approve-helper.user.js +++ b/github-pr-approve-helper.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name GitHub PR Approve Helper // @namespace https://github.com/MishaKav/userscripts/github-pr-approve-helper -// @version 1.3.0 +// @version 1.3.1 // @description Auto-fills the review comment with LGTM on Approve, adds a quick-approve button and can approve a whole stack of PRs // @author Misha Kav // @copyright 2026, Misha Kav @@ -19,7 +19,7 @@ 'use strict'; // keep in sync with @version above, shown in the logs and the badge - const VERSION = '1.3.0'; + const VERSION = '1.3.1'; // automatically select the approve option when the review dialog opens const AUTO_SELECT_APPROVE = true; @@ -650,9 +650,12 @@ const prStateFetches = new Set(); // what to show for this pr: merged/closed/own hide the button, approved - // shows the passive indicator, open shows the button. layered: our own recorded - // approvals, then the live page, then (from other tabs) one cached fetch - // of the conversation page. unknown always falls open to the button + // shows the passive indicator, open shows the button. layered: our own + // recorded approvals, then one cached fetch of the conversation page + // (authoritative), and until that lands a hint from the live page. + // the hint is never cached: during github's soft navigation the url + // already names the next pr while the previous pr's sidebar is still in + // the dom, and caching that would pin the wrong state on the new pr const getPrDisplayState = (pr) => { const key = prKey(pr); @@ -665,22 +668,6 @@ return cached; } - const liveDetection = detectPrStateInDoc(document, getMyLogin()); - if (liveDetection) { - prStateCache.set(key, liveDetection.state); - console.log( - `[GitHub PR Approve Helper] pr state: ${liveDetection.state} (live page, via ${liveDetection.via})`, - ); - return liveDetection.state; - } - - // the conversation tab shows every signal - nothing found means open - if (location.pathname === prPagePath(pr)) { - prStateCache.set(key, 'open'); - return 'open'; - } - - // other tabs lack the sidebar/timeline - ask the conversation page once if (!prStateFetches.has(key)) { prStateFetches.add(key); fetch(prPagePath(pr), { credentials: 'include' }) @@ -704,6 +691,15 @@ .catch(() => prStateCache.set(key, 'open')); } + // the live page is only a hint while the fetch is in flight, and only + // once the tab title names this pr ("… · Pull Request #254 · org/repo") + if (document.title.includes(`#${pr.number}`)) { + const liveDetection = detectPrStateInDoc(document, getMyLogin()); + if (liveDetection) { + return liveDetection.state; + } + } + return 'open'; // fail open while the fetch resolves }; From bac788aaa9639906f4f9fae9e118a25d6bfda519 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 13:17:01 +0000 Subject: [PATCH 4/7] Ship the stack approve and state fixes as v1.3.0 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MM2QDa5SMgF9CRr94AZzyW --- README.md | 2 +- github-pr-approve-helper.user.js | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 2a6fcba..7aa81db 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ Misc Userscripts | Userscript Wiki | Direct Install | Created | Updated | | ------------------------------------- | :-----------------: | :--------: | :--------: | | [CloudWatch Helper][cwh-wiki] | [install][cwh-raw] | 30.03.2021 | 13.12.2021 | -| [GitHub PR Approve Helper][gpah-wiki] | [install][gpah-raw] | 04.08.2026 | 22.08.2026 | +| [GitHub PR Approve Helper][gpah-wiki] | [install][gpah-raw] | 04.08.2026 | 03.09.2026 | [cwh-wiki]: https://github.com/MishaKav/userscripts/wiki/CloudWatch-Helper [cwh-raw]: https://raw.githubusercontent.com/MishaKav/userscripts/main/cloudwatch-helper.user.js diff --git a/github-pr-approve-helper.user.js b/github-pr-approve-helper.user.js index fa3f767..b365479 100644 --- a/github-pr-approve-helper.user.js +++ b/github-pr-approve-helper.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name GitHub PR Approve Helper // @namespace https://github.com/MishaKav/userscripts/github-pr-approve-helper -// @version 1.3.1 +// @version 1.3.0 // @description Auto-fills the review comment with LGTM on Approve, adds a quick-approve button and can approve a whole stack of PRs // @author Misha Kav // @copyright 2026, Misha Kav @@ -19,7 +19,7 @@ 'use strict'; // keep in sync with @version above, shown in the logs and the badge - const VERSION = '1.3.1'; + const VERSION = '1.3.0'; // automatically select the approve option when the review dialog opens const AUTO_SELECT_APPROVE = true; From c032c8efa5d27bc85752ea7284e4e4503e3a3f86 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 3 Sep 2026 13:21:10 +0000 Subject: [PATCH 5/7] Simplify the approve and state paths One approvePr primitive serves the plain click and the stack run, one cache-aware loadPrState serves the button and the stack run, and a stack member's conversation page is fetched once for both its state and its csrf token. The stack payload walker now only accepts the documented stack.entries shape, the popover is found from its pr links, and the badge lookup does the cheap text test first. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MM2QDa5SMgF9CRr94AZzyW --- github-pr-approve-helper.user.js | 358 ++++++++++++++++--------------- 1 file changed, 188 insertions(+), 170 deletions(-) diff --git a/github-pr-approve-helper.user.js b/github-pr-approve-helper.user.js index b365479..8eea74f 100644 --- a/github-pr-approve-helper.user.js +++ b/github-pr-approve-helper.user.js @@ -316,6 +316,15 @@ } }; + // the parsed json payloads github embeds in a page - only the scripts + // whose text mentions `needle`, so the huge ones that can't hold what + // the caller wants are never parsed + const jsonPayloads = (doc, needle) => + [...doc.querySelectorAll('script[type="application/json"]')] + .filter((script) => script.textContent.includes(needle)) + .map((script) => safeJsonParse(script.textContent)) + .filter(Boolean); + // walk a parsed react payload for a `csrf_tokens: {path: {method: token}}` // object, the way the new github ui embeds its csrf tokens const findCsrfTokensMap = (node) => { @@ -344,9 +353,8 @@ // searched (a count and a bounded sample of paths, never token values), // so the failure diagnostic always describes the actual search const scanForReviewToken = (doc, pr) => { - const scripts = [...doc.querySelectorAll('script[type="application/json"]')]; const stats = { - jsonScripts: scripts.length, + jsonScripts: doc.querySelectorAll('script[type="application/json"]').length, reviewForms: doc.querySelectorAll('form[action$="/reviews"]').length, csrfTokenPathCount: 0, csrfTokenPaths: [], @@ -368,14 +376,8 @@ }; } - for (const script of scripts) { - // github embeds huge page payloads in these scripts - skip the json - // parse and walk for the ones that can't contain a csrf_tokens map - if (!script.textContent.includes('csrf_tokens')) { - continue; - } - - const map = findCsrfTokensMap(safeJsonParse(script.textContent)); + for (const payload of jsonPayloads(doc, 'csrf_tokens')) { + const map = findCsrfTokensMap(payload); if (!map) { continue; } @@ -406,13 +408,13 @@ }; // approve the PR the same way github's own ui does: find a fresh csrf - // token (on the live page, or on the fetched files page) and post the - // approve to the reviews endpoint with the session cookies - const submitApproval = async (comment, pr) => { - // the page we're already on may embed the token - const liveScan = scanForReviewToken(document, pr); + // token (in the given documents - the live page by default - then on + // the fetched files page) and post the approve to the reviews endpoint + // with the session cookies + const submitApproval = async (comment, pr, docs = [document]) => { + const scans = docs.map((doc) => scanForReviewToken(doc, pr)); let filesScan = null; - let found = liveScan.found; + let found = scans.map((scan) => scan.found).find(Boolean); if (!found) { const filesResponse = await fetch(prPagePath(pr, 'files'), { @@ -435,7 +437,7 @@ console.log( '[GitHub PR Approve Helper] csrf discovery details:', JSON.stringify({ - livePage: liveScan.stats, + givenPages: scans.map((scan) => scan.stats), filesPage: filesScan?.stats ?? null, }), ); @@ -645,17 +647,57 @@ }; const prStateCache = new Map(); // prKey -> merged|closed|own|approved|open - // prKeys whose conversation-page fetch was already started - one fetch - // per pr, its result lands in prStateCache (entries are never removed) - const prStateFetches = new Set(); + const prStateLoads = new Map(); // prKey -> in-flight loadPrState promise + + // the parsed conversation page of a pr, fetched with the session cookies + const fetchPrDoc = async (pr) => { + const response = await fetch(prPagePath(pr), { credentials: 'include' }); + if (!response.ok) { + throw new Error(`pr page failed to load (${response.status})`); + } + return new DOMParser().parseFromString(await response.text(), 'text/html'); + }; + + // the pr state from its conversation page (it shows every signal), one + // fetch per pr for the whole session, shared by the button and the + // stack run. a failed fetch fails open. `doc` skips the fetch when the + // caller already has the page + const loadPrState = (pr, doc = null) => { + const key = prKey(pr); + const known = approvedPrs.has(key) ? 'approved' : prStateCache.get(key); + if (known) { + return Promise.resolve(known); + } + if (!prStateLoads.has(key)) { + const load = (doc ? Promise.resolve(doc) : fetchPrDoc(pr)) + .then((page) => detectPrStateInDoc(page, getMyLogin())) + .catch(() => null) + .then((detection) => { + const state = detection?.state ?? 'open'; + prStateCache.set(key, state); + console.log( + `[GitHub PR Approve Helper] pr state: ${state} (conversation page${ + detection ? `, via ${detection.via}` : '' + })`, + ); + scheduleScan(); + return state; + }); + prStateLoads.set(key, load); + } + return prStateLoads.get(key); + }; + + // the live page shows this pr - during github's soft navigation the url + // already names the next pr while the previous pr's page is still in + // the dom, and the tab title ("… · Pull Request #254 · org/repo") is + // what flips last + const livePageShows = (pr) => document.title.includes(`#${pr.number}`); // what to show for this pr: merged/closed/own hide the button, approved - // shows the passive indicator, open shows the button. layered: our own - // recorded approvals, then one cached fetch of the conversation page - // (authoritative), and until that lands a hint from the live page. - // the hint is never cached: during github's soft navigation the url - // already names the next pr while the previous pr's sidebar is still in - // the dom, and caching that would pin the wrong state on the new pr + // shows the passive indicator, open shows the button. our own recorded + // approvals first, then the cached conversation-page state, and until + // that lands an uncached hint from the live page const getPrDisplayState = (pr) => { const key = prKey(pr); @@ -668,32 +710,15 @@ return cached; } - if (!prStateFetches.has(key)) { - prStateFetches.add(key); - fetch(prPagePath(pr), { credentials: 'include' }) - .then((response) => (response.ok ? response.text() : null)) - .then((html) => { - const detection = html - ? detectPrStateInDoc( - new DOMParser().parseFromString(html, 'text/html'), - getMyLogin(), - ) - : null; // fetch failed - fail open - const state = detection?.state ?? 'open'; - prStateCache.set(key, state); - console.log( - `[GitHub PR Approve Helper] pr state: ${state} (conversation page${ - detection ? `, via ${detection.via}` : '' - })`, - ); - scheduleScan(); - }) - .catch(() => prStateCache.set(key, 'open')); + // already on the conversation page: it is the authoritative document + if (livePageShows(pr) && location.pathname === prPagePath(pr)) { + loadPrState(pr, document); + return prStateCache.get(key) ?? 'open'; } - // the live page is only a hint while the fetch is in flight, and only - // once the tab title names this pr ("… · Pull Request #254 · org/repo") - if (document.title.includes(`#${pr.number}`)) { + loadPrState(pr); + + if (livePageShows(pr)) { const liveDetection = detectPrStateInDoc(document, getMyLogin()); if (liveDetection) { return liveDetection.state; @@ -711,70 +736,60 @@ const getStackBadge = () => { for (const el of document.querySelectorAll('button, summary, a')) { const match = el.textContent.trim().match(/^(\d+)\s*\/\s*(\d+)$/); + if (!match || !el.querySelector('svg')) { + continue; + } // must sit next to the pr state label, so a "1/2" elsewhere on the // page (a checks counter, pagination) is never taken for the badge - const nearStateLabel = el.parentElement - ?.closest('*') - ?.parentElement?.querySelector(SELECTORS.STATE_BADGE); - if (match && el.querySelector('svg') && nearStateLabel) { + if (el.parentElement?.parentElement?.querySelector(SELECTORS.STATE_BADGE)) { return { el, position: Number(match[1]), size: Number(match[2]) }; } } return null; }; - // walk the json payloads github embeds in the page for the pr's stack: - // graphql-shaped `stack: { number, size, entries: [{ position, - // pullRequest: { number } }] }`, tolerant to flattened entries or an - // `edges/nodes` connection. returns [{ number, position }] or [] + // the pr's stack from the json payloads github embeds in the page, in + // the documented graphql shape: `stack: { number, size, entries: + // [{ position, pullRequest: { number } }] }` (entries may sit under + // `nodes` or `edges[].node`). returns [{ number, position }], empty + // when no stack object of that shape lists this pr const collectStackFromPayload = (doc, pr) => { const entries = new Map(); // pr number -> position - const collectEntries = (node, position, depth) => { - if (!node || typeof node !== 'object' || depth > 8) { + const collectEntries = (node, depth = 0) => { + if (!node || typeof node !== 'object' || depth > 4) { return; } if (Array.isArray(node)) { - node.forEach((item) => collectEntries(item, position, depth + 1)); + node.forEach((item) => collectEntries(item, depth + 1)); return; } - const ownPosition = Number.isInteger(node.position) ? node.position : position; - const isPr = - Number.isInteger(node.number) && - !('size' in node) && - !('entries' in node) && - (typeof node.title === 'string' || - 'headRefName' in node || - 'headRef' in node || - Number.isInteger(ownPosition)); - if (isPr) { - entries.set(node.number, ownPosition ?? entries.get(node.number) ?? null); - } - for (const value of Object.values(node)) { - collectEntries(value, ownPosition, depth + 1); + const target = node.pullRequest ?? node; + if (Number.isInteger(node.position) && Number.isInteger(target.number)) { + entries.set(target.number, node.position); + return; } + Object.values(node).forEach((value) => collectEntries(value, depth + 1)); }; - // find every object that sits under a key named like "stack" - const findStacks = (node, depth) => { + const isStack = (node) => + node && typeof node === 'object' && !Array.isArray(node) && 'entries' in node; + + const findStacks = (node, depth = 0) => { if (!node || typeof node !== 'object' || depth > 12) { return; } - for (const [key, value] of Object.entries(node)) { - if (/stack/i.test(key) && value && typeof value === 'object') { - collectEntries(value, undefined, 0); + for (const value of Object.values(node)) { + if (isStack(value)) { + collectEntries(value.entries); + } else { + findStacks(value, depth + 1); } - findStacks(value, depth + 1); } }; - for (const script of doc.querySelectorAll('script[type="application/json"]')) { - if (/stack/i.test(script.textContent)) { - findStacks(safeJsonParse(script.textContent), 0); - } - } + jsonPayloads(doc, 'entries').forEach((payload) => findStacks(payload)); - // a real stack of this pr contains this pr if (!entries.has(Number(pr.number))) { return []; } @@ -785,40 +800,44 @@ // branch" rows). opens it through the badge when closed, and closes it // again with escape const collectStackFromPopover = async (pr, badge) => { - const prefix = `/${pr.owner}/${pr.repo}/pull/`; - const findPopover = () => - [...document.querySelectorAll('[role="dialog"], [data-component="Popover"], [class*="Overlay" i], dialog, div')].find( - (el) => - /^\s*Stack #\d+/.test(el.textContent) && - el.textContent.length < 5000 && - el.querySelector(`a[href*="${prefix}"]`), - ); + const linkSelector = `a[href*="${prPagePath({ ...pr, number: '' })}"]`; + + // the popover is the closest ancestor of a pr link whose text starts + // with the "Stack #N" heading + const findPopover = () => { + for (const link of document.querySelectorAll(linkSelector)) { + for (let el = link.parentElement; el && el !== document.body; el = el.parentElement) { + if (/^\s*Stack #\d+/.test(el.textContent)) { + return el; + } + } + } + return null; + }; let popover = findPopover(); - let opened = false; - if (!popover && badge) { + const opened = !popover && badge; + if (opened) { badge.el.click(); - opened = true; popover = await waitFor(findPopover, 3000); } if (!popover) { return []; } - const numbers = []; - for (const link of popover.querySelectorAll(`a[href*="${prefix}"]`)) { - const number = Number(link.getAttribute('href').split(prefix)[1]?.match(/^\d+/)?.[0]); - if (number && !numbers.includes(number)) { - numbers.push(number); - } - } + const numbers = [ + ...new Set( + [...popover.querySelectorAll(linkSelector)] + .map((link) => Number(link.getAttribute('href').match(/\/pull\/(\d+)/)?.[1])) + .filter(Boolean), + ), + ]; if (opened) { document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - popover.querySelector('button[aria-label*="close" i]')?.click(); } - // the popover lists top of the stack first - flip to bottom-first + // the popover lists the top of the stack first - flip to bottom-first return numbers.reverse().map((number, index) => ({ number, position: index + 1 })); }; @@ -832,7 +851,7 @@ via = 'stack popover'; } - entries.sort((a, b) => (a.position ?? 0) - (b.position ?? 0)); + entries.sort((a, b) => a.position - b.position); console.log( `[GitHub PR Approve Helper] stack of ${prKey(pr)}: ${ entries.map((e) => `#${e.number}`).join(', ') || 'not found' @@ -841,18 +860,6 @@ return entries.map((entry) => ({ ...pr, number: String(entry.number) })); }; - // state of another pr of the stack, from its conversation page - so the - // stack run skips merged/closed/own/already-approved prs instead of - // failing on them - const fetchPrState = async (pr) => { - const response = await fetch(prPagePath(pr), { credentials: 'include' }); - if (!response.ok) { - throw new Error(`pr page failed to load (${response.status})`); - } - const doc = new DOMParser().parseFromString(await response.text(), 'text/html'); - return detectPrStateInDoc(doc, getMyLogin())?.state ?? 'open'; - }; - // per-pr dismissal of the indicator, so a click hides it until the next // navigation to a different pr let indicatorDismissedFor = null; @@ -899,51 +906,57 @@ const closeQuickApproveMenu = () => document.getElementById(MENU_ID)?.remove(); - // approve the whole stack, bottom to top. the current pr may fall back - // to driving the ui; the others are approved directly or reported - const approveStack = async (comment, pr, button) => { - const prs = await findStackPrs(pr, getStackBadge()); - if (prs.length < 2) { - throw new Error('could not read the stack - see the "stack of" console line'); + // approve one pr: directly, or - for the pr open in the tab - by driving + // github's own review dialog when the direct post fails. prs other than + // the current one come with their fetched conversation page, which also + // serves the csrf token search + const approvePr = async (comment, pr, doc = null) => { + const isCurrent = pr.number === parsePrPath()?.number; + try { + await submitApproval(comment, pr, doc ? [doc, document] : [document]); + } catch (directError) { + if (!isCurrent) { + throw directError; + } + console.log( + `[GitHub PR Approve Helper] direct approve failed (${directError.message}), driving the ui instead`, + ); + await submitViaUi(comment, pr); } + approvedPrs.add(prKey(pr)); + }; + // approve several prs in order, skipping the ones that can't take an + // approval (merged, closed, mine, already approved). one page fetch per + // pr, shared between the state check and the csrf token search + const approvePrs = async (comment, prs, onProgress) => { const results = { approved: [], skipped: [], failed: [] }; - for (const [index, member] of prs.entries()) { - setButtonState(button, `⏳ Approving ${index + 1}/${prs.length}…`, '#9a6700', 'busy'); - const key = prKey(member); - const isCurrent = member.number === pr.number; + for (const [index, pr] of prs.entries()) { + onProgress(index + 1, prs.length); + const key = prKey(pr); try { - const state = isCurrent ? 'open' : await fetchPrState(member); + const isCurrent = pr.number === parsePrPath()?.number; + const doc = isCurrent ? null : await fetchPrDoc(pr); + const state = isCurrent ? getPrDisplayState(pr) : await loadPrState(pr, doc); if (state !== 'open') { results.skipped.push(`${key} (${state})`); continue; } - - try { - await submitApproval(comment, member); - } catch (directError) { - if (!isCurrent) { - throw directError; - } - console.log( - `[GitHub PR Approve Helper] direct approve failed (${directError.message}), driving the ui instead`, - ); - await submitViaUi(comment, member); - } - approvedPrs.add(key); + await approvePr(comment, pr, doc); results.approved.push(key); } catch (error) { results.failed.push(`${key} (${error.message})`); } } - console.log(`[GitHub PR Approve Helper] stack run: ${JSON.stringify(results)}`); return results; }; - const onQuickApprove = async (comment, { stack = false } = {}) => { + // `stack` (the header badge) approves the whole stack instead of the + // current pr only + const onQuickApprove = async (comment, stack = null) => { closeQuickApproveMenu(); const button = document.getElementById(BUTTON_ID); const pr = parsePrPath(); @@ -963,33 +976,38 @@ setButtonState(button, '⏳ Approving…', '#9a6700', 'busy'); try { + let prs = [pr]; if (stack) { - const { approved, skipped, failed } = await approveStack(comment, pr, button); - const total = approved.length + skipped.length + failed.length; - if (failed.length) { - throw new Error( - `approved ${approved.length}/${total} of the stack, failed: ${failed.join('; ')}`, - ); + prs = await findStackPrs(pr, stack); + if (prs.length < 2) { + throw new Error('could not read the stack - see the "stack of" console line'); } - setButtonState( - button, - `🎉 Stack approved ${approved.length}/${total}${skipped.length ? ` (${skipped.length} skipped)` : ''}`, - '#1f883d', - 'done', - ); - } else { - try { - await submitApproval(comment, pr); - } catch (directError) { - console.log( - `[GitHub PR Approve Helper] direct approve failed (${directError.message}), driving the ui instead`, - ); - await submitViaUi(comment, pr); + } + + const results = await approvePrs(comment, prs, (done, total) => { + if (stack) { + setButtonState(button, `⏳ Approving ${done}/${total}…`, '#9a6700', 'busy'); } - approvedPrs.add(prKey(pr)); - setButtonState(button, '🎉 Approved', '#1f883d', 'done'); + }); + const { approved, skipped, failed } = results; + const total = prs.length; + + if (stack) { + console.log(`[GitHub PR Approve Helper] stack run: ${JSON.stringify(results)}`); } - console.log(`[GitHub PR Approve Helper] approved ${prKey(pr)}${stack ? ' (stack)' : ''}: "${comment}"`); + if (failed.length) { + throw new Error( + stack + ? `approved ${approved.length}/${total} of the stack, failed: ${failed.join('; ')}` + : failed[0], + ); + } + + const label = stack + ? `🎉 Stack approved ${approved.length}/${total}${skipped.length ? ` (${skipped.length} skipped)` : ''}` + : '🎉 Approved'; + setButtonState(button, label, '#1f883d', 'done'); + console.log(`[GitHub PR Approve Helper] approved ${approved.join(', ')}: "${comment}"`); setTimeout(() => { button.remove(); scheduleScan(); // hands over to the "already approved" indicator @@ -1058,7 +1076,7 @@ menu.appendChild(divider); menu.appendChild( createMenuRow(`🥞 Approve whole stack (${badge.size} PRs) with ${DEFAULT_COMMENT}`, () => - onQuickApprove(DEFAULT_COMMENT, { stack: true }), + onQuickApprove(DEFAULT_COMMENT, badge), ), ); } From c9aea43bf28e94eb439bc6e0b3f00c87fdf333eb Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 14:56:17 +0000 Subject: [PATCH 6/7] Read the live conversation page synchronously On the conversation page the state was loaded through the async path even though the document was already there, so the quick-approve button could render for one frame on a merged or already-approved PR before the scan re-ran. Detect from the live document synchronously there. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MM2QDa5SMgF9CRr94AZzyW --- github-pr-approve-helper.user.js | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/github-pr-approve-helper.user.js b/github-pr-approve-helper.user.js index 8eea74f..e2c7b92 100644 --- a/github-pr-approve-helper.user.js +++ b/github-pr-approve-helper.user.js @@ -710,10 +710,18 @@ return cached; } - // already on the conversation page: it is the authoritative document + // already on the conversation page: it is the authoritative document, + // read synchronously so the button never flashes on a merged/approved pr if (livePageShows(pr) && location.pathname === prPagePath(pr)) { - loadPrState(pr, document); - return prStateCache.get(key) ?? 'open'; + const detection = detectPrStateInDoc(document, getMyLogin()); + const state = detection?.state ?? 'open'; + prStateCache.set(key, state); + console.log( + `[GitHub PR Approve Helper] pr state: ${state} (live conversation page${ + detection ? `, via ${detection.via}` : '' + })`, + ); + return state; } loadPrState(pr); From 1df9bca83b0282f9808382da8600b81a0febfc61 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 4 Sep 2026 15:03:51 +0000 Subject: [PATCH 7/7] Guard null script text and never claim an approval that was skipped jsonPayloads treats a null textContent as empty instead of throwing. When every pr of a run was skipped (merged, closed, own or approved meanwhile) the button now says so rather than showing the success label. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01MM2QDa5SMgF9CRr94AZzyW --- github-pr-approve-helper.user.js | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/github-pr-approve-helper.user.js b/github-pr-approve-helper.user.js index e2c7b92..36021e9 100644 --- a/github-pr-approve-helper.user.js +++ b/github-pr-approve-helper.user.js @@ -321,8 +321,9 @@ // the caller wants are never parsed const jsonPayloads = (doc, needle) => [...doc.querySelectorAll('script[type="application/json"]')] - .filter((script) => script.textContent.includes(needle)) - .map((script) => safeJsonParse(script.textContent)) + .map((script) => script.textContent ?? '') + .filter((text) => text.includes(needle)) + .map(safeJsonParse) .filter(Boolean); // walk a parsed react payload for a `csrf_tokens: {path: {method: token}}` @@ -1011,11 +1012,19 @@ ); } - const label = stack - ? `🎉 Stack approved ${approved.length}/${total}${skipped.length ? ` (${skipped.length} skipped)` : ''}` - : '🎉 Approved'; - setButtonState(button, label, '#1f883d', 'done'); - console.log(`[GitHub PR Approve Helper] approved ${approved.join(', ')}: "${comment}"`); + // nothing approved: every pr was skipped (e.g. it got merged or was + // approved in another tab meanwhile) - say so, never claim an approval + const label = !approved.length + ? `⏭️ Nothing to approve (${skipped.map((entry) => entry.match(/\((\w+)\)$/)?.[1]).join(', ')})` + : stack + ? `🎉 Stack approved ${approved.length}/${total}${skipped.length ? ` (${skipped.length} skipped)` : ''}` + : '🎉 Approved'; + setButtonState(button, label, approved.length ? '#1f883d' : '#57606a', 'done'); + console.log( + `[GitHub PR Approve Helper] ${ + approved.length ? `approved ${approved.join(', ')}: "${comment}"` : `skipped ${skipped.join(', ')}` + }`, + ); setTimeout(() => { button.remove(); scheduleScan(); // hands over to the "already approved" indicator