Skip to content

feat(merge-gate): gate and merge a native stack with --stack - #20

Merged
lucasheriques merged 3 commits into
mainfrom
claude/merge-gate-native-stack
Oct 28, 2026
Merged

lucasheriques merged 3 commits into
mainfrom
claude/merge-gate-native-stack

Conversation

@lucasheriques

@lucasheriques lucasheriques commented Oct 28, 2026 •

Copy link
Copy Markdown
Owner

Why

merge-gate judges one PR at a time and refuses every PR above position 1 of a GitHub-native stack with base-not-trunk, because its base is the branch of the PR below it. A stack therefore can't go through the gate as a unit. Today it lands one layer at a time: merge the bottom, wait for GitHub to retarget and rebase the next one, wait for CI and review to rerun on it, gate it, and repeat. That costs one full CI cycle per layer (live on nagringa-dev/monorepo stack #2006: #2003 -> #2004 -> #2005 -> #2009).

What changed

  • merge-gate <pr> --stack [--merge] reads the PR's native stack (GraphQL stack.entries, paged) and gates every open PR from the bottom up to <pr> with the same per-PR decision. One merge-async call on the top PR, pinned to its gated head, then lands the range atomically. Every PR in the range is read back after the merge, and each merged head must equal its gated head.
  • The base check takes an ExpectedBase: trunk for the lowest open PR, and the head branch of the open PR directly below for every other one. Any other base still refuses with base-not-trunk. A stack that targets a non-default branch fails at its bottom PR's trunk check. Merged entries below are skipped. A closed entry or a non-open target refuses.
  • The single-PR path is now a one-PR plan through the same code. Plain-mode verdict text is unchanged, and every verdict carries stack (null in plain mode). A stacked base-not-trunk refusal in plain mode adds a hint to rerun with --stack, at no extra API call: the existing PR query reads stack { number }.
  • Per-PR reads, pre-merge head re-checks and post-merge read-backs run in parallel. Reasons stay in plan order and are tagged #N:.
  • New reason codes:
    • merged-ungated-head (final, exit 1): a PR merged with a head other than the one gated. The detail names each PR's gated and merged SHA and tells a human to review what landed. The verdict is never merged: true in that case.
    • stack-changed: the stack is re-read just before the PUT, in parallel with the head re-check. Its number, its ordered open entries (number, state, headRefName) and each PR's base must match what was gated.
    • method-mismatch (final): the PRs in the range call for different merge methods, which one merge-async call cannot apply.
  • A merge-async 409 means a merge request already exists. The gate parses the returned request. If its expected_head_sha differs from the gated top head, the gate does not poll it and reports merge-unverified (or merged-ungated-head if it landed). Otherwise the gate polls that uuid. A 409 never reports merge-failed, in the single-PR fallback too.
  • stack.prs is now [{ number, head }], the gated head per PR.
  • The fake gh used by the tests claims response slots atomically, since parallel calls made it racy.
  • Docs: shipping.md step 4, opening-a-pr.md, README.md and translation.md describe --stack, the new codes, and the residual window.

Scope

  • Native stacks only (stack on the PR). A base-branch chain that is not a native stack still refuses, because merge-async on its top would merge into the parent branch, not trunk.
  • merge_action stays direct_merge, matching the single-PR fallback. A branch that requires a merge queue fails cleanly, and enqueued reports as merge-unverified.

Tradeoffs

  • Residual window: merge-async's sha pins only the top PR's head, and the gh-stack v0.1.0 merge API docs say nothing about lower heads. A push to a lower PR between the pre-merge re-check and the merge, or while the job is pending, still lands. The post-merge head check catches it and reports merged-ungated-head, but cannot prevent it. Shipping step 5 keeps everyone off the stack while it lands for this reason.
  • stack.prs lists only the PRs this run gates. Merged entries below are omitted.

Blast Radius

