Archived
248 lines
8.1 KiB
TypeScript
248 lines
8.1 KiB
TypeScript
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]);
|
|
}
|