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