From b40e7e446ae459959a9feef8ba6a689a9a39154e Mon Sep 17 00:00:00 2001 From: jsboige Date: Sun, 4 Oct 2026 22:40:29 +0200 Subject: [PATCH 1/3] fix(picker,#18832): le tapis avance a la reservation, sans attendre le merge Une visite du tapis devient la plus recente de trois dates : le merge d'une PR qui cite l'issue (seule date lue jusqu'ici), le dernier [CLAIMED]/[CLAIMED-AMEND] pose sur l'issue (toutes lanes, lu sur la tete de file par settle_belt_head), et la creation de la plus recente sous-issue ouverte qui la nomme comme parent (prefixe de titre [#N ou "Part of #N", zero appel reseau). Cas fondateur du 04/10 : l'EPIC #7265 servie le matin via la sous-issue #19088 (reservee, elle) est restee en tete de file et une seconde lane l'a tiree le soir. --belt-merge-only rend l'ancien ordre. Co-Authored-By: Claude-Code --- scripts/pick_idle_grain.py | 154 +++++++++++++++++++-- scripts/tests/test_pick_idle_grain_belt.py | 134 ++++++++++++++++++ 2 files changed, 278 insertions(+), 10 deletions(-) diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index 4e2d5bc32e..35b831e751 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -3925,19 +3925,140 @@ 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 dernier `[CLAIMED]` / `[CLAIMED-AMEND]` pose sur l'issue, toutes +# lanes (`last_claim_stamp`, lu sur la tete de file par +# `settle_belt_head`) ; +# 3. la creation de la plus recente sous-issue ouverte qui la nomme comme +# parent (`last_child_stamp`, `apply_child_visits`, zero appel reseau). +_CLAIM_VISIT_RE = re.compile(r"^\s*\[CLAIMED(?:-AMEND)?\]", re.MULTILINE) +_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 latest_claim_stamp(issue_number: int) -> str | None: + """``createdAt`` serveur du dernier ``[CLAIMED]``/``[CLAIMED-AMEND]`` de l'issue. + + Toutes lanes confondues : une reservation est une visite, quelle que soit + la lane qui la pose. Cout : 1 requete. ``None`` si aucun claim 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 [] + except Exception: # noqa: BLE001 - sonde best-effort, l'issue garde son merge + return None + stamps = [c.get("createdAt") for c in comments + if isinstance(c, dict) and c.get("createdAt") + and _CLAIM_VISIT_RE.search(c.get("body") or "")] + return max(stamps) if stamps else 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 +4931,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 +5453,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 +5573,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..67aeca1b5f 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,135 @@ 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 test_latest_claim_stamp_reads_claims_of_any_lane(monkeypatch): + payload = {"comments": [ + {"createdAt": "2026-10-04T09:50:12Z", + "body": "[CLAIMED] lane myia-po-2027:CoursIA -- T1"}, + {"createdAt": "2026-10-04T10:50:00Z", + "body": "[CLAIMED-AMEND] lane myia-po-2027:CoursIA -- paths: a/**"}, + {"createdAt": "2026-10-04T12:00:00Z", + "body": "T1 livree. Le [CLAIMED] du matin reste valable."}, + {"createdAt": "2026-10-04T13:00:00Z", + "body": "> [CLAIMED] cite dans une reponse"}, + ]} + + 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" From 53fbc6bb1efd2d5c169012e6552eb5502068968f Mon Sep 17 00:00:00 2001 From: jsboige Date: Mon, 5 Oct 2026 00:34:30 +0200 Subject: [PATCH 2/3] fix(picker,#18832): le tapis lit les claims avec l'organe check_lane_claim Reserve tierce sur #19147 : le tapis lisait les claims avec sa propre regex, plus etroite que l'organe (formes decorees ignorees, citation en bloc fence comptee) et sans cloture (un [RELEASED] laissait l'issue a la date du claim pour toujours). active_claim_stamp delegue lecture et reduction a check_lane_claim (_sort_events + compute_active_claims) : seule compte la date du claim encore actif. Un claim rendu ne laisse aucune date, l'issue reprend le rang que lui donnent ses merges. [DELIVERED] suit la semantique v2. Six tests : formes decorees, mentions citees, clotures, lane qui tient encore, [DELIVERED] selon l'etat de la PR, et le tri de bout en bout (tenue -> recule, rendue -> revient). Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/pick_idle_grain.py | 45 ++++++-- scripts/tests/test_pick_idle_grain_belt.py | 121 +++++++++++++++++++-- 2 files changed, 147 insertions(+), 19 deletions(-) diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index 35b831e751..0b6884464c 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -3933,12 +3933,14 @@ def upsert_orphans_comment(number: int, body: str) -> None: # 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 dernier `[CLAIMED]` / `[CLAIMED-AMEND]` pose sur l'issue, toutes -# lanes (`last_claim_stamp`, lu sur la tete de file par -# `settle_belt_head`) ; +# 2. le plus recent claim ENCORE ACTIF sur l'issue, toutes lanes +# (`last_claim_stamp`, lu sur la tete de file par `settle_belt_head`) ; +# la grammaire et la reduction sont celles de l'organe +# `check_lane_claim.py` (`active_claim_stamp`), jamais une regex propre : +# un claim rendu (`[RELEASED]`, `[DONE]`, livraison fermee) n'a pas +# servi l'issue, qui reprend son rang ; # 3. la creation de la plus recente sous-issue ouverte qui la nomme comme # parent (`last_child_stamp`, `apply_child_visits`, zero appel reseau). -_CLAIM_VISIT_RE = re.compile(r"^\s*\[CLAIMED(?:-AMEND)?\]", re.MULTILINE) _PARENT_BODY_RE = re.compile(r"(?i)\bpart of #(\d+)") _PARENT_TITLE_RE = re.compile(r"^\s*\[#(\d+)\b") @@ -4006,12 +4008,35 @@ def apply_child_visits(pool: list[dict], targets: list[dict]) -> dict[int, str]: return latest +def active_claim_stamp( + comments: list[dict], + pr_states: dict[int, str] | None = None, +) -> str | None: + """``createdAt`` serveur du plus recent claim encore actif, toutes lanes. + + Lecture et reduction deleguees 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), ``compute_active_claims`` rejoue ouvertures, amendements, + overrides et clotures. Un claim clos ne laisse aucune date : l'issue + reprend le rang que lui donnent ses merges. ``pr_states`` est transmis au + reducteur pour les ``[DELIVERED]`` (injection testable, comme dans + l'organe). + """ + from check_lane_claim import _sort_events, compute_active_claims + + active, _unattributed = compute_active_claims( + _sort_events({"comments": comments}), pr_states) + stamps = [ev.created_at for ev in active.values() if ev.created_at] + return max(stamps) if stamps else None + + def latest_claim_stamp(issue_number: int) -> str | None: - """``createdAt`` serveur du dernier ``[CLAIMED]``/``[CLAIMED-AMEND]`` de l'issue. + """Date du plus recent claim actif de l'issue (cf `active_claim_stamp`). Toutes lanes confondues : une reservation est une visite, quelle que soit - la lane qui la pose. Cout : 1 requete. ``None`` si aucun claim ou si la - lecture echoue -- l'issue garde alors sa date de merge, comme avant. + la lane qui la pose. Cout : 1 requete. ``None`` si aucun claim actif ou si + la lecture echoue -- l'issue garde alors sa date de merge, comme avant. """ try: out = subprocess.run( @@ -4021,12 +4046,10 @@ def latest_claim_stamp(issue_number: int) -> str | None: timeout=30, ).stdout comments = (json.loads(out) or {}).get("comments") or [] + return active_claim_stamp( + [c for c in comments if isinstance(c, dict)]) except Exception: # noqa: BLE001 - sonde best-effort, l'issue garde son merge return None - stamps = [c.get("createdAt") for c in comments - if isinstance(c, dict) and c.get("createdAt") - and _CLAIM_VISIT_RE.search(c.get("body") or "")] - return max(stamps) if stamps else None def settle_belt_head( diff --git a/scripts/tests/test_pick_idle_grain_belt.py b/scripts/tests/test_pick_idle_grain_belt.py index 67aeca1b5f..e394c44043 100644 --- a/scripts/tests/test_pick_idle_grain_belt.py +++ b/scripts/tests/test_pick_idle_grain_belt.py @@ -729,16 +729,121 @@ def test_parent_refs_reads_part_of_and_title_prefix_not_self(): assert pig.parent_refs(plain) == set() +def _claim(at: str, body: str) -> dict: + return {"createdAt": at, "body": body, "author": {"login": "jsboige"}} + + +def test_active_claim_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.active_claim_stamp( + [_claim("2026-10-04T09:50:12Z", body)]) == "2026-10-04T09:50:12Z", body + + +def test_active_claim_stamp_ignores_quoted_and_midline_mentions(): + """Une citation en bloc fence, une mention en milieu de ligne, un marqueur + sans lane ne sont pas des reservations.""" + 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.active_claim_stamp(comments) is None + + +def test_active_claim_stamp_released_claim_gives_the_rank_back(): + """Point 2 de la reserve : un claim rendu n'a pas servi l'issue.""" + comments = [ + _claim("2026-10-04T09:50:12Z", + "[CLAIMED] lane myia-po-2027:CoursIA -- T1"), + _claim("2026-10-04T12:00:00Z", + "[RELEASED] lane myia-po-2027:CoursIA -- rendu"), + ] + assert pig.active_claim_stamp(comments) is None + for close in ("DONE", "ABANDONED", "CANCELLED"): + assert pig.active_claim_stamp([ + comments[0], + _claim("2026-10-04T12:00:00Z", + f"[{close}] lane myia-po-2027:CoursIA"), + ]) is None, close + + +def test_active_claim_stamp_keeps_the_lane_still_holding_it(): + """Une lane rend, une autre tient encore : la date est celle du claim + encore actif, pas la plus recente de toutes.""" + 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-04T12:00:00Z", + "[RELEASED] lane myia-po-2027:CoursIA -- rendu"), + ] + assert pig.active_claim_stamp(comments) == "2026-10-04T08:00:00Z" + + +def test_active_claim_stamp_delivered_follows_the_pr_state(): + """``[DELIVERED]`` suit la semantique v2 de l'organe (#12386) : PR ouverte + ou mergee = encore tenue, PR fermee sans merge = rendue.""" + def comments(): + return [ + _claim("2026-10-04T09:00:00Z", + "[CLAIMED] lane myia-po-2027:CoursIA -- T1"), + _claim("2026-10-04T15:00:00Z", + "[DELIVERED] lane myia-po-2027:CoursIA -- PR #19999"), + ] + assert pig.active_claim_stamp( + comments(), {19999: "OPEN"}) == "2026-10-04T15:00:00Z" + assert pig.active_claim_stamp(comments(), {19999: "CLOSED"}) is None + + +def test_belt_released_issue_returns_ahead_of_visited_ones(): + """Bout en bout sur le tri : apres un ``[RELEASED]``, l'issue repasse + devant une issue visitee plus recemment.""" + claim = _claim("2026-10-04T09:00:00Z", + "[CLAIMED] lane myia-po-2027:CoursIA -- T1") + release = _claim("2026-10-04T12:00:00Z", + "[RELEASED] lane myia-po-2027:CoursIA -- rendu") + + def order(comments): + pool = [ + _make_item(100, age_days=60, idle=0, + last="2026-08-01T00:00:00Z", + created="2026-07-01T00:00:00Z"), + _make_item(200, age_days=10, idle=0, + last="2026-09-20T00:00:00Z", + created="2026-09-01T00:00:00Z"), + ] + pig.settle_belt_head( + pool, need=2, + probe=lambda n: pig.active_claim_stamp(comments) + if n == 100 else None, + max_probes=4) + return [it["number"] for it in pool] + + assert order([claim]) == [200, 100] # tenue : elle recule + assert order([claim, release]) == [100, 200] # rendue : elle revient + + def test_latest_claim_stamp_reads_claims_of_any_lane(monkeypatch): payload = {"comments": [ - {"createdAt": "2026-10-04T09:50:12Z", - "body": "[CLAIMED] lane myia-po-2027:CoursIA -- T1"}, - {"createdAt": "2026-10-04T10:50:00Z", - "body": "[CLAIMED-AMEND] lane myia-po-2027:CoursIA -- paths: a/**"}, - {"createdAt": "2026-10-04T12:00:00Z", - "body": "T1 livree. Le [CLAIMED] du matin reste valable."}, - {"createdAt": "2026-10-04T13:00:00Z", - "body": "> [CLAIMED] cite dans une reponse"}, + _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: From f4b816f77225653c301a15768a2501f8bb82a885 Mon Sep 17 00:00:00 2001 From: jsboige Date: Mon, 5 Oct 2026 00:39:55 +0200 Subject: [PATCH 3/3] fix(picker,#18832): une cloture de claim est une visite, pas un rang rendu MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Le passage reel a la tete 53fbc6bb1e a refute la lecture « claim rendu = rang rendu » : #7742, rendue le 19/09 apres deux tranches mergees, repassait en tete du tapis comme jamais servie. claim_visit_stamp lit tous les marqueurs attribues a une lane avec la grammaire de l'organe (_sort_events) : prise, amendement, override, livraison, rendu. La date est celle de la plus recente marque ; une cloture l'avance. Un marqueur sans lane (citation, gabarit) ne compte pas. Tests : formes decorees, mentions citees, clotures qui avancent la visite, toutes lanes, et le scenario #7742 de bout en bout. Co-Authored-By: Claude Opus 5.5 (1M context) --- scripts/pick_idle_grain.py | 52 ++++++------ scripts/tests/test_pick_idle_grain_belt.py | 94 +++++++++------------- 2 files changed, 62 insertions(+), 84 deletions(-) diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index 0b6884464c..be645461f3 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -3933,12 +3933,14 @@ def upsert_orphans_comment(number: int, body: str) -> None: # 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 claim ENCORE ACTIF sur l'issue, toutes lanes -# (`last_claim_stamp`, lu sur la tete de file par `settle_belt_head`) ; -# la grammaire et la reduction sont celles de l'organe -# `check_lane_claim.py` (`active_claim_stamp`), jamais une regex propre : -# un claim rendu (`[RELEASED]`, `[DONE]`, livraison fermee) n'a pas -# servi l'issue, qui reprend son rang ; +# 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+)") @@ -4008,35 +4010,29 @@ def apply_child_visits(pool: list[dict], targets: list[dict]) -> dict[int, str]: return latest -def active_claim_stamp( - comments: list[dict], - pr_states: dict[int, str] | None = None, -) -> str | None: - """``createdAt`` serveur du plus recent claim encore actif, toutes lanes. - - Lecture et reduction deleguees 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), ``compute_active_claims`` rejoue ouvertures, amendements, - overrides et clotures. Un claim clos ne laisse aucune date : l'issue - reprend le rang que lui donnent ses merges. ``pr_states`` est transmis au - reducteur pour les ``[DELIVERED]`` (injection testable, comme dans - l'organe). +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, compute_active_claims + from check_lane_claim import _sort_events - active, _unattributed = compute_active_claims( - _sort_events({"comments": comments}), pr_states) - stamps = [ev.created_at for ev in active.values() if ev.created_at] + 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 claim actif de l'issue (cf `active_claim_stamp`). + """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 claim actif ou si - la lecture echoue -- l'issue garde alors sa date de merge, comme avant. + 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( @@ -4046,7 +4042,7 @@ def latest_claim_stamp(issue_number: int) -> str | None: timeout=30, ).stdout comments = (json.loads(out) or {}).get("comments") or [] - return active_claim_stamp( + 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 diff --git a/scripts/tests/test_pick_idle_grain_belt.py b/scripts/tests/test_pick_idle_grain_belt.py index e394c44043..ec4b31948c 100644 --- a/scripts/tests/test_pick_idle_grain_belt.py +++ b/scripts/tests/test_pick_idle_grain_belt.py @@ -733,7 +733,7 @@ def _claim(at: str, body: str) -> dict: return {"createdAt": at, "body": body, "author": {"login": "jsboige"}} -def test_active_claim_stamp_reads_decorated_markers_like_the_organ(): +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 ( @@ -744,13 +744,13 @@ def test_active_claim_stamp_reads_decorated_markers_like_the_organ(): "→[CLAIMED] lane myia-po-2027:CoursIA -- T1", "[claimed] lane myia-po-2027:CoursIA -- T1", ): - assert pig.active_claim_stamp( + assert pig.claim_visit_stamp( [_claim("2026-10-04T09:50:12Z", body)]) == "2026-10-04T09:50:12Z", body -def test_active_claim_stamp_ignores_quoted_and_midline_mentions(): +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 pas des reservations.""" + sans lane ne sont la visite de personne.""" fenced = ("Le gabarit est :\n```\n[CLAIMED] lane myia-po-2027:CoursIA" " -- T1\n```\n") comments = [ @@ -759,81 +759,63 @@ def test_active_claim_stamp_ignores_quoted_and_midline_mentions(): "T1 livree. Le [CLAIMED] du matin reste valable."), _claim("2026-10-04T11:00:00Z", "> [CLAIMED] cite sans lane"), ] - assert pig.active_claim_stamp(comments) is None + assert pig.claim_visit_stamp(comments) is None -def test_active_claim_stamp_released_claim_gives_the_rank_back(): - """Point 2 de la reserve : un claim rendu n'a pas servi l'issue.""" - comments = [ - _claim("2026-10-04T09:50:12Z", - "[CLAIMED] lane myia-po-2027:CoursIA -- T1"), - _claim("2026-10-04T12:00:00Z", - "[RELEASED] lane myia-po-2027:CoursIA -- rendu"), - ] - assert pig.active_claim_stamp(comments) is None - for close in ("DONE", "ABANDONED", "CANCELLED"): - assert pig.active_claim_stamp([ - comments[0], +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"), - ]) is None, close + f"[{close}] lane myia-po-2027:CoursIA -- PR #19999"), + ]) == "2026-10-04T12:00:00Z", close -def test_active_claim_stamp_keeps_the_lane_still_holding_it(): - """Une lane rend, une autre tient encore : la date est celle du claim - encore actif, pas la plus recente de toutes.""" +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-04T12:00:00Z", - "[RELEASED] lane myia-po-2027:CoursIA -- rendu"), + _claim("2026-10-04T07:00:00Z", + "[RELEASED] lane myia-po-2023:CoursIA -- ancien"), ] - assert pig.active_claim_stamp(comments) == "2026-10-04T08:00:00Z" - - -def test_active_claim_stamp_delivered_follows_the_pr_state(): - """``[DELIVERED]`` suit la semantique v2 de l'organe (#12386) : PR ouverte - ou mergee = encore tenue, PR fermee sans merge = rendue.""" - def comments(): - return [ - _claim("2026-10-04T09:00:00Z", - "[CLAIMED] lane myia-po-2027:CoursIA -- T1"), - _claim("2026-10-04T15:00:00Z", - "[DELIVERED] lane myia-po-2027:CoursIA -- PR #19999"), - ] - assert pig.active_claim_stamp( - comments(), {19999: "OPEN"}) == "2026-10-04T15:00:00Z" - assert pig.active_claim_stamp(comments(), {19999: "CLOSED"}) is None + assert pig.claim_visit_stamp(comments) == "2026-10-04T09:00:00Z" -def test_belt_released_issue_returns_ahead_of_visited_ones(): - """Bout en bout sur le tri : apres un ``[RELEASED]``, l'issue repasse - devant une issue visitee plus recemment.""" - claim = _claim("2026-10-04T09:00:00Z", - "[CLAIMED] lane myia-po-2027:CoursIA -- T1") - release = _claim("2026-10-04T12:00:00Z", - "[RELEASED] lane myia-po-2027:CoursIA -- rendu") +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(100, age_days=60, idle=0, - last="2026-08-01T00:00:00Z", - created="2026-07-01T00:00:00Z"), - _make_item(200, age_days=10, idle=0, - last="2026-09-20T00:00:00Z", + _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.active_claim_stamp(comments) - if n == 100 else None, + 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([claim]) == [200, 100] # tenue : elle recule - assert order([claim, release]) == [100, 200] # rendue : elle revient + 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):