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
64 changes: 45 additions & 19 deletions scripts/pick_idle_grain.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand All @@ -5086,15 +5096,15 @@ 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,
# et la sortie ne doit jamais etre sans grain (cf CHANGES_REQUESTED
# 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:
Expand Down Expand Up @@ -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"],
Expand Down Expand Up @@ -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
Expand Down
13 changes: 13 additions & 0 deletions scripts/series_saturation.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
130 changes: 129 additions & 1 deletion scripts/tests/test_pick_idle_grain_belt.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
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
Loading