Files
orca/src/main/github/client-work-items.test.ts
Neil 583ab1601b refactor(shared): group worktree, github, and linear modules into folders (#14437)
`src/shared` is a flat directory of ~1,150 entries. The worktree, github, and
linear domains accounted for 71 of them, so finding the module you wanted meant
scanning a wall of same-prefixed filenames.

Move each domain into its own folder and drop the now-redundant prefix:

    src/shared/github-pr-types.ts    -> src/shared/github/pull-request-types.ts
    src/shared/worktree-id.ts        -> src/shared/worktree/id.ts
    src/shared/linear-links.ts       -> src/shared/linear/links.ts

This follows the existing `network/` and `new-workspace/` convention in the
same directory, which also drop the prefix inside the folder.

Whole clusters move, including tests. Foldering only part of a domain would be
worse than flat: a reader would have to check both `github/` and the flat
directory, and `github-auth-types.ts` / `github-project-types.ts` are type
modules that belong with the rest. No files with these prefixes remain flat.

Import specifiers were rewritten by resolving each one to an absolute path and
recomputing it, not by string substitution, so the `@/../../shared/...` alias
forms are handled correctly. 501 specifiers across 298 files.

Two things `tsc` cannot catch, handled explicitly:

- `github-project-types.ts` carries its own `max-lines` bypass, so its baseline
  entry is REPOINTED to the new path rather than pruned. Pruning would drop the
  bypass and then flag the new path as a fresh violation. Ratchet stays at 345.
- `mobile/` is outside `pnpm typecheck` and cannot be typechecked here
  (`mobile/node_modules` is empty). Instead every relative specifier in the repo
  was resolved against the filesystem: 174 unresolved before this change and 174
  after — identical, so nothing broke in mobile either.

The pinned `tests/e2e/.cross-version-checkouts` fixtures are deliberately NOT
rewritten; they are a snapshot of an older release and still reference the old
paths.

Verified: cold `tsc --noEmit` green on node, cli, and web (buildinfo deleted
first — these projects are `composite: true` and reuse stale caches).
2026-08-13 20:44:16 -07:00

829 lines
28 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
import type * as GithubApiRepositoryModule from './github-api-repository'
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' })),
// Mirror production: shared-scope calls delegate to the global guard/spend.
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,
// Why: these suites drive source resolution through the legacy gh-utils
// mocks; bridge the hosted seams onto the same mocks.
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,
listWorkItems,
_resetMergeQueueCacheForTests,
_resetOwnerRepoCache
} from './client'
import { GITHUB_WORK_ITEMS_QUERY_MAX_BYTES } from '../../shared/github/work-items-query-bounds'
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
// The origin-repository cache is module-level state; reset it so slugs
// resolved by one test cannot leak into the next.
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)
// Why: preference-aware `listWorkItems` calls `resolveIssueSource`.
// Route through the same `getIssueOwnerRepoMock` so existing tests that
// only set up `getIssueOwnerRepoMock` continue to work.
resolveIssueSourceMock.mockImplementation(async () => ({
source: await getIssueOwnerRepoMock(),
fellBack: false
}))
// Why: since #7331 `resolvePrWorkItemSource` fetches origin through
// `getOwnerRepoForRemote` (getOwnerRepo became upstream-first). Delegate
// the origin probe to `getOwnerRepoMock` so existing tests keep defining
// the PR-side origin through it; default upstream to null.
getOwnerRepoForRemoteMock.mockImplementation(
async (repoPath: string, remoteName: string, connectionId?: string | null, opts = {}) =>
remoteName === 'origin' ? getOwnerRepoMock(repoPath, connectionId, opts) : null
)
_resetOwnerRepoCache()
_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)
// Why: every list spawn must pin options.host so the runner targets the
// Enterprise server instead of gh's default host.
expect(calls.every(([, options]) => options?.host === 'github.acme-corp.com')).toBe(true)
})
it('does not run unscoped work-item queries when a local repository has no GitHub source', async () => {
getIssueOwnerRepoMock.mockResolvedValue(null)
getOwnerRepoMock.mockResolvedValue(null)
// Why: unresolved sources must return empty null-source envelopes (not throw, not query) so the renderer can
// distinguish "no GitHub source detected" from genuine zero via `sources` (#9660 follow-up).
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',
'is:pr 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
)
// Why: the suite bridge routes origin through getOwnerRepoMock and non-origin
// remotes through getOwnerRepoForRemoteMock.
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('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:pr 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:pr 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:pr 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:pr 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()
})
})