Skip to content

feat: merge and lifecycle actions on a linked pull request (A5b) - #667

Merged
Tryanks merged 12 commits into
mainfrom
feat/pr-actions-merge
Oct 9, 2026
Merged

Tryanks merged 12 commits into
mainfrom
feat/pr-actions-merge

Conversation

@Tryanks

@Tryanks Tryanks commented Oct 9, 2026 •

Copy link
Copy Markdown
Owner

Summary

A5b of #641: merge and the lifecycle actions on a linked pull request. The host reads a fresh PullRequestActionState before a merge or a branch update (the other lifecycle writes name the pull request by its cached node id), routes native-stack layers away from a single-PR merge, sends each mutation once, and answers in domain terms; the UI ranks one primary action from that state and adds the lifecycle group to the shared row menu.

client                                   host (runtime)                      services (GitHub)
RunLifecycle ─▶ Target::run              RunPullRequestAction
  Ready/Draft/Reopen/DisableAutoMerge ─▶   stack_route(links,key)             Merge/UpdateBranch: PullRequestActionState (fresh)
                                                                              others: cached node id, no read
  Close/Revert/UpdateRebase: confirm       ├ Layer{i,n} → Rejected InStack ─┐   ├ head ≠ expected → Rejected StaleHead
  Merge/EnableAutoMerge: merge dialog      ├ Unknown    → Rejected StackUnknown  ├ UpdateBranch: behind 0 → UpToDate
     (reads ActionState itself)            └ Single ─────────────────────────▶   ├ Merge: [PullRequestMergeMessage →
  AskConflicts/AskChecks: composer text    (nothing read or sent for a stack)   │    remove_agent_credits, body only if changed]
                                                                                 │  mergePullRequest | enablePullRequestAutoMerge
                                         Opened{n,url} → link as `created`      │    {expectedHeadOid}; GitHub's answer decides
                                         non-rejected → sync read requested     │    Applied | Queued{pos} | AutoMergeEnabled{m}
                                                                                 └ no answer / 5xx → Uncertain, never resent
  • protocol: PullRequestRead::ActionState → PullRequestActionState (head, merge state, behind count, merge queue, enabled methods, auto-merge allowed/armed, queue entry, failing check names, pending count, can_update / can_update_branch / can_merge); actions ReadyForReview, ConvertToDraft, Close, Reopen, Revert, UpdateBranch{head,rebase}, Merge{head,method,auto,remove_credits}, DisableAutoMerge; results UpToDate, Queued, AutoMergeEnabled, Opened; rejections InStack, StackUnknown; wire note above PROTOCOL_VERSION.
  • core: PullRequestMergeMethod; stack_route(links, key) → (route, layer), the one fold the host checks before a write and the UI ranks with (the layer gives the UI a stack layer's state and URL); settings project_merge_methods (set only from the merge dialog's checkbox) and remove_agent_credits_on_merge (Settings → Source Control), patches ProjectMergeMethod, RemoveAgentCreditsOnMerge.
  • services: the action-state read (cached for display for the reads' READ_TTL, 60 s; a merge and a branch update read it uncached; ready/draft/close/reopen/disable auto-merge/revert send by the cached node id), the lifecycle mutations, merge_message::remove_agent_credits (port of upstream mergeMessage.ts). A lifecycle write drops all of the pull request's reads, on top of main's drop_written.
  • ui (pull_requests/lifecycle.rs): rank conflicts → ready → failing checks → auto-merge-on / queued chips → Merge on GitHub (stack) → update branch (when BEHIND) → enable auto-merge (checks running) → merge; SubBar button on desktop, full-width row on phone; the ⋯ group added once to row_menu (list rows build it from the snapshot; the host reads the rest before writing); the group's writes are disabled while one is in flight or after an Uncertain answer until GitHub is read again, on list rows and the detail alike; Update branch / Update with rebase offered by their own rule (behind, may update the branch, not conflicting, single route), drafts included; the fresh action state speaks over the snapshot for conflicting / failing / pending; "Merge now…" hidden when conflicting and disabled with the blocked reason when GitHub says Blocked; confirmations for close, revert, rebase; the merge dialog with the facts, method track, project-default and credit checkboxes and notes; one toast slot per pull request, shared with A5a's conversation writes (PullRequestWrite); "Ask the agent in this thread" appends an editable message to the thread's composer and sends nothing, and is disabled for a settled or archived thread as well as a read-only one (conversation_read_only covers only native subagent mirrors, so the UI checks the thread's settled/archived state itself); behind-base chip with the update choices.

Closes #641

