/** * 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, ): 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, `
` blocks, and tags so guidance text does not skew verdicts. */ export function findingText(body: unknown): string { return String(body || '') .replace(//g, ' ') .replace(//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((?; 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, ): Array { const findings: Array = [] 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, 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) }