/* oxlint-disable max-lines -- Why: GitHub client fixtures cover local and SSH repo identity paths in one suite so mocked CLI behavior stays consistent. */ import { beforeEach, describe, expect, it, vi } from 'vitest' import type * as GithubApiRepositoryModule from './github-api-repository' type RateLimitGuardResult = | { blocked: false } | { blocked: true; remaining: number; limit: number; resetAt: number } const { execFileAsyncMock, ghExecFileAsyncMock, getOwnerRepoMock, getIssueOwnerRepoMock, getOwnerRepoForRemoteMock, resolvePRRepositoryCandidatesMock, getRemoteUrlForRepoMock, gitExecFileAsyncMock, getRateLimitMock, rateLimitGuardMock, noteRateLimitSpendMock, ghRepoExecOptionsMock, githubRepoContextMock, getSshGitProviderMock, readLocalGitConfigSignatureMock, acquireMock, releaseMock } = vi.hoisted(() => ({ execFileAsyncMock: vi.fn(), ghExecFileAsyncMock: vi.fn(), getOwnerRepoMock: vi.fn(), getIssueOwnerRepoMock: vi.fn(), getOwnerRepoForRemoteMock: vi.fn(), resolvePRRepositoryCandidatesMock: vi.fn(), getRemoteUrlForRepoMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), getRateLimitMock: vi.fn(), rateLimitGuardMock: vi.fn<(bucket?: string) => RateLimitGuardResult>(() => ({ blocked: false })), noteRateLimitSpendMock: vi.fn(), ghRepoExecOptionsMock: vi.fn((context) => context.connectionId ? {} : { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) } ), githubRepoContextMock: vi.fn((repoPath, connectionId, localGitOptions) => ({ repoPath, connectionId: connectionId ?? null, ...localGitOptions })), getSshGitProviderMock: vi.fn(), readLocalGitConfigSignatureMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() })) vi.mock('./gh-utils', () => ({ execFileAsync: execFileAsyncMock, ghExecFileAsync: ghExecFileAsyncMock, getOwnerRepo: getOwnerRepoMock, getIssueOwnerRepo: getIssueOwnerRepoMock, getOwnerRepoForRemote: getOwnerRepoForRemoteMock, resolvePRRepositoryCandidates: resolvePRRepositoryCandidatesMock, getRemoteUrlForRepo: getRemoteUrlForRepoMock, gitExecFileAsync: gitExecFileAsyncMock, ghRepoExecOptions: ghRepoExecOptionsMock, githubRepoContext: githubRepoContextMock, classifyGhError: (stderr: string) => { const lower = stderr.toLowerCase() if (lower.includes('not found') || stderr.includes('HTTP 404')) { return { type: 'not_found', message: stderr } } if (lower.includes('rate limit')) { return { type: 'rate_limited', message: stderr } } if (lower.includes('resource not accessible')) { return { type: 'permission_denied', message: stderr } } return { type: 'unknown', message: stderr } }, parseGitHubOwnerRepo: (remoteUrl: string) => { const match = remoteUrl.trim().match(/github\.com[:/]([^/]+)\/([^/]+?)(?:\.git)?$/) return match ? { owner: match[1], repo: match[2] } : null }, acquire: acquireMock, release: releaseMock, _resetOwnerRepoCache: vi.fn() })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) vi.mock('../providers/ssh-git-dispatch', () => ({ getSshGitProvider: getSshGitProviderMock })) vi.mock('./local-git-config-signature', () => ({ readLocalGitConfigSignature: readLocalGitConfigSignatureMock })) vi.mock('./rate-limit', () => ({ getRateLimit: getRateLimitMock, rateLimitGuard: rateLimitGuardMock, noteRateLimitSpend: noteRateLimitSpendMock, // Why: the repository-scoped guards share the same bucket-keyed budget as the // legacy ones, so delegate to the existing mocks to keep per-bucket blocking // and spend assertions working unchanged. repositoryRateLimitGuard: (_repository: unknown, bucket: string) => rateLimitGuardMock(bucket), noteRepositoryRateLimitSpend: (_repository: unknown, bucket: string) => noteRateLimitSpendMock(bucket), spendsSharedGitHubComQuota: () => true })) vi.mock('./github-api-repository', async (importOriginal) => { const actual = await importOriginal() return { ...actual, // Why: these suites inject repo identities through the legacy gh-utils // mocks; bridge the hosted seams onto the same mocks so per-test setups // keep driving resolution without real enterprise probes. resolveGitHubApiRepositoryCandidates: ( repoPath: string, connectionId?: string | null, localGitOptions?: unknown ) => resolvePRRepositoryCandidatesMock(repoPath, connectionId, localGitOptions), getGitHubApiRepositoryForRemote: ( repoPath: string, remoteName: string, connectionId?: string | null ) => getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId), getOriginGitHubApiRepository: async ( repoPath: string, connectionId?: string | null, localGitOptions?: unknown ) => { // Prefer the remote-specific mock (production origin path); fall back for // suites that only configure getOwnerRepo. const fromRemote = await getOwnerRepoForRemoteMock( repoPath, 'origin', connectionId, localGitOptions ) const slug = fromRemote ?? (await getOwnerRepoMock(repoPath, connectionId, localGitOptions)) // Mirror production: dotcom origin slugs come back pinned to github.com. return slug ? { host: 'github.com', ...slug } : slug }, getIssueGitHubApiRepository: async (repoPath: string, connectionId?: string | null) => { const slug = await getIssueOwnerRepoMock(repoPath, connectionId) // Mirror production: issue slugs come back host-qualified to github.com. return slug ? { host: 'github.com', ...slug } : slug }, resolveIssueGitHubApiRepositorySource: async ( repoPath: string, _preference: unknown, connectionId?: string | null ) => { const slug = await getIssueOwnerRepoMock(repoPath, connectionId) return { source: slug ? { host: 'github.com', ...slug } : slug, fellBack: false } } } }) import { checkOrcaStarred, getPRComments, getPRForBranch, getPRForBranchOutcome, getGitHubPRLookupRateLimitBlock, getRepoSlug, getRepoUpstream, getWorkItem, getWorkItemByOwnerRepo, getPullRequestPushTarget, mergePR, resolveReviewThread, setPRAutoMerge, updatePRState, updatePRTitle, _getMergeQueueCacheSizeForTests, _getTrackedUpstreamBranchCacheSizesForTests, _resetOwnerRepoCache, _resetMergeQueueCacheForTests, __resetTrackedUpstreamBranchCacheForTests } from './client' import { __resetPRConflictSummaryCachesForTests } from './conflict-summary' import { resetMergedPRCommitMembershipCacheForTest } from './merged-pr-commit-membership' import { __resetRepoDefaultBranchCacheForTests } from '../source-control/repo-default-branch' import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository' // The origin-repository cache is module-level state; reset it so slugs // resolved by one test cannot leak into the next. beforeEach(() => { _resetOriginGitHubApiRepositoryCache() }) describe('checkOrcaStarred', () => { beforeEach(() => { execFileAsyncMock.mockReset() acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) }) it('returns true only for an included successful GitHub response', async () => { execFileAsyncMock.mockResolvedValueOnce({ stdout: 'HTTP/2.0 204 No Content\r\n', stderr: '' }) await expect(checkOrcaStarred()).resolves.toBe(true) expect(execFileAsyncMock).toHaveBeenCalledWith( 'gh', ['api', '--include', 'user/starred/stablyai/orca'], { encoding: 'utf-8' } ) }) it('returns true for an HTTP 200 starred response', async () => { execFileAsyncMock.mockResolvedValueOnce({ stdout: 'HTTP/2.0 200 OK\r\n', stderr: '' }) await expect(checkOrcaStarred()).resolves.toBe(true) }) it('returns false for GitHub 404 not starred responses', async () => { execFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404: Not Found')) await expect(checkOrcaStarred()).resolves.toBe(false) }) it('returns null when gh exits successfully without response headers', async () => { execFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect(checkOrcaStarred()).resolves.toBe(null) }) }) describe('getPRForBranch', () => { beforeEach(() => { execFileAsyncMock.mockReset() ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() // Why: resolveGitHubRepoExecution probes origin via getOwnerRepoForRemote. getOwnerRepoForRemoteMock.mockImplementation( async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null ) resolvePRRepositoryCandidatesMock.mockReset() resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { const origin = await getOwnerRepoMock(repoPath, connectionId) return { candidates: origin ? [origin] : [], headRepo: origin } }) getRemoteUrlForRepoMock.mockReset() gitExecFileAsyncMock.mockReset() getRateLimitMock.mockReset() getRateLimitMock.mockResolvedValue({ resources: {} }) rateLimitGuardMock.mockReset() rateLimitGuardMock.mockReturnValue({ blocked: false }) noteRateLimitSpendMock.mockReset() ghRepoExecOptionsMock.mockClear() githubRepoContextMock.mockClear() getSshGitProviderMock.mockReset() readLocalGitConfigSignatureMock.mockReset() readLocalGitConfigSignatureMock.mockResolvedValue(undefined) acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() __resetTrackedUpstreamBranchCacheForTests() __resetPRConflictSummaryCachesForTests() resetMergedPRCommitMembershipCacheForTest() // Why: the #9171 guard caches default-branch resolutions per repoPath; // reset so non-open implicit lookups stay order-independent across tests. __resetRepoDefaultBranchCacheForTests() }) it('queries GitHub by head branch when the remote is on github.com', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 42, title: 'Fix PR discovery', state: 'open', html_url: 'https://github.com/acme/widgets/pull/42', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable: true, base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'feature/test', sha: 'head-oid' } } ]) }) const pr = await getPRForBranch('/repo-root', 'refs/heads/feature/test') expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', undefined) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=1'], { cwd: '/repo-root' } ) expect(pr?.number).toBe(42) expect(pr?.state).toBe('open') expect(pr?.mergeable).toBe('MERGEABLE') expect(pr?.prRepo).toEqual({ owner: 'acme', repo: 'widgets' }) expect(pr?.headRepo).toEqual({ owner: 'acme', repo: 'widgets' }) }) it('resolves fork PRs from the upstream PR repo with the origin head owner', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [ { owner: 'stablyai', repo: 'orca' }, { owner: 'fork', repo: 'orca' } ], headRepo: { owner: 'fork', repo: 'orca' } }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 1738, title: 'Fork PR', state: 'open', html_url: 'https://github.com/stablyai/orca/pull/1738', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable_state: 'clean', base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'feature/test', sha: 'head-oid' } } ]) }) const pr = await getPRForBranch('/repo-root', 'feature/test') expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', 'repos/stablyai/orca/pulls?head=fork%3Afeature%2Ftest&state=all&per_page=1'], { cwd: '/repo-root' } ) expect(pr).toMatchObject({ number: 1738, prRepo: { owner: 'stablyai', repo: 'orca' }, headRepo: { owner: 'fork', repo: 'orca' } }) }) it('looks up a linked PR number across PR repo candidates', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [ { owner: 'stablyai', repo: 'orca' }, { owner: 'fork', repo: 'orca' } ], headRepo: { owner: 'fork', repo: 'orca' } }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'linked-head-oid\n', stderr: '' }) ghExecFileAsyncMock .mockRejectedValueOnce(new Error('HTTP 404: Not Found')) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 99, title: 'Linked fork PR', state: 'OPEN', url: 'https://github.com/fork/orca/pull/99', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'feature/test', baseRefOid: 'base-oid', headRefOid: 'linked-head-oid' }) }) const pr = await getPRForBranch('/repo-root', 'feature/test', 99) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, [ 'pr', 'view', '99', '--repo', 'stablyai/orca', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, [ 'pr', 'view', '99', '--repo', 'fork/orca', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) expect(pr?.prRepo).toEqual({ owner: 'fork', repo: 'orca' }) }) it('prefers exact linked PR lookup when the repo identity is known', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'linked-head-oid\n', stderr: '' }) ghExecFileAsyncMock.mockResolvedValueOnce({ 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: 'someone/fix', baseRefOid: 'base-oid', headRefOid: 'linked-head-oid' }) }) const pr = await getPRForBranch('/repo-root', 'feature/local-worktree', 99) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ 'pr', 'view', '99', '--repo', 'acme/widgets', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) expect(pr).toMatchObject({ number: 99, title: 'Linked PR', state: 'open', headSha: 'linked-head-oid' }) }) it('hydrates repository merge method settings for exact PR lookups', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ 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: 'someone/fix', baseRefOid: 'base-oid', headRefOid: 'linked-head-oid' }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ data: { repository: { viewerDefaultMergeMethod: 'REBASE', mergeCommitAllowed: false, rebaseMergeAllowed: true, squashMergeAllowed: true, autoMergeAllowed: true, mergeQueue: null } } }) }) const pr = await getPRForBranch('/repo-root', 'feature/local-worktree', 99) expect(pr?.mergeMethodSettings).toEqual({ defaultMethod: 'rebase', allowedMethods: { squash: true, merge: false, rebase: true } }) expect(pr?.mergeQueueRequired).toBe(false) expect(pr?.autoMergeAllowed).toBe(true) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, expect.arrayContaining([ 'api', 'graphql', '-f', 'owner=acme', '-f', 'repo=widgets', '-f', 'branch=main' ]), { cwd: '/repo-root' } ) }) 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: '' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 99, title: 'Stale 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: 'someone/other-work', baseRefOid: 'base-oid', headRefOid: 'stale-linked-head' }) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 42, title: 'Branch PR', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/42', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'feature/test', baseRefOid: 'base-oid', headRefOid: 'current-worktree-head' } ]) }) const pr = await getPRForBranch('/repo-root', 'feature/test', 99) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(pr?.number).toBe(99) }) it('does not fall back to branch discovery when linked PR metadata is stale', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockRejectedValueOnce(new Error('HTTP 404: Not Found')) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 42, title: 'Branch PR', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/42', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'feature/test', baseRefOid: 'base-oid', headRefOid: 'head-oid' } ]) }) const pr = await getPRForBranch('/repo-root', 'feature/test', 99) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, [ 'pr', 'view', '99', '--repo', 'acme/widgets', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(pr).toBeNull() }) it('returns no PR when linked PR REST fallback also misses', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockRejectedValueOnce(new Error('GraphQL: could not resolve to PullRequest')) .mockRejectedValueOnce(new Error('HTTP 404: Not Found')) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 42, title: 'Branch PR after stale linked miss', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/42', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'feature/test', baseRefOid: 'base-oid', headRefOid: 'head-oid' } ]) }) const pr = await getPRForBranch('/repo-root', 'feature/test', 99) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/acme/widgets/pulls/99'], { cwd: '/repo-root' }) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(pr).toBeNull() }) it('returns no PR when linked PR REST fallback has an unclassified failure', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockRejectedValueOnce(new Error('GraphQL: server exploded')) .mockRejectedValueOnce(new Error('HTTP 500: server error')) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 42, title: 'Branch PR after exact lookup outage', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/42', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'feature/test', baseRefOid: 'base-oid', headRefOid: 'head-oid' } ]) }) const pr = await getPRForBranch('/repo-root', 'feature/test', 99) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/acme/widgets/pulls/99'], { cwd: '/repo-root' }) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(pr).toBeNull() }) it('does not continue to branch discovery when linked PR REST fallback is rate limited', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockRejectedValueOnce(new Error('GraphQL: API rate limit already exceeded')) .mockRejectedValueOnce(new Error('REST API rate limit already exceeded')) const pr = await getPRForBranch('/repo-root', 'feature/test', 99) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, [ 'pr', 'view', '99', '--repo', 'acme/widgets', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/acme/widgets/pulls/99'], { cwd: '/repo-root' }) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(pr).toBeNull() }) it('uses REST branch lookup directly when origin head repo is known', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 43, title: 'REST branch lookup', 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: 'rest-head-oid' }, base: { ref: 'main', sha: 'rest-base-oid' } } ]) }) const pr = await getPRForBranch('/repo-root', 'feature/test') 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: 'REST branch lookup', state: 'open', url: 'https://github.com/acme/widgets/pull/43', checksStatus: 'neutral', mergeable: 'MERGEABLE', headSha: 'rest-head-oid' }) }) it('ignores merged PRs discovered only by branch lookup when the branch moved on', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 5511, title: 'Merged branch PR', state: 'closed', merged_at: '2026-06-16T17:15:33Z', html_url: 'https://github.com/stablyai/orca/pull/5511', updated_at: '2026-06-16T17:15:33Z', draft: false, mergeable_state: 'clean', head: { ref: 'add-guide-for-mobile-emulator-use', sha: 'head-oid' }, base: { ref: 'main', sha: 'base-oid' } } ]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 5511, title: 'Merged branch PR', state: 'MERGED', url: 'https://github.com/stablyai/orca/pull/5511', statusCheckRollup: [], updatedAt: '2026-06-16T17:15:33Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'add-guide-for-mobile-emulator-use', baseRefOid: 'base-oid', headRefOid: 'head-oid' }) }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'new-local-head-oid\n', stderr: '' }) const pr = await getPRForBranch('/repo-root', 'add-guide-for-mobile-emulator-use') expect(pr).toBeNull() }) it('shows a merged branch PR when it still matches the current HEAD', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 5875, title: 'Merged current branch PR', state: 'closed', merged_at: '2026-06-20T04:53:05Z', html_url: 'https://github.com/acme/widgets/pull/5875', updated_at: '2026-06-20T04:53:05Z', draft: false, mergeable_state: 'clean', head: { ref: 'fix-tab-strip-layout-test', sha: 'current-head-oid' }, base: { ref: 'main', sha: 'base-oid' } } ]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 5875, title: 'Merged current branch PR', state: 'MERGED', url: 'https://github.com/acme/widgets/pull/5875', statusCheckRollup: [], updatedAt: '2026-06-20T04:53:05Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'fix-tab-strip-layout-test', baseRefOid: 'base-oid', headRefOid: 'current-head-oid' }) }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'current-head-oid\n', stderr: '' }) const pr = await getPRForBranch('/repo-root', 'fix-tab-strip-layout-test') expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['rev-parse', 'HEAD'], { cwd: '/repo-root' }) expect(pr).toMatchObject({ number: 5875, title: 'Merged current branch PR', state: 'merged', headSha: 'current-head-oid' }) }) const mockMergedBranchPRLookupBehindHead = (prNumber = 6011): void => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: prNumber, title: 'Merged PR with unpulled final head', state: 'closed', merged_at: '2026-07-03T21:27:36Z', html_url: `https://github.com/acme/widgets/pull/${prNumber}`, updated_at: '2026-07-03T21:27:36Z', draft: false, mergeable_state: 'clean', head: { ref: 'fix-hibernation-wake', sha: 'aaaa1111aaaa1111' }, base: { ref: 'main', sha: 'base-oid' } } ]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: prNumber, title: 'Merged PR with unpulled final head', state: 'MERGED', url: `https://github.com/acme/widgets/pull/${prNumber}`, statusCheckRollup: [], updatedAt: '2026-07-03T21:27:36Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'fix-hibernation-wake', baseRefOid: 'base-oid', headRefOid: 'aaaa1111aaaa1111' }) }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'bbbb2222bbbb2222\n', stderr: '' }) } it('shows a merged branch PR when the worktree head is one of its own commits', async () => { mockMergedBranchPRLookupBehindHead() ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([{ number: 6011 }, { number: 42 }]) }) const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake') expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', 'repos/acme/widgets/commits/bbbb2222bbbb2222/pulls?per_page=100&page=1'], expect.anything() ) expect(pr).toMatchObject({ number: 6011, state: 'merged', headSha: 'aaaa1111aaaa1111', confirmedContainedHeadOid: 'bbbb2222bbbb2222' }) }) it('keeps hiding a merged branch PR when the head belongs to a different PR (reused branch)', async () => { mockMergedBranchPRLookupBehindHead() ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([{ number: 42 }]) }) const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake') expect(pr).toBeNull() expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) // A definitive "not part of this PR" answer is immutable for a merged PR: // repeated polls must not re-probe GitHub. mockMergedBranchPRLookupBehindHead() const second = await getPRForBranch('/repo-root', 'fix-hibernation-wake') expect(second).toBeNull() expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(5) }) it('keeps hiding a merged branch PR when the commit membership probe fails', async () => { mockMergedBranchPRLookupBehindHead() ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 422: No commit found')) const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake') expect(pr).toBeNull() }) it('skips the membership probe while the core rate-limit budget is blocked', async () => { mockMergedBranchPRLookupBehindHead() rateLimitGuardMock.mockImplementation((bucket?: string) => bucket === 'core' ? { blocked: true, remaining: 0, limit: 5000, resetAt: 0 } : { blocked: false } ) const pr = await getPRForBranch('/repo-root', 'fix-hibernation-wake') expect(pr).toBeNull() expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) }) it('reuses the cached membership answer instead of re-querying per poll', async () => { mockMergedBranchPRLookupBehindHead() ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([{ number: 6011 }]) }) const first = await getPRForBranch('/repo-root', 'fix-hibernation-wake') expect(first).toMatchObject({ number: 6011, confirmedContainedHeadOid: 'bbbb2222bbbb2222' }) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(3) mockMergedBranchPRLookupBehindHead() const second = await getPRForBranch('/repo-root', 'fix-hibernation-wake') expect(second).toMatchObject({ number: 6011, confirmedContainedHeadOid: 'bbbb2222bbbb2222' }) // No fourth membership call: the confirmed answer is immutable and cached. expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(5) }) it('uses the caller-supplied worktree head for the membership probe without shelling out', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 6012, title: 'Merged PR queried by checks panel', state: 'closed', merged_at: '2026-07-03T21:27:36Z', html_url: 'https://github.com/acme/widgets/pull/6012', updated_at: '2026-07-03T21:27:36Z', draft: false, mergeable_state: 'clean', head: { ref: 'fix-hibernation-wake', sha: 'aaaa1111aaaa1111' }, base: { ref: 'main', sha: 'base-oid' } } ]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 6012, title: 'Merged PR queried by checks panel', state: 'MERGED', url: 'https://github.com/acme/widgets/pull/6012', statusCheckRollup: [], updatedAt: '2026-07-03T21:27:36Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'fix-hibernation-wake', baseRefOid: 'base-oid', headRefOid: 'aaaa1111aaaa1111' }) }) .mockResolvedValueOnce({ stdout: JSON.stringify([{ number: 6012 }]) }) const outcome = await getPRForBranchOutcome( '/repo-root', 'fix-hibernation-wake', null, null, null, { currentHeadOid: 'cccc3333cccc3333' } ) // Why: the merged-implicit head probe must use the supplied oid — no // `rev-parse HEAD` shell-out. (The #9171 guard may still probe the repo // default branch for this non-open implicit result; that is unrelated.) expect( gitExecFileAsyncMock.mock.calls.some( (call) => call[0][0] === 'rev-parse' && call[0][1] === 'HEAD' ) ).toBe(false) expect(outcome).toMatchObject({ kind: 'found', pr: { number: 6012, headRefName: 'fix-hibernation-wake', confirmedContainedHeadOid: 'cccc3333cccc3333' } }) }) function mockMergedLinkedPRLookup(prNumber = 7447) { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [{ owner: 'acme', repo: 'widgets' }], headRepo: { owner: 'acme', repo: 'widgets' } }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ number: prNumber, title: 'Merged linked PR', state: 'MERGED', url: `https://github.com/acme/widgets/pull/${prNumber}`, statusCheckRollup: [], updatedAt: '2026-07-03T21:27:36Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'old-linked-branch', baseRefOid: 'base-oid', headRefOid: 'aaaa1111aaaa1111' }) }) } it('stamps confirmedContainedHeadOid for a linked merged PR when HEAD is its commit', async () => { mockMergedLinkedPRLookup() ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([{ number: 7447 }]) }) const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { currentHeadOid: 'bbbb2222bbbb2222' }) expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged', confirmedContainedHeadOid: 'bbbb2222bbbb2222' } }) expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( undefined ) }) it('stamps headDivergedFromMergedPRAtOid for a linked merged PR with a definite miss', async () => { mockMergedLinkedPRLookup() ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([{ number: 42 }]) }) const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { currentHeadOid: 'bbbb2222bbbb2222' }) expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged', headDivergedFromMergedPRAtOid: 'bbbb2222bbbb2222' } }) }) it('stamps linked merged divergence when a later membership page proves absence', async () => { mockMergedLinkedPRLookup() // Page 1 is full and omits the linked PR (truncated), but page 2 is short and // still omits it — that pair definitively proves the head is not contained. const fullPage = Array.from({ length: 100 }, (_, index) => ({ number: 1000 + index })) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify(fullPage) }) .mockResolvedValueOnce({ stdout: JSON.stringify([{ number: 2000 }]) }) const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { currentHeadOid: 'bbbb2222bbbb2222' }) expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged', headDivergedFromMergedPRAtOid: 'bbbb2222bbbb2222' } }) }) it('leaves linked merged divergence unset when membership pages stay full to the cap', async () => { mockMergedLinkedPRLookup() // Every page up to the cap is full and omits the linked PR, so absence can // never be proven — the probe must stay unknown rather than clear the link. const fullPage = Array.from({ length: 100 }, (_, index) => ({ number: 1000 + index })) for (let page = 0; page < 5; page += 1) { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify(fullPage) }) } const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { currentHeadOid: 'bbbb2222bbbb2222' }) expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( undefined ) }) it('stamps linked merged divergence via the PR url when no repo candidates resolve', async () => { // Fallback path: no resolved candidates, so `gh pr view` returns the PR with // dataRepo=null. The membership probe must still run against the repo derived // from the PR's own URL so a diverged merged linked PR can clear. resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [], headRepo: null }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 7447, title: 'Merged linked PR', state: 'MERGED', url: 'https://github.com/acme/widgets/pull/7447', statusCheckRollup: [], updatedAt: '2026-07-03T21:27:36Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'old-linked-branch', baseRefOid: 'base-oid', headRefOid: 'aaaa1111aaaa1111' }) }) .mockResolvedValueOnce({ stdout: JSON.stringify([{ number: 2000 }]) }) const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { currentHeadOid: 'bbbb2222bbbb2222' }) expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged', headDivergedFromMergedPRAtOid: 'bbbb2222bbbb2222' } }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', 'repos/acme/widgets/commits/bbbb2222bbbb2222/pulls?per_page=100&page=1'], expect.anything() ) }) it('leaves linked merged divergence unset when the membership probe is rate-limited', async () => { mockMergedLinkedPRLookup() rateLimitGuardMock.mockImplementation((bucket?: string) => bucket === 'core' ? { blocked: true, remaining: 0, limit: 5000, resetAt: 0 } : { blocked: false } ) const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { currentHeadOid: 'bbbb2222bbbb2222' }) expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( undefined ) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) }) it('leaves linked merged divergence unset when the membership probe throws', async () => { mockMergedLinkedPRLookup() ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 422: No commit found')) const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { currentHeadOid: 'bbbb2222bbbb2222' }) expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( undefined ) }) it('leaves linked merged divergence unset when the membership probe returns a non-array payload', async () => { mockMergedLinkedPRLookup() ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ message: 'Server Error' }) }) const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null, { currentHeadOid: 'bbbb2222bbbb2222' }) expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( undefined ) }) it('leaves linked merged divergence unset without a current head oid', async () => { mockMergedLinkedPRLookup() const outcome = await getPRForBranchOutcome('/repo-root', 'new-work', 7447, null, null) expect(outcome).toMatchObject({ kind: 'found', pr: { number: 7447, state: 'merged' } }) expect(outcome.kind === 'found' ? outcome.pr.headDivergedFromMergedPRAtOid : undefined).toBe( undefined ) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) }) it('prefers branch lookup over a fallback PR number', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) 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: '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(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: 'Hydrated branch PR wins' }) }) it('uses a fallback PR number only after branch lookup misses', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 42, title: 'Fallback PR lookup', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/42', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'contributor/original', baseRefOid: 'base-oid', headRefOid: 'fallback-head-oid' }) }) const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, 42) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['api', 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=1'], { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, [ 'pr', 'view', '42', '--repo', 'acme/widgets', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) expect(pr).toMatchObject({ number: 42, title: 'Fallback PR lookup' }) }) it('reports upstream error when fallback branch discovery fails transiently then retry misses', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [{ owner: 'stablyai', repo: 'orca' }], headRepo: null }) ghExecFileAsyncMock .mockRejectedValueOnce(new Error('HTTP 429: API rate limit exceeded')) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test') expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'rate_limited' }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, [ 'pr', 'list', '--repo', 'stablyai/orca', '--head', 'feature/test', '--state', 'all', '--limit', '1', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', 'repos/stablyai/orca/pulls?head=stablyai%3Afeature%2Ftest&state=all&per_page=1'], { cwd: '/repo-root' } ) }) it('propagates a Retry-After cooldown into the rate-limited retry schedule', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [{ owner: 'stablyai', repo: 'orca' }], headRepo: null }) // gh puts the diagnostic on `.stderr`; a secondary limit carries Retry-After. ghExecFileAsyncMock .mockRejectedValueOnce( Object.assign(new Error('gh exited with 1.'), { stderr: 'HTTP 403: You have exceeded a secondary rate limit\nRetry-After: 120' }) ) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) const before = Date.now() const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test') expect(outcome.kind).toBe('upstream-error') if (outcome.kind !== 'upstream-error') { throw new Error('expected upstream-error') } expect(outcome.errorType).toBe('rate_limited') // ~120s cooldown surfaced as both the manual gate and the auto-retry time. expect(outcome.retryDisabledUntil).toBeDefined() expect(outcome.nextAutoRetryAt).toBe(outcome.retryDisabledUntil) expect(outcome.retryDisabledUntil ?? 0).toBeGreaterThanOrEqual(before + 119_000) expect(outcome.retryDisabledUntil ?? 0).toBeLessThanOrEqual(Date.now() + 121_000) }) it('reports no PR when fallback branch discovery cleanly misses', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [{ owner: 'stablyai', repo: 'orca' }], headRepo: null }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([]) }) const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test') expect(outcome.kind).toBe('no-pr') expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) }) it('returns found when fallback branch discovery retry finds the PR', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [{ owner: 'stablyai', repo: 'orca' }], headRepo: null }) ghExecFileAsyncMock .mockRejectedValueOnce(new Error('HTTP 429: API rate limit exceeded')) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 42, title: 'Retry branch PR', state: 'open', html_url: 'https://github.com/stablyai/orca/pull/42', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable: true, base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'feature/test', sha: 'retry-head-oid' } } ]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 42, title: 'Hydrated retry branch PR', state: 'OPEN', url: 'https://github.com/stablyai/orca/pull/42', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'feature/test', baseRefOid: 'base-oid', headRefOid: 'retry-head-oid' }) }) const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test') expect(outcome).toMatchObject({ kind: 'found', pr: { number: 42, title: 'Hydrated retry branch PR', prRepo: { owner: 'stablyai', repo: 'orca' } } }) }) it('lets fallback PR number recovery win after fallback branch queries throw', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [{ owner: 'stablyai', repo: 'orca' }], headRepo: null }) ghExecFileAsyncMock .mockRejectedValueOnce(new Error('HTTP 429: API rate limit exceeded')) .mockRejectedValueOnce(new Error('HTTP 502: Bad Gateway')) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 42, title: 'Fallback number recovered PR', state: 'OPEN', url: 'https://github.com/stablyai/orca/pull/42', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'feature/test', baseRefOid: 'base-oid', headRefOid: 'fallback-head-oid' }) }) const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test', null, null, 42) expect(outcome).toMatchObject({ kind: 'found', pr: { number: 42, title: 'Fallback number recovered PR' } }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, [ 'pr', 'view', '42', '--repo', 'stablyai/orca', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) }) it('reports upstream error when fallback branch discovery has a network failure', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [{ owner: 'stablyai', repo: 'orca' }], headRepo: null }) ghExecFileAsyncMock .mockRejectedValueOnce(new Error('could not resolve host: api.github.com')) .mockRejectedValueOnce(new Error('could not resolve host: api.github.com')) const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test') expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'network' }) }) it('reports a GitHub server error when fallback branch discovery receives 5xx responses', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [{ owner: 'stablyai', repo: 'orca' }], headRepo: null }) ghExecFileAsyncMock .mockRejectedValueOnce(new Error('HTTP 503: Service Unavailable')) .mockRejectedValueOnce(new Error('HTTP 502: Bad Gateway')) const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test') expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'server_error' }) }) it('keeps a pending fallback branch error when a later candidate cleanly misses', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [ { owner: 'stablyai', repo: 'orca' }, { owner: 'fork', repo: 'orca' } ], headRepo: null }) ghExecFileAsyncMock .mockRejectedValueOnce(new Error('HTTP 429: API rate limit exceeded')) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) const outcome = await getPRForBranchOutcome('/repo-root', 'feature/test') expect(outcome).toMatchObject({ kind: 'upstream-error', errorType: 'rate_limited' }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, [ 'pr', 'list', '--repo', 'fork/orca', '--head', 'feature/test', '--state', 'all', '--limit', '1', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) }) it('treats a merged branch lookup as a miss before using a fallback PR number', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 5511, title: 'Merged branch PR', state: 'closed', merged_at: '2026-06-16T17:15:33Z', html_url: 'https://github.com/stablyai/orca/pull/5511', updated_at: '2026-06-16T17:15:33Z', draft: false, mergeable_state: 'clean', head: { ref: 'feature/test', sha: 'merged-head-oid' }, base: { ref: 'main', sha: 'base-oid' } } ]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 5511, title: 'Merged branch PR', state: 'MERGED', url: 'https://github.com/stablyai/orca/pull/5511', statusCheckRollup: [], updatedAt: '2026-06-16T17:15:33Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'feature/test', baseRefOid: 'base-oid', headRefOid: 'merged-head-oid' }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 42, title: 'Open fallback PR', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/42', statusCheckRollup: [], updatedAt: '2026-06-17T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'contributor/original', baseRefOid: 'base-oid', headRefOid: 'fallback-head-oid' }) }) const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, 42) expect(pr).toMatchObject({ number: 42, title: 'Open fallback PR' }) }) it('returns a merged PR when branch lookup and fallback point at the same PR', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 5511, title: 'Merged current PR', state: 'closed', merged_at: '2026-06-16T17:15:33Z', html_url: 'https://github.com/acme/widgets/pull/5511', updated_at: '2026-06-16T17:15:33Z', draft: false, mergeable_state: 'clean', head: { ref: 'feature/test', sha: 'merged-head-oid' }, base: { ref: 'main', sha: 'base-oid' } } ]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 5511, title: 'Merged current PR', state: 'MERGED', url: 'https://github.com/acme/widgets/pull/5511', statusCheckRollup: [], updatedAt: '2026-06-16T17:15:33Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'feature/test', baseRefOid: 'base-oid', headRefOid: 'merged-head-oid' }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 5511, title: 'Merged current PR', state: 'MERGED', url: 'https://github.com/acme/widgets/pull/5511', statusCheckRollup: [], updatedAt: '2026-06-16T17:15:33Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'feature/test', baseRefOid: 'base-oid', headRefOid: 'merged-head-oid' }) }) const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, 5511) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, [ 'pr', 'view', '5511', '--repo', 'acme/widgets', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) expect(pr).toMatchObject({ number: 5511, state: 'merged', title: 'Merged current PR' }) }) it('does not carry a merged upstream branch head repo into a fallback PR number', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [{ owner: 'stablyai', repo: 'orca' }], headRepo: { owner: 'origin-owner', repo: 'orca' } }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork-owner', repo: 'orca' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'local-created-from-pr\0fork/contributor/original\n', stderr: '' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 5511, title: 'Merged upstream branch PR', state: 'closed', merged_at: '2026-06-16T17:15:33Z', html_url: 'https://github.com/stablyai/orca/pull/5511', updated_at: '2026-06-16T17:15:33Z', draft: false, mergeable_state: 'clean', head: { ref: 'contributor/original', sha: 'merged-head-oid' }, base: { ref: 'main', sha: 'base-oid' } } ]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 5511, title: 'Merged upstream branch PR', state: 'MERGED', url: 'https://github.com/stablyai/orca/pull/5511', statusCheckRollup: [], updatedAt: '2026-06-16T17:15:33Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'contributor/original', baseRefOid: 'base-oid', headRefOid: 'merged-head-oid' }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 42, title: 'Open fallback PR', state: 'OPEN', url: 'https://github.com/stablyai/orca/pull/42', statusCheckRollup: [], updatedAt: '2026-06-17T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'fresh/fallback', baseRefOid: 'base-oid', headRefOid: 'fallback-head-oid' }) }) const pr = await getPRForBranch('/repo-root', 'local-created-from-pr', null, null, 42) expect(pr).toMatchObject({ number: 42, title: 'Open fallback PR', headRepo: { owner: 'origin-owner', repo: 'orca' } }) }) it('ignores merged PRs discovered only from a fallback PR number', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 5511, title: 'Merged fallback PR', state: 'MERGED', url: 'https://github.com/stablyai/orca/pull/5511', statusCheckRollup: [], updatedAt: '2026-06-16T17:15:33Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'add-guide-for-mobile-emulator-use', baseRefOid: 'base-oid', headRefOid: 'head-oid' }) }) const pr = await getPRForBranch( '/repo-root', 'add-guide-for-mobile-emulator-use', null, null, 5511 ) expect(pr).toBeNull() }) it('returns a merged fallback PR when visible fallback lifecycle is accepted', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 5511, title: 'Merged visible fallback PR', state: 'MERGED', url: 'https://github.com/acme/widgets/pull/5511', statusCheckRollup: [], updatedAt: '2026-06-16T17:15:33Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'deleted-head', baseRefOid: 'base-oid', headRefOid: 'head-oid' }) }) const pr = await getPRForBranch('/repo-root', 'deleted-head', null, null, 5511, { acceptMergedFallbackPR: true }) expect(pr).toMatchObject({ number: 5511, state: 'merged', title: 'Merged visible fallback PR' }) }) it('falls back to the tracked upstream branch when the local branch name differs', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 78, title: 'Upstream branch PR', state: 'open', html_url: 'https://github.com/acme/widgets/pull/78', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable: true, base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'contributor/original', sha: 'upstream-head-oid' } } ]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 78, title: 'Hydrated upstream branch PR', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/78', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'contributor/original', baseRefOid: 'base-oid', headRefOid: 'upstream-head-oid' }) }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'local-created-from-pr\0origin/contributor/original\n', stderr: '' }) const pr = await getPRForBranch('/repo-root', 'local-created-from-pr') expect(gitExecFileAsyncMock).toHaveBeenCalledWith( ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads'], { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['api', 'repos/acme/widgets/pulls?head=acme%3Alocal-created-from-pr&state=all&per_page=1'], { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', 'repos/acme/widgets/pulls?head=acme%3Acontributor%2Foriginal&state=all&per_page=1'], { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, [ 'pr', 'view', '78', '--repo', 'acme/widgets', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) expect(pr).toMatchObject({ number: 78, title: 'Hydrated upstream branch PR', headSha: 'upstream-head-oid' }) }) it('does not repeat missing tracked-upstream probes during PR refresh polling', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [{ owner: 'acme', repo: 'widgets' }], headRepo: { owner: 'acme', repo: 'widgets' } }) ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) gitExecFileAsyncMock.mockResolvedValue({ stdout: 'no-pr-branch\0\n', stderr: '' }) await getPRForBranch('/repo-root', 'no-pr-branch') await getPRForBranch('/repo-root', 'no-pr-branch') await getPRForBranch('/repo-root', 'no-pr-branch') const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => (args as string[]).includes('refs/heads') ) expect(trackedUpstreamCalls).toHaveLength(1) }) it('releases tracked-upstream probe generations across runtime identities', async () => { const sshGitProvider = { exec: vi.fn().mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) } getSshGitProviderMock.mockReturnValue(sshGitProvider) getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) await getPRForBranch('/repo-root', 'feature') await getPRForBranch('/repo-root', 'feature', null, null, null, { localGitExecOptions: { wslDistro: 'Ubuntu' } }) await getPRForBranch('/repo-root', 'feature', null, 'ssh-1') expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ snapshots: 3, inFlight: 0, generations: 0 }) expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(sshGitProvider.exec).toHaveBeenCalledTimes(1) }) it('bounds unique tracked-upstream snapshots and sweeps expired identities', async () => { vi.useFakeTimers() try { getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) gitExecFileAsyncMock.mockResolvedValue({ stdout: 'feature\0\n', stderr: '' }) for (let index = 0; index < 513; index += 1) { await getPRForBranch(`/repo-root-${index}`, 'feature') } await getPRForBranch('/repo-root-512', 'feature') expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ snapshots: 512, inFlight: 0, generations: 0 }) expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(513) await vi.advanceTimersByTimeAsync(30_001) await getPRForBranch('/repo-root-fresh', 'feature') expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ snapshots: 1, inFlight: 0, generations: 0 }) } finally { vi.useRealTimers() } }) it('keeps stale probe cleanup from releasing a replacement generation', async () => { let resolveOldProbe: (value: { stdout: string; stderr: string }) => void let resolveCurrentProbe: (value: { stdout: string; stderr: string }) => void getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) gitExecFileAsyncMock .mockImplementationOnce( () => new Promise((resolve) => { resolveOldProbe = resolve }) ) .mockResolvedValueOnce({ stdout: 'replacement\0\n', stderr: '' }) .mockImplementationOnce( () => new Promise((resolve) => { resolveCurrentProbe = resolve }) ) const oldLookup = getPRForBranch('/repo-root', 'old') await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)) __resetTrackedUpstreamBranchCacheForTests() await getPRForBranch('/repo-root', 'replacement') const currentLookup = getPRForBranch('/repo-root', 'current') await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(3)) resolveOldProbe!({ stdout: 'old\0\n', stderr: '' }) await oldLookup expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ snapshots: 0, inFlight: 1, generations: 1 }) resolveCurrentProbe!({ stdout: 'current\0\n', stderr: '' }) await currentLookup expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ snapshots: 1, inFlight: 0, generations: 0 }) }) it('releases tracked-upstream probe state when setup rejects', async () => { getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) readLocalGitConfigSignatureMock.mockRejectedValue(new Error('git config unavailable')) await expect(getPRForBranch('/repo-root', 'feature')).resolves.toBeNull() expect(_getTrackedUpstreamBranchCacheSizesForTests()).toEqual({ snapshots: 0, inFlight: 0, generations: 0 }) }) it('does not fan out tracked-upstream probes after a transient for-each-ref failure', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [{ owner: 'acme', repo: 'widgets' }], headRepo: { owner: 'acme', repo: 'widgets' } }) ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) gitExecFileAsyncMock .mockRejectedValueOnce(new Error('fatal: cannot lock ref')) .mockResolvedValue({ stdout: 'alpha\0\nbeta\0\ngamma\0\n', stderr: '' }) await getPRForBranch('/repo-root', 'alpha') await getPRForBranch('/repo-root', 'beta') await getPRForBranch('/repo-root', 'gamma') const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => (args as string[]).includes('refs/heads') ) expect(trackedUpstreamCalls).toHaveLength(2) }) it('refreshes the tracked-upstream snapshot when a branch appears inside the TTL', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [{ owner: 'acme', repo: 'widgets' }], headRepo: { owner: 'acme', repo: 'widgets' } }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'existing\0\n', stderr: '' }) .mockResolvedValueOnce({ stdout: 'existing\0\nnew-feature\0origin/contributor/original\n', stderr: '' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 78, title: 'New branch upstream PR', state: 'open', html_url: 'https://github.com/acme/widgets/pull/78', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable: true, base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'contributor/original', sha: 'upstream-head-oid' } } ]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 78, title: 'Hydrated new branch upstream PR', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/78', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'contributor/original', baseRefOid: 'base-oid', headRefOid: 'upstream-head-oid' }) }) await getPRForBranch('/repo-root', 'existing') const pr = await getPRForBranch('/repo-root', 'new-feature') const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => (args as string[]).includes('refs/heads') ) expect(trackedUpstreamCalls).toHaveLength(2) expect(pr).toMatchObject({ number: 78, title: 'Hydrated new branch upstream PR' }) }) it('parses full local branch refs from the tracked-upstream snapshot', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'refs/heads/feature\0origin/contributor/original\n', stderr: '' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 80, title: 'Ambiguous ref upstream PR', state: 'open', html_url: 'https://github.com/acme/widgets/pull/80', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable: true, base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'contributor/original', sha: 'upstream-head-oid' } } ]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 80, title: 'Hydrated ambiguous ref upstream PR', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/80', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'contributor/original', baseRefOid: 'base-oid', headRefOid: 'upstream-head-oid' }) }) const pr = await getPRForBranch('/repo-root', 'feature') expect(pr).toMatchObject({ number: 80, title: 'Hydrated ambiguous ref upstream PR' }) }) it('parses full upstream refs from the tracked-upstream snapshot', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork-owner', repo: 'widgets' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'refs/heads/feature\0refs/remotes/fork/contributor/original\n', stderr: '' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 83, title: 'Full upstream ref PR', state: 'open', html_url: 'https://github.com/acme/widgets/pull/83', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable: true, base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'contributor/original', sha: 'upstream-head-oid' } } ]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 83, title: 'Hydrated full upstream ref PR', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/83', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'contributor/original', baseRefOid: 'base-oid', headRefOid: 'upstream-head-oid' }) }) const pr = await getPRForBranch('/repo-root', 'feature') expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'fork', undefined) expect(pr).toMatchObject({ number: 83, title: 'Hydrated full upstream ref PR', headRepo: { owner: 'fork-owner', repo: 'widgets' } }) }) it('ignores full local-branch upstream refs from the tracked-upstream snapshot', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'refs/heads/feature\0refs/heads/main\n', stderr: '' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([]) }) const pr = await getPRForBranch('/repo-root', 'feature') expect(pr).toBeNull() expect(getOwnerRepoForRemoteMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) }) it('invalidates the local tracked-upstream snapshot when git config changes', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [{ owner: 'acme', repo: 'widgets' }], headRepo: { owner: 'acme', repo: 'widgets' } }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) readLocalGitConfigSignatureMock .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-a\u0000100') .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-a\u0000100') .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'feature\0\n', stderr: '' }) .mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 79, title: 'Reconfigured upstream PR', state: 'open', html_url: 'https://github.com/acme/widgets/pull/79', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable: true, base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'contributor/original', sha: 'upstream-head-oid' } } ]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 79, title: 'Hydrated reconfigured upstream PR', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/79', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'contributor/original', baseRefOid: 'base-oid', headRefOid: 'upstream-head-oid' }) }) await getPRForBranch('/repo-root', 'feature') const pr = await getPRForBranch('/repo-root', 'feature') const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => (args as string[]).includes('refs/heads') ) expect(trackedUpstreamCalls).toHaveLength(2) expect(pr).toMatchObject({ number: 79, title: 'Hydrated reconfigured upstream PR' }) }) it('does not cache positive tracked-upstream entries when config changes during the snapshot', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [{ owner: 'acme', repo: 'widgets' }], headRepo: { owner: 'acme', repo: 'widgets' } }) getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) readLocalGitConfigSignatureMock .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-a\u0000100') .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'feature\0origin/old-upstream\n', stderr: '' }) .mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 82, title: 'Stable config upstream PR', state: 'open', html_url: 'https://github.com/acme/widgets/pull/82', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable: true, base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'contributor/original', sha: 'upstream-head-oid' } } ]) }) await getPRForBranch('/repo-root', 'feature') const pr = await getPRForBranch('/repo-root', 'feature') const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => (args as string[]).includes('refs/heads') ) expect(trackedUpstreamCalls).toHaveLength(2) expect(pr).toMatchObject({ number: 82, title: 'Stable config upstream PR' }) }) it('does not cache null tracked-upstream entries when config changes during the snapshot', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [{ owner: 'acme', repo: 'widgets' }], headRepo: { owner: 'acme', repo: 'widgets' } }) getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) readLocalGitConfigSignatureMock .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-a\u0000100') .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') .mockResolvedValueOnce('/repo-root/.git/config\u0000mtime-b\u0000120') gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: 'feature\0\n', stderr: '' }) .mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 85, title: 'Unstable config upstream PR', state: 'open', html_url: 'https://github.com/acme/widgets/pull/85', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable: true, base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'contributor/original', sha: 'upstream-head-oid' } } ]) }) await getPRForBranch('/repo-root', 'feature') const pr = await getPRForBranch('/repo-root', 'feature') const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => (args as string[]).includes('refs/heads') ) expect(trackedUpstreamCalls).toHaveLength(2) expect(pr).toMatchObject({ number: 85, title: 'Unstable config upstream PR' }) }) it('coalesces concurrent missing tracked-upstream probes', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [{ owner: 'acme', repo: 'widgets' }], headRepo: { owner: 'acme', repo: 'widgets' } }) ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) gitExecFileAsyncMock.mockImplementation(async () => { await Promise.resolve() return { stdout: 'no-pr-branch\0\n', stderr: '' } }) await Promise.all([ getPRForBranch('/repo-root', 'no-pr-branch'), getPRForBranch('/repo-root', 'no-pr-branch'), getPRForBranch('/repo-root', 'no-pr-branch') ]) const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => (args as string[]).includes('refs/heads') ) expect(trackedUpstreamCalls).toHaveLength(1) }) it('does not cache synthetic nulls from concurrent tracked-upstream waiters', async () => { let resolveSnapshot: (value: { stdout: string; stderr: string }) => void resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [{ owner: 'acme', repo: 'widgets' }], headRepo: { owner: 'acme', repo: 'widgets' } }) getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) readLocalGitConfigSignatureMock.mockResolvedValue( '/repo-root/.git/config\u0000mtime-a\u0000100' ) gitExecFileAsyncMock .mockImplementationOnce( () => new Promise((resolve) => { resolveSnapshot = resolve }) ) .mockResolvedValueOnce({ stdout: 'new-feature\0origin/contributor/original\n', stderr: '' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 84, title: 'Concurrent waiter upstream PR', state: 'open', html_url: 'https://github.com/acme/widgets/pull/84', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable: true, base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'contributor/original', sha: 'upstream-head-oid' } } ]) }) const existingLookup = getPRForBranch('/repo-root', 'existing') const waiterLookup = getPRForBranch('/repo-root', 'new-feature') await vi.waitFor(() => expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)) resolveSnapshot!({ stdout: 'existing\0\n', stderr: '' }) const [, waiterPr] = await Promise.all([existingLookup, waiterLookup]) const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => (args as string[]).includes('refs/heads') ) expect(trackedUpstreamCalls).toHaveLength(2) expect(waiterPr).toMatchObject({ number: 84, title: 'Concurrent waiter upstream PR' }) }) it('keeps missing tracked-upstream probes separate for host and WSL runtimes', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [{ owner: 'acme', repo: 'widgets' }], headRepo: { owner: 'acme', repo: 'widgets' } }) ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) gitExecFileAsyncMock.mockResolvedValue({ stdout: 'no-pr-branch\0\n', stderr: '' }) await getPRForBranch('/repo-root', 'no-pr-branch') await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, { localGitExecOptions: { wslDistro: 'Ubuntu' } }) await getPRForBranch('/repo-root', 'no-pr-branch') await getPRForBranch('/repo-root', 'no-pr-branch', null, null, null, { localGitExecOptions: { wslDistro: 'Ubuntu' } }) const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => (args as string[]).includes('refs/heads') ) expect(trackedUpstreamCalls).toHaveLength(2) expect(trackedUpstreamCalls[0][1]).toEqual({ cwd: '/repo-root' }) expect(trackedUpstreamCalls[1][1]).toEqual({ cwd: '/repo-root', wslDistro: 'Ubuntu' }) }) it('rechecks missing tracked-upstream probes after the null-cache TTL expires', async () => { vi.useFakeTimers() try { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [{ owner: 'acme', repo: 'widgets' }], headRepo: { owner: 'acme', repo: 'widgets' } }) ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) gitExecFileAsyncMock .mockRejectedValueOnce(new Error("fatal: no upstream configured for branch 'feature'")) .mockResolvedValueOnce({ stdout: 'feature\0origin/contributor/original\n', stderr: '' }) await getPRForBranch('/repo-root', 'feature') await vi.advanceTimersByTimeAsync(30_001) await getPRForBranch('/repo-root', 'feature') const trackedUpstreamCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => (args as string[]).includes('refs/heads') ) expect(trackedUpstreamCalls).toHaveLength(2) } finally { vi.useRealTimers() } }) it('uses the tracked upstream remote owner for fork branch lookup', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [ { owner: 'stablyai', repo: 'orca' }, { owner: 'origin-owner', repo: 'orca' } ], headRepo: { owner: 'origin-owner', repo: 'orca' } }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork-owner', repo: 'orca' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 78, title: 'Fork upstream branch PR', state: 'open', html_url: 'https://github.com/stablyai/orca/pull/78', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable: true, base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'contributor/original', sha: 'upstream-head-oid' } } ]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 78, title: 'Hydrated fork upstream branch PR', state: 'OPEN', url: 'https://github.com/stablyai/orca/pull/78', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'contributor/original', baseRefOid: 'base-oid', headRefOid: 'upstream-head-oid' }) }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'local-created-from-pr\0fork/contributor/original\n', stderr: '' }) const pr = await getPRForBranch('/repo-root', 'local-created-from-pr') expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'fork', undefined) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, [ 'api', 'repos/stablyai/orca/pulls?head=fork-owner%3Acontributor%2Foriginal&state=all&per_page=1' ], { cwd: '/repo-root' } ) expect(pr).toMatchObject({ number: 78, title: 'Hydrated fork upstream branch PR', prRepo: { owner: 'stablyai', repo: 'orca' }, headRepo: { owner: 'fork-owner', repo: 'orca' } }) }) it('uses the tracked upstream remote owner when the fork branch name matches locally', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [ { owner: 'stablyai', repo: 'orca' }, { owner: 'origin-owner', repo: 'orca' } ], headRepo: { owner: 'origin-owner', repo: 'orca' } }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'brennanb2025', repo: 'orca' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 6433, title: 'Recover Windows worktree deletes from long paths', state: 'open', html_url: 'https://github.com/stablyai/orca/pull/6433', updated_at: '2026-06-26T00:00:00Z', draft: false, mergeable: true, base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'brennanb2025/worktree-remove-fix', sha: 'same-name-fork-head-oid' } } ]) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 6433, title: 'Recover Windows worktree deletes from long paths', state: 'OPEN', url: 'https://github.com/stablyai/orca/pull/6433', statusCheckRollup: [], updatedAt: '2026-06-26T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'brennanb2025/worktree-remove-fix', baseRefOid: 'base-oid', headRefOid: 'same-name-fork-head-oid' }) }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'brennanb2025/worktree-remove-fix\0brennan/brennanb2025/worktree-remove-fix\n', stderr: '' }) const pr = await getPRForBranch('/repo-root', 'brennanb2025/worktree-remove-fix') expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'brennan', undefined) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, [ 'api', 'repos/stablyai/orca/pulls?head=brennanb2025%3Abrennanb2025%2Fworktree-remove-fix&state=all&per_page=1' ], { cwd: '/repo-root' } ) expect(pr).toMatchObject({ number: 6433, prRepo: { owner: 'stablyai', repo: 'orca' }, headRepo: { owner: 'brennanb2025', repo: 'orca' } }) }) it('does not retry same-name tracked upstream lookup for the same head repo', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [{ owner: 'acme', repo: 'widgets' }], headRepo: { owner: 'acme', repo: 'widgets' } }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([]) }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'feature/no-pr\0origin/feature/no-pr\n', stderr: '' }) await expect(getPRForBranch('/repo-root', 'feature/no-pr')).resolves.toBeNull() expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'origin', undefined) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', 'repos/acme/widgets/pulls?head=acme%3Afeature%2Fno-pr&state=all&per_page=1'], { cwd: '/repo-root' } ) }) it('checks the tracked upstream branch through the SSH git provider', async () => { const sshGitProvider = { exec: vi.fn().mockResolvedValue({ stdout: 'local-created-from-pr\0origin/contributor/original\n', stderr: '' }) } getSshGitProviderMock.mockReturnValue(sshGitProvider) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 78, title: 'SSH upstream branch PR', state: 'open', html_url: 'https://github.com/acme/widgets/pull/78', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable: true, base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'contributor/original', sha: 'upstream-head-oid' } } ]) }) const pr = await getPRForBranch( '/remote/repo-root', 'local-created-from-pr', undefined, 'ssh-1' ) expect(sshGitProvider.exec).toHaveBeenCalledWith( ['for-each-ref', '--format=%(refname)%00%(upstream)', 'refs/heads'], '/remote/repo-root' ) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', 'repos/acme/widgets/pulls?head=acme%3Acontributor%2Foriginal&state=all&per_page=1'], {} ) expect(pr).toMatchObject({ number: 78, title: 'SSH upstream branch PR' }) }) it('uses the same-name tracked upstream fork owner through the SSH git provider', async () => { const sshGitProvider = { exec: vi.fn().mockResolvedValue({ stdout: 'contributor/fix\0fork/contributor/fix\n', stderr: '' }) } getSshGitProviderMock.mockReturnValue(sshGitProvider) resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [ { owner: 'stablyai', repo: 'orca' }, { owner: 'origin-owner', repo: 'orca' } ], headRepo: { owner: 'origin-owner', repo: 'orca' } }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork-owner', repo: 'orca' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 79, title: 'SSH same-name fork PR', state: 'open', html_url: 'https://github.com/stablyai/orca/pull/79', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable: true, base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'contributor/fix', sha: 'same-name-ssh-head-oid' } } ]) }) const pr = await getPRForBranch('/remote/repo-root', 'contributor/fix', undefined, 'ssh-1') expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/remote/repo-root', 'fork', 'ssh-1') expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['api', 'repos/stablyai/orca/pulls?head=fork-owner%3Acontributor%2Ffix&state=all&per_page=1'], {} ) expect(pr).toMatchObject({ number: 79, title: 'SSH same-name fork PR', prRepo: { owner: 'stablyai', repo: 'orca' }, headRepo: { owner: 'fork-owner', repo: 'orca' } }) }) it('caches positive tracked-upstream entries for unsigned SSH runtimes during PR refresh polling', async () => { const sshGitProvider = { exec: vi.fn().mockResolvedValue({ stdout: 'refs/heads/feature\0origin/contributor/original\n', stderr: '' }) } getSshGitProviderMock.mockReturnValue(sshGitProvider) getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValue({ stdout: JSON.stringify([]) }) await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1') await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1') await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1') expect(sshGitProvider.exec).toHaveBeenCalledTimes(1) }) it('refreshes positive tracked-upstream entries for unsigned SSH runtimes after the TTL', async () => { vi.useFakeTimers() try { const sshGitProvider = { exec: vi .fn() .mockResolvedValueOnce({ stdout: 'refs/heads/feature\0origin/old-upstream\n', stderr: '' }) .mockResolvedValueOnce({ stdout: 'refs/heads/feature\0origin/contributor/original\n', stderr: '' }) } getSshGitProviderMock.mockReturnValue(sshGitProvider) getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getOwnerRepoForRemoteMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([]) }) .mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 81, title: 'Fresh SSH upstream PR', state: 'open', html_url: 'https://github.com/acme/widgets/pull/81', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable: true, base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'contributor/original', sha: 'upstream-head-oid' } } ]) }) await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1') await vi.advanceTimersByTimeAsync(30_001) const pr = await getPRForBranch('/remote/repo-root', 'feature', undefined, 'ssh-1') expect(sshGitProvider.exec).toHaveBeenCalledTimes(2) expect(pr).toMatchObject({ number: 81, title: 'Fresh SSH upstream PR' }) } finally { vi.useRealTimers() } }) it('uses linked PR number as the source of truth when provided', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ number: 77, title: 'Linked PR lookup', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/77', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'contributor/original', baseRefOid: 'base-oid', headRefOid: 'head-oid' }) }) const pr = await getPRForBranch('/repo-root', 'refs/heads/local-created-from-pr', 77) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ 'pr', 'view', '77', '--repo', 'acme/widgets', '--json', '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({ stdout: JSON.stringify({ number: 7, title: 'Fallback lookup', state: 'OPEN', url: 'https://example.com/pr/7', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: true, mergeable: 'CONFLICTING', baseRefName: 'main', headRefName: 'feature/test', baseRefOid: 'base-oid', headRefOid: 'head-oid' }) }) const pr = await getPRForBranch('/non-github-repo', 'feature/test') expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ 'pr', 'view', 'feature/test', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/non-github-repo' } ) expect(pr?.number).toBe(7) expect(pr?.state).toBe('draft') expect(pr?.mergeable).toBe('CONFLICTING') }) it('derives a read-only conflict summary for conflicting PRs when the base ref exists locally', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 42, title: 'Fix PR discovery', state: 'open', html_url: 'https://github.com/acme/widgets/pull/42', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable_state: 'dirty', base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'feature/test', sha: 'head-oid' } } ]) }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: '3\n' }) .mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/a.ts\u0000src/b.ts\u0000' }) const pr = await getPRForBranch('/repo-root', 'feature/test') expect(pr?.conflictSummary).toEqual({ baseRef: 'main', baseCommit: 'latest-', commitsBehind: 3, files: ['src/a.ts', 'src/b.ts'] }) }) it('routes local WSL branch status and conflict summary git probes through the selected distro', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 42, title: 'Fix PR discovery', state: 'open', html_url: 'https://github.com/acme/widgets/pull/42', updated_at: '2026-06-16T00:00:00Z', draft: false, mergeable_state: 'dirty', base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'feature/test', sha: 'head-oid' } } ]) }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: '2\n' }) .mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, null, { localGitExecOptions: { wslDistro: 'Ubuntu' } }) expect(pr?.conflictSummary?.files).toEqual(['src/conflict.ts']) expect(resolvePRRepositoryCandidatesMock).toHaveBeenCalledWith('/repo-root', null, { wslDistro: 'Ubuntu' }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( expect.any(Array), expect.objectContaining({ cwd: '/repo-root', wslDistro: 'Ubuntu' }) ) expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['fetch', '--quiet', 'origin', 'main'], { cwd: '/repo-root', timeout: 10_000, wslDistro: 'Ubuntu' } ) expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( [ 'merge-tree', '--write-tree', '--name-only', '-z', '--no-messages', '--merge-base', 'merge-base-oid', 'head-oid', 'latest-base-oid' ], { cwd: '/repo-root', wslDistro: 'Ubuntu' } ) }) it('treats GitHub DIRTY merge state as conflicting when mergeable is still unknown', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 42, title: 'Fix PR discovery', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/42', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'UNKNOWN', mergeStateStatus: 'DIRTY', baseRefName: 'main', headRefName: 'feature/test', baseRefOid: 'base-oid', headRefOid: 'head-oid' }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ data: { repository: { mergeQueue: null } } }) }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: '1\n' }) .mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) const pr = await getPRForBranch('/repo-root', 'feature/test', 42) expect(pr?.mergeable).toBe('CONFLICTING') expect(pr?.conflictSummary?.files).toEqual(['src/conflict.ts']) }) it('omits conflict summaries for SSH-backed repos', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 42, title: 'Fix PR discovery', state: 'open', html_url: 'https://github.com/acme/widgets/pull/42', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable_state: 'dirty', base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'feature/test', sha: 'head-oid' } } ]) }) const pr = await getPRForBranch('/remote/repo-root', 'feature/test', undefined, 'ssh-1') expect(pr?.mergeable).toBe('CONFLICTING') expect(pr?.conflictSummary).toBeUndefined() expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) it('keeps conflicted file paths when git merge-tree exits 1 with stdout', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 42, title: 'Fix PR discovery', state: 'open', html_url: 'https://github.com/acme/widgets/pull/42', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable_state: 'dirty', base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'feature/test', sha: 'head-oid' } } ]) }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: '2\n' }) .mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) const pr = await getPRForBranch('/repo-root', 'feature/test') expect(pr?.conflictSummary?.files).toEqual(['src/conflict.ts']) }) it('falls back to the legacy merge-tree invocation when Git lacks --merge-base', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 42, title: 'Fix PR discovery', state: 'open', html_url: 'https://github.com/acme/widgets/pull/42', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable_state: 'dirty', base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'feature/test', sha: 'head-oid' } } ]) }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: '2\n' }) .mockRejectedValueOnce({ stderr: "error: unknown option `merge-base'" }) .mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) const pr = await getPRForBranch('/repo-root', 'feature/test') expect(gitExecFileAsyncMock).toHaveBeenCalledWith( [ 'merge-tree', '--write-tree', '--name-only', '-z', '--no-messages', '--merge-base', 'merge-base-oid', 'head-oid', 'latest-base-oid' ], { cwd: '/repo-root' } ) expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( [ 'merge-tree', '--write-tree', '--name-only', '-z', '--no-messages', 'head-oid', 'latest-base-oid' ], { cwd: '/repo-root' } ) expect(pr?.conflictSummary?.files).toEqual(['src/conflict.ts']) }) it('skips the unsupported merge-tree --merge-base retry after the first capability miss', async () => { getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) const branchLookup = { number: 42, title: 'Fix PR discovery', state: 'open', html_url: 'https://github.com/acme/widgets/pull/42', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable_state: 'dirty', base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'feature/test', sha: 'head-oid' } } const exactLookup = { number: 42, title: 'Fix PR discovery', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/42', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'CONFLICTING', baseRefName: 'main', headRefName: 'feature/test', baseRefOid: 'base-oid', headRefOid: 'head-oid' } // Why a second head OID: identical inputs now hit the summary result // cache outright; a pushed head re-derives and must still skip the // unsupported --merge-base retry via the capability cache. const pushedBranchLookup = { ...branchLookup, head: { ref: 'feature/test', sha: 'head-oid-2' } } const pushedExactLookup = { ...exactLookup, headRefOid: 'head-oid-2' } ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([branchLookup]) }) .mockResolvedValueOnce({ stdout: JSON.stringify(exactLookup) }) .mockResolvedValueOnce({ stdout: JSON.stringify([pushedBranchLookup]) }) .mockResolvedValueOnce({ stdout: JSON.stringify(pushedExactLookup) }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: '2\n' }) .mockRejectedValueOnce({ stderr: "error: unknown option `merge-base'" }) .mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: '2\n' }) .mockRejectedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) await getPRForBranch('/repo-root', 'feature/test') await getPRForBranch('/repo-root', 'feature/test') const modernMergeTreeCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => (args as string[]).includes('--merge-base') ) const legacyMergeTreeCalls = gitExecFileAsyncMock.mock.calls.filter(([args]) => { const argv = args as string[] return argv[0] === 'merge-tree' && !argv.includes('--merge-base') }) expect(modernMergeTreeCalls).toHaveLength(1) expect(legacyMergeTreeCalls).toHaveLength(2) }) it('does not retry legacy merge-tree for older Git failures unrelated to --merge-base', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 42, title: 'Fix PR discovery', state: 'open', html_url: 'https://github.com/acme/widgets/pull/42', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable_state: 'dirty', base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'feature/test', sha: 'head-oid' } } ]) }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: '2\n' }) .mockRejectedValueOnce({ stderr: 'usage: git merge-tree ' }) const pr = await getPRForBranch('/repo-root', 'feature/test') expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(5) expect(gitExecFileAsyncMock).toHaveBeenLastCalledWith( [ 'merge-tree', '--write-tree', '--name-only', '-z', '--no-messages', '--merge-base', 'merge-base-oid', 'head-oid', 'latest-base-oid' ], { cwd: '/repo-root' } ) expect(pr?.mergeable).toBe('CONFLICTING') expect(pr?.conflictSummary).toBeUndefined() }) it('marks the conflict summary as locally clean when GitHub reports dirty but merge-tree has no conflicted files', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 42, title: 'Fix PR discovery', state: 'open', html_url: 'https://github.com/acme/widgets/pull/42', updated_at: '2026-06-20T22:16:43Z', draft: false, mergeable_state: 'dirty', base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'feature/test', sha: 'head-oid' } } ]) }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: '1\n' }) .mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000' }) const pr = await getPRForBranch('/repo-root', 'feature/test') expect(pr?.mergeable).toBe('CONFLICTING') expect(pr?.conflictSummary).toEqual({ baseRef: 'main', baseCommit: 'latest-', commitsBehind: 1, files: [], localMergeState: 'clean' }) }) it('falls back to GitHub baseRefOid when fetching or resolving the base ref fails', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify([ { number: 42, title: 'Fix PR discovery', state: 'open', html_url: 'https://github.com/acme/widgets/pull/42', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable_state: 'dirty', base: { ref: 'main', sha: 'base-oid' }, head: { ref: 'feature/test', sha: 'head-oid' } } ]) }) gitExecFileAsyncMock .mockRejectedValueOnce(new Error('fetch failed')) .mockRejectedValueOnce(new Error('missing refs/remotes/origin/main')) .mockRejectedValueOnce(new Error('missing origin/main')) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: '1\n' }) .mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/fallback.ts\u0000' }) const pr = await getPRForBranch('/repo-root', 'feature/test') expect(pr?.conflictSummary).toEqual({ baseRef: 'main', baseCommit: 'base-oi', commitsBehind: 1, files: ['src/fallback.ts'] }) }) it('returns null for empty branch (e.g. during rebase with detached HEAD)', async () => { const pr = await getPRForBranch('/repo-root', '') expect(pr).toBeNull() // Should not call gh at all expect(execFileAsyncMock).not.toHaveBeenCalled() }) it('returns null for refs/heads/ only branch (detached after strip)', async () => { const pr = await getPRForBranch('/repo-root', 'refs/heads/') expect(pr).toBeNull() expect(execFileAsyncMock).not.toHaveBeenCalled() }) it('uses fallback PR number for empty branch when detached', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ number: 42, title: 'Detached fallback lookup', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/42', statusCheckRollup: [], updatedAt: '2026-03-28T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefName: 'feature/test', baseRefOid: 'base-oid', headRefOid: 'head-oid' }) }) const pr = await getPRForBranch('/repo-root', '', null, null, 42) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ 'pr', 'view', '42', '--repo', 'acme/widgets', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) expect(pr).toMatchObject({ number: 42, title: 'Detached fallback lookup' }) }) it('returns null when pr list returns an empty array', async () => { execFileAsyncMock .mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' }) .mockResolvedValueOnce({ stdout: '[]' }) const pr = await getPRForBranch('/repo-root', 'no-pr-branch') expect(pr).toBeNull() }) it('falls back to REST number lookup when linked PR GraphQL lookup is rate limited', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'linked-head-oid\n', stderr: '' }) ghExecFileAsyncMock .mockRejectedValueOnce(new Error('GraphQL: API rate limit already exceeded')) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 99, title: 'REST linked PR lookup', state: 'closed', merged_at: '2026-03-28T00:00:00Z', html_url: 'https://github.com/acme/widgets/pull/99', updated_at: '2026-03-28T00:00:00Z', draft: false, mergeable_state: 'clean', head: { ref: 'someone/fix', sha: 'linked-head-oid' }, base: { ref: 'main', sha: 'linked-base-oid' } }) }) const pr = await getPRForBranch('/repo-root', 'feature/test', 99) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, [ 'pr', 'view', '99', '--repo', 'acme/widgets', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['api', 'repos/acme/widgets/pulls/99'], { cwd: '/repo-root' }) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(pr).toMatchObject({ number: 99, state: 'merged', mergeable: 'MERGEABLE', headSha: 'linked-head-oid' }) }) it('resolves fork PR push target using the origin URL protocol', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ head: { ref: 'prateek/fix-sidebar-agents-toggle', repo: { full_name: 'prateek/orca', name: 'orca', clone_url: 'https://github.com/prateek/orca.git', ssh_url: 'git@github.com:prateek/orca.git', owner: { login: 'prateek' } } } }) }) getRemoteUrlForRepoMock.mockResolvedValueOnce('git@github.com:stablyai/orca.git') const target = await getPullRequestPushTarget('/repo-root', 1738) expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'repos/stablyai/orca/pulls/1738'], { cwd: '/repo-root' }) expect(target).toEqual({ pushTarget: { remoteName: 'pr-prateek-orca', branchName: 'prateek/fix-sidebar-agents-toggle', remoteUrl: 'git@github.com:prateek/orca.git' } }) }) it('pins explicit origin push-target lookup when upstream has the same PR number', async () => { getOwnerRepoMock.mockResolvedValue({ owner: 'fork', repo: 'orca' }) resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [ { owner: 'upstream', repo: 'orca' }, { owner: 'fork', repo: 'orca' } ], headRepo: { owner: 'fork', repo: 'orca' } }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ head: { ref: 'contributor/fix', repo: { full_name: 'contributor/orca', name: 'orca', clone_url: 'https://github.com/contributor/orca.git', ssh_url: 'git@github.com:contributor/orca.git', owner: { login: 'contributor' } } } }) }) getRemoteUrlForRepoMock.mockResolvedValueOnce('git@github.com:fork/orca.git') await getPullRequestPushTarget('/repo-root', 1738, null, {}, 'origin') expect(resolvePRRepositoryCandidatesMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'repos/fork/orca/pulls/1738'], { cwd: '/repo-root', host: 'github.com' }) expect(ghExecFileAsyncMock).not.toHaveBeenCalledWith( ['api', 'repos/upstream/orca/pulls/1738'], expect.anything() ) }) it('surfaces maintainer_can_modify=false alongside a fork PR push target', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ maintainer_can_modify: false, head: { ref: 'prateek/fix-sidebar-agents-toggle', repo: { full_name: 'prateek/orca', name: 'orca', clone_url: 'https://github.com/prateek/orca.git', ssh_url: 'git@github.com:prateek/orca.git', owner: { login: 'prateek' } } } }) }) getRemoteUrlForRepoMock.mockResolvedValueOnce('git@github.com:stablyai/orca.git') await expect(getPullRequestPushTarget('/repo-root', 1738)).resolves.toEqual({ pushTarget: { remoteName: 'pr-prateek-orca', branchName: 'prateek/fix-sidebar-agents-toggle', remoteUrl: 'git@github.com:prateek/orca.git' }, maintainerCanModify: false }) }) it('omits maintainerCanModify when the API does not report the flag', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ head: { ref: 'fix-sidebar', repo: { full_name: 'stablyai/orca', name: 'orca', clone_url: 'https://github.com/stablyai/orca.git', ssh_url: 'git@github.com:stablyai/orca.git', owner: { login: 'stablyai' } } } }) }) await expect(getPullRequestPushTarget('/repo-root', 1738)).resolves.toEqual({ pushTarget: { remoteName: 'origin', branchName: 'fix-sidebar' } }) }) it('uses origin for same-repository PR push targets', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ head: { ref: 'fix-sidebar', repo: { full_name: 'stablyai/orca', name: 'orca', clone_url: 'https://github.com/stablyai/orca.git', ssh_url: 'git@github.com:stablyai/orca.git', owner: { login: 'stablyai' } } } }) }) await expect(getPullRequestPushTarget('/repo-root', 1738)).resolves.toEqual({ pushTarget: { remoteName: 'origin', branchName: 'fix-sidebar' } }) expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) it('keeps getRepoSlug origin-based on a fork checkout (#7331)', async () => { // The slug is the checkout's own identity (renderer display, icon // autodetect); it must not flip to the upstream parent. getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => remoteName === 'origin' ? { owner: 'fsdwen', repo: 'orca' } : { owner: 'stablyai', repo: 'orca' } ) // Why: getRepoSlug imports getOriginGitHubApiRepository; the suite bridge // prefers getOwnerRepoForRemote for origin, so set both seams. getOwnerRepoMock.mockResolvedValue({ owner: 'fsdwen', repo: 'orca' }) await expect(getRepoSlug('/repo-root')).resolves.toEqual({ owner: 'fsdwen', repo: 'orca', host: 'github.com' }) }) it('resolves a distinct upstream remote as the repo upstream', async () => { // getRepoUpstream probes origin then upstream via getOwnerRepoForRemote (#7331). getOwnerRepoMock.mockResolvedValue({ owner: 'tmchow', repo: 'orca' }) getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => remoteName === 'origin' ? { owner: 'tmchow', repo: 'orca' } : { owner: 'stablyai', repo: 'orca' } ) // Why: the suite bridge returns getOwnerRepoForRemote fixtures as-is (no host pin). await expect(getRepoUpstream('/repo-root')).resolves.toEqual({ owner: 'stablyai', repo: 'orca' }) expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) it('does not treat a same-repository upstream remote as a fork', async () => { getOwnerRepoMock.mockResolvedValue({ owner: 'StablyAI', repo: 'Orca' }) getOwnerRepoForRemoteMock.mockImplementation(async (_repoPath: string, remoteName: string) => remoteName === 'origin' ? { owner: 'StablyAI', repo: 'Orca' } : { owner: 'stablyai', repo: 'orca' } ) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ isFork: false, parent: null }) }) await expect(getRepoUpstream('/repo-root')).resolves.toBeNull() expect(ghExecFileAsyncMock).toHaveBeenCalledWith( // Why: positional slugs are explicit about github.com too, so GH_HOST // cannot redirect them. ['repo', 'view', 'github.com/StablyAI/Orca', '--json', 'isFork,parent'], { cwd: '/repo-root', host: 'github.com', timeout: 10_000 } ) }) it('does not mark an upstream-only GitHub remote as a fork', async () => { // Missing origin short-circuits before the upstream probe. getOwnerRepoForRemoteMock.mockResolvedValueOnce(null) await expect(getRepoUpstream('/repo-root')).resolves.toBeNull() expect(getOwnerRepoForRemoteMock).toHaveBeenCalledTimes(1) expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith('/repo-root', 'origin', undefined, {}) expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) it('falls back to the GitHub parent when no upstream remote is configured', async () => { getOwnerRepoForRemoteMock .mockResolvedValueOnce({ owner: 'tmchow', repo: 'orca' }) .mockResolvedValueOnce(null) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ isFork: true, parent: { name: 'orca', owner: { login: 'stablyai' } } }) }) await expect(getRepoUpstream('/repo-root')).resolves.toEqual({ owner: 'stablyai', repo: 'orca', // Why: fork parents live on the same server as the fork's origin. host: 'github.com' }) }) it('routes GHES push-target probes through the Enterprise host', async () => { const ghes = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [ghes], headRepo: ghes }) getOwnerRepoForRemoteMock.mockResolvedValueOnce(ghes) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ head: { ref: 'feature', repo: { full_name: 'team/orca', name: 'orca', clone_url: 'https://github.acme-corp.com/team/orca.git', ssh_url: 'git@github.acme-corp.com:team/orca.git', owner: { login: 'team' } } } }) }) await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toEqual({ pushTarget: { remoteName: 'origin', branchName: 'feature' } }) // Why: the candidate probe must pin options.host so the runner targets the // Enterprise server instead of gh's default host. expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['api', 'repos/team/orca/pulls/7'], expect.objectContaining({ host: 'github.acme-corp.com' }) ) }) it('does not confuse same-slug PR repositories across GitHub hosts', async () => { const enterprise = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' } const dotCom = { owner: 'team', repo: 'orca', host: 'github.com' } resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [enterprise, dotCom], headRepo: dotCom }) getOwnerRepoForRemoteMock.mockResolvedValueOnce(dotCom) getRemoteUrlForRepoMock.mockResolvedValueOnce('git@github.com:team/orca.git') ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ head: { ref: 'feature', repo: { full_name: 'team/orca', name: 'orca', clone_url: 'https://github.acme-corp.com/team/orca.git', ssh_url: 'git@github.acme-corp.com:team/orca.git', owner: { login: 'team' } } } }) }) await expect(getPullRequestPushTarget('/repo-root', 7)).resolves.toEqual({ pushTarget: { remoteName: 'pr-team-orca', branchName: 'feature', remoteUrl: 'git@github.acme-corp.com:team/orca.git' } }) }) it('probes additional PR repo candidates when the first lookup is not found', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [ { owner: 'fork', repo: 'orca' }, { owner: 'stablyai', repo: 'orca' } ], headRepo: { owner: 'fork', repo: 'orca' } }) getOwnerRepoForRemoteMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' }) ghExecFileAsyncMock .mockRejectedValueOnce(new Error('HTTP 404: Not Found')) .mockResolvedValueOnce({ stdout: JSON.stringify({ head: { ref: 'feature/test', repo: { full_name: 'fork/orca', name: 'orca', clone_url: 'https://github.com/fork/orca.git', ssh_url: 'git@github.com:fork/orca.git', owner: { login: 'fork' } } } }) }) await expect(getPullRequestPushTarget('/repo-root', 1849)).resolves.toEqual({ pushTarget: { remoteName: 'origin', branchName: 'feature/test' } }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['api', 'repos/fork/orca/pulls/1849'], { cwd: '/repo-root' }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', 'repos/stablyai/orca/pulls/1849'], { cwd: '/repo-root' } ) }) it('probes the next PR work-item candidate after a permission denial', async () => { const upstream = { owner: 'upstream', repo: 'orca', host: 'github.com' } const origin = { owner: 'fork', repo: 'orca', host: 'github.com' } resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [upstream, origin], headRepo: origin }) ghExecFileAsyncMock .mockRejectedValueOnce(new Error('GraphQL: Resource not accessible by integration')) .mockRejectedValueOnce(new Error('GraphQL: Resource not accessible by integration')) .mockResolvedValueOnce({ stdout: JSON.stringify({ number: 42, title: 'Origin PR', state: 'OPEN', url: 'https://github.com/fork/orca/pull/42', labels: [], updatedAt: '2026-07-16T00:00:00Z', author: { login: 'octo' }, isDraft: false }) }) .mockRejectedValueOnce(new Error('metadata unavailable')) await expect(getWorkItem('/repo-root', 42, 'pr')).resolves.toMatchObject({ number: 42, title: 'Origin PR', prRepo: origin }) expect(ghExecFileAsyncMock.mock.calls[0][0]).toEqual( expect.arrayContaining(['pr', 'view', '--repo', 'upstream/orca']) ) expect(ghExecFileAsyncMock.mock.calls[1][0]).toEqual(['api', 'repos/upstream/orca/pulls/42']) expect(ghExecFileAsyncMock.mock.calls[2][0]).toEqual( expect.arrayContaining(['pr', 'view', '--repo', 'fork/orca']) ) }) it('normalizes reviewer avatars from REST pull request payloads', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ number: 42, title: 'Review me', state: 'open', html_url: 'https://github.com/acme/widgets/pull/42', labels: [], updated_at: '2026-03-28T00:00:00Z', user: { login: 'author' }, draft: false, requested_reviewers: [ { login: 'AmethystLiang', avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4' } ] }) }) await expect(getWorkItem('/repo-root', 42, 'pr')).resolves.toMatchObject({ reviewRequests: [ { login: 'AmethystLiang', avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4' } ] }) }) }) describe('updatePRState', () => { beforeEach(() => { ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() // Why: updatePRState resolves origin through getOwnerRepoForRemote. getOwnerRepoForRemoteMock.mockImplementation( async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null ) ghRepoExecOptionsMock.mockClear() githubRepoContextMock.mockClear() acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() }) it('reopens pull requests through the gh PR command', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect(updatePRState('/repo-root', 3977, { state: 'open' })).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['pr', 'reopen', '3977', '--repo', 'stablyai/orca'], { cwd: '/repo-root', host: 'github.com' } ) expect(acquireMock).toHaveBeenCalledTimes(1) expect(releaseMock).toHaveBeenCalledTimes(1) }) it('closes pull requests through the gh PR command', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect(updatePRState('/repo-root', 3977, { state: 'closed' })).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['pr', 'close', '3977', '--repo', 'stablyai/orca'], { cwd: '/repo-root', host: 'github.com' } ) }) it('reopens SSH-backed pull requests without local cwd options', async () => { getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' }) ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect( updatePRState('/remote/repo-root', 3977, { state: 'open' }, 'ssh-1') ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenCalledWith( ['pr', 'reopen', '3977', '--repo', 'stablyai/orca'], { host: 'github.com' } ) }) }) describe('GitHub GraphQL rate-limit guard', () => { beforeEach(() => { execFileAsyncMock.mockReset() ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() getIssueOwnerRepoMock.mockReset() getOwnerRepoForRemoteMock.mockReset() // Why: getPRComments and mutations resolve origin via getOwnerRepoForRemote. getOwnerRepoForRemoteMock.mockImplementation( async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) => remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null ) resolvePRRepositoryCandidatesMock.mockReset() resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => { const origin = await getOwnerRepoMock(repoPath, connectionId) return { candidates: origin ? [origin] : [], headRepo: origin } }) getRemoteUrlForRepoMock.mockReset() gitExecFileAsyncMock.mockReset() rateLimitGuardMock.mockReset() rateLimitGuardMock.mockReturnValue({ blocked: false }) noteRateLimitSpendMock.mockReset() acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() _resetMergeQueueCacheForTests() __resetPRConflictSummaryCachesForTests() }) it('skips PR review-thread GraphQL fetch while preserving REST comments', async () => { rateLimitGuardMock.mockImplementation(((bucket: string) => bucket === 'graphql' ? { blocked: true, remaining: 4, limit: 5000, resetAt: 1_800_000_000 } : { blocked: false }) as () => RateLimitGuardResult) getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' }) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([ { id: 10, user: { login: 'octo', avatar_url: 'https://avatar', type: 'User' }, body: 'top-level', created_at: '2026-04-01T00:00:00Z', html_url: 'https://github.com/acme/widgets/pull/7#issuecomment-10' } ]) }) .mockResolvedValueOnce({ stdout: '[]' }) const comments = await getPRComments('/repo-root', 7) expect(comments).toHaveLength(1) expect(comments[0].body).toBe('top-level') expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(ghExecFileAsyncMock.mock.calls.some((call) => call[0][1] === 'graphql')).toBe(false) expect(noteRateLimitSpendMock).not.toHaveBeenCalledWith('graphql') }) it('uses explicit PR repo for comments when a fork PR is discovered', async () => { rateLimitGuardMock.mockImplementation(((bucket: string) => bucket === 'graphql' ? { blocked: true, remaining: 4, limit: 5000, resetAt: 1_800_000_000 } : { blocked: false }) as () => RateLimitGuardResult) ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify([ { id: 10, user: { login: 'octo', avatar_url: 'https://avatar', type: 'User' }, body: 'top-level', created_at: '2026-04-01T00:00:00Z', html_url: 'https://github.com/stablyai/orca/pull/7#issuecomment-10' } ]) }) .mockResolvedValueOnce({ stdout: '[]' }) await getPRComments( '/repo-root', 7, { prRepo: { owner: 'stablyai', repo: 'orca', host: 'github.com' } }, undefined ) expect(getOwnerRepoMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['api', '--cache', '60s', 'repos/stablyai/orca/issues/7/comments?per_page=100'], { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['api', '--cache', '60s', 'repos/stablyai/orca/pulls/7/reviews?per_page=100'], { cwd: '/repo-root', host: 'github.com' } ) }) it('uses explicit PR repo for merge and title mutations', 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', baseRefName: 'main', baseRefOid: 'base-oid', headRefOid: 'head-oid' }) }) .mockResolvedValue({ stdout: '', stderr: '' }) await expect( mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca', host: 'github.com' }) ).resolves.toEqual({ ok: true }) await expect( updatePRTitle('/repo-root', 7, 'New title', undefined, { owner: 'stablyai', repo: 'orca', host: 'github.com' }) ).resolves.toBe(true) expect(getOwnerRepoMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, [ 'pr', 'view', '7', '--repo', 'stablyai/orca', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,reviewDecision,mergeStateStatus,autoMergeRequest,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), host: 'github.com' }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['pr', 'edit', '7', '--title', 'New title', '--repo', 'stablyai/orca'], { cwd: '/repo-root', host: 'github.com' } ) }) it('sets and disables PR auto-merge with explicit PR repos and SSH context', async () => { ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) }) .mockResolvedValue({ stdout: '', stderr: '' }) await expect( setPRAutoMerge('/remote/repo-root', 7, true, 'squash', 'ssh-1', { owner: 'stablyai', repo: 'orca', host: 'github.com' }) ).resolves.toEqual({ ok: true }) await expect( setPRAutoMerge('/remote/repo-root', 7, false, 'squash', 'ssh-1', { owner: 'stablyai', repo: 'orca', host: 'github.com' }) ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 1, ['pr', 'view', '7', '--json', 'id,headRefOid,baseRefName', '--repo', 'stablyai/orca'], { host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, expect.arrayContaining([ 'api', 'graphql', '-f', 'pullRequestId=PR_kwDO123', '-f', 'mergeMethod=SQUASH', '-f', 'expectedHeadOid=head-oid' ]), expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), host: 'github.com' }) ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['pr', 'merge', '7', '--disable-auto', '--repo', 'stablyai/orca'], expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), host: 'github.com' }) ) expect(ghExecFileAsyncMock.mock.calls[0]?.[1]).not.toHaveProperty('cwd') }) it('enables auto-merge without invoking the direct merge command', async () => { ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) }) .mockResolvedValue({ stdout: '', stderr: '' }) await expect( setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { owner: 'stablyai', repo: 'orca', host: 'github.com' }) ).resolves.toEqual({ ok: true }) expect( ghExecFileAsyncMock.mock.calls.some((call) => (call[0] as string[]).some((arg) => arg.includes('enablePullRequestAutoMerge')) ) ).toBe(true) expect( ghExecFileAsyncMock.mock.calls.some( (call) => call[0][0] === 'pr' && call[0][1] === 'merge' && (call[0] as string[]).includes('--auto') ) ).toBe(false) }) it('translates the GitHub clean-status rejection into an actionable message', async () => { ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid' }) }) .mockRejectedValueOnce(new Error('GraphQL: Pull request is in clean status')) await expect( setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { owner: 'stablyai', repo: 'orca', host: 'github.com' }) ).resolves.toEqual({ ok: false, error: 'This pull request can already be merged. Use Merge instead of auto-merge.' }) }) it('uses the queue-aware gh merge path when the base branch has a merge queue', async () => { ghExecFileAsyncMock .mockResolvedValueOnce({ stdout: JSON.stringify({ id: 'PR_kwDO123', headRefOid: 'head-oid', baseRefName: 'main' }) }) .mockResolvedValueOnce({ stdout: JSON.stringify({ data: { repository: { mergeQueue: { id: 'MQ_kw' } } } }) }) .mockResolvedValue({ stdout: '', stderr: '' }) await expect( setPRAutoMerge('/repo-root', 7, true, 'squash', undefined, { owner: 'stablyai', repo: 'orca', host: 'github.com' }) ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, expect.arrayContaining(['api', 'graphql', '-f', 'branch=main']), { cwd: '/repo-root', host: 'github.com' } ) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 3, ['pr', 'merge', '7', '--auto', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ cwd: '/repo-root', env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }), host: 'github.com' }) ) expect( ghExecFileAsyncMock.mock.calls.some((call) => (call[0] as string[]).some((arg) => arg.includes('enablePullRequestAutoMerge')) ) ).toBe(false) }) it('blocks direct merge when GitHub reports required approval', async () => { ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: JSON.stringify({ 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', host: 'github.com' }) ).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', host: 'github.com' }) ).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('bounds merge metadata cache entries across many base branches', async () => { getOwnerRepoMock.mockResolvedValue({ owner: 'stablyai', repo: 'orca' }) let prViewCount = 0 ghExecFileAsyncMock.mockImplementation(async (args) => { if (args.includes('graphql')) { return { stdout: JSON.stringify({ data: { repository: { mergeQueue: null } } }) } } prViewCount += 1 return { stdout: JSON.stringify({ number: prViewCount, title: 'PR', state: 'OPEN', url: `https://github.com/stablyai/orca/pull/${prViewCount}`, statusCheckRollup: [], updatedAt: '2026-04-01T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', reviewDecision: 'APPROVED', mergeStateStatus: 'CLEAN', autoMergeRequest: null, baseRefName: `base-${prViewCount}`, baseRefOid: 'base-oid', headRefOid: 'head-oid' }) } }) for (let i = 0; i < 260; i++) { await getPRForBranch('/repo-root', `feature/${i}`, i + 1) } expect(_getMergeQueueCacheSizeForTests()).toBe(256) }) it('isolates merge metadata for the same slug on different GitHub hosts', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValue({ candidates: [ { owner: 'acme', repo: 'widgets', host: 'github.com' }, { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' } ], headRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } }) const prView = { number: 7, title: 'PR', state: 'OPEN', url: 'https://github.com/acme/widgets/pull/7', statusCheckRollup: [], updatedAt: '2026-07-16T00:00:00Z', isDraft: false, mergeable: 'MERGEABLE', baseRefName: 'main', headRefOid: 'head-oid' } ghExecFileAsyncMock.mockImplementation(async (args, options) => { if (args.includes('graphql')) { const enterprise = options?.host === 'github.acme-corp.com' return { stdout: JSON.stringify({ data: { repository: { mergeQueue: null, autoMergeAllowed: !enterprise } } }) } } return { stdout: JSON.stringify(prView) } }) const githubDotCom = await getWorkItemByOwnerRepo( '/repo-root', { owner: 'acme', repo: 'widgets', host: 'github.com' }, 7, 'pr' ) const enterprise = await getWorkItemByOwnerRepo( '/repo-root', { owner: 'acme', repo: 'widgets', host: 'github.acme-corp.com' }, 7, 'pr' ) expect(githubDotCom?.autoMergeAllowed).toBe(true) expect(enterprise?.autoMergeAllowed).toBe(false) const graphqlCalls = ghExecFileAsyncMock.mock.calls.filter(([args]) => args.includes('graphql')) expect(graphqlCalls).toHaveLength(2) expect(graphqlCalls[1]?.[1]).toEqual(expect.objectContaining({ host: 'github.acme-corp.com' })) }) it('rejects explicit work-item lookups outside configured repository candidates', async () => { resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [{ owner: 'acme', repo: 'widgets', host: 'github.com' }], headRepo: { owner: 'acme', repo: 'widgets', host: 'github.com' } }) await expect( getWorkItemByOwnerRepo( '/repo-root', { owner: 'victim', repo: 'secrets', host: 'evil.example.test' }, 7, 'pr' ) ).resolves.toBeNull() expect(acquireMock).not.toHaveBeenCalled() expect(ghExecFileAsyncMock).not.toHaveBeenCalled() }) 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' }) }) gitExecFileAsyncMock .mockResolvedValueOnce({ stdout: '' }) .mockResolvedValueOnce({ stdout: 'latest-base-oid\n' }) .mockResolvedValueOnce({ stdout: 'merge-base-oid\n' }) .mockResolvedValueOnce({ stdout: '3\n' }) .mockResolvedValueOnce({ stdout: 'result-tree-oid\u0000src/conflict.ts\u0000' }) await expect( mergePR('/repo-root', 7, 'squash', undefined, { owner: 'stablyai', repo: 'orca', host: 'github.com' }) ).resolves.toEqual({ ok: false, error: 'This pull request has merge conflicts and cannot be merged yet.\n' + '3 commits behind main (base commit: latest-).\n\n' + 'Conflicting files:\n' + '- src/conflict.ts' }) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1) }) it('does not run merge conflict preflight for SSH-backed repos', 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' }) }) .mockResolvedValueOnce({ stdout: '', stderr: '' }) await expect( mergePR('/remote/repo-root', 7, 'squash', 'ssh-1', { owner: 'stablyai', repo: 'orca', host: 'github.com' }) ).resolves.toEqual({ ok: true }) expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2) expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith( 2, ['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'], expect.objectContaining({ env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' }) }) ) expect(ghExecFileAsyncMock.mock.calls[0]?.[1]).not.toHaveProperty('cwd') expect(gitExecFileAsyncMock).not.toHaveBeenCalled() }) it('blocks review-thread resolve mutations before spawning gh when GraphQL is low', async () => { rateLimitGuardMock.mockReturnValue({ blocked: true, remaining: 4, limit: 5000, resetAt: 1_800_000_000 }) await expect(resolveReviewThread('/repo-root', 'thread-1', true)).resolves.toBe(false) expect(ghExecFileAsyncMock).not.toHaveBeenCalled() expect(noteRateLimitSpendMock).not.toHaveBeenCalled() }) }) describe('getGitHubPRLookupRateLimitBlock', () => { beforeEach(() => { execFileAsyncMock.mockReset() ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' }) getRemoteUrlForRepoMock.mockReset() gitExecFileAsyncMock.mockReset() getRateLimitMock.mockReset() getRateLimitMock.mockResolvedValue(undefined) rateLimitGuardMock.mockReset() rateLimitGuardMock.mockReturnValue({ blocked: false }) noteRateLimitSpendMock.mockReset() _resetOwnerRepoCache() }) it('reports no block while every lookup bucket has budget', async () => { await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toBeNull() expect(getRateLimitMock).toHaveBeenCalled() }) it('reports a block when either lookup bucket is exhausted', async () => { rateLimitGuardMock.mockImplementation(((bucket: string) => bucket === 'graphql' ? { blocked: true, remaining: 4, limit: 5000, resetAt: 1_800_000_000 } : { blocked: false }) as () => RateLimitGuardResult) await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toEqual({ resetAt: 1_800_000_000 }) }) it('reports the latest reset when both lookup buckets are exhausted', async () => { rateLimitGuardMock.mockImplementation(((bucket: string) => ({ blocked: true, remaining: 4, limit: 5000, // Why: core resets first, so returning it would retry into graphql's block. resetAt: bucket === 'core' ? 1_800_000_000 : 1_800_003_600 })) as () => RateLimitGuardResult) await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toEqual({ resetAt: 1_800_003_600 }) }) it('reports the later reset when graphql outlasts core', async () => { // Retrying at the earlier reset would fail again on the bucket still blocked. rateLimitGuardMock.mockImplementation(((bucket: string) => ({ blocked: true, remaining: 0, limit: 5000, resetAt: bucket === 'graphql' ? 1_800_000_600 : 1_800_000_000 })) as () => RateLimitGuardResult) await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toEqual({ resetAt: 1_800_000_600 }) }) it('fails open when the exempt rate-limit probe itself fails', async () => { getRateLimitMock.mockRejectedValue(new Error('probe offline')) await expect(getGitHubPRLookupRateLimitBlock('/repo-root')).resolves.toBeNull() }) })