diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index de79b5f683..0c41aee507 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -151,6 +151,49 @@ REPO = "jsboige/CoursIA" +# c.1115 voie 1 (msg-20260912T165428-k6rbfc, ai-01 spec) : klass `delivered` +# si label `candidate-delivered` OU marqueur `[INFO] candidate-delivered` en +# commentaire. Le sweep quotidien retracte le label sur activite de commentaire +# (le marqueur lui-meme en fait partie), donc certaines LIVRE-urn restent +# invisibles au seul filtre labels. Le pattern matche les deux formes +# employees par les lanes : `[INFO] candidate-delivered` et `[INFO +# candidate-delivered]` (espace au lieu de `]`). +_DELIVERED_MARKER_RE = re.compile( + r"\[INFO[\s_]candidate-delivered", re.IGNORECASE) + + +def _has_delivered_marker(issue_number: int) -> bool | None: + """Retourne True si l'issue porte un marqueur [INFO] candidate-delivered. + + Cout : 1 requete HTTP par appel (invariant recent_delivery l.958 preserve -- + appelee seulement sur les candidats TIRES, jamais sur le pool). Retourne + None si la lecture echoue (timeout, rate-limit) ; l'appelant traite None + comme "pas de signal" et continue, exactement comme une absence. + """ + try: + out = subprocess.run( + ["gh", "issue", "view", str(issue_number), + "--repo", REPO, "--comments", "--json", "comments"], + capture_output=True, text=True, encoding="utf-8", check=True, + timeout=20, + ).stdout + payload = json.loads(out) + except Exception as exc: # noqa: BLE001 - diagnostic best-effort + return None + # Tolérance : la charge utile peut être [] (issue introuvable, ou mock de + # test ancien), {comments: [...]} (gh standard), voire {data: ...}. Le + # contrat utile est "iterable de dict avec .body" ; tout le reste = pas + # de signal. + if not isinstance(payload, dict): + return False + comments = payload.get("comments") or [] + if not isinstance(comments, list): + return False + return any(_DELIVERED_MARKER_RE.search((c.get("body") or "") + if isinstance(c, dict) else "") + for c in comments) + + # Saturation par zone d atterrissage (#13420) : l axe partition-proof que # le compteur par issue ne peut pas porter. Voir scripts/series_saturation.py # pour le diagnostic complet (EPIC decoupe en 9 filles = 9 veines invisibles). @@ -1067,6 +1110,20 @@ def recent_delivery(picks: list[dict]) -> dict[int, str]: notes[n] = f"(recherche PR indisponible: {type(exc).__name__})" continue if not prs: + # c.1115 voie 1 (Tell c.1060-L1 reformule ai-01) : pas de PR + # couvrante, mais le label `candidate-delivered` peut etre absent + # alors que le marqueur `[INFO] candidate-delivered` est present + # en commentaire (sweep 05:37Z retracte sur activite). Cout : 1 + # requete par pick, invariant recent_delivery preserve. + if _has_delivered_marker(n): + notes[n] = ( + f"LIVRE-urn VIA MARQUEUR [INFO] candidate-delivered en " + f"commentaire (label GitHub absent/decay -- sweep " + f"quotidien retracte sur activite post-merge, documentee " + f"dans l'en-tete du workflow advisory). Verifier " + f"firsthand `gh issue view {n} --comments` AVANT de " + f"claimer ; substance deja livree par une autre lane.") + p["klass"] = "delivered" continue # Une PR fermee-sans-fusion n'atteste de rien : on l'ecarte ici plutot diff --git a/scripts/tests/test_pick_idle_grain.py b/scripts/tests/test_pick_idle_grain.py index 59a559b2e6..58a3f66689 100644 --- a/scripts/tests/test_pick_idle_grain.py +++ b/scripts/tests/test_pick_idle_grain.py @@ -62,16 +62,26 @@ def test_founding_case_12014_surfaces_12077(monkeypatch): def test_query_shape_bounded_one_per_candidate(monkeypatch): - """Cout borne : exactement une requete par candidat tire, jamais le pool. + """Cout borne : une requete PR par candidat tire, jamais le pool. La commande doit chercher les PRs MERGEES referencant le numero - (troisieme surface de grounding, cf #12174). + (troisieme surface de grounding, cf #12174). c.1115 voie 1 ajoute un + appel `gh issue view N --comments` conditionnel (uniquement si pas de + PR couvrante) -- verifie separement dans + `test_marker_check_one_request_per_pick_invariant`. """ calls = [] - _patch_gh(monkeypatch, [[], []], calls) + # Patch retourne TOUJOURS [] -- traite comme "pas de PR couvrante", + # declenche le check marqueur qui retourne aussi [] (charge vide). + # recent_delivery appelle donc 2x par pick (pr list + issue view). + def fake_run(cmd, **kwargs): + calls.append(cmd) + return _FakeCompleted("[]") + monkeypatch.setattr(pig.subprocess, "run", fake_run) pig.recent_delivery([_pick(n=1), _pick(n=2)]) - assert len(calls) == 2 - for cmd, n in zip(calls, (1, 2)): + pr_list = [c for c in calls if c[1] == "pr" and c[2] == "list"] + assert len(pr_list) == 2 + for cmd, n in zip(pr_list, (1, 2)): # --state all depuis #12504 : ouvertes ET mergees dans la MEME # requete, donc l'invariant "une par candidat" tient toujours. assert "--state" in cmd and "all" in cmd @@ -2097,3 +2107,148 @@ def composition(factors): f"{share_starved_boost:.2f} vs {share_starved_base:.2f}") assert max(boosted.values()) / 400 < 0.5, ( f"aucune umbrella ne doit monopoliser : {boosted}") + + +# --- LIVRÉ-urn via marqueur [INFO] candidate-delivered (c.1115 voie 1) ------- +# +# Le sweep quotidien 05:37Z retracte le label `candidate-delivered` sur +# activite de commentaire post-merge ; or les lanes elles-memes postent des +# commentaires `[INFO] candidate-delivered` quand elles en rencontrent une. +# Resultat : des LIVRE-urn restent sans label alors qu'un marqueur en +# commentaire les designe explicitement. Tell c.1060-L1 reformule (msg-20260912T165428-k6rbfc, +# ai-01 spec) : la klasse `delivered` doit etre posee sur signal label OU +# marqueur, avec 1 requete par candidat tire (invariant recent_delivery l.958). +# +# Cas fondateur (2026-09-12) : #14373 (4 commentaires `[INFO candidate-delivered]` +# de 4 lanes distinctes, PR #14455 MERGED, label absent au moment du test). + + +def _patch_gh_dispatch(calls, monkeypatch, pr_payload, comments_payload): + """Dispatcher : repond selon la sous-commande gh (pr list vs issue view). + + `calls` (premier arg, positionnel obligatoire) est une liste mutable + enrichie en place pour permettre les assertions sur le nombre d'appels + et les commandes exactes. + """ + def fake_run(cmd, **kwargs): + calls.append(cmd) + # gh pr list ... | gh issue view N ... + if cmd[1:3] == ["issue", "view"]: + return _FakeCompleted(json.dumps(comments_payload)) + return _FakeCompleted(json.dumps(pr_payload)) + monkeypatch.setattr(pig.subprocess, "run", fake_run) + + +def _delivered_marker_comment(body=( + "[INFO] candidate-delivered — verification first-hand du geste 1 " + "sur origin/main, MERGE 6d0bd02093.")): + return {"body": body, "author": {"login": "jsboige"}} + + +def test_marker_only_surfaces_delivered_urn(monkeypatch): + """c.1115 voie 1 controle positif : pas de PR couvrante, marqueur en + commentaire -> klass mutee a delivered, note ajoutee. Cas fondateur + #14373 (label absent, 4 marqueurs multi-lanes).""" + calls = [] + _patch_gh_dispatch( + calls, monkeypatch, pr_payload=[], + comments_payload={"comments": [ + _delivered_marker_comment(), + {"body": "Commentaire sans marqueur, hors perimetre."}, + _delivered_marker_comment( + body="[INFO candidate-delivered] verifie par po-2023 c.485"), + ]}) + picks = [_pick(n=14373)] + notes = pig.recent_delivery(picks) + assert 14373 in notes + assert "MARQUEUR" in notes[14373] + assert "[INFO]" in notes[14373] + assert picks[0]["klass"] == "delivered" + + +def test_no_marker_no_label_no_note(monkeypatch): + """Pas de PR couvrante, pas de marqueur -> pas de signal (regression + preservee). Couvre le cas standard 'issue vivante sans livraison'.""" + calls = [] + _patch_gh_dispatch( + calls, monkeypatch, pr_payload=[], + comments_payload={"comments": [ + {"body": "Commentaire normal d'un humain."}, + {"body": "Autre commentaire sans [INFO] candidate-delivered."}, + ]}) + picks = [_pick(n=15794)] + notes = pig.recent_delivery(picks) + assert notes == {} + assert picks[0]["klass"] == "grain" + + +def test_marker_check_one_request_per_pick_invariant(monkeypatch): + """L'invariant recent_delivery (1 requete par candidat tire) tient aussi + pour le check marqueur : pour 3 picks SANS PR couvrante, exactement 3 + appels a `gh pr list` ET 3 appels a `gh issue view`.""" + calls = [] + _patch_gh_dispatch( + calls, monkeypatch, pr_payload=[], + comments_payload={"comments": []}) + pig.recent_delivery([_pick(n=1), _pick(n=2), _pick(n=3)]) + pr_list = [c for c in calls if c[1] == "pr" and c[2] == "list"] + issue_view = [c for c in calls if c[1] == "issue" and c[2] == "view"] + assert len(pr_list) == 3 + assert len(issue_view) == 3 + # Verification qu'on ne scanne PAS le pool : les appels `issue view` + # prennent un numero explicite, pas un filtre large. + for cmd in issue_view: + assert cmd[3] in {"1", "2", "3"} + + +def test_marker_does_not_shortcut_pr_check(monkeypatch): + """Si une PR OUVERTE couvre l'issue, le marqueur en commentaire ne doit + pas detourner l'annotation : TRAVAIL EN COURS prime (priorite du signal, + l.1044).""" + calls = [] + _patch_gh_dispatch( + calls, monkeypatch, + pr_payload=[{"number": 15755, "state": "OPEN", + "isDraft": False, "mergedAt": None}], + comments_payload={"comments": [ + _delivered_marker_comment(), + ]}) + picks = [_pick(n=15794)] + notes = pig.recent_delivery(picks) + assert notes[15794].startswith("TRAVAIL EN COURS") + assert picks[0]["klass"] == "grain" # PAS mute : TRAVAIL EN COURS prime + # Et on n'a PAS appele gh issue view pour ce pick (shortcut evite). + issue_view = [c for c in calls if c[1] == "issue" and c[2] == "view"] + assert issue_view == [] + + +def test_marker_check_failure_treated_as_no_signal(monkeypatch): + """Si `gh issue view` timeout/rate-limit, _has_delivered_marker retourne + None ; recent_delivery continue sans annoter (best-effort, parite avec + la doctrine candidate-delivered : signale sans casser le flux).""" + def boom(cmd, **kwargs): + if cmd[1:3] == ["issue", "view"]: + raise pig.subprocess.TimeoutExpired(cmd, 20) + return _FakeCompleted("[]") + monkeypatch.setattr(pig.subprocess, "run", boom) + picks = [_pick(n=14373)] + notes = pig.recent_delivery(picks) + # Pas de LIVRE-urn annotation, pas de mutation de klasse. + assert notes == {} + assert picks[0]["klass"] == "grain" + + +def test_marker_regex_matches_both_bracket_forms(monkeypatch): + """Le pattern couvre les deux formes employees : `[INFO] candidate-delivered` + ET `[INFO candidate-delivered]` (espace au lieu de `]`). Cf Tell c.1115 + voie 1 : unification lexicale sans casser l'existant.""" + calls = [] + _patch_gh_dispatch( + calls, monkeypatch, pr_payload=[], + comments_payload={"comments": [ + {"body": "[INFO candidate-delivered] variante espace au lieu de ]"}, + ]}) + picks = [_pick(n=14373)] + notes = pig.recent_delivery(picks) + assert 14373 in notes + assert picks[0]["klass"] == "delivered"