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
+173 -20
View File
@@ -8,13 +8,14 @@ import { fileURLToPath } from "node:url";
import {
allSettings, deleteRepository, enqueueJob, findJobBySha, findRepository,
getJob, getSetting, jobStats, listJobs, listRepositories, openDatabase,
recordDelivery, pruneDeliveries, setSetting, upsertRepository, getRepository,
getJob, getReviewRecord, jobStats, listJobs, listRepositories, listReviewRecords,
openDatabase, recordDelivery, pruneDeliveries, requeueStaleSummaries, setSetting,
updateReviewRecord, upsertRepository, getRepository,
} from "./lib/db.js";
import { GiteaClient } from "./lib/gitea.js";
import { OcrRunner } from "./lib/ocr.js";
import { JobQueue } from "./lib/queue.js";
import { ReviewEngine, SkipJob, branchMatches } from "./lib/review.js";
import { ReviewEngine, SkipJob, branchMatches, parseRepoUrl } from "./lib/review.js";
const APP_DIR = resolve(fileURLToPath(new URL(".", import.meta.url)));
const ROOT_DIR = resolve(APP_DIR, "..");
@@ -130,6 +131,15 @@ function verifySignature(secret, rawBody, signature) {
return a.length === b.length && timingSafeEqual(a, b);
}
function safeParse(value, fallback) {
try { return JSON.parse(value || "null") ?? fallback; } catch { return fallback; }
}
function safeCount(value) {
const parsed = safeParse(value, []);
return Array.isArray(parsed) ? parsed.length : 0;
}
function authorized(req, cfg) {
if (!cfg.adminToken) return true;
const header = req.headers.authorization || "";
@@ -156,8 +166,8 @@ async function handlePush(payload, cfg) {
const refName = refNameFromPayload(payload);
if (!refName || payload.deleted) return { queued: 0, reason: "branch deletion or empty ref" };
if (!branchMatches(refName, repo.branch_patterns)) {
return { queued: 0, reason: `branch ${refName} does not match patterns` };
if (!branchMatches(refName, repo.check_branches)) {
return { queued: 0, reason: `branch ${refName} is not in the checked branch list` };
}
const toSha = payload.after || payload.head_commit?.id;
@@ -178,7 +188,7 @@ async function handlePush(payload, cfg) {
repoId: repo.id,
trigger: "push",
refName,
baseRef: pr?.base?.ref ?? repo.base_branch,
baseRef: pr?.base?.ref ?? repo.managed_branch,
fromSha: before,
toSha,
prNumber: pr?.number ?? null,
@@ -202,8 +212,8 @@ async function handlePullRequest(payload, cfg) {
const pr = payload.pull_request;
if (!pr) return { queued: 0, reason: "no pull_request in payload" };
if (pr.draft) return { queued: 0, reason: "draft pull request" };
if (!branchMatches(pr.head?.ref, repo.branch_patterns)) {
return { queued: 0, reason: `head branch ${pr.head?.ref} does not match patterns` };
if (!branchMatches(pr.head?.ref, repo.check_branches)) {
return { queued: 0, reason: `head branch ${pr.head?.ref} is not in the checked branch list` };
}
const toSha = pr.head?.sha;
if (!toSha) return { queued: 0, reason: "no head sha" };
@@ -214,7 +224,7 @@ async function handlePullRequest(payload, cfg) {
repoId: repo.id,
trigger: `pull_request.${action}`,
refName: pr.head.ref,
baseRef: pr.base?.ref ?? repo.base_branch,
baseRef: pr.base?.ref ?? repo.managed_branch,
fromSha: pr.base?.sha ?? null,
toSha,
prNumber: pr.number,
@@ -289,13 +299,32 @@ async function handleApi(req, res, url, cfg) {
if (path === "/repos" && req.method === "POST") {
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
if (!body.owner || !body.name) return json(res, 400, { error: "owner and name are required" });
let owner = body.owner;
let name = body.name;
let repoUrl = body.repo_url || null;
// Owner/name are normally derived from the git URL, not typed by hand.
if (repoUrl && (!owner || !name)) {
try {
const parsed = parseRepoUrl(repoUrl);
owner = parsed.owner;
name = parsed.name;
} catch (err) {
return json(res, 400, { error: err.message });
}
}
if (!owner || !name) return json(res, 400, { error: "请填写 Git 地址" });
if (!repoUrl) {
repoUrl = `${String(cfg.giteaUrl).replace(/\/+$/, "")}/${owner}/${name}.git`;
}
const managed = body.managed_branch || body.base_branch || "main";
const checks = body.check_branches || body.branch_patterns || managed;
const repo = upsertRepository(db, {
owner: body.owner,
name: body.name,
owner,
name,
repo_url: repoUrl,
enabled: body.enabled === undefined ? 1 : Number(Boolean(body.enabled)),
base_branch: body.base_branch || "main",
branch_patterns: body.branch_patterns || "*",
managed_branch: managed,
check_branches: checks,
review_scope: body.review_scope || "both",
create_issue: body.create_issue === undefined ? 1 : Number(Boolean(body.create_issue)),
auto_merge: Number(Boolean(body.auto_merge)),
@@ -303,6 +332,41 @@ async function handleApi(req, res, url, cfg) {
return json(res, 201, repo);
}
/** Repositories the global token can see, for one-click import. */
if (path === "/gitea/repos" && req.method === "GET") {
const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: cfg.giteaToken });
try {
const repos = await client.listAccessibleRepos();
const configured = new Set(listRepositories(db).map((r) => `${r.owner}/${r.name}`));
return json(res, 200, repos.map((r) => ({
full_name: r.full_name,
owner: r.owner?.login ?? r.full_name.split("/")[0],
name: r.name,
clone_url: r.clone_url,
ssh_url: r.ssh_url,
default_branch: r.default_branch,
private: Boolean(r.private),
empty: Boolean(r.empty),
archived: Boolean(r.archived),
description: r.description ?? "",
configured: configured.has(r.full_name),
})));
} catch (err) {
return json(res, 502, { error: err.message });
}
}
/** Validate a git URL without saving anything. */
if (path === "/repos/parse" && req.method === "POST") {
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
try {
const parsed = parseRepoUrl(body.repo_url);
return json(res, 200, parsed);
} catch (err) {
return json(res, 400, { error: err.message });
}
}
const repoMatch = /^\/repos\/(\d+)$/.exec(path);
if (repoMatch) {
const id = Number(repoMatch[1]);
@@ -331,15 +395,39 @@ async function handleApi(req, res, url, cfg) {
try {
const info = await client.getRepo(repo.owner, repo.name);
const branches = await client.listRepoBranches(repo.owner, repo.name);
const updated = upsertRepository(db, {
id: repo.id,
base_branch: repo.base_branch || info.default_branch,
});
const names = branches.map((b) => b.name);
// Seed sensible branch defaults the first time a repository is opened.
const patch = { id: repo.id };
if (!repo.managed_branch) patch.managed_branch = info.default_branch || "main";
if (!repo.check_branches) patch.check_branches = names.join(",") || patch.managed_branch;
const updated = upsertRepository(db, patch);
return json(res, 200, {
repo: updated,
default_branch: info.default_branch,
has_issues: info.has_issues,
has_pull_requests: info.has_pull_requests,
branches: names,
managed_branch_exists: names.includes(updated.managed_branch),
});
} catch (err) {
return json(res, 502, { error: err.message });
}
}
/** List branches for a repository that is not saved yet. */
if (path === "/repos/branches" && req.method === "POST") {
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
try {
const parsed = body.owner && body.name ? body : parseRepoUrl(body.repo_url);
const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: cfg.giteaToken });
const info = await client.getRepo(parsed.owner, parsed.name);
const branches = await client.listRepoBranches(parsed.owner, parsed.name);
return json(res, 200, {
owner: parsed.owner,
name: parsed.name,
default_branch: info.default_branch,
has_issues: info.has_issues,
has_pull_requests: info.has_pull_requests,
branches: branches.map((b) => b.name),
});
} catch (err) {
@@ -347,6 +435,71 @@ async function handleApi(req, res, url, cfg) {
}
}
/** Create the managed branch, optionally cloned from another branch. */
const branchMatch = /^\/repos\/(\d+)\/branches$/.exec(path);
if (branchMatch && req.method === "POST") {
const repo = getRepository(db, Number(branchMatch[1]));
if (!repo) return json(res, 404, { error: "repository not found" });
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
const newBranch = String(body.new_branch || "").trim();
const fromBranch = String(body.from_branch || "").trim();
if (!newBranch) return json(res, 400, { error: "请填写要创建的分支名" });
const client = new GiteaClient({
baseUrl: cfg.giteaUrl,
token: repo.gitea_token || cfg.giteaToken,
});
try {
await client.createBranch(repo.owner, repo.name, { newBranch, fromBranch: fromBranch || undefined });
const branches = await client.listRepoBranches(repo.owner, repo.name);
const names = branches.map((b) => b.name);
const updated = upsertRepository(db, {
id: repo.id,
managed_branch: newBranch,
check_branches: names.join(","),
});
return json(res, 201, { repo: updated, branches: names });
} catch (err) {
return json(res, 502, { error: err.message });
}
}
/* ------------------------------ review records ------------------------- */
if (path === "/records" && req.method === "GET") {
const repoId = url.searchParams.get("repo_id");
const limit = Math.min(Number(url.searchParams.get("limit") || 100), 500);
const rows = listReviewRecords(db, { repoId: repoId ? Number(repoId) : undefined, limit });
return json(res, 200, rows.map((r) => ({
...r,
findings_json: undefined,
params_json: undefined,
findings_count: safeCount(r.findings_json),
})));
}
const recordMatch = /^\/records\/(\d+)$/.exec(path);
if (recordMatch && req.method === "GET") {
const record = getReviewRecord(db, Number(recordMatch[1]));
if (!record) return json(res, 404, { error: "record not found" });
const repo = getRepository(db, record.repo_id);
return json(res, 200, {
...record,
findings: safeParse(record.findings_json, []),
params: safeParse(record.params_json, {}),
repository: repo ? `${repo.owner}/${repo.name}` : null,
});
}
const resummariseMatch = /^\/records\/(\d+)\/summarise$/.exec(path);
if (resummariseMatch && req.method === "POST") {
const record = getReviewRecord(db, Number(resummariseMatch[1]));
if (!record) return json(res, 404, { error: "record not found" });
updateReviewRecord(db, record.id, {
summary_status: "pending", summary_error: null,
});
return json(res, 202, { ok: true, queued: true });
}
if (path === "/jobs" && req.method === "GET") {
const repoId = url.searchParams.get("repo_id");
const limit = Math.min(Number(url.searchParams.get("limit") || 50), 200);
@@ -387,8 +540,8 @@ async function handleApi(req, res, url, cfg) {
const jobId = enqueueJob(db, {
repoId: repo.id,
trigger: "manual",
refName: body.ref || repo.base_branch,
baseRef: body.base_ref || repo.base_branch,
refName: body.ref || repo.managed_branch,
baseRef: body.base_ref || repo.managed_branch,
fromSha: body.from_sha || null,
toSha,
prNumber: body.pr_number || null,