Files
git-codereview/app/lib/ocr.js
T
kgod 6dc77ce907 feat: 仓库导入、分支模型重构与审查历史摘要
仓库接入
- 新增「从 Gitea 导入」:用全局 Token 列出可见仓库,一键建配置
- 新增仓库只填 Git 地址,自动解析 owner/name,支持 https/ssh/scp 写法
- 新增「测试连接」按钮,保存前即可校验地址并拉取分支

分支模型
- 由单一 base_branch + glob 改为「一个管理分支 + 多个检查分支」
- 管理分支是唯一合并目标;检查分支全部纳入监控
- 支持从任意分支克隆创建管理分支
- 审查基准改为管理分支的 merge-base;仅目标为管理分支的 PR 才自动合并
- 旧库自动迁移:base_branch 播种 managed_branch,branch_patterns 展开为检查分支

审查历史
- 新增 review_records 表与「审查历史」页
- 记录触发来源、PR 链接、审查范围、阻断阈值、发布方式、自动合并设置、
  排除路径、LLM 模型、token 消耗、耗时、需求背景与全部审查意见
- 详情弹窗一览,支持按仓库过滤

AI 摘要
- 新增独立摘要模块(app/lib/summary.js),与代码审查提示词分离
- 专用提示词输出固定四节、500 字内的中文记录:结论/范围/问题/要点
- 与代码审查共用全局 LLM 设置;摘要失败不影响审查与合并,可单条重跑

修复
- 摘要改用内置 fetch:运行镜像没有 curl,原先 spawn curl 必然 ENOENT
- 去掉 blob:none 部分克隆并把凭据写入 .git/config:
  惰性取 blob 不会带上 per-command extraHeader,私有库会报 could not read Username

UI
- 审查背景改为多行文本域(可滚动)
- 分支改为可点选列表,管理分支高亮
- 仓库表格展示管理分支与检查分支
2026-09-20 12:38:23 +08:00

285 lines
9.8 KiB
JavaScript

/** 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];
}
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);
}