From 7242b416985b95bfe5e0713ba1e4faf14d320540 Mon Sep 17 00:00:00 2001 From: Jonathan Avila Date: Fri, 29 May 2026 20:22:41 -0400 Subject: [PATCH] Fix GitHub merge queue PR status (#2856) * Fix GitHub merge queue PR status * Fix merge queue GraphQL field encoding Co-authored-by: Orca --------- Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com> Co-authored-by: Jinwoo-H Co-authored-by: Orca --- src/main/github/client-issue-source.test.ts | 31 +- src/main/github/client.test.ts | 287 ++++++++++++++++-- src/main/github/client.ts | 256 ++++++++++++++-- src/main/ipc/github.test.ts | 28 ++ src/main/ipc/github.ts | 30 ++ src/main/runtime/orca-runtime.ts | 11 + src/main/runtime/rpc/methods/github.test.ts | 23 ++ src/main/runtime/rpc/methods/github.ts | 17 ++ src/main/runtime/runtime-rpc.ts | 1 + src/preload/api-types.ts | 7 + src/preload/index.ts | 9 + .../src/components/GitHubItemDialog.tsx | 80 ++--- .../src/components/PullRequestPage.tsx | 80 ++--- src/renderer/src/components/TaskPage.tsx | 121 +++----- .../components/github-pr-merge-state.test.ts | 76 +++++ .../src/components/github-pr-merge-state.ts | 196 ++++++++++++ .../components/right-sidebar/PRActions.tsx | 84 +++-- .../task-page-cache-selectors.test.ts | 21 ++ .../components/task-page-cache-selectors.ts | 2 + src/renderer/src/web/web-preload-api.test.ts | 7 + src/renderer/src/web/web-preload-api.ts | 5 + src/shared/hosted-review-github.ts | 15 + src/shared/hosted-review-queue.test.ts | 21 ++ src/shared/hosted-review-queue.ts | 12 + src/shared/hosted-review.ts | 7 +- src/shared/types.ts | 9 +- 26 files changed, 1214 insertions(+), 222 deletions(-) create mode 100644 src/renderer/src/components/github-pr-merge-state.test.ts create mode 100644 src/renderer/src/components/github-pr-merge-state.ts diff --git a/src/main/github/client-issue-source.test.ts b/src/main/github/client-issue-source.test.ts index c2eb7c042..d65c37841 100644 --- a/src/main/github/client-issue-source.test.ts +++ b/src/main/github/client-issue-source.test.ts @@ -278,9 +278,18 @@ describe('GitHub issue source split', () => { const item = await getWorkItem('/repo-root', 42, 'pr') expect(getIssueOwnerRepoMock).not.toHaveBeenCalled() - expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'repos/fork/orca/pulls/42'], { - cwd: '/repo-root' - }) + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'pr', + 'view', + '42', + '--repo', + 'fork/orca', + '--json', + expect.stringContaining('reviewDecision') + ], + { cwd: '/repo-root' } + ) expect(item?.type).toBe('pr') }) @@ -311,9 +320,19 @@ describe('GitHub issue source split', () => { ['api', 'repos/stablyai/orca/issues/42'], { cwd: '/repo-root' } ) - expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/fork/orca/pulls/42'], { - cwd: '/repo-root' - }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + [ + 'pr', + 'view', + '42', + '--repo', + 'fork/orca', + '--json', + expect.stringContaining('reviewDecision') + ], + { cwd: '/repo-root' } + ) expect(item?.type).toBe('pr') }) diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index b6620d4f3..a2e720a36 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -91,8 +91,10 @@ import { getPullRequestPushTarget, mergePR, resolveReviewThread, + setPRAutoMerge, updatePRTitle, - _resetOwnerRepoCache + _resetOwnerRepoCache, + _resetMergeQueueCacheForTests } from './client' describe('getPRForBranch', () => { @@ -120,6 +122,7 @@ describe('getPRForBranch', () => { releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() + _resetMergeQueueCacheForTests() }) it('queries GitHub by head branch when the remote is on github.com', async () => { @@ -230,7 +233,7 @@ describe('getPRForBranch', () => { '--repo', 'stablyai/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -243,7 +246,7 @@ describe('getPRForBranch', () => { '--repo', 'fork/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -282,7 +285,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -373,7 +376,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -464,7 +467,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -513,30 +516,48 @@ describe('getPRForBranch', () => { it('prefers branch lookup over a fallback PR number', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) - ghExecFileAsyncMock.mockResolvedValueOnce({ - stdout: JSON.stringify([ - { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify([ + { + number: 43, + title: 'Branch PR wins', + state: 'open', + html_url: 'https://github.com/acme/widgets/pull/43', + updated_at: '2026-03-28T00:00:00Z', + draft: false, + mergeable: true, + head: { ref: 'feature/test', sha: 'branch-head-oid' }, + base: { ref: 'main', sha: 'branch-base-oid' } + } + ]) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ number: 43, - title: 'Branch PR wins', - state: 'open', - html_url: 'https://github.com/acme/widgets/pull/43', - updated_at: '2026-03-28T00:00:00Z', - draft: false, - mergeable: true, - head: { ref: 'feature/test', sha: 'branch-head-oid' }, - base: { ref: 'main', sha: 'branch-base-oid' } - } - ]) - }) + title: 'Hydrated branch PR wins', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/43', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature/test', + baseRefOid: 'branch-base-oid', + headRefOid: 'branch-head-oid' + }) + }) const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, 42) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, ['api', 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=1'], { cwd: '/repo-root' } ) - expect(pr).toMatchObject({ number: 43, title: 'Branch PR wins' }) + expect(pr).toMatchObject({ number: 43, title: 'Hydrated branch PR wins' }) }) it('uses a fallback PR number only after branch lookup misses', async () => { @@ -576,7 +597,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -613,13 +634,54 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) expect(pr?.number).toBe(77) }) + it('normalizes exact linked PR fallback metadata when no GitHub remote is resolved', async () => { + getOwnerRepoMock.mockResolvedValueOnce(null) + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 77, + title: 'Linked fallback PR', + state: 'OPEN', + url: 'https://example.com/pr/77', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: '', + autoMergeRequest: { enabledAt: '2026-03-28T00:00:00Z' }, + baseRefName: 'main', + headRefName: 'feature/test', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) + }) + + const pr = await getPRForBranch('/non-github-repo', 'feature/test', 77) + + expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + [ + 'pr', + 'view', + '77', + '--json', + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' + ], + { cwd: '/non-github-repo' } + ) + expect(pr).toMatchObject({ + number: 77, + reviewDecision: null, + autoMergeEnabled: true + }) + expect(pr?.mergeQueueRequired).toBeUndefined() + }) + it('falls back to gh pr view when the remote cannot be resolved to GitHub', async () => { getOwnerRepoMock.mockResolvedValueOnce(null) ghExecFileAsyncMock.mockResolvedValueOnce({ @@ -647,7 +709,7 @@ describe('getPRForBranch', () => { 'view', 'feature/test', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/non-github-repo' } ) @@ -823,7 +885,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -871,7 +933,7 @@ describe('getPRForBranch', () => { '--repo', 'acme/widgets', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -1039,6 +1101,7 @@ describe('GitHub GraphQL rate-limit guard', () => { releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() + _resetMergeQueueCacheForTests() }) it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => { @@ -1140,7 +1203,7 @@ describe('GitHub GraphQL rate-limit guard', () => { '--repo', 'stablyai/orca', '--json', - 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' + 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) @@ -1159,6 +1222,149 @@ 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: '' }) + + await expect( + setPRAutoMerge('/remote/repo-root', 7, true, 'ssh-1', { + owner: 'stablyai', + repo: 'orca' + }) + ).resolves.toEqual({ ok: true }) + await expect( + setPRAutoMerge('/remote/repo-root', 7, false, 'ssh-1', { + owner: 'stablyai', + repo: 'orca' + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + ['pr', 'merge', '7', '--auto', '--repo', 'stablyai/orca'], + expect.objectContaining({ + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + }) + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'], + expect.objectContaining({ + env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) + }) + ) + expect(ghExecFileAsyncMock.mock.calls[0]?.[1]).not.toHaveProperty('cwd') + }) + + it('blocks direct merge when GitHub reports required approval', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'REVIEW_REQUIRED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) + }) + + await expect( + mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + ).resolves.toEqual({ + ok: false, + error: 'This pull request requires review approval before it can be merged.' + }) + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toContain('graphql') + }) + + it('detects merge queues once per base branch and blocks direct merges', async () => { + const prView = { + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'true', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + } + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } }) + }) + .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + + await expect( + mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + ).resolves.toEqual({ + ok: false, + error: + 'This pull request must be merged through GitHub merge queue. Use Merge when ready instead.' + }) + await expect( + mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca' }) + ).resolves.toMatchObject({ ok: false }) + + expect( + ghExecFileAsyncMock.mock.calls.filter((call) => call[0].includes('graphql')) + ).toHaveLength(1) + expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toEqual( + expect.arrayContaining(['-f', 'owner=stablyai', '-f', 'repo=orca', '-f', 'branch=true']) + ) + expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).not.toContain('-F') + }) + + it('caches unknown merge queue probes after GraphQL failures', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) + const prView = { + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + } + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + .mockRejectedValueOnce(new Error('network is down')) + .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + + await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({ + mergeQueueRequired: null + }) + await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({ + mergeQueueRequired: null + }) + + expect( + ghExecFileAsyncMock.mock.calls.filter((call) => call[0].includes('graphql')) + ).toHaveLength(1) + }) + it('returns conflicting file details instead of running gh merge when PR is dirty', async () => { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ @@ -1197,14 +1403,31 @@ describe('GitHub GraphQL rate-limit guard', () => { }) it('does not run merge conflict preflight for SSH-backed repos', async () => { - ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/7', + statusCheckRollup: [], + updatedAt: '2026-04-01T00:00:00Z', + isDraft: false, + mergeable: 'CONFLICTING', + baseRefName: 'main', + baseRefOid: 'base-oid', + headRefOid: 'head-oid' + }) + }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect( mergePR('/remote/repo-root', 7, 'squash', 'ssh-1', { owner: 'stablyai', repo: 'orca' }) ).resolves.toEqual({ ok: true }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) - expect(ghExecFileAsyncMock).toHaveBeenCalledWith( + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) diff --git a/src/main/github/client.ts b/src/main/github/client.ts index e9cb2177a..fb5c5be0a 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -9,6 +9,7 @@ import type { PRConflictSummary, PRRefreshOutcome, PRMergeableState, + PRReviewDecision, PRCheckDetail, PRCheckRunDetails, GitHubCommentResult, @@ -83,6 +84,13 @@ import { type GhExecOptions = ReturnType const ORCA_REPO = 'stablyai/orca' +const MERGE_QUEUE_CACHE_TTL_MS = 10 * 60 * 1000 +const MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS = 60 * 1000 +const mergeQueueRequiredCache = new Map() + +export function _resetMergeQueueCacheForTests(): void { + mergeQueueRequiredCache.clear() +} async function assertRateLimitBudget(bucket: RateLimitBucketKind): Promise { await getRateLimit() @@ -346,7 +354,7 @@ const WORK_ITEM_PR_LIST_JSON_FIELDS = // GraphQL work across every row. Requested reviewers are kept in the list // payload because the Tasks table renders that column on first paint. const WORK_ITEM_PR_DETAIL_JSON_FIELDS = - 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,maintainerCanModify' + 'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,additions,deletions,changedFiles,reviewDecision,reviewRequests,latestReviews,assignees,statusCheckRollup,mergeable,mergeStateStatus,autoMergeRequest,maintainerCanModify' function mapIssueWorkItem(item: Record): MainWorkItem { return { @@ -509,6 +517,16 @@ function normalizePRMergeable(value: unknown): PRMergeableState | undefined { return undefined } +function normalizeReviewDecision(value: unknown): PRReviewDecision | null { + return value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED' + ? value + : null +} + +function isAutoMergeEnabled(value: unknown): boolean { + return typeof value === 'object' && value !== null +} + function checkRollupEntries(value: unknown): unknown[] { if (Array.isArray(value)) { return value @@ -637,7 +655,7 @@ function mapPullRequestWorkItem( ...(deletions !== undefined ? { deletions } : {}), ...(changedFiles !== undefined ? { changedFiles } : {}), ...('reviewDecision' in item - ? { reviewDecision: typeof item.reviewDecision === 'string' ? item.reviewDecision : null } + ? { reviewDecision: normalizeReviewDecision(item.reviewDecision) } : {}), ...(item.reviewRequests !== undefined || item.requested_reviewers !== undefined ? { reviewRequests: usersFromUnknown(item.reviewRequests ?? item.requested_reviewers) } @@ -650,6 +668,9 @@ function mapPullRequestWorkItem( ? { checksSummary: deriveWorkItemCheckSummary(item.statusCheckRollup) } : {}), ...(mergeable ? { mergeable } : {}), + ...('autoMergeRequest' in item + ? { autoMergeEnabled: isAutoMergeEnabled(item.autoMergeRequest) } + : {}), ...('mergeStateStatus' in item ? { mergeStateStatus: typeof item.mergeStateStatus === 'string' ? item.mergeStateStatus : null @@ -696,11 +717,31 @@ async function fetchPullRequestWorkItem( ): Promise { const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) if (ownerRepo) { - const { stdout } = await ghExecFileAsync( - ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], - ghOptions - ) - return mapPullRequestWorkItem(JSON.parse(stdout) as Record, ownerRepo) + try { + const { stdout } = await ghExecFileAsync( + [ + 'pr', + 'view', + String(number), + '--repo', + `${ownerRepo.owner}/${ownerRepo.repo}`, + '--json', + WORK_ITEM_PR_DETAIL_JSON_FIELDS + ], + ghOptions + ) + const item = JSON.parse(stdout) as Record + const mapped = mapPullRequestWorkItem(item, ownerRepo) + const baseRefName = typeof item.baseRefName === 'string' ? item.baseRefName : undefined + const mergeQueueRequired = await detectMergeQueueRequired(ownerRepo, baseRefName, ghOptions) + return { ...mapped, mergeQueueRequired } + } catch { + const { stdout } = await ghExecFileAsync( + ['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/pulls/${number}`], + ghOptions + ) + return mapPullRequestWorkItem(JSON.parse(stdout) as Record, ownerRepo) + } } const { stdout } = await ghExecFileAsync( @@ -1647,6 +1688,11 @@ type PullRequestLookupData = { updatedAt: string isDraft?: boolean mergeable: string + reviewDecision?: PRReviewDecision | null + autoMergeRequest?: unknown + autoMergeEnabled?: boolean + mergeQueueRequired?: boolean | null + mergeStateStatus?: string | null baseRefName?: string headRefName?: string baseRefOid?: string @@ -1669,6 +1715,8 @@ type RestPullRequest = { } 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' function mapRestPRMergeable(pr: RestPullRequest): PRMergeableState { @@ -1699,6 +1747,108 @@ function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData { } } +function normalizePullRequestLookupData(data: PullRequestLookupData): PullRequestLookupData { + return { + ...data, + reviewDecision: + data.reviewDecision !== undefined ? normalizeReviewDecision(data.reviewDecision) : undefined, + autoMergeEnabled: + data.autoMergeEnabled ?? + ('autoMergeRequest' in data ? isAutoMergeEnabled(data.autoMergeRequest) : undefined) + } +} + +function cacheMergeQueueRequired(cacheKey: string, value: boolean | null, ttlMs: number): void { + mergeQueueRequiredCache.set(cacheKey, { + value, + expiresAt: Date.now() + ttlMs + }) +} + +async function detectMergeQueueRequired( + ownerRepo: OwnerRepo, + branchName: string | undefined, + ghOptions: GhExecOptions +): Promise { + if (!branchName) { + return null + } + const cacheKey = `${ownerRepo.owner.toLowerCase()}/${ownerRepo.repo.toLowerCase()}:${branchName}` + const cached = mergeQueueRequiredCache.get(cacheKey) + if (cached && cached.expiresAt > Date.now()) { + return cached.value + } + const guard = rateLimitGuard('graphql') + if (guard.blocked) { + return null + } + const query = `query($owner: String!, $repo: String!, $branch: String!) { + repository(owner: $owner, name: $repo) { + mergeQueue(branch: $branch) { id } + } + }` + try { + noteRateLimitSpend('graphql') + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${query}`, + '-f', + `owner=${ownerRepo.owner}`, + '-f', + `repo=${ownerRepo.repo}`, + '-f', + `branch=${branchName}` + ], + ghOptions + ) + const parsed = JSON.parse(stdout) as { + data?: { repository?: { mergeQueue?: { id?: unknown } | null } | null } + } + const value = Boolean(parsed.data?.repository?.mergeQueue) + cacheMergeQueueRequired(cacheKey, value, MERGE_QUEUE_CACHE_TTL_MS) + return value + } catch { + // Why: failed merge-queue probes should stay conservative without + // retrying GraphQL on every status poll while GitHub/network is unhappy. + cacheMergeQueueRequired(cacheKey, null, MERGE_QUEUE_UNKNOWN_CACHE_TTL_MS) + return null + } +} + +async function hydratePullRequestLookupData( + ownerRepo: OwnerRepo, + data: PullRequestLookupData, + ghOptions: GhExecOptions +): Promise { + const normalized = normalizePullRequestLookupData(data) + const hasRichMergeFields = + 'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data + return { + ...normalized, + mergeQueueRequired: hasRichMergeFields + ? await detectMergeQueueRequired(ownerRepo, normalized.baseRefName, ghOptions) + : undefined + } +} + +async function hydrateBranchLookupWithExactPR( + ownerRepo: OwnerRepo, + branchData: PullRequestLookupData | null, + ghOptions: GhExecOptions +): Promise { + if (!branchData) { + return null + } + try { + return (await getPRByNumber(ownerRepo, branchData.number, ghOptions)) ?? branchData + } catch { + return branchData + } +} + async function getRestPRForBranch( prRepo: OwnerRepo, headOwner: string, @@ -1733,7 +1883,7 @@ async function getFallbackPRListForBranch( '--limit', '1', '--json', - PR_LOOKUP_JSON_FIELDS + PR_BRANCH_LIST_JSON_FIELDS ], ghOptions ) @@ -1771,7 +1921,11 @@ async function getPRByNumber( ], ghOptions ) - return JSON.parse(stdout) as PullRequestLookupData + return hydratePullRequestLookupData( + ownerRepo, + JSON.parse(stdout) as PullRequestLookupData, + ghOptions + ) } catch (err) { // Why: deleted or manually edited linked PR metadata should fall back to // branch discovery; quota/auth/network failures get one cheaper REST exact lookup. @@ -1779,7 +1933,8 @@ async function getPRByNumber( return null } try { - return await getRestPRByNumber(ownerRepo, number, ghOptions) + const restData = await getRestPRByNumber(ownerRepo, number, ghOptions) + return restData ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions) : null } catch (restErr) { if (isNotFoundGhError(restErr)) { return null @@ -1821,7 +1976,10 @@ async function lookupPRByNumber(args: { ['pr', 'view', String(args.number), '--json', PR_LOOKUP_JSON_FIELDS], args.ghOptions ) - return { data: JSON.parse(stdout), dataRepo: null } + return { + data: normalizePullRequestLookupData(JSON.parse(stdout) as PullRequestLookupData), + dataRepo: null + } } catch (err) { if (isNoPullRequestError(err)) { // Why: stale cached fallback numbers should not turn every poll into an @@ -1908,9 +2066,12 @@ export async function getPRForBranchOutcome( if (candidates.length > 0) { for (const candidate of candidates) { try { - data = headRepo + const branchData = headRepo ? await getRestPRForBranch(candidate, headRepo.owner, branchName, ghOptions) : await getFallbackPRListForBranch(candidate, branchName, ghOptions) + // Why: REST/list branch lookup identifies the PR cheaply; exact + // `gh pr view` carries review, merge queue, and auto-merge state. + data = await hydrateBranchLookupWithExactPR(candidate, branchData, ghOptions) if (data) { dataRepo = candidate break @@ -1919,7 +2080,13 @@ export async function getPRForBranchOutcome( if (headRepo) { throw err } else { - data = await getRestPRForBranch(candidate, candidate.owner, branchName, ghOptions) + const branchData = await getRestPRForBranch( + candidate, + candidate.owner, + branchName, + ghOptions + ) + data = await hydrateBranchLookupWithExactPR(candidate, branchData, ghOptions) if (data) { dataRepo = candidate break @@ -1933,7 +2100,7 @@ export async function getPRForBranchOutcome( ['pr', 'view', branchName, '--json', PR_LOOKUP_JSON_FIELDS], ghOptions ) - data = JSON.parse(stdout) + data = normalizePullRequestLookupData(JSON.parse(stdout)) } catch (err) { if (isNoPullRequestError(err)) { data = null @@ -1976,6 +2143,12 @@ export async function getPRForBranchOutcome( checksStatus: deriveCheckStatus(data.statusCheckRollup), updatedAt: data.updatedAt, mergeable: (data.mergeable as PRMergeableState) ?? 'UNKNOWN', + ...(data.reviewDecision !== undefined ? { reviewDecision: data.reviewDecision } : {}), + ...(data.autoMergeEnabled !== undefined ? { autoMergeEnabled: data.autoMergeEnabled } : {}), + ...(data.mergeQueueRequired !== undefined + ? { mergeQueueRequired: data.mergeQueueRequired } + : {}), + ...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}), headSha: data.headRefOid, prRepo: dataRepo ?? undefined, headRepo: headRepo ?? undefined, @@ -2889,6 +3062,35 @@ export async function mergePR( } } +export async function setPRAutoMerge( + repoPath: string, + prNumber: number, + enabled: boolean, + connectionId?: string | null, + prRepo?: OwnerRepo | null +): Promise<{ ok: true } | { ok: false; error: string }> { + const ghOptions = ghRepoExecOptions(githubRepoContext(repoPath, connectionId)) + const ownerRepo = prRepo ?? (await getOwnerRepo(repoPath, connectionId)) + await acquire() + try { + const args = ['pr', 'merge', String(prNumber), enabled ? '--auto' : '--disable-auto'] + if (ownerRepo) { + args.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`) + } + await ghExecFileAsync(args, { + ...ghOptions, + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) + return { ok: true } + } catch (err) { + const message = + err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' + return { ok: false, error: classifyGhError(message).message } + } finally { + release() + } +} + async function getPRMergeBlocker( repoPath: string, prNumber: number, @@ -2896,15 +3098,33 @@ async function getPRMergeBlocker( ghOptions: GhExecOptions, connectionId?: string | null ): Promise { - // Why: conflict summaries shell out to local git; SSH repo paths are remote-only - // until that helper is routed through the SSH git provider. - if (!ownerRepo || connectionId) { + if (!ownerRepo) { return null } try { const pr = await getPRByNumber(ownerRepo, prNumber, ghOptions) - if (pr?.mergeable !== 'CONFLICTING' || !pr.baseRefName || !pr.baseRefOid || !pr.headRefOid) { + if (!pr) { + return null + } + if (pr.reviewDecision === 'REVIEW_REQUIRED') { + return 'This pull request requires review approval before it can be merged.' + } + if (pr.reviewDecision === 'CHANGES_REQUESTED') { + return 'This pull request has requested changes and cannot be merged yet.' + } + if (pr.mergeQueueRequired === true) { + return 'This pull request must be merged through GitHub merge queue. Use Merge when ready instead.' + } + // Why: conflict summaries shell out to local git; SSH repo paths are remote-only + // until that helper is routed through the SSH git provider. + if ( + connectionId || + pr.mergeable !== 'CONFLICTING' || + !pr.baseRefName || + !pr.baseRefOid || + !pr.headRefOid + ) { return null } diff --git a/src/main/ipc/github.test.ts b/src/main/ipc/github.test.ts index c527c805e..2a5896dc0 100644 --- a/src/main/ipc/github.test.ts +++ b/src/main/ipc/github.test.ts @@ -1,3 +1,5 @@ +/* eslint-disable max-lines -- Why: GitHub IPC tests share one mocked Electron +handler harness; keeping the related route wiring together avoids duplicated setup. */ import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -8,6 +10,7 @@ const { listWorkItemsMock, getAuthenticatedViewerMock, mergePRMock, + setPRAutoMergeMock, checkOrcaStarredMock, starOrcaMock, trackMock, @@ -21,6 +24,7 @@ const { listWorkItemsMock: vi.fn(), getAuthenticatedViewerMock: vi.fn(), mergePRMock: vi.fn(), + setPRAutoMergeMock: vi.fn(), checkOrcaStarredMock: vi.fn(), starOrcaMock: vi.fn(), trackMock: vi.fn(), @@ -44,6 +48,7 @@ vi.mock('../github/client', () => ({ listWorkItems: listWorkItemsMock, getAuthenticatedViewer: getAuthenticatedViewerMock, mergePR: mergePRMock, + setPRAutoMerge: setPRAutoMergeMock, checkOrcaStarred: checkOrcaStarredMock, starOrca: starOrcaMock })) @@ -88,6 +93,7 @@ describe('registerGitHubHandlers', () => { listWorkItemsMock.mockReset() getAuthenticatedViewerMock.mockReset() mergePRMock.mockReset() + setPRAutoMergeMock.mockReset() checkOrcaStarredMock.mockReset() starOrcaMock.mockReset() trackMock.mockReset() @@ -282,6 +288,28 @@ describe('registerGitHubHandlers', () => { }) }) + it('threads SSH connectionId through pull request auto-merge', async () => { + repos[0].connectionId = 'openclaw-2' + setPRAutoMergeMock.mockResolvedValue({ ok: true }) + + registerGitHubHandlers(store as never, stats as never) + + await handlers['gh:setPRAutoMerge']( + { sender: { id: 1 } }, + { + repoPath: '/workspace/repo', + prNumber: 42, + enabled: true, + prRepo: { owner: 'acme', repo: 'orca' } + } + ) + + expect(setPRAutoMergeMock).toHaveBeenCalledWith('/workspace/repo', 42, true, 'openclaw-2', { + owner: 'acme', + repo: 'orca' + }) + }) + it('forwards the authenticated viewer lookup', async () => { getAuthenticatedViewerMock.mockResolvedValue({ login: 'octocat', email: 'octocat@example.com' }) diff --git a/src/main/ipc/github.ts b/src/main/ipc/github.ts index 4eb3ab744..4a7fe5cb3 100644 --- a/src/main/ipc/github.ts +++ b/src/main/ipc/github.ts @@ -39,6 +39,7 @@ import { addPRReviewCommentReply, updatePRTitle, mergePR, + setPRAutoMerge, updatePRState, rerunPRChecks, requestPRReviewers, @@ -683,6 +684,35 @@ export function registerGitHubHandlers(store: Store, stats: StatsCollector): voi } ) + ipcMain.handle( + 'gh:setPRAutoMerge', + async ( + event, + args: { + repoPath: string + prNumber: number + enabled: boolean + prRepo?: GitHubOwnerRepo | null + } + ) => { + const repo = assertRegisteredRepo(args, store) + const result = await setPRAutoMerge( + repo.path, + args.prNumber, + args.enabled, + repoConnectionId(repo), + args.prRepo ?? null + ) + if (result.ok) { + broadcastWorkItemMutated( + { repoPath: repo.path, repoId: repo.id, type: 'pr', number: args.prNumber }, + event.sender.id + ) + } + return result + } + ) + ipcMain.handle( 'gh:updatePRState', async ( diff --git a/src/main/runtime/orca-runtime.ts b/src/main/runtime/orca-runtime.ts index 976371284..ba53880d2 100644 --- a/src/main/runtime/orca-runtime.ts +++ b/src/main/runtime/orca-runtime.ts @@ -170,6 +170,7 @@ import { updatePRTitle, updatePRDetails, mergePR, + setPRAutoMerge, updatePRState, requestPRReviewers, removePRReviewers, @@ -6424,6 +6425,16 @@ export class OrcaRuntimeService { return mergePR(repo.path, prNumber, method, repo.connectionId ?? null, prRepo ?? null) } + async setRepoPRAutoMerge( + repoSelector: string, + prNumber: number, + enabled: boolean, + prRepo?: GitHubOwnerRepo | null + ): Promise>> { + const repo = await this.resolveRepoSelector(repoSelector) + return setPRAutoMerge(repo.path, prNumber, enabled, repo.connectionId ?? null, prRepo ?? null) + } + async updateRepoPRState( repoSelector: string, prNumber: number, diff --git a/src/main/runtime/rpc/methods/github.test.ts b/src/main/runtime/rpc/methods/github.test.ts index b7223989d..37bf218a1 100644 --- a/src/main/runtime/rpc/methods/github.test.ts +++ b/src/main/runtime/rpc/methods/github.test.ts @@ -371,6 +371,29 @@ describe('github RPC methods', () => { expect(response).toMatchObject({ ok: true, result: { ok: true } }) }) + it('sets PR auto-merge on the runtime server', async () => { + const runtime = { + getRuntimeId: () => 'test-runtime', + setRepoPRAutoMerge: vi.fn().mockResolvedValue({ ok: true }) + } as unknown as OrcaRuntimeService + const dispatcher = new RpcDispatcher({ runtime, methods: GITHUB_METHODS }) + + const response = await dispatcher.dispatch( + makeRequest('github.setPRAutoMerge', { + repo: 'repo-1', + prNumber: 7, + enabled: true, + prRepo: { owner: 'acme', repo: 'widgets' } + }) + ) + + expect(runtime.setRepoPRAutoMerge).toHaveBeenCalledWith('repo-1', 7, true, { + owner: 'acme', + repo: 'widgets' + }) + expect(response).toMatchObject({ ok: true, result: { ok: true } }) + }) + it('routes PR reviewer mutations on the runtime server', async () => { const runtime = { getRuntimeId: () => 'test-runtime', diff --git a/src/main/runtime/rpc/methods/github.ts b/src/main/runtime/rpc/methods/github.ts index ebccf77bc..ed48d4c6a 100644 --- a/src/main/runtime/rpc/methods/github.ts +++ b/src/main/runtime/rpc/methods/github.ts @@ -123,6 +123,12 @@ const MergePr = RepoSelector.extend({ prRepo: SlugRepo.nullable().optional() }) +const SetPrAutoMerge = RepoSelector.extend({ + prNumber: z.number().int().positive(), + enabled: z.boolean(), + prRepo: SlugRepo.nullable().optional() +}) + const UpdatePrState = RepoSelector.extend({ prNumber: z.number().int().positive(), updates: z.object({ @@ -440,6 +446,17 @@ export const GITHUB_METHODS: RpcMethod[] = [ handler: async (params, { runtime }) => runtime.mergeRepoPR(params.repo, params.prNumber, params.method, params.prRepo ?? null) }), + defineMethod({ + name: 'github.setPRAutoMerge', + params: SetPrAutoMerge, + handler: async (params, { runtime }) => + runtime.setRepoPRAutoMerge( + params.repo, + params.prNumber, + params.enabled, + params.prRepo ?? null + ) + }), defineMethod({ name: 'github.updatePRState', params: UpdatePrState, diff --git a/src/main/runtime/runtime-rpc.ts b/src/main/runtime/runtime-rpc.ts index 8cc6486c6..da35dd97f 100644 --- a/src/main/runtime/runtime-rpc.ts +++ b/src/main/runtime/runtime-rpc.ts @@ -169,6 +169,7 @@ const MOBILE_RPC_METHOD_ALLOWLIST = new Set([ 'github.listLabels', 'github.listWorkItems', 'github.mergePR', + 'github.setPRAutoMerge', 'github.requestPRReviewers', 'github.project.listAccessible', 'github.project.listAssignableUsersBySlug', diff --git a/src/preload/api-types.ts b/src/preload/api-types.ts index c9fc9ca9b..6081437dd 100644 --- a/src/preload/api-types.ts +++ b/src/preload/api-types.ts @@ -1005,6 +1005,13 @@ export type PreloadApi = { method?: 'merge' | 'squash' | 'rebase' prRepo?: GitHubOwnerRepo | null }) => Promise<{ ok: true } | { ok: false; error: string }> + setPRAutoMerge: (args: { + repoPath: string + repoId?: string + prNumber: number + enabled: boolean + prRepo?: GitHubOwnerRepo | null + }) => Promise<{ ok: true } | { ok: false; error: string }> updatePRState: (args: { repoPath: string repoId?: string diff --git a/src/preload/index.ts b/src/preload/index.ts index 1b52641f5..589bb6e41 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -1074,6 +1074,15 @@ const api = { }): Promise<{ ok: true } | { ok: false; error: string }> => ipcRenderer.invoke('gh:mergePR', args), + setPRAutoMerge: (args: { + repoPath: string + repoId?: string + prNumber: number + enabled: boolean + prRepo?: { owner: string; repo: string } | null + }): Promise<{ ok: true } | { ok: false; error: string }> => + ipcRenderer.invoke('gh:setPRAutoMerge', args), + updatePRState: (args: { repoPath: string repoId?: string diff --git a/src/renderer/src/components/GitHubItemDialog.tsx b/src/renderer/src/components/GitHubItemDialog.tsx index 0df1667fd..fb2bc9361 100644 --- a/src/renderer/src/components/GitHubItemDialog.tsx +++ b/src/renderer/src/components/GitHubItemDialog.tsx @@ -61,6 +61,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger } from '@/components/ui/dropdown-menu' import CommentMarkdown from '@/components/sidebar/CommentMarkdown' @@ -107,6 +108,7 @@ import { getGitHubPRReviewerRows, normalizeGitHubReviewerLogins } from '@/components/github-pr-reviewer-display' +import { presentGitHubPRMergeState } from '@/components/github-pr-merge-state' import { AGENT_CATALOG } from '@/lib/agent-catalog' import { filterEnabledTuiAgents } from '../../../shared/tui-agent-selection' import { getConnectionId } from '@/lib/connection-context' @@ -365,31 +367,6 @@ function getStateTone(item: GitHubWorkItem): string { return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-600 dark:text-emerald-300' } -function getPRMergeTooltip(item: GitHubWorkItem): string { - if (item.state === 'merged') { - return 'This pull request is already merged' - } - if (item.state === 'closed') { - return 'This pull request is closed' - } - if (item.mergeable === undefined && item.mergeStateStatus === undefined) { - return 'Merge status is unavailable for this PR' - } - if (item.mergeable === 'CONFLICTING') { - return 'GitHub reports merge conflicts' - } - if (item.mergeStateStatus === 'BEHIND') { - return 'Update the branch before merging' - } - if (item.mergeStateStatus === 'BLOCKED') { - return 'GitHub reports this pull request is blocked' - } - if (item.mergeable === 'MERGEABLE' || item.mergeStateStatus === 'CLEAN') { - return 'GitHub says this PR can merge' - } - return 'GitHub has not reported a final merge status' -} - function WorkItemStateBadge({ item, className @@ -2830,14 +2807,10 @@ function PRActionsPanel({ const patchProjectRowContent = useAppStore((s) => s.patchProjectRowContent) const confirm = useConfirmationDialog() const actionItem = { ...item, state: localState } + const mergePresentation = presentGitHubPRMergeState(actionItem) const canMutateState = localState !== 'merged' && (!!repoPath || !!projectOrigin) const nextState: 'open' | 'closed' = localState === 'closed' ? 'open' : 'closed' - const mergeDisabled = - !repoPath || - mergePending || - localState === 'closed' || - localState === 'merged' || - item.mergeable === 'CONFLICTING' + const mergeDisabled = !repoPath || mergePending || !mergePresentation.directMergeAvailable const patchProjectRowIfNeeded = useCallback( (state: GitHubWorkItem['state']) => { @@ -2916,7 +2889,8 @@ function PRActionsPanel({ repoPath, repoId: repoId ?? undefined, prNumber: item.number, - method + method, + prRepo: item.prRepo ?? null }) if (!result.ok) { toast.error(result.error) @@ -2932,6 +2906,33 @@ function PRActionsPanel({ } } + const handleAutoMerge = async (): Promise => { + if (!repoPath || !mergePresentation.autoMergeAction) { + return + } + const enabled = mergePresentation.autoMergeAction.kind === 'enable' + setMergePending(true) + try { + const result = await window.api.gh.setPRAutoMerge({ + repoPath, + repoId: repoId ?? undefined, + prNumber: item.number, + enabled, + prRepo: item.prRepo ?? null + }) + if (!result.ok) { + toast.error(result.error) + return + } + toast.success(enabled ? 'Auto-merge enabled' : 'Auto-merge disabled') + onMutated() + } catch { + toast.error(enabled ? 'Failed to enable auto-merge' : 'Failed to disable auto-merge') + } finally { + setMergePending(false) + } + } + return (