← Public packages
@gsimone/pr-health
Flag your open PRs that are stuck: red CI, merge conflicts, unanswered review-bot comments, or gone quiet.
src/github.ts
118 lines · 3.5 KB · TypeScript/**
* Thin GitHub GraphQL client used by the rest of the package.
* The PAT never reaches this code: `{{secret:githubPat}}` is substituted by
* Kody on the serialized request, at the network boundary.
*/
const ENDPOINT = 'https://api.github.com/graphql'
export type GraphQLError = { message: string; type?: string }
export type GraphQLResult<T> = { data?: T; errors?: Array<GraphQLError> }
/**
* GitHub answers a partially-forbidden search with BOTH `data` and `errors`:
* some orgs refuse fine-grained PATs (long token lifetimes, SAML, IP rules) and
* null out only those nodes. Throwing on any `errors` would let one such org
* blank the whole report, so keep the data and hand the errors back instead.
*/
/** Transient upstream failures worth one retry rather than a lost run. */
const RETRYABLE = new Set([502, 503, 504])
export async function graphql<T>(
query: string,
variables: Record<string, unknown> = {},
): Promise<{ data: T; errors: Array<GraphQLError> }> {
let response!: Response
for (let attempt = 0; attempt < 2; attempt++) {
response = await fetch(ENDPOINT, {
method: 'POST',
headers: {
Authorization: 'Bearer {{secret:githubPat}}',
'Content-Type': 'application/json',
'User-Agent': 'kody-pr-health',
},
body: JSON.stringify({ query, variables }),
})
if (!RETRYABLE.has(response.status)) break
if (attempt === 0) await new Promise((r) => setTimeout(r, 2000))
}
if (!response.ok) {
const body = await response.text()
throw new Error(`GitHub GraphQL ${response.status}: ${body.slice(0, 300)}`)
}
const payload = (await response.json()) as GraphQLResult<T>
const errors = payload.errors ?? []
// Only a total failure is fatal.
if (!payload.data) {
const detail = errors.map((e) => e.message).join('; ') || 'no data'
throw new Error(`GitHub GraphQL returned no data: ${detail.slice(0, 300)}`)
}
return { data: payload.data, errors }
}
/** Summarize partial-access errors into one short human line, or null. */
export function describeAccessErrors(
errors: Array<GraphQLError>,
): string | null {
const forbidden = errors.filter((e) => e.type === 'FORBIDDEN')
if (forbidden.length === 0) return null
const orgs = new Set(
forbidden
.map((e) => /the '([^']+)' organization/.exec(e.message)?.[1])
.filter((org): org is string => Boolean(org)),
)
const where = orgs.size ? ` (${[...orgs].join(', ')})` : ''
return `${forbidden.length} PR${forbidden.length === 1 ? '' : 's'} skipped — token lacks access${where}`
}
/**
* One query for everything the health check needs: rollup CI state, mergeable
* state, review decision, and the tail of the comment thread. Fetching this in
* a single request keeps the job well inside its time budget even with many
* open PRs.
*/
export const OPEN_PRS_QUERY = `
query OpenPRs($search: String!, $first: Int!) {
search(query: $search, type: ISSUE, first: $first) {
nodes {
... on PullRequest {
number
title
url
isDraft
updatedAt
createdAt
mergeable
reviewDecision
repository { nameWithOwner }
author { login }
commits(last: 1) {
nodes {
commit {
statusCheckRollup { state }
}
}
}
comments(last: 20) {
nodes {
author { login }
createdAt
body
}
}
reviews(last: 10) {
nodes {
author { login }
state
submittedAt
}
}
}
}
}
}
`