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
48 changes: 43 additions & 5 deletions scripts/pick_idle_grain.py
Original file line number Diff line number Diff line change
Expand Up @@ -1247,6 +1247,32 @@ def blocking_causes(state: dict, *, age_hours: float | None = None,
return causes


def _is_adjacency_red(body: str) -> bool:
"""Verdict LIGHT-genre adjacency pour le corps d'une PR rouge (#13967).

Pont entre l'organe `scripts/ci/variation_adjacency_guard.py` (verdict
G-VAR-3, fail-CLOSED sur `genre == prev_genre` dans la liste LIGHT) et
le picker : la branche d'affichage specialisee `print_red_assignment`
se declenche SI ET SEULEMENT SI tous les rouges sont detenus par cette
cause. Enveloppe tolérante : si l'organe est indisponible (import,
panne), on rend False -- les trois conseils generiques restent le
fallback sur, jamais un crash de picker.
"""
try:
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent / "ci"))
import variation_adjacency_guard as vag # noqa: PLC0415 - import tardif volontaire
except Exception: # noqa: BLE001 - le picker ne doit jamais casser sur l'organe
return False
try:
verdict = vag.check(body)
except Exception: # noqa: BLE001 - idem : organe optionnel, picker robuste
return False
# L'organe est fail-CLOSED : blocking=True <=> LIGHT adjacency reelle
# (cf docstring `check`). On conserve `adjacent` pour les diagnostics
# futurs (DEEP/MED adjacency = advisory, hors branche specialised).
return bool(verdict.get("blocking"))


