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
34 changes: 34 additions & 0 deletions scripts/ci/merge_dwell.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@
from __future__ import annotations

import json
import re
import subprocess
from datetime import datetime, timedelta, timezone

Expand All @@ -156,6 +157,20 @@
#: Label qui leve le plancher sur une PR donnee.
WAIVER_LABEL = "merge-dwell-waived"

#: Forme du message « plancher non ecoule », pour les consommateurs qui ne
#: peuvent pas reevaluer le plancher eux-memes. Le picker en est un : il ne voit
#: que le TEXTE du gate (ni date de committer ni labels sous la main), et il a
#: besoin de distinguer « ce rouge est un minuteur » de « ce rouge est un
#: defaut ». La forme vit ici, avec l'emetteur, parce qu'une copie chez le
#: lecteur deriverait en silence -- le lecteur cesserait de matcher et le rouge
#: DWELL redeviendrait un grain dit reparable sans qu'aucun test ne rougisse.
_DWELL_PENDING_RE = re.compile(
r"tete du (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)[^\n]*?"
r"plancher (\d+) min[^\n]*?"
r"reste (\d+) min[^\n]*?"
r"ecoule a (\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z)"
)


def _next_sweep_after(floor: datetime) -> datetime:
"""Le premier instant `SWEEP_MINUTE:00:00Z` strictement posterieur a `floor`.
Expand Down Expand Up @@ -253,6 +268,25 @@ def evaluate(
)


def parse_pending_message(message: str) -> "dict | None":
"""Champs du plancher NON ecoule dans un message de `evaluate`, ou None.

Inverse de la branche « plancher en cours » de `evaluate` : c'est ce que lit
un consommateur qui n'a pas de quoi recalculer le plancher lui-meme (#15910).

``None`` couvre les DEUX autres verdicts du gate -- plancher ecoule et
derogation par label -- et tout texte etranger. Les distinguer importe : un
plancher ecoule est un rouge qui va tomber seul au prochain balayage, une
derogation dit que le plancher ne mord pas du tout, et aucun des deux n'est
« un plancher de 0 minute ».
"""
match = _DWELL_PENDING_RE.search(message or "")
if not match:
return None
return {"head_at": match.group(1), "dwell_min": int(match.group(2)),
"remaining_min": int(match.group(3)), "lift_at": match.group(4)}


def _gh_json(path: str) -> object:
try:
completed = subprocess.run(
Expand Down
152 changes: 151 additions & 1 deletion scripts/pick_idle_grain.py
Original file line number Diff line number Diff line change
Expand Up @@ -1804,6 +1804,14 @@ def recent_delivery(picks: list[dict]) -> dict[int, str]:
# Banniere finale de l'agregateur always-on-guards.yml : l'organe en echec
# vit dans l'ANNOTATION du check-run, pas dans son nom.
_ORGAN_BANNER_RE = re.compile(r"Organes bloquants en echec\s*:\s*([a-z_ ]+?)\s*(?:\(|$)")
# #15910 : un agregateur rouge **par DWELL** n'a AUCUN organe en echec -- il n'y
# a rien a reparer, la cause est un minuteur. `pr_gate.py` dit deja le bon
# verdict et le rend dans l'annotation du check-run ; on lit ce texte pour
# distinguer « il n'y avait rien a lire » de « je n'ai pas pu lire ». Sans lui,
# `fetch_check_organs` rend [] dans les deux cas et le rouge retombe sur la lane.
# La FORME du message n'est pas re-decrite ici : elle appartient a son emetteur
# (`scripts/ci/merge_dwell.py`), qui en expose l'inverse (cf
# `_dwell_message_parser`). Une copie locale deriverait en silence.

_PR_STATE_FRAGMENT = """
p%(n)d: pullRequest(number:%(n)d) {
Expand Down Expand Up @@ -2053,6 +2061,58 @@ def fetch_gate_cut_evidence(check_run_id: int) -> tuple[list[str], list[str]]:
return failed, cut


def fetch_check_dwell(check_run_id: int) -> dict | None:
"""Echeance d'un plancher de DWELL, ou None si l'annotation n'en porte pas.

#15910 : un agregateur rouge par DWELL n'a **aucun organe** tombe -- le
plancher vaut 120 min et seul l'ecoulement du temps le leve. Sans cette
lecture, ``fetch_check_organs`` rend ``[]`` et l'appelant confond « rien a
lire » (le DWELL est la cause) avec « pas pu lire » (fail-closed, rouge
rendu a la lane) : la lane brulait son cycle a chercher dans son diff une
cause inexistante, et trois PRs poussees dans la meme fenetre suffisaient a
declencher P0 par le seul minuteur.

Le FORMAT du message n'est pas re-decrit ici : il appartient a
`scripts/ci/merge_dwell.py`, qui l'emet (`evaluate`) et qui en expose
l'inverse (`parse_pending_message`). Une copie locale deriverait en silence.

Best-effort comme son voisin : annotation illisible -> ``None``, et
l'appelant retombe sur le fail-closed (le rouge reste a la lane).
"""
parser = _dwell_message_parser()
if parser is None:
return None
try:
raw = subprocess.run(
["gh", "api", f"repos/{REPO}/check-runs/{check_run_id}/annotations"],
capture_output=True, text=True, encoding="utf-8", check=True, timeout=60,
).stdout
annotations = json.loads(raw)
except Exception: # noqa: BLE001 - reseau/parse : jamais un crash de picker
return None
for ann in annotations or []:
parsed = parser(ann.get("message") or "")
if parsed:
return parsed
return None


def _dwell_message_parser():
"""`merge_dwell.parse_pending_message`, ou None si l'import est impossible.

Import tardif et defensif, comme `_is_adjacency_red` : l'organe est
optionnel, une `ImportError` ici ne doit pas casser le tirage -- elle doit
seulement rendre le DWELL illisible, c'est-a-dire retomber sur le
fail-closed d'avant #15910 (le rouge reste a la lane).
"""
try:
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent / "ci"))
from merge_dwell import parse_pending_message # noqa: PLC0415 - import tardif
return parse_pending_message
except Exception: # noqa: BLE001 - organe optionnel, picker robuste
return None


