From ac9d00e2807d782b30acffc3c78f8031ccfccc2d Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Thu, 25 Jun 2026 21:45:58 -0700 Subject: [PATCH] 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. --- .../pr-sidebar/PRActionsSection.tsx | 9 +- .../pr-sidebar/pr-auto-merge-availability.ts | 45 ++++++++ .../github/client-pr-local-runtime.test.ts | 1 + src/main/github/client.test.ts | 88 +++++++++++++- src/main/github/client.ts | 107 +++++++++++++++++- .../components/github-pr-merge-state.test.ts | 41 +++++-- .../src/components/github-pr-merge-state.ts | 15 +-- .../github-pr-auto-merge-availability.test.ts | 67 +++++++++++ .../github-pr-auto-merge-availability.ts | 54 +++++++++ 9 files changed, 397 insertions(+), 30 deletions(-) create mode 100644 mobile/src/components/pr-sidebar/pr-auto-merge-availability.ts create mode 100644 src/shared/github-pr-auto-merge-availability.test.ts create mode 100644 src/shared/github-pr-auto-merge-availability.ts diff --git a/mobile/src/components/pr-sidebar/PRActionsSection.tsx b/mobile/src/components/pr-sidebar/PRActionsSection.tsx index f9dc072af56..e924ca254a2 100644 --- a/mobile/src/components/pr-sidebar/PRActionsSection.tsx +++ b/mobile/src/components/pr-sidebar/PRActionsSection.tsx @@ -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 => { 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 ? ( Auto-merge when ready + +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)) +} diff --git a/src/main/github/client-pr-local-runtime.test.ts b/src/main/github/client-pr-local-runtime.test.ts index be6e90ba880..9da948f43c4 100644 --- a/src/main/github/client-pr-local-runtime.test.ts +++ b/src/main/github/client-pr-local-runtime.test.ts @@ -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', diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index 9e6e5eb92bf..831690f3133 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -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({ diff --git a/src/main/github/client.ts b/src/main/github/client.ts index 158c28ef4d6..f37e376fe14 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -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 = { + 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 { + 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 { + 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 { + 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, diff --git a/src/renderer/src/components/github-pr-merge-state.test.ts b/src/renderer/src/components/github-pr-merge-state.test.ts index dc4b274f895..562b43e8a4f 100644 --- a/src/renderer/src/components/github-pr-merge-state.test.ts +++ b/src/renderer/src/components/github-pr-merge-state.test.ts @@ -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' }) }) diff --git a/src/renderer/src/components/github-pr-merge-state.ts b/src/renderer/src/components/github-pr-merge-state.ts index 14d141028b9..d5a4d39e17c 100644 --- a/src/renderer/src/components/github-pr-merge-state.ts +++ b/src/renderer/src/components/github-pr-merge-state.ts @@ -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, diff --git a/src/shared/github-pr-auto-merge-availability.test.ts b/src/shared/github-pr-auto-merge-availability.test.ts new file mode 100644 index 00000000000..47989de6b6d --- /dev/null +++ b/src/shared/github-pr-auto-merge-availability.test.ts @@ -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 { + 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) + }) +}) diff --git a/src/shared/github-pr-auto-merge-availability.ts b/src/shared/github-pr-auto-merge-availability.ts new file mode 100644 index 00000000000..5c7417d622f --- /dev/null +++ b/src/shared/github-pr-auto-merge-availability.ts @@ -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)) +}