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
+161 -3
View File
@@ -16,9 +16,12 @@ CREATE TABLE IF NOT EXISTS repositories (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL,
name TEXT NOT NULL,
repo_url TEXT,
enabled INTEGER NOT NULL DEFAULT 1,
base_branch TEXT NOT NULL DEFAULT 'main',
branch_patterns TEXT NOT NULL DEFAULT '*',
-- The single branch every reviewed branch is merged into.
managed_branch TEXT NOT NULL DEFAULT 'main',
-- Comma-separated branches that are reviewed and may be merged up.
check_branches TEXT NOT NULL DEFAULT 'main',
review_scope TEXT NOT NULL DEFAULT 'both',
gitea_token TEXT,
llm_provider TEXT,
@@ -79,6 +82,36 @@ CREATE TABLE IF NOT EXISTS jobs (
finished_at TEXT
);
CREATE TABLE IF NOT EXISTS review_records (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
ref_name TEXT NOT NULL,
from_sha TEXT,
to_sha TEXT NOT NULL,
pr_number INTEGER,
pr_url TEXT,
trigger TEXT,
source TEXT,
requirement TEXT,
params_json TEXT,
findings_json TEXT,
llm_model TEXT,
llm_status TEXT,
summary_status TEXT NOT NULL DEFAULT 'pending',
summary TEXT,
summary_error TEXT,
summary_model TEXT,
tokens_total INTEGER DEFAULT 0,
elapsed TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
summarized_at TEXT
);
CREATE INDEX IF NOT EXISTS idx_review_records_repo ON review_records (repo_id, id DESC);
CREATE INDEX IF NOT EXISTS idx_review_records_job ON review_records (job_id);
CREATE INDEX IF NOT EXISTS idx_review_records_summary ON review_records (summary_status, id);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status, id);
CREATE INDEX IF NOT EXISTS idx_jobs_repo ON jobs (repo_id, id DESC);
CREATE INDEX IF NOT EXISTS idx_jobs_sha ON jobs (repo_id, to_sha);
@@ -89,10 +122,57 @@ CREATE TABLE IF NOT EXISTS webhook_deliveries (
);
`;
function tableColumns(db, table) {
try {
return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((r) => r.name));
} catch {
return new Set();
}
}
function addColumnIfMissing(db, table, column, definition) {
if (tableColumns(db, table).has(column)) return false;
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
return true;
}
/**
* Bring an existing database up to the current schema.
* SQLite cannot drop or rename columns in place, so the pre-branch-model
* `base_branch` / `branch_patterns` columns are left in place but ignored;
* their values seed the new `managed_branch` / `check_branches` columns once.
*/
function migrate(db) {
const addedManaged = addColumnIfMissing(db, "repositories", "managed_branch", "TEXT NOT NULL DEFAULT 'main'");
addColumnIfMissing(db, "repositories", "check_branches", "TEXT NOT NULL DEFAULT 'main'");
addColumnIfMissing(db, "repositories", "repo_url", "TEXT");
addColumnIfMissing(db, "jobs", "record_id", "INTEGER");
const cols = tableColumns(db, "repositories");
if (addedManaged && cols.has("base_branch")) {
// Seed from the legacy single-branch configuration.
db.exec("UPDATE repositories SET managed_branch = COALESCE(NULLIF(base_branch, ''), 'main')");
}
if (cols.has("branch_patterns")) {
// A legacy glob list becomes the explicit list of branches to check.
const rows = db.prepare("SELECT id, branch_patterns, check_branches FROM repositories").all();
for (const row of rows) {
const legacy = String(row.branch_patterns || "").trim();
const current = String(row.check_branches || "").trim();
if (!legacy || legacy === "*" || current !== "main") continue;
const branches = legacy.split(",").map((x) => x.trim()).filter((x) => x && !x.includes("*") && !x.includes("?"));
if (branches.length) {
db.prepare("UPDATE repositories SET check_branches = ? WHERE id = ?").run(branches.join(","), row.id);
}
}
}
}
export function openDatabase(path) {
mkdirSync(dirname(path), { recursive: true });
const db = new DatabaseSync(path);
db.exec(SCHEMA);
migrate(db);
return db;
}
@@ -129,7 +209,7 @@ export function findRepository(db, owner, name) {
}
const REPO_FIELDS = [
"owner", "name", "enabled", "base_branch", "branch_patterns", "review_scope",
"owner", "name", "repo_url", "enabled", "managed_branch", "check_branches", "review_scope",
"gitea_token", "llm_provider", "llm_model", "llm_base_url", "llm_token",
"rule_path", "background_template", "excludes", "publish_mode", "create_issue",
"issue_labels", "block_severity", "block_categories", "fail_on_findings",
@@ -263,6 +343,84 @@ export function pruneDeliveries(db, keep = 2000) {
).run(keep);
}
/* ------------------------------ review records ---------------------------- */
export function createReviewRecord(db, record) {
const info = db.prepare(
`INSERT INTO review_records
(job_id, repo_id, ref_name, from_sha, to_sha, pr_number, pr_url, trigger, source,
requirement, params_json, findings_json, llm_model, llm_status, tokens_total, elapsed,
summary_status)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')`,
).run(
record.jobId, record.repoId, record.refName, record.fromSha ?? null, record.toSha,
record.prNumber ?? null, record.prUrl ?? null, record.trigger ?? null, record.source ?? null,
record.requirement ?? null, record.paramsJson ?? null, record.findingsJson ?? null,
record.llmModel ?? null, record.llmStatus ?? null, record.tokensTotal ?? 0, record.elapsed ?? null,
);
const id = Number(info.lastInsertRowid);
db.prepare("UPDATE jobs SET record_id = ? WHERE id = ?").run(id, record.jobId);
return id;
}
export function updateReviewRecord(db, id, patch) {
const allowed = [
"pr_url", "requirement", "params_json", "findings_json", "llm_model", "llm_status",
"summary_status", "summary", "summary_error", "summary_model", "tokens_total", "elapsed",
];
const keys = Object.keys(patch).filter((k) => allowed.includes(k));
if (keys.length === 0) return;
const sets = keys.map((k) => `${k} = ?`);
if (patch.summary_status && ["done", "failed", "skipped"].includes(patch.summary_status)) {
sets.push("summarized_at = datetime('now')");
}
db.prepare(`UPDATE review_records SET ${sets.join(", ")} WHERE id = ?`).run(
...keys.map((k) => patch[k]), id,
);
}
export function getReviewRecord(db, id) {
return db.prepare("SELECT * FROM review_records WHERE id = ?").get(id);
}
export function getReviewRecordByJob(db, jobId) {
return db.prepare("SELECT * FROM review_records WHERE job_id = ?").get(jobId);
}
export function listReviewRecords(db, { repoId, limit = 100 } = {}) {
if (repoId) {
return db.prepare(
`SELECT rr.*, r.owner, r.name FROM review_records rr
JOIN repositories r ON r.id = rr.repo_id
WHERE rr.repo_id = ? ORDER BY rr.id DESC LIMIT ?`,
).all(repoId, limit);
}
return db.prepare(
`SELECT rr.*, r.owner, r.name FROM review_records rr
JOIN repositories r ON r.id = rr.repo_id
ORDER BY rr.id DESC LIMIT ?`,
).all(limit);
}
export function claimPendingSummary(db) {
const row = db.prepare(
"SELECT * FROM review_records WHERE summary_status = 'pending' ORDER BY id LIMIT 1",
).get();
if (!row) return null;
const info = db.prepare(
"UPDATE review_records SET summary_status = 'running' WHERE id = ? AND summary_status = 'pending'",
).run(row.id);
if (info.changes === 0) return null;
return db.prepare("SELECT * FROM review_records WHERE id = ?").get(row.id);
}
export function requeueStaleSummaries(db) {
const info = db.prepare(
"UPDATE review_records SET summary_status = 'pending' WHERE summary_status = 'running'",
).run();
return info.changes;
}
export function jobStats(db) {
const rows = db.prepare("SELECT status, COUNT(*) AS n FROM jobs GROUP BY status").all();
const out = { queued: 0, running: 0, succeeded: 0, failed: 0, skipped: 0 };
+31
View File
@@ -96,6 +96,37 @@ export class GiteaClient {
return this.get(`/api/v1/repos/${owner}/${repo}`);
}
/** Repositories visible to the token, across pages. */
async listAccessibleRepos(limit = 50, maxPages = 40) {
const out = [];
const seen = new Set();
for (let page = 1; page <= maxPages; page += 1) {
let batch;
try {
batch = await this.get("/api/v1/user/repos", { query: { limit, page } });
} catch (err) {
if (page === 1) throw err;
break;
}
if (!Array.isArray(batch) || batch.length === 0) break;
for (const r of batch) {
if (r?.full_name && !seen.has(r.full_name)) {
seen.add(r.full_name);
out.push(r);
}
}
if (batch.length < limit) break;
}
return out;
}
/** Create a branch, optionally from another branch/tag/commit. */
async createBranch(owner, repo, { newBranch, fromBranch }) {
const payload = { new_branch_name: newBranch };
if (fromBranch) payload.old_ref_name = fromBranch;
return this.post(`/api/v1/repos/${owner}/${repo}/branches`, payload);
}
async listRepoBranches(owner, repo, limit = 100) {
const out = [];
for (let page = 1; page <= 20; page += 1) {
+25 -12
View File
@@ -115,20 +115,19 @@ export class OcrRunner {
};
}
async gitClone({ cloneUrl, token, dir, extraHeader = true }) {
const args = ["clone", "--no-tags", "--filter=blob:none"];
async gitClone({ cloneUrl, token, dir }) {
// No partial clone: a blob:none clone defers blob downloads to later
// `git show` / `git grep` calls, and those lazily-fetched requests do not
// inherit the per-command extraHeader, so private repositories fail with
// "could not read Username". A full clone keeps every read local.
const args = ["clone", "--no-tags"];
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
if (token) {
if (extraHeader) {
env.GIT_CONFIG_COUNT = "1";
env.GIT_CONFIG_KEY_0 = "http.extraHeader";
env.GIT_CONFIG_VALUE_0 = `Authorization: token ${token}`;
} else {
const url = new URL(cloneUrl);
url.username = "oauth2";
url.password = token;
cloneUrl = url.toString();
}
// Written into .git/config by `git clone`, so every later fetch/rebase in
// this workspace authenticates without extra plumbing.
env.GIT_CONFIG_COUNT = "1";
env.GIT_CONFIG_KEY_0 = "http.extraHeader";
env.GIT_CONFIG_VALUE_0 = `Authorization: token ${token}`;
}
args.push(cloneUrl, dir);
const res = await run("git", args, { env, timeoutMs: this.gitTimeoutMs });
@@ -137,10 +136,24 @@ export class OcrRunner {
exitCode: res.code, stderr: res.stderr, stdout: res.stdout,
});
}
// Persist the auth header for this workspace so later fetches keep working
// even if the caller forgets to pass a token.
if (token) {
const cfg = await run("git", ["config", "--local", "http.extraHeader", `Authorization: token ${token}`], {
cwd: dir, timeoutMs: 60000,
});
if (cfg.code !== 0) {
throw new OcrError("failed to persist repository credentials", {
exitCode: cfg.code, stderr: cfg.stderr, stdout: cfg.stdout,
});
}
}
return dir;
}
async fetch(dir, { refs = [], token } = {}) {
// GIT_TERMINAL_PROMPT=0 keeps a missing credential a hard error instead of
// a hanging prompt inside the container.
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
if (token) {
env.GIT_CONFIG_COUNT = "1";
+68 -1
View File
@@ -1,6 +1,10 @@
/** Sequential job worker with retry and stale-job recovery. */
import { claimNextJob, updateJob } from "./db.js";
import {
claimNextJob, claimPendingSummary, requeueStaleSummaries,
updateJob, updateReviewRecord,
} from "./db.js";
import { SkipJob } from "./review.js";
import { summariseReview } from "./summary.js";
const STALE_MS = 2 * 60 * 60 * 1000;
@@ -21,6 +25,8 @@ export class JobQueue {
if (this.running) return;
this.running = true;
this.recoverStale();
const requeued = requeueStaleSummaries(this.db);
if (requeued) this.logger.warn?.(`requeued ${requeued} interrupted summariser task(s)`);
this.tick();
}
@@ -51,6 +57,8 @@ export class JobQueue {
}
const job = claimNextJob(this.db);
if (!job) {
// Nothing to review: use the idle slot for pending summaries.
await this.runSummaryOnce();
this.timer = setTimeout(() => this.tick(), this.pollMs);
return;
}
@@ -74,6 +82,65 @@ export class JobQueue {
this.busy = false;
this.currentJobId = null;
}
// Summarising runs on the same worker so it never competes with a review
// for the LLM endpoint.
await this.runSummaryOnce();
this.timer = setTimeout(() => this.tick(), this.pollMs);
}
/**
* Summarise one finished review, if any is pending.
* Failures are recorded on the record and never block the queue.
*/
async runSummaryOnce() {
const record = claimPendingSummary(this.db);
if (!record) return false;
try {
const repo = this.db.prepare("SELECT * FROM repositories WHERE id = ?").get(record.repo_id);
if (!repo) {
updateReviewRecord(this.db, record.id, {
summary_status: "skipped", summary_error: "repository no longer exists",
});
return false;
}
let findings = [];
try { findings = JSON.parse(record.findings_json || "[]"); } catch { findings = []; }
const job = this.db.prepare("SELECT result_json FROM jobs WHERE id = ?").get(record.job_id);
let summary = null;
try { summary = JSON.parse(job?.result_json || "{}").summary ?? null; } catch { summary = null; }
const llm = {
url: repo.llm_base_url || this.engine.config.llmUrl,
token: repo.llm_token || this.engine.config.llmToken,
model: repo.llm_model || this.engine.config.llmModel,
protocol: repo.llm_provider || this.engine.config.llmProtocol,
authHeader: this.engine.config.llmAuthHeader,
extraHeaders: this.engine.config.llmExtraHeaders,
};
this.logger.info?.(`summarising review record #${record.id} (${repo.owner}/${repo.name} ${record.ref_name})`);
const res = await summariseReview({
llm, repo, record, findings, summary, requirement: record.requirement,
});
if (res.ok) {
updateReviewRecord(this.db, record.id, {
summary_status: "done",
summary: res.text,
summary_model: res.model,
summary_error: res.complete ? null : "摘要缺少标准小节,已按原文保存",
});
this.logger.info?.(`review record #${record.id} summarised (${res.text.length} chars)`);
} else {
updateReviewRecord(this.db, record.id, {
summary_status: "failed", summary_error: String(res.error).slice(0, 500),
});
this.logger.warn?.(`review record #${record.id} summary failed: ${res.error}`);
}
} catch (err) {
updateReviewRecord(this.db, record.id, {
summary_status: "failed", summary_error: String(err.message || err).slice(0, 500),
});
this.logger.warn?.(`review record #${record.id} summary error: ${err.message}`);
}
return true;
}
}
+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;
+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 };