From 6dc77ce9077b6c914634bc6c5da3c9324b11f11d Mon Sep 17 00:00:00 2001 From: kgod <1257628228@qq.com> Date: Sun, 20 Sep 2026 12:38:23 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=93=E5=BA=93=E5=AF=BC=E5=85=A5?= =?UTF-8?q?=E3=80=81=E5=88=86=E6=94=AF=E6=A8=A1=E5=9E=8B=E9=87=8D=E6=9E=84?= =?UTF-8?q?=E4=B8=8E=E5=AE=A1=E6=9F=A5=E5=8E=86=E5=8F=B2=E6=91=98=E8=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 仓库接入 - 新增「从 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 - 审查背景改为多行文本域(可滚动) - 分支改为可点选列表,管理分支高亮 - 仓库表格展示管理分支与检查分支 --- README.md | 50 ++++- app/lib/db.js | 164 +++++++++++++++- app/lib/gitea.js | 31 +++ app/lib/ocr.js | 37 ++-- app/lib/queue.js | 69 ++++++- app/lib/review.js | 125 ++++++++++-- app/lib/summary.js | 167 ++++++++++++++++ app/server.js | 193 +++++++++++++++++-- app/static/app.js | 434 +++++++++++++++++++++++++++++++++++++----- app/static/index.html | 161 ++++++++++++---- app/static/style.css | 57 +++++- tests/core.test.js | 39 +++- 12 files changed, 1374 insertions(+), 153 deletions(-) create mode 100644 app/lib/summary.js diff --git a/README.md b/README.md index 729daa7..0b334ac 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,21 @@ Push / Pull Request 事件 ## 功能 +**仓库接入** +- 「从 Gitea 导入」一键列出全局 Token 可见的仓库,点「添加」即建好监控配置 +- 手动新增时只填 Git 地址,自动识别 `所有者/仓库名`,旁边可「测试连接」验证 +- 支持 https / ssh / scp 三种地址写法 + +**分支模型** +- 一个「管理分支」作为唯一合并目标 +- 多个「检查分支」全部纳入监控,点选即可增减 +- 管理分支不存在时可「克隆创建」,从任意已有分支复制 +- 审查始终以管理分支为对比基准,即「这些改动合入管理分支会怎样」 + **审查触发** -- 监听分支推送,自动对比上次提交或与目标分支的 merge-base +- 监听检查分支的推送,对比管理分支的 merge-base - 监听 Pull Request 的 opened / synchronize / reopened / ready_for_review -- 按分支 glob 过滤(`*`、`release/*`、`main`),可选只审 PR 或只审 push +- 可选只审 PR 或只审 push - 同一 commit 在队列中只入队一次;webhook 按 delivery id 去重 **发布结果** @@ -38,8 +49,20 @@ Push / Pull Request 事件 - 合并方式、是否删除分支可配 - PR head 已变化或 PR 不可合并时自动放弃 +**审查历史** +- 「审查历史」页按仓库汇总每次审查的摘要,可一键跳转 PR +- 每条记录保留完整上下文:触发来源、PR 链接、审查范围、阻断阈值、发布方式、 + 自动合并设置、排除路径、LLM 模型、token 消耗、耗时、本次填写的需求背景 +- 详情弹窗列出本次全部审查意见,标注严重级别与是否阻断 + +**AI 摘要** +- 独立的摘要模块,与代码审查的提示词完全分开,但共用同一份全局 LLM 设置 +- 专用提示词把一次审查压缩成固定四节、500 字内的记录: + `结论 / 范围 / 问题 / 要点` +- 摘要失败不影响审查结果,可单条重新生成 + **运维** -- 内置 Web 后台:仓库配置、任务列表、实时日志、重跑、连通性自检 +- 内置 Web 后台:仓库配置、审查历史、任务列表、实时日志、重跑、连通性自检 - 每次审查的完整日志和结果 JSON 落库 - worker 重启后自动回收卡住的任务,失败任务自动重试一次 - 全局或按仓库覆盖 Gitea token、LLM 端点 / 模型 / Key、排除路径、并发数 @@ -135,9 +158,10 @@ curl -fsS http://localhost:8090/api/health | 字段 | 说明 | | --- | --- | -| `branch_patterns` | 监听的分支 glob,逗号分隔;`*` 为全部 | +| `repo_url` | 仓库 Git 地址,`owner` / `name` 由此自动解析 | +| `managed_branch` | 唯一的管理分支,所有审查都相对它做对比,也是唯一允许自动合并的目标 | +| `check_branches` | 纳入监控的分支列表,逗号分隔;支持任意多个 | | `review_scope` | `both` / `pr` / `push` | -| `base_branch` | 对比基准分支,也是无 PR 时 push 审查的目标 | | `block_severity` | 阻断级别阈值,如 `critical,high`;留空则不看级别 | | `block_categories` | 额外按类别阻断,如 `security` | | `fail_on_findings` | 打开后任何意见都视为阻断 | @@ -163,6 +187,13 @@ curl -fsS http://localhost:8090/api/health | `GET` `POST` | `/api/repos` | 列出 / 新增仓库配置 | | `GET` `PATCH` `DELETE` | `/api/repos/:id` | 读取 / 修改 / 删除 | | `POST` | `/api/repos/:id/discover` | 校验连接,返回默认分支与分支列表 | +| `GET` | `/api/gitea/repos` | 列出全局 Token 可见的仓库,供一键导入 | +| `POST` | `/api/repos/parse` | 解析 Git 地址,返回 `owner` / `name` | +| `POST` | `/api/repos/branches` | 用 Git 地址查询分支(仓库尚未保存时使用) | +| `POST` | `/api/repos/:id/branches` | 创建分支,可从指定分支克隆 | +| `GET` | `/api/records` | 审查历史(`?repo_id=`、`?limit=`) | +| `GET` | `/api/records/:id` | 单条记录,含参数、意见与摘要 | +| `POST` | `/api/records/:id/summarise` | 重新排队生成摘要 | | `GET` | `/api/jobs` | 任务列表(`?repo_id=`、`?limit=`) | | `GET` | `/api/jobs/:id` | 任务详情,含日志 | | `POST` | `/api/jobs/:id/retry` | 重跑任务 | @@ -181,7 +212,11 @@ curl -X POST http://localhost:8090/api/review \ ## 行为说明 -**审查范围**:PR 事件用 `base.sha..head.sha`;push 事件用 webhook 里的 `before..after`,如果 `before` 是新建分支的全零值,则退回到与 `base_branch` 的 merge-base。 +**分支与合并**:`check_branches` 里的每个分支都会被监控审查,审查基准是 `managed_branch`(用 merge-base 计算),也就是「这些改动合入管理分支会怎样」。自动合并只作用于目标为 `managed_branch` 的 PR,其他分支的 PR 只审不合。 + +**审查范围**:PR 事件优先用 PR 的目标分支作为基准;push 事件优先用 webhook 里的 `before`,当 `before` 是新建分支的全零值或缺失时,退回到与管理分支的 merge-base。 + +**审查背景**:仓库配置里的「审查背景 / 需求」是多行文本,既作为 OCR 的 `--background` 传给模型,也会完整记录到审查历史,方便回溯「当时是按什么需求审的」。 **阻断判定**:一条意见命中任一条件即为阻断 —— 严重级别 ≥ `block_severity` 中任一项,类别在 `block_categories` 中,或打开了 `fail_on_findings`。阻断会创建 Issue、把提交状态置为 `failure`,并阻止自动合并。 @@ -209,6 +244,9 @@ Gitea 拦截了内网地址。按上文给 `[webhook] ALLOWED_HOST_LIST` 加上 **后台打开后要求输入访问 Token** 这是 `CR_ADMIN_TOKEN` 在生效。输入 `.env` 里的值即可,Token 只存在浏览器本地。想免登录就把它留空并重启容器(仅限不对外暴露的网络)。 +**历史记录一直显示「摘要生成中…」** +摘要与审查串行执行,一次只跑一个;如果 LLM 不可达会在记录里留下失败原因,可在详情里点「重新生成摘要」。摘要失败不会影响审查结论与自动合并。 + **任务一直停在 `reviewing`** LLM 慢或不可达。在后台「设置」点「测试 LLM」,或在「任务」页查看日志;调大 `CR_REVIEW_TIMEOUT_MS`。 diff --git a/app/lib/db.js b/app/lib/db.js index ab76957..2cc7709 100644 --- a/app/lib/db.js +++ b/app/lib/db.js @@ -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 }; diff --git a/app/lib/gitea.js b/app/lib/gitea.js index e639185..0d7d3ee 100644 --- a/app/lib/gitea.js +++ b/app/lib/gitea.js @@ -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) { diff --git a/app/lib/ocr.js b/app/lib/ocr.js index 4f82adf..f562ae2 100644 --- a/app/lib/ocr.js +++ b/app/lib/ocr.js @@ -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"; diff --git a/app/lib/queue.js b/app/lib/queue.js index 1229271..850ef24 100644 --- a/app/lib/queue.js +++ b/app/lib/queue.js @@ -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; + } } \ No newline at end of file diff --git a/app/lib/review.js b/app/lib/review.js index 411c9f2..05f1e66 100644 --- a/app/lib/review.js +++ b/app/lib/review.js @@ -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 = ""; export const COMMENT_MARKER = ""; @@ -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; diff --git a/app/lib/summary.js b/app/lib/summary.js new file mode 100644 index 0000000..3731eb9 --- /dev/null +++ b/app/lib/summary.js @@ -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 }; \ No newline at end of file diff --git a/app/server.js b/app/server.js index 046428a..35e4fc9 100644 --- a/app/server.js +++ b/app/server.js @@ -8,13 +8,14 @@ import { fileURLToPath } from "node:url"; import { allSettings, deleteRepository, enqueueJob, findJobBySha, findRepository, - getJob, getSetting, jobStats, listJobs, listRepositories, openDatabase, - recordDelivery, pruneDeliveries, setSetting, upsertRepository, getRepository, + getJob, getReviewRecord, jobStats, listJobs, listRepositories, listReviewRecords, + openDatabase, recordDelivery, pruneDeliveries, requeueStaleSummaries, setSetting, + updateReviewRecord, upsertRepository, getRepository, } from "./lib/db.js"; import { GiteaClient } from "./lib/gitea.js"; import { OcrRunner } from "./lib/ocr.js"; import { JobQueue } from "./lib/queue.js"; -import { ReviewEngine, SkipJob, branchMatches } from "./lib/review.js"; +import { ReviewEngine, SkipJob, branchMatches, parseRepoUrl } from "./lib/review.js"; const APP_DIR = resolve(fileURLToPath(new URL(".", import.meta.url))); const ROOT_DIR = resolve(APP_DIR, ".."); @@ -130,6 +131,15 @@ function verifySignature(secret, rawBody, signature) { return a.length === b.length && timingSafeEqual(a, b); } +function safeParse(value, fallback) { + try { return JSON.parse(value || "null") ?? fallback; } catch { return fallback; } +} + +function safeCount(value) { + const parsed = safeParse(value, []); + return Array.isArray(parsed) ? parsed.length : 0; +} + function authorized(req, cfg) { if (!cfg.adminToken) return true; const header = req.headers.authorization || ""; @@ -156,8 +166,8 @@ async function handlePush(payload, cfg) { const refName = refNameFromPayload(payload); if (!refName || payload.deleted) return { queued: 0, reason: "branch deletion or empty ref" }; - if (!branchMatches(refName, repo.branch_patterns)) { - return { queued: 0, reason: `branch ${refName} does not match patterns` }; + if (!branchMatches(refName, repo.check_branches)) { + return { queued: 0, reason: `branch ${refName} is not in the checked branch list` }; } const toSha = payload.after || payload.head_commit?.id; @@ -178,7 +188,7 @@ async function handlePush(payload, cfg) { repoId: repo.id, trigger: "push", refName, - baseRef: pr?.base?.ref ?? repo.base_branch, + baseRef: pr?.base?.ref ?? repo.managed_branch, fromSha: before, toSha, prNumber: pr?.number ?? null, @@ -202,8 +212,8 @@ async function handlePullRequest(payload, cfg) { const pr = payload.pull_request; if (!pr) return { queued: 0, reason: "no pull_request in payload" }; if (pr.draft) return { queued: 0, reason: "draft pull request" }; - if (!branchMatches(pr.head?.ref, repo.branch_patterns)) { - return { queued: 0, reason: `head branch ${pr.head?.ref} does not match patterns` }; + if (!branchMatches(pr.head?.ref, repo.check_branches)) { + return { queued: 0, reason: `head branch ${pr.head?.ref} is not in the checked branch list` }; } const toSha = pr.head?.sha; if (!toSha) return { queued: 0, reason: "no head sha" }; @@ -214,7 +224,7 @@ async function handlePullRequest(payload, cfg) { repoId: repo.id, trigger: `pull_request.${action}`, refName: pr.head.ref, - baseRef: pr.base?.ref ?? repo.base_branch, + baseRef: pr.base?.ref ?? repo.managed_branch, fromSha: pr.base?.sha ?? null, toSha, prNumber: pr.number, @@ -289,13 +299,32 @@ async function handleApi(req, res, url, cfg) { if (path === "/repos" && req.method === "POST") { const body = JSON.parse((await readBody(req)).toString("utf8") || "{}"); - if (!body.owner || !body.name) return json(res, 400, { error: "owner and name are required" }); + let owner = body.owner; + let name = body.name; + let repoUrl = body.repo_url || null; + // Owner/name are normally derived from the git URL, not typed by hand. + if (repoUrl && (!owner || !name)) { + try { + const parsed = parseRepoUrl(repoUrl); + owner = parsed.owner; + name = parsed.name; + } catch (err) { + return json(res, 400, { error: err.message }); + } + } + if (!owner || !name) return json(res, 400, { error: "请填写 Git 地址" }); + if (!repoUrl) { + repoUrl = `${String(cfg.giteaUrl).replace(/\/+$/, "")}/${owner}/${name}.git`; + } + const managed = body.managed_branch || body.base_branch || "main"; + const checks = body.check_branches || body.branch_patterns || managed; const repo = upsertRepository(db, { - owner: body.owner, - name: body.name, + owner, + name, + repo_url: repoUrl, enabled: body.enabled === undefined ? 1 : Number(Boolean(body.enabled)), - base_branch: body.base_branch || "main", - branch_patterns: body.branch_patterns || "*", + managed_branch: managed, + check_branches: checks, review_scope: body.review_scope || "both", create_issue: body.create_issue === undefined ? 1 : Number(Boolean(body.create_issue)), auto_merge: Number(Boolean(body.auto_merge)), @@ -303,6 +332,41 @@ async function handleApi(req, res, url, cfg) { return json(res, 201, repo); } + /** Repositories the global token can see, for one-click import. */ + if (path === "/gitea/repos" && req.method === "GET") { + const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: cfg.giteaToken }); + try { + const repos = await client.listAccessibleRepos(); + const configured = new Set(listRepositories(db).map((r) => `${r.owner}/${r.name}`)); + return json(res, 200, repos.map((r) => ({ + full_name: r.full_name, + owner: r.owner?.login ?? r.full_name.split("/")[0], + name: r.name, + clone_url: r.clone_url, + ssh_url: r.ssh_url, + default_branch: r.default_branch, + private: Boolean(r.private), + empty: Boolean(r.empty), + archived: Boolean(r.archived), + description: r.description ?? "", + configured: configured.has(r.full_name), + }))); + } catch (err) { + return json(res, 502, { error: err.message }); + } + } + + /** Validate a git URL without saving anything. */ + if (path === "/repos/parse" && req.method === "POST") { + const body = JSON.parse((await readBody(req)).toString("utf8") || "{}"); + try { + const parsed = parseRepoUrl(body.repo_url); + return json(res, 200, parsed); + } catch (err) { + return json(res, 400, { error: err.message }); + } + } + const repoMatch = /^\/repos\/(\d+)$/.exec(path); if (repoMatch) { const id = Number(repoMatch[1]); @@ -331,15 +395,39 @@ async function handleApi(req, res, url, cfg) { try { const info = await client.getRepo(repo.owner, repo.name); const branches = await client.listRepoBranches(repo.owner, repo.name); - const updated = upsertRepository(db, { - id: repo.id, - base_branch: repo.base_branch || info.default_branch, - }); + const names = branches.map((b) => b.name); + // Seed sensible branch defaults the first time a repository is opened. + const patch = { id: repo.id }; + if (!repo.managed_branch) patch.managed_branch = info.default_branch || "main"; + if (!repo.check_branches) patch.check_branches = names.join(",") || patch.managed_branch; + const updated = upsertRepository(db, patch); return json(res, 200, { repo: updated, default_branch: info.default_branch, has_issues: info.has_issues, has_pull_requests: info.has_pull_requests, + branches: names, + managed_branch_exists: names.includes(updated.managed_branch), + }); + } catch (err) { + return json(res, 502, { error: err.message }); + } + } + + /** List branches for a repository that is not saved yet. */ + if (path === "/repos/branches" && req.method === "POST") { + const body = JSON.parse((await readBody(req)).toString("utf8") || "{}"); + try { + const parsed = body.owner && body.name ? body : parseRepoUrl(body.repo_url); + const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: cfg.giteaToken }); + const info = await client.getRepo(parsed.owner, parsed.name); + const branches = await client.listRepoBranches(parsed.owner, parsed.name); + return json(res, 200, { + owner: parsed.owner, + name: parsed.name, + default_branch: info.default_branch, + has_issues: info.has_issues, + has_pull_requests: info.has_pull_requests, branches: branches.map((b) => b.name), }); } catch (err) { @@ -347,6 +435,71 @@ async function handleApi(req, res, url, cfg) { } } + /** Create the managed branch, optionally cloned from another branch. */ + const branchMatch = /^\/repos\/(\d+)\/branches$/.exec(path); + if (branchMatch && req.method === "POST") { + const repo = getRepository(db, Number(branchMatch[1])); + if (!repo) return json(res, 404, { error: "repository not found" }); + const body = JSON.parse((await readBody(req)).toString("utf8") || "{}"); + const newBranch = String(body.new_branch || "").trim(); + const fromBranch = String(body.from_branch || "").trim(); + if (!newBranch) return json(res, 400, { error: "请填写要创建的分支名" }); + const client = new GiteaClient({ + baseUrl: cfg.giteaUrl, + token: repo.gitea_token || cfg.giteaToken, + }); + try { + await client.createBranch(repo.owner, repo.name, { newBranch, fromBranch: fromBranch || undefined }); + const branches = await client.listRepoBranches(repo.owner, repo.name); + const names = branches.map((b) => b.name); + const updated = upsertRepository(db, { + id: repo.id, + managed_branch: newBranch, + check_branches: names.join(","), + }); + return json(res, 201, { repo: updated, branches: names }); + } catch (err) { + return json(res, 502, { error: err.message }); + } + } + + /* ------------------------------ review records ------------------------- */ + + if (path === "/records" && req.method === "GET") { + const repoId = url.searchParams.get("repo_id"); + const limit = Math.min(Number(url.searchParams.get("limit") || 100), 500); + const rows = listReviewRecords(db, { repoId: repoId ? Number(repoId) : undefined, limit }); + return json(res, 200, rows.map((r) => ({ + ...r, + findings_json: undefined, + params_json: undefined, + findings_count: safeCount(r.findings_json), + }))); + } + + const recordMatch = /^\/records\/(\d+)$/.exec(path); + if (recordMatch && req.method === "GET") { + const record = getReviewRecord(db, Number(recordMatch[1])); + if (!record) return json(res, 404, { error: "record not found" }); + const repo = getRepository(db, record.repo_id); + return json(res, 200, { + ...record, + findings: safeParse(record.findings_json, []), + params: safeParse(record.params_json, {}), + repository: repo ? `${repo.owner}/${repo.name}` : null, + }); + } + + const resummariseMatch = /^\/records\/(\d+)\/summarise$/.exec(path); + if (resummariseMatch && req.method === "POST") { + const record = getReviewRecord(db, Number(resummariseMatch[1])); + if (!record) return json(res, 404, { error: "record not found" }); + updateReviewRecord(db, record.id, { + summary_status: "pending", summary_error: null, + }); + return json(res, 202, { ok: true, queued: true }); + } + if (path === "/jobs" && req.method === "GET") { const repoId = url.searchParams.get("repo_id"); const limit = Math.min(Number(url.searchParams.get("limit") || 50), 200); @@ -387,8 +540,8 @@ async function handleApi(req, res, url, cfg) { const jobId = enqueueJob(db, { repoId: repo.id, trigger: "manual", - refName: body.ref || repo.base_branch, - baseRef: body.base_ref || repo.base_branch, + refName: body.ref || repo.managed_branch, + baseRef: body.base_ref || repo.managed_branch, fromSha: body.from_sha || null, toSha, prNumber: body.pr_number || null, diff --git a/app/static/app.js b/app/static/app.js index 537ce44..1a67532 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -1,6 +1,6 @@ "use strict"; -const state = { repos: [], jobs: [], token: "" }; +const state = { repos: [], jobs: [], records: [], token: "", branches: [] }; /* ---------------------------------- auth ---------------------------------- */ @@ -83,9 +83,10 @@ async function api(path, { method = "GET", body } = {}) { return parsed; } -function show(selector, content) { +function show(selector, content, cls) { const el = document.querySelector(selector); el.textContent = typeof content === "string" ? content : JSON.stringify(content, null, 2); + if (cls) el.className = cls; else el.className = "log"; el.classList.remove("hidden"); } @@ -102,6 +103,15 @@ function fmtTime(value) { return String(value).replace("T", " ").replace("Z", ""); } +function handleError(err) { + if (err.status === 401) { + document.getElementById("sign-out").classList.add("hidden"); + showGate("Token 无效或已过期,请重新输入。"); + return; + } + setBanner(err.message); +} + /* ---------------------------------- tabs ---------------------------------- */ for (const tab of document.querySelectorAll(".tab")) { @@ -111,12 +121,11 @@ for (const tab of document.querySelectorAll(".tab")) { p.classList.toggle("active", p.id === `tab-${tab.dataset.tab}`); } if (tab.dataset.tab === "jobs") loadJobs().catch(handleError); + if (tab.dataset.tab === "records") loadRecords().catch(handleError); if (tab.dataset.tab === "settings") loadSettings().catch(handleError); }); } -/* --------------------------------- health --------------------------------- */ - async function loadHealth() { try { const h = await api("/health"); @@ -129,19 +138,10 @@ async function loadHealth() { } } -function handleError(err) { - if (err.status === 401) { - document.getElementById("sign-out").classList.add("hidden"); - showGate("Token 无效或已过期,请重新输入。"); - return; - } - setBanner(err.message); -} - /* ---------------------------------- repos --------------------------------- */ const REPO_FIELDS = [ - "id", "owner", "name", "enabled", "base_branch", "branch_patterns", "review_scope", + "id", "repo_url", "enabled", "managed_branch", "check_branches", "review_scope", "gitea_token", "llm_model", "llm_token", "background_template", "excludes", "publish_mode", "create_issue", "issue_labels", "block_severity", "block_categories", "fail_on_findings", "auto_merge", "auto_merge_mode", "merge_method", "delete_branch", @@ -152,23 +152,38 @@ async function loadRepos() { state.repos = await api("/repos"); const tbody = document.querySelector("#repos-table tbody"); if (state.repos.length === 0) { - tbody.innerHTML = '还没有配置仓库。点右上角「新增仓库」开始,然后在 Gitea 仓库里添加 Webhook。'; - return; + tbody.innerHTML = '还没有配置仓库。点「从 Gitea 导入」批量选择,或「新增仓库」手动填 Git 地址。'; + } else { + tbody.innerHTML = state.repos.map((r) => ` + + ${esc(r.owner)}/${esc(r.name)}
${esc(shortUrl(r.repo_url))} + ${esc(r.managed_branch)} + ${branchTags(r.check_branches, r.managed_branch)} + ${esc(scopeLabel(r.review_scope))} + ${r.enabled ? '启用' : '停用'} + ${r.auto_merge ? `${esc(r.merge_method)}` : '否'} + ${esc(r.block_severity || "—")} + + + + + + + `).join(""); } - tbody.innerHTML = state.repos.map((r) => ` - - ${esc(r.owner)}/${esc(r.name)}
${esc(r.base_branch)} - ${esc(r.branch_patterns)} - ${esc(scopeLabel(r.review_scope))} - ${r.enabled ? '启用' : '停用'} - ${r.auto_merge ? `${esc(r.merge_method)}` : '否'} - ${esc(r.block_severity || "—")} - - - - - - `).join(""); + syncRecordFilter(); +} + +function shortUrl(url) { + return String(url || "").replace(/^https?:\/\//, ""); +} + +function branchTags(list, managed) { + const names = String(list || "").split(",").map((s) => s.trim()).filter(Boolean); + if (!names.length) return '—'; + return names.map((n) => n === managed + ? `${esc(n)}` + : `${esc(n)}`).join(" "); } function scopeLabel(scope) { @@ -179,13 +194,17 @@ document.querySelector("#repos-table").addEventListener("click", async (ev) => { const target = ev.target.closest("button"); if (!target) return; try { - if (target.dataset.edit) openRepo(Number(target.dataset.edit)); + if (target.dataset.edit) await openRepo(Number(target.dataset.edit)); if (target.dataset.del) { if (!confirm("删除该仓库配置?历史任务会一并删除。")) return; await api(`/repos/${target.dataset.del}`, { method: "DELETE" }); await loadRepos(); } if (target.dataset.run) openManual(Number(target.dataset.run)); + if (target.dataset.records) { + document.querySelector('#record-filter').value = String(target.dataset.records); + document.querySelector('.tab[data-tab="records"]').click(); + } } catch (err) { handleError(err); } @@ -193,35 +212,130 @@ document.querySelector("#repos-table").addEventListener("click", async (ev) => { document.getElementById("add-repo").addEventListener("click", () => openRepo(null)); document.getElementById("refresh-jobs").addEventListener("click", () => loadJobs().catch(handleError)); +document.getElementById("refresh-records").addEventListener("click", () => loadRecords().catch(handleError)); +document.getElementById("record-filter").addEventListener("change", () => loadRecords().catch(handleError)); -/* ------------------------------- repo dialog ------------------------------ */ +/* ------------------------------ repo dialog ------------------------------- */ const modal = document.getElementById("modal"); const form = document.getElementById("repo-form"); +const branchList = document.getElementById("branch-list"); -function openRepo(id) { +function selectedBranches() { + return String(form.elements.check_branches.value || "") + .split(",").map((s) => s.trim()).filter(Boolean); +} + +function setSelectedBranches(list) { + form.elements.check_branches.value = list.join(","); +} + +function renderBranchList() { + const chosen = new Set(selectedBranches()); + const managed = form.elements.managed_branch.value.trim(); + if (!state.branches.length) { + branchList.innerHTML = '点「加载分支」获取远端分支列表。'; + return; + } + branchList.innerHTML = state.branches.map((b) => { + const on = chosen.has(b); + const label = b === managed ? `${b}(管理)` : b; + return ``; + }).join(""); +} + +branchList.addEventListener("click", (ev) => { + const btn = ev.target.closest("button[data-branch]"); + if (!btn) return; + const name = btn.dataset.branch; + const chosen = selectedBranches(); + const idx = chosen.indexOf(name); + if (idx >= 0) chosen.splice(idx, 1); else chosen.push(name); + setSelectedBranches(chosen); + renderBranchList(); +}); + +async function openRepo(id) { form.reset(); hide("#repo-result"); + state.branches = []; + renderBranchList(); const repo = id ? state.repos.find((r) => r.id === id) : null; document.getElementById("modal-title").textContent = repo ? `${repo.owner}/${repo.name}` : "新增仓库"; + document.getElementById("repo-parsed").textContent = ""; for (const field of REPO_FIELDS) { const input = form.elements[field]; if (!input) continue; if (!repo) { - // Defaults for a brand-new repository. if (field === "enabled" || field === "create_issue") input.checked = true; + if (field === "managed_branch") input.value = "main"; + if (field === "check_branches") input.value = "main"; continue; } if (input.type === "checkbox") input.checked = Boolean(repo[field]); else if (input.type === "password") input.value = ""; else input.value = repo[field] ?? ""; } + if (repo) { + document.getElementById("repo-parsed").textContent = `${repo.owner}/${repo.name}`; + // Existing repositories get their branch list loaded for the picker. + loadBranches({ silent: true }).catch(() => {}); + } modal.classList.remove("hidden"); } document.getElementById("modal-close").addEventListener("click", () => modal.classList.add("hidden")); modal.addEventListener("click", (ev) => { if (ev.target === modal) modal.classList.add("hidden"); }); +/** Fetch branch info for whatever git URL is currently in the form. */ +async function loadBranches({ silent = false } = {}) { + const repoUrl = form.elements.repo_url.value.trim(); + const id = form.elements.id.value; + const hint = document.getElementById("repo-parsed"); + if (!repoUrl && !id) { + if (!silent) hint.textContent = "请先填写 Git 地址。"; + return; + } + if (!silent) hint.textContent = "正在连接…"; + try { + const body = repoUrl ? { repo_url: repoUrl } : undefined; + const info = body + ? await api("/repos/branches", { method: "POST", body }) + : await api(`/repos/${id}/discover`, { method: "POST" }); + state.branches = info.branches || []; + if (!form.elements.managed_branch.value) form.elements.managed_branch.value = info.default_branch || "main"; + if (!form.elements.check_branches.value) form.elements.check_branches.value = state.branches.join(","); + renderBranchList(); + hint.className = "hint ok"; + hint.textContent = + `连接成功:${info.owner}/${info.name} · 默认分支 ${info.default_branch} · 共 ${state.branches.length} 个分支` + + (info.has_issues ? "" : " · 该仓库未启用 Issue"); + document.getElementById("clone-source").innerHTML = + state.branches.map((b) => ``).join(""); + return info; + } catch (err) { + hint.className = "hint err"; + hint.textContent = `连接失败:${err.message}`; + throw err; + } +} + +document.getElementById("repo-test").addEventListener("click", () => loadBranches().catch(() => {})); +document.getElementById("branch-reload").addEventListener("click", () => loadBranches().catch(() => {})); + +form.elements.repo_url.addEventListener("blur", async () => { + const url = form.elements.repo_url.value.trim(); + if (!url) return; + try { + const parsed = await api("/repos/parse", { method: "POST", body: { repo_url: url } }); + document.getElementById("repo-parsed").className = "hint"; + document.getElementById("repo-parsed").textContent = `识别为:${parsed.owner}/${parsed.name}`; + } catch (err) { + document.getElementById("repo-parsed").className = "hint err"; + document.getElementById("repo-parsed").textContent = err.message; + } +}); + form.addEventListener("submit", async (ev) => { ev.preventDefault(); const data = {}; @@ -244,17 +358,116 @@ form.addEventListener("submit", async (ev) => { } }); -document.getElementById("discover").addEventListener("click", async () => { +/* ------------------------------ clone branch ------------------------------ */ + +const cloneModal = document.getElementById("clone-modal"); +const cloneForm = document.getElementById("clone-form"); + +document.getElementById("branch-create").addEventListener("click", () => { + cloneForm.reset(); + hide("#clone-result"); + cloneForm.elements.new_branch.value = form.elements.managed_branch.value.trim() || "main"; + if (!state.branches.length) { + loadBranches().then(() => cloneModal.classList.remove("hidden")).catch(() => {}); + return; + } + cloneModal.classList.remove("hidden"); +}); + +document.getElementById("clone-close").addEventListener("click", () => cloneModal.classList.add("hidden")); +cloneModal.addEventListener("click", (ev) => { if (ev.target === cloneModal) cloneModal.classList.add("hidden"); }); + +cloneForm.addEventListener("submit", async (ev) => { + ev.preventDefault(); const id = form.elements.id.value; - if (!id) return show("#repo-result", "请先保存仓库,再测试连接。"); - show("#repo-result", "测试中…"); + if (!id) return show("#clone-result", "请先保存仓库,再创建分支。"); try { - const info = await api(`/repos/${id}/discover`, { method: "POST" }); - show("#repo-result", - `连接成功\n默认分支:${info.default_branch}\nIssue 功能:${info.has_issues}\nPR 功能:${info.has_pull_requests}\n分支:${info.branches.join(", ")}`); + const res = await api(`/repos/${id}/branches`, { + method: "POST", + body: { + new_branch: cloneForm.elements.new_branch.value.trim(), + from_branch: cloneForm.elements.from_branch.value, + }, + }); + state.branches = res.branches || []; + form.elements.managed_branch.value = res.repo.managed_branch; + form.elements.check_branches.value = res.repo.check_branches; + renderBranchList(); + cloneModal.classList.add("hidden"); + show("#repo-result", `已创建分支 ${res.repo.managed_branch}`); } catch (err) { - if (err.status === 401) return handleError(err); - show("#repo-result", err.message); + show("#clone-result", err.message); + } +}); + +/* ------------------------------ import dialog ----------------------------- */ + +const importModal = document.getElementById("import-modal"); + +document.getElementById("import-repo").addEventListener("click", async () => { + importModal.classList.remove("hidden"); + document.getElementById("import-list").innerHTML = '
正在拉取仓库列表…
'; + try { + state.remoteRepos = await api("/gitea/repos"); + renderImportList(); + } catch (err) { + document.getElementById("import-list").innerHTML = `
拉取失败:${esc(err.message)}
`; + } +}); + +document.getElementById("import-close").addEventListener("click", () => importModal.classList.add("hidden")); +importModal.addEventListener("click", (ev) => { if (ev.target === importModal) importModal.classList.add("hidden"); }); +document.getElementById("import-search").addEventListener("input", renderImportList); + +function renderImportList() { + const q = document.getElementById("import-search").value.trim().toLowerCase(); + const list = (state.remoteRepos || []).filter((r) => !q || r.full_name.toLowerCase().includes(q)); + const box = document.getElementById("import-list"); + if (!list.length) { + box.innerHTML = '
没有匹配的仓库。
'; + return; + } + box.innerHTML = list.map((r) => ` +
+
+
${esc(r.full_name)}
+
${esc(r.default_branch || "main")}${r.private ? " · 私有" : ""}${r.empty ? " · 空仓库" : ""}${r.archived ? " · 已归档" : ""}${r.description ? ` · ${esc(r.description)}` : ""}
+
+
+ ${r.configured + ? '已配置' + : ``} +
+
`).join(""); +} + +document.getElementById("import-list").addEventListener("click", async (ev) => { + const btn = ev.target.closest("button[data-import]"); + if (!btn) return; + const fullName = btn.dataset.import; + const remote = (state.remoteRepos || []).find((r) => r.full_name === fullName); + if (!remote) return; + btn.disabled = true; + btn.textContent = "添加中…"; + try { + const created = await api("/repos", { + method: "POST", + body: { + repo_url: remote.clone_url, + managed_branch: remote.default_branch || "main", + check_branches: remote.default_branch || "main", + }, + }); + // Discover immediately so the branch list is populated on first open. + await api(`/repos/${created.id}/discover`, { method: "POST" }).catch(() => {}); + remote.configured = true; + renderImportList(); + await loadRepos(); + setBanner(`已添加 ${fullName}`, "ok"); + } catch (err) { + btn.disabled = false; + btn.textContent = "添加"; + setBanner(`添加 ${fullName} 失败:${err.message}`); } }); @@ -269,8 +482,8 @@ function openManual(repoId) { manualForm.reset(); hide("#manual-result"); manualForm.elements.repo_id.value = repo.id; - manualForm.elements.ref.value = repo.base_branch || "main"; - manualForm.elements.base_ref.value = repo.base_branch || "main"; + manualForm.elements.ref.value = repo.managed_branch || "main"; + manualForm.elements.base_ref.value = repo.managed_branch || "main"; document.getElementById("manual-repo").textContent = `${repo.owner}/${repo.name}`; manualModal.classList.remove("hidden"); } @@ -299,6 +512,139 @@ manualForm.addEventListener("submit", async (ev) => { } }); +/* -------------------------------- records --------------------------------- */ + +function syncRecordFilter() { + const sel = document.getElementById("record-filter"); + const current = sel.value; + sel.innerHTML = '' + + state.repos.map((r) => ``).join(""); + sel.value = current; +} + +async function loadRecords() { + const repoId = document.getElementById("record-filter").value; + state.records = await api(`/records${repoId ? `?repo_id=${repoId}` : ""}`); + const box = document.getElementById("records-list"); + if (!state.records.length) { + box.innerHTML = '
暂无审查记录。仓库收到 push 或 PR 后,这里会出现摘要。
'; + return; + } + box.innerHTML = state.records.map(renderRecordCard).join(""); +} + +function renderRecordCard(r) { + const summaryCls = r.summary_status === "done" ? "" : (r.summary_status === "failed" ? " failed" : " pending"); + let summary; + if (r.summary_status === "done") { + summary = esc(r.summary || ""); + } else if (r.summary_status === "failed") { + summary = `摘要生成失败:${esc(r.summary_error || "未知原因")}`; + } else if (r.summary_status === "skipped") { + summary = "未生成摘要。"; + } else { + summary = "摘要生成中…"; + } + return ` +
+
+
+
${esc(r.owner)}/${esc(r.name)} · ${esc(r.ref_name)}
+
+ ${fmtTime(r.created_at)} + 提交 ${esc(String(r.to_sha).slice(0, 10))} + ${esc(r.trigger || "")} + ${r.pr_number ? `PR #${r.pr_number}` : ""} + 发现 ${r.findings_count ?? 0} 条 + ${r.tokens_total ? `${r.tokens_total} tokens` : ""} + ${r.elapsed ? `${esc(r.elapsed)}` : ""} +
+
+
+ ${esc(r.summary_status)} + +
+
+
${summary}
+
`; +} + +document.getElementById("records-list").addEventListener("click", async (ev) => { + const btn = ev.target.closest("button[data-record]"); + if (!btn) return; + try { + const rec = await api(`/records/${btn.dataset.record}`); + showRecord(rec); + } catch (err) { + handleError(err); + } +}); + +const recordModal = document.getElementById("record-modal"); +let currentRecordId = null; + +function showRecord(rec) { + currentRecordId = rec.id; + document.getElementById("record-title").textContent = + `审查记录 #${rec.id} · ${rec.repository || ""} · ${rec.ref_name}`; + const params = rec.params || {}; + const findings = rec.findings || []; + const rows = [ + ["提交", `${esc(rec.to_sha)}`], + ["对比基准", rec.from_sha ? `${esc(rec.from_sha)}` : "—"], + ["触发来源", esc(rec.trigger || "—")], + ["Pull Request", rec.pr_number + ? `#${rec.pr_number}` + : "—"], + ["管理分支", esc(params.managed_branch || "—")], + ["PR 目标分支", esc(params.base_ref || "—")], + ["审查范围", esc(params.review_scope || "—")], + ["阻断阈值", `${esc(params.block_severity || "—")}${params.block_categories ? ` / ${esc(params.block_categories)}` : ""}`], + ["发布方式", esc(params.publish_mode || "—")], + ["自动合并", params.auto_merge ? `${esc(params.auto_merge_mode || "")} · ${esc(params.merge_method || "")}` : "关闭"], + ["排除路径", esc(params.excludes || "—")], + ["LLM 模型", esc(rec.llm_model || "—")], + ["审查状态", `${esc(rec.llm_status || "—")}${params.review_incomplete ? "(覆盖不完整)" : ""}`], + ["消耗 / 耗时", `${rec.tokens_total || 0} tokens · ${esc(rec.elapsed || "—")}`], + ["摘要模型", esc(rec.summary_model || "—")], + ]; + const findingsHtml = findings.length + ? findings.map((f) => ` +
+
${esc(f.path)}${f.start_line ? `:${f.start_line}${f.end_line && f.end_line !== f.start_line ? `-${f.end_line}` : ""}` : ""} · ${esc(f.severity || "未分级")}/${esc(f.category || "-")}${f.blocking ? " · 阻断" : ""}
+
${esc(f.content || "")}
+
`).join("") + : '
本次没有产生意见。
'; + + document.getElementById("record-body").innerHTML = ` +

