Files
git-codereview/app/server.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

521 lines
19 KiB
JavaScript

/** gitea-codereview HTTP server: webhooks, REST API, and admin UI. */
import { createServer } from "node:http";
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
import { readFile, stat } from "node:fs/promises";
import { existsSync } from "node:fs";
import { extname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
allSettings, deleteRepository, enqueueJob, findJobBySha, findRepository,
getJob, getSetting, jobStats, listJobs, listRepositories, openDatabase,
recordDelivery, pruneDeliveries, setSetting, upsertRepository, getRepository,
} from "./lib/db.js";
import { GiteaClient } from "./lib/gitea.js";
import { OcrRunner } from "./lib/ocr.js";
import { JobQueue } from "./lib/queue.js";
import { ReviewEngine, SkipJob, branchMatches } from "./lib/review.js";
const APP_DIR = resolve(fileURLToPath(new URL(".", import.meta.url)));
const ROOT_DIR = resolve(APP_DIR, "..");
const STATIC_DIR = join(APP_DIR, "static");
const SECRET_SETTING_KEYS = new Set(["giteaToken", "llmToken", "adminToken", "webhookSecret"]);
function readConfig() {
const dataDir = process.env.CR_DATA_DIR || join(ROOT_DIR, "data");
return {
dataDir,
dbPath: process.env.CR_DB_PATH || join(dataDir, "codereview.db"),
port: Number(process.env.CR_PORT || 8090),
host: process.env.CR_HOST || "0.0.0.0",
ocrCommand: process.env.CR_OCR_COMMAND || "ocr",
reviewTimeoutMs: Number(process.env.CR_REVIEW_TIMEOUT_MS || 45 * 60 * 1000),
httpTimeoutMs: Number(process.env.CR_HTTP_TIMEOUT_MS || 60000),
defaultConcurrency: Number(process.env.CR_CONCURRENCY || 4),
maxTokensBudget: Number(process.env.CR_MAX_TOKENS_BUDGET || 0),
pollMs: Number(process.env.CR_POLL_MS || 3000),
maxAttempts: Number(process.env.CR_MAX_ATTEMPTS || 2),
// Bootstrap defaults; persisted settings win once set through the UI.
giteaUrl: process.env.CR_GITEA_URL || "http://127.0.0.1:80",
giteaToken: process.env.CR_GITEA_TOKEN || "",
webhookSecret: process.env.CR_WEBHOOK_SECRET || "",
adminToken: process.env.CR_ADMIN_TOKEN || "",
llmUrl: process.env.CR_LLM_URL || process.env.OCR_LLM_URL || "",
llmToken: process.env.CR_LLM_TOKEN || process.env.OCR_LLM_TOKEN || "",
llmModel: process.env.CR_LLM_MODEL || process.env.OCR_LLM_MODEL || "",
llmProtocol: process.env.CR_LLM_PROTOCOL || process.env.OCR_LLM_PROTOCOL || "",
llmAuthHeader: process.env.CR_LLM_AUTH_HEADER || "",
llmExtraHeaders: process.env.CR_LLM_EXTRA_HEADERS || "",
llmTimeoutSeconds: Number(process.env.CR_LLM_TIMEOUT || 180),
rulePath: process.env.CR_RULE_PATH || "",
};
}
const CONFIG = readConfig();
const db = openDatabase(CONFIG.dbPath);
const logger = {
info: (m) => console.log(`[${new Date().toISOString()}] ${m}`),
warn: (m) => console.warn(`[${new Date().toISOString()}] WARN ${m}`),
error: (m) => console.error(`[${new Date().toISOString()}] ERROR ${m}`),
};
// Persisted settings override environment bootstrap values.
function effectiveConfig() {
const saved = allSettings(db);
return {
...CONFIG,
giteaUrl: saved.giteaUrl || CONFIG.giteaUrl,
giteaToken: saved.giteaToken || CONFIG.giteaToken,
webhookSecret: saved.webhookSecret ?? CONFIG.webhookSecret,
adminToken: saved.adminToken ?? CONFIG.adminToken,
llmUrl: saved.llmUrl || CONFIG.llmUrl,
llmToken: saved.llmToken || CONFIG.llmToken,
llmModel: saved.llmModel || CONFIG.llmModel,
llmProtocol: saved.llmProtocol || CONFIG.llmProtocol,
llmAuthHeader: saved.llmAuthHeader || CONFIG.llmAuthHeader,
llmExtraHeaders: saved.llmExtraHeaders || CONFIG.llmExtraHeaders,
rulePath: saved.rulePath || CONFIG.rulePath,
};
}
const engine = new ReviewEngine({ db, config: effectiveConfig(), logger });
const queue = new JobQueue({
db, engine, logger,
pollMs: CONFIG.pollMs,
maxAttempts: CONFIG.maxAttempts,
});
// The engine reads config at call time through a getter so UI changes apply
// without a restart.
Object.defineProperty(engine, "config", {
get: effectiveConfig,
configurable: true,
});
/* ---------------------------------- utils --------------------------------- */
function json(res, status, payload) {
const body = JSON.stringify(payload, null, 2);
res.writeHead(status, {
"Content-Type": "application/json; charset=utf-8",
"Content-Length": Buffer.byteLength(body),
"Cache-Control": "no-store",
});
res.end(body);
}
function text(res, status, body, type = "text/plain; charset=utf-8") {
res.writeHead(status, { "Content-Type": type, "Cache-Control": "no-store" });
res.end(body);
}
async function readBody(req, limit = 5 * 1024 * 1024) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > limit) throw new Error("request body too large");
chunks.push(chunk);
}
return Buffer.concat(chunks);
}
function verifySignature(secret, rawBody, signature) {
if (!secret) return true;
if (!signature) return false;
const expected = createHmac("sha256", secret).update(rawBody).digest("hex");
const a = Buffer.from(expected, "utf8");
const b = Buffer.from(String(signature).trim(), "utf8");
return a.length === b.length && timingSafeEqual(a, b);
}
function authorized(req, cfg) {
if (!cfg.adminToken) return true;
const header = req.headers.authorization || "";
const token = header.startsWith("Bearer ") ? header.slice(7).trim() : "";
if (!token) return false;
const a = Buffer.from(token);
const b = Buffer.from(cfg.adminToken);
return a.length === b.length && timingSafeEqual(a, b);
}
/* -------------------------------- webhooks -------------------------------- */
function refNameFromPayload(payload) {
const ref = payload.ref || "";
return ref.replace(/^refs\/heads\//, "");
}
async function handlePush(payload, cfg) {
const owner = payload.repository?.owner?.username || payload.repository?.owner?.login;
const name = payload.repository?.name;
if (!owner || !name) return { queued: 0 };
const repo = findRepository(db, owner, name);
if (!repo || !repo.enabled) return { queued: 0, reason: "repository not configured" };
const refName = refNameFromPayload(payload);
if (!refName || payload.deleted) return { queued: 0, reason: "branch deletion or empty ref" };
if (!branchMatches(refName, repo.branch_patterns)) {
return { queued: 0, reason: `branch ${refName} does not match patterns` };
}
const toSha = payload.after || payload.head_commit?.id;
if (!toSha) return { queued: 0, reason: "no head commit in payload" };
const scoped = repo.review_scope === "pr";
const pr = scoped ? null : await findOpenPullRequestForRef(cfg, repo, refName, toSha);
if (scoped && !pr) {
return { queued: 0, reason: "review_scope=pr and no open pull request" };
}
if (findJobBySha(db, repo.id, toSha)) {
return { queued: 0, reason: `commit ${toSha.slice(0, 10)} already queued or running` };
}
const before = payload.before && !/^0+$/.test(payload.before) ? payload.before : null;
const jobId = enqueueJob(db, {
repoId: repo.id,
trigger: "push",
refName,
baseRef: pr?.base?.ref ?? repo.base_branch,
fromSha: before,
toSha,
prNumber: pr?.number ?? null,
});
logger.info(`queued job #${jobId} for ${owner}/${name} ${refName}@${toSha.slice(0, 10)}`);
return { queued: 1, jobId };
}
async function handlePullRequest(payload, cfg) {
const action = payload.action;
if (!["opened", "synchronize", "reopened", "ready_for_review"].includes(action)) {
return { queued: 0, reason: `action ${action} ignored` };
}
const owner = payload.repository?.owner?.username || payload.repository?.owner?.login;
const name = payload.repository?.name;
const repo = owner && name ? findRepository(db, owner, name) : null;
if (!repo || !repo.enabled) return { queued: 0, reason: "repository not configured" };
if (repo.review_scope === "push") {
return { queued: 0, reason: "review_scope=push; PRs reviewed via push events" };
}
const pr = payload.pull_request;
if (!pr) return { queued: 0, reason: "no pull_request in payload" };
if (pr.draft) return { queued: 0, reason: "draft pull request" };
if (!branchMatches(pr.head?.ref, repo.branch_patterns)) {
return { queued: 0, reason: `head branch ${pr.head?.ref} does not match patterns` };
}
const toSha = pr.head?.sha;
if (!toSha) return { queued: 0, reason: "no head sha" };
if (findJobBySha(db, repo.id, toSha)) {
return { queued: 0, reason: `commit ${toSha.slice(0, 10)} already queued or running` };
}
const jobId = enqueueJob(db, {
repoId: repo.id,
trigger: `pull_request.${action}`,
refName: pr.head.ref,
baseRef: pr.base?.ref ?? repo.base_branch,
fromSha: pr.base?.sha ?? null,
toSha,
prNumber: pr.number,
});
logger.info(`queued job #${jobId} for PR #${pr.number} (${owner}/${name})`);
return { queued: 1, jobId };
}
async function findOpenPullRequestForRef(cfg, repo, refName, sha) {
try {
const client = new GiteaClient({
baseUrl: cfg.giteaUrl,
token: repo.gitea_token || cfg.giteaToken,
});
const list = await client.listPullRequests(repo.owner, repo.name, { state: "open", limit: 50 });
return (list || []).find((p) => p.head?.ref === refName || p.head?.sha === sha) ?? null;
} catch (err) {
logger.warn(`cannot look up pull request for ${refName}: ${err.message}`);
return null;
}
}
/* ---------------------------------- routes -------------------------------- */
async function handleApi(req, res, url, cfg) {
const path = url.pathname.replace(/^\/api/, "");
if (path === "/health") {
return json(res, 200, {
ok: true,
queue: jobStats(db),
currentJob: queue.currentJobId,
giteaUrl: cfg.giteaUrl,
llmModel: cfg.llmModel || null,
});
}
if (!authorized(req, cfg)) return json(res, 401, { error: "unauthorized" });
if (path === "/settings" && req.method === "GET") {
const saved = allSettings(db);
const out = {
giteaUrl: cfg.giteaUrl,
webhookSecretSet: Boolean(cfg.webhookSecret),
adminTokenSet: Boolean(cfg.adminToken),
llmUrl: cfg.llmUrl,
llmModel: cfg.llmModel,
llmProtocol: cfg.llmProtocol,
rulePath: cfg.rulePath,
giteaTokenSet: Boolean(cfg.giteaToken),
llmTokenSet: Boolean(cfg.llmToken),
raw: Object.fromEntries(Object.entries(saved).filter(([k]) => !SECRET_SETTING_KEYS.has(k))),
};
return json(res, 200, out);
}
if (path === "/settings" && req.method === "PUT") {
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
const allowed = [
"giteaUrl", "giteaToken", "webhookSecret", "adminToken", "llmUrl", "llmToken",
"llmModel", "llmProtocol", "llmAuthHeader", "llmExtraHeaders", "rulePath",
];
for (const key of allowed) {
if (body[key] !== undefined) setSetting(db, key, body[key]);
}
return json(res, 200, { ok: true });
}
if (path === "/repos" && req.method === "GET") {
return json(res, 200, listRepositories(db));
}
if (path === "/repos" && req.method === "POST") {
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
if (!body.owner || !body.name) return json(res, 400, { error: "owner and name are required" });
const repo = upsertRepository(db, {
owner: body.owner,
name: body.name,
enabled: body.enabled === undefined ? 1 : Number(Boolean(body.enabled)),
base_branch: body.base_branch || "main",
branch_patterns: body.branch_patterns || "*",
review_scope: body.review_scope || "both",
create_issue: body.create_issue === undefined ? 1 : Number(Boolean(body.create_issue)),
auto_merge: Number(Boolean(body.auto_merge)),
});
return json(res, 201, repo);
}
const repoMatch = /^\/repos\/(\d+)$/.exec(path);
if (repoMatch) {
const id = Number(repoMatch[1]);
const repo = getRepository(db, id);
if (!repo) return json(res, 404, { error: "repository not found" });
if (req.method === "GET") return json(res, 200, repo);
if (req.method === "PATCH") {
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
const updated = upsertRepository(db, { ...body, id });
return json(res, 200, updated);
}
if (req.method === "DELETE") {
deleteRepository(db, id);
return json(res, 200, { ok: true });
}
}
const discoverMatch = /^\/repos\/(\d+)\/discover$/.exec(path);
if (discoverMatch && req.method === "POST") {
const repo = getRepository(db, Number(discoverMatch[1]));
if (!repo) return json(res, 404, { error: "repository not found" });
const client = new GiteaClient({
baseUrl: cfg.giteaUrl,
token: repo.gitea_token || cfg.giteaToken,
});
try {
const info = await client.getRepo(repo.owner, repo.name);
const branches = await client.listRepoBranches(repo.owner, repo.name);
const updated = upsertRepository(db, {
id: repo.id,
base_branch: repo.base_branch || info.default_branch,
});
return json(res, 200, {
repo: updated,
default_branch: info.default_branch,
has_issues: info.has_issues,
has_pull_requests: info.has_pull_requests,
branches: branches.map((b) => b.name),
});
} catch (err) {
return json(res, 502, { error: err.message });
}
}
if (path === "/jobs" && req.method === "GET") {
const repoId = url.searchParams.get("repo_id");
const limit = Math.min(Number(url.searchParams.get("limit") || 50), 200);
return json(res, 200, listJobs(db, { repoId: repoId ? Number(repoId) : undefined, limit }));
}
const jobMatch = /^\/jobs\/(\d+)$/.exec(path);
if (jobMatch && req.method === "GET") {
const job = getJob(db, Number(jobMatch[1]));
if (!job) return json(res, 404, { error: "job not found" });
const repo = getRepository(db, job.repo_id);
return json(res, 200, { ...job, repository: repo ? `${repo.owner}/${repo.name}` : null });
}
const retryMatch = /^\/jobs\/(\d+)\/retry$/.exec(path);
if (retryMatch && req.method === "POST") {
const job = getJob(db, Number(retryMatch[1]));
if (!job) return json(res, 404, { error: "job not found" });
const newId = enqueueJob(db, {
repoId: job.repo_id,
trigger: `${job.trigger}+retry`,
refName: job.ref_name,
baseRef: job.base_ref,
fromSha: job.from_sha,
toSha: job.to_sha,
prNumber: job.pr_number,
});
return json(res, 201, { jobId: newId });
}
if (path === "/review" && req.method === "POST") {
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
const repo = body.repo_id ? getRepository(db, Number(body.repo_id))
: findRepository(db, body.owner, body.name);
if (!repo) return json(res, 404, { error: "repository not configured" });
const toSha = body.sha;
if (!toSha) return json(res, 400, { error: "sha is required" });
const jobId = enqueueJob(db, {
repoId: repo.id,
trigger: "manual",
refName: body.ref || repo.base_branch,
baseRef: body.base_ref || repo.base_branch,
fromSha: body.from_sha || null,
toSha,
prNumber: body.pr_number || null,
});
return json(res, 201, { jobId });
}
if (path === "/selftest" && req.method === "POST") {
const runner = new OcrRunner({
command: cfg.ocrCommand,
llm: {
url: cfg.llmUrl, token: cfg.llmToken, model: cfg.llmModel,
protocol: cfg.llmProtocol, authHeader: cfg.llmAuthHeader,
extraHeaders: cfg.llmExtraHeaders, timeoutSeconds: cfg.llmTimeoutSeconds,
},
});
try {
const result = await runner.selfTest();
return json(res, 200, result);
} catch (err) {
return json(res, 502, { error: err.message, stderr: err.stderr ?? null });
}
}
if (path === "/gitea/test" && req.method === "POST") {
try {
const client = new GiteaClient({
baseUrl: cfg.giteaUrl,
token: cfg.giteaToken,
});
const version = await client.getVersion();
let user = null;
try { user = await client.getCurrentUser(); } catch { /* token may be missing */ }
return json(res, 200, { version: version?.version ?? null, user: user?.login ?? null });
} catch (err) {
return json(res, 502, { error: err.message });
}
}
return json(res, 404, { error: "not found" });
}
async function serveStatic(res, path) {
const rel = path === "/" ? "/index.html" : path;
const full = join(STATIC_DIR, rel);
if (!resolve(full).startsWith(STATIC_DIR)) return text(res, 403, "forbidden");
if (!existsSync(full)) {
return text(res, 404, "not found");
}
const info = await stat(full);
if (!info.isFile()) return text(res, 404, "not found");
const types = {
".html": "text/html; charset=utf-8",
".css": "text/css; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".svg": "image/svg+xml",
".json": "application/json; charset=utf-8",
};
return text(res, 200, await readFile(full), types[extname(full)] || "application/octet-stream");
}
/* ---------------------------------- server -------------------------------- */
const server = createServer(async (req, res) => {
const url = new URL(req.url, `http://${req.headers.host || "localhost"}`);
try {
if (url.pathname === "/webhook/gitea" && req.method === "POST") {
const cfg = effectiveConfig();
const raw = await readBody(req);
const signature = req.headers["x-gitea-signature"];
if (!verifySignature(cfg.webhookSecret, raw, signature)) {
logger.warn("webhook rejected: bad signature");
return json(res, 401, { error: "invalid signature" });
}
const deliveryId = req.headers["x-gitea-delivery"] || randomUUID();
if (!recordDelivery(db, deliveryId)) {
return json(res, 200, { ok: true, duplicate: true });
}
pruneDeliveries(db);
const event = req.headers["x-gitea-event"] || "unknown";
let payload;
try {
payload = JSON.parse(raw.toString("utf8") || "{}");
} catch {
return json(res, 400, { error: "invalid JSON payload" });
}
let result = { queued: 0 };
if (event === "push") result = await handlePush(payload, cfg);
else if (event === "pull_request") result = await handlePullRequest(payload, cfg);
else result = { queued: 0, reason: `event ${event} ignored` };
logger.info(`webhook ${event}: ${JSON.stringify(result)}`);
return json(res, 202, { ok: true, event, ...result });
}
if (url.pathname.startsWith("/api")) {
return await handleApi(req, res, url, effectiveConfig());
}
if (req.method === "GET") return await serveStatic(res, url.pathname);
return text(res, 405, "method not allowed");
} catch (err) {
logger.error(`${req.method} ${url.pathname} -> ${err.stack || err.message}`);
return json(res, 500, { error: err.message });
}
});
server.listen(CONFIG.port, CONFIG.host, () => {
logger.info(`gitea-codereview listening on http://${CONFIG.host}:${CONFIG.port}`);
logger.info(`database: ${CONFIG.dbPath}`);
logger.info(`webhook endpoint: /webhook/gitea`);
queue.start();
});
function shutdown(signal) {
logger.info(`${signal} received, shutting down`);
queue.stop();
server.close(() => {
try { db.close(); } catch { /* ignore */ }
process.exit(0);
});
setTimeout(() => process.exit(0), 15000).unref();
}
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
export { server, db, engine, queue };