Every agent merge runs through this. Exit codes are unchanged for existing reasons, and merged-ungated-head exits 1 like merge-unverified. The verdict gains stack. This touches skills/poteto-mode/scripts/merge-gate/**, which the merge policy marks human-only, so it needs the owner's approval.

Verification

  • bun test orch watch-pr merge-gate check-plan.test.ts: 225 pass, 0 fail. bun test merge-gate alone: 159 pass in each of 3 runs. New table rows cover:
    • a whole range passing, with one merge-async call on the top PR and its sha
    • a middle PR with pending checks: refused with #N: and no merge call
    • a non-adjacent base: still base-not-trunk
    • a non-default stack base
    • a merged lower entry: skipped
    • a closed lower entry: refused
    • --stack on an unstacked PR: refused
    • the plain-mode hint
    • a post-merge read-back where one PR didn't merge: merge-unverified
    • a lower PR that merged with a head other than the gated one: merged-ungated-head, merged: false
    • a PR inserted below the target, and a PR retargeted, between planning and merging: stack-changed, no merge call
    • different merge methods across the range: method-mismatch (CLI and decidePlan)
    • a 409 whose request pins the gated head: polled, then merged
    • a 409 whose request pins another head: merge-unverified, not polled
    • a 409 whose request later fails, or with no readable body: merge-unverified, never merge-failed
  • bun run typecheck and bun scripts/check-port.ts: clean.
  • Dry runs against the real nagringa-dev/monorepo stack #2006, with no --merge:
    • 2009 --stack gated #2005 and #2009 and refused only on their real checks-pending / UNSTABLE state, each tagged #N:, with no base-not-trunk.
    • 2003 --stack refused not-open.
    • 2009 in plain mode printed the --stack hint.
    • After the fix round, 2009 --stack (by then #2005 had merged) read the stack with baseRefName, gated #2009 alone, and refused on its failing checks with exit 2.
  • Interrogate (Opus + Sonnet): fixed lower-head pinning (post-merge head check), stack re-read, method mismatch, 409 handling; added per-PR heads to the verdict.
  • Coordinator follow-up: a 409 pinning the gated head with a different merge_method is reported merge-unverified, not polled (new table row).

A PR above position 1 in a native stack was refused base-not-trunk, so a
stack could not go through the gate. `merge-gate <pr> --stack [--merge]`
reads the stack (paginated), plans positions 1..P (merged entries below
skipped, a closed one or a non-open target refused), and gates every open
PR with the same per-PR decide(). decide() now takes an ExpectedBase:
trunk, or the head of the open PR directly below it. A stack that targets
another branch is refused by the bottom PR's trunk check. Reasons are
tagged "#N: ". One merge-async call on the top PR, pinned to its gated
head, lands the range, and every PR is read back (merge-unverified unless
all read MERGED; merge-failed only when the job failed and none merged).

Plain mode runs through the same plan path (a one-PR plan on trunk) with
unchanged verdict text, plus a `stack` field (null) on every verdict and a
--stack hint on base-not-trunk when the PR query reports a stack. facts,
pre-merge head checks, and read-backs now run in parallel, so the fake gh
claims response slots with mkdir instead of a shared counts file.

Tested: bun test orch watch-pr merge-gate check-plan.test.ts (216 pass);
bun run typecheck (clean); bun scripts/check-port.ts (0 findings); dry
runs without --merge on nagringa-dev/monorepo #2009 --stack (gated
#2005 and #2009, refused on their pending checks), #2003 --stack (merged
target refused), and plain #2009 (hint printed).

Reviewer notes: merge_action stays direct_merge, as in the single-PR
fallback, so a merge-queue branch fails instead of enqueueing; an
`enqueued` status reads back as merge-unverified. The merge method is the
top PR's (policy mergeCommitBranches), applied to the whole range.
…tack, treat 409 as pending

merge-async pins only the top PR's head, so a push to a lower PR after the
pre-merge check landed ungated commits and still read back as merged.

- The read-back now includes headRefOid. A merged PR whose head differs
  from its gated head returns the new final code merged-ungated-head (exit 1)
  and names each PR's gated and merged SHA. The window that remains is
  documented in the code, shipping.md, opening-a-pr.md and translation.md.
  The gh-stack v0.1.0 merge API docs say nothing about lower heads.
- Just before the PUT, the gate re-reads the stack, in parallel with the
  head re-check. The stack read now includes baseRefName. A different
  number, a different ordered list of open entries, a different headRefName,
  or a base other than the gated one returns stack-changed.
- Every PR's merge method is collected. If they differ, the gate returns
  method-mismatch instead of using the top PR's method for the whole range.
- A merge-async 409 means a merge request already exists. Its body is parsed.
  A different expected_head_sha is not polled and ends merge-unverified, or
  merged-ungated-head if it landed. Otherwise the gate polls that uuid. A 409
  never reports merge-failed. The single-PR fallback shares this path.
- verdict.stack.prs is now [{ number, head }].

Tested: bun test orch watch-pr merge-gate check-plan.test.ts (225 pass);
merge-gate alone 3 runs (159 pass each); bun run typecheck (clean);
bun scripts/check-port.ts (0 findings); read-only dry run
merge-gate 2009 --stack --repo nagringa-dev/monorepo (stack read with
baseRefName parsed, refused on #2009's failing checks, exit 2).
… polled

A 409 that pins the gated head but a different merge_method would land
the PR with a method the policy did not choose; report merge-unverified
instead of polling it, like a request for another head.

Tested: bun test merge-gate 160 pass; typecheck clean; check-port 0.
@lucasheriques
lucasheriques marked this pull request as ready for review October 28, 2026 18:16
@lucasheriques
lucasheriques merged commit e5bdb2b into main Oct 28, 2026
1 check passed
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