diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index c53d1e7c45..581e625a6b 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -270,6 +270,7 @@ def _has_delivered_marker(issue_number: int) -> bool | None: # 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). from series_saturation import ( # noqa: E402 + BELT_WINDOW_DAYS, CONSOLIDATION, DEFAULT_WINDOW_DAYS, DELIVERY_DELIVERED, @@ -5056,26 +5057,35 @@ def main(argv: list[str] | None = None) -> int: # forme precedente ("REFUS DE TIRAGE", sortie 2, aucun candidat) rendait # un travail nomme sous l'apparence d'un vide, et se declenchait # d'autant plus souvent que la lane etait active. - if args.json: - # Rouge et WIP se composent : le grain reste le premier rouge - # quand les deux declenchent (la reparation est la sequence la - # plus urgente), sinon c'est la PR la plus ancienne de la file - # WIP -- dans les deux cas le consommateur machine lit un grain - # et un nom de travail, pas un motif de refus. - assignment = None - grain = None - if red_hit: - assignment = "reparer-son-rouge" - grain = (backlog.get("red") or [None])[0] - if wip_hit: - assignment = ((assignment + "+") if assignment else "") + "drainer-son-wip" - grain = grain or (backlog.get("wip_prs") or [None])[0] + # Mode --belt (#18832) : on n'imprime PAS le rappel rouge/wip en + # standalone -- il sera fusionne dans l'objet JSON du tapis (cle + # `repair`) pour rendre UN SEUL document. Cf #18866 point 2. + assignment = None + grain = None + if red_hit: + assignment = "reparer-son-rouge" + grain = (backlog.get("red") or [None])[0] + if wip_hit: + assignment = ((assignment + "+") if assignment else "") + "drainer-son-wip" + grain = grain or (backlog.get("wip_prs") or [None])[0] + if args.belt and args.json: + # Conserve pour fusion dans la sortie tapis plus bas. + repair_payload = { + "assignment": assignment or "drainer-son-wip", + "grain": grain, + "red_hours": args.red_hours, + **backlog, + } + elif args.json: + # Mode nominal hors --belt : impression standalone du garde + # rouge/WIP, puis fin de la commande. print(json.dumps({"lane": args.lane, "mode": "repair", "assignment": assignment or "drainer-son-wip", "grain": grain, "red_hours": args.red_hours, "lane_record": lane_record, **backlog}, ensure_ascii=False, indent=2)) + return 0 else: if red_hit: print_red_assignment(args.lane, backlog, args.red_hours) @@ -5086,6 +5096,8 @@ def main(argv: list[str] | None = None) -> int: # Apres l'assignation : l'en-tete "FILE DE REPARATION" doit rester # la premiere ligne lue (test pinne), l'ardoise vient en rappel. print_lane_record(lane_record) + if not args.belt: + return 0 # #18832 spec : "les gardes restent en amont du tapis et produisent # LEUR sortie, jamais un silence". Mais le tapis ne refuse JAMAIS : # une lane avec un rouge doit quand meme recevoir la tete du tapis, @@ -5093,8 +5105,6 @@ def main(argv: list[str] | None = None) -> int: # coordinateur, review 5391008313, point 4). On ne return PAS ici en # mode --belt : on imprime le rappel rouge, puis on enchaƮne sur le # tapis qui produit ses grains. - if not args.belt: - return 0 # Mode --belt : on continue pour tirer la tete du tapis en plus du # rappel rouge/wip deja imprime. La sortie reste sans aucun vide. if not args.json: @@ -5152,16 +5162,23 @@ def main(argv: list[str] | None = None) -> int: # fetch repasse par le meme payload cache que fetch_series_visits : hit, # pas de requete gh supplementaire. Fenetre identique a celle de la # saturation, pour que les deux mesures se lisent ensemble. + # Mode --belt (#18832, #18866) : la fenetre par defaut (14 j) oublie les + # livraisons au-dela, et `belt_sort_key` reclasse alors l'issue a sa + # date de creation. On bascule sur `BELT_WINDOW_DAYS` (90 j), qui couvre + # un tour complet de la file au regime lent (10-20 grains/jour). La cle + # de cache integre `days` (cf fetch_merged identity), donc le payload + # 14 j et 90 j ne se chevauchent pas. + delivery_window_days = BELT_WINDOW_DAYS if args.belt else DEFAULT_WINDOW_DAYS umbrella_numbers = [it["number"] for it in pool if it["klass"] == "umbrella"] delivery_prs, delivery_fetch_err = fetch_merged( - DEFAULT_WINDOW_DAYS, + delivery_window_days, cache=payload_cache, cache_mode=effective_cache_mode, cache_status=cache_status, cache_ttl_seconds=SERIES_CACHE_TTL_SECONDS, ) delivery_sig = measure_delivery( - delivery_prs, umbrella_numbers, now=NOW, days=DEFAULT_WINDOW_DAYS, + delivery_prs, umbrella_numbers, now=NOW, days=delivery_window_days, fetch_error=delivery_fetch_err) delivery_weights = { num: delivery_factor(item["state"], item["age_days"], @@ -5341,12 +5358,21 @@ def main(argv: list[str] | None = None) -> int: } for it in belt_picks}, "withheld": [{"number": it["number"], "title": it["title"], "cause": c} for it, c in belt_withheld], - "last_delivery_window_days": DEFAULT_WINDOW_DAYS, + "last_delivery_window_days": delivery_window_days, "substance_drought": {"triggered": False, "measured": False, "run": 0, "mode": "belt-bypassed"}, "lane_record": lane_record, "cache": cache_status, } + # #18866 point 2 : la sortie --json rend UN document. Si le + # garde rouge/WIP s'est declenche, sa charge utile est fusionnee + # sous la cle `repair` (memes champs que le document standalone + # du mode non-belt). Sinon la cle est None, ce qui dit au + # consommateur que la lane n'a pas de reparation a faire. + if "repair_payload" in locals(): + out_belt["repair"] = repair_payload + else: + out_belt["repair"] = None print(json.dumps(out_belt, ensure_ascii=False, indent=2)) else: # Sortie texte : un tableau compact, aligne sur la volee diff --git a/scripts/series_saturation.py b/scripts/series_saturation.py index 10eb9d7b5d..776368aec5 100644 --- a/scripts/series_saturation.py +++ b/scripts/series_saturation.py @@ -50,6 +50,19 @@ # 28 aout : chaque jour, pris seul, avait l'air calme. DEFAULT_WINDOW_DAYS = 14 +# Mode --belt (cf #18832, #18866) : la fenetre par defaut (14 j) oublie les +# livraisons au-dela, et `belt_sort_key` reclasse alors l'issue a sa date +# de creation (comme si elle n'avait jamais ete servie). Une vieille issue +# servie il y a 15 a 30 jours passe devant une issue de juin-aout que +# personne n'a jamais servie, ce qui contredit la regle du tapis. La +# fenetre doit etre nettement plus longue qu'un tour complet de la file : +# avec ~100 grains/jour et ~500 issues ouvertes, un tour fait ~5 j, et +# une lane a regime lent (10-20 grains/jour) complete un tour en 25-50 j. +# 90 j couvrent les deux regimes. Le plafond `MERGED_FETCH_LIMIT = 400` +# borne le corpus de toute facon -- si la fenetre depasse 400 PRs, le +# tapis sert avec ce qui rentre, comme la volee ponderee aujourd'hui. +BELT_WINDOW_DAYS = 90 + # Amortissement plus mordant que celui par issue : une zone qui a deja recu # quatre notebooks neufs dans la quinzaine n'a pas besoin du cinquieme. SERIES_SCALE_DEFAULT = 2.0 diff --git a/scripts/tests/test_pick_idle_grain_belt.py b/scripts/tests/test_pick_idle_grain_belt.py index 3265ee7261..981939bf70 100644 --- a/scripts/tests/test_pick_idle_grain_belt.py +++ b/scripts/tests/test_pick_idle_grain_belt.py @@ -461,4 +461,132 @@ def test_belt_report_metrics_computes_max_gap(): assert sample == 2 assert closed_7d == 12 # max_gap est arrondi, on ne teste pas la valeur exacte mais le signe - assert max_gap is not None and max_gap > 0 \ No newline at end of file + assert max_gap is not None and max_gap > 0 + + +# ================================================================== +# Tests #18866 : mode --belt --json = un seul document JSON. +# La cle `repair` fusionne le rappel rouge/WIP qui etait sinon imprime +# en double (deux objets JSON sur la sortie standard). La fenetre +# `last_delivery_window_days` elargit a 90 j pour ne pas oublier les +# livraisons au-dela des 14 j par defaut. +# ================================================================== + + +def _state_red(): + """Retourne un etat GraphQL shape compatible `fetch_pr_states`.""" + return {"checks": [("PR gate", "FAILURE", True)], + "mergeable": "MERGEABLE", + "reviews": []} + + +def _patch_belt_network(monkeypatch, prs, red_state): + """Patche le strict minimum pour faire passer `main --belt --json` + jusqu'au bloc `out_belt` sans toucher au reseau. + + Le test reste en memoire : pas de `fetch_pool` reel (un pool vide + court-circuite la volee ponderee et le tapis no-op). `red_backlog` + reste fonctionnel : il voit 1 PR rouge de la lane, declenche le garde, + et le `repair_payload` est calcule pour la fusion. + """ + monkeypatch.setattr(pig, "fetch_open_prs", lambda: prs) + # fetch_pool = reseau reel (gh issue list). En mode test, on rend + # un pool vide pour court-circuiter la volee ponderee et garder + # la sortie compacte. + monkeypatch.setattr(pig, "fetch_pool", + lambda **k: ([], None)) + monkeypatch.setattr(pig, "fetch_pr_states", + lambda nums: {n: red_state for n in nums if n in {p["number"] for p in prs}}) + monkeypatch.setattr(pig, "unaddressed_review_points", lambda nums: {18844: 1} if prs else {}) + monkeypatch.setattr(pig, "fetch_lane_record_prs", lambda **k: ([], None)) + monkeypatch.setattr(pig, "fetch_main_head_probe", lambda *a, **k: None) + # Le tapis fait un check_claims : on rend toujours FREE. + monkeypatch.setattr(pig, "check_claims", + lambda nums, lane: {n: (pig.CLAIM_CODE_FREE, "libre") + for n in nums}) + + +def test_belt_json_emits_single_document_when_red_present(monkeypatch, capsys): + """`--belt --json` produit UN SEUL document JSON parseable. + + Avant le fix (#18866 point 2), la branche rouge du main() faisait + `print(json.dumps(...))` puis retournait 0 sans condition sur + `args.belt`. Le tapis re-imprimait son propre JSON juste apres. Le + consommateur lisait DEUX objets, et `json.loads` se cassait sur + `Extra data`. + + Apres le fix, en mode belt, le rappel rouge est mis sous la cle + `repair` du document du tapis, et la sortie reste UN document. + """ + red = _state_red() + prs = [{ + "number": 18844, + "title": "PR rouge de la lane", + "body": "Grain: MED/guard -- lane myia-po-2024:CoursIA-2", + "createdAt": "2026-09-30T12:00:00Z", + "isDraft": False, + }] + _patch_belt_network(monkeypatch, prs, red) + + rc = pig.main(["--lane", "myia-po-2024:CoursIA-2", + "--belt", "--json"]) + + assert rc == 0 + out = capsys.readouterr().out + # CRITIQUE : UN seul document JSON. Si le fix est casse, on a + # DEUX objets et `json.loads` leve `Extra data`. + payload = json.loads(out) + # `mode` est l'identifiant du tapis -- la fusion a bien eu lieu. + assert payload["mode"] == "belt" + # Le repair est fusionne (non-None) : le garde rouge s'est declenche. + assert payload["repair"] is not None + assert payload["repair"]["assignment"] == "reparer-son-rouge" + assert payload["repair"]["grain"]["number"] == 18844 + # La fenetre de livraisons en mode belt fait 90 j, pas 14 j. + assert payload["last_delivery_window_days"] == 90 + + +def test_belt_json_repair_key_absent_when_no_red(monkeypatch, capsys): + """`--belt --json` sans garde rouge : `repair` est None. + + Controle positif du test precedent : la cle `repair` existe + toujours (les consommateurs peuvent compter dessus), mais sa valeur + est None quand la lane n'a pas de reparation a faire, distinct + d'une cle absente (qui signalerait un schema inconsistant). + """ + _patch_belt_network(monkeypatch, prs=[], red_state=_state_red()) + + rc = pig.main(["--lane", "myia-po-2024:CoursIA-2", "--belt", "--json"]) + + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + assert payload["mode"] == "belt" + assert payload["repair"] is None + assert payload["last_delivery_window_days"] == 90 + + +def test_non_belt_json_red_still_emits_standalone_repair(monkeypatch, capsys): + """Regression check : hors `--belt`, le mode repair reste standalone. + + Sans ce controle, le refactor pourrait fusionner par erreur la cle + `repair` dans le mode non-belt et briser la volee ponderee. + L'ancien contrat -- `mode: "repair"`, pas de `mode: belt` -- est + preserve pour le consommateur de la volee. + """ + red = _state_red() + prs = [{ + "number": 18844, + "title": "PR rouge de la lane", + "body": "Grain: MED/guard -- lane myia-po-2024:CoursIA-2", + "createdAt": "2026-09-30T12:00:00Z", + "isDraft": False, + }] + _patch_belt_network(monkeypatch, prs, red) + + rc = pig.main(["--lane", "myia-po-2024:CoursIA-2", "--json"]) + + assert rc == 0 + payload = json.loads(capsys.readouterr().out) + # Le mode reste `repair`, pas `belt` : la volee ponderee est inchangee. + assert payload["mode"] == "repair" + assert payload["grain"]["number"] == 18844 \ No newline at end of file