Skip to content

Pending-review leave warning and new commits since last look - #4

Merged
waterfausett merged 1 commit into
masterfrom
feat/review-state
Oct 20, 2026
Merged

waterfausett merged 1 commit into
masterfrom
feat/review-state

Conversation

@waterfausett

Copy link
Copy Markdown
Owner

Two review-state features. Both are on by default and have toggles in settings. No new @connect host is needed.

1. Warn before leaving with an unsubmitted pending review

What it does: If your pending review has comments, leaving the PR asks first. Same-origin links that go off the PR (another PR, issues, a blob view) get a confirm(); cancelling it stops the navigation. Anything that unloads the page (closing the tab, cross-origin links, typed URLs) gets the browser's own beforeunload prompt. These never prompt:

  • links inside /<o>/<r>/pull/<n> (any tab, hash links, /files/a..b)
  • clicking Submit in the review dialog
  • form submits (the classic review form posts)
  • F5 / Ctrl+R
  • the toolkit's own whitespace redirect

How it detects the pending review (pendingReviewCount). It checks these in order:

  • React Files view: GitHub's review button (button[class*="ReviewMenuButton__"]) is a Primer Button with count={pendingReviewIDs.length || undefined}. Primer renders that as [data-component="ButtonCounter"]. I confirmed both in GitHub's shipped bundles (the ReviewMenuButton component in the 75-*.js chunk, and ButtonCounter in primer-react-*.js). The same bundle mounts this button in the PR's sticky header on every tab.
  • Classic Files view: .js-pending-review-comment-count. GitHub's diffs-*.js keeps it in sync with pendingReviewCommentsCount and toggles is-review-pending.
  • Fresh page load: viewerPendingReview from the React payload (react-app[initial-path] → script[data-target="react-app.embeddedData"]). I confirmed it in a real PR page ({"id":null,"comments":[]} when signed out). It's only used when initial-path is this PR, so a stale payload from another page after in-app navigation is ignored.
  • The last value read from the page sticks for the PR, so going to a tab without a counter keeps it. A button with no counter means 0, which overrides the payload.

False-positive guards:

  • Your submitted comments are never counted. Only GitHub's pending counters are read, never "Pending"-style text.
  • Submitting from the React dialog runs clearPendingReview(); window.location.href = redirectUrl (seen in the bundle), so the counter is likely still rendered when beforeunload fires. beforeunload therefore stays quiet while the review overlay's #anchored-review-title is on the page.

2. New commits since your last look

What it does: The panel shows "N new commits since your last look", with a View changes chip and a Dismiss button. The chip opens GitHub's own range view /<o>/<r>/pull/<n>/files/<old>..<new>.

