From 439a9692872090bc81961dd2a511f1d59794e510 Mon Sep 17 00:00:00 2001 From: jsboige Date: Wed, 28 Oct 2026 15:03:22 +0100 Subject: [PATCH] Fix(coordination,#23115): belt_queues board survit a une issue illisible MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit L'action `board` perdait le tableau entier des qu'une seule issue de l'assiette ne pouvait pas etre lue : `gh_issue_state` leve sur `check=True`, `measure_plates` boucle sans `try/except`, et `main` rendait 2 avant le `print` du board. Reproduction reelle (binaire `gh`, issue inexistante dans l'assiette) : AVANT : UNKNOWN: belt_queues injoignable (CalledProcessError) rc=2, aucun tableau APRES : tableau complet + "#999999999 — gh rc=1 : Could not resolve..." rc=0 L'echec est isole par item et **nomme** ; l'issue illisible reste en assiette (fail-closed : une issue qu'on n'a pas pu lire ne peut pas etre declaree servie) ; le reste du tableau s'affiche. Un tour de recette qui echoue pour une lane degrade cette lane en `UNKNOWN` au lieu de tuer le tableau des autres. La mesure globale reste un UNKNOWN honnete quand aucune mesure n'est possible (binaire `gh` absent, ou toutes les issues illisibles) -- le message nomme alors les issues concernees. Co-Authored-By: Claude Haiku 5.5 --- scripts/coordination/belt_queues.py | 78 +++++++++++++++++++-- scripts/tests/test_belt_queues.py | 103 ++++++++++++++++++++++++++++ 2 files changed, 174 insertions(+), 7 deletions(-) diff --git a/scripts/coordination/belt_queues.py b/scripts/coordination/belt_queues.py index 7786598e3b2..6bdb0c0461f 100644 --- a/scripts/coordination/belt_queues.py +++ b/scripts/coordination/belt_queues.py @@ -137,20 +137,51 @@ def lane_served_since(payload: dict, lane: str, since: str, def measure_plates(previous: dict, fetch_issue, pr_states: dict[int, str] | None = None) -> dict[str, dict]: - """Ce qui reste dans l'assiette de chaque lane depuis la repartition precedente.""" + """Ce qui reste dans l'assiette de chaque lane depuis la repartition precedente. + + Une issue **illisible** n'interrompt pas la mesure (#23115) : l'echec est + consigne et nomme, l'issue **reste en assiette** (fail-closed — une issue + qu'on n'a pas pu lire ne peut pas etre declaree servie), et les autres + items sont mesures normalement. Seul le binaire ``gh`` absent remonte + (c'est un incident global, pas un incident de lecture). + """ since = previous["assigned_at"] plates = {} for lane, info in previous["lanes"].items(): remaining = [] + unreadable = [] for it in info["queue"]: - payload = fetch_issue(it["number"]) + try: + payload = fetch_issue(it["number"]) + except (subprocess.CalledProcessError, json.JSONDecodeError) as exc: + unreadable.append({"number": it["number"], "error": error_label(exc)}) + remaining.append(it) + continue if payload.get("state", "OPEN").upper() != "OPEN" or lane_served_since(payload, lane, since, pr_states): continue remaining.append(it) - plates[lane] = {"assigned": len(info["queue"]), "remaining": remaining, "arc": info.get("arc")} + plates[lane] = {"assigned": len(info["queue"]), "remaining": remaining, + "arc": info.get("arc"), "unreadable": unreadable} return plates +def error_label(exc: BaseException) -> str: + """Cause courte et nommable d'un echec de lecture (#23115). + + Le message doit permettre de reconnaitre l'incident sans ouvrir les + journaux : numero de l'issue cote appelant, cause ici. + """ + if isinstance(exc, subprocess.CalledProcessError): + stderr = (exc.stderr or "").strip().splitlines() + tail = stderr[-1][:160] if stderr else "" + return f"gh rc={exc.returncode}" + (f" : {tail}" if tail else "") + if isinstance(exc, json.JSONDecodeError): + return f"json illisible ({exc.msg})" + if isinstance(exc, FileNotFoundError): + return "binaire gh introuvable" + return type(exc).__name__ + + def gh_issue_state(number: int) -> dict: out = subprocess.run(["gh", "issue", "view", str(number), "--repo", rd.REPO, "--json", "number,state,comments"], @@ -159,21 +190,38 @@ def gh_issue_state(number: int) -> dict: def board(belt: dict, lanes: list[str], previous: dict | None, now: datetime, - fetch_issue=gh_issue_state, recette=None) -> dict: + fetch_issue=None, recette=None) -> dict: + # `fetch_issue` est resolu a l'appel (et non a la definition) pour que le + # defaut reste le vrai lecteur `gh` tout en restant substituable en test. + fetch_issue = fetch_issue or gh_issue_state plates = measure_plates(previous, fetch_issue) if previous else {} on_plate = {it["number"] for p in plates.values() for it in p["remaining"]} picks = [p for p in belt.get("picks", []) if p["number"] not in on_plate] + incidents: list[dict] = [] out_lanes = {} for lane in list(dict.fromkeys(lanes + list(plates))): plate = plates.get(lane) + for inc in (plate["unreadable"] if plate else []): + incidents.append({"kind": "issue", "lane": lane, **inc}) + rec = None + if recette: + try: + rec = recette(lane) + except (subprocess.CalledProcessError, json.JSONDecodeError) as exc: + # La recette d'une lane ne doit pas tuer le tableau des autres + # (#23115) : elle est declaree illisible, elle est nommee. + rec = {"verdict": "UNKNOWN", "reason": error_label(exc)} + incidents.append({"kind": "recette", "lane": lane, "number": None, + "error": error_label(exc)}) out_lanes[lane] = { "assigned": plate["assigned"] if plate else 0, "remaining": plate["remaining"] if plate else [], "arc": plate["arc"] if plate else None, - "recette": recette(lane) if recette else None, + "recette": rec, } return {"drawn": len(belt.get("picks", [])), "families": group_picks(picks, now), "lanes": out_lanes, - "previous_at": previous["assigned_at"] if previous else None} + "previous_at": previous["assigned_at"] if previous else None, + "unreadable": incidents} def render_board(b: dict) -> str: @@ -192,6 +240,12 @@ def render_board(b: dict) -> str: rec = info["recette"] or {} rec_txt = f" | recette {rec.get('verdict')}" + (f" #{rec['issue']}" if rec.get("verdict") == "DUE" else "") lines.append(f" {lane} : {served}/{info['assigned']} rendues, assiette {rest}{rec_txt if rec else ''}") + unreadable = b.get("unreadable") or [] + if unreadable: + lines += ["", "# Lectures impossibles (la mesure a echoue pour ces la)"] + for inc in unreadable: + what = f"#{inc['number']}" if inc.get("number") else "tour de recette" + lines.append(f" {inc['lane']} : {what} — {inc['error']}") return "\n".join(lines) @@ -272,7 +326,17 @@ def recette(lane: str) -> dict: return rd.run(lane, rd.DEFAULT_WINDOW_DAYS, now, list_issues=lambda: listed, fetch_issue=fetch) result = board(_read_json(args.belt_json), lanes, _load_previous(args.previous), now, recette=recette) except (subprocess.CalledProcessError, FileNotFoundError, json.JSONDecodeError) as exc: - print(f"UNKNOWN: belt_queues injoignable ({type(exc).__name__})") + print(f"UNKNOWN: belt_queues injoignable ({type(exc).__name__}) — {error_label(exc)}") + return 2 + unreadable_issues = [i for i in result.get("unreadable") or [] if i["kind"] == "issue"] + assigned = sum(info.get("assigned", 0) for info in result["lanes"].values()) + if assigned and len(unreadable_issues) >= assigned: + # Toutes les assiettes sont illisibles : le tableau ne mesurerait rien. + # UNKNOWN honnete, nomme — plutot qu'un tableau vide qui se lit comme + # « aucune issue servie » (#23115). + first = unreadable_issues[0] + print(f"UNKNOWN: belt_queues illisible ({len(unreadable_issues)}/{assigned} issues de " + f"l'assiette non lisibles) — premiere : #{first['number']} ({first['error']})") return 2 print(json.dumps(result, ensure_ascii=False, indent=1) if args.json else render_board(result)) return 0 diff --git a/scripts/tests/test_belt_queues.py b/scripts/tests/test_belt_queues.py index a9889dfc79f..6f018b6d6a2 100644 --- a/scripts/tests/test_belt_queues.py +++ b/scripts/tests/test_belt_queues.py @@ -11,6 +11,8 @@ """ from __future__ import annotations +import json +import subprocess import sys from datetime import datetime, timezone from pathlib import Path @@ -155,6 +157,107 @@ def test_board_without_previous_assignment(): assert res["previous_at"] is None and res["lanes"][A]["assigned"] == 0 +# --- lectures impossibles (#23115) ------------------------------------------------ +# +# Une issue illisible ne doit pas tuer le tableau : elle est NOMMEE, elle reste +# en assiette (fail-closed), et les autres items sont mesures. Avant la +# correction, le premier `gh issue view` en echec remontait jusqu'au `try` de +# `main` et le board entier etait perdu (`UNKNOWN: belt_queues injoignable`). + + +def _boom(number): + raise subprocess.CalledProcessError( + 1, ["gh", "issue", "view", str(number)], + stderr="Could not resolve to an Issue with the number of 11.\n", + ) + + +def test_une_issue_illisible_ne_tue_pas_la_mesure(): + payloads = {10: _payload(10), 20: _payload(20)} + + def fetch(n): + if n == 11: + _boom(n) + return payloads[n] + + plates = bq.measure_plates(PREV, fetch) + # l'issue illisible reste en assiette (fail-closed) et n'est pas comptee rendue + assert [it["number"] for it in plates[A]["remaining"]] == [10, 11] + assert [it["number"] for it in plates[B]["remaining"]] == [20] + assert [i["number"] for i in plates[A]["unreadable"]] == [11] + assert "rc=1" in plates[A]["unreadable"][0]["error"] + + +def test_le_board_nomme_les_issues_illisibles_et_affiche_le_reste(): + payloads = {10: _payload(10), 20: _payload(20)} + + def fetch(n): + if n == 11: + _boom(n) + return payloads[n] + + belt = {"picks": [_pick(30, "[X] a"), _pick(31, "[Z] b")]} + res = bq.board(belt, [A, B], PREV, NOW, fetch_issue=fetch) + assert [(i["lane"], i["number"]) for i in res["unreadable"]] == [(A, 11)] + # le reste du tableau est bien mesure + assert [it["number"] for g in res["families"] for it in g["items"]] == [30, 31] + assert [it["number"] for it in res["lanes"][A]["remaining"]] == [10, 11] + text = bq.render_board(res) + assert "#11" in text and "Lectures impossibles" in text + assert "myia-po-2023:CoursIA" in text + + +def test_un_echec_de_recette_ne_tue_pas_le_tableau(): + payloads = {10: _payload(10), 11: _payload(11), 20: _payload(20)} + + def recette(lane): + if lane == B: + _boom(0) + return {"verdict": "OK", "issue": 17586} + + res = bq.board({"picks": [_pick(30, "[X] a")]}, [A, B], PREV, NOW, + fetch_issue=payloads.__getitem__, recette=recette) + assert res["lanes"][B]["recette"]["verdict"] == "UNKNOWN" + assert res["lanes"][A]["recette"]["verdict"] == "OK" + assert [(i["kind"], i["lane"]) for i in res["unreadable"]] == [("recette", B)] + + +def test_le_board_signale_une_assiette_entierement_illisible(tmp_path, capsys, monkeypatch): + belt = tmp_path / "belt.json" + belt.write_text(json.dumps({"picks": [_pick(30, "[X] a")]}), encoding="utf-8") + prev = tmp_path / "prev.json" + prev.write_text(json.dumps({"assigned_at": "2026-10-06T09:43:00Z", + "lanes": {A: {"arc": "X", "queue": [{"number": 10}]}}}), + encoding="utf-8") + monkeypatch.setattr(bq, "gh_issue_state", _boom) + rc = bq.main(["board", "--belt-json", str(belt), "--previous", str(prev), "--no-recette"]) + out = capsys.readouterr().out + assert rc == 2 + assert "UNKNOWN" in out and "#10" in out + + +def test_le_board_survit_a_une_issue_illisible(tmp_path, capsys, monkeypatch): + belt = tmp_path / "belt.json" + belt.write_text(json.dumps({"picks": [_pick(30, "[X] a")]}), encoding="utf-8") + prev = tmp_path / "prev.json" + prev.write_text(json.dumps({"assigned_at": "2026-10-06T09:43:00Z", + "lanes": {A: {"arc": "X", "queue": [{"number": 10}]}, + B: {"arc": "Y", "queue": [{"number": 20}]}}}), + encoding="utf-8") + + def fetch(n): + if n == 10: + _boom(n) + return _payload(n) + + monkeypatch.setattr(bq, "gh_issue_state", fetch) + rc = bq.main(["board", "--belt-json", str(belt), "--previous", str(prev), "--no-recette"]) + out = capsys.readouterr().out + assert rc == 0 + assert "#10" in out and "Lectures impossibles" in out + assert "myia-po-2027:CoursIA-2" in out + + # --- record ------------------------------------------------------------------------- def test_record_stamps_and_keeps_order_and_arc():