Skip to content

src/classify.ts

290 lines · 8.4 KB · TypeScript
Raw
/**
 * Classify Bugbot / Devin / Seer pull-request review comments for ship-pr.
 *
 * Source of truth (ported from kentcdodds/kody
 * `.agents/skills/ship-pr/scripts/classify-review-bot.mjs`).
 *
 * Conservative rules: unsure stays a blocker (never auto-dismissed). Invalid
 * findings are safe to reply to and drop from the blocker list.
 */

export const reviewBotLogins = [
	'cursor',
	'bugbot',
	'devin-ai-integration',
	'devin',
	'seer',
	'seer-bot',
]

export type ReviewBotKind = 'bugbot' | 'devin' | 'seer'

export type ReviewVerdict = 'valid' | 'invalid' | 'unsure'

export type ReviewFinding = {
	bot: ReviewBotKind
	authorLogin: string
	commentId: number
	url?: string
	bodyPreview: string
	verdict: ReviewVerdict
	reason: string
}

export type ClassifiableComment = {
	id: number
	user: { login: string }
	body: string
	html_url?: string
	in_reply_to_id?: number | null
}

/** Marker used to detect an existing invalid-dismissal reply in a thread. */
export const invalidReplyMarker = 'Marked **invalid** for ship-pr by'

/**
 * Human / kody-bot reply that cites a fixing commit or an intentional wontfix.
 * Review-bot authors and other automation never count.
 */
