基于 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 指向的实例不可达, 且后台配置与任务历史需要独立进程承载。
271 lines
9.2 KiB
JavaScript
271 lines
9.2 KiB
JavaScript
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;
|
|
} |