Files
git-codereview/app/lib/summary.js
T
kgod 6dc77ce907 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
- 审查背景改为多行文本域(可滚动)
- 分支改为可点选列表,管理分支高亮
- 仓库表格展示管理分支与检查分支
2026-09-20 12:38:23 +08:00

167 lines
7.0 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 };