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
+167
View File
@@ -0,0 +1,167 @@
/**
* Review-history summariser.
*
* Deliberately separate from the OpenCodeReview pipeline: OCR decides what is
* wrong with a diff; this module turns a finished review into a short,
* uniformly formatted record for the history list. It shares the global LLM
* settings but uses its own prompt, and it never influences blocking or merge
* decisions.
*/
export const SUMMARY_MAX_CHARS = 500;
export const SUMMARY_PROMPT_VERSION = "review-history-summary/v1";
export const SUMMARY_SECTIONS = ["结论", "范围", "问题", "要点"];
const SYSTEM_PROMPT = [
"你是一名代码审查记录整理员。你会收到一次自动代码审查的结构化结果。",
"你的唯一任务是把这次审查整理成一段简洁、客观的中文记录,供团队在审查历史里快速浏览。",
"",
"严格要求:",
"1. 只依据输入内容,不要推测、不要补充未出现的信息,不要提出新建议。",
"2. 不要评价审查工具本身,不要输出“建议进一步检查”之类的话。",
"3. 按下面的固定格式输出,保留小标题,每节一行到两行:",
"结论:<通过 / 有阻断问题 / 有非阻断问题 / 未发现变更>",
"范围:<审查了哪些文件、多少行;信息不足写“未知”>",
"问题:<按严重级别归纳问题类型与数量;无问题写“无”>",
"要点:<最值得关注的一到三条具体问题,写明文件与行为;无问题写“无”>",
"4. 全文不超过 500 个字符,不要使用 Markdown 标题符号、代码块或列表符号。",
"5. 直接输出上述四节内容,不要任何前言、后缀或解释。",
].join("\n");
function clip(text, max) {
const value = String(text ?? "");
return value.length <= max ? value : `${value.slice(0, max)}…`;
}
/** Build the user message describing one finished review. */
export function buildSummaryPrompt({ repo, record, findings, summary, requirement }) {
const lines = [];
lines.push(`仓库:${repo.owner}/${repo.name}`);
lines.push(`分支:${record.ref_name}`);
if (record.pr_number) lines.push(`Pull Request:#${record.pr_number}`);
lines.push(`提交:${record.to_sha}`);
if (record.from_sha) lines.push(`对比基准:${record.from_sha}`);
if (requirement) lines.push(`本次需求/背景:${clip(requirement, 600)}`);
if (summary) {
const bits = [];
if (summary.files_reviewed != null) bits.push(`审查文件 ${summary.files_reviewed} 个`);
if (summary.comments != null) bits.push(`原始意见 ${summary.comments} 条`);
if (summary.total_tokens) bits.push(`消耗 ${summary.total_tokens} tokens`);
if (summary.elapsed) bits.push(`耗时 ${summary.elapsed}`);
if (bits.length) lines.push(`运行数据:${bits.join(",")}`);
}
if (record.llm_status) lines.push(`审查状态:${record.llm_status}`);
lines.push("", "审查发现:");
if (!findings.length) {
lines.push("(本次没有产生任何意见)");
} else {
for (const f of findings) {
const where = f.start_line
? `${f.path}:${f.start_line}${f.end_line && f.end_line !== f.start_line ? `-${f.end_line}` : ""}`
: f.path;
const tags = [f.severity, f.category].filter(Boolean).join("/");
lines.push(`- [${tags || "未分级"}] ${where} ${clip(f.content, 300)}`);
}
}
return lines.join("\n");
}
/**
* Call an OpenAI- or Anthropic-compatible endpoint with the built-in fetch, so
* the runtime image needs no extra HTTP client.
*/
async function runLlm(llm, messages, timeoutMs) {
const base = String(llm.url).replace(/\/+$/, "");
const isAnthropic = String(llm.protocol || "").toLowerCase().includes("anthropic");
const endpoint = isAnthropic
? (base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`)
: (base.endsWith("/v1") ? `${base}/chat/completions` : `${base}/v1/chat/completions`);
const headers = { "Content-Type": "application/json" };
if (isAnthropic) {
headers[llm.authHeader || "x-api-key"] = llm.token;
headers["anthropic-version"] = "2023-06-01";
} else {
headers.Authorization = `Bearer ${llm.token}`;
}
for (const pair of String(llm.extraHeaders || "").split(",")) {
const [k, ...rest] = pair.split("=");
if (k && rest.length) headers[k.trim()] = rest.join("=").trim();
}
const payload = isAnthropic
? {
model: llm.model,
max_tokens: 1024,
system: messages[0].content,
messages: [{ role: "user", content: messages[1].content }],
}
: { model: llm.model, messages, max_tokens: 1024, stream: false };
let res;
try {
res = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify(payload),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const reason = err.name === "TimeoutError" ? `请求超时(${timeoutMs} ms)` : err.message;
return { ok: false, error: `调用 LLM 失败:${reason}` };
}
const text = await res.text();
let parsed;
try {
parsed = JSON.parse(text);
} catch {
return { ok: false, error: `无法解析 LLM 响应(HTTP ${res.status}):${clip(text, 200)}` };
}
if (!res.ok) {
const msg = typeof parsed.error === "string"
? parsed.error
: (parsed.error?.message || JSON.stringify(parsed.error || parsed));
return { ok: false, error: `LLM 返回 HTTP ${res.status}:${clip(msg, 200)}` };
}
const content = isAnthropic
? (parsed.content || []).map((p) => p.text || "").join("")
: (parsed.choices?.[0]?.message?.content ?? "");
if (!content) return { ok: false, error: `LLM 返回空内容:${clip(text, 200)}` };
return { ok: true, text: String(content).trim(), servedModel: parsed.model || null };
}
/** Normalise the model output into the fixed four-section shape. */
export function normaliseSummary(text) {
const cleaned = String(text || "")
.replace(/```[a-z]*\n?/gi, "")
.replace(/^\s*#{1,6}\s*/gm, "")
.trim();
const found = SUMMARY_SECTIONS.filter((s) => new RegExp(`^\\s*${s}[::]`, "m").test(cleaned));
const collapsed = cleaned.replace(/\n{3,}/g, "\n\n");
const clipped = collapsed.length > SUMMARY_MAX_CHARS
? `${collapsed.slice(0, SUMMARY_MAX_CHARS - 1)}…`
: collapsed;
return { text: clipped, complete: found.length === SUMMARY_SECTIONS.length, sectionsFound: found };
}
/**
* Summarise one review record.
* @returns {Promise<{ok: boolean, text?: string, error?: string, model?: string}>}
*/
export async function summariseReview({ llm, repo, record, findings, summary, requirement, timeoutMs = 120000 }) {
if (!llm?.url || !llm?.token || !llm?.model) {
return { ok: false, error: "未配置全局 LLM,无法生成审查摘要" };
}
const messages = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: buildSummaryPrompt({ repo, record, findings, summary, requirement }) },
];
const res = await runLlm(llm, messages, timeoutMs);
if (!res.ok) return res;
const normalised = normaliseSummary(res.text);
return { ok: true, text: normalised.text, model: res.servedModel || llm.model, complete: normalised.complete };
}
export { SYSTEM_PROMPT };