Skip to content

feat: GitHub stack actions: async merge and rebase (A6) - #672

Merged
Tryanks merged 17 commits into
mainfrom
feat/stack-actions
Oct 9, 2026
Merged

Tryanks merged 17 commits into
mainfrom
feat/stack-actions

Conversation

@Tryanks

@Tryanks Tryanks commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Summary

Merge stack (layer page primary, ⋯ menu, stack map)
  └─ client: StackState read ─▶ confirmation (scope, heads, notes)
       └─ RunPullRequestAction::MergeStack { stack, heads, method }
            host: only from a linked layer; one write per stack
            ├─ GET stacks?pull_request=N → GET stacks/{n}   (number, scope, heads, open, not draft)
            ├─ PUT pulls/N/merge-async {merge_method, merge_action: default, sha}
            │    merged → Applied · enqueued → Queued · failed → Refused
            │    pending/409{uuid} → Pending{id, adopted} + PullRequestStackOperation::Merging
            └─ follow: GET merge-async/{id} at 1,3,7,15,25,…s to 300 s (resumed after a restart)
                 end → toast (RuntimeToast::PullRequestStack) · deadline → MergeUnconfirmed (never resubmitted)
                 MergeUnconfirmed → sync reads target merged/closed, or 24 h → cleared

Rebase stack (⋯ menu, stack map; never primary)
  └─ RebaseStack { stack, heads }
       host: re-read stack · heads · all open · push access per layer · host Git name+email
       └─ scratch clone (token only in GIT_CONFIG extraheader env), 120 s per git command
            per layer: checkout reviewed sha · rebase --onto <new parent> <old parent>
            push --force-with-lease=refs/heads/B:<reviewed sha>; stop at first failure
            progress → PullRequestStackOperation::Rebasing (every device) · end → toast
            end → RebaseEnded {final per-layer steps} kept until the next full sync
                  (the map's operation line reopens the rows and the Recovery block)

Files: core::pull_request (stack map policy, operation record), protocol (StackState read, MergeStack/RebaseStack, results, rejections, toast; notes above PROTOCOL_VERSION), services::github::{stack_actions, stack_rebase}, runtime::app::pull_request_stacks, ui::pull_requests::stack (map, confirmations, progress, chip, toasts) and the A5b slots it fills.

Closes #642

Evidence

  • Before: the layer selector listed layers only; a stack layer's primary was "Merge on GitHub" (A5b placeholder).
    After:
    desktop stack map
    desktop merge stack confirmation
    phone stack map sheet
    (phone stack map retaken on a synthetic profile: sample data keyed to a non-routable GitHub Enterprise address, so the files list behind the sheet is still loading instead of showing the earlier "Couldn't load the files" card)
    phone linked rows with the stack note

Checks run on the final commit:

cargo fmt --all --check                                        ok
cargo clippy --workspace --all-targets --locked -- -D warnings  ok
cargo nextest run --workspace --locked                          1084 passed, 13 skipped
cargo machete                                                   no unused dependencies

New tests (real behaviour, no test-only seams):

  • services/tests/stack_actions.rs: one submission at the target head, merged / enqueued / pending / failed told apart; a 409 naming an operation is followed, one naming none is refused; a moved head, a changed scope, another stack number, a draft, a closed or a merged target send no PUT; real bare-repo rebase: each layer replays only its own commits (committed with the host identity), a second run is all "already up to date"; the lease keeps a branch pushed to since review and the layer below stays pushed; a conflict stops at the failing layer, which is untouched, with the lower layer pushed.
  • runtime pull_requests_tests::a_stack_merge_is_followed_across_a_restart_and_never_submitted_again: Pending → followed → merged toast, one PUT; an unconfirmed record refuses another write; a restarted host past the deadline asks once, records it unconfirmed, never submits again, and the next sync reading the target merged ends it with a "finished late" toast; no GraphQL mutation is ever sent for a stack merge.
  • services/tests/stack_actions.rs::a_layer_the_account_cannot_push_to_refuses_the_rebase_before_anything_runs: push access denied on Bump actions/upload-artifact from 4 to 7 #3 → plan_stack_rebase answers NoPushAccess { [3] }, no plan exists so no Git runs, and GitHub saw only reads.
  • runtime pull_requests_tests::an_ended_rebase_is_kept_for_every_device_until_the_next_full_sync_and_blocks_no_write: a RebaseEnded record survives the reads its end requests, does not refuse the next write (fails with OperationRunning if the busy check counts it), and the next full sweep drops it.
  • core pull_request::tests::a_layer_waits_only_for_the_write_moving_it_now: PullRequestStackOperation::covers, the one owner the page primary, the menu and the linked rows' signal now share.
  • The restart test seeds pull_request_operations in thread meta and calls resume_stack_operations directly; it does not restart a host process.
  • Flaky on macOS CI, a_stack_merge_is_followed_across_a_restart_and_never_submitted_again (matches!(operation(&state), Merging …) at line 1842): settled by CONTRIBUTING "When a test fails", step 1. The fixture answered "merged" before submission, so on a slow runner the real 1 s poll could end the operation before the test read it. GitHub now answers "pending" until the test has read the Merging record, then "merged", and run_until waits on the record clearing. The assertions are unchanged.
  • Changed: a_stack_layer_is_never_merged_alone_and_a_revert_is_linked_as_created now expects Invalid where it expected InStack (the variant is removed with A5's placeholder, design D5); its contract — a stack layer's single merge or branch update reads and sends nothing — is unchanged (CONTRIBUTING "When a test fails", step 3: the contract is real, the wire name changed).

Live, on a disposable private repository with a native stack of #1–#3 (noreply commits; afterwards its pull requests were closed and its branches deleted, the repository stays private (deleting it needs the delete_repo scope, which this token lacks)):

Known costs and accepted deviations

  • The operation record is duplicated per thread. It is written to every unarchived thread that shows the stack, so each device and a restarted host see it. Costs: one meta save per showing thread for each rebase step and state change; stack_operation_record reads the first copy it finds; a thread that starts showing the stack mid-operation only gets the record at the next write.
  • Deviations from the design, accepted: no "May be out of date" freshness mark on the map (§3.2: the confirmations' fresh read decides); no icon-only primary below 360 pt (§4.1: the label stays); after an adopted 409, the end toast names the scope the user confirmed, not what GitHub's adopted operation merged (§6.4).
  • The rebase runs Git with LC_ALL=C / LANGUAGE= so the lease refusal ("stale info") is recognised under any host locale.

Merge Danger

Door: two-way
Blast Radius: stack
Stack writes force-push branches and land several pull requests at once; both go only from a linked layer, re-read GitHub first, and send exactly the confirmed heads. Wire changes are noted above PROTOCOL_VERSION; thread metadata gains pull_request_operations (absent when empty).

Tryanks added 17 commits October 9, 2026 10:31
…rom a host-owned draft, reply, resolve, react, edit, labels, reviewers
…scope, labels, stale head, no retry, the host's review draft
…ments against the diff, and read the account's rights, labels and reviewers
…view draft in Files, review sheet, thread reply and resolve, reactions, edits, labels and reviewers
…rom the host: a fresh action state before each, stack routing, merge-message credit cleaning, a linked revert
…ry action, menu group, merge dialog, confirmations, results, the composer message for conflicts and checks, and the credit-cleaning setting
…base one on the host: the operation is host state every thread showing the stack carries, followed across restarts and never submitted again
…stack as the primary and in the menu, the merge and rebase confirmations, the rebase's progress, the operation chip and the stack's toasts
# Conflicts:
#	crates/core/src/pull_request.rs
#	crates/protocol/src/lib.rs
#	crates/protocol/src/pull_request.rs
#	crates/runtime/src/app/pull_requests.rs
#	crates/runtime/src/app/pull_requests_tests.rs
#	crates/services/src/github/pull_request_actions.rs
#	crates/ui/src/assets.rs
#	crates/ui/src/pull_requests/compose.rs
#	crates/ui/src/pull_requests/detail.rs
#	crates/ui/src/pull_requests/lifecycle.rs
#	crates/ui/src/pull_requests/mod.rs
#	locales/en.yml
#	locales/zh-CN.yml
…en, and is refused before running without push access
…r for the layers an operation covers; a rebase chip without a target opens nothing; queued stacks don't rebase
@Tryanks
Tryanks marked this pull request as ready for review October 9, 2026 08:10
@Tryanks
Tryanks merged commit 3617386 into main Oct 9, 2026
7 checks passed
@Tryanks
Tryanks deleted the feat/stack-actions branch October 9, 2026 08:10
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.

Source control: GitHub stack actions, merge and rebase (A6)

1 participant