Skip to content

Defer review of stacked pull requests until they land on the default branch - #45

Merged
josephschorr merged 6 commits into
mainfrom
reviewbot-skip-stacked-prs
Nov 6, 2026
Merged

josephschorr merged 6 commits into
mainfrom
reviewbot-skip-stacked-prs

Conversation

@josephschorr

@josephschorr josephschorr commented Oct 28, 2026 •

Copy link
Copy Markdown
Member

Motivation

A stack of about ten dependent pull requests was marked ready for review at once. Each one is based on the branch of the PR beneath it, and reviewbot started a review for every one of them in the same second. That burst ran the cluster out of room, so most of the reviews failed. And the ones that did run reviewed each lower PR's changes again inside every PR stacked above it.

Summary of changes

  • A pull request whose base branch is not the repository's default branch is treated as stacked. Opening it, pushing to it, or marking it ready no longer starts a review.
  • When the PR beneath it merges, GitHub retargets the stacked PR onto the default branch, and that retarget now starts its review. The agent is told the PR was retargeted, and the thread's opening line says "retargeted onto main" rather than "edited". The review covers only the PR's own commits, even when the PR beneath it was squash-merged.
  • Renaming the default branch, which moves every open PR, does not start reviews. A retarget only starts a review on a channel that reviews newly opened PRs.
  • A new GitHub channel setting, skipStacked, controls this. It is opt-in, so existing channels keep reviewing PRs into every branch after upgrade. Channels created with oap channel create turn it on. Leave it off for repositories that genuinely review into non-default branches, such as release branches: those PRs look exactly like stacks to this check.
  • If a payload doesn't say what the default branch is, the PR is reviewed as before rather than silently dropped.
  • The reviewbot example's README describes the new behavior.

Alternatives considered

  • Asking GitHub on each delivery whether the base branch belongs to another open PR. This would tell a stack apart from a release branch exactly. Rejected for now because it puts a GitHub API call, and its failure modes, in the webhook path. The webhook payload alone covers the common case, and the setting covers the rest.
  • Leaving a check run on each deferred PR saying it's waiting. Useful, but it also needs a GitHub call from the webhook path, so it's left as a follow-up.

Provenance

  • Author: Joseph Schorr
  • Person who read the diff: Joseph Schorr

Ship gate

  • mage test:unit
  • mage test:integration
  • mage test:e2e

All three pass. The first e2e run had one failure, in an unrelated identity scenario. It passed when re-run on its own and on a full re-run, and the machine was heavily loaded at the time.

