"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"); const tbody = document.querySelector("#repos-table tbody"); if (state.repos.length === 0) { tbody.innerHTML = '还没有配置仓库。点「从 Gitea 导入」批量选择,或「新增仓库」手动填 Git 地址。'; } else { tbody.innerHTML = state.repos.map((r) => ` ${esc(r.owner)}/${esc(r.name)}
${esc(shortUrl(r.repo_url))} ${esc(r.managed_branch)} ${branchTags(r.check_branches, r.managed_branch)} ${esc(scopeLabel(r.review_scope))} ${r.enabled ? '启用' : '停用'} ${r.auto_merge ? `${esc(r.merge_method)}` : '否'} ${esc(r.block_severity || "—")} `).join(""); } syncRecordFilter(); } 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 '—'; return names.map((n) => n === managed ? `${esc(n)}` : `${esc(n)}`).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(); } } 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 = '点「加载分支」获取远端分支列表。'; return; } branchList.innerHTML = state.branches.map((b) => { const on = chosen.has(b); const label = b === managed ? `${b}(管理)` : b; return ``; }).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) => ``).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 }); else await api("/repos", { method: "POST", body: data }); modal.classList.add("hidden"); setBanner(""); await loadRepos(); } catch (err) { if (err.status === 401) return handleError(err); show("#repo-result", err.message); } }); /* ------------------------------ 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 = '
正在拉取仓库列表…
'; try { state.remoteRepos = await api("/gitea/repos"); renderImportList(); } catch (err) { document.getElementById("import-list").innerHTML = `
拉取失败:${esc(err.message)}
`; } }); 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 = '
没有匹配的仓库。
'; return; } box.innerHTML = list.map((r) => `
${esc(r.full_name)}
${esc(r.default_branch || "main")}${r.private ? " · 私有" : ""}${r.empty ? " · 空仓库" : ""}${r.archived ? " · 已归档" : ""}${r.description ? ` · ${esc(r.description)}` : ""}
${r.configured ? '已配置' : ``}
`).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 = '' + state.repos.map((r) => ``).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 = '
暂无审查记录。仓库收到 push 或 PR 后,这里会出现摘要。
'; 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 `
${esc(r.owner)}/${esc(r.name)} · ${esc(r.ref_name)}
${fmtTime(r.created_at)} 提交 ${esc(String(r.to_sha).slice(0, 10))} ${esc(r.trigger || "")} ${r.pr_number ? `PR #${r.pr_number}` : ""} 发现 ${r.findings_count ?? 0} 条 ${r.tokens_total ? `${r.tokens_total} tokens` : ""} ${r.elapsed ? `${esc(r.elapsed)}` : ""}
${esc(r.summary_status)}
${summary}
`; } 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 = [ ["提交", `${esc(rec.to_sha)}`], ["对比基准", rec.from_sha ? `${esc(rec.from_sha)}` : "—"], ["触发来源", esc(rec.trigger || "—")], ["Pull Request", rec.pr_number ? `#${rec.pr_number}` : "—"], ["管理分支", 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) => `
${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 ? " · 阻断" : ""}
${esc(f.content || "")}
`).join("") : '
本次没有产生意见。
'; document.getElementById("record-body").innerHTML = `

审查需求 / 背景

${rec.requirement ? esc(rec.requirement) : "(未填写)"}

AI 摘要

${ rec.summary_status === "done" ? esc(rec.summary || "") : esc(rec.summary_error || "生成中…") }

运行参数

${rows.map(([k, v]) => `
${esc(k)}
${v}
`).join("")}

审查意见(${findings.length})

${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 = '暂无任务。仓库收到 push 或 Pull Request 后会出现在这里。'; return; } tbody.innerHTML = state.jobs.map((j) => ` ${j.id} ${esc(j.owner)}/${esc(j.name)} ${esc(j.ref_name)}${j.pr_number ? ` PR #${j.pr_number}` : ""} ${esc(String(j.to_sha).slice(0, 10))} ${esc(j.status)}${j.phase ? ` ${esc(j.phase)}` : ""} ${j.findings ?? 0} ${j.blocking ? `${j.blocking}` : "0"} ${j.merged ? '已合并' : ""} ${fmtTime(j.started_at || j.created_at)} `).join(""); } document.querySelector("#jobs-table").addEventListener("click", async (ev) => { const target = ev.target.closest("button"); if (!target) return; try { if (target.dataset.log) { const job = await api(`/jobs/${target.dataset.log}`); show("#job-log", job.log || "(无日志)"); } if (target.dataset.retry) { await api(`/jobs/${target.dataset.retry}/retry`, { method: "POST" }); await loadJobs(); } } catch (err) { handleError(err); } }); /* -------------------------------- settings -------------------------------- */ const SETTINGS_FIELDS = [ "giteaUrl", "giteaToken", "webhookSecret", "adminToken", "llmUrl", "llmToken", "llmModel", "llmProtocol", "llmAuthHeader", "llmExtraHeaders", "rulePath", ]; async function loadSettings() { const settingsForm = document.getElementById("settings-form"); const s = await api("/settings"); for (const field of SETTINGS_FIELDS) { const input = settingsForm.elements[field]; if (!input) continue; if (input.type === "password") input.value = ""; else input.value = s[field] ?? ""; } document.getElementById("hook-url").textContent = `${location.origin}/webhook/gitea`; } document.getElementById("settings-form").addEventListener("submit", async (ev) => { ev.preventDefault(); const settingsForm = ev.target; const data = {}; for (const field of SETTINGS_FIELDS) { const input = settingsForm.elements[field]; if (input && input.value) data[field] = input.value; } try { await api("/settings", { method: "PUT", body: data }); if (data.adminToken) { state.token = data.adminToken; localStorage.setItem(TOKEN_KEY, data.adminToken); } for (const field of SETTINGS_FIELDS) { const input = settingsForm.elements[field]; if (input && input.type === "password") input.value = ""; } show("#settings-result", "已保存。"); await loadHealth(); } catch (err) { if (err.status === 401) return handleError(err); show("#settings-result", err.message); } }); document.getElementById("test-gitea").addEventListener("click", async () => { show("#settings-result", "测试中…"); try { show("#settings-result", await api("/gitea/test", { method: "POST" })); } catch (err) { show("#settings-result", err.message); } }); document.getElementById("test-llm").addEventListener("click", async () => { show("#settings-result", "测试中,请稍候…"); try { show("#settings-result", await api("/selftest", { method: "POST" })); } catch (err) { show("#settings-result", err.message); } }); /* --------------------------------- startup -------------------------------- */ async function refreshAll() { await loadRepos(); await loadHealth(); } async function boot() { await loadHealth(); if (!token()) { showGate(""); return; } try { await refreshAll(); document.getElementById("sign-out").classList.remove("hidden"); } catch (err) { handleError(err); } } boot(); setInterval(loadHealth, 10000);