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:
+161
-3
@@ -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 };
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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;
|
||||
|
||||
@@ -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