feat(stack): opt-in emission of GitHub-native stacks - #1762
Open
jd wants to merge 1 commit into
Conversation
Contributor
Merge Protections🔴 2 of 6 protections blocking · waiting on 👀 reviews
🔴 👀 Review RequirementsWaiting for
This rule is failing.
🔴 🔎 ReviewsWaiting for
This rule is failing.
Show 4 satisfied protections🟢 🤖 Continuous Integration
🟢 Enforce conventional commitMake sure that we follow https://www.conventionalcommits.org/en/v1.0.0/
🟢 📕 PR description
🟢 🚦 Auto-queueWhen all merge protections are satisfied, this pull request will be queued automatically. |
There was a problem hiding this comment.
Pull request overview
Adds an opt-in path for mergify stack push to register stacks with GitHub’s native Stacks API, while keeping default behavior byte-identical and fencing PR mutations by dissolving/re-forming native stack membership around the mutation phase.
Changes:
- Introduces
--github-native(andgit config mergify-cli.stack-github-native true) to best-effort register stacks viaPOST /repos/{o}/{r}/stacks. - Implements a “native stack fence”: unstack before any PR mutation (fatal on failure), then re-register after all mutations (non-fatal on failure).
- Adds unit + HTTP client tests and end-to-end request-sequencing tests to lock in the contract.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| skills/mergify-stack/SKILL.md | Documents the new opt-in --github-native behavior and its implications. |
| crates/mergify-stack/src/stack_context.rs | Adds git-config default resolver for GitHub-native registration opt-in. |
| crates/mergify-stack/src/native_stack.rs | New module implementing native stack discovery, unstacking, and best-effort registration. |
| crates/mergify-stack/src/lib.rs | Exposes the new native_stack module. |
| crates/mergify-stack/src/commands/push.rs | Wires the unstack/register fence into stack push and reports outcomes in progress output. |
| crates/mergify-stack/Cargo.toml | Adds tracing dependency for debug-level degradation logging. |
| crates/mergify-core/src/http.rs | Adds post_empty_if_exists to support no-body POSTs with 404 treated as success. |
| crates/mergify-cli/tests/stack_push_github_native.rs | New e2e tests asserting “no stacks requests” by default and correct unstack/mutate/register sequencing when enabled. |
| crates/mergify-cli/src/snapshots/mergify__tests__cli_schema_golden.snap | Updates CLI schema golden for the new flag. |
| crates/mergify-cli/src/main.rs | Adds --github-native flag plumbing with git-config fallback at dispatch. |
| Cargo.lock | Records the new tracing dependency for mergify-stack. |
Suppressed comments (1)
crates/mergify-stack/src/native_stack.rs:23
- Similarly,
POST /stacksis shorthand; the full GitHub REST endpoint isPOST /repos/{owner}/{repo}/stacks. Being precise in these docs makes it easier to correlate with wire logs.
//! - `POST /stacks` on PRs that are already in an open stack fails with
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
jd
force-pushed
the
devs/jd/jd/mrgfy-8452-mergify-stack-opt-in-emission-of-github-native-stacks/opt-emission-github-native-stacks--13d5f27e
branch
from
August 5, 2026 14:05
ba1e271 to
cc860b3
Compare
Member
Author
Revision history
|
`mergify stack push --github-native` additionally registers the pushed
stack with GitHub's native Stacks API, so GitHub renders it as a stack.
Off by default (`git config mergify-cli.stack-github-native true` to
opt in per repo); with the flag off, not a single stacks request is
issued and the flow is what it was.
Native membership is additive — Change-Ids, branch layout, stack
comments and revision history are untouched.
## What a live registration actually blocks
Registration is not inert, but it locks exactly one thing. Measured
against the live API on a sandbox repo:
- `PATCH /pulls/{n}` 422s ("Cannot change the base branch because the
pull request is part of a stack") whenever the body carries `base` —
including when it is set to the value the PR already has. That is the
whole lock. The same PATCH *without* the key succeeds while stacked
(new title, new body, even a `head` that does not exist — `head` is
not an updatable field and GitHub ignores it), and the stack survives
the force-push of its members' branches.
- The 422 is **not atomic**: a body carrying `base` *and* `title`
applies the title and rejects the base. Firing one blind and reading
the error as "nothing happened" is not an option.
- Orphan teardown deletes a dropped change's head branch, and GitHub
closes every PR still based on it. Push survives that only because
step 9 retargets the survivors *before* step 11 deletes the branch —
the very PATCH the lock blocks. A PR closed this way cannot be
reopened (base branch gone) nor retargeted (it is closed). The dropped
PR itself also stays in the stack, closed and unmerged.
- `POST /stacks` over PRs already in an open stack 422s, so *re-forming*
a stack does require an unstack first. But `POST /stacks/{n}/add`
appends onto an existing one, keeping its number, its webhooks and its
members' registration. It needs the first appended PR's base ref to be
the current top's head ref; for anything else — insert in the middle,
reorder, remove a single member — there is no endpoint at all.
## So the dance is per-shape, not per-push
| push | stacks requests |
|---|---|
| refresh commits (amend, reword, force-push) | **none** |
| append a change on top | one `POST /stacks/{n}/add` |
| retarget a pull request, or tear down an orphan | `unstack` up front, `POST /stacks` at the end |
`create_or_update_pr` now sends `base` only when the pull request is
really being retargeted — the same technique the function already used
for `title` under `--keep-pull-request-title-and-body`: to leave a field
alone, omit the key. That is what makes the common case free. It also
fixes a smaller thing on its own: we no longer ask GitHub to set a base
to the value it already has on every single update.
This matters beyond API economy. Every dissolve is a window where the
stack is unregistered, and a failed unstack is fatal by design — fewer
windows, fewer ways to leave a user half-registered. A re-registration
also mints a new stack number and re-emits `pull_request.stacked` for
every member, which watchers see.
The fence, where a push still needs one, remains the whole mutation
stage rather than one call site: the retarget can come from
`neutralize_stale_bases` or from the upsert itself, and the orphan
teardown at the end depends on it having landed.
Failure policy is deliberately asymmetric. Registering and appending
never fail a push — an old GHES, a repo without the feature, a chain
with a hole, or a stack below GitHub's 2-PR floor all just leave the PRs
unregistered and report `not registered on GitHub`. A failed *unstack
before the mutations* is fatal, because carrying on is what produces the
unrecoverable state above. A failed *append* is repaired by rebuilding
the stack, which is safe there because the mutations are already done.
## Tested
Unit + wiremock coverage of the module, plus end-to-end tests that
assert on the request *sequence* (`stack_push_github_native.rs`) — that
is where the contract lives: a routine push sends no `/stacks` request
and no `base` key, a push with a new change on top sends exactly one
`add`, an append GitHub refuses is rebuilt, and a push that retargets a
PR unstacks before the first mutation and registers after the last.
Verified the fence test fails when the fence is removed.
Byte-identical default: an end-to-end test asserts a flag-off push
issues zero `/stacks` requests, and the only CLI-schema drift is the one
new flag.
Also exercised live against a real repo with the built binary: create a
2-PR stack (registered as #33); amend and re-push (`GitHub stack #33
unchanged`, zero stacks requests — the case that 422d before `base`
became conditional); add a change on top (`added to GitHub stack #33`,
same stack number and `created_at`, three members); drop the middle
change (fence: dropped PR closed, both survivors open, retargeted and
re-registered); push again with nothing to do (`unchanged`).
MRGFY-8452
Change-Id: I13d5f27ebf27217473c2588aeab90f0c28e1a392
jd
force-pushed
the
devs/jd/jd/mrgfy-8452-mergify-stack-opt-in-emission-of-github-native-stacks/opt-emission-github-native-stacks--13d5f27e
branch
from
August 5, 2026 20:50
cc860b3 to
7f22fc9
Compare
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.
mergify stack push --github-nativeadditionally registers the pushedstack with GitHub's native Stacks API, so GitHub renders it as a stack.
Off by default (
git config mergify-cli.stack-github-native truetoopt in per repo); with the flag off, not a single stacks request is
issued and the flow is what it was.
Native membership is additive — Change-Ids, branch layout, stack
comments and revision history are untouched.
What a live registration actually blocks
Registration is not inert, but it locks exactly one thing. Measured
against the live API on a sandbox repo:
PATCH /pulls/{n}422s ("Cannot change the base branch because thepull request is part of a stack") whenever the body carries
base—including when it is set to the value the PR already has. That is the
whole lock. The same PATCH without the key succeeds while stacked
(new title, new body, even a
headthat does not exist —headisnot an updatable field and GitHub ignores it), and the stack survives
the force-push of its members' branches.
baseandtitleapplies the title and rejects the base. Firing one blind and reading
the error as "nothing happened" is not an option.
closes every PR still based on it. Push survives that only because
step 9 retargets the survivors before step 11 deletes the branch —
the very PATCH the lock blocks. A PR closed this way cannot be
reopened (base branch gone) nor retargeted (it is closed). The dropped
PR itself also stays in the stack, closed and unmerged.
POST /stacksover PRs already in an open stack 422s, so re-forminga stack does require an unstack first. But
POST /stacks/{n}/addappends onto an existing one, keeping its number, its webhooks and its
members' registration. It needs the first appended PR's base ref to be
the current top's head ref; for anything else — insert in the middle,
reorder, remove a single member — there is no endpoint at all.
So the dance is per-shape, not per-push
POST /stacks/{n}/addunstackup front,POST /stacksat the endcreate_or_update_prnow sendsbaseonly when the pull request isreally being retargeted — the same technique the function already used
for
titleunder--keep-pull-request-title-and-body: to leave a fieldalone, omit the key. That is what makes the common case free. It also
fixes a smaller thing on its own: we no longer ask GitHub to set a base
to the value it already has on every single update.
This matters beyond API economy. Every dissolve is a window where the
stack is unregistered, and a failed unstack is fatal by design — fewer
windows, fewer ways to leave a user half-registered. A re-registration
also mints a new stack number and re-emits
pull_request.stackedforevery member, which watchers see.
The fence, where a push still needs one, remains the whole mutation
stage rather than one call site: the retarget can come from
neutralize_stale_basesor from the upsert itself, and the orphanteardown at the end depends on it having landed.
Failure policy is deliberately asymmetric. Registering and appending
never fail a push — an old GHES, a repo without the feature, a chain
with a hole, or a stack below GitHub's 2-PR floor all just leave the PRs
unregistered and report
not registered on GitHub. A failed unstackbefore the mutations is fatal, because carrying on is what produces the
unrecoverable state above. A failed append is repaired by rebuilding
the stack, which is safe there because the mutations are already done.
Tested
Unit + wiremock coverage of the module, plus end-to-end tests that
assert on the request sequence (
stack_push_github_native.rs) — thatis where the contract lives: a routine push sends no
/stacksrequestand no
basekey, a push with a new change on top sends exactly oneadd, an append GitHub refuses is rebuilt, and a push that retargets aPR unstacks before the first mutation and registers after the last.
Verified the fence test fails when the fence is removed.
Byte-identical default: an end-to-end test asserts a flag-off push
issues zero
/stacksrequests, and the only CLI-schema drift is the onenew flag.
Also exercised live against a real repo with the built binary: create a
2-PR stack (registered as #33); amend and re-push (
GitHub stack #33 unchanged, zero stacks requests — the case that 422d beforebasebecame conditional); add a change on top (
added to GitHub stack #33,same stack number and
created_at, three members); drop the middlechange (fence: dropped PR closed, both survivors open, retargeted and
re-registered); push again with nothing to do (
unchanged).MRGFY-8452