审查需求 / 背景

+
${rec.requirement ? esc(rec.requirement) : "(未填写)"}
+

AI 摘要

+
${ + rec.summary_status === "done" ? esc(rec.summary || "") : esc(rec.summary_error || "生成中…") + }
+

运行参数

+
${rows.map(([k, v]) => `
${esc(k)}
${v}
`).join("")}
+

审查意见(${findings.length})

+ ${findingsHtml}`; + recordModal.classList.remove("hidden"); +} + +document.getElementById("record-close").addEventListener("click", () => recordModal.classList.add("hidden")); +recordModal.addEventListener("click", (ev) => { if (ev.target === recordModal) recordModal.classList.add("hidden"); }); + +document.getElementById("record-resummarise").addEventListener("click", async () => { + if (!currentRecordId) return; + try { + await api(`/records/${currentRecordId}/summarise`, { method: "POST" }); + recordModal.classList.add("hidden"); + setBanner("已重新排队生成摘要。", "ok"); + await loadRecords(); + } catch (err) { + handleError(err); + } +}); + /* ---------------------------------- jobs ---------------------------------- */ const STATUS_TAG = { diff --git a/app/static/index.html b/app/static/index.html index e37b082..1504c0a 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -10,11 +10,15 @@

Gitea 代码审查

-
加载中…
+
+
加载中…
+ +
@@ -25,19 +29,33 @@

已配置仓库

- +
+ + +
- - + +
仓库分支过滤范围启用自动合并阻断级别仓库管理分支检查分支范围启用自动合并阻断级别
+
+
+

审查历史摘要

+
+ + +
+
+
+
+

审查任务

@@ -58,17 +76,17 @@
Gitea - +

Webhook 地址:(在仓库 Settings → Webhooks 中添加,密钥填上面这一项)

- LLM + LLM(代码审查与历史摘要共用) - + @@ -100,6 +118,18 @@
+ +