Skip to content

feat: replace gh pr merge subprocess with GraphQL mutation and add auto-merge status indicators#809

Open
robberwick wants to merge 22 commits intodlvhdr:mainfrom
robberwick:merge-queue-status-indicator
Open

feat: replace gh pr merge subprocess with GraphQL mutation and add auto-merge status indicators#809
robberwick wants to merge 22 commits intodlvhdr:mainfrom
robberwick:merge-queue-status-indicator

Conversation

@robberwick
Copy link
Copy Markdown

Summary

Replaces the gh pr merge subprocess with a direct GraphQL mutation and wires up visual indicators so that auto-merge and merge-queue states are reflected immediately in the PR list and branch sections without waiting for a full data refresh.

Core merge logic

MergePullRequest inspects mergeStateStatus to decide between a direct merge and calling enableAutoMerge, and returns a typed PRMergeStatus so the caller knows which path was taken. The MergePR task reads merge-method settings from the PR data, dispatches anUpdatePRMsg carrying IsMerged=true or AutoMergeEnabled=true depending on the outcome, and guards against attempting to merge a draft PR.

Auto-merge status indicators

A new AutoMergeIcon is introduced. Both the PR row and the branch row check AutoMergeRequest != nil (server-fetched) or the local AutoMergeEnabled flag (set optimistically after a successful enableAutoMerge call) and render the icon when either is true.

AutoMergeEnabled flag placement

The flag cannot live on data.PullRequestData because that struct is used directly as a GraphQL deserialization target: theshurcooL-graphql library writes every exported field into the query, so placing the flag there causes GitHub to reject the request. Instead, the flag is carried on the UI-layer wrapper types (prrow.Data and branch.Branch). The branch section uses anautoMergeEnabledPRs map[int]bool on reposection.Model to persist the flag across the scratch-rebuild that updateBranchesWithPrs performs on every update cycle.

Concurrency fix

The GraphQL client lazy-init in FetchPullRequests andFetchPullRequest was unprotected. A mutex is added to guard against concurrent calls.

Nil-safe markdown rendering

This was a drive-by fix for a bug I encountered while testing. markdownStyle is only set when tea.BackgroundColorMsg arrives. If the PR sidebar renders before that message is delivered, GetMarkdownRenderer would dereference a nil pointer and panic. A nil guard was added that falls back to the dark style as a safe default.

How did you test this change?

  • Unit tests for MergePullRequest, mergeMethodForRepo, and the extractPullRequestData helper.
  • Integration tests for the MergePR task using a shared GraphQL mock-client helper package (internal/testhelpers/graphql).
  • Tests for auto-merge icon rendering in both the branch and prrow packages.
  • prssection UpdatePRMsg test refactored to call m.Update() end-to-end with a fully wired context.
  • reposection UpdatePRMsg tests covering the auto-merge flag, a direct merge, and a no-op for an unknown PR number.
  • go test -race ./internal/... passes cleanly.
  • Manually built and ran the app against GHES instance, exercising the new feature.

Add FinishedText field to TaskFinishedMsg to allow tasks to override
their completion message based on the actual outcome. This enables
more accurate feedback when task results vary (e.g., merge vs queue).
…uest type to PullRequestData

- Add AllowMergeCommit, AllowSquashMerge, AllowRebaseMerge to Repository so the
  preferred merge method can be determined without an extra query
- Add ID string to PullRequestData to carry the GraphQL node ID required as
  pullRequestId input for merge mutations
- Add AutoMergeRequest struct and AutoMergeRequest field to PullRequestData
  to represent auto-merge configuration and support the visual indicator
…enabled PRs

- Add AutoMergeIcon constant (timer with checkmark)
- Update PR row to show auto-merge icon when AutoMergeRequest is set or AutoMergeEnabled flag is true
- Update RenderState() to show "Auto-merge" text in PR details
…lper

Replace the two-step approach (subprocess + follow-up query) with a single
GraphQL mutation whose response directly reveals the outcome.

- Add mergeMethodForRepo() to select MERGE > SQUASH > REBASE based on what
  the repository allows
