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:
+109
-16
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user