mirror of
https://github.com/stablyai/orca.git
synced 2026-11-06 12:07:58 +00:00
gh pr list already scopes --search with repo:OWNER/REPO type:pr. The extra is:pr Orca added made private-repo PR lists come back empty when gh is authenticated with a GitHub App user token (ghu_). Fixes #34536
816 lines
29 KiB
TypeScript
816 lines
29 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import type * as GithubApiRepositoryModule from './github-api-repository'
|
|
import type * as GhUtils from './gh-utils'
|
|
|
|
// Keep legacy REST request/failure coverage; API-boundary suites exercise the GraphQL path.
|
|
vi.mock('./client/list/work-item-search-page', () => ({ usesGraphqlWorkItemSearch: () => false }))
|
|
|
|
const {
|
|
execFileAsyncMock,
|
|
ghExecFileAsyncMock,
|
|
getOwnerRepoMock,
|
|
getIssueOwnerRepoMock,
|
|
getOwnerRepoForRemoteMock,
|
|
resolvePRRepositoryCandidatesMock,
|
|
resolveIssueSourceMock,
|
|
rateLimitGuardMock,
|
|
noteRateLimitSpendMock,
|
|
acquireMock,
|
|
releaseMock
|
|
} = vi.hoisted(() => ({
|
|
execFileAsyncMock: vi.fn(),
|
|
ghExecFileAsyncMock: vi.fn(),
|
|
getOwnerRepoMock: vi.fn(),
|
|
getIssueOwnerRepoMock: vi.fn(),
|
|
getOwnerRepoForRemoteMock: vi.fn(),
|
|
resolvePRRepositoryCandidatesMock: vi.fn(),
|
|
resolveIssueSourceMock: vi.fn(),
|
|
rateLimitGuardMock: vi.fn(() => ({ blocked: false })),
|
|
noteRateLimitSpendMock: vi.fn(),
|
|
acquireMock: vi.fn(),
|
|
releaseMock: vi.fn()
|
|
}))
|
|
|
|
vi.mock('./gh-utils', async () => {
|
|
const actual = await vi.importActual<typeof GhUtils>('./gh-utils')
|
|
return {
|
|
...actual,
|
|
execFileAsync: execFileAsyncMock,
|
|
ghExecFileAsync: ghExecFileAsyncMock,
|
|
getOwnerRepo: getOwnerRepoMock,
|
|
getIssueOwnerRepo: getIssueOwnerRepoMock,
|
|
getOwnerRepoForRemote: getOwnerRepoForRemoteMock,
|
|
resolveIssueSource: resolveIssueSourceMock,
|
|
acquire: acquireMock,
|
|
release: releaseMock,
|
|
_resetOwnerRepoCache: vi.fn()
|
|
}
|
|
})
|
|
|
|
vi.mock('./rate-limit', () => ({
|
|
rateLimitGuard: rateLimitGuardMock,
|
|
noteRateLimitSpend: noteRateLimitSpendMock,
|
|
getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })),
|
|
repositoryRateLimitGuard: vi.fn(() => ({ blocked: false })),
|
|
noteRepositoryRateLimitSpend: vi.fn(),
|
|
spendsSharedGitHubComQuota: () => true
|
|
}))
|
|
|
|
vi.mock('./github-api-repository', async (importOriginal) => {
|
|
const actual = await importOriginal<typeof GithubApiRepositoryModule>()
|
|
return {
|
|
...actual,
|
|
// Bridge hosted source resolution to the existing gh-utils fixtures.
|
|
resolveIssueGitHubApiRepositorySource: (
|
|
repoPath: string,
|
|
preference: unknown,
|
|
connectionId?: string | null,
|
|
localGitOptions?: unknown
|
|
) => resolveIssueSourceMock(repoPath, preference, connectionId, localGitOptions),
|
|
getIssueGitHubApiRepository: (repoPath: string, connectionId?: string | null) =>
|
|
getIssueOwnerRepoMock(repoPath, connectionId),
|
|
getOriginGitHubApiRepository: (
|
|
repoPath: string,
|
|
connectionId?: string | null,
|
|
localGitOptions?: unknown
|
|
) => getOwnerRepoMock(repoPath, connectionId, localGitOptions),
|
|
getGitHubApiRepositoryForRemote: (
|
|
repoPath: string,
|
|
remoteName: string,
|
|
connectionId?: string | null,
|
|
localGitOptions?: unknown
|
|
) =>
|
|
remoteName === 'origin'
|
|
? getOwnerRepoMock(repoPath, connectionId, localGitOptions)
|
|
: getOwnerRepoForRemoteMock(repoPath, remoteName, connectionId, localGitOptions),
|
|
resolveGitHubApiRepositoryCandidates: (
|
|
repoPath: string,
|
|
connectionId?: string | null,
|
|
localGitOptions?: unknown
|
|
) => resolvePRRepositoryCandidatesMock(repoPath, connectionId, localGitOptions)
|
|
}
|
|
})
|
|
|
|
import { countWorkItems } from './client/list/count-work-items'
|
|
import { getWorkItem } from './client/fetch/get-work-item'
|
|
import { listWorkItems } from './client/list/list-work-items'
|
|
import { _resetOwnerRepoCache } from './gh-utils'
|
|
|
|
const PR_LIST_FIELDS =
|
|
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests'
|
|
|
|
function issueSearchArgs(
|
|
ownerRepo: string,
|
|
options: { noCache?: boolean; query?: string } = {}
|
|
): string[] {
|
|
const query = options.query ?? 'is:issue is:open'
|
|
return [
|
|
'api',
|
|
...(options.noCache ? [] : ['--cache', '120s']),
|
|
`search/issues?q=${encodeURIComponent(`repo:${ownerRepo} ${query}`)}&sort=created&order=desc&per_page=10&page=1`,
|
|
'--jq',
|
|
'.items'
|
|
]
|
|
}
|
|
|
|
function prListArgs(ownerRepo: string, query = 'is:open'): string[] {
|
|
return [
|
|
'pr',
|
|
'list',
|
|
'--limit',
|
|
'10',
|
|
'--state',
|
|
'all',
|
|
'--json',
|
|
PR_LIST_FIELDS,
|
|
'--repo',
|
|
ownerRepo,
|
|
'--search',
|
|
`${query} sort:created-desc`
|
|
]
|
|
}
|
|
|
|
function decodedIssueSearchPath(callIndex: number): string {
|
|
const args = ghExecFileAsyncMock.mock.calls[callIndex]?.[0] as string[] | undefined
|
|
const apiPath = args?.find((arg) => arg.startsWith('search/issues?'))
|
|
expect(apiPath).toBeDefined()
|
|
return decodeURIComponent(apiPath ?? '')
|
|
}
|
|
|
|
// Share origin fixtures while letting candidate tests opt into upstream.
|
|
function mockUpstreamCandidate(upstream: { owner: string; repo: string } | null): void {
|
|
getOwnerRepoForRemoteMock.mockImplementation(
|
|
async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) =>
|
|
remoteName === 'upstream' ? upstream : getOwnerRepoMock(repoPath, connectionId, opts)
|
|
)
|
|
}
|
|
|
|
describe('GitHub issue source split', () => {
|
|
beforeEach(() => {
|
|
execFileAsyncMock.mockReset()
|
|
ghExecFileAsyncMock.mockReset()
|
|
getOwnerRepoMock.mockReset()
|
|
getIssueOwnerRepoMock.mockReset()
|
|
getOwnerRepoForRemoteMock.mockReset()
|
|
resolvePRRepositoryCandidatesMock.mockReset()
|
|
resolveIssueSourceMock.mockReset()
|
|
rateLimitGuardMock.mockReset()
|
|
rateLimitGuardMock.mockReturnValue({ blocked: false })
|
|
noteRateLimitSpendMock.mockReset()
|
|
acquireMock.mockReset()
|
|
releaseMock.mockReset()
|
|
acquireMock.mockResolvedValue(undefined)
|
|
// Preserve one source lookup per test while exercising the preference-aware resolver.
|
|
resolveIssueSourceMock.mockImplementation(async () => ({
|
|
source: await getIssueOwnerRepoMock(),
|
|
fellBack: false
|
|
}))
|
|
getOwnerRepoForRemoteMock.mockImplementation(
|
|
async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) =>
|
|
remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null
|
|
)
|
|
resolvePRRepositoryCandidatesMock.mockImplementation(async (repoPath, connectionId) => {
|
|
const origin = await getOwnerRepoMock(repoPath, connectionId)
|
|
const repository = origin ? { host: 'github.com', ...origin } : null
|
|
return { candidates: repository ? [repository] : [], headRepo: repository }
|
|
})
|
|
_resetOwnerRepoCache()
|
|
})
|
|
|
|
it('uses upstream for issues and origin for PRs in mixed recent results', async () => {
|
|
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
ghExecFileAsyncMock
|
|
.mockResolvedValueOnce({
|
|
stdout: JSON.stringify([
|
|
{
|
|
number: 923,
|
|
title: 'Use upstream issues',
|
|
state: 'open',
|
|
html_url: 'https://github.com/stablyai/orca/issues/923',
|
|
labels: [],
|
|
updated_at: '2026-04-01T00:00:00Z',
|
|
user: { login: 'octocat' }
|
|
}
|
|
])
|
|
})
|
|
.mockResolvedValueOnce({
|
|
stdout: JSON.stringify([
|
|
{
|
|
number: 42,
|
|
title: 'Fork PR',
|
|
state: 'open',
|
|
html_url: 'https://github.com/fork/orca/pull/42',
|
|
labels: [],
|
|
updated_at: '2026-03-31T00:00:00Z',
|
|
user: { login: 'octocat' },
|
|
draft: false,
|
|
head: { ref: 'feature' },
|
|
base: { ref: 'main' }
|
|
}
|
|
])
|
|
})
|
|
|
|
await listWorkItems('/repo-root', 10)
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), {
|
|
cwd: '/repo-root'
|
|
})
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), {
|
|
cwd: '/repo-root'
|
|
})
|
|
})
|
|
|
|
it('omits gh api cache args for no-cache recent work-item requests', async () => {
|
|
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({
|
|
stdout: '[]'
|
|
})
|
|
|
|
await listWorkItems('/repo-root', 10, undefined, undefined, undefined, undefined, true)
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
issueSearchArgs('stablyai/orca', { noCache: true }),
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), {
|
|
cwd: '/repo-root'
|
|
})
|
|
})
|
|
|
|
it('lists SSH repo work items with explicit owner/repo and no local cwd', async () => {
|
|
resolveIssueSourceMock.mockResolvedValueOnce({
|
|
source: { owner: 'stablyai', repo: 'orca' },
|
|
fellBack: false
|
|
})
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({
|
|
stdout: '[]'
|
|
})
|
|
|
|
await listWorkItems('/home/jinwoo/orca', 10, undefined, undefined, 'auto', 'openclaw-2')
|
|
|
|
expect(resolveIssueSourceMock).toHaveBeenCalledWith(
|
|
'/home/jinwoo/orca',
|
|
'auto',
|
|
'openclaw-2',
|
|
{}
|
|
)
|
|
expect(getOwnerRepoMock).toHaveBeenCalledWith('/home/jinwoo/orca', 'openclaw-2', {})
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), {})
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('fork/orca'), {})
|
|
})
|
|
|
|
it('uses upstream for issue-only queries and origin for PR-only queries', async () => {
|
|
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
|
|
|
await listWorkItems('/repo-root', 10, 'is:issue')
|
|
|
|
expect(decodedIssueSearchPath(0)).toContain('q=repo:stablyai/orca is:issue')
|
|
|
|
ghExecFileAsyncMock.mockClear()
|
|
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
|
|
|
await listWorkItems('/repo-root', 10, 'is:pr')
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
|
expect.arrayContaining(['--repo', 'fork/orca']),
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
})
|
|
|
|
it.each(['is:issue', 'is:pr'])(
|
|
'propagates GitHub outages for scoped %s queries',
|
|
async (query) => {
|
|
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 503: Service Unavailable'))
|
|
|
|
await expect(listWorkItems('/repo-root', 10, query)).rejects.toThrow(
|
|
'HTTP 503: Service Unavailable'
|
|
)
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
|
}
|
|
)
|
|
|
|
it('propagates an outage when both sides of a combined query are unavailable', async () => {
|
|
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
ghExecFileAsyncMock
|
|
.mockRejectedValueOnce(new Error('HTTP 503: Service Unavailable'))
|
|
.mockRejectedValueOnce(new Error('HTTP 502: Bad Gateway'))
|
|
|
|
await expect(listWorkItems('/repo-root', 10, 'is:open')).rejects.toThrow(
|
|
'HTTP 503: Service Unavailable'
|
|
)
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
it("uses upstream for recent PRs when preference='upstream'", async () => {
|
|
resolveIssueSourceMock.mockResolvedValueOnce({
|
|
source: { owner: 'stablyai', repo: 'orca' },
|
|
fellBack: false
|
|
})
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({
|
|
stdout: '[]'
|
|
})
|
|
|
|
await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream')
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(2, prListArgs('stablyai/orca'), {
|
|
cwd: '/repo-root'
|
|
})
|
|
})
|
|
|
|
it("uses upstream for queried PRs when preference='upstream'", async () => {
|
|
resolveIssueSourceMock.mockResolvedValueOnce({
|
|
source: { owner: 'stablyai', repo: 'orca' },
|
|
fellBack: false
|
|
})
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
|
|
|
await listWorkItems('/repo-root', 10, 'is:pr is:open', undefined, 'upstream')
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
|
expect.arrayContaining(['--repo', 'stablyai/orca']),
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
})
|
|
|
|
it("uses upstream for PR counts when preference='upstream'", async () => {
|
|
resolveIssueSourceMock.mockResolvedValueOnce({
|
|
source: { owner: 'stablyai', repo: 'orca' },
|
|
fellBack: false
|
|
})
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '9\n' })
|
|
|
|
const count = await countWorkItems('/repo-root', 'is:pr is:open', 'upstream')
|
|
|
|
expect(count).toBe(9)
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
|
[
|
|
'api',
|
|
'--cache',
|
|
'120s',
|
|
`search/issues?q=${encodeURIComponent('repo:stablyai/orca is:pull-request is:open')}&per_page=1`,
|
|
'--jq',
|
|
'.total_count'
|
|
],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
})
|
|
|
|
it("falls back to origin for PRs when preference='upstream' and upstream is missing", async () => {
|
|
resolveIssueSourceMock.mockResolvedValueOnce({
|
|
source: { owner: 'fork', repo: 'orca' },
|
|
fellBack: true
|
|
})
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
|
|
|
|
const result = await listWorkItems('/repo-root', 10, 'is:pr', undefined, 'upstream')
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
|
expect.arrayContaining(['--repo', 'fork/orca']),
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
expect(result.sources).toEqual({
|
|
issues: { owner: 'fork', repo: 'orca' },
|
|
prs: { owner: 'fork', repo: 'orca' },
|
|
originCandidate: { owner: 'fork', repo: 'orca' },
|
|
upstreamCandidate: null
|
|
})
|
|
expect(result.issueSourceFellBack).toBe(true)
|
|
})
|
|
|
|
it('counts default work items across upstream issues and origin PRs', async () => {
|
|
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
ghExecFileAsyncMock
|
|
.mockResolvedValueOnce({ stdout: '7\n' })
|
|
.mockResolvedValueOnce({ stdout: '5\n' })
|
|
|
|
const count = await countWorkItems('/repo-root')
|
|
|
|
expect(count).toBe(12)
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
[
|
|
'api',
|
|
'--cache',
|
|
'120s',
|
|
`search/issues?q=${encodeURIComponent('repo:stablyai/orca is:issue is:open')}&per_page=1`,
|
|
'--jq',
|
|
'.total_count'
|
|
],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
[
|
|
'api',
|
|
'--cache',
|
|
'120s',
|
|
`search/issues?q=${encodeURIComponent('repo:fork/orca is:pull-request is:open')}&per_page=1`,
|
|
'--jq',
|
|
'.total_count'
|
|
],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
})
|
|
|
|
it('typed PR lookup does not fetch an upstream issue with the same number', async () => {
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({
|
|
stdout: JSON.stringify({
|
|
number: 42,
|
|
title: 'Origin PR',
|
|
state: 'open',
|
|
html_url: 'https://github.com/fork/orca/pull/42',
|
|
labels: [],
|
|
updated_at: '2026-04-02T00:00:00Z',
|
|
user: { login: 'octocat' },
|
|
draft: false,
|
|
head: { ref: 'feature' },
|
|
base: { ref: 'main' }
|
|
})
|
|
})
|
|
|
|
const item = await getWorkItem('/repo-root', 42, 'pr')
|
|
|
|
expect(getIssueOwnerRepoMock).not.toHaveBeenCalled()
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
|
[
|
|
'pr',
|
|
'view',
|
|
'42',
|
|
'--repo',
|
|
'fork/orca',
|
|
'--json',
|
|
expect.stringContaining('reviewDecision')
|
|
],
|
|
{ cwd: '/repo-root', host: 'github.com' }
|
|
)
|
|
expect(item?.type).toBe('pr')
|
|
})
|
|
|
|
it('probes the upstream repository for a typed fork PR before origin', async () => {
|
|
const upstream = { owner: 'stablyai', repo: 'orca', host: 'github.com' }
|
|
const origin = { owner: 'fork', repo: 'orca', host: 'github.com' }
|
|
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
|
|
candidates: [upstream, origin],
|
|
headRepo: origin
|
|
})
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({
|
|
stdout: JSON.stringify({
|
|
number: 42,
|
|
title: 'Upstream PR',
|
|
state: 'open',
|
|
url: 'https://github.com/stablyai/orca/pull/42',
|
|
labels: [],
|
|
updatedAt: '2026-04-02T00:00:00Z',
|
|
author: { login: 'octocat' },
|
|
isDraft: false
|
|
})
|
|
})
|
|
|
|
const item = await getWorkItem('/repo-root', 42, 'pr')
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
|
[
|
|
'pr',
|
|
'view',
|
|
'42',
|
|
'--repo',
|
|
'stablyai/orca',
|
|
'--json',
|
|
expect.stringContaining('reviewDecision')
|
|
],
|
|
{ cwd: '/repo-root', host: 'github.com' }
|
|
)
|
|
expect(item?.prRepo).toEqual(upstream)
|
|
})
|
|
|
|
it('pins typed PR metadata to explicit origin when upstream has the same number', async () => {
|
|
const upstream = { owner: 'stablyai', repo: 'orca', host: 'github.com' }
|
|
const origin = { owner: 'fork', repo: 'orca', host: 'github.com' }
|
|
getOwnerRepoMock.mockResolvedValue(origin)
|
|
mockUpstreamCandidate(upstream)
|
|
resolvePRRepositoryCandidatesMock.mockResolvedValue({
|
|
candidates: [upstream, origin],
|
|
headRepo: origin
|
|
})
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({
|
|
stdout: JSON.stringify({
|
|
number: 42,
|
|
title: 'Origin PR',
|
|
state: 'open',
|
|
url: 'https://github.com/fork/orca/pull/42',
|
|
labels: [],
|
|
updatedAt: '2026-04-02T00:00:00Z',
|
|
author: { login: 'octocat' },
|
|
isDraft: false,
|
|
headRefName: 'origin/fix',
|
|
baseRefName: 'main'
|
|
})
|
|
})
|
|
|
|
const item = await getWorkItem('/repo-root', 42, 'pr', null, {}, 'origin')
|
|
|
|
expect(resolvePRRepositoryCandidatesMock).not.toHaveBeenCalled()
|
|
expect(ghExecFileAsyncMock.mock.calls[0]?.[0]).toEqual(
|
|
expect.arrayContaining(['pr', 'view', '--repo', 'fork/orca'])
|
|
)
|
|
expect(
|
|
ghExecFileAsyncMock.mock.calls.some((call) =>
|
|
(call[0] as string[]).some((arg) => arg.includes('upstream/orca'))
|
|
)
|
|
).toBe(false)
|
|
expect(item?.prRepo).toEqual(origin)
|
|
})
|
|
|
|
it('does not run a bare PR lookup when explicit origin identity is unresolved', async () => {
|
|
getOwnerRepoMock.mockResolvedValue(null)
|
|
mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' })
|
|
|
|
await expect(getWorkItem('/repo-root', 42, 'pr', null, {}, 'origin')).resolves.toBeNull()
|
|
|
|
expect(resolvePRRepositoryCandidatesMock).not.toHaveBeenCalled()
|
|
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not run a bare gh lookup for an SSH repo without candidates', async () => {
|
|
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({ candidates: [], headRepo: null })
|
|
|
|
await expect(getWorkItem('/remote/repo', 42, 'pr', 'ssh-1')).resolves.toBeNull()
|
|
|
|
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('does not probe a second PR repository after a non-not-found failure', async () => {
|
|
resolvePRRepositoryCandidatesMock.mockResolvedValueOnce({
|
|
candidates: [
|
|
{ owner: 'stablyai', repo: 'orca', host: 'github.com' },
|
|
{ owner: 'fork', repo: 'orca', host: 'github.com' }
|
|
],
|
|
headRepo: { owner: 'fork', repo: 'orca', host: 'github.com' }
|
|
})
|
|
ghExecFileAsyncMock
|
|
.mockRejectedValueOnce(new Error('HTTP 500: server error'))
|
|
.mockRejectedValueOnce(new Error('HTTP 500: server error'))
|
|
|
|
await expect(getWorkItem('/repo-root', 42, 'pr')).resolves.toBeNull()
|
|
|
|
// Only a confirmed 404 permits trying a same-number PR on another repository.
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
|
|
expect(
|
|
ghExecFileAsyncMock.mock.calls.some(([args]) =>
|
|
(args as string[]).some((arg) => arg.includes('fork/orca'))
|
|
)
|
|
).toBe(false)
|
|
})
|
|
|
|
it('raw number lookup tries upstream issue before origin PR', async () => {
|
|
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
|
// Non-404 upstream failures must not route to an unrelated origin PR with the same number.
|
|
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 404: Not Found'))
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({
|
|
stdout: JSON.stringify({
|
|
number: 42,
|
|
title: 'Origin PR',
|
|
state: 'open',
|
|
html_url: 'https://github.com/fork/orca/pull/42',
|
|
labels: [],
|
|
updated_at: '2026-04-02T00:00:00Z',
|
|
user: { login: 'octocat' },
|
|
draft: false
|
|
})
|
|
})
|
|
|
|
const item = await getWorkItem('/repo-root', 42)
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
|
1,
|
|
['api', 'repos/stablyai/orca/issues/42'],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
[
|
|
'pr',
|
|
'view',
|
|
'42',
|
|
'--repo',
|
|
'fork/orca',
|
|
'--json',
|
|
expect.stringContaining('reviewDecision')
|
|
],
|
|
{ cwd: '/repo-root', host: 'github.com' }
|
|
)
|
|
expect(item?.type).toBe('pr')
|
|
})
|
|
|
|
it('surfaces a 403 from upstream issues through the listWorkItems envelope', async () => {
|
|
// Preserve source metadata with classified failures so the retry banner can name its repository.
|
|
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
ghExecFileAsyncMock
|
|
.mockRejectedValueOnce(new Error('HTTP 403: Resource not accessible by integration'))
|
|
.mockResolvedValueOnce({ stdout: '[]' })
|
|
|
|
const result = await listWorkItems('/repo-root', 10)
|
|
|
|
expect(result.items).toEqual([])
|
|
expect(result.sources).toMatchObject({
|
|
issues: { owner: 'stablyai', repo: 'orca' },
|
|
prs: { owner: 'fork', repo: 'orca' }
|
|
})
|
|
expect(result.errors?.issues?.type).toBe('permission_denied')
|
|
})
|
|
|
|
it('returns partial results when upstream issues fail but origin PRs succeed', async () => {
|
|
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
ghExecFileAsyncMock
|
|
.mockRejectedValueOnce(new Error('HTTP 403: Resource not accessible by integration'))
|
|
.mockResolvedValueOnce({
|
|
stdout: JSON.stringify([
|
|
{
|
|
number: 42,
|
|
title: 'Fork PR',
|
|
state: 'open',
|
|
html_url: 'https://github.com/fork/orca/pull/42',
|
|
labels: [],
|
|
updated_at: '2026-03-31T00:00:00Z',
|
|
user: { login: 'octocat' },
|
|
draft: false,
|
|
head: { ref: 'feature' },
|
|
base: { ref: 'main' }
|
|
}
|
|
])
|
|
})
|
|
|
|
const result = await listWorkItems('/repo-root', 10)
|
|
|
|
expect(result.items.map((i) => i.id)).toEqual(['pr:42'])
|
|
expect(result.errors?.issues?.type).toBe('permission_denied')
|
|
})
|
|
|
|
it('raw number lookup does not fall through on transient upstream errors', async () => {
|
|
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockRejectedValueOnce(new Error('HTTP 500: server error'))
|
|
|
|
const item = await getWorkItem('/repo-root', 42)
|
|
|
|
expect(item).toBeNull()
|
|
expect(getOwnerRepoMock).not.toHaveBeenCalled()
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
describe('per-repo issue-source preference', () => {
|
|
it("preference='auto' + upstream exists → queries upstream", async () => {
|
|
resolveIssueSourceMock.mockResolvedValueOnce({
|
|
source: { owner: 'stablyai', repo: 'orca' },
|
|
fellBack: false
|
|
})
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({
|
|
stdout: '[]'
|
|
})
|
|
|
|
const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'auto')
|
|
|
|
expect(resolveIssueSourceMock).toHaveBeenCalledWith('/repo-root', 'auto', undefined, {})
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(1, issueSearchArgs('stablyai/orca'), {
|
|
cwd: '/repo-root'
|
|
})
|
|
expect(result.issueSourceFellBack).toBeUndefined()
|
|
})
|
|
|
|
it("preference='auto' + upstream exists → PRs query upstream too", async () => {
|
|
// Fork contribution PRs live upstream; automatic selection must query that namespace.
|
|
resolveIssueSourceMock.mockResolvedValueOnce({
|
|
source: { owner: 'stablyai', repo: 'orca' },
|
|
fellBack: false
|
|
})
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({
|
|
stdout: '[]'
|
|
})
|
|
|
|
const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'auto')
|
|
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
expect.arrayContaining(['--repo', 'stablyai/orca']),
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
expect(result.sources).toEqual({
|
|
issues: { owner: 'stablyai', repo: 'orca' },
|
|
prs: { owner: 'stablyai', repo: 'orca' },
|
|
originCandidate: { owner: 'fork', repo: 'orca' },
|
|
upstreamCandidate: { owner: 'stablyai', repo: 'orca' }
|
|
})
|
|
})
|
|
|
|
it('collapses the default count to one query when auto resolves both sides to upstream', async () => {
|
|
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '11\n' })
|
|
|
|
const count = await countWorkItems('/repo-root')
|
|
|
|
expect(count).toBe(11)
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
|
|
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
|
|
[
|
|
'api',
|
|
'--cache',
|
|
'120s',
|
|
`search/issues?q=${encodeURIComponent('repo:stablyai/orca is:open')}&per_page=1`,
|
|
'--jq',
|
|
'.total_count'
|
|
],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
})
|
|
|
|
it("preference='upstream' + no upstream → falls back to origin with fellBack=true", async () => {
|
|
resolveIssueSourceMock.mockResolvedValueOnce({
|
|
source: { owner: 'solo', repo: 'orca' },
|
|
fellBack: true
|
|
})
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'solo', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({
|
|
stdout: '[]'
|
|
})
|
|
|
|
const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream')
|
|
|
|
expect(decodedIssueSearchPath(0)).toContain('q=repo:solo/orca is:issue is:open')
|
|
expect(result.issueSourceFellBack).toBe(true)
|
|
})
|
|
|
|
it('surfaces upstreamCandidate in sources regardless of effective preference', async () => {
|
|
// Preserve raw upstream metadata so the selector can still switch back after choosing origin.
|
|
resolveIssueSourceMock.mockResolvedValueOnce({
|
|
source: { owner: 'fork', repo: 'orca' },
|
|
fellBack: false
|
|
})
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({
|
|
stdout: '[]'
|
|
})
|
|
|
|
const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'origin')
|
|
|
|
expect(result.sources).toEqual({
|
|
issues: { owner: 'fork', repo: 'orca' },
|
|
prs: { owner: 'fork', repo: 'orca' },
|
|
originCandidate: { owner: 'fork', repo: 'orca' },
|
|
upstreamCandidate: { owner: 'stablyai', repo: 'orca' }
|
|
})
|
|
})
|
|
|
|
it('keeps raw origin metadata when effective PR source is upstream', async () => {
|
|
resolveIssueSourceMock.mockResolvedValueOnce({
|
|
source: { owner: 'stablyai', repo: 'orca' },
|
|
fellBack: false
|
|
})
|
|
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'orca' })
|
|
mockUpstreamCandidate({ owner: 'stablyai', repo: 'orca' })
|
|
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({
|
|
stdout: '[]'
|
|
})
|
|
|
|
const result = await listWorkItems('/repo-root', 10, undefined, undefined, 'upstream')
|
|
|
|
expect(result.sources).toEqual({
|
|
issues: { owner: 'stablyai', repo: 'orca' },
|
|
prs: { owner: 'stablyai', repo: 'orca' },
|
|
originCandidate: { owner: 'fork', repo: 'orca' },
|
|
upstreamCandidate: { owner: 'stablyai', repo: 'orca' }
|
|
})
|
|
})
|
|
})
|
|
})
|