Archived
feat: add conversational agent reviews
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
const maximumDiffBytes = 500_000;
|
||||
const maximumFindings = 100;
|
||||
|
||||
export interface DiffAnchor {
|
||||
path: string;
|
||||
side: "old" | "new";
|
||||
line: number;
|
||||
}
|
||||
|
||||
export interface UnifiedDiffLine {
|
||||
text: string;
|
||||
anchors: DiffAnchor[];
|
||||
}
|
||||
|
||||
export interface ParsedUnifiedDiff {
|
||||
lines: UnifiedDiffLine[];
|
||||
anchors: DiffAnchor[];
|
||||
}
|
||||
|
||||
export interface StructuredDiffFinding extends DiffAnchor {
|
||||
body: string;
|
||||
}
|
||||
|
||||
export interface PullReviewCommentInput {
|
||||
path: string;
|
||||
body: string;
|
||||
old_position: number;
|
||||
new_position: number;
|
||||
}
|
||||
|
||||
interface HunkState {
|
||||
path: string;
|
||||
oldLine: number;
|
||||
newLine: number;
|
||||
oldEnd: number;
|
||||
newEnd: number;
|
||||
}
|
||||
|
||||
export function parseUnifiedDiff(diff: string): ParsedUnifiedDiff {
|
||||
if (Buffer.byteLength(diff) > maximumDiffBytes)
|
||||
throw new Error(`Unified diff exceeds ${maximumDiffBytes} bytes`);
|
||||
const lines: UnifiedDiffLine[] = [];
|
||||
const anchors: DiffAnchor[] = [];
|
||||
const keys = new Set<string>();
|
||||
let oldPath: string | undefined;
|
||||
let path: string | undefined;
|
||||
let hunk: HunkState | undefined;
|
||||
|
||||
for (const text of diff.split("\n")) {
|
||||
const lineAnchors: DiffAnchor[] = [];
|
||||
if (hunk && text === "\\ No newline at end of file") {
|
||||
lines.push({ text, anchors: lineAnchors });
|
||||
continue;
|
||||
}
|
||||
if (hunk && /^[- +]/.test(text)) {
|
||||
consumeHunkLine(hunk, text, lineAnchors);
|
||||
for (const anchor of lineAnchors) {
|
||||
const key = anchorKey(anchor);
|
||||
if (keys.has(key))
|
||||
throw new Error(`Unified diff repeats anchor ${key}`);
|
||||
keys.add(key);
|
||||
anchors.push(anchor);
|
||||
}
|
||||
lines.push({ text, anchors: lineAnchors });
|
||||
continue;
|
||||
}
|
||||
finishHunk(hunk);
|
||||
hunk = undefined;
|
||||
if (text.startsWith("diff --git ")) {
|
||||
oldPath = undefined;
|
||||
path = undefined;
|
||||
} else if (text.startsWith("--- ")) {
|
||||
oldPath = parseHeaderPath(text.slice(4), "a/");
|
||||
} else if (text.startsWith("+++ ")) {
|
||||
const newPath = parseHeaderPath(text.slice(4), "b/");
|
||||
path = newPath === "/dev/null" ? oldPath : newPath;
|
||||
if (!path || path === "/dev/null")
|
||||
throw new Error("Unified diff has no usable file path");
|
||||
} else if (text.startsWith("@@")) {
|
||||
if (!path) throw new Error("Unified diff hunk has no file path");
|
||||
hunk = parseHunk(text, path);
|
||||
}
|
||||
lines.push({ text, anchors: lineAnchors });
|
||||
}
|
||||
finishHunk(hunk);
|
||||
return { lines, anchors };
|
||||
}
|
||||
|
||||
export function renderUnifiedDiff(value: string | ParsedUnifiedDiff): string {
|
||||
const parsed = typeof value === "string" ? parseUnifiedDiff(value) : value;
|
||||
const rendered = parsed.lines
|
||||
.map((line) => {
|
||||
if (!line.anchors.length) return line.text;
|
||||
const refs = line.anchors.map(renderDiffAnchor).join(" ");
|
||||
return `${refs} ${line.text}`;
|
||||
})
|
||||
.join("\n");
|
||||
if (Buffer.byteLength(rendered) > maximumDiffBytes * 2)
|
||||
throw new Error("Rendered unified diff exceeds the output limit");
|
||||
return rendered;
|
||||
}
|
||||
|
||||
export function renderDiffAnchor(anchor: DiffAnchor): string {
|
||||
return `[${anchor.side}:${anchor.line}:${JSON.stringify(anchor.path)}]`;
|
||||
}
|
||||
|
||||
export function isValidDiffAnchor(
|
||||
parsed: ParsedUnifiedDiff,
|
||||
anchor: DiffAnchor,
|
||||
): boolean {
|
||||
const expected = anchorKey(anchor);
|
||||
return parsed.anchors.some(
|
||||
(candidate) => anchorKey(candidate) === expected,
|
||||
);
|
||||
}
|
||||
|
||||
export function validateStructuredFindings(
|
||||
value: unknown,
|
||||
diff: string | ParsedUnifiedDiff,
|
||||
): StructuredDiffFinding[] {
|
||||
if (!Array.isArray(value) || value.length > maximumFindings)
|
||||
throw new Error("Review findings must be a bounded array");
|
||||
const parsed = typeof diff === "string" ? parseUnifiedDiff(diff) : diff;
|
||||
let bodyBytes = 0;
|
||||
return value.map((item, index) => {
|
||||
if (!item || typeof item !== "object" || Array.isArray(item))
|
||||
throw new Error(`Review finding ${index} must be an object`);
|
||||
const finding = item as Partial<StructuredDiffFinding>;
|
||||
if (
|
||||
typeof finding.path !== "string" ||
|
||||
!safePath(finding.path) ||
|
||||
(finding.side !== "old" && finding.side !== "new") ||
|
||||
!Number.isSafeInteger(finding.line) ||
|
||||
Number(finding.line) <= 0
|
||||
) {
|
||||
throw new Error(`Review finding ${index} has a malformed anchor`);
|
||||
}
|
||||
const anchor = {
|
||||
path: finding.path,
|
||||
side: finding.side,
|
||||
line: Number(finding.line),
|
||||
};
|
||||
if (!isValidDiffAnchor(parsed, anchor))
|
||||
throw new Error(
|
||||
`Review finding ${index} is not anchored in the diff`,
|
||||
);
|
||||
if (typeof finding.body !== "string" || !finding.body.trim())
|
||||
throw new Error(`Review finding ${index} has no body`);
|
||||
const body = finding.body.trim();
|
||||
bodyBytes += Buffer.byteLength(body);
|
||||
if (body.length > 10_000 || bodyBytes > maximumDiffBytes)
|
||||
throw new Error("Review findings exceed the output limit");
|
||||
return { ...anchor, body };
|
||||
});
|
||||
}
|
||||
|
||||
export function renderPullReviewComments(
|
||||
findings: StructuredDiffFinding[],
|
||||
): PullReviewCommentInput[] {
|
||||
return findings.map((finding) => ({
|
||||
path: finding.path,
|
||||
body: finding.body,
|
||||
old_position: finding.side === "old" ? finding.line : 0,
|
||||
new_position: finding.side === "new" ? finding.line : 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function parseHunk(text: string, path: string): HunkState {
|
||||
const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/.exec(text);
|
||||
if (!match) throw new Error(`Malformed unified diff hunk: ${text}`);
|
||||
const oldLine = Number(match[1]);
|
||||
const oldCount = Number(match[2] ?? 1);
|
||||
const newLine = Number(match[3]);
|
||||
const newCount = Number(match[4] ?? 1);
|
||||
if (
|
||||
![oldLine, oldCount, newLine, newCount].every(Number.isSafeInteger) ||
|
||||
(oldCount > 0 && oldLine < 1) ||
|
||||
(newCount > 0 && newLine < 1)
|
||||
)
|
||||
throw new Error(`Malformed unified diff hunk: ${text}`);
|
||||
return {
|
||||
path,
|
||||
oldLine,
|
||||
newLine,
|
||||
oldEnd: oldLine + oldCount,
|
||||
newEnd: newLine + newCount,
|
||||
};
|
||||
}
|
||||
|
||||
function consumeHunkLine(
|
||||
hunk: HunkState,
|
||||
text: string,
|
||||
anchors: DiffAnchor[],
|
||||
): void {
|
||||
const prefix = text[0];
|
||||
if (prefix === " " || prefix === "-") {
|
||||
if (hunk.oldLine >= hunk.oldEnd)
|
||||
throw new Error("Unified diff exceeds its old-line hunk range");
|
||||
anchors.push({ path: hunk.path, side: "old", line: hunk.oldLine++ });
|
||||
}
|
||||
if (prefix === " " || prefix === "+") {
|
||||
if (hunk.newLine >= hunk.newEnd)
|
||||
throw new Error("Unified diff exceeds its new-line hunk range");
|
||||
anchors.push({ path: hunk.path, side: "new", line: hunk.newLine++ });
|
||||
}
|
||||
}
|
||||
|
||||
function finishHunk(hunk: HunkState | undefined): void {
|
||||
if (hunk && (hunk.oldLine !== hunk.oldEnd || hunk.newLine !== hunk.newEnd))
|
||||
throw new Error(
|
||||
"Unified diff hunk ended before its declared line counts",
|
||||
);
|
||||
}
|
||||
|
||||
function parseHeaderPath(value: string, prefix: string): string {
|
||||
const decoded = value.startsWith('"') ? decodeQuotedPath(value) : value;
|
||||
if (decoded === "/dev/null") return decoded;
|
||||
const path = decoded.startsWith(prefix)
|
||||
? decoded.slice(prefix.length)
|
||||
: decoded;
|
||||
if (!safePath(path))
|
||||
throw new Error("Unified diff contains an unsafe path");
|
||||
return path;
|
||||
}
|
||||
|
||||
function decodeQuotedPath(value: string): string {
|
||||
try {
|
||||
return JSON.parse(value) as string;
|
||||
} catch {
|
||||
throw new Error("Unified diff contains a malformed quoted path");
|
||||
}
|
||||
}
|
||||
|
||||
function safePath(path: string): boolean {
|
||||
return Boolean(
|
||||
path &&
|
||||
path.length <= 1_000 &&
|
||||
!path.startsWith("/") &&
|
||||
!path.includes("\0") &&
|
||||
!path.includes("\n") &&
|
||||
!path.split("/").includes(".."),
|
||||
);
|
||||
}
|
||||
|
||||
function anchorKey(anchor: DiffAnchor): string {
|
||||
return JSON.stringify([anchor.path, anchor.side, anchor.line]);
|
||||
}
|
||||
Reference in New Issue
Block a user