Enhance PR auto-merge controls and switch to GraphQL mutation

- Centralize and align auto-merge eligibility logic across web and
  mobile clients.
- Use the `enablePullRequestAutoMerge` GraphQL mutation instead of
  `gh pr merge --auto` to prevent immediate merges on clean branches.
- Fall back to `gh pr merge --auto` when a merge queue is required on
  the base branch.
- Hide the auto-merge control when only optional checks are pending.
This commit is contained in:
Jinjing
2026-06-25 21:45:58 -07:00
parent d993336f90
commit ac9d00e280
9 changed files with 397 additions and 30 deletions
@@ -8,6 +8,7 @@ import type { MobilePrActions } from '../../session/use-mobile-pr-actions'
import { unlinkMobilePr } from '../../source-control/mobile-pr-link'
import { ConfirmModal } from '../ConfirmModal'
import { PRSection } from './PRSection'
import { canShowMobilePRAutoMergeControl } from './pr-auto-merge-availability'
import { resolvePrActionAvailability } from './pr-actions-state'
import { prActionsStyles as styles } from './pr-actions-styles'
@@ -62,6 +63,12 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
const mergeBusy = actions.isBusy({ kind: 'merge' })
const autoMergeBusy = actions.isBusy({ kind: 'autoMerge' })
const stateBusy = actions.isBusy({ kind: 'state' })
const showAutoMerge =
avail.canAutoMerge &&
canShowMobilePRAutoMergeControl({
...pr,
autoMergeEnabled: autoMerge || pr.autoMergeEnabled === true
})
const unlink = useCallback(async (): Promise<void> => {
if (!client || unlinking) {
@@ -166,7 +173,7 @@ export function PRActionsSection({ pr, actions, client, worktreeId, onUnlinked }
) : null}
{/* Auto-merge toggle — optimistic, reverts on transient failure. */}
{avail.canAutoMerge ? (
{showAutoMerge ? (
<View style={styles.toggleRow}>
<Text style={styles.toggleLabel}>Auto-merge when ready</Text>
<Pressable
@@ -0,0 +1,45 @@
import type { PRInfo } from '../../../../src/shared/types'
type MobilePRAutoMergeAvailabilityInput = Pick<
PRInfo,
| 'state'
| 'mergeable'
| 'mergeStateStatus'
| 'reviewDecision'
| 'autoMergeEnabled'
| 'autoMergeAllowed'
| 'mergeQueueRequired'
>
function isConflicting(item: MobilePRAutoMergeAvailabilityInput): boolean {
return item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY'
}
function hasReviewRequirement(item: MobilePRAutoMergeAvailabilityInput): boolean {
return item.reviewDecision === 'REVIEW_REQUIRED' || item.reviewDecision === 'CHANGES_REQUESTED'
}
function canMergeImmediately(item: MobilePRAutoMergeAvailabilityInput): boolean {
if (item.mergeStateStatus === 'BLOCKED' || item.mergeStateStatus === 'BEHIND') {
return false
}
return item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN'
}
function canRequestWhenReady(item: MobilePRAutoMergeAvailabilityInput): boolean {
if (item.state !== 'open' || isConflicting(item)) {
return false
}
if (item.mergeQueueRequired === true) {
return true
}
return (
item.autoMergeAllowed !== false && (hasReviewRequirement(item) || !canMergeImmediately(item))
)
}
export function canShowMobilePRAutoMergeControl(item: MobilePRAutoMergeAvailabilityInput): boolean {
// Why: Metro/Expo cannot reliably transform runtime imports from root
// src/shared, so this mirrors github-pr-auto-merge-availability.ts.
return item.state === 'open' && (item.autoMergeEnabled === true || canRequestWhenReady(item))
}
@@ -135,6 +135,7 @@ describe('GitHub PR local runtime routing', () => {
if (args[0] === 'pr' && args[1] === 'view') {
return {
stdout: JSON.stringify({
id: 'PR_kwDO123',
number: 7,
title: 'PR',
state: 'OPEN',
+85 -3
View File
@@ -2410,7 +2410,11 @@ describe('GitHub GraphQL rate-limit guard', () => {
})
it('sets and disables PR auto-merge with explicit PR repos and SSH context', async () => {
ghExecFileAsyncMock.mockResolvedValue({ stdout: '', stderr: '' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' })
})
.mockResolvedValue({ stdout: '', stderr: '' })
await expect(
setPRAutoMerge('/remote/repo-root', 7, true, 'squash', 'ssh-1', {
@@ -2427,13 +2431,27 @@ describe('GitHub GraphQL rate-limit guard', () => {
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'],
['pr', 'view', '7', '--json', 'id,headRefOid,baseRefName', '--repo', 'stablyai/orca'],
{}
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
expect.arrayContaining([
'api',
'graphql',
'-f',
'pullRequestId=PR_kwDO123',
'-f',
'mergeMethod=SQUASH',
'-f',
'expectedHeadOid=head-oid'
]),
expect.objectContaining({
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })
})
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
3,
['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'],
expect.objectContaining({
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })
@@ -2442,6 +2460,70 @@ describe('GitHub GraphQL rate-limit guard', () => {
expect(ghExecFileAsyncMock.mock.calls[0]?.[1]).not.toHaveProperty('cwd')
})
it('enables auto-merge without invoking the direct merge command', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' })
})
.mockResolvedValue({ stdout: '', stderr: '' })
await expect(
setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, {
owner: 'stablyai',
repo: 'orca'
})
).resolves.toEqual({ ok: true })
expect(
ghExecFileAsyncMock.mock.calls.some((call) =>
(call[0] as string[]).some((arg) => arg.includes('enablePullRequestAutoMerge'))
)
).toBe(true)
expect(
ghExecFileAsyncMock.mock.calls.some(
(call) =>
call[0][0] === 'pr' && call[0][1] === 'merge' && (call[0] as string[]).includes('--auto')
)
).toBe(false)
})
it('uses the queue-aware gh merge path when the base branch has a merge queue', async () => {
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid', baseRefName: 'main' })
})
.mockResolvedValueOnce({
stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } })
})
.mockResolvedValue({ stdout: '', stderr: '' })
await expect(
setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, {
owner: 'stablyai',
repo: 'orca'
})
).resolves.toEqual({ ok: true })
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
expect.arrayContaining(['api', 'graphql', '-f', 'branch=main']),
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'],
expect.objectContaining({
cwd: '/repo-root',
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })
})
)
expect(
ghExecFileAsyncMock.mock.calls.some((call) =>
(call[0] as string[]).some((arg) => arg.includes('enablePullRequestAutoMerge'))
)
).toBe(false)
})
it('blocks direct merge when GitHub reports required approval', async () => {
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify({
+105 -2
View File
@@ -1988,6 +1988,12 @@ const PR_LOOKUP_JSON_FIELDS =
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid'
const PR_BRANCH_LIST_JSON_FIELDS =
'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid'
const PR_AUTO_MERGE_IDENTITY_JSON_FIELDS = 'id,headRefOid,baseRefName'
const GITHUB_AUTO_MERGE_METHODS: Record<GitHubPRMergeMethod, 'MERGE' | 'SQUASH' | 'REBASE'> = {
merge: 'MERGE',
squash: 'SQUASH',
rebase: 'REBASE'
}
export type GitHubPRBranchLookupOptions = HostedReviewExecutionOptions & {
acceptMergedFallbackPR?: boolean
@@ -3751,10 +3757,10 @@ export async function setPRAutoMerge(
const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId, localGitOptions))
await acquire()
try {
const args = ['pr', 'merge', String(prNumber), enabled ? '--auto' : '--disable-auto']
if (enabled) {
args.push(`--${method}`)
return await enablePRAutoMerge(prNumber, method, ownerRepo, ghOptions)
}
const args = ['pr', 'merge', String(prNumber), '--disable-auto']
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
@@ -3772,6 +3778,103 @@ export async function setPRAutoMerge(
}
}
type PRAutoMergeIdentity = {
id?: string
headRefOid?: string
baseRefName?: string
}
async function getPRAutoMergeIdentity(
prNumber: number,
ownerRepo: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<PRAutoMergeIdentity | null> {
const args = ['pr', 'view', String(prNumber), '--json', PR_AUTO_MERGE_IDENTITY_JSON_FIELDS]
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
const { stdout } = await ghExecFileAsync(args, ghOptions)
const data = JSON.parse(stdout) as PRAutoMergeIdentity
return {
id: typeof data.id === 'string' ? data.id : undefined,
headRefOid: typeof data.headRefOid === 'string' ? data.headRefOid : undefined,
baseRefName: typeof data.baseRefName === 'string' ? data.baseRefName : undefined
}
}
async function runPRAutoMergeCommand(
prNumber: number,
method: GitHubPRMergeMethod,
ownerRepo: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<void> {
const args = ['pr', 'merge', String(prNumber), '--auto', `--${method}`]
if (ownerRepo) {
args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
}
await ghExecFileAsync(args, {
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
}
async function shouldUseMergeQueueAutoMerge(
pr: PRAutoMergeIdentity,
ownerRepo: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<boolean> {
if (!ownerRepo || !pr.baseRefName) {
return false
}
const mergeMetadata = await detectRepositoryMergeMetadata(ownerRepo, pr.baseRefName, ghOptions)
return mergeMetadata.mergeQueueRequired === true
}
async function enablePRAutoMerge(
prNumber: number,
method: GitHubPRMergeMethod,
ownerRepo: OwnerRepo | null,
ghOptions: GhExecOptions
): Promise<{ ok: true } | { ok: false; error: string }> {
const pr = await getPRAutoMergeIdentity(prNumber, ownerRepo, ghOptions)
if (!pr?.id) {
return { ok: false, error: 'Could not resolve GitHub pull request ID' }
}
if (await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions)) {
await runPRAutoMergeCommand(prNumber, method, ownerRepo, ghOptions)
return { ok: true }
}
const query = `mutation($pullRequestId: ID!, $mergeMethod: PullRequestMergeMethod!, $expectedHeadOid: GitObjectID) {
enablePullRequestAutoMerge(input: {
pullRequestId: $pullRequestId,
mergeMethod: $mergeMethod,
expectedHeadOid: $expectedHeadOid
}) {
pullRequest { id }
}
}`
const args = [
'api',
'graphql',
'-f',
`query=${query}`,
'-f',
`pullRequestId=${pr.id}`,
'-f',
`mergeMethod=${GITHUB_AUTO_MERGE_METHODS[method]}`
]
if (pr.headRefOid) {
args.push('-f', `expectedHeadOid=${pr.headRefOid}`)
}
// Why: `gh pr merge --auto` can perform an immediate merge; this mutation
// only creates GitHub's auto-merge request and lets branch requirements gate it.
await ghExecFileAsync(args, {
...ghOptions,
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
})
return { ok: true }
}
async function getPRMergeBlocker(
repoPath: string,
prNumber: number,
@@ -33,24 +33,35 @@ describe('presentGitHubPRMergeState', () => {
})
it('offers enable auto-merge for open PRs that are waiting on requirements', () => {
expect(presentGitHubPRMergeState(pr()).autoMergeAction).toMatchObject({
kind: 'enable',
label: 'Enable auto-merge'
})
expect(presentGitHubPRMergeState(pr({ mergeQueueRequired: null })).autoMergeAction).toBeNull()
// Approval-required and required-check-blocked PRs are exactly when auto-merge helps.
expect(
presentGitHubPRMergeState(pr({ mergeQueueRequired: null })).autoMergeAction
).toMatchObject({ kind: 'enable', label: 'Enable auto-merge' })
// Approval-required and checks-pending PRs are exactly when auto-merge helps.
expect(
presentGitHubPRMergeState(pr({ reviewDecision: 'REVIEW_REQUIRED' })).autoMergeAction
presentGitHubPRMergeState(
pr({ reviewDecision: 'REVIEW_REQUIRED', mergeStateStatus: 'BLOCKED' })
).autoMergeAction
).toMatchObject({ kind: 'enable', label: 'Enable auto-merge' })
expect(
presentGitHubPRMergeState(
pr({ checksSummary: { state: 'pending', total: 1, passed: 0, failed: 0, pending: 1 } })
pr({
mergeStateStatus: 'BLOCKED',
checksSummary: { state: 'pending', total: 1, passed: 0, failed: 0, pending: 1 }
})
).autoMergeAction
).toMatchObject({ kind: 'enable', label: 'Enable auto-merge' })
})
it('does not offer enable auto-merge when only optional checks are pending', () => {
expect(
presentGitHubPRMergeState(
pr({ checksSummary: { state: 'pending', total: 1, passed: 0, failed: 0, pending: 1 } })
)
).toMatchObject({
label: 'Checks pending',
directMergeAvailable: true,
autoMergeAction: null
})
})
it('does not offer enable auto-merge on conflicting PRs (GitHub would reject it)', () => {
expect(
presentGitHubPRMergeState(pr({ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' }))
@@ -63,9 +74,15 @@ describe('presentGitHubPRMergeState', () => {
})
it('does not offer enable auto-merge when GitHub reports the repository disallows it', () => {
expect(presentGitHubPRMergeState(pr({ autoMergeAllowed: false })).autoMergeAction).toBeNull()
expect(
presentGitHubPRMergeState(pr({ autoMergeAllowed: undefined })).autoMergeAction
presentGitHubPRMergeState(
pr({ autoMergeAllowed: false, mergeable: 'UNKNOWN', mergeStateStatus: 'BLOCKED' })
).autoMergeAction
).toBeNull()
expect(
presentGitHubPRMergeState(
pr({ autoMergeAllowed: undefined, mergeable: 'UNKNOWN', mergeStateStatus: 'BLOCKED' })
).autoMergeAction
).toMatchObject({ kind: 'enable', label: 'Enable auto-merge' })
})
@@ -5,6 +5,7 @@ import type {
PRReviewDecision,
PRState
} from '../../../shared/types'
import { canEnableGitHubPRAutoMerge } from '../../../shared/github-pr-auto-merge-availability'
import { translate } from '@/i18n/i18n'
export type GitHubPRMergeStateInput = {
@@ -54,21 +55,11 @@ function hasFullMergeMetadata(item: GitHubPRMergeStateInput): boolean {
return item.mergeable !== undefined || item.mergeStateStatus !== undefined
}
function isConflicting(item: GitHubPRMergeStateInput): boolean {
return item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY'
}
// Why: GitHub rejects enabling auto-merge on a conflicting PR, so offering it
// there only yields an error toast. Repos can also disable auto-merge entirely,
// so suppress the action when GitHub explicitly reports that setting is off.
function canEnableAutoMerge(item: GitHubPRMergeStateInput): boolean {
return (
item.state === 'open' &&
item.autoMergeEnabled !== true &&
item.autoMergeAllowed !== false &&
item.mergeQueueRequired !== true &&
!isConflicting(item)
)
return canEnableGitHubPRAutoMerge(item)
}
function passedChecksMergePresentation(
@@ -219,7 +210,7 @@ export function presentGitHubPRMergeState(
autoMergeAction
}
}
if (isConflicting(item)) {
if (item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY') {
return {
label: translate('auto.components.github.pr.merge.state.7e8bbe3cd7', 'Conflicts'),
tone: DANGER_TONE,
@@ -0,0 +1,67 @@
import { describe, expect, it } from 'vitest'
import {
canEnableGitHubPRAutoMerge,
canShowGitHubPRAutoMergeControl,
type GitHubPRAutoMergeAvailabilityInput
} from './github-pr-auto-merge-availability'
function pr(
overrides: Partial<GitHubPRAutoMergeAvailabilityInput> = {}
): GitHubPRAutoMergeAvailabilityInput {
return {
state: 'open',
mergeable: 'MERGEABLE',
mergeStateStatus: 'CLEAN',
autoMergeAllowed: true,
...overrides
}
}
describe('github PR auto-merge availability', () => {
it('does not offer auto-merge for directly mergeable PRs with only optional checks pending', () => {
expect(canEnableGitHubPRAutoMerge(pr())).toBe(false)
expect(canShowGitHubPRAutoMergeControl(pr())).toBe(false)
})
it('offers auto-merge for requirement-blocked PRs', () => {
expect(
canEnableGitHubPRAutoMerge(
pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'BLOCKED', reviewDecision: 'REVIEW_REQUIRED' })
)
).toBe(true)
expect(canEnableGitHubPRAutoMerge(pr({ mergeStateStatus: 'BLOCKED' }))).toBe(true)
expect(
canShowGitHubPRAutoMergeControl(pr({ mergeable: 'UNKNOWN', mergeStateStatus: 'BLOCKED' }))
).toBe(true)
})
it('keeps merge-queue branches available for merge-when-ready', () => {
expect(canEnableGitHubPRAutoMerge(pr({ mergeQueueRequired: true }))).toBe(false)
expect(canShowGitHubPRAutoMergeControl(pr({ mergeQueueRequired: true }))).toBe(true)
expect(
canShowGitHubPRAutoMergeControl(pr({ autoMergeAllowed: false, mergeQueueRequired: true }))
).toBe(true)
})
it('keeps enabled auto-merge visible so users can disable it', () => {
expect(canEnableGitHubPRAutoMerge(pr({ autoMergeEnabled: true }))).toBe(false)
expect(canShowGitHubPRAutoMergeControl(pr({ autoMergeEnabled: true }))).toBe(true)
})
it('suppresses closed, draft, disallowed, and conflicting PRs', () => {
expect(
canShowGitHubPRAutoMergeControl(pr({ state: 'draft', mergeStateStatus: 'BLOCKED' }))
).toBe(false)
expect(canShowGitHubPRAutoMergeControl(pr({ state: 'closed', autoMergeEnabled: true }))).toBe(
false
)
expect(
canShowGitHubPRAutoMergeControl(
pr({ autoMergeAllowed: false, mergeable: 'UNKNOWN', mergeStateStatus: 'BLOCKED' })
)
).toBe(false)
expect(
canShowGitHubPRAutoMergeControl(pr({ mergeable: 'CONFLICTING', mergeStateStatus: 'DIRTY' }))
).toBe(false)
})
})
@@ -0,0 +1,54 @@
import type { PRMergeableState, PRReviewDecision, PRState } from './types'
export type GitHubPRAutoMergeAvailabilityInput = {
state: PRState | 'open' | 'closed' | 'merged' | 'draft'
mergeable?: PRMergeableState
mergeStateStatus?: string | null
reviewDecision?: PRReviewDecision | null
autoMergeEnabled?: boolean
autoMergeAllowed?: boolean | null
mergeQueueRequired?: boolean | null
}
function isOpenPR(item: GitHubPRAutoMergeAvailabilityInput): boolean {
return item.state === 'open'
}
function isConflicting(item: GitHubPRAutoMergeAvailabilityInput): boolean {
return item.mergeable === 'CONFLICTING' || item.mergeStateStatus === 'DIRTY'
}
function hasReviewRequirement(item: GitHubPRAutoMergeAvailabilityInput): boolean {
return item.reviewDecision === 'REVIEW_REQUIRED' || item.reviewDecision === 'CHANGES_REQUESTED'
}
function canMergeImmediately(item: GitHubPRAutoMergeAvailabilityInput): boolean {
if (item.mergeStateStatus === 'BLOCKED' || item.mergeStateStatus === 'BEHIND') {
return false
}
return item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN'
}
function canRequestWhenReady(item: GitHubPRAutoMergeAvailabilityInput): boolean {
if (!isOpenPR(item) || isConflicting(item)) {
return false
}
if (item.mergeQueueRequired === true) {
return true
}
return (
item.autoMergeAllowed !== false && (hasReviewRequirement(item) || !canMergeImmediately(item))
)
}
export function canEnableGitHubPRAutoMerge(item: GitHubPRAutoMergeAvailabilityInput): boolean {
return (
item.autoMergeEnabled !== true && item.mergeQueueRequired !== true && canRequestWhenReady(item)
)
}
export function canShowGitHubPRAutoMergeControl(item: GitHubPRAutoMergeAvailabilityInput): boolean {
// Why: GitHub auto-merge waits for branch requirements, not arbitrary optional CI.
// Keep already-enabled PRs visible so users can disable the setting.
return isOpenPR(item) && (item.autoMergeEnabled === true || canRequestWhenReady(item))
}