diff --git a/.github/workflows/pr-gate.yml b/.github/workflows/pr-gate.yml index 4ea10719e8..a18f4bed78 100644 --- a/.github/workflows/pr-gate.yml +++ b/.github/workflows/pr-gate.yml @@ -232,8 +232,9 @@ jobs: # aussi les fichiers de la racine, ce qui suffit au reste du job. # # Perimetre requis, mesure : pr_gate.py n importe que la stdlib plus - # yaml en import paresseux, et son seul chemin est - # DEFAULT_WORKFLOWS_DIR = /.github/workflows, que + # yaml en import paresseux et scripts/gh_identity.py (#17418 Phase A, + # epinglage du jeton machine — in-come par le cone), et son seul + # chemin est DEFAULT_WORKFLOWS_DIR = /.github/workflows, que # derive_always_on_jobs et derive_advisory_jobs globent (canari regle 8). - uses: actions/checkout@v4 with: diff --git a/scripts/check_adjoint_prevalidation.py b/scripts/check_adjoint_prevalidation.py index f08e109ec8..9431891097 100644 --- a/scripts/check_adjoint_prevalidation.py +++ b/scripts/check_adjoint_prevalidation.py @@ -86,12 +86,19 @@ import argparse import hashlib import json +import os import re import subprocess import sys from dataclasses import dataclass from typing import Any +try: + import gh_identity +except ImportError: # charge via importlib dans les tests (hors scripts/) + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import gh_identity + REPO = "jsboige/CoursIA" # The adjoint remains the canonical emitter: `--template` renders its lane, and # it is the lane the coordinator nudges first. It is no longer the only one. @@ -806,6 +813,14 @@ def render_template(snapshot: dict[str, Any], lane: str = ADJOINT_LANE) -> str: def main() -> int: + # Warn-fort + poursuite (pas d'abort) : l'echec BRUYANT est porte par le + # helper, gh_identity --whoami et detect_shared_login.py ; fermer l'organe + # sur une lane sans compte machine (#17418 Phase B/C) arreterait les + # dossiers pendant la transition. + try: + gh_identity.pin_gh_token() + except gh_identity.GhIdentityError as exc: + print(f"GH-IDENTITY (WARN, poursuite sous compte actif): {exc}", file=sys.stderr) parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("pr", type=int, help="pull request number") parser.add_argument("--json", action="store_true", help="emit machine-readable output") @@ -857,11 +872,19 @@ def main() -> int: UnicodeError, json.JSONDecodeError, ) as exc: + errors = [f"UNKNOWN: {exc}"] + # #17418 Phase A : un rc=2 par rate-limit ne doit plus se lire comme + # « pas de dossier » (rc=1). La banniere nomme la cause et la + # remediation — c'est la confusion des deux qui a coute ~3 h de merge. + if gh_identity.is_rate_limit_error(str(exc)): + banner = gh_identity.rate_limit_banner(str(exc)) + print(banner, file=sys.stderr) + errors.append(banner) result = { "pr": args.pr, "ready": False, "verdict": "UNKNOWN", - "errors": [f"UNKNOWN: {exc}"], + "errors": errors, } print(json.dumps(result, ensure_ascii=False) if args.json else f"UNKNOWN -- {exc}") return EXIT_UNKNOWN diff --git a/scripts/check_unaddressed_nits.py b/scripts/check_unaddressed_nits.py index 9624b15468..91c4268648 100644 --- a/scripts/check_unaddressed_nits.py +++ b/scripts/check_unaddressed_nits.py @@ -94,12 +94,19 @@ import argparse import json +import os import re import subprocess import sys import unicodedata from datetime import datetime, timezone +try: + import gh_identity +except ImportError: # charge via importlib dans les tests (hors scripts/) + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import gh_identity + REPO = "jsboige/CoursIA" BOT_LOGINS = {"github-actions", "codecov", "dependabot", "copilot-pull-request-reviewer"} @@ -5218,6 +5225,12 @@ def audit(limit: int, search: str | None = None) -> int: def main() -> int: + # Warn-fort + poursuite : le FAIL bruyant est porte par gh_identity + # --whoami et detect_shared_login.py (#17418 Phase A, transition B/C). + try: + gh_identity.pin_gh_token() + except gh_identity.GhIdentityError as exc: + print(f"GH-IDENTITY (WARN, poursuite sous compte actif): {exc}", file=sys.stderr) ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("pr", nargs="?", type=int, help="numero de PR (mode gate)") diff --git a/scripts/detect_shared_login.py b/scripts/detect_shared_login.py new file mode 100644 index 0000000000..4e435c2cdb --- /dev/null +++ b/scripts/detect_shared_login.py @@ -0,0 +1,131 @@ +"""Detecteur de lane tournant sous le login GitHub partage (#17418 Phase A). + +Un detecteur muet se lit comme « tout va bien » : le controle positif est la +moitie du livrable. Ce detecteur fait l'inverse d'un auto-bilan — il sonde ce +qu'une lane SANS epinglage ferait reellement (``gh api user`` dans un env ou +``GH_TOKEN`` est retire), puis nomme la lane et le login qu'elle utiliserait. + +Verdicts : + +- ``SHARED`` (rc=1) : la sonde non-epinglee sort sous un login qui n'est pas + le compte machine — le seau commun se vide, remediation affichee ; +- ``MACHINE-EVEN-UNPINNED`` (rc=0) : meme sans epinglage, le compte actif du + trousseau est deja le compte machine (etag de session) ; +- ``UNRESOLVABLE`` (rc=2) : la machine n'a pas de compte machine connu — + exactement l'etat que la Phase B/C de #17418 doit fermer ; +- ``PINNED-OK`` avec ``--self`` : le chemin epingle rend le compte machine. + +Le test ``scripts/tests/test_detect_shared_login.py`` porte le controle +positif : il declenche volontairement un appel non epingle et ECHOUE si la +detection ne le voit pas. +""" + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys + +import gh_identity + + +def probe_unpinned_login() -> str | None: + """Login qu'un appel GH volontairement NON epingle utiliserait. + + Simule la lane sans discipline : ``GH_TOKEN``/``GITHUB_TOKEN`` retires, + gh resout alors le compte actif du trousseau — typiquement le login + partage sur les machines de la flotte. + """ + env = { + k: v for k, v in os.environ.items() + if k not in ("GH_TOKEN", "GITHUB_TOKEN") + } + try: + proc = subprocess.run( + ["gh", "api", "user", "--jq", ".login"], + capture_output=True, text=True, encoding="utf-8", + env=env, timeout=30, + ) + except FileNotFoundError: + return None + if proc.returncode != 0: + return None + return proc.stdout.strip() or None + + +def lane_label() -> str: + workspace = os.environ.get("COURSIA_WORKSPACE", "CoursIA") + return f"{gh_identity.machine_hostname()}:{workspace}" + + +def _self_check() -> int: + try: + gh_identity.pin_gh_token() + except gh_identity.GhIdentityError as exc: + print(f"UNRESOLVABLE lane {lane_label()} — {exc}") + return 2 + proc = subprocess.run( + ["gh", "api", "user", "--jq", ".login"], + capture_output=True, text=True, encoding="utf-8", timeout=30, + ) + if proc.returncode != 0: + print(f"PROBE-ERROR lane {lane_label()} — gh api user (epingle) : " + f"{proc.stderr.strip()[:200]}") + return 2 + login = proc.stdout.strip() + account = gh_identity.machine_account() + if login.lower() != account.lower(): + print(f"SHARED lane {lane_label()} — chemin epingle sort sous " + f"'{login}' != compte machine '{account}'") + return 1 + print(f"PINNED-OK lane {lane_label()} — login epingle = {login}") + return 0 + + +def classify(probe_login: str | None, account: str) -> tuple[str, int]: + """Verdict (label, rc) d'une sonde non-epinglee contre le compte machine. + + Pure — le controle positif des tests (#17418 Phase A) la consomme : la + sonde live doit rendre SHARED sur une machine ou le compte actif du + trousseau n'est pas le compte machine, sinon la detection est cassee. + """ + if probe_login is None: + return ("PROBE-ERROR", 2) + if probe_login.lower() == account.lower(): + return ("MACHINE-EVEN-UNPINNED", 0) + return ("SHARED", 1) + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--self", action="store_true", + help="verifie le chemin EPINGLE (complement de la sonde non-epinglee)", + ) + args = parser.parse_args(argv) + if args.self: + return _self_check() + + account = gh_identity.machine_account() + unpinned = probe_unpinned_login() + label, rc = classify(unpinned, account) + if label == "PROBE-ERROR": + print(f"PROBE-ERROR lane {lane_label()} — sonde non-epinglee " + "(gh absent, non authentifie ou reseau)") + elif label == "MACHINE-EVEN-UNPINNED": + print(f"MACHINE-EVEN-UNPINNED lane {lane_label()} — compte actif du " + f"trousseau deja '{unpinned}'") + else: + print( + f"SHARED lane {lane_label()} — un appel NON epingle sortirait sous " + f"'{unpinned}' (partage) au lieu du compte machine '{account}'. " + "Chaque organe non epingle vide le seau 5000/h commun. Remediation : " + "les organes epinglent via gh_identity.pin_gh_token() ; verifier que " + "COURSIA_GH_PINNING n'est pas a 'off'." + ) + return rc + + +if __name__ == "__main__": # pragma: no cover + sys.exit(main()) diff --git a/scripts/gh_identity.py b/scripts/gh_identity.py new file mode 100644 index 0000000000..75d3259d28 --- /dev/null +++ b/scripts/gh_identity.py @@ -0,0 +1,207 @@ +"""Resolution du jeton GitHub par compte machine — epinglage par organe, pas par discipline. + +#17418 Phase A. Le bucket GraphQL GitHub est 5000/h par UTILISATEUR : toutes +les lanes qui sortent sous le login partage (``jsboige``) vident le meme seau. +Mesure du 2026-09-22 : seau a sec -> ``check_adjoint_prevalidation.py`` rend +rc=2 UNKNOWN sur 10/10 PRs, indiscernable de « pas de dossier », ~3 h sans +merge pendant que 10 dossiers READY existaient. + +Ce module est l'implementation UNIQUE de la resolution : + +- ``GH_TOKEN`` deja pose (epinglage explicite, CI runner, session debug) -> + respecte tel quel ; +- sinon -> ``gh auth token --user `` et pose + ``os.environ["GH_TOKEN"]``. Jamais ``gh auth switch`` : c'est un etat global + du process ``gh`` qui corrompt les autres lanes du meme trousseau ; +- compte machine non resolvable -> echec BRUYANT avec la remediation. Un repli + muet sur le compte actif reconstituerait exactement le seau unique qu'on + corrige. + +Transition Phase B/C (#17418) : les lanes dont le compte machine n'existe pas +encore (``myia-po-2024``..``2027``) posent ``COURSIA_GH_PINNING=off`` — le +helper renonce ALORS en imprimant un avertissement fort sur stderr (jamais en +silence), et ``detect_shared_login.py`` continue de les nommer comme tournant +sous le login partage. La Creation des comptes (Phase B) et le provisionnement +des jetons (Phase C) ferment cette echappatoire. +""" + +from __future__ import annotations + +import argparse +import os +import re +import socket +import subprocess +import sys + +SHARED_LOGIN = "jsboige" + +# Comptes machine meses le 2026-09-22 (#17418) : ai-01, po-2023 et Web1 +# existent ; po-2024..2027 sont 404 jusqu'a la Phase B. Ils figurent deja ici +# pour que la Phase C ne demande AUCUN changement de code : des que le jeton +# est dans le trousseau, les organes l'epinglent. +HOST_ACCOUNTS = { + "myia-ai-01": "myia-ai-01", + "myia-po-2023": "myia-po-2023", + "myia-po-2024": "myia-po-2024", + "myia-po-2025": "myia-po-2025", + "myia-po-2026": "myia-po-2026", + "myia-po-2027": "myia-po-2027", + "myia-web1": "MyIA-Web1", +} + +RATE_LIMIT_RE = re.compile(r"rate limit", re.IGNORECASE) + + +class GhIdentityError(RuntimeError): + """Aucun jeton machine resolvable — le repli sur le compte actif est interdit.""" + + +def machine_hostname() -> str: + """Hostname normalise — ``COMPUTERNAME`` prime sur Windows (cf #17418).""" + name = os.environ.get("COMPUTERNAME") or socket.gethostname() + return name.split(".")[0].strip().lower() + + +def machine_account(hostname: str | None = None) -> str: + """Compte GitHub de la machine locale. + + ``COURSIA_GH_ACCOUNT`` est une configuration EXPLICITE (tests, machines au + nom hors convention) — pas un repli : elle ne masque rien, elle designe. + """ + explicit = os.environ.get("COURSIA_GH_ACCOUNT") + if explicit: + return explicit + host = (hostname if hostname is not None else machine_hostname()).lower() + try: + return HOST_ACCOUNTS[host] + except KeyError: + raise GhIdentityError( + f"hostname '{host}' n'a pas de compte machine connu. " + f"Comptes mappees : {', '.join(sorted(HOST_ACCOUNTS))}. " + "Poser COURSIA_GH_ACCOUNT= si cette machine doit en " + "utiliser un, ou COURSIA_GH_PINNING=off pendant la transition " + "#17418 Phase B/C." + ) from None + + +def pinning_disabled() -> bool: + return os.environ.get("COURSIA_GH_PINNING", "").lower() == "off" + + +def resolve_gh_token() -> str: + """Jeton a epingler : GH_TOKEN existant, sinon trousseau du compte machine. + + N'imprime JAMAIS la valeur du jeton. + """ + existing = os.environ.get("GH_TOKEN") + if existing: + return existing + account = machine_account() + try: + proc = subprocess.run( + ["gh", "auth", "token", "--user", account], + capture_output=True, text=True, encoding="utf-8", timeout=30, + ) + except FileNotFoundError as exc: + raise GhIdentityError(f"gh CLI introuvable : {exc}") from exc + if proc.returncode != 0 or not proc.stdout.strip(): + detail = (proc.stderr or proc.stdout).strip()[:300] + raise GhIdentityError( + f"gh auth token --user {account} a echoue (rc={proc.returncode}) : " + f"{detail or 'sortie vide'}. Provisionner le jeton machine " + f"(#17418 Phase C : master.env + trousseau), ou poser GH_TOKEN " + "explicitement." + ) + return proc.stdout.strip() + + +def pin_gh_token() -> str: + """Epingle le jeton machine dans ``os.environ`` — idempotent, loud en echec. + + Transition : ``COURSIA_GH_PINNING=off`` renonce en preventif avec un + avertissement fort (lanes sans compte machine jusqu'a la Phase C). + """ + if os.environ.get("GH_TOKEN"): + return os.environ["GH_TOKEN"] + if pinning_disabled(): + print( + "GH-IDENTITY (WARN): COURSIA_GH_PINNING=off — appel(s) GitHub sous " + f"le compte actif (potentiellement le login partage '{SHARED_LOGIN}', " + "seau commun). Dettes visibles par detect_shared_login.py. #17418 Phase C.", + file=sys.stderr, + ) + return "" + token = resolve_gh_token() + os.environ["GH_TOKEN"] = token + return token + + +def gh_env(base: dict | None = None) -> dict: + """Env de subprocess avec le jeton epingle (pour les env construits a la main).""" + env = dict(base if base is not None else os.environ) + if not env.get("GH_TOKEN"): + if not pinning_disabled(): + env["GH_TOKEN"] = resolve_gh_token() + return env + + +def is_rate_limit_error(text: str) -> bool: + return bool(RATE_LIMIT_RE.search(text or "")) + + +def rate_limit_banner(exc_text: str) -> str: + """Ligne que l'appelant lit SANS --json : rc=2 rate-limit != rc=1 dossier absent. + + C'est la confusion des deux qui a coute ~3 h de merge le 2026-09-22, plus + que le quota lui-meme. + """ + detail = (exc_text or "").strip().splitlines() + first = detail[0][:200] if detail else "" + return ( + "[RATE-LIMIT] refus GitHub par epuisement de quota — ce n'est PAS un " + "dossier absent (rc=1). Reessayer avec le jeton machine epingle : " + "GH_TOKEN=$(gh auth token --user ). Motif : " + first + ) + + +def _cli(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + parser.add_argument( + "--whoami", action="store_true", + help="identite + quota GraphQL obtenus par le MEME chemin de resolution que les organes", + ) + parser.add_argument( + "--account", action="store_true", + help="affiche uniquement le compte machine resolu (sans appel reseau)", + ) + args = parser.parse_args(argv) + if args.account: + print(machine_account()) + return 0 + if args.whoami: + try: + pin_gh_token() + except GhIdentityError as exc: + print(f"GH-IDENTITY (FAIL): {exc}", file=sys.stderr) + return 1 + proc = subprocess.run( + ["gh", "api", "user", "--jq", ".login"], + capture_output=True, text=True, encoding="utf-8", + ) + if proc.returncode != 0: + print(f"gh api user a echoue : {proc.stderr.strip()[:300]}", file=sys.stderr) + return 1 + login = proc.stdout.strip() + quota = subprocess.run( + ["gh", "api", "rate_limit", "--jq", ".resources.graphql.remaining"], + capture_output=True, text=True, encoding="utf-8", + ) + print(f"login={login} graphql_remaining={quota.stdout.strip()}") + return 0 + parser.print_help() + return 0 + + +if __name__ == "__main__": # pragma: no cover + sys.exit(_cli()) diff --git a/scripts/pick_idle_grain.py b/scripts/pick_idle_grain.py index b743ac55fe..aad295ca15 100644 --- a/scripts/pick_idle_grain.py +++ b/scripts/pick_idle_grain.py @@ -167,6 +167,12 @@ import sys from typing import Any +try: + import gh_identity +except ImportError: # charge via importlib dans les tests (hors scripts/) + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import gh_identity + REPO = "jsboige/CoursIA" # c.1115 voie 1 (msg-20260912T165428-k6rbfc, ai-01 spec) : klass `delivered` @@ -3776,6 +3782,14 @@ def main(argv: list[str] | None = None) -> int: for _stream in (sys.stdout, sys.stderr): if hasattr(_stream, "reconfigure"): _stream.reconfigure(encoding="utf-8", errors="replace") + # #17418 Phase A : epingle le jeton machine AVANT tout appel gh — les + # enfants (check_lane_claim, nits...) heritent via os.environ propage par + # _utf8_child_env(). Warn-fort + poursuite : le FAIL bruyant est porte + # par gh_identity --whoami et detect_shared_login.py (transition B/C). + try: + gh_identity.pin_gh_token() + except gh_identity.GhIdentityError as exc: + print(f"GH-IDENTITY (WARN, poursuite sous compte actif): {exc}", file=sys.stderr) ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--lane", default=None, diff --git a/scripts/pr_gate.py b/scripts/pr_gate.py index d3579cf695..8f9de8b4da 100644 --- a/scripts/pr_gate.py +++ b/scripts/pr_gate.py @@ -160,6 +160,12 @@ import re import subprocess import sys + +try: + import gh_identity +except ImportError: # charge via importlib dans les tests (hors scripts/) + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + import gh_identity import time import traceback from datetime import datetime @@ -1506,6 +1512,13 @@ def _optional_int(raw: str) -> "int | None": def main(argv: Iterable[str] | None = None) -> int: + # Warn-fort + poursuite : en CI, GH_TOKEN est pose par le runner (pin = + # no-op) ; le FAIL bruyant est porte par gh_identity --whoami et + # detect_shared_login.py (#17418 Phase A, transition B/C). + try: + gh_identity.pin_gh_token() + except gh_identity.GhIdentityError as exc: + print(f"GH-IDENTITY (WARN, poursuite sous compte actif): {exc}", file=sys.stderr) parser = argparse.ArgumentParser(description=__doc__.splitlines()[0]) parser.add_argument("--repo", required=True, help="owner/name") parser.add_argument("--sha", required=True, help="head SHA of the PR") diff --git a/scripts/tests/test_detect_shared_login.py b/scripts/tests/test_detect_shared_login.py new file mode 100644 index 0000000000..717e8b04e6 --- /dev/null +++ b/scripts/tests/test_detect_shared_login.py @@ -0,0 +1,110 @@ +"""Controle positif du detecteur de login partage (#17418 Phase A). + +Un detecteur sans controle positif rend zero et se lit « tout va bien ». Le +test live declenche VOLONTAIREMENT un appel non epingle (sonde sans GH_TOKEN) +et ECCHOUE si la detection ne le voit pas : sur une machine de flotte ou le +compte actif du trousseau n'est pas le compte machine, la sonde doit rendre +SHARED. Si quelqu'un casse la detection (sonde muette, classification +inversee), ce test devient rouge — pas silencieusement vert. +""" + +import shutil +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import detect_shared_login # noqa: E402 +import gh_identity # noqa: E402 + +GH_AVAILABLE = shutil.which("gh") is not None + + +# --- classification pure ---------------------------------------------------- + + +def test_classify_shared(): + label, rc = detect_shared_login.classify("jsboige", "myia-po-2023") + assert (label, rc) == ("SHARED", 1) + + +def test_classify_machine_meme_non_epingle(): + # Cas etag : le compte actif du trousseau est deja le compte machine. + label, rc = detect_shared_login.classify("myia-po-2023", "myia-po-2023") + assert (label, rc) == ("MACHINE-EVEN-UNPINNED", 0) + + +def test_classify_sonde_muette(): + label, rc = detect_shared_login.classify(None, "myia-po-2023") + assert (label, rc) == ("PROBE-ERROR", 2) + + +def test_classify_insensible_a_la_casse(): + assert detect_shared_login.classify("MyIA-Web1", "myia-web1")[0] == "MACHINE-EVEN-UNPINNED" + + +# --- controle positif live --------------------------------------------------- + + +@pytest.mark.skipif(not GH_AVAILABLE, reason="gh CLI absent — controle positif live impossible") +def test_controle_positif_sonde_non_epinglee_est_vue(): + """La detection DOIT voir l'appel volontairement non epingle. + + Saute (skip) si la sonde est muette (gh absent, non authentifie ou + machine sans compte machine) ; echoue si la classification refuse de + nommer SHARED un login etranger au compte machine. + """ + try: + account = gh_identity.machine_account() + except gh_identity.GhIdentityError: + pytest.skip("machine sans compte machine connu — transition #17418 B/C") + probe = detect_shared_login.probe_unpinned_login() + label, rc = detect_shared_login.classify(probe, account) + if probe is None: + pytest.skip("sonde live indisponible (gh non authentifie ou reseau)") + # L'invariant du controle positif : un probe NON MUTE qui rend un login + # etranger DOIT etre classe SHARED. C'est exactement ce qui echoue si la + # detection est cassee. + if probe.lower() != account.lower(): + assert label == "SHARED" and rc == 1, ( + f"detection cassee : sonde non-epinglee sous '{probe}' != " + f"'{account}', verdict rendu ({label}, {rc})" + ) + + +# --- cablage de la banniere dans l'adjoint (#17418 rc=2 != rc=1) ------------ + + +def test_adjoint_unknown_par_rate_limit_porte_la_banniere(monkeypatch, capsys): + """Le chemin UNKNOWN de l'adjoint doit EMETTRE la banniere sur rate-limit. + + Demo live impossible a souhait (le seau partage se recharge chaque heure) : + le test simule le refus exact mesure le 2026-09-22 (`gh_json` leve + RuntimeError sur l'erreur gh) et verifie la ligne lisible sans --json. + """ + import importlib.util + + here = Path(__file__).resolve().parent + spec = importlib.util.spec_from_file_location( + "check_adjoint_prevalidation_banner", here.parent / "check_adjoint_prevalidation.py" + ) + mod = importlib.util.module_from_spec(spec) + sys.modules["check_adjoint_prevalidation_banner"] = mod + spec.loader.exec_module(mod) + + def _rate_limited(args): + raise RuntimeError( + "gh api repos/jsboige/CoursIA/pulls/17410 failed: " + "gh: API rate limit already exceeded for user ID 3159389." + ) + + monkeypatch.setattr(mod, "gh_json", _rate_limited) + monkeypatch.setattr(sys, "argv", ["check_adjoint_prevalidation.py", "17410"]) + rc = mod.main() + err = capsys.readouterr().err + assert rc == 2, "un refus rate-limit reste fail-closed (rc=2)" + assert "[RATE-LIMIT]" in err, "la banniere doit vivre sur stderr, lisible sans --json" + assert "rc=1" in err, "la banniere nomme explicitement la confusion rc=2 vs rc=1" + assert "gh auth token" in err, "la banniere porte la remediation" diff --git a/scripts/tests/test_gh_identity.py b/scripts/tests/test_gh_identity.py new file mode 100644 index 0000000000..53e5e29786 --- /dev/null +++ b/scripts/tests/test_gh_identity.py @@ -0,0 +1,138 @@ +"""Tests de gh_identity — helper unique d'epinglage de jeton machine (#17418 Phase A). + +Unitaires (aucun reseau) pour la cartographie hostname->compte, le respect +d'un GH_TOKEN deja pose, l'echec BRUYANT sans repli silencieux, et la +classification rate-limit qui distingue rc=2 de rc=1. +""" + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import gh_identity # noqa: E402 + + +# --- machine_account ------------------------------------------------------- + + +def test_machine_account_connu(): + assert gh_identity.machine_account("myia-po-2023") == "myia-po-2023" + assert gh_identity.machine_account("MYIA-AI-01") == "myia-ai-01" + # La Phase B/C provisionnera ces comptes ; la cartographie existe deja. + assert gh_identity.machine_account("myia-po-2025") == "myia-po-2025" + + +def test_machine_hostname_normalise(monkeypatch): + monkeypatch.setenv("COMPUTERNAME", "MyIA-PO-2023") + assert gh_identity.machine_hostname() == "myia-po-2023" + + +def test_machine_account_override_explicite(monkeypatch): + monkeypatch.setenv("COURSIA_GH_ACCOUNT", "MyIA-Web1") + assert gh_identity.machine_account("unknown-host") == "MyIA-Web1" + + +def test_machine_account_inconnu_echoue_avec_remediation(): + with pytest.raises(gh_identity.GhIdentityError) as exc: + gh_identity.machine_account("laptop-perso") + msg = str(exc.value) + # L'echec porte sa remediation — jamais de repli muet sur le compte actif. + assert "COURSIA_GH_ACCOUNT" in msg or "COURSIA_GH_PINNING" in msg + assert "myia-po-2023" in msg # la liste des comptes mappees est citee + + +# --- pin_gh_token ---------------------------------------------------------- + + +def test_pin_respecte_gh_token_deja_pose(monkeypatch): + monkeypatch.setenv("GH_TOKEN", "tok-deja-pose") + assert gh_identity.pin_gh_token() == "tok-deja-pose" + assert gh_identity.resolve_gh_token() == "tok-deja-pose" + + +def test_pin_epingle_le_jeton_resolu(monkeypatch): + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("COURSIA_GH_PINNING", raising=False) + monkeypatch.setattr(gh_identity, "resolve_gh_token", lambda: "tok-machine") + assert gh_identity.pin_gh_token() == "tok-machine" + assert os.environ["GH_TOKEN"] == "tok-machine" + + +def test_pin_pinning_off_avertit_et_ne_epingle_pas(monkeypatch, capsys): + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.setenv("COURSIA_GH_PINNING", "off") + called = [] + monkeypatch.setattr( + gh_identity, "resolve_gh_token", + lambda: called.append(1) or "tok-machine", + ) + assert gh_identity.pin_gh_token() == "" + assert os.environ.get("GH_TOKEN") is None + assert called == [] # transition : on n'a meme pas tente de resoudre + err = capsys.readouterr().err + assert "WARN" in err and gh_identity.SHARED_LOGIN in err + + +def test_resolve_echoue_bruyament_sans_compte_tresor(monkeypatch): + monkeypatch.delenv("GH_TOKEN", raising=False) + # Hermetique : sans compte explicite, machine_account() leve AVANT le + # sous-processus sur tout hostname non mappe (le runner GA), et le + # message n'est alors pas celui de l'echec de trousseau. + monkeypatch.setenv("COURSIA_GH_ACCOUNT", "myia-po-test") + + class FakeProc: + returncode = 1 + stderr = "no users found" + stdout = "" + + monkeypatch.setattr(subprocess, "run", lambda *a, **k: FakeProc()) + with pytest.raises(gh_identity.GhIdentityError) as exc: + gh_identity.resolve_gh_token() + msg = str(exc.value) + # Parties stables du message : commande nommee, compte attendu, rc -- + # pas la reformulation de la remediation (re-review Hermes, 0393a1d1). + assert "gh auth token" in msg + assert "myia-po-test" in msg + assert "rc=1" in msg + + +# --- classification rate-limit (rc=2 != rc=1) ------------------------------ + + +@pytest.mark.parametrize("text", [ + "API rate limit already exceeded for user ID 3159389", + "You have exceeded a secondary rate limit", + "RATE LIMIT: too many requests", +]) +def test_is_rate_limit_error_positif(text): + assert gh_identity.is_rate_limit_error(text) is True + + +@pytest.mark.parametrize("text", ["Not Found", "gh: issue not found", ""]) +def test_is_rate_limit_error_negatif(text): + assert gh_identity.is_rate_limit_error(text) is False + + +def test_banniere_distingue_rc2_de_rc1(): + banner = gh_identity.rate_limit_banner( + "gh: API rate limit already exceeded for user ID 3159389" + ) + # La ligne que l'appelant lit sans --json doit nommer la confusion et la + # remediation — c'est ce qui a coute ~3 h de merge le 2026-09-22. + assert "RATE-LIMIT" in banner + assert "rc=1" in banner + assert "gh auth token" in banner + + +def test_gh_env_pose_le_jeton_resolu(monkeypatch): + monkeypatch.delenv("GH_TOKEN", raising=False) + monkeypatch.delenv("COURSIA_GH_PINNING", raising=False) + monkeypatch.setattr(gh_identity, "resolve_gh_token", lambda: "tok-env") + env = gh_identity.gh_env({"PATH": "/bin"}) + assert env["GH_TOKEN"] == "tok-env" + assert env["PATH"] == "/bin" diff --git a/scripts/tests/test_pick_idle_grain.py b/scripts/tests/test_pick_idle_grain.py index f938374787..70cd6e31e5 100644 --- a/scripts/tests/test_pick_idle_grain.py +++ b/scripts/tests/test_pick_idle_grain.py @@ -2313,6 +2313,12 @@ def test_14704_cli_accepts_repeated_and_csv_prev_genres(monkeypatch, capsys) -> """Les deux formes CLI alimentent le meme ensemble de genres de session.""" class _R: stdout = "[]" + stderr = "" + # #17418 : main() epingle le jeton AVANT argparse -- le fake doit + # impersonifier un CompletedProcess complet (returncode requis) pour + # que pin_gh_token() suive son chemin d'echec propre (GhIdentityError + # attrapee par main, WARN stderr) au lieu d'un AttributeError. + returncode = 1 monkeypatch.setattr(pig.subprocess, "run", lambda *args, **kwargs: _R()) rc = pig.main([ @@ -2342,6 +2348,9 @@ def test_14591_volet_a_cli_integration_prev_genre_autoload(tmp_path, monkeypatch # toucher au pool reel. class _R: stdout = "[]" + stderr = "" + # #17418 : cf. test_14704 -- returncode requis par pin_gh_token(). + returncode = 1 def fake_run(cmd, **kw): return _R() monkeypatch.setattr(pig.subprocess, "run", fake_run)