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
70 changes: 66 additions & 4 deletions scripts/pick_idle_grain.py
Original file line number Diff line number Diff line change
Expand Up @@ -626,20 +626,38 @@ def drop_superseded(contexts: list[dict]) -> list[dict]:
return kept


def blocking_causes(state: dict) -> list[str]:
def blocking_causes(state: dict, *, age_hours: float | None = None,
saturation_hours: float | None = None) -> list[str]:
"""Causes qui empechent VRAIMENT le merge, formulees en geste de reparation.

`mergeStateStatus: BLOCKED` n'est deliberement PAS une cause : il vaut
aussi pour "en attente de review", que la lane ne peut pas lever -- c'est
au coordinateur de merger. Verifie firsthand sur #12108 le 2026-08-22 :
BLOCKED, MERGEABLE, zero check en echec. L'accuser aurait renvoye la lane
reparer une PR qui n'a rien a reparer.

`file_saturation` (issue #12830, mandant c.508-L2 + ai-01 c.1331p69) : le
3ᵉ etat distinct de `mergeStateStatus: BLOCKED` est `BLOCKED + MERGEABLE
+ statusCheckRollup.state=PENDING > saturation_hours`. La lane pioche sur
un pool virtuellement vide parce que ce 3ᵉ etat n'etait pas declencheur
(mesure ai-01 du 2026-08-26T04:52Z : 1000 runs en file, 14 concurrents,
attente observee 4 h 25 -- c'est le regime nominal, plus un cas limite).
Le critere exige qu'AUCUN check n'ait demarre (PENDING/QUEUED partout)
sur une PR MERGEABLE : c'est exactement la file qui n'a pas bouge, pas
un rouge substance. La cause est formulee comme geste = commentaire
+ `--ignore-red` ou `rerun/updater-branch` selon le cas -- la lane peut
poser un acte (commenter) mais ne peut pas derainer la file seule.

Le critere reste PASSIF si `age_hours` ou `saturation_hours` ne sont pas
fournis (defaut=None), ce qui preserve la signature utilisee par les 12
tests existants (cf `_state(...)` qui ne porte pas `age_hours`).
"""
causes: list[str] = []
advisory: list[str] = []
commits = state.get("commits", {}).get("nodes") or []
rollup = (commits[0]["commit"].get("statusCheckRollup") if commits else None) or {}
for ctx in drop_superseded((rollup.get("contexts", {}) or {}).get("nodes") or []):
contexts = drop_superseded((rollup.get("contexts", {}) or {}).get("nodes") or [])
for ctx in contexts:
name = ctx.get("name") or ctx.get("context") or "?"
verdict = (ctx.get("conclusion") or ctx.get("state") or "").upper()
if verdict not in CHECK_FAILED:
Expand All @@ -659,6 +677,24 @@ def blocking_causes(state: dict) -> list[str]:
for login, review in latest.items():
if review["state"] == "CHANGES_REQUESTED":
causes.append(f"CHANGES_REQUESTED non leve ({login})")
# 3ᵉ declencheur `file_saturation` (cf issue #12830) : aucun check n'a
# demarre (PENDING/QUEUED partout), pas de conflit, pas de CHANGES_REQUESTED
# non leve, et la PR est ouverte depuis plus de `saturation_hours`.
# On ne l'ajoute que si rien d'autre n'a deja ete trouve : un rouge
# substance prime sur la file-saturation (la lane reparera la substance,
# la file draine naturellement).
if (age_hours is not None and saturation_hours is not None
and age_hours >= saturation_hours
and state.get("mergeable") == "MERGEABLE"
and not causes
and contexts):
statuses = {(c.get("conclusion") or c.get("state") or "").upper() for c in contexts}
if statuses <= {"PENDING", "QUEUED", ""}:
causes.append(
f"file_saturation : {len(contexts)} check(s) tous pending depuis > "
f"{int(saturation_hours)}h (faux-rouge non-reparable par la lane -- "
f"commenter la PR + --ignore-red ou rerun/updater-branch si gel CI)"
)
if causes and advisory:
causes.append("(diagnostic, non bloquant : " + ", ".join(advisory[:3]) + ")")
return causes
Expand Down Expand Up @@ -778,6 +814,16 @@ def red_backlog(lane: str, threshold_hours: float,
des points a traiter »). Le declencheur `nits` rend cette regle explicite
au lieu de la laisser dependre d'un filtre retire ailleurs.

