mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
614 lines
20 KiB
TypeScript
614 lines
20 KiB
TypeScript
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
|
|
const {
|
|
execFileAsyncMock,
|
|
ghExecFileAsyncMock,
|
|
getOwnerRepoMock,
|
|
getIssueOwnerRepoMock,
|
|
gitExecFileAsyncMock,
|
|
extractExecErrorMock,
|
|
getRateLimitMock,
|
|
rateLimitGuardMock,
|
|
noteRateLimitSpendMock,
|
|
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(),
|
|
rateLimitGuardMock: vi.fn(() => ({ 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
|
|
}),
|
|
ghRepoExecOptions: (context: { repoPath: string; wslDistro?: string }) => ({
|
|
cwd: context.repoPath,
|
|
...(context.wslDistro ? { wslDistro: context.wslDistro } : {})
|
|
}),
|
|
getOwnerRepo: getOwnerRepoMock,
|
|
getIssueOwnerRepo: getIssueOwnerRepoMock,
|
|
extractExecError: extractExecErrorMock,
|
|
acquire: acquireMock,
|
|
release: releaseMock,
|
|
_resetOwnerRepoCache: vi.fn()
|
|
}))
|
|
|
|
vi.mock('../git/runner', () => ({
|
|
gitExecFileAsync: gitExecFileAsyncMock
|
|
}))
|
|
|
|
vi.mock('./rate-limit', () => ({
|
|
getRateLimit: getRateLimitMock,
|
|
rateLimitGuard: rateLimitGuardMock,
|
|
noteRateLimitSpend: noteRateLimitSpendMock
|
|
}))
|
|
|
|
import { getPRChecks, rerunPRChecks, _resetOwnerRepoCache } from './client'
|
|
|
|
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: {} })
|
|
rateLimitGuardMock.mockReset()
|
|
rateLimitGuardMock.mockReturnValue({ blocked: false })
|
|
noteRateLimitSpendMock.mockReset()
|
|
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' }
|
|
)
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
|
3,
|
|
['api', '--cache', '60s', 'repos/acme/widgets/commits/head-oid/status?per_page=100'],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
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' }
|
|
)
|
|
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' } }
|
|
)
|
|
})
|
|
|
|
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' })
|
|
})
|
|
)
|
|
})
|
|
|
|
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' })
|
|
|
|
expect(getOwnerRepoMock).not.toHaveBeenCalled()
|
|
expectGraphQLRollupCall()
|
|
expect(ghExecFileAsyncMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
['pr', 'checks', '42', '--json', 'name,state,link', '--repo', 'acme/widgets'],
|
|
{ cwd: '/repo-root' }
|
|
)
|
|
})
|
|
|
|
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')
|
|
})
|
|
})
|