def failed_check_keys(ctx: dict, organ_cache: dict) -> list[str]:
"""Cles de CAUSE d'un check rouge, pas de son nom de job (#14537, #14567).

Expand Down Expand Up @@ -2215,6 +2275,7 @@ def blocking_causes(state: dict, *, age_hours: float | None = None,
saturation_hours: float | None = None,
inherited: set[str] | None = None,
resolved_keys_by_name: dict[str, set[str]] | None = None,
dwell_by_name: dict[str, dict] | None = None,
gate_evidence: dict[str, tuple[list[str], list[str]]] | None = None,
) -> list[str]:
"""Causes qui empechent VRAIMENT le merge, formulees en geste de reparation.
Expand Down Expand Up @@ -2263,6 +2324,14 @@ def blocking_causes(state: dict, *, age_hours: float | None = None,
if cause not in causes:
causes.append(cause)
continue
if dwell_by_name and name in dwell_by_name:
# #15910 : ce rouge est un MINUTEUR, pas un defaut. Le gate le dit
# dans son texte (plancher de 120 min) ; le compter comme « check
# requis en echec » envoyait la lane chercher dans son diff une
# cause qui n'existe pas. Le seul geste correct est l'attente -- on
# ne fabrique donc aucune cause, et le declencheur `count` ne peut
# plus basculer tout le cycle sur une reparation inexistante.
continue
if inherited:
# #13545/#14537 : rouge impute a la base (cause commune corroboree
# chez >=2 lanes distinctes) -- pas reparable par cette lane. Pour
Expand Down Expand Up @@ -2662,7 +2731,7 @@ def red_backlog(lane: str, threshold_hours: float,
return {"unavailable": f"{type(exc).__name__}", "red": [],
"triggers": [], "unattributed_blocked": [],
"nits_unavailable": None, "base_inherited": [],
"base_unresolved": [],
"base_unresolved": [], "dwell_waiting": [],
"saturation_hours": sat_threshold}

mine, others = [], []
Expand Down Expand Up @@ -2695,6 +2764,7 @@ def red_backlog(lane: str, threshold_hours: float,
for pr in mine + others:
lane_by[pr["number"]] = (parse_grain_tag(pr.get("body") or "") or {}).get("lane")
organ_cache: dict[int, list[str]] = {}
dwell_cache: dict[int, dict] = {}
# #15764 : cache des verdicts FAIL parsés par check-run -- partage entre
# PRs de la lane dans ce passage, comme organ_cache.
gate_cache: dict[int, tuple[list[str], list[str]]] = {}
Expand All @@ -2708,6 +2778,7 @@ def red_backlog(lane: str, threshold_hours: float,
organ_cache=organ_cache,
unresolved_out=unresolved_aggregates)
red = []
dwell_waiting: list[dict] = []
for pr in mine:
state = states.get(pr["number"])
if state is None:
Expand All @@ -2727,9 +2798,33 @@ def red_backlog(lane: str, threshold_hours: float,
ctx_name = ctx.get("name") or ctx.get("context") or "?"
keys_by_name.setdefault(ctx_name, set()).update(
failed_check_keys(ctx, organ_cache))
# #15910 : un agregateur rouge par DWELL n'a pas d'organe a lire. On ne
# paie la lecture d'annotation que pour les agregateurs dont AUCUN
# organe n'a pu etre resolu -- exactement le cas ambigu, jamais le cas
# nominal (un organe nomme tranche deja la question).
dwell_by_name: dict[str, dict] = {}
for ctx in _failed_contexts(state):
ctx_name = ctx.get("name") or ctx.get("context") or "?"
if not is_aggregator_check(ctx_name):
continue
if keys_by_name and keys_by_name.get(ctx_name):
continue
ctx_run_id = ctx.get("databaseId")
if ctx_run_id is None:
continue
if ctx_run_id not in dwell_cache:
dwell_cache[ctx_run_id] = fetch_check_dwell(ctx_run_id) or {}
dwell = dwell_cache[ctx_run_id]
if dwell:
dwell_by_name[ctx_name] = dwell
for ctx_name, info in sorted(dwell_by_name.items()):
dwell_waiting.append({"number": pr["number"], "check": ctx_name,
"lift_at": info.get("lift_at"),
"remaining_min": info.get("remaining_min")})
causes = blocking_causes(state, age_hours=age, saturation_hours=threshold_hours,
inherited=set(inherited),
resolved_keys_by_name=keys_by_name,
dwell_by_name=dwell_by_name,
gate_evidence=gate_evidence_for(state, gate_cache))
n_nits = nits_by_pr.get(pr["number"], 0)
if n_nits:
Expand Down Expand Up @@ -2767,6 +2862,19 @@ def red_backlog(lane: str, threshold_hours: float,
"is_adjacency": is_adj})
red.sort(key=lambda r: -r["age_hours"])

# #15910 : un agregateur tranche par DWELL n'est PAS « non resolu ». Sans ce
# retrait, `impute_base_reds` (qui a lu l'annotation AVANT la boucle) le
# classe dans `base_unresolved` et la sortie annonce « organe non lisible --
# pas pu trancher » sur le rouge dont on vient d'etablir qu'il n'y a rien a
# reparer : deux lignes qui se contredisent, et la lane repart chercher.
# Portee volontairement limitee aux PRs de la lane : lire le DWELL d'une PR
# etrangere couterait jusqu'a 16 lectures d'annotation sur l'echantillon de
# corroboration, pour une surface qui ne decide rien pour cette lane.
resolved_dwell = {(item["check"], item["number"]) for item in dwell_waiting}
if resolved_dwell:
unresolved_aggregates[:] = [pair for pair in unresolved_aggregates
if pair not in resolved_dwell]

triggers = []
if any(nits_by_pr.get(r["number"]) for r in red):
# D'abord dans la liste : c'est l'ordre dans lequel le mandat du
Expand Down Expand Up @@ -2810,6 +2918,10 @@ def red_backlog(lane: str, threshold_hours: float,
# sinon l'absence d'imputation se lirait comme une acquittement.
"base_unresolved": [{"check": name, "prs": sorted(nums)}
for name, nums in sorted(unresolved_by_name.items())],
# #15910 : les agregateurs rouges par DWELL. Ni un defaut a
# reparer, ni un rouge impute a la base : un minuteur qu'aucune
# lane ne peut avancer en poussant (pousser le remet a zero).
"dwell_waiting": dwell_waiting,
"nits_unavailable": nits_unavailable}


Expand Down Expand Up @@ -2847,6 +2959,42 @@ def print_base_inherited(backlog: dict) -> None:
print()


def print_dwell_waiting(backlog: dict) -> None:
"""Agregateurs rouges par DWELL : un minuteur, pas un defaut (#15910).

`pr_gate.py` n'applique le plancher d'anciennete que sur le chemin VERT
(`code == 0`) : quand tout est vert et que la tete est trop jeune, il rend
malgre tout un code non nul. Cote picker, ce rouge n'avait aucun organe a
lire (il n'y a rien a reparer) et retombait donc sur la lane comme un grain
reparable -- un cycle entier pouvait partir sur une reparation inexistante.
Ce rouge ne se corrige pas : il s'ecoule. Apres l'echeance, la lane peut
rejouer la jambe elle-meme (`gh run rerun <run_id> --job <job_id>`, sans
push -- un push remet le plancher a zero) ou laisser le balayage
`pr-gate-stale-sweep.yml` s'en charger (cadence MESUREE 2 h 33 - 5 h 18
entre tirs, pas horaire -- #15197).
"""
items = backlog.get("dwell_waiting") or []
if not items:
return
print("PLANCHER DE DWELL -- un minuteur, pas un defaut, rien a reparer :")
for item in items:
# « reste » est le chiffre du gate AU MOMENT DU CHECK : sur une PR dont
# le plancher est deja ecoule il est perime (mesure du 2026-09-13 :
# #15952 annoncait « reste ~23 min » pour une levee passee depuis 20
# min). L'heure de levee absolue est la seule donnee qui ne vieillit
# pas -- c'est elle qui decide, « reste » n'est qu'un contexte.
reste = item.get("remaining_min")
reste_txt = f" (reste ~{reste} min lu au moment du check)" if reste is not None else ""
quand = f" -- plancher ecoule a {item['lift_at']}" if item.get("lift_at") else ""
print(f" - #{item['number']} {item['check']}{quand}{reste_txt}")
print("Ces rouges ne comptent pas dans le refus et ne sont PAS imputes a la")
print("base. NE PAS repousser : un push remet le plancher a zero. Apres")
print("l'echeance, rejouer la jambe soi-meme (`gh run rerun <run_id> --job")
print("<job_id>`) ou enchainer un autre grain -- c'est la candidate qui")
print("attend, pas la lane.")
print()


def print_nits_gap(backlog: dict) -> None:
"""Dire qu'une surface n'a pas ete regardee, plutot que la taire.

Expand Down Expand Up @@ -3058,6 +3206,7 @@ def print_red_assignment(lane: str, backlog: dict, threshold_hours: float) -> No
print()
print_unattributed_blocked(backlog)
print_base_inherited(backlog)
print_dwell_waiting(backlog)
print("Si un rouge n'est PAS reparable par cette lane (garde casse sur main,")
print("dependance d'une autre PR), l'ECRIRE en commentaire sur la PR concernee,")
print("puis relancer avec --ignore-red. L'echappatoire se justifie par ecrit,")
Expand Down Expand Up @@ -3876,6 +4025,7 @@ def main(argv: list[str] | None = None) -> int:
if not args.json:
print_nits_gap(backlog)
print_base_inherited(backlog)
print_dwell_waiting(backlog)
if backlog.get("unavailable") and not args.json:
print(f"(garde rouge indisponible : {backlog['unavailable']} -- tirage rendu sans verification)")
print()
Expand Down
43 changes: 41 additions & 2 deletions scripts/tests/test_merge_dwell.py
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,45 @@ def fetch(path):
assert "dwell ecoule" in msg


# --- 5. #16149 -- le rafraichissement de base ne re-arme pas le plancher ----
# --- 5. le message est RELISIBLE par ses consommateurs (#15910) --------------

def test_le_plancher_est_relisible_par_ses_consommateurs():
"""Round-trip emetteur -> lecteur : la forme du message tient des deux cotes.

Le picker ne peut pas recalculer le plancher (il ne voit que le texte du
gate) : il lit ce message pour distinguer « ce rouge est un minuteur » de
« ce rouge est un defaut ». Si la formulation derive d'un cote, le lecteur
cesse de matcher EN SILENCE et le rouge DWELL redevient un grain dit
reparable -- ce test echoue a la place, dans le module qui possede la forme.
"""
ok, remaining, msg = merge_dwell.evaluate(NOW - timedelta(minutes=7), NOW, 120.0)
assert ok is False
parsed = merge_dwell.parse_pending_message(msg)
# #16092 : lift_at est l'heure GARANTIE du balayage :07 posterieur au
# plancher brut (11:53 + 120 min = 13:53 -> 14:07), pas le plancher brut.
assert parsed == {"head_at": "2026-09-07T11:53:00Z", "dwell_min": 120,
"remaining_min": 113, "lift_at": "2026-09-07T14:07:00Z"}
assert parsed["remaining_min"] == int(remaining)


def test_controle_negatif_les_autres_verdicts_ne_sont_pas_des_planchers():
"""Les DEUX autres verdicts du gate ne doivent PAS se lire comme un plancher.

« plancher ecoule » est un rouge qui tombe seul au prochain balayage ;
« derogation » dit que le plancher ne mord pas. Les confondre avec un
plancher en cours ferait attendre une PR qui n'attend rien -- et, pire,
ferait acquitter un rouge que personne ne levera.
"""
_ok, _rem, ecoule = merge_dwell.evaluate(NOW - timedelta(minutes=200), NOW, 120.0)
assert merge_dwell.parse_pending_message(ecoule) is None
_ok, _rem, derogation = merge_dwell.evaluate(
NOW - timedelta(minutes=5), NOW, 120.0, waived=True)
assert merge_dwell.parse_pending_message(derogation) is None
assert merge_dwell.parse_pending_message("") is None
assert merge_dwell.parse_pending_message("texte etranger") is None


# --- 6. #16149 -- le rafraichissement de base ne re-arme pas le plancher ----

def _commit(sha, date, parents, tree=None):
payload = {
Expand Down Expand Up @@ -511,7 +549,8 @@ def _git_version_supported():
"""`git merge-tree --write-tree` demande Git >= 2.38 (CR 2026-09-16)."""
try:
out = subprocess.run(
["git", "--version"], capture_output=True, text=True
["git", "--version"], capture_output=True, text=True,
encoding="utf-8", errors="replace"
).stdout
except OSError:
return False
Expand Down
Loading
Loading