278 lines
11 KiB
TypeScript
278 lines
11 KiB
TypeScript
import { commandError, githubRequest, isGitHubError, validationError } from "./client";
|
|
import { prCompactSummary, prFileSummary, sumPrFileStats, numberOrNull } from "./pr-summary";
|
|
import { MAX_PR_FILES_LIMIT } from "./types";
|
|
import type { GitHubCommandResult, GitHubOptions, GitHubPullRequest, GitHubPullRequestFile } from "./types";
|
|
import { repoParts } from "./auth-and-safety";
|
|
|
|
const DEFAULT_PR_PATCH_LINE_LIMIT = 30;
|
|
|
|
interface PatchHunk {
|
|
index: number;
|
|
header: string;
|
|
lines: string[];
|
|
additions: number;
|
|
deletions: number;
|
|
}
|
|
|
|
interface PatchLineSelection {
|
|
hunk: PatchHunk | null;
|
|
lines: string[];
|
|
}
|
|
|
|
interface RedactedPatchLines {
|
|
lines: string[];
|
|
redactions: number;
|
|
}
|
|
|
|
interface PrFilesFetch {
|
|
pr: GitHubPullRequest;
|
|
files: GitHubPullRequestFile[];
|
|
totalFiles: number | null;
|
|
truncated: boolean;
|
|
limit: number;
|
|
}
|
|
|
|
export async function prReviewPlan(repo: string, token: string, number: number, limit: number): Promise<GitHubCommandResult> {
|
|
const fetched = await fetchPrFiles(repo, token, number, Math.min(limit, MAX_PR_FILES_LIMIT), "pr review-plan");
|
|
if (isGitHubCommandResult(fetched)) return fetched;
|
|
const listedStats = sumPrFileStats(fetched.files);
|
|
const totalAdditions = numberOrNull(fetched.pr.additions);
|
|
const totalDeletions = numberOrNull(fetched.pr.deletions);
|
|
const files = fetched.files.map((file, index) => reviewPlanFile(file, index + 1, repo, number));
|
|
const firstFile = files[0];
|
|
return {
|
|
ok: true,
|
|
command: "pr review-plan",
|
|
repo,
|
|
readOnly: true,
|
|
rawDiffIncluded: false,
|
|
pullRequest: prCompactSummary(fetched.pr),
|
|
summary: {
|
|
files: fetched.totalFiles ?? fetched.files.length,
|
|
additions: totalAdditions ?? listedStats.additions,
|
|
deletions: totalDeletions ?? listedStats.deletions,
|
|
changes: (totalAdditions !== null && totalDeletions !== null) ? totalAdditions + totalDeletions : listedStats.changes,
|
|
commits: numberOrNull(fetched.pr.commits),
|
|
},
|
|
files,
|
|
filesReturned: fetched.files.length,
|
|
limit: fetched.limit,
|
|
truncation: {
|
|
truncated: fetched.truncated,
|
|
returned: fetched.files.length,
|
|
totalFiles: fetched.totalFiles,
|
|
maxLimit: MAX_PR_FILES_LIMIT,
|
|
},
|
|
next: {
|
|
firstFilePatch: drillDownCommand(repo, number, typeof firstFile?.filename === "string" ? firstFile.filename : "<path>"),
|
|
files: `bun scripts/cli.ts gh pr files ${number} --repo ${repo} --limit ${Math.min(fetched.totalFiles ?? MAX_PR_FILES_LIMIT, MAX_PR_FILES_LIMIT)}`,
|
|
metadata: `bun scripts/cli.ts gh pr view ${number} --repo ${repo}`,
|
|
},
|
|
request: {
|
|
method: "GET",
|
|
paths: githubPrFilePaths(repo, number),
|
|
},
|
|
};
|
|
}
|
|
|
|
export async function prDiffFile(repo: string, token: string, number: number, options: GitHubOptions): Promise<GitHubCommandResult> {
|
|
const filePath = options.filePath;
|
|
if (filePath === undefined || filePath.trim().length === 0) {
|
|
return validationError("pr diff", repo, "gh pr diff --file requires a non-empty path", {
|
|
supportedCommands: [`bun scripts/cli.ts gh pr review-plan ${number} --repo ${repo}`],
|
|
});
|
|
}
|
|
const fetched = await fetchPrFiles(repo, token, number, MAX_PR_FILES_LIMIT, "pr diff");
|
|
if (isGitHubCommandResult(fetched)) return fetched;
|
|
const file = fetched.files.find((candidate) => candidate.filename === filePath);
|
|
if (file === undefined) {
|
|
return validationError("pr diff", repo, `PR #${number} does not include file: ${filePath}`, {
|
|
filesScanned: fetched.files.length,
|
|
truncated: fetched.truncated,
|
|
sampleFiles: fetched.files.slice(0, 20).map((candidate) => candidate.filename),
|
|
supportedCommands: [`bun scripts/cli.ts gh pr review-plan ${number} --repo ${repo} --limit 100`],
|
|
});
|
|
}
|
|
|
|
const patch = file.patch ?? "";
|
|
const patchLines = splitPatchLines(patch);
|
|
const hunks = parsePatchHunks(patch);
|
|
const selected = selectPatchLines(repo, hunks, patchLines, options.hunk);
|
|
if (isGitHubCommandResult(selected)) return selected;
|
|
const includeFullPatch = options.full || options.raw;
|
|
const appliedLimit = includeFullPatch ? selected.lines.length : Math.min(options.limit, selected.lines.length);
|
|
const excerpt = selected.lines.slice(0, appliedLimit);
|
|
const redacted = redactPatchLines(excerpt);
|
|
const nextHunk = options.hunk === undefined && hunks.length > 0
|
|
? `bun scripts/cli.ts gh pr diff ${number} --repo ${repo} --file ${shellQuote(file.filename)} --hunk 1`
|
|
: options.hunk !== undefined && options.hunk < hunks.length
|
|
? `bun scripts/cli.ts gh pr diff ${number} --repo ${repo} --file ${shellQuote(file.filename)} --hunk ${options.hunk + 1}`
|
|
: null;
|
|
return {
|
|
ok: true,
|
|
command: "pr diff",
|
|
repo,
|
|
readOnly: true,
|
|
rawDiffIncluded: includeFullPatch,
|
|
pullRequest: prCompactSummary(fetched.pr),
|
|
file: {
|
|
...prFileSummary(file),
|
|
patchAvailable: file.patch !== undefined,
|
|
patchLines: patchLines.length,
|
|
hunks: hunks.length,
|
|
drillDown: reviewPlanFile(file, 1, repo, number).drillDown,
|
|
},
|
|
patch: {
|
|
file: file.filename,
|
|
hunk: selected.hunk === null ? null : {
|
|
index: selected.hunk.index,
|
|
header: selected.hunk.header,
|
|
additions: selected.hunk.additions,
|
|
deletions: selected.hunk.deletions,
|
|
lines: selected.hunk.lines.length,
|
|
},
|
|
lines: redacted.lines,
|
|
linesTotal: selected.lines.length,
|
|
linesShown: redacted.lines.length,
|
|
truncated: redacted.lines.length < selected.lines.length,
|
|
limit: includeFullPatch ? null : options.limit,
|
|
redactions: redacted.redactions,
|
|
fullPatchIncluded: includeFullPatch,
|
|
...(includeFullPatch ? { fullPatch: patch } : {}),
|
|
},
|
|
next: {
|
|
reviewPlan: `bun scripts/cli.ts gh pr review-plan ${number} --repo ${repo}`,
|
|
fullFilePatch: `bun scripts/cli.ts gh pr diff ${number} --repo ${repo} --file ${shellQuote(file.filename)} --full`,
|
|
nextHunk,
|
|
},
|
|
request: {
|
|
method: "GET",
|
|
paths: githubPrFilePaths(repo, number),
|
|
},
|
|
};
|
|
}
|
|
|
|
function reviewPlanFile(file: GitHubPullRequestFile, index: number, repo: string, number: number): Record<string, unknown> {
|
|
const patch = file.patch ?? "";
|
|
const patchLines = splitPatchLines(patch);
|
|
const hunks = parsePatchHunks(patch);
|
|
const firstHunk = hunks[0];
|
|
return {
|
|
index,
|
|
...prFileSummary(file),
|
|
patch: {
|
|
available: file.patch !== undefined,
|
|
lines: patchLines.length,
|
|
hunks: hunks.length,
|
|
defaultLineLimit: DEFAULT_PR_PATCH_LINE_LIMIT,
|
|
defaultTruncated: patchLines.length > DEFAULT_PR_PATCH_LINE_LIMIT,
|
|
omittedReason: file.patch === undefined ? "github-rest-patch-unavailable-binary-or-large-file" : null,
|
|
},
|
|
drillDown: {
|
|
patch: drillDownCommand(repo, number, file.filename),
|
|
firstHunk: firstHunk === undefined ? null : `${drillDownCommand(repo, number, file.filename)} --hunk ${firstHunk.index}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
async function fetchPrFiles(repo: string, token: string, number: number, limit: number, commandName: "pr review-plan" | "pr diff"): Promise<PrFilesFetch | GitHubCommandResult> {
|
|
const { owner, name } = repoParts(repo);
|
|
const boundedLimit = Math.max(1, Math.min(limit, MAX_PR_FILES_LIMIT));
|
|
const pr = await githubRequest<GitHubPullRequest>(token, "GET", `/repos/${owner}/${name}/pulls/${number}`);
|
|
if (isGitHubError(pr)) return commandError(commandName, repo, pr, { number });
|
|
const files: GitHubPullRequestFile[] = [];
|
|
let page = 1;
|
|
while (files.length < boundedLimit) {
|
|
const remaining = boundedLimit - files.length;
|
|
const pageSize = Math.min(100, remaining);
|
|
const pageFiles = await githubRequest<GitHubPullRequestFile[]>(token, "GET", `/repos/${owner}/${name}/pulls/${number}/files?per_page=${pageSize}&page=${page}`);
|
|
if (isGitHubError(pageFiles)) return commandError(commandName, repo, pageFiles, { number, phase: "fetch-pr-files", filesReturned: files.length });
|
|
files.push(...pageFiles);
|
|
if (pageFiles.length < pageSize || pageFiles.length === 0) break;
|
|
page += 1;
|
|
}
|
|
const totalFiles = numberOrNull(pr.changed_files);
|
|
return {
|
|
pr,
|
|
files,
|
|
totalFiles,
|
|
truncated: totalFiles !== null ? files.length < totalFiles : files.length >= boundedLimit,
|
|
limit: boundedLimit,
|
|
};
|
|
}
|
|
|
|
function selectPatchLines(repo: string, hunks: PatchHunk[], patchLines: string[], hunkIndex: number | undefined): PatchLineSelection | GitHubCommandResult {
|
|
if (hunkIndex === undefined) return { hunk: null, lines: patchLines };
|
|
const hunk = hunks[hunkIndex - 1];
|
|
if (hunk === undefined) {
|
|
return validationError("pr diff", repo, `--hunk ${hunkIndex} is outside the available hunk range`, {
|
|
requestedHunk: hunkIndex,
|
|
availableHunks: hunks.length,
|
|
});
|
|
}
|
|
return { hunk, lines: hunk.lines };
|
|
}
|
|
|
|
function parsePatchHunks(patch: string): PatchHunk[] {
|
|
const lines = splitPatchLines(patch);
|
|
if (lines.length === 0) return [];
|
|
const hunks: PatchHunk[] = [];
|
|
let current: PatchHunk | null = null;
|
|
for (const line of lines) {
|
|
if (line.startsWith("@@")) {
|
|
if (current !== null) hunks.push(current);
|
|
current = { index: hunks.length + 1, header: line, lines: [line], additions: 0, deletions: 0 };
|
|
continue;
|
|
}
|
|
if (current === null) current = { index: 1, header: "(patch)", lines: [], additions: 0, deletions: 0 };
|
|
current.lines.push(line);
|
|
if (line.startsWith("+") && !line.startsWith("+++")) current.additions += 1;
|
|
if (line.startsWith("-") && !line.startsWith("---")) current.deletions += 1;
|
|
}
|
|
if (current !== null) hunks.push(current);
|
|
return hunks;
|
|
}
|
|
|
|
function splitPatchLines(patch: string): string[] {
|
|
if (patch.length === 0) return [];
|
|
return patch.split(/\r?\n/u);
|
|
}
|
|
|
|
function redactPatchLines(lines: string[]): RedactedPatchLines {
|
|
let redactions = 0;
|
|
const redacted = lines.map((line) => {
|
|
if (!looksSecretLike(line)) return line;
|
|
redactions += 1;
|
|
const prefix = line.startsWith("+") || line.startsWith("-") || line.startsWith(" ") ? line[0] : "";
|
|
return `${prefix}<redacted secret-like diff line>`;
|
|
});
|
|
return { lines: redacted, redactions };
|
|
}
|
|
|
|
function looksSecretLike(line: string): boolean {
|
|
if (/ghp_[A-Za-z0-9_]+|github_pat_[A-Za-z0-9_]+|sk-[A-Za-z0-9_-]+|xox[baprs]-[A-Za-z0-9-]+/u.test(line)) return true;
|
|
if (!/(token|secret|api[_-]?key|password|passwd|authorization|bearer|database_url|private[_-]?key)/iu.test(line)) return false;
|
|
return /[:=]|Bearer\s+\S+/iu.test(line);
|
|
}
|
|
|
|
function drillDownCommand(repo: string, number: number, filePath: string): string {
|
|
return `bun scripts/cli.ts gh pr diff ${number} --repo ${repo} --file ${shellQuote(filePath)}`;
|
|
}
|
|
|
|
function shellQuote(value: string): string {
|
|
if (/^[A-Za-z0-9_./:@%+=,-]+$/u.test(value)) return value;
|
|
return "'" + value.replace(/'/gu, "'\\''") + "'";
|
|
}
|
|
|
|
function githubPrFilePaths(repo: string, number: number): string[] {
|
|
const { owner, name } = repoParts(repo);
|
|
return [
|
|
`/repos/${owner}/${name}/pulls/${number}`,
|
|
`/repos/${owner}/${name}/pulls/${number}/files`,
|
|
];
|
|
}
|
|
|
|
function isGitHubCommandResult(value: unknown): value is GitHubCommandResult {
|
|
return typeof value === "object" && value !== null && "ok" in value && "command" in value;
|
|
}
|