feat: backport a merged PR stack as a single pull request - #433
claude[bot] wants to merge 2 commits into
Conversation
When the top PR of a GitHub stack is merged, trop now opens one backport
PR per `target/*` label of that top PR, containing the commits of every
member of the stack bottom to top, instead of one backport per member.
- Backport targets are read from the top PR only; `target/*` labels on
lower members are ignored and their merge does not start a backport.
- Members are resolved through the stack endpoint
(`GET /repos/{owner}/{repo}/stacks/{stack_number}`), retrying briefly
while GitHub is still flagging lower members as merged.
- backportImpl operates on an ordered list of PRs: the first attempt
applies every member's PR commits, the second every member's squash
commit. The top PR drives the queue ident, check run, title, temp
branch and reviewer; the backport body lists one `Backport of #N` line
per member and combines their release notes; labels are updated on
every member.
- The Valid Backport check judges an original PR by the base of its
stack, the Backport Labels Added check passes for lower members, and
the Backportable? dry run checks the whole stack from the top PR.
- `semver/none` is now a valid (lowest) input of getHighestSemverLabel.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015Eroy2J8ggyZ1vtFm2FcqB
…ee with the top PR A lower member of a stack no longer passes the Backport Labels Added check unconditionally. Its labels are compared with the top PR's: a `target/*` label the top PR does not carry, or `no-backport` against the top PR's targets, fails the check naming the top PR and the mismatched labels; no labels or a subset of the top PR's targets passes. When a `target/*` or `no-backport` label is added to or removed from the top PR, the lower members are re-evaluated so a failure clears once the top PR is labeled to match. If the stack cannot be resolved the check is left queued. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015Eroy2J8ggyZ1vtFm2FcqB
There was a problem hiding this comment.
Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.
Beyond the inline finding, I also checked the needs-manual-bp label handling in backportStackImpl's failure path (src/utils.ts around line 960), which applies the label to every stack member including lower ones that never carried the matching target/ label — this matches the PR's stated design of updating labels on every member rather than just the top PR, so it's not a bug.
Extended reasoning...
One confirmed finding (src/index.ts:271) is already queued to be posted inline: when getStackMemberPRs() throws for a top-of-stack PR, runCheck() returns early and skips the stale-check cleanup loop, unlike the single-PR path. I verified this directly by reading src/index.ts and confirming the early return bypasses the checkRuns cancellation logic below it.
Separately, I examined the candidate issue about backportStackImpl's failure executor applying needs-manual-bp/<branch> unconditionally to every stack member (src/utils.ts near line 960), including lower members that never had the corresponding target/<branch> label. Reading the code and cross-referencing the PR description's explicit "Decisions to confirm" section ("Labels ... are updated on every member, not just the top PR"), this appears to be intentional behavior rather than a bug, so it was correctly ruled out.
Given a confirmed finding will be posted inline and I have one concrete additional item I examined and ruled out, I'm using the narrow defer exception to record that without restating the inline finding itself.
Requested by Samuel Attard · Slack thread
Before: every merged PR of a GitHub stack triggered its own backport. Because each member only applies on top of the one below it, this produced backports that either failed or applied by accident: on electron/electron stack #53879 trop opened two backports that happened to apply and flagged the other four members
needs-manual-bp.After: merging the top PR of a stack opens one backport PR per
target/label on that top PR, containing the commits of every member bottom to top. Lower members are not backported on their own (theirtarget/labels are ignored) and are labeledin-flight/*/merged/*along with the top PR through the sharedBackport of #Nlines in the backport body, whichlabelClosedPRalready handles. The Valid Backport check judges an original PR by the base of its stack (a member's ownbase.refis the sibling branch), the Backport Labels Added check compares a lower member's labels with the top PR's, and the Backportable? dry run on a top PR checks the whole stack./trop run backportand/trop run backport-tofollow the same rule and tell the commenter to use the top PR when run on a lower member.How:
getStackMemberPRs(src/utils/stack-util.ts) resolves the members throughGET /repos/{owner}/{repo}/stacks/{stack_number}(ordered bottom to top), fetches each lower member withpulls.get, appends the top PR payload last and retries up to three times (3s apart) while GitHub is still flagging lower members as merged;getStackTopPRis the lighter variant that only resolves the top.backportImplis now a thin wrapper overbackportStackImpl(robot, context, prs, ...): attempt one gathers every member's non-merge PR commits into one patch list, attempt two every member's squash commit (both bottom to top), andbackportCommitsToBranchapplies the list unchanged. The top PR drives the queue ident, check run, title, temp branch, breadcrumb comment and reviewer;getEffectiveBaseRefis reused for the title/Subject rewrite and the Valid Backport check. Single-PR behaviour, including the backport body, is unchanged.Also fixes
getHighestSemverLabelthrowingInvalid semver labelsonsemver/none: it is now ranked belowsemver/patch, which the cross-member semver precedence needs (stack #53879 mixedsemver/patchandsemver/none) and which also fixes thepull_request.opened500 seen on electron/electron #53914, whereupdateManualBackportcalled it with the original'ssemver/none.Decisions to confirm
target/removed,in-flight/added,needs-manual-bp/on failure) are updated on every member, not just the top PR.*bullet per member's notes under a singleNotes:block, dropping members whose notes arenone; a single set of notes is kept as-is.target/labels are ignored for the backport itself. Their Backport Labels Added check passes when they carry notarget/labels or a subset of the top PR's, and fails with "Conflicting Backport Information" (naming the top PR and the mismatched labels) when they carry atarget/label the top PR does not, orno-backportagainst the top PR's targets. Adding or removing atarget/orno-backportlabel on the top PR re-evaluates the lower members' checks so a failure clears once the top PR is labeled to match; if the stack cannot be resolved the member's check is left queued.target/labels forneeds-manual-bp/.Known follow-ups
getOriginalBackportNumber(src/utils.ts, TODO "support multi-backports") is bypassed when a backport PR body lists more than one original, so/trop run backporton a multi-original backport PR labels the backport PR itself rather than the originals. Out of scope here.🤖 Generated with Claude Code
https://claude.ai/code/session_015Eroy2J8ggyZ1vtFm2FcqB