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:
+95
-7
@@ -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") || "{}");
|
||||
|
||||
Reference in New Issue
Block a user