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
+109 -16
View File
@@ -8,7 +8,9 @@ import { join } from "node:path";
import { GiteaClient, webBaseUrl, normalizeBaseUrl } from "./gitea.js";
import { OcrRunner, parseList, severityAtLeast } from "./ocr.js";
import { parseUnifiedDiff, pickAnchorLine } from "./diff.js";
import { getBranchState, setBranchState, updateJob } from "./db.js";
import {
createReviewRecord, getBranchState, setBranchState, updateJob, updateReviewRecord,
} from "./db.js";
export const SUMMARY_MARKER = "<!-- gitea-codereview:summary -->";
export const COMMENT_MARKER = "<!-- gitea-codereview -->";
@@ -26,21 +28,43 @@ function repoSlug(repo) {
return `${repo.owner}/${repo.name}`;
}
function globToRegExp(pattern) {
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
const body = escaped
.replace(/\*\*/g, "\u0000")
.replace(/\*/g, "[^/]*")
.replace(/\?/g, ".")
.replace(/\u0000/g, ".*");
return new RegExp(`^${body}$`);
/**
* Parse a git remote URL into { owner, name, host }.
* Accepts https://host/owner/repo.git, http://host/owner/repo,
* ssh://git@host:2222/owner/repo.git and git@host:owner/repo.git.
*/
export function parseRepoUrl(input) {
const raw = String(input || "").trim();
if (!raw) throw new Error("请填写 Git 地址");
let host = "";
let path = "";
const scp = /^(?:[^@/]+@)?([^:/]+):(?!\d+\/)(.+)$/.exec(raw);
if (scp && !raw.includes("://")) {
host = scp[1];
path = scp[2];
} else {
let url;
try {
url = new URL(raw);
} catch {
throw new Error(`无法识别的 Git 地址:${raw}`);
}
host = url.host;
path = url.pathname;
}
const parts = path.replace(/^\/+/, "").replace(/\.git$/, "").split("/").filter(Boolean);
if (parts.length < 2) throw new Error(`Git 地址缺少 所有者/仓库名:${raw}`);
const name = parts.pop();
const owner = parts.join("/");
return { owner, name, host };
}
export function branchMatches(refName, patterns) {
const list = parseList(patterns);
if (list.length === 0 || list.includes("*")) return true;
const short = refName.replace(/^refs\/heads\//, "");
return list.some((p) => globToRegExp(p).test(short) || globToRegExp(p).test(refName));
/** Whether `refName` is one of the branches this repository reviews. */
export function branchMatches(refName, checkBranches) {
const list = parseList(checkBranches);
if (list.length === 0) return true;
const short = String(refName || "").replace(/^refs\/heads\//, "");
return list.some((b) => b === short || b === refName);
}
function badge(comment) {
@@ -148,6 +172,9 @@ async function resolveRange(runner, { repo, job, workspace, token }) {
const headRef = `refs/heads/${job.ref_name}`;
const refs = [headRef, `+${headRef}:refs/remotes/origin/${job.ref_name}`];
if (job.base_ref) refs.push(`+refs/heads/${job.base_ref}:refs/remotes/origin/${job.base_ref}`);
if (job.managed_branch && job.managed_branch !== job.base_ref) {
refs.push(`+refs/heads/${job.managed_branch}:refs/remotes/origin/${job.managed_branch}`);
}
if (job.pr_number) refs.push(`+refs/pull/${job.pr_number}/head:refs/remotes/origin/pr/${job.pr_number}`);
await runner.fetch(workspace, { refs, token });
@@ -161,8 +188,11 @@ async function resolveRange(runner, { repo, job, workspace, token }) {
if (job.from_sha) {
fromSha = await runner.revParse(workspace, job.from_sha);
}
if (!fromSha && job.base_ref) {
const baseSha = await runner.revParse(workspace, `refs/remotes/origin/${job.base_ref}`);
// The review base is the managed branch: every checked branch is compared
// against what it would merge into, not against its own previous commit.
const baseBranch = job.managed_branch || job.base_ref;
if (!fromSha && baseBranch) {
const baseSha = await runner.revParse(workspace, `refs/remotes/origin/${baseBranch}`);
if (baseSha) fromSha = await runner.mergeBase(workspace, baseSha, toSha) ?? baseSha;
}
if (!fromSha) {
@@ -222,6 +252,8 @@ export class ReviewEngine {
await mkdir(root, { recursive: true });
const dir = join(root, `${repo.owner}__${repo.name}`);
if (existsSync(join(dir, ".git"))) return dir;
// Reaching here means the workspace is missing or was removed; a fresh
// clone always persists the credential for later fetches.
await rm(dir, { recursive: true, force: true });
const cloneUrl = `${normalizeBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}.git`;
const runner = new OcrRunner({ command: this.config.ocrCommand, logger: this.log });
@@ -253,6 +285,9 @@ export class ReviewEngine {
setPhase("preparing");
const workspace = await this.ensureWorkspace(repo);
// Jobs carry the managed branch so the fetch/merge-base logic does not need
// to re-read the repository row.
job.managed_branch = repo.managed_branch || repo.base_branch;
const { fromSha, toSha } = await resolveRange(runner, {
repo, job, workspace, token: repo.gitea_token || this.config.giteaToken,
});
@@ -441,6 +476,57 @@ export class ReviewEngine {
warnings: review.warnings,
};
// Persist the review record that the history list and summariser consume.
let recordId = null;
try {
recordId = createReviewRecord(this.db, {
jobId: job.id,
repoId: repo.id,
refName: job.ref_name,
fromSha,
toSha,
prNumber,
prUrl: prNumber
? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}`
: null,
trigger: job.trigger,
source: job.trigger?.startsWith("pull_request") ? "pull_request" : "push",
requirement: (repo.background_template || "").trim() || null,
paramsJson: JSON.stringify({
managed_branch: job.managed_branch || null,
base_ref: job.base_ref ?? null,
review_scope: repo.review_scope,
block_severity: repo.block_severity,
block_categories: repo.block_categories,
publish_mode: repo.publish_mode,
auto_merge: Boolean(repo.auto_merge),
auto_merge_mode: repo.auto_merge_mode,
merge_method: repo.merge_method,
concurrency: repo.concurrency,
excludes: repo.excludes || null,
max_comments: repo.max_comments,
review_incomplete: reviewIncomplete,
}),
findingsJson: JSON.stringify(published.map((p) => ({
path: p.comment.path,
start_line: p.comment.start_line,
end_line: p.comment.end_line,
severity: p.comment.severity,
category: p.comment.category,
content: p.comment.content,
inline: p.inline,
blocking: blocking.includes(p),
}))),
llmModel: review.summary?.model ?? repo.llm_model ?? this.config.llmModel,
llmStatus: review.status,
tokensTotal: review.summary?.total_tokens ?? 0,
elapsed: review.summary?.elapsed ?? null,
});
result.recordId = recordId;
} catch (err) {
appendLog(`review record failed: ${err.message}`);
}
updateJob(this.db, job.id, {
status: "succeeded",
phase: "done",
@@ -552,6 +638,13 @@ export class ReviewEngine {
appendLog("auto-merge skipped: no pull request for this branch");
return false;
}
// Only merges into the managed branch are performed; a PR targeting any
// other branch is reviewed but never auto-merged.
const managed = repo.managed_branch || repo.base_branch;
if (job.base_ref && managed && job.base_ref !== managed) {
appendLog(`auto-merge skipped: PR targets ${job.base_ref}, managed branch is ${managed}`);
return false;
}
if (reviewIncomplete) {
appendLog("auto-merge skipped: review coverage was incomplete");
return false;