export function isAddressingReviewReplyBody(text: unknown): boolean {
	const body = String(text ?? '')
	if (
		/(?:n't|\bnot\b|\bnever\b)\s+(?:been\s+)?(?:fixed|addressed|landed)\b/i.test(
			body,
		)
	) {
		return false
	}
	if (/\b(wont\s*fix|won't fix|will not fix)\b/i.test(body)) {
		return true
	}
	return /\b(?:fixed|addressed|landed)\s+(?:in|by|with)\s+[0-9a-f]{7,40}\b/i.test(
		body,
	)
}

/**
 * kody-bot or a non-bot human. Review bots and other `*bot*` / `[bot]`
 * accounts cannot dismiss a finding.
 */
export function isAddressingReviewAuthor(login: unknown): boolean {
	const raw = String(login ?? '')
	const normalized = normalizeBotLogin(raw)
	if (!normalized || reviewBotKind(raw)) return false
	if (normalized === 'kody-bot') return true
	if (/\[bot\]$/i.test(raw)) return false
	if (/(?:^|[-_])bot$/.test(normalized) || normalized.endsWith('bot')) {
		return false
	}
	if (normalized === 'coderabbitai' || normalized === 'copilot') return false
	return true
}

export function isAddressingReviewReply(comment: ClassifiableComment): boolean {
	if (!isAddressingReviewAuthor(comment.user?.login)) return false
	return isAddressingReviewReplyBody(comment.body)
}

export function findingHasAddressingReply(
	finding: { commentId: number },
	comments: Array<ClassifiableComment>,
): boolean {
	return comments.some(
		(comment) =>
			comment.in_reply_to_id === finding.commentId &&
			isAddressingReviewReply(comment),
	)
}

export function truncate(text: unknown, maxLength: number): string {
	const value = String(text ?? '')
	return value.length <= maxLength ? value : `${value.slice(0, maxLength - 1)}…`
}

export function normalizeBotLogin(login: unknown): string {
	return String(login ?? '')
		.toLowerCase()
		.replace(/\[bot\]$/i, '')
		.trim()
}

export function reviewBotKind(login: unknown): ReviewBotKind | null {
	const normalized = normalizeBotLogin(login)
	if (!normalized) return null
	if (
		normalized === 'cursor' ||
		normalized === 'bugbot' ||
		normalized.startsWith('cursor-') ||
		normalized.startsWith('bugbot')
	) {
		return 'bugbot'
	}
	if (
		normalized === 'devin' ||
		normalized === 'devin-ai-integration' ||
		normalized.startsWith('devin')
	) {
		return 'devin'
	}
	if (
		normalized === 'seer' ||
		normalized === 'seer-bot' ||
		normalized.startsWith('seer')
	) {
		return 'seer'
	}
	return null
}

/** Strip HTML comments, `<details>` blocks, and tags so guidance text does not skew verdicts. */
export function findingText(body: unknown): string {
	return String(body || '')
		.replace(/<!--[\s\S]*?-->/g, ' ')
		.replace(/<details[\s\S]*?<\/details>/gi, ' ')
		.replace(/<[^>]+>/g, ' ')
}

const invalidMatchers = [
	{
		re: /\b(false\s*positive|not\s+a\s+bug|not\s+a\s+real\s+issue)\b/i,
		reason: 'comment self-identifies as false positive / not a bug',
	},
	{
		// Require finding-level optionality. Bare "optional" matches technical
		// wording ("optional parameter") and must not dismiss a real defect.
		re: /\b(nit|nitpick|style[- ]only|cosmetic|(this\s+is\s+)?optional\s+(fix|change|suggestion|nit))\b/i,
		reason: 'nit / optional / style-only signal',
	},
	{
		re: /\b(already\s+fixed|no\s+longer\s+applies|outdated\s+suggestion)\b/i,
		reason: 'already fixed / outdated suggestion',
	},
	{
		re: /\b(ignore\s+this|can\s+safely\s+ignore|no\s+action\s+needed)\b/i,
		reason: 'explicit no-action guidance',
	},
]

const validMatchers = [
	{
		re: /\b(security|vulnerab|injection|xss|csrf|secret|credential|rce)\b/i,
		reason: 'security-related finding',
	},
	{
		// Do not treat "not a bug" as a defect claim.
		re: /\b((?<!not\s+a\s)bug|incorrect|broken|crash|regress|race\s+condition|null\s+pointer|type\s+error)\b/i,
		reason: 'defect / correctness claim',
	},
	{
		re: /\b(must\s+fix|needs?\s+to\s+be\s+fixed|high\s+severity|critical)\b/i,
		reason: 'explicit fix-required language',
	},
]

export function classifyReviewBotComment(
	comment: ClassifiableComment,
): ReviewFinding | null {
	const bot = reviewBotKind(comment.user.login)
	if (!bot) return null

	// Devin (and similar) wrap guidance in <details>; classify the finding
	// summary only so instructional words like "nit" in the docs do not
	// dismiss a real defect.
	const body = findingText(comment.body)
	const preview = truncate(body.replace(/\s+/g, ' ').trim(), 160)

	const invalidHit = invalidMatchers.find((matcher) => matcher.re.test(body))
	const validHit = validMatchers.find((matcher) => matcher.re.test(body))

	const base = {
		bot,
		authorLogin: comment.user.login,
		commentId: comment.id,
		url: comment.html_url,
		bodyPreview: preview,
	}

	// Mixed signals stay unsure so incidental "nit"/"optional" wording cannot
	// dismiss a real defect claim.
	if (invalidHit && validHit) {
		return {
			...base,
			verdict: 'unsure',
			reason: 'mixed invalid and valid signals - treat as valid',
		}
	}
	if (invalidHit) {
		return { ...base, verdict: 'invalid', reason: invalidHit.reason }
	}
	if (validHit) {
		return { ...base, verdict: 'valid', reason: validHit.reason }
	}
	return {
		...base,
		verdict: 'unsure',
		reason: 'insufficient signal - treat as valid',
	}
}

export function classifyReviewBotComments(
	comments: Array<ClassifiableComment>,
): Array<ReviewFinding> {
	const findings: Array<ReviewFinding> = []
	for (const comment of comments) {
		const finding = classifyReviewBotComment(comment)
		if (finding) findings.push(finding)
	}
	return findings
}

export function isShipPrBlocker(finding: ReviewFinding): boolean {
	return finding.verdict === 'valid' || finding.verdict === 'unsure'
}

export function botLabel(bot: ReviewBotKind): string {
	return bot === 'bugbot' ? 'Bugbot' : bot === 'devin' ? 'Devin' : 'Seer'
}

export function invalidReplyBody(finding: ReviewFinding): string {
	return [
		`Marked **invalid** for ship-pr by the @kentcdodds/ship-pr review-bot sort (${botLabel(finding.bot)}: ${finding.reason}).`,
		'Not treated as a merge blocker. Re-open the thread if this was wrong.',
	].join('\n')
}

/** True when a reply in the thread already carries an invalid-dismissal marker (old in-repo or package wording). */
export function hasInvalidReply(
	comments: Array<ClassifiableComment>,
	commentId: number,
): boolean {
	return comments.some(
		(comment) =>
			comment.in_reply_to_id === commentId &&
			String(comment.body).includes(invalidReplyMarker),
	)
}

/**
 * Best-effort severity from review-bot / CodeRabbit wording.
 * Bugbot: `**High Severity**`; Devin: 🔴/🟡 + `"kind": "bug"`; CodeRabbit: `_🟠 Major_`.
 */
export function severityOf(body: unknown): string | null {
	const text = String(body ?? '')
	const bugbot = text.match(/\*\*(Critical|High|Medium|Low) Severity\*\*/i)
	if (bugbot) return bugbot[1].toLowerCase()
	const rabbit = text.match(/_(?:🔴|🟠|🟡|🔵|⚪)?\s*(Critical|Major|Minor|Trivial|Nitpick)_/i)
	if (rabbit) return rabbit[1].toLowerCase()
	if (/🔴/.test(text)) return 'high'
	if (/🟡/.test(text)) return 'medium'
	return null
}

/** First markdown heading or bold line, for a glanceable finding title. */
export function findingTitle(body: unknown): string {
	const text = findingText(body)
	const heading = text.match(/^\s*#{1,4}\s+(.+)$/m)
	if (heading) return truncate(heading[1].trim(), 120)
	const bold = text.match(/\*\*([^*\n]{6,160})\*\*/)
	if (bold && !/severity/i.test(bold[1])) return truncate(bold[1].trim(), 120)
	return truncate(text.replace(/\s+/g, ' ').trim(), 120)
}