Files
姚章浩 05c8395bc0 fix(adopt): 合并必需 context 只认明确策略,并核对 base/state/来源
必需 context 只能来自 --require-context 或仓库 .gitea/required-contexts.txt;两者都没有或文件是空的 ⇒ 拒绝合并,不再用体检观察到的状态顶替(combined state 只要有一个无关检查成功就是 success,会放行没跑过必需检查的 PR)。同时核对 PR 目标分支 == 默认分支、state=open、head 就是本仓。
2026-10-12 11:15:32 +08:00

682 lines
36 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""把一个**既有** Gitea 工程接入工程规范(正本 §8 的可执行版)。
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> --approve-sha <完整40位SHA> \
[--require-context "build / build (push)"] # 显式批准后合并
**合并是独立动作**:本工具建完 PR 就**停下**,把状态如实打给你,等你批准。
批准时必须给出**完整的 40 位 SHA**(短 sha 起不了身份作用),它会去核对:
① PR 头提交就是那个 SHA(期间没人再推);② 每个必需 status context 都是 success;
③ 汇总状态是 success。任一条不成立就不请求合并。
不自动合并、不自动审批的理由是它自己的教训:
① 检查失败 / 取消 / 从未运行 / 状态拿不到(文件缺失、超时、未知)时合并,等于把没验过的东西合进 main;
② "等一会儿就合"把**审批人**变成了这个脚本 —— 而脚本没有资格替人拍板。
判据见 `merge_decision()`(纯函数,`--self-test` 直接打它)。
**分支保护默认不开**(原稿口径:所有改动直接进 main,正本 §1.3)。
只有加 `--with-protection` 才动它。
为什么开保护要单独一个开关、而且还得分两步入(体检 / 执行):
"强制状态检查"开错了会把 PR **永久锁死** —— 检查永远没人报,PR 永远合不了。
所以必须先体检判定该仓的工作流**能不能覆盖 PR 头提交**,判定通过才允许开。
只依赖 Python 标准库 + git。凭据运行时从 ~/.git-credentials 读,**永不打印**。
"""
import base64
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
from pathlib import Path
HOSTS = ["http://192.168.50.223:3000", "https://gitea.zheromiku.top"]
STANDARDS_REPO = "zheromiku/eng-standards-main"
# 完整 SHA:批准与身份核对都只认 40 位十六进制 —— 短 sha 起不了身份作用(前缀撞车不报错)
FULL_SHA = re.compile(r"^[0-9a-f]{40}$")
def D(v):
"""JSON 对象,非 dict 一律当空。"""
return v if isinstance(v, dict) else {}
def L(v):
"""JSON 数组,非 list 一律当空。"""
return v if isinstance(v, list) else []
def load_token(netloc: str) -> str:
"""~/.git-credentials 里主机可能是 URL 编码的(`192.168.50.223%3a3000`),
所以先解码 netloc 再比 —— 按字面比会找不到凭据,而症状是"没有凭据"。"""
fp = Path.home() / ".git-credentials"
want = netloc.lower()
for line in fp.read_text(encoding="utf-8", errors="replace").splitlines():
m = re.match(r"^https?://([^:]+):([^@]+)@(.+)$", line)
if not m:
continue
got = urllib.parse.unquote(m.group(3)).strip().lower()
if got == want:
return urllib.parse.unquote(m.group(2))
return ""
def pick_host():
for base in HOSTS:
try:
with urllib.request.urlopen(f"{base}/api/v1/version", timeout=6) as x:
if x.status == 200:
return base
except Exception:
continue
return None
class Gitea:
def __init__(self):
self.base = pick_host() or ""
if not self.base:
sys.exit("❌ 连不上任何 Gitea 候选地址,先确认网络/代理")
self.host = urllib.parse.urlparse(self.base).netloc # 用于 git clone / 显示
self.tok = load_token(self.host)
if not self.tok:
sys.exit(f"❌ ~/.git-credentials 里没有 {self.host} 的凭据")
# 提交令牌走 URL 里的 basic auth 会进 shell 历史/进程表,所以一律用 credential helper
def api(self, path, method="GET", body=None):
"""返回 (status, value)。value 可能是 dict/list;空 body 归一化成 {}。
⚠️ 空 body 是 Gitea 合并**成功**的返回,不能当成失败(正本里写过的坑)。"""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{self.base}/api/v1{path}", data=data, method=method)
req.add_header("Authorization", "token " + self.tok)
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req, timeout=30) as x:
raw = x.read().decode()
if not raw.strip():
return x.status, {}
try:
return x.status, json.loads(raw)
except json.JSONDecodeError:
return x.status, {"_raw": raw[:200]}
except urllib.error.HTTPError as e:
return e.code, {"_error": e.read().decode()[:300]}
def file(self, repo, path, ref=None):
p = f"/repos/{repo}/contents/{urllib.parse.quote(path)}"
if ref:
p += f"?ref={urllib.parse.quote(ref)}"
st, b = self.api(p)
if st != 200:
return None
try:
return base64.b64decode(D(b).get("content", "")).decode("utf-8", "replace")
except Exception:
return ""
def branch(self, repo, name):
st, b = self.api(f"/repos/{repo}/branches/{urllib.parse.quote(name, safe='')}")
return D(D(b).get("commit")).get("id") if st == 200 else None
def workflow_triggers(text: str):
"""启发式解析 `on:` 块(不引 YAML:本仓不许引依赖)。"""
lines = text.splitlines()
start = next((i for i, l in enumerate(lines) if re.match(r"^on\s*:", l)), None)
if start is None:
return {"push_all": False, "push_any_branch": False, "pull_request": False}
block = []
for l in lines[start + 1:]:
if l.strip() and not l.startswith((" ", "\t", "#")):
break
block.append(l)
raw = "\n".join(block)
has_push = bool(re.search(r"^\s*push\s*:", raw, re.M))
# "推任意分支":push 块里没有 branches 过滤,或 branches 里写了 **
push_all = has_push and (not re.search(r"^\s{2,}branches\s*:", raw, re.M) or "**" in raw)
return {"push_all": push_all, "push_any_branch": push_all,
"pull_request": bool(re.search(r"^\s*pull_request\s*:", raw, re.M))}
def preflight(g: Gitea, repo: str):
rep = {}
st, r = g.api(f"/repos/{repo}")
if st != 200:
sys.exit(f"❌ 取不到仓库 {repo}:HTTP {st} {str(r)[:120]}")
rep["default_branch"] = D(r).get("default_branch") or "main"
rep["empty"] = bool(D(r).get("empty"))
st, lst = g.api(f"/repos/{repo}/contents/.gitea/workflows")
rep["workflows"] = [D(x).get("name", "") for x in L(lst)] if st == 200 else []
rep["triggers"] = {}
for wf in rep["workflows"]:
t = g.file(repo, f".gitea/workflows/{wf}")
if t is not None:
rep["triggers"][wf] = workflow_triggers(t)
st, rr = g.api(f"/repos/{repo}/actions/runners")
rep["runners"] = [D(x).get("name", "") for x in L(D(rr).get("runners"))] if st == 200 else []
rep["runners_scope"] = "仓库级" if rep["runners"] else ""
if not rep["runners"]: # aiball 的 runner 挂在组织上,只查仓库级会误报"无 runner"
st2, orr = g.api(f"/orgs/{repo.split('/')[0]}/actions/runners")
if st2 == 200:
rep["runners"] = [D(x).get("name", "") for x in L(D(orr).get("runners"))]
rep["runners_scope"] = "组织级" if rep["runners"] else ""
st, cs = g.api(f"/repos/{repo}/commits?limit=1")
sha = D(L(cs)[0]).get("sha") if st == 200 and L(cs) else None
rep["head"] = sha
rep["contexts"], rep["combined"] = [], None
if sha:
st, s = g.api(f"/repos/{repo}/commits/{sha}/status")
if st == 200:
rep["contexts"] = sorted({c for c in (D(x).get("context") for x in L(D(s).get("statuses"))) if c})
rep["combined"] = D(s).get("state")
rep["has_agents"] = g.file(repo, "AGENTS.md") is not None
rep["has_hermes_md"] = g.file(repo, ".hermes.md") is not None
st, bp = g.api(f"/repos/{repo}/branch_protections")
rep["protections"] = [(D(x).get("branch_name"), D(x).get("enable_status_check"),
D(x).get("status_check_contexts")) for x in L(bp)] if st == 200 else []
return rep
def verdict(rep):
"""能不能开"强制状态检查"?只有"推任意分支都会跑"的工作流,PR 头提交才拿得到状态。"""
why = []
for wf, t in rep["triggers"].items():
if t["push_all"]:
why.append(f"{wf}: on.push 覆盖所有分支 → PR 头提交带着 (push) 状态 ✅ 可用")
elif t["pull_request"]:
why.append(f"{wf}: push 只覆盖 main、但有 pull_request → PR 走 (pull_request) 上下文,"
f"与 (push) 不匹配,需人工确认后按 (pull_request) 开 ⛔ 本工具不自动开")
else:
why.append(f"{wf}: 只在 push[main] 触发且无 pull_request → PR 不产生任何状态 ⛔ 不可开")
allowed = any(t["push_all"] for t in rep["triggers"].values())
ctxs = [c for c in rep["contexts"] if c and c.endswith("(push)")] if allowed else []
return allowed and bool(ctxs), ctxs, why
def agents_md(repo: str, rep: dict):
tpl = Path(__file__).resolve().parent.parent / "templates" / "README.md"
m = re.search(r"^````markdown\n(.*?)^````", tpl.read_text(encoding="utf-8"), re.S | re.M)
if not m:
sys.exit("❌ templates/README.md 里找不到 ```markdown 骨架块")
name = repo.split("/")[1]
body = m.group(1).replace("<PROJECT>", name).replace("<ns>/<repo>", repo)
# 如实写:默认**不开**分支保护(正本 §1.3)。别把模板里的假设写进别人的仓库 ——
# 一行"已启用分支保护"在没开保护时就是假的,而下一个人会信它。
branch_line = (f"| 默认分支 | `{rep['default_branch']}` —— 已启用分支保护(禁直推 + 必须 PR) |"
if rep.get("protections")
else f"| 默认分支 | `{rep['default_branch']}` —— **直推**(默认不开分支保护,正本 §1.3) |")
# 用 .get() 而不是下标:缺 key 时抛 KeyError 会**掩盖**下面那条真正要报的错
# (实测:夹具少一个 key,于是"锚点坏了"的自检报的是 KeyError,等于没验)。
_wf = "、".join(rep.get("workflows") or []) or "(无)"
_ctx = "、".join(rep.get("contexts") or []) or "(无)"
_rn = "、".join(rep.get("runners") or []) or "(无本仓 runner —— 有工作流也会永远 queued)"
if rep.get("runners_scope"):
_rn += f"({rep['runners_scope']})"
facts = "\n".join([
"## 0b. 接入时核实的现状(事实变了就更新;别让它过期)", "",
"| 项 | 值 |", "|---|---|",
branch_line,
f"| CI | 工作流 {_wf};status context {_ctx} |",
f"| Runner | {_rn} |", "",
])
# ⚠️ 锚点必须真的存在。`str.replace` 找不到时**静默返回原文**,不会报错 ——
# 实测踩过:锚点写成 `## 2. 硬约束`(骨架里其实是 `## 1. 硬约束`),
# 于是这张现状表从来没被插进去,而工具一切正常地"成功"了。
# 所以这里显式断言锚点存在(正本 §3.2-3:断言校验器真的作用在目标上)。
anchor = "## 1. 硬约束"
if anchor not in body:
sys.exit(f"❌ AGENTS 骨架里找不到锚点 {anchor!r};现状表会**静默**插不进去,先修锚点再跑")
return body.replace(anchor, facts + "\n" + anchor, 1)
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 head_contexts(g: Gitea, repo: str, head: str) -> dict[str, str]:
"""头提交上**每个** status context 的状态(不是只看总的 combined state)。
为什么不能只看 combined:合并要核对的是"我要求的那几个检查都成功了",
而 combined 只给一个汇总值 —— 某个必需 context 根本没报时,combined 仍可能是 success。
"""
st, s = g.api(f"/repos/{repo}/commits/{head}/status")
if st != 200:
return {}
out = {}
for x in L(D(s).get("statuses")):
ctx, stt = D(x).get("context"), D(x).get("status")
if ctx:
out[ctx] = stt
return out
def merge_decision(state, head_matches: bool, explicitly_approved: bool,
approved_sha: str = "", missing_contexts: list[str] | None = None):
"""能不能**请求**合并?纯函数(`--self-test` 直接打它)。
默认拒绝。只有下面**全部**成立才放行:
· 显式批准,且批准的是一个**完整 40 位 SHA**(短 sha 起不了身份作用);
· PR 头提交 == 那个被批准的 SHA;
· 要求的每一个 status context 都在头提交上 **success**(缺一个都不行);
· 汇总状态是 success。
检查失败 / 取消 / 从未运行 / 拿不到状态 / 超时,一律不请求合并。
"""
if not explicitly_approved:
return False, ("没有显式批准:合并是独立动作,要用 --merge-pr <n> --approve-sha <完整SHA> 明确指定"
"(本工具不替人拍板,也不自动审批)")
if not FULL_SHA.match(approved_sha or ""):
return False, (f"没有给出获批的**完整 40 位** SHA(收到 {str(approved_sha)[:20]!r})—— "
f"短 sha / 空值都不能作为批准依据")
if not head_matches:
return False, "PR 头提交 ≠ 被批准的 SHA(期间有人推了新提交)—— 不合并"
if missing_contexts:
return False, f"这些必需 status context 没有在头提交上 success:{missing_contexts} —— 不合并"
if state == "success":
return True, "检查 success + 显式批准(完整 SHA 一致、必需 context 齐)"
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:
"""反向自检:每一条"不许合并"的分支都要真的拦下来。"""
good = "a" * 40
cases = [
("全绿 + 已批准(完整 SHA)+ 头一致 + context 齐",
("success", True, True, good, []), True),
("全绿但**没有**批准", ("success", True, False, good, []), False),
("已批准但 SHA 是空的(没给批准依据)", ("success", True, True, "", []), False),
("已批准但 SHA 只有 12 位(短 sha 不算批准依据)", ("success", True, True, good[:12], []), False),
("失败 + 已批准", ("failure", True, True, good, []), False),
("被取消 + 已批准", ("cancelled", True, True, good, []), False),
("仍在跑 + 已批准", ("pending", True, True, good, []), False),
("拿不到状态 + 已批准", ("none", True, True, good, []), False),
("状态未知 + 已批准", (None, True, True, good, []), False),
("头提交变了 + 已批准", ("success", False, True, good, []), False),
("必需 context 没 success + 已批准", ("success", True, True, good, ["build / build (push)"]), False),
]
problems = []
for name, (state, head_ok, approved, sha, missing), want in cases:
got, why = merge_decision(state, head_ok, approved, sha, missing)
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"(失败/取消/未运行/未知/超时/未批准/缺完整 SHA/头提交变了/必需 context 不齐 —— 全部拒绝合并)")
return 0
class _StubGitea:
"""假 Gitea:只实现 merge_pr / resolve_required_contexts 用到的那几个 API。
为什么要有它:`merge_decision()` 是纯函数,测它只能证明"判定对不对";真正要证明的是
**调用链上有没有发出那个 POST /merge**。所以这里把真实的 `merge_pr()` 跑一遍,
断言看的是 `self.calls` 里有没有 merge 请求 —— 一个真实请求都不发出去。
"""
def __init__(self, pr: dict, contexts: dict, policy=None):
self.calls: list[tuple[str, str]] = []
self.pr, self.contexts, self.policy = pr, contexts, policy
def api(self, path, method="GET", body=None):
self.calls.append((method, path))
if method == "POST":
# 真实的 Gitea 合并成功后 body 可能是空的;这里模拟"合并完成"
self.pr["merged"] = True
self.pr["state"] = "closed"
self.pr["merge_commit_sha"] = "e" * 40
return 200, {}
if path.endswith("/pulls/7"):
return 200, self.pr
if path.endswith("/status"):
# Gitea 的 combined state:**只要报过状态的都成功就是 success** ——
# 这正是"无关检查成功也算 success"的由来,所以不能只看 combined。
st = "success" if self.contexts and all(v == "success" for v in self.contexts.values()) else "failure"
return 200, {"state": st,
"statuses": [{"context": k, "status": v} for k, v in self.contexts.items()]}
return 404, {}
def file(self, repo, path, ref=None):
return self.policy
def self_test_chain() -> int:
"""真实调用链的负例:不同报告/状态组合下,**POST /merge 到底发没发**。"""
sha = "5f3a71cadee1f2b27c0b71d0c9fd1ea1b57a4b0f"
req = ["build / build (push)", "ci / gate (push)"]
base_pr = {"merged": False, "state": "open", "mergeable": True,
"head": {"sha": sha, "repo": {"full_name": "Weknora/knowledge-governance"}},
"base": {"ref": "main"}}
rep = {"default_branch": "main"}
allok = {"build / build (push)": "success", "ci / gate (push)": "success"}
cases = [
# 名字, PR, contexts, require_ctx, 仓库策略文件内容, 期望的 merge POST 次数
("正例:策略齐 + 必需 context 全 success + 完整 SHA + base/state 对 ⇒ 发 1 次",
base_pr, allok, req, None, 1),
("只有无关检查 success(必需 context 缺失)⇒ 0 次",
base_pr, {"unrelated / x (push)": "success"}, req, None, 0),
("必需 context 里有一个 failure ⇒ 0 次",
base_pr, {"build / build (push)": "success", "ci / gate (push)": "failure"}, req, None, 0),
("必需 context 是 pending ⇒ 0 次",
base_pr, {"build / build (push)": "success", "ci / gate (push)": "pending"}, req, None, 0),
("没有策略、也没有策略文件(require_ctx 为空)⇒ 0 次",
base_pr, allok, [], None, 0),
("策略文件是空的(全是注释)⇒ 0 次",
base_pr, allok, [], "# 还没有写\n\n", 0),
("策略文件只有无关 context ⇒ 0 次",
base_pr, allok, [], "unrelated / x (push)\n", 0),
("策略文件齐全(不传 --require-context)⇒ 发 1 次",
base_pr, allok, [], "build / build (push)\nci / gate (push)\n", 1),
("PR 目标分支不是默认分支 ⇒ 0 次",
{**base_pr, "base": {"ref": "release"}}, allok, req, None, 0),
("PR 不是 open(closed)⇒ 0 次",
{**base_pr, "state": "closed"}, allok, req, None, 0),
("PR 来自另一个仓库 ⇒ 0 次",
{**base_pr, "head": {"sha": sha, "repo": {"full_name": "someone/fork"}}}, allok, req, None, 0),
("头提交已经不是获批的 SHA ⇒ 0 次",
{**base_pr, "head": {"sha": "b" * 40, "repo": {"full_name": "Weknora/knowledge-governance"}}},
allok, req, None, 0),
("获批 SHA 只给了短 sha ⇒ 0 次", base_pr, allok, req, None, 0),
]
problems, lines = [], []
for i, (name, pr, contexts, explicit, policy, want) in enumerate(cases):
approve = sha if "短 sha" not in name else sha[:12]
g = _StubGitea(dict(pr), dict(contexts), policy)
ctxs, source, err = resolve_required_contexts(g, "Weknora/knowledge-governance", list(explicit))
if err:
result = f"⛔ 不请求合并({err[:40]}…)"
posts = 0
else:
result = merge_pr(g, "Weknora/knowledge-governance", rep, "7", approve, ctxs, source)
posts = len([c for c in g.calls if c[0] == "POST" and c[1].endswith("/merge")])
ok = posts == want and ("不请求合并" in result) == (want == 0)
lines.append(f"{'✅' if ok else '❌'} {name}:merge 请求 {posts} 次(期望 {want})|{result[:60]}")
if not ok:
problems.append(name)
print("── 合并调用链负例(假 Gitea,真实 merge_pr();断言 merge POST 发没发)──")
for line in lines:
print(" " + line)
if problems:
print(f"\n❌ {len(problems)} 个分支不符合预期:{problems}", file=sys.stderr)
return 1
print(f"\n✅ {len(cases)} 个分支全部符合预期:只有'策略齐 + 必需 context 全 success + "
f"完整获批 SHA + base/state/来源都对'才发出 merge 请求")
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,**建完 PR 即停,等你批准合并**)"
tmp = tempfile.mkdtemp(prefix="adopt-")
d = os.path.join(tmp, repo.split("/")[1])
br = "docs/agents-pointer"
try:
if run_git("clone", "-q", "--depth", "1", f"{g.base}/{repo}.git", d).returncode:
return "❌ clone 失败"
run_git("switch", "-q", "-c", br, cwd=d)
with open(os.path.join(d, "AGENTS.md"), "w", encoding="utf-8") as fh:
fh.write(agents_md(repo, rep))
run_git("add", "AGENTS.md", cwd=d)
msg = ("docs(agents): 加 AGENTS.md 操作手册与规范指针\n\n"
f"指向系统级规范({STANDARDS_REPO}),并写明本仓接入时核实的现状。\n"
"为什么用 AGENTS.md 而不是 .hermes.md:Codex / Claude Code / OpenCode 读的也是 AGENTS.md。\n")
if run_git("commit", "-q", "-F", "-", cwd=d, stdin=msg).returncode:
return "❌ commit 被提交闸门拦下(提交信息不合规)"
if run_git("push", "-q", "-u", "origin", br, cwd=d).returncode:
return "❌ push 失败"
head = run_git("rev-parse", "HEAD", cwd=d).stdout.strip()
st, pr = g.api(f"/repos/{repo}/pulls", "POST",
{"title": "docs(agents): 加 AGENTS.md 操作手册与规范指针",
"head": br, "base": rep["default_branch"],
"body": f"指向系统级规范 `{STANDARDS_REPO}`,记录本仓接入时核实的现状。未改动任何代码。\n\n"
f"⚠️ 本 PR **未合并**:合并由人批准后单独执行。\n"
f"命令:`python3 ~/eng-standards/scripts/adopt-existing.py {repo} --merge-pr <n> --approve-sha <完整SHA>`"})
if st not in (200, 201):
return f"❌ 建 PR 失败 HTTP {st}"
prn = D(pr).get("number")
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} --approve-sha <完整40位SHA>")
finally:
shutil.rmtree(tmp, ignore_errors=True)
CONTEXT_POLICY_PATH = ".gitea/required-contexts.txt"
def parse_context_policy(text: str) -> list[str]:
"""解析策略文件:一行一个 context,`#` 后是注释,空行忽略。"""
out = []
for line in (text or "").splitlines():
line = line.split("#", 1)[0].strip()
if line:
out.append(line)
return out
def resolve_required_contexts(g, repo: str, explicit: list[str]):
"""**"要求哪些检查"只能来自明确策略**。返回 (列表, 来源, 错误)。
不许用"体检时在该仓观察到的 context"来顶替:那等于把"要求什么"交给当时碰巧报过的状态,
只要有一个无关检查报过 success,就会把没有跑过必需检查的 PR 放过去(实测会漏)。
策略按优先级取:① --require-context 显式给定;② 仓库里的 .gitea/required-contexts.txt。
两个都没有 / 文件是空的 ⇒ 报错,不合并(空策略不等于"没有要求")。
"""
if explicit:
return list(explicit), "显式 --require-context", ""
text = g.file(repo, CONTEXT_POLICY_PATH)
if text is None:
return [], "", (f"没有任何明确策略:既没有 --require-context,仓库里也没有 "
f"{CONTEXT_POLICY_PATH} —— 没有'要求哪些检查'就不许合并"
f"(不能拿'观察到哪些状态'代替)")
ctxs = parse_context_policy(text)
if not ctxs:
return [], "", f"{CONTEXT_POLICY_PATH} 里没有任何 context(空文件/只有注释)—— 空策略不算'没有要求'"
return ctxs, f"仓库策略文件 {CONTEXT_POLICY_PATH}", ""
def merge_pr(g: Gitea, repo: str, rep: dict, prn: str, approved_sha: str, require_ctx: list[str],
ctx_source: str = "显式 --require-context") -> str:
"""显式批准的合并:核对"获批的完整 SHA + **明确策略要求的** status context",再合并,再读回。"""
if not FULL_SHA.match(approved_sha or ""):
return (f"⛔ **不请求合并**:--approve-sha 必须是**完整 40 位** SHA"
f"(收到 {str(approved_sha)[:20]!r})—— 短 sha 不能作为批准依据")
if not require_ctx:
return ("⛔ **不请求合并**:必需 context 列表为空 —— 没有'要求哪些检查'就不许合并。"
"(用 --require-context,或在仓库里放 .gitea/required-contexts.txt)")
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 ""
if bool(pr.get("merged")):
return f"PR #{prn} 已经是 merged 状态(merged=true)—— 不重复合并"
# 合到哪儿、PR 是什么状态、改动从哪来 —— 三个都要核对:
# 合错分支/合一个已经关掉的 PR/合一个跨仓 PR,都不是"合并获批的那个改动"。
base_ref = D(pr.get("base")).get("ref") or ""
default_branch = rep.get("default_branch") or ""
if base_ref != default_branch:
return (f"⛔ **不请求合并**:PR #{prn} 的目标分支是 {base_ref!r},"
f"不是默认分支 {default_branch!r}")
if str(pr.get("state") or "") != "open":
return f"⛔ **不请求合并**:PR #{prn} 的状态是 {pr.get('state')!r},不是 open"
head_repo = D(D(pr.get("head")).get("repo")).get("full_name") or ""
if head_repo and head_repo != repo:
return (f"⛔ **不请求合并**:PR #{prn} 来自另一个仓库({head_repo})—— "
f"跨仓合并不属于本流程,不自动做")
head_matches = head == approved_sha
state, _ctxs, waited = head_status(g, repo, head)
have = head_contexts(g, repo, head)
missing = [c for c in require_ctx if have.get(c) != "success"]
print(f" PR #{prn} 头提交 {head[:12]}|获批 SHA {approved_sha[:12]}|base={base_ref}|"
f"state={pr.get('state')}|status {state}|等了 {waited}s")
print(f" 必需 context(来源:{ctx_source}):"
+ "、".join(f"{c}={have.get(c, '缺失')}" for c in require_ctx))
ok, why = merge_decision(state, head_matches, True, approved_sha, missing)
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,
"dismiss_stale_approvals": True, "require_signed_commits": False,
"enable_status_check": bool(allow), "status_check_contexts": ctxs if allow else []}
def set_protection(g: Gitea, repo: str, rep: dict, apply: bool, allow: bool, ctxs, with_protection: bool):
if not with_protection:
return "默认不开(原稿口径:直推 main,正本 §1.3);要开请加 --with-protection"
plan = f"保护 {rep['default_branch']}(禁直推 + 必须 PR;强制状态检查 {'开 ' + str(ctxs) if allow else '不开'})"
if not apply:
return "计划:" + plan
payload = protection_payload(rep, allow, ctxs)
st, resp = g.api(f"/repos/{repo}/branch_protections", "POST", payload)
if st in (403, 409): # 已存在 → 改
st, resp = g.api(f"/repos/{repo}/branch_protections/{urllib.parse.quote(rep['default_branch'], safe='')}",
"PATCH", payload)
if st not in (200, 201):
return f"❌ 设置失败 HTTP {st} {str(resp)[:100]}"
return "已设置 —— " + plan
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 "--self-test-chain" in sys.argv:
sys.exit(self_test_chain())
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 ""
sha_index = sys.argv.index("--approve-sha") if "--approve-sha" in sys.argv else -1
approve_sha = sys.argv[sha_index + 1] if sha_index >= 0 and sha_index + 1 < len(sys.argv) else ""
ctx_index = sys.argv.index("--require-context") if "--require-context" in sys.argv else -1
require_ctx = ([x for x in sys.argv[ctx_index + 1].split(",") if x]
if ctx_index >= 0 and ctx_index + 1 < len(sys.argv) else [])
g = Gitea()
print(f"Gitea: {g.base} 仓库: {repo} 模式: "
f"{'MERGE-PR(只合并,显式批准)' if merge_n else ('APPLY(会改)' if apply else 'DRY-RUN(只体检,不动任何东西)')}\n")
rep = preflight(g, repo)
if merge_n:
req_ctx, ctx_source, ctx_err = resolve_required_contexts(g, repo, require_ctx)
if ctx_err:
print("── 合并(显式批准模式)──")
print(f" ⛔ **不请求合并**:{ctx_err}")
print(f" (体检在该仓观察到的 context(仅参考,**不用于合并判定**):"
f"{rep.get('contexts') or '(该仓没有产生过状态)'})")
return
print("── 合并(显式批准模式)──")
print(f" 必需 context 来源:{ctx_source}")
print(" " + merge_pr(g, repo, rep, merge_n, approve_sha, req_ctx, ctx_source))
print("\n ⚠️ 合并后仍要读回:state=closed + merged=true + merge_commit_sha 非空(上面已打印)。")
return
print("── 体检 ──")
print(f" 默认分支 : {rep['default_branch']}")
print(f" 工作流 : {rep['workflows'] or '(无)'}")
for wf, t in rep["triggers"].items():
print(f" {wf}: push_覆盖所有分支={t['push_all']} pull_request={t['pull_request']}")
print(f" 本仓 runner : "
f"{(str(rep['runners']) + '(' + rep['runners_scope'] + ')') if rep['runners'] else '(无 —— 有工作流也会永远 queued)'}")
print(f" 最新提交 : {str(rep['head'] or '')[:12]} combined={rep['combined']} "
f"contexts={rep['contexts']}")
print(f" AGENTS.md/.hermes.md : {rep['has_agents']}/{rep['has_hermes_md']}")
print(f" 现有分支保护 : {rep['protections'] or '(无)'}")
allow, ctxs, why = verdict(rep)
print("\n── 状态检查判定(决定 PR 会不会被锁死)──")
for line in (why or ["(本仓没有工作流 → 不能开状态检查)"]):
print(" · " + line)
print(f" ⇒ 强制状态检查: {'✅ 可以开 —— ' + str(ctxs) if allow else '⛔ 不开(开了 PR 永远合不了)'}")
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> --approve-sha <完整SHA>;"
"它会核对头提交 SHA 与每一个必需 context,任一不成立就拒绝合并")
if not apply:
print("\n(DRY-RUN:什么都没改。确认计划无误后加 --apply 重跑)")
return
print("\n── 执行 ──")
print(" AGENTS.md : " + add_pointer(g, repo, rep, True))
print(" 分支保护 : " + set_protection(g, repo, rep, True, allow, ctxs, with_protection))
_, bp = g.api(f"/repos/{repo}/branch_protections")
for b in L(bp):
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(" ① 你去看那个 PR 并批准,然后:")
print(f" python3 ~/eng-standards/scripts/adopt-existing.py {repo} --merge-pr <n> --approve-sha <完整SHA>")
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")
if __name__ == "__main__":
main()