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 指向的实例不可达,
且后台配置与任务历史需要独立进程承载。
This commit is contained in:
2026-09-20 12:09:46 +08:00
commit ed172bf369
18 changed files with 3451 additions and 0 deletions
+271
View File
@@ -0,0 +1,271 @@
import { DatabaseSync } from "node:sqlite";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
const SCHEMA = `
PRAGMA journal_mode = WAL;
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS repositories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL,
name TEXT NOT NULL,
enabled INTEGER NOT NULL DEFAULT 1,
base_branch TEXT NOT NULL DEFAULT 'main',
branch_patterns TEXT NOT NULL DEFAULT '*',
review_scope TEXT NOT NULL DEFAULT 'both',
gitea_token TEXT,
llm_provider TEXT,
llm_model TEXT,
llm_base_url TEXT,
llm_token TEXT,
rule_path TEXT,
background_template TEXT,
excludes TEXT,
publish_mode TEXT NOT NULL DEFAULT 'inline',
create_issue INTEGER NOT NULL DEFAULT 1,
issue_labels TEXT NOT NULL DEFAULT 'code-review',
block_severity TEXT NOT NULL DEFAULT 'critical,high',
block_categories TEXT NOT NULL DEFAULT '',
fail_on_findings INTEGER NOT NULL DEFAULT 0,
auto_merge INTEGER NOT NULL DEFAULT 0,
auto_merge_mode TEXT NOT NULL DEFAULT 'when_checks_succeed',
merge_method TEXT NOT NULL DEFAULT 'squash',
delete_branch INTEGER NOT NULL DEFAULT 0,
max_comments INTEGER NOT NULL DEFAULT 30,
concurrency INTEGER NOT NULL DEFAULT 4,
last_seen_at TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (owner, name)
);
CREATE TABLE IF NOT EXISTS branch_state (
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
ref_name TEXT NOT NULL,
last_sha TEXT NOT NULL,
reviewed_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (repo_id, ref_name)
);
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
trigger TEXT NOT NULL,
ref_name TEXT NOT NULL,
base_ref TEXT,
from_sha TEXT,
to_sha TEXT NOT NULL,
pr_number INTEGER,
status TEXT NOT NULL DEFAULT 'queued',
phase TEXT,
attempts INTEGER NOT NULL DEFAULT 0,
findings INTEGER NOT NULL DEFAULT 0,
blocking INTEGER NOT NULL DEFAULT 0,
comment_count INTEGER NOT NULL DEFAULT 0,
issue_number INTEGER,
merged INTEGER NOT NULL DEFAULT 0,
result_json TEXT,
error TEXT,
log TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
started_at TEXT,
finished_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status, id);
CREATE INDEX IF NOT EXISTS idx_jobs_repo ON jobs (repo_id, id DESC);
CREATE INDEX IF NOT EXISTS idx_jobs_sha ON jobs (repo_id, to_sha);
CREATE TABLE IF NOT EXISTS webhook_deliveries (
delivery_id TEXT PRIMARY KEY,
received_at TEXT NOT NULL DEFAULT (datetime('now'))
);
`;
export function openDatabase(path) {
mkdirSync(dirname(path), { recursive: true });
const db = new DatabaseSync(path);
db.exec(SCHEMA);
return db;
}
export function getSetting(db, key, fallback = null) {
const row = db.prepare("SELECT value FROM settings WHERE key = ?").get(key);
return row ? row.value : fallback;
}
export function setSetting(db, key, value) {
db.prepare(
`INSERT INTO settings (key, value, updated_at) VALUES (?, ?, datetime('now'))
ON CONFLICT (key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`,
).run(key, value == null ? "" : String(value));
}
export function allSettings(db) {
const out = {};
for (const row of db.prepare("SELECT key, value FROM settings").all()) {
out[row.key] = row.value;
}
return out;
}
export function listRepositories(db) {
return db.prepare("SELECT * FROM repositories ORDER BY owner, name").all();
}
export function getRepository(db, id) {
return db.prepare("SELECT * FROM repositories WHERE id = ?").get(id);
}
export function findRepository(db, owner, name) {
return db.prepare("SELECT * FROM repositories WHERE owner = ? AND name = ?").get(owner, name);
}
const REPO_FIELDS = [
"owner", "name", "enabled", "base_branch", "branch_patterns", "review_scope",
"gitea_token", "llm_provider", "llm_model", "llm_base_url", "llm_token",
"rule_path", "background_template", "excludes", "publish_mode", "create_issue",
"issue_labels", "block_severity", "block_categories", "fail_on_findings",
"auto_merge", "auto_merge_mode", "merge_method", "delete_branch", "max_comments",
"concurrency",
];
export function upsertRepository(db, input) {
const existing = input.id
? getRepository(db, input.id)
: findRepository(db, input.owner, input.name);
const values = {};
for (const field of REPO_FIELDS) {
if (input[field] !== undefined) values[field] = input[field];
}
if (existing) {
const sets = Object.keys(values).map((k) => `${k} = ?`);
if (sets.length > 0) {
db.prepare(
`UPDATE repositories SET ${sets.join(", ")}, updated_at = datetime('now') WHERE id = ?`,
).run(...Object.values(values), existing.id);
}
return getRepository(db, existing.id);
}
const cols = ["owner", "name", ...Object.keys(values)];
const params = [input.owner, input.name, ...Object.values(values)];
const placeholders = cols.map(() => "?").join(", ");
const info = db.prepare(
`INSERT INTO repositories (${cols.join(", ")}) VALUES (${placeholders})`,
).run(...params);
return getRepository(db, Number(info.lastInsertRowid));
}
export function deleteRepository(db, id) {
db.prepare("DELETE FROM repositories WHERE id = ?").run(id);
}
export function enqueueJob(db, job) {
const info = db.prepare(
`INSERT INTO jobs (repo_id, trigger, ref_name, base_ref, from_sha, to_sha, pr_number, status)
VALUES (?, ?, ?, ?, ?, ?, ?, 'queued')`,
).run(
job.repoId, job.trigger, job.refName, job.baseRef ?? null,
job.fromSha ?? null, job.toSha, job.prNumber ?? null,
);
return Number(info.lastInsertRowid);
}
export function claimNextJob(db) {
const row = db.prepare(
"SELECT * FROM jobs WHERE status = 'queued' ORDER BY id LIMIT 1",
).get();
if (!row) return null;
const info = db.prepare(
`UPDATE jobs SET status = 'running', attempts = attempts + 1,
started_at = datetime('now'), phase = 'starting'
WHERE id = ? AND status = 'queued'`,
).run(row.id);
if (info.changes === 0) return null;
return db.prepare("SELECT * FROM jobs WHERE id = ?").get(row.id);
}
export function updateJob(db, id, patch) {
const allowed = [
"status", "phase", "findings", "blocking", "comment_count",
"issue_number", "merged", "result_json", "error", "log", "pr_number",
];
const keys = Object.keys(patch).filter((k) => allowed.includes(k));
if (keys.length === 0) return;
const sets = keys.map((k) => `${k} = ?`);
if (patch.status && ["succeeded", "failed", "skipped"].includes(patch.status)) {
sets.push("finished_at = datetime('now')");
}
db.prepare(`UPDATE jobs SET ${sets.join(", ")} WHERE id = ?`).run(
...keys.map((k) => patch[k]), id,
);
}
export function getJob(db, id) {
return db.prepare("SELECT * FROM jobs WHERE id = ?").get(id);
}
export function listJobs(db, { repoId, limit = 50 } = {}) {
if (repoId) {
return db.prepare(
"SELECT j.*, r.owner, r.name FROM jobs j JOIN repositories r ON r.id = j.repo_id " +
"WHERE j.repo_id = ? ORDER BY j.id DESC LIMIT ?",
).all(repoId, limit);
}
return db.prepare(
"SELECT j.*, r.owner, r.name FROM jobs j JOIN repositories r ON r.id = j.repo_id " +
"ORDER BY j.id DESC LIMIT ?",
).all(limit);
}
export function findJobBySha(db, repoId, sha) {
return db.prepare(
"SELECT * FROM jobs WHERE repo_id = ? AND to_sha = ? AND status IN ('queued','running') ORDER BY id DESC LIMIT 1",
).get(repoId, sha);
}
export function getBranchState(db, repoId, refName) {
return db.prepare(
"SELECT * FROM branch_state WHERE repo_id = ? AND ref_name = ?",
).get(repoId, refName);
}
export function setBranchState(db, repoId, refName, sha) {
db.prepare(
`INSERT INTO branch_state (repo_id, ref_name, last_sha, reviewed_at)
VALUES (?, ?, ?, datetime('now'))
ON CONFLICT (repo_id, ref_name) DO UPDATE SET
last_sha = excluded.last_sha, reviewed_at = datetime('now')`,
).run(repoId, refName, sha);
}
export function recordDelivery(db, deliveryId) {
try {
db.prepare("INSERT INTO webhook_deliveries (delivery_id) VALUES (?)").run(deliveryId);
return true;
} catch {
return false;
}
}
export function pruneDeliveries(db, keep = 2000) {
db.prepare(
`DELETE FROM webhook_deliveries WHERE delivery_id IN (
SELECT delivery_id FROM webhook_deliveries ORDER BY received_at DESC LIMIT -1 OFFSET ?
)`,
).run(keep);
}
export function jobStats(db) {
const rows = db.prepare("SELECT status, COUNT(*) AS n FROM jobs GROUP BY status").all();
const out = { queued: 0, running: 0, succeeded: 0, failed: 0, skipped: 0 };
for (const r of rows) out[r.status] = r.n;
return out;
}