Files
kgod 216bb51c92 feat: 重构被审查分支的选择逻辑
push 审查
  check_branches 现在只表示「要做 push 审查的分支」,不再兼作 PR 目标分支:
  - 勾选具体分支:这些分支的 push 会审查,通过后晋级到管理分支
  - 新增「任意分支」(*):所有分支的 push 都审查
  - 留空:只审 PR,不审 push

PR 审查
  始终覆盖「勾选的分支 + 管理分支」作为合并目标。
  管理分支是最终目标,不需要出现在检查分支里也能被保护。

管理分支从候选中移除
  分支选择器不再列出管理分支——它是终点,不需要再检查自己。

安全边界
  「任意分支」只扩大审查范围,不会把任意分支自动合并进管理分支;
  晋级仍要求分支被显式勾选(promotesToManaged)。

同时修正一处语义错误
  早先 branchMatches 把空列表当成「审所有分支」,导致新建仓库在用户
  还没选分支时就审查全部 push。现在空列表明确表示「只审 PR」。

测试:分支匹配矩阵重写,覆盖 push/PR/晋级/保护目标四类判定,共 20 项通过。
2026-09-21 17:12:57 +08:00

849 lines
33 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use strict";
const state = { repos: [], jobs: [], records: [], token: "", branches: [] };
/* ---------------------------------- 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, cls) {
const el = document.querySelector(selector);
el.textContent = typeof content === "string" ? content : JSON.stringify(content, null, 2);
if (cls) el.className = cls; else el.className = "log";
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", "");
}
function handleError(err) {
if (err.status === 401) {
document.getElementById("sign-out").classList.add("hidden");
showGate("Token 无效或已过期,请重新输入。");
return;
}
setBanner(err.message);
}
/* ---------------------------------- 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 === "records") loadRecords().catch(handleError);
if (tab.dataset.tab === "settings") loadSettings().catch(handleError);
});
}
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}`;
}
}
/* ---------------------------------- repos --------------------------------- */
const REPO_FIELDS = [
"id", "repo_url", "enabled", "managed_branch", "check_branches", "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");
// The webhook is what makes reviews fire at all, so its state belongs in the
// repository row instead of hiding in Gitea's settings.
await Promise.all(state.repos.map(async (r) => {
try {
r.webhook = await api(`/repos/${r.id}/webhook`);
} catch (err) {
r.webhook = { error: err.message };
}
}));
const tbody = document.querySelector("#repos-table tbody");
if (state.repos.length === 0) {
tbody.innerHTML = '<tr><td colspan="8" class="empty">还没有配置仓库。点「从 Gitea 导入」批量选择,或「新增仓库」手动填 Git 地址。</td></tr>';
} else {
tbody.innerHTML = state.repos.map((r) => `
<tr>
<td><strong>${esc(r.owner)}/${esc(r.name)}</strong><br><span class="hint">${esc(shortUrl(r.repo_url))}</span></td>
<td><code>${esc(r.managed_branch)}</code></td>
<td>${branchTags(r.check_branches, r.managed_branch)}</td>
<td>${esc(scopeLabel(r.review_scope))}</td>
<td>${webhookCell(r)}</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 data-records="${r.id}">历史</button>
<button data-hook="${r.id}">修复 Webhook</button>
<button class="danger" data-del="${r.id}">删除</button>
</td>
</tr>`).join("");
}
syncRecordFilter();
}
function webhookCell(r) {
if (r.webhook?.error) return `<span class="tag err" title="${esc(r.webhook.error)}">未知</span>`;
if (r.webhook?.installed) return '<span class="tag ok">已配置</span>';
return '<span class="tag err">未配置</span>';
}
function shortUrl(url) {
return String(url || "").replace(/^https?:\/\//, "");
}
function branchTags(list, managed) {
const names = String(list || "").split(",").map((s) => s.trim()).filter(Boolean);
if (names.includes("*")) {
const extra = names.filter((n) => n !== "*");
return `<span class="tag warn">任意分支</span>` +
(extra.length ? ` ${extra.map((n) => `<span class="tag muted">${esc(n)}</span>`).join(" ")}` : "");
}
const own = names.filter((n) => n !== managed);
if (!own.length) return '<span class="tag muted">仅 PR</span>';
return own.map((n) => `<span class="tag muted">${esc(n)}</span>`).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) await 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));
if (target.dataset.records) {
document.querySelector('#record-filter').value = String(target.dataset.records);
document.querySelector('.tab[data-tab="records"]').click();
}
if (target.dataset.hook) {
target.disabled = true;
const label = target.textContent;
target.textContent = "处理中…";
try {
const res = await api(`/repos/${target.dataset.hook}/webhook`, { method: "POST" });
setBanner(`Webhook ${res.created ? "已创建" : "已更新"}:${res.url}`, "ok");
} finally {
target.disabled = false;
target.textContent = label;
await loadRepos();
}
}
} catch (err) {
handleError(err);
}
});
document.getElementById("add-repo").addEventListener("click", () => openRepo(null));
document.getElementById("refresh-jobs").addEventListener("click", () => loadJobs().catch(handleError));
document.getElementById("refresh-records").addEventListener("click", () => loadRecords().catch(handleError));
document.getElementById("record-filter").addEventListener("change", () => loadRecords().catch(handleError));
/* ------------------------------ repo dialog ------------------------------- */
const modal = document.getElementById("modal");
const form = document.getElementById("repo-form");
const branchList = document.getElementById("branch-list");
const ANY_BRANCH = "*";
function allSelected() {
return String(form.elements.check_branches.value || "")
.split(",").map((s) => s.trim()).filter(Boolean);
}
/** Branch names only; the "任意分支" token is not a branch. */
function selectedBranches() {
return allSelected().filter((b) => b !== ANY_BRANCH);
}
function reviewsAnyBranch() {
return allSelected().includes(ANY_BRANCH);
}
function setSelectedBranches(list, { any = reviewsAnyBranch() } = {}) {
const names = list.filter((b) => b && b !== ANY_BRANCH);
form.elements.check_branches.value = (any ? [ANY_BRANCH, ...names] : names).join(",");
}
function renderBranchList() {
const chosen = new Set(selectedBranches());
const managed = form.elements.managed_branch.value.trim();
const any = reviewsAnyBranch();
if (!state.branches.length) {
branchList.innerHTML = '<span class="hint">点「加载分支」获取远端分支列表。</span>';
return;
}
// The managed branch is the final target and is always reviewed for pull
// requests, so it is not offered as a checked branch.
const selectable = state.branches.filter((b) => b !== managed);
const parts = [
`<button type="button" class="${any ? "on" : ""}" data-any="1">任意分支</button>`,
];
if (selectable.length === 0) {
parts.push('<span class="hint">除管理分支外没有其它分支。</span>');
} else {
parts.push(...selectable.map((b) => {
const on = chosen.has(b);
return `<button type="button" class="${on ? "on" : ""}" data-branch="${esc(b)}">${esc(b)}</button>`;
}));
}
branchList.innerHTML = parts.join("");
}
branchList.addEventListener("click", (ev) => {
const anyBtn = ev.target.closest("button[data-any]");
if (anyBtn) {
setSelectedBranches(selectedBranches(), { any: !reviewsAnyBranch() });
renderBranchList();
return;
}
const btn = ev.target.closest("button[data-branch]");
if (!btn) return;
const name = btn.dataset.branch;
const chosen = selectedBranches();
const idx = chosen.indexOf(name);
if (idx >= 0) chosen.splice(idx, 1); else chosen.push(name);
setSelectedBranches(chosen);
renderBranchList();
});
async function openRepo(id) {
form.reset();
hide("#repo-result");
state.branches = [];
renderBranchList();
const repo = id ? state.repos.find((r) => r.id === id) : null;
document.getElementById("modal-title").textContent = repo ? `${repo.owner}/${repo.name}` : "新增仓库";
document.getElementById("repo-parsed").textContent = "";
for (const field of REPO_FIELDS) {
const input = form.elements[field];
if (!input) continue;
if (!repo) {
if (field === "enabled" || field === "create_issue") input.checked = true;
if (field === "managed_branch") input.value = "main";
if (field === "check_branches") input.value = "";
continue;
}
if (input.type === "checkbox") input.checked = Boolean(repo[field]);
else if (input.type === "password") input.value = "";
else input.value = repo[field] ?? "";
}
if (repo) {
document.getElementById("repo-parsed").textContent = `${repo.owner}/${repo.name}`;
// Existing repositories get their branch list loaded for the picker.
loadBranches({ silent: true }).catch(() => {});
}
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"); });
/** Fetch branch info for whatever git URL is currently in the form. */
async function loadBranches({ silent = false } = {}) {
const repoUrl = form.elements.repo_url.value.trim();
const id = form.elements.id.value;
const hint = document.getElementById("repo-parsed");
if (!repoUrl && !id) {
if (!silent) hint.textContent = "请先填写 Git 地址。";
return;
}
if (!silent) hint.textContent = "正在连接…";
try {
const body = repoUrl ? { repo_url: repoUrl } : undefined;
const info = body
? await api("/repos/branches", { method: "POST", body })
: await api(`/repos/${id}/discover`, { method: "POST" });
state.branches = info.branches || [];
if (!form.elements.managed_branch.value) form.elements.managed_branch.value = info.default_branch || "main";
// Existing repositories keep their saved selection; a fresh one starts
// with no explicit branches checked (only the managed branch is reviewed).
if (form.elements.check_branches.value === "") setSelectedBranches([], { any: false });
renderBranchList();
hint.className = "hint ok";
hint.textContent =
`连接成功:${info.owner}/${info.name} · 默认分支 ${info.default_branch} · 共 ${state.branches.length} 个分支` +
(info.has_issues ? "" : " · 该仓库未启用 Issue");
document.getElementById("clone-source").innerHTML =
state.branches.map((b) => `<option value="${esc(b)}">${esc(b)}</option>`).join("");
return info;
} catch (err) {
hint.className = "hint err";
hint.textContent = `连接失败:${err.message}`;
throw err;
}
}
document.getElementById("repo-test").addEventListener("click", () => loadBranches().catch(() => {}));
document.getElementById("branch-reload").addEventListener("click", () => loadBranches().catch(() => {}));
form.elements.repo_url.addEventListener("blur", async () => {
const url = form.elements.repo_url.value.trim();
if (!url) return;
try {
const parsed = await api("/repos/parse", { method: "POST", body: { repo_url: url } });
document.getElementById("repo-parsed").className = "hint";
document.getElementById("repo-parsed").textContent = `识别为:${parsed.owner}/${parsed.name}`;
} catch (err) {
document.getElementById("repo-parsed").className = "hint err";
document.getElementById("repo-parsed").textContent = err.message;
}
});
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 });
modal.classList.add("hidden");
setBanner("");
} else {
const created = await api("/repos", { method: "POST", body: data });
modal.classList.add("hidden");
if (created.webhook?.error) {
setBanner(`仓库已保存,但 Webhook 安装失败:${created.webhook.error}`);
} else if (created.webhook) {
setBanner(`仓库已保存,Webhook ${created.webhook.created ? "已创建" : "已更新"}。`, "ok");
} else {
setBanner("仓库已保存。");
}
}
await loadRepos();
} catch (err) {
if (err.status === 401) return handleError(err);
show("#repo-result", err.message);
}
});
/* ------------------------------ clone branch ------------------------------ */
const cloneModal = document.getElementById("clone-modal");
const cloneForm = document.getElementById("clone-form");
document.getElementById("branch-create").addEventListener("click", () => {
cloneForm.reset();
hide("#clone-result");
cloneForm.elements.new_branch.value = form.elements.managed_branch.value.trim() || "main";
if (!state.branches.length) {
loadBranches().then(() => cloneModal.classList.remove("hidden")).catch(() => {});
return;
}
cloneModal.classList.remove("hidden");
});
document.getElementById("clone-close").addEventListener("click", () => cloneModal.classList.add("hidden"));
cloneModal.addEventListener("click", (ev) => { if (ev.target === cloneModal) cloneModal.classList.add("hidden"); });
cloneForm.addEventListener("submit", async (ev) => {
ev.preventDefault();
const id = form.elements.id.value;
if (!id) return show("#clone-result", "请先保存仓库,再创建分支。");
try {
const res = await api(`/repos/${id}/branches`, {
method: "POST",
body: {
new_branch: cloneForm.elements.new_branch.value.trim(),
from_branch: cloneForm.elements.from_branch.value,
},
});
state.branches = res.branches || [];
form.elements.managed_branch.value = res.repo.managed_branch;
form.elements.check_branches.value = res.repo.check_branches;
renderBranchList();
cloneModal.classList.add("hidden");
show("#repo-result", `已创建分支 ${res.repo.managed_branch}`);
} catch (err) {
show("#clone-result", err.message);
}
});
/* ------------------------------ import dialog ----------------------------- */
const importModal = document.getElementById("import-modal");
document.getElementById("import-repo").addEventListener("click", async () => {
importModal.classList.remove("hidden");
document.getElementById("import-list").innerHTML = '<div class="empty">正在拉取仓库列表…</div>';
try {
state.remoteRepos = await api("/gitea/repos");
renderImportList();
} catch (err) {
document.getElementById("import-list").innerHTML = `<div class="empty">拉取失败:${esc(err.message)}</div>`;
}
});
document.getElementById("import-close").addEventListener("click", () => importModal.classList.add("hidden"));
importModal.addEventListener("click", (ev) => { if (ev.target === importModal) importModal.classList.add("hidden"); });
document.getElementById("import-search").addEventListener("input", renderImportList);
function renderImportList() {
const q = document.getElementById("import-search").value.trim().toLowerCase();
const list = (state.remoteRepos || []).filter((r) => !q || r.full_name.toLowerCase().includes(q));
const box = document.getElementById("import-list");
if (!list.length) {
box.innerHTML = '<div class="empty">没有匹配的仓库。</div>';
return;
}
box.innerHTML = list.map((r) => `
<div class="import-row">
<div>
<div class="name">${esc(r.full_name)}</div>
<div class="meta">${esc(r.default_branch || "main")}${r.private ? " · 私有" : ""}${r.empty ? " · 空仓库" : ""}${r.archived ? " · 已归档" : ""}${r.description ? ` · ${esc(r.description)}` : ""}</div>
</div>
<div class="actions">
${r.configured
? '<span class="tag ok">已配置</span>'
: `<button data-import="${esc(r.full_name)}" ${r.empty ? "disabled" : ""}>${r.empty ? "空仓库" : "添加"}</button>`}
</div>
</div>`).join("");
}
document.getElementById("import-list").addEventListener("click", async (ev) => {
const btn = ev.target.closest("button[data-import]");
if (!btn) return;
const fullName = btn.dataset.import;
const remote = (state.remoteRepos || []).find((r) => r.full_name === fullName);
if (!remote) return;
btn.disabled = true;
btn.textContent = "添加中…";
try {
const created = await api("/repos", {
method: "POST",
body: {
repo_url: remote.clone_url,
managed_branch: remote.default_branch || "main",
check_branches: "",
},
});
// Discover immediately so the branch list is populated on first open.
await api(`/repos/${created.id}/discover`, { method: "POST" }).catch(() => {});
remote.configured = true;
renderImportList();
await loadRepos();
setBanner(`已添加 ${fullName}`, "ok");
} catch (err) {
btn.disabled = false;
btn.textContent = "添加";
setBanner(`添加 ${fullName} 失败:${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.managed_branch || "main";
manualForm.elements.base_ref.value = repo.managed_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);
}
});
/* -------------------------------- records --------------------------------- */
function syncRecordFilter() {
const sel = document.getElementById("record-filter");
const current = sel.value;
sel.innerHTML = '<option value="">全部仓库</option>' +
state.repos.map((r) => `<option value="${r.id}">${esc(r.owner)}/${esc(r.name)}</option>`).join("");
sel.value = current;
}
async function loadRecords() {
const repoId = document.getElementById("record-filter").value;
state.records = await api(`/records${repoId ? `?repo_id=${repoId}` : ""}`);
const box = document.getElementById("records-list");
if (!state.records.length) {
box.innerHTML = '<div class="empty">暂无审查记录。仓库收到 push 或 PR 后,这里会出现摘要。</div>';
return;
}
box.innerHTML = state.records.map(renderRecordCard).join("");
}
function renderRecordCard(r) {
const summaryCls = r.summary_status === "done" ? "" : (r.summary_status === "failed" ? " failed" : " pending");
let summary;
if (r.summary_status === "done") {
summary = esc(r.summary || "");
} else if (r.summary_status === "failed") {
summary = `摘要生成失败:${esc(r.summary_error || "未知原因")}`;
} else if (r.summary_status === "skipped") {
summary = "未生成摘要。";
} else {
summary = "摘要生成中…";
}
return `
<article class="record">
<div class="record-head">
<div>
<div class="who">${esc(r.owner)}/${esc(r.name)} · ${esc(r.ref_name)}</div>
<div class="record-meta">
<span>${fmtTime(r.created_at)}</span>
<span>提交 <code>${esc(String(r.to_sha).slice(0, 10))}</code></span>
<span>${esc(r.trigger || "")}</span>
${r.pr_number ? `<a href="${esc(r.pr_url || "#")}" target="_blank" rel="noreferrer">PR #${r.pr_number}</a>` : ""}
<span>发现 ${r.findings_count ?? 0} 条</span>
${r.tokens_total ? `<span>${r.tokens_total} tokens</span>` : ""}
${r.elapsed ? `<span>${esc(r.elapsed)}</span>` : ""}
</div>
</div>
<div class="actions">
<span class="tag ${r.summary_status === "done" ? "ok" : (r.summary_status === "failed" ? "err" : "muted")}">${esc(r.summary_status)}</span>
<button data-record="${r.id}">详情</button>
</div>
</div>
<div class="record-summary${summaryCls}">${summary}</div>
</article>`;
}
document.getElementById("records-list").addEventListener("click", async (ev) => {
const btn = ev.target.closest("button[data-record]");
if (!btn) return;
try {
const rec = await api(`/records/${btn.dataset.record}`);
showRecord(rec);
} catch (err) {
handleError(err);
}
});
const recordModal = document.getElementById("record-modal");
let currentRecordId = null;
function showRecord(rec) {
currentRecordId = rec.id;
document.getElementById("record-title").textContent =
`审查记录 #${rec.id} · ${rec.repository || ""} · ${rec.ref_name}`;
const params = rec.params || {};
const findings = rec.findings || [];
const rows = [
["提交", `<code>${esc(rec.to_sha)}</code>`],
["对比基准", rec.from_sha ? `<code>${esc(rec.from_sha)}</code>` : "—"],
["触发来源", esc(rec.trigger || "—")],
["Pull Request", rec.pr_number
? `<a href="${esc(rec.pr_url || "#")}" target="_blank" rel="noreferrer">#${rec.pr_number}</a>`
: "—"],
["管理分支", esc(params.managed_branch || "—")],
["PR 目标分支", esc(params.base_ref || "—")],
["审查范围", esc(params.review_scope || "—")],
["阻断阈值", `${esc(params.block_severity || "—")}${params.block_categories ? ` / ${esc(params.block_categories)}` : ""}`],
["发布方式", esc(params.publish_mode || "—")],
["自动合并", params.auto_merge ? `${esc(params.auto_merge_mode || "")} · ${esc(params.merge_method || "")}` : "关闭"],
["排除路径", esc(params.excludes || "—")],
["LLM 模型", esc(rec.llm_model || "—")],
["审查状态", `${esc(rec.llm_status || "—")}${params.review_incomplete ? "(覆盖不完整)" : ""}`],
["消耗 / 耗时", `${rec.tokens_total || 0} tokens · ${esc(rec.elapsed || "—")}`],
["摘要模型", esc(rec.summary_model || "—")],
];
const findingsHtml = findings.length
? findings.map((f) => `
<div class="finding${f.blocking ? " blocking" : ""}">
<div class="where">${esc(f.path)}${f.start_line ? `:${f.start_line}${f.end_line && f.end_line !== f.start_line ? `-${f.end_line}` : ""}` : ""} · ${esc(f.severity || "未分级")}/${esc(f.category || "-")}${f.blocking ? " · 阻断" : ""}</div>
<div>${esc(f.content || "")}</div>
</div>`).join("")
: '<div class="empty">本次没有产生意见。</div>';
document.getElementById("record-body").innerHTML = `
<h3>审查需求 / 背景</h3>
<div class="record-summary">${rec.requirement ? esc(rec.requirement) : "(未填写)"}</div>
<h3 style="margin-top:16px">AI 摘要</h3>
<div class="record-summary${rec.summary_status === "done" ? "" : " pending"}">${
rec.summary_status === "done" ? esc(rec.summary || "") : esc(rec.summary_error || "生成中…")
}</div>
<h3 style="margin-top:16px">运行参数</h3>
<dl class="kv">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join("")}</dl>
<h3 style="margin-top:16px">审查意见(${findings.length})</h3>
${findingsHtml}`;
recordModal.classList.remove("hidden");
}
document.getElementById("record-close").addEventListener("click", () => recordModal.classList.add("hidden"));
recordModal.addEventListener("click", (ev) => { if (ev.target === recordModal) recordModal.classList.add("hidden"); });
document.getElementById("record-resummarise").addEventListener("click", async () => {
if (!currentRecordId) return;
try {
await api(`/records/${currentRecordId}/summarise`, { method: "POST" });
recordModal.classList.add("hidden");
setBanner("已重新排队生成摘要。", "ok");
await loadRecords();
} catch (err) {
handleError(err);
}
});
/* ---------------------------------- 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", "webhookUrl",
"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);