← 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')}`
}