Regeneration

  • Added a field to the Channel CRD: regenerated the API, the install manifests and the CRD reference docs.
  • No other config/** changes. n/a
  • CRD schema changed: CRD docs regenerated.
  • Go code is gofmt-clean. mage fmt:check couldn't run locally because of a package-manager signature error in its markdown/TypeScript step.

Coverage

  • Adds user-visible behavior, and a new bronzethread scenario covers it: a signed retarget webhook starts a full review. The deferral half (no review while stacked) is covered by the receiver's tests, because a bundle cannot assert that no session was created.
  • Does not touch authorization, the SpiceDB schema, or the approver model.

Notes for reviewers

Automatic retargeting only happens when the merged PR's branch is deleted, through the repository's "Automatically delete head branches" setting or a stacking tool. Without that, stacked PRs keep pointing at the merged branch and wait until someone retargets them.

…branch

A pull request whose base is another branch (stacked on an earlier,
still-open PR) is no longer reviewed when opened, pushed to, or marked
ready. It is reviewed when it is retargeted onto the default branch,
which GitHub does when the PR beneath it merges. Channels can opt out
with skipStacked: false for repositories that review into non-default
branches.
@vercel

vercel Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated
openagentprimitives Ignored Ignored Preview Nov 6, 2026 8:43pm UTC

Request Review

josephschorr and others added 2 commits October 28, 2026 16:07
GitHub moves a stacked PR onto the default branch only when the PR beneath
it merges and that PR's branch is deleted, so the docs now say so and name
the recovery: enable "Automatically delete head branches" or change the
base by hand.

A payload with no default_branch no longer admits an edited base change as
a retarget; before, any base change passed, including a move onto a stack.
The prompt states only that the base changed, naming the previous base
inside the untrusted block, and a deferred stacked PR now leaves a log
line instead of a silent 204. Fixture branches use demo-contributor names.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@samkim

samkim commented Oct 30, 2026

Copy link
Copy Markdown
Member

Review follow-up in 9842485.

Fixed

  • When GitHub retargets. GitHub moves a stacked PR onto the default branch only when the PR beneath it merges and that PR's branch is deleted. With "Automatically delete head branches" off (the default), the stacked PR was never reviewed, but the docs promised it would be. The field comment, CRD docs and README now say this and give the recovery: turn the setting on, or change the base by hand (also the path when the lower PR is closed without merging).
  • Missing default_branch. retargetedOffStack now requires a known default branch and a PR base equal to it. Before, a payload without default_branch admitted any edited base change as a retarget, including a move onto a stack, and the opening line read "retargeted onto ," with no branch name.
  • Silent deferral. A deferred stacked PR now leaves a log line (channel, repo, PR, action, base, default branch) instead of an untraceable 204.
  • Prompt wording. The prompt states only that the base changed to the default branch, not that a lower PR merged, since a hand-moved release-branch PR arrives the same way. The previous base is named inside the untrusted block, because branch names are submitter-chosen.
  • Fixture names. Test branches now use demo-contributor/stack-layer-{1,2}.

Left open

  • Default-branch rename. If GitHub sends edited deliveries when a master→main rename retargets open PRs, each one is admitted as a retarget. The payload can't tell that apart without an API call; noting it as a known limitation.
  • Retargets bypass spec.github.events. A Channel narrowed to ["ready_for_review"] still reviews on a retarget. This is a product decision, so I left it as is.

Verified: github receiver and webhook route packages pass under -race, the stacked-pr-retargeted-review bundle passes, the manifest drift test and mage fmt:check pass. The full mage test:unit / test:integration / test:e2e gate has not been run.

🤖 Generated with Claude Code

An unset skipStacked now means off, so existing Channels keep reviewing
into every base branch after upgrade; the github wizard writes
skipStacked: true for new Channels.

A base change that keeps the base commit (a renamed default branch) is no
longer a retarget, and a retarget is admitted only on a Channel whose
events list "opened". The repositories allowlist runs before the stacked
check, so the deferral log only names repositories the Channel reviews.

The retarget prompt carries the previous base commit, scoping the review
to the PR's own commits when the lower PR was squash- or rebase-merged,
and tells the agent not to re-review a head commit an earlier review
already covered. Translate decides the retarget once and passes it to
renderPrompt.
A deferred stacked PR could wait forever for a review with nothing on the
PR saying so. Each deferred delivery now leaves a queued check run naming
the base it waits on and how to end the wait; Claim adopts that run when
the review starts. Queued, not neutral, so a lost retarget cannot let an
unreviewed PR satisfy a required check.

Admission no longer strands PRs:
- a retarget is admitted for a Channel listing any review action, not
  only "opened";
- ready_for_review is admitted on skipStacked Channels that skip drafts,
  since a retarget that arrived as a draft was dropped;
- an unchanged base commit no longer marks a rename, which also matched
  a lower PR merged by fast-forward.

The retarget prompt no longer asserts the lower PR merged: it narrows
the review only when the agent confirms the merge, else reviews the
whole diff. oap channel create now points at "Automatically delete head
branches", which GitHub's retarget depends on.

Deferral reporting is a new optional WebhookReceiver capability,
WebhookDeferralReporter, called by webd's webhook route for 204
deliveries; the e2e harness points it at the fake GitHub.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@samkim

samkim commented Nov 6, 2026

Copy link
Copy Markdown
Member

Review: ways a stacked PR could wait forever for a review

A review of this branch looked for ways a deferred stacked PR ends up never reviewed. It found seven, plus one correctness bug in the retarget prompt. 909af5c fixes all of them.

# Problem Fix
1 New Channels default to skipStacked: true, but GitHub only retargets when the lower PR's branch is deleted on merge. "Automatically delete head branches" is off by default, so the upper PR was never reviewed, and nothing on the PR said so. Each deferred delivery leaves a queued check run naming the base it waits on and how to end the wait. oap channel create now points at the auto-delete setting.
2 A retarget was admitted only if Events listed opened, so a Channel reviewing on ready_for_review alone dropped it. A retarget is admitted if Events lists any review action (opened, reopened, ready_for_review, synchronize).
3 A retarget that arrived while the PR was a draft was dropped. If ready_for_review wasn't listed, nothing came next. On skipStacked Channels that skip drafts, ready_for_review is admitted even when it isn't listed.
4 "Base commit unchanged means a rename" also matched a lower PR merged by fast-forward, so the only trigger was dropped. Rule removed. Cost: a default-branch rename triggers one review per open PR, and most stop right away because their head commit already has a finished review.
5 Switching the default to a different branch made every open PR into the old default look stacked, for good. No code fix can tell the two apart. Now documented, and the queued check run makes it visible on each affected PR.
6 A deferred delivery was answered with 204 and nothing recorded. A lost retarget webhook meant no review and no trace. Covered by the queued check run, which stays visible until a review concludes it.
7 If GitHub closes the stacked PR when the lower PR is closed unmerged (unconfirmed), the deferral was permanent and silent. Covered by the queued check run. Its text also says what to do in that case.
– The retarget prompt said the lower PR "has merged" even when the base was moved by hand after it was closed unmerged, so the agent skipped commits nobody had reviewed. The prompt narrows the review only when the agent confirms the merge. Otherwise it reviews the whole diff.

Design notes for review

  • Queued, not neutral. A neutral conclusion would satisfy a required check. If the retarget delivery is lost, a PR that was never reviewed could then merge into the default branch. Queued blocks the required check until a review concludes it. Claim takes over the queued run, marks it in_progress, and the review writes its verdict to that same run.
  • One decision function. Translate and the deferral reporter both call one decide() in receiver.go, so they can't disagree about which deliveries were deferred.
  • New seam: channelkinds.WebhookDeferralReporter. It's an optional capability of a webhook receiver, found the same way as TriggerFactProvider. webd's webhook route calls it only for 204 deliveries. A failure is logged and the delivery is still answered 204: the deferral already happened, and a retry would defer again. Check-run construction is shared with TriggerSurface through newCheckRunSurface.
  • e2e safety. The e2e harness now points the webhook route at its fake GitHub, so a deferral in a scenario can never reach api.github.com.

Not verified

Two findings depend on GitHub behaviour I couldn't confirm from the docs, and I didn't test them live:

The fixes for both hold either way.

Verification

🤖 Generated with Claude Code

Loop.fail emits the RunnerTerminal lifecycle event before calling
WriteFailed. The operator folds that event into phase=Failed and
FinishedAt but writes neither FailureReason nor the Failed condition, and
WriteFailed skipped any session already in a terminal phase. When the
operator's reconcile landed between the two, the session ended Failed
with no reason, silently. This made TestIdentityChoice_NonInteractive
flaky in CI (on main as well), and widening the window with a sleep made
it fail every time.

WriteFailed now fills in only the missing parts of a Failed phase's record,
so a reason another writer already recorded is kept, and a Succeeded
session is left alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@josephschorr
josephschorr merged commit 820bc64 into main Nov 6, 2026
9 checks passed
@josephschorr
josephschorr deleted the reviewbot-skip-stacked-prs branch November 6, 2026 21:10
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.

2 participants