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