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
60 changes: 58 additions & 2 deletions scripts/check_adjoint_prevalidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -971,8 +971,10 @@ def recheck_blocked_b0(
Only the b0-ONLY case expires, and only toward a re-stamp: the answer
becomes the no-dossier outcome (exit 1), which routes the pull request to
a third-party lane for a fresh dossier -- never back to the author as
mergeable. Any other blocking field (checks, scope, domain) keeps the
dossier standing: its reason may still hold.
mergeable. Any other blocking field keeps the dossier standing HERE: the
``checks`` field has its own recheck (``recheck_blocked_checks``), and
``scope``/``domain`` are judgments about a diff, which cannot move without
a push -- and a push already expires the stamp.
"""
if verdict != VERDICT_BLOCKED or dossier is None:
return verdict, [], dossier
Expand All @@ -990,6 +992,56 @@ def recheck_blocked_b0(
], None


def recheck_blocked_checks(
snapshot: dict[str, Any],
verdict: str,
dossier: Dossier | None,
) -> tuple[str, list[str], Dossier | None]:
"""Expire a BLOCKED dossier whose only blocking field, ``checks``, no longer blocks.

Third of the family, after ``refute_ready_b0`` and ``recheck_blocked_b0``
(#19093). ``checks`` is the ONE blocking field whose value moves without a
head mutation: a check concludes asynchronously, so a dossier stamped while
a leg was still running attests ``checks: BLOCKED`` for a state a later
verdict extinguishes on the very same head. Measured instances: #19906
(dossier BLOCKED at 2026-10-08T10:42:32Z, ``PR gate`` success at
11:21:46Z on the same head ``4d3eb7f67a3f`` -- 95/95 legs green and the
gate still answering rc=3) and #20058 (reported BLOCKED at 09:35:37Z, green
at 10:00:36Z, still read as unmergeable 25 min later). ``scope`` and
``domain`` cannot move without a push, and a push already expires the
stamp; ``checks`` can, and did.

Unlike ``recheck_blocked_b0`` this costs no probe: ``checkRuns`` is already
on the snapshot (``_head_check_runs``), so the comparison is local. The
predicate is the one that refutes a false ``latest-wins-green`` claim --
every latest-wins conclusion green AND every required check present -- read
in the other direction.

Only the checks-ONLY case expires, and only toward a re-stamp: the answer
becomes the no-dossier outcome (exit 1), which routes the pull request to a
third-party lane for a fresh dossier -- never back to the author as
mergeable.
"""
if verdict != VERDICT_BLOCKED or dossier is None:
return verdict, [], dossier
if blocking_fields(dossier) != ["checks"]:
return verdict, [], dossier
check_runs = snapshot.get("checkRuns")
# `None` means "not measured", and a failure to measure is never a pass.
# An empty list IS measured, and the required-check rule refutes it below.
if check_runs is None:
return verdict, [], dossier
if check_claim_contradictions("latest-wins-green", check_runs):
return verdict, [], dossier
return "", [
"dossier BLOCKED for checks only, but the live latest-wins state of the "
f"head no longer blocks PR #{snapshot.get('number')} -- every conclusion "
"is green and every required check is present, so the dossier's stated "
"reason is extinguished and a re-stamp is required: a third-party lane "
"must post a fresh dossier (never merge on this one)"
], None


def carrying_lane(snapshot: dict[str, Any]) -> str | None:
"""Return the lane that carries this pull request, from its `Grain:` tag.

Expand Down Expand Up @@ -1963,6 +2015,10 @@ def main() -> int:
)
if not errors:
verdict, errors, dossier = recheck_blocked_b0(args.pr, verdict, dossier)
if not errors:
verdict, errors, dossier = recheck_blocked_checks(
snapshot, verdict, dossier
)
except (
RuntimeError,
KeyError,
Expand Down
117 changes: 115 additions & 2 deletions scripts/tests/test_check_adjoint_prevalidation.py
Original file line number Diff line number Diff line change
Expand Up @@ -1992,8 +1992,14 @@ def test_blocked_b0_only_stands_when_organ_still_blocks(monkeypatch, capsys):


def test_blocked_with_other_motif_not_touched_by_the_recheck(monkeypatch, capsys):
"""Acceptance 3 : un autre motif bloquant (checks) garde son dossier,
meme quand l'organe B.0 ne bloque plus -- sa raison peut tenir encore."""
"""Acceptance 3 : le re-jeu B.0 ne touche QUE la forme `b0` seul.

Ici `checks` bloque aussi, donc `blocking_fields` rend `['checks', 'b0']` et
le dossier tient -- l'organe B.0 ne dit rien du motif `checks`. Ce n'est PAS
la regle generale du champ `checks` : un dossier bloque pour `checks` SEUL
est re-lu par `recheck_blocked_checks` (#20082), qui l'expire vers un
re-tampon quand la tete est toute verte (cf. tests *blocked_checks* plus bas).
"""
snapshot = _blocked_b0_snapshot(checks="BLOCKED:gate rouge")
monkeypatch.setattr(mod, "load_snapshot", lambda pr: snapshot)
monkeypatch.setattr(mod.gh_identity, "pin_gh_token", lambda: None)
Expand All @@ -2013,6 +2019,113 @@ def test_recheck_blocked_b0_never_probes_a_non_blocked_verdict():
assert calls == []


# --- `checks` est le SEUL champ bloquant qui bouge sans push (#20082) --------


def _blocked_checks_snapshot(**changes: str):
"""Dossier BLOCKED dont `checks` est le SEUL champ bloquant.

`b0`/`scope`/`domain` restent aux valeurs READY (defauts de `_body`), donc
`blocking_fields` rend exactement `['checks']` -- la seule forme que
`recheck_blocked_checks` a le droit d'expirer.
"""
fields = {"verdict": "BLOCKED", "checks": "BLOCKED:gate rouge"}
fields.update(changes)
return _snapshot_with(**fields)


def _red_pr_gate() -> dict:
return {
"id": 2,
"name": "PR gate",
"status": "completed",
"conclusion": "failure",
"started_at": "2026-10-08T09:00:00Z",
"output": {"title": "PR gate -- 1 failing check"},
}


def test_blocked_checks_only_expired_when_the_head_no_longer_blocks(
monkeypatch, capsys
):
"""Acceptance 1 : tete toute verte -> le gate rend 1 et nomme le re-tampon.

Mesure fondatrice (#19906) : dossier BLOCKED a 2026-10-08T10:42:32Z pour un
`PR gate` encore en vol, conclu `success` a 11:21:46Z sur la MEME tete
`4d3eb7f67a3f` -- 95/95 jambes vertes et le gate repondait encore rc=3.
"""
snapshot = _blocked_checks_snapshot()
rc = _run_main(monkeypatch, snapshot)
assert rc == mod.EXIT_NO_DOSSIER
out = capsys.readouterr().out
assert "NO-DOSSIER" in out
assert "no longer blocks PR #123" in out
assert "re-stamp" in out and "third-party lane" in out


def test_blocked_checks_only_stands_when_the_head_still_blocks(monkeypatch, capsys):
"""Acceptance 2 (temoin negatif) : une jambe rouge -> le gate rend toujours 3."""
snapshot = _blocked_checks_snapshot()
snapshot["checkRuns"] = [*snapshot["checkRuns"], _red_pr_gate()]
assert _run_main(monkeypatch, snapshot) == mod.EXIT_BLOCKED_WITH_SUBSTANCE
assert capsys.readouterr().out.startswith("BLOCKED-WITH-SUBSTANCE")


def test_blocked_checks_only_stands_when_the_required_check_is_absent(
monkeypatch, capsys
):
"""Temoin negatif du temoin negatif (#18579) : une tete qui ne porte QUE des
jambes CodeQL est verte par vacuite ; l'absence de `PR gate` refute la
lecture, donc le dossier tient. Sans ce garde, la re-lecture fabriquerait
un re-tampon sur une tete ou les workflows `pull_request` n'ont jamais tire.
"""
snapshot = _blocked_checks_snapshot()
snapshot["checkRuns"] = [
{
"id": 3,
"name": "Analyze (python)",
"status": "completed",
"conclusion": "success",
"started_at": "2026-10-08T09:00:00Z",
}
]
assert _run_main(monkeypatch, snapshot) == mod.EXIT_BLOCKED_WITH_SUBSTANCE
assert capsys.readouterr().out.startswith("BLOCKED-WITH-SUBSTANCE")


def test_blocked_checks_stands_when_the_head_was_never_measured(monkeypatch, capsys):
"""`checkRuns is None` = non mesure, et un echec de mesure n'est jamais un
passe : le dossier tient au lieu d'etre expire sur une absence de donnee."""
snapshot = _blocked_checks_snapshot()
snapshot["checkRuns"] = None
assert _run_main(monkeypatch, snapshot) == mod.EXIT_BLOCKED_WITH_SUBSTANCE
assert capsys.readouterr().out.startswith("BLOCKED-WITH-SUBSTANCE")


def test_recheck_blocked_checks_never_fires_on_a_non_blocked_verdict():
"""Comme `recheck_blocked_b0`, le re-jeu ne touche que BLOCKED + dossier.

Le cout est nul ici (les check-runs sont deja sur le snapshot), mais la
garde reste : un READY ne se re-decompose pas par cette porte.
"""
snapshot = _blocked_checks_snapshot()
verdict, dossier = _ready_dossier()
assert mod.recheck_blocked_checks(snapshot, verdict, dossier) == (
verdict,
[],
dossier,
)
assert mod.recheck_blocked_checks(snapshot, "", None) == ("", [], None)


def test_blocked_checks_plus_another_motif_is_left_standing(monkeypatch, capsys):
"""Un dossier bloque pour `checks` ET un autre champ n'est pas expire : la
re-lecture des checks ne dit rien du motif restant."""
snapshot = _blocked_checks_snapshot(scope="FAIL: 6 fichiers pour 5 annonces")
assert _run_main(monkeypatch, snapshot) == mod.EXIT_BLOCKED_WITH_SUBSTANCE
assert capsys.readouterr().out.startswith("BLOCKED-WITH-SUBSTANCE")


# --- #18637 : advisories sticky (marqueur en FIN de corps) et resumes de bot --

_STICKY_STALE = (
Expand Down
Loading