← Public packages
@kentcdodds/ship-pr
Progressive ship-pr loop for agents: PR status, Bugbot, review sort, 7-day decisions, merge, deploy, wrap-up.
src/classify.ts
290 lines · 8.4 KB · TypeScript/**
* 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)
}