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