push 审查 check_branches 现在只表示「要做 push 审查的分支」,不再兼作 PR 目标分支: - 勾选具体分支:这些分支的 push 会审查,通过后晋级到管理分支 - 新增「任意分支」(*):所有分支的 push 都审查 - 留空:只审 PR,不审 push PR 审查 始终覆盖「勾选的分支 + 管理分支」作为合并目标。 管理分支是最终目标,不需要出现在检查分支里也能被保护。 管理分支从候选中移除 分支选择器不再列出管理分支——它是终点,不需要再检查自己。 安全边界 「任意分支」只扩大审查范围,不会把任意分支自动合并进管理分支; 晋级仍要求分支被显式勾选(promotesToManaged)。 同时修正一处语义错误 早先 branchMatches 把空列表当成「审所有分支」,导致新建仓库在用户 还没选分支时就审查全部 push。现在空列表明确表示「只审 PR」。 测试:分支匹配矩阵重写,覆盖 push/PR/晋级/保护目标四类判定,共 20 项通过。
849 lines
33 KiB
JavaScript
849 lines
33 KiB
JavaScript
"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) => (
|
||
{ "&": "&", "<": "<", ">": ">", '"': """ }[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); |