Files
git-codereview/app/static/app.js
T
kgod 34f2897888 fix: 自动安装 Webhook、修正 PR 审查范围与分支匹配语义
三个导致「提交了新 PR 但没有任何反应」的独立问题:

1. 从不创建 Webhook
   服务只被动接收事件,但配置仓库时不会去 Gitea 里建 Webhook,
   结果仓库永远收不到 push / PR 事件,看起来和坏掉一样。
   - 保存仓库时自动创建或更新 Webhook(POST /repos 返回值带 webhook 结果)
   - 新增 GET/POST /api/repos/:id/webhook 查询与修复
   - 仓库列表新增 Webhook 列,未配置可一键「修复 Webhook」
   - 新增 CR_WEBHOOK_URL,留空则推导为「Gitea 主机名 + 本服务端口」

2. 审查范围用了错的分支做基准
   resolveRange 优先取 managed_branch 而不是 job.base_ref,
   于是 PR 被拿去和一个无关分支比较;当两者内容相同就报
   「base and head resolve to the same commit」直接跳过。
   改为优先用该改动实际要合入的分支(PR 的目标分支),
   仅 push 事件回退到 managed_branch。

3. 检查分支的匹配语义反了
   原先要求 PR 的 head(功能分支)出现在 check_branches 里,
   而用户配置的是「要保护的目标分支」(如 prd/test),
   功能分支永远不会被列出,所以 PR 一律被过滤掉。
   新增 pullRequestMatches:PR 的 base 命中检查分支即审查,
   head 命中仍保留支持。这样「审查所有合入 prd/test 的改动」成立。

验证:PR offerpai/offerpai_h5#2 重跑通过,7 条内联评论、
2 条阻断、commit status failure、Issue #3 创建。
2026-09-20 14:44:26 +08:00

814 lines
31 KiB
JavaScript
Raw 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.length) return '<span class="tag muted">—</span>';
return names.map((n) => n === managed
? `<span class="tag ok">${esc(n)}</span>`
: `<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");
function selectedBranches() {
return String(form.elements.check_branches.value || "")
.split(",").map((s) => s.trim()).filter(Boolean);
}
function setSelectedBranches(list) {
form.elements.check_branches.value = list.join(",");
}
function renderBranchList() {
const chosen = new Set(selectedBranches());
const managed = form.elements.managed_branch.value.trim();
if (!state.branches.length) {
branchList.innerHTML = '<span class="hint">点「加载分支」获取远端分支列表。</span>';
return;
}
branchList.innerHTML = state.branches.map((b) => {
const on = chosen.has(b);
const label = b === managed ? `${b}(管理)` : b;
return `<button type="button" class="${on ? "on" : ""}" data-branch="${esc(b)}">${esc(label)}</button>`;
}).join("");
}
branchList.addEventListener("click", (ev) => {
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 = "main";
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";
if (!form.elements.check_branches.value) form.elements.check_branches.value = state.branches.join(",");
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: remote.default_branch || "main",
},
});
// 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);