diff --git a/README.md b/README.md index 0b334ac..bdae913 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,8 @@ Push / Pull Request 事件 - 「从 Gitea 导入」一键列出全局 Token 可见的仓库,点「添加」即建好监控配置 - 手动新增时只填 Git 地址,自动识别 `所有者/仓库名`,旁边可「测试连接」验证 - 支持 https / ssh / scp 三种地址写法 +- 保存仓库时**自动在 Gitea 里创建 Webhook**,无需手工配置;仓库列表会显示 + Webhook 状态,未配置可一键「修复 Webhook」 **分支模型** - 一个「管理分支」作为唯一合并目标 @@ -140,6 +142,7 @@ curl -fsS http://localhost:8090/api/health | `CR_GITEA_TOKEN` | 发布评论、Issue、提交状态用的 Token | | `CR_WEBHOOK_SECRET` | Webhook HMAC 密钥;设置后拒绝签名不符的请求 | | `CR_ADMIN_TOKEN` | 保护后台和 REST API 的 Bearer Token;设置后打开后台需先输入;留空则不校验 | +| `CR_WEBHOOK_URL` | Gitea 回调本服务的地址;留空则自动推导为「Gitea 主机名 + 本服务端口」 | | `CR_LLM_URL` / `CR_LLM_TOKEN` / `CR_LLM_MODEL` | LLM 端点与凭据 | | `CR_LLM_PROTOCOL` | `openai` 或 `anthropic` | | `CR_LLM_AUTH_HEADER` | 自定义认证头名,默认由协议决定(如 `x-api-key`) | @@ -160,7 +163,7 @@ curl -fsS http://localhost:8090/api/health | --- | --- | | `repo_url` | 仓库 Git 地址,`owner` / `name` 由此自动解析 | | `managed_branch` | 唯一的管理分支,所有审查都相对它做对比,也是唯一允许自动合并的目标 | -| `check_branches` | 纳入监控的分支列表,逗号分隔;支持任意多个 | +| `check_branches` | 受保护的目标分支列表,逗号分隔。任何**合并进**这些分支的 PR 都会审查(功能分支不必列出),向这些分支推送也会审查 | | `review_scope` | `both` / `pr` / `push` | | `block_severity` | 阻断级别阈值,如 `critical,high`;留空则不看级别 | | `block_categories` | 额外按类别阻断,如 `security` | @@ -191,6 +194,8 @@ curl -fsS http://localhost:8090/api/health | `POST` | `/api/repos/parse` | 解析 Git 地址,返回 `owner` / `name` | | `POST` | `/api/repos/branches` | 用 Git 地址查询分支(仓库尚未保存时使用) | | `POST` | `/api/repos/:id/branches` | 创建分支,可从指定分支克隆 | +| `GET` | `/api/repos/:id/webhook` | 查询该仓库的 Webhook 是否已安装 | +| `POST` | `/api/repos/:id/webhook` | 安装或修复该仓库的 Webhook | | `GET` | `/api/records` | 审查历史(`?repo_id=`、`?limit=`) | | `GET` | `/api/records/:id` | 单条记录,含参数、意见与摘要 | | `POST` | `/api/records/:id/summarise` | 重新排队生成摘要 | @@ -212,7 +217,7 @@ curl -X POST http://localhost:8090/api/review \ ## 行为说明 -**分支与合并**:`check_branches` 里的每个分支都会被监控审查,审查基准是 `managed_branch`(用 merge-base 计算),也就是「这些改动合入管理分支会怎样」。自动合并只作用于目标为 `managed_branch` 的 PR,其他分支的 PR 只审不合。 +**分支与合并**:`check_branches` 是**被保护的目标分支**。任何合并进这些分支的 PR 都会被审查,所以功能分支不必列进去;向这些分支直接推送也会审查。审查基准是该改动实际要合入的分支(PR 用 PR 自己的目标分支,push 用管理分支),也就是「这次改动合进去会怎样」。自动合并只作用于目标为 `managed_branch` 的 PR。 **审查范围**:PR 事件优先用 PR 的目标分支作为基准;push 事件优先用 webhook 里的 `before`,当 `before` 是新建分支的全零值或缺失时,退回到与管理分支的 merge-base。 @@ -238,6 +243,9 @@ docker compose build # 构建镜像 ## 故障排查 +**PR 提交了却没有触发审查** +先看仓库列表的 Webhook 列:显示「未配置」说明 Gitea 不会发事件,点「修复 Webhook」即可。其次确认 `check_branches` 是否包含该 PR 的**目标分支**(不是功能分支)。 + **Webhook 投递失败,提示 `webhook can only call allowed HTTP servers`** Gitea 拦截了内网地址。按上文给 `[webhook] ALLOWED_HOST_LIST` 加上本服务地址,重启 Gitea。 diff --git a/app/lib/gitea.js b/app/lib/gitea.js index 0d7d3ee..21a24bb 100644 --- a/app/lib/gitea.js +++ b/app/lib/gitea.js @@ -120,6 +120,37 @@ export class GiteaClient { return out; } + async listRepoWebhooks(owner, repo) { + return this.get(`/api/v1/repos/${owner}/${repo}/hooks`); + } + + /** + * Create (or update) the review webhook for a repository. + * Returns { created: boolean, id }. + */ + async ensureRepoWebhook(owner, repo, { url, secret, events = ["push", "pull_request"] }) { + const desired = { + type: "gitea", + active: true, + name: "gitea-codereview", + events, + config: { url, content_type: "json", ...(secret ? { secret } : {}) }, + }; + let existing = []; + try { + existing = (await this.listRepoWebhooks(owner, repo)) ?? []; + } catch { + existing = []; + } + const match = existing.find((h) => h.config?.url === url) ?? existing.find((h) => h.name === desired.name); + if (match) { + await this.patch(`/api/v1/repos/${owner}/${repo}/hooks/${match.id}`, desired); + return { created: false, id: match.id }; + } + const created = await this.post(`/api/v1/repos/${owner}/${repo}/hooks`, desired); + return { created: true, id: created?.id ?? null }; + } + /** Create a branch, optionally from another branch/tag/commit. */ async createBranch(owner, repo, { newBranch, fromBranch }) { const payload = { new_branch_name: newBranch }; diff --git a/app/lib/review.js b/app/lib/review.js index 05f1e66..067504d 100644 --- a/app/lib/review.js +++ b/app/lib/review.js @@ -67,6 +67,20 @@ export function branchMatches(refName, checkBranches) { return list.some((b) => b === short || b === refName); } +/** + * Whether a pull request should be reviewed. + * + * The check list names the *target* branches worth protecting, so a PR counts + * when it merges INTO a checked branch. That keeps "review everything that + * lands on prd/test" working even though feature branches are never listed. + * As a fallback the head branch is also matched, so explicitly listing a + * long-lived branch still reviews pushes and PRs originating from it. + */ +export function pullRequestMatches(headRef, baseRef, checkBranches) { + if (branchMatches(baseRef, checkBranches)) return true; + return branchMatches(headRef, checkBranches); +} + function badge(comment) { const parts = [comment.category, comment.severity].filter(Boolean); return parts.length ? `[${parts.join(" · ")}] ` : ""; @@ -188,9 +202,11 @@ async function resolveRange(runner, { repo, job, workspace, token }) { if (job.from_sha) { fromSha = await runner.revParse(workspace, job.from_sha); } - // 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; + // The review base is whatever this change would merge into: for a pull + // request that is the PR's own target branch, and only a bare push falls + // back to the managed branch. Preferring the managed branch here would + // compare a PR against an unrelated branch. + const baseBranch = job.base_ref || job.managed_branch; if (!fromSha && baseBranch) { const baseSha = await runner.revParse(workspace, `refs/remotes/origin/${baseBranch}`); if (baseSha) fromSha = await runner.mergeBase(workspace, baseSha, toSha) ?? baseSha; diff --git a/app/server.js b/app/server.js index 35e4fc9..85ea937 100644 --- a/app/server.js +++ b/app/server.js @@ -15,7 +15,7 @@ import { import { GiteaClient } from "./lib/gitea.js"; import { OcrRunner } from "./lib/ocr.js"; import { JobQueue } from "./lib/queue.js"; -import { ReviewEngine, SkipJob, branchMatches, parseRepoUrl } from "./lib/review.js"; +import { ReviewEngine, SkipJob, branchMatches, parseRepoUrl, pullRequestMatches } from "./lib/review.js"; const APP_DIR = resolve(fileURLToPath(new URL(".", import.meta.url))); const ROOT_DIR = resolve(APP_DIR, ".."); @@ -41,6 +41,7 @@ function readConfig() { giteaUrl: process.env.CR_GITEA_URL || "http://127.0.0.1:80", giteaToken: process.env.CR_GITEA_TOKEN || "", webhookSecret: process.env.CR_WEBHOOK_SECRET || "", + webhookUrl: process.env.CR_WEBHOOK_URL || "", adminToken: process.env.CR_ADMIN_TOKEN || "", llmUrl: process.env.CR_LLM_URL || process.env.OCR_LLM_URL || "", llmToken: process.env.CR_LLM_TOKEN || process.env.OCR_LLM_TOKEN || "", @@ -69,6 +70,7 @@ function effectiveConfig() { giteaUrl: saved.giteaUrl || CONFIG.giteaUrl, giteaToken: saved.giteaToken || CONFIG.giteaToken, webhookSecret: saved.webhookSecret ?? CONFIG.webhookSecret, + webhookUrl: saved.webhookUrl || CONFIG.webhookUrl, adminToken: saved.adminToken ?? CONFIG.adminToken, llmUrl: saved.llmUrl || CONFIG.llmUrl, llmToken: saved.llmToken || CONFIG.llmToken, @@ -131,6 +133,20 @@ function verifySignature(secret, rawBody, signature) { return a.length === b.length && timingSafeEqual(a, b); } +/** + * The webhook URL Gitea should call. CR_WEBHOOK_URL wins; otherwise assume the + * service shares a host with Gitea and reuse that hostname. + */ +function webhookUrlFor(cfg) { + if (cfg.webhookUrl) return cfg.webhookUrl; + try { + const gitea = new URL(cfg.giteaUrl); + return `${gitea.protocol}//${gitea.hostname}:${cfg.port}/webhook/gitea`; + } catch { + return `http://127.0.0.1:${cfg.port}/webhook/gitea`; + } +} + function safeParse(value, fallback) { try { return JSON.parse(value || "null") ?? fallback; } catch { return fallback; } } @@ -178,6 +194,9 @@ async function handlePush(payload, cfg) { if (scoped && !pr) { return { queued: 0, reason: "review_scope=pr and no open pull request" }; } + // Base the review on what the push would merge into: the open PR's target + // when there is one, otherwise the managed branch. + const baseRef = pr?.base?.ref || repo.managed_branch; if (findJobBySha(db, repo.id, toSha)) { return { queued: 0, reason: `commit ${toSha.slice(0, 10)} already queued or running` }; @@ -188,7 +207,7 @@ async function handlePush(payload, cfg) { repoId: repo.id, trigger: "push", refName, - baseRef: pr?.base?.ref ?? repo.managed_branch, + baseRef, fromSha: before, toSha, prNumber: pr?.number ?? null, @@ -212,8 +231,11 @@ 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.check_branches)) { - return { queued: 0, reason: `head branch ${pr.head?.ref} is not in the checked branch list` }; + if (!pullRequestMatches(pr.head?.ref, pr.base?.ref, repo.check_branches)) { + return { + queued: 0, + reason: `neither head ${pr.head?.ref} nor base ${pr.base?.ref} is in the checked branch list`, + }; } const toSha = pr.head?.sha; if (!toSha) return { queued: 0, reason: "no head sha" }; @@ -274,6 +296,7 @@ async function handleApi(req, res, url, cfg) { llmModel: cfg.llmModel, llmProtocol: cfg.llmProtocol, rulePath: cfg.rulePath, + webhookUrl: webhookUrlFor(cfg), giteaTokenSet: Boolean(cfg.giteaToken), llmTokenSet: Boolean(cfg.llmToken), raw: Object.fromEntries(Object.entries(saved).filter(([k]) => !SECRET_SETTING_KEYS.has(k))), @@ -284,8 +307,9 @@ async function handleApi(req, res, url, cfg) { if (path === "/settings" && req.method === "PUT") { const body = JSON.parse((await readBody(req)).toString("utf8") || "{}"); const allowed = [ - "giteaUrl", "giteaToken", "webhookSecret", "adminToken", "llmUrl", "llmToken", - "llmModel", "llmProtocol", "llmAuthHeader", "llmExtraHeaders", "rulePath", + "giteaUrl", "giteaToken", "webhookSecret", "adminToken", "webhookUrl", + "llmUrl", "llmToken", "llmModel", "llmProtocol", "llmAuthHeader", + "llmExtraHeaders", "rulePath", ]; for (const key of allowed) { if (body[key] !== undefined) setSetting(db, key, body[key]); @@ -329,7 +353,25 @@ async function handleApi(req, res, url, cfg) { create_issue: body.create_issue === undefined ? 1 : Number(Boolean(body.create_issue)), auto_merge: Number(Boolean(body.auto_merge)), }); - return json(res, 201, repo); + // Install the webhook straight away: an imported repository that never + // receives events looks identical to a broken service. + let webhook = null; + if (body.install_webhook !== false) { + try { + const client = new GiteaClient({ + baseUrl: cfg.giteaUrl, + token: repo.gitea_token || cfg.giteaToken, + }); + webhook = await client.ensureRepoWebhook(repo.owner, repo.name, { + url: webhookUrlFor(cfg), + secret: cfg.webhookSecret, + }); + } catch (err) { + webhook = { error: err.message }; + logger.warn(`cannot install webhook for ${owner}/${name}: ${err.message}`); + } + } + return json(res, 201, { ...repo, webhook }); } /** Repositories the global token can see, for one-click import. */ @@ -414,6 +456,52 @@ async function handleApi(req, res, url, cfg) { } } + /** Report whether this repository's webhook is installed. */ + const hookMatch = /^\/repos\/(\d+)\/webhook$/.exec(path); + if (hookMatch && req.method === "GET") { + const repo = getRepository(db, Number(hookMatch[1])); + if (!repo) return json(res, 404, { error: "repository not found" }); + const url = webhookUrlFor(cfg); + const client = new GiteaClient({ + baseUrl: cfg.giteaUrl, + token: repo.gitea_token || cfg.giteaToken, + }); + try { + const hooks = (await client.listRepoWebhooks(repo.owner, repo.name)) ?? []; + const match = hooks.find((h) => h.config?.url === url); + return json(res, 200, { + installed: Boolean(match), + active: match?.active ?? false, + events: match?.events ?? [], + url, + expectedEvents: ["push", "pull_request"], + id: match?.id ?? null, + }); + } catch (err) { + return json(res, 200, { installed: false, url, error: err.message }); + } + } + + /** Install or repair this repository's webhook. */ + if (hookMatch && req.method === "POST") { + const repo = getRepository(db, Number(hookMatch[1])); + if (!repo) return json(res, 404, { error: "repository not found" }); + const client = new GiteaClient({ + baseUrl: cfg.giteaUrl, + token: repo.gitea_token || cfg.giteaToken, + }); + const url = webhookUrlFor(cfg); + try { + const result = await client.ensureRepoWebhook(repo.owner, repo.name, { + url, + secret: cfg.webhookSecret, + }); + return json(res, 200, { ...result, url, hasSecret: Boolean(cfg.webhookSecret) }); + } catch (err) { + return json(res, 502, { error: err.message, url }); + } + } + /** 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") || "{}"); diff --git a/app/static/app.js b/app/static/app.js index 1a67532..8caabdd 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -150,6 +150,15 @@ const REPO_FIELDS = [ async function loadRepos() { state.repos = await api("/repos"); + // The webhook is what makes reviews fire at all, so its state belongs in the + // repository row instead of hiding in Gitea's settings. + await Promise.all(state.repos.map(async (r) => { + try { + r.webhook = await api(`/repos/${r.id}/webhook`); + } catch (err) { + r.webhook = { error: err.message }; + } + })); const tbody = document.querySelector("#repos-table tbody"); if (state.repos.length === 0) { tbody.innerHTML = '
${esc(r.managed_branch)}${esc(r.block_severity || "—")}Webhook 地址:(在仓库 Settings → Webhooks 中添加,密钥填上面这一项)
保存仓库时会自动在 Gitea 里创建/更新 Webhook,地址用上面这一项,密钥用 Webhook 密钥。