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
+31
View File
@@ -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 };
+19 -3
View File
@@ -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;
+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") || "{}");
+48 -6
View File
@@ -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 = '<tr><td colspan="8" class="empty">还没有配置仓库。点「从 Gitea 导入」批量选择,或「新增仓库」手动填 Git 地址。</td></tr>';
@@ -160,6 +169,7 @@ async function loadRepos() {
<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>${webhookCell(r)}</td>
<td>${r.enabled ? '<span class="tag ok">启用</span>' : '<span class="tag muted">停用</span>'}</td>
<td>${r.auto_merge ? `<span class="tag warn">${esc(r.merge_method)}</span>` : '<span class="tag muted">否</span>'}</td>
<td><code>${esc(r.block_severity || "—")}</code></td>
@@ -167,6 +177,7 @@ async function loadRepos() {
<button data-edit="${r.id}">编辑</button>
<button data-run="${r.id}">立即审查</button>
<button data-records="${r.id}">历史</button>
<button data-hook="${r.id}">修复 Webhook</button>
<button class="danger" data-del="${r.id}">删除</button>
</td>
</tr>`).join("");
@@ -174,6 +185,12 @@ async function loadRepos() {
syncRecordFilter();
}
function webhookCell(r) {
if (r.webhook?.error) return `<span class="tag err" title="${esc(r.webhook.error)}">未知</span>`;
if (r.webhook?.installed) return '<span class="tag ok">已配置</span>';
return '<span class="tag err">未配置</span>';
}
function shortUrl(url) {
return String(url || "").replace(/^https?:\/\//, "");
}
@@ -205,6 +222,19 @@ document.querySelector("#repos-table").addEventListener("click", async (ev) => {
document.querySelector('#record-filter').value = String(target.dataset.records);
document.querySelector('.tab[data-tab="records"]').click();
}
if (target.dataset.hook) {
target.disabled = true;
const label = target.textContent;
target.textContent = "处理中…";
try {
const res = await api(`/repos/${target.dataset.hook}/webhook`, { method: "POST" });
setBanner(`Webhook ${res.created ? "已创建" : "已更新"}:${res.url}`, "ok");
} finally {
target.disabled = false;
target.textContent = label;
await loadRepos();
}
}
} catch (err) {
handleError(err);
}
@@ -347,10 +377,21 @@ form.addEventListener("submit", async (ev) => {
else if (input.value !== "") data[field] = input.value;
}
try {
if (data.id) await api(`/repos/${data.id}`, { method: "PATCH", body: data });
else await api("/repos", { method: "POST", body: data });
modal.classList.add("hidden");
setBanner("");
if (data.id) {
await api(`/repos/${data.id}`, { method: "PATCH", body: data });
modal.classList.add("hidden");
setBanner("");
} else {
const created = await api("/repos", { method: "POST", body: data });
modal.classList.add("hidden");
if (created.webhook?.error) {
setBanner(`仓库已保存,但 Webhook 安装失败:${created.webhook.error}`);
} else if (created.webhook) {
setBanner(`仓库已保存,Webhook ${created.webhook.created ? "已创建" : "已更新"}。`, "ok");
} else {
setBanner("仓库已保存。");
}
}
await loadRepos();
} catch (err) {
if (err.status === 401) return handleError(err);
@@ -693,8 +734,9 @@ document.querySelector("#jobs-table").addEventListener("click", async (ev) => {
/* -------------------------------- settings -------------------------------- */
const SETTINGS_FIELDS = [
"giteaUrl", "giteaToken", "webhookSecret", "adminToken", "llmUrl", "llmToken",
"llmModel", "llmProtocol", "llmAuthHeader", "llmExtraHeaders", "rulePath",
"giteaUrl", "giteaToken", "webhookSecret", "adminToken", "webhookUrl",
"llmUrl", "llmToken", "llmModel", "llmProtocol", "llmAuthHeader",
"llmExtraHeaders", "rulePath",
];
async function loadSettings() {
+5 -4
View File
@@ -38,7 +38,7 @@
<thead>
<tr>
<th>仓库</th><th>管理分支</th><th>检查分支</th><th>范围</th>
<th>启用</th><th>自动合并</th><th>阻断级别</th><th></th>
<th>Webhook</th><th>启用</th><th>自动合并</th><th>阻断级别</th><th></th>
</tr>
</thead>
<tbody></tbody>
@@ -80,7 +80,8 @@
<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>
<label>Webhook 回调地址 <input name="webhookUrl" placeholder="留空自动推导为 <Gitea 主机>:<本服务端口>" /></label>
<p class="hint">保存仓库时会自动在 Gitea 里创建/更新 Webhook,地址用上面这一项,密钥用 Webhook 密钥。<code id="hook-url"></code></p>
</fieldset>
<fieldset>
<legend>LLM(代码审查与历史摘要共用)</legend>
@@ -184,7 +185,7 @@
<button type="button" id="branch-create">克隆创建</button>
</span>
</label>
<p class="hint">所有检查通过后只合并到这一个分支。</p>
<p class="hint">所有检查通过后只合并到这一个分支;它也会出现在下面的检查分支里。</p>
<label>检查分支(可多选)
<span class="inline">
<input name="check_branches" placeholder="main,release/1.0" />
@@ -192,7 +193,7 @@
</span>
</label>
<div id="branch-list" class="branch-list"></div>
<p class="hint">这些分支都会被监控审查;只有指向管理分支的 PR 才会自动合并。</p>
<p class="hint">填「被保护的目标分支」。任何合并进这些分支的 PR 都会审查(功能分支不用列出来);向这些分支推送也会审查。只有指向管理分支的 PR 才会自动合并。</p>
</fieldset>
<fieldset>