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 { 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 : ""), 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 { 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 { 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 { const { owner, name } = repoParts(repo); const boundedLimit = Math.max(1, Math.min(limit, MAX_PR_FILES_LIMIT)); const pr = await githubRequest(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(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}`; }); 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; }