Files
orca/src/main/github/client-pr-checks.test.ts
T
Jinjing 63dbf12d14 Split github client (#15214)
* refactor(github-client): reorganize client into lifecycle folders

* refactor(github-client): extract PR refresh data and outcome assembly

Separate the derived data calculation and outcome assembly logic from
branch-lookup-resolution into dedicated modules for better separation of
concerns. Modernize type import syntax and format exports consistently.

* refactor(github-client): improve error handling and resilience

Defensive GraphQL parsing prevents partial responses from breaking REST fallbacks.
Cache failures now use shorter TTLs for faster recovery. PR operations have
dedicated error classification. GraphQL mutations track rate limit usage to prevent
quota exhaustion. Data validation improved to reject spurious values.

* Extract check rerun error classification with operation context

Create classifyRerunChecksError() to provide operation-specific error
messages when check reruns fail. This replaces generic GitHub error
copy with context appropriate to what the user attempted (rerun
checks). Follows the pattern of classifyListPrsError and improves
error handling by delegating extraction to extractExecError.

* Make check-rerun not-found error message resource-neutral

Error handling for failed check reruns now covers both workflow-run
reruns and standalone check-run rerequests. Tests verify the neutral
message works for both scenarios.
2026-08-17 21:18:44 -07:00

724 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,
// Why: origin repository resolution calls getOwnerRepoForRemote, not getOwnerRepo.
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, rerunPRChecks, _resetOwnerRepoCache } from './client'
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()
})
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', () => {
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')
})
})