From f20ec61084d534161d279467e7dce2f39fdabc7e Mon Sep 17 00:00:00 2001 From: jsboige Date: Thu, 5 Nov 2026 05:57:19 +0100 Subject: [PATCH] Fix(guard,#23605): exempter les PRs de TP relayees de tag_required Un corps qui declare explicitement 'TP relayee' (variantes accentuees/ non accentuees et genrees, TP\s+relay[e]) est exempte du Grain-tag, sans condition de branche -- le relais part d'une branche de l'enseignant, aucun prefixe n'est contraignable (option 1 de l'issue, tranchee au dispatch c2772). Avant : toute PR de TP relayee echouait l'organe PAR CONSTRUCTION (mesure #22969, job 122392394402). 8 tests : cas fondateur, 5 variantes orthographiques, controle negatif (sans declaration = bloque), collision marqueur+tag, CLI --body-file. Co-Authored-By: Claude Sonnet 5.5 --- scripts/ci/variation_tag_required.py | 26 +++++ scripts/tests/test_variation_tag_required.py | 107 +++++++++++++++++++ 2 files changed, 133 insertions(+) diff --git a/scripts/ci/variation_tag_required.py b/scripts/ci/variation_tag_required.py index 8677c0551f7..47666d89a03 100644 --- a/scripts/ci/variation_tag_required.py +++ b/scripts/ci/variation_tag_required.py @@ -82,6 +82,7 @@ import argparse import json +import re import sys from pathlib import Path @@ -105,6 +106,13 @@ def check(body: str | None, head_ref: str | None = None) -> dict: #17713: a Claude Code session opened from the maintainer's cloud environment writes no `Grain:` tag, and ``CLAUDE.md`` ('À qui ce fichier s'adresse') explicitly exempts such sessions from the variation protocol. + + Independently of `head_ref`, a body that explicitly declares a relayed + student assignment (``TP relayée`` and its unaccented/gendered variants) + is exempt too -- cf. #23605: a TP relayed by the teacher opens from the + teacher's branch (no constrainable prefix), and the student protocol + (student-pr-reviews.md) exempts it in prose; before this, the gate failed + such PRs BY CONSTRUCTION (measured on #22969, job 122392394402). """ if body is None or body.strip() == "": return { @@ -132,6 +140,24 @@ def check(body: str | None, head_ref: str | None = None) -> dict: "lane": None, } + # #23605 -- exemption : PR de TP relayee par l'enseignant. + # Le relais part d'une branche de l'enseignant (pas de prefixe + # contraignable), donc l'exemption tient sur la declaration explicite + # du corps seule (option 1 de l'issue, tranchee au dispatch c2772) : + # avant elle, toute PR de TP relayee echouait l'organe PAR CONSTRUCTION, + # quel que soit son contenu (mesure #22969, job 122392394402 -- un body + # qui declarait pourtant la convention en prose). Les variantes + # accentuees/non accentuees et genrees sont couvertes (`TP relayée`, + # `TP relayee`, `TP relayé`, `TP relaye`). + if re.search(r"TP\s+relay[eé]", body): + return { + "required_pass": True, + "reason": "exempt by #23605 ('TP relayée' declared in body)", + "tier": None, + "genre": None, + "lane": None, + } + parsed = gt.parse_grain_tag(body) if parsed is None: return { diff --git a/scripts/tests/test_variation_tag_required.py b/scripts/tests/test_variation_tag_required.py index 5ea5783ca5e..d8638f5b1f6 100644 --- a/scripts/tests/test_variation_tag_required.py +++ b/scripts/tests/test_variation_tag_required.py @@ -422,3 +422,110 @@ def test_17713_cli_head_ref_arg_propagates(): assert "17713" in verdict["reason"] finally: fixture.unlink(missing_ok=True) + + +# --- #23605 : exemption « TP relayée » (PR de TP relayée par l'enseignant) -- +# +# Les groupes qui n'ouvrent pas leur PR eux-mêmes se font relayer par +# l'enseignant, depuis une branche de l'enseignant -- pas de préfixe +# contraignable, donc l'exemption tient sur la déclaration explicite du +# corps seule (option 1 de l'issue, tranchée au dispatch c2772). Avant +# elle, toute PR de TP relayée échouait l'organe PAR CONSTRUCTION (mesure +# #22969, job 122392394402 : « Grain tag absent » sur un body qui +# déclarait pourtant la convention en prose). + + +def test_23605_tp_relayee_declaration_is_exempt(): + """Body sans Grain qui déclare « PR de TP relayée » -> required_pass True. + + Le cas fondateur : le body de #22969 disait « Comme pour toute PR de TP, + le tag Grain et les checklists internes ne s'appliquent pas » -- la + convention existait en prose, pas dans le code. + """ + body = ( + "TP1 MIS G01, rendu du groupe.\n\n" + "Comme pour toute PR de TP relayée par l'enseignant, le tag Grain et " + "les checklists internes ne s'appliquent pas.\n" + ) + v = vtr.check(body) + assert v["required_pass"] is True + assert "23605" in v["reason"] + assert v["tier"] is None + assert v["lane"] is None + + +@pytest.mark.parametrize( + "marker", + [ + "TP relayée", # canonique (la PR) + "TP relayee", # accent perdu (clavier/CI) + "TP relayé", # le rendu, masculin + "TP relaye", # les deux à la fois + "PR de TP relayée", # préfixe le plus naturel + ], +) +def test_23605_variantes_orthographiques_exemptent(marker): + """Les formes fléchies couvertes par `TP\\s+relay[eé]` exemptent toutes.""" + v = vtr.check(f"Rendu de {marker} par l'enseignant.\n") + assert v["required_pass"] is True, f"marqueur {marker!r} devrait exempter" + assert "23605" in v["reason"] + + +def test_23605_body_tp_sans_declaration_reste_bloque(): + """Un body de TP SANS la déclaration explicite reste bloqué. + + L'exemption est une DÉCLARATION, pas une détection du sujet : « rendu du + TP1 du groupe G01 » seul n'exempte pas -- sinon tout body mentionnant un + TP s'affranchirait du tag sans le dire. + """ + body = "Rendu du TP1 du groupe G01. Aucune déclaration de relais.\n" + v = vtr.check(body) + assert v["required_pass"] is False + assert "tag" in v["reason"].lower() or "grain" in v["reason"].lower() + + +def test_23605_marqueur_ne_casse_pas_un_body_tague(): + """Marqueur + Grain tag valides ensemble : la PR passe (l'exemption + court-circuite avant le parse ; le verdict consommé est le même).""" + body = ( + "Grain: MED/guard -- lane myia-po-2027:CoursIA\n\n" + "Le body mentionne aussi une PR de TP relayée voisine (#22969).\n" + ) + v = vtr.check(body) + assert v["required_pass"] is True + + +def test_23605_cli_body_file_exempt_exit_zero(): + """End-to-end CLI : body-file « TP relayée » sans --head-ref -> exit 0. + + Le relais part d'une branche de l'enseignant : aucun argument de + branche n'est requis, contrairement à #17713. + """ + fixture = Path(__file__).resolve().parent / "_vtr_fixture_23605.md" + fixture.write_text( + "TP2 SYP G03 -- PR de TP relayée par l'enseignant.\n", + encoding="utf-8", + ) + try: + proc = subprocess.run( + [ + sys.executable, + "scripts/ci/variation_tag_required.py", + "--body-file", + str(fixture), + ], + capture_output=True, + text=True, + check=False, + encoding="utf-8", + errors="replace", + ) + assert proc.returncode == 0, ( + f"expected exit 0 (exempt TP relayée), got {proc.returncode}; " + f"stderr={proc.stderr!r}" + ) + verdict = json.loads(proc.stdout) + assert verdict["required_pass"] is True + assert "23605" in verdict["reason"] + finally: + fixture.unlink(missing_ok=True)