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:
Executable → Regular
+124
-20
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user