fix(adopt): 合并必需 context 只认明确策略,并核对 base/state/来源
必需 context 只能来自 --require-context 或仓库 .gitea/required-contexts.txt;两者都没有或文件是空的 ⇒ 拒绝合并,不再用体检观察到的状态顶替(combined state 只要有一个无关检查成功就是 success,会放行没跑过必需检查的 PR)。同时核对 PR 目标分支 == 默认分支、state=open、head 就是本仓。
This commit is contained in:
+243
-27
@@ -4,9 +4,13 @@
|
||||
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
|
||||
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;
|
||||
② "等一会儿就合"把**审批人**变成了这个脚本 —— 而脚本没有资格替人拍板。
|
||||
@@ -37,6 +41,8 @@ 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):
|
||||
@@ -259,19 +265,46 @@ def head_status(g: Gitea, repo: str, head: str, tries: int = 12, interval: int =
|
||||
return (state or "unknown"), ctxs, tries * interval
|
||||
|
||||
|
||||
def merge_decision(state, head_matches: bool, explicitly_approved: bool):
|
||||
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` 直接打它)。
|
||||
|
||||
默认拒绝,只有"显式批准 + 头提交没变 + 检查 success"三条同时成立才放行。
|
||||
默认拒绝。只有下面**全部**成立才放行:
|
||||
· 显式批准,且批准的是一个**完整 40 位 SHA**(短 sha 起不了身份作用);
|
||||
· PR 头提交 == 那个被批准的 SHA;
|
||||
· 要求的每一个 status context 都在头提交上 **success**(缺一个都不行);
|
||||
· 汇总状态是 success。
|
||||
检查失败 / 取消 / 从未运行 / 拿不到状态 / 超时,一律不请求合并。
|
||||
"""
|
||||
if not explicitly_approved:
|
||||
return False, ("没有显式批准:合并是独立动作,要用 --merge-pr <n> 明确指定"
|
||||
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 头提交与会话记录的那个提交不一致(期间有人推了新提交)—— 不合并"
|
||||
return False, "PR 头提交 ≠ 被批准的 SHA(期间有人推了新提交)—— 不合并"
|
||||
if missing_contexts:
|
||||
return False, f"这些必需 status context 没有在头提交上 success:{missing_contexts} —— 不合并"
|
||||
if state == "success":
|
||||
return True, "检查 success + 显式批准"
|
||||
return True, "检查 success + 显式批准(完整 SHA 一致、必需 context 齐)"
|
||||
if state == "pending":
|
||||
return False, f"检查仍是 {state}(可能在等一个不会出现的检查,或还在跑)—— 不合并"
|
||||
if state in (None, "", "unknown", "none"):
|
||||
@@ -281,19 +314,24 @@ def merge_decision(state, head_matches: bool, explicitly_approved: bool):
|
||||
|
||||
def self_test() -> int:
|
||||
"""反向自检:每一条"不许合并"的分支都要真的拦下来。"""
|
||||
good = "a" * 40
|
||||
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),
|
||||
("全绿 + 已批准(完整 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), want in cases:
|
||||
got, why = merge_decision(state, head_ok, approved)
|
||||
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:
|
||||
@@ -302,7 +340,105 @@ def self_test() -> int:
|
||||
print(f" · {p}", file=sys.stderr)
|
||||
return 1
|
||||
print(f"✅ 合并判定自检通过:{len(cases)} 个分支"
|
||||
f"(失败/取消/未运行/未知/超时/未批准/头提交变了 —— 全部拒绝合并)")
|
||||
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
|
||||
|
||||
|
||||
@@ -334,28 +470,92 @@ def add_pointer(g: Gitea, repo: str, rep: dict, apply: bool):
|
||||
"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>`"})
|
||||
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}")
|
||||
f"**未合并**,等你在 Gitea 上看过并批准后跑 --merge-pr {prn} --approve-sha <完整40位SHA>")
|
||||
finally:
|
||||
shutil.rmtree(tmp, ignore_errors=True)
|
||||
|
||||
|
||||
def merge_pr(g: Gitea, repo: str, rep: dict, prn: str, watch_head: str = ""):
|
||||
"""显式批准的合并:先核对状态,再合并,再读回。"""
|
||||
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 ""
|
||||
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 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",
|
||||
@@ -395,6 +595,8 @@ 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]
|
||||
@@ -402,6 +604,11 @@ def main():
|
||||
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} 模式: "
|
||||
@@ -409,8 +616,16 @@ def main():
|
||||
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(" " + merge_pr(g, repo, rep, merge_n))
|
||||
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
|
||||
|
||||
@@ -435,7 +650,8 @@ 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 时它会拒绝合并)")
|
||||
print(" 3. 合并 : **不自动做**。批准后跑 --merge-pr <n> --approve-sha <完整SHA>;"
|
||||
"它会核对头提交 SHA 与每一个必需 context,任一不成立就拒绝合并")
|
||||
|
||||
if not apply:
|
||||
print("\n(DRY-RUN:什么都没改。确认计划无误后加 --apply 重跑)")
|
||||
@@ -451,7 +667,7 @@ def main():
|
||||
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>")
|
||||
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 会拦住直推」当成前提。")
|
||||
|
||||
Reference in New Issue
Block a user