- Add MergePullRequest() which picks the correct mutation based on
  mergeStateStatus:
    CLEAN/UNSTABLE → mergePullRequest (direct merge)
    BLOCKED        → addPullRequestToMergeQueue
    default        → enablePullRequestAutoMerge
  and returns PRMergeStatus directly from the mutation response
…tation

MergePR now calls data.MergePullRequest() directly via a goroutine instead
of spawning a 'gh pr merge' subprocess followed by a follow-up status query.
The mutation response is authoritative — no fallback 'assume merged' path.

- Rewrite MergePR to use a plain func() tea.Msg goroutine
- Add extractPullRequestData() helper and primaryPRDataProvider interface to
  retrieve the underlying *data.PullRequestData from the RowData value
  (handles both prrow.Data from the PR list and *data.PullRequestData from
  the branch section)
- Add GetPrimaryPRData() to prrow.Data to satisfy the interface
- Add IsQueued and AutoMergeEnabled fields to UpdatePRMsg
…nableAutoMerge helper

- Use sync.Mutex (clientMu) for goroutine-safe client initialisation
- Respect FF_MOCK_DATA feature flag to point the GraphQL client at localhost:3000
- Extract enableAutoMerge() helper encapsulating the enablePullRequestAutoMerge
  mutation (reused by BLOCKED, BEHIND, and UNKNOWN/default cases)
- Expand mergeStateStatus switch:
    CLEAN, UNSTABLE, HAS_HOOKS → mergePullRequest (direct merge)
    DIRTY                      → error (merge conflicts)
    BLOCKED, BEHIND            → enableAutoMerge (enable auto-merge)
    UNKNOWN                    → log warning, attempt enableAutoMerge
    default                    → log warning, attempt enableAutoMerge
- Use package-level client (not cachedClient) for all mutations
…ction

- prssection: on UpdatePRMsg with AutoMergeEnabled=true, set a non-nil
  AutoMergeRequest sentinel so the auto-merge icon renders immediately
  without waiting for a full data refresh
- reposection: add tasks.UpdatePRMsg handler to Update() so merge results
  are reflected in the branch section
- reposection/commands.go: update UpdatePRMsg doc comment
- Reject draft PRs before the network call with a clear error message
- Remove IsQueued field from UpdatePRMsg (merge-queue support is not yet
  implemented; reserved for a future commit)
- Add internal/testhelpers/graphql package with LocalRoundTripper and
  NewMockGraphQLClient for use across test packages
- Refactor internal/data/graphql_test_helpers_test.go to delegate to the
  new shared helpers, removing the inline localRoundTripper duplicate
- TestMergeMethodForRepo: priority order (MERGE > SQUASH > REBASE) and
  error for no allowed methods
- TestMergePullRequest_*: direct merge (CLEAN/UNSTABLE/HAS_HOOKS),
  auto-merge (BLOCKED/BEHIND), DIRTY conflict error, and GraphQL error
  propagation
- TestFF_MOCK_DATA_*: FF_MOCK_DATA feature flag routes to localhost:3000
… and AutoMergeEnabled

- TestExtractPullRequestData_*: direct *PullRequestData, prrow.Data wrapper,
  nil primary provider, nil input, and unknown RowData types
- TestMergePR_*: full MergePR task integration for direct merge, auto-merge
  (BLOCKED), auto-merge (BEHIND), draft PR guard, nil PR data error, and
  GraphQL error propagation
- TestUpdatePRMsg_AutoMergeEnabled_SetsFlag: AutoMergeEnabled in UpdatePRMsg
  sets AutoMergeRequest sentinel in prssection so the icon renders immediately
The AutoMergeEnabled flag is a local UI state set when users enable
auto-merge via the UI, not API-fetched data. Move it from PullRequestData
to the prrow Data struct to properly separate concerns.
…ssertions

PRMergeStatus.IsInMergeQueue was always false — the enableAutoMerge mutation
does not return queue membership, and merge-queue mutation support is not yet
implemented.  Keeping the field would mislead callers into thinking the status
is tracked.  Remove it now and re-add alongside the actual merge-queue mutation
when that work lands.

The six assert.False(t, status.IsInMergeQueue) calls in prapi_test.go were
tautological (asserting a zero value that could never be anything else), so
they are removed along with the field.
…ientMu

