Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 36 additions & 4 deletions scripts/coordination/post_dossier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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 "
Expand Down
68 changes: 66 additions & 2 deletions scripts/tests/test_post_dossier.py
Original file line number Diff line number Diff line change
Expand Up @@ -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=@"
Expand Down Expand Up @@ -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))
Expand Down
Loading