feat: 仓库导入、分支模型重构与审查历史摘要

仓库接入
- 新增「从 Gitea 导入」:用全局 Token 列出可见仓库,一键建配置
- 新增仓库只填 Git 地址,自动解析 owner/name,支持 https/ssh/scp 写法
- 新增「测试连接」按钮,保存前即可校验地址并拉取分支

分支模型
- 由单一 base_branch + glob 改为「一个管理分支 + 多个检查分支」
- 管理分支是唯一合并目标;检查分支全部纳入监控
- 支持从任意分支克隆创建管理分支
- 审查基准改为管理分支的 merge-base;仅目标为管理分支的 PR 才自动合并
- 旧库自动迁移:base_branch 播种 managed_branch,branch_patterns 展开为检查分支

审查历史
- 新增 review_records 表与「审查历史」页
- 记录触发来源、PR 链接、审查范围、阻断阈值、发布方式、自动合并设置、
  排除路径、LLM 模型、token 消耗、耗时、需求背景与全部审查意见
- 详情弹窗一览,支持按仓库过滤

AI 摘要
- 新增独立摘要模块(app/lib/summary.js),与代码审查提示词分离
- 专用提示词输出固定四节、500 字内的中文记录:结论/范围/问题/要点
- 与代码审查共用全局 LLM 设置;摘要失败不影响审查与合并,可单条重跑

修复
- 摘要改用内置 fetch:运行镜像没有 curl,原先 spawn curl 必然 ENOENT
- 去掉 blob:none 部分克隆并把凭据写入 .git/config:
  惰性取 blob 不会带上 per-command extraHeader,私有库会报 could not read Username

UI
- 审查背景改为多行文本域(可滚动)
- 分支改为可点选列表,管理分支高亮
- 仓库表格展示管理分支与检查分支
This commit is contained in:
2026-09-20 12:38:23 +08:00
parent ed172bf369
commit 6dc77ce907
12 changed files with 1374 additions and 153 deletions
+390 -44
View File
@@ -1,6 +1,6 @@
"use strict";
const state = { repos: [], jobs: [], token: "" };
const state = { repos: [], jobs: [], records: [], token: "", branches: [] };
/* ---------------------------------- auth ---------------------------------- */
@@ -83,9 +83,10 @@ async function api(path, { method = "GET", body } = {}) {
return parsed;
}
function show(selector, content) {
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");
}
@@ -102,6 +103,15 @@ function fmtTime(value) {
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")) {
@@ -111,12 +121,11 @@ for (const tab of document.querySelectorAll(".tab")) {
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);
});
}
/* --------------------------------- health --------------------------------- */
async function loadHealth() {
try {
const h = await api("/health");
@@ -129,19 +138,10 @@ async function loadHealth() {
}
}
function handleError(err) {
if (err.status === 401) {
document.getElementById("sign-out").classList.add("hidden");
showGate("Token 无效或已过期,请重新输入。");
return;
}
setBanner(err.message);
}
/* ---------------------------------- repos --------------------------------- */
const REPO_FIELDS = [
"id", "owner", "name", "enabled", "base_branch", "branch_patterns", "review_scope",
"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",
@@ -152,23 +152,38 @@ async function loadRepos() {
state.repos = await api("/repos");
const tbody = document.querySelector("#repos-table tbody");
if (state.repos.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty">还没有配置仓库。点右上角「新增仓库」开始,然后在 Gitea 仓库里添加 Webhook。</td></tr>';
return;
tbody.innerHTML = '<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>${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 class="danger" data-del="${r.id}">删除</button>
</td>
</tr>`).join("");
}
tbody.innerHTML = state.repos.map((r) => `
<tr>
<td><strong>${esc(r.owner)}/${esc(r.name)}</strong><br><span class="tag muted">${esc(r.base_branch)}</span></td>
<td><code>${esc(r.branch_patterns)}</code></td>
<td>${esc(scopeLabel(r.review_scope))}</td>
<td>${r.enabled ? '<span class="tag ok">启用</span>' : '<span class="tag muted">停用</span>'}</td>
<td>${r.auto_merge ? `<span class="tag warn">${esc(r.merge_method)}</span>` : '<span class="tag muted">否</span>'}</td>
<td><code>${esc(r.block_severity || "—")}</code></td>
<td>
<button data-edit="${r.id}">编辑</button>
<button data-run="${r.id}">立即审查</button>
<button class="danger" data-del="${r.id}">删除</button>
</td>
</tr>`).join("");
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 '<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) {
@@ -179,13 +194,17 @@ document.querySelector("#repos-table").addEventListener("click", async (ev) => {
const target = ev.target.closest("button");
if (!target) return;
try {
if (target.dataset.edit) openRepo(Number(target.dataset.edit));
if (target.dataset.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);
}
@@ -193,35 +212,130 @@ document.querySelector("#repos-table").addEventListener("click", async (ev) => {
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 ------------------------------ */
/* ------------------------------ repo dialog ------------------------------- */
const modal = document.getElementById("modal");
const form = document.getElementById("repo-form");
const branchList = document.getElementById("branch-list");
function openRepo(id) {
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) {
// Defaults for a brand-new repository.
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 = {};
@@ -244,17 +358,116 @@ form.addEventListener("submit", async (ev) => {
}
});
document.getElementById("discover").addEventListener("click", async () => {
/* ------------------------------ 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("#repo-result", "请先保存仓库,再测试连接。");
show("#repo-result", "测试中…");
if (!id) return show("#clone-result", "请先保存仓库,再创建分支。");
try {
const info = await api(`/repos/${id}/discover`, { method: "POST" });
show("#repo-result",
`连接成功\n默认分支:${info.default_branch}\nIssue 功能:${info.has_issues}\nPR 功能:${info.has_pull_requests}\n分支:${info.branches.join(", ")}`);
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) {
if (err.status === 401) return handleError(err);
show("#repo-result", err.message);
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}`);
}
});
@@ -269,8 +482,8 @@ function openManual(repoId) {
manualForm.reset();
hide("#manual-result");
manualForm.elements.repo_id.value = repo.id;
manualForm.elements.ref.value = repo.base_branch || "main";
manualForm.elements.base_ref.value = repo.base_branch || "main";
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");
}
@@ -299,6 +512,139 @@ manualForm.addEventListener("submit", async (ev) => {
}
});
/* -------------------------------- 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 = {