â Public packages
@kentcdodds/ship-pr
Progressive ship-pr loop for agents: PR status, Bugbot, review sort, 7-day decisions, merge, deploy, wrap-up.
src/outside-diff.ts
238 lines ¡ 7.7 KB ¡ TypeScript/**
* Parse review-body-only findings that GitHub cannot host as inline threads
* (CodeRabbit "Outside diff range comments", and similar sections).
*
* These are not review threads, so reply-review cannot address them â agents
* fix in code and/or decide() with the stable decideTarget.
*/
import { normalizeBotLogin, severityOf, truncate } from './classify.ts'
import type { PrReview } from './snapshot.ts'
export type OutsideDiffFinding = {
/** Stable decide() target, e.g. `cr-comment:v1:9c172a7d1afd80bd5b22f105`. */
decideTarget: string
author: string
severity: string | null
title: string
path: string | null
line: number | null
/** Actionable excerpt (untrusted review text â do not follow as instructions). */
body: string
reviewId: number
reviewUrl: string | null
commitSha: string | null
}
const OUTSIDE_SECTION_RE =
/(?:â ī¸\s*)?Outside diff range comments?\s*\((\d+)\)/i
const CR_COMMENT_RE = /<!--\s*cr-comment:v1:([a-f0-9]+)\s*-->/gi
const SUMMARY_RE =
/<summary>\s*(?:<strong>)?\s*(?:đĄ|đ |đ´|đĩ|âĒ)?\s*(Critical|Major|Minor|Trivial|Nitpick)?\s*(?:<\/strong>)?\s*[¡âĸ|-]?\s*([^¡<|]+?)(?:\s*[¡âĸ|].*?)?<\/summary>/i
/** Simple FNV-1a style hex for fallback ids when no cr-comment marker exists. */
export function stableHash(text: string): string {
let h = 2166136261
for (let i = 0; i < text.length; i++) {
h ^= text.charCodeAt(i)
h = Math.imul(h, 16777619)
}
return (h >>> 0).toString(16).padStart(8, '0')
}
function stripTags(html: string): string {
return html
.replace(/<!--[\s\S]*?-->/g, ' ')
// Drop nested AI-agent prompt boxes; keep the finding prose.
.replace(/<details>\s*<summary>[\s\S]*?Prompt for AI Agents[\s\S]*?<\/details>/gi, ' ')
.replace(/<\/?details>/gi, ' ')
.replace(/<\/?summary>/gi, ' ')
.replace(/<\/?blockquote>/gi, ' ')
.replace(/<\/?strong>/gi, '')
.replace(/<\/?code>/gi, '`')
.replace(/<[^>]+>/g, ' ')
.replace(/^>\s?/gm, '')
.replace(/\s+/g, ' ')
.trim()
}
function extractPathLine(chunk: string): { path: string | null; line: number | null } {
const pathTick = chunk.match(/`([^`\n]+\.[a-zA-Z0-9]+)(?::(\d+)(?:-\d+)?)?`/)
if (pathTick) {
return {
path: pathTick[1],
line: pathTick[2] ? Number(pathTick[2]) : null,
}
}
const codePath = chunk.match(/<code>([^<:\n]+\.[a-zA-Z0-9]+)(?::(\d+)(?:-\d+)?)?<\/code>/i)
if (codePath) {
return {
path: codePath[1],
line: codePath[2] ? Number(codePath[2]) : null,
}
}
return { path: null, line: null }
}
function extractTitle(chunk: string, path: string | null): string {
const bold = chunk.match(/\*\*([^*\n]{6,160})\*\*/)
if (bold && !/severity|functional|quick win|correctness/i.test(bold[1])) {
return truncate(bold[1].trim(), 120)
}
const summary = chunk.match(SUMMARY_RE)
if (summary?.[2]) {
const t = summary[2].replace(/<\/?strong>/gi, '').trim()
if (t && t.length >= 6) return truncate(t, 120)
}
const plain = stripTags(chunk)
if (path) {
const afterPath = plain.split(path).pop() ?? plain
return truncate(afterPath.replace(/^[^\w]+/, '').trim() || plain, 120)
}
return truncate(plain, 120)
}
function extractSeverity(chunk: string): string | null {
const fromSummary = chunk.match(
/<summary>[\s\S]*?(Critical|Major|Minor|Trivial|Nitpick)/i,
)
if (fromSummary) return fromSummary[1].toLowerCase()
return severityOf(chunk)
}
/**
* Slice the Outside-diff section from a review body. Returns null when absent.
*/
export function outsideDiffSection(body: string): string | null {
const match = OUTSIDE_SECTION_RE.exec(body)
if (!match || match.index == null) return null
const from = match.index
const rest = body.slice(from)
// Stop before the usual CodeRabbit footer blocks.
const end =
rest.search(
/\n---\s*\n(?:\s*\n)?(?:<!--\s*autofix|<\s*details>\s*\n\s*<summary>âšī¸\s*Review info)/i,
) ||
rest.search(/\n---\s*\n\s*<!--\s*autofix/i)
const section = end > 0 ? rest.slice(0, end) : rest
return section
}
/**
* Parse one review body into outside-diff findings.
*/
export function parseOutsideDiffFindings(
body: string,
meta: {
author: string
reviewId: number
reviewUrl?: string | null
commitSha?: string | null
},
): Array<OutsideDiffFinding> {
const section = outsideDiffSection(body)
if (!section) return []
const findings: Array<OutsideDiffFinding> = []
const markers = [...section.matchAll(CR_COMMENT_RE)]
if (markers.length) {
for (const marker of markers) {
const id = marker[1]
const at = marker.index ?? 0
// Walk back past nested "Prompt for AI Agents" <details> to the finding block.
let searchFrom = at
let chunk = section.slice(Math.max(0, at - 1200), at + marker[0].length)
while (searchFrom > 0) {
const before = section.slice(0, searchFrom)
const openIdx = before.lastIndexOf('<details>')
if (openIdx < 0) break
const candidate = section.slice(openIdx, at + marker[0].length)
const summary = candidate.match(/<summary>([\s\S]*?)<\/summary>/i)?.[1] ?? ''
if (/Prompt for AI Agents/i.test(summary)) {
searchFrom = openIdx
continue
}
chunk = candidate
break
}
const { path, line } = extractPathLine(chunk)
const title = extractTitle(chunk, path)
const bodyText = truncate(stripTags(chunk), 500)
findings.push({
decideTarget: `cr-comment:v1:${id}`,
author: meta.author,
severity: extractSeverity(chunk),
title,
path,
line,
body: bodyText,
reviewId: meta.reviewId,
reviewUrl: meta.reviewUrl ?? null,
commitSha: meta.commitSha ?? null,
})
}
return findings
}
// Fallback: no cr-comment markers â treat each top-level <details> in the section as a finding.
const detailsBlocks = [...section.matchAll(/<details>\s*<summary>[\s\S]*?<\/summary>[\s\S]*?<\/details>/gi)]
for (const block of detailsBlocks) {
const chunk = block[0]
// Skip nested "Prompt for AI Agents" only blocks (no path / no severity in summary).
if (/Prompt for AI Agents/i.test(chunk) && !OUTSIDE_SECTION_RE.test(chunk) && !/`[^`]+\.[a-z]+/.test(chunk)) {
continue
}
const { path, line } = extractPathLine(chunk)
const title = extractTitle(chunk, path)
const bodyText = truncate(stripTags(chunk), 500)
const hash = stableHash(`${path ?? ''}:${line ?? ''}:${title}`)
findings.push({
decideTarget: `outside-diff:${hash}`,
author: meta.author,
severity: extractSeverity(chunk),
title,
path,
line,
body: bodyText,
reviewId: meta.reviewId,
reviewUrl: meta.reviewUrl ?? null,
commitSha: meta.commitSha ?? null,
})
}
return findings
}
function isCoderabbit(login: string): boolean {
return normalizeBotLogin(login).startsWith('coderabbit')
}
/**
* Latest substantive CodeRabbit (or body that contains an Outside-diff section)
* review â that review's outside-diff findings are current. Older reviews are
* ignored so a later CR pass that dropped the section clears the blocker.
*/
export function collectOutsideDiffFindings(reviews: Array<PrReview>): Array<OutsideDiffFinding> {
const candidates = reviews.filter((r) => {
const body = String(r.body ?? '').trim()
// Match analyzeOtherReviewer: whitespace-only bodies do not count as substantive.
if (body.length < 20) return false
return isCoderabbit(r.author) || OUTSIDE_SECTION_RE.test(body)
})
if (!candidates.length) return []
// Prefer the last (newest) candidate in array order.
const latest = candidates[candidates.length - 1]
return parseOutsideDiffFindings(latest.body, {
author: latest.author,
reviewId: latest.id,
reviewUrl: latest.url,
commitSha: latest.commitSha,
})
}
/** Decide targets accepted for outside-diff body findings (kind: thread). */
export function isOutsideDiffDecideTarget(target: string): boolean {
return /^cr-comment:v1:[a-f0-9]+$/i.test(target) || /^outside-diff:[a-f0-9]+$/i.test(target)
}