mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 08:02:28 +00:00
- Use fallback PR numbers after branch lookup misses, including detached HEAD - Preserve review cards for forked or deleted-head PRs across manual refreshes - Clear stale GitHub PR cache entries when unlinking worktree review metadata
1231 lines
41 KiB
TypeScript
1231 lines
41 KiB
TypeScript
/* 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'
|
|
|
|
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,
|
|
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<() => RateLimitGuardResult>(() => ({ blocked: false })),
|
|
noteRateLimitSpendMock: vi.fn(),
|
|
ghRepoExecOptionsMock: vi.fn((context) =>
|
|
context.connectionId ? {} : { cwd: context.repoPath }
|
|
),
|
|
githubRepoContextMock: vi.fn((repoPath, connectionId) => ({
|
|
repoPath,
|
|
connectionId: connectionId ?? null
|
|
})),
|
|
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 }
|
|
}
|
|
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('./rate-limit', () => ({
|
|
getRateLimit: getRateLimitMock,
|
|
rateLimitGuard: rateLimitGuardMock,
|
|
noteRateLimitSpend: noteRateLimitSpendMock
|
|
}))
|
|
|
|
import {
|
|
getPRComments,
|
|
getPRForBranch,
|
|
getWorkItem,
|
|
getPullRequestPushTarget,
|
|
mergePR,
|
|
resolveReviewThread,
|
|
updatePRTitle,
|
|
_resetOwnerRepoCache
|
|
} from './client'
|
|
|
|
describe('getPRForBranch', () => {
|
|
beforeEach(() => {
|
|
execFileAsyncMock.mockReset()
|
|
ghExecFileAsyncMock.mockReset()
|
|
getOwnerRepoMock.mockReset()
|
|
getIssueOwnerRepoMock.mockReset()
|
|
getOwnerRepoForRemoteMock.mockReset()
|
|
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()
|
|
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',
|
|
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,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,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,baseRefName,headRefName,baseRefOid,headRefOid'
|
|
],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
expect(pr).toMatchObject({
|
|
number: 99,
|
|
title: 'Linked PR',
|
|
state: 'open',
|
|
headSha: 'linked-head-oid'
|
|
})
|
|
})
|
|
|
|
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,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,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('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' }
|
|
}
|
|
])
|
|
})
|
|
|
|
const pr = await getPRForBranch('/repo-root', 'feature/test', null, null, 42)
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
|
['api', 'repos/acme/widgets/pulls?head=acme%3Afeature%2Ftest&state=all&per_page=1'],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
expect(pr).toMatchObject({ number: 43, title: 'Branch PR wins' })
|
|
})
|
|
|
|
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,baseRefName,headRefName,baseRefOid,headRefOid'
|
|
],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
expect(pr).toMatchObject({ number: 42, title: 'Fallback PR lookup' })
|
|
})
|
|
|
|
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,baseRefName,headRefName,baseRefOid,headRefOid'
|
|
],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
expect(pr?.number).toBe(77)
|
|
})
|
|
|
|
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',
|
|
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('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 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,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,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' }
|
|
}
|
|
}
|
|
})
|
|
})
|
|
gitExecFileAsyncMock.mockResolvedValueOnce({
|
|
stdout: 'git@github.com:stablyai/orca.git\n',
|
|
stderr: ''
|
|
})
|
|
|
|
const target = await getPullRequestPushTarget('/repo-root', 1738)
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(['api', 'repos/stablyai/orca/pulls/1738'], {
|
|
cwd: '/repo-root'
|
|
})
|
|
expect(target).toEqual({
|
|
remoteName: 'pr-prateek-orca',
|
|
branchName: 'prateek/fix-sidebar-agents-toggle',
|
|
remoteUrl: 'git@github.com:prateek/orca.git'
|
|
})
|
|
})
|
|
|
|
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({
|
|
remoteName: 'origin',
|
|
branchName: 'fix-sidebar'
|
|
})
|
|
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
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({
|
|
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('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('GitHub GraphQL rate-limit guard', () => {
|
|
beforeEach(() => {
|
|
execFileAsyncMock.mockReset()
|
|
ghExecFileAsyncMock.mockReset()
|
|
getOwnerRepoMock.mockReset()
|
|
getIssueOwnerRepoMock.mockReset()
|
|
getOwnerRepoForRemoteMock.mockReset()
|
|
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()
|
|
})
|
|
|
|
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' } }, undefined)
|
|
|
|
expect(getOwnerRepoMock).not.toHaveBeenCalled()
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
['api', '--cache', '60s', 'repos/stablyai/orca/issues/7/comments?per_page=100'],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
['api', '--cache', '60s', 'repos/stablyai/orca/pulls/7/reviews?per_page=100'],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
})
|
|
|
|
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' })
|
|
).resolves.toEqual({ ok: true })
|
|
await expect(
|
|
updatePRTitle('/repo-root', 7, 'New title', undefined, { owner: 'stablyai', repo: 'orca' })
|
|
).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,baseRefName,headRefName,baseRefOid,headRefOid'
|
|
],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
['pr', 'merge', '7', '--squash', '--repo', 'stablyai/orca'],
|
|
expect.objectContaining({
|
|
cwd: '/repo-root',
|
|
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })
|
|
})
|
|
)
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
|
3,
|
|
['pr', 'edit', '7', '--title', 'New title', '--repo', 'stablyai/orca'],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
})
|
|
|
|
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' })
|
|
).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: '', stderr: '' })
|
|
|
|
await expect(
|
|
mergePR('/remote/repo-root', 7, 'squash', 'ssh-1', { owner: 'stablyai', repo: 'orca' })
|
|
).resolves.toEqual({ ok: true })
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
|
['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()
|
|
})
|
|
})
|