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:
@@ -23,6 +23,8 @@ Push / Pull Request 事件
|
|||||||
- 「从 Gitea 导入」一键列出全局 Token 可见的仓库,点「添加」即建好监控配置
|
- 「从 Gitea 导入」一键列出全局 Token 可见的仓库,点「添加」即建好监控配置
|
||||||
- 手动新增时只填 Git 地址,自动识别 `所有者/仓库名`,旁边可「测试连接」验证
|
- 手动新增时只填 Git 地址,自动识别 `所有者/仓库名`,旁边可「测试连接」验证
|
||||||
- 支持 https / ssh / scp 三种地址写法
|
- 支持 https / ssh / scp 三种地址写法
|
||||||
|
- 保存仓库时**自动在 Gitea 里创建 Webhook**,无需手工配置;仓库列表会显示
|
||||||
|
Webhook 状态,未配置可一键「修复 Webhook」
|
||||||
|
|
||||||
**分支模型**
|
**分支模型**
|
||||||
- 一个「管理分支」作为唯一合并目标
|
- 一个「管理分支」作为唯一合并目标
|
||||||
@@ -140,6 +142,7 @@ curl -fsS http://localhost:8090/api/health
|
|||||||
| `CR_GITEA_TOKEN` | 发布评论、Issue、提交状态用的 Token |
|
| `CR_GITEA_TOKEN` | 发布评论、Issue、提交状态用的 Token |
|
||||||
| `CR_WEBHOOK_SECRET` | Webhook HMAC 密钥;设置后拒绝签名不符的请求 |
|
| `CR_WEBHOOK_SECRET` | Webhook HMAC 密钥;设置后拒绝签名不符的请求 |
|
||||||
| `CR_ADMIN_TOKEN` | 保护后台和 REST API 的 Bearer Token;设置后打开后台需先输入;留空则不校验 |
|
| `CR_ADMIN_TOKEN` | 保护后台和 REST API 的 Bearer Token;设置后打开后台需先输入;留空则不校验 |
|
||||||
|
| `CR_WEBHOOK_URL` | Gitea 回调本服务的地址;留空则自动推导为「Gitea 主机名 + 本服务端口」 |
|
||||||
| `CR_LLM_URL` / `CR_LLM_TOKEN` / `CR_LLM_MODEL` | LLM 端点与凭据 |
|
| `CR_LLM_URL` / `CR_LLM_TOKEN` / `CR_LLM_MODEL` | LLM 端点与凭据 |
|
||||||
| `CR_LLM_PROTOCOL` | `openai` 或 `anthropic` |
|
| `CR_LLM_PROTOCOL` | `openai` 或 `anthropic` |
|
||||||
| `CR_LLM_AUTH_HEADER` | 自定义认证头名,默认由协议决定(如 `x-api-key`) |
|
| `CR_LLM_AUTH_HEADER` | 自定义认证头名,默认由协议决定(如 `x-api-key`) |
|
||||||
@@ -160,7 +163,7 @@ curl -fsS http://localhost:8090/api/health
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `repo_url` | 仓库 Git 地址,`owner` / `name` 由此自动解析 |
|
| `repo_url` | 仓库 Git 地址,`owner` / `name` 由此自动解析 |
|
||||||
| `managed_branch` | 唯一的管理分支,所有审查都相对它做对比,也是唯一允许自动合并的目标 |
|
| `managed_branch` | 唯一的管理分支,所有审查都相对它做对比,也是唯一允许自动合并的目标 |
|
||||||
| `check_branches` | 纳入监控的分支列表,逗号分隔;支持任意多个 |
|
| `check_branches` | 受保护的目标分支列表,逗号分隔。任何**合并进**这些分支的 PR 都会审查(功能分支不必列出),向这些分支推送也会审查 |
|
||||||
| `review_scope` | `both` / `pr` / `push` |
|
| `review_scope` | `both` / `pr` / `push` |
|
||||||
| `block_severity` | 阻断级别阈值,如 `critical,high`;留空则不看级别 |
|
| `block_severity` | 阻断级别阈值,如 `critical,high`;留空则不看级别 |
|
||||||
| `block_categories` | 额外按类别阻断,如 `security` |
|
| `block_categories` | 额外按类别阻断,如 `security` |
|
||||||
@@ -191,6 +194,8 @@ curl -fsS http://localhost:8090/api/health
|
|||||||
| `POST` | `/api/repos/parse` | 解析 Git 地址,返回 `owner` / `name` |
|
| `POST` | `/api/repos/parse` | 解析 Git 地址,返回 `owner` / `name` |
|
||||||
| `POST` | `/api/repos/branches` | 用 Git 地址查询分支(仓库尚未保存时使用) |
|
| `POST` | `/api/repos/branches` | 用 Git 地址查询分支(仓库尚未保存时使用) |
|
||||||
| `POST` | `/api/repos/:id/branches` | 创建分支,可从指定分支克隆 |
|
| `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` | 审查历史(`?repo_id=`、`?limit=`) |
|
||||||
| `GET` | `/api/records/:id` | 单条记录,含参数、意见与摘要 |
|
| `GET` | `/api/records/:id` | 单条记录,含参数、意见与摘要 |
|
||||||
| `POST` | `/api/records/:id/summarise` | 重新排队生成摘要 |
|
| `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。
|
**审查范围**: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`**
|
**Webhook 投递失败,提示 `webhook can only call allowed HTTP servers`**
|
||||||
Gitea 拦截了内网地址。按上文给 `[webhook] ALLOWED_HOST_LIST` 加上本服务地址,重启 Gitea。
|
Gitea 拦截了内网地址。按上文给 `[webhook] ALLOWED_HOST_LIST` 加上本服务地址,重启 Gitea。
|
||||||
|
|
||||||
|
|||||||
@@ -120,6 +120,37 @@ export class GiteaClient {
|
|||||||
return out;
|
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. */
|
/** Create a branch, optionally from another branch/tag/commit. */
|
||||||
async createBranch(owner, repo, { newBranch, fromBranch }) {
|
async createBranch(owner, repo, { newBranch, fromBranch }) {
|
||||||
const payload = { new_branch_name: newBranch };
|
const payload = { new_branch_name: newBranch };
|
||||||
|
|||||||
+19
-3
@@ -67,6 +67,20 @@ export function branchMatches(refName, checkBranches) {
|
|||||||
return list.some((b) => b === short || b === refName);
|
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) {
|
function badge(comment) {
|
||||||
const parts = [comment.category, comment.severity].filter(Boolean);
|
const parts = [comment.category, comment.severity].filter(Boolean);
|
||||||
return parts.length ? `[${parts.join(" · ")}] ` : "";
|
return parts.length ? `[${parts.join(" · ")}] ` : "";
|
||||||
@@ -188,9 +202,11 @@ async function resolveRange(runner, { repo, job, workspace, token }) {
|
|||||||
if (job.from_sha) {
|
if (job.from_sha) {
|
||||||
fromSha = await runner.revParse(workspace, job.from_sha);
|
fromSha = await runner.revParse(workspace, job.from_sha);
|
||||||
}
|
}
|
||||||
// The review base is the managed branch: every checked branch is compared
|
// The review base is whatever this change would merge into: for a pull
|
||||||
// against what it would merge into, not against its own previous commit.
|
// request that is the PR's own target branch, and only a bare push falls
|
||||||
const baseBranch = job.managed_branch || job.base_ref;
|
// 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) {
|
if (!fromSha && baseBranch) {
|
||||||
const baseSha = await runner.revParse(workspace, `refs/remotes/origin/${baseBranch}`);
|
const baseSha = await runner.revParse(workspace, `refs/remotes/origin/${baseBranch}`);
|
||||||
if (baseSha) fromSha = await runner.mergeBase(workspace, baseSha, toSha) ?? baseSha;
|
if (baseSha) fromSha = await runner.mergeBase(workspace, baseSha, toSha) ?? baseSha;
|
||||||
|
|||||||
+95
-7
@@ -15,7 +15,7 @@ import {
|
|||||||
import { GiteaClient } from "./lib/gitea.js";
|
import { GiteaClient } from "./lib/gitea.js";
|
||||||
import { OcrRunner } from "./lib/ocr.js";
|
import { OcrRunner } from "./lib/ocr.js";
|
||||||
import { JobQueue } from "./lib/queue.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 APP_DIR = resolve(fileURLToPath(new URL(".", import.meta.url)));
|
||||||
const ROOT_DIR = resolve(APP_DIR, "..");
|
const ROOT_DIR = resolve(APP_DIR, "..");
|
||||||
@@ -41,6 +41,7 @@ function readConfig() {
|
|||||||
giteaUrl: process.env.CR_GITEA_URL || "http://127.0.0.1:80",
|
giteaUrl: process.env.CR_GITEA_URL || "http://127.0.0.1:80",
|
||||||
giteaToken: process.env.CR_GITEA_TOKEN || "",
|
giteaToken: process.env.CR_GITEA_TOKEN || "",
|
||||||
webhookSecret: process.env.CR_WEBHOOK_SECRET || "",
|
webhookSecret: process.env.CR_WEBHOOK_SECRET || "",
|
||||||
|
webhookUrl: process.env.CR_WEBHOOK_URL || "",
|
||||||
adminToken: process.env.CR_ADMIN_TOKEN || "",
|
adminToken: process.env.CR_ADMIN_TOKEN || "",
|
||||||
llmUrl: process.env.CR_LLM_URL || process.env.OCR_LLM_URL || "",
|
llmUrl: process.env.CR_LLM_URL || process.env.OCR_LLM_URL || "",
|
||||||
llmToken: process.env.CR_LLM_TOKEN || process.env.OCR_LLM_TOKEN || "",
|
llmToken: process.env.CR_LLM_TOKEN || process.env.OCR_LLM_TOKEN || "",
|
||||||
@@ -69,6 +70,7 @@ function effectiveConfig() {
|
|||||||
giteaUrl: saved.giteaUrl || CONFIG.giteaUrl,
|
giteaUrl: saved.giteaUrl || CONFIG.giteaUrl,
|
||||||
giteaToken: saved.giteaToken || CONFIG.giteaToken,
|
giteaToken: saved.giteaToken || CONFIG.giteaToken,
|
||||||
webhookSecret: saved.webhookSecret ?? CONFIG.webhookSecret,
|
webhookSecret: saved.webhookSecret ?? CONFIG.webhookSecret,
|
||||||
|
webhookUrl: saved.webhookUrl || CONFIG.webhookUrl,
|
||||||
adminToken: saved.adminToken ?? CONFIG.adminToken,
|
adminToken: saved.adminToken ?? CONFIG.adminToken,
|
||||||
llmUrl: saved.llmUrl || CONFIG.llmUrl,
|
llmUrl: saved.llmUrl || CONFIG.llmUrl,
|
||||||
llmToken: saved.llmToken || CONFIG.llmToken,
|
llmToken: saved.llmToken || CONFIG.llmToken,
|
||||||
@@ -131,6 +133,20 @@ function verifySignature(secret, rawBody, signature) {
|
|||||||
return a.length === b.length && timingSafeEqual(a, b);
|
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) {
|
function safeParse(value, fallback) {
|
||||||
try { return JSON.parse(value || "null") ?? fallback; } catch { return fallback; }
|
try { return JSON.parse(value || "null") ?? fallback; } catch { return fallback; }
|
||||||
}
|
}
|
||||||
@@ -178,6 +194,9 @@ async function handlePush(payload, cfg) {
|
|||||||
if (scoped && !pr) {
|
if (scoped && !pr) {
|
||||||
return { queued: 0, reason: "review_scope=pr and no open pull request" };
|
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)) {
|
if (findJobBySha(db, repo.id, toSha)) {
|
||||||
return { queued: 0, reason: `commit ${toSha.slice(0, 10)} already queued or running` };
|
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,
|
repoId: repo.id,
|
||||||
trigger: "push",
|
trigger: "push",
|
||||||
refName,
|
refName,
|
||||||
baseRef: pr?.base?.ref ?? repo.managed_branch,
|
baseRef,
|
||||||
fromSha: before,
|
fromSha: before,
|
||||||
toSha,
|
toSha,
|
||||||
prNumber: pr?.number ?? null,
|
prNumber: pr?.number ?? null,
|
||||||
@@ -212,8 +231,11 @@ async function handlePullRequest(payload, cfg) {
|
|||||||
const pr = payload.pull_request;
|
const pr = payload.pull_request;
|
||||||
if (!pr) return { queued: 0, reason: "no pull_request in payload" };
|
if (!pr) return { queued: 0, reason: "no pull_request in payload" };
|
||||||
if (pr.draft) return { queued: 0, reason: "draft pull request" };
|
if (pr.draft) return { queued: 0, reason: "draft pull request" };
|
||||||
if (!branchMatches(pr.head?.ref, repo.check_branches)) {
|
if (!pullRequestMatches(pr.head?.ref, pr.base?.ref, repo.check_branches)) {
|
||||||
return { queued: 0, reason: `head branch ${pr.head?.ref} is not in the checked branch list` };
|
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;
|
const toSha = pr.head?.sha;
|
||||||
if (!toSha) return { queued: 0, reason: "no 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,
|
llmModel: cfg.llmModel,
|
||||||
llmProtocol: cfg.llmProtocol,
|
llmProtocol: cfg.llmProtocol,
|
||||||
rulePath: cfg.rulePath,
|
rulePath: cfg.rulePath,
|
||||||
|
webhookUrl: webhookUrlFor(cfg),
|
||||||
giteaTokenSet: Boolean(cfg.giteaToken),
|
giteaTokenSet: Boolean(cfg.giteaToken),
|
||||||
llmTokenSet: Boolean(cfg.llmToken),
|
llmTokenSet: Boolean(cfg.llmToken),
|
||||||
raw: Object.fromEntries(Object.entries(saved).filter(([k]) => !SECRET_SETTING_KEYS.has(k))),
|
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") {
|
if (path === "/settings" && req.method === "PUT") {
|
||||||
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
||||||
const allowed = [
|
const allowed = [
|
||||||
"giteaUrl", "giteaToken", "webhookSecret", "adminToken", "llmUrl", "llmToken",
|
"giteaUrl", "giteaToken", "webhookSecret", "adminToken", "webhookUrl",
|
||||||
"llmModel", "llmProtocol", "llmAuthHeader", "llmExtraHeaders", "rulePath",
|
"llmUrl", "llmToken", "llmModel", "llmProtocol", "llmAuthHeader",
|
||||||
|
"llmExtraHeaders", "rulePath",
|
||||||
];
|
];
|
||||||
for (const key of allowed) {
|
for (const key of allowed) {
|
||||||
if (body[key] !== undefined) setSetting(db, key, body[key]);
|
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)),
|
create_issue: body.create_issue === undefined ? 1 : Number(Boolean(body.create_issue)),
|
||||||
auto_merge: Number(Boolean(body.auto_merge)),
|
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. */
|
/** 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. */
|
/** List branches for a repository that is not saved yet. */
|
||||||
if (path === "/repos/branches" && req.method === "POST") {
|
if (path === "/repos/branches" && req.method === "POST") {
|
||||||
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
const body = JSON.parse((await readBody(req)).toString("utf8") || "{}");
|
||||||
|
|||||||
+48
-6
@@ -150,6 +150,15 @@ const REPO_FIELDS = [
|
|||||||
|
|
||||||
async function loadRepos() {
|
async function loadRepos() {
|
||||||
state.repos = await api("/repos");
|
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");
|
const tbody = document.querySelector("#repos-table tbody");
|
||||||
if (state.repos.length === 0) {
|
if (state.repos.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="8" class="empty">还没有配置仓库。点「从 Gitea 导入」批量选择,或「新增仓库」手动填 Git 地址。</td></tr>';
|
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><code>${esc(r.managed_branch)}</code></td>
|
||||||
<td>${branchTags(r.check_branches, r.managed_branch)}</td>
|
<td>${branchTags(r.check_branches, r.managed_branch)}</td>
|
||||||
<td>${esc(scopeLabel(r.review_scope))}</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.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>${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>
|
<td><code>${esc(r.block_severity || "—")}</code></td>
|
||||||
@@ -167,6 +177,7 @@ async function loadRepos() {
|
|||||||
<button data-edit="${r.id}">编辑</button>
|
<button data-edit="${r.id}">编辑</button>
|
||||||
<button data-run="${r.id}">立即审查</button>
|
<button data-run="${r.id}">立即审查</button>
|
||||||
<button data-records="${r.id}">历史</button>
|
<button data-records="${r.id}">历史</button>
|
||||||
|
<button data-hook="${r.id}">修复 Webhook</button>
|
||||||
<button class="danger" data-del="${r.id}">删除</button>
|
<button class="danger" data-del="${r.id}">删除</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>`).join("");
|
</tr>`).join("");
|
||||||
@@ -174,6 +185,12 @@ async function loadRepos() {
|
|||||||
syncRecordFilter();
|
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) {
|
function shortUrl(url) {
|
||||||
return String(url || "").replace(/^https?:\/\//, "");
|
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('#record-filter').value = String(target.dataset.records);
|
||||||
document.querySelector('.tab[data-tab="records"]').click();
|
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) {
|
} catch (err) {
|
||||||
handleError(err);
|
handleError(err);
|
||||||
}
|
}
|
||||||
@@ -347,10 +377,21 @@ form.addEventListener("submit", async (ev) => {
|
|||||||
else if (input.value !== "") data[field] = input.value;
|
else if (input.value !== "") data[field] = input.value;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
if (data.id) await api(`/repos/${data.id}`, { method: "PATCH", body: data });
|
if (data.id) {
|
||||||
else await api("/repos", { method: "POST", body: data });
|
await api(`/repos/${data.id}`, { method: "PATCH", body: data });
|
||||||
modal.classList.add("hidden");
|
modal.classList.add("hidden");
|
||||||
setBanner("");
|
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();
|
await loadRepos();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err.status === 401) return handleError(err);
|
if (err.status === 401) return handleError(err);
|
||||||
@@ -693,8 +734,9 @@ document.querySelector("#jobs-table").addEventListener("click", async (ev) => {
|
|||||||
/* -------------------------------- settings -------------------------------- */
|
/* -------------------------------- settings -------------------------------- */
|
||||||
|
|
||||||
const SETTINGS_FIELDS = [
|
const SETTINGS_FIELDS = [
|
||||||
"giteaUrl", "giteaToken", "webhookSecret", "adminToken", "llmUrl", "llmToken",
|
"giteaUrl", "giteaToken", "webhookSecret", "adminToken", "webhookUrl",
|
||||||
"llmModel", "llmProtocol", "llmAuthHeader", "llmExtraHeaders", "rulePath",
|
"llmUrl", "llmToken", "llmModel", "llmProtocol", "llmAuthHeader",
|
||||||
|
"llmExtraHeaders", "rulePath",
|
||||||
];
|
];
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<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>Webhook</th><th>启用</th><th>自动合并</th><th>阻断级别</th><th></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody></tbody>
|
<tbody></tbody>
|
||||||
@@ -80,7 +80,8 @@
|
|||||||
<label>管理员 Token <input name="giteaToken" type="password" placeholder="留空表示不修改" /></label>
|
<label>管理员 Token <input name="giteaToken" type="password" placeholder="留空表示不修改" /></label>
|
||||||
<label>Webhook 密钥 <input name="webhookSecret" type="password" placeholder="留空表示不修改" /></label>
|
<label>Webhook 密钥 <input name="webhookSecret" type="password" placeholder="留空表示不修改" /></label>
|
||||||
<label>后台访问 Token <input name="adminToken" 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>
|
||||||
<fieldset>
|
<fieldset>
|
||||||
<legend>LLM(代码审查与历史摘要共用)</legend>
|
<legend>LLM(代码审查与历史摘要共用)</legend>
|
||||||
@@ -184,7 +185,7 @@
|
|||||||
<button type="button" id="branch-create">克隆创建</button>
|
<button type="button" id="branch-create">克隆创建</button>
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<p class="hint">所有检查通过后只合并到这一个分支。</p>
|
<p class="hint">所有检查通过后只合并到这一个分支;它也会出现在下面的检查分支里。</p>
|
||||||
<label>检查分支(可多选)
|
<label>检查分支(可多选)
|
||||||
<span class="inline">
|
<span class="inline">
|
||||||
<input name="check_branches" placeholder="main,release/1.0" />
|
<input name="check_branches" placeholder="main,release/1.0" />
|
||||||
@@ -192,7 +193,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
<div id="branch-list" class="branch-list"></div>
|
<div id="branch-list" class="branch-list"></div>
|
||||||
<p class="hint">这些分支都会被监控审查;只有指向管理分支的 PR 才会自动合并。</p>
|
<p class="hint">填「被保护的目标分支」。任何合并进这些分支的 PR 都会审查(功能分支不用列出来);向这些分支推送也会审查。只有指向管理分支的 PR 才会自动合并。</p>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
|
|
||||||
<fieldset>
|
<fieldset>
|
||||||
|
|||||||
Reference in New Issue
Block a user