diff --git a/scripts/coordination/post_dossier.py b/scripts/coordination/post_dossier.py index 4abe3d7404..312a72d180 100644 --- a/scripts/coordination/post_dossier.py +++ b/scripts/coordination/post_dossier.py @@ -33,7 +33,11 @@ peut etre perime a la meme tete et la lane d'origine peut etre indisponible (#19420). Le gate juge ensuite la refutation comme pour toute contradiction muette (#18934). rc 0 reste refuse : un second - tampon n'ouvre pas une guerre de dossiers. + tampon n'ouvre pas une guerre de dossiers -- SAUF la porte #19922 : + un READY du secretariat sur une PR ``Grain: DEEP`` peut etre remplace + par le dossier de domaine d'une lane qualifiee (le merge refuse un + DEEP dossiere par le secretariat, #19612), toujours sous supersedes + + supersedes-why explicites. Le POST part par ``gh api ... --input payload.json`` (jamais ``-f body=@``, cf gh-posting-hygiene.md), puis le corps publie est relu (ligne 1, longueur, @@ -59,12 +63,19 @@ import check_adjoint_prevalidation as adjoint_gate # noqa: E402 import check_closure_dossier as closure_gate # noqa: E402 +import grain_tag # noqa: E402 REPO = "jsboige/CoursIA" # Hors de l'espace 0-3 du gate : 0 READY/CLOSE, 1 NO-DOSSIER/REFUSED, # 2 UNKNOWN, 3 BLOCKED-WITH-SUBSTANCE/KEEP. Un refus du poster ne se lit # ainsi jamais comme un verdict du gate. EXIT_REFUSED = 4 +# #19922 : le contrat du secretariat exclut le DEEP -- la garde merge refuse +# un DEEP dont le dossier tient du secretariat (#19612) -- donc le dossier +# de domaine que cette meme garde exige doit pouvoir REMPLACER un READY du +# secretariat qui tient le gate. Sans cette porte, le DEEP sur dossier +# secretariat est impostable (mesure 2026-10-08 sur #19801 et #19838). +SECRETARIAT_LANES = frozenset({"myia-po-2026:CoursIA-3"}) # gh-posting-hygiene regle 2, membre metrique : un corps court apres le POST # d'un fichier est la signature du piege de transport. MIN_PUBLISHED_LEN = 100 @@ -146,6 +157,12 @@ def refuse(reason: str) -> int: return EXIT_REFUSED +def grain_tier_of(pr_body: str) -> str | None: + """Tier du tag ``Grain:`` du body de la PR (grammaire grain_tag, #9485).""" + tag = grain_tag.parse_grain_tag(pr_body) + return tag.get("tier") if tag else None + + def preflight(family: Family, body: str, lane: str) -> tuple[Any, int] | tuple[None, int]: """Refus locaux (1, 2, 3) : aucune commande gh n'est lancee.""" lines = body.splitlines() @@ -265,10 +282,11 @@ def main(argv: list[str] | None = None) -> int: if incoherent_rc is not None: return incoherent_rc + pr_body = "" if family is ADJOINT: - current_head = gh_json( - ["pr", "view", str(target), "--json", "headRefOid"] - )["headRefOid"] + pr_view = gh_json(["pr", "view", str(target), "--json", "headRefOid,body"]) + current_head = pr_view["headRefOid"] + pr_body = pr_view.get("body") or "" stated_head = dossier.fields.get("head", "") if stated_head != current_head: return refuse( @@ -306,6 +324,20 @@ def main(argv: list[str] | None = None) -> int: "is licite only with non-empty 'supersedes' and " "'supersedes-why' fields naming what is refuted (#19420)" ) + elif ( + # #19922 -- rc 0 (READY) du secretariat sur un Grain: DEEP : la + # garde merge refuse un DEEP dossiere par le secretariat + # (#19612), le dossier de domaine d'une lane qualifiee doit + # pouvoir remplacer le READY qui tient le gate. Refutation + # explicite exigee comme pour le rc 3 (#19420) : le gate + # juge ensuite la refutation (#18934). + family is ADJOINT + and existing_lane in SECRETARIAT_LANES + and grain_tier_of(pr_body) == "DEEP" + and dossier.fields.get("supersedes", "").strip() + and dossier.fields.get("supersedes-why", "").strip() + ): + pass # re-stamp tiers autorise : dossier de domaine pour un DEEP else: return refuse( f"anti-double-stamp: gate rc {gate_rc} with an intact dossier by " diff --git a/scripts/tests/test_post_dossier.py b/scripts/tests/test_post_dossier.py index 7d7bce5e3e..da1317853f 100644 --- a/scripts/tests/test_post_dossier.py +++ b/scripts/tests/test_post_dossier.py @@ -92,15 +92,21 @@ def _closure_body() -> str: class GhRouter: """gh_json simule : route pr view / api comments et enregistre les appels.""" - def __init__(self, head: str = HEAD, published_body: str | None = None): + def __init__( + self, + head: str = HEAD, + published_body: str | None = None, + pr_body: str | None = None, + ): self.head = head self.published_body = published_body + self.pr_body = pr_body self.calls: list[list[str]] = [] def __call__(self, args: list[str]): self.calls.append(list(args)) if args[0] == "pr": - return {"headRefOid": self.head} + return {"headRefOid": self.head, "body": self.pr_body or ""} if args[0] == "api": assert args[1].endswith("/comments"), f"unexpected endpoint: {args[1]}" assert "--input" in args, "POST must go through --input, never -f body=@" @@ -292,6 +298,64 @@ def test_double_stamp_rc0_with_supersedes_refused(tmp_path, monkeypatch, capsys) assert "a re-stamp is licite only for its own lane" in err +SECRETARIAT_LANE = "myia-po-2026:CoursIA-3" +DEEP_PR_BODY = ( + "Grain: DEEP/lean -- lane myia-po-2024:CoursIA -- prev: MED/guard #19800\n\n" + "## Summary\n\nDossier de domaine." +) +MED_PR_BODY = ( + "Grain: MED/guard -- lane myia-po-2024:CoursIA -- prev: DEEP/lean #19800\n\n" + "## Summary\n\nDossier de domaine." +) + + +def test_rc0_secretariat_deep_with_supersedes_posts_through(tmp_path, monkeypatch, capsys): + """#19922, controle positif (rejeu causal du refus #19801) : gate rc 0, + dossier intact du SECRETARIAT, PR ``Grain: DEEP``, refute explicite -- + le re-stamp de la lane qualifiee passe et le POST part.""" + router = GhRouter(pr_body=DEEP_PR_BODY) + monkeypatch.setattr(mod, "gh_json", router) + monkeypatch.setattr(mod, "run_gate", GateStub(rc=0, lane=SECRETARIAT_LANE)) + monkeypatch.setattr(mod, "rerun_gate", lambda family, repo, target: 0) + body = _adjoint_body( + supersedes="5", + **{"supersedes-why": "dossier de domaine requis pour un DEEP (#19922)"}, + ) + rc = mod.main(["--pr", "101", "--file", str(_write(tmp_path, body)), "--lane", LANE]) + assert rc == 0 + assert any(call[0] == "api" for call in router.calls) + assert "anti-double-stamp" not in capsys.readouterr().err + + +def test_rc0_secretariat_med_with_supersedes_refused(tmp_path, monkeypatch, capsys): + """#19922, controle negatif : la porte est reservee au DEEP -- un re-stamp + tiers sur un dossier MED du secretariat reste refuse.""" + router = GhRouter(pr_body=MED_PR_BODY) + monkeypatch.setattr(mod, "gh_json", router) + monkeypatch.setattr(mod, "run_gate", GateStub(rc=0, lane=SECRETARIAT_LANE)) + body = _adjoint_body( + supersedes="5", + **{"supersedes-why": "dossier de domaine requis pour un DEEP (#19922)"}, + ) + rc = mod.main(["--pr", "101", "--file", str(_write(tmp_path, body)), "--lane", LANE]) + assert rc == mod.EXIT_REFUSED + assert not any(call[0] == "api" for call in router.calls) + assert "anti-double-stamp" in capsys.readouterr().err + + +def test_rc0_secretariat_deep_without_supersedes_refused(tmp_path, monkeypatch, capsys): + """#19922 -- DEEP sur dossier secretariat mais SANS refute explicite : + refus conserve (la porte exige supersedes + supersedes-why, comme le + rc 3 de #19420).""" + router = GhRouter(pr_body=DEEP_PR_BODY) + monkeypatch.setattr(mod, "gh_json", router) + monkeypatch.setattr(mod, "run_gate", GateStub(rc=0, lane=SECRETARIAT_LANE)) + rc = mod.main(["--pr", "101", "--file", str(_write(tmp_path, _adjoint_body())), "--lane", LANE]) + assert rc == mod.EXIT_REFUSED + assert not any(call[0] == "api" for call in router.calls) + assert "anti-double-stamp" in capsys.readouterr().err + + def test_gate_unknown_refused(tmp_path, monkeypatch): monkeypatch.setattr(mod, "gh_json", GhRouter()) monkeypatch.setattr(mod, "run_gate", GateStub(rc=2))