Files
oh-my-pi/packages/coding-agent/src/tools/gh-pr-diff.ts
T
can1357 cafe52cd9f refactor(coding-agent): split github tool into domain modules
- gh.ts held wire types, search, Actions run-watch, PR checkout/push/create,
  PR diff parsing and view fetch/format in 3958 lines.
- Split into gh-types, gh-search, gh-run-watch, gh-pr-checkout, gh-pr-diff,
  gh-view and a gh-common module holding the shared primitives and the single
  process-lifetime default-repo memo pair; gh.ts is now 246 lines.
- All 22 exports stay on gh.ts because tools/index.ts star-exports ./gh, so
  the issue:// and pr:// protocol handlers needed no edits.
2026-08-08 06:32:01 +02:00

474 lines
15 KiB
TypeScript

import { logger } from "@oh-my-pi/pi-utils";
import type { Settings } from "../config/settings";
import * as git from "../utils/git";
import { appendRepoFlag } from "./gh-common";
import type { ViewLookupResult } from "./gh-view";
import { getOrFetchView, resolveGithubCacheAuthKey } from "./github-cache";
import { ToolError } from "./tool-errors";
export const PR_DIFF_FILES_PAGE_SIZE = 100;
export const PR_DIFF_FILES_MAX = 3000;
// ────────────────────────────────────────────────────────────────────────────
// PR diff fetcher
//
// Used by the `pr://<n>/diff[/…]` internal-URL family. Stores the verbatim
// `gh pr diff` text plus a parsed file index so the listing, full-diff, and
// per-file slice variants all share one cache row.
// ────────────────────────────────────────────────────────────────────────────
export interface PrDiffFile {
/** Display path. Prefers the post-image (`b/<path>`) when present. */
path: string;
additions: number;
deletions: number;
changeType: "modified" | "added" | "deleted" | "renamed" | "binary";
/** Pre-image path for renames/deletes; same as `path` otherwise. */
oldPath?: string;
/** Byte offset of the section's `diff --git` line in the unified diff. */
startOffset: number;
/** Byte offset of the next section (or end-of-text). */
endOffset: number;
}
export interface PrDiffPayload {
/** Full unified diff text as returned by `gh pr diff --color never`. */
unified: string;
files: PrDiffFile[];
}
export interface PrDiffLookupOptions {
cwd: string;
repo: string;
number: number;
signal?: AbortSignal;
settings?: Settings;
cacheAuthKey?: string | null;
}
/**
* Split `gh pr diff` output on `^diff --git ` boundaries and parse per-file
* metadata. The unified diff is preserved verbatim so callers can slice it by
* byte offsets without re-running gh.
*/
export function parsePrUnifiedDiff(text: string): PrDiffPayload {
const files: PrDiffFile[] = [];
if (text.length === 0) {
return { unified: text, files };
}
// Walk match positions manually so we capture each section's byte range.
const sectionStarts: number[] = [];
const re = /^diff --git /gm;
let m: RegExpExecArray | null = re.exec(text);
while (m !== null) {
sectionStarts.push(m.index);
// Avoid zero-length match infinite loop (regex has fixed prefix, but
// be explicit).
if (re.lastIndex === m.index) re.lastIndex += 1;
m = re.exec(text);
}
for (let i = 0; i < sectionStarts.length; i += 1) {
const startOffset = sectionStarts[i] ?? 0;
const endOffset = sectionStarts[i + 1] ?? text.length;
const section = text.slice(startOffset, endOffset);
files.push(parsePrDiffSection(section, startOffset, endOffset));
}
return { unified: text, files };
}
export interface ParsedDiffHeaderToken {
value: string;
nextIndex: number;
}
export function skipDiffHeaderSpaces(text: string, index: number): number {
let i = index;
while (text.charAt(i) === " ") i += 1;
return i;
}
export function parseDiffQuotedEscape(text: string, slashIndex: number): ParsedDiffHeaderToken {
const next = text.charAt(slashIndex + 1);
if (next === "") return { value: "\\", nextIndex: slashIndex + 1 };
if (next >= "0" && next <= "7") {
let end = slashIndex + 1;
while (end < text.length && end < slashIndex + 4) {
const digit = text.charAt(end);
if (digit < "0" || digit > "7") break;
end += 1;
}
return {
value: String.fromCharCode(Number.parseInt(text.slice(slashIndex + 1, end), 8)),
nextIndex: end,
};
}
switch (next) {
case "a":
return { value: "\x07", nextIndex: slashIndex + 2 };
case "b":
return { value: "\b", nextIndex: slashIndex + 2 };
case "f":
return { value: "\f", nextIndex: slashIndex + 2 };
case "n":
return { value: "\n", nextIndex: slashIndex + 2 };
case "r":
return { value: "\r", nextIndex: slashIndex + 2 };
case "t":
return { value: "\t", nextIndex: slashIndex + 2 };
case "v":
return { value: "\v", nextIndex: slashIndex + 2 };
case "\\":
case '"':
return { value: next, nextIndex: slashIndex + 2 };
default:
return { value: next, nextIndex: slashIndex + 2 };
}
}
export function parseDiffQuotedToken(text: string, startIndex: number): ParsedDiffHeaderToken | undefined {
if (text.charAt(startIndex) !== '"') return undefined;
let value = "";
for (let i = startIndex + 1; i < text.length; i += 1) {
const ch = text.charAt(i);
if (ch === '"') return { value, nextIndex: i + 1 };
if (ch !== "\\") {
value += ch;
continue;
}
const escaped = parseDiffQuotedEscape(text, i);
value += escaped.value;
i = escaped.nextIndex - 1;
}
return undefined;
}
export function parseDiffHeaderToken(text: string, startIndex: number): ParsedDiffHeaderToken | undefined {
const start = skipDiffHeaderSpaces(text, startIndex);
if (start >= text.length) return undefined;
const quoted = parseDiffQuotedToken(text, start);
if (quoted) return quoted;
const end = text.indexOf(" ", start);
if (end === -1) return { value: text.slice(start), nextIndex: text.length };
return { value: text.slice(start, end), nextIndex: end };
}
export function stripPrDiffPathPrefix(value: string, prefix: "a/" | "b/"): string | undefined {
return value.startsWith(prefix) ? value.slice(prefix.length) : undefined;
}
export function parsePrDiffHeaderPaths(header: string): { oldPath?: string; newPath?: string } {
const trail = header.slice("diff --git ".length);
if (trail.startsWith('"')) {
const oldToken = parseDiffQuotedToken(trail, 0);
if (!oldToken) return {};
const newToken = parseDiffHeaderToken(trail, oldToken.nextIndex);
if (!newToken) return {};
return {
oldPath: stripPrDiffPathPrefix(oldToken.value, "a/"),
newPath: stripPrDiffPathPrefix(newToken.value, "b/"),
};
}
const bIdx = trail.indexOf(" b/");
if (trail.startsWith("a/") && bIdx > 0) {
return {
oldPath: trail.slice(2, bIdx),
newPath: trail.slice(bIdx + 3),
};
}
return {};
}
export function isPrDiffFileHeaderLine(line: string): boolean {
return (
line === "--- /dev/null" ||
line === "+++ /dev/null" ||
line.startsWith("--- a/") ||
line.startsWith("+++ b/") ||
line.startsWith('--- "a/') ||
line.startsWith('+++ "b/')
);
}
export function parsePrDiffSection(section: string, startOffset: number, endOffset: number): PrDiffFile {
const lines = section.split("\n");
const header = lines[0] ?? "";
const headerPaths = parsePrDiffHeaderPaths(header);
let oldPath = headerPaths.oldPath;
let newPath = headerPaths.newPath;
let changeType: PrDiffFile["changeType"] = "modified";
let isBinary = false;
let additions = 0;
let deletions = 0;
let inHunk = false;
for (let li = 1; li < lines.length; li += 1) {
const line = lines[li] ?? "";
if (line.startsWith("new file mode")) {
changeType = "added";
continue;
}
if (line.startsWith("deleted file mode")) {
changeType = "deleted";
continue;
}
if (line.startsWith("rename from ")) {
changeType = "renamed";
oldPath = line.slice("rename from ".length);
continue;
}
if (line.startsWith("rename to ")) {
newPath = line.slice("rename to ".length);
continue;
}
if (line.startsWith("Binary files ") && line.endsWith(" differ")) {
isBinary = true;
continue;
}
if (line.startsWith("@@ ")) {
inHunk = true;
continue;
}
if (!inHunk && isPrDiffFileHeaderLine(line)) continue;
if (line.startsWith("+")) {
additions += 1;
} else if (line.startsWith("-")) {
deletions += 1;
}
}
if (isBinary) {
if (changeType === "modified") changeType = "binary";
additions = 0;
deletions = 0;
}
const displayPath =
changeType === "deleted" ? (oldPath ?? newPath ?? "(unknown)") : (newPath ?? oldPath ?? "(unknown)");
const file: PrDiffFile = {
path: displayPath,
additions,
deletions,
changeType,
startOffset,
endOffset,
};
if (oldPath && oldPath !== displayPath) {
file.oldPath = oldPath;
}
return file;
}
/**
* A single entry from `GET /repos/{owner}/{repo}/pulls/{n}/files`. `patch` is
* absent for binary files and for individual file diffs GitHub deems too large
* to render.
*/
export interface GhPrFileApi {
filename?: string;
previous_filename?: string;
status?: string;
additions?: number;
deletions?: number;
patch?: string;
}
export interface GhPrApi {
changed_files?: number;
}
/**
* GitHub rejects the aggregate PR diff endpoint with HTTP 406 once the diff
* exceeds 20,000 lines. Detect that specific failure so the caller can fall
* back to the per-file endpoint instead of aborting the whole review.
*/
export function isPrDiffTooLargeError(err: unknown): boolean {
const message = err instanceof Error ? err.message : String(err);
return (
/\bHTTP 406\b/.test(message) ||
/exceeded the maximum number of lines/i.test(message) ||
/\btoo_large\b/.test(message)
);
}
export function formatSyntheticDiffPath(prefix: "a/" | "b/", path: string): string {
const prefixedPath = `${prefix}${path}`;
if (!/[\u0000-\u001F\s"\\]/.test(prefixedPath)) return prefixedPath;
let escaped = "";
for (const char of prefixedPath) {
switch (char) {
case "\\":
escaped += "\\\\";
break;
case '"':
escaped += '\\"';
break;
case "\n":
escaped += "\\n";
break;
case "\r":
escaped += "\\r";
break;
case "\t":
escaped += "\\t";
break;
default: {
const code = char.charCodeAt(0);
escaped += code < 32 ? `\\${code.toString(8).padStart(3, "0")}` : char;
}
}
}
return `"${escaped}"`;
}
/**
* Reconstruct a `diff --git` section from a single files-API entry. The API's
* `patch` field carries only the hunk body, so the `diff --git`/`---`/`+++`
* headers are synthesized to match `gh pr diff` output — this keeps
* {@link parsePrUnifiedDiff} and the review parser producing identical section
* boundaries and byte offsets. Files whose `patch` is omitted (binary or
* too-large) stay visible with an explicit marker rather than being dropped.
*/
export function buildSyntheticDiffSection(file: GhPrFileApi): string | undefined {
const newPath = file.filename;
if (!newPath) return undefined;
const status = file.status ?? "modified";
const oldPath = file.previous_filename ?? newPath;
const oldDiffPath = formatSyntheticDiffPath("a/", oldPath);
const newDiffPath = formatSyntheticDiffPath("b/", newPath);
const lines: string[] = [`diff --git ${oldDiffPath} ${newDiffPath}`];
if (status === "added") {
lines.push("new file mode 100644");
} else if (status === "removed") {
lines.push("deleted file mode 100644");
} else if (status === "renamed" || file.previous_filename) {
lines.push(`rename from ${oldPath}`, `rename to ${newPath}`);
}
if (typeof file.patch === "string" && file.patch.length > 0) {
lines.push(status === "added" ? "--- /dev/null" : `--- ${oldDiffPath}`);
lines.push(status === "removed" ? "+++ /dev/null" : `+++ ${newDiffPath}`);
lines.push(file.patch);
} else {
lines.push(
`* patch unavailable (binary or too large); additions ${file.additions ?? 0}, deletions ${file.deletions ?? 0}`,
);
}
return lines.join("\n");
}
/**
* Fallback PR diff retrieval via the paginated per-file endpoint, used when the
* aggregate `gh pr diff` is rejected for exceeding GitHub's 20,000-line limit.
* The per-file patches are not subject to that aggregate cap, so even very
* large PRs can be reassembled into a synthetic unified diff.
*/
export async function fetchPrDiffViaFilesApi(
cwd: string,
repo: string,
number: number,
signal: AbortSignal | undefined,
): Promise<string> {
const pull = await git.github.json<GhPrApi>(
cwd,
["api", "--method", "GET", `/repos/${repo}/pulls/${number}`],
signal,
{ repoProvided: true },
);
if ((pull.changed_files ?? 0) > PR_DIFF_FILES_MAX) {
throw new ToolError(
`Pull request changes ${pull.changed_files} files, exceeding GitHub's ${PR_DIFF_FILES_MAX}-file limit for the per-file diff API.`,
);
}
const sections: string[] = [];
let page = 1;
while (true) {
const response = await git.github.json<GhPrFileApi[]>(
cwd,
[
"api",
"--method",
"GET",
`/repos/${repo}/pulls/${number}/files`,
"-F",
`per_page=${PR_DIFF_FILES_PAGE_SIZE}`,
"-F",
`page=${page}`,
],
signal,
{ repoProvided: true },
);
for (const file of response) {
const section = buildSyntheticDiffSection(file);
if (section) sections.push(section);
}
if (response.length < PR_DIFF_FILES_PAGE_SIZE) {
break;
}
page += 1;
}
// Trailing newline mirrors `gh pr diff` so downstream parsers splitting on
// `^diff --git ` see identical boundaries.
return sections.length > 0 ? `${sections.join("\n")}\n` : "";
}
export async function fetchPrDiffFresh(
cwd: string,
repo: string,
number: number,
signal: AbortSignal | undefined,
): Promise<{ rendered: string; sourceUrl: string | undefined; payload: PrDiffPayload }> {
const args = ["pr", "diff", String(number), "--color", "never"];
appendRepoFlag(args, repo, String(number));
let text: string;
try {
text = await git.github.text(cwd, args, signal, { repoProvided: true, trimOutput: false });
} catch (err) {
if (!isPrDiffTooLargeError(err)) throw err;
logger.debug("gh pr diff exceeded GitHub's aggregate line limit; falling back to per-file API", {
repo,
number,
err: String(err),
});
text = await fetchPrDiffViaFilesApi(cwd, repo, number, signal);
}
const payload = parsePrUnifiedDiff(text);
// `rendered` already carries the verbatim diff; blank the payload copy so
// the cache row stores a potentially huge diff once instead of twice.
// `getOrFetchPrDiff` rehydrates `unified` from `rendered`.
return { rendered: text, sourceUrl: undefined, payload: { unified: "", files: payload.files } };
}
/**
* Cache-aware PR diff fetcher. Stores the full unified diff plus a parsed
* file index in a single `pr-diff` cache row so the listing, full-diff, and
* per-file slice variants of `pr://<n>/diff` share one `gh pr diff`
* invocation.
*/
export async function getOrFetchPrDiff(options: PrDiffLookupOptions): Promise<ViewLookupResult<PrDiffPayload>> {
const authKey = options.cacheAuthKey === undefined ? (resolveGithubCacheAuthKey() ?? null) : options.cacheAuthKey;
const doFetch = () => fetchPrDiffFresh(options.cwd, options.repo, options.number, options.signal);
const lookup = await getOrFetchView<PrDiffPayload>({
repo: options.repo,
kind: "pr-diff",
number: options.number,
includeComments: false,
settings: options.settings,
authKey,
fetchFresh: doFetch,
});
return {
rendered: lookup.rendered,
sourceUrl: lookup.sourceUrl,
// Rehydrate the unified text from `rendered` (stored once per row).
payload: { unified: lookup.rendered, files: lookup.payload.files },
status: lookup.status,
fetchedAt: lookup.fetchedAt,
};
}