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
+199 -3
View File
@@ -14,6 +14,9 @@ import {
export const SUMMARY_MARKER = "<!-- gitea-codereview:summary -->";
export const COMMENT_MARKER = "<!-- gitea-codereview -->";
// Marks a pull request this service opened itself to promote a branch. Its
// webhook event is ignored because the same commits were already reviewed.
export const PROMOTION_MARKER = "<!-- gitea-codereview:promotion -->";
export const STATUS_CONTEXT = "code-review/ocr";
export class SkipJob extends Error {
@@ -645,14 +648,20 @@ export class ReviewEngine {
return created?.number ?? null;
}
/**
* Get the change into the next branch up the chain.
*
* When the reviewed ref has an open pull request we merge it. When it does
* not (a direct push to a checked branch) we open one automatically, so a
* push to `test` still flows into `prd` with a reviewable record.
*/
async maybeAutoMerge({
client, repo, job, prNumber, blocking, toSha, appendLog,
reviewIncomplete = false, statusState = "success",
}) {
if (!repo.auto_merge) return false;
if (!prNumber) {
appendLog("auto-merge skipped: no pull request for this branch");
return false;
return this.promoteBranch({ client, repo, job, toSha, appendLog, reviewIncomplete, statusState });
}
// A two-stage flow (feature -> test -> prd) legitimately merges into a
// checked branch that is not the managed branch, so the gate is "is the
@@ -706,7 +715,8 @@ export class ReviewEngine {
headCommitId: toSha,
mergeWhenChecksSucceed: repo.auto_merge_mode === "when_checks_succeed",
});
appendLog("auto-merge requested");
appendLog(`auto-merge requested for PR #${prNumber}`);
await this.closeRepoIssues({ client, repo, appendLog, reason: `PR #${prNumber} 已合并` });
return true;
} catch (err) {
appendLog(`auto-merge failed: ${err.message}`);
@@ -714,6 +724,192 @@ export class ReviewEngine {
}
}
/**
* Open a pull request from the pushed branch into the next branch in the
* chain and merge it, then close the repository's review issues.
*/
async promoteBranch({ client, repo, job, toSha, appendLog, reviewIncomplete, statusState }) {
if (reviewIncomplete) {
appendLog("auto-merge skipped: review coverage was incomplete");
return false;
}
if (statusState !== "success") {
appendLog(`auto-merge skipped: commit status is ${statusState}`);
return false;
}
if (!branchMatches(job.ref_name, repo.check_branches)) {
appendLog(`auto-merge skipped: ${job.ref_name} is not a checked branch`);
return false;
}
// Walk up the chain: test -> prd. The managed branch is the top, so a push
// to it has nowhere to go.
const managed = repo.managed_branch;
if (job.ref_name === managed) {
appendLog(`auto-merge skipped: ${job.ref_name} is the managed branch`);
return false;
}
const target = managed;
if (!branchMatches(target, repo.check_branches) && target !== managed) {
appendLog(`auto-merge skipped: ${target} is not a checked branch`);
return false;
}
let head;
let base;
try {
head = await client.getBranch(repo.owner, repo.name, job.ref_name);
base = await client.getBranch(repo.owner, repo.name, target);
} catch (err) {
appendLog(`auto-merge skipped: cannot resolve branches: ${err.message}`);
return false;
}
const headSha = head?.commit?.id;
const baseSha = base?.commit?.id;
if (!headSha || !baseSha) {
appendLog("auto-merge skipped: branch head unknown");
return false;
}
if (headSha === baseSha) {
appendLog(`auto-merge skipped: ${job.ref_name} already matches ${target}`);
return false;
}
// Only promote the exact commit that was reviewed.
if (headSha !== toSha) {
appendLog(`auto-merge skipped: ${job.ref_name} moved to ${headSha.slice(0, 10)} after the review`);
return false;
}
// Is there already an open PR for this promotion? Reuse it instead of
// opening a duplicate on every push.
let promotionPr = null;
try {
const open = await client.listPullRequests(repo.owner, repo.name, { state: "open", limit: 50 });
promotionPr = (open || []).find(
(p) => p.head?.ref === job.ref_name && p.base?.ref === target,
) ?? null;
} catch { /* fall through to creating one */ }
if (!promotionPr) {
try {
promotionPr = await client.createPullRequest(repo.owner, repo.name, {
title: `[OCR] ${job.ref_name} → ${target}`,
head: job.ref_name,
base: target,
body: [
PROMOTION_MARKER,
`由 gitea-codereview 自动创建。`,
"",
`- 来源分支:\`${job.ref_name}\``,
`- 目标分支:\`${target}\``,
`- 审查提交:\`${toSha}\``,
`- 代码审查已通过(job #${job.id})`,
].join("\n"),
});
appendLog(`opened promotion PR #${promotionPr?.number} (${job.ref_name} -> ${target})`);
} catch (err) {
appendLog(`auto-merge failed: cannot open promotion PR: ${err.message}`);
return false;
}
} else {
appendLog(`reusing open promotion PR #${promotionPr.number}`);
}
const index = promotionPr?.number;
if (!index) {
appendLog("auto-merge failed: promotion PR has no number");
return false;
}
// Gitea computes mergeability asynchronously and a large divergence can
// take a while, so poll with backoff instead of giving up after a few
// seconds. `null`/`undefined` means "not computed yet"; only an explicit
// false is a real conflict.
let pr = promotionPr;
let delayMs = 1000;
for (let attempt = 0; attempt < 8; attempt += 1) {
if (pr.mergeable === true || pr.mergeable === false) break;
await new Promise((r) => setTimeout(r, delayMs));
delayMs = Math.min(delayMs * 1.6, 8000);
pr = await client.getPullRequest(repo.owner, repo.name, index).catch(() => pr);
}
if (pr.mergeable === false) {
appendLog(`auto-merge skipped: promotion PR #${index} conflicts with ${target}`);
await client.createIssueComment(repo.owner, repo.name, index,
`自动合并失败:\`${job.ref_name}\` 与 \`${target}\` 存在冲突,需要人工解决后重新推送。\n\n` +
`审查提交:\`${toSha}\``).catch(() => {});
return false;
}
if (pr.mergeable === undefined || pr.mergeable === null) {
appendLog(`auto-merge skipped: promotion PR #${index} mergeability still unknown after polling`);
await client.createIssueComment(repo.owner, repo.name, index,
`自动合并暂缓:Gitea 尚未算出该 PR 是否可合并。稍后重新推送或在界面手动合并。\n\n` +
`审查提交:\`${toSha}\``).catch(() => {});
return false;
}
if (pr.head?.sha && pr.head.sha !== toSha) {
appendLog(`auto-merge skipped: promotion PR #${index} head moved`);
return false;
}
try {
// Promotion always merges (never squashes): squashing rewrites the
// promoted commits into a brand-new commit, so the two long-lived
// branches diverge a little more on every promotion and eventually
// conflict. A real merge keeps the shared ancestry, so the next
// promotion only carries the new commits.
await client.mergePullRequest(repo.owner, repo.name, index, {
style: "merge",
title: pr.title,
deleteBranch: false,
headCommitId: toSha,
mergeWhenChecksSucceed: repo.auto_merge_mode === "when_checks_succeed",
});
appendLog(`auto-merge requested for promotion PR #${index}`);
await this.closeRepoIssues({
client, repo, appendLog,
reason: `${job.ref_name} 已合并到 ${target}(PR #${index})`,
});
return true;
} catch (err) {
appendLog(`auto-merge failed: promotion PR #${index}: ${err.message}`);
return false;
}
}
/**
* Close every open review issue for this repository.
*
* Called whenever a merge lands: once code is on its way into the managed
* branch the previous round is over, and any remaining problem will be
* reported again by the next review.
*/
async closeRepoIssues({ client, repo, appendLog, reason }) {
let open = [];
try {
open = await client.get(`/api/v1/repos/${repo.owner}/${repo.name}/issues`, {
query: { state: "open", type: "issues", limit: 100 },
}) ?? [];
} catch (err) {
appendLog(`cannot list issues to close: ${err.message}`);
return 0;
}
const mine = open.filter((i) => String(i.body || "").includes(SUMMARY_MARKER));
let closed = 0;
for (const issue of mine) {
try {
await client.createIssueComment(repo.owner, repo.name, issue.number,
`已合并:${reason}。本轮问题视为结束,关闭该 Issue;如后续审查再发现问题会重新创建。`);
await client.updateIssue(repo.owner, repo.name, issue.number, { state: "closed" });
closed += 1;
} catch (err) {
appendLog(`cannot close issue #${issue.number}: ${err.message}`);
}
}
if (closed) appendLog(`closed ${closed} review issue(s) after merge`);
return closed;
}
async failJob(job, err) {
const message = err instanceof SkipJob
? `skipped: ${err.reason}`