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:
Jinjing
2026-08-17 21:18:44 -07:00
committed by GitHub
parent 604169f4af
commit 63dbf12d14
62 changed files with 6314 additions and 5601 deletions
@@ -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()
}
}