feat: 仓库导入、分支模型重构与审查历史摘要

仓库接入
- 新增「从 Gitea 导入」:用全局 Token 列出可见仓库,一键建配置
- 新增仓库只填 Git 地址,自动解析 owner/name,支持 https/ssh/scp 写法
- 新增「测试连接」按钮,保存前即可校验地址并拉取分支

分支模型
- 由单一 base_branch + glob 改为「一个管理分支 + 多个检查分支」
- 管理分支是唯一合并目标;检查分支全部纳入监控
- 支持从任意分支克隆创建管理分支
- 审查基准改为管理分支的 merge-base;仅目标为管理分支的 PR 才自动合并
- 旧库自动迁移:base_branch 播种 managed_branch,branch_patterns 展开为检查分支

审查历史
- 新增 review_records 表与「审查历史」页
- 记录触发来源、PR 链接、审查范围、阻断阈值、发布方式、自动合并设置、
  排除路径、LLM 模型、token 消耗、耗时、需求背景与全部审查意见
- 详情弹窗一览,支持按仓库过滤

AI 摘要
- 新增独立摘要模块(app/lib/summary.js),与代码审查提示词分离
- 专用提示词输出固定四节、500 字内的中文记录:结论/范围/问题/要点
- 与代码审查共用全局 LLM 设置;摘要失败不影响审查与合并,可单条重跑

修复
- 摘要改用内置 fetch:运行镜像没有 curl,原先 spawn curl 必然 ENOENT
- 去掉 blob:none 部分克隆并把凭据写入 .git/config:
  惰性取 blob 不会带上 per-command extraHeader,私有库会报 could not read Username