`file_saturation` (issue #12830) : au moins une PR file-saturee (cf
`blocking_causes` pour le critere exact) ouverte depuis plus de
`threshold_hours`. C'est le 3ᵉ etat distinct de `mergeStateStatus:
BLOCKED` (le `BLOCKED + MERGEABLE + checks tous PENDING depuis > N h`)
que le picker ratait jusqu'ici, et la lane po-2027 en etait la premiere
victime : narrow persistant ×27 cycles dont la file CI etait la cause
jamais diagnostiquee. Le declencheur precede `aged` parce qu'il est
l'attribution directe du narrow : la lane doit SAVOIR qu'elle subit la
file avant de tenter d'autres rouges.

Rend aussi `unattributed_blocked` : les PRs bloquees dont le tag `Grain:`
est illisible. Elles ne peuvent bloquer AUCUNE lane -- c'est la bonne
arithmetique (deviner une lane serait pire) -- mais les taire donnerait a
Expand Down Expand Up @@ -815,7 +861,12 @@ def red_backlog(lane: str, threshold_hours: float,
state = states.get(pr["number"])
if state is None:
continue
causes = blocking_causes(state)
# age_hours sert au critere file_saturation (cf blocking_causes). On le
# passe ici plutot que dans la query GraphQL parce que le fragment
# `_PR_STATE_FRAGMENT` ne porte pas `createdAt` et l'ajouter alourdirait
# chaque appel pour 2 octets deconomie ; le PR-listing le fournit deja.
age = _hours_since(pr["createdAt"])
causes = blocking_causes(state, age_hours=age, saturation_hours=threshold_hours)
n_nits = nits_by_pr.get(pr["number"], 0)
if n_nits:
# Un point de review non leve est une cause A PART ENTIERE : la PR
Expand All @@ -831,7 +882,7 @@ def red_backlog(lane: str, threshold_hours: float,
causes.append(sat_cause)
if causes:
red.append({"number": pr["number"], "title": pr["title"],
"age_hours": round(_hours_since(pr["createdAt"])),
"age_hours": round(age),
"causes": causes})
red.sort(key=lambda r: -r["age_hours"])

Expand All @@ -840,6 +891,17 @@ def red_backlog(lane: str, threshold_hours: float,
# D'abord dans la liste : c'est l'ordre dans lequel le mandat du
# 2026-08-24 veut que la lane les traite.
triggers.append("nits")
# `file_saturation` precede `aged` : une PR file-saturee est l'attribution
# directe d'un narrow de file CI (cf issue #12830), et la lane doit
# distinguer ce cas des rouges substance. Le critere dans `blocking_causes`
# exige que rien d'autre ne soit deja en cause -- si la PR a un FAIL
# substance ET est en file-saturation, elle reste categorisee rouge
# substance (cause FAIL gagne).
file_saturated = [r for r in red
if any("file_saturation" in c for c in r["causes"])
and r["age_hours"] >= threshold_hours]
if file_saturated:
triggers.append("file_saturation")
aged = [r for r in red if r["age_hours"] >= threshold_hours]
if aged:
triggers.append("aged")
Expand Down
166 changes: 166 additions & 0 deletions scripts/tests/test_pick_idle_grain.py
Original file line number Diff line number Diff line change
Expand Up @@ -516,6 +516,172 @@ def test_repeated_identical_failures_are_named_once():
assert pig.blocking_causes(state) == ["check requis en echec : PR gate"]


# --- file_saturation : 4ᵉ declencheur (issue #12830, c.508-L2) -------------
#
# Le defaut fondateur : la lane po-2023 (puis po-2027) tirait dans un pool
# virtuellement vide parce que `mergeStateStatus: BLOCKED + MERGEABLE +
# checks tous PENDING depuis > N h` n'etait PAS un declencheur de refus.
# Le 3ᵉ etat etait mecaniquement non-different d'un `BLOCKED + MERGEABLE
# + checks SUCCESS` (attente coordinateur), qu'on veut deliberement laisser
# passer. La discrimination tient en 3 proprietes : AUCUN check n'a demarre
# (statut dans {PENDING, QUEUED}), la PR est MERGEABLE, et elle est ouverte
# depuis plus de `saturation_hours`. Mesure ai-01 du 2026-08-26T04:52Z :
# 1000 runs en file, 14 concurrents, attente observee 4 h 25.


