Files
git-codereview/app/lib/review.js
T
kgod ed172bf369 feat: Gitea 自动代码审查服务
基于 OpenCodeReview 的 webhook 服务:监听 Gitea 的 push 与 Pull Request 事件,
调用 OCR 审查 diff,把结果发布回 Gitea,并按阻断阈值决定是否自动合并。

主要能力:
- Push / PR 事件触发,支持分支 glob 过滤与 PR-only / push-only 范围
- PR 内联评论(按 diff 行号定位)、汇总评论、Issue 生命周期、提交状态
- 可配置阻断阈值(严重级别 / 类别 / 任意意见)
- 无阻断问题时自动合并,审查覆盖不完整时拒绝合并
- 内置 Web 后台:仓库配置、任务日志、失败重跑、连通性自检
- SQLite 持久化,worker 重启回收卡死任务,失败自动重试

实现为独立服务而非 Gitea Action:本机 act_runner 指向的实例不可达,
且后台配置与任务历史需要独立进程承载。
2026-09-20 12:09:46 +08:00

641 lines
24 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Core review pipeline: fetch -> diff -> OCR -> publish -> gate -> merge.
*/
import { mkdir, rm, writeFile } from "node:fs/promises";
import { existsSync } from "node:fs";
import { join } from "node:path";
import { GiteaClient, webBaseUrl, normalizeBaseUrl } from "./gitea.js";
import { OcrRunner, parseList, severityAtLeast } from "./ocr.js";
import { parseUnifiedDiff, pickAnchorLine } from "./diff.js";
import { getBranchState, setBranchState, updateJob } from "./db.js";
export const SUMMARY_MARKER = "<!-- gitea-codereview:summary -->";
export const COMMENT_MARKER = "<!-- gitea-codereview -->";
export const STATUS_CONTEXT = "code-review/ocr";
export class SkipJob extends Error {
constructor(reason) {
super(reason);
this.name = "SkipJob";
this.reason = reason;
}
}
function repoSlug(repo) {
return `${repo.owner}/${repo.name}`;
}
function globToRegExp(pattern) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
const body = escaped
.replace(/\*\*/g, "\u0000")
.replace(/\*/g, "[^/]*")
.replace(/\?/g, ".")
.replace(/\u0000/g, ".*");
return new RegExp(`^${body}$`);
}
export function branchMatches(refName, patterns) {
const list = parseList(patterns);
if (list.length === 0 || list.includes("*")) return true;
const short = refName.replace(/^refs\/heads\//, "");
return list.some((p) => globToRegExp(p).test(short) || globToRegExp(p).test(refName));
}
function badge(comment) {
const parts = [comment.category, comment.severity].filter(Boolean);
return parts.length ? `[${parts.join(" · ")}] ` : "";
}
function renderCommentBody(comment) {
const lines = [`${badge(comment)}${String(comment.content || "").trim()}`];
if (comment.suggestion_code) {
lines.push("", "```suggestion", String(comment.suggestion_code).replace(/\n+$/, ""), "```");
}
lines.push("", COMMENT_MARKER);
return lines.join("\n");
}
function renderSummaryBody({ repo, job, review, published, failed, blocking, note }) {
const lines = [SUMMARY_MARKER, "## OCR 代码审查", ""];
lines.push(`- 仓库:\`${repoSlug(repo)}\``);
lines.push(`- 分支:\`${job.ref_name}\``);
if (job.pr_number) lines.push(`- Pull Request:#${job.pr_number}`);
lines.push(`- 提交:\`${String(job.to_sha).slice(0, 10)}\``);
if (review.summary) {
const s = review.summary;
lines.push(
`- 审查:${s.files_reviewed ?? "?"} 个文件 / ${s.comments ?? 0} 条意见` +
`${s.total_tokens ? ` / ${s.total_tokens} tokens` : ""}` +
`${s.elapsed ? ` / ${s.elapsed}` : ""}`,
);
}
if (note) lines.push("", note);
lines.push("");
if (published.length === 0) {
lines.push("未发现需要处理的问题。");
} else {
lines.push(`### 审查意见(${published.length} 条)`, "");
const grouped = new Map();
for (const item of published) {
const key = item.comment.path;
if (!grouped.has(key)) grouped.set(key, []);
grouped.get(key).push(item);
}
for (const [path, items] of grouped) {
lines.push(`**\`${path}\`**`, "");
for (const item of items) {
const where = item.comment.start_line
? `L${item.comment.start_line}${item.comment.end_line && item.comment.end_line !== item.comment.start_line ? `-${item.comment.end_line}` : ""}`
: "位置未知";
const flag = item.inline ? "" : "(无法内联定位)";
lines.push(`- ${badge(item.comment)}${where}${flag} — ${String(item.comment.content || "").replace(/\s*\n\s*/g, " ").trim()}`);
}
lines.push("");
}
}
if (failed.length > 0) {
lines.push(`### 发布失败(${failed.length} 条)`, "");
for (const item of failed) {
lines.push(`- \`${item.comment.path}\` — ${item.error}`);
}
lines.push("");
}
if (blocking.length > 0) {
lines.push(
"### 结论",
"",
`存在 ${blocking.length} 条达到阻断阈值的问题,已创建/更新 Issue,且不会自动合并。`,
);
} else if (published.length > 0) {
lines.push("### 结论", "", "未发现达到阻断阈值的问题。");
}
lines.push(
"",
`<sub>由 gitea-codereview 基于 [OpenCodeReview](https://github.com/alibaba/open-code-review) 生成 · job #${job.id}</sub>`,
);
return lines.join("\n");
}
function renderIssueBody({ repo, job, blocking, summaryUrl }) {
const lines = [
SUMMARY_MARKER,
`自动代码审查在 \`${job.ref_name}\` @ \`${String(job.to_sha).slice(0, 10)}\` 上发现阻断级问题。`,
"",
`- 仓库:\`${repoSlug(repo)}\``,
`- 分支:\`${job.ref_name}\``,
`- 提交:\`${job.to_sha}\``,
];
if (job.pr_number) lines.push(`- Pull Request:#${job.pr_number}`);
if (summaryUrl) lines.push(`- 审查详情:${summaryUrl}`);
lines.push("", `### 阻断问题(${blocking.length} 条)`, "");
for (const item of blocking) {
const c = item.comment;
const where = c.start_line ? `${c.path}:${c.start_line}` : c.path;
lines.push(`- ${badge(c)}\`${where}\` — ${String(c.content || "").replace(/\s*\n\s*/g, " ").trim()}`);
}
lines.push("", `<sub>job #${job.id} · 由 gitea-codereview 生成</sub>`);
return lines.join("\n");
}
/** Resolve the fetch/review range for a job against the workspace clone. */
async function resolveRange(runner, { repo, job, workspace, token }) {
const headRef = `refs/heads/${job.ref_name}`;
const refs = [headRef, `+${headRef}:refs/remotes/origin/${job.ref_name}`];
if (job.base_ref) refs.push(`+refs/heads/${job.base_ref}:refs/remotes/origin/${job.base_ref}`);
if (job.pr_number) refs.push(`+refs/pull/${job.pr_number}/head:refs/remotes/origin/pr/${job.pr_number}`);
await runner.fetch(workspace, { refs, token });
const toSha = job.to_sha;
const local = await runner.revParse(workspace, toSha);
if (!local) {
throw new Error(`commit ${toSha} not found in workspace after fetch`);
}
let fromSha = null;
if (job.from_sha) {
fromSha = await runner.revParse(workspace, job.from_sha);
}
if (!fromSha && job.base_ref) {
const baseSha = await runner.revParse(workspace, `refs/remotes/origin/${job.base_ref}`);
if (baseSha) fromSha = await runner.mergeBase(workspace, baseSha, toSha) ?? baseSha;
}
if (!fromSha) {
const parent = await runner.revParse(workspace, `${toSha}^`);
if (parent) fromSha = parent;
}
if (!fromSha) {
throw new SkipJob(`no base commit available for ${toSha.slice(0, 10)} (initial commit)`);
}
if (fromSha === toSha) {
throw new SkipJob("base and head resolve to the same commit (empty change set)");
}
return { fromSha, toSha };
}
export class ReviewEngine {
constructor({ db, config, logger = console }) {
this.db = db;
this.config = config;
this.logger = logger;
this.log = (msg) => logger.info?.(msg) ?? console.log(msg);
}
globalLlm() {
return {
url: this.config.llmUrl,
token: this.config.llmToken,
model: this.config.llmModel,
protocol: this.config.llmProtocol,
authHeader: this.config.llmAuthHeader,
extraHeaders: this.config.llmExtraHeaders,
timeoutSeconds: this.config.llmTimeoutSeconds,
};
}
repoLlm(repo) {
const base = this.globalLlm();
return {
...base,
url: repo.llm_base_url || base.url,
token: repo.llm_token || base.token,
model: repo.llm_model || base.model,
protocol: repo.llm_provider || base.protocol,
};
}
clientFor(repo) {
return new GiteaClient({
baseUrl: normalizeBaseUrl(this.config.giteaUrl),
token: repo.gitea_token || this.config.giteaToken,
timeoutMs: this.config.httpTimeoutMs,
});
}
async ensureWorkspace(repo) {
const root = join(this.config.dataDir, "workspaces");
await mkdir(root, { recursive: true });
const dir = join(root, `${repo.owner}__${repo.name}`);
if (existsSync(join(dir, ".git"))) return dir;
await rm(dir, { recursive: true, force: true });
const cloneUrl = `${normalizeBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}.git`;
const runner = new OcrRunner({ command: this.config.ocrCommand, logger: this.log });
await runner.gitClone({ cloneUrl, token: repo.gitea_token || this.config.giteaToken, dir });
return dir;
}
async execute(job) {
const repo = this.db.prepare("SELECT * FROM repositories WHERE id = ?").get(job.repo_id);
if (!repo) throw new Error(`repository ${job.repo_id} no longer exists`);
if (!repo.enabled) throw new SkipJob("repository disabled");
const client = this.clientFor(repo);
const runner = new OcrRunner({
command: this.config.ocrCommand,
llm: this.repoLlm(repo),
timeoutMs: this.config.reviewTimeoutMs,
logger: this.log,
});
const logs = [];
const appendLog = (line) => {
logs.push(line);
if (logs.length > 400) logs.shift();
};
const setPhase = (phase, patch = {}) => {
updateJob(this.db, job.id, { phase, log: logs.join("\n"), ...patch });
};
setPhase("preparing");
const workspace = await this.ensureWorkspace(repo);
const { fromSha, toSha } = await resolveRange(runner, {
repo, job, workspace, token: repo.gitea_token || this.config.giteaToken,
});
appendLog(`range ${fromSha}..${toSha}`);
const excludes = parseList(repo.excludes);
setPhase("reviewing");
const review = await runner.review({
dir: workspace,
fromSha,
toSha,
excludes,
background: (repo.background_template || "").trim() || undefined,
concurrency: repo.concurrency || this.config.defaultConcurrency,
maxComments: repo.max_comments || 30,
maxTokensBudget: this.config.maxTokensBudget || 0,
rulePath: repo.rule_path || this.config.rulePath || undefined,
onOutput: (stream, text) => {
const trimmed = text.trim();
if (trimmed) appendLog(`${stream === "stderr" ? "[stderr] " : ""}${trimmed}`);
},
});
updateJob(this.db, job.id, { log: logs.join("\n") });
// Build the diff map so findings can be anchored to real diff lines.
let diffText = "";
try {
diffText = await this.gitDiff(workspace, fromSha, toSha);
} catch (err) {
appendLog(`diff fetch failed: ${err.message}`);
}
const diffFiles = parseUnifiedDiff(diffText);
const published = [];
const failed = [];
for (const comment of review.comments) {
const entry = diffFiles.get(comment.path);
if (!entry) {
failed.push({ comment, error: "文件不在本次 diff 中,已跳过" });
continue;
}
const anchor = pickAnchorLine(entry, comment.start_line, comment.end_line);
published.push({ comment, anchor, inline: anchor.inDiff });
}
// A finding blocks auto-merge (and turns the commit status red) when it
// meets the severity threshold, matches a blocked category, or when the
// repository opts into failing on any finding at all.
const blockSeverities = parseList(repo.block_severity);
const blockCategories = parseList(repo.block_categories);
const failOnFindings = Boolean(repo.fail_on_findings);
const blocking = published.filter(({ comment }) => {
if (failOnFindings) return true;
const sevHit = blockSeverities.some((s) => severityAtLeast(comment.severity, s));
const catHit = blockCategories.includes(String(comment.category || "").toLowerCase());
return sevHit || catHit;
});
// A partial or degraded review must never gate a merge: OCR reports
// warnings when whole files could not be reviewed, and merging on an
// incomplete result is exactly the failure mode this service must avoid.
// OCR terminal states: complete | partial | failed | skipped, plus the
// legacy "success" / "completed_with_warnings" spellings.
// "skipped" means the diff contained no reviewable file at all, which is a
// clean outcome rather than partial coverage; "partial" / "failed" mean
// some selected files were never reviewed and must not gate a merge.
const CLEAN_STATUSES = new Set(["complete", "success", "skipped"]);
const reviewIncomplete = Boolean(review.summary?.budget_exceeded)
|| (Array.isArray(review.warnings) && review.warnings.length > 0)
|| !CLEAN_STATUSES.has(review.status ?? "complete");
if (reviewIncomplete) {
appendLog(`review reported incomplete coverage (status=${review.status ?? "?"}, warnings=${review.warnings?.length ?? 0})`);
}
setPhase("publishing");
const prNumber = job.pr_number ?? (await this.findPullRequestForSha(client, repo, toSha, job.ref_name));
if (prNumber && !job.pr_number) {
updateJob(this.db, job.id, { pr_number: prNumber });
job.pr_number = prNumber;
}
let inlinePosted = 0;
let reviewBody = "";
// Publishing to a merged or closed PR would be noise; keep the findings in
// the job record instead.
let prIsOpen = Boolean(prNumber);
if (prIsOpen) {
try {
const pr = await client.getPullRequest(repo.owner, repo.name, prNumber);
prIsOpen = pr?.state === "open" && !pr?.merged;
} catch (err) {
appendLog(`cannot load PR #${prNumber}: ${err.message}`);
prIsOpen = false;
}
}
if (prIsOpen && repo.publish_mode !== "issue-only") {
const inline = published.filter((p) => p.inline);
reviewBody = renderSummaryBody({
repo, job, review, published, failed, blocking,
note: inline.length < published.length
? `${published.length - inline.length} 条意见无法定位到本次 diff 的行,已汇总在本评论中。`
: "",
});
try {
await client.createPullReview(repo.owner, repo.name, prNumber, {
event: "COMMENT",
body: reviewBody,
commitId: toSha,
comments: inline.map((p) => ({
path: p.comment.path,
newPosition: p.anchor.line,
body: renderCommentBody(p.comment),
})),
});
inlinePosted = inline.length;
appendLog(`posted pull review with ${inlinePosted} inline comment(s)`);
} catch (err) {
appendLog(`pull review failed: ${err.message}`);
// Fall back to an issue comment so the findings are not lost.
try {
await client.createIssueComment(repo.owner, repo.name, prNumber, reviewBody);
appendLog("posted summary as issue comment instead");
} catch (fallbackErr) {
appendLog(`issue comment fallback failed: ${fallbackErr.message}`);
}
}
}
// Issue lifecycle: one open issue per repository+ref, updated in place.
// When issue creation is disabled the service still closes any issue it
// previously opened once the ref is clean, so stale issues do not linger.
let issueNumber = null;
const labels = parseList(repo.issue_labels);
const issueTitle = `[OCR] ${repoSlug(repo)} · ${job.ref_name} 存在阻断级代码问题`;
try {
issueNumber = await this.upsertIssue({
client, repo, job, blocking, labels, title: issueTitle,
createEnabled: Boolean(repo.create_issue),
summaryUrl: prNumber
? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}`
: `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/commit/${toSha}`,
body: renderIssueBody({
repo, job, blocking,
summaryUrl: prNumber ? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}` : "",
}),
});
} catch (err) {
appendLog(`issue upsert failed: ${err.message}`);
}
// Commit status so branch protection can require this context.
const state = blocking.length > 0 ? "failure" : "success";
try {
await client.createCommitStatus(repo.owner, repo.name, toSha, {
state,
context: STATUS_CONTEXT,
description: blocking.length > 0
? `${blocking.length} blocking issue(s), ${published.length} total`
: published.length > 0
? `${published.length} comment(s), none blocking`
: "no issues found",
targetUrl: prNumber
? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}`
: "",
});
appendLog(`commit status ${state} (${STATUS_CONTEXT})`);
} catch (err) {
appendLog(`commit status failed: ${err.message}`);
}
setPhase("finalizing");
const merged = await this.maybeAutoMerge({
client, repo, job, prNumber, blocking, toSha, appendLog,
reviewIncomplete, statusState: state,
});
const result = {
fromSha, toSha, prNumber, issueNumber,
comments: published.length,
inlineComments: inlinePosted,
blocking: blocking.length,
failed: failed.length,
merged,
reviewStatus: review.status,
summary: review.summary,
warnings: review.warnings,
};
updateJob(this.db, job.id, {
status: "succeeded",
phase: "done",
findings: published.length,
blocking: blocking.length,
comment_count: inlinePosted,
issue_number: issueNumber,
merged: merged ? 1 : 0,
result_json: JSON.stringify(result),
log: logs.join("\n"),
});
setBranchState(this.db, repo.id, job.ref_name, toSha);
return result;
}
async gitDiff(dir, fromSha, toSha) {
const { spawn } = await import("node:child_process");
return new Promise((resolve, reject) => {
const child = spawn("git", ["diff", "--no-color", "--find-renames", fromSha, toSha], {
cwd: dir, windowsHide: true,
});
let out = "";
let err = "";
child.stdout.on("data", (c) => { out += c.toString(); });
child.stderr.on("data", (c) => { err += c.toString(); });
child.on("error", reject);
child.on("close", (code) => {
if (code === 0) resolve(out);
else reject(new Error(`git diff failed (${code}): ${err.trim()}`));
});
});
}
/**
* Find the OPEN pull request that a push belongs to.
* Merged/closed pull requests are ignored: a push to the base branch after a
* merge must not attach new review comments to the finished PR.
*/
async findPullRequestForSha(client, repo, sha, refName) {
try {
const list = await client.listPullRequests(repo.owner, repo.name, {
state: "open", limit: 50,
});
const match = (list || []).find(
(p) => p.head?.sha === sha || (refName && p.head?.ref === refName),
);
if (match) return match.number;
} catch { /* ignore */ }
return null;
}
async upsertIssue({ client, repo, job, blocking, labels, title, body, createEnabled = true }) {
// Look up any open issue previously opened by this service for this
// repository + ref, so reruns update it instead of stacking new issues.
const openIssues = await client.get(`/api/v1/repos/${repo.owner}/${repo.name}/issues`, {
query: { state: "open", type: "issues", limit: 100 },
}).catch(() => []);
const existing = (openIssues || []).find((i) => i.title === title)
?? (openIssues || []).find(
(i) => String(i.body || "").includes(SUMMARY_MARKER)
&& String(i.title || "").includes(`${repoSlug(repo)} · ${job.ref_name}`),
);
if (blocking.length === 0) {
if (existing) {
await client.createIssueComment(repo.owner, repo.name, existing.number,
`已在 \`${String(job.to_sha).slice(0, 10)}\` 上复查通过,关闭该 Issue。`);
await client.updateIssue(repo.owner, repo.name, existing.number, { state: "closed" });
return existing.number;
}
return null;
}
if (!createEnabled) {
// Issue creation is disabled for this repository; leave any existing
// issue untouched rather than opening a new one.
return existing?.number ?? null;
}
// Gitea's issue API expects label IDs, so resolve names first.
const labelIds = labels.length
? await client.ensureLabels(repo.owner, repo.name, labels)
: [];
if (existing) {
await client.updateIssue(repo.owner, repo.name, existing.number, { body, title });
if (labelIds.length) {
await client.put(`/api/v1/repos/${repo.owner}/${repo.name}/issues/${existing.number}/labels`,
{ labels: labelIds }).catch(() => {});
}
await client.createIssueComment(repo.owner, repo.name, existing.number,
`已用 \`${String(job.to_sha).slice(0, 10)}\` 的最新审查结果更新该 Issue。`);
return existing.number;
}
const created = await client.createIssue(repo.owner, repo.name, {
title, body, labels: labelIds.length ? labelIds : undefined,
});
return created?.number ?? null;
}
async maybeAutoMerge({
client, repo, job, prNumber, blocking, toSha, appendLog,
reviewIncomplete = false, statusState = "success",
}) {
if (!repo.auto_merge) return false;
if (!prNumber) {
appendLog("auto-merge skipped: no pull request for this branch");
return false;
}
if (reviewIncomplete) {
appendLog("auto-merge skipped: review coverage was incomplete");
return false;
}
if (statusState !== "success") {
appendLog(`auto-merge skipped: commit status is ${statusState}`);
return false;
}
if (blocking.length > 0) {
appendLog(`auto-merge skipped: ${blocking.length} blocking finding(s)`);
return false;
}
let pr;
try {
pr = await client.getPullRequest(repo.owner, repo.name, prNumber);
} catch (err) {
appendLog(`auto-merge skipped: cannot load PR: ${err.message}`);
return false;
}
if (!pr || pr.merged) return false;
if (pr.state !== "open") {
appendLog("auto-merge skipped: PR is not open");
return false;
}
if (pr.head?.sha && pr.head.sha !== toSha) {
appendLog(`auto-merge skipped: PR head moved to ${String(pr.head.sha).slice(0, 10)}`);
return false;
}
if (pr.mergeable === false) {
appendLog("auto-merge skipped: PR is not mergeable");
return false;
}
if (repo.auto_merge_mode === "immediate" && pr.mergeable === undefined) {
appendLog("auto-merge skipped: mergeability unknown");
return false;
}
try {
await client.mergePullRequest(repo.owner, repo.name, prNumber, {
style: repo.merge_method || "squash",
title: pr.title,
deleteBranch: Boolean(repo.delete_branch),
headCommitId: toSha,
mergeWhenChecksSucceed: repo.auto_merge_mode === "when_checks_succeed",
});
appendLog("auto-merge requested");
return true;
} catch (err) {
appendLog(`auto-merge failed: ${err.message}`);
return false;
}
}
async failJob(job, err) {
const message = err instanceof SkipJob
? `skipped: ${err.reason}`
: `${err.name || "Error"}: ${err.message}`;
this.log(`job #${job.id} ${message}`);
updateJob(this.db, job.id, {
status: err instanceof SkipJob ? "skipped" : "failed",
phase: err instanceof SkipJob ? "skipped" : "failed",
error: message,
});
if (!(err instanceof SkipJob) && job.pr_number) {
try {
const repo = this.db.prepare("SELECT * FROM repositories WHERE id = ?").get(job.repo_id);
if (repo) {
const client = this.clientFor(repo);
await client.createCommitStatus(repo.owner, repo.name, job.to_sha, {
state: "error",
context: STATUS_CONTEXT,
description: "review failed to run",
});
await client.createIssueComment(repo.owner, repo.name, job.pr_number,
`${SUMMARY_MARKER}\n代码审查执行失败:\n\n\`\`\`\n${err.message}\n\`\`\`\n\n<sub>job #${job.id}</sub>`);
}
} catch (postErr) {
this.log(`failed to report job error: ${postErr.message}`);
}
}
}
}
export async function writeWorkspaceFile(dir, name, content) {
await mkdir(dir, { recursive: true });
await writeFile(join(dir, name), content, "utf8");
}