Skip to content
← 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
          }
        }
      }
    }
  }
}
`