直接推送也能晋级 往受保护分支直接推代码(没有 PR)时,审查通过后自动开一个 <分支> → <管理分支> 的 PR 并合并它,而不是放弃自动合并。 已存在同类 open PR 时复用它,不重复创建。 晋级 PR 不再被重复审查 自动创建的 PR 带 PROMOTION_MARKER,其 pull_request 事件直接跳过。 同一批提交在 push 时已经审过,重复审会在合并完成后凭空造出 Issue (此前确实产生了这样一个幽灵 Issue)。 合并即关闭该仓库全部审查 Issue 两条触发路径: - 服务自己完成合并后立即关闭 - 人在 Gitea 手动合并时,pull_request closed + merged=true 事件触发关闭 手动合并是「代码已落地」最可靠的信号,不再依赖 push 侧的 merge 提交探测。 晋级使用 merge 而非 squash squash 会把晋级提交重写成全新提交,两个长期分支每晋级一次就多分叉 一点,最终必然冲突(已在 offerpai_h5 上复现 add/add 冲突)。 merge 保留共同祖先,下次晋级只携带新提交。 合并可等待性 Gitea 异步计算 mergeable,原先只轮询 5 秒就放弃。改为指数退避约 30 秒, 并区分「尚未算出」(继续等)与「确实冲突」(放弃)。失败时往 PR 留评论 说明原因,不再只写服务日志。 验证(真实 webhook): 推送 test → 审查通过 → 自动开 PR #9 (test→prd) → merge 合并 → pull_request merged 事件 → 关闭 0 个待处理 Issue prd 顶端前进为合并提交,test/prd 保持一致,无幽灵 Issue 产生
293 lines
10 KiB
JavaScript
293 lines
10 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];
|
|
}
|
|
|
|
/** 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);
|
|
} |