mirror of
https://github.com/stablyai/orca.git
synced 2026-10-26 16:02:42 +00:00
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.
This commit is contained in:
@@ -0,0 +1,83 @@
|
||||
import type { PRCheckRunDetails } from '../../../../shared/github/check-types'
|
||||
export function nullableString(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length > 0 ? value : null
|
||||
}
|
||||
|
||||
export function nullableNumber(value: unknown): number | null {
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null
|
||||
}
|
||||
|
||||
export function mapCheckAnnotations(raw: unknown): PRCheckRunDetails['annotations'] {
|
||||
if (!Array.isArray(raw)) {
|
||||
return []
|
||||
}
|
||||
return raw
|
||||
.filter((annotation): annotation is Record<string, unknown> => Boolean(annotation))
|
||||
.map((annotation) => ({
|
||||
path: nullableString(annotation.path),
|
||||
startLine: nullableNumber(annotation.start_line),
|
||||
endLine: nullableNumber(annotation.end_line),
|
||||
annotationLevel: nullableString(annotation.annotation_level),
|
||||
title: nullableString(annotation.title),
|
||||
message: nullableString(annotation.message) ?? '',
|
||||
rawDetails: nullableString(annotation.raw_details)
|
||||
}))
|
||||
}
|
||||
|
||||
export function mapWorkflowJobs(raw: unknown, checkName?: string): PRCheckRunDetails['jobs'] {
|
||||
if (!raw || typeof raw !== 'object' || !Array.isArray((raw as { jobs?: unknown }).jobs)) {
|
||||
return []
|
||||
}
|
||||
const jobs = (raw as { jobs: unknown[] }).jobs
|
||||
.filter((job): job is Record<string, unknown> => Boolean(job))
|
||||
.map((job) => ({
|
||||
id: nullableNumber(job.id),
|
||||
name: nullableString(job.name) ?? 'Unnamed job',
|
||||
status: nullableString(job.status),
|
||||
conclusion: nullableString(job.conclusion),
|
||||
startedAt: nullableString(job.started_at),
|
||||
completedAt: nullableString(job.completed_at),
|
||||
url: nullableString(job.html_url),
|
||||
logTail: null,
|
||||
steps: Array.isArray(job.steps)
|
||||
? job.steps
|
||||
.filter((step): step is Record<string, unknown> => Boolean(step))
|
||||
.map((step) => ({
|
||||
name: nullableString(step.name) ?? 'Unnamed step',
|
||||
status: nullableString(step.status),
|
||||
conclusion: nullableString(step.conclusion),
|
||||
startedAt: nullableString(step.started_at),
|
||||
completedAt: nullableString(step.completed_at)
|
||||
}))
|
||||
: []
|
||||
}))
|
||||
const exactMatches = checkName ? jobs.filter((job) => job.name === checkName) : []
|
||||
return exactMatches.length > 0 ? exactMatches : jobs
|
||||
}
|
||||
|
||||
export function getWorkflowRunIdFromCheckRun(
|
||||
checkRun: Record<string, unknown> | null
|
||||
): number | undefined {
|
||||
const checkSuite = checkRun?.check_suite
|
||||
if (!checkSuite || typeof checkSuite !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const workflowRun = (checkSuite as { workflow_run?: unknown }).workflow_run
|
||||
if (!workflowRun || typeof workflowRun !== 'object') {
|
||||
return undefined
|
||||
}
|
||||
const id = (workflowRun as { id?: unknown }).id
|
||||
return typeof id === 'number' && Number.isSafeInteger(id) ? id : undefined
|
||||
}
|
||||
|
||||
export function parseActionsRunId(url: string | null | undefined): number | undefined {
|
||||
if (!url) {
|
||||
return undefined
|
||||
}
|
||||
const match = /\/actions\/runs\/(\d+)(?:[/?#]|$)/.exec(url)
|
||||
if (!match) {
|
||||
return undefined
|
||||
}
|
||||
const id = Number(match[1])
|
||||
return Number.isSafeInteger(id) ? id : undefined
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export function rethrowCheckDetailsAbort(signal: AbortSignal | undefined, error: unknown): void {
|
||||
if (signal?.aborted) {
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
export function waitForCheckDetailsResolution<T>(
|
||||
operation: Promise<T>,
|
||||
signal: AbortSignal
|
||||
): Promise<T> {
|
||||
if (signal.aborted) {
|
||||
return Promise.reject(signal.reason)
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const finish = (settle: () => void): void => {
|
||||
signal.removeEventListener('abort', onAbort)
|
||||
settle()
|
||||
}
|
||||
const onAbort = (): void => finish(() => reject(signal.reason))
|
||||
signal.addEventListener('abort', onAbort, { once: true })
|
||||
void operation.then(
|
||||
(value) => finish(() => resolve(value)),
|
||||
(error) => finish(() => reject(error))
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { PRCheckRunDetails } from '../../../../shared/github/check-types'
|
||||
import { sliceCheckLogTail } from '../../../../shared/check-job-log-tail-slice'
|
||||
import { ghExecFileAsync } from '../../gh-utils'
|
||||
import type { GitHubApiRepository } from '../../github-api-repository'
|
||||
import { githubRepoIdentityKey } from '../../../../shared/github/repository-identity-key'
|
||||
import type { GhExecOptions } from './../github-exec-scope'
|
||||
import { rethrowCheckDetailsAbort } from './check-details-abort'
|
||||
export const PR_CHECK_LOG_TAIL_JOB_LIMIT = 5
|
||||
|
||||
// Why: only the tail is kept, but the whole log buffers first — the default 10MiB cap drops long CI logs.
|
||||
// Still far below the V8 string ceiling that DEFAULT_GIT_MAX_BUFFER guards against.
|
||||
export const PR_CHECK_LOG_MAX_BUFFER = 64 * 1024 * 1024
|
||||
|
||||
// Why: each entry holds up to 16KB of log text; bound the cache so a long session can't grow it unbounded.
|
||||
export const PR_CHECK_LOG_TAIL_CACHE_MAX_ENTRIES = 128
|
||||
|
||||
export const prCheckLogTailCache = new Map<string, string | null>()
|
||||
|
||||
export function setPrCheckLogTailCache(cacheKey: string, logTail: string | null): void {
|
||||
prCheckLogTailCache.set(cacheKey, logTail)
|
||||
while (prCheckLogTailCache.size > PR_CHECK_LOG_TAIL_CACHE_MAX_ENTRIES) {
|
||||
const oldestKey = prCheckLogTailCache.keys().next().value
|
||||
if (oldestKey === undefined) {
|
||||
break
|
||||
}
|
||||
prCheckLogTailCache.delete(oldestKey)
|
||||
}
|
||||
}
|
||||
|
||||
export function isCheckJobFailureState(state: string | null | undefined): boolean {
|
||||
return (
|
||||
state === 'failure' ||
|
||||
state === 'failed' ||
|
||||
state === 'action_required' ||
|
||||
state === 'cancelled' ||
|
||||
state === 'stale' ||
|
||||
state === 'startup_failure' ||
|
||||
state === 'timed_out'
|
||||
)
|
||||
}
|
||||
|
||||
export function getCheckJobLogTailCacheKey(job: PRCheckRunDetails['jobs'][number]): string | null {
|
||||
if (job.id === null) {
|
||||
return null
|
||||
}
|
||||
return `${job.id}:${job.completedAt ?? ''}`
|
||||
}
|
||||
|
||||
export async function attachFailedJobLogTails(
|
||||
jobs: PRCheckRunDetails['jobs'],
|
||||
ownerRepo: GitHubApiRepository,
|
||||
ghOptions: GhExecOptions
|
||||
): Promise<void> {
|
||||
const failedJobs = jobs
|
||||
.filter((job) => {
|
||||
const state = job.conclusion ?? job.status
|
||||
return job.id !== null && isCheckJobFailureState(state)
|
||||
})
|
||||
.slice(0, PR_CHECK_LOG_TAIL_JOB_LIMIT)
|
||||
|
||||
// Why: cap log fetches so failed-job details stay a bounded follow-up, not a burst of hosted log downloads.
|
||||
for (const job of failedJobs) {
|
||||
const jobCacheKey = getCheckJobLogTailCacheKey(job)
|
||||
const cacheKey = jobCacheKey ? `${githubRepoIdentityKey(ownerRepo)}:${jobCacheKey}` : null
|
||||
if (!cacheKey) {
|
||||
continue
|
||||
}
|
||||
if (prCheckLogTailCache.has(cacheKey)) {
|
||||
job.logTail = prCheckLogTailCache.get(cacheKey) ?? null
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/jobs/${job.id}/logs`],
|
||||
{ ...ghOptions, maxBuffer: PR_CHECK_LOG_MAX_BUFFER }
|
||||
)
|
||||
job.logTail = sliceCheckLogTail(stdout)
|
||||
} catch (err) {
|
||||
rethrowCheckDetailsAbort(ghOptions.signal, err)
|
||||
console.warn('getPRCheckDetails workflow job log fetch failed:', err)
|
||||
job.logTail = null
|
||||
}
|
||||
setPrCheckLogTailCache(cacheKey, job.logTail)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import type { PRCheckRunDetails } from '../../../../shared/github/check-types'
|
||||
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import {
|
||||
GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS,
|
||||
GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE
|
||||
} from '../../../../shared/github/check-details-deadline'
|
||||
import type { GhExecOptions } from './../github-exec-scope'
|
||||
import {
|
||||
nullableString,
|
||||
mapCheckAnnotations,
|
||||
mapWorkflowJobs,
|
||||
getWorkflowRunIdFromCheckRun
|
||||
} from './check-detail-field-mapping'
|
||||
import { rethrowCheckDetailsAbort, waitForCheckDetailsResolution } from './check-details-abort'
|
||||
import { attachFailedJobLogTails } from './check-job-log-tails'
|
||||
export async function getPRCheckDetails(
|
||||
repoPath: string,
|
||||
args: {
|
||||
checkRunId?: number
|
||||
workflowRunId?: number
|
||||
checkName?: string
|
||||
url?: string | null
|
||||
prRepo?: GitHubApiRepository | null
|
||||
},
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {},
|
||||
callerSignal?: AbortSignal
|
||||
): Promise<PRCheckRunDetails | null> {
|
||||
const controller = new AbortController()
|
||||
let hostDeadlineExpired = false
|
||||
const forwardCallerAbort = (): void => controller.abort(callerSignal?.reason)
|
||||
if (callerSignal?.aborted) {
|
||||
forwardCallerAbort()
|
||||
} else {
|
||||
callerSignal?.addEventListener('abort', forwardCallerAbort, { once: true })
|
||||
}
|
||||
const hostDeadline = setTimeout(() => {
|
||||
hostDeadlineExpired = true
|
||||
controller.abort(new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE))
|
||||
}, GITHUB_CHECK_DETAILS_HOST_TIMEOUT_MS)
|
||||
let acquired = false
|
||||
try {
|
||||
const resolved = await waitForCheckDetailsResolution(
|
||||
resolveGitHubRepoExecution(repoPath, args.prRepo, connectionId, localGitOptions),
|
||||
controller.signal
|
||||
)
|
||||
if (!resolved.ownerRepo) {
|
||||
return null
|
||||
}
|
||||
const ownerRepo = resolved.ownerRepo
|
||||
const ghOptions: GhExecOptions = { ...resolved.ghOptions, signal: controller.signal }
|
||||
await acquire(controller.signal)
|
||||
acquired = true
|
||||
let checkRun: Record<string, unknown> | null = null
|
||||
let annotations: PRCheckRunDetails['annotations'] = []
|
||||
if (args.checkRunId) {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
['api', `repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}`],
|
||||
ghOptions
|
||||
)
|
||||
checkRun = JSON.parse(stdout) as Record<string, unknown>
|
||||
try {
|
||||
const annotationsResult = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${args.checkRunId}/annotations?per_page=20`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
annotations = mapCheckAnnotations(JSON.parse(annotationsResult.stdout))
|
||||
} catch (err) {
|
||||
rethrowCheckDetailsAbort(controller.signal, err)
|
||||
console.warn('getPRCheckDetails annotations fetch failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const workflowRunId = args.workflowRunId ?? getWorkflowRunIdFromCheckRun(checkRun)
|
||||
let jobs: PRCheckRunDetails['jobs'] = []
|
||||
if (workflowRunId) {
|
||||
try {
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${workflowRunId}/jobs?per_page=100`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
jobs = mapWorkflowJobs(JSON.parse(stdout), args.checkName)
|
||||
await attachFailedJobLogTails(jobs, ownerRepo, ghOptions)
|
||||
} catch (err) {
|
||||
rethrowCheckDetailsAbort(controller.signal, err)
|
||||
console.warn('getPRCheckDetails workflow jobs fetch failed:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const output =
|
||||
checkRun?.output && typeof checkRun.output === 'object'
|
||||
? (checkRun.output as Record<string, unknown>)
|
||||
: null
|
||||
return {
|
||||
name: nullableString(checkRun?.name) ?? args.checkName ?? 'Check',
|
||||
status: nullableString(checkRun?.status),
|
||||
conclusion: nullableString(checkRun?.conclusion),
|
||||
url: nullableString(checkRun?.html_url) ?? args.url ?? null,
|
||||
detailsUrl: nullableString(checkRun?.details_url) ?? args.url ?? null,
|
||||
startedAt: nullableString(checkRun?.started_at),
|
||||
completedAt: nullableString(checkRun?.completed_at),
|
||||
title: nullableString(output?.title),
|
||||
summary: nullableString(output?.summary),
|
||||
text: nullableString(output?.text),
|
||||
annotations,
|
||||
jobs
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn('getPRCheckDetails failed:', err)
|
||||
if (hostDeadlineExpired && !callerSignal?.aborted) {
|
||||
throw new Error(GITHUB_CHECK_DETAILS_TIMEOUT_MESSAGE)
|
||||
}
|
||||
throw err
|
||||
} finally {
|
||||
clearTimeout(hostDeadline)
|
||||
callerSignal?.removeEventListener('abort', forwardCallerAbort)
|
||||
if (acquired) {
|
||||
release()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import type { PRCheckDetail } from '../../../../shared/github/check-types'
|
||||
import { GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE } from '../../../../shared/work-items'
|
||||
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
|
||||
import { extractExecError } from '../../../git/exec-error'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { mapCheckStatus, mapCheckConclusion } from '../../mappers'
|
||||
import { noteRepositoryRateLimitSpend } from '../../rate-limit'
|
||||
import type { GhExecOptions } from './../github-exec-scope'
|
||||
import { assertRateLimitBudget } from './../lookup/pr-lookup-rate-limit'
|
||||
import {
|
||||
PR_CHECKS_ROLLUP_QUERY,
|
||||
type GraphQLPRChecksResponse,
|
||||
type RestCheckRun,
|
||||
type RestCommitStatus,
|
||||
type RestCheckSuite
|
||||
} from './pr-checks-graphql-query'
|
||||
import {
|
||||
mapRestCheckRun,
|
||||
mapRestCommitStatus,
|
||||
mapGraphQLPRChecksResponse,
|
||||
getPendingApprovalCheckSuiteName,
|
||||
getPendingApprovalCheckSuiteUrl
|
||||
} from './pr-checks-response-mapping'
|
||||
import { parseActionsRunId } from './check-detail-field-mapping'
|
||||
export async function getPRChecksViaRestFallback(
|
||||
ownerRepo: GitHubApiRepository,
|
||||
headSha: string | undefined,
|
||||
ghOptions: GhExecOptions,
|
||||
noCache?: boolean
|
||||
): Promise<PRCheckDetail[] | null> {
|
||||
if (!headSha) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
await assertRateLimitBudget('core', ownerRepo, ghOptions)
|
||||
} catch (err) {
|
||||
console.warn('getPRChecks skipped REST fallback, falling back to gh pr checks:', err)
|
||||
return null
|
||||
}
|
||||
|
||||
await acquire()
|
||||
try {
|
||||
const cacheArgs = noCache ? [] : ['--cache', '60s']
|
||||
const encodedHeadSha = encodeURIComponent(headSha)
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...cacheArgs,
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/check-runs?per_page=100`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions)
|
||||
const checkRunData = JSON.parse(stdout) as {
|
||||
check_runs?: RestCheckRun[]
|
||||
}
|
||||
const checkRuns = (checkRunData.check_runs ?? []).map(mapRestCheckRun)
|
||||
const checkRunNames = new Set(checkRuns.map((check) => check.name))
|
||||
|
||||
let legacyStatuses: PRCheckDetail[] = []
|
||||
try {
|
||||
const statusResult = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...cacheArgs,
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/status?per_page=100`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions)
|
||||
const statusData = JSON.parse(statusResult.stdout) as {
|
||||
statuses?: RestCommitStatus[]
|
||||
}
|
||||
legacyStatuses = (statusData.statuses ?? [])
|
||||
.map(mapRestCommitStatus)
|
||||
.filter((check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name))
|
||||
} catch (err) {
|
||||
// Why: REST fallback is already degraded; keep the richer check-run rows if legacy-status enrichment fails.
|
||||
console.warn('getPRChecks REST status fallback failed:', err)
|
||||
}
|
||||
|
||||
let pendingApprovalChecks: PRCheckDetail[] = []
|
||||
try {
|
||||
const suitesResult = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
...cacheArgs,
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/commits/${encodedHeadSha}/check-suites?per_page=100`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'core', 1, ghOptions)
|
||||
const suitesData = JSON.parse(suitesResult.stdout) as {
|
||||
check_suites?: RestCheckSuite[]
|
||||
}
|
||||
pendingApprovalChecks = (suitesData.check_suites ?? [])
|
||||
.filter((suite) => suite.conclusion?.toLowerCase() === 'action_required')
|
||||
.map((suite, index) => ({
|
||||
name: getPendingApprovalCheckSuiteName(suite, headSha, index),
|
||||
status: 'completed' as const,
|
||||
conclusion: 'action_required' as const,
|
||||
url: getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.id)
|
||||
}))
|
||||
} catch (err) {
|
||||
console.warn('getPRChecks REST check-suite fallback failed:', err)
|
||||
}
|
||||
|
||||
const checks = [...checkRuns, ...legacyStatuses, ...pendingApprovalChecks]
|
||||
return checks.length > 0 ? checks : null
|
||||
} catch (err) {
|
||||
console.warn('getPRChecks via REST fallback failed, falling back to gh pr checks:', err)
|
||||
return null
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get detailed check statuses for a PR.
|
||||
* Uses GitHub's combined GraphQL rollup so check runs and legacy commit statuses
|
||||
* arrive in one cached request; suite-only approval blockers are included too.
|
||||
*/
|
||||
export async function getPRChecks(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
headSha?: string,
|
||||
prRepo?: GitHubApiRepository | null,
|
||||
options?: { noCache?: boolean },
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<PRCheckDetail[]> {
|
||||
void headSha
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (connectionId && !ownerRepo) {
|
||||
throw new Error(GITHUB_WORK_ITEMS_SSH_REMOTE_REQUIRED_MESSAGE)
|
||||
}
|
||||
const fallbackToPRChecks = async (): Promise<PRCheckDetail[]> => {
|
||||
await assertRateLimitBudget('graphql', ownerRepo, ghOptions)
|
||||
await acquire()
|
||||
try {
|
||||
const fallbackArgs = ['pr', 'checks', String(prNumber), '--json', 'name,state,link']
|
||||
if (ownerRepo) {
|
||||
fallbackArgs.push('--repo', `${ownerRepo.owner}/${ownerRepo.repo}`)
|
||||
}
|
||||
const { stdout } = await ghExecFileAsync(fallbackArgs, ghOptions).catch((err: unknown) => {
|
||||
const { stderr } = extractExecError(err)
|
||||
// Why: `gh pr checks` exits non-zero when a PR has no check runs yet; treat that as empty, not a load failure.
|
||||
if (stderr.toLowerCase().includes('no checks reported')) {
|
||||
return { stdout: '[]', stderr }
|
||||
}
|
||||
throw err
|
||||
})
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
|
||||
const data = JSON.parse(stdout) as { name: string; state: string; link: string }[]
|
||||
return data.map((d) => ({
|
||||
name: d.name,
|
||||
status: mapCheckStatus(d.state),
|
||||
conclusion: mapCheckConclusion(d.state),
|
||||
url: d.link || null,
|
||||
workflowRunId: parseActionsRunId(d.link)
|
||||
}))
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
|
||||
if (ownerRepo) {
|
||||
let canUseGraphQLRollup = true
|
||||
try {
|
||||
await assertRateLimitBudget('graphql', ownerRepo, ghOptions)
|
||||
} catch (err) {
|
||||
canUseGraphQLRollup = false
|
||||
console.warn('getPRChecks skipped GraphQL rollup, falling back to gh pr checks:', err)
|
||||
}
|
||||
if (canUseGraphQLRollup) {
|
||||
await acquire()
|
||||
try {
|
||||
// Why: --cache 60s saves rate-limit budget during polling; explicit refresh skips it for fresh data.
|
||||
const cacheArgs = options?.noCache ? [] : ['--cache', '60s']
|
||||
const { stdout } = await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'graphql',
|
||||
...cacheArgs,
|
||||
'-f',
|
||||
`owner=${ownerRepo.owner}`,
|
||||
'-f',
|
||||
`repo=${ownerRepo.repo}`,
|
||||
'-F',
|
||||
`pr=${prNumber}`,
|
||||
'-f',
|
||||
`query=${PR_CHECKS_ROLLUP_QUERY}`
|
||||
],
|
||||
ghOptions
|
||||
)
|
||||
noteRepositoryRateLimitSpend(ownerRepo, 'graphql', 1, ghOptions)
|
||||
const checks = mapGraphQLPRChecksResponse(
|
||||
ownerRepo,
|
||||
JSON.parse(stdout) as GraphQLPRChecksResponse
|
||||
)
|
||||
if (checks !== null) {
|
||||
return checks
|
||||
}
|
||||
} catch (err) {
|
||||
// Why: fall back to older `gh pr checks` when GitHub's richer rollup query is unavailable.
|
||||
console.warn('getPRChecks via GraphQL rollup failed, falling back to gh pr checks:', err)
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
const restChecks = await getPRChecksViaRestFallback(
|
||||
ownerRepo,
|
||||
headSha,
|
||||
ghOptions,
|
||||
options?.noCache
|
||||
)
|
||||
if (restChecks !== null) {
|
||||
return restChecks
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
return await fallbackToPRChecks()
|
||||
} catch (err) {
|
||||
console.warn('getPRChecks failed:', err)
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
export const PR_CHECKS_ROLLUP_QUERY = `
|
||||
query($owner: String!, $repo: String!, $pr: Int!) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequest(number: $pr) {
|
||||
headRefOid
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
statusCheckRollup {
|
||||
contexts(first: 100) {
|
||||
nodes {
|
||||
__typename
|
||||
... on CheckRun {
|
||||
databaseId
|
||||
name
|
||||
status
|
||||
conclusion
|
||||
detailsUrl
|
||||
url
|
||||
checkSuite {
|
||||
databaseId
|
||||
workflowRun {
|
||||
databaseId
|
||||
}
|
||||
}
|
||||
}
|
||||
... on StatusContext {
|
||||
context
|
||||
state
|
||||
targetUrl
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
checkSuites(first: 100) {
|
||||
nodes {
|
||||
databaseId
|
||||
status
|
||||
conclusion
|
||||
url
|
||||
app {
|
||||
name
|
||||
slug
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
export type GraphQLPRChecksResponse = {
|
||||
data?: {
|
||||
repository?: {
|
||||
pullRequest?: {
|
||||
headRefOid?: string | null
|
||||
commits?: {
|
||||
nodes?: { commit?: GraphQLPRChecksCommit | null }[] | null
|
||||
} | null
|
||||
} | null
|
||||
} | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type GraphQLPRChecksCommit = {
|
||||
statusCheckRollup?: {
|
||||
contexts?: {
|
||||
nodes?: GraphQLStatusCheckContext[] | null
|
||||
} | null
|
||||
} | null
|
||||
checkSuites?: {
|
||||
nodes?: GraphQLCheckSuite[] | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type GraphQLCheckRunContext = {
|
||||
__typename: 'CheckRun'
|
||||
databaseId?: number | null
|
||||
name?: string | null
|
||||
status?: string | null
|
||||
conclusion?: string | null
|
||||
detailsUrl?: string | null
|
||||
url?: string | null
|
||||
checkSuite?: {
|
||||
databaseId?: number | null
|
||||
workflowRun?: { databaseId?: number | null } | null
|
||||
} | null
|
||||
}
|
||||
|
||||
export type GraphQLStatusContext = {
|
||||
__typename: 'StatusContext'
|
||||
context?: string | null
|
||||
state?: string | null
|
||||
targetUrl?: string | null
|
||||
}
|
||||
|
||||
export type GraphQLStatusCheckContext =
|
||||
| GraphQLCheckRunContext
|
||||
| GraphQLStatusContext
|
||||
| { __typename?: string | null }
|
||||
|
||||
export type GraphQLCheckSuite = {
|
||||
databaseId?: number | null
|
||||
status?: string | null
|
||||
conclusion?: string | null
|
||||
url?: string | null
|
||||
app?: { name?: string | null; slug?: string | null } | null
|
||||
}
|
||||
|
||||
export type RestCheckRun = {
|
||||
id?: number
|
||||
name: string
|
||||
status: string
|
||||
conclusion: string | null
|
||||
html_url: string
|
||||
details_url: string | null
|
||||
}
|
||||
|
||||
export type RestCommitStatus = {
|
||||
context?: string
|
||||
state?: string
|
||||
target_url?: string | null
|
||||
}
|
||||
|
||||
export type RestCheckSuite = {
|
||||
id?: number | null
|
||||
status: string | null
|
||||
conclusion: string | null
|
||||
app?: { name?: string | null; slug?: string | null } | null
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
import type { PRCheckDetail } from '../../../../shared/github/check-types'
|
||||
import { githubRepositoryWebHost, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import {
|
||||
mapCheckRunRESTStatus,
|
||||
mapCheckRunRESTConclusion,
|
||||
mapCommitStatusRESTStatus,
|
||||
mapCommitStatusRESTConclusion
|
||||
} from '../../mappers'
|
||||
import type {
|
||||
GraphQLPRChecksResponse,
|
||||
GraphQLCheckRunContext,
|
||||
GraphQLStatusContext,
|
||||
GraphQLStatusCheckContext,
|
||||
GraphQLCheckSuite,
|
||||
RestCheckRun,
|
||||
RestCommitStatus
|
||||
} from './pr-checks-graphql-query'
|
||||
import { nullableString, nullableNumber, parseActionsRunId } from './check-detail-field-mapping'
|
||||
export function isGraphQLCheckRunContext(
|
||||
context: GraphQLStatusCheckContext
|
||||
): context is GraphQLCheckRunContext {
|
||||
return context.__typename === 'CheckRun'
|
||||
}
|
||||
|
||||
export function isGraphQLStatusContext(
|
||||
context: GraphQLStatusCheckContext
|
||||
): context is GraphQLStatusContext {
|
||||
return context.__typename === 'StatusContext'
|
||||
}
|
||||
|
||||
export function mapGraphQLCheckRunContext(context: GraphQLCheckRunContext): PRCheckDetail | null {
|
||||
const name = nullableString(context.name)
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
const url = nullableString(context.detailsUrl) ?? nullableString(context.url)
|
||||
const checkRunId = nullableNumber(context.databaseId)
|
||||
const workflowRunId =
|
||||
nullableNumber(context.checkSuite?.workflowRun?.databaseId) ?? parseActionsRunId(url)
|
||||
return {
|
||||
name,
|
||||
status: mapCheckRunRESTStatus(context.status ?? ''),
|
||||
conclusion: mapCheckRunRESTConclusion(context.status ?? '', context.conclusion ?? null),
|
||||
url,
|
||||
...(checkRunId !== null ? { checkRunId } : {}),
|
||||
...(typeof workflowRunId === 'number' ? { workflowRunId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGraphQLStatusContext(context: GraphQLStatusContext): PRCheckDetail | null {
|
||||
const name = nullableString(context.context)
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
const url = nullableString(context.targetUrl)
|
||||
const workflowRunId = parseActionsRunId(url)
|
||||
return {
|
||||
name,
|
||||
status: mapCommitStatusRESTStatus(context.state ?? ''),
|
||||
conclusion: mapCommitStatusRESTConclusion(context.state ?? ''),
|
||||
url,
|
||||
...(workflowRunId !== undefined ? { workflowRunId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function mapRestCheckRun(checkRun: RestCheckRun): PRCheckDetail {
|
||||
return {
|
||||
name: checkRun.name,
|
||||
status: mapCheckRunRESTStatus(checkRun.status),
|
||||
conclusion: mapCheckRunRESTConclusion(checkRun.status, checkRun.conclusion),
|
||||
url: checkRun.details_url || checkRun.html_url || null,
|
||||
...(typeof checkRun.id === 'number' ? { checkRunId: checkRun.id } : {}),
|
||||
workflowRunId: parseActionsRunId(checkRun.details_url || checkRun.html_url || null)
|
||||
}
|
||||
}
|
||||
|
||||
export function mapRestCommitStatus(status: RestCommitStatus): PRCheckDetail | null {
|
||||
const name = nullableString(status.context)
|
||||
if (!name) {
|
||||
return null
|
||||
}
|
||||
const url = nullableString(status.target_url)
|
||||
const workflowRunId = parseActionsRunId(url)
|
||||
return {
|
||||
name,
|
||||
status: mapCommitStatusRESTStatus(status.state ?? ''),
|
||||
conclusion: mapCommitStatusRESTConclusion(status.state ?? ''),
|
||||
url,
|
||||
...(workflowRunId !== undefined ? { workflowRunId } : {})
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGraphQLPendingApprovalCheckSuite(
|
||||
ownerRepo: GitHubApiRepository,
|
||||
suite: GraphQLCheckSuite,
|
||||
headSha: string | null | undefined,
|
||||
index: number
|
||||
): PRCheckDetail {
|
||||
return {
|
||||
name: getPendingApprovalCheckSuiteName(suite, headSha, index),
|
||||
status: 'completed',
|
||||
conclusion: 'action_required',
|
||||
// Why: suite-only approval blockers have no check run; link the suite page when GraphQL exposes one.
|
||||
url:
|
||||
nullableString(suite.url) ??
|
||||
(headSha ? getPendingApprovalCheckSuiteUrl(ownerRepo, headSha, suite.databaseId) : null)
|
||||
}
|
||||
}
|
||||
|
||||
export function mapGraphQLPRChecksResponse(
|
||||
ownerRepo: GitHubApiRepository,
|
||||
response: GraphQLPRChecksResponse
|
||||
): PRCheckDetail[] | null {
|
||||
const pullRequest = response.data?.repository?.pullRequest
|
||||
if (!pullRequest) {
|
||||
return null
|
||||
}
|
||||
const commit = pullRequest.commits?.nodes?.[0]?.commit
|
||||
if (!commit) {
|
||||
return []
|
||||
}
|
||||
|
||||
const contexts = commit.statusCheckRollup?.contexts?.nodes ?? []
|
||||
const checkRunContexts = contexts.filter(isGraphQLCheckRunContext)
|
||||
const checkRuns = checkRunContexts
|
||||
.map(mapGraphQLCheckRunContext)
|
||||
.filter((check): check is PRCheckDetail => check !== null)
|
||||
const checkRunNames = new Set(checkRuns.map((check) => check.name))
|
||||
const checkSuiteIdsWithRuns = new Set(
|
||||
checkRunContexts
|
||||
.map((context) => nullableNumber(context.checkSuite?.databaseId))
|
||||
.filter((id): id is number => id !== null)
|
||||
)
|
||||
// Why: mixed-CI repos expose Jenkins/Prow/Tide as legacy status contexts in the same rollup; keep check-run metadata on name collisions.
|
||||
const legacyStatuses = contexts
|
||||
.filter(isGraphQLStatusContext)
|
||||
.map(mapGraphQLStatusContext)
|
||||
.filter((check): check is PRCheckDetail => check !== null && !checkRunNames.has(check.name))
|
||||
const pendingApprovalChecks = (commit.checkSuites?.nodes ?? [])
|
||||
.filter((suite) => suite.conclusion?.toLowerCase() === 'action_required')
|
||||
.filter((suite) => {
|
||||
const suiteId = nullableNumber(suite.databaseId)
|
||||
return suiteId === null || !checkSuiteIdsWithRuns.has(suiteId)
|
||||
})
|
||||
.map((suite, index) =>
|
||||
mapGraphQLPendingApprovalCheckSuite(ownerRepo, suite, pullRequest.headRefOid, index)
|
||||
)
|
||||
|
||||
return [...checkRuns, ...legacyStatuses, ...pendingApprovalChecks]
|
||||
}
|
||||
|
||||
export function getPendingApprovalCheckSuiteName(
|
||||
suite: {
|
||||
id?: number | null
|
||||
databaseId?: number | null
|
||||
app?: { name?: string | null; slug?: string | null } | null
|
||||
},
|
||||
headSha: string | null | undefined,
|
||||
index: number
|
||||
): string {
|
||||
const appName = suite.app?.name ?? suite.app?.slug ?? null
|
||||
const rawSuiteId = suite.databaseId ?? suite.id
|
||||
const suiteId =
|
||||
typeof rawSuiteId === 'number' && Number.isFinite(rawSuiteId) ? `#${rawSuiteId}` : null
|
||||
if (appName && suiteId) {
|
||||
return `${appName} ${suiteId}`
|
||||
}
|
||||
if (appName) {
|
||||
return appName
|
||||
}
|
||||
if (suiteId) {
|
||||
return suiteId
|
||||
}
|
||||
return `${headSha?.slice(0, 12) ?? 'check-suite'}:${index + 1}`
|
||||
}
|
||||
|
||||
export function getPendingApprovalCheckSuiteUrl(
|
||||
ownerRepo: GitHubApiRepository,
|
||||
headSha: string,
|
||||
suiteId: number | null | undefined
|
||||
): string {
|
||||
const base = `https://${githubRepositoryWebHost(ownerRepo)}/${ownerRepo.owner}/${ownerRepo.repo}/commits/${headSha}/checks`
|
||||
return typeof suiteId === 'number' && Number.isFinite(suiteId)
|
||||
? `${base}#check-suite-${suiteId}`
|
||||
: base
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import type { GitHubRerunPRChecksResult } from '../../../../shared/github/check-types'
|
||||
import { ghExecFileAsync, acquire, release, type LocalGitExecOptions } from '../../gh-utils'
|
||||
// Why: pure error helpers come from their own modules so tests that mock gh-utils still classify for real.
|
||||
import { extractExecError } from '../../../git/exec-error'
|
||||
import { classifyRerunChecksError } from '../../gh-error-classification'
|
||||
import { resolveGitHubRepoExecution, type GitHubApiRepository } from '../../github-api-repository'
|
||||
import { getPRChecks } from './get-pr-checks'
|
||||
import { parseActionsRunId } from './check-detail-field-mapping'
|
||||
export async function rerunPRChecks(
|
||||
repoPath: string,
|
||||
prNumber: number,
|
||||
options: {
|
||||
headSha?: string
|
||||
failedOnly?: boolean
|
||||
prRepo?: GitHubApiRepository | null
|
||||
} = {},
|
||||
connectionId?: string | null,
|
||||
localGitOptions: LocalGitExecOptions = {}
|
||||
): Promise<GitHubRerunPRChecksResult> {
|
||||
const { ownerRepo, ghOptions } = await resolveGitHubRepoExecution(
|
||||
repoPath,
|
||||
options.prRepo,
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
if (!ownerRepo) {
|
||||
return { ok: false, error: 'Could not resolve GitHub owner/repo for this repository' }
|
||||
}
|
||||
|
||||
const checks = await getPRChecks(
|
||||
repoPath,
|
||||
prNumber,
|
||||
options.headSha,
|
||||
ownerRepo,
|
||||
{ noCache: true },
|
||||
connectionId,
|
||||
localGitOptions
|
||||
)
|
||||
const candidates = options.failedOnly
|
||||
? checks.filter((check) =>
|
||||
['failure', 'cancelled', 'timed_out'].includes(check.conclusion ?? '')
|
||||
)
|
||||
: checks
|
||||
const workflowRunIds = new Set(
|
||||
candidates
|
||||
.map((check) => check.workflowRunId ?? parseActionsRunId(check.url))
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
)
|
||||
const checkRunIds = new Set(
|
||||
candidates
|
||||
.filter((check) => !check.workflowRunId && !parseActionsRunId(check.url))
|
||||
.map((check) => check.checkRunId)
|
||||
.filter((id): id is number => typeof id === 'number')
|
||||
)
|
||||
|
||||
if (workflowRunIds.size === 0 && checkRunIds.size === 0) {
|
||||
return {
|
||||
ok: false,
|
||||
error: options.failedOnly
|
||||
? 'No failed GitHub Actions checks to rerun.'
|
||||
: 'No rerunnable checks found.'
|
||||
}
|
||||
}
|
||||
|
||||
let count = 0
|
||||
await acquire()
|
||||
try {
|
||||
for (const runId of workflowRunIds) {
|
||||
const endpoint = options.failedOnly
|
||||
? `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${runId}/rerun-failed-jobs`
|
||||
: `repos/${ownerRepo.owner}/${ownerRepo.repo}/actions/runs/${runId}/rerun`
|
||||
await ghExecFileAsync(['api', '-X', 'POST', endpoint], {
|
||||
...ghOptions,
|
||||
env: { ...process.env, GH_PROMPT_DISABLED: '1' }
|
||||
})
|
||||
count += 1
|
||||
}
|
||||
for (const checkRunId of checkRunIds) {
|
||||
await ghExecFileAsync(
|
||||
[
|
||||
'api',
|
||||
'-X',
|
||||
'POST',
|
||||
`repos/${ownerRepo.owner}/${ownerRepo.repo}/check-runs/${checkRunId}/rerequest`
|
||||
],
|
||||
{ ...ghOptions, env: { ...process.env, GH_PROMPT_DISABLED: '1' } }
|
||||
)
|
||||
count += 1
|
||||
}
|
||||
return { ok: true, count }
|
||||
} catch (err) {
|
||||
const { stderr } = extractExecError(err)
|
||||
const classified = classifyRerunChecksError(stderr).message
|
||||
// Why: these POSTs are not idempotent — say how many reruns already started so a retry isn't blind.
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
count > 0
|
||||
? `${classified} (${count} rerun${count === 1 ? '' : 's'} already started)`
|
||||
: classified
|
||||
}
|
||||
} finally {
|
||||
release()
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user