Skip to content

src/outside-diff.ts

238 lines ¡ 7.7 KB ¡ TypeScript
Raw
/**
 * 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)
}