fix: 自动安装 Webhook、修正 PR 审查范围与分支匹配语义

三个导致「提交了新 PR 但没有任何反应」的独立问题:

1. 从不创建 Webhook
   服务只被动接收事件,但配置仓库时不会去 Gitea 里建 Webhook,
   结果仓库永远收不到 push / PR 事件,看起来和坏掉一样。
   - 保存仓库时自动创建或更新 Webhook(POST /repos 返回值带 webhook 结果)
   - 新增 GET/POST /api/repos/:id/webhook 查询与修复
   - 仓库列表新增 Webhook 列,未配置可一键「修复 Webhook」
   - 新增 CR_WEBHOOK_URL,留空则推导为「Gitea 主机名 + 本服务端口」

2. 审查范围用了错的分支做基准
   resolveRange 优先取 managed_branch 而不是 job.base_ref,
   于是 PR 被拿去和一个无关分支比较;当两者内容相同就报
   「base and head resolve to the same commit」直接跳过。
   改为优先用该改动实际要合入的分支(PR 的目标分支),
   仅 push 事件回退到 managed_branch。

3. 检查分支的匹配语义反了
   原先要求 PR 的 head(功能分支)出现在 check_branches 里,
   而用户配置的是「要保护的目标分支」(如 prd/test),
   功能分支永远不会被列出,所以 PR 一律被过滤掉。
   新增 pullRequestMatches:PR 的 base 命中检查分支即审查,
   head 命中仍保留支持。这样「审查所有合入 prd/test 的改动」成立。

验证:PR offerpai/offerpai_h5#2 重跑通过,7 条内联评论、
2 条阻断、commit status failure、Issue #3 创建。
This commit is contained in:
2026-09-20 14:44:26 +08:00
parent 6dc77ce907
commit 34f2897888
6 changed files with 208 additions and 22 deletions
+95 -7
View File
@@ -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") || "{}");