Repository navigation
feat(merge-gate): gate and merge a native stack with --stack - #20
Merged
Merged
Conversation
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
marked this pull request as ready for review
October 28, 2026 18:16
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
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 (GraphQLstack.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.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 withbase-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.stack(nullin plain mode). A stackedbase-not-trunkrefusal in plain mode adds a hint to rerun with--stack, at no extra API call: the existing PR query readsstack { number }.#N:.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 nevermerged: truein 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.expected_head_shadiffers from the gated top head, the gate does not poll it and reportsmerge-unverified(ormerged-ungated-headif it landed). Otherwise the gate polls that uuid. A 409 never reportsmerge-failed, in the single-PR fallback too.stack.prsis now[{ number, head }], the gated head per PR.shipping.mdstep 4,opening-a-pr.md,README.mdandtranslation.mddescribe--stack, the new codes, and the residual window.Scope
stackon 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_actionstaysdirect_merge, matching the single-PR fallback. A branch that requires a merge queue fails cleanly, andenqueuedreports asmerge-unverified.Tradeoffs
shapins 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 reportsmerged-ungated-head, but cannot prevent it. Shipping step 5 keeps everyone off the stack while it lands for this reason.stack.prslists 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-headexits 1 likemerge-unverified. The verdict gainsstack. This touchesskills/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-gatealone: 159 pass in each of 3 runs. New table rows cover:#N:and no merge callbase-not-trunk--stackon an unstacked PR: refusedmerge-unverifiedmerged-ungated-head,merged: falsestack-changed, no merge callmethod-mismatch(CLI anddecidePlan)merge-unverified, not polledmerge-unverified, nevermerge-failedbun run typecheckandbun scripts/check-port.ts: clean.--merge:2009 --stackgated #2005 and #2009 and refused only on their realchecks-pending/ UNSTABLE state, each tagged#N:, with nobase-not-trunk.2003 --stackrefusednot-open.2009in plain mode printed the--stackhint.2009 --stack(by then #2005 had merged) read the stack withbaseRefName, gated #2009 alone, and refused on its failing checks with exit 2.merge_methodis reportedmerge-unverified, not polled (new table row).