import { beforeEach, describe, expect, it, vi } from 'vitest' const { execFileAsyncMock, ghExecFileAsyncMock, getOwnerRepoMock, gitExecFileAsyncMock, acquireMock, releaseMock } = vi.hoisted(() => ({ execFileAsyncMock: vi.fn(), ghExecFileAsyncMock: vi.fn(), getOwnerRepoMock: vi.fn(), gitExecFileAsyncMock: vi.fn(), acquireMock: vi.fn(), releaseMock: vi.fn() })) vi.mock('./gh-utils', () => ({ execFileAsync: execFileAsyncMock, ghExecFileAsync: ghExecFileAsyncMock, getOwnerRepo: getOwnerRepoMock, acquire: acquireMock, release: releaseMock, _resetOwnerRepoCache: vi.fn() })) vi.mock('../git/runner', () => ({ gitExecFileAsync: gitExecFileAsyncMock })) import { getPRForBranch, _resetOwnerRepoCache } from './client' describe('getPRForBranch', () => { beforeEach(() => { execFileAsyncMock.mockReset() ghExecFileAsyncMock.mockReset() getOwnerRepoMock.mockReset() gitExecFileAsyncMock.mockReset() acquireMock.mockReset() releaseMock.mockReset() acquireMock.mockResolvedValue(undefined) _resetOwnerRepoCache() }) 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', 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', 'refs/heads/feature/test') expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root') expect(ghExecFileAsyncMock).toHaveBeenCalledWith( [ 'pr', 'list', '--repo', 'acme/widgets', '--head', 'feature/test', '--state', 'all', '--limit', '1', '--json', 'number,title,state,url,statusCheckRollup,updatedAt,isDraft,mergeable,baseRefName,headRefName,baseRefOid,headRefOid' ], { cwd: '/repo-root' } ) expect(pr?.number).toBe(42) expect(pr?.state).toBe('open') expect(pr?.mergeable).toBe('MERGEABLE') }) 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,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', 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' } ]) }) 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('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', 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' } ]) }) 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 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', 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' } ]) }) 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('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() }) })