Files
orca/src/main/github/client-issue-source.test.ts
T
BingZ 92f3fef421 fix(github): drop redundant is:pr from gh pr list search (#34633)
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
2026-10-27 23:09:11 -07:00

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' }
})
})
})
})