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/index.ts

85 lines · 2.7 KB · TypeScript
import { graphql, describeAccessErrors, OPEN_PRS_QUERY } from './github.ts'
import { classify, formatDigest, type Finding, type PullRequest } from './classify.ts'

export type CheckInput = {
	/** Extra GitHub search qualifiers, e.g. "repo:me/thing" or "org:acme". */
	scope?: string
	/** Max PRs to examine. Default 50. */
	limit?: number
	/** Bot comments on PRs idle longer than this are history. Default 14. */
	botReplyWindowDays?: number
	/** Silence past this many days means abandoned, not stuck. Default 120. */
	abandonedDays?: number
}

export type CheckResult = {
	viewer: string
	examined: number
	findings: Array<Finding>
	digest: string
	/** Set when some PRs were unreadable, e.g. an org that refuses the token. */
	accessWarning: string | null
}

/**
 * Check your open pull requests and return only the ones that are actually
 * stuck: red CI, merge conflicts, requested changes, or a review-bot comment
 * nobody answered on a still-active PR. Drafts and long-abandoned PRs are
 * skipped on purpose. Call this for an on-demand answer; the `daily-check` job
 * calls it on a schedule and mails you only when something changed.
 *
 * @param input - Optional search scope, limit, and tuning thresholds
 * @returns Viewer login, PRs examined, the stuck ones, a ready-to-read digest,
 *   and an access warning when some PRs could not be read
 *
 * @example
 * import checkPrHealth from 'kody:@gsimone/pr-health'
 *
 * const { digest } = await checkPrHealth()
 */
export default async function checkPrHealth(
	input: CheckInput = {},
): Promise<CheckResult> {
	const limit = input.limit ?? 50

	const viewerResult = await graphql<{ viewer: { login: string } }>(
		'query { viewer { login } }',
	)
	const viewer = viewerResult.data.viewer.login

	// Your own PRs plus anything awaiting your review — both can be "stuck".
	const scope = input.scope ? ` ${input.scope}` : ''
	const search = `is:open is:pr involves:${viewer}${scope}`

	const { data, errors } = await graphql<{
		search: { nodes: Array<PullRequest | null> }
	}>(OPEN_PRS_QUERY, { search, first: limit })

	const nodes = (data.search?.nodes ?? []).filter(
		(node): node is PullRequest => typeof node?.number === 'number',
	)
	const now = Date.now()
	const findings = nodes
		.map((pr) =>
			classify(pr, {
				viewer,
				now,
				botReplyWindowDays: input.botReplyWindowDays,
				abandonedDays: input.abandonedDays,
			}),
		)
		.filter((finding): finding is Finding => finding !== null)
		// Worst first: most reasons, then most recently touched.
		.sort(
			(a, b) =>
				b.reasons.length - a.reasons.length || a.idleDays - b.idleDays,
		)

	return {
		viewer,
		examined: nodes.length,
		findings,
		digest: formatDigest(findings),
		accessWarning: describeAccessErrors(errors),
	}
}