Files
orca/src/main/github/client-work-items.test.ts
T
Brennan Bensonandyasuhiro nishioka d2baeff227 Skip proven other-forge projects in Tasks requests (#34219)
* Skip proven other-forge task repositories at the provider boundary

Revalidate GitLab project identity after remote configuration changes and pin resolved list calls to their host. Preserve unresolved local MR inference and settled partial results; carry additive eligibility metadata for GitHub notices.

Co-authored-by: yasuhiro nishioka <yasu00000@gmail.com>

* Keep task source eligibility scoped to each repository host

* Preserve shared host routing for GitLab task lists

* Cover runtime task source marker replacement through its notice reader

* Share initial GitLab config observation across source resolution

---------

Co-authored-by: yasuhiro nishioka <yasu00000@gmail.com>
2026-10-29 14:21:22 -07:00

869 lines
29 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GithubApiRepositoryModule from './github-api-repository'
// 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,
resolveIssueSourceMock,
gitExecFileAsyncMock,
rateLimitGuardMock,
noteRateLimitSpendMock,
acquireMock,
releaseMock
} = vi.hoisted(() => ({
execFileAsyncMock: vi.fn(),
ghExecFileAsyncMock: vi.fn(),
getOwnerRepoMock: vi.fn(),
getIssueOwnerRepoMock: vi.fn(),
getOwnerRepoForRemoteMock: vi.fn(),
resolveIssueSourceMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
rateLimitGuardMock: vi.fn((_bucket?: unknown) => ({ blocked: false })),
noteRateLimitSpendMock: vi.fn(),
acquireMock: vi.fn(),
releaseMock: vi.fn()
}))
vi.mock('./gh-utils', () => ({
execFileAsync: execFileAsyncMock,
ghExecFileAsync: ghExecFileAsyncMock,
githubRepoContext: (
repoPath: string,
connectionId?: string | null,
localGitOptions: { wslDistro?: string } = {}
) => ({
repoPath,
connectionId: connectionId ?? null,
...(localGitOptions.wslDistro ? { wslDistro: localGitOptions.wslDistro } : {})
}),
ghRepoExecOptions: (context: {
repoPath: string
connectionId?: string | null
wslDistro?: string
}) =>
context.connectionId
? {}
: { cwd: context.repoPath, ...(context.wslDistro ? { wslDistro: context.wslDistro } : {}) },
getOwnerRepo: getOwnerRepoMock,
getIssueOwnerRepo: getIssueOwnerRepoMock,
getOwnerRepoForRemote: getOwnerRepoForRemoteMock,
resolveIssueSource: resolveIssueSourceMock,
acquire: acquireMock,
release: releaseMock,
_resetOwnerRepoCache: vi.fn(),
classifyGhError: (stderr: string) => ({ type: 'unknown', message: stderr }),
classifyListIssuesError: (stderr: string) => ({ type: 'unknown', message: stderr }),
classifyListPrsError: (stderr: string) => ({ type: 'unknown', message: stderr })
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('./rate-limit', () => ({
rateLimitGuard: rateLimitGuardMock,
noteRateLimitSpend: noteRateLimitSpendMock,
getRateLimit: vi.fn(async () => ({ ok: false, error: 'not probed in tests' })),
repositoryRateLimitGuard: vi.fn((_repo: unknown, bucket: string) => rateLimitGuardMock(bucket)),
noteRepositoryRateLimitSpend: vi.fn((_repo: unknown, bucket: string, cost?: number) =>
noteRateLimitSpendMock(bucket, cost)
),
spendsSharedGitHubComQuota: () => true
}))
vi.mock('./github-api-repository', async (importOriginal) => {
const actual = await importOriginal<typeof GithubApiRepositoryModule>()
return {
...actual,
// Bridge hosted source resolution to the suite's 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)
}
})
import { countWorkItems } from './client/list/count-work-items'
import { listWorkItems } from './client/list/list-work-items'
import { _resetMergeQueueCacheForTests } from './client/detect/repository-merge-metadata-cache'
import { _resetOwnerRepoCache } from './gh-utils'
import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github/work-items-query-bounds'
import { _resetRemoteNameListingCache } from '../git/remote-name-listing'
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
beforeEach(() => {
_resetOriginGitHubApiRepositoryCache()
})
describe('listWorkItems', () => {
beforeEach(() => {
execFileAsyncMock.mockReset()
ghExecFileAsyncMock.mockReset()
getOwnerRepoMock.mockReset()
getIssueOwnerRepoMock.mockReset()
getOwnerRepoForRemoteMock.mockReset()
resolveIssueSourceMock.mockReset()
gitExecFileAsyncMock.mockReset()
rateLimitGuardMock.mockReset()
rateLimitGuardMock.mockReturnValue({ blocked: false })
noteRateLimitSpendMock.mockReset()
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
// Preserve one source lookup through the preference-aware resolver.
resolveIssueSourceMock.mockImplementation(async () => ({
source: await getIssueOwnerRepoMock(),
fellBack: false
}))
// Bridge explicit origin to getOwnerRepoMock; upstream candidates require opt-in fixtures.
getOwnerRepoForRemoteMock.mockImplementation(
async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) =>
remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null
)
_resetOwnerRepoCache()
_resetRemoteNameListingCache()
_resetMergeQueueCacheForTests()
})
it('routes GHES work-item listing through the Enterprise host', async () => {
const ghes = { owner: 'team', repo: 'orca', host: 'github.acme-corp.com' }
getIssueOwnerRepoMock.mockResolvedValue(ghes)
getOwnerRepoMock.mockResolvedValue(ghes)
ghExecFileAsyncMock.mockResolvedValue({ stdout: '[]' })
await listWorkItems('/repo-root', 10)
const calls = ghExecFileAsyncMock.mock.calls
expect(calls.length).toBeGreaterThan(0)
expect(calls.every(([, options]) => options?.host === 'github.acme-corp.com')).toBe(true)
})
it('marks proven GitLab remotes without listing them', async () => {
getIssueOwnerRepoMock.mockResolvedValue(null)
getOwnerRepoMock.mockResolvedValue(null)
gitExecFileAsyncMock.mockImplementation(async (args: string[]) => ({
stdout: args[1] === '-v' ? 'origin\tgit@gitlab.com:team/project.git (fetch)\n' : 'origin\n'
}))
const result = await listWorkItems('/repo', 24)
expect(result.sources).toMatchObject({ issues: null, prs: null, ineligible: true })
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it.each([
'origin\tgit@custom.example.com:team/project.git (fetch)\n',
'origin\tgit@gitlab.com:team/project.git (fetch)\nupstream\tgit@github.com:team/project.git (fetch)\n'
])('keeps unproven or mixed remote identity unresolved: %s', async (stdout) => {
getIssueOwnerRepoMock.mockResolvedValue(null)
getOwnerRepoMock.mockResolvedValue(null)
gitExecFileAsyncMock.mockResolvedValue({ stdout })
const result = await listWorkItems('/repo', 24)
expect(result.sources.ineligible).toBeUndefined()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('does not run unscoped work-item queries when a local repository has no GitHub source', async () => {
getIssueOwnerRepoMock.mockResolvedValue(null)
getOwnerRepoMock.mockResolvedValue(null)
// Null-source envelopes distinguish unavailable repository identity from successful empty work.
await expect(listWorkItems('/private-repo', 36)).resolves.toMatchObject({
items: [],
sources: { issues: null, prs: null }
})
await expect(listWorkItems('/private-repo', 36, 'is:open')).resolves.toMatchObject({
items: [],
sources: { issues: null, prs: null }
})
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('queries only the resolved PR source when the issue source is unavailable', async () => {
getIssueOwnerRepoMock.mockResolvedValue(null)
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValue({ stdout: '[]' })
await expect(listWorkItems('/private-repo', 36)).resolves.toMatchObject({ items: [] })
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining(['pr', 'list', '--repo', 'acme/widgets']),
{ cwd: '/private-repo' }
)
})
it('runs both issue and PR GitHub searches for a mixed query and merges the results by recency', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 12,
title: 'Fix bug',
state: 'OPEN',
url: 'https://github.com/acme/widgets/issues/12',
labels: [],
updatedAt: '2026-03-29T00:00:00Z',
author: { login: 'octocat' },
assignees: [
{
login: 'test-assignee',
name: 'Test Assignee',
databaseId: 1
}
]
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Add feature',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
labels: [],
updatedAt: '2026-03-28T00:00:00Z',
author: { login: 'octocat' },
isDraft: false,
headRefName: 'feature/add-feature',
headRefOid: 'head-42',
baseRefName: 'main',
reviewRequests: [
{
requestedReviewer: {
login: 'test-assignee',
name: 'Test Assignee',
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
}
]
}
])
})
const { items, sources } = await listWorkItems('/repo-root', 10, 'assignee:@me')
expect(sources).toMatchObject({
issues: { owner: 'acme', repo: 'widgets' },
prs: { owner: 'acme', repo: 'widgets' }
})
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
1,
[
'api',
'--cache',
'120s',
`search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue assignee:@me')}&sort=created&order=desc&per_page=10&page=1`,
'--jq',
'.items'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
[
'pr',
'list',
'--limit',
'10',
'--state',
'all',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--search',
'assignee:@me sort:created-desc'
],
{ cwd: '/repo-root' }
)
const prListFields = ghExecFileAsyncMock.mock.calls[1][0].join(',')
expect(prListFields).not.toContain('statusCheckRollup')
expect(prListFields).toContain('reviewRequests')
expect(prListFields).not.toContain('mergeStateStatus')
expect(items).toEqual([
{
id: 'pr:42',
type: 'pr',
number: 42,
title: 'Add feature',
state: 'open',
url: 'https://github.com/acme/widgets/pull/42',
labels: [],
updatedAt: '2026-03-28T00:00:00Z',
author: 'octocat',
branchName: 'feature/add-feature',
baseRefName: 'main',
headSha: 'head-42',
prRepo: { owner: 'acme', repo: 'widgets' },
reviewRequests: [
{
login: 'test-assignee',
name: 'Test Assignee',
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
},
{
id: 'issue:12',
type: 'issue',
number: 12,
title: 'Fix bug',
state: 'open',
url: 'https://github.com/acme/widgets/issues/12',
labels: [],
updatedAt: '2026-03-29T00:00:00Z',
author: 'octocat',
assignees: [
{
login: 'test-assignee',
name: 'Test Assignee',
avatarUrl: 'https://avatars.githubusercontent.com/u/1?v=4'
}
]
}
])
})
it('routes local WSL work-item listing through repo resolution and gh execution options', async () => {
const localGitOptions = { wslDistro: 'Ubuntu' }
resolveIssueSourceMock.mockResolvedValue({
source: { owner: 'acme', repo: 'widgets' },
fellBack: false
})
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
getOwnerRepoForRemoteMock.mockImplementation(
async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) =>
remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null
)
ghExecFileAsyncMock.mockResolvedValue({ stdout: '[]' })
await listWorkItems(
'/repo-root',
5,
undefined,
undefined,
undefined,
null,
false,
localGitOptions
)
expect(resolveIssueSourceMock).toHaveBeenCalledWith(
'/repo-root',
undefined,
null,
localGitOptions
)
expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions)
expect(getOwnerRepoForRemoteMock).toHaveBeenCalledWith(
'/repo-root',
'upstream',
null,
localGitOptions
)
expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe(
true
)
})
it('skips upstream PR source probing when the clone only has origin', async () => {
getIssueOwnerRepoMock.mockResolvedValue(null)
getOwnerRepoMock.mockResolvedValue({ owner: 'fork', repo: 'orca' })
gitExecFileAsyncMock.mockResolvedValue({ stdout: 'origin\n' })
ghExecFileAsyncMock.mockResolvedValue({ stdout: '[]' })
await expect(listWorkItems('/origin-only-repo', 10, 'is:pr')).resolves.toMatchObject({
items: [],
sources: {
issues: null,
prs: { owner: 'fork', repo: 'orca' },
originCandidate: { owner: 'fork', repo: 'orca' },
upstreamCandidate: null
}
})
expect(getOwnerRepoForRemoteMock.mock.calls.map(([, remote]) => remote)).not.toContain(
'upstream'
)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(
['remote'],
expect.objectContaining({ cwd: '/origin-only-repo' })
)
})
it('rejects oversized queries before resolving repo sources or executing gh', async () => {
const secret = 'main-github-work-items-secret'
const oversizedQuery = secret + 'x'.repeat(GITHUB_WORK_ITEMS_QUERY_MAX_BYTES)
await expect(listWorkItems('/repo-root', 10, oversizedQuery)).resolves.toEqual({
items: [],
sources: {
issues: null,
prs: null,
originCandidate: null,
upstreamCandidate: null
}
})
expect(resolveIssueSourceMock).not.toHaveBeenCalled()
expect(getIssueOwnerRepoMock).not.toHaveBeenCalled()
expect(getOwnerRepoMock).not.toHaveBeenCalled()
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
expect(acquireMock).not.toHaveBeenCalled()
expect(releaseMock).not.toHaveBeenCalled()
})
it('hydrates PR list rows with repository merge metadata', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 42,
title: 'Add feature',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/42',
labels: [],
updatedAt: '2026-03-28T00:00:00Z',
author: { login: 'octocat' },
isDraft: false,
headRefName: 'feature/add-feature',
headRefOid: 'head-42',
baseRefName: 'main'
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
data: {
repository: {
viewerDefaultMergeMethod: 'REBASE',
mergeCommitAllowed: false,
rebaseMergeAllowed: true,
squashMergeAllowed: true,
autoMergeAllowed: false
}
}
})
})
const { items } = await listWorkItems('/repo-root', 10, 'is:pr')
expect(items).toHaveLength(1)
expect(items[0]?.mergeMethodSettings).toEqual({
defaultMethod: 'rebase',
allowedMethods: {
squash: true,
merge: false,
rebase: true
}
})
expect(items[0]?.autoMergeAllowed).toBe(false)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
expect.arrayContaining(['api', 'graphql', '-f', 'owner=acme', '-f', 'repo=widgets']),
{ cwd: '/repo-root' }
)
})
it('routes draft queries to PR search only', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 7,
title: 'Draft work',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/7',
labels: [],
updatedAt: '2026-03-30T00:00:00Z',
author: { login: 'octocat' },
isDraft: true,
headRefName: 'draft/work',
headRefOid: 'head-7',
baseRefName: 'main'
}
])
})
const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:draft')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'pr',
'list',
'--limit',
'10',
'--state',
'all',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--search',
'is:open draft:true sort:created-desc'
],
{ cwd: '/repo-root' }
)
expect(items).toEqual([
{
id: 'pr:7',
type: 'pr',
number: 7,
title: 'Draft work',
state: 'draft',
url: 'https://github.com/acme/widgets/pull/7',
labels: [],
updatedAt: '2026-03-30T00:00:00Z',
author: 'octocat',
branchName: 'draft/work',
baseRefName: 'main',
headSha: 'head-7',
prRepo: { owner: 'acme', repo: 'widgets' }
}
])
})
it('routes merged queries to PR search only and maps MERGED PR state', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 8,
title: 'Merged work',
state: 'MERGED',
url: 'https://github.com/acme/widgets/pull/8',
labels: [],
updatedAt: '2026-03-31T00:00:00Z',
author: { login: 'octocat' },
isDraft: false,
headRefName: 'feature/merged',
headRefOid: 'head-8',
baseRefName: 'main'
}
])
})
const { items } = await listWorkItems('/repo-root', 10, 'is:merged')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'pr',
'list',
'--limit',
'10',
'--state',
'all',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--search',
'is:merged sort:created-desc'
],
{ cwd: '/repo-root' }
)
expect(items).toMatchObject([
{
id: 'pr:8',
type: 'pr',
number: 8,
state: 'merged'
}
])
})
it('passes state:all through to gh instead of using the default open state', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' })
await listWorkItems('/repo-root', 10, 'is:pr state:all')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(expect.arrayContaining(['--state', 'all']), {
cwd: '/repo-root'
})
})
it('excludes merged PRs from closed PR searches', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 9,
title: 'Closed without merge',
state: 'CLOSED',
url: 'https://github.com/acme/widgets/pull/9',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: { login: 'octocat' },
isDraft: false,
headRefName: 'feature/closed',
headRefOid: 'head-9',
baseRefName: 'main'
},
{
number: 8,
title: 'Merged work',
state: 'MERGED',
url: 'https://github.com/acme/widgets/pull/8',
labels: [],
updatedAt: '2026-03-31T00:00:00Z',
author: { login: 'octocat' },
isDraft: false,
headRefName: 'feature/merged',
headRefOid: 'head-8',
baseRefName: 'main'
}
])
})
const { items } = await listWorkItems('/repo-root', 10, 'is:pr is:closed')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
expect.arrayContaining([
'--state',
'all',
'--search',
'is:closed -is:merged sort:created-desc'
]),
{ cwd: '/repo-root' }
)
expect(items).toMatchObject([{ id: 'pr:9', type: 'pr', state: 'closed' }])
})
it('quotes spaced label qualifiers when counting search results', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '12' })
const count = await countWorkItems('/repo-root', 'is:pr label:"needs review"')
const apiPath = ghExecFileAsyncMock.mock.calls[0][0][3] as string
expect(count).toBe(12)
expect(decodeURIComponent(apiPath)).toContain('label:"needs review"')
})
it('does not add the merged exclusion to issue-only closed count queries', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '4' })
await countWorkItems('/repo-root', 'is:issue is:closed')
const apiPath = decodeURIComponent(ghExecFileAsyncMock.mock.calls[0][0][3] as string)
expect(apiPath).toContain('is:issue is:closed')
expect(apiPath).not.toContain('-is:merged')
})
it('returns zero without spawning gh when the search bucket is rate-limit blocked', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
rateLimitGuardMock.mockReturnValue({
blocked: true,
remaining: 0,
limit: 30,
resetAt: Math.floor(Date.now() / 1000) + 60
} as unknown as { blocked: boolean })
await expect(countWorkItems('/repo-root', 'is:issue is:open')).resolves.toBe(0)
expect(rateLimitGuardMock).toHaveBeenCalledWith('search')
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
it('returns open issues and PRs for the all-open preset query', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 1,
title: 'Open issue',
state: 'OPEN',
url: 'https://github.com/acme/widgets/issues/1',
labels: [],
updatedAt: '2026-03-31T00:00:00Z',
author: { login: 'octocat' }
}
])
})
.mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 2,
title: 'Open PR',
state: 'OPEN',
url: 'https://github.com/acme/widgets/pull/2',
labels: [],
updatedAt: '2026-03-30T00:00:00Z',
author: { login: 'octocat' },
isDraft: false,
headRefName: 'feature/open-pr',
headRefOid: 'head-2',
baseRefName: 'main'
}
])
})
const { items } = await listWorkItems('/repo-root', 10, 'is:open')
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'api',
'--cache',
'120s',
`search/issues?q=${encodeURIComponent('repo:acme/widgets is:issue is:open')}&sort=created&order=desc&per_page=10&page=1`,
'--jq',
'.items'
],
{ cwd: '/repo-root' }
)
expect(ghExecFileAsyncMock).toHaveBeenCalledWith(
[
'pr',
'list',
'--limit',
'10',
'--state',
'all',
'--json',
'number,title,state,url,labels,updatedAt,author,isDraft,headRefName,baseRefName,headRefOid,headRepositoryOwner,reviewRequests',
'--repo',
'acme/widgets',
'--search',
'is:open sort:created-desc'
],
{ cwd: '/repo-root' }
)
expect(items).toEqual([
{
id: 'pr:2',
type: 'pr',
number: 2,
title: 'Open PR',
state: 'open',
url: 'https://github.com/acme/widgets/pull/2',
labels: [],
updatedAt: '2026-03-30T00:00:00Z',
author: 'octocat',
branchName: 'feature/open-pr',
baseRefName: 'main',
headSha: 'head-2',
prRepo: { owner: 'acme', repo: 'widgets' }
},
{
id: 'issue:1',
type: 'issue',
number: 1,
title: 'Open issue',
state: 'open',
url: 'https://github.com/acme/widgets/issues/1',
labels: [],
updatedAt: '2026-03-31T00:00:00Z',
author: 'octocat'
}
])
})
it('marks fork PRs as cross-repository when REST payload only includes head.label', async () => {
getIssueOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'stablyai', repo: 'orca' })
ghExecFileAsyncMock.mockResolvedValueOnce({ stdout: '[]' }).mockResolvedValueOnce({
stdout: JSON.stringify([
{
number: 1849,
title: 'Fork PR with missing head repo',
state: 'open',
html_url: 'https://github.com/stablyai/orca/pull/1849',
updated_at: '2026-04-01T00:00:00Z',
user: { login: 'contributor' },
head: {
ref: 'feat/onboarding-model-choice-782',
sha: 'head-1849',
repo: null,
label: 'contributor:feat/onboarding-model-choice-782'
},
base: { ref: 'main' }
}
])
})
const { items } = await listWorkItems('/repo-root', 10)
expect(items).toEqual([
{
id: 'pr:1849',
type: 'pr',
number: 1849,
title: 'Fork PR with missing head repo',
state: 'open',
url: 'https://github.com/stablyai/orca/pull/1849',
labels: [],
updatedAt: '2026-04-01T00:00:00Z',
author: 'contributor',
branchName: 'feat/onboarding-model-choice-782',
baseRefName: 'main',
headSha: 'head-1849',
prRepo: { owner: 'stablyai', repo: 'orca' },
isCrossRepository: true
}
])
})
it('rejects unresolved SSH repositories without running unscoped GitHub work-item queries', async () => {
getIssueOwnerRepoMock.mockResolvedValue(null)
getOwnerRepoMock.mockResolvedValue(null)
getOwnerRepoForRemoteMock.mockResolvedValue(null)
await expect(
listWorkItems('/remote/repo', 10, undefined, undefined, undefined, 'ssh-1')
).rejects.toThrow('GitHub work items require a GitHub remote for SSH repositories')
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
ghExecFileAsyncMock.mockClear()
getIssueOwnerRepoMock.mockResolvedValue(null)
getOwnerRepoMock.mockResolvedValue(null)
getOwnerRepoForRemoteMock.mockResolvedValue(null)
await expect(
listWorkItems('/remote/repo', 10, 'is:open', undefined, undefined, 'ssh-1')
).rejects.toThrow('GitHub work items require a GitHub remote for SSH repositories')
expect(ghExecFileAsyncMock).not.toHaveBeenCalled()
})
})