Files

180 lines
7.2 KiB
JavaScript

import { createHash } from "node:crypto";
import { isKnownSeverity, normalizeSeverity, severityRank } from "./severity.mjs";
export const INLINE_SEVERITIES = new Set(["p0", "p1", "p2"]);
export const MAX_INLINE_COMMENTS = 10;
// A model that wraps the block in a code fence leaves an empty fence behind once the block is cut out.
const EMPTY_FENCE_RE = /^[ \t]*(`{3,}|~{3,})[a-z]*[ \t]*\n\s*\1[ \t]*$/gim;
const FINGERPRINT_RE = /<!--\s*ai-review-fp:([0-9a-f]{40})\s*-->/g;
const SEVERITY_LABEL = { p0: "🔴 P0", p1: "🟠 P1", p2: "🟡 P2" };
// Lines a review comment may anchor to with side RIGHT: added and context lines of each hunk, keyed by new path.
export function rightSideLines(diff) {
const files = new Map();
let lines = null;
let next = 0;
let oldLeft = 0;
let newLeft = 0;
for (const line of diff.split("\n")) {
if (oldLeft > 0 || newLeft > 0) {
if (line.startsWith("\\")) continue;
if (line.startsWith("-")) { oldLeft -= 1; continue; }
// Some tools strip the space off an empty context line.
if (line.startsWith("+")) newLeft -= 1;
else { oldLeft -= 1; newLeft -= 1; }
lines?.add(next);
next += 1;
continue;
}
if (line.startsWith("diff --git ")) {
lines = null;
continue;
}
if (line.startsWith("+++ ")) {
const path = /^\+\+\+ "?b\/(.*?)"?$/.exec(line)?.[1];
lines = path ? new Set() : null;
if (path) files.set(path, lines);
continue;
}
const hunk = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(line);
if (hunk) {
oldLeft = hunk[1] === undefined ? 1 : Number(hunk[1]);
next = Number(hunk[2]);
newLeft = hunk[3] === undefined ? 1 : Number(hunk[3]);
}
}
return files;
}
// Cut one comment at a time until no opener is left, so nested input like "<!<!---->--" cannot rebuild one;
// an unterminated opener is dropped and the text after it kept.
function stripComments(text) {
let out = String(text ?? "");
for (let i = out.indexOf("<!--"); i !== -1; i = out.indexOf("<!--")) {
const end = out.indexOf("-->", i + 4);
out = out.slice(0, i) + (end === -1 ? out.slice(i + 4) : out.slice(end + 3));
}
return out.trim();
}
function normalizeFinding(raw) {
if (!raw || typeof raw !== "object") return null;
const title = stripComments(raw.title ?? raw.summary);
if (!title) return null;
return {
file: String(raw.file ?? "").trim().replace(/^\.?\//, ""),
line: Number.parseInt(raw.line, 10) || 0,
severity: normalizeSeverity(raw.severity),
category: stripComments(raw.category),
title,
body: stripComments(raw.body ?? raw.description),
};
}
// Finds each `<!-- ai-review-inline [...] -->` block. The end is the first `-->` after which the JSON parses,
// so a `-->` inside a JSON string does not cut the block short.
function findInlineBlocks(source) {
const blocks = [];
const startRe = /<!--\s*ai-review-inline\b/gi;
let start;
while ((start = startRe.exec(source)) !== null) {
const bodyAt = start.index + start[0].length;
let end = source.indexOf("-->", bodyAt);
// Nothing closes it, so its findings cannot be read: it still counts as a block, and only the opener is cut so a reader sees the rest.
if (end === -1) {
blocks.push({ from: start.index, to: bodyAt, json: null, raw: "" });
continue;
}
const firstEnd = end;
let json = null;
while (end !== -1) {
try {
json = JSON.parse(source.slice(bodyAt, end).trim());
break;
} catch {
end = source.indexOf("-->", end + 3);
}
}
if (json === null) end = firstEnd;
blocks.push({ from: start.index, to: end + 3, json, raw: source.slice(bodyAt, end) });
startRe.lastIndex = end + 3;
}
return blocks;
}
// Cuts every inline block out of the coordinator output and parses the first one; a further block is never counted, so it makes the count unknown.
export function extractInlineBlock(text) {
const source = String(text ?? "");
const blocks = findInlineBlocks(source);
let stripped = source;
for (const b of [...blocks].reverse()) stripped = stripped.slice(0, b.from) + stripped.slice(b.to);
stripped = stripped.replace(EMPTY_FENCE_RE, "").replace(/\n{3,}/g, "\n\n").trimEnd();
if (blocks.length === 0) return { text: stripped, findings: [], error: "no inline block" };
const parsed = blocks[0].json;
if (parsed === null) return { text: stripped, findings: [], error: "inline block is not valid JSON" };
if (!Array.isArray(parsed)) return { text: stripped, findings: [], error: "inline block is not a JSON array" };
const findings = parsed.map(normalizeFinding).filter(Boolean);
// The list backs "no blockers", so a lost or ungraded entry makes the count unknown even though the rest still post.
const countable = blocks.length === 1 && findings.length === parsed.length && parsed.every(raw => isKnownSeverity(raw.severity));
return { text: stripped, findings, error: countable ? null : "inline block has entries that cannot be counted" };
}
export function normalizeTitle(title) {
return String(title ?? "").toLowerCase().replace(/[^a-z0-9]+/g, " ").trim();
}
export function fingerprint(file, title) {
return createHash("sha1").update(`${file}|${normalizeTitle(title)}`).digest("hex");
}
export function fingerprintMarker(fp) {
return `<!-- ai-review-fp:${fp} -->`;
}
export function existingFingerprints(comments, botLogin) {
const found = new Set();
if (!botLogin) return found;
for (const c of comments) {
if (c?.user?.login !== botLogin) continue;
for (const m of String(c.body ?? "").matchAll(FINGERPRINT_RE)) found.add(m[1]);
}
return found;
}
// GitHub rejects a whole review if one comment anchors outside the RIGHT side of the diff, so such findings are dropped here.
export function selectInlineComments(findings, rightLines, posted = new Set(), max = MAX_INLINE_COMMENTS) {
const selected = [];
const dropped = { severity: 0, anchor: 0, duplicate: 0, cap: 0 };
const seen = new Set(posted);
const ranked = [...findings].sort((a, b) => severityRank(a.severity) - severityRank(b.severity));
for (const f of ranked) {
if (!INLINE_SEVERITIES.has(f.severity)) { dropped.severity += 1; continue; }
if (!f.file || !(f.line > 0) || !rightLines.get(f.file)?.has(f.line)) { dropped.anchor += 1; continue; }
const fp = fingerprint(f.file, f.title);
if (seen.has(fp)) { dropped.duplicate += 1; continue; }
if (selected.length >= max) { dropped.cap += 1; continue; }
seen.add(fp);
selected.push({ ...f, fingerprint: fp });
}
return { selected, dropped };
}
// `scrub` runs over the model-written text only; the fingerprint is appended after it so the scrub cannot alter it.
export function renderInlineBody(f, scrub = text => text) {
const label = SEVERITY_LABEL[f.severity] || f.severity;
const head = `**${label}${f.category ? ` · ${f.category}` : ""}:** ${f.title}`;
const text = scrub(f.body ? `${head}\n\n${f.body}` : head);
return `${text}\n\n${fingerprintMarker(f.fingerprint)}`;
}
export function buildReviewPayload(selected, commitId, body, scrub) {
const payload = {
event: "COMMENT",
body,
comments: selected.map(f => ({ path: f.file, line: f.line, side: "RIGHT", body: renderInlineBody(f, scrub) })),
};
if (commitId) payload.commit_id = commitId;
return payload;
}