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
+11 -1
View File
@@ -7,7 +7,7 @@ import { test } from "node:test";
import { rmSync } from "node:fs";
import { parseUnifiedDiff, addedLineNumbers, diffLineNumbers, pickAnchorLine } from "../app/lib/diff.js";
import { branchMatches, parseRepoUrl } from "../app/lib/review.js";
import { branchMatches, parseRepoUrl, pullRequestMatches } from "../app/lib/review.js";
import { severityAtLeast, parseList } from "../app/lib/ocr.js";
import { normalizeBaseUrl } from "../app/lib/gitea.js";
import {
@@ -100,6 +100,16 @@ test("branchMatches matches an explicit branch list, not globs", () => {
assert.ok(!branchMatches("release/2.0", "release/*"), "globs are no longer patterns");
});
test("pullRequestMatches keys on the target branch, not the feature branch", () => {
// The two-stage flow: feature -> test -> prd.
assert.ok(pullRequestMatches("fix/x", "test", "test,prd"), "PR into test");
assert.ok(pullRequestMatches("test", "prd", "test,prd"), "promotion PR into prd");
assert.ok(!pullRequestMatches("fix/x", "master", "test,prd"), "PR into an unprotected branch");
assert.ok(pullRequestMatches("any", "any", ""), "empty list reviews everything");
// Listing a long-lived branch still reviews PRs originating from it.
assert.ok(pullRequestMatches("test", "unprotected", "test,prd"), "head branch listed");
});
test("parseRepoUrl derives owner and name from every supported URL form", () => {
const cases = [
["http://192.168.31.51/kgod/myrepo.git", "kgod", "myrepo"],