def _newest_start_hours(stamps) -> float | None:
"""Age, en heures, du demarrage le PLUS RECENT parmi `stamps` (ISO-8601).

Expand Down Expand Up @@ -1524,14 +1550,26 @@ def red_backlog(lane: str, threshold_hours: float,
if sat_cause:
causes.append(sat_cause)
if causes:
# #13967 : verdict adjacency par organe. La cause ROUGE la plus
# frequente (mesure 2026-09-01 = 13/25) etait silencieusement
# recouverte par les trois conseils generiques (update-branch /
# rebase / pousser) -- invariants au predicat, donc la lane qui
# les suit BOUCLE. Le picker dispose deja du corps de la PR
# (`pr["body"]` -- porte du `Grain:`), donc on appelle
# `variation_adjacency_guard.check` localement : fonction pure,
# pas de `gh` round-trip, cout negligible vs les 2 lots GraphQL
# dejas payes dans `red_backlog`. On preserve le contrat
# d'override (un caller externe peut poser `is_adjacency=False`
# pour court-circuiter -- utile pour les tests pinnes qui ne
# veulent pas re-parser un body realiste).
if "is_adjacency" in pr:
is_adj = bool(pr["is_adjacency"])
else:
is_adj = _is_adjacency_red(pr.get("body") or "")
red.append({"number": pr["number"], "title": pr["title"],
"age_hours": round(age),
"causes": causes,
# #13967 : signal optionnel fourni par un caller
# externe (wrapper sur `variation_adjacency_guard`).
# Falsy par defaut pour ne pas changer le contrat
# des 79 tests existants.
"is_adjacency": bool(pr.get("is_adjacency"))})
"is_adjacency": is_adj})
red.sort(key=lambda r: -r["age_hours"])

triggers = []
Expand Down
80 changes: 80 additions & 0 deletions scripts/tests/test_pick_idle_grain.py
Original file line number Diff line number Diff line change
Expand Up @@ -620,6 +620,86 @@ def test_non_adjacency_red_keeps_three_generic_gestures(monkeypatch, capsys):
assert "Piocher un grain d'UN AUTRE genre" not in out


def _pr_with_body(n: int, lane: str, age_hours: int, body: str) -> dict:
"""PR rouge avec un corps arbitraire (permet de pinner le tag `Grain:`
du test : `_pr` le fige a `MED/guard` pour les tests existants).
"""
created = (pig.NOW - pig.dt.timedelta(hours=age_hours)).strftime("%Y-%m-%dT%H:%M:%SZ")
return {"number": n, "title": f"pr {n}", "body": body,
"createdAt": created, "isDraft": False}


def test_adjacency_detected_from_body_when_caller_omits_flag(monkeypatch, capsys):
"""#13967 (cycle 158) : le picker doit DEDUIRE `is_adjacency` du corps
de la PR via `variation_adjacency_guard.check`, pas attendre qu'un
caller externe pose le flag. Le bug fondateur : la branche specialisee
L1718 etait du code mort (flag jamais pose), donc les 13 PRs
mesurees en adjacency recevaient les 3 conseils generiques invariants
au predicat.
"""
red = _state(checks=[("PR gate", "FAILURE", True)])
body = ("Grain: LIGHT/guard -- lane myia-po-2026:CoursIA -- "
"prev: LIGHT/guard #13940\n")
pr = _pr_with_body(101, "myia-po-2026:CoursIA", 30, body)
# Pas de champ `is_adjacency` : on verifie que le picker le deduit.
assert "is_adjacency" not in pr
_patch_backlog(monkeypatch, [pr], {101: red})
rc = pig.main(["--lane", "myia-po-2026:CoursIA"])
out = capsys.readouterr().out
assert rc == 0
assert "Piocher un grain d'UN AUTRE genre" in out, (
"le picker doit deduire l'adjacency du corps via l'organe "
"variation_adjacency_guard ; le contrat du test existant etait "
"que le caller pose le flag, mais aucun caller in-process ne le "
"fait (cf #13967)"
)
assert "gh pr update-branch" not in out


def test_adjacency_not_triggered_when_genres_differ_in_body(monkeypatch, capsys):
"""#13967 controle negatif : un corps `MED/guard` apres `LIGHT/ledger`
NE TRIPPE PAS l'organe d'adjacence, donc le picker conserve les
trois conseils generiques (le push peut reparer le rouge).
"""
red = _state(checks=[("PR gate", "FAILURE", True)])
body = ("Grain: MED/guard -- lane myia-po-2026:CoursIA -- "
"prev: LIGHT/ledger #13941\n")
pr = _pr_with_body(102, "myia-po-2026:CoursIA", 30, body)
_patch_backlog(monkeypatch, [pr], {102: red})
rc = pig.main(["--lane", "myia-po-2026:CoursIA"])
out = capsys.readouterr().out
assert rc == 0
assert "Trois gestes, dans cet ordre" in out
assert "gh pr update-branch" in out, (
"genres distincts = pas d'adjacence = conseil generic applicable"
)
assert "Piocher un grain d'UN AUTRE genre" not in out


def test_adjacency_caller_override_still_respected(monkeypatch, capsys):
"""#13967 preservation du contrat : un caller externe peut toujours
poser `is_adjacency=False` pour court-circuiter la deduction par
organe. Utile pour les tests pinnes qui ne veulent pas dependre du
parseur de tag (cf le test existant L555 qui pose `True`).
"""
red = _state(checks=[("PR gate", "FAILURE", True)])
# Corps qui TRIPPERAIT l'organe (LIGHT/guard apres LIGHT/guard)...
body = ("Grain: LIGHT/guard -- lane myia-po-2026:CoursIA -- "
"prev: LIGHT/guard #13942\n")
pr = _pr_with_body(103, "myia-po-2026:CoursIA", 30, body)
# ...mais le caller force l'override a False.
pr["is_adjacency"] = False
_patch_backlog(monkeypatch, [pr], {103: red})
rc = pig.main(["--lane", "myia-po-2026:CoursIA"])
out = capsys.readouterr().out
assert rc == 0
assert "Trois gestes, dans cet ordre" in out, (
"l'override du caller (`is_adjacency=False`) doit primer sur la "
"deduction par organe -- le contrat de l'API externe est preserve"
)
assert "Piocher un grain d'UN AUTRE genre" not in out


def test_a_clean_lane_is_not_sent_to_repair(monkeypatch, capsys):
"""Controle positif des deux precedents.

Expand Down
Loading