From 013bb083c7541f5a9dce465b021a18c849b0c62a Mon Sep 17 00:00:00 2001 From: jsboige Date: Sat, 12 Sep 2026 15:01:19 +0200 Subject: [PATCH] fix(guard,#15734): check_slot_reservation tranche sur le status, pas sur additions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pr_claims` reservait un slot ssi `additions > 0`, en supposant qu'une entree a `additions == 0` etait une suppression pure, donc un slot LIBERE. Mesure sur le pool vivant (2026-09-12, 53-54 PRs ouvertes, 101 entrees .ipynb, `gh pr list` et `gh api .../files` croisees, 0 desaccord de compteur) : status=modified 70 | renamed 27 | added 4 | removed 0 `additions == 0` recouvre donc une modification qui ne retire que des lignes, un rename byte-identique (#15734) et un fichier ajoute vide -- jamais, dans ce pool, une suppression. Le seul discriminant est `status`, que seul `gh api repos/.../pulls//files` expose. - `pr_claims(prs, removed=None)` : une entree n'est ecartee que si son chemin est dans `removed`. La fonction reste pure ; la carte des retraits lui est passee. - `load_removed_paths` n'interroge `status` que pour les PRs dont une entree .ipynb porte `additions == 0` -- l'ensemble ambigu, 1 PR sur 53 mesuree -- et jamais en `--offline`. - Sens d'echec : une lecture ratee laisse les slots RESERVES et le dit (`partial` + numeros). Sous-reserver en silence est le seul mode de panne que ce preflight ne rattrape pas. - Docstring : la justification du filtre (« sans lui, une PR qui renomme reserverait le slot qu'elle quitte ») est sans objet -- mesure, 0 des 27 renames publient leur chemin quitte dans `files[]`. La severite reelle est ecrite noir sur blanc : latente, nulle sur ce pool, la seule entree vivante (#15729) portant un nom sans index que `slot_of` ecartait deja. Non-regression : les 19 tests anterieurs restent verts ; l'invocation de voie rapide (`--offline`) est inchangee, sortie et code retour identiques. Controle positif (origin/main, module charge par `git show`, jamais edite) : 12 des tests neufs rougissent, dont le controle comportemental a signature identique -- ORIGINE pr_claims(#15729 (0,12)) -> AUCUNE RESERVATION | carte = {} CORRIGE pr_claims(#15729 (0,12)) -> RESERVE | carte = {...} Closes #15734 Co-Authored-By: Claude Sonnet 5 --- .../notebook_tools/check_slot_reservation.py | 187 ++++++++++++++++-- scripts/tests/test_check_slot_reservation.py | 170 ++++++++++++++++ 2 files changed, 338 insertions(+), 19 deletions(-) diff --git a/scripts/notebook_tools/check_slot_reservation.py b/scripts/notebook_tools/check_slot_reservation.py index 540d1a41a9..c74d6ae73d 100644 --- a/scripts/notebook_tools/check_slot_reservation.py +++ b/scripts/notebook_tools/check_slot_reservation.py @@ -69,10 +69,42 @@ SOURCES : CE QU'UNE PR OUVERTE DIT DE SES SLOTS ----------------------------------------------- `gh pr list --json files` ne rend que `path`, `additions`, `deletions` -- pas de -`changeType` (mesure faite). Une ecriture se reconnait donc a `additions > 0` : -un chemin a `additions == 0` est une suppression PURE, c'est-a-dire un slot -LIBERE, pas un slot reserve. Sans ce filtre, chaque PR qui renomme un notebook -reserverait le slot qu'elle vient de quitter. +`status` ni de `changeType` (mesure faite). Un premier jet en a conclu que +`additions == 0` valait « suppression pure, donc slot LIBERE ». C'est faux, et +la mesure du 2026-09-12 le dit : 54 PRs ouvertes, 101 entrees `.ipynb`, les deux +API croisees (0 desaccord de compteur, 0 entree presente d'un seul cote) -- + + status=modified 70 | renamed 27 | added 4 | removed 0 + +`additions == 0` recouvre donc des etats qui ne sont PAS des suppressions : + + - une **modification qui ne retire que des lignes** (#15729, + `status=modified`, `(0, 12)`) ; + - un **rename byte-identique** (`(0, 0)`, #15734) ; + - un fichier **ajoute vide**. + +IMPACT MESURE : **latent, nul sur le pool du 2026-09-12**. La seule entree +vivante a `additions == 0` est #15729, et son nom ne porte AUCUN index +(`index_key("GameTheory-06e-...")` rend `None`) : `slot_of` l'ecartait donc deja +et l'ancien filtre comme le nouveau rendent le meme verdict sur elle. Le defaut +n'a pas brule. Il n'en est pas moins reel : la forme fautive est exactement +celle qu'une tranche de renumeration produit des son PREMIER commit -- un +`git mv` pur d'un `NN-N-Nom.ipynb`, `(0, 0)` -- et c'est la fenetre pendant +laquelle deux lanes peuvent viser le meme slot. Ce correctif ferme la classe ; +il ne repare pas un incendie, et ne doit pas etre presente comme tel. + +La justification ecrite de ce filtre -- « sans lui, chaque PR qui renomme +reserverait le slot qu'elle vient de quitter » -- est sans objet : mesure, **0 +des 27 renames** publient leur chemin QUITTE dans `files[]` ; un rename n'y +figure que sous son chemin d'arrivee. Le chemin quitte n'est pas dans la liste, +il ne peut donc rien reserver. + +Le seul discriminant honnete est `status`, que seul +`gh api repos/.../pulls//files` expose. Il n'est paye QUE pour les PRs dont +une entree `.ipynb` porte `additions == 0` -- l'ensemble ambigu, soit 1 PR sur +54 le 2026-09-12 -- et jamais en `--offline`. Une PR dont la resolution echoue +voit ses slots RESERVES : sous-reserver en silence etant le seul mode de panne +que cet organe ne rattrape pas, l'echec va dans l'autre sens et se dit. MESURES SUR L'ARBRE ET LE POOL VIVANTS (2026-09-11) --------------------------------------------------- @@ -148,6 +180,7 @@ SOURCE_BASE = "base" SOURCE_REVISION = "revision" SOURCE_OPEN_PRS = "open_prs" +SOURCE_OPEN_PRS_REMOVED = "open_prs_removed" SOURCE_DECLARED = "declared" # Precedence du verdict quand plusieurs sources se disputent le meme slot. Du @@ -233,22 +266,27 @@ def group_by_slot(paths, source, detail=""): return out -def pr_claims(prs): +def pr_claims(prs, removed=None): """Slots tenus par les PRs ouvertes. - `prs` = la charge rendue par ``gh pr list --json number,files``. Un chemin a - ``additions == 0`` est une suppression pure : la PR LIBERE ce slot, elle ne - le reserve pas (cf docstring). + `prs` = la charge rendue par ``gh pr list --json number,files``. + `removed` = ``{numero de PR: {chemins}}``, les chemins REELLEMENT retires -- + la seule chose que `gh pr list --json files` ne sait pas dire (cf docstring). + + Une entree n'est donc ecartee que si son chemin figure dans `removed`. + `additions == 0` n'est plus un motif d'exclusion : mesure, il recouvre aussi + la modification qui ne retire que des lignes et le rename byte-identique. """ claims: dict[tuple[str, str], list[Occupant]] = {} for pr in prs or []: num = pr.get("number") + gone = (removed or {}).get(num) or set() for f in pr.get("files") or []: path = f.get("path") or "" if not path.lower().endswith(".ipynb"): continue - if not (f.get("additions") or 0) > 0: - continue + if path in gone: + continue # suppression PURE : ce slot est libere slot = slot_of(path) if slot is None: continue @@ -256,6 +294,68 @@ def pr_claims(prs): return claims +def ambiguous_pr_numbers(prs): + """PRs dont la charge `gh pr list` ne suffit pas a trancher. + + Critere : au moins une entree `.ipynb` a `additions == 0`. Ce sont les seules + pour lesquelles `status` doit etre lu (`gh api .../files`), et elles sont + rares -- 1 PR sur 54 au 2026-09-12, la ou `additions > 0` suffit seul. + """ + out = [] + for pr in prs or []: + for f in pr.get("files") or []: + if not (f.get("path") or "").lower().endswith(".ipynb"): + continue + if not (f.get("additions") or 0) > 0: + out.append(pr.get("number")) + break + return out + + +def load_removed_paths(numbers, repo, status): + """Chemins reellement retires par ces PRs (`status == "removed"`). + + Seul `gh api .../pulls//files` expose `status`. Une PR dont la lecture + echoue n'entre PAS dans la carte : ses slots restent donc reserves, ce qui + est le bon sens d'echec pour un preflight (cf docstring), et l'echec est + ecrit dans `status` plutot que tu. + """ + removed: dict[int, set[str]] = {} + if not numbers: + status[SOURCE_OPEN_PRS_REMOVED] = {"status": "not_needed", "prs": 0, + "removed": 0, "failed": []} + return removed + failed = [] + for num in numbers: + # `{owner}`/`{repo}` sont resolus par `gh` depuis le depot courant quand + # aucun `--repo` n'est impose : la meme tolerance que la source PRs. + path = ("repos/%s/pulls/%d/files?per_page=100" % (repo, num) if repo + else "repos/{owner}/{repo}/pulls/%d/files?per_page=100" % num) + try: + r = subprocess.run(["gh", "api", path, "--paginate", "--jq", + '.[] | [.filename, .status] | @tsv'], + capture_output=True, text=True, encoding="utf-8", + errors="replace") + except FileNotFoundError: + failed.append(num) + continue + if r.returncode != 0: + failed.append(num) + continue + gone = set() + for line in (r.stdout or "").splitlines(): + cols = line.split("\t") + if len(cols) >= 2 and cols[1].strip() == "removed": + gone.add(cols[0]) + removed[num] = gone + status[SOURCE_OPEN_PRS_REMOVED] = { + "status": "ok" if not failed else "partial", + "prs": len(numbers), "removed": sum(len(v) for v in removed.values()), + "failed": failed, + } + return removed + + def declared_claims(doc): """Slots declares reserves par une table publiee (`slot_reservations.json`).""" claims: dict[tuple[str, str], list[Occupant]] = {} @@ -342,6 +442,7 @@ def load_open_pr_claims(limit, repo, exclude_pr, offline, status): """Source PR ouvertes. Toute indisponibilite est ECRITE dans `status`.""" if offline: status[SOURCE_OPEN_PRS] = {"status": "unavailable", "reason": "--offline"} + status[SOURCE_OPEN_PRS_REMOVED] = {"status": "unavailable", "reason": "--offline"} return {} args = ["pr", "list", "--state", "open", "--limit", str(limit), "--json", "number,files"] @@ -365,7 +466,7 @@ def load_open_pr_claims(limit, repo, exclude_pr, offline, status): if exclude_pr: prs = [p for p in prs if p.get("number") != exclude_pr] status[SOURCE_OPEN_PRS] = {"status": "ok", "prs": len(prs)} - return pr_claims(prs) + return pr_claims(prs, load_removed_paths(ambiguous_pr_numbers(prs), repo, status)) def load_declared(path, status): @@ -463,15 +564,32 @@ def self_test(): ("NEGATIF slot reellement libre", [A], merge(group_by_slot(["MyIA.AI.Notebooks/X/04-1-Previous.ipynb"], SOURCE_BASE)), set(), ["free"]), - # --- la source PR ouvertes ne reserve pas ce qu'une PR supprime --- - # `gh pr list --json files` ne rend pas `changeType` : c'est - # `additions == 0` qui distingue une suppression pure d'une ecriture. - ("FAUX POSITIF PR qui SUPPRIME un notebook ne reserve pas son slot", + # --- la source PR ouvertes : c'est `status` qui tranche, pas `additions` --- + # Ce bloc portait auparavant un unique scenario « (0, 12) -> free », en + # supposant qu'`additions == 0` signifiait « suppression pure ». Ce + # fixture EST la forme de #15729, mesure vivante : `status=modified`, + # une edition reelle. Le scenario affirmait donc l'inverse du fait, et + # couvrait la suppression REELLE par un proxy qui ne la designe pas. + ("VRAI POSITIF modification qui ne retire que des lignes RESERVE (#15729)", [A], pr_claims([{"number": 1, "files": [ - {"path": C, "additions": 0, "deletions": 12}]}]), set(), ["free"]), - ("VRAI POSITIF PR qui ECRIT un notebook reserve son slot", + {"path": C, "additions": 0, "deletions": 12}]}], {1: set()}), + set(), ["reserved_by_open_pr"]), + ("VRAI POSITIF rename byte-identique RESERVE son slot (#15734)", [A], pr_claims([{"number": 2, "files": [ - {"path": C, "additions": 8, "deletions": 2}]}]), set(), ["reserved_by_open_pr"]), + {"path": C, "additions": 0, "deletions": 0}]}], {2: set()}), + set(), ["reserved_by_open_pr"]), + ("VRAI POSITIF statut NON lu (echec) : RESERVE, jamais sous-reserver", + [A], pr_claims([{"number": 3, "files": [ + {"path": C, "additions": 0, "deletions": 12}]}]), + set(), ["reserved_by_open_pr"]), + ("NEGATIF suppression REELLE (status=removed) ne reserve pas", + [A], pr_claims([{"number": 4, "files": [ + {"path": C, "additions": 0, "deletions": 12}]}], {4: {C}}), + set(), ["free"]), + ("VRAI POSITIF PR qui ECRIT un notebook reserve son slot", + [A], pr_claims([{"number": 5, "files": [ + {"path": C, "additions": 8, "deletions": 2}]}], {5: set()}), + set(), ["reserved_by_open_pr"]), ] print("") @@ -482,7 +600,27 @@ def self_test(): print(" %-4s %-62s -> %s (attendu %s)" % ("OK" if ok else "KO", label, got, list(want))) - total = len(_SLOT_CASES) + len(scenarios) + print("") + print("--- ensemble ambigu : quelles PRs exigent une lecture de `status` ---") + amb_cases = [ + ("entree .ipynb a additions==0 -> ambigue", + [{"number": 1, "files": [{"path": C, "additions": 0, "deletions": 12}]}], [1]), + ("toutes les entrees .ipynb ecrites -> non ambigue", + [{"number": 2, "files": [{"path": C, "additions": 3, "deletions": 1}]}], []), + ("un .md a additions==0 n'est pas une entree de notebook", + [{"number": 3, "files": [{"path": "docs/x.md", "additions": 0, "deletions": 4}]}], []), + ("deux entrees dont une ambigue -> la PR compte UNE fois", + [{"number": 4, "files": [{"path": C, "additions": 0, "deletions": 4}, + {"path": B, "additions": 2, "deletions": 0}]}], [4]), + ] + for label, prs, want in amb_cases: + got = ambiguous_pr_numbers(prs) + ok = got == want + ko += 0 if ok else 1 + print(" %-4s %-62s -> %s (attendu %s)" + % ("OK" if ok else "KO", label, got, want)) + + total = len(_SLOT_CASES) + len(scenarios) + len(amb_cases) print("") print("%s : %d cas, %d echec(s)" % ("ECHEC" if ko else "SUCCES", total, ko)) return 2 if ko else 0 @@ -570,6 +708,17 @@ def main(argv=None): pr_status = status.get(SOURCE_OPEN_PRS, {}) if pr_status.get("status") == "ok": print(" open_prs : %d PR(s) ouverte(s)" % pr_status.get("prs", 0)) + rem = status.get(SOURCE_OPEN_PRS_REMOVED, {}) + if rem.get("status") == "ok": + print(" retraits : %d PR(s) ambigue(s) (additions==0) lue(s), " + "%d chemin(s) reellement retire(s)" + % (rem.get("prs", 0), rem.get("removed", 0))) + elif rem.get("status") == "partial": + print(" retraits : %d PR(s) NON lue(s) -> leurs slots sont RESERVES " + "(sous-reserver serait le sens d'echec non rattrapable)" + % len(rem.get("failed") or [])) + else: + print(" retraits : %s" % rem.get("status", "?")) else: print(" open_prs : INDISPONIBLE (%s)" % pr_status.get("reason", "?")) dec_status = status.get(SOURCE_DECLARED, {}) diff --git a/scripts/tests/test_check_slot_reservation.py b/scripts/tests/test_check_slot_reservation.py index 94a2247f67..18600b61a3 100644 --- a/scripts/tests/test_check_slot_reservation.py +++ b/scripts/tests/test_check_slot_reservation.py @@ -35,10 +35,17 @@ import tempfile import unittest from pathlib import Path +from unittest import mock _SCRIPT = (Path(__file__).resolve().parent.parent / "notebook_tools" / "check_slot_reservation.py") +# Le module est importe pour eprouver ses fonctions pures (`pr_claims`, +# `ambiguous_pr_numbers`) et son lecteur `gh`, que le harnais `--offline` +# ci-dessus ne peut pas atteindre. +sys.path.insert(0, str(_SCRIPT.parent)) +import check_slot_reservation as csr # noqa: E402 + SERIES = "MyIA.AI.Notebooks/GenAI/Audio" @@ -360,8 +367,171 @@ def test_json_payload_carries_sources_and_states(self): self.assertEqual(doc["mode"], "revision") self.assertEqual(doc["states"]["duplicate_within_revision"], 2) self.assertEqual(doc["sources"]["open_prs"]["status"], "unavailable") + # La lecture de `status` est une source a part, et elle se dit + # indisponible elle aussi : une source muette n'est pas une source + # absente (cf #15734). + self.assertEqual(doc["sources"]["open_prs_removed"]["status"], + "unavailable") self.assertEqual(doc["sources"]["base"]["notebooks"], 1) +class TestOpenPrSourceDiscriminatesOnStatus(unittest.TestCase): + """#15734 -- « une entree a `additions == 0` » ne veut pas dire « suppression ». + + Mesure du 2026-09-12, 54 PRs ouvertes / 101 entrees `.ipynb`, les deux API + croisees (0 desaccord de compteur) : `modified` 70, `renamed` 27, `added` 4, + `removed` **0**. La source n'avait donc jamais vu la seule chose que son + filtre `additions > 0` pretendait ecarter. + + Severite honnete : **latente, nulle sur ce pool**. La seule entree vivante a + `additions == 0` (#15729) porte un nom sans index, que `slot_of` ecartait + deja -- l'ancien filtre et le nouveau rendent le meme verdict sur elle. Ce + qui est faux est la REGLE, et la classe qu'elle rouvre est celle d'un + `git mv` pur (`(0, 0)`) sur un `NN-N-Nom.ipynb` indexe, c'est-a-dire le + premier commit d'une tranche de renumeration. + + La charge `gh pr list --json files` ne permet PAS de trancher -- c'est + exactement pourquoi `removed` est un parametre, resolu par `status` + ailleurs, et pourquoi son absence veut dire « reserver ». + """ + + SLOT = SERIES + "/04-2-Alpha.ipynb" + + def _claims(self, prs, removed): + return csr.pr_claims(prs, removed).get(csr.slot_of(self.SLOT), []) + + def test_modification_that_only_deletes_lines_holds_the_slot(self): + """La forme vivante dans le pool : #15729, `(0, 12)`, `status=modified`. + + Le nom de ce notebook ne porte pas d'index, donc l'impact du defaut est + latent -- c'est la regle qui est fautive, et elle l'est pour tout + `NN-N-Nom.ipynb` (cf docstring du module).""" + prs = [{"number": 15729, "files": [ + {"path": self.SLOT, "additions": 0, "deletions": 12}]}] + self.assertEqual(len(self._claims(prs, {15729: set()})), 1, + "retirer 12 lignes d'un notebook, c'est l'EDITER, pas " + "le supprimer : le slot reste tenu") + + def test_byte_identical_rename_holds_the_slot(self): + """Le cas nomme par #15734 : `(0, 0)`. Aucune instance vivante, mais a + couvert par la meme regle -- c'est la forme d'un `git mv` pur.""" + prs = [{"number": 1, "files": [ + {"path": self.SLOT, "additions": 0, "deletions": 0}]}] + self.assertEqual(len(self._claims(prs, {1: set()})), 1) + + def test_unresolved_status_reserves_rather_than_releases(self): + """Sens d'echec : cet organe est un preflight, sous-reserver en silence + est son seul mode de panne non rattrapable.""" + prs = [{"number": 2, "files": [ + {"path": self.SLOT, "additions": 0, "deletions": 12}]}] + self.assertEqual(len(self._claims(prs, None)), 1) + self.assertEqual(len(self._claims(prs, {})), 1) + self.assertEqual(len(self._claims(prs, {2: set()})), 1) + + def test_real_removal_releases(self): + """Le seul cas ou le slot est REELLEMENT libere : `status=removed`.""" + prs = [{"number": 3, "files": [ + {"path": self.SLOT, "additions": 0, "deletions": 12}]}] + self.assertEqual(self._claims(prs, {3: {self.SLOT}}), []) + + def test_written_entries_are_untouched_by_the_change(self): + """Non-regression : les 100/101 entrees a `additions > 0` reservent + exactement comme avant, `removed` vide ou non.""" + prs = [{"number": 4, "files": [ + {"path": self.SLOT, "additions": 8, "deletions": 2}]}] + self.assertEqual(len(self._claims(prs, {4: set()})), 1) + self.assertEqual(len(self._claims(prs, {})), 1) + + def test_ambiguous_set_is_exactly_the_zero_addition_notebooks(self): + prs = [ + {"number": 1, "files": [{"path": self.SLOT, "additions": 0, "deletions": 1}]}, + {"number": 2, "files": [{"path": self.SLOT, "additions": 4, "deletions": 0}]}, + {"number": 3, "files": [{"path": "docs/x.md", "additions": 0, "deletions": 4}]}, + {"number": 4, "files": [{"path": self.SLOT, "additions": 0, "deletions": 4}, + {"path": self.SLOT, "additions": 2, "deletions": 0}]}, + ] + self.assertEqual(csr.ambiguous_pr_numbers(prs), [1, 4], + "seules les PRs a entree .ipynb `additions == 0` exigent " + "une lecture de `status` ; un .md n'en est pas une, et " + "une PR ne compte qu'une fois") + + +class TestRemovalReaderContract(unittest.TestCase): + """Le contrat du LECTEUR de `status`, epingle. + + Lecon de #15759 : un test qui devine la forme du producteur, ou qui la lui + fournit a la main, passe vert par construction. Ici on epingle donc les + arguments `gh` reellement passes ET le sens d'echec -- pas seulement la + fonction de parsing. + """ + + def _patched(self, stdout, returncode=0): + seen = {} + + class _Result: + def __init__(self): + self.stdout = stdout + self.stderr = "boom" if returncode else "" + self.returncode = returncode + + def fake(argv, **kwargs): + seen["argv"] = list(argv) + return _Result() + + status: dict = {} + with mock.patch.object(csr.subprocess, "run", fake): + removed = csr.load_removed_paths([15729], None, status) + return removed, status, seen + + def test_reader_asks_the_files_endpoint_for_status_and_paginates(self): + removed, _status, seen = self._patched("") + argv = seen["argv"] + joined = " ".join(argv) + self.assertEqual(argv[0], "gh") + self.assertEqual(argv[1], "api") + self.assertIn("pulls/15729/files", argv[2]) + self.assertIn("--paginate", argv, + "un PR a plus de 100 fichiers doit rendre toutes ses pages") + self.assertIn(".status", joined, + "le discriminant est `status` ; le lire est tout l'objet " + "de cette lecture") + + def test_reader_uses_the_repo_placeholder_when_no_repo_is_imposed(self): + _removed, _status, seen = self._patched("") + self.assertIn("{owner}/{repo}", seen["argv"][2], + "sans --repo explicite, `gh` doit resoudre le depot courant " + "comme le fait deja la source PRs") + + def test_only_removed_rows_are_collected(self): + out = ("MyIA.AI.Notebooks/GenAI/Audio/04-1-Gone.ipynb\tremoved\n" + "MyIA.AI.Notebooks/GenAI/Audio/04-2-Edited.ipynb\tmodified\n") + removed, status, _seen = self._patched(out) + self.assertEqual(removed, {15729: { + "MyIA.AI.Notebooks/GenAI/Audio/04-1-Gone.ipynb"}}) + self.assertEqual(status["open_prs_removed"]["status"], "ok") + self.assertEqual(status["open_prs_removed"]["removed"], 1) + + def test_a_failed_read_keeps_the_slots_reserved_and_says_so(self): + removed, status, _seen = self._patched("", returncode=1) + self.assertEqual(removed, {}, + "aucune PR dans la carte => ses slots restent RESERVES") + self.assertEqual(status["open_prs_removed"]["status"], "partial") + self.assertEqual(status["open_prs_removed"]["failed"], [15729]) + + def test_no_ambiguous_pr_costs_no_call(self): + called = [] + + def fake(argv, **kwargs): + called.append(argv) + raise AssertionError("aucun appel `gh` ne doit partir sans PR ambigue") + + status: dict = {} + with mock.patch.object(csr.subprocess, "run", fake): + removed = csr.load_removed_paths([], None, status) + self.assertEqual(removed, {}) + self.assertEqual(called, []) + self.assertEqual(status["open_prs_removed"]["status"], "not_needed") + + if __name__ == "__main__": unittest.main()