/** Runs the OpenCodeReview CLI and parses its JSON output. */ import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; export class OcrError extends Error { constructor(message, { exitCode, stderr, stdout } = {}) { super(message); this.name = "OcrError"; this.exitCode = exitCode; this.stderr = stderr; this.stdout = stdout; } } export const CATEGORY_VALUES = [ "bug", "security", "performance", "maintainability", "test", "style", "documentation", "other", ]; export const SEVERITY_RANK = { critical: 4, high: 3, medium: 2, low: 1 }; const MAX_CAPTURE = 2 * 1024 * 1024; function run(command, args, { cwd, env, timeoutMs, onOutput } = {}) { return new Promise((resolve) => { const child = spawn(command, args, { cwd, env, windowsHide: true }); let stdout = ""; let stderr = ""; let settled = false; const finish = (result) => { if (settled) return; settled = true; clearTimeout(timer); resolve(result); }; const timer = timeoutMs ? setTimeout(() => { try { child.kill("SIGKILL"); } catch { /* already gone */ } finish({ code: 124, stdout, stderr: `${stderr}\n[timeout after ${timeoutMs} ms]` }); }, timeoutMs) : null; child.stdout.on("data", (chunk) => { const text = chunk.toString(); if (stdout.length < MAX_CAPTURE) stdout += text; onOutput?.("stdout", text); }); child.stderr.on("data", (chunk) => { const text = chunk.toString(); if (stderr.length < MAX_CAPTURE) stderr += text; onOutput?.("stderr", text); }); child.on("error", (err) => { finish({ code: 127, stdout, stderr: `${stderr}\n${err.message}` }); }); child.on("close", (code) => finish({ code, stdout, stderr })); }); } function git(args, { cwd, timeoutMs = 300000 } = {}) { return run("git", args, { cwd, timeoutMs }); } export class OcrRunner { constructor({ command = "ocr", workspaceDir, llm = {}, timeoutMs = 45 * 60 * 1000, gitTimeoutMs = 10 * 60 * 1000, logger = () => {}, } = {}) { this.command = command; this.workspaceDir = workspaceDir; this.llm = llm; this.timeoutMs = timeoutMs; this.gitTimeoutMs = gitTimeoutMs; this.logger = logger; } ocrEnv(extra = {}) { const env = { ...process.env, ...extra }; if (this.llm.url) env.OCR_LLM_URL = this.llm.url; if (this.llm.token) env.OCR_LLM_TOKEN = this.llm.token; if (this.llm.model) env.OCR_LLM_MODEL = this.llm.model; if (this.llm.protocol) env.OCR_LLM_PROTOCOL = this.llm.protocol; if (this.llm.authHeader) env.OCR_LLM_AUTH_HEADER = this.llm.authHeader; if (this.llm.extraHeaders) env.OCR_LLM_EXTRA_HEADERS = this.llm.extraHeaders; if (this.llm.timeoutSeconds) env.OCR_LLM_TIMEOUT = String(this.llm.timeoutSeconds); env.OCR_ENABLE_TELEMETRY = "0"; return env; } /** Verify the OCR binary and the configured LLM endpoint. */ async selfTest() { const version = await run(this.command, ["version"], { env: this.ocrEnv(), timeoutMs: 60000, }); if (version.code !== 0) { throw new OcrError(`cannot run '${this.command} version'`, { exitCode: version.code, stderr: version.stderr, stdout: version.stdout, }); } const test = await run(this.command, ["llm", "test"], { env: this.ocrEnv(), timeoutMs: 120000, }); return { version: version.stdout.trim(), llmOk: test.code === 0, llmOutput: `${test.stdout}\n${test.stderr}`.trim(), }; } async gitClone({ cloneUrl, token, dir }) { // No partial clone: a blob:none clone defers blob downloads to later // `git show` / `git grep` calls, and those lazily-fetched requests do not // inherit the per-command extraHeader, so private repositories fail with // "could not read Username". A full clone keeps every read local. const args = ["clone", "--no-tags"]; const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; if (token) { // Written into .git/config by `git clone`, so every later fetch/rebase in // this workspace authenticates without extra plumbing. env.GIT_CONFIG_COUNT = "1"; env.GIT_CONFIG_KEY_0 = "http.extraHeader"; env.GIT_CONFIG_VALUE_0 = `Authorization: token ${token}`; } args.push(cloneUrl, dir); const res = await run("git", args, { env, timeoutMs: this.gitTimeoutMs }); if (res.code !== 0) { throw new OcrError(`git clone failed for ${cloneUrl}`, { exitCode: res.code, stderr: res.stderr, stdout: res.stdout, }); } // Persist the auth header for this workspace so later fetches keep working // even if the caller forgets to pass a token. if (token) { const cfg = await run("git", ["config", "--local", "http.extraHeader", `Authorization: token ${token}`], { cwd: dir, timeoutMs: 60000, }); if (cfg.code !== 0) { throw new OcrError("failed to persist repository credentials", { exitCode: cfg.code, stderr: cfg.stderr, stdout: cfg.stdout, }); } } return dir; } async fetch(dir, { refs = [], token } = {}) { // GIT_TERMINAL_PROMPT=0 keeps a missing credential a hard error instead of // a hanging prompt inside the container. const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" }; if (token) { env.GIT_CONFIG_COUNT = "1"; env.GIT_CONFIG_KEY_0 = "http.extraHeader"; env.GIT_CONFIG_VALUE_0 = `Authorization: token ${token}`; } const args = ["fetch", "--prune", "--no-tags", "origin"]; for (const ref of refs) args.push(ref); const res = await run("git", args, { cwd: dir, env, timeoutMs: this.gitTimeoutMs }); if (res.code !== 0) { throw new OcrError(`git fetch failed in ${dir}`, { exitCode: res.code, stderr: res.stderr, stdout: res.stdout, }); } return res; } async revParse(dir, ref) { const res = await git(["rev-parse", "--verify", `${ref}^{commit}`], { cwd: dir, timeoutMs: 60000 }); if (res.code !== 0) return null; return res.stdout.trim().split("\n")[0]; } async mergeBase(dir, a, b) { const res = await git(["merge-base", a, b], { cwd: dir, timeoutMs: 120000 }); if (res.code !== 0) return null; return res.stdout.trim().split("\n")[0]; } /** Parent commit count for a commit; >1 means it is a merge commit. */ async parentCount(dir, sha) { const res = await git(["rev-list", "--parents", "-n", "1", sha], { cwd: dir, timeoutMs: 60000 }); if (res.code !== 0) return 0; const parts = res.stdout.trim().split(/\s+/).filter(Boolean); return Math.max(0, parts.length - 1); } async changedFiles(dir, fromSha, toSha) { const res = await git( ["diff", "--name-only", "--diff-filter=ACMRTUXB", fromSha, toSha], { cwd: dir, timeoutMs: this.gitTimeoutMs }, ); if (res.code !== 0) return []; return res.stdout.split("\n").map((s) => s.trim()).filter(Boolean); } /** * Run a diff review. * @returns {{ comments: object[], summary: object|null, raw: object, stderr: string }} */ async review({ dir, fromSha, toSha, excludes = [], background, concurrency = 4, maxComments = 30, maxTokensBudget = 0, rulePath, onOutput, }) { const outFile = join(await mkdtemp(join(tmpdir(), "ocr-out-")), "result.json"); const args = [ "review", "--repo", dir, "--from", fromSha, "--to", toSha, "--format", "json", "--audience", "agent", "--concurrency", String(concurrency), "--output", outFile, ]; if (excludes.length) args.push("--exclude", excludes.join(",")); if (background) args.push("--background", background); if (rulePath && existsSync(rulePath)) args.push("--rule", rulePath); if (maxTokensBudget > 0) args.push("--max-tokens-budget", String(maxTokensBudget)); this.logger(`ocr review --from ${fromSha} --to ${toSha} (cwd=${dir})`); const res = await run(this.command, args, { cwd: dir, env: this.ocrEnv(), timeoutMs: this.timeoutMs, onOutput, }); let raw = null; try { const { readFile } = await import("node:fs/promises"); raw = JSON.parse(await readFile(outFile, "utf8")); } catch (err) { if (res.code !== 0) { throw new OcrError(`ocr review failed (exit ${res.code})`, { exitCode: res.code, stderr: res.stderr, stdout: res.stdout, }); } throw new OcrError(`cannot parse ocr JSON output: ${err.message}`, { exitCode: res.code, stderr: res.stderr, stdout: res.stdout, }); } finally { await rm(outFile, { force: true }).catch(() => {}); } const comments = Array.isArray(raw?.comments) ? raw.comments : []; const selected = comments .filter((c) => c && typeof c.path === "string" && c.path.length > 0) .slice(0, Math.max(1, maxComments)); return { comments: selected, totalComments: comments.length, summary: raw?.summary ?? null, status: raw?.status ?? null, projectSummary: raw?.project_summary ?? "", warnings: raw?.warnings ?? [], raw, stderr: res.stderr, exitCode: res.code, }; } async preview({ dir, fromSha, toSha, excludes = [], onOutput }) { const args = [ "review", "--repo", dir, "--from", fromSha, "--to", toSha, "--format", "json", "--audience", "agent", "--preview", ]; if (excludes.length) args.push("--exclude", excludes.join(",")); const res = await run(this.command, args, { cwd: dir, env: this.ocrEnv(), timeoutMs: 300000, onOutput, }); if (res.code !== 0) { throw new OcrError(`ocr review --preview failed (exit ${res.code})`, { exitCode: res.code, stderr: res.stderr, stdout: res.stdout, }); } return JSON.parse(res.stdout); } } export function severityAtLeast(severity, threshold) { const a = SEVERITY_RANK[String(severity || "").toLowerCase()] ?? 0; const b = SEVERITY_RANK[String(threshold || "").toLowerCase()] ?? 0; return a > 0 && b > 0 && a >= b; } export function parseList(value) { if (!value) return []; return String(value).split(",").map((s) => s.trim()).filter(Boolean); }