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:
@@ -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) => (
|
||||
{ "&": "&", "<": "<", ">": ">", '"': """ }[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);
|
||||
Reference in New Issue
Block a user