UI
- 审查背景改为多行文本域(可滚动)
- 分支改为可点选列表,管理分支高亮
- 仓库表格展示管理分支与检查分支
This commit is contained in:
2026-09-20 12:38:23 +08:00
parent ed172bf369
commit 6dc77ce907
12 changed files with 1374 additions and 153 deletions
+44 -6
View File
@@ -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 - 监听 Pull Request 的 opened / synchronize / reopened / ready_for_review
- 按分支 glob 过滤(`*`、`release/*`、`main`),可选只审 PR 或只审 push - 可选只审 PR 或只审 push
- 同一 commit 在队列中只入队一次;webhook 按 delivery id 去重 - 同一 commit 在队列中只入队一次;webhook 按 delivery id 去重
**发布结果** **发布结果**
@@ -38,8 +49,20 @@ Push / Pull Request 事件
- 合并方式、是否删除分支可配 - 合并方式、是否删除分支可配
- PR head 已变化或 PR 不可合并时自动放弃 - PR head 已变化或 PR 不可合并时自动放弃
**审查历史**
- 「审查历史」页按仓库汇总每次审查的摘要,可一键跳转 PR
- 每条记录保留完整上下文:触发来源、PR 链接、审查范围、阻断阈值、发布方式、
自动合并设置、排除路径、LLM 模型、token 消耗、耗时、本次填写的需求背景
- 详情弹窗列出本次全部审查意见,标注严重级别与是否阻断
**AI 摘要**
- 独立的摘要模块,与代码审查的提示词完全分开,但共用同一份全局 LLM 设置
- 专用提示词把一次审查压缩成固定四节、500 字内的记录:
`结论 / 范围 / 问题 / 要点`
- 摘要失败不影响审查结果,可单条重新生成
**运维** **运维**
- 内置 Web 后台:仓库配置、任务列表、实时日志、重跑、连通性自检 - 内置 Web 后台:仓库配置、审查历史、任务列表、实时日志、重跑、连通性自检
- 每次审查的完整日志和结果 JSON 落库 - 每次审查的完整日志和结果 JSON 落库
- worker 重启后自动回收卡住的任务,失败任务自动重试一次 - worker 重启后自动回收卡住的任务,失败任务自动重试一次
- 全局或按仓库覆盖 Gitea token、LLM 端点 / 模型 / Key、排除路径、并发数 - 全局或按仓库覆盖 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` | | `review_scope` | `both` / `pr` / `push` |
| `base_branch` | 对比基准分支,也是无 PR 时 push 审查的目标 |
| `block_severity` | 阻断级别阈值,如 `critical,high`;留空则不看级别 | | `block_severity` | 阻断级别阈值,如 `critical,high`;留空则不看级别 |
| `block_categories` | 额外按类别阻断,如 `security` | | `block_categories` | 额外按类别阻断,如 `security` |
| `fail_on_findings` | 打开后任何意见都视为阻断 | | `fail_on_findings` | 打开后任何意见都视为阻断 |
@@ -163,6 +187,13 @@ curl -fsS http://localhost:8090/api/health
| `GET` `POST` | `/api/repos` | 列出 / 新增仓库配置 | | `GET` `POST` | `/api/repos` | 列出 / 新增仓库配置 |
| `GET` `PATCH` `DELETE` | `/api/repos/:id` | 读取 / 修改 / 删除 | | `GET` `PATCH` `DELETE` | `/api/repos/:id` | 读取 / 修改 / 删除 |
| `POST` | `/api/repos/:id/discover` | 校验连接,返回默认分支与分支列表 | | `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` | 任务列表(`?repo_id=`、`?limit=`) |
| `GET` | `/api/jobs/:id` | 任务详情,含日志 | | `GET` | `/api/jobs/:id` | 任务详情,含日志 |
| `POST` | `/api/jobs/:id/retry` | 重跑任务 | | `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`,并阻止自动合并。 **阻断判定**:一条意见命中任一条件即为阻断 —— 严重级别 ≥ `block_severity` 中任一项,类别在 `block_categories` 中,或打开了 `fail_on_findings`。阻断会创建 Issue、把提交状态置为 `failure`,并阻止自动合并。
@@ -209,6 +244,9 @@ Gitea 拦截了内网地址。按上文给 `[webhook] ALLOWED_HOST_LIST` 加上
**后台打开后要求输入访问 Token** **后台打开后要求输入访问 Token**
这是 `CR_ADMIN_TOKEN` 在生效。输入 `.env` 里的值即可,Token 只存在浏览器本地。想免登录就把它留空并重启容器(仅限不对外暴露的网络)。 这是 `CR_ADMIN_TOKEN` 在生效。输入 `.env` 里的值即可,Token 只存在浏览器本地。想免登录就把它留空并重启容器(仅限不对外暴露的网络)。
**历史记录一直显示「摘要生成中…」**
摘要与审查串行执行,一次只跑一个;如果 LLM 不可达会在记录里留下失败原因,可在详情里点「重新生成摘要」。摘要失败不会影响审查结论与自动合并。
**任务一直停在 `reviewing`** **任务一直停在 `reviewing`**
LLM 慢或不可达。在后台「设置」点「测试 LLM」,或在「任务」页查看日志;调大 `CR_REVIEW_TIMEOUT_MS`。 LLM 慢或不可达。在后台「设置」点「测试 LLM」,或在「任务」页查看日志;调大 `CR_REVIEW_TIMEOUT_MS`。
+161 -3
View File
@@ -16,9 +16,12 @@ CREATE TABLE IF NOT EXISTS repositories (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
owner TEXT NOT NULL, owner TEXT NOT NULL,
name TEXT NOT NULL, name TEXT NOT NULL,
repo_url TEXT,
enabled INTEGER NOT NULL DEFAULT 1, enabled INTEGER NOT NULL DEFAULT 1,
base_branch TEXT NOT NULL DEFAULT 'main', -- The single branch every reviewed branch is merged into.
branch_patterns TEXT NOT NULL DEFAULT '*', 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', review_scope TEXT NOT NULL DEFAULT 'both',
gitea_token TEXT, gitea_token TEXT,
llm_provider TEXT, llm_provider TEXT,
@@ -79,6 +82,36 @@ CREATE TABLE IF NOT EXISTS jobs (
finished_at TEXT 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_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_repo ON jobs (repo_id, id DESC);
CREATE INDEX IF NOT EXISTS idx_jobs_sha ON jobs (repo_id, to_sha); 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) { export function openDatabase(path) {
mkdirSync(dirname(path), { recursive: true }); mkdirSync(dirname(path), { recursive: true });
const db = new DatabaseSync(path); const db = new DatabaseSync(path);
db.exec(SCHEMA); db.exec(SCHEMA);
migrate(db);
return db; return db;
} }
@@ -129,7 +209,7 @@ export function findRepository(db, owner, name) {
} }
const REPO_FIELDS = [ 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", "gitea_token", "llm_provider", "llm_model", "llm_base_url", "llm_token",
"rule_path", "background_template", "excludes", "publish_mode", "create_issue", "rule_path", "background_template", "excludes", "publish_mode", "create_issue",
"issue_labels", "block_severity", "block_categories", "fail_on_findings", "issue_labels", "block_severity", "block_categories", "fail_on_findings",
@@ -263,6 +343,84 @@ export function pruneDeliveries(db, keep = 2000) {
).run(keep); ).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) { export function jobStats(db) {
const rows = db.prepare("SELECT status, COUNT(*) AS n FROM jobs GROUP BY status").all(); 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 }; const out = { queued: 0, running: 0, succeeded: 0, failed: 0, skipped: 0 };
+31
View File
@@ -96,6 +96,37 @@ export class GiteaClient {
return this.get(`/api/v1/repos/${owner}/${repo}`); 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) { async listRepoBranches(owner, repo, limit = 100) {
const out = []; const out = [];
for (let page = 1; page <= 20; page += 1) { for (let page = 1; page <= 20; page += 1) {
+25 -12
View File
@@ -115,20 +115,19 @@ export class OcrRunner {
}; };
} }
async gitClone({ cloneUrl, token, dir, extraHeader = true }) { async gitClone({ cloneUrl, token, dir }) {
const args = ["clone", "--no-tags", "--filter=blob:none"]; // 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" }; const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
if (token) { if (token) {
if (extraHeader) { // Written into .git/config by `git clone`, so every later fetch/rebase in
env.GIT_CONFIG_COUNT = "1"; // this workspace authenticates without extra plumbing.
env.GIT_CONFIG_KEY_0 = "http.extraHeader"; env.GIT_CONFIG_COUNT = "1";
env.GIT_CONFIG_VALUE_0 = `Authorization: token ${token}`; env.GIT_CONFIG_KEY_0 = "http.extraHeader";
} else { env.GIT_CONFIG_VALUE_0 = `Authorization: token ${token}`;
const url = new URL(cloneUrl);
url.username = "oauth2";
url.password = token;
cloneUrl = url.toString();
}
} }
args.push(cloneUrl, dir); args.push(cloneUrl, dir);
const res = await run("git", args, { env, timeoutMs: this.gitTimeoutMs }); 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, 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; return dir;
} }
async fetch(dir, { refs = [], token } = {}) { 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" }; const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
if (token) { if (token) {
env.GIT_CONFIG_COUNT = "1"; env.GIT_CONFIG_COUNT = "1";
+68 -1
View File
@@ -1,6 +1,10 @@
/** Sequential job worker with retry and stale-job recovery. */ /** 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 { SkipJob } from "./review.js";
import { summariseReview } from "./summary.js";
const STALE_MS = 2 * 60 * 60 * 1000; const STALE_MS = 2 * 60 * 60 * 1000;
@@ -21,6 +25,8 @@ export class JobQueue {
if (this.running) return; if (this.running) return;
this.running = true; this.running = true;
this.recoverStale(); this.recoverStale();
const requeued = requeueStaleSummaries(this.db);
if (requeued) this.logger.warn?.(`requeued ${requeued} interrupted summariser task(s)`);
this.tick(); this.tick();
} }
@@ -51,6 +57,8 @@ export class JobQueue {
} }
const job = claimNextJob(this.db); const job = claimNextJob(this.db);
if (!job) { if (!job) {
// Nothing to review: use the idle slot for pending summaries.
await this.runSummaryOnce();
this.timer = setTimeout(() => this.tick(), this.pollMs); this.timer = setTimeout(() => this.tick(), this.pollMs);
return; return;
} }
@@ -74,6 +82,65 @@ export class JobQueue {
this.busy = false; this.busy = false;
this.currentJobId = null; 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); this.timer = setTimeout(() => this.tick(), this.pollMs);
} }
/**
* Summarise one finished review, if any is pending.
* Failures are recorded on the record and never block the queue.
*/
async runSummaryOnce() {
const record = claimPendingSummary(this.db);
if (!record) return false;
try {
const repo = this.db.prepare("SELECT * FROM repositories WHERE id = ?").get(record.repo_id);
if (!repo) {
updateReviewRecord(this.db, record.id, {
summary_status: "skipped", summary_error: "repository no longer exists",
});
return false;
}
let findings = [];
try { findings = JSON.parse(record.findings_json || "[]"); } catch { findings = []; }
const job = this.db.prepare("SELECT result_json FROM jobs WHERE id = ?").get(record.job_id);
let summary = null;
try { summary = JSON.parse(job?.result_json || "{}").summary ?? null; } catch { summary = null; }
const llm = {
url: repo.llm_base_url || this.engine.config.llmUrl,
token: repo.llm_token || this.engine.config.llmToken,
model: repo.llm_model || this.engine.config.llmModel,
protocol: repo.llm_provider || this.engine.config.llmProtocol,
authHeader: this.engine.config.llmAuthHeader,
extraHeaders: this.engine.config.llmExtraHeaders,
};
this.logger.info?.(`summarising review record #${record.id} (${repo.owner}/${repo.name} ${record.ref_name})`);
const res = await summariseReview({
llm, repo, record, findings, summary, requirement: record.requirement,
});
if (res.ok) {
updateReviewRecord(this.db, record.id, {
summary_status: "done",
summary: res.text,
summary_model: res.model,
summary_error: res.complete ? null : "摘要缺少标准小节,已按原文保存",
});
this.logger.info?.(`review record #${record.id} summarised (${res.text.length} chars)`);
} else {
updateReviewRecord(this.db, record.id, {
summary_status: "failed", summary_error: String(res.error).slice(0, 500),
});
this.logger.warn?.(`review record #${record.id} summary failed: ${res.error}`);
}
} catch (err) {
updateReviewRecord(this.db, record.id, {
summary_status: "failed", summary_error: String(err.message || err).slice(0, 500),
});
this.logger.warn?.(`review record #${record.id} summary error: ${err.message}`);
}
return true;
}
} }
+109 -16
View File
@@ -8,7 +8,9 @@ import { join } from "node:path";
import { GiteaClient, webBaseUrl, normalizeBaseUrl } from "./gitea.js"; import { GiteaClient, webBaseUrl, normalizeBaseUrl } from "./gitea.js";
import { OcrRunner, parseList, severityAtLeast } from "./ocr.js"; import { OcrRunner, parseList, severityAtLeast } from "./ocr.js";
import { parseUnifiedDiff, pickAnchorLine } from "./diff.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 SUMMARY_MARKER = "<!-- gitea-codereview:summary -->";
export const COMMENT_MARKER = "<!-- gitea-codereview -->"; export const COMMENT_MARKER = "<!-- gitea-codereview -->";
@@ -26,21 +28,43 @@ function repoSlug(repo) {
return `${repo.owner}/${repo.name}`; return `${repo.owner}/${repo.name}`;
} }
function globToRegExp(pattern) { /**
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&"); * Parse a git remote URL into { owner, name, host }.
const body = escaped * Accepts https://host/owner/repo.git, http://host/owner/repo,
.replace(/\*\*/g, "\u0000") * ssh://git@host:2222/owner/repo.git and git@host:owner/repo.git.
.replace(/\*/g, "[^/]*") */
.replace(/\?/g, ".") export function parseRepoUrl(input) {
.replace(/\u0000/g, ".*"); const raw = String(input || "").trim();
return new RegExp(`^${body}$`); 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) { /** Whether `refName` is one of the branches this repository reviews. */
const list = parseList(patterns); export function branchMatches(refName, checkBranches) {
if (list.length === 0 || list.includes("*")) return true; const list = parseList(checkBranches);
const short = refName.replace(/^refs\/heads\//, ""); if (list.length === 0) return true;
return list.some((p) => globToRegExp(p).test(short) || globToRegExp(p).test(refName)); const short = String(refName || "").replace(/^refs\/heads\//, "");
return list.some((b) => b === short || b === refName);
} }
function badge(comment) { function badge(comment) {
@@ -148,6 +172,9 @@ async function resolveRange(runner, { repo, job, workspace, token }) {
const headRef = `refs/heads/${job.ref_name}`; const headRef = `refs/heads/${job.ref_name}`;
const refs = [headRef, `+${headRef}:refs/remotes/origin/${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.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}`); 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 }); await runner.fetch(workspace, { refs, token });
@@ -161,8 +188,11 @@ async function resolveRange(runner, { repo, job, workspace, token }) {
if (job.from_sha) { if (job.from_sha) {
fromSha = await runner.revParse(workspace, job.from_sha); fromSha = await runner.revParse(workspace, job.from_sha);
} }
if (!fromSha && job.base_ref) { // The review base is the managed branch: every checked branch is compared
const baseSha = await runner.revParse(workspace, `refs/remotes/origin/${job.base_ref}`); // 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 (baseSha) fromSha = await runner.mergeBase(workspace, baseSha, toSha) ?? baseSha;
} }
if (!fromSha) { if (!fromSha) {
@@ -222,6 +252,8 @@ export class ReviewEngine {
await mkdir(root, { recursive: true }); await mkdir(root, { recursive: true });
const dir = join(root, `${repo.owner}__${repo.name}`); const dir = join(root, `${repo.owner}__${repo.name}`);
if (existsSync(join(dir, ".git"))) return dir; 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 }); await rm(dir, { recursive: true, force: true });
const cloneUrl = `${normalizeBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}.git`; const cloneUrl = `${normalizeBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}.git`;
const runner = new OcrRunner({ command: this.config.ocrCommand, logger: this.log }); const runner = new OcrRunner({ command: this.config.ocrCommand, logger: this.log });
@@ -253,6 +285,9 @@ export class ReviewEngine {
setPhase("preparing"); setPhase("preparing");
const workspace = await this.ensureWorkspace(repo); 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, { const { fromSha, toSha } = await resolveRange(runner, {
repo, job, workspace, token: repo.gitea_token || this.config.giteaToken, repo, job, workspace, token: repo.gitea_token || this.config.giteaToken,
}); });
@@ -441,6 +476,57 @@ export class ReviewEngine {
warnings: review.warnings, 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, { updateJob(this.db, job.id, {
status: "succeeded", status: "succeeded",
phase: "done", phase: "done",
@@ -552,6 +638,13 @@ export class ReviewEngine {
appendLog("auto-merge skipped: no pull request for this branch"); appendLog("auto-merge skipped: no pull request for this branch");
return false; 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) { if (reviewIncomplete) {
appendLog("auto-merge skipped: review coverage was incomplete"); appendLog("auto-merge skipped: review coverage was incomplete");
return false; return false;
+167
View File
@@ -0,0 +1,167 @@
/**
* Review-history summariser.
*
* Deliberately separate from the OpenCodeReview pipeline: OCR decides what is
* wrong with a diff; this module turns a finished review into a short,
* uniformly formatted record for the history list. It shares the global LLM
* settings but uses its own prompt, and it never influences blocking or merge
* decisions.
*/
export const SUMMARY_MAX_CHARS = 500;
export const SUMMARY_PROMPT_VERSION = "review-history-summary/v1";
export const SUMMARY_SECTIONS = ["结论", "范围", "问题", "要点"];
const SYSTEM_PROMPT = [
"你是一名代码审查记录整理员。你会收到一次自动代码审查的结构化结果。",
"你的唯一任务是把这次审查整理成一段简洁、客观的中文记录,供团队在审查历史里快速浏览。",
"",
"严格要求:",
"1. 只依据输入内容,不要推测、不要补充未出现的信息,不要提出新建议。",
"2. 不要评价审查工具本身,不要输出“建议进一步检查”之类的话。",
"3. 按下面的固定格式输出,保留小标题,每节一行到两行:",
"结论:<通过 / 有阻断问题 / 有非阻断问题 / 未发现变更>",
"范围:<审查了哪些文件、多少行;信息不足写“未知”>",
"问题:<按严重级别归纳问题类型与数量;无问题写“无”>",
"要点:<最值得关注的一到三条具体问题,写明文件与行为;无问题写“无”>",
"4. 全文不超过 500 个字符,不要使用 Markdown 标题符号、代码块或列表符号。",
"5. 直接输出上述四节内容,不要任何前言、后缀或解释。",
].join("\n");
function clip(text, max) {
const value = String(text ?? "");
return value.length <= max ? value : `${value.slice(0, max)}…`;
}
/** Build the user message describing one finished review. */
export function buildSummaryPrompt({ repo, record, findings, summary, requirement }) {
const lines = [];
lines.push(`仓库:${repo.owner}/${repo.name}`);
lines.push(`分支:${record.ref_name}`);
if (record.pr_number) lines.push(`Pull Request:#${record.pr_number}`);
lines.push(`提交:${record.to_sha}`);
if (record.from_sha) lines.push(`对比基准:${record.from_sha}`);
if (requirement) lines.push(`本次需求/背景:${clip(requirement, 600)}`);
if (summary) {
const bits = [];
if (summary.files_reviewed != null) bits.push(`审查文件 ${summary.files_reviewed} 个`);
if (summary.comments != null) bits.push(`原始意见 ${summary.comments} 条`);
if (summary.total_tokens) bits.push(`消耗 ${summary.total_tokens} tokens`);
if (summary.elapsed) bits.push(`耗时 ${summary.elapsed}`);
if (bits.length) lines.push(`运行数据:${bits.join(",")}`);
}
if (record.llm_status) lines.push(`审查状态:${record.llm_status}`);
lines.push("", "审查发现:");
if (!findings.length) {
lines.push("(本次没有产生任何意见)");
} else {
for (const f of findings) {
const where = f.start_line
? `${f.path}:${f.start_line}${f.end_line && f.end_line !== f.start_line ? `-${f.end_line}` : ""}`
: f.path;
const tags = [f.severity, f.category].filter(Boolean).join("/");
lines.push(`- [${tags || "未分级"}] ${where} ${clip(f.content, 300)}`);
}
}
return lines.join("\n");
}
/**
* Call an OpenAI- or Anthropic-compatible endpoint with the built-in fetch, so
* the runtime image needs no extra HTTP client.
*/
async function runLlm(llm, messages, timeoutMs) {
const base = String(llm.url).replace(/\/+$/, "");
const isAnthropic = String(llm.protocol || "").toLowerCase().includes("anthropic");
const endpoint = isAnthropic
? (base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`)
: (base.endsWith("/v1") ? `${base}/chat/completions` : `${base}/v1/chat/completions`);
const headers = { "Content-Type": "application/json" };
if (isAnthropic) {
headers[llm.authHeader || "x-api-key"] = llm.token;
headers["anthropic-version"] = "2023-06-01";
} else {
headers.Authorization = `Bearer ${llm.token}`;
}
for (const pair of String(llm.extraHeaders || "").split(",")) {
const [k, ...rest] = pair.split("=");
if (k && rest.length) headers[k.trim()] = rest.join("=").trim();
}
const payload = isAnthropic
? {
model: llm.model,
max_tokens: 1024,
system: messages[0].content,
messages: [{ role: "user", content: messages[1].content }],
}
: { model: llm.model, messages, max_tokens: 1024, stream: false };
let res;
try {
res = await fetch(endpoint, {
method: "POST",
headers,
body: JSON.stringify(payload),
signal: AbortSignal.timeout(timeoutMs),
});
} catch (err) {
const reason = err.name === "TimeoutError" ? `请求超时(${timeoutMs} ms)` : err.message;
return { ok: false, error: `调用 LLM 失败:${reason}` };
}
const text = await res.text();
let parsed;
try {
parsed = JSON.parse(text);
} catch {
return { ok: false, error: `无法解析 LLM 响应(HTTP ${res.status}):${clip(text, 200)}` };
}
if (!res.ok) {
const msg = typeof parsed.error === "string"
? parsed.error
: (parsed.error?.message || JSON.stringify(parsed.error || parsed));
return { ok: false, error: `LLM 返回 HTTP ${res.status}:${clip(msg, 200)}` };
}
const content = isAnthropic
? (parsed.content || []).map((p) => p.text || "").join("")
: (parsed.choices?.[0]?.message?.content ?? "");
if (!content) return { ok: false, error: `LLM 返回空内容:${clip(text, 200)}` };
return { ok: true, text: String(content).trim(), servedModel: parsed.model || null };
}
/** Normalise the model output into the fixed four-section shape. */
export function normaliseSummary(text) {
const cleaned = String(text || "")
.replace(/```[a-z]*\n?/gi, "")
.replace(/^\s*#{1,6}\s*/gm, "")
.trim();
const found = SUMMARY_SECTIONS.filter((s) => new RegExp(`^\\s*${s}[::]`, "m").test(cleaned));
const collapsed = cleaned.replace(/\n{3,}/g, "\n\n");
const clipped = collapsed.length > SUMMARY_MAX_CHARS
? `${collapsed.slice(0, SUMMARY_MAX_CHARS - 1)}…`
: collapsed;
return { text: clipped, complete: found.length === SUMMARY_SECTIONS.length, sectionsFound: found };
}
/**
* Summarise one review record.
* @returns {Promise<{ok: boolean, text?: string, error?: string, model?: string}>}
*/
export async function summariseReview({ llm, repo, record, findings, summary, requirement, timeoutMs = 120000 }) {
if (!llm?.url || !llm?.token || !llm?.model) {
return { ok: false, error: "未配置全局 LLM,无法生成审查摘要" };
}
const messages = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: buildSummaryPrompt({ repo, record, findings, summary, requirement }) },
];
const res = await runLlm(llm, messages, timeoutMs);
if (!res.ok) return res;
const normalised = normaliseSummary(res.text);
return { ok: true, text: normalised.text, model: res.servedModel || llm.model, complete: normalised.complete };
}
export { SYSTEM_PROMPT };
+173 -20
View File
@@ -8,13 +8,14 @@ import { fileURLToPath } from "node:url";
import { import {
allSettings, deleteRepository, enqueueJob, findJobBySha, findRepository, allSettings, deleteRepository, enqueueJob, findJobBySha, findRepository,
getJob, getSetting, jobStats, listJobs, listRepositories, openDatabase, getJob, getReviewRecord, jobStats, listJobs, listRepositories, listReviewRecords,
recordDelivery, pruneDeliveries, setSetting, upsertRepository, getRepository, openDatabase, recordDelivery, pruneDeliveries, requeueStaleSummaries, setSetting,
updateReviewRecord, upsertRepository, getRepository,
} from "./lib/db.js"; } from "./lib/db.js";
import { GiteaClient } from "./lib/gitea.js"; import { GiteaClient } from "./lib/gitea.js";
import { OcrRunner } from "./lib/ocr.js"; import { OcrRunner } from "./lib/ocr.js";
import { JobQueue } from "./lib/queue.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 APP_DIR = resolve(fileURLToPath(new URL(".", import.meta.url)));
const ROOT_DIR = resolve(APP_DIR, ".."); const ROOT_DIR = resolve(APP_DIR, "..");
@@ -130,6 +131,15 @@ function verifySignature(secret, rawBody, signature) {
return a.length === b.length && timingSafeEqual(a, b); 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) { function authorized(req, cfg) {
if (!cfg.adminToken) return true; if (!cfg.adminToken) return true;
const header = req.headers.authorization || ""; const header = req.headers.authorization || "";
@@ -156,8 +166,8 @@ async function handlePush(payload, cfg) {
const refName = refNameFromPayload(payload); const refName = refNameFromPayload(payload);
if (!refName || payload.deleted) return { queued: 0, reason: "branch deletion or empty ref" }; if (!refName || payload.deleted) return { queued: 0, reason: "branch deletion or empty ref" };
if (!branchMatches(refName, repo.branch_patterns)) { if (!branchMatches(refName, repo.check_branches)) {
return { queued: 0, reason: `branch ${refName} does not match patterns` }; return { queued: 0, reason: `branch ${refName} is not in the checked branch list` };
} }
const toSha = payload.after || payload.head_commit?.id; const toSha = payload.after || payload.head_commit?.id;
@@ -178,7 +188,7 @@ async function handlePush(payload, cfg) {
repoId: repo.id, repoId: repo.id,
trigger: "push", trigger: "push",
refName, refName,
baseRef: pr?.base?.ref ?? repo.base_branch, baseRef: pr?.base?.ref ?? repo.managed_branch,
fromSha: before, fromSha: before,
toSha, toSha,
prNumber: pr?.number ?? null, prNumber: pr?.number ?? null,
@@ -202,8 +212,8 @@ async function handlePullRequest(payload, cfg) {
const pr = payload.pull_request; const pr = payload.pull_request;
if (!pr) return { queued: 0, reason: "no pull_request in payload" }; if (!pr) return { queued: 0, reason: "no pull_request in payload" };
if (pr.draft) return { queued: 0, reason: "draft pull request" }; if (pr.draft) return { queued: 0, reason: "draft pull request" };
if (!branchMatches(pr.head?.ref, repo.branch_patterns)) { if (!branchMatches(pr.head?.ref, repo.check_branches)) {
return { queued: 0, reason: `head branch ${pr.head?.ref} does not match patterns` }; return { queued: 0, reason: `head branch ${pr.head?.ref} is not in the checked branch list` };
} }
const toSha = pr.head?.sha; const toSha = pr.head?.sha;
if (!toSha) return { queued: 0, reason: "no head sha" }; if (!toSha) return { queued: 0, reason: "no head sha" };
@@ -214,7 +224,7 @@ async function handlePullRequest(payload, cfg) {
repoId: repo.id, repoId: repo.id,
trigger: `pull_request.${action}`, trigger: `pull_request.${action}`,
refName: pr.head.ref, refName: pr.head.ref,
baseRef: pr.base?.ref ?? repo.base_branch, baseRef: pr.base?.ref ?? repo.managed_branch,
fromSha: pr.base?.sha ?? null, fromSha: pr.base?.sha ?? null,
toSha, toSha,
prNumber: pr.number, prNumber: pr.number,
@@ -289,13 +299,32 @@ async function handleApi(req, res, url, cfg) {
if (path === "/repos" && req.method === "POST") { if (path === "/repos" && req.method === "POST") {
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}"); 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, { const repo = upsertRepository(db, {
owner: body.owner, owner,
name: body.name, name,
repo_url: repoUrl,
enabled: body.enabled === undefined ? 1 : Number(Boolean(body.enabled)), enabled: body.enabled === undefined ? 1 : Number(Boolean(body.enabled)),
base_branch: body.base_branch || "main", managed_branch: managed,
branch_patterns: body.branch_patterns || "*", check_branches: checks,
review_scope: body.review_scope || "both", review_scope: body.review_scope || "both",
create_issue: body.create_issue === undefined ? 1 : Number(Boolean(body.create_issue)), create_issue: body.create_issue === undefined ? 1 : Number(Boolean(body.create_issue)),
auto_merge: Number(Boolean(body.auto_merge)), auto_merge: Number(Boolean(body.auto_merge)),
@@ -303,6 +332,41 @@ async function handleApi(req, res, url, cfg) {
return json(res, 201, repo); 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); const repoMatch = /^\/repos\/(\d+)$/.exec(path);
if (repoMatch) { if (repoMatch) {
const id = Number(repoMatch[1]); const id = Number(repoMatch[1]);
@@ -331,15 +395,39 @@ async function handleApi(req, res, url, cfg) {
try { try {
const info = await client.getRepo(repo.owner, repo.name); const info = await client.getRepo(repo.owner, repo.name);
const branches = await client.listRepoBranches(repo.owner, repo.name); const branches = await client.listRepoBranches(repo.owner, repo.name);
const updated = upsertRepository(db, { const names = branches.map((b) => b.name);
id: repo.id, // Seed sensible branch defaults the first time a repository is opened.
base_branch: repo.base_branch || info.default_branch, 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, { return json(res, 200, {
repo: updated, repo: updated,
default_branch: info.default_branch, default_branch: info.default_branch,
has_issues: info.has_issues, has_issues: info.has_issues,
has_pull_requests: info.has_pull_requests, 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), branches: branches.map((b) => b.name),
}); });
} catch (err) { } 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") { if (path === "/jobs" && req.method === "GET") {
const repoId = url.searchParams.get("repo_id"); const repoId = url.searchParams.get("repo_id");
const limit = Math.min(Number(url.searchParams.get("limit") || 50), 200); 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, { const jobId = enqueueJob(db, {
repoId: repo.id, repoId: repo.id,
trigger: "manual", trigger: "manual",
refName: body.ref || repo.base_branch, refName: body.ref || repo.managed_branch,
baseRef: body.base_ref || repo.base_branch, baseRef: body.base_ref || repo.managed_branch,
fromSha: body.from_sha || null, fromSha: body.from_sha || null,
toSha, toSha,
prNumber: body.pr_number || null, prNumber: body.pr_number || null,
+390 -44
View File
@@ -1,6 +1,6 @@
"use strict"; "use strict";
const state = { repos: [], jobs: [], token: "" }; const state = { repos: [], jobs: [], records: [], token: "", branches: [] };
/* ---------------------------------- auth ---------------------------------- */ /* ---------------------------------- auth ---------------------------------- */
@@ -83,9 +83,10 @@ async function api(path, { method = "GET", body } = {}) {
return parsed; return parsed;
} }
function show(selector, content) { function show(selector, content, cls) {
const el = document.querySelector(selector); const el = document.querySelector(selector);
el.textContent = typeof content === "string" ? content : JSON.stringify(content, null, 2); el.textContent = typeof content === "string" ? content : JSON.stringify(content, null, 2);
if (cls) el.className = cls; else el.className = "log";
el.classList.remove("hidden"); el.classList.remove("hidden");
} }
@@ -102,6 +103,15 @@ function fmtTime(value) {
return String(value).replace("T", " ").replace("Z", ""); 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 ---------------------------------- */ /* ---------------------------------- tabs ---------------------------------- */
for (const tab of document.querySelectorAll(".tab")) { 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}`); p.classList.toggle("active", p.id === `tab-${tab.dataset.tab}`);
} }
if (tab.dataset.tab === "jobs") loadJobs().catch(handleError); if (tab.dataset.tab === "jobs") loadJobs().catch(handleError);
if (tab.dataset.tab === "records") loadRecords().catch(handleError);
if (tab.dataset.tab === "settings") loadSettings().catch(handleError); if (tab.dataset.tab === "settings") loadSettings().catch(handleError);
}); });
} }
/* --------------------------------- health --------------------------------- */
async function loadHealth() { async function loadHealth() {
try { try {
const h = await api("/health"); 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 --------------------------------- */ /* ---------------------------------- repos --------------------------------- */
const REPO_FIELDS = [ 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", "gitea_token", "llm_model", "llm_token", "background_template", "excludes",
"publish_mode", "create_issue", "issue_labels", "block_severity", "block_categories", "publish_mode", "create_issue", "issue_labels", "block_severity", "block_categories",
"fail_on_findings", "auto_merge", "auto_merge_mode", "merge_method", "delete_branch", "fail_on_findings", "auto_merge", "auto_merge_mode", "merge_method", "delete_branch",
@@ -152,23 +152,38 @@ async function loadRepos() {
state.repos = await api("/repos"); state.repos = await api("/repos");
const tbody = document.querySelector("#repos-table tbody"); const tbody = document.querySelector("#repos-table tbody");
if (state.repos.length === 0) { if (state.repos.length === 0) {
tbody.innerHTML = '<tr><td colspan="7" class="empty">还没有配置仓库。点右上角「新增仓库」开始,然后在 Gitea 仓库里添加 Webhook。</td></tr>'; tbody.innerHTML = '<tr><td colspan="8" class="empty">还没有配置仓库。点「从 Gitea 导入」批量选择,或「新增仓库」手动填 Git 地址。</td></tr>';
return; } else {
tbody.innerHTML = state.repos.map((r) => `
<tr>
<td><strong>${esc(r.owner)}/${esc(r.name)}</strong><br><span class="hint">${esc(shortUrl(r.repo_url))}</span></td>
<td><code>${esc(r.managed_branch)}</code></td>
<td>${branchTags(r.check_branches, r.managed_branch)}</td>
<td>${esc(scopeLabel(r.review_scope))}</td>
<td>${r.enabled ? '<span class="tag ok">启用</span>' : '<span class="tag muted">停用</span>'}</td>
<td>${r.auto_merge ? `<span class="tag warn">${esc(r.merge_method)}</span>` : '<span class="tag muted">否</span>'}</td>
<td><code>${esc(r.block_severity || "—")}</code></td>
<td>
<button data-edit="${r.id}">编辑</button>
<button data-run="${r.id}">立即审查</button>
<button data-records="${r.id}">历史</button>
<button class="danger" data-del="${r.id}">删除</button>
</td>
</tr>`).join("");
} }
tbody.innerHTML = state.repos.map((r) => ` syncRecordFilter();
<tr> }
<td><strong>${esc(r.owner)}/${esc(r.name)}</strong><br><span class="tag muted">${esc(r.base_branch)}</span></td>
<td><code>${esc(r.branch_patterns)}</code></td> function shortUrl(url) {
<td>${esc(scopeLabel(r.review_scope))}</td> return String(url || "").replace(/^https?:\/\//, "");
<td>${r.enabled ? '<span class="tag ok">启用</span>' : '<span class="tag muted">停用</span>'}</td> }
<td>${r.auto_merge ? `<span class="tag warn">${esc(r.merge_method)}</span>` : '<span class="tag muted">否</span>'}</td>
<td><code>${esc(r.block_severity || "—")}</code></td> function branchTags(list, managed) {
<td> const names = String(list || "").split(",").map((s) => s.trim()).filter(Boolean);
<button data-edit="${r.id}">编辑</button> if (!names.length) return '<span class="tag muted">—</span>';
<button data-run="${r.id}">立即审查</button> return names.map((n) => n === managed
<button class="danger" data-del="${r.id}">删除</button> ? `<span class="tag ok">${esc(n)}</span>`
</td> : `<span class="tag muted">${esc(n)}</span>`).join(" ");
</tr>`).join("");
} }
function scopeLabel(scope) { function scopeLabel(scope) {
@@ -179,13 +194,17 @@ document.querySelector("#repos-table").addEventListener("click", async (ev) => {
const target = ev.target.closest("button"); const target = ev.target.closest("button");
if (!target) return; if (!target) return;
try { 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 (target.dataset.del) {
if (!confirm("删除该仓库配置?历史任务会一并删除。")) return; if (!confirm("删除该仓库配置?历史任务会一并删除。")) return;
await api(`/repos/${target.dataset.del}`, { method: "DELETE" }); await api(`/repos/${target.dataset.del}`, { method: "DELETE" });
await loadRepos(); await loadRepos();
} }
if (target.dataset.run) openManual(Number(target.dataset.run)); 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) { } catch (err) {
handleError(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("add-repo").addEventListener("click", () => openRepo(null));
document.getElementById("refresh-jobs").addEventListener("click", () => loadJobs().catch(handleError)); 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 modal = document.getElementById("modal");
const form = document.getElementById("repo-form"); 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 = '<span class="hint">点「加载分支」获取远端分支列表。</span>';
return;
}
branchList.innerHTML = state.branches.map((b) => {
const on = chosen.has(b);
const label = b === managed ? `${b}(管理)` : b;
return `<button type="button" class="${on ? "on" : ""}" data-branch="${esc(b)}">${esc(label)}</button>`;
}).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(); form.reset();
hide("#repo-result"); hide("#repo-result");
state.branches = [];
renderBranchList();
const repo = id ? state.repos.find((r) => r.id === id) : null; const repo = id ? state.repos.find((r) => r.id === id) : null;
document.getElementById("modal-title").textContent = repo ? `${repo.owner}/${repo.name}` : "新增仓库"; document.getElementById("modal-title").textContent = repo ? `${repo.owner}/${repo.name}` : "新增仓库";
document.getElementById("repo-parsed").textContent = "";
for (const field of REPO_FIELDS) { for (const field of REPO_FIELDS) {
const input = form.elements[field]; const input = form.elements[field];
if (!input) continue; if (!input) continue;
if (!repo) { if (!repo) {
// Defaults for a brand-new repository.
if (field === "enabled" || field === "create_issue") input.checked = true; if (field === "enabled" || field === "create_issue") input.checked = true;
if (field === "managed_branch") input.value = "main";
if (field === "check_branches") input.value = "main";
continue; continue;
} }
if (input.type === "checkbox") input.checked = Boolean(repo[field]); if (input.type === "checkbox") input.checked = Boolean(repo[field]);
else if (input.type === "password") input.value = ""; else if (input.type === "password") input.value = "";
else input.value = repo[field] ?? ""; 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"); modal.classList.remove("hidden");
} }
document.getElementById("modal-close").addEventListener("click", () => modal.classList.add("hidden")); document.getElementById("modal-close").addEventListener("click", () => modal.classList.add("hidden"));
modal.addEventListener("click", (ev) => { if (ev.target === modal) 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) => `<option value="${esc(b)}">${esc(b)}</option>`).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) => { form.addEventListener("submit", async (ev) => {
ev.preventDefault(); ev.preventDefault();
const data = {}; 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; const id = form.elements.id.value;
if (!id) return show("#repo-result", "请先保存仓库,再测试连接。"); if (!id) return show("#clone-result", "请先保存仓库,再创建分支。");
show("#repo-result", "测试中…");
try { try {
const info = await api(`/repos/${id}/discover`, { method: "POST" }); const res = await api(`/repos/${id}/branches`, {
show("#repo-result", method: "POST",
`连接成功\n默认分支:${info.default_branch}\nIssue 功能:${info.has_issues}\nPR 功能:${info.has_pull_requests}\n分支:${info.branches.join(", ")}`); 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) { } catch (err) {
if (err.status === 401) return handleError(err); show("#clone-result", err.message);
show("#repo-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 = '<div class="empty">正在拉取仓库列表…</div>';
try {
state.remoteRepos = await api("/gitea/repos");
renderImportList();
} catch (err) {
document.getElementById("import-list").innerHTML = `<div class="empty">拉取失败:${esc(err.message)}</div>`;
}
});
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 = '<div class="empty">没有匹配的仓库。</div>';
return;
}
box.innerHTML = list.map((r) => `
<div class="import-row">
<div>
<div class="name">${esc(r.full_name)}</div>
<div class="meta">${esc(r.default_branch || "main")}${r.private ? " · 私有" : ""}${r.empty ? " · 空仓库" : ""}${r.archived ? " · 已归档" : ""}${r.description ? ` · ${esc(r.description)}` : ""}</div>
</div>
<div class="actions">
${r.configured
? '<span class="tag ok">已配置</span>'
: `<button data-import="${esc(r.full_name)}" ${r.empty ? "disabled" : ""}>${r.empty ? "空仓库" : "添加"}</button>`}
</div>
</div>`).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(); manualForm.reset();
hide("#manual-result"); hide("#manual-result");
manualForm.elements.repo_id.value = repo.id; manualForm.elements.repo_id.value = repo.id;
manualForm.elements.ref.value = repo.base_branch || "main"; manualForm.elements.ref.value = repo.managed_branch || "main";
manualForm.elements.base_ref.value = repo.base_branch || "main"; manualForm.elements.base_ref.value = repo.managed_branch || "main";
document.getElementById("manual-repo").textContent = `${repo.owner}/${repo.name}`; document.getElementById("manual-repo").textContent = `${repo.owner}/${repo.name}`;
manualModal.classList.remove("hidden"); 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 = '<option value="">全部仓库</option>' +
state.repos.map((r) => `<option value="${r.id}">${esc(r.owner)}/${esc(r.name)}</option>`).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 = '<div class="empty">暂无审查记录。仓库收到 push 或 PR 后,这里会出现摘要。</div>';
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 `
<article class="record">
<div class="record-head">
<div>
<div class="who">${esc(r.owner)}/${esc(r.name)} · ${esc(r.ref_name)}</div>
<div class="record-meta">
<span>${fmtTime(r.created_at)}</span>
<span>提交 <code>${esc(String(r.to_sha).slice(0, 10))}</code></span>
<span>${esc(r.trigger || "")}</span>
${r.pr_number ? `<a href="${esc(r.pr_url || "#")}" target="_blank" rel="noreferrer">PR #${r.pr_number}</a>` : ""}
<span>发现 ${r.findings_count ?? 0} 条</span>
${r.tokens_total ? `<span>${r.tokens_total} tokens</span>` : ""}
${r.elapsed ? `<span>${esc(r.elapsed)}</span>` : ""}
</div>
</div>
<div class="actions">
<span class="tag ${r.summary_status === "done" ? "ok" : (r.summary_status === "failed" ? "err" : "muted")}">${esc(r.summary_status)}</span>
<button data-record="${r.id}">详情</button>
</div>
</div>
<div class="record-summary${summaryCls}">${summary}</div>
</article>`;
}
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 = [
["提交", `<code>${esc(rec.to_sha)}</code>`],
["对比基准", rec.from_sha ? `<code>${esc(rec.from_sha)}</code>` : "—"],
["触发来源", esc(rec.trigger || "—")],
["Pull Request", rec.pr_number
? `<a href="${esc(rec.pr_url || "#")}" target="_blank" rel="noreferrer">#${rec.pr_number}</a>`
: "—"],
["管理分支", 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) => `
<div class="finding${f.blocking ? " blocking" : ""}">
<div class="where">${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 ? " · 阻断" : ""}</div>
<div>${esc(f.content || "")}</div>
</div>`).join("")
: '<div class="empty">本次没有产生意见。</div>';
document.getElementById("record-body").innerHTML = `
<h3>审查需求 / 背景</h3>
<div class="record-summary">${rec.requirement ? esc(rec.requirement) : "(未填写)"}</div>
<h3 style="margin-top:16px">AI 摘要</h3>
<div class="record-summary${rec.summary_status === "done" ? "" : " pending"}">${
rec.summary_status === "done" ? esc(rec.summary || "") : esc(rec.summary_error || "生成中…")
}</div>
<h3 style="margin-top:16px">运行参数</h3>
<dl class="kv">${rows.map(([k, v]) => `<dt>${esc(k)}</dt><dd>${v}</dd>`).join("")}</dl>
<h3 style="margin-top:16px">审查意见(${findings.length})</h3>
${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 ---------------------------------- */ /* ---------------------------------- jobs ---------------------------------- */
const STATUS_TAG = { const STATUS_TAG = {
+125 -36
View File
@@ -10,11 +10,15 @@
<body> <body>
<header> <header>
<h1>Gitea 代码审查</h1> <h1>Gitea 代码审查</h1>
<div class="right"><div class="status" id="status">加载中…</div><button class="linkish hidden" id="sign-out">清除访问 Token</button></div> <div class="right">
<div class="status" id="status">加载中…</div>
<button class="linkish hidden" id="sign-out">清除访问 Token</button>
</div>
</header> </header>
<nav> <nav>
<button class="tab active" data-tab="repos">仓库</button> <button class="tab active" data-tab="repos">仓库</button>
<button class="tab" data-tab="records">审查历史</button>
<button class="tab" data-tab="jobs">任务</button> <button class="tab" data-tab="jobs">任务</button>
<button class="tab" data-tab="settings">设置</button> <button class="tab" data-tab="settings">设置</button>
</nav> </nav>
@@ -25,19 +29,33 @@
<section id="tab-repos" class="panel active"> <section id="tab-repos" class="panel active">
<div class="row"> <div class="row">
<h2>已配置仓库</h2> <h2>已配置仓库</h2>
<button id="add-repo">新增仓库</button> <div class="actions">
<button id="import-repo">从 Gitea 导入</button>
<button class="primary" id="add-repo">新增仓库</button>
</div>
</div> </div>
<table id="repos-table"> <table id="repos-table">
<thead> <thead>
<tr> <tr>
<th>仓库</th><th>分支过滤</th><th>范围</th><th>启用</th> <th>仓库</th><th>管理分支</th><th>检查分支</th><th>范围</th>
<th>自动合并</th><th>阻断级别</th><th></th> <th>启用</th><th>自动合并</th><th>阻断级别</th><th></th>
</tr> </tr>
</thead> </thead>
<tbody></tbody> <tbody></tbody>
</table> </table>
</section> </section>
<section id="tab-records" class="panel">
<div class="row">
<h2>审查历史摘要</h2>
<div class="actions">
<select id="record-filter"><option value="">全部仓库</option></select>
<button id="refresh-records">刷新</button>
</div>
</div>
<div id="records-list" class="records"></div>
</section>
<section id="tab-jobs" class="panel"> <section id="tab-jobs" class="panel">
<div class="row"> <div class="row">
<h2>审查任务</h2> <h2>审查任务</h2>
@@ -58,17 +76,17 @@
<form id="settings-form"> <form id="settings-form">
<fieldset> <fieldset>
<legend>Gitea</legend> <legend>Gitea</legend>
<label>API 地址 <input name="giteaUrl" placeholder="http://127.0.0.1:80" /></label> <label>API 地址 <input name="giteaUrl" placeholder="http://192.168.31.51" /></label>
<label>管理员 Token <input name="giteaToken" type="password" placeholder="留空表示不修改" /></label> <label>管理员 Token <input name="giteaToken" type="password" placeholder="留空表示不修改" /></label>
<label>Webhook 密钥 <input name="webhookSecret" type="password" placeholder="留空表示不修改" /></label> <label>Webhook 密钥 <input name="webhookSecret" type="password" placeholder="留空表示不修改" /></label>
<label>后台访问 Token <input name="adminToken" type="password" placeholder="留空表示不修改" /></label> <label>后台访问 Token <input name="adminToken" type="password" placeholder="留空表示不修改" /></label>
<p class="hint">Webhook 地址:<code id="hook-url"></code>(在仓库 Settings → Webhooks 中添加,密钥填上面这一项)</p> <p class="hint">Webhook 地址:<code id="hook-url"></code>(在仓库 Settings → Webhooks 中添加,密钥填上面这一项)</p>
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>LLM</legend> <legend>LLM(代码审查与历史摘要共用)</legend>
<label>接口地址 <input name="llmUrl" placeholder="https://api.openai.com/v1" /></label> <label>接口地址 <input name="llmUrl" placeholder="https://api.openai.com/v1" /></label>
<label>API Key <input name="llmToken" type="password" placeholder="留空表示不修改" /></label> <label>API Key <input name="llmToken" type="password" placeholder="留空表示不修改" /></label>
<label>模型 <input name="llmModel" placeholder="gpt-5.5" /></label> <label>模型 <input name="llmModel" placeholder="gpt-5.6-sol" /></label>
<label>协议 <input name="llmProtocol" placeholder="openai / anthropic" /></label> <label>协议 <input name="llmProtocol" placeholder="openai / anthropic" /></label>
<label>自定义认证头 <input name="llmAuthHeader" placeholder="留空使用默认" /></label> <label>自定义认证头 <input name="llmAuthHeader" placeholder="留空使用默认" /></label>
<label>附加请求头 <input name="llmExtraHeaders" placeholder="K=V,K=V" /></label> <label>附加请求头 <input name="llmExtraHeaders" placeholder="K=V,K=V" /></label>
@@ -100,6 +118,18 @@
</div> </div>
</div> </div>
<div id="import-modal" class="modal hidden">
<div class="modal-body">
<h2>从 Gitea 导入仓库</h2>
<p class="hint">使用全局 Token 拉取当前账号可见的仓库,点击即可创建监控配置。</p>
<input id="import-search" placeholder="按仓库名过滤…" />
<div id="import-list" class="import-list"></div>
<div class="actions">
<button type="button" id="import-close">关闭</button>
</div>
</div>
</div>
<div id="manual-modal" class="modal hidden"> <div id="manual-modal" class="modal hidden">
<div class="modal-body narrow"> <div class="modal-body narrow">
<h2>立即审查</h2> <h2>立即审查</h2>
@@ -108,7 +138,7 @@
<p class="hint" id="manual-repo"></p> <p class="hint" id="manual-repo"></p>
<label>分支 <input name="ref" placeholder="main" required /></label> <label>分支 <input name="ref" placeholder="main" required /></label>
<label>提交 SHA <input name="sha" placeholder="40 位 commit hash" required /></label> <label>提交 SHA <input name="sha" placeholder="40 位 commit hash" required /></label>
<label>基准分支 <input name="base_ref" placeholder="main" /></label> <label>基准分支 <input name="base_ref" placeholder="管理分支" /></label>
<label>关联 Pull Request 编号(可选) <input name="pr_number" type="number" min="1" /></label> <label>关联 Pull Request 编号(可选) <input name="pr_number" type="number" min="1" /></label>
<div class="actions"> <div class="actions">
<button type="submit">加入队列</button> <button type="submit">加入队列</button>
@@ -119,40 +149,83 @@
</div> </div>
</div> </div>
<div id="record-modal" class="modal hidden">
<div class="modal-body">
<h2 id="record-title">审查记录</h2>
<div id="record-body"></div>
<div class="actions">
<button type="button" id="record-resummarise">重新生成摘要</button>
<button type="button" id="record-close">关闭</button>
</div>
</div>
</div>
<div id="modal" class="modal hidden"> <div id="modal" class="modal hidden">
<div class="modal-body"> <div class="modal-body">
<h2 id="modal-title">仓库配置</h2> <h2 id="modal-title">仓库配置</h2>
<form id="repo-form"> <form id="repo-form">
<input type="hidden" name="id" /> <input type="hidden" name="id" />
<div class="grid"> <fieldset>
<label>所有者 <input name="owner" required /></label> <legend>仓库</legend>
<label>仓库名 <input name="name" required /></label> <label>Git 地址
<label>目标分支 <input name="base_branch" placeholder="main" /></label> <span class="inline">
<label>监听分支模式 <input name="branch_patterns" placeholder="*, release/*" /></label> <input name="repo_url" placeholder="http://192.168.31.51/kgod/myrepo.git" required />
<label>审查范围 <button type="button" id="repo-test">测试连接</button>
<select name="review_scope"> </span>
<option value="both">Push 与 PR 都审查</option>
<option value="pr">仅 Pull Request</option>
<option value="push">仅分支 Push</option>
</select>
</label> </label>
<label>发布方式 <p class="hint" id="repo-parsed"></p>
<select name="publish_mode"> </fieldset>
<option value="inline">PR 内联评论</option>
<option value="issue-only">仅 Issue</option> <fieldset>
</select> <legend>分支</legend>
<label>管理分支(合并目标)
<span class="inline">
<input name="managed_branch" placeholder="main" />
<button type="button" id="branch-create">克隆创建</button>
</span>
</label> </label>
<label>阻断级别 <input name="block_severity" placeholder="critical,high" /></label> <p class="hint">所有检查通过后只合并到这一个分支。</p>
<label>阻断类别 <input name="block_categories" placeholder="security" /></label> <label>检查分支(可多选)
<label>Issue 标签 <input name="issue_labels" placeholder="code-review" /></label> <span class="inline">
<label>最大意见数 <input name="max_comments" type="number" min="1" max="200" /></label> <input name="check_branches" placeholder="main,release/1.0" />
<label>并发数 <input name="concurrency" type="number" min="1" max="16" /></label> <button type="button" id="branch-reload">加载分支</button>
<label>排除路径 <input name="excludes" placeholder="**/dist/**,**/*.lock" /></label> </span>
<label>仓库专用 Gitea Token <input name="gitea_token" type="password" placeholder="留空使用全局" /></label> </label>
<label>仓库专用 LLM Key <input name="llm_token" type="password" placeholder="留空使用全局" /></label> <div id="branch-list" class="branch-list"></div>
<label>LLM 模型 <input name="llm_model" placeholder="留空使用全局" /></label> <p class="hint">这些分支都会被监控审查;只有指向管理分支的 PR 才会自动合并。</p>
<label>审查背景 <input name="background_template" placeholder="补充业务上下文" /></label> </fieldset>
</div>
<fieldset>
<legend>审查</legend>
<div class="grid">
<label>审查范围
<select name="review_scope">
<option value="both">Push 与 PR 都审查</option>
<option value="pr">仅 Pull Request</option>
<option value="push">仅分支 Push</option>
</select>
</label>
<label>发布方式
<select name="publish_mode">
<option value="inline">PR 内联评论</option>
<option value="issue-only">仅 Issue</option>
</select>
</label>
<label>阻断级别 <input name="block_severity" placeholder="critical,high" /></label>
<label>阻断类别 <input name="block_categories" placeholder="security" /></label>
<label>Issue 标签 <input name="issue_labels" placeholder="code-review" /></label>
<label>最大意见数 <input name="max_comments" type="number" min="1" max="200" /></label>
<label>并发数 <input name="concurrency" type="number" min="1" max="16" /></label>
<label>排除路径 <input name="excludes" placeholder="**/dist/**,**/*.lock" /></label>
<label>仓库专用 Gitea Token <input name="gitea_token" type="password" placeholder="留空使用全局" /></label>
<label>仓库专用 LLM Key <input name="llm_token" type="password" placeholder="留空使用全局" /></label>
<label>LLM 模型 <input name="llm_model" placeholder="留空使用全局" /></label>
</div>
<label>审查背景 / 需求
<textarea name="background_template" rows="6" placeholder="补充业务上下文、本次需求说明;会作为审查背景传给模型,并记录到审查历史。"></textarea>
</label>
</fieldset>
<div class="checks"> <div class="checks">
<label><input type="checkbox" name="enabled" /> 启用</label> <label><input type="checkbox" name="enabled" /> 启用</label>
<label><input type="checkbox" name="create_issue" /> 发现问题时创建 Issue</label> <label><input type="checkbox" name="create_issue" /> 发现问题时创建 Issue</label>
@@ -179,7 +252,6 @@
</div> </div>
<div class="actions"> <div class="actions">
<button type="submit">保存</button> <button type="submit">保存</button>
<button type="button" id="discover">测试连接</button>
<button type="button" id="modal-close">取消</button> <button type="button" id="modal-close">取消</button>
</div> </div>
<pre id="repo-result" class="log hidden"></pre> <pre id="repo-result" class="log hidden"></pre>
@@ -187,6 +259,23 @@
</div> </div>
</div> </div>
<div id="clone-modal" class="modal hidden">
<div class="modal-body narrow">
<h2>创建管理分支</h2>
<form id="clone-form">
<label>新分支名 <input name="new_branch" placeholder="main" required /></label>
<label>从哪个分支复制
<select name="from_branch" id="clone-source"></select>
</label>
<div class="actions">
<button type="submit">创建</button>
<button type="button" id="clone-close">取消</button>
</div>
<pre id="clone-result" class="log hidden"></pre>
</form>
</div>
</div>
<script src="/app.js"></script> <script src="/app.js"></script>
</body> </body>
</html> </html>
+51 -6
View File
@@ -23,8 +23,10 @@ header {
} }
h1 { font-size: 18px; margin: 0; } h1 { font-size: 18px; margin: 0; }
h2 { font-size: 15px; margin: 0 0 12px; } h2 { font-size: 15px; margin: 0 0 12px; }
h3 { font-size: 14px; margin: 0 0 8px; }
.status { color: var(--muted); font-size: 13px; } .status { color: var(--muted); font-size: 13px; }
nav { display: flex; gap: 4px; padding: 12px 24px 0; } header .right { display: flex; align-items: center; gap: 12px; }
nav { display: flex; gap: 4px; padding: 12px 24px 0; flex-wrap: wrap; }
.tab { .tab {
background: transparent; border: 1px solid transparent; color: var(--muted); background: transparent; border: 1px solid transparent; color: var(--muted);
padding: 8px 14px; border-radius: 8px 8px 0 0; cursor: pointer; font-size: 14px; padding: 8px 14px; border-radius: 8px 8px 0 0; cursor: pointer; font-size: 14px;
@@ -33,7 +35,7 @@ nav { display: flex; gap: 4px; padding: 12px 24px 0; }
main { padding: 0 24px 48px; } main { padding: 0 24px 48px; }
.panel { display: none; background: var(--panel); border: 1px solid var(--border); border-radius: 0 8px 8px 8px; padding: 20px; } .panel { display: none; background: var(--panel); border: 1px solid var(--border); border-radius: 0 8px 8px 8px; padding: 20px; }
.panel.active { display: block; } .panel.active { display: block; }
.row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; } .row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; gap: 12px; flex-wrap: wrap; }
button { button {
background: var(--panel-2); color: var(--text); border: 1px solid var(--border); background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
padding: 7px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; padding: 7px 14px; border-radius: 6px; cursor: pointer; font-size: 13px;
@@ -41,6 +43,7 @@ button {
button:hover { border-color: var(--accent); } button:hover { border-color: var(--accent); }
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; } button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
button.danger:hover { border-color: var(--err); color: var(--err); } button.danger:hover { border-color: var(--err); color: var(--err); }
button:disabled { opacity: 0.5; cursor: not-allowed; }
table { width: 100%; border-collapse: collapse; font-size: 13px; } table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: top; } th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
th { color: var(--muted); font-weight: 500; } th { color: var(--muted); font-weight: 500; }
@@ -54,17 +57,23 @@ code { background: var(--panel-2); padding: 1px 5px; border-radius: 4px; font-si
fieldset { border: 1px solid var(--border); border-radius: 8px; margin: 0 0 18px; padding: 16px; } fieldset { border: 1px solid var(--border); border-radius: 8px; margin: 0 0 18px; padding: 16px; }
legend { color: var(--muted); padding: 0 6px; } legend { color: var(--muted); padding: 0 6px; }
label { display: block; margin-bottom: 10px; color: var(--muted); font-size: 13px; } label { display: block; margin-bottom: 10px; color: var(--muted); font-size: 13px; }
input, select { input, select, textarea {
display: block; width: 100%; margin-top: 4px; padding: 7px 9px; display: block; width: 100%; margin-top: 4px; padding: 7px 9px;
background: var(--bg); border: 1px solid var(--border); border-radius: 6px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
color: var(--text); font-size: 13px; color: var(--text); font-size: 13px; font-family: inherit;
} }
textarea { resize: vertical; min-height: 120px; max-height: 360px; overflow-y: auto; line-height: 1.6; }
.inline { display: flex; gap: 8px; align-items: flex-start; }
.inline input { flex: 1; }
.inline button { margin-top: 4px; white-space: nowrap; }
.checks { display: flex; flex-wrap: wrap; gap: 16px; margin: 8px 0 14px; } .checks { display: flex; flex-wrap: wrap; gap: 16px; margin: 8px 0 14px; }
.checks label { display: flex; align-items: center; gap: 6px; margin: 0; } .checks label { display: flex; align-items: center; gap: 6px; margin: 0; }
.checks input { width: auto; margin: 0; } .checks input { width: auto; margin: 0; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 0 16px; } .grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); gap: 0 16px; }
.actions { display: flex; gap: 8px; margin-top: 10px; } .actions { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
.hint { color: var(--muted); font-size: 12px; margin: 6px 0 0; } .hint { color: var(--muted); font-size: 12px; margin: 6px 0 0; }
.hint.ok { color: var(--ok); }
.hint.err { color: var(--err); }
.log { .log {
background: #0a0c10; border: 1px solid var(--border); border-radius: 6px; background: #0a0c10; border: 1px solid var(--border); border-radius: 6px;
padding: 12px; font-size: 12px; overflow: auto; max-height: 420px; white-space: pre-wrap; padding: 12px; font-size: 12px; overflow: auto; max-height: 420px; white-space: pre-wrap;
@@ -75,15 +84,51 @@ input, select {
/* Keep this last: it has to beat the display rules above. */ /* Keep this last: it has to beat the display rules above. */
.hidden { display: none !important; } .hidden { display: none !important; }
.banner { .banner {
margin: 0 0 16px; padding: 10px 14px; border-radius: 8px; font-size: 13px; margin: 0 0 16px; padding: 10px 14px; border-radius: 8px; font-size: 13px;
border: 1px solid var(--err); color: var(--err); background: rgba(248,81,73,0.08); border: 1px solid var(--err); color: var(--err); background: rgba(248,81,73,0.08);
} }
.banner.ok { border-color: var(--ok); color: var(--ok); background: rgba(63,185,80,0.08); } .banner.ok { border-color: var(--ok); color: var(--ok); background: rgba(63,185,80,0.08); }
.empty { color: var(--muted); padding: 18px 4px; } .empty { color: var(--muted); padding: 18px 4px; }
header .right { display: flex; align-items: center; gap: 12px; }
.linkish { .linkish {
background: none; border: none; color: var(--muted); cursor: pointer; background: none; border: none; color: var(--muted); cursor: pointer;
font-size: 13px; text-decoration: underline; padding: 0; font-size: 13px; text-decoration: underline; padding: 0;
} }
.linkish:hover { color: var(--accent); } .linkish:hover { color: var(--accent); }
.branch-list { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0 0; min-height: 20px; }
.branch-list button {
padding: 3px 10px; border-radius: 12px; font-size: 12px;
}
.branch-list button.on { background: var(--accent); border-color: var(--accent); color: #fff; }
.import-list { max-height: 460px; overflow: auto; border: 1px solid var(--border); border-radius: 8px; margin-top: 10px; }
.import-row {
display: flex; align-items: center; justify-content: space-between; gap: 12px;
padding: 10px 14px; border-bottom: 1px solid var(--border);
}
.import-row:last-child { border-bottom: none; }
.import-row .meta { color: var(--muted); font-size: 12px; }
.import-row .name { font-family: ui-monospace, Consolas, monospace; }
.records { display: flex; flex-direction: column; gap: 12px; }
.record {
border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; background: var(--panel-2);
}
.record-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.record-head .who { font-family: ui-monospace, Consolas, monospace; font-size: 13px; }
.record-meta { color: var(--muted); font-size: 12px; margin-top: 4px; display: flex; gap: 10px; flex-wrap: wrap; }
.record-summary {
margin-top: 10px; padding: 10px 12px; border-left: 3px solid var(--accent);
background: rgba(76,141,255,0.06); border-radius: 0 6px 6px 0;
white-space: pre-wrap; font-size: 13px; line-height: 1.7;
}
.record-summary.pending { border-left-color: var(--warn); color: var(--muted); }
.record-summary.failed { border-left-color: var(--err); color: var(--muted); }
.record-actions { margin-top: 10px; display: flex; gap: 8px; }
.kv { display: grid; grid-template-columns: 140px 1fr; gap: 4px 12px; font-size: 13px; margin: 10px 0; }
.kv dt { color: var(--muted); }
.kv dd { margin: 0; word-break: break-all; }
.finding { border-left: 3px solid var(--border); padding: 6px 10px; margin: 6px 0; background: rgba(255,255,255,0.02); }
.finding.blocking { border-left-color: var(--err); }
.finding .where { font-family: ui-monospace, Consolas, monospace; font-size: 12px; color: var(--muted); }
+30 -9
View File
@@ -7,7 +7,7 @@ import { test } from "node:test";
import { rmSync } from "node:fs"; import { rmSync } from "node:fs";
import { parseUnifiedDiff, addedLineNumbers, diffLineNumbers, pickAnchorLine } from "../app/lib/diff.js"; import { parseUnifiedDiff, addedLineNumbers, diffLineNumbers, pickAnchorLine } from "../app/lib/diff.js";
import { branchMatches } from "../app/lib/review.js"; import { branchMatches, parseRepoUrl } from "../app/lib/review.js";
import { severityAtLeast, parseList } from "../app/lib/ocr.js"; import { severityAtLeast, parseList } from "../app/lib/ocr.js";
import { normalizeBaseUrl } from "../app/lib/gitea.js"; import { normalizeBaseUrl } from "../app/lib/gitea.js";
import { import {
@@ -91,14 +91,34 @@ test("pickAnchorLine falls back to context then to the start line", () => {
assert.deepEqual(pickAnchorLine(entry, 999, 999), { line: 999, inDiff: false }); assert.deepEqual(pickAnchorLine(entry, 999, 999), { line: 999, inDiff: false });
}); });
test("branchMatches honours glob patterns and ref prefixes", () => { test("branchMatches matches an explicit branch list, not globs", () => {
assert.ok(branchMatches("main", "*")); assert.ok(branchMatches("main", "main"));
assert.ok(branchMatches("main", ""));
assert.ok(branchMatches("refs/heads/main", "main")); assert.ok(branchMatches("refs/heads/main", "main"));
assert.ok(branchMatches("release/1.2", "release/*")); assert.ok(branchMatches("release/1.2", "main,release/1.2"));
assert.ok(!branchMatches("feature/x", "release/*")); assert.ok(branchMatches("dev", ""));
assert.ok(branchMatches("feature/x", "main, feature/*")); assert.ok(!branchMatches("feature/x", "main,release/1.2"));
assert.ok(branchMatches("a/b/c", "a/**")); assert.ok(!branchMatches("release/2.0", "release/*"), "globs are no longer patterns");
});
test("parseRepoUrl derives owner and name from every supported URL form", () => {
const cases = [
["http://192.168.31.51/kgod/myrepo.git", "kgod", "myrepo"],
["http://192.168.31.51/kgod/myrepo", "kgod", "myrepo"],
["https://git.example.com/group/sub/repo.git", "group/sub", "repo"],
["ssh://git@git.example.com:2222/kgod/myrepo.git", "kgod", "myrepo"],
["git@git.example.com:kgod/myrepo.git", "kgod", "myrepo"],
];
for (const [url, owner, name] of cases) {
const parsed = parseRepoUrl(url);
assert.equal(parsed.owner, owner, url);
assert.equal(parsed.name, name, url);
}
});
test("parseRepoUrl rejects unusable input", () => {
assert.throws(() => parseRepoUrl(""), /Git 地址/);
assert.throws(() => parseRepoUrl("not a url"), /无法识别|缺少/);
assert.throws(() => parseRepoUrl("http://host/onlyowner"), /缺少/);
}); });
test("severityAtLeast compares known severities and rejects unknown ones", () => { test("severityAtLeast compares known severities and rejects unknown ones", () => {
@@ -185,7 +205,8 @@ test("repository defaults and branch state round-trip", () => {
const { db, cleanup } = tempDb("repo"); const { db, cleanup } = tempDb("repo");
try { try {
const repo = upsertRepository(db, { owner: "kgod", name: "demo" }); const repo = upsertRepository(db, { owner: "kgod", name: "demo" });
assert.equal(repo.base_branch, "main"); assert.equal(repo.managed_branch, "main");
assert.equal(repo.check_branches, "main");
assert.equal(repo.block_severity, "critical,high"); assert.equal(repo.block_severity, "critical,high");
assert.equal(repo.review_scope, "both"); assert.equal(repo.review_scope, "both");
assert.equal(repo.create_issue, 1); assert.equal(repo.create_issue, 1);