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
+426
View File
@@ -0,0 +1,426 @@
"use strict";
const state = { repos: [], jobs: [], token: "" };
/* ---------------------------------- auth ---------------------------------- */
const TOKEN_KEY = "cr-admin-token";
function token() {
return state.token || localStorage.getItem(TOKEN_KEY) || "";
}
function showGate(message) {
const gate = document.getElementById("token-gate");
const err = document.getElementById("token-error");
gate.classList.remove("hidden");
if (message) {
err.textContent = message;
err.classList.remove("hidden");
} else {
err.classList.add("hidden");
}
document.getElementById("token-form").elements.token.focus();
}
function hideGate() {
document.getElementById("token-gate").classList.add("hidden");
}
function setBanner(message, kind) {
const el = document.getElementById("banner");
if (!message) {
el.classList.add("hidden");
return;
}
el.textContent = message;
el.className = `banner${kind === "ok" ? " ok" : ""}`;
}
document.getElementById("token-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
const value = ev.target.elements.token.value.trim();
if (!value) return;
state.token = value;
try {
await api("/repos");
localStorage.setItem(TOKEN_KEY, value);
hideGate();
document.getElementById("sign-out").classList.remove("hidden");
setBanner("");
await refreshAll();
} catch (err) {
state.token = "";
showGate(`Token 无效:${err.message}`);
}
});
document.getElementById("sign-out").addEventListener("click", () => {
state.token = "";
localStorage.removeItem(TOKEN_KEY);
document.getElementById("sign-out").classList.add("hidden");
showGate("");
});
/* ---------------------------------- api ----------------------------------- */
async function api(path, { method = "GET", body } = {}) {
const headers = { Accept: "application/json" };
const t = token();
if (t) headers.Authorization = `Bearer ${t}`;
if (body !== undefined) headers["Content-Type"] = "application/json";
const res = await fetch(`/api${path}`, {
method, headers, body: body === undefined ? undefined : JSON.stringify(body),
});
const textBody = await res.text();
let parsed = null;
try { parsed = textBody ? JSON.parse(textBody) : null; } catch { parsed = textBody; }
if (!res.ok) {
const error = new Error(parsed?.error || `HTTP ${res.status}`);
error.status = res.status;
throw error;
}
return parsed;
}
function show(selector, content) {
const el = document.querySelector(selector);
el.textContent = typeof content === "string" ? content : JSON.stringify(content, null, 2);
el.classList.remove("hidden");
}
function hide(selector) { document.querySelector(selector).classList.add("hidden"); }
function esc(value) {
return String(value ?? "").replace(/[&<>"]/g, (c) => (
{ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]
));
}
function fmtTime(value) {
if (!value) return "";
return String(value).replace("T", " ").replace("Z", "");
}
/* ---------------------------------- tabs ---------------------------------- */
for (const tab of document.querySelectorAll(".tab")) {
tab.addEventListener("click", () => {
for (const t of document.querySelectorAll(".tab")) t.classList.toggle("active", t === tab);
for (const p of document.querySelectorAll(".panel")) {
p.classList.toggle("active", p.id === `tab-${tab.dataset.tab}`);
}
if (tab.dataset.tab === "jobs") loadJobs().catch(handleError);
if (tab.dataset.tab === "settings") loadSettings().catch(handleError);
});
}
/* --------------------------------- health --------------------------------- */
async function loadHealth() {
try {
const h = await api("/health");
const q = h.queue || {};
document.getElementById("status").textContent =
`队列 运行${q.running || 0} / 等待${q.queued || 0} · 成功${q.succeeded || 0} · 失败${q.failed || 0}` +
(h.llmModel ? ` · ${h.llmModel}` : "");
} catch (err) {
document.getElementById("status").textContent = `服务异常:${err.message}`;
}
}
function handleError(err) {
if (err.status === 401) {
document.getElementById("sign-out").classList.add("hidden");
showGate("Token 无效或已过期,请重新输入。");
return;
}
setBanner(err.message);
}
/* ---------------------------------- repos --------------------------------- */
const REPO_FIELDS = [
"id", "owner", "name", "enabled", "base_branch", "branch_patterns", "review_scope",
"gitea_token", "llm_model", "llm_token", "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",
];
async function loadRepos() {
state.repos = await api("/repos");
const tbody = document.querySelector("#repos-table tbody");
if (state.repos.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty">还没有配置仓库。点右上角「新增仓库」开始,然后在 Gitea 仓库里添加 Webhook。</td></tr>';
return;
}
tbody.innerHTML = state.repos.map((r) => `
<tr>
<td><strong>${esc(r.owner)}/${esc(r.name)}</strong><br><span class="tag muted">${esc(r.base_branch)}</span></td>
<td><code>${esc(r.branch_patterns)}</code></td>
<td>${esc(scopeLabel(r.review_scope))}</td>
<td>${r.enabled ? '<span class="tag ok">启用</span>' : '<span class="tag muted">停用</span>'}</td>
<td>${r.auto_merge ? `<span class="tag warn">${esc(r.merge_method)}</span>` : '<span class="tag muted">否</span>'}</td>
<td><code>${esc(r.block_severity || "—")}</code></td>
<td>
<button data-edit="${r.id}">编辑</button>
<button data-run="${r.id}">立即审查</button>
<button class="danger" data-del="${r.id}">删除</button>
</td>
</tr>`).join("");
}
function scopeLabel(scope) {
return { both: "Push + PR", pr: "仅 PR", push: "仅 Push" }[scope] || scope;
}
document.querySelector("#repos-table").addEventListener("click", async (ev) => {
const target = ev.target.closest("button");
if (!target) return;
try {
if (target.dataset.edit) openRepo(Number(target.dataset.edit));
if (target.dataset.del) {
if (!confirm("删除该仓库配置?历史任务会一并删除。")) return;
await api(`/repos/${target.dataset.del}`, { method: "DELETE" });
await loadRepos();
}
if (target.dataset.run) openManual(Number(target.dataset.run));
} catch (err) {
handleError(err);
}
});
document.getElementById("add-repo").addEventListener("click", () => openRepo(null));
document.getElementById("refresh-jobs").addEventListener("click", () => loadJobs().catch(handleError));
/* ------------------------------- repo dialog ------------------------------ */
const modal = document.getElementById("modal");
const form = document.getElementById("repo-form");
function openRepo(id) {
form.reset();
hide("#repo-result");
const repo = id ? state.repos.find((r) => r.id === id) : null;
document.getElementById("modal-title").textContent = repo ? `${repo.owner}/${repo.name}` : "新增仓库";
for (const field of REPO_FIELDS) {
const input = form.elements[field];
if (!input) continue;
if (!repo) {
// Defaults for a brand-new repository.
if (field === "enabled" || field === "create_issue") input.checked = true;
continue;
}
if (input.type === "checkbox") input.checked = Boolean(repo[field]);
else if (input.type === "password") input.value = "";
else input.value = repo[field] ?? "";
}
modal.classList.remove("hidden");
}
document.getElementById("modal-close").addEventListener("click", () => modal.classList.add("hidden"));
modal.addEventListener("click", (ev) => { if (ev.target === modal) modal.classList.add("hidden"); });
form.addEventListener("submit", async (ev) => {
ev.preventDefault();
const data = {};
for (const field of REPO_FIELDS) {
const input = form.elements[field];
if (!input) continue;
if (input.type === "checkbox") data[field] = input.checked ? 1 : 0;
else if (input.type === "password") { if (input.value) data[field] = input.value; }
else if (input.value !== "") data[field] = input.value;
}
try {
if (data.id) await api(`/repos/${data.id}`, { method: "PATCH", body: data });
else await api("/repos", { method: "POST", body: data });
modal.classList.add("hidden");
setBanner("");
await loadRepos();
} catch (err) {
if (err.status === 401) return handleError(err);
show("#repo-result", err.message);
}
});
document.getElementById("discover").addEventListener("click", async () => {
const id = form.elements.id.value;
if (!id) return show("#repo-result", "请先保存仓库,再测试连接。");
show("#repo-result", "测试中…");
try {
const info = await api(`/repos/${id}/discover`, { method: "POST" });
show("#repo-result",
`连接成功\n默认分支:${info.default_branch}\nIssue 功能:${info.has_issues}\nPR 功能:${info.has_pull_requests}\n分支:${info.branches.join(", ")}`);
} catch (err) {
if (err.status === 401) return handleError(err);
show("#repo-result", err.message);
}
});
/* ------------------------------ manual dialog ----------------------------- */
const manualModal = document.getElementById("manual-modal");
const manualForm = document.getElementById("manual-form");
function openManual(repoId) {
const repo = state.repos.find((r) => r.id === repoId);
if (!repo) return;
manualForm.reset();
hide("#manual-result");
manualForm.elements.repo_id.value = repo.id;
manualForm.elements.ref.value = repo.base_branch || "main";
manualForm.elements.base_ref.value = repo.base_branch || "main";
document.getElementById("manual-repo").textContent = `${repo.owner}/${repo.name}`;
manualModal.classList.remove("hidden");
}
document.getElementById("manual-close").addEventListener("click", () => manualModal.classList.add("hidden"));
manualModal.addEventListener("click", (ev) => { if (ev.target === manualModal) manualModal.classList.add("hidden"); });
manualForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
const body = {
repo_id: Number(manualForm.elements.repo_id.value),
ref: manualForm.elements.ref.value.trim(),
sha: manualForm.elements.sha.value.trim(),
base_ref: manualForm.elements.base_ref.value.trim() || undefined,
};
const pr = manualForm.elements.pr_number.value.trim();
if (pr) body.pr_number = Number(pr);
try {
const res = await api("/review", { method: "POST", body });
manualModal.classList.add("hidden");
setBanner(`已加入队列:任务 #${res.jobId}`, "ok");
await loadJobs();
} catch (err) {
if (err.status === 401) return handleError(err);
show("#manual-result", err.message);
}
});
/* ---------------------------------- jobs ---------------------------------- */
const STATUS_TAG = {
queued: "muted", running: "warn", succeeded: "ok", failed: "err", skipped: "muted",
};
async function loadJobs() {
state.jobs = await api("/jobs?limit=100");
const tbody = document.querySelector("#jobs-table tbody");
if (state.jobs.length === 0) {
tbody.innerHTML = '<tr><td colspan="10" class="empty">暂无任务。仓库收到 push 或 Pull Request 后会出现在这里。</td></tr>';
return;
}
tbody.innerHTML = state.jobs.map((j) => `
<tr>
<td>${j.id}</td>
<td>${esc(j.owner)}/${esc(j.name)}</td>
<td><code>${esc(j.ref_name)}</code>${j.pr_number ? ` <span class="tag muted">PR #${j.pr_number}</span>` : ""}</td>
<td><code>${esc(String(j.to_sha).slice(0, 10))}</code></td>
<td><span class="tag ${STATUS_TAG[j.status] || "muted"}">${esc(j.status)}</span>${j.phase ? ` <span class="tag muted">${esc(j.phase)}</span>` : ""}</td>
<td>${j.findings ?? 0}</td>
<td>${j.blocking ? `<span class="tag err">${j.blocking}</span>` : "0"}</td>
<td>${j.merged ? '<span class="tag ok">已合并</span>' : ""}</td>
<td>${fmtTime(j.started_at || j.created_at)}</td>
<td><button data-log="${j.id}">日志</button> <button data-retry="${j.id}">重跑</button></td>
</tr>`).join("");
}
document.querySelector("#jobs-table").addEventListener("click", async (ev) => {
const target = ev.target.closest("button");
if (!target) return;
try {
if (target.dataset.log) {
const job = await api(`/jobs/${target.dataset.log}`);
show("#job-log", job.log || "(无日志)");
}
if (target.dataset.retry) {
await api(`/jobs/${target.dataset.retry}/retry`, { method: "POST" });
await loadJobs();
}
} catch (err) {
handleError(err);
}
});
/* -------------------------------- settings -------------------------------- */
const SETTINGS_FIELDS = [
"giteaUrl", "giteaToken", "webhookSecret", "adminToken", "llmUrl", "llmToken",
"llmModel", "llmProtocol", "llmAuthHeader", "llmExtraHeaders", "rulePath",
];
async function loadSettings() {
const settingsForm = document.getElementById("settings-form");
const s = await api("/settings");
for (const field of SETTINGS_FIELDS) {
const input = settingsForm.elements[field];
if (!input) continue;
if (input.type === "password") input.value = "";
else input.value = s[field] ?? "";
}
document.getElementById("hook-url").textContent = `${location.origin}/webhook/gitea`;
}
document.getElementById("settings-form").addEventListener("submit", async (ev) => {
ev.preventDefault();
const settingsForm = ev.target;
const data = {};
for (const field of SETTINGS_FIELDS) {
const input = settingsForm.elements[field];
if (input && input.value) data[field] = input.value;
}
try {
await api("/settings", { method: "PUT", body: data });
if (data.adminToken) {
state.token = data.adminToken;
localStorage.setItem(TOKEN_KEY, data.adminToken);
}
for (const field of SETTINGS_FIELDS) {
const input = settingsForm.elements[field];
if (input && input.type === "password") input.value = "";
}
show("#settings-result", "已保存。");
await loadHealth();
} catch (err) {
if (err.status === 401) return handleError(err);
show("#settings-result", err.message);
}
});
document.getElementById("test-gitea").addEventListener("click", async () => {
show("#settings-result", "测试中…");
try { show("#settings-result", await api("/gitea/test", { method: "POST" })); }
catch (err) { show("#settings-result", err.message); }
});
document.getElementById("test-llm").addEventListener("click", async () => {
show("#settings-result", "测试中,请稍候…");
try { show("#settings-result", await api("/selftest", { method: "POST" })); }
catch (err) { show("#settings-result", err.message); }
});
/* --------------------------------- startup -------------------------------- */
async function refreshAll() {
await loadRepos();
await loadHealth();
}
async function boot() {
await loadHealth();
if (!token()) {
showGate("");
return;
}
try {
await refreshAll();
document.getElementById("sign-out").classList.remove("hidden");
} catch (err) {
handleError(err);
}
}
boot();
setInterval(loadHealth, 10000);