Evidence

  • Before: desktop, light, the PR page on main (A4): the SubBar has Back · refresh · Open on GitHub · ⋯ and no lifecycle action:
    desktop light PR page before
    After: desktop, light, the same synthetic profile (acme-sample/payments-api#413, a seeded action state; conversation reads fail because the repository does not exist): primary "Create a merge commit" and the merge confirmation (Squash chosen, credit cleaning on):
    desktop light merge dialog
    phone, light, PR page with the primary action:
    phone light PR page

Tests through the real client against the in-process HTTP fixture (crates/services/tests/pull_request_reads.rs), new:

  • a_merge_reads_the_head_fresh_and_tells_merged_queued_and_armed_apart: exact mergePullRequest / enablePullRequestAutoMerge inputs with expectedHeadOid; merged vs auto-merge armed vs merge queue reported distinctly from GitHub's answer; a method the repository does not enable → Invalid, nothing sent; a head moved after the display read rejected StaleHead before any mutation (which is what proves the merge's read is fresh).
  • credits_leave_the_merge_message_only_when_it_has_some: commitBody only when cleaning changed GitHub's message; people stay credited; no message read for rebase.
  • a_branch_update_carries_the_head_and_is_nothing_when_not_behind: MERGE by default, REBASE on request, stale head rejected, UpToDate with no mutation.
  • lifecycle_writes_name_the_pull_request_and_a_revert_opens_one: ready/draft/close/reopen/disable/revert payloads name the pull request's node id; revert answers Opened.
  • A5a's a_write_left_unanswered_is_uncertain_and_never_sent_again, extended: a merge whose connection drops and a revert answer that names no pull request are Uncertain, each sent once.
  • crates/runtime/src/app/pull_requests_tests.rs::a_stack_layer_is_never_merged_alone_and_a_revert_is_linked_as_created: unknown stack and a native layer reject merge and update branch with no request at all; a revert's PR is linked with source created; the sync reads after the writes.
  • merge_message::tests::credits_quoted_in_code_and_people_on_githubs_noreply_address_stay: fences, indented quotes and a person on GitHub's noreply address survive.

Tests deleted or rewritten (CONTRIBUTING, When a test fails, or is met on the way, step 2):

  • a_merge_left_unanswered_is_uncertain_and_never_sent_again deleted: its lost-answer case is folded into A5a's a_write_left_unanswered_is_uncertain_and_never_sent_again; its 502 case duplicated that test's 502 contract (labels) through the same answered path.
  • The reads_of("PullRequestActionState") == 6 assertions in the merge and lifecycle tests removed: they counted reads, an implementation detail; the payload assertions and the stale-head case carry the contracts. The lifecycle test was renamed (it no longer reads the action state).
  • pull_requests_tests.rs: the A5b test's fixture now answers the REST pull request read (the node id comes from it); the two copies of the ack-wait loop became one acked helper.

Deferred: the merge dialog's ChangedNotice (head, checks or mergeability changing while the dialog is open) is not built; the guard is the host's fresh read and stale-head rejection at confirm, which rejects a moved head with nothing sent ("Not merged: #n changed").

Checks on the final commit:

cargo fmt --all --check                                       → clean
cargo clippy --workspace --all-targets --locked -- -D warnings → clean
cargo nextest run --workspace --locked                         → 1074 tests run: 1074 passed, 13 skipped
cargo machete                                                  → didn't find any unused dependencies

Live, through the host's PullRequestReads against a disposable public repository in this account (Tryanks/tcode-a5b-live-…, two rounds):

#4 ReadyForReview -> Applied
#4 ConvertToDraft -> Applied
#4 ReadyForReview -> Applied
#4 Close -> Applied
#4 Reopen -> Applied
#one before update: head e469b5e behind Some(1) Behind
#4 UpdateBranch { head: e469b5e…, rebase: false } -> Applied
#one after update: head 7129ee1 behind Some(0) Blocked
#4 UpdateBranch { head: 7129ee1…, rebase: false } -> UpToDate
#4 Merge { head: e469b5e…, method: Squash, remove_credits: true } -> Rejected(StaleHead { head: 7129ee1… })
#4 Merge { head: 7129ee1…, method: Squash, remove_credits: true } -> Applied
#5 Merge { head: a979f38…, method: Squash, auto: true } -> AutoMergeEnabled { method: Squash }
#5 DisableAutoMerge -> Applied

GitHub's squash body for the PR was Co-authored-by: Tryanks … + Co-authored-by: Claude <noreply@anthropic.com>; the merged commit kept only the first. Round 1 (#1–#3) did the same sequence and also Revert → Opened #3. From the app on a synthetic profile: Update branch on #5 → toast "Updated feature-4 with main"; the primary then became "Create a merge commit" once ci was green.
The live runs above predate the review corrections; the simple writes now send by the cached node id and were checked only through the fixture after the change.
Platform gaps: the repository's required check was satisfied by admin bypass (enforce_admins: false) in round 2, so a blocked merge was not exercised live; blocked and queued merges and native stacks are fixture-only (no organization merge queue or native stack is available to this account).

Merge Danger

Door: two-way
Blast Radius: pull-requests
Every lifecycle write goes to GitHub as the user's account: merge, close and revert are real. Each is gated by a fresh head check, a single attempt and, for merge, close, revert and rebase, a confirmation. The wire changes under the current PROTOCOL_VERSION with a note.

Tryanks added 12 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
…de id, keep the action state for the read TTL, and fold the lost merge answer into the unanswered-write test
…d, offer branch updates by their own rule, prefer the fresh action state, and resolve the project and stack layer once
@Tryanks
Tryanks marked this pull request as ready for review October 9, 2026 06:38
@Tryanks
Tryanks merged commit b0f6333 into main Oct 9, 2026
7 checks passed
@Tryanks
Tryanks deleted the feat/pr-actions-merge branch October 9, 2026 06:38
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: pull request actions (A5)

1 participant