Getting the head SHA without a token. loadCommitOids fetches /<o>/<r>/pull/<n>/commits with Accept: application/json (the request GitHub's own React router makes). That returns payload.pullRequestsCommitsRoute.commitGroups[].commits[].oid, about 2 KB per commit. If the response isn't JSON, it parses the same payload out of the HTML page. The last oid is cross-checked against the head SHA embedded in metadata.commitHeadShaChannel. If the route is truncated or the two disagree, the feature shows nothing.

Rejected alternative: .patch. Each commit starts with From <sha>, but .patch leaves out merge commits. On microsoft/vscode#342850, whose head is "Merge branch 'main' into …", the last From was 2d80450 while the real head is ad2fe67. A head-only push of a merge from main would also go unnoticed. It also grows with the diff size times the commit count.

What counts as the "last look": the head SHA when you last tick a file's Viewed box. It hooks into the existing Viewed-control binding. I chose this over review submission because:

  • Viewed is an explicit "I looked at this code at this state" signal that the toolkit already observes reliably.
  • Many reviews are never formally submitted.
  • Detecting submission in the React dialog would rely on more DOM guessing.

Un-ticking Viewed doesn't move the last look. Dismiss sets it to the current head.

Force-pushes: when the old SHA is no longer in the PR's commit list, the PR range view 404s ("Commit range not found"; checked on cli/cli#14655 with its real before/after force-push SHAs). The link switches to /<o>/<r>/compare/<old>..<new>, which returns 200 and is the same link GitHub uses for force-push events. The panel then says "Force-pushed since your last look" and notes that a rebase also brings in base-branch changes.

Storage is v3:lastLook:<digest>, which pruneStalePrs prunes.

Verified

  • node --check passes.
  • I curled public PRs without auth:
  • I ran the whole userscript in jsdom with GM_* stubs (scratch harness, not committed). All 49 checks pass. They cover:
    • parsing the real captured JSON and HTML, truncated lists, channel mismatches and garbage input
    • recording on Viewed, the 3-commit and 1-commit wording, the range link, the force-push compare link, Dismiss, un-tick, and the feature-off case (no request)
    • the guard: in-PR, hash and range links, /pull/147 vs /pull/14752, outbound links with cancel and accept, external, _blank and Ctrl-click links, the allowance window expiring, the open review dialog, counter removed, the sticky count, form submit, classic counters, payload on and off, a payload for another PR, the live button overriding a stale payload, the feature-off case, and a real signed-out PR page

Unverified (no signed-in browser available)

  • The signed-in markup with a pending review: the counter's real text, whether viewerPendingReview.comments lists the pending comments, and the shape of the classic count element. These are inferred from GitHub's current JS bundles, not from a rendered page.
  • Whether #anchored-review-title is still present when beforeunload fires during a React submit. If it isn't, submitting a review from the Files tab could prompt once.
  • How the beforeunload prompt behaves in Tampermonkey's sandbox (it uses preventDefault plus returnValue).
  • Whether confirm() in a capture-phase window click listener reliably beats GitHub's Turbo and React Router handlers. It should, because they check defaultPrevented.
  • Whether the commits JSON route works for private repos through GM_xmlhttpRequest cookies (it should, because it's the route the React app uses), and how it behaves past 250 commits. It bails on truncated or a channel mismatch.
  • Browser back/forward inside GitHub's app isn't intercepted. Neither is a tab close within 10s of clicking a link inside the PR.

Manual test

  1. On a PR's Files tab, add a review comment with "Start a review". The review button shows 1.
  2. Click a link to the repo's Issues: a confirm appears. Cancel keeps you on the PR; OK leaves.
  3. Back on the PR, switch Conversation / Commits / Files with the panel and with GitHub's tabs: no prompt. Close the tab: the browser asks.
  4. Submit the review from the dialog: no prompt during the redirect. Afterwards, leaving the PR doesn't ask.
  5. On a PR you can push to, tick Viewed on any file. Push 2 commits and reload: the panel shows "2 new commits since your last look". View changes opens /files/<old>..<new> with only those commits.
  6. Force-push (amend + push -f) and reload: "Force-pushed since your last look", with View changes opening /compare/.... Dismiss hides the row.
  7. Turn either toggle off in ⚙ and check the behaviour stops.

🤖 Generated with Claude Code

…t look

Pending-review guard: reads the pending comment count from GitHub's review
button (React ButtonCounter / classic .js-pending-review-comment-count),
seeded from the React payload's viewerPendingReview on a fresh load.
beforeunload covers unloads; same-origin links that leave the PR get a
confirm(). In-PR links, form submits, F5/Ctrl+R and the open review dialog
never prompt.

Since last look: records the PR head SHA when a file is marked Viewed and,
once the head moves, links to /pull/<n>/files/<old>..<new> (or
/compare/<old>..<new> after a force-push). The head comes from the commits
route's JSON, cross-checked against its head-SHA channel; .patch is not used
because it omits merge commits.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@waterfausett
waterfausett merged commit 84072d1 into master Oct 20, 2026
@waterfausett
waterfausett deleted the feat/review-state branch October 20, 2026 18:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant