Skip to content

feat(ui): merge a GitHub stack up to the cursor PR atomically - #170

Merged
noamsto merged 7 commits into
mainfrom
feat/169-feat-ui-merge-a-github-stack-up-to-the-c
Oct 11, 2026
Merged

noamsto merged 7 commits into
mainfrom
feat/169-feat-ui-merge-a-github-stack-up-to-the-c

Conversation

@noamsto

@noamsto noamsto commented Oct 11, 2026 •

Copy link
Copy Markdown
Owner

Closes #169

Summary

S on a PR in a GitHub stack merges every not-yet-merged layer from the bottom of the stack up to and including that PR, in one atomic, server-side operation, behind a confirm prompt such as Merge stack up to #142 (3 PRs: #140 #141 #142)?. The merge is a squash when it goes directly into the base, or uses the queue's own method when the base merges through a merge queue. On a non-stacked PR, S shows PR #N is not in a stack and merges nothing. There is no fallback that merges layers one at a time.

Flow:

  1. Load the stack and pre-check every layer, bottom first. Each layer must be open, not a draft, not conflicted, and have resolved mergeability. The first offender is named and nothing is sent.
  2. Show the prompt.
  3. Re-check the stack's membership. If it changed since the prompt, refuse with Stack changed since the prompt — press S again.
  4. Submit the merge, then poll (every 2s, up to 90 times) until it is merged, queued or failed.
  5. Report every merged layer through actionDoneMsg.merged, which drives the session-merged marks and branch cleanup, and invalidate the cache for every layer.

Endpoint, taken from github/gh-stack v0.2.1 (29a36d3):

  • Submit: PUT /repos/{o}/{r}/pulls/{n}/merge-async (internal/github/merge_async.go:221-241).
    • Body {"merge_method":"squash","merge_action":"direct_merge"}, or {"merge_action":"merge_queue"} with no method when the base has a merge queue. This is how gh-stack itself routes (cmd/merge.go:148-155, 471-476).
  • Poll: GET /repos/{o}/{r}/pulls/{n}/merge-async/{uuid} (merge_async.go:247-254). Statuses: pending, merged, enqueued, failed (:96-107).
  • Membership: GET /repos/{o}/{r}/stacks?pull_request={n} (internal/github/github.go:515-526).
  • Merge-queue detection: the GraphQL mergeQueue(branch:) field or a MERGE_QUEUE ref rule (merge_async.go:166-205).
  • Auth: prdash's own oauth2 bearer token and Accept: application/vnd.github+json. No preview header; gh-stack uses a stock go-gh REST client (github.go:66-88).

Layers that are not on the loaded board are never assumed fine. Their state and draft flag come from the REST membership, and their mergeability from a batched FetchDetails.

Plan: docs/superpowers/plans/2026-10-11-stack-merge.md; spec: docs/superpowers/specs/2026-10-11-stack-merge-design.md.

Contract: gh.MutationSource gains MergeStack and StackMergeStatus. Both are REST calls and take the PR number, not the node ID. A new gh.StackSource is wired in main.go. GraphSource and both test fakes are updated.

confirmPanel is now capped at the terminal width and wraps its question, so a long stack prompt can't cut off its PR numbers. Single-line prompts render exactly as before.

Testing

  • nix build -L .#checks.x86_64-linux.go-gate (golangci-lint, nilaway, go test, go test -race -short): pass
  • go test -count=1 ./internal/gh ./internal/ui ./internal/action: pass
  • Client tests use httptest and assert method, path, headers and exact body. No real stack merge was run against GitHub.

Review notes

  • Blocking mutations (m/u/cascade) on plan layers while a stack merge runs: refuted. Allowed on purpose (spec S5). A moved head or an already-merged layer makes the atomic merge fail with nothing merged.
  • The diverse-engine review (cursor one-shot) timed out at 540s with no output and was dropped. Same-engine reviewers covered the diff.
  • Stack merged ×1 badge on a single-layer stack: dropped (below bar).

Assumptions

  • After the prompt, a layer added below the target in the instant between the re-check and the PUT is a server-side race the API cannot close: it takes only a target PR number. gh-stack has the same race.
Agent ledger

Acceptance ledger

  • AC1: TestStackRange (bottom/middle/top), TestStackMergeRefusesNonStackedPR, TestCheckStackLayersRefusals (closed/draft/conflicted/pending, names the PR), TestMergeStackDirect/TestMergeStackQueue/TestStackMergeStatus (method/path/body via httptest), TestStackMergeConfirmMergesEveryLayer (merged = [140 141 142]): pass
  • AC2: TestStackMergePlanQuestion, TestStackMergeMiddleLayerPromptsWithOffBoardLayer: pass
  • AC3: CI gate on de4ab47: pass (https://github.com/noamsto/prdash/actions/runs/38139324482)
  • AC4: endpoint and gh-stack citations above

Review: mode full. Round 1: go-reviewer (opus, promoted), charm-tui-reviewer (opus), security-reviewer (opus), test-runner. Round 2: a targeted go and charm-tui re-review of fix commit 4d36185 approved all seven fixes. Roster base 868c08e, harness-only (no repo-local reviewers). The diverse cursor one-shot timed out. recurrence_escalation: unused.

invariant/family finding observed head fix commit proof disposition rounds
prompt == what merges membership drift between prompt and submit add1380 4d36185 TestStackMergeSubmitRefusesChangedMembership + round-2 review fixed 2
never bypass a queue queue-detection error forced direct_merge add1380 4d36185 TestLoadStackPlanMergeQueueDetectionErrorRefuses fixed 2
prompt shows every PR confirm panel overflow cut PR numbers add1380 4d36185 TestConfirmPanelFitsLongStackQuestion (50/80 cols) fixed 2
deliberate confirmation stray y confirmed a late prompt add1380 4d36185 TestStackMergeLoadDroppedAfterLaterKey fixed 2
track the real merge unknown status dropped the uuid add1380 4d36185 TestStackMergeRunUnknownStatusKeepsPolling fixed 2
no interleave with cascade plan landed during a live cascade add1380 4d36185 TestStackMergeLoadDroppedDuringCascade fixed 2
no interleave with cascade block m/u during a stack run add1380 n/a spec S5: the server's atomic merge fails on a moved head refuted 1
honest error class FetchDetails error reported as pending add1380 4d36185 TestLoadStackPlanDetailFetchErrorRefuses fixed 2
cosmetic ×1 badge on a single layer add1380 n/a — dropped (below bar) 1

Consumer map

  • MutationSource: implemented by GraphSource, fakeMutationSource and partialMergeSource. MergeStack and StackMergeStatus are called only from stackmerge.go.
  • StackSource: implemented by GraphSource, wired via main.go SetStackSource, consumed by loadStackPlan and the re-check in runStackMerge.
  • actionDoneMsg.stackMerge: produced only by stackMergeRun; every other producer leaves it false.
  • The confirm path: every m.pending set site clears pendingStackMerge.
  • actionOrder/keyPanes: the new S hint. The panel height it reserves still matches what renders.

@noamsto noamsto self-assigned this Oct 11, 2026
@noamsto
noamsto merged commit 4deb1e3 into main Oct 11, 2026
1 check passed
@noamsto
noamsto deleted the feat/169-feat-ui-merge-a-github-stack-up-to-the-c branch October 11, 2026 13:39
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.

feat(ui): merge a GitHub stack up to the cursor PR atomically

1 participant