def _state_with_started_at(*, checks=(), mergeable="MERGEABLE", reviews=()):
"""Comme `_state` mais chaque check porte `startedAt` ET `conclusion`/`state`.

Le format GraphQL reel de `statusCheckRollup.contexts` inclut `startedAt`
pour les `CheckRun` et `state` (PAS `conclusion`) pour les `StatusContext`
encore en attente. Le discriminateur file_saturation lit `conclusion` puis
`state` -- d'ou les helpers ci-dessous qui couvrent les deux formes.
"""
return {
"number": 1, "mergeable": mergeable,
"reviews": {"nodes": [
{"state": s, "submittedAt": "2026-08-20T00:00:00Z", "author": {"login": a}}
for s, a in reviews
]},
"commits": {"nodes": [{"commit": {"statusCheckRollup": {"contexts": {"nodes": [
{"name": t[0], "conclusion": t[1], "state": t[1],
"isRequired": t[2], "startedAt": "2026-08-25T00:00:00Z"}
for t in checks
]}}}}]},
}


def test_file_saturation_detected_when_all_checks_pending():
"""Cas fondateur (c.508-L2 sur #12640) : tous les checks en PENDING depuis
> N h, MERGEABLE, pas de conflit, pas de CHANGES_REQUESTED -- c'est
exactement la file qui n'a pas bouge, pas un rouge substance.

Discrimination exigee : un seul FAIL vivant masquerait la file-saturation,
parce que la lane peut le reparer et la file draine naturellement.
"""
state = _state_with_started_at(checks=[
("PR gate", "PENDING", True),
("Scripts Tests (CPU)", "PENDING", True),
("Notebook Validation", "PENDING", False),
])
causes = pig.blocking_causes(state, age_hours=28, saturation_hours=24)
assert any("file_saturation" in c for c in causes)
# La cause doit nommer le geste -- la lane peut commenter, pas rerun seule.
assert any("commenter la PR" in c for c in causes)


def test_file_saturation_not_detected_when_a_check_is_success():
"""Faux-positif a eviter : un SUCCESS + des PENDING n'est PAS de la
file-saturation. La PR a au moins un verdict defini ; elle est en cours
de merge, pas en attente de derainage de file.
"""
state = _state_with_started_at(checks=[
("PR gate", "PENDING", True),
("Scripts Tests (CPU)", "SUCCESS", True),
])
assert pig.blocking_causes(state, age_hours=28, saturation_hours=24) == []


def test_file_saturation_not_detected_when_a_check_fails():
"""Faux-positif a eviter : un FAIL substance prime sur la file-saturation.

La lane peut reparer la substance ; la file draine naturellement apres
re-push. Le discriminateur `not causes` dans `blocking_causes` protege
exactement ce cas (les causes FAIL sont ajoutees avant la detection
file_saturation).
"""
state = _state_with_started_at(checks=[
("PR gate", "FAILURE", True),
("Scripts Tests (CPU)", "PENDING", True),
])
causes = pig.blocking_causes(state, age_hours=28, saturation_hours=24)
# Le FAIL gagne : file_saturation ne s'y ajoute pas, sinon la cause serait
# `conflitante` et le geste suggere (commenter + ignore-red) perdrait
# l'option reparation.
assert any("check requis en echec" in c for c in causes)
assert not any("file_saturation" in c for c in causes)


