feat: 打通 推送→审查→晋级→关闭 Issue 的完整闭环

直接推送也能晋级
  往受保护分支直接推代码(没有 PR)时,审查通过后自动开一个
  <分支> → <管理分支> 的 PR 并合并它,而不是放弃自动合并。
  已存在同类 open PR 时复用它,不重复创建。

晋级 PR 不再被重复审查
  自动创建的 PR 带 PROMOTION_MARKER,其 pull_request 事件直接跳过。
  同一批提交在 push 时已经审过,重复审会在合并完成后凭空造出 Issue
  (此前确实产生了这样一个幽灵 Issue)。

合并即关闭该仓库全部审查 Issue
  两条触发路径:
  - 服务自己完成合并后立即关闭
  - 人在 Gitea 手动合并时,pull_request closed + merged=true 事件触发关闭
  手动合并是「代码已落地」最可靠的信号,不再依赖 push 侧的 merge 提交探测。

晋级使用 merge 而非 squash
  squash 会把晋级提交重写成全新提交,两个长期分支每晋级一次就多分叉
  一点,最终必然冲突(已在 offerpai_h5 上复现 add/add 冲突)。
  merge 保留共同祖先,下次晋级只携带新提交。

合并可等待性
  Gitea 异步计算 mergeable,原先只轮询 5 秒就放弃。改为指数退避约 30 秒,
  并区分「尚未算出」(继续等)与「确实冲突」(放弃)。失败时往 PR 留评论
  说明原因,不再只写服务日志。

验证(真实 webhook):
  推送 test → 审查通过 → 自动开 PR #9 (test→prd) → merge 合并
  → pull_request merged 事件 → 关闭 0 个待处理 Issue
  prd 顶端前进为合并提交,test/prd 保持一致,无幽灵 Issue 产生
This commit is contained in:
2026-09-20 16:23:45 +08:00
parent ab509eee0d
commit a0b15fb2d9
6 changed files with 312 additions and 8 deletions
+59 -4
View File
@@ -15,7 +15,10 @@ import {
import { GiteaClient } from "./lib/gitea.js";
import { OcrRunner } from "./lib/ocr.js";
import { JobQueue } from "./lib/queue.js";
import { ReviewEngine, SkipJob, branchMatches, parseRepoUrl, pullRequestMatches } from "./lib/review.js";
import {
ReviewEngine, SkipJob, branchMatches, parseRepoUrl, pullRequestMatches,
PROMOTION_MARKER,
} from "./lib/review.js";
const APP_DIR = resolve(fileURLToPath(new URL(".", import.meta.url)));
const ROOT_DIR = resolve(APP_DIR, "..");
@@ -189,6 +192,33 @@ async function handlePush(payload, cfg) {
const toSha = payload.after || payload.head_commit?.id;
if (!toSha) return { queued: 0, reason: "no head commit in payload" };
// A merge that a human performed in Gitea also ends the current round: the
// code has landed, so the open review issues are stale.
if (refName === (repo.managed_branch || repo.base_branch) && payload.commits?.length) {
const workspace = join(CONFIG.dataDir, "workspaces", `${repo.owner}__${repo.name}`);
if (existsSync(join(workspace, ".git"))) {
try {
const runner = new OcrRunner({ command: cfg.ocrCommand });
const parents = await runner.parentCount(workspace, toSha);
if (parents > 1) {
const client = new GiteaClient({
baseUrl: cfg.giteaUrl,
token: repo.gitea_token || cfg.giteaToken,
});
const engine = new ReviewEngine({ db, config: cfg, logger });
const closed = await engine.closeRepoIssues({
client, repo, appendLog: () => {},
reason: `管理分支 ${refName} 收到合并提交 ${toSha.slice(0, 10)}`,
});
if (closed) logger.info(`closed ${closed} issue(s) after merge into ${refName}`);
return { queued: 0, reason: `merge detected on ${refName}; closed ${closed} issue(s)` };
}
} catch (err) {
logger.warn(`merge detection failed on ${owner}/${name}: ${err.message}`);
}
}
}
const scoped = repo.review_scope === "pr";
const pr = scoped ? null : await findOpenPullRequestForRef(cfg, repo, refName, toSha);
if (scoped && !pr) {
@@ -224,19 +254,44 @@ const PR_ACTIONS = new Set([
async function handlePullRequest(payload, cfg) {
const action = payload.action;
if (!PR_ACTIONS.has(action)) {
return { queued: 0, reason: `action ${action} ignored` };
}
const owner = payload.repository?.owner?.username || payload.repository?.owner?.login;
const name = payload.repository?.name;
const repo = owner && name ? findRepository(db, owner, name) : null;
if (!repo || !repo.enabled) return { queued: 0, reason: "repository not configured" };
// A merged pull request is the authoritative "code has landed" signal: the
// open review issues describe a state that no longer exists.
if (action === "closed" && payload.pull_request?.merged) {
const pr = payload.pull_request;
const client = new GiteaClient({
baseUrl: cfg.giteaUrl,
token: repo.gitea_token || cfg.giteaToken,
});
const engine = new ReviewEngine({ db, config: cfg, logger });
const closed = await engine.closeRepoIssues({
client, repo, appendLog: () => {},
reason: `PR #${pr.number} 已合并到 ${pr.base?.ref ?? "目标分支"}`,
});
logger.info(`PR #${pr.number} merged into ${pr.base?.ref}; closed ${closed} issue(s)`);
return { queued: 0, closedIssues: closed, reason: "pull request merged" };
}
if (!PR_ACTIONS.has(action)) {
return { queued: 0, reason: `action ${action} ignored` };
}
if (repo.review_scope === "push") {
return { queued: 0, reason: "review_scope=push; PRs reviewed via push events" };
}
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" };
// A promotion pull request this service opened itself: the same commits were
// already reviewed when they were pushed, so reviewing again would produce a
// duplicate issue for code that has already been merged.
if (String(pr.body || "").includes(PROMOTION_MARKER)) {
return { queued: 0, reason: "self-created promotion pull request; already reviewed on push" };
}
if (!pullRequestMatches(pr.head?.ref, pr.base?.ref, repo.check_branches)) {
return {
queued: 0,