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
78 changes: 71 additions & 7 deletions scripts/coordination/belt_queues.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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:
Expand All @@ -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)


Expand Down Expand Up @@ -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
Expand Down
103 changes: 103 additions & 0 deletions scripts/tests/test_belt_queues.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@
"""
from __future__ import annotations

import json
import subprocess
import sys
from datetime import datetime, timezone
from pathlib import Path
Expand Down Expand Up @@ -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():
Expand Down
Loading