diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index 4e2d5bc32e..be645461f3 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -3925,19 +3925,159 @@ def upsert_orphans_comment(number: int, body: str) -> None: # ou biaise vers le recent. Les filtres actifs (exclusions, urnes) restent # appliques, et les issues tenues par une autre lane sont sautees comme # dans la voie normale -- aucun court-circuit de ce contrat. +# Mandat user 2026-10-04 : le tapis avance au claim, pas au merge. Mesure +# fondatrice du meme jour : l'EPIC #7265 n'avait plus vu de merge depuis +# aout ; po-2027:CoursIA l'a servie a 09:50Z en creant la sous-issue #19088 +# et en reservant CELLE-CI, donc l'EPIC est restee en tete de file, ni +# reservee ni visitee, et po-2024:CoursIA l'a tiree a 19:49Z -- deux lanes +# sur le meme patrimoine le meme jour. +# Une visite, c'est desormais la plus recente de trois dates : +# 1. la derniere PR mergee qui cite l'issue (`last_delivery_stamp`) ; +# 2. le plus recent marqueur de claim pose sur l'issue par une lane, +# toutes lanes (`last_claim_stamp`, lu sur la tete de file par +# `settle_belt_head`) ; la grammaire est celle de l'organe +# `check_lane_claim.py` (`claim_visit_stamp`), jamais une regex propre. +# Une cloture (`[RELEASED]`, `[DONE]`, `[DELIVERED]`...) est aussi une +# visite : elle AVANCE la date, elle ne l'efface pas -- mesure #7742, +# rendue le 19/09 apres deux tranches mergees, qu'une lecture « rendu = +# rang rendu » remettait en tete comme jamais servie ; +# 3. la creation de la plus recente sous-issue ouverte qui la nomme comme +# parent (`last_child_stamp`, `apply_child_visits`, zero appel reseau). +_PARENT_BODY_RE = re.compile(r"(?i)\bpart of #(\d+)") +_PARENT_TITLE_RE = re.compile(r"^\s*\[#(\d+)\b") + + +def belt_visit_stamp(it: dict) -> str | None: + """Derniere visite connue de l'issue, ``None`` si elle n'a jamais ete servie. + + Les trois dates sont des ISO 8601 UTC serveur (suffixe ``Z``) : l'ordre + lexicographique est l'ordre chronologique. + """ + stamps = [s for s in (it.get("last_delivery_stamp"), + it.get("last_claim_stamp"), + it.get("last_child_stamp")) if s] + return max(stamps) if stamps else None + + def belt_sort_key(it: dict) -> tuple: """Cle de tri deterministe pour le tapis roulant. - Spec #18832 : **une seule ligne de temps** -- derniere PR mergee citant - l'issue, sinon date de creation, sinon NOW. La plus ancienne en tete. - Une sous-issue tout juste creee repart en queue, pas en tete. + Spec #18832 : **une seule ligne de temps** -- derniere visite (merge + d'une PR citant l'issue, claim pose sur elle, ou creation d'une + sous-issue qui la nomme ; cf `belt_visit_stamp`), sinon date de + creation, sinon NOW. La plus ancienne en tete. Une sous-issue tout juste + creee repart en queue, pas en tete, et pousse son parent avec elle. Tri : (stamp ISO asc, numero asc). """ - stamp = it.get("last_delivery_stamp") or it.get("created_at") or NOW.isoformat() + stamp = belt_visit_stamp(it) or it.get("created_at") or NOW.isoformat() return (stamp, it.get("number", 0)) +def parent_refs(it: dict) -> set[int]: + """Parents nommes par une issue : prefixe de titre ``[#N`` et ``Part of #N``. + + ``Part of #N`` est la syntaxe de lien sure de git-workflow.md ; le + prefixe de titre est la forme des sous-grains d'EPIC (``[#7265 - + pepite A3] ...``). Une issue ne se nomme pas elle-meme. + """ + refs = {int(m) for m in _PARENT_BODY_RE.findall(it.get("body") or "")} + m = _PARENT_TITLE_RE.match(it.get("title") or "") + if m: + refs.add(int(m.group(1))) + refs.discard(it.get("number")) + return refs + + +def apply_child_visits(pool: list[dict], targets: list[dict]) -> dict[int, str]: + """Pose ``last_child_stamp`` sur ``targets`` : creer une sous-issue visite le parent. + + ``pool`` = toutes les issues ouvertes lues (corps et date de creation deja + charges par `fetch_pool`) : zero appel reseau. Rend ``{parent: stamp}``. + """ + latest: dict[int, str] = {} + for child in pool: + created = child.get("created_at") + if not created: + continue + for parent in parent_refs(child): + if created > latest.get(parent, ""): + latest[parent] = created + for it in targets: + stamp = latest.get(it["number"]) + if stamp: + it["last_child_stamp"] = stamp + return latest + + +def claim_visit_stamp(comments: list[dict]) -> str | None: + """``createdAt`` serveur du plus recent marqueur de claim attribue a une lane. + + Lecture deleguee a l'organe des claims (#19147, reserve tierce) : + ``_sort_events`` lit les marqueurs avec sa grammaire (decorations markdown + et non-ASCII tolerees, blocs fence neutralises, mentions en milieu de ligne + ignorees). Tout marqueur compte -- prise, amendement, override, livraison, + rendu : chacun dit qu'une lane a servi l'issue a cette date. Un marqueur + sans lane (citation, gabarit) n'est la visite de personne. + """ + from check_lane_claim import _sort_events + + stamps = [ev.created_at for ev in _sort_events({"comments": comments}) + if ev.lane and ev.created_at] + return max(stamps) if stamps else None + + +def latest_claim_stamp(issue_number: int) -> str | None: + """Date du plus recent marqueur de claim de l'issue (cf `claim_visit_stamp`). + + Toutes lanes confondues : une reservation est une visite, quelle que soit + la lane qui la pose. Cout : 1 requete. ``None`` si aucun marqueur ou si la + lecture echoue -- l'issue garde alors sa date de merge, comme avant. + """ + try: + out = subprocess.run( + ["gh", "issue", "view", str(issue_number), "--repo", REPO, + "--json", "comments"], + capture_output=True, text=True, encoding="utf-8", check=True, + timeout=30, + ).stdout + comments = (json.loads(out) or {}).get("comments") or [] + return claim_visit_stamp( + [c for c in comments if isinstance(c, dict)]) + except Exception: # noqa: BLE001 - sonde best-effort, l'issue garde son merge + return None + + +def settle_belt_head( + belt_pool: list[dict], + need: int, + probe: Callable[[int], str | None], + max_probes: int, +) -> set[int]: + """Lit les claims de la tete de file jusqu'a ce que ses ``need`` premieres places soient stables. + + Une issue reservee depuis son dernier merge recule a la date de sa + reservation ; la place liberee est prise par la suivante, qui est lue a + son tour. Trie ``belt_pool`` en place et rend les numeros sondes. Le + plafond ``max_probes`` borne le cout reseau si toute la tete est reservee. + """ + probed: set[int] = set() + belt_pool.sort(key=belt_sort_key) + while len(probed) < max_probes: + todo = [it for it in belt_pool[:need] if it["number"] not in probed] + if not todo: + break + for it in todo: + if len(probed) >= max_probes: + break + probed.add(it["number"]) + stamp = probe(it["number"]) + if stamp: + it["last_claim_stamp"] = stamp + belt_pool.sort(key=belt_sort_key) + return probed + + def belt_filter( admitted: list[dict], args, @@ -4810,6 +4950,12 @@ def main(argv: list[str] | None = None) -> int: ap.add_argument("--umbrellas", type=int, default=2, help="candidats urne 'umbrella' (defaut 2)") ap.add_argument("--delivered", type=int, default=2, help="candidats urne 'delivered' (defaut 2)") ap.add_argument("--reroll", type=int, default=0, help="decale la graine pour un nouveau tirage") + ap.add_argument("--belt-merge-only", dest="belt_merge_only", + action="store_true", + help="tapis : ne compter comme visite que le merge d'une " + "PR citant l'issue (ordre d'avant le 2026-10-04 ; " + "par defaut, un claim et la creation d'une sous-issue " + "comptent aussi)") ap.add_argument("--belt", action="store_true", help="#18832 mode 'tapis roulant' : trie le pool ouvert " "par date de derniere livraison (None = jamais servie en " @@ -5326,6 +5472,13 @@ def main(argv: list[str] | None = None) -> int: # point 6 -- le verbe "CLEAR" humain est reserve a l'affichage). belt_check_window = max(args.grains + 4, 8) belt_check_window = min(belt_check_window, len(belt_pool)) + # Le tapis avance au claim, pas au merge (mandat user 2026-10-04, + # cf `belt_visit_stamp`). La sous-issue d'abord (gratuit), puis les + # claims de la tete de file. `--belt-merge-only` rend l'ancien ordre. + if not args.belt_merge_only: + apply_child_visits(pool, belt_pool) + settle_belt_head(belt_pool, belt_check_window, latest_claim_stamp, + max_probes=belt_check_window * 3 + 12) belt_check_nums = [it["number"] for it in belt_pool[:belt_check_window]] belt_claims = check_claims(belt_check_nums, args.lane) belt_picks: list[dict] = [] @@ -5439,18 +5592,18 @@ def main(argv: list[str] | None = None) -> int: inact = int(it.get("idle", 0)) vus = visits.get(it["number"], 0) genre = it.get("genre", "") - stamp = it.get("last_delivery_stamp") + stamp = belt_visit_stamp(it) # Le titre est precede du marqueur "jamais servie" quand - # `last_delivery_stamp` est None : c'est lui que la file - # remonte en tete, il merite un signe visible. + # aucune visite n'est connue (ni merge, ni claim, ni + # sous-issue) : c'est elle que la file remonte en tete. marker = "[NEVER] " if stamp is None else "" title = it.get("title", "")[:60] print(f"{urn:<10} {age:>4}j {inact:>5}j {vus:>4} " f"{genre:<14} {'-':>5} {marker}{title}") print() - print("Belt : pool trie par date de derniere livraison (None = " - "jamais servie, classe en tete). Deterministe, sans " - "ponderation.") + print("Belt : pool trie par date de derniere visite -- merge, " + "claim ou sous-issue (None = jamais servie, classee par sa " + "creation). Deterministe, sans ponderation.") return 0 filtered, filter_funnel = filter_candidates_with_continuity( admitted, diff --git a/scripts/tests/test_pick_idle_grain_belt.py b/scripts/tests/test_pick_idle_grain_belt.py index 74e0122fcb..ec4b31948c 100644 --- a/scripts/tests/test_pick_idle_grain_belt.py +++ b/scripts/tests/test_pick_idle_grain_belt.py @@ -542,6 +542,8 @@ def _patch_belt_network(monkeypatch, prs, red_state): monkeypatch.setattr(pig, "check_claims", lambda nums, lane: {n: (pig.CLAIM_CODE_FREE, "libre") for n in nums}) + # Le tapis lit aussi les claims comme visites : jamais de reseau en test. + monkeypatch.setattr(pig, "latest_claim_stamp", lambda n: None) def test_belt_json_emits_single_document_when_red_present(monkeypatch, capsys): @@ -628,3 +630,222 @@ def test_non_belt_json_red_still_emits_standalone_repair(monkeypatch, capsys): # Le mode reste `repair`, pas `belt` : la volee ponderee est inchangee. assert payload["mode"] == "repair" assert payload["grain"]["number"] == 18844 + + +# Le tapis avance au claim, pas au merge (mandat user 2026-10-04). +# Mesure fondatrice : l'EPIC #7265 servie le matin par une sous-issue +# reservee (#19088) est restee en tete de file, et une seconde lane l'a +# tiree le soir. + + +def test_belt_visit_stamp_is_latest_of_merge_claim_child(): + it = _make_item(1, age_days=90, idle=1, last="2026-08-01T00:00:00Z") + assert pig.belt_visit_stamp(it) == "2026-08-01T00:00:00Z" + it["last_claim_stamp"] = "2026-10-04T19:49:00Z" + it["last_child_stamp"] = "2026-10-04T09:50:00Z" + assert pig.belt_visit_stamp(it) == "2026-10-04T19:49:00Z" + never = _make_item(2, age_days=90, idle=1, last=None) + assert pig.belt_visit_stamp(never) is None + + +def test_belt_claim_moves_issue_behind_unvisited_ones(): + """Une issue reservee depuis son dernier merge passe derriere une issue + plus recente jamais visitee, sans attendre de merge.""" + old = _make_item(10, age_days=90, idle=1, last="2026-08-01T00:00:00Z", + created="2026-07-01T00:00:00Z") + newer = _make_item(11, age_days=30, idle=1, last=None, + created="2026-09-01T00:00:00Z") + pool = [old, newer] + claims = {10: "2026-10-04T19:49:00Z"} + probed = pig.settle_belt_head(pool, need=2, probe=claims.get, max_probes=10) + assert [it["number"] for it in pool] == [11, 10] + assert probed == {10, 11} + assert old["last_claim_stamp"] == "2026-10-04T19:49:00Z" + + +def test_belt_claim_older_than_merge_changes_nothing(): + it = _make_item(12, age_days=90, idle=1, last="2026-09-20T00:00:00Z", + created="2026-07-01T00:00:00Z") + other = _make_item(13, age_days=30, idle=1, last="2026-09-25T00:00:00Z", + created="2026-09-01T00:00:00Z") + pool = [other, it] + pig.settle_belt_head(pool, need=2, probe={12: "2026-09-01T00:00:00Z"}.get, + max_probes=10) + assert [x["number"] for x in pool] == [12, 13] + + +def test_belt_settle_reads_the_freed_slot_until_head_is_stable(): + """Toute la tete est reservee : chaque place liberee est lue a son tour, + et la premiere issue non reservee finit en tete.""" + pool = [_make_item(20 + k, age_days=90, idle=1, last=None, + created=f"2026-07-0{k + 1}T00:00:00Z") for k in range(5)] + claims = {20: "2026-10-04T10:00:00Z", 21: "2026-10-04T11:00:00Z", + 22: "2026-10-04T12:00:00Z"} + probed = pig.settle_belt_head(pool, need=2, probe=claims.get, max_probes=10) + assert [it["number"] for it in pool][:2] == [23, 24] + assert {20, 21, 22, 23, 24} <= probed + + +def test_belt_settle_is_bounded_by_max_probes(): + pool = [_make_item(40 + k, age_days=90, idle=1, last=None, + created=f"2026-07-{k + 1:02d}T00:00:00Z") for k in range(20)] + calls = [] + + def probe(n): + calls.append(n) + return f"2026-10-04T{len(calls):02d}:00:00Z" + + pig.settle_belt_head(pool, need=3, probe=probe, max_probes=7) + assert len(calls) == 7 + + +def test_belt_child_issue_visits_its_parent_7265_scenario(): + """Cas fondateur : la sous-issue #19088 (titre ``[#7265 ...``) creee a + 09:50Z fait passer l'EPIC #7265 derriere une issue d'aout jamais servie.""" + epic = _make_item(7265, age_days=78, idle=0, klass="umbrella", + last="2026-08-13T00:00:00Z", + created="2026-07-18T00:00:00Z") + august = _make_item(14000, age_days=40, idle=3, last=None, + created="2026-08-25T00:00:00Z") + child = _make_item(19088, age_days=0, idle=0, last=None, + created="2026-10-04T09:50:00Z") + child["title"] = "[#7265 · pépite A3] Object explorer metadata-driven" + child["body"] = "Pepite A3 de l'EPIC #7265." + belt_pool = [epic, august] + latest = pig.apply_child_visits([epic, august, child], belt_pool) + assert latest[7265] == "2026-10-04T09:50:00Z" + assert epic["last_child_stamp"] == "2026-10-04T09:50:00Z" + belt_pool.sort(key=pig.belt_sort_key) + assert [it["number"] for it in belt_pool] == [14000, 7265] + + +def test_parent_refs_reads_part_of_and_title_prefix_not_self(): + it = _make_item(500, age_days=1, idle=0) + it["title"] = "[#16231] renommer ICT-45" + it["body"] = "Part of #4362. See #9999.\nPart of #500 (soi-meme)" + assert pig.parent_refs(it) == {16231, 4362} + plain = _make_item(501, age_days=1, idle=0) + plain["body"] = "See #4362 et Refs #12" + assert pig.parent_refs(plain) == set() + + +def _claim(at: str, body: str) -> dict: + return {"createdAt": at, "body": body, "author": {"login": "jsboige"}} + + +def test_claim_visit_stamp_reads_decorated_markers_like_the_organ(): + """Reserve tierce #19147, point 1 : la grammaire est celle de + ``check_lane_claim.py`` (#10906, #12711), pas une regex propre au tapis.""" + for body in ( + "**[CLAIMED] lane myia-po-2027:CoursIA -- T1**", + "## [CLAIMED] lane myia-po-2027:CoursIA -- T1", + "- [CLAIMED] lane myia-po-2027:CoursIA -- T1", + "> [CLAIMED] lane myia-po-2027:CoursIA -- T1", + "→[CLAIMED] lane myia-po-2027:CoursIA -- T1", + "[claimed] lane myia-po-2027:CoursIA -- T1", + ): + assert pig.claim_visit_stamp( + [_claim("2026-10-04T09:50:12Z", body)]) == "2026-10-04T09:50:12Z", body + + +def test_claim_visit_stamp_ignores_quoted_and_midline_mentions(): + """Une citation en bloc fence, une mention en milieu de ligne, un marqueur + sans lane ne sont la visite de personne.""" + fenced = ("Le gabarit est :\n```\n[CLAIMED] lane myia-po-2027:CoursIA" + " -- T1\n```\n") + comments = [ + _claim("2026-10-04T09:00:00Z", fenced), + _claim("2026-10-04T10:00:00Z", + "T1 livree. Le [CLAIMED] du matin reste valable."), + _claim("2026-10-04T11:00:00Z", "> [CLAIMED] cite sans lane"), + ] + assert pig.claim_visit_stamp(comments) is None + + +def test_claim_visit_stamp_closures_advance_the_visit(): + """Point 2 de la reserve : une cloture est lue, et c'est une visite. + Elle AVANCE la date au lieu de la laisser figee sur la prise.""" + claim = _claim("2026-10-04T09:50:12Z", + "[CLAIMED] lane myia-po-2027:CoursIA -- T1") + for close in ("RELEASED", "DONE", "ABANDONED", "CANCELLED", "DELIVERED"): + assert pig.claim_visit_stamp([ + claim, + _claim("2026-10-04T12:00:00Z", + f"[{close}] lane myia-po-2027:CoursIA -- PR #19999"), + ]) == "2026-10-04T12:00:00Z", close + + +def test_claim_visit_stamp_counts_every_lane(): + """Toutes lanes : la plus recente marque, quelle que soit la lane.""" + comments = [ + _claim("2026-10-04T08:00:00Z", + "[CLAIMED] lane myia-po-2024:CoursIA -- B"), + _claim("2026-10-04T09:00:00Z", + "[CLAIMED] lane myia-po-2027:CoursIA -- A"), + _claim("2026-10-04T07:00:00Z", + "[RELEASED] lane myia-po-2023:CoursIA -- ancien"), + ] + assert pig.claim_visit_stamp(comments) == "2026-10-04T09:00:00Z" + + +def test_belt_released_issue_stays_behind_7742_scenario(): + """Bout en bout, cas mesure #7742 : prise le 31/08, rendue le 19/09 apres + deux tranches mergees. Lue a la prise seule, elle repassait devant une + issue visitee le 10/09 ; lue a sa cloture, elle reste derriere.""" + claim = _claim("2026-08-31T01:31:34Z", + "[CLAIMED] lane myia-po-2024:CoursIA -- paths: a.ipynb") + release = _claim("2026-09-19T14:46:38Z", + "[RELEASED] lane myia-po-2024:CoursIA -- tranches " + "livrees, le claim rend la main") + + def order(comments): + pool = [ + _make_item(7742, age_days=75, idle=0, last=None, + created="2026-07-21T16:31:50Z"), + _make_item(200, age_days=30, idle=0, + last="2026-09-10T00:00:00Z", + created="2026-09-01T00:00:00Z"), + ] + pig.settle_belt_head( + pool, need=2, + probe=lambda n: pig.claim_visit_stamp(comments) + if n == 7742 else None, + max_probes=4) + return [it["number"] for it in pool] + + assert order([]) == [7742, 200] # jamais servie : en tete + assert order([claim]) == [7742, 200] # prise le 31/08 < 10/09 + assert order([claim, release]) == [200, 7742] # rendue le 19/09 : derriere + + +def test_latest_claim_stamp_reads_claims_of_any_lane(monkeypatch): + payload = {"comments": [ + _claim("2026-10-04T09:50:12Z", + "[CLAIMED] lane myia-po-2027:CoursIA -- T1"), + _claim("2026-10-04T10:50:00Z", + "[CLAIMED-AMEND] lane myia-po-2027:CoursIA -- paths: a/**"), + _claim("2026-10-04T12:00:00Z", + "T1 livree. Le [CLAIMED] du matin reste valable."), + ]} + + class _R: + stdout = json.dumps(payload) + + monkeypatch.setattr(pig.subprocess, "run", lambda *a, **k: _R()) + assert pig.latest_claim_stamp(19088) == "2026-10-04T10:50:00Z" + + +def test_latest_claim_stamp_read_failure_is_none(monkeypatch): + def boom(*a, **k): + raise OSError("gh absent") + + monkeypatch.setattr(pig.subprocess, "run", boom) + assert pig.latest_claim_stamp(1) is None + + +def test_belt_merge_only_flag_is_accepted(monkeypatch, capsys): + _patch_belt_network(monkeypatch, prs=[], red_state=_state_red()) + rc = pig.main(["--lane", "myia-po-2024:CoursIA-2", "--belt", + "--belt-merge-only", "--json"]) + assert rc == 0 + assert json.loads(capsys.readouterr().out)["mode"] == "belt"