Files
orca/src/main/github/gh-utils.test.ts
T
Brennan BensonandJinjing 56ab5fd1dc fix(tasks): make GitHub pagination honest — cap unreachable pages, survive background refreshes, explain empty pages (#11584)
* fix(tasks): cap advertised GitHub pages at the search result window

GitHub's Search API rejects requests past its first-1000-results window
with HTTP 422, but totalPages was derived from the raw total_count, so
the pagination bar advertised pages that could never load and clicks on
them silently did nothing (#11485).

Cap per-repo advertised pages at floor(1000 / perRepoLimit), and when a
page load comes back empty, say so with a toast instead of ignoring the
click — clamping the advertised count only when no fetch threw, so
transient failures don't shrink the bar.

* fix(tasks): key pagination resets on repo selection, not array identity

The repos store installs a fresh array on every repos:changed event, so
the pagination-reset effect fired on background refreshes and bumped the
request generation, silently discarding any in-flight page navigation —
clicking an unloaded page did nothing whenever a repo refresh landed
during the fetch. Key the effect on the stable selection string instead.

* fix(tasks): distinguish end-of-data, window 422s, and failures on empty pages

Adversarial-review round 1 rework:
- fetchWorkItemsNextPage now returns issue-side envelope error types — the
  channel the search-window 422 actually travels on (failedCount only
  counts thrown repo calls).
- resolveEmptyPageOutcome (unit-tested) maps an empty page to
  window-unreachable (clamp + toast), load-failed (toast only; may be
  transient), or end-of-data (silently withdraw the speculative page the
  count-fallback advertises).
- The work-items fetch effect is keyed on selectedReposKey too — its
  unconditional page reset re-fired on every repos:changed array identity,
  bouncing the user to page 1 mid-click. The key now includes the resolved
  GitHub source context so identity changes still re-dispatch.
- Toasts carry stable ids so repeats replace instead of stack.
- Cap comment documents the conservative PR-scope tail loss; cap tests
  pinned at shipped (36 → 27) and dividing (25 → 40) limits.

* fix(tasks): withdraw the speculative page when the failed count is zero

countedTotalPages of 0 comes from a swallowed count failure and routes
totalPages through the fallback, so the clamp must replace it like null.

* fix(tasks): tighten empty-page outcomes after round-2 review

- en.json's loadPageUnreachable carried the pre-reword text, and the
  catalog beats the inline default — the two toasts were identical.
- end-of-data clamps only while the count is unknown/failed: the PR list
  path swallows its own failures into clean-empty results, and clamping a
  real count silently hid healthy pages (worse than the pre-fix no-op).
- A window 422 no longer clamps when a sibling repo's fetch threw.
- The generation effect mirrors every fetch-effect dep that resets page
  state, so manual refresh/source switches invalidate in-flight clicks.
- selectedReposKey extracted as buildSelectedReposKey with stability
  tests; envelope error types wire-tested through the store.

* fix(tasks): clamp against the committed count, not the click-time closure

Round-3 review: the count promise routinely resolves between click and
response, so deciding the end-of-data clamp from the closure value let a
stale null overwrite a real count. applyEmptyPageClamp now runs inside
the functional updater against the committed value, never raises an
earlier clamp, and a window 422 coinciding with a thrown sibling repo
resolves as load-failed so the toast and the clamp always agree.

* fix(tasks): only an all-window-422 empty page may clamp; harden count merges

Round-4 review: a sibling repo's envelope 403/404 arrives with
failedCount still 0, so the window branch now requires every error to be
the window 422 (non-window validation errors are demoted at the store);
the count resolution mins against an applied clamp instead of
re-advertising withdrawn pages; the generation effect mirrors
taskResumeApplied so its doc claim holds.

* fix(tasks): split the proven window limit from the count slot

Round-5 review: min-ing the count against an applied clamp pinned a
SPECULATIVE end-of-data withdrawal that raced ahead of the count,
permanently collapsing the bar for the generation. Proven window-422
limits now live in provenPageLimit (set once, only lowered, reset per
generation); the count overwrites its own slot unconditionally; and
deriveAdvertisedTotalPages (unit-tested for both arrival orders) caps
the count-or-fallback estimate with the proven limit, floored at the
loaded pages.

* fix(tasks): surface PR-side list failures so they can't read as end-of-data

Round-6 review: PartialWorkItemsResult had no PR error slot, so a
swallowed gh pr list failure reached the renderer as a clean empty page
— and with the count blocked (0) the speculative withdrawal deleted the
pagination bar with no toast and no recovery (a regression vs main's
silent no-op). PR-side errors now ride the envelope (errors.prs),
demoted so they can never join the issue-only window-422 signal;
errorTypes replaces issueErrorTypes; an empty page that a real count
said should exist now toasts instead of looking dead.

* test(tasks): cover the PR-error envelope end-to-end; neutral no-more-results toast

Round-7 review: the two literal gh-utils mocks lacked classifyListPrsError
(a PR-side rejection in those suites would TypeError instead of assert),
and the producer half of the errors.prs contract had no main-side test —
added both, plus a classifier contract test pinning the search-window
phrase the renderer keys on. The refused-clamp toast now reads the
committed count via a synchronous ref mirror instead of the click-time
closure, and says 'No more results' — nothing failed on that branch.
Both toast keys plus the new one are translated in es/ja/ko/zh.

* fix(tasks): preserve final reachable GitHub search page

* Extract GitHub search result window error pattern to shared constant

Extract the 1000-result window detection pattern to a single source of truth so
the classifier and consumer stay synchronized. The pattern is the only signal
separating a permanently unreachable page from a transient validation failure,
so drift or trimming silently demotes window 422s to generic failures and stops
capping the advertised page count (#11485).

---------

Co-authored-by: Jinjing <6427696+AmethystLiang@users.noreply.github.com>
2026-08-02 12:05:50 -07:00

825 lines
30 KiB
TypeScript

import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { beforeEach, describe, expect, it, vi } from 'vitest'
const { gitExecFileAsyncMock, getSshGitProviderGenerationMock, getSshGitProviderMock } = vi.hoisted(
() => ({
gitExecFileAsyncMock: vi.fn(),
getSshGitProviderGenerationMock: vi.fn(() => 0),
getSshGitProviderMock: vi.fn()
})
)
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock,
ghExecFileAsync: vi.fn()
}))
vi.mock('../providers/ssh-git-dispatch', () => ({
getSshGitProviderGeneration: getSshGitProviderGenerationMock,
getSshGitProvider: getSshGitProviderMock
}))
import {
_getOwnerRepoCacheSize,
_resetOwnerRepoCache,
classifyGhError,
classifyListIssuesError,
getIssueOwnerRepo,
getOwnerRepo,
getOwnerRepoForRemote,
parseGitHubRemoteIdentity,
parseGitHubOwnerRepo,
resolvePRRepositoryCandidates,
resolveIssueSource
} from './gh-utils'
import {
__resetLocalGitConfigSignatureCacheForTests,
readLocalGitConfigSignature
} from './local-git-config-signature'
import { GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN } from '../../shared/github-work-items-query-bounds'
describe('github owner/repo resolution', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
getSshGitProviderGenerationMock.mockReset()
getSshGitProviderGenerationMock.mockReturnValue(0)
getSshGitProviderMock.mockReset()
_resetOwnerRepoCache()
__resetLocalGitConfigSignatureCacheForTests()
})
it('parses GitHub HTTPS and SSH remotes', () => {
expect(parseGitHubOwnerRepo('https://github.com/acme/widgets.git')).toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(parseGitHubOwnerRepo('https://alice@github.com/acme/widgets.git')).toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(parseGitHubOwnerRepo('https://github.com:443/acme/widgets.git')).toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(parseGitHubOwnerRepo('git@github.com:stablyai/orca.git')).toEqual({
owner: 'stablyai',
repo: 'orca'
})
expect(parseGitHubOwnerRepo('git@github.com:TheBoredTeam/boring.notch.git')).toEqual({
owner: 'TheBoredTeam',
repo: 'boring.notch'
})
expect(parseGitHubOwnerRepo('ssh://git@github.com/stablyai/orca.git')).toEqual({
owner: 'stablyai',
repo: 'orca'
})
expect(parseGitHubOwnerRepo('ssh://git@ssh.github.com:443/stablyai/orca.git')).toEqual({
owner: 'stablyai',
repo: 'orca'
})
expect(parseGitHubOwnerRepo('git@example.com:stablyai/orca.git')).toBeNull()
})
it('parses GitHub Enterprise host identity', () => {
expect(parseGitHubRemoteIdentity('https://ghe.acme.internal/acme/orca.git')).toEqual({
host: 'ghe.acme.internal',
owner: 'acme',
repo: 'orca'
})
expect(parseGitHubRemoteIdentity('git@ghe.acme.internal:acme/orca.git')).toEqual({
host: 'ghe.acme.internal',
owner: 'acme',
repo: 'orca'
})
expect(parseGitHubOwnerRepo('https://ghe.acme.internal/acme/orca.git')).toBeNull()
})
it('prefers upstream for PR owner/repo resolution (#7331)', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@github.com:stablyai/orca.git\n'
})
await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'stablyai', repo: 'orca' })
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], {
cwd: '/repo',
timeout: 30_000
})
})
it('resolves GitHub HTTPS origin remotes with user info and a default port', async () => {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error("fatal: No such remote 'upstream'"))
.mockResolvedValueOnce({
stdout: 'https://alice@github.com:443/acme/widgets.git\n'
})
await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'acme', repo: 'widgets' })
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
cwd: '/repo',
timeout: 30_000
})
})
it('prefers upstream for issue owner/repo resolution', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@github.com:stablyai/orca.git\n'
})
await expect(getIssueOwnerRepo('/repo')).resolves.toEqual({ owner: 'stablyai', repo: 'orca' })
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], {
cwd: '/repo',
timeout: 30_000
})
})
it('falls back to origin when upstream is missing or non-GitHub', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@example.com:stablyai/orca.git\n' })
.mockResolvedValueOnce({ stdout: 'git@github.com:fork/orca.git\n' })
await expect(getIssueOwnerRepo('/repo')).resolves.toEqual({ owner: 'fork', repo: 'orca' })
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(1, ['remote', 'get-url', 'upstream'], {
cwd: '/repo',
timeout: 30_000
})
expect(gitExecFileAsyncMock).toHaveBeenNthCalledWith(2, ['remote', 'get-url', 'origin'], {
cwd: '/repo',
timeout: 30_000
})
})
it('does not mix origin and upstream cache entries for the same repo path', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@github.com:fork/orca.git\n' })
.mockResolvedValueOnce({ stdout: 'git@github.com:stablyai/orca.git\n' })
await expect(getOwnerRepoForRemote('/repo', 'origin')).resolves.toEqual({
owner: 'fork',
repo: 'orca'
})
await expect(getOwnerRepoForRemote('/repo', 'upstream')).resolves.toEqual({
owner: 'stablyai',
repo: 'orca'
})
})
it('coalesces concurrent missing remote probes for the same repo and remote', async () => {
gitExecFileAsyncMock.mockImplementation(async () => {
await Promise.resolve()
throw new Error("error: No such remote 'upstream'")
})
await expect(
Promise.all([
getOwnerRepoForRemote('/repo', 'upstream'),
getOwnerRepoForRemote('/repo', 'upstream'),
getOwnerRepoForRemote('/repo', 'upstream'),
getOwnerRepoForRemote('/repo', 'upstream')
])
).resolves.toEqual([null, null, null, null])
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'upstream'], {
cwd: '/repo',
timeout: 30_000
})
await expect(getOwnerRepoForRemote('/repo', 'upstream')).resolves.toBeNull()
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
})
it('resolves SSH repo remotes through the registered SSH git provider', async () => {
const sshProvider = {
exec: vi.fn(async (args: string[]) => {
if (args[2] === 'upstream') {
throw new Error("fatal: No such remote 'upstream'")
}
return { stdout: 'git@github.com:stablyai/orca.git\n', stderr: '' }
})
}
getSshGitProviderMock.mockReturnValue(sshProvider)
await expect(getOwnerRepo('/home/user/orca', 'openclaw-2')).resolves.toEqual({
owner: 'stablyai',
repo: 'orca'
})
expect(gitExecFileAsyncMock).not.toHaveBeenCalled()
expect(getSshGitProviderMock).toHaveBeenCalledWith('openclaw-2')
expect(sshProvider.exec).toHaveBeenCalledWith(
['remote', 'get-url', 'origin'],
'/home/user/orca',
{
signal: expect.any(AbortSignal)
}
)
})
it('keeps local and SSH owner/repo cache entries separate for the same path', async () => {
const sshProvider = {
exec: vi.fn().mockResolvedValue({ stdout: 'git@github.com:remote/orca.git\n', stderr: '' })
}
gitExecFileAsyncMock.mockResolvedValueOnce({ stdout: 'git@github.com:local/orca.git\n' })
getSshGitProviderMock.mockReturnValue(sshProvider)
await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'local', repo: 'orca' })
await expect(getOwnerRepo('/repo', 'ssh-1')).resolves.toEqual({ owner: 'remote', repo: 'orca' })
})
it('keeps local host and local WSL owner/repo cache entries separate for the same path', async () => {
gitExecFileAsyncMock.mockImplementation(
async (args: string[], options: { wslDistro?: string } = {}) => {
if (args[2] === 'upstream') {
throw new Error("fatal: No such remote 'upstream'")
}
return {
stdout: options.wslDistro
? 'git@github.com:wsl/orca.git\n'
: 'git@github.com:host/orca.git\n'
}
}
)
await expect(getOwnerRepo('/repo')).resolves.toEqual({ owner: 'host', repo: 'orca' })
await expect(getOwnerRepo('/repo', null, { wslDistro: 'Ubuntu' })).resolves.toEqual({
owner: 'wsl',
repo: 'orca'
})
await expect(getOwnerRepo('/repo', null, { wslDistro: 'Ubuntu' })).resolves.toEqual({
owner: 'wsl',
repo: 'orca'
})
// 2 runtimes x (1 upstream miss + 1 origin hit); repeat WSL call is cached.
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(4)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
cwd: '/repo',
timeout: 30_000
})
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
cwd: '/repo',
timeout: 30_000,
wslDistro: 'Ubuntu'
})
})
it('prunes expired distinct owner/repo cache entries on later lookups', async () => {
const nowSpy = vi.spyOn(Date, 'now')
try {
nowSpy.mockReturnValue(1_000)
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@github.com:stablyai/orca.git\n'
})
await expect(getOwnerRepo('/repo-a')).resolves.toEqual({ owner: 'stablyai', repo: 'orca' })
expect(_getOwnerRepoCacheSize()).toBe(1)
nowSpy.mockReturnValue(32_000)
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@github.com:acme/widgets.git\n'
})
await expect(getOwnerRepo('/repo-b')).resolves.toEqual({ owner: 'acme', repo: 'widgets' })
expect(_getOwnerRepoCacheSize()).toBe(1)
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
nowSpy.mockRestore()
}
})
it('resolves PR candidates as upstream then origin and de-dupes matching slugs', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@github.com:Acme/Orca.git\n' })
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/orca.git\n' })
await expect(resolvePRRepositoryCandidates('/repo')).resolves.toEqual({
candidates: [{ owner: 'Acme', repo: 'Orca' }],
headRepo: { owner: 'acme', repo: 'orca' }
})
})
it('ignores non-GitHub upstream while keeping origin as the head repo', async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@example.com:Acme/Orca.git\n' })
.mockResolvedValueOnce({ stdout: 'git@github.com:fork/orca.git\n' })
await expect(resolvePRRepositoryCandidates('/repo')).resolves.toEqual({
candidates: [{ owner: 'fork', repo: 'orca' }],
headRepo: { owner: 'fork', repo: 'orca' }
})
})
it('expires cached remote owner/repo entries after the TTL', async () => {
vi.useFakeTimers()
try {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@github.com:old/orca.git\n' })
.mockResolvedValueOnce({ stdout: 'git@github.com:new/orca.git\n' })
await expect(getOwnerRepoForRemote('/repo', 'origin')).resolves.toEqual({
owner: 'old',
repo: 'orca'
})
await expect(getOwnerRepoForRemote('/repo', 'origin')).resolves.toEqual({
owner: 'old',
repo: 'orca'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
await vi.advanceTimersByTimeAsync(30_001)
await expect(getOwnerRepoForRemote('/repo', 'origin')).resolves.toEqual({
owner: 'new',
repo: 'orca'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
}
})
it('keeps local missing-remote probes cached beyond the short positive TTL', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
await writeFile(join(repoPath, '.git', 'config'), '[core]\n\trepositoryformatversion = 0\n')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock.mockRejectedValue(new Error("error: No such remote 'origin'"))
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('treats stderr-only missing-remote errors as stable negatives', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
await writeFile(join(repoPath, '.git', 'config'), '[core]\n\trepositoryformatversion = 0\n')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock.mockRejectedValue(
Object.assign(new Error('Command failed'), {
stderr: "fatal: No such remote 'origin'"
})
)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('does not apply the long negative TTL when git remote get-url fails transiently', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
await writeFile(join(repoPath, '.git', 'config'), '[core]\n\trepositoryformatversion = 0\n')
try {
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('fatal: cannot lock ref'))
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
await rm(repoPath, { recursive: true, force: true })
}
})
it('invalidates a cached local missing remote when git config changes', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
const configPath = join(repoPath, '.git', 'config')
await writeFile(configPath, '[core]\n\trepositoryformatversion = 0\n')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error("error: No such remote 'origin'"))
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
await writeFile(
configPath,
'[core]\n\trepositoryformatversion = 0\n[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('invalidates a cached local missing remote when an included git config changes', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
const includedConfigPath = join(repoPath, 'remote.inc')
await writeFile(
join(repoPath, '.git', 'config'),
`[core]\n\trepositoryformatversion = 0\n[include]\n\tpath = ${includedConfigPath}\n`
)
await writeFile(includedConfigPath, '')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error("error: No such remote 'origin'"))
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
await writeFile(
includedConfigPath,
'[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('tracks included git config paths with inline comments', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
const includedConfigPath = join(repoPath, 'remote-with-comment.inc')
await writeFile(
join(repoPath, '.git', 'config'),
`[core]\n\trepositoryformatversion = 0\n[include]\n\tpath = ${includedConfigPath} # origin remote lives here\n`
)
await writeFile(includedConfigPath, '')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error("error: No such remote 'origin'"))
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
await writeFile(
includedConfigPath,
'[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('tracks included git config paths when section headers have inline comments', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
const includedConfigPath = join(repoPath, 'section-comment.inc')
await writeFile(
join(repoPath, '.git', 'config'),
`[core]\n\trepositoryformatversion = 0\n[include] # comment\n\tpath = ${includedConfigPath}\n`
)
await writeFile(includedConfigPath, '')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error("error: No such remote 'origin'"))
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
await writeFile(
includedConfigPath,
'[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('tracks quoted included git config paths with inline comments', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
const includedConfigPath = join(repoPath, 'quoted-comment.inc')
await writeFile(
join(repoPath, '.git', 'config'),
`[core]\n\trepositoryformatversion = 0\n[include]\n\tpath = "${includedConfigPath}" # comment\n`
)
await writeFile(includedConfigPath, '')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error("error: No such remote 'origin'"))
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
await writeFile(
includedConfigPath,
'[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('tracks quoted included git config paths with comment characters in the path', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
await mkdir(join(repoPath, '.git'))
const includeDir = join(repoPath, 'include # hash')
await mkdir(includeDir)
const includedConfigPath = join(includeDir, 'remote.inc')
await writeFile(
join(repoPath, '.git', 'config'),
`[core]\n\trepositoryformatversion = 0\n[include]\n\tpath = "${includedConfigPath}"\n`
)
await writeFile(includedConfigPath, '')
vi.useFakeTimers()
try {
vi.setSystemTime(1_000)
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error("error: No such remote 'origin'"))
.mockResolvedValueOnce({ stdout: 'git@github.com:acme/widgets.git\n' })
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toBeNull()
await writeFile(
includedConfigPath,
'[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
vi.setSystemTime(32_000)
await expect(getOwnerRepoForRemote(repoPath, 'origin')).resolves.toEqual({
owner: 'acme',
repo: 'widgets'
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(2)
} finally {
vi.useRealTimers()
await rm(repoPath, { recursive: true, force: true })
}
})
it('includes per-worktree git config in local config signatures', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
const gitDir = join(repoPath, '.git')
await mkdir(gitDir)
await writeFile(join(gitDir, 'config'), '[core]\n\trepositoryformatversion = 0\n')
try {
const firstSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: null
})
await writeFile(
join(gitDir, 'config.worktree'),
'[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
const secondSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: null
})
expect(secondSignature).not.toEqual(firstSignature)
} finally {
await rm(repoPath, { recursive: true, force: true })
}
})
it('includes linked worktree config in local config signatures', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
const commonGitDir = join(repoPath, 'common-git')
const worktreeGitDir = join(commonGitDir, 'worktrees', 'feature')
const worktreePath = join(repoPath, 'feature-worktree')
await mkdir(worktreeGitDir, { recursive: true })
await mkdir(worktreePath)
await writeFile(join(worktreePath, '.git'), `gitdir: ${worktreeGitDir}\n`)
await writeFile(join(worktreeGitDir, 'commondir'), '../..\n')
await writeFile(join(commonGitDir, 'config'), '[core]\n\trepositoryformatversion = 0\n')
try {
const firstSignature = await readLocalGitConfigSignature({
repoPath: worktreePath,
connectionId: null
})
await writeFile(
join(worktreeGitDir, 'config.worktree'),
'[branch "feature"]\n\tremote = origin\n\tmerge = refs/heads/contributor/original\n'
)
const secondSignature = await readLocalGitConfigSignature({
repoPath: worktreePath,
connectionId: null
})
expect(secondSignature).not.toEqual(firstSignature)
} finally {
await rm(repoPath, { recursive: true, force: true })
}
})
it('tracks includeIf paths with comment markers inside quoted section headers', async () => {
const repoPath = await mkdtemp(join(tmpdir(), 'orca-gh-utils-'))
const gitDir = join(repoPath, '.git')
const includedDir = join(repoPath, 'Work #1')
const includedConfigPath = join(includedDir, 'included.gitconfig')
await mkdir(gitDir)
await mkdir(includedDir)
await writeFile(
includedConfigPath,
'[remote "origin"]\n\turl = git@github.com:acme/widgets.git\n'
)
await writeFile(
join(gitDir, 'config'),
`[includeIf "gitdir:${includedDir}/"]\n\tpath = "${includedConfigPath}"\n`
)
try {
const firstSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: null
})
await writeFile(
includedConfigPath,
'[remote "origin"]\n\turl = git@github.com:acme/renamed-widgets.git\n'
)
const secondSignature = await readLocalGitConfigSignature({
repoPath,
connectionId: null
})
expect(secondSignature).not.toEqual(firstSignature)
} finally {
await rm(repoPath, { recursive: true, force: true })
}
})
})
describe('resolveIssueSource', () => {
beforeEach(() => {
gitExecFileAsyncMock.mockReset()
getSshGitProviderMock.mockReset()
_resetOwnerRepoCache()
})
it("'auto' + upstream exists → upstream, fellBack=false", async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@github.com:stablyai/orca.git\n'
})
await expect(resolveIssueSource('/repo', 'auto')).resolves.toEqual({
source: { owner: 'stablyai', repo: 'orca' },
fellBack: false
})
})
it("'auto' + no upstream → origin, fellBack=false", async () => {
gitExecFileAsyncMock
.mockResolvedValueOnce({ stdout: 'git@example.com:stablyai/orca.git\n' })
.mockResolvedValueOnce({ stdout: 'git@github.com:solo/orca.git\n' })
await expect(resolveIssueSource('/repo', 'auto')).resolves.toEqual({
source: { owner: 'solo', repo: 'orca' },
fellBack: false
})
})
it("'upstream' + upstream exists → upstream, fellBack=false", async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@github.com:stablyai/orca.git\n'
})
await expect(resolveIssueSource('/repo', 'upstream')).resolves.toEqual({
source: { owner: 'stablyai', repo: 'orca' },
fellBack: false
})
})
it("'upstream' + no upstream remote → origin, fellBack=true", async () => {
// No upstream remote configured — the first call fails.
gitExecFileAsyncMock
.mockRejectedValueOnce(new Error('fatal: No such remote'))
.mockResolvedValueOnce({ stdout: 'git@github.com:solo/orca.git\n' })
await expect(resolveIssueSource('/repo', 'upstream')).resolves.toEqual({
source: { owner: 'solo', repo: 'orca' },
fellBack: true
})
})
it("'origin' + upstream exists → origin (ignores upstream), fellBack=false", async () => {
// Only one gh call should happen — origin. Upstream is never consulted.
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@github.com:fork/orca.git\n'
})
await expect(resolveIssueSource('/repo', 'origin')).resolves.toEqual({
source: { owner: 'fork', repo: 'orca' },
fellBack: false
})
expect(gitExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(gitExecFileAsyncMock).toHaveBeenCalledWith(['remote', 'get-url', 'origin'], {
cwd: '/repo',
timeout: 30_000
})
})
it("'origin' + no upstream → origin, fellBack=false", async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@github.com:solo/orca.git\n'
})
await expect(resolveIssueSource('/repo', 'origin')).resolves.toEqual({
source: { owner: 'solo', repo: 'orca' },
fellBack: false
})
})
it('undefined preference is treated identically to auto', async () => {
gitExecFileAsyncMock.mockResolvedValueOnce({
stdout: 'git@github.com:stablyai/orca.git\n'
})
await expect(resolveIssueSource('/repo', undefined)).resolves.toEqual({
source: { owner: 'stablyai', repo: 'orca' },
fellBack: false
})
})
})
describe('gh error classification', () => {
// Why: a fork with Issues turned off triggers `gh issue list` stderr
// "the '<slug>' repository has disabled issues". Without a dedicated branch
// the raw "Command failed: gh issue list …" line leaks into the Tasks banner
// via the `unknown` fallback — which is what users see when they flip the
// per-repo selector to an origin fork that has issues disabled.
it('classifies "has disabled issues" stderr as issues_disabled', () => {
const stderr =
"Command failed: gh issue list --limit 36 --json number,title,state --repo brennanb2025/orca --state open\nthe 'brennanb2025/orca' repository has disabled issues"
expect(classifyGhError(stderr)).toEqual({
type: 'issues_disabled',
message: 'Issues are disabled on this repository.'
})
expect(classifyListIssuesError(stderr)).toEqual({
type: 'issues_disabled',
message: 'Issues are disabled on this repository.'
})
})
// Why: the renderer detects the Search API 1000-result window by matching
// GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN against this message (#11485) —
// trimming the raw stderr out of the validation_error copy, or drifting the
// pattern off GitHub's real wording, would silently downgrade every window
// 422 to a generic failure. The stderr stays verbatim so this pins both ends.
it('keeps the search-window phrase in validation_error list messages', () => {
const stderr =
'Command failed: gh api --hostname github.com search/issues\nValidation Failed: Only the first 1000 search results are available (HTTP 422)'
const classified = classifyListIssuesError(stderr)
expect(classified.type).toBe('validation_error')
expect(classified.message).toMatch(GITHUB_SEARCH_RESULT_WINDOW_ERROR_PATTERN)
})
})