def test_file_saturation_not_detected_below_saturation_threshold():
"""L'age seul ne suffit pas : une PR jeune avec tous checks PENDING est
dans la queue normale du depot, pas en saturation.

Cf le mandat du 2026-08-24 sur le retrait du filtre d'age amont : c'est
ici un garde-fou qui empeche la sur-accusation (mesure : 52/55 PRs le
2026-08-22 pour la definition naive). Le seuil minimum est le meme que
pour `aged` (`threshold_hours`), ce qui garde la coherence.
"""
state = _state_with_started_at(checks=[
("PR gate", "PENDING", True),
("Scripts Tests (CPU)", "PENDING", True),
])
# age < saturation : rien.
assert pig.blocking_causes(state, age_hours=2, saturation_hours=24) == []
# age == saturation : strictement au-dessus (>=) declenche.
causes = pig.blocking_causes(state, age_hours=24, saturation_hours=24)
assert any("file_saturation" in c for c in causes)


def test_file_saturation_not_detected_when_not_mergeable():
"""CONFLICTING prime sur la file-saturation : la lane peut rebaser, ce
n'est plus un rouge de file.

Le discriminateur exige `mergeable == MERGEABLE`. Une PR en conflit
attend un rebase, pas un drainage CI.
"""
state = _state_with_started_at(
checks=[("PR gate", "PENDING", True)],
mergeable="CONFLICTING",
)
causes = pig.blocking_causes(state, age_hours=28, saturation_hours=24)
assert "conflits avec main -> rebaser" in causes
assert not any("file_saturation" in c for c in causes)


def test_file_saturation_trigger_in_red_backlog(monkeypatch):
"""Le 4ᵉ declencheur remonte dans `triggers` quand au moins une PR de la
lane est file-saturee et a l'age du seuil.

Ce test est l'integration : `red_backlog` doit appeler `blocking_causes`
avec `age_hours` et `saturation_hours` derives du seuil, et ajouter
`"file_saturation"` aux triggers. C'est ce qui rend le narrow
diagnostique enfin visible a la lane.
"""
red = _state_with_started_at(checks=[
("PR gate", "PENDING", True),
("Scripts Tests (CPU)", "PENDING", True),
])
_patch_backlog(monkeypatch, [
_pr(1, "myia-po-2026:CoursIA", 28),
_pr(2, "myia-po-2026:CoursIA", 2),
], {1: red, 2: red})
out = pig.red_backlog("myia-po-2026:CoursIA", 24, count_threshold=10)
# La PR #1 est file-saturee (28 h > 24 h, tous PENDING, MERGEABLE).
# La PR #2 ne l'est pas (2 h < 24 h).
assert "file_saturation" in out["triggers"]
assert any(any("file_saturation" in c for c in r["causes"])
for r in out["red"] if r["number"] == 1)
# Et l'ordre respecte la docstring : file_saturation precede `aged`
# dans la liste des triggers (les deux sont vrais ici, file_saturation
# doit apparaitre AVANT `aged`).
assert out["triggers"].index("file_saturation") < out["triggers"].index("aged")


def test_file_saturation_signature_backward_compatible():
"""Le defaut `age_hours=None, saturation_hours=None` preserve les appels
existants : un PR avec tous checks PENDING et sans ces parametres ne
declenche PAS file_saturation. C'est ce qui permet aux 12 tests
historiques de `blocking_causes` de rester verts sans modification.
"""
state = _state_with_started_at(checks=[
("PR gate", "PENDING", True),
("Scripts Tests (CPU)", "PENDING", True),
])
# Meme PR que test_file_saturation_detected_when_all_checks_pending,
# mais sans age_hours : la cause ne doit PAS apparaitre.
assert pig.blocking_causes(state) == []


# --- affluence flotte : cited_issues / fetch_visits / amortissement --------
#
# Le defaut que ces tests auraient attrape (2026-08-23, avant merge) : la
Expand Down
Loading