Both functions used a client == nil check-then-set pattern without holding
clientMu, creating a data race with MergePullRequest (which already used the
mutex) when called concurrently from goroutines.

Wrap the lazy-init block in each function with clientMu.Lock/Unlock, matching
the existing pattern in MergePullRequest.  Add
TestClientInit_ConcurrentCallsAreSafe to verify the fix under -race.
Add AutoMergeEnabled bool to data.PullRequestData as a local UI flag (not
API-fetched) so the branch section can show the auto-merge icon immediately
after the user triggers a merge, without waiting for a full refresh.

Handle the field in reposection.Update's UpdatePRMsg case, mirroring the
existing handler in prssection.  Add reposection_test.go with three tests
covering AutoMergeEnabled, IsMerged, and unknown-PR no-op cases.
…erState

Add auto-merge icon rendering to branch.renderState() and branch.RenderState():
when a PR is open and either AutoMergeRequest is non-nil (set by the API) or
AutoMergeEnabled is true (set locally after the user triggers auto-merge), the
branch cell shows the AutoMergeIcon in warning colour, and RenderState returns
'<icon> Auto-merge'.

Also add a nil guard to RenderState so it returns an empty string when b.PR is
nil, preventing a panic for branches without an associated PR.

Add branch_test.go with 6 tests covering all RenderState paths including both
auto-merge trigger sources.
…ate() end-to-end

Replace the direct field-mutation approach with a call to m.Update() so the
test exercises the full update path including BuildRows().  Introduce
newFullTestModel() which uses the real NewModel() constructor to wire up
Table, SearchBar, and PromptConfirmationBox with a fully-populated
ProgramContext (Config, Theme, Styles), preventing panics during BuildRows().

Fix the type assertion from result.(Model) to result.(*Model): Update() returns
section.Section wrapping a *Model (pointer receiver), so value-type assertion
is a compile error.
Both cases use enableAutoMerge as an optimistic fallback rather than
hard-failing.  Add comments explaining the rationale: UNKNOWN means GitHub
cannot determine merge state yet (checks pending, transient error), so
auto-merge will fire once the condition clears.  The default branch catches
any future MergeStateStatus values added by GitHub.  In both cases the
log warning is intentionally log-only; surfacing it as a user-visible
notification is deferred.
shurcooL-graphql reflects all exported fields of PullRequestData into the
API query.  AutoMergeEnabled is a local UI flag, not a GitHub API field, so
without a tag it generated an 'autoMergeEnabled' selector that GitHub
rejected with 'field doesn't exist on type PullRequest'.

Add `graphql:"-"` to opt the field out of query generation.  Expand the
comment to explain why the field lives on the data-layer struct (branch.Branch
holds *data.PullRequestData directly, with no UI-wrapper to carry UI flags)
and flag it as a candidate for a future refactor toward a dedicated wrapper
type analogous to prrow.Data.
shurcooL-graphql has no skip-field mechanism — the graphql:"-" tag
caused GitHub to receive the literal string "-" as a field selector,
rejecting the query at runtime.

Remove AutoMergeEnabled from data.PullRequestData entirely.  Instead:
- Add AutoMergeEnabled bool to branch.Branch (UI-layer struct, never
  used as a GraphQL deserialization target).
- Add autoMergeEnabledPRs map[int]bool to reposection.Model so the flag
  survives the scratch-rebuild inside updateBranchesWithPrs().
- UpdatePRMsg handler writes to the map; updateBranchesWithPrs re-applies
  it to each Branch after building from Prs.
- Update branch.renderState/RenderState to read b.AutoMergeEnabled.
- Update branch_test.go and reposection_test.go accordingly.
InitializeMarkdownStyle is only called when tea.BackgroundColorMsg
arrives, which can happen after the first View() call that renders
the PR sidebar.  If GetMarkdownRenderer is called before that message
arrives, dereferencing markdownStyle panics.

Add a nil-check that falls back to the dark style, matching the
behaviour InitializeMarkdownStyle would produce.  The style will be
overwritten as normal if BackgroundColorMsg subsequently arrives.
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.

1 participant