Skip to content

feat(stack): opt-in emission of GitHub-native stacks - #1762

Open
jd wants to merge 1 commit into
mainfrom
devs/jd/jd/mrgfy-8452-mergify-stack-opt-in-emission-of-github-native-stacks/opt-emission-github-native-stacks--13d5f27e
Open

feat(stack): opt-in emission of GitHub-native stacks#1762
jd wants to merge 1 commit into
mainfrom
devs/jd/jd/mrgfy-8452-mergify-stack-opt-in-emission-of-github-native-stacks/opt-emission-github-native-stacks--13d5f27e

Conversation

@jd

@jd jd commented Aug 5, 2026

Copy link
Copy Markdown
Member

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

Copilot AI lite review requested due to automatic review settings August 5, 2026 13:43
@mergify
mergify Bot had a problem deploying to Mergify Merge Protections August 5, 2026 13:43 Failure
@jd
jd temporarily deployed to func-tests-live August 5, 2026 13:43 — with GitHub Actions Inactive
@mergify

mergify Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Merge Protections

🔴 2 of 6 protections blocking · waiting on 👀 reviews

Protection Waiting on
🔴 👀 Review Requirements 👀 reviews
🔴 🔎 Reviews 👀 reviews
🟢 🤖 Continuous Integration
🟢 Enforce conventional commit
🟢 📕 PR description
🟢 🚦 Auto-queue

🔴 👀 Review Requirements

Waiting for

  • #approved-reviews-by>=2
This rule is failing.
  • any of:
    • #approved-reviews-by>=2
    • author = dependabot[bot]
    • author = mergify-ci-bot
    • author = renovate[bot]

🔴 🔎 Reviews

Waiting for

  • #review-requested = 0
This rule is failing.
  • #review-requested = 0
  • #changes-requested-reviews-by = 0
  • #review-threads-unresolved = 0

Show 4 satisfied protections

🟢 🤖 Continuous Integration

  • all of:
    • check-success=ci-gate

🟢 Enforce conventional commit

Make sure that we follow https://www.conventionalcommits.org/en/v1.0.0/

  • title ~= ^(fix|feat|internal|docs|style|refactor|perf|test|build|ci|chore|revert|ui)(?:\(.+\))?!?:

🟢 📕 PR description

  • body ~= (?ms:.{48,})

🟢 🚦 Auto-queue

When all merge protections are satisfied, this pull request will be queued automatically.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 (and git config mergify-cli.stack-github-native true) to best-effort register stacks via POST /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 /stacks is shorthand; the full GitHub REST endpoint is POST /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.

Comment thread skills/mergify-stack/SKILL.md
Comment thread crates/mergify-stack/src/native_stack.rs
@mergify
mergify Bot requested a review from a team August 5, 2026 13:50
@jd
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 ba1e271 to cc860b3 Compare August 5, 2026 14:05
@jd

jd commented Aug 5, 2026

Copy link
Copy Markdown
Member Author

Revision history

# Type Changes Reason Date
1 initial ba1e271 2026-08-05 14:05 UTC
2 content ba1e271 → cc860b3 2026-08-05 14:05 UTC
3 content cc860b3 → 7f22fc9 2026-08-05 20:50 UTC

@jd
jd temporarily deployed to func-tests-live August 5, 2026 14:05 — with GitHub Actions Inactive
@mergify
mergify Bot had a problem deploying to Mergify Merge Protections August 5, 2026 14:05 Failure
@jd
jd marked this pull request as ready for review August 5, 2026 20:25
`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
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 cc860b3 to 7f22fc9 Compare August 5, 2026 20:50
@jd
jd deployed to func-tests-live August 5, 2026 20:50 — with GitHub Actions Active
@mergify
mergify Bot had a problem deploying to Mergify Merge Protections August 5, 2026 20:51 Failure
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants