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

189 lines · 5.3 KB · TypeScript
/**
 * Pure classification: given a PR's raw GraphQL node, decide whether it is
 * stuck and why. No network, no secrets — so it can be unit tested directly.
 */

/** Bots whose comments mean "a human needs to look at this". */
export const REVIEW_BOTS = [
	'greptile',
	'greptileai',
	'coderabbit',
	'coderabbitai',
	'bugbot',
	'cursor',
	'linear-code',
	'macroscopeapp',
	'github-actions',
] as const

/**
 * Past this many days of silence a PR is not stuck, it is abandoned — and
 * nagging about it is noise. Measured against a real 40-PR account where the
 * tail reached four years.
 */
export const ABANDONED_DAYS = 120

export type Reason =
	| { kind: 'ci_failing'; detail: string }
	| { kind: 'conflicts'; detail: string }
	| { kind: 'bot_awaiting_reply'; detail: string }
	| { kind: 'changes_requested'; detail: string }

export type PullRequest = {
	number: number
	title: string
	url: string
	isDraft: boolean
	updatedAt: string
	createdAt: string
	mergeable: 'MERGEABLE' | 'CONFLICTING' | 'UNKNOWN' | null
	reviewDecision: 'APPROVED' | 'CHANGES_REQUESTED' | 'REVIEW_REQUIRED' | null
	repository: { nameWithOwner: string } | null
	author: { login: string } | null
	commits: {
		nodes: Array<{
			commit: { statusCheckRollup: { state: string } | null }
		}>
	} | null
	comments: {
		nodes: Array<{
			author: { login: string } | null
			createdAt: string
			body: string
		}>
	} | null
	reviews: {
		nodes: Array<{
			author: { login: string } | null
			state: string
			submittedAt: string | null
		}>
	} | null
}

export type Finding = {
	number: number
	title: string
	url: string
	repo: string
	reasons: Array<Reason>
	/** Stable digest of the reasons, so an unchanged PR does not re-notify. */
	fingerprint: string
	updatedAt: string
	idleDays: number
}

function isReviewBot(login: string | null | undefined): boolean {
	if (!login) return false
	const normalized = login.toLowerCase().replace(/\[bot\]$/, '')
	return REVIEW_BOTS.some(
		(bot) => normalized === bot || normalized.startsWith(`${bot}-`),
	)
}

function daysBetween(later: number, earlier: number): number {
	return (later - earlier) / 86_400_000
}

export function classify(
	pr: PullRequest,
	options: {
		viewer: string
		now: number
		/** Bot comments older than this are history, not a pending ask. */
		botReplyWindowDays?: number
		/** Silence past this many days means abandoned, not stuck. */
		abandonedDays?: number
	},
): Finding | null {
	// A draft is stuck on purpose. Never nag about one.
	if (pr.isDraft) return null

	const botWindow = options.botReplyWindowDays ?? 14
	const abandonedDays = options.abandonedDays ?? ABANDONED_DAYS
	const idleDays = daysBetween(options.now, Date.parse(pr.updatedAt))

	// An ancient PR is a deliberate parking spot, not an emergency. Bail before
	// doing any work so nothing downstream can resurrect it.
	if (idleDays >= abandonedDays) return null

	const reasons: Array<Reason> = []

	const rollup =
		pr.commits?.nodes?.[0]?.commit?.statusCheckRollup?.state ?? null
	if (rollup === 'FAILURE' || rollup === 'ERROR') {
		reasons.push({
			kind: 'ci_failing',
			detail: `checks are ${rollup.toLowerCase()}`,
		})
	}

	if (pr.mergeable === 'CONFLICTING') {
		reasons.push({ kind: 'conflicts', detail: 'merge conflicts with base' })
	}

	// A review bot commented and nobody replied after it — but only worth
	// raising while the PR is still warm. On a long-idle PR the unanswered bot
	// comment is just part of the furniture.
	const comments = pr.comments?.nodes ?? []
	const lastBotIndex = comments.findLastIndex((c) => isReviewBot(c.author?.login))
	if (lastBotIndex !== -1 && idleDays <= botWindow) {
		const repliedAfter = comments
			.slice(lastBotIndex + 1)
			.some((c) => !isReviewBot(c.author?.login))
		if (!repliedAfter) {
			const bot = comments[lastBotIndex]?.author?.login ?? 'a review bot'
			reasons.push({
				kind: 'bot_awaiting_reply',
				detail: `${bot} commented with no reply since`,
			})
		}
	}

	if (pr.reviewDecision === 'CHANGES_REQUESTED') {
		reasons.push({
			kind: 'changes_requested',
			detail: 'a reviewer requested changes',
		})
	}

	// Deliberately no "stale" rule. Against real data a 3-day idle threshold
	// flagged 30 of 34 open PRs; long-lived PRs here are parked on purpose, so
	// idleness alone says nothing. Only concrete, fixable states qualify.

	if (reasons.length === 0) return null

	return {
		number: pr.number,
		title: pr.title,
		url: pr.url,
		repo: pr.repository?.nameWithOwner ?? 'unknown',
		reasons,
		fingerprint: reasons
			.map((r) => r.kind)
			.sort()
			.join(','),
		updatedAt: pr.updatedAt,
		idleDays: Math.floor(idleDays),
	}
}

const LABELS: Record<Reason['kind'], string> = {
	ci_failing: 'CI failing',
	conflicts: 'Conflicts',
	bot_awaiting_reply: 'Bot waiting on you',
	changes_requested: 'Changes requested',
}

/** Render findings as a short plain-text digest body. */
export function formatDigest(findings: Array<Finding>): string {
	if (findings.length === 0) return 'No stuck PRs.'
	const lines = findings.map((f) => {
		const tags = f.reasons.map((r) => LABELS[r.kind]).join(' · ')
		const why = f.reasons.map((r) => r.detail).join('; ')
		const idle = f.idleDays === 0 ? 'today' : `${f.idleDays}d idle`
		return `${f.repo} #${f.number} — ${f.title}\n  ${tags} (${idle})\n  ${why}\n  ${f.url}`
	})
	const noun = findings.length === 1 ? 'PR needs' : 'PRs need'
	return `${findings.length} ${noun} attention:\n\n${lines.join('\n\n')}`
}