diff --git a/src/main/github/client.test.ts b/src/main/github/client.test.ts index a33b9b38d20..b462b77a9cd 100644 --- a/src/main/github/client.test.ts +++ b/src/main/github/client.test.ts @@ -91,7 +91,8 @@ vi.mock('./gh-utils', () => ({ })) vi.mock('../git/runner', () => ({ - gitExecFileAsync: gitExecFileAsyncMock + gitExecFileAsync: gitExecFileAsyncMock, + ghExecFileAsync: ghExecFileAsyncMock })) vi.mock('../providers/ssh-git-dispatch', () => ({ @@ -186,6 +187,7 @@ import { _getTrackedUpstreamBranchCacheSizesForTests, _resetOwnerRepoCache, _resetMergeQueueCacheForTests, + _resetPRStackSummaryCacheForTests, __resetTrackedUpstreamBranchCacheForTests } from './client' import { __resetPRConflictSummaryCachesForTests } from './conflict-summary' @@ -193,6 +195,7 @@ import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-me import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' +import { _resetGitHubPRStackCacheForTests } from './github-pr-stack' // The origin-repository cache is module-level state; reset it so slugs // resolved by one test cannot leak into the next. @@ -273,6 +276,8 @@ describe('getPRForBranch', () => { acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() + _resetPRStackSummaryCacheForTests() + _resetGitHubPRStackCacheForTests() __resetTrackedUpstreamBranchCacheForTests() __resetPRConflictSummaryCachesForTests() resetMergedPRCommitMembershipCacheForTest() @@ -431,7 +436,7 @@ describe('getPRForBranch', () => { const pr = await getPRForBranch('/repo-root', 'feature/local-worktree', 99) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ @@ -453,6 +458,81 @@ describe('getPRForBranch', () => { }) }) + it('caches exact REST stack probes across linked PR refreshes', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'pr') { + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/99', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature', + headRefOid: 'head-oid' + }) + } + } + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'open', + stack: null + }) + } + }) + + await getPRForBranch('/repo-root', 'feature', 99) + await getPRForBranch('/repo-root', 'feature', 99) + await getPRForBranch('/repo-root', 'feature', 99, 'ssh-1') + + expect( + ghExecFileAsyncMock.mock.calls.filter( + ([args]) => args[0] === 'api' && args[1]?.includes('/99') + ) + ).toHaveLength(2) + expect(ghExecFileAsyncMock.mock.calls.filter(([args]) => args[0] === 'pr')).toHaveLength(3) + }) + + it('caches failed REST stack probes across linked PR refreshes', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args[0] === 'pr') { + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/99', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'main', + headRefName: 'feature', + headRefOid: 'head-oid' + }) + } + } + throw new Error('GitHub is temporarily unavailable') + }) + + await getPRForBranch('/repo-root', 'feature', 99) + await getPRForBranch('/repo-root', 'feature', 99) + + expect( + ghExecFileAsyncMock.mock.calls.filter( + ([args]) => args[0] === 'api' && args[1]?.includes('/99') + ) + ).toHaveLength(1) + }) + it('hydrates repository merge method settings for exact PR lookups', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock @@ -518,6 +598,54 @@ describe('getPRForBranch', () => { ) }) + it('isolates viewer-dependent merge metadata across SSH connections', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) + let metadataProbe = 0 + ghExecFileAsyncMock.mockImplementation(async (args: string[]) => { + if (args.includes('graphql')) { + metadataProbe += 1 + return { + stdout: JSON.stringify({ + data: { + repository: { + autoMergeAllowed: metadataProbe === 1, + mergeQueue: null + } + } + }) + } + } + if (args[0] === 'pr') { + return { + stdout: JSON.stringify({ + number: 99, + title: 'Linked PR', + state: 'OPEN', + url: 'https://github.com/acme/widgets/pull/99', + statusCheckRollup: [], + updatedAt: '2026-03-28T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + autoMergeRequest: null, + baseRefName: 'main', + headRefName: 'feature', + headRefOid: 'head-oid' + }) + } + } + return { stdout: JSON.stringify({ number: 99, state: 'open', stack: null }) } + }) + + const firstAccount = await getPRForBranch('/repo-root', 'feature', 99, 'ssh-account-1') + const secondAccount = await getPRForBranch('/repo-root', 'feature', 99, 'ssh-account-2') + + expect(firstAccount?.autoMergeAllowed).toBe(true) + expect(secondAccount?.autoMergeAllowed).toBe(false) + expect(metadataProbe).toBe(2) + }) + it('treats linked PR metadata as authoritative even when the branch head differs', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'current-worktree-head\n', stderr: '' }) @@ -559,7 +687,7 @@ describe('getPRForBranch', () => { const pr = await getPRForBranch('/repo-root', 'feature/test', 99) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(pr?.number).toBe(99) }) @@ -1181,7 +1309,7 @@ describe('getPRForBranch', () => { expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( undefined ) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('leaves linked merged divergence unset when the membership probe throws', async () => { @@ -1223,7 +1351,7 @@ describe('getPRForBranch', () => { expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( undefined ) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('prefers branch lookup over a fallback PR number', async () => { @@ -2866,7 +2994,7 @@ describe('getPRForBranch', () => { const pr = await getPRForBranch('/repo-root', 'refs/heads/local-created-from-pr', 77) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ 'pr', @@ -4244,8 +4372,252 @@ describe('GitHub GraphQL rate-limit guard', () => { expect(noteRateLimitSpendMock).toHaveBeenCalledTimes(2) }) + it('hydrates GitHub-registered stack metadata for exact linked PRs', async () => { + getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca', host: 'github.com' }) + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/202', + statusCheckRollup: [], + updatedAt: '2026-08-10T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'stack/models', + headRefName: 'stack/api', + headRefOid: 'api-sha' + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'open', + html_url: 'https://github.com/stablyai/orca/pull/202', + head: { ref: 'stack/api', sha: 'api-sha' }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'main-sha' } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { + number: 51, + size: 2, + baseRefName: 'main', + entries: { + nodes: [ + { + position: 1, + pullRequest: { + number: 201, + title: 'Stack models', + url: 'https://github.com/stablyai/orca/pull/201', + state: 'OPEN', + isDraft: false, + mergeable: 'MERGEABLE', + statusCheckRollup: { state: 'SUCCESS' } + } + }, + { + position: 2, + pullRequest: { + number: 202, + title: 'Stack API', + url: 'https://github.com/stablyai/orca/pull/202', + state: 'OPEN', + isDraft: false, + mergeable: 'MERGEABLE', + statusCheckRollup: { state: 'SUCCESS' } + } + } + ] + } + } + } + } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + data: { + repository: { + mergeQueue: null, + ref: { rules: { nodes: [{ type: 'MERGE_QUEUE' }] } } + } + } + }) + }) + + const pr = await getPRForBranch('/repo-root', 'stack/api', 202) + + expect(pr?.stack).toMatchObject({ + number: 51, + position: 2, + size: 2, + baseRefName: 'main', + entries: [ + { number: 201, position: 1 }, + { number: 202, position: 2 } + ] + }) + expect(pr?.mergeQueueRequired).toBe(true) + const mergeQueueMetadataCall = ghExecFileAsyncMock.mock.calls.find( + ([args]) => args.includes('graphql') && args.includes('branch=main') + ) + expect(mergeQueueMetadataCall?.[0]).toEqual(expect.arrayContaining(['-f', 'branch=main'])) + }) + + it('uses async merge only for GitHub-registered stacks', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'open', + head: { ref: 'stack/api', sha: 'api-sha' }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'main-sha' } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'enqueued', details: { message: 'Queued' } }) + }) + + await expect( + mergePR('/repo-root', 202, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ ok: true }) + + const mergeCall = ghExecFileAsyncMock.mock.calls.find(([args]) => + args.includes('repos/stablyai/orca/pulls/202/merge-async') + ) + expect(mergeCall?.[0]).toEqual( + expect.arrayContaining([ + 'PUT', + 'repos/stablyai/orca/pulls/202/merge-async', + 'merge_action=merge_queue', + 'sha=api-sha' + ]) + ) + expect(mergeCall?.[0]).not.toContain('merge_method=squash') + expect(acquireMock).toHaveBeenCalledTimes(2) + expect(releaseMock).toHaveBeenCalledTimes(2) + expect( + ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge') + ).toBe(false) + }) + + it('never falls back to legacy merge after an async stack merge transport failure', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + state: 'open', + head: { ref: 'stack/api', sha: 'api-sha' }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'main-sha' } + } + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ data: { repository: { mergeQueue: null } } }) + }) + .mockRejectedValueOnce(new Error('socket closed after request submission')) + + await expect( + mergePR('/repo-root', 202, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ ok: false, error: 'socket closed after request submission' }) + expect( + ghExecFileAsyncMock.mock.calls.some(([args]) => args[0] === 'pr' && args[1] === 'merge') + ).toBe(false) + }) + + it('keeps legacy merge for unregistered dependent PR chains', async () => { + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Dependent API', + state: 'open', + head: { ref: 'feature/api', sha: 'api-sha' }, + base: { ref: 'feature/models', sha: 'models-sha' }, + stack: null + }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Dependent API', + state: 'OPEN', + url: 'https://github.com/stablyai/orca/pull/202', + statusCheckRollup: [], + updatedAt: '2026-08-10T00:00:00Z', + isDraft: false, + mergeable: 'MERGEABLE', + baseRefName: 'feature/models', + headRefOid: 'api-sha' + }) + }) + .mockResolvedValueOnce({ stdout: '', stderr: '' }) + + await expect( + mergePR('/repo-root', 202, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ ok: true }) + + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 3, + ['pr', 'merge', '202', '--squash', '--repo', 'stablyai/orca'], + expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) }) + ) + }) + it('uses explicit PR repo for merge and title mutations', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 7, + title: 'PR', + state: 'open', + head: { ref: 'feature', sha: 'head-oid' }, + base: { ref: 'main', sha: 'base-oid' }, + stack: null + }) + }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 7, @@ -4279,8 +4651,12 @@ describe('GitHub GraphQL rate-limit guard', () => { ).resolves.toBe(true) expect(getOwnerRepoMock).not.toHaveBeenCalled() + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['api', 'repos/stablyai/orca/pulls/7'], { + cwd: '/repo-root', + host: 'github.com' + }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, + 2, [ 'pr', 'view', @@ -4293,7 +4669,7 @@ describe('GitHub GraphQL rate-limit guard', () => { { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', @@ -4302,7 +4678,7 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, + 4, ['pr', 'edit', '7', '--title', 'New title', '--repo', 'stablyai/orca'], { cwd: '/repo-root', host: 'github.com' } ) @@ -4310,6 +4686,7 @@ describe('GitHub GraphQL rate-limit guard', () => { it('sets and disables PR auto-merge with explicit PR repos and SSH context', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) }) @@ -4330,13 +4707,16 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ).resolves.toEqual({ ok: true }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['api', 'repos/stablyai/orca/pulls/7'], { + host: 'github.com' + }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 1, + 2, ['pr', 'view', '7', '--json', 'id,headRefOid,baseRefName', '--repo', 'stablyai/orca'], { host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, expect.arrayContaining([ 'api', 'graphql', @@ -4353,7 +4733,7 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, + 4, ['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'], expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), @@ -4365,6 +4745,7 @@ describe('GitHub GraphQL rate-limit guard', () => { it('enables auto-merge without invoking the direct merge command', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) }) @@ -4391,8 +4772,39 @@ describe('GitHub GraphQL rate-limit guard', () => { ).toBe(false) }) + it('rejects auto-merge for GitHub-registered stacks', async () => { + ghExecFileAsyncMock.mockResolvedValueOnce({ + stdout: JSON.stringify({ + number: 202, + title: 'Stack API', + state: 'open', + head: { ref: 'stack/api', sha: 'head-oid' }, + base: { ref: 'stack/models', sha: 'models-sha' }, + stack: { + number: 51, + position: 2, + size: 2, + base: { ref: 'main', sha: 'main-sha' } + } + }) + }) + + await expect( + setPRAutoMerge('/repo-root', 202, true, 'squash', undefined, { + owner: 'stablyai', + repo: 'orca', + host: 'github.com' + }) + ).resolves.toEqual({ + ok: false, + error: 'GitHub does not support auto-merge for stacked pull requests.' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + }) + it('translates the GitHub clean-status rejection into an actionable message', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) }) @@ -4412,6 +4824,7 @@ describe('GitHub GraphQL rate-limit guard', () => { it('uses the queue-aware gh merge path when the base branch has a merge queue', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid', baseRefName: 'main' }) }) @@ -4429,12 +4842,12 @@ describe('GitHub GraphQL rate-limit guard', () => { ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, expect.arrayContaining(['api', 'graphql', '-f', 'branch=main']), { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 3, + 4, ['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', @@ -4450,24 +4863,26 @@ describe('GitHub GraphQL rate-limit guard', () => { }) 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' + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) + .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, { @@ -4480,8 +4895,8 @@ describe('GitHub GraphQL rate-limit guard', () => { error: 'This pull request requires review approval before it can be merged.' }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) - expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toContain('graphql') + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) + expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).toContain('graphql') }) it('detects merge queues once per base branch and blocks direct merges', async () => { @@ -4502,10 +4917,12 @@ describe('GitHub GraphQL rate-limit guard', () => { headRefOid: 'head-oid' } ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } }) }) + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) await expect( @@ -4526,10 +4943,10 @@ describe('GitHub GraphQL rate-limit guard', () => { expect( ghExecFileAsyncMock.mock.calls.filter((call) => call[0].includes('graphql')) ).toHaveLength(1) - expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).toEqual( + expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).toEqual( expect.arrayContaining(['-f', 'owner=stablyai', '-f', 'repo=orca', '-f', 'branch=true']) ) - expect(ghExecFileAsyncMock.mock.calls[1]?.[0]).not.toContain('-F') + expect(ghExecFileAsyncMock.mock.calls[2]?.[0]).not.toContain('-F') }) it('caches unknown merge queue probes after GraphQL failures', async () => { @@ -4553,7 +4970,9 @@ describe('GitHub GraphQL rate-limit guard', () => { ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) .mockRejectedValueOnce(new Error('network is down')) + .mockResolvedValueOnce({ stdout: '{}' }) .mockResolvedValueOnce({ stdout: JSON.stringify(prView) }) + .mockResolvedValueOnce({ stdout: '{}' }) await expect(getPRForBranch('/repo-root', 'feature/test', 7)).resolves.toMatchObject({ mergeQueueRequired: null @@ -4679,21 +5098,23 @@ describe('GitHub GraphQL rate-limit guard', () => { }) it('returns conflicting file details instead of running gh merge when PR is dirty', 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: 'CONFLICTING', - baseRefName: 'main', - baseRefOid: 'base-oid', - headRefOid: 'head-oid' + ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) + .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' + }) }) - }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) @@ -4716,11 +5137,12 @@ describe('GitHub GraphQL rate-limit guard', () => { '- src/conflict.ts' }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('does not run merge conflict preflight for SSH-backed repos', async () => { ghExecFileAsyncMock + .mockResolvedValueOnce({ stdout: JSON.stringify({ stack: null }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 7, @@ -4746,9 +5168,9 @@ describe('GitHub GraphQL rate-limit guard', () => { }) ).resolves.toEqual({ ok: true }) - expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( - 2, + 3, ['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 4f95432fcfd..828f3290647 100644 --- a/src/main/github/client.ts +++ b/src/main/github/client.ts @@ -20,7 +20,8 @@ import type { GitHubPullRequestStateUpdate, GitHubRerunPRChecksResult, GitHubPRMergeMethod, - GitHubPRMergeMethodSettings + GitHubPRMergeMethodSettings, + GitHubPRStack } from '../../shared/types' import type { CreateHostedReviewInput, CreateHostedReviewResult } from '../../shared/hosted-review' import { @@ -123,6 +124,7 @@ import { spendsSharedGitHubComQuota, type RateLimitBucketKind } from './rate-limit' +import { hydrateGitHubPRStack, mergeGitHubPRStack } from './github-pr-stack' type GhExecOptions = GitHubRepoExecOptions type HostedReviewLocalGitOptions = ReturnType @@ -161,11 +163,30 @@ const repositoryMergeMetadataCache = new Map< string, { value: GitHubRepositoryMergeMetadata; expiresAt: number } >() +const PR_STACK_SUMMARY_CACHE_TTL_MS = 60_000 +const PR_STACK_SUMMARY_CACHE_MAX_ENTRIES = 512 +const prStackSummaryCache = new Map< + string, + { value: GitHubPRStack | undefined; expiresAt: number } +>() +const prStackSummaryInFlight = new Map>() + +function githubPRStackExecutionScope( + connectionId?: string | null, + localGitOptions: LocalGitExecOptions = {} +): string { + return connectionId ? `ssh:${connectionId}` : `local:${localGitOptions.wslDistro ?? 'host'}` +} export function _resetMergeQueueCacheForTests(): void { repositoryMergeMetadataCache.clear() } +export function _resetPRStackSummaryCacheForTests(): void { + prStackSummaryCache.clear() + prStackSummaryInFlight.clear() +} + export function _getMergeQueueCacheSizeForTests(): number { return repositoryMergeMetadataCache.size } @@ -2162,6 +2183,8 @@ type PullRequestLookupData = { headRefName?: string baseRefOid?: string headRefOid?: string + stack?: GitHubPRStack + stackMetadataChecked?: boolean } type RestPullRequest = { @@ -2177,6 +2200,12 @@ type RestPullRequest = { mergeable_state?: string | null base?: { ref?: string; sha?: string } head?: { ref?: string; sha?: string } + stack?: { + number?: number + position?: number + size?: number + base?: { ref?: string; sha?: string } + } | null } const PR_LOOKUP_JSON_FIELDS = @@ -2216,6 +2245,19 @@ function derivePullRequestMergeable(data: PullRequestLookupData): PRMergeableSta } function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData { + const stack = + typeof pr.stack?.number === 'number' && + typeof pr.stack.position === 'number' && + typeof pr.stack.size === 'number' && + typeof pr.stack.base?.ref === 'string' + ? { + number: pr.stack.number, + position: pr.stack.position, + size: pr.stack.size, + baseRefName: pr.stack.base.ref, + ...(typeof pr.stack.base.sha === 'string' ? { baseSha: pr.stack.base.sha } : {}) + } + : undefined return { number: pr.number, title: pr.title, @@ -2228,7 +2270,9 @@ function mapRestPullRequest(pr: RestPullRequest): PullRequestLookupData { baseRefName: pr.base?.ref, headRefName: pr.head?.ref, baseRefOid: pr.base?.sha, - headRefOid: pr.head?.sha + headRefOid: pr.head?.sha, + stackMetadataChecked: true, + ...(stack ? { stack } : {}) } } @@ -2298,9 +2342,10 @@ function cacheRepositoryMergeMetadata( async function detectRepositoryMergeMetadata( ownerRepo: GitHubApiRepository, branchName: string | undefined, - ghOptions: GhExecOptions + ghOptions: GhExecOptions, + executionScope = 'default' ): Promise { - const cacheKey = `${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}` + const cacheKey = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}:${branchName ?? '__repo__'}` pruneRepositoryMergeMetadataCache() const cached = repositoryMergeMetadataCache.get(cacheKey) if (cached) { @@ -2311,7 +2356,7 @@ async function detectRepositoryMergeMetadata( return { mergeQueueRequired: null, autoMergeAllowed: null } } const query = branchName - ? `query($owner: String!, $repo: String!, $branch: String!) { + ? `query($owner: String!, $repo: String!, $branch: String!, $qualified: String!) { repository(owner: $owner, name: $repo) { viewerDefaultMergeMethod mergeCommitAllowed @@ -2319,6 +2364,9 @@ async function detectRepositoryMergeMetadata( squashMergeAllowed autoMergeAllowed mergeQueue(branch: $branch) { id } + ref(qualifiedName: $qualified) { + rules(first: 50) { nodes { type } } + } } }` : `query($owner: String!, $repo: String!) { @@ -2344,6 +2392,7 @@ async function detectRepositoryMergeMetadata( ] if (branchName) { args.push('-f', `branch=${branchName}`) + args.push('-f', `qualified=refs/heads/${branchName}`) } const { stdout } = await ghExecFileAsync(args, { ...ghOptions, @@ -2358,6 +2407,7 @@ async function detectRepositoryMergeMetadata( squashMergeAllowed?: unknown autoMergeAllowed?: unknown mergeQueue?: { id?: unknown } | null + ref?: { rules?: { nodes?: ({ type?: unknown } | null)[] | null } | null } | null } | null } } @@ -2371,7 +2421,10 @@ async function detectRepositoryMergeMetadata( }) : undefined const value: GitHubRepositoryMergeMetadata = { - mergeQueueRequired: branchName ? Boolean(repository?.mergeQueue) : null, + mergeQueueRequired: branchName + ? Boolean(repository?.mergeQueue) || + Boolean(repository?.ref?.rules?.nodes?.some((rule) => rule?.type === 'MERGE_QUEUE')) + : null, autoMergeAllowed: typeof repository?.autoMergeAllowed === 'boolean' ? repository.autoMergeAllowed : null, ...(mergeMethodSettings ? { mergeMethodSettings } : {}) @@ -2392,13 +2445,19 @@ async function detectRepositoryMergeMetadata( async function hydratePullRequestLookupData( ownerRepo: OwnerRepo, data: PullRequestLookupData, - ghOptions: GhExecOptions + ghOptions: GhExecOptions, + executionScope: string ): Promise { const normalized = normalizePullRequestLookupData(data) const hasRichMergeFields = 'reviewDecision' in data || 'mergeStateStatus' in data || 'autoMergeRequest' in data const mergeMetadata = hasRichMergeFields - ? await detectRepositoryMergeMetadata(ownerRepo, normalized.baseRefName, ghOptions) + ? await detectRepositoryMergeMetadata( + ownerRepo, + normalized.stack?.baseRefName ?? normalized.baseRefName, + ghOptions, + executionScope + ) : undefined return { ...normalized, @@ -2413,13 +2472,17 @@ async function hydratePullRequestLookupData( async function hydrateBranchLookupWithExactPR( ownerRepo: OwnerRepo, branchData: PullRequestLookupData | null, - ghOptions: GhExecOptions + ghOptions: GhExecOptions, + executionScope: string ): Promise { if (!branchData) { return null } try { - return (await getPRByNumber(ownerRepo, branchData.number, ghOptions)) ?? branchData + return ( + (await getPRByNumber(ownerRepo, branchData.number, ghOptions, executionScope, branchData)) ?? + branchData + ) } catch { return branchData } @@ -2772,6 +2835,7 @@ async function lookupPRByBranchName(args: { headRepo: OwnerRepo | null branchName: string ghOptions: GhExecOptions + executionScope: string }): Promise<{ data: PullRequestLookupData | null dataRepo: OwnerRepo | null @@ -2791,7 +2855,12 @@ async function lookupPRByBranchName(args: { ) : await getFallbackPRListForBranch(candidate, args.branchName, args.ghOptions) // Why: REST/list branch lookup identifies the PR cheaply; exact `gh pr view` carries review, merge-queue, and auto-merge state. - const data = await hydrateBranchLookupWithExactPR(candidate, branchData, args.ghOptions) + const data = await hydrateBranchLookupWithExactPR( + candidate, + branchData, + args.ghOptions, + args.executionScope + ) if (data) { return { data, dataRepo: candidate } } @@ -2810,7 +2879,12 @@ async function lookupPRByBranchName(args: { args.branchName, args.ghOptions ) - const data = await hydrateBranchLookupWithExactPR(candidate, branchData, args.ghOptions) + const data = await hydrateBranchLookupWithExactPR( + candidate, + branchData, + args.ghOptions, + args.executionScope + ) if (data) { return { data, dataRepo: candidate } } @@ -2857,10 +2931,71 @@ async function getRestPRByNumber( return mapRestPullRequest(JSON.parse(stdout) as RestPullRequest) } +function prunePRStackSummaryCache(now = Date.now()): void { + for (const [key, cached] of prStackSummaryCache) { + if (cached.expiresAt <= now) { + prStackSummaryCache.delete(key) + } + } + while (prStackSummaryCache.size > PR_STACK_SUMMARY_CACHE_MAX_ENTRIES) { + const oldestKey = prStackSummaryCache.keys().next().value + if (oldestKey === undefined) { + return + } + prStackSummaryCache.delete(oldestKey) + } +} + +async function getCachedGitHubPRStackSummary( + ownerRepo: GitHubApiRepository, + number: number, + ghOptions: ReturnType, + executionScope: string +): Promise { + const key = `${executionScope}\0${githubRepoIdentityKey(ownerRepo)}#${number}` + const now = Date.now() + prunePRStackSummaryCache(now) + const cached = prStackSummaryCache.get(key) + if (cached && cached.expiresAt > now) { + return cached.value + } + const existing = prStackSummaryInFlight.get(key) + if (existing) { + return existing + } + const request = getRestPRByNumber(ownerRepo, number, ghOptions).then((pr) => pr?.stack) + prStackSummaryInFlight.set(key, request) + try { + const value = await request + prStackSummaryCache.delete(key) + prStackSummaryCache.set(key, { + value, + expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS + }) + prunePRStackSummaryCache() + return value + } catch (err) { + // Why: avoid repeating a failed REST probe on every review poll. + prStackSummaryCache.delete(key) + prStackSummaryCache.set(key, { + value: undefined, + expiresAt: Date.now() + PR_STACK_SUMMARY_CACHE_TTL_MS + }) + prunePRStackSummaryCache() + throw err + } finally { + if (prStackSummaryInFlight.get(key) === request) { + prStackSummaryInFlight.delete(key) + } + } +} + async function getPRByNumber( ownerRepo: GitHubApiRepository, number: number, - ghOptions: ReturnType + ghOptions: ReturnType, + executionScope: string, + knownPullRequestData?: PullRequestLookupData | null ): Promise { try { const { stdout } = await ghExecFileAsync( @@ -2875,10 +3010,16 @@ async function getPRByNumber( ], { ...ghOptions, ...githubHostExecOptions(ownerRepo) } ) + const exactData = JSON.parse(stdout) as PullRequestLookupData return hydratePullRequestLookupData( ownerRepo, - JSON.parse(stdout) as PullRequestLookupData, - ghOptions + { + ...knownPullRequestData, + ...exactData, + ...(knownPullRequestData?.stack ? { stack: knownPullRequestData.stack } : {}) + }, + ghOptions, + executionScope ) } catch (err) { // Why: deleted/edited linked PR metadata falls back to branch discovery; quota/auth/network failures get one cheaper REST exact lookup. @@ -2886,8 +3027,13 @@ async function getPRByNumber( return null } try { - const restData = await getRestPRByNumber(ownerRepo, number, ghOptions) - return restData ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions) : null + const restData = + knownPullRequestData === undefined + ? await getRestPRByNumber(ownerRepo, number, ghOptions) + : knownPullRequestData + return restData + ? hydratePullRequestLookupData(ownerRepo, restData, ghOptions, executionScope) + : null } catch (restErr) { if (isNotFoundGhError(restErr)) { return null @@ -2904,10 +3050,16 @@ async function lookupPRByNumber(args: { candidates: OwnerRepo[] number: number ghOptions: ReturnType + executionScope: string }): Promise<{ data: PullRequestLookupData | null; dataRepo: OwnerRepo | null }> { for (const candidate of args.candidates) { try { - const linkedData = await getPRByNumber(candidate, args.number, args.ghOptions) + const linkedData = await getPRByNumber( + candidate, + args.number, + args.ghOptions, + args.executionScope + ) if (!linkedData) { continue } @@ -3006,6 +3158,7 @@ export async function getPRForBranchOutcome( const localGitOptions = localGitArgs[0] ?? {} const context = githubRepoContext(repoPath, connectionId, localGitOptions) const ghOptions = ghRepoExecOptions(context) + const executionScope = githubPRStackExecutionScope(connectionId, localGitOptions) await acquire() try { @@ -3032,6 +3185,7 @@ export async function getPRForBranchOutcome( let pendingBranchLookupError: unknown let hasPendingBranchLookupError = false let currentHeadOidForMergedImplicit: string | null | undefined + let usedExactNumberLookup = false const explicitCurrentHeadOid = typeof options.currentHeadOid === 'string' && options.currentHeadOid.trim().length > 0 @@ -3104,10 +3258,12 @@ export async function getPRForBranchOutcome( } if (typeof linkedPRNumber === 'number') { + usedExactNumberLookup = true const exactLookup = await lookupPRByNumber({ candidates, number: linkedPRNumber, - ghOptions + ghOptions, + executionScope }) data = exactLookup.data dataRepo = exactLookup.dataRepo @@ -3117,7 +3273,8 @@ export async function getPRForBranchOutcome( candidates, headRepo, branchName, - ghOptions + ghOptions, + executionScope }) data = branchLookup.data dataRepo = branchLookup.dataRepo @@ -3149,7 +3306,8 @@ export async function getPRForBranchOutcome( candidates, headRepo: upstreamHeadRepo, branchName: upstreamBranch.branchName, - ghOptions + ghOptions, + executionScope }) data = upstreamLookup.data dataRepo = upstreamLookup.dataRepo @@ -3172,10 +3330,12 @@ export async function getPRForBranchOutcome( dataHeadRepo = headRepo } if (!data && typeof linkedPRNumber !== 'number' && typeof fallbackPRNumber === 'number') { + usedExactNumberLookup = true const fallbackLookup = await lookupPRByNumber({ candidates, number: fallbackPRNumber, - ghOptions + ghOptions, + executionScope }) data = fallbackLookup.data dataRepo = fallbackLookup.dataRepo @@ -3221,7 +3381,42 @@ export async function getPRForBranchOutcome( return { kind: 'no-pr', fetchedAt: Date.now() } } + if (!data.stackMetadataChecked && dataRepo && usedExactNumberLookup) { + try { + data.stack = await getCachedGitHubPRStackSummary( + dataRepo, + data.number, + ghOptions, + executionScope + ) + data.stackMetadataChecked = true + } catch { + // Stack metadata is additive; exact PR lookup remains usable without it. + } + } const mergeable = derivePullRequestMergeable(data) + const stack = + data.stack && dataRepo + ? await hydrateGitHubPRStack( + dataRepo, + data.number, + data.stack, + ghOptions, + data.updatedAt, + executionScope + ) + : data.stack + const stackMergeQueueRequired = + stack && dataRepo + ? ( + await detectRepositoryMergeMetadata( + dataRepo, + stack.baseRefName, + ghOptions, + executionScope + ) + ).mergeQueueRequired + : undefined const conflictSummary = !connectionId && mergeable === 'CONFLICTING' && @@ -3251,13 +3446,19 @@ export async function getPRForBranchOutcome( ...(data.reviewDecision !== undefined ? { reviewDecision: data.reviewDecision } : {}), ...(data.autoMergeEnabled !== undefined ? { autoMergeEnabled: data.autoMergeEnabled } : {}), ...(data.autoMergeAllowed !== undefined ? { autoMergeAllowed: data.autoMergeAllowed } : {}), - ...(data.mergeQueueRequired !== undefined - ? { mergeQueueRequired: data.mergeQueueRequired } + ...(stackMergeQueueRequired !== undefined || data.mergeQueueRequired !== undefined + ? { + mergeQueueRequired: + stackMergeQueueRequired !== undefined + ? stackMergeQueueRequired + : data.mergeQueueRequired + } : {}), ...(data.mergeMethodSettings !== undefined ? { mergeMethodSettings: data.mergeMethodSettings } : {}), ...(data.mergeStateStatus !== undefined ? { mergeStateStatus: data.mergeStateStatus } : {}), + ...(stack ? { stack } : {}), headSha: data.headRefOid, ...(confirmedContainedHeadOid ? { confirmedContainedHeadOid } : {}), ...(headDivergedFromMergedPRAtOid ? { headDivergedFromMergedPRAtOid } : {}), @@ -4760,7 +4961,35 @@ export async function mergePR( return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' } } await acquire() + let concurrencySlotHeld = true try { + let stackSummary: GitHubPRStack | undefined + let stackHeadSha: string | undefined + try { + const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions) + stackSummary = restData?.stack + stackHeadSha = restData?.headRefOid + } catch { + // GitHub remains authoritative when stack metadata cannot be read. + } + if (stackSummary) { + const mergeMetadata = await detectRepositoryMergeMetadata( + ownerRepo, + stackSummary.baseRefName, + ghOptions, + githubPRStackExecutionScope(connectionId, localGitOptions) + ) + release() + concurrencySlotHeld = false + return await mergeGitHubPRStack({ + repository: ownerRepo, + prNumber, + method, + mergeAction: mergeMetadata.mergeQueueRequired === true ? 'merge_queue' : 'direct_merge', + headSha: stackHeadSha, + ghOptions + }) + } const mergeBlocker = await getPRMergeBlocker( repoPath, prNumber, @@ -4788,7 +5017,9 @@ export async function mergePR( err instanceof Error ? err.message : typeof err === 'string' ? err : 'Unknown error' return { ok: false, error: message } } finally { - release() + if (concurrencySlotHeld) { + release() + } } } @@ -4899,11 +5130,25 @@ async function enablePRAutoMerge( ownerRepo: GitHubApiRepository | null, ghOptions: GhExecOptions ): Promise<{ ok: true } | { ok: false; error: string }> { + if (ownerRepo) { + try { + const restData = await getRestPRByNumber(ownerRepo, prNumber, ghOptions) + if (restData?.stack) { + return { + ok: false, + error: 'GitHub does not support auto-merge for stacked pull requests.' + } + } + } catch { + // GitHub remains authoritative when stack metadata cannot be read. + } + } 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)) { + const useMergeQueue = await shouldUseMergeQueueAutoMerge(pr, ownerRepo, ghOptions) + if (useMergeQueue) { await runPRAutoMergeCommand(prNumber, method, ownerRepo, ghOptions) return { ok: true } } @@ -4950,7 +5195,12 @@ async function getPRMergeBlocker( } try { - const pr = await getPRByNumber(ownerRepo, prNumber, ghOptions) + const pr = await getPRByNumber( + ownerRepo, + prNumber, + ghOptions, + githubPRStackExecutionScope(connectionId, localGitOptions) + ) if (!pr) { return null } diff --git a/src/main/github/github-pr-stack-async-merge.ts b/src/main/github/github-pr-stack-async-merge.ts new file mode 100644 index 00000000000..1b3909af035 --- /dev/null +++ b/src/main/github/github-pr-stack-async-merge.ts @@ -0,0 +1,145 @@ +import type { GitHubPRMergeMethod } from '../../shared/types' +import { ghExecFileAsync } from '../git/runner' +import { acquire, release } from './gh-utils' +import { + githubHostExecOptions, + type GitHubApiRepository, + type GitHubRepoExecOptions +} from './github-api-repository' + +const POLL_INTERVAL_MS = 1_000 +const MAX_POLLS = 180 + +type AsyncMergeResponse = { + status?: unknown + details?: { + message?: unknown + uuid?: unknown + } +} + +type AsyncMergeResult = + | { kind: 'pending'; uuid: string } + | { kind: 'success' } + | { kind: 'failure'; message: string } + +export type GitHubPRStackMergeAction = 'direct_merge' | 'merge_queue' + +function parseResponse(value: string): AsyncMergeResponse | null { + try { + return JSON.parse(value) as AsyncMergeResponse + } catch { + return null + } +} + +function errorResponseBody(error: unknown): AsyncMergeResponse | null { + if (!error || typeof error !== 'object' || !('stdout' in error)) { + return null + } + const stdout = (error as { stdout?: unknown }).stdout + return parseResponse(Buffer.isBuffer(stdout) ? stdout.toString('utf8') : String(stdout)) +} + +function classifyResult(response: AsyncMergeResponse): AsyncMergeResult { + if (response.status === 'merged' || response.status === 'enqueued') { + return { kind: 'success' } + } + if (response.status === 'pending' && typeof response.details?.uuid === 'string') { + return { kind: 'pending', uuid: response.details.uuid } + } + const message = + typeof response.details?.message === 'string' + ? response.details.message + : 'GitHub could not merge this stack.' + return { kind: 'failure', message } +} + +function waitForNextPoll(): Promise { + return new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) +} + +async function runStackMergeCommand( + command: string[], + options: NonNullable[1]> +) { + await acquire() + try { + return await ghExecFileAsync(command, options) + } finally { + release() + } +} + +export async function mergeGitHubPRStack(args: { + repository: GitHubApiRepository + prNumber: number + method: GitHubPRMergeMethod + mergeAction: GitHubPRStackMergeAction + headSha?: string + ghOptions: GitHubRepoExecOptions +}): Promise<{ ok: true } | { ok: false; error: string }> { + const endpoint = `repos/${args.repository.owner}/${args.repository.repo}/pulls/${args.prNumber}/merge-async` + const command = ['api', '-X', 'PUT', endpoint, '-f', `merge_action=${args.mergeAction}`] + if (args.mergeAction === 'direct_merge') { + command.push('-f', `merge_method=${args.method}`) + } + if (args.headSha) { + command.push('-f', `sha=${args.headSha}`) + } + + let submitted: AsyncMergeResponse | null + try { + const { stdout } = await runStackMergeCommand(command, { + ...args.ghOptions, + ...githubHostExecOptions(args.repository), + env: { ...process.env, GH_PROMPT_DISABLED: '1' } + }) + submitted = parseResponse(stdout) + } catch (error) { + submitted = errorResponseBody(error) + if (!submitted) { + throw error + } + } + if (!submitted) { + return { ok: false, error: 'GitHub returned an invalid stack merge response.' } + } + let result = classifyResult(submitted) + if (result.kind === 'success') { + return { ok: true } + } + if (result.kind === 'failure') { + return { ok: false, error: result.message } + } + + const uuid = encodeURIComponent(result.uuid) + for (let poll = 0; poll < MAX_POLLS; poll++) { + await waitForNextPoll() + let response: AsyncMergeResponse | null + try { + const { stdout } = await runStackMergeCommand(['api', `${endpoint}/${uuid}`], { + ...args.ghOptions, + ...githubHostExecOptions(args.repository) + }) + response = parseResponse(stdout) + } catch (error) { + // Why: the merge was submitted; a transient poll failure cannot make it fail. + response = errorResponseBody(error) + if (!response) { + continue + } + } + if (!response) { + return { ok: false, error: 'GitHub returned an invalid stack merge response.' } + } + result = classifyResult(response) + if (result.kind === 'success') { + return { ok: true } + } + if (result.kind === 'failure') { + return { ok: false, error: result.message } + } + } + return { ok: false, error: 'GitHub is still merging this stack. Refresh to check its status.' } +} diff --git a/src/main/github/github-pr-stack.test.ts b/src/main/github/github-pr-stack.test.ts new file mode 100644 index 00000000000..17cd6475941 --- /dev/null +++ b/src/main/github/github-pr-stack.test.ts @@ -0,0 +1,331 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitHubPRStack } from '../../shared/types' + +const { ghExecFileAsyncMock, rateLimitGuardMock, noteRateLimitSpendMock } = vi.hoisted(() => ({ + ghExecFileAsyncMock: vi.fn(), + rateLimitGuardMock: vi.fn(() => ({ blocked: false })), + noteRateLimitSpendMock: vi.fn() +})) + +vi.mock('../git/runner', () => ({ ghExecFileAsync: ghExecFileAsyncMock })) +vi.mock('./rate-limit', () => ({ + repositoryRateLimitGuard: rateLimitGuardMock, + noteRepositoryRateLimitSpend: noteRateLimitSpendMock +})) + +import { + _resetGitHubPRStackCacheForTests, + hydrateGitHubPRStack, + mergeGitHubPRStack +} from './github-pr-stack' + +const repository = { owner: 'stablyai', repo: 'orca', host: 'github.com' } +const summary: GitHubPRStack = { + number: 51, + position: 2, + size: 3, + baseRefName: 'main', + baseSha: 'base-sha' +} + +beforeEach(() => { + vi.useRealTimers() + ghExecFileAsyncMock.mockReset() + rateLimitGuardMock.mockReset() + rateLimitGuardMock.mockReturnValue({ blocked: false }) + noteRateLimitSpendMock.mockReset() + _resetGitHubPRStackCacheForTests() +}) + +describe('hydrateGitHubPRStack', () => { + it('maps and caches every entry in one GraphQL request', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { + number: 51, + size: 3, + baseRefName: 'main', + entries: { + nodes: [ + { + position: 2, + pullRequest: { + number: 202, + title: 'API', + url: 'https://github.com/stablyai/orca/pull/202', + updatedAt: '2026-08-10T00:00:00Z', + state: 'OPEN', + isDraft: false, + headRefName: 'stack/api', + headRefOid: 'api-sha', + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED', + mergeStateStatus: 'CLEAN', + statusCheckRollup: { state: 'SUCCESS' } + } + }, + { + position: 1, + pullRequest: { + number: 201, + title: 'Models', + url: 'https://github.com/stablyai/orca/pull/201', + updatedAt: '2026-08-10T00:00:00Z', + state: 'OPEN', + isDraft: false, + mergeable: 'UNKNOWN', + statusCheckRollup: { state: 'PENDING' } + } + } + ] + } + } + } + } + } + }) + }) + + const first = await hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' }) + const sibling = await hydrateGitHubPRStack( + repository, + 201, + { ...summary, position: 1 }, + { cwd: '/other-worktree' } + ) + + expect(first).toMatchObject({ + number: 51, + position: 2, + baseSha: 'base-sha', + entries: [ + { number: 201, position: 1, checksStatus: 'pending' }, + { + number: 202, + position: 2, + checksStatus: 'success', + reviewDecision: 'APPROVED' + } + ] + }) + expect(sibling.position).toBe(1) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) + expect(noteRateLimitSpendMock).toHaveBeenCalledWith( + repository, + 'graphql', + 1, + expect.any(Object) + ) + }) + + it('refreshes cached stack details when the current PR changed', async () => { + const response = (updatedAt: string, isDraft: boolean) => ({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { + number: 51, + size: 3, + baseRefName: 'main', + entries: { + nodes: [ + { + position: 2, + pullRequest: { + number: 202, + title: 'API', + url: 'https://github.com/stablyai/orca/pull/202', + updatedAt, + state: 'OPEN', + isDraft, + mergeable: 'MERGEABLE' + } + } + ] + } + } + } + } + } + }) + }) + ghExecFileAsyncMock + .mockResolvedValueOnce(response('2026-08-10T00:00:00Z', true)) + .mockResolvedValueOnce(response('2026-08-10T00:01:00Z', false)) + + const draft = await hydrateGitHubPRStack( + repository, + 202, + summary, + { cwd: '/repo' }, + '2026-08-10T00:00:00Z' + ) + const ready = await hydrateGitHubPRStack( + repository, + 202, + summary, + { cwd: '/repo' }, + '2026-08-10T00:01:00Z' + ) + + expect(draft.entries?.[0]?.state).toBe('draft') + expect(ready.entries?.[0]?.state).toBe('open') + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) + + it('keeps the REST summary when GraphQL is unavailable', async () => { + ghExecFileAsyncMock.mockRejectedValue(new Error('field is unavailable')) + + await expect(hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' })).resolves.toEqual( + summary + ) + }) + + it('isolates cached details across execution scopes', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + data: { + repository: { + pullRequest: { + stack: { number: 51, size: 3, baseRefName: 'main', entries: { nodes: [] } } + } + } + } + }) + }) + + await hydrateGitHubPRStack(repository, 202, summary, { cwd: '/repo' }, undefined, 'local:host') + await hydrateGitHubPRStack(repository, 202, summary, {}, undefined, 'ssh:ssh-1') + + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) + }) +}) + +describe('mergeGitHubPRStack', () => { + it('submits the expected head and polls pending merges to completion', async () => { + vi.useFakeTimers() + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } }) + }) + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'merged', details: { message: 'Merged' } }) + }) + + const result = mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'direct_merge', + headSha: 'api-sha', + ghOptions: { cwd: '/repo' } + }) + await vi.advanceTimersByTimeAsync(1_000) + + await expect(result).resolves.toEqual({ ok: true }) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 1, + expect.arrayContaining([ + 'PUT', + 'repos/stablyai/orca/pulls/202/merge-async', + 'merge_method=squash', + 'merge_action=direct_merge', + 'sha=api-sha' + ]), + expect.objectContaining({ cwd: '/repo', host: 'github.com' }) + ) + expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( + 2, + ['api', 'repos/stablyai/orca/pulls/202/merge-async/merge-uuid'], + expect.objectContaining({ cwd: '/repo', host: 'github.com' }) + ) + }) + + it('returns GitHub atomic failure details', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ + status: 'failed', + details: { message: 'A pull request has merge conflicts.' } + }) + }) + + await expect( + mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'rebase', + mergeAction: 'direct_merge', + ghOptions: { cwd: '/repo' } + }) + ).resolves.toEqual({ ok: false, error: 'A pull request has merge conflicts.' }) + }) + + it('omits the unsupported merge method when queueing a stack', async () => { + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ status: 'enqueued', details: { message: 'Queued' } }) + }) + + await expect( + mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'merge_queue', + ghOptions: { cwd: '/repo' } + }) + ).resolves.toEqual({ ok: true }) + + const command = ghExecFileAsyncMock.mock.calls[0]?.[0] + expect(command).toContain('merge_action=merge_queue') + expect(command).not.toContain('merge_method=squash') + }) + + it('continues polling after a transient transport failure', async () => { + vi.useFakeTimers() + ghExecFileAsyncMock + .mockResolvedValueOnce({ + stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } }) + }) + .mockRejectedValueOnce(new Error('temporary gateway failure')) + .mockResolvedValueOnce({ stdout: JSON.stringify({ status: 'merged' }) }) + + const result = mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'direct_merge', + ghOptions: { cwd: '/repo' } + }) + await vi.advanceTimersByTimeAsync(1_000) + await vi.advanceTimersByTimeAsync(1_000) + + await expect(result).resolves.toEqual({ ok: true }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) + }) + + it('reports an in-progress merge after polling is exhausted', async () => { + vi.useFakeTimers() + ghExecFileAsyncMock.mockResolvedValue({ + stdout: JSON.stringify({ status: 'pending', details: { uuid: 'merge-uuid' } }) + }) + + const result = mergeGitHubPRStack({ + repository, + prNumber: 202, + method: 'squash', + mergeAction: 'direct_merge', + ghOptions: { cwd: '/repo' } + }) + await vi.advanceTimersByTimeAsync(180_000) + + await expect(result).resolves.toEqual({ + ok: false, + error: 'GitHub is still merging this stack. Refresh to check its status.' + }) + expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(181) + }) +}) diff --git a/src/main/github/github-pr-stack.ts b/src/main/github/github-pr-stack.ts new file mode 100644 index 00000000000..24d0773a91c --- /dev/null +++ b/src/main/github/github-pr-stack.ts @@ -0,0 +1,282 @@ +import type { + CheckStatus, + GitHubPRStack, + GitHubPRStackEntry, + PRMergeableState, + PRReviewDecision, + PRState +} from '../../shared/types' +import { githubRepoIdentityKey } from '../../shared/github-repository-identity-key' +import { ghExecFileAsync } from '../git/runner' +import { + githubHostExecOptions, + type GitHubApiRepository, + type GitHubRepoExecOptions +} from './github-api-repository' +import { noteRepositoryRateLimitSpend, repositoryRateLimitGuard } from './rate-limit' + +const STACK_CACHE_TTL_MS = 30_000 +const STACK_CACHE_MAX_ENTRIES = 256 + +type CachedStackDetails = { + value: Omit | null + expiresAt: number +} + +type GraphQLStackEntry = { + position?: unknown + pullRequest?: { + number?: unknown + title?: unknown + url?: unknown + updatedAt?: unknown + state?: unknown + isDraft?: unknown + headRefName?: unknown + headRefOid?: unknown + mergeable?: unknown + reviewDecision?: unknown + mergeStateStatus?: unknown + statusCheckRollup?: { state?: unknown } | null + } | null +} + +type GraphQLStackResponse = { + data?: { + repository?: { + pullRequest?: { + stack?: { + number?: unknown + size?: unknown + baseRefName?: unknown + entries?: { nodes?: (GraphQLStackEntry | null)[] | null } | null + } | null + } | null + } | null + } +} + +const stackDetailsCache = new Map() +const stackDetailsInFlight = new Map | null>>() + +export function _resetGitHubPRStackCacheForTests(): void { + stackDetailsCache.clear() + stackDetailsInFlight.clear() +} + +function stackCacheKey( + repository: GitHubApiRepository, + stackNumber: number, + executionScope: string +): string { + return `${executionScope}\0${githubRepoIdentityKey(repository)}:${stackNumber}` +} + +function pruneStackCache(now = Date.now()): void { + for (const [key, cached] of stackDetailsCache) { + if (cached.expiresAt <= now) { + stackDetailsCache.delete(key) + } + } + while (stackDetailsCache.size > STACK_CACHE_MAX_ENTRIES) { + const oldestKey = stackDetailsCache.keys().next().value + if (oldestKey === undefined) { + return + } + stackDetailsCache.delete(oldestKey) + } +} + +function mapStackPRState(value: unknown, isDraft: unknown): PRState { + if (value === 'MERGED') { + return 'merged' + } + if (value === 'CLOSED') { + return 'closed' + } + return isDraft === true ? 'draft' : 'open' +} + +function mapStackCheckStatus(value: unknown): CheckStatus { + if (value === 'SUCCESS') { + return 'success' + } + if (value === 'FAILURE' || value === 'ERROR') { + return 'failure' + } + if (value === 'PENDING' || value === 'EXPECTED') { + return 'pending' + } + return 'neutral' +} + +function mapStackMergeable(value: unknown): PRMergeableState { + return value === 'MERGEABLE' || value === 'CONFLICTING' ? value : 'UNKNOWN' +} + +function mapReviewDecision(value: unknown): PRReviewDecision | null | undefined { + if (value === null) { + return null + } + if (value === 'APPROVED' || value === 'CHANGES_REQUESTED' || value === 'REVIEW_REQUIRED') { + return value + } + return undefined +} + +function mapStackEntry(entry: GraphQLStackEntry): GitHubPRStackEntry | null { + const pr = entry.pullRequest + if ( + typeof entry.position !== 'number' || + typeof pr?.number !== 'number' || + typeof pr.title !== 'string' || + typeof pr.url !== 'string' + ) { + return null + } + const reviewDecision = mapReviewDecision(pr.reviewDecision) + return { + position: entry.position, + number: pr.number, + title: pr.title, + url: pr.url, + ...(typeof pr.updatedAt === 'string' ? { updatedAt: pr.updatedAt } : {}), + state: mapStackPRState(pr.state, pr.isDraft), + checksStatus: mapStackCheckStatus(pr.statusCheckRollup?.state), + mergeable: mapStackMergeable(pr.mergeable), + ...(reviewDecision !== undefined ? { reviewDecision } : {}), + ...(typeof pr.mergeStateStatus === 'string' ? { mergeStateStatus: pr.mergeStateStatus } : {}), + ...(typeof pr.headRefName === 'string' ? { headRefName: pr.headRefName } : {}), + ...(typeof pr.headRefOid === 'string' ? { headSha: pr.headRefOid } : {}) + } +} + +function parseStackDetails( + response: GraphQLStackResponse, + expectedStackNumber: number, + fallbackBaseRefName: string, + fallbackSize: number +): Omit | null { + const stack = response.data?.repository?.pullRequest?.stack + if (!stack || stack.number !== expectedStackNumber) { + return null + } + const entries = (stack.entries?.nodes ?? []) + .flatMap((entry) => (entry ? [mapStackEntry(entry)] : [])) + .filter((entry): entry is GitHubPRStackEntry => entry !== null) + .sort((a, b) => a.position - b.position) + return { + number: expectedStackNumber, + size: typeof stack.size === 'number' ? stack.size : fallbackSize, + baseRefName: typeof stack.baseRefName === 'string' ? stack.baseRefName : fallbackBaseRefName, + ...(entries.length > 0 ? { entries } : {}) + } +} + +const STACK_DETAILS_QUERY = ` +query($owner: String!, $repo: String!, $pr: Int!) { + repository(owner: $owner, name: $repo) { + pullRequest(number: $pr) { + stack { + number + size + baseRefName + entries(first: 100) { + nodes { + position + pullRequest { + number + title + url + updatedAt + state + isDraft + headRefName + headRefOid + mergeable + reviewDecision + mergeStateStatus + statusCheckRollup { state } + } + } + } + } + } + } +}` + +async function fetchStackDetails( + repository: GitHubApiRepository, + prNumber: number, + summary: GitHubPRStack, + ghOptions: GitHubRepoExecOptions +): Promise | null> { + if (repositoryRateLimitGuard(repository, 'graphql', ghOptions).blocked) { + return null + } + noteRepositoryRateLimitSpend(repository, 'graphql', 1, ghOptions) + const { stdout } = await ghExecFileAsync( + [ + 'api', + 'graphql', + '-f', + `query=${STACK_DETAILS_QUERY}`, + '-f', + `owner=${repository.owner}`, + '-f', + `repo=${repository.repo}`, + '-F', + `pr=${prNumber}` + ], + { ...ghOptions, ...githubHostExecOptions(repository) } + ) + return parseStackDetails( + JSON.parse(stdout) as GraphQLStackResponse, + summary.number, + summary.baseRefName, + summary.size + ) +} + +export async function hydrateGitHubPRStack( + repository: GitHubApiRepository, + prNumber: number, + summary: GitHubPRStack, + ghOptions: GitHubRepoExecOptions, + prUpdatedAt?: string, + executionScope = 'local:host' +): Promise { + const key = stackCacheKey(repository, summary.number, executionScope) + const now = Date.now() + pruneStackCache(now) + const cached = stackDetailsCache.get(key) + const cachedPRUpdatedAt = cached?.value?.entries?.find( + (entry) => entry.number === prNumber + )?.updatedAt + const cachedMatchesPR = !prUpdatedAt || !cachedPRUpdatedAt || cachedPRUpdatedAt === prUpdatedAt + if (cached && cached.expiresAt > now && cachedMatchesPR) { + return cached.value + ? { ...cached.value, position: summary.position, baseSha: summary.baseSha } + : summary + } + const existing = stackDetailsInFlight.get(key) + if (existing) { + const value = await existing + return value ? { ...value, position: summary.position, baseSha: summary.baseSha } : summary + } + const request = fetchStackDetails(repository, prNumber, summary, ghOptions).catch(() => null) + stackDetailsInFlight.set(key, request) + try { + const value = await request + stackDetailsCache.delete(key) + stackDetailsCache.set(key, { value, expiresAt: Date.now() + STACK_CACHE_TTL_MS }) + pruneStackCache() + return value ? { ...value, position: summary.position, baseSha: summary.baseSha } : summary + } finally { + if (stackDetailsInFlight.get(key) === request) { + stackDetailsInFlight.delete(key) + } + } +} + +export { mergeGitHubPRStack } from './github-pr-stack-async-merge' diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index c7351f973ec..3a6bceee5f1 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -42,6 +42,7 @@ import { import { isFolderRepo } from '../../../../shared/repo-kind' import { githubProjectHost } from '../../../../shared/github-project-identity' import HostedReviewActions from './HostedReviewActions' +import { GitHubPRStackMap, type GitHubPRStackMapNavigationModifiers } from './GitHubPRStackMap' import { PullRequestIcon, prStateColor, @@ -3734,6 +3735,18 @@ export default function ChecksPanel(): React.JSX.Element { [activeReview, activeWorktreeId] ) + const handleOpenStackPR = useCallback( + (url: string, modifiers: GitHubPRStackMapNavigationModifiers) => { + openChecksPanelHostedReviewUrl({ + url, + event: modifiers, + isMac: isMacPlatform(), + worktreeId: activeWorktreeId + }) + }, + [activeWorktreeId] + ) + const handleUnlinkPullRequest = useCallback(() => { if (!activeWorktreeId || activeReview?.provider !== 'github' || linkedPR === null) { return @@ -4437,6 +4450,14 @@ export default function ChecksPanel(): React.JSX.Element { {detachedHeadDisplay && } + {activeReview.provider === 'github' && pr?.stack ? ( + + ) : null} + {/* Review title */} {editingTitle ? (
diff --git a/src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx b/src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx new file mode 100644 index 00000000000..e29d0b74361 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/GitHubPRStackMap.test.tsx @@ -0,0 +1,113 @@ +// @vitest-environment happy-dom + +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { GitHubPRStack } from '../../../../shared/types' +import { GitHubPRStackMap } from './GitHubPRStackMap' + +const stack: GitHubPRStack = { + number: 51, + position: 2, + size: 3, + baseRefName: 'main', + entries: [ + { + position: 1, + number: 201, + title: 'Models', + url: 'https://github.com/acme/repo/pull/201', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE', + reviewDecision: 'APPROVED' + }, + { + position: 2, + number: 202, + title: 'API', + url: 'https://github.com/acme/repo/pull/202', + state: 'open', + checksStatus: 'failure', + mergeable: 'MERGEABLE' + }, + { + position: 3, + number: 203, + title: 'UI', + url: 'https://github.com/acme/repo/pull/203', + state: 'draft', + checksStatus: 'neutral', + mergeable: 'UNKNOWN' + } + ] +} + +let container: HTMLDivElement +let root: Root + +describe('GitHubPRStackMap', () => { + beforeEach(() => { + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + }) + + afterEach(() => { + act(() => root.unmount()) + container.remove() + }) + + it('shows compact identity then expands top-to-bottom with the current PR highlighted', () => { + act(() => { + root.render( + + ) + }) + + expect(container.textContent).toContain('Stack #51') + expect(container.textContent).toContain('2 of 3 · main') + expect(container.querySelectorAll('button[data-stack-pr-number]')).toHaveLength(0) + + const trigger = container.querySelector( + 'button[aria-label="Expand stack #51"]' + ) + act(() => trigger?.click()) + + const rows = [...container.querySelectorAll('button[data-stack-pr-number]')] + expect(rows.map((row) => row.textContent)).toEqual([ + expect.stringContaining('#203'), + expect.stringContaining('#202'), + expect.stringContaining('#201') + ]) + expect(rows[1]?.dataset.current).toBe('true') + expect(container.textContent).toContain('checks failed') + expect(container.textContent).toContain('main') + }) + + it('opens another PR without changing local branches', () => { + const onOpenPullRequest = vi.fn() + act(() => { + root.render( + + ) + }) + act(() => + container.querySelector('button[aria-label="Expand stack #51"]')?.click() + ) + act(() => + container.querySelector('button[data-stack-pr-number="203"]')?.click() + ) + + expect(onOpenPullRequest).toHaveBeenCalledTimes(1) + expect(onOpenPullRequest).toHaveBeenCalledWith('https://github.com/acme/repo/pull/203', { + metaKey: false, + ctrlKey: false, + shiftKey: false + }) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/GitHubPRStackMap.tsx b/src/renderer/src/components/right-sidebar/GitHubPRStackMap.tsx new file mode 100644 index 00000000000..e0187b37709 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/GitHubPRStackMap.tsx @@ -0,0 +1,156 @@ +import { useMemo, useState } from 'react' +import { ChevronDown, GitBranch, GitPullRequest } from 'lucide-react' +import type { GitHubPRStack, GitHubPRStackEntry } from '../../../../shared/types' +import { cn } from '@/lib/utils' +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { translate } from '@/i18n/i18n' + +export type GitHubPRStackMapNavigationModifiers = Pick< + React.MouseEvent, + 'metaKey' | 'ctrlKey' | 'shiftKey' +> + +function stackEntryStatus(entry: GitHubPRStackEntry): string { + if (entry.state === 'merged') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.8a9bdc36c0', 'merged') + } + if (entry.state === 'closed') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.3511405914', 'closed') + } + if (entry.state === 'draft') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.568c647ccd', 'draft') + } + if (entry.mergeable === 'CONFLICTING') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.bea9ade223', 'conflicts') + } + if (entry.checksStatus === 'failure') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.838aadf512', 'checks failed') + } + if (entry.checksStatus === 'pending') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.316039b5db', 'checks pending') + } + if (entry.reviewDecision === 'CHANGES_REQUESTED') { + return translate( + 'auto.components.right.sidebar.GitHubPRStackMap.4b1e5ee9d3', + 'changes requested' + ) + } + if (entry.reviewDecision === 'REVIEW_REQUIRED') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.9a17b5255c', 'review needed') + } + if (entry.reviewDecision === 'APPROVED') { + return translate('auto.components.right.sidebar.GitHubPRStackMap.d3d97cf3f2', 'approved') + } + return translate('auto.components.right.sidebar.GitHubPRStackMap.e6cb964305', 'open') +} + +export function GitHubPRStackMap({ + stack, + currentPRNumber, + onOpenPullRequest +}: { + stack: GitHubPRStack + currentPRNumber: number + onOpenPullRequest: (url: string, modifiers: GitHubPRStackMapNavigationModifiers) => void +}): React.JSX.Element { + const [open, setOpen] = useState(false) + const entries = useMemo( + () => [...(stack.entries ?? [])].sort((a, b) => b.position - a.position), + [stack.entries] + ) + + return ( + + + + + + {entries.length > 0 ? ( +
+ {entries.map((entry) => { + const current = entry.number === currentPRNumber + return ( + + ) + })} +
+ + {stack.baseRefName} +
+
+ ) : ( +
+ {translate( + 'auto.components.right.sidebar.GitHubPRStackMap.525259fa17', + 'Stack details are temporarily unavailable.' + )} +
+ )} +
+
+ ) +} diff --git a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx index 3c625e2003c..b8adeb40c4f 100644 --- a/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx +++ b/src/renderer/src/components/right-sidebar/HostedReviewActions.tsx @@ -28,6 +28,11 @@ import { RIGHT_SIDEBAR_SPLIT_ACTION_ROW_CLASS } from './right-sidebar-primary-action-layout' import { translate } from '@/i18n/i18n' +import { + getGitHubPRStackMergeBlocker, + getGitHubPRStackMergeScope, + isGitHubPRStackMergeQueueRequired +} from './github-pr-stack-merge' export default function HostedReviewActions({ review, @@ -48,11 +53,33 @@ export default function HostedReviewActions({ const isGitLab = review.provider === 'gitlab' const shortLabel = isGitLab ? 'MR' : 'PR' const reviewLabel = isGitLab ? 'merge request' : 'pull request' + const stackMergeScope = useMemo( + () => (githubPR?.stack ? getGitHubPRStackMergeScope(githubPR.stack, review.number) : null), + [githubPR?.stack, review.number] + ) + const stackUsesMergeQueue = isGitHubPRStackMergeQueueRequired( + review.mergeQueueRequired, + githubPR?.mergeQueueRequired + ) + const stackMergeLabel = + stackMergeScope && stackUsesMergeQueue + ? stackMergeScope.count === 1 + ? translate( + 'auto.components.right.sidebar.HostedReviewActions.9a41a687b7', + 'Queue through #{{pr}} · {{count}} PR', + { pr: review.number, count: stackMergeScope.count } + ) + : translate( + 'auto.components.right.sidebar.HostedReviewActions.38a1bccb14', + 'Queue through #{{pr}} · {{count}} PRs', + { pr: review.number, count: stackMergeScope.count } + ) + : stackMergeScope?.label const mergePresentation = useMemo(() => { if (isGitLab) { return { ...presentGitLabMRMergeState(review), autoMergeAction: null } } - return presentGitHubPRMergeState({ + const presentation = presentGitHubPRMergeState({ ...githubPR, state: review.state, mergeable: review.mergeable, @@ -63,7 +90,30 @@ export default function HostedReviewActions({ autoMergeAllowed: review.autoMergeAllowed, mergeQueueRequired: review.mergeQueueRequired }) - }, [githubPR, isGitLab, review]) + if (!githubPR?.stack || !stackMergeScope) { + return presentation + } + const stackBlocker = getGitHubPRStackMergeBlocker(stackMergeScope) + return { + ...presentation, + label: stackMergeLabel ?? stackMergeScope.label, + tooltip: + stackBlocker ?? + (stackUsesMergeQueue + ? translate( + 'auto.components.right.sidebar.HostedReviewActions.3de88351c5', + 'GitHub will add this pull request and every pull request below it to the merge queue.' + ) + : translate( + 'auto.components.right.sidebar.HostedReviewActions.a32fe6dba6', + 'GitHub will merge this pull request and every pull request below it in the stack.' + )), + directMergeAvailable: + !stackBlocker && + (stackMergeScope.complete || presentation.directMergeAvailable || stackUsesMergeQueue), + autoMergeAction: null + } + }, [githubPR, isGitLab, review, stackMergeLabel, stackMergeScope, stackUsesMergeQueue]) const mergeMethods = useMemo( () => resolveGitHubPRMergeMethods(isGitLab ? null : (githubPR?.mergeMethodSettings ?? null)), [githubPR?.mergeMethodSettings, isGitLab] @@ -140,12 +190,22 @@ export default function HostedReviewActions({ )} {merging - ? translate( - 'auto.components.right.sidebar.HostedReviewActions.d2ca293f3d', - 'Working...' - ) + ? stackMergeScope + ? stackUsesMergeQueue + ? translate( + 'auto.components.right.sidebar.HostedReviewActions.73e0e1819d', + 'Queueing stack...' + ) + : translate( + 'auto.components.right.sidebar.HostedReviewActions.e555a41d32', + 'Merging stack...' + ) + : translate( + 'auto.components.right.sidebar.HostedReviewActions.d2ca293f3d', + 'Working...' + ) : mergePresentation.directMergeAvailable - ? mergeMethods.defaultLabel + ? (stackMergeLabel ?? mergeMethods.defaultLabel) : (mergePresentation.autoMergeAction?.label ?? mergePresentation.label)} @@ -198,16 +258,17 @@ export default function HostedReviewActions({ )} - {mergeMethods.methods.map(({ method, label }) => ( - void handleMerge(method)} - > - - {label} - - ))} + {(!stackMergeScope || !stackUsesMergeQueue) && + mergeMethods.methods.map(({ method, label }) => ( + void handleMerge(method)} + > + + {label} + + ))} `#${entry.number}`).join(', ') + const included = + scope.complete && numbers + ? translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.84f6f5b9eb', + 'Included: {{numbers}}. ', + { numbers } + ) + : '' + + if (usesMergeQueue) { + return { + title: translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.541984b2eb', + 'Queue through #{{pr}}?', + { pr: currentPRNumber } + ), + description: + scope.count === 1 + ? translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.4809f55cdb', + '{{included}}GitHub will add {{count}} pull request to the merge queue together. The queue chooses the merge method and may merge them in separate groups.', + { included, count: scope.count } + ) + : translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.be8f2621be', + '{{included}}GitHub will add {{count}} pull requests to the merge queue together. The queue chooses the merge method and may merge them in separate groups.', + { included, count: scope.count } + ), + confirmLabel: + scope.count === 1 + ? translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.92ca033e72', + 'Queue {{count}} PR', + { count: scope.count } + ) + : translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.478a527b15', + 'Queue {{count}} PRs', + { count: scope.count } + ) + } + } + + return { + title: translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.1feef35ca4', + 'Merge through #{{pr}}?', + { pr: currentPRNumber } + ), + description: + scope.count === 1 + ? translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.c3e036c99f', + '{{included}}GitHub will merge {{count}} pull request atomically using {{method}}. If it cannot merge, nothing will be merged.', + { included, count: scope.count, method } + ) + : translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.369aba4b32', + '{{included}}GitHub will merge {{count}} pull requests atomically using {{method}}. If any cannot merge, none will.', + { included, count: scope.count, method } + ), + confirmLabel: + scope.count === 1 + ? translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.493c78f521', + 'Merge {{count}} PR', + { count: scope.count } + ) + : translate( + 'auto.components.right.sidebar.github.pr.stack.confirmation.eb7051d268', + 'Merge {{count}} PRs', + { count: scope.count } + ) + } +} diff --git a/src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts b/src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts new file mode 100644 index 00000000000..be889a5eb74 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/github-pr-stack-merge.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest' +import type { GitHubPRStack } from '../../../../shared/types' +import { + getGitHubPRStackMergeBlocker, + getGitHubPRStackMergeScope, + isGitHubPRStackMergeQueueRequired +} from './github-pr-stack-merge' + +function makeStack(): GitHubPRStack { + return { + number: 51, + position: 2, + size: 3, + baseRefName: 'main', + entries: [ + { + position: 1, + number: 201, + title: 'Models', + url: 'https://example.test/201', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE' + }, + { + position: 2, + number: 202, + title: 'API', + url: 'https://example.test/202', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE' + }, + { + position: 3, + number: 203, + title: 'UI', + url: 'https://example.test/203', + state: 'draft', + checksStatus: 'neutral', + mergeable: 'UNKNOWN' + } + ] + } +} + +describe('GitHub stack merge scope', () => { + it('uses stack metadata when review metadata has not observed the merge queue', () => { + expect(isGitHubPRStackMergeQueueRequired(false, true)).toBe(true) + }) + + it('includes the current PR and downstack entries, never upstack entries', () => { + const scope = getGitHubPRStackMergeScope(makeStack(), 202) + + expect(scope.entries.map((entry) => entry.number)).toEqual([201, 202]) + expect(scope.complete).toBe(true) + expect(scope.label).toBe('Merge through #202 · 2 PRs') + expect(getGitHubPRStackMergeBlocker(scope)).toBeNull() + }) + + it('keeps the GitHub-reported merge count when entry details are incomplete', () => { + const stack = makeStack() + stack.entries = stack.entries?.filter((entry) => entry.position !== 1) + + const scope = getGitHubPRStackMergeScope(stack, 202) + + expect(scope.entries.map((entry) => entry.number)).toEqual([202]) + expect(scope.count).toBe(2) + expect(scope.complete).toBe(false) + expect(scope.label).toBe('Merge through #202 · 2 PRs') + }) + + it('surfaces the first downstack blocker', () => { + const stack = makeStack() + stack.entries![0] = { ...stack.entries![0]!, mergeable: 'CONFLICTING' } + + expect(getGitHubPRStackMergeBlocker(getGitHubPRStackMergeScope(stack, 202))).toBe( + '#201 has merge conflicts.' + ) + }) +}) diff --git a/src/renderer/src/components/right-sidebar/github-pr-stack-merge.ts b/src/renderer/src/components/right-sidebar/github-pr-stack-merge.ts new file mode 100644 index 00000000000..8eacfb0380b --- /dev/null +++ b/src/renderer/src/components/right-sidebar/github-pr-stack-merge.ts @@ -0,0 +1,100 @@ +import type { GitHubPRStack, GitHubPRStackEntry } from '../../../../shared/types' +import { translate } from '@/i18n/i18n' + +export type GitHubPRStackMergeScope = { + count: number + complete: boolean + entries: GitHubPRStackEntry[] + label: string +} + +export function isGitHubPRStackMergeQueueRequired( + reviewMergeQueueRequired: boolean | null | undefined, + stackMergeQueueRequired: boolean | null | undefined +): boolean { + return reviewMergeQueueRequired === true || stackMergeQueueRequired === true +} + +export function getGitHubPRStackMergeScope( + stack: GitHubPRStack, + currentPRNumber: number +): GitHubPRStackMergeScope { + const entries = [...(stack.entries ?? [])] + .filter((entry) => entry.position <= stack.position) + .sort((a, b) => a.position - b.position) + const count = stack.position + const complete = + entries.length === count && entries.every((entry, index) => entry.position === index + 1) + return { + count, + complete, + entries, + label: + count === 1 + ? translate( + 'auto.components.right.sidebar.github.pr.stack.merge.55ae29b907', + 'Merge through #{{pr}} · {{count}} PR', + { pr: currentPRNumber, count } + ) + : translate( + 'auto.components.right.sidebar.github.pr.stack.merge.b8446f6ec2', + 'Merge through #{{pr}} · {{count}} PRs', + { pr: currentPRNumber, count } + ) + } +} + +export function getGitHubPRStackMergeBlocker(scope: GitHubPRStackMergeScope): string | null { + for (const entry of scope.entries) { + if (entry.state === 'draft') { + return translate( + 'auto.components.right.sidebar.github.pr.stack.merge.189d0ec614', + '#{{pr}} is still a draft.', + { pr: entry.number } + ) + } + if (entry.state === 'closed') { + return translate( + 'auto.components.right.sidebar.github.pr.stack.merge.640fb50d9c', + '#{{pr}} is closed.', + { pr: entry.number } + ) + } + if (entry.mergeable === 'CONFLICTING' || entry.mergeStateStatus === 'DIRTY') { + return translate( + 'auto.components.right.sidebar.github.pr.stack.merge.46ffcbda75', + '#{{pr}} has merge conflicts.', + { pr: entry.number } + ) + } + if (entry.reviewDecision === 'CHANGES_REQUESTED') { + return translate( + 'auto.components.right.sidebar.github.pr.stack.merge.6dabefd63e', + '#{{pr}} has requested changes.', + { pr: entry.number } + ) + } + if (entry.reviewDecision === 'REVIEW_REQUIRED') { + return translate( + 'auto.components.right.sidebar.github.pr.stack.merge.2bb21fc326', + '#{{pr}} still needs review approval.', + { pr: entry.number } + ) + } + if (entry.mergeStateStatus === 'BEHIND') { + return translate( + 'auto.components.right.sidebar.github.pr.stack.merge.c23faf74df', + '#{{pr}} must be updated.', + { pr: entry.number } + ) + } + if (entry.mergeStateStatus === 'BLOCKED') { + return translate( + 'auto.components.right.sidebar.github.pr.stack.merge.f561e80968', + '#{{pr}} is blocked.', + { pr: entry.number } + ) + } + } + return null +} diff --git a/src/renderer/src/components/right-sidebar/hosted-review-github-actions.ts b/src/renderer/src/components/right-sidebar/hosted-review-github-actions.ts index a498da99071..2481e5b4293 100644 --- a/src/renderer/src/components/right-sidebar/hosted-review-github-actions.ts +++ b/src/renderer/src/components/right-sidebar/hosted-review-github-actions.ts @@ -30,7 +30,8 @@ export async function mergeGitHubHostedReview(args: { method: args.method, prRepo: args.prRepo ?? null }, - { timeoutMs: 30_000 } + // Why: GitHub stack merges can run asynchronously for several minutes. + { timeoutMs: 4 * 60_000 } ) } return window.api.gh.mergePR({ diff --git a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx index 1eae2a150bc..09fd8fe4133 100644 --- a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx +++ b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.test.tsx @@ -46,10 +46,14 @@ function makeRepo(overrides: Partial = {}): Repo { } as Repo } -function HookProbe(props: { repo: Repo; onRefreshReview: () => Promise }): null { +function HookProbe(props: { + repo: Repo + onRefreshReview: () => Promise + pullRequest?: PRInfo +}): null { latest = useHostedReviewActions({ review, - githubPR, + githubPR: props.pullRequest ?? githubPR, repo: props.repo, isGitLab: false, shortLabel: 'PR', @@ -61,12 +65,16 @@ function HookProbe(props: { repo: Repo; onRefreshReview: () => Promise }): return null } -async function renderHook(repo: Repo, onRefreshReview = vi.fn().mockResolvedValue(undefined)) { +async function renderHook( + repo: Repo, + onRefreshReview = vi.fn().mockResolvedValue(undefined), + pullRequest?: PRInfo +) { const container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) await act(async () => { - root?.render(createElement(HookProbe, { repo, onRefreshReview })) + root?.render(createElement(HookProbe, { repo, onRefreshReview, pullRequest })) }) return { onRefreshReview } } @@ -115,7 +123,7 @@ describe('useHostedReviewActions', () => { method: 'squash', prRepo }, - { timeoutMs: 30_000 } + { timeoutMs: 4 * 60_000 } ) expect(window.api.gh.mergePR).not.toHaveBeenCalled() expect(onRefreshReview).toHaveBeenCalledTimes(1) @@ -140,4 +148,103 @@ describe('useHostedReviewActions', () => { expect(runtimeRpcMocks.callRuntimeRpc).not.toHaveBeenCalled() expect(onRefreshReview).toHaveBeenCalledTimes(1) }) + + it('confirms the downstack merge scope before merging a registered stack', async () => { + const stackedPR = { + ...githubPR, + stack: { + number: 51, + position: 2, + size: 3, + baseRefName: 'main', + entries: [ + { + position: 1, + number: 1014, + title: 'Models', + url: 'https://github.com/stablyai/orca/pull/1014', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE' + }, + { + position: 2, + number: 1015, + title: 'API', + url: 'https://github.com/stablyai/orca/pull/1015', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE' + }, + { + position: 3, + number: 1016, + title: 'UI', + url: 'https://github.com/stablyai/orca/pull/1016', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE' + } + ] + } + } as PRInfo + await renderHook(makeRepo(), undefined, stackedPR) + + await act(async () => { + await latest?.handleMerge('squash') + }) + + expect(confirmationMocks.confirm).toHaveBeenCalledWith({ + title: 'Merge through #1015?', + description: + 'Included: #1014, #1015. GitHub will merge 2 pull requests atomically using squash. If any cannot merge, none will.', + confirmLabel: 'Merge 2 PRs' + }) + expect(window.api.gh.mergePR).toHaveBeenCalledTimes(1) + }) + + it('describes merge-queue stack behavior without promising atomicity or a method', async () => { + const stackedPR = { + ...githubPR, + mergeQueueRequired: true, + stack: { + number: 51, + position: 2, + size: 2, + baseRefName: 'main', + entries: [ + { + position: 1, + number: 1014, + title: 'Models', + url: 'https://github.com/stablyai/orca/pull/1014', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE' + }, + { + position: 2, + number: 1015, + title: 'API', + url: 'https://github.com/stablyai/orca/pull/1015', + state: 'open', + checksStatus: 'success', + mergeable: 'MERGEABLE' + } + ] + } + } as PRInfo + await renderHook(makeRepo(), undefined, stackedPR) + + await act(async () => { + await latest?.handleMerge('squash') + }) + + expect(confirmationMocks.confirm).toHaveBeenCalledWith({ + title: 'Queue through #1015?', + description: + 'Included: #1014, #1015. GitHub will add 2 pull requests to the merge queue together. The queue chooses the merge method and may merge them in separate groups.', + confirmLabel: 'Queue 2 PRs' + }) + }) }) diff --git a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts index eec79060650..6830fe86eee 100644 --- a/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts +++ b/src/renderer/src/components/right-sidebar/use-hosted-review-actions.ts @@ -10,6 +10,7 @@ import { updateGitHubHostedReviewState } from './hosted-review-github-actions' import { translate } from '@/i18n/i18n' +import { buildGitHubPRStackMergeConfirmation } from './github-pr-stack-confirmation' export type HostedReviewActionInfo = Pick< HostedReviewInfo, @@ -62,6 +63,21 @@ export function useHostedReviewActions({ const handleMerge = useCallback( async (method: GitHubPRMergeMethod = defaultMergeMethod) => { + if (!isGitLab && githubPR?.stack) { + const usesMergeQueue = + review.mergeQueueRequired === true || githubPR.mergeQueueRequired === true + const confirmed = await confirm( + buildGitHubPRStackMergeConfirmation({ + stack: githubPR.stack, + currentPRNumber: review.number, + method, + usesMergeQueue + }) + ) + if (!confirmed) { + return + } + } setMerging(true) setActionError(null) try { @@ -89,7 +105,18 @@ export function useHostedReviewActions({ setMerging(false) } }, - [githubPR?.prRepo, isGitLab, defaultMergeMethod, onRefreshReview, repo, review.number] + [ + confirm, + githubPR?.prRepo, + githubPR?.mergeQueueRequired, + githubPR?.stack, + isGitLab, + defaultMergeMethod, + onRefreshReview, + repo, + review.mergeQueueRequired, + review.number + ] ) const handleAutoMerge = useCallback(async () => { diff --git a/src/renderer/src/i18n/locales/en.json b/src/renderer/src/i18n/locales/en.json index 3cf660f4515..0ac928f9927 100644 --- a/src/renderer/src/i18n/locales/en.json +++ b/src/renderer/src/i18n/locales/en.json @@ -10744,7 +10744,13 @@ "b25f63edd7": "open", "d2ca293f3d": "Working...", "ef064cb7c3": "default", - "59b4dccf70": "destructive" + "59b4dccf70": "destructive", + "9a41a687b7": "Queue through #{{pr}} · {{count}} PR", + "38a1bccb14": "Queue through #{{pr}} · {{count}} PRs", + "3de88351c5": "GitHub will add this pull request and every pull request below it to the merge queue.", + "a32fe6dba6": "GitHub will merge this pull request and every pull request below it in the stack.", + "73e0e1819d": "Queueing stack...", + "e555a41d32": "Merging stack..." }, "PortsPanel": { "3ea4a02a8f": "Cancel", @@ -11908,6 +11914,34 @@ "d9dd7c6687": "Couldn't refresh from GitHub. Showing the last known status." } } + }, + "pr": { + "stack": { + "confirmation": { + "84f6f5b9eb": "Included: {{numbers}}. ", + "541984b2eb": "Queue through #{{pr}}?", + "4809f55cdb": "{{included}}GitHub will add {{count}} pull request to the merge queue together. The queue chooses the merge method and may merge them in separate groups.", + "be8f2621be": "{{included}}GitHub will add {{count}} pull requests to the merge queue together. The queue chooses the merge method and may merge them in separate groups.", + "92ca033e72": "Queue {{count}} PR", + "478a527b15": "Queue {{count}} PRs", + "1feef35ca4": "Merge through #{{pr}}?", + "c3e036c99f": "{{included}}GitHub will merge {{count}} pull request atomically using {{method}}. If it cannot merge, nothing will be merged.", + "369aba4b32": "{{included}}GitHub will merge {{count}} pull requests atomically using {{method}}. If any cannot merge, none will.", + "493c78f521": "Merge {{count}} PR", + "eb7051d268": "Merge {{count}} PRs" + }, + "merge": { + "55ae29b907": "Merge through #{{pr}} · {{count}} PR", + "b8446f6ec2": "Merge through #{{pr}} · {{count}} PRs", + "189d0ec614": "#{{pr}} is still a draft.", + "640fb50d9c": "#{{pr}} is closed.", + "46ffcbda75": "#{{pr}} has merge conflicts.", + "6dabefd63e": "#{{pr}} has requested changes.", + "2bb21fc326": "#{{pr}} still needs review approval.", + "c23faf74df": "#{{pr}} must be updated.", + "f561e80968": "#{{pr}} is blocked." + } + } } }, "PluginPanel": { @@ -11926,6 +11960,23 @@ "mayBeSlower": "May be slower", "slowest": "Slowest", "unlimited": "Unlimited" + }, + "GitHubPRStackMap": { + "3511405914": "closed", + "8a9bdc36c0": "merged", + "568c647ccd": "draft", + "bea9ade223": "conflicts", + "838aadf512": "checks failed", + "316039b5db": "checks pending", + "4b1e5ee9d3": "changes requested", + "9a17b5255c": "review needed", + "d3d97cf3f2": "approved", + "e6cb964305": "open", + "7737bd66be": "Collapse stack #{{value0}}", + "0c1645ebd0": "Expand stack #{{value0}}", + "e3ee2daa32": "Stack #{{value0}}", + "cb440931b7": "{{value0}} of {{value1}} · {{value2}}", + "525259fa17": "Stack details are temporarily unavailable." } } }, diff --git a/src/shared/types.ts b/src/shared/types.ts index cd874d94037..96c6d14dabf 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -1245,6 +1245,30 @@ export type GitHubPRMergeMethodSettings = { allowedMethods: Record } +export type GitHubPRStackEntry = { + position: number + number: number + title: string + url: string + updatedAt?: string + state: PRState + checksStatus: CheckStatus + mergeable: PRMergeableState + reviewDecision?: PRReviewDecision | null + mergeStateStatus?: string | null + headRefName?: string + headSha?: string +} + +export type GitHubPRStack = { + number: number + position: number + size: number + baseRefName: string + baseSha?: string + entries?: GitHubPRStackEntry[] +} + export type PRInfo = { number: number title: string @@ -1259,6 +1283,8 @@ export type PRInfo = { mergeQueueRequired?: boolean | null mergeMethodSettings?: GitHubPRMergeMethodSettings mergeStateStatus?: string | null + /** GitHub-registered stack metadata. Absent for ordinary dependent PR chains. */ + stack?: GitHubPRStack // Why: check-runs are keyed by the PR head commit, not the mutable branch name. // Keeping the head SHA in cached PR metadata lets the checks panel poll the // correct commit without re-querying GitHub or guessing from local branch refs.