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:
+173
-20
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user