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:
@@ -19,10 +19,21 @@ Push / Pull Request 事件
|
||||
|
||||
## 功能
|
||||
|
||||
**仓库接入**
|
||||
- 「从 Gitea 导入」一键列出全局 Token 可见的仓库,点「添加」即建好监控配置
|
||||
- 手动新增时只填 Git 地址,自动识别 `所有者/仓库名`,旁边可「测试连接」验证
|
||||
- 支持 https / ssh / scp 三种地址写法
|
||||
|
||||
**分支模型**
|
||||
- 一个「管理分支」作为唯一合并目标
|
||||
- 多个「检查分支」全部纳入监控,点选即可增减
|
||||
- 管理分支不存在时可「克隆创建」,从任意已有分支复制
|
||||
- 审查始终以管理分支为对比基准,即「这些改动合入管理分支会怎样」
|
||||
|
||||
**审查触发**
|
||||
- 监听分支推送,自动对比上次提交或与目标分支的 merge-base
|
||||
- 监听检查分支的推送,对比管理分支的 merge-base
|
||||
- 监听 Pull Request 的 opened / synchronize / reopened / ready_for_review
|
||||
- 按分支 glob 过滤(`*`、`release/*`、`main`),可选只审 PR 或只审 push
|
||||
- 可选只审 PR 或只审 push
|
||||
- 同一 commit 在队列中只入队一次;webhook 按 delivery id 去重
|
||||
|
||||
**发布结果**
|
||||
@@ -38,8 +49,20 @@ Push / Pull Request 事件
|
||||
- 合并方式、是否删除分支可配
|
||||
- PR head 已变化或 PR 不可合并时自动放弃
|
||||
|
||||
**审查历史**
|
||||
- 「审查历史」页按仓库汇总每次审查的摘要,可一键跳转 PR
|
||||
- 每条记录保留完整上下文:触发来源、PR 链接、审查范围、阻断阈值、发布方式、
|
||||
自动合并设置、排除路径、LLM 模型、token 消耗、耗时、本次填写的需求背景
|
||||
- 详情弹窗列出本次全部审查意见,标注严重级别与是否阻断
|
||||
|
||||
**AI 摘要**
|
||||
- 独立的摘要模块,与代码审查的提示词完全分开,但共用同一份全局 LLM 设置
|
||||
- 专用提示词把一次审查压缩成固定四节、500 字内的记录:
|
||||
`结论 / 范围 / 问题 / 要点`
|
||||
- 摘要失败不影响审查结果,可单条重新生成
|
||||
|
||||
**运维**
|
||||
- 内置 Web 后台:仓库配置、任务列表、实时日志、重跑、连通性自检
|
||||
- 内置 Web 后台:仓库配置、审查历史、任务列表、实时日志、重跑、连通性自检
|
||||
- 每次审查的完整日志和结果 JSON 落库
|
||||
- worker 重启后自动回收卡住的任务,失败任务自动重试一次
|
||||
- 全局或按仓库覆盖 Gitea token、LLM 端点 / 模型 / Key、排除路径、并发数
|
||||
@@ -135,9 +158,10 @@ curl -fsS http://localhost:8090/api/health
|
||||
|
||||
| 字段 | 说明 |
|
||||
| --- | --- |
|
||||
| `branch_patterns` | 监听的分支 glob,逗号分隔;`*` 为全部 |
|
||||
| `repo_url` | 仓库 Git 地址,`owner` / `name` 由此自动解析 |
|
||||
| `managed_branch` | 唯一的管理分支,所有审查都相对它做对比,也是唯一允许自动合并的目标 |
|
||||
| `check_branches` | 纳入监控的分支列表,逗号分隔;支持任意多个 |
|
||||
| `review_scope` | `both` / `pr` / `push` |
|
||||
| `base_branch` | 对比基准分支,也是无 PR 时 push 审查的目标 |
|
||||
| `block_severity` | 阻断级别阈值,如 `critical,high`;留空则不看级别 |
|
||||
| `block_categories` | 额外按类别阻断,如 `security` |
|
||||
| `fail_on_findings` | 打开后任何意见都视为阻断 |
|
||||
@@ -163,6 +187,13 @@ curl -fsS http://localhost:8090/api/health
|
||||
| `GET` `POST` | `/api/repos` | 列出 / 新增仓库配置 |
|
||||
| `GET` `PATCH` `DELETE` | `/api/repos/:id` | 读取 / 修改 / 删除 |
|
||||
| `POST` | `/api/repos/:id/discover` | 校验连接,返回默认分支与分支列表 |
|
||||
| `GET` | `/api/gitea/repos` | 列出全局 Token 可见的仓库,供一键导入 |
|
||||
| `POST` | `/api/repos/parse` | 解析 Git 地址,返回 `owner` / `name` |
|
||||
| `POST` | `/api/repos/branches` | 用 Git 地址查询分支(仓库尚未保存时使用) |
|
||||
| `POST` | `/api/repos/:id/branches` | 创建分支,可从指定分支克隆 |
|
||||
| `GET` | `/api/records` | 审查历史(`?repo_id=`、`?limit=`) |
|
||||
| `GET` | `/api/records/:id` | 单条记录,含参数、意见与摘要 |
|
||||
| `POST` | `/api/records/:id/summarise` | 重新排队生成摘要 |
|
||||
| `GET` | `/api/jobs` | 任务列表(`?repo_id=`、`?limit=`) |
|
||||
| `GET` | `/api/jobs/:id` | 任务详情,含日志 |
|
||||
| `POST` | `/api/jobs/:id/retry` | 重跑任务 |
|
||||
@@ -181,7 +212,11 @@ curl -X POST http://localhost:8090/api/review \
|
||||
|
||||
## 行为说明
|
||||
|
||||
**审查范围**:PR 事件用 `base.sha..head.sha`;push 事件用 webhook 里的 `before..after`,如果 `before` 是新建分支的全零值,则退回到与 `base_branch` 的 merge-base。
|
||||
**分支与合并**:`check_branches` 里的每个分支都会被监控审查,审查基准是 `managed_branch`(用 merge-base 计算),也就是「这些改动合入管理分支会怎样」。自动合并只作用于目标为 `managed_branch` 的 PR,其他分支的 PR 只审不合。
|
||||
|
||||
**审查范围**:PR 事件优先用 PR 的目标分支作为基准;push 事件优先用 webhook 里的 `before`,当 `before` 是新建分支的全零值或缺失时,退回到与管理分支的 merge-base。
|
||||
|
||||
**审查背景**:仓库配置里的「审查背景 / 需求」是多行文本,既作为 OCR 的 `--background` 传给模型,也会完整记录到审查历史,方便回溯「当时是按什么需求审的」。
|
||||
|
||||
**阻断判定**:一条意见命中任一条件即为阻断 —— 严重级别 ≥ `block_severity` 中任一项,类别在 `block_categories` 中,或打开了 `fail_on_findings`。阻断会创建 Issue、把提交状态置为 `failure`,并阻止自动合并。
|
||||
|
||||
@@ -209,6 +244,9 @@ Gitea 拦截了内网地址。按上文给 `[webhook] ALLOWED_HOST_LIST` 加上
|
||||
**后台打开后要求输入访问 Token**
|
||||
这是 `CR_ADMIN_TOKEN` 在生效。输入 `.env` 里的值即可,Token 只存在浏览器本地。想免登录就把它留空并重启容器(仅限不对外暴露的网络)。
|
||||
|
||||
**历史记录一直显示「摘要生成中…」**
|
||||
摘要与审查串行执行,一次只跑一个;如果 LLM 不可达会在记录里留下失败原因,可在详情里点「重新生成摘要」。摘要失败不会影响审查结论与自动合并。
|
||||
|
||||
**任务一直停在 `reviewing`**
|
||||
LLM 慢或不可达。在后台「设置」点「测试 LLM」,或在「任务」页查看日志;调大 `CR_REVIEW_TIMEOUT_MS`。
|
||||
|
||||
|
||||
+161
-3
@@ -16,9 +16,12 @@ CREATE TABLE IF NOT EXISTS repositories (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
owner TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
repo_url TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
base_branch TEXT NOT NULL DEFAULT 'main',
|
||||
branch_patterns TEXT NOT NULL DEFAULT '*',
|
||||
-- The single branch every reviewed branch is merged into.
|
||||
managed_branch TEXT NOT NULL DEFAULT 'main',
|
||||
-- Comma-separated branches that are reviewed and may be merged up.
|
||||
check_branches TEXT NOT NULL DEFAULT 'main',
|
||||
review_scope TEXT NOT NULL DEFAULT 'both',
|
||||
gitea_token TEXT,
|
||||
llm_provider TEXT,
|
||||
@@ -79,6 +82,36 @@ CREATE TABLE IF NOT EXISTS jobs (
|
||||
finished_at TEXT
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS review_records (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
job_id INTEGER NOT NULL REFERENCES jobs(id) ON DELETE CASCADE,
|
||||
repo_id INTEGER NOT NULL REFERENCES repositories(id) ON DELETE CASCADE,
|
||||
ref_name TEXT NOT NULL,
|
||||
from_sha TEXT,
|
||||
to_sha TEXT NOT NULL,
|
||||
pr_number INTEGER,
|
||||
pr_url TEXT,
|
||||
trigger TEXT,
|
||||
source TEXT,
|
||||
requirement TEXT,
|
||||
params_json TEXT,
|
||||
findings_json TEXT,
|
||||
llm_model TEXT,
|
||||
llm_status TEXT,
|
||||
summary_status TEXT NOT NULL DEFAULT 'pending',
|
||||
summary TEXT,
|
||||
summary_error TEXT,
|
||||
summary_model TEXT,
|
||||
tokens_total INTEGER DEFAULT 0,
|
||||
elapsed TEXT,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
summarized_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_review_records_repo ON review_records (repo_id, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_review_records_job ON review_records (job_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_review_records_summary ON review_records (summary_status, id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs (status, id);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_repo ON jobs (repo_id, id DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_jobs_sha ON jobs (repo_id, to_sha);
|
||||
@@ -89,10 +122,57 @@ CREATE TABLE IF NOT EXISTS webhook_deliveries (
|
||||
);
|
||||
`;
|
||||
|
||||
function tableColumns(db, table) {
|
||||
try {
|
||||
return new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((r) => r.name));
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
function addColumnIfMissing(db, table, column, definition) {
|
||||
if (tableColumns(db, table).has(column)) return false;
|
||||
db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${definition}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Bring an existing database up to the current schema.
|
||||
* SQLite cannot drop or rename columns in place, so the pre-branch-model
|
||||
* `base_branch` / `branch_patterns` columns are left in place but ignored;
|
||||
* their values seed the new `managed_branch` / `check_branches` columns once.
|
||||
*/
|
||||
function migrate(db) {
|
||||
const addedManaged = addColumnIfMissing(db, "repositories", "managed_branch", "TEXT NOT NULL DEFAULT 'main'");
|
||||
addColumnIfMissing(db, "repositories", "check_branches", "TEXT NOT NULL DEFAULT 'main'");
|
||||
addColumnIfMissing(db, "repositories", "repo_url", "TEXT");
|
||||
addColumnIfMissing(db, "jobs", "record_id", "INTEGER");
|
||||
|
||||
const cols = tableColumns(db, "repositories");
|
||||
if (addedManaged && cols.has("base_branch")) {
|
||||
// Seed from the legacy single-branch configuration.
|
||||
db.exec("UPDATE repositories SET managed_branch = COALESCE(NULLIF(base_branch, ''), 'main')");
|
||||
}
|
||||
if (cols.has("branch_patterns")) {
|
||||
// A legacy glob list becomes the explicit list of branches to check.
|
||||
const rows = db.prepare("SELECT id, branch_patterns, check_branches FROM repositories").all();
|
||||
for (const row of rows) {
|
||||
const legacy = String(row.branch_patterns || "").trim();
|
||||
const current = String(row.check_branches || "").trim();
|
||||
if (!legacy || legacy === "*" || current !== "main") continue;
|
||||
const branches = legacy.split(",").map((x) => x.trim()).filter((x) => x && !x.includes("*") && !x.includes("?"));
|
||||
if (branches.length) {
|
||||
db.prepare("UPDATE repositories SET check_branches = ? WHERE id = ?").run(branches.join(","), row.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function openDatabase(path) {
|
||||
mkdirSync(dirname(path), { recursive: true });
|
||||
const db = new DatabaseSync(path);
|
||||
db.exec(SCHEMA);
|
||||
migrate(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
@@ -129,7 +209,7 @@ export function findRepository(db, owner, name) {
|
||||
}
|
||||
|
||||
const REPO_FIELDS = [
|
||||
"owner", "name", "enabled", "base_branch", "branch_patterns", "review_scope",
|
||||
"owner", "name", "repo_url", "enabled", "managed_branch", "check_branches", "review_scope",
|
||||
"gitea_token", "llm_provider", "llm_model", "llm_base_url", "llm_token",
|
||||
"rule_path", "background_template", "excludes", "publish_mode", "create_issue",
|
||||
"issue_labels", "block_severity", "block_categories", "fail_on_findings",
|
||||
@@ -263,6 +343,84 @@ export function pruneDeliveries(db, keep = 2000) {
|
||||
).run(keep);
|
||||
}
|
||||
|
||||
/* ------------------------------ review records ---------------------------- */
|
||||
|
||||
export function createReviewRecord(db, record) {
|
||||
const info = db.prepare(
|
||||
`INSERT INTO review_records
|
||||
(job_id, repo_id, ref_name, from_sha, to_sha, pr_number, pr_url, trigger, source,
|
||||
requirement, params_json, findings_json, llm_model, llm_status, tokens_total, elapsed,
|
||||
summary_status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')`,
|
||||
).run(
|
||||
record.jobId, record.repoId, record.refName, record.fromSha ?? null, record.toSha,
|
||||
record.prNumber ?? null, record.prUrl ?? null, record.trigger ?? null, record.source ?? null,
|
||||
record.requirement ?? null, record.paramsJson ?? null, record.findingsJson ?? null,
|
||||
record.llmModel ?? null, record.llmStatus ?? null, record.tokensTotal ?? 0, record.elapsed ?? null,
|
||||
);
|
||||
const id = Number(info.lastInsertRowid);
|
||||
db.prepare("UPDATE jobs SET record_id = ? WHERE id = ?").run(id, record.jobId);
|
||||
return id;
|
||||
}
|
||||
|
||||
export function updateReviewRecord(db, id, patch) {
|
||||
const allowed = [
|
||||
"pr_url", "requirement", "params_json", "findings_json", "llm_model", "llm_status",
|
||||
"summary_status", "summary", "summary_error", "summary_model", "tokens_total", "elapsed",
|
||||
];
|
||||
const keys = Object.keys(patch).filter((k) => allowed.includes(k));
|
||||
if (keys.length === 0) return;
|
||||
const sets = keys.map((k) => `${k} = ?`);
|
||||
if (patch.summary_status && ["done", "failed", "skipped"].includes(patch.summary_status)) {
|
||||
sets.push("summarized_at = datetime('now')");
|
||||
}
|
||||
db.prepare(`UPDATE review_records SET ${sets.join(", ")} WHERE id = ?`).run(
|
||||
...keys.map((k) => patch[k]), id,
|
||||
);
|
||||
}
|
||||
|
||||
export function getReviewRecord(db, id) {
|
||||
return db.prepare("SELECT * FROM review_records WHERE id = ?").get(id);
|
||||
}
|
||||
|
||||
export function getReviewRecordByJob(db, jobId) {
|
||||
return db.prepare("SELECT * FROM review_records WHERE job_id = ?").get(jobId);
|
||||
}
|
||||
|
||||
export function listReviewRecords(db, { repoId, limit = 100 } = {}) {
|
||||
if (repoId) {
|
||||
return db.prepare(
|
||||
`SELECT rr.*, r.owner, r.name FROM review_records rr
|
||||
JOIN repositories r ON r.id = rr.repo_id
|
||||
WHERE rr.repo_id = ? ORDER BY rr.id DESC LIMIT ?`,
|
||||
).all(repoId, limit);
|
||||
}
|
||||
return db.prepare(
|
||||
`SELECT rr.*, r.owner, r.name FROM review_records rr
|
||||
JOIN repositories r ON r.id = rr.repo_id
|
||||
ORDER BY rr.id DESC LIMIT ?`,
|
||||
).all(limit);
|
||||
}
|
||||
|
||||
export function claimPendingSummary(db) {
|
||||
const row = db.prepare(
|
||||
"SELECT * FROM review_records WHERE summary_status = 'pending' ORDER BY id LIMIT 1",
|
||||
).get();
|
||||
if (!row) return null;
|
||||
const info = db.prepare(
|
||||
"UPDATE review_records SET summary_status = 'running' WHERE id = ? AND summary_status = 'pending'",
|
||||
).run(row.id);
|
||||
if (info.changes === 0) return null;
|
||||
return db.prepare("SELECT * FROM review_records WHERE id = ?").get(row.id);
|
||||
}
|
||||
|
||||
export function requeueStaleSummaries(db) {
|
||||
const info = db.prepare(
|
||||
"UPDATE review_records SET summary_status = 'pending' WHERE summary_status = 'running'",
|
||||
).run();
|
||||
return info.changes;
|
||||
}
|
||||
|
||||
export function jobStats(db) {
|
||||
const rows = db.prepare("SELECT status, COUNT(*) AS n FROM jobs GROUP BY status").all();
|
||||
const out = { queued: 0, running: 0, succeeded: 0, failed: 0, skipped: 0 };
|
||||
|
||||
@@ -96,6 +96,37 @@ export class GiteaClient {
|
||||
return this.get(`/api/v1/repos/${owner}/${repo}`);
|
||||
}
|
||||
|
||||
/** Repositories visible to the token, across pages. */
|
||||
async listAccessibleRepos(limit = 50, maxPages = 40) {
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
for (let page = 1; page <= maxPages; page += 1) {
|
||||
let batch;
|
||||
try {
|
||||
batch = await this.get("/api/v1/user/repos", { query: { limit, page } });
|
||||
} catch (err) {
|
||||
if (page === 1) throw err;
|
||||
break;
|
||||
}
|
||||
if (!Array.isArray(batch) || batch.length === 0) break;
|
||||
for (const r of batch) {
|
||||
if (r?.full_name && !seen.has(r.full_name)) {
|
||||
seen.add(r.full_name);
|
||||
out.push(r);
|
||||
}
|
||||
}
|
||||
if (batch.length < limit) break;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/** Create a branch, optionally from another branch/tag/commit. */
|
||||
async createBranch(owner, repo, { newBranch, fromBranch }) {
|
||||
const payload = { new_branch_name: newBranch };
|
||||
if (fromBranch) payload.old_ref_name = fromBranch;
|
||||
return this.post(`/api/v1/repos/${owner}/${repo}/branches`, payload);
|
||||
}
|
||||
|
||||
async listRepoBranches(owner, repo, limit = 100) {
|
||||
const out = [];
|
||||
for (let page = 1; page <= 20; page += 1) {
|
||||
|
||||
+22
-9
@@ -115,20 +115,19 @@ export class OcrRunner {
|
||||
};
|
||||
}
|
||||
|
||||
async gitClone({ cloneUrl, token, dir, extraHeader = true }) {
|
||||
const args = ["clone", "--no-tags", "--filter=blob:none"];
|
||||
async gitClone({ cloneUrl, token, dir }) {
|
||||
// No partial clone: a blob:none clone defers blob downloads to later
|
||||
// `git show` / `git grep` calls, and those lazily-fetched requests do not
|
||||
// inherit the per-command extraHeader, so private repositories fail with
|
||||
// "could not read Username". A full clone keeps every read local.
|
||||
const args = ["clone", "--no-tags"];
|
||||
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
if (token) {
|
||||
if (extraHeader) {
|
||||
// Written into .git/config by `git clone`, so every later fetch/rebase in
|
||||
// this workspace authenticates without extra plumbing.
|
||||
env.GIT_CONFIG_COUNT = "1";
|
||||
env.GIT_CONFIG_KEY_0 = "http.extraHeader";
|
||||
env.GIT_CONFIG_VALUE_0 = `Authorization: token ${token}`;
|
||||
} else {
|
||||
const url = new URL(cloneUrl);
|
||||
url.username = "oauth2";
|
||||
url.password = token;
|
||||
cloneUrl = url.toString();
|
||||
}
|
||||
}
|
||||
args.push(cloneUrl, dir);
|
||||
const res = await run("git", args, { env, timeoutMs: this.gitTimeoutMs });
|
||||
@@ -137,10 +136,24 @@ export class OcrRunner {
|
||||
exitCode: res.code, stderr: res.stderr, stdout: res.stdout,
|
||||
});
|
||||
}
|
||||
// Persist the auth header for this workspace so later fetches keep working
|
||||
// even if the caller forgets to pass a token.
|
||||
if (token) {
|
||||
const cfg = await run("git", ["config", "--local", "http.extraHeader", `Authorization: token ${token}`], {
|
||||
cwd: dir, timeoutMs: 60000,
|
||||
});
|
||||
if (cfg.code !== 0) {
|
||||
throw new OcrError("failed to persist repository credentials", {
|
||||
exitCode: cfg.code, stderr: cfg.stderr, stdout: cfg.stdout,
|
||||
});
|
||||
}
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
async fetch(dir, { refs = [], token } = {}) {
|
||||
// GIT_TERMINAL_PROMPT=0 keeps a missing credential a hard error instead of
|
||||
// a hanging prompt inside the container.
|
||||
const env = { ...process.env, GIT_TERMINAL_PROMPT: "0" };
|
||||
if (token) {
|
||||
env.GIT_CONFIG_COUNT = "1";
|
||||
|
||||
+68
-1
@@ -1,6 +1,10 @@
|
||||
/** Sequential job worker with retry and stale-job recovery. */
|
||||
import { claimNextJob, updateJob } from "./db.js";
|
||||
import {
|
||||
claimNextJob, claimPendingSummary, requeueStaleSummaries,
|
||||
updateJob, updateReviewRecord,
|
||||
} from "./db.js";
|
||||
import { SkipJob } from "./review.js";
|
||||
import { summariseReview } from "./summary.js";
|
||||
|
||||
const STALE_MS = 2 * 60 * 60 * 1000;
|
||||
|
||||
@@ -21,6 +25,8 @@ export class JobQueue {
|
||||
if (this.running) return;
|
||||
this.running = true;
|
||||
this.recoverStale();
|
||||
const requeued = requeueStaleSummaries(this.db);
|
||||
if (requeued) this.logger.warn?.(`requeued ${requeued} interrupted summariser task(s)`);
|
||||
this.tick();
|
||||
}
|
||||
|
||||
@@ -51,6 +57,8 @@ export class JobQueue {
|
||||
}
|
||||
const job = claimNextJob(this.db);
|
||||
if (!job) {
|
||||
// Nothing to review: use the idle slot for pending summaries.
|
||||
await this.runSummaryOnce();
|
||||
this.timer = setTimeout(() => this.tick(), this.pollMs);
|
||||
return;
|
||||
}
|
||||
@@ -74,6 +82,65 @@ export class JobQueue {
|
||||
this.busy = false;
|
||||
this.currentJobId = null;
|
||||
}
|
||||
// Summarising runs on the same worker so it never competes with a review
|
||||
// for the LLM endpoint.
|
||||
await this.runSummaryOnce();
|
||||
this.timer = setTimeout(() => this.tick(), this.pollMs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarise one finished review, if any is pending.
|
||||
* Failures are recorded on the record and never block the queue.
|
||||
*/
|
||||
async runSummaryOnce() {
|
||||
const record = claimPendingSummary(this.db);
|
||||
if (!record) return false;
|
||||
try {
|
||||
const repo = this.db.prepare("SELECT * FROM repositories WHERE id = ?").get(record.repo_id);
|
||||
if (!repo) {
|
||||
updateReviewRecord(this.db, record.id, {
|
||||
summary_status: "skipped", summary_error: "repository no longer exists",
|
||||
});
|
||||
return false;
|
||||
}
|
||||
let findings = [];
|
||||
try { findings = JSON.parse(record.findings_json || "[]"); } catch { findings = []; }
|
||||
const job = this.db.prepare("SELECT result_json FROM jobs WHERE id = ?").get(record.job_id);
|
||||
let summary = null;
|
||||
try { summary = JSON.parse(job?.result_json || "{}").summary ?? null; } catch { summary = null; }
|
||||
|
||||
const llm = {
|
||||
url: repo.llm_base_url || this.engine.config.llmUrl,
|
||||
token: repo.llm_token || this.engine.config.llmToken,
|
||||
model: repo.llm_model || this.engine.config.llmModel,
|
||||
protocol: repo.llm_provider || this.engine.config.llmProtocol,
|
||||
authHeader: this.engine.config.llmAuthHeader,
|
||||
extraHeaders: this.engine.config.llmExtraHeaders,
|
||||
};
|
||||
this.logger.info?.(`summarising review record #${record.id} (${repo.owner}/${repo.name} ${record.ref_name})`);
|
||||
const res = await summariseReview({
|
||||
llm, repo, record, findings, summary, requirement: record.requirement,
|
||||
});
|
||||
if (res.ok) {
|
||||
updateReviewRecord(this.db, record.id, {
|
||||
summary_status: "done",
|
||||
summary: res.text,
|
||||
summary_model: res.model,
|
||||
summary_error: res.complete ? null : "摘要缺少标准小节,已按原文保存",
|
||||
});
|
||||
this.logger.info?.(`review record #${record.id} summarised (${res.text.length} chars)`);
|
||||
} else {
|
||||
updateReviewRecord(this.db, record.id, {
|
||||
summary_status: "failed", summary_error: String(res.error).slice(0, 500),
|
||||
});
|
||||
this.logger.warn?.(`review record #${record.id} summary failed: ${res.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
updateReviewRecord(this.db, record.id, {
|
||||
summary_status: "failed", summary_error: String(err.message || err).slice(0, 500),
|
||||
});
|
||||
this.logger.warn?.(`review record #${record.id} summary error: ${err.message}`);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+109
-16
@@ -8,7 +8,9 @@ import { join } from "node:path";
|
||||
import { GiteaClient, webBaseUrl, normalizeBaseUrl } from "./gitea.js";
|
||||
import { OcrRunner, parseList, severityAtLeast } from "./ocr.js";
|
||||
import { parseUnifiedDiff, pickAnchorLine } from "./diff.js";
|
||||
import { getBranchState, setBranchState, updateJob } from "./db.js";
|
||||
import {
|
||||
createReviewRecord, getBranchState, setBranchState, updateJob, updateReviewRecord,
|
||||
} from "./db.js";
|
||||
|
||||
export const SUMMARY_MARKER = "<!-- gitea-codereview:summary -->";
|
||||
export const COMMENT_MARKER = "<!-- gitea-codereview -->";
|
||||
@@ -26,21 +28,43 @@ function repoSlug(repo) {
|
||||
return `${repo.owner}/${repo.name}`;
|
||||
}
|
||||
|
||||
function globToRegExp(pattern) {
|
||||
const escaped = pattern.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
||||
const body = escaped
|
||||
.replace(/\*\*/g, "\u0000")
|
||||
.replace(/\*/g, "[^/]*")
|
||||
.replace(/\?/g, ".")
|
||||
.replace(/\u0000/g, ".*");
|
||||
return new RegExp(`^${body}$`);
|
||||
/**
|
||||
* Parse a git remote URL into { owner, name, host }.
|
||||
* Accepts https://host/owner/repo.git, http://host/owner/repo,
|
||||
* ssh://git@host:2222/owner/repo.git and git@host:owner/repo.git.
|
||||
*/
|
||||
export function parseRepoUrl(input) {
|
||||
const raw = String(input || "").trim();
|
||||
if (!raw) throw new Error("请填写 Git 地址");
|
||||
let host = "";
|
||||
let path = "";
|
||||
const scp = /^(?:[^@/]+@)?([^:/]+):(?!\d+\/)(.+)$/.exec(raw);
|
||||
if (scp && !raw.includes("://")) {
|
||||
host = scp[1];
|
||||
path = scp[2];
|
||||
} else {
|
||||
let url;
|
||||
try {
|
||||
url = new URL(raw);
|
||||
} catch {
|
||||
throw new Error(`无法识别的 Git 地址:${raw}`);
|
||||
}
|
||||
host = url.host;
|
||||
path = url.pathname;
|
||||
}
|
||||
const parts = path.replace(/^\/+/, "").replace(/\.git$/, "").split("/").filter(Boolean);
|
||||
if (parts.length < 2) throw new Error(`Git 地址缺少 所有者/仓库名:${raw}`);
|
||||
const name = parts.pop();
|
||||
const owner = parts.join("/");
|
||||
return { owner, name, host };
|
||||
}
|
||||
|
||||
export function branchMatches(refName, patterns) {
|
||||
const list = parseList(patterns);
|
||||
if (list.length === 0 || list.includes("*")) return true;
|
||||
const short = refName.replace(/^refs\/heads\//, "");
|
||||
return list.some((p) => globToRegExp(p).test(short) || globToRegExp(p).test(refName));
|
||||
/** Whether `refName` is one of the branches this repository reviews. */
|
||||
export function branchMatches(refName, checkBranches) {
|
||||
const list = parseList(checkBranches);
|
||||
if (list.length === 0) return true;
|
||||
const short = String(refName || "").replace(/^refs\/heads\//, "");
|
||||
return list.some((b) => b === short || b === refName);
|
||||
}
|
||||
|
||||
function badge(comment) {
|
||||
@@ -148,6 +172,9 @@ async function resolveRange(runner, { repo, job, workspace, token }) {
|
||||
const headRef = `refs/heads/${job.ref_name}`;
|
||||
const refs = [headRef, `+${headRef}:refs/remotes/origin/${job.ref_name}`];
|
||||
if (job.base_ref) refs.push(`+refs/heads/${job.base_ref}:refs/remotes/origin/${job.base_ref}`);
|
||||
if (job.managed_branch && job.managed_branch !== job.base_ref) {
|
||||
refs.push(`+refs/heads/${job.managed_branch}:refs/remotes/origin/${job.managed_branch}`);
|
||||
}
|
||||
if (job.pr_number) refs.push(`+refs/pull/${job.pr_number}/head:refs/remotes/origin/pr/${job.pr_number}`);
|
||||
await runner.fetch(workspace, { refs, token });
|
||||
|
||||
@@ -161,8 +188,11 @@ async function resolveRange(runner, { repo, job, workspace, token }) {
|
||||
if (job.from_sha) {
|
||||
fromSha = await runner.revParse(workspace, job.from_sha);
|
||||
}
|
||||
if (!fromSha && job.base_ref) {
|
||||
const baseSha = await runner.revParse(workspace, `refs/remotes/origin/${job.base_ref}`);
|
||||
// The review base is the managed branch: every checked branch is compared
|
||||
// against what it would merge into, not against its own previous commit.
|
||||
const baseBranch = job.managed_branch || job.base_ref;
|
||||
if (!fromSha && baseBranch) {
|
||||
const baseSha = await runner.revParse(workspace, `refs/remotes/origin/${baseBranch}`);
|
||||
if (baseSha) fromSha = await runner.mergeBase(workspace, baseSha, toSha) ?? baseSha;
|
||||
}
|
||||
if (!fromSha) {
|
||||
@@ -222,6 +252,8 @@ export class ReviewEngine {
|
||||
await mkdir(root, { recursive: true });
|
||||
const dir = join(root, `${repo.owner}__${repo.name}`);
|
||||
if (existsSync(join(dir, ".git"))) return dir;
|
||||
// Reaching here means the workspace is missing or was removed; a fresh
|
||||
// clone always persists the credential for later fetches.
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
const cloneUrl = `${normalizeBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}.git`;
|
||||
const runner = new OcrRunner({ command: this.config.ocrCommand, logger: this.log });
|
||||
@@ -253,6 +285,9 @@ export class ReviewEngine {
|
||||
|
||||
setPhase("preparing");
|
||||
const workspace = await this.ensureWorkspace(repo);
|
||||
// Jobs carry the managed branch so the fetch/merge-base logic does not need
|
||||
// to re-read the repository row.
|
||||
job.managed_branch = repo.managed_branch || repo.base_branch;
|
||||
const { fromSha, toSha } = await resolveRange(runner, {
|
||||
repo, job, workspace, token: repo.gitea_token || this.config.giteaToken,
|
||||
});
|
||||
@@ -441,6 +476,57 @@ export class ReviewEngine {
|
||||
warnings: review.warnings,
|
||||
};
|
||||
|
||||
// Persist the review record that the history list and summariser consume.
|
||||
let recordId = null;
|
||||
try {
|
||||
recordId = createReviewRecord(this.db, {
|
||||
jobId: job.id,
|
||||
repoId: repo.id,
|
||||
refName: job.ref_name,
|
||||
fromSha,
|
||||
toSha,
|
||||
prNumber,
|
||||
prUrl: prNumber
|
||||
? `${webBaseUrl(this.config.giteaUrl)}/${repo.owner}/${repo.name}/pulls/${prNumber}`
|
||||
: null,
|
||||
trigger: job.trigger,
|
||||
source: job.trigger?.startsWith("pull_request") ? "pull_request" : "push",
|
||||
requirement: (repo.background_template || "").trim() || null,
|
||||
paramsJson: JSON.stringify({
|
||||
managed_branch: job.managed_branch || null,
|
||||
base_ref: job.base_ref ?? null,
|
||||
review_scope: repo.review_scope,
|
||||
block_severity: repo.block_severity,
|
||||
block_categories: repo.block_categories,
|
||||
publish_mode: repo.publish_mode,
|
||||
auto_merge: Boolean(repo.auto_merge),
|
||||
auto_merge_mode: repo.auto_merge_mode,
|
||||
merge_method: repo.merge_method,
|
||||
concurrency: repo.concurrency,
|
||||
excludes: repo.excludes || null,
|
||||
max_comments: repo.max_comments,
|
||||
review_incomplete: reviewIncomplete,
|
||||
}),
|
||||
findingsJson: JSON.stringify(published.map((p) => ({
|
||||
path: p.comment.path,
|
||||
start_line: p.comment.start_line,
|
||||
end_line: p.comment.end_line,
|
||||
severity: p.comment.severity,
|
||||
category: p.comment.category,
|
||||
content: p.comment.content,
|
||||
inline: p.inline,
|
||||
blocking: blocking.includes(p),
|
||||
}))),
|
||||
llmModel: review.summary?.model ?? repo.llm_model ?? this.config.llmModel,
|
||||
llmStatus: review.status,
|
||||
tokensTotal: review.summary?.total_tokens ?? 0,
|
||||
elapsed: review.summary?.elapsed ?? null,
|
||||
});
|
||||
result.recordId = recordId;
|
||||
} catch (err) {
|
||||
appendLog(`review record failed: ${err.message}`);
|
||||
}
|
||||
|
||||
updateJob(this.db, job.id, {
|
||||
status: "succeeded",
|
||||
phase: "done",
|
||||
@@ -552,6 +638,13 @@ export class ReviewEngine {
|
||||
appendLog("auto-merge skipped: no pull request for this branch");
|
||||
return false;
|
||||
}
|
||||
// Only merges into the managed branch are performed; a PR targeting any
|
||||
// other branch is reviewed but never auto-merged.
|
||||
const managed = repo.managed_branch || repo.base_branch;
|
||||
if (job.base_ref && managed && job.base_ref !== managed) {
|
||||
appendLog(`auto-merge skipped: PR targets ${job.base_ref}, managed branch is ${managed}`);
|
||||
return false;
|
||||
}
|
||||
if (reviewIncomplete) {
|
||||
appendLog("auto-merge skipped: review coverage was incomplete");
|
||||
return false;
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* Review-history summariser.
|
||||
*
|
||||
* Deliberately separate from the OpenCodeReview pipeline: OCR decides what is
|
||||
* wrong with a diff; this module turns a finished review into a short,
|
||||
* uniformly formatted record for the history list. It shares the global LLM
|
||||
* settings but uses its own prompt, and it never influences blocking or merge
|
||||
* decisions.
|
||||
*/
|
||||
|
||||
export const SUMMARY_MAX_CHARS = 500;
|
||||
export const SUMMARY_PROMPT_VERSION = "review-history-summary/v1";
|
||||
export const SUMMARY_SECTIONS = ["结论", "范围", "问题", "要点"];
|
||||
|
||||
const SYSTEM_PROMPT = [
|
||||
"你是一名代码审查记录整理员。你会收到一次自动代码审查的结构化结果。",
|
||||
"你的唯一任务是把这次审查整理成一段简洁、客观的中文记录,供团队在审查历史里快速浏览。",
|
||||
"",
|
||||
"严格要求:",
|
||||
"1. 只依据输入内容,不要推测、不要补充未出现的信息,不要提出新建议。",
|
||||
"2. 不要评价审查工具本身,不要输出“建议进一步检查”之类的话。",
|
||||
"3. 按下面的固定格式输出,保留小标题,每节一行到两行:",
|
||||
"结论:<通过 / 有阻断问题 / 有非阻断问题 / 未发现变更>",
|
||||
"范围:<审查了哪些文件、多少行;信息不足写“未知”>",
|
||||
"问题:<按严重级别归纳问题类型与数量;无问题写“无”>",
|
||||
"要点:<最值得关注的一到三条具体问题,写明文件与行为;无问题写“无”>",
|
||||
"4. 全文不超过 500 个字符,不要使用 Markdown 标题符号、代码块或列表符号。",
|
||||
"5. 直接输出上述四节内容,不要任何前言、后缀或解释。",
|
||||
].join("\n");
|
||||
|
||||
function clip(text, max) {
|
||||
const value = String(text ?? "");
|
||||
return value.length <= max ? value : `${value.slice(0, max)}…`;
|
||||
}
|
||||
|
||||
/** Build the user message describing one finished review. */
|
||||
export function buildSummaryPrompt({ repo, record, findings, summary, requirement }) {
|
||||
const lines = [];
|
||||
lines.push(`仓库:${repo.owner}/${repo.name}`);
|
||||
lines.push(`分支:${record.ref_name}`);
|
||||
if (record.pr_number) lines.push(`Pull Request:#${record.pr_number}`);
|
||||
lines.push(`提交:${record.to_sha}`);
|
||||
if (record.from_sha) lines.push(`对比基准:${record.from_sha}`);
|
||||
if (requirement) lines.push(`本次需求/背景:${clip(requirement, 600)}`);
|
||||
if (summary) {
|
||||
const bits = [];
|
||||
if (summary.files_reviewed != null) bits.push(`审查文件 ${summary.files_reviewed} 个`);
|
||||
if (summary.comments != null) bits.push(`原始意见 ${summary.comments} 条`);
|
||||
if (summary.total_tokens) bits.push(`消耗 ${summary.total_tokens} tokens`);
|
||||
if (summary.elapsed) bits.push(`耗时 ${summary.elapsed}`);
|
||||
if (bits.length) lines.push(`运行数据:${bits.join(",")}`);
|
||||
}
|
||||
if (record.llm_status) lines.push(`审查状态:${record.llm_status}`);
|
||||
|
||||
lines.push("", "审查发现:");
|
||||
if (!findings.length) {
|
||||
lines.push("(本次没有产生任何意见)");
|
||||
} else {
|
||||
for (const f of findings) {
|
||||
const where = f.start_line
|
||||
? `${f.path}:${f.start_line}${f.end_line && f.end_line !== f.start_line ? `-${f.end_line}` : ""}`
|
||||
: f.path;
|
||||
const tags = [f.severity, f.category].filter(Boolean).join("/");
|
||||
lines.push(`- [${tags || "未分级"}] ${where} ${clip(f.content, 300)}`);
|
||||
}
|
||||
}
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
/**
|
||||
* Call an OpenAI- or Anthropic-compatible endpoint with the built-in fetch, so
|
||||
* the runtime image needs no extra HTTP client.
|
||||
*/
|
||||
async function runLlm(llm, messages, timeoutMs) {
|
||||
const base = String(llm.url).replace(/\/+$/, "");
|
||||
const isAnthropic = String(llm.protocol || "").toLowerCase().includes("anthropic");
|
||||
const endpoint = isAnthropic
|
||||
? (base.endsWith("/v1") ? `${base}/messages` : `${base}/v1/messages`)
|
||||
: (base.endsWith("/v1") ? `${base}/chat/completions` : `${base}/v1/chat/completions`);
|
||||
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (isAnthropic) {
|
||||
headers[llm.authHeader || "x-api-key"] = llm.token;
|
||||
headers["anthropic-version"] = "2023-06-01";
|
||||
} else {
|
||||
headers.Authorization = `Bearer ${llm.token}`;
|
||||
}
|
||||
for (const pair of String(llm.extraHeaders || "").split(",")) {
|
||||
const [k, ...rest] = pair.split("=");
|
||||
if (k && rest.length) headers[k.trim()] = rest.join("=").trim();
|
||||
}
|
||||
|
||||
const payload = isAnthropic
|
||||
? {
|
||||
model: llm.model,
|
||||
max_tokens: 1024,
|
||||
system: messages[0].content,
|
||||
messages: [{ role: "user", content: messages[1].content }],
|
||||
}
|
||||
: { model: llm.model, messages, max_tokens: 1024, stream: false };
|
||||
|
||||
let res;
|
||||
try {
|
||||
res = await fetch(endpoint, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
});
|
||||
} catch (err) {
|
||||
const reason = err.name === "TimeoutError" ? `请求超时(${timeoutMs} ms)` : err.message;
|
||||
return { ok: false, error: `调用 LLM 失败:${reason}` };
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(text);
|
||||
} catch {
|
||||
return { ok: false, error: `无法解析 LLM 响应(HTTP ${res.status}):${clip(text, 200)}` };
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg = typeof parsed.error === "string"
|
||||
? parsed.error
|
||||
: (parsed.error?.message || JSON.stringify(parsed.error || parsed));
|
||||
return { ok: false, error: `LLM 返回 HTTP ${res.status}:${clip(msg, 200)}` };
|
||||
}
|
||||
const content = isAnthropic
|
||||
? (parsed.content || []).map((p) => p.text || "").join("")
|
||||
: (parsed.choices?.[0]?.message?.content ?? "");
|
||||
if (!content) return { ok: false, error: `LLM 返回空内容:${clip(text, 200)}` };
|
||||
return { ok: true, text: String(content).trim(), servedModel: parsed.model || null };
|
||||
}
|
||||
|
||||
/** Normalise the model output into the fixed four-section shape. */
|
||||
export function normaliseSummary(text) {
|
||||
const cleaned = String(text || "")
|
||||
.replace(/```[a-z]*\n?/gi, "")
|
||||
.replace(/^\s*#{1,6}\s*/gm, "")
|
||||
.trim();
|
||||
const found = SUMMARY_SECTIONS.filter((s) => new RegExp(`^\\s*${s}[::]`, "m").test(cleaned));
|
||||
const collapsed = cleaned.replace(/\n{3,}/g, "\n\n");
|
||||
const clipped = collapsed.length > SUMMARY_MAX_CHARS
|
||||
? `${collapsed.slice(0, SUMMARY_MAX_CHARS - 1)}…`
|
||||
: collapsed;
|
||||
return { text: clipped, complete: found.length === SUMMARY_SECTIONS.length, sectionsFound: found };
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarise one review record.
|
||||
* @returns {Promise<{ok: boolean, text?: string, error?: string, model?: string}>}
|
||||
*/
|
||||
export async function summariseReview({ llm, repo, record, findings, summary, requirement, timeoutMs = 120000 }) {
|
||||
if (!llm?.url || !llm?.token || !llm?.model) {
|
||||
return { ok: false, error: "未配置全局 LLM,无法生成审查摘要" };
|
||||
}
|
||||
const messages = [
|
||||
{ role: "system", content: SYSTEM_PROMPT },
|
||||
{ role: "user", content: buildSummaryPrompt({ repo, record, findings, summary, requirement }) },
|
||||
];
|
||||
const res = await runLlm(llm, messages, timeoutMs);
|
||||
if (!res.ok) return res;
|
||||
const normalised = normaliseSummary(res.text);
|
||||
return { ok: true, text: normalised.text, model: res.servedModel || llm.model, complete: normalised.complete };
|
||||
}
|
||||
|
||||
export { SYSTEM_PROMPT };
|
||||
+173
-20
@@ -8,13 +8,14 @@ import { fileURLToPath } from "node:url";
|
||||
|
||||
import {
|
||||
allSettings, deleteRepository, enqueueJob, findJobBySha, findRepository,
|
||||
getJob, getSetting, jobStats, listJobs, listRepositories, openDatabase,
|
||||
recordDelivery, pruneDeliveries, setSetting, upsertRepository, getRepository,
|
||||
getJob, getReviewRecord, jobStats, listJobs, listRepositories, listReviewRecords,
|
||||
openDatabase, recordDelivery, pruneDeliveries, requeueStaleSummaries, setSetting,
|
||||
updateReviewRecord, upsertRepository, getRepository,
|
||||
} from "./lib/db.js";
|
||||
import { GiteaClient } from "./lib/gitea.js";
|
||||
import { OcrRunner } from "./lib/ocr.js";
|
||||
import { JobQueue } from "./lib/queue.js";
|
||||
import { ReviewEngine, SkipJob, branchMatches } from "./lib/review.js";
|
||||
import { ReviewEngine, SkipJob, branchMatches, parseRepoUrl } from "./lib/review.js";
|
||||
|
||||
const APP_DIR = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
||||
const ROOT_DIR = resolve(APP_DIR, "..");
|
||||
@@ -130,6 +131,15 @@ function verifySignature(secret, rawBody, signature) {
|
||||
return a.length === b.length && timingSafeEqual(a, b);
|
||||
}
|
||||
|
||||
function safeParse(value, fallback) {
|
||||
try { return JSON.parse(value || "null") ?? fallback; } catch { return fallback; }
|
||||
}
|
||||
|
||||
function safeCount(value) {
|
||||
const parsed = safeParse(value, []);
|
||||
return Array.isArray(parsed) ? parsed.length : 0;
|
||||
}
|
||||
|
||||
function authorized(req, cfg) {
|
||||
if (!cfg.adminToken) return true;
|
||||
const header = req.headers.authorization || "";
|
||||
@@ -156,8 +166,8 @@ async function handlePush(payload, cfg) {
|
||||
|
||||
const refName = refNameFromPayload(payload);
|
||||
if (!refName || payload.deleted) return { queued: 0, reason: "branch deletion or empty ref" };
|
||||
if (!branchMatches(refName, repo.branch_patterns)) {
|
||||
return { queued: 0, reason: `branch ${refName} does not match patterns` };
|
||||
if (!branchMatches(refName, repo.check_branches)) {
|
||||
return { queued: 0, reason: `branch ${refName} is not in the checked branch list` };
|
||||
}
|
||||
|
||||
const toSha = payload.after || payload.head_commit?.id;
|
||||
@@ -178,7 +188,7 @@ async function handlePush(payload, cfg) {
|
||||
repoId: repo.id,
|
||||
trigger: "push",
|
||||
refName,
|
||||
baseRef: pr?.base?.ref ?? repo.base_branch,
|
||||
baseRef: pr?.base?.ref ?? repo.managed_branch,
|
||||
fromSha: before,
|
||||
toSha,
|
||||
prNumber: pr?.number ?? null,
|
||||
@@ -202,8 +212,8 @@ async function handlePullRequest(payload, cfg) {
|
||||
const pr = payload.pull_request;
|
||||
if (!pr) return { queued: 0, reason: "no pull_request in payload" };
|
||||
if (pr.draft) return { queued: 0, reason: "draft pull request" };
|
||||
if (!branchMatches(pr.head?.ref, repo.branch_patterns)) {
|
||||
return { queued: 0, reason: `head branch ${pr.head?.ref} does not match patterns` };
|
||||
if (!branchMatches(pr.head?.ref, repo.check_branches)) {
|
||||
return { queued: 0, reason: `head branch ${pr.head?.ref} is not in the checked branch list` };
|
||||
}
|
||||
const toSha = pr.head?.sha;
|
||||
if (!toSha) return { queued: 0, reason: "no head sha" };
|
||||
@@ -214,7 +224,7 @@ async function handlePullRequest(payload, cfg) {
|
||||
repoId: repo.id,
|
||||
trigger: `pull_request.${action}`,
|
||||
refName: pr.head.ref,
|
||||
baseRef: pr.base?.ref ?? repo.base_branch,
|
||||
baseRef: pr.base?.ref ?? repo.managed_branch,
|
||||
fromSha: pr.base?.sha ?? null,
|
||||
toSha,
|
||||
prNumber: pr.number,
|
||||
@@ -289,13 +299,32 @@ async function handleApi(req, res, url, cfg) {
|
||||
|
||||
if (path === "/repos" && req.method === "POST") {
|
||||
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
||||
if (!body.owner || !body.name) return json(res, 400, { error: "owner and name are required" });
|
||||
let owner = body.owner;
|
||||
let name = body.name;
|
||||
let repoUrl = body.repo_url || null;
|
||||
// Owner/name are normally derived from the git URL, not typed by hand.
|
||||
if (repoUrl && (!owner || !name)) {
|
||||
try {
|
||||
const parsed = parseRepoUrl(repoUrl);
|
||||
owner = parsed.owner;
|
||||
name = parsed.name;
|
||||
} catch (err) {
|
||||
return json(res, 400, { error: err.message });
|
||||
}
|
||||
}
|
||||
if (!owner || !name) return json(res, 400, { error: "请填写 Git 地址" });
|
||||
if (!repoUrl) {
|
||||
repoUrl = `${String(cfg.giteaUrl).replace(/\/+$/, "")}/${owner}/${name}.git`;
|
||||
}
|
||||
const managed = body.managed_branch || body.base_branch || "main";
|
||||
const checks = body.check_branches || body.branch_patterns || managed;
|
||||
const repo = upsertRepository(db, {
|
||||
owner: body.owner,
|
||||
name: body.name,
|
||||
owner,
|
||||
name,
|
||||
repo_url: repoUrl,
|
||||
enabled: body.enabled === undefined ? 1 : Number(Boolean(body.enabled)),
|
||||
base_branch: body.base_branch || "main",
|
||||
branch_patterns: body.branch_patterns || "*",
|
||||
managed_branch: managed,
|
||||
check_branches: checks,
|
||||
review_scope: body.review_scope || "both",
|
||||
create_issue: body.create_issue === undefined ? 1 : Number(Boolean(body.create_issue)),
|
||||
auto_merge: Number(Boolean(body.auto_merge)),
|
||||
@@ -303,6 +332,41 @@ async function handleApi(req, res, url, cfg) {
|
||||
return json(res, 201, repo);
|
||||
}
|
||||
|
||||
/** Repositories the global token can see, for one-click import. */
|
||||
if (path === "/gitea/repos" && req.method === "GET") {
|
||||
const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: cfg.giteaToken });
|
||||
try {
|
||||
const repos = await client.listAccessibleRepos();
|
||||
const configured = new Set(listRepositories(db).map((r) => `${r.owner}/${r.name}`));
|
||||
return json(res, 200, repos.map((r) => ({
|
||||
full_name: r.full_name,
|
||||
owner: r.owner?.login ?? r.full_name.split("/")[0],
|
||||
name: r.name,
|
||||
clone_url: r.clone_url,
|
||||
ssh_url: r.ssh_url,
|
||||
default_branch: r.default_branch,
|
||||
private: Boolean(r.private),
|
||||
empty: Boolean(r.empty),
|
||||
archived: Boolean(r.archived),
|
||||
description: r.description ?? "",
|
||||
configured: configured.has(r.full_name),
|
||||
})));
|
||||
} catch (err) {
|
||||
return json(res, 502, { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate a git URL without saving anything. */
|
||||
if (path === "/repos/parse" && req.method === "POST") {
|
||||
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
||||
try {
|
||||
const parsed = parseRepoUrl(body.repo_url);
|
||||
return json(res, 200, parsed);
|
||||
} catch (err) {
|
||||
return json(res, 400, { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
const repoMatch = /^\/repos\/(\d+)$/.exec(path);
|
||||
if (repoMatch) {
|
||||
const id = Number(repoMatch[1]);
|
||||
@@ -331,15 +395,39 @@ async function handleApi(req, res, url, cfg) {
|
||||
try {
|
||||
const info = await client.getRepo(repo.owner, repo.name);
|
||||
const branches = await client.listRepoBranches(repo.owner, repo.name);
|
||||
const updated = upsertRepository(db, {
|
||||
id: repo.id,
|
||||
base_branch: repo.base_branch || info.default_branch,
|
||||
});
|
||||
const names = branches.map((b) => b.name);
|
||||
// Seed sensible branch defaults the first time a repository is opened.
|
||||
const patch = { id: repo.id };
|
||||
if (!repo.managed_branch) patch.managed_branch = info.default_branch || "main";
|
||||
if (!repo.check_branches) patch.check_branches = names.join(",") || patch.managed_branch;
|
||||
const updated = upsertRepository(db, patch);
|
||||
return json(res, 200, {
|
||||
repo: updated,
|
||||
default_branch: info.default_branch,
|
||||
has_issues: info.has_issues,
|
||||
has_pull_requests: info.has_pull_requests,
|
||||
branches: names,
|
||||
managed_branch_exists: names.includes(updated.managed_branch),
|
||||
});
|
||||
} catch (err) {
|
||||
return json(res, 502, { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/** List branches for a repository that is not saved yet. */
|
||||
if (path === "/repos/branches" && req.method === "POST") {
|
||||
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
||||
try {
|
||||
const parsed = body.owner && body.name ? body : parseRepoUrl(body.repo_url);
|
||||
const client = new GiteaClient({ baseUrl: cfg.giteaUrl, token: cfg.giteaToken });
|
||||
const info = await client.getRepo(parsed.owner, parsed.name);
|
||||
const branches = await client.listRepoBranches(parsed.owner, parsed.name);
|
||||
return json(res, 200, {
|
||||
owner: parsed.owner,
|
||||
name: parsed.name,
|
||||
default_branch: info.default_branch,
|
||||
has_issues: info.has_issues,
|
||||
has_pull_requests: info.has_pull_requests,
|
||||
branches: branches.map((b) => b.name),
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -347,6 +435,71 @@ async function handleApi(req, res, url, cfg) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Create the managed branch, optionally cloned from another branch. */
|
||||
const branchMatch = /^\/repos\/(\d+)\/branches$/.exec(path);
|
||||
if (branchMatch && req.method === "POST") {
|
||||
const repo = getRepository(db, Number(branchMatch[1]));
|
||||
if (!repo) return json(res, 404, { error: "repository not found" });
|
||||
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
||||
const newBranch = String(body.new_branch || "").trim();
|
||||
const fromBranch = String(body.from_branch || "").trim();
|
||||
if (!newBranch) return json(res, 400, { error: "请填写要创建的分支名" });
|
||||
const client = new GiteaClient({
|
||||
baseUrl: cfg.giteaUrl,
|
||||
token: repo.gitea_token || cfg.giteaToken,
|
||||
});
|
||||
try {
|
||||
await client.createBranch(repo.owner, repo.name, { newBranch, fromBranch: fromBranch || undefined });
|
||||
const branches = await client.listRepoBranches(repo.owner, repo.name);
|
||||
const names = branches.map((b) => b.name);
|
||||
const updated = upsertRepository(db, {
|
||||
id: repo.id,
|
||||
managed_branch: newBranch,
|
||||
check_branches: names.join(","),
|
||||
});
|
||||
return json(res, 201, { repo: updated, branches: names });
|
||||
} catch (err) {
|
||||
return json(res, 502, { error: err.message });
|
||||
}
|
||||
}
|
||||
|
||||
/* ------------------------------ review records ------------------------- */
|
||||
|
||||
if (path === "/records" && req.method === "GET") {
|
||||
const repoId = url.searchParams.get("repo_id");
|
||||
const limit = Math.min(Number(url.searchParams.get("limit") || 100), 500);
|
||||
const rows = listReviewRecords(db, { repoId: repoId ? Number(repoId) : undefined, limit });
|
||||
return json(res, 200, rows.map((r) => ({
|
||||
...r,
|
||||
findings_json: undefined,
|
||||
params_json: undefined,
|
||||
findings_count: safeCount(r.findings_json),
|
||||
})));
|
||||
}
|
||||
|
||||
const recordMatch = /^\/records\/(\d+)$/.exec(path);
|
||||
if (recordMatch && req.method === "GET") {
|
||||
const record = getReviewRecord(db, Number(recordMatch[1]));
|
||||
if (!record) return json(res, 404, { error: "record not found" });
|
||||
const repo = getRepository(db, record.repo_id);
|
||||
return json(res, 200, {
|
||||
...record,
|
||||
findings: safeParse(record.findings_json, []),
|
||||
params: safeParse(record.params_json, {}),
|
||||
repository: repo ? `${repo.owner}/${repo.name}` : null,
|
||||
});
|
||||
}
|
||||
|
||||
const resummariseMatch = /^\/records\/(\d+)\/summarise$/.exec(path);
|
||||
if (resummariseMatch && req.method === "POST") {
|
||||
const record = getReviewRecord(db, Number(resummariseMatch[1]));
|
||||
if (!record) return json(res, 404, { error: "record not found" });
|
||||
updateReviewRecord(db, record.id, {
|
||||
summary_status: "pending", summary_error: null,
|
||||
});
|
||||
return json(res, 202, { ok: true, queued: true });
|
||||
}
|
||||
|
||||
if (path === "/jobs" && req.method === "GET") {
|
||||
const repoId = url.searchParams.get("repo_id");
|
||||
const limit = Math.min(Number(url.searchParams.get("limit") || 50), 200);
|
||||
@@ -387,8 +540,8 @@ async function handleApi(req, res, url, cfg) {
|
||||
const jobId = enqueueJob(db, {
|
||||
repoId: repo.id,
|
||||
trigger: "manual",
|
||||
refName: body.ref || repo.base_branch,
|
||||
baseRef: body.base_ref || repo.base_branch,
|
||||
refName: body.ref || repo.managed_branch,
|
||||
baseRef: body.base_ref || repo.managed_branch,
|
||||
fromSha: body.from_sha || null,
|
||||
toSha,
|
||||
prNumber: body.pr_number || null,
|
||||
|
||||
+379
-33
@@ -1,6 +1,6 @@
|
||||
"use strict";
|
||||
|
||||
const state = { repos: [], jobs: [], token: "" };
|
||||
const state = { repos: [], jobs: [], records: [], token: "", branches: [] };
|
||||
|
||||
/* ---------------------------------- auth ---------------------------------- */
|
||||
|
||||
@@ -83,9 +83,10 @@ async function api(path, { method = "GET", body } = {}) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function show(selector, content) {
|
||||
function show(selector, content, cls) {
|
||||
const el = document.querySelector(selector);
|
||||
el.textContent = typeof content === "string" ? content : JSON.stringify(content, null, 2);
|
||||
if (cls) el.className = cls; else el.className = "log";
|
||||
el.classList.remove("hidden");
|
||||
}
|
||||
|
||||
@@ -102,6 +103,15 @@ function fmtTime(value) {
|
||||
return String(value).replace("T", " ").replace("Z", "");
|
||||
}
|
||||
|
||||
function handleError(err) {
|
||||
if (err.status === 401) {
|
||||
document.getElementById("sign-out").classList.add("hidden");
|
||||
showGate("Token 无效或已过期,请重新输入。");
|
||||
return;
|
||||
}
|
||||
setBanner(err.message);
|
||||
}
|
||||
|
||||
/* ---------------------------------- tabs ---------------------------------- */
|
||||
|
||||
for (const tab of document.querySelectorAll(".tab")) {
|
||||
@@ -111,12 +121,11 @@ for (const tab of document.querySelectorAll(".tab")) {
|
||||
p.classList.toggle("active", p.id === `tab-${tab.dataset.tab}`);
|
||||
}
|
||||
if (tab.dataset.tab === "jobs") loadJobs().catch(handleError);
|
||||
if (tab.dataset.tab === "records") loadRecords().catch(handleError);
|
||||
if (tab.dataset.tab === "settings") loadSettings().catch(handleError);
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------------------- health --------------------------------- */
|
||||
|
||||
async function loadHealth() {
|
||||
try {
|
||||
const h = await api("/health");
|
||||
@@ -129,19 +138,10 @@ async function loadHealth() {
|
||||
}
|
||||
}
|
||||
|
||||
function handleError(err) {
|
||||
if (err.status === 401) {
|
||||
document.getElementById("sign-out").classList.add("hidden");
|
||||
showGate("Token 无效或已过期,请重新输入。");
|
||||
return;
|
||||
}
|
||||
setBanner(err.message);
|
||||
}
|
||||
|
||||
/* ---------------------------------- repos --------------------------------- */
|
||||
|
||||
const REPO_FIELDS = [
|
||||
"id", "owner", "name", "enabled", "base_branch", "branch_patterns", "review_scope",
|
||||
"id", "repo_url", "enabled", "managed_branch", "check_branches", "review_scope",
|
||||
"gitea_token", "llm_model", "llm_token", "background_template", "excludes",
|
||||
"publish_mode", "create_issue", "issue_labels", "block_severity", "block_categories",
|
||||
"fail_on_findings", "auto_merge", "auto_merge_mode", "merge_method", "delete_branch",
|
||||
@@ -152,13 +152,13 @@ async function loadRepos() {
|
||||
state.repos = await api("/repos");
|
||||
const tbody = document.querySelector("#repos-table tbody");
|
||||
if (state.repos.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="7" class="empty">还没有配置仓库。点右上角「新增仓库」开始,然后在 Gitea 仓库里添加 Webhook。</td></tr>';
|
||||
return;
|
||||
}
|
||||
tbody.innerHTML = '<tr><td colspan="8" class="empty">还没有配置仓库。点「从 Gitea 导入」批量选择,或「新增仓库」手动填 Git 地址。</td></tr>';
|
||||
} else {
|
||||
tbody.innerHTML = state.repos.map((r) => `
|
||||
<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>
|
||||
<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>
|
||||
@@ -166,9 +166,24 @@ async function loadRepos() {
|
||||
<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("");
|
||||
}
|
||||
syncRecordFilter();
|
||||
}
|
||||
|
||||
function shortUrl(url) {
|
||||
return String(url || "").replace(/^https?:\/\//, "");
|
||||
}
|
||||
|
||||
function branchTags(list, managed) {
|
||||
const names = String(list || "").split(",").map((s) => s.trim()).filter(Boolean);
|
||||
if (!names.length) return '<span class="tag muted">—</span>';
|
||||
return names.map((n) => n === managed
|
||||
? `<span class="tag ok">${esc(n)}</span>`
|
||||
: `<span class="tag muted">${esc(n)}</span>`).join(" ");
|
||||
}
|
||||
|
||||
function scopeLabel(scope) {
|
||||
@@ -179,13 +194,17 @@ document.querySelector("#repos-table").addEventListener("click", async (ev) => {
|
||||
const target = ev.target.closest("button");
|
||||
if (!target) return;
|
||||
try {
|
||||
if (target.dataset.edit) openRepo(Number(target.dataset.edit));
|
||||
if (target.dataset.edit) await openRepo(Number(target.dataset.edit));
|
||||
if (target.dataset.del) {
|
||||
if (!confirm("删除该仓库配置?历史任务会一并删除。")) return;
|
||||
await api(`/repos/${target.dataset.del}`, { method: "DELETE" });
|
||||
await loadRepos();
|
||||
}
|
||||
if (target.dataset.run) openManual(Number(target.dataset.run));
|
||||
if (target.dataset.records) {
|
||||
document.querySelector('#record-filter').value = String(target.dataset.records);
|
||||
document.querySelector('.tab[data-tab="records"]').click();
|
||||
}
|
||||
} catch (err) {
|
||||
handleError(err);
|
||||
}
|
||||
@@ -193,35 +212,130 @@ document.querySelector("#repos-table").addEventListener("click", async (ev) => {
|
||||
|
||||
document.getElementById("add-repo").addEventListener("click", () => openRepo(null));
|
||||
document.getElementById("refresh-jobs").addEventListener("click", () => loadJobs().catch(handleError));
|
||||
document.getElementById("refresh-records").addEventListener("click", () => loadRecords().catch(handleError));
|
||||
document.getElementById("record-filter").addEventListener("change", () => loadRecords().catch(handleError));
|
||||
|
||||
/* ------------------------------- repo dialog ------------------------------ */
|
||||
/* ------------------------------ repo dialog ------------------------------- */
|
||||
|
||||
const modal = document.getElementById("modal");
|
||||
const form = document.getElementById("repo-form");
|
||||
const branchList = document.getElementById("branch-list");
|
||||
|
||||
function openRepo(id) {
|
||||
function selectedBranches() {
|
||||
return String(form.elements.check_branches.value || "")
|
||||
.split(",").map((s) => s.trim()).filter(Boolean);
|
||||
}
|
||||
|
||||
function setSelectedBranches(list) {
|
||||
form.elements.check_branches.value = list.join(",");
|
||||
}
|
||||
|
||||
function renderBranchList() {
|
||||
const chosen = new Set(selectedBranches());
|
||||
const managed = form.elements.managed_branch.value.trim();
|
||||
if (!state.branches.length) {
|
||||
branchList.innerHTML = '<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();
|
||||
hide("#repo-result");
|
||||
state.branches = [];
|
||||
renderBranchList();
|
||||
const repo = id ? state.repos.find((r) => r.id === id) : null;
|
||||
document.getElementById("modal-title").textContent = repo ? `${repo.owner}/${repo.name}` : "新增仓库";
|
||||
document.getElementById("repo-parsed").textContent = "";
|
||||
for (const field of REPO_FIELDS) {
|
||||
const input = form.elements[field];
|
||||
if (!input) continue;
|
||||
if (!repo) {
|
||||
// Defaults for a brand-new repository.
|
||||
if (field === "enabled" || field === "create_issue") input.checked = true;
|
||||
if (field === "managed_branch") input.value = "main";
|
||||
if (field === "check_branches") input.value = "main";
|
||||
continue;
|
||||
}
|
||||
if (input.type === "checkbox") input.checked = Boolean(repo[field]);
|
||||
else if (input.type === "password") input.value = "";
|
||||
else input.value = repo[field] ?? "";
|
||||
}
|
||||
if (repo) {
|
||||
document.getElementById("repo-parsed").textContent = `${repo.owner}/${repo.name}`;
|
||||
// Existing repositories get their branch list loaded for the picker.
|
||||
loadBranches({ silent: true }).catch(() => {});
|
||||
}
|
||||
modal.classList.remove("hidden");
|
||||
}
|
||||
|
||||
document.getElementById("modal-close").addEventListener("click", () => modal.classList.add("hidden"));
|
||||
modal.addEventListener("click", (ev) => { if (ev.target === modal) modal.classList.add("hidden"); });
|
||||
|
||||
/** Fetch branch info for whatever git URL is currently in the form. */
|
||||
async function loadBranches({ silent = false } = {}) {
|
||||
const repoUrl = form.elements.repo_url.value.trim();
|
||||
const id = form.elements.id.value;
|
||||
const hint = document.getElementById("repo-parsed");
|
||||
if (!repoUrl && !id) {
|
||||
if (!silent) hint.textContent = "请先填写 Git 地址。";
|
||||
return;
|
||||
}
|
||||
if (!silent) hint.textContent = "正在连接…";
|
||||
try {
|
||||
const body = repoUrl ? { repo_url: repoUrl } : undefined;
|
||||
const info = body
|
||||
? await api("/repos/branches", { method: "POST", body })
|
||||
: await api(`/repos/${id}/discover`, { method: "POST" });
|
||||
state.branches = info.branches || [];
|
||||
if (!form.elements.managed_branch.value) form.elements.managed_branch.value = info.default_branch || "main";
|
||||
if (!form.elements.check_branches.value) form.elements.check_branches.value = state.branches.join(",");
|
||||
renderBranchList();
|
||||
hint.className = "hint ok";
|
||||
hint.textContent =
|
||||
`连接成功:${info.owner}/${info.name} · 默认分支 ${info.default_branch} · 共 ${state.branches.length} 个分支` +
|
||||
(info.has_issues ? "" : " · 该仓库未启用 Issue");
|
||||
document.getElementById("clone-source").innerHTML =
|
||||
state.branches.map((b) => `<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) => {
|
||||
ev.preventDefault();
|
||||
const data = {};
|
||||
@@ -244,17 +358,116 @@ form.addEventListener("submit", async (ev) => {
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById("discover").addEventListener("click", async () => {
|
||||
/* ------------------------------ clone branch ------------------------------ */
|
||||
|
||||
const cloneModal = document.getElementById("clone-modal");
|
||||
const cloneForm = document.getElementById("clone-form");
|
||||
|
||||
document.getElementById("branch-create").addEventListener("click", () => {
|
||||
cloneForm.reset();
|
||||
hide("#clone-result");
|
||||
cloneForm.elements.new_branch.value = form.elements.managed_branch.value.trim() || "main";
|
||||
if (!state.branches.length) {
|
||||
loadBranches().then(() => cloneModal.classList.remove("hidden")).catch(() => {});
|
||||
return;
|
||||
}
|
||||
cloneModal.classList.remove("hidden");
|
||||
});
|
||||
|
||||
document.getElementById("clone-close").addEventListener("click", () => cloneModal.classList.add("hidden"));
|
||||
cloneModal.addEventListener("click", (ev) => { if (ev.target === cloneModal) cloneModal.classList.add("hidden"); });
|
||||
|
||||
cloneForm.addEventListener("submit", async (ev) => {
|
||||
ev.preventDefault();
|
||||
const id = form.elements.id.value;
|
||||
if (!id) return show("#repo-result", "请先保存仓库,再测试连接。");
|
||||
show("#repo-result", "测试中…");
|
||||
if (!id) return show("#clone-result", "请先保存仓库,再创建分支。");
|
||||
try {
|
||||
const info = await api(`/repos/${id}/discover`, { method: "POST" });
|
||||
show("#repo-result",
|
||||
`连接成功\n默认分支:${info.default_branch}\nIssue 功能:${info.has_issues}\nPR 功能:${info.has_pull_requests}\n分支:${info.branches.join(", ")}`);
|
||||
const res = await api(`/repos/${id}/branches`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
new_branch: cloneForm.elements.new_branch.value.trim(),
|
||||
from_branch: cloneForm.elements.from_branch.value,
|
||||
},
|
||||
});
|
||||
state.branches = res.branches || [];
|
||||
form.elements.managed_branch.value = res.repo.managed_branch;
|
||||
form.elements.check_branches.value = res.repo.check_branches;
|
||||
renderBranchList();
|
||||
cloneModal.classList.add("hidden");
|
||||
show("#repo-result", `已创建分支 ${res.repo.managed_branch}`);
|
||||
} catch (err) {
|
||||
if (err.status === 401) return handleError(err);
|
||||
show("#repo-result", err.message);
|
||||
show("#clone-result", err.message);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------ import dialog ----------------------------- */
|
||||
|
||||
const importModal = document.getElementById("import-modal");
|
||||
|
||||
document.getElementById("import-repo").addEventListener("click", async () => {
|
||||
importModal.classList.remove("hidden");
|
||||
document.getElementById("import-list").innerHTML = '<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();
|
||||
hide("#manual-result");
|
||||
manualForm.elements.repo_id.value = repo.id;
|
||||
manualForm.elements.ref.value = repo.base_branch || "main";
|
||||
manualForm.elements.base_ref.value = repo.base_branch || "main";
|
||||
manualForm.elements.ref.value = repo.managed_branch || "main";
|
||||
manualForm.elements.base_ref.value = repo.managed_branch || "main";
|
||||
document.getElementById("manual-repo").textContent = `${repo.owner}/${repo.name}`;
|
||||
manualModal.classList.remove("hidden");
|
||||
}
|
||||
@@ -299,6 +512,139 @@ manualForm.addEventListener("submit", async (ev) => {
|
||||
}
|
||||
});
|
||||
|
||||
/* -------------------------------- records --------------------------------- */
|
||||
|
||||
function syncRecordFilter() {
|
||||
const sel = document.getElementById("record-filter");
|
||||
const current = sel.value;
|
||||
sel.innerHTML = '<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 ---------------------------------- */
|
||||
|
||||
const STATUS_TAG = {
|
||||
|
||||
+103
-14
@@ -10,11 +10,15 @@
|
||||
<body>
|
||||
<header>
|
||||
<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>
|
||||
|
||||
<nav>
|
||||
<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="settings">设置</button>
|
||||
</nav>
|
||||
@@ -25,19 +29,33 @@
|
||||
<section id="tab-repos" class="panel active">
|
||||
<div class="row">
|
||||
<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>
|
||||
<table id="repos-table">
|
||||
<thead>
|
||||
<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>
|
||||
</thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</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">
|
||||
<div class="row">
|
||||
<h2>审查任务</h2>
|
||||
@@ -58,17 +76,17 @@
|
||||
<form id="settings-form">
|
||||
<fieldset>
|
||||
<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>Webhook 密钥 <input name="webhookSecret" 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>
|
||||
</fieldset>
|
||||
<fieldset>
|
||||
<legend>LLM</legend>
|
||||
<legend>LLM(代码审查与历史摘要共用)</legend>
|
||||
<label>接口地址 <input name="llmUrl" placeholder="https://api.openai.com/v1" /></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="llmAuthHeader" placeholder="留空使用默认" /></label>
|
||||
<label>附加请求头 <input name="llmExtraHeaders" placeholder="K=V,K=V" /></label>
|
||||
@@ -100,6 +118,18 @@
|
||||
</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 class="modal-body narrow">
|
||||
<h2>立即审查</h2>
|
||||
@@ -108,7 +138,7 @@
|
||||
<p class="hint" id="manual-repo"></p>
|
||||
<label>分支 <input name="ref" placeholder="main" 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>
|
||||
<div class="actions">
|
||||
<button type="submit">加入队列</button>
|
||||
@@ -119,16 +149,55 @@
|
||||
</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 class="modal-body">
|
||||
<h2 id="modal-title">仓库配置</h2>
|
||||
<form id="repo-form">
|
||||
<input type="hidden" name="id" />
|
||||
<fieldset>
|
||||
<legend>仓库</legend>
|
||||
<label>Git 地址
|
||||
<span class="inline">
|
||||
<input name="repo_url" placeholder="http://192.168.31.51/kgod/myrepo.git" required />
|
||||
<button type="button" id="repo-test">测试连接</button>
|
||||
</span>
|
||||
</label>
|
||||
<p class="hint" id="repo-parsed"></p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>分支</legend>
|
||||
<label>管理分支(合并目标)
|
||||
<span class="inline">
|
||||
<input name="managed_branch" placeholder="main" />
|
||||
<button type="button" id="branch-create">克隆创建</button>
|
||||
</span>
|
||||
</label>
|
||||
<p class="hint">所有检查通过后只合并到这一个分支。</p>
|
||||
<label>检查分支(可多选)
|
||||
<span class="inline">
|
||||
<input name="check_branches" placeholder="main,release/1.0" />
|
||||
<button type="button" id="branch-reload">加载分支</button>
|
||||
</span>
|
||||
</label>
|
||||
<div id="branch-list" class="branch-list"></div>
|
||||
<p class="hint">这些分支都会被监控审查;只有指向管理分支的 PR 才会自动合并。</p>
|
||||
</fieldset>
|
||||
|
||||
<fieldset>
|
||||
<legend>审查</legend>
|
||||
<div class="grid">
|
||||
<label>所有者 <input name="owner" required /></label>
|
||||
<label>仓库名 <input name="name" required /></label>
|
||||
<label>目标分支 <input name="base_branch" placeholder="main" /></label>
|
||||
<label>监听分支模式 <input name="branch_patterns" placeholder="*, release/*" /></label>
|
||||
<label>审查范围
|
||||
<select name="review_scope">
|
||||
<option value="both">Push 与 PR 都审查</option>
|
||||
@@ -151,8 +220,12 @@
|
||||
<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>
|
||||
<label>审查背景 <input name="background_template" placeholder="补充业务上下文" /></label>
|
||||
</div>
|
||||
<label>审查背景 / 需求
|
||||
<textarea name="background_template" rows="6" placeholder="补充业务上下文、本次需求说明;会作为审查背景传给模型,并记录到审查历史。"></textarea>
|
||||
</label>
|
||||
</fieldset>
|
||||
|
||||
<div class="checks">
|
||||
<label><input type="checkbox" name="enabled" /> 启用</label>
|
||||
<label><input type="checkbox" name="create_issue" /> 发现问题时创建 Issue</label>
|
||||
@@ -179,7 +252,6 @@
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="submit">保存</button>
|
||||
<button type="button" id="discover">测试连接</button>
|
||||
<button type="button" id="modal-close">取消</button>
|
||||
</div>
|
||||
<pre id="repo-result" class="log hidden"></pre>
|
||||
@@ -187,6 +259,23 @@
|
||||
</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>
|
||||
</body>
|
||||
</html>
|
||||
+51
-6
@@ -23,8 +23,10 @@ header {
|
||||
}
|
||||
h1 { font-size: 18px; margin: 0; }
|
||||
h2 { font-size: 15px; margin: 0 0 12px; }
|
||||
h3 { font-size: 14px; margin: 0 0 8px; }
|
||||
.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 {
|
||||
background: transparent; border: 1px solid transparent; color: var(--muted);
|
||||
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; }
|
||||
.panel { display: none; background: var(--panel); border: 1px solid var(--border); border-radius: 0 8px 8px 8px; padding: 20px; }
|
||||
.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 {
|
||||
background: var(--panel-2); color: var(--text); border: 1px solid var(--border);
|
||||
padding: 7px 14px; border-radius: 6px; cursor: pointer; font-size: 13px;
|
||||
@@ -41,6 +43,7 @@ button {
|
||||
button:hover { border-color: var(--accent); }
|
||||
button.primary { background: var(--accent); border-color: var(--accent); color: #fff; }
|
||||
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; }
|
||||
th, td { text-align: left; padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
|
||||
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; }
|
||||
legend { color: var(--muted); padding: 0 6px; }
|
||||
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;
|
||||
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 label { display: flex; align-items: center; gap: 6px; margin: 0; }
|
||||
.checks input { width: auto; margin: 0; }
|
||||
.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.ok { color: var(--ok); }
|
||||
.hint.err { color: var(--err); }
|
||||
.log {
|
||||
background: #0a0c10; border: 1px solid var(--border); border-radius: 6px;
|
||||
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. */
|
||||
.hidden { display: none !important; }
|
||||
|
||||
.banner {
|
||||
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);
|
||||
}
|
||||
.banner.ok { border-color: var(--ok); color: var(--ok); background: rgba(63,185,80,0.08); }
|
||||
.empty { color: var(--muted); padding: 18px 4px; }
|
||||
header .right { display: flex; align-items: center; gap: 12px; }
|
||||
.linkish {
|
||||
background: none; border: none; color: var(--muted); cursor: pointer;
|
||||
font-size: 13px; text-decoration: underline; padding: 0;
|
||||
}
|
||||
.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
@@ -7,7 +7,7 @@ import { test } from "node:test";
|
||||
import { rmSync } from "node:fs";
|
||||
|
||||
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 { normalizeBaseUrl } from "../app/lib/gitea.js";
|
||||
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 });
|
||||
});
|
||||
|
||||
test("branchMatches honours glob patterns and ref prefixes", () => {
|
||||
assert.ok(branchMatches("main", "*"));
|
||||
assert.ok(branchMatches("main", ""));
|
||||
test("branchMatches matches an explicit branch list, not globs", () => {
|
||||
assert.ok(branchMatches("main", "main"));
|
||||
assert.ok(branchMatches("refs/heads/main", "main"));
|
||||
assert.ok(branchMatches("release/1.2", "release/*"));
|
||||
assert.ok(!branchMatches("feature/x", "release/*"));
|
||||
assert.ok(branchMatches("feature/x", "main, feature/*"));
|
||||
assert.ok(branchMatches("a/b/c", "a/**"));
|
||||
assert.ok(branchMatches("release/1.2", "main,release/1.2"));
|
||||
assert.ok(branchMatches("dev", ""));
|
||||
assert.ok(!branchMatches("feature/x", "main,release/1.2"));
|
||||
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", () => {
|
||||
@@ -185,7 +205,8 @@ test("repository defaults and branch state round-trip", () => {
|
||||
const { db, cleanup } = tempDb("repo");
|
||||
try {
|
||||
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.review_scope, "both");
|
||||
assert.equal(repo.create_issue, 1);
|
||||
|
||||
Reference in New Issue
Block a user