fix(adopt): 接入器不再自动合并 —— 失败/取消/未运行/未知/超时/未批准一律不请求合并

原来的 add_pointer 是这样干的:建完 PR → 最多等 180 秒状态离开 pending → **无条件调合并接口**。
也就是说:检查失败、被取消、从来没有检查、状态读不到、等超时 —— 五种情况都会合并。
这等于把"审批人"变成了这个脚本,而脚本没有资格替人拍板。

改法:合并变成独立动作。
- add_pointer 建完 PR 就把状态如实打出来(有界等待 12×5s)然后**停下**,不合并。
- 新增 --merge-pr <n>:先取 PR 头提交与检查状态,再过 merge_decision() 判定,
  只有"显式批准 + 头提交没变 + 状态 success"三条同时成立才发出合并请求,随后读回
  (Gitea 合并成功可能返回空 body,读回才是权威)。
- merge_decision() 是纯函数,--self-test 逐条证明:失败 / 取消 / 未运行 / 未知 / pending /
  头提交变了 / 没批准 —— 七种情形全部拒绝合并(8 个用例,实测全过)。

顺带修口径:原来打印的"(直推也拦得住:gate 在 CI 里)"是错的 ——
CI 的 gate 是推完再跑的,它拦不住那次推送本身。要拦推送只有两条路:本机 hook 或开分支保护。
文档里 06-接入既有工程.md 的同一句话一并改掉。

