Files
orca/src/main/github/client-pr-checks.test.ts
T
OrcaWinandOrcaWin 4687e90a16 [comment audit] Continue main-process comment cleanup (#32946)
* [comment audit] Shorten daemon, hook, and emulator comments [skip ci]

* [comment audit] Shorten VM, conversation, SQLite, and theme-import comments [skip ci]

* [comment audit] Tighten Git execution and source-control comment contracts [skip ci]

* [comment audit] Clarify repository probes and maintenance comments [skip ci]

* [comment audit] Shorten status, diff-cache, and checkout comments [skip ci]

* [comment audit] Clarify worktree lifecycle and WSL routing comments [skip ci]

* [comment audit] Shorten forge identity and review-refresh comments [skip ci]

* [comment audit] Tighten hosted-review client comment contracts [skip ci]

* [comment audit] Clarify GitLab compatibility and review comments [skip ci]

* [comment audit] Tighten hook ownership and watchdog rationale [skip ci]

* [comment audit] Condense host and integration compatibility comments [skip ci]

* [comment audit] Clarify IPC authority and remote-routing rationale [skip ci]

* [comment audit] Finalize file-import and containment comment cleanup

* [comment audit] Fix stale Jira type imports blocking full CI

---------

Co-authored-by: OrcaWin <293788423+OrcaWin@users.noreply.github.com>
2026-10-23 16:27:37 -07:00

746 lines
24 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
execFileAsyncMock,
ghExecFileAsyncMock,
getOwnerRepoMock,
getIssueOwnerRepoMock,
gitExecFileAsyncMock,
extractExecErrorMock,
getRateLimitMock,
repositoryRateLimitGuardMock,
noteRepositoryRateLimitSpendMock,
spendsSharedGitHubComQuotaMock,
acquireMock,
releaseMock
} = vi.hoisted(() => ({
execFileAsyncMock: vi.fn(),
ghExecFileAsyncMock: vi.fn(),
getOwnerRepoMock: vi.fn(),
getIssueOwnerRepoMock: vi.fn(),
gitExecFileAsyncMock: vi.fn(),
extractExecErrorMock: vi.fn((err: unknown) => {
if (err && typeof err === 'object') {
const e = err as { stderr?: unknown; stdout?: unknown; message?: unknown }
return {
stderr: typeof e.stderr === 'string' ? e.stderr : String(e.message ?? err),
stdout: typeof e.stdout === 'string' ? e.stdout : ''
}
}
return { stderr: String(err), stdout: '' }
}),
getRateLimitMock: vi.fn(),
repositoryRateLimitGuardMock: vi.fn(() => ({ blocked: false })),
noteRepositoryRateLimitSpendMock: vi.fn(),
spendsSharedGitHubComQuotaMock: vi.fn<
(repository?: { host?: string } | null, options?: { wslDistro?: string }) => boolean
>(() => true),
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
}),
ghRepoExecOptions: (context: { repoPath: string; wslDistro?: string }) => ({
cwd: context.repoPath,
...(context.wslDistro ? { wslDistro: context.wslDistro } : {})
}),
getOwnerRepo: getOwnerRepoMock,
getIssueOwnerRepo: getIssueOwnerRepoMock,
getOwnerRepoForRemote: (
repoPath: string,
remoteName: string,
connectionId?: string | null,
localGitOptions?: unknown
) =>
remoteName === 'origin'
? getOwnerRepoMock(repoPath, connectionId, localGitOptions)
: Promise.resolve(null),
extractExecError: extractExecErrorMock,
acquire: acquireMock,
release: releaseMock,
_resetOwnerRepoCache: vi.fn()
}))
vi.mock('../git/runner', () => ({
gitExecFileAsync: gitExecFileAsyncMock
}))
vi.mock('./rate-limit', () => ({
getRateLimit: getRateLimitMock,
repositoryRateLimitGuard: repositoryRateLimitGuardMock,
noteRepositoryRateLimitSpend: noteRepositoryRateLimitSpendMock,
spendsSharedGitHubComQuota: spendsSharedGitHubComQuotaMock
}))
import { getPRChecks } from './client/check/get-pr-checks'
import { rerunPRChecks } from './client/check/rerun-pr-checks'
import { _resetOwnerRepoCache } from './gh-utils'
import { _resetOriginGitHubApiRepositoryCache } from './github-api-repository'
beforeEach(() => {
_resetOriginGitHubApiRepositoryCache()
})
function graphQLChecksResponse({
contexts = [],
checkSuites = [],
headRefOid = 'head-oid'
}: {
contexts?: unknown[]
checkSuites?: unknown[]
headRefOid?: string
} = {}): { stdout: string } {
return {
stdout: JSON.stringify({
data: {
repository: {
pullRequest: {
headRefOid,
commits: {
nodes: [
{
commit: {
statusCheckRollup: { contexts: { nodes: contexts } },
checkSuites: { nodes: checkSuites }
}
}
]
}
}
}
}
})
}
}
function graphQLCheckRun(
overrides: Partial<{
databaseId: number
name: string
status: string
conclusion: string | null
detailsUrl: string | null
url: string | null
checkSuiteId: number | null
workflowRunId: number | null
}> = {}
): Record<string, unknown> {
const checkSuiteId = overrides.checkSuiteId ?? 1000
const workflowRunId = overrides.workflowRunId ?? 1
return {
__typename: 'CheckRun',
databaseId: overrides.databaseId ?? 88,
name: overrides.name ?? 'build',
status: overrides.status ?? 'COMPLETED',
conclusion: overrides.conclusion ?? 'SUCCESS',
detailsUrl: overrides.detailsUrl ?? 'https://github.com/acme/widgets/actions/runs/1',
url: overrides.url ?? 'https://github.com/acme/widgets/runs/88',
checkSuite: {
databaseId: checkSuiteId,
workflowRun: workflowRunId === null ? null : { databaseId: workflowRunId }
}
}
}
function graphQLStatusContext(
overrides: Partial<{
context: string
state: string
targetUrl: string | null
}> = {}
): Record<string, unknown> {
return {
__typename: 'StatusContext',
context: overrides.context ?? 'continuous-integration/jenkins/pr-merge',
state: overrides.state ?? 'PENDING',
targetUrl: overrides.targetUrl ?? 'https://jenkins.example.com/job/merge/1'
}
}
function graphQLCheckSuite(
overrides: Partial<{
databaseId: number
status: string
conclusion: string | null
url: string | null
app: { name?: string | null; slug?: string | null } | null
}> = {}
): Record<string, unknown> {
const databaseId = overrides.databaseId ?? 1001
return {
databaseId,
status: overrides.status ?? 'COMPLETED',
conclusion: overrides.conclusion ?? 'ACTION_REQUIRED',
url:
overrides.url ??
`https://github.com/acme/widgets/commit/head-oid/checks?check_suite_id=${databaseId}`,
app: overrides.app ?? { name: 'GitHub Actions', slug: 'github-actions' }
}
}
function expectGraphQLRollupCall(callIndex = 1, noCache = false): void {
const args = ghExecFileAsyncMock.mock.calls[callIndex - 1]?.[0] as string[]
expect(args.slice(0, 2)).toEqual(['api', 'graphql'])
expect(args).toEqual(
expect.arrayContaining(['-f', 'owner=acme', '-f', 'repo=widgets', '-F', 'pr=42'])
)
if (noCache) {
expect(args).not.toContain('--cache')
} else {
expect(args).toEqual(expect.arrayContaining(['--cache', '60s']))
}
const queryArg = args.find((arg) => arg.startsWith('query='))
expect(queryArg).toContain('statusCheckRollup')
expect(queryArg).toContain('checkSuites')
}
describe('getPRChecks', () => {
it('shares concurrent checks reads and honors explicit uncached refreshes', async () => {
vi.clearAllMocks()
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
acquireMock.mockResolvedValue(undefined)
const response = graphQLChecksResponse()
let finish: ((value: typeof response) => void) | undefined
ghExecFileAsyncMock.mockImplementation(
() =>
new Promise<typeof response>((resolve) => {
finish = resolve
})
)
const first = getPRChecks('/repo', 12)
const second = getPRChecks('/repo', 12)
await vi.waitFor(() => expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1))
finish?.(response)
await expect(Promise.all([first, second])).resolves.toEqual([[], []])
ghExecFileAsyncMock.mockResolvedValue(response)
await getPRChecks('/repo', 12, undefined, undefined, { noCache: true })
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(2)
expect(ghExecFileAsyncMock.mock.calls[1][0]).not.toContain('--cache')
})
beforeEach(() => {
execFileAsyncMock.mockReset()
ghExecFileAsyncMock.mockReset()
getOwnerRepoMock.mockReset()
getIssueOwnerRepoMock.mockReset()
gitExecFileAsyncMock.mockReset()
extractExecErrorMock.mockClear()
getRateLimitMock.mockReset()
getRateLimitMock.mockResolvedValue({ resources: {} })
repositoryRateLimitGuardMock.mockReset()
repositoryRateLimitGuardMock.mockReturnValue({ blocked: false })
noteRepositoryRateLimitSpendMock.mockReset()
spendsSharedGitHubComQuotaMock.mockReset()
spendsSharedGitHubComQuotaMock.mockImplementation(
(repository?: { host?: string } | null, options?: { wslDistro?: string }) =>
(!repository?.host || repository.host.toLowerCase() === 'github.com') && !options?.wslDistro
)
acquireMock.mockReset()
releaseMock.mockReset()
acquireMock.mockResolvedValue(undefined)
_resetOwnerRepoCache()
})
it('queries GitHub rollup details with one cached GraphQL call', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce({
stdout: graphQLChecksResponse({
contexts: [
graphQLCheckRun({
databaseId: 88,
name: 'build',
detailsUrl: 'https://github.com/acme/widgets/actions/runs/1'
})
]
}).stdout
})
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
expectGraphQLRollupCall()
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(checks).toEqual([
{
name: 'build',
status: 'completed',
conclusion: 'success',
url: 'https://github.com/acme/widgets/actions/runs/1',
checkRunId: 88,
workflowRunId: 1
}
])
})
it('merges rollup check-runs with legacy commit status contexts', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce(
graphQLChecksResponse({
contexts: [
graphQLCheckRun({
databaseId: 88,
name: 'Summary',
detailsUrl: 'https://github.com/acme/widgets/actions/runs/88',
workflowRunId: 88
}),
graphQLStatusContext(),
graphQLStatusContext({
context: 'Summary',
state: 'SUCCESS',
targetUrl: 'https://example.com/duplicate-summary'
})
]
})
)
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
expectGraphQLRollupCall()
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(checks).toEqual([
{
name: 'Summary',
status: 'completed',
conclusion: 'success',
url: 'https://github.com/acme/widgets/actions/runs/88',
checkRunId: 88,
workflowRunId: 88
},
{
name: 'continuous-integration/jenkins/pr-merge',
status: 'queued',
conclusion: 'pending',
url: 'https://jenkins.example.com/job/merge/1'
}
])
})
it('surfaces an action_required check suite that has no check run', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce(
graphQLChecksResponse({
contexts: [
graphQLCheckRun({
name: 'track-community-pr',
databaseId: 66,
checkSuiteId: 1000,
detailsUrl: 'https://github.com/acme/widgets/actions/runs/1'
})
],
checkSuites: [
graphQLCheckSuite({ databaseId: 1000, conclusion: 'SUCCESS' }),
graphQLCheckSuite({ databaseId: 1001 }),
graphQLCheckSuite({ databaseId: 1002 })
]
})
)
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
expectGraphQLRollupCall()
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(checks).toEqual([
{
name: 'track-community-pr',
status: 'completed',
conclusion: 'success',
url: 'https://github.com/acme/widgets/actions/runs/1',
checkRunId: 66,
workflowRunId: 1
},
{
name: 'GitHub Actions #1001',
status: 'completed',
conclusion: 'action_required',
url: 'https://github.com/acme/widgets/commit/head-oid/checks?check_suite_id=1001'
},
{
name: 'GitHub Actions #1002',
status: 'completed',
conclusion: 'action_required',
url: 'https://github.com/acme/widgets/commit/head-oid/checks?check_suite_id=1002'
}
])
})
it('maps stale and startup_failure conclusions to failure and action_required to its own state', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce(
graphQLChecksResponse({
contexts: [
graphQLCheckRun({ name: 'needs-approval', conclusion: 'ACTION_REQUIRED' }),
graphQLCheckRun({ name: 'old-run', conclusion: 'STALE' }),
graphQLCheckRun({ name: 'boot', conclusion: 'STARTUP_FAILURE' })
]
})
)
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
expect(checks.map((check) => check.conclusion)).toEqual([
'action_required',
'failure',
'failure'
])
})
it('surfaces an action_required suite even when there are zero check runs', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce(
graphQLChecksResponse({
checkSuites: [graphQLCheckSuite({ databaseId: 1001 })]
})
)
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(checks).toEqual([
{
name: 'GitHub Actions #1001',
status: 'completed',
conclusion: 'action_required',
url: 'https://github.com/acme/widgets/commit/head-oid/checks?check_suite_id=1001'
}
])
})
it('returns an empty list when the rollup has no checks or pending suites', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock.mockResolvedValueOnce(graphQLChecksResponse())
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
expect(ghExecFileAsyncMock).toHaveBeenCalledTimes(1)
expect(checks).toEqual([])
})
it('uses REST fallback when the GraphQL rollup is unavailable', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('GraphQL rollup failed'))
.mockResolvedValueOnce({
stdout: JSON.stringify({
check_runs: [
{
id: 88,
name: 'Summary',
status: 'completed',
conclusion: 'success',
html_url: 'https://github.com/acme/widgets/actions/runs/88',
details_url: null
}
]
})
})
.mockResolvedValueOnce({
stdout: JSON.stringify({
statuses: [
{
context: 'continuous-integration/jenkins/pr-merge',
state: 'pending',
target_url: 'https://jenkins.example.com/job/merge/1'
}
]
})
})
.mockResolvedValueOnce({ stdout: JSON.stringify({ check_suites: [] }) })
const checks = await getPRChecks('/repo-root', 42, 'head-oid')
expectGraphQLRollupCall()
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/check-runs?per_page=100'],
{ cwd: '/repo-root', host: 'github.com' }
)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/status?per_page=100'],
{ cwd: '/repo-root', host: 'github.com' }
)
expect(checks).toEqual([
{
name: 'Summary',
status: 'completed',
conclusion: 'success',
url: 'https://github.com/acme/widgets/actions/runs/88',
checkRunId: 88,
workflowRunId: 88
},
{
name: 'continuous-integration/jenkins/pr-merge',
status: 'queued',
conclusion: 'pending',
url: 'https://jenkins.example.com/job/merge/1'
}
])
})
it('treats gh pr checks "no checks reported" as an empty fallback list', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('GraphQL rollup failed'))
.mockRejectedValueOnce(
Object.assign(new Error('Command failed: gh pr checks 42'), {
stderr: "no checks reported on the 'codex/keybindings-toml' branch\n",
stdout: ''
})
)
const checks = await getPRChecks('/repo-root', 42)
expect(checks).toEqual([])
expect(consoleWarnSpy).toHaveBeenCalledWith(
'getPRChecks via GraphQL rollup failed, falling back to gh pr checks:',
expect.any(Error)
)
consoleWarnSpy.mockRestore()
})
it('throws unexpected gh pr checks fallback failures so callers preserve cache', async () => {
const consoleWarnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined)
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('GraphQL rollup failed'))
.mockRejectedValueOnce(
Object.assign(new Error('Command failed: gh pr checks 42'), {
stderr: 'GraphQL: Could not resolve to a PullRequest',
stdout: ''
})
)
await expect(getPRChecks('/repo-root', 42)).rejects.toThrow('Command failed: gh pr checks 42')
expect(consoleWarnSpy).toHaveBeenCalledWith(
'getPRChecks via GraphQL rollup failed, falling back to gh pr checks:',
expect.any(Error)
)
expect(consoleWarnSpy).toHaveBeenCalledWith('getPRChecks failed:', expect.any(Error))
consoleWarnSpy.mockRestore()
})
it('falls back to gh pr checks when GraphQL and REST details are unavailable', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('GraphQL rollup failed'))
.mockRejectedValueOnce(new Error('REST details failed'))
.mockResolvedValueOnce({
stdout: JSON.stringify([{ name: 'lint', state: 'PASS', link: 'https://example.com/lint' }])
})
const checks = await getPRChecks('/repo-root', 42, 'stale-head')
expectGraphQLRollupCall()
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
3,
['pr', 'checks', '42', '--json', 'name,state,link', '--repo', 'acme/widgets'],
{ cwd: '/repo-root', host: 'github.com' }
)
expect(checks).toEqual([
{
name: 'lint',
status: 'completed',
conclusion: 'success',
url: 'https://example.com/lint',
workflowRunId: undefined
}
])
})
it('reruns GitHub Actions checks for a PR', async () => {
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce(
graphQLChecksResponse({
contexts: [
graphQLCheckRun({
name: 'lint',
conclusion: 'FAILURE',
detailsUrl: 'https://github.com/acme/widgets/actions/runs/77/job/88',
workflowRunId: 77
})
]
})
)
.mockResolvedValueOnce({ stdout: '' })
const result = await rerunPRChecks('/repo-root', 42, { failedOnly: true })
expect(result).toEqual({ ok: true, count: 1 })
expectGraphQLRollupCall(1, true)
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['api', '-X', 'POST', 'repos/acme/widgets/actions/runs/77/rerun-failed-jobs'],
{
cwd: '/repo-root',
env: { ...process.env, GH_PROMPT_DISABLED: '1' },
host: 'github.com'
}
)
})
it('reports a resource-neutral not-found error when a workflow-run rerun 404s', async () => {
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce(
graphQLChecksResponse({
contexts: [
graphQLCheckRun({
name: 'lint',
conclusion: 'FAILURE',
detailsUrl: 'https://github.com/acme/widgets/actions/runs/77/job/88',
workflowRunId: 77
})
]
})
)
.mockRejectedValueOnce(
Object.assign(new Error('Command failed: gh api'), {
stderr: 'gh: HTTP 404 Not Found (repos/acme/widgets/actions/runs/77/rerun-failed-jobs)',
stdout: ''
})
)
const result = await rerunPRChecks('/repo-root', 42, { failedOnly: true })
expect(result).toEqual({
ok: false,
error: 'GitHub resource to rerun was not found — it may have expired or been deleted.'
})
})
it('reports a resource-neutral not-found error when a standalone check-run rerequest 404s', async () => {
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce(
graphQLChecksResponse({
contexts: [
{
__typename: 'CheckRun',
databaseId: 88,
name: 'external-ci',
status: 'COMPLETED',
conclusion: 'FAILURE',
detailsUrl: 'https://ci.example.com/builds/88',
url: 'https://ci.example.com/builds/88',
checkSuite: { databaseId: 1000, workflowRun: null }
}
]
})
)
.mockRejectedValueOnce(
Object.assign(new Error('Command failed: gh api'), {
stderr: 'gh: HTTP 404 Not Found (repos/acme/widgets/check-runs/88/rerequest)',
stdout: ''
})
)
const result = await rerunPRChecks('/repo-root', 42, { failedOnly: true })
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['api', '-X', 'POST', 'repos/acme/widgets/check-runs/88/rerequest'],
expect.objectContaining({ cwd: '/repo-root' })
)
expect(result).toEqual({
ok: false,
error: 'GitHub resource to rerun was not found — it may have expired or been deleted.'
})
})
it('routes local WSL check retrieval and reruns through the selected distro', async () => {
const localGitOptions = { wslDistro: 'Ubuntu' }
getOwnerRepoMock.mockResolvedValue({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockResolvedValueOnce(
graphQLChecksResponse({
contexts: [
graphQLCheckRun({
name: 'build',
detailsUrl: 'https://github.com/acme/widgets/actions/runs/66',
workflowRunId: 66
})
]
})
)
.mockResolvedValueOnce(
graphQLChecksResponse({
contexts: [
graphQLCheckRun({
name: 'lint',
conclusion: 'FAILURE',
detailsUrl: 'https://github.com/acme/widgets/actions/runs/77/job/88',
workflowRunId: 77
})
]
})
)
.mockResolvedValueOnce({ stdout: '' })
await getPRChecks('/repo-root', 42, 'head-oid', undefined, undefined, null, localGitOptions)
await rerunPRChecks('/repo-root', 42, { failedOnly: true }, null, localGitOptions)
expect(getOwnerRepoMock).toHaveBeenCalledWith('/repo-root', null, localGitOptions)
expect(ghExecFileAsyncMock.mock.calls.every((call) => call[1]?.wslDistro === 'Ubuntu')).toBe(
true
)
expect(ghExecFileAsyncMock).toHaveBeenLastCalledWith(
['api', '-X', 'POST', 'repos/acme/widgets/actions/runs/77/rerun-failed-jobs'],
expect.objectContaining({
cwd: '/repo-root',
wslDistro: 'Ubuntu',
env: expect.objectContaining({ GH_PROMPT_DISABLED: '1' })
})
)
expect(getRateLimitMock).not.toHaveBeenCalled()
expect(repositoryRateLimitGuardMock).toHaveBeenCalledWith(
{ owner: 'acme', repo: 'widgets', host: 'github.com' },
'graphql',
expect.objectContaining({ cwd: '/repo-root', wslDistro: 'Ubuntu', host: 'github.com' })
)
})
it('uses explicit PR repo for rollup and gh pr checks fallback', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'fork', repo: 'widgets' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('GraphQL rollup failed'))
.mockResolvedValueOnce({
stdout: JSON.stringify([{ name: 'lint', state: 'PASS', link: 'https://example.com/lint' }])
})
await getPRChecks('/repo-root', 42, undefined, {
owner: 'acme',
repo: 'widgets',
host: 'github.com'
})
expect(getOwnerRepoMock).not.toHaveBeenCalled()
expectGraphQLRollupCall()
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
2,
['pr', 'checks', '42', '--json', 'name,state,link', '--repo', 'acme/widgets'],
{ cwd: '/repo-root', host: 'github.com' }
)
})
it('throws when both GraphQL rollup and gh pr checks fail', async () => {
getOwnerRepoMock.mockResolvedValueOnce({ owner: 'acme', repo: 'widgets' })
ghExecFileAsyncMock
.mockRejectedValueOnce(new Error('GraphQL rollup failed'))
.mockRejectedValueOnce(new Error('rate limited'))
await expect(getPRChecks('/repo-root', 42)).rejects.toThrow('rate limited')
})
})