验证:python3 scripts/adopt-existing.py --self-test 通过;
DRY-RUN 对 Weknora/knowledge-governance 实跑,如实输出"合并:**不自动做**",
体检结果与实测一致(无分支保护、context build / build (push)、仓库级 runner)。
This commit is contained in:
姚章浩
2026-10-12 00:12:06 +08:00
parent 7f8698c16a
commit 1e71887b7e
Executable → Regular
+124 -20
View File
@@ -1,12 +1,19 @@
#!/usr/bin/env python3
"""把一个**既有** Gitea 工程接入工程规范(正本 §8 的可执行版)。
python3 adopt-existing.py <owner/repo> [--apply] [--with-protection]
python3 adopt-existing.py <owner/repo> # DRY-RUN:只体检 + 打印计划
python3 adopt-existing.py <owner/repo> --apply # 执行(加指针 = 分支 + PR,**不合并**)
python3 adopt-existing.py <owner/repo> --apply --with-protection
python3 adopt-existing.py <owner/repo> --merge-pr <n> # 显式批准后合并某个 PR
默认**只体检 + 打印计划,不动任何东西**。加 --apply 才真的改。
**合并是独立动作**:本工具建完 PR 就**停下**,把状态如实打给你,等你批准。
不自动合并、不自动审批的理由是它自己的教训:
① 检查失败 / 取消 / 从未运行 / 状态拿不到(文件缺失、超时、未知)时合并,等于把没验过的东西合进 main;
② "等一会儿就合"把**审批人**变成了这个脚本 —— 而脚本没有资格替人拍板。
判据见 `merge_decision()`(纯函数,`--self-test` 直接打它)。
**分支保护默认不开**(原稿口径:所有改动直接进 main,正本 §1.3)。
只有加 --with-protection 才动它。
只有加 `--with-protection` 才动它。
为什么开保护要单独一个开关、而且还得分两步入(体检 / 执行):
"强制状态检查"开错了会把 PR **永久锁死** —— 检查永远没人报,PR 永远合不了。
@@ -233,11 +240,77 @@ def run_git(*args, cwd=None, stdin=None):
return subprocess.run(["git", *args], cwd=cwd, input=stdin, capture_output=True, text=True)
def head_status(g: Gitea, repo: str, head: str, tries: int = 12, interval: int = 5):
"""**有界**轮询 PR 头提交的检查状态。
返回 (state, contexts, waited)。state ∈ success / failure / pending / none / unknown。
没有 CI 的仓会一直 pending ⇒ 必须设上限,否则整批预算被一个仓耗光(正本 §2.6)。
拿不到状态时返回 none/unknown —— 那两种都**不允许合并**。
"""
state, ctxs = None, []
for i in range(tries):
st, s = g.api(f"/repos/{repo}/commits/{head}/status")
if st == 200:
state = D(s).get("state")
ctxs = sorted({c for c in (D(x).get("context") for x in L(D(s).get("statuses"))) if c})
if state and state != "pending":
return state, ctxs, i * interval
time.sleep(interval)
return (state or "unknown"), ctxs, tries * interval
def merge_decision(state, head_matches: bool, explicitly_approved: bool):
"""能不能**请求**合并?纯函数(`--self-test` 直接打它)。
默认拒绝,只有"显式批准 + 头提交没变 + 检查 success"三条同时成立才放行。
检查失败 / 取消 / 从未运行 / 拿不到状态 / 超时,一律不请求合并。
"""
if not explicitly_approved:
return False, ("没有显式批准:合并是独立动作,要用 --merge-pr <n> 明确指定"
"(本工具不替人拍板,也不自动审批)")
if not head_matches:
return False, "PR 头提交与会话记录的那个提交不一致(期间有人推了新提交)—— 不合并"
if state == "success":
return True, "检查 success + 显式批准"
if state == "pending":
return False, f"检查仍是 {state}(可能在等一个不会出现的检查,或还在跑)—— 不合并"
if state in (None, "", "unknown", "none"):
return False, f"拿不到检查状态({state})—— 缺失/未知都不合并"
return False, f"检查状态是 {state}(失败或被取消)—— 不合并"
def self_test() -> int:
"""反向自检:每一条"不许合并"的分支都要真的拦下来。"""
cases = [
("全绿 + 已批准 + 头一致", ("success", True, True), True),
("全绿但**没有**批准", ("success", True, False), False),
("失败 + 已批准", ("failure", True, True), False),
("被取消 + 已批准", ("cancelled", True, True), False),
("仍在跑 + 已批准", ("pending", True, True), False),
("拿不到状态 + 已批准", ("none", True, True), False),
("状态未知 + 已批准", (None, True, True), False),
("头提交变了 + 已批准", ("success", False, True), False),
]
problems = []
for name, (state, head_ok, approved), want in cases:
got, why = merge_decision(state, head_ok, approved)
if got != want:
problems.append(f"{name}:期望 {'合并' if want else '拒绝'},实际 {'合并' if got else '拒绝'}({why})")
if problems:
print("❌ 合并判定自检失败:", file=sys.stderr)
for p in problems:
print(f" · {p}", file=sys.stderr)
return 1
print(f"✅ 合并判定自检通过:{len(cases)} 个分支"
f"(失败/取消/未运行/未知/超时/未批准/头提交变了 —— 全部拒绝合并)")
return 0
def add_pointer(g: Gitea, repo: str, rep: dict, apply: bool):
if rep["has_agents"] or rep["has_hermes_md"]:
return "已有上下文文件,跳过"
if not apply:
return "计划:走分支 + PR 新增 AGENTS.md(不推 main)"
return "计划:走分支 + PR 新增 AGENTS.md(不推 main,**建完 PR 即停,等你批准合并**)"
tmp = tempfile.mkdtemp(prefix="adopt-")
d = os.path.join(tmp, repo.split("/")[1])
br = "docs/agents-pointer"
@@ -259,24 +332,42 @@ def add_pointer(g: Gitea, repo: str, rep: dict, apply: bool):
st, pr = g.api(f"/repos/{repo}/pulls", "POST",
{"title": "docs(agents): 加 AGENTS.md 操作手册与规范指针",
"head": br, "base": rep["default_branch"],
"body": f"指向系统级规范 `{STANDARDS_REPO}`,记录本仓接入时核实的现状。未改动任何代码。"})
"body": f"指向系统级规范 `{STANDARDS_REPO}`,记录本仓接入时核实的现状。未改动任何代码。\n\n"
f"⚠️ 本 PR **未合并**:合并由人批准后单独执行。\n"
f"命令:`python3 ~/eng-standards/scripts/adopt-existing.py {repo} --merge-pr <n>`"})
if st not in (200, 201):
return f"❌ 建 PR 失败 HTTP {st}"
prn = D(pr).get("number")
for _ in range(30): # 有 CI 的仓要等状态;没有的立刻退出等待
_, s = g.api(f"/repos/{repo}/commits/{head}/status")
if D(s).get("state") not in ("pending", None):
break
time.sleep(6)
g.api(f"/repos/{repo}/pulls/{prn}/merge", "POST",
{"Do": "merge", "head_commit_id": head, "delete_branch_after_merge": True})
_, back = g.api(f"/repos/{repo}/pulls/{prn}") # 读回;不信 POST 的返回
merged = bool(D(back).get("merged"))
return f"PR #{prn} → 合并={merged}(读回 merge_commit_sha={str(D(back).get('merge_commit_sha') or '')[:12]})"
state, ctxs, waited = head_status(g, repo, head)
return (f"已建 **PR #{prn}**(头提交 {head[:12]})—— 检查状态 {state}"
f"{',context ' + str(ctxs) if ctxs else ''}(等了 {waited}s 上限);"
f"**未合并**,等你在 Gitea 上看过并批准后跑 --merge-pr {prn}")
finally:
shutil.rmtree(tmp, ignore_errors=True)
def merge_pr(g: Gitea, repo: str, rep: dict, prn: str, watch_head: str = ""):
"""显式批准的合并:先核对状态,再合并,再读回。"""
st, pr = g.api(f"/repos/{repo}/pulls/{prn}")
if st != 200:
return f"❌ 取不到 PR #{prn}(HTTP {st})"
pr = D(pr)
head = D(pr.get("head")).get("sha") or ""
state, ctxs, waited = head_status(g, repo, head)
ok, why = merge_decision(state, head_matches=True, explicitly_approved=True)
print(f" PR #{prn} 头提交 {head[:12]}|状态 {state}|context {ctxs or '(无)'}|等了 {waited}s")
if not ok:
return f"⛔ **不请求合并**:{why}"
g.api(f"/repos/{repo}/pulls/{prn}/merge", "POST",
{"Do": "merge", "head_commit_id": head, "delete_branch_after_merge": True})
_, back = g.api(f"/repos/{repo}/pulls/{prn}") # 读回;不信 POST 的返回(成功时可能是空 body)
back = D(back)
merged = bool(back.get("merged"))
return (f"合并请求已发出 → 读回 merged={merged} "
f"state={back.get('state')} merge_commit_sha={str(back.get('merge_commit_sha') or '')[:12]}"
+ ("" if merged else " ⚠️ 读回显示没合并:**不要重试**,先看 PR 页面"))
def protection_payload(rep, allow, ctxs):
return {"branch_name": rep["default_branch"], "enable_push": False, "enable_force_push": False,
"required_approvals": 0, "block_on_outdated_branch": True, "block_on_rejected_reviews": True,
@@ -302,17 +393,27 @@ def set_protection(g: Gitea, repo: str, rep: dict, apply: bool, allow: bool, ctx
def main():
args = [a for a in sys.argv[1:] if not a.startswith("--")]
if "--self-test" in sys.argv:
sys.exit(self_test())
if not args:
sys.exit(__doc__)
repo = args[0]
apply = "--apply" in sys.argv
with_protection = "--with-protection" in sys.argv
merge_index = sys.argv.index("--merge-pr") if "--merge-pr" in sys.argv else -1
merge_n = sys.argv[merge_index + 1] if merge_index >= 0 and merge_index + 1 < len(sys.argv) else ""
g = Gitea()
print(f"Gitea: {g.base} 仓库: {repo} 模式: "
f"{'APPLY(会改)' if apply else 'DRY-RUN(只体检,不动任何东西)'}\n")
f"{'MERGE-PR(只合并,显式批准)' if merge_n else ('APPLY(会改)' if apply else 'DRY-RUN(只体检,不动任何东西)')}\n")
rep = preflight(g, repo)
if merge_n:
print("── 合并(显式批准模式)──")
print(" " + merge_pr(g, repo, rep, merge_n))
print("\n ⚠️ 合并后仍要读回:state=closed + merged=true + merge_commit_sha 非空(上面已打印)。")
return
print("── 体检 ──")
print(f" 默认分支 : {rep['default_branch']}")
print(f" 工作流 : {rep['workflows'] or '(无)'}")
@@ -334,6 +435,7 @@ def main():
print("\n── 计划 ──")
print(" 1. AGENTS.md : " + add_pointer(g, repo, rep, False))
print(" 2. 分支保护 : " + set_protection(g, repo, rep, False, allow, ctxs, with_protection))
print(" 3. 合并 : **不自动做**。批准后跑 --merge-pr <n>(状态不 success 时它会拒绝合并)")
if not apply:
print("\n(DRY-RUN:什么都没改。确认计划无误后加 --apply 重跑)")
@@ -347,15 +449,17 @@ def main():
d = D(b)
print(f" 读回: {d.get('branch_name')} enable_push={d.get('enable_push')} "
f"status_check={d.get('enable_status_check')} ctx={d.get('status_check_contexts')}")
print("\n ⚠️ 还差一步反向自检(必须做,别跳):")
print("\n ⚠️ 还差两步(必须做,别跳):")
print(" ① 你去看那个 PR 并批准,然后:")
print(f" python3 ~/eng-standards/scripts/adopt-existing.py {repo} --merge-pr <n>")
print(" ② 反向自检:推一个故意违规的提交 → 本机 hook 必须红、CI 的 gate 必须红。")
print(" ⚠️ CI 的 gate 是**推完再跑**的:它拦不住那次推送本身(要拦推送只有两条路:")
print(" 本机 hook,或开分支保护禁直推)。别把「CI 会拦住直推」当成前提。")
if with_protection:
print(f" t=$(mktemp -d); git clone {g.base}/{repo}.git $t; cd $t \\")
print(" && git commit --allow-empty -m 'chore(probe): 不该进 main' \\")
print(f" && git push origin {rep['default_branch']}")
print(" 判据:必须被拒 —— Not allowed to push to protected branch")
else:
print(" 推一个故意违规的提交 → 本机 hook 必须红、CI 的 gate 必须红")
print(" (直推也拦得住:gate 在 CI 里,不是靠 PR 才有的)")
if __name__ == "__main__":