diff --git a/.github/workflows/stale-branch-update-sweep.yml b/.github/workflows/stale-branch-update-sweep.yml new file mode 100644 index 0000000000..e7c83d19e7 --- /dev/null +++ b/.github/workflows/stale-branch-update-sweep.yml @@ -0,0 +1,129 @@ +name: Stale branch update sweep + +# Cable le pilote de #16915 : le CI rattrape lui-meme les PRs en retard sur +# leur base, au lieu d'une session de worker dediee par rebase mandate. +# Mandat user 2026-09-19 : « il faut qu'elles soient automatiques pour la +# plupart ». +# +# QUE FAIT CE SWEEP. `scripts/ci/sweep_stale_pr_branches.py` enumere les PRs +# ouvertes, ecarte par metadonnees (brouillon, fork, non-mergeable), mesure le +# retard reel via l'API de comparaison, prend les --max-updates plus ANCIENNES +# candidates (les PRs quietes et agees n'ont pas d'autre voie de rattrapage -- +# lecon mesuree du pr-gate-stale-sweep, run 33169455408) et les DELEEGUE a +# l'organe `scripts/ci/update_stale_pr_branches.py` par sous-processus. Le +# pilote n'ecrit jamais : l'organe porte seul les neuf gardes (SHA pinnes, +# registre en vol, jamais de force-push, jamais --rebase). +# +# CE QUE CE SWEEP NE SURVEND PAS. Mesure du 2026-09-19 (213 PRs ouvertes) : +# ~12 DIRTY mises a part, la population rattrapable est les CLEAN/BLOCKED/ +# UNSTABLE a behind_by > 0. La valeur n'est pas le decompte debloque, c'est la +# suppression des allers-retours (commentaire + session worker + dossier +# exact-head remplaces par un run). +# +# CADENCE. 6 h declarees, cap 3 mises a jour par run : le gisement MESURE le +# 2026-09-20 par le dry-run du pilote est de 47 candidates a behind_by > 0 +# (sur 214 PRs ouvertes ; l'estimation ~12 de l'issue #16915 etait basse) -- +# a cap 3, il se vide en ~16 runs (~4 jours), sans vague : chaque update +# perime le dossier [ADJOINT PREFLIGHT] de sa PR (freshness STALE, +# invalidated), et une rafale non bornee perimerait 47 dossiers d'un coup. +# Caveat mesure (#15197/check_scheduler_liveness) : les crons sub-horaires de +# ce depot sont servis 4.7-7.2x en retard ; pour un filet de rattrapage sans +# SLA de latence, servir ~1 fois par 12-24 h reste utile et sans cout. +# +# PLANCHER DWELL. Un update-branch reecrit la tete : avant #16149 il +# re-armait les 120 min que le geste sert a franchir. merge_dwell.py mesure +# desormais last_authoritative_committed_at et EXEMPTE les fusions de base +# prouvees content-free. Ce contrat est verrouille DANS la suite du pilote +# (test_update_branch_shape_does_not_rearm_dwell) : si la exemption regresse, +# la suite rougit avec le gate, et ce cron n'introduit pas la taxe en silence. +# +# CODE DE SORTIE. Le relais de l'organe est honnete : un run ROUGE signifie +# « au moins un REFUSE » (conflit, tete qui a bouge, echec d'appel) -- un +# humain doit regarder. Etouffer ce rouge pour faire joli serait le +# « rapport vert sur un pool vide » que #16149 interdit. + +on: + schedule: + # Minute off-:00 (convention anti-stampede du depot). + - cron: '23 */6 * * *' + workflow_dispatch: + inputs: + apply: + description: 'Apply the updates (default: dry-run, list only)' + required: false + type: boolean + default: false + max_updates: + description: 'Max branch updates this run (--max-updates)' + required: false + type: number + default: 3 + +permissions: + contents: write # gh pr update-branch (via l'organe) reecrit la branche + pull-requests: read # enumeration + lectures de l'organe + +concurrency: + # Une classe d'evenement a la fois, JAMAIS d'annulation en cours : tuer un + # balayage mi-course laisserait des reservations du registre en vol posees + # pour rien (elles periment par TTL, mais le run suivant les lirait comme + # des mises a jour vivantes). Deux arrivals concurrents se coalescent. + group: stale-branch-update-sweep-${{ github.event_name }} + cancel-in-progress: false + +jobs: + sweep: + name: Delegate stale-PR updates to the organ + # Pool dedie (mandat : consommer les runs conteneurises). Le balayage est + # gh-driven et l'image coursia-linux ne ship pas gh : bootstrap toolcache. + runs-on: [self-hosted, coursia-ephemeral, coursia-linux] + timeout-minutes: 20 + steps: + - name: Bootstrap gh into the persistent toolcache + env: + GH_BOOTSTRAP_VERSION: "2.80.0" + run: | + set -uo pipefail + if command -v gh >/dev/null 2>&1; then + echo "[gh] preinstalled on this runner" + gh --version + exit 0 + fi + GH_BIN="/opt/hostedtoolcache/bin/gh" + if [ ! -x "$GH_BIN" ]; then + VER="${GH_BOOTSTRAP_VERSION}" + echo "[gh] bootstrapping v${VER} into toolcache (one-time, cached)" + curl -fsSL "https://github.com/cli/cli/releases/download/v${VER}/gh_${VER}_linux_amd64.tar.gz" -o /tmp/gh.tgz + tar -xzf /tmp/gh.tgz -C /tmp + mkdir -p /opt/hostedtoolcache/bin + mv "/tmp/gh_${VER}_linux_amd64/bin/gh" "$GH_BIN" + fi + echo "/opt/hostedtoolcache/bin" >> "$GITHUB_PATH" + "$GH_BIN" --version + + # Sparse : le pilote importe l'organe (les DEUX fichiers doivent etre + # sur disque), et il n'invoque rien d'autre du depot. L'organe + # n'appelle pas git -- le checkout n'a pas besoin d'histoire. + - name: Checkout sparse (pilot + organ) + uses: actions/checkout@v4 + with: + sparse-checkout: | + scripts/ci/sweep_stale_pr_branches.py + scripts/ci/update_stale_pr_branches.py + sparse-checkout-cone-mode: false + + - name: Sweep and delegate + env: + GH_TOKEN: ${{ github.token }} + REPO: ${{ github.repository }} + APPLY_INPUT: ${{ github.event.inputs.apply }} + MAX_INPUT: ${{ github.event.inputs.max_updates }} + run: | + set -uo pipefail + ARGS=(--repo "$REPO" --max-updates "${MAX_INPUT:-3}") + # Le cron balaye en --apply ; un dispatch manuel ne l'applique que + # sur demande explicite (defaut : dry-run, liste seulement). + if [ "${GITHUB_EVENT_NAME}" = "schedule" ] || [ "${APPLY_INPUT:-false}" = "true" ]; then + ARGS+=(--apply) + fi + python3 scripts/ci/sweep_stale_pr_branches.py "${ARGS[@]}" diff --git a/docs/reference/scripts-reference.md b/docs/reference/scripts-reference.md index fac45ece2a..6d88c05f32 100644 --- a/docs/reference/scripts-reference.md +++ b/docs/reference/scripts-reference.md @@ -195,6 +195,7 @@ Pipeline d'audit qualité et de **matrice de coût** (EPIC #8056) + audit séman | `scripts/ci/measure_runner_demand.py` | **Baseline exhaustive de demande GitHub Actions** (#12704) : collecte une fenêtre UTC avec bisection anti-cap 1 000 + pagination de tous les jobs, mesure attente (`started_at-created_at`), travail runner (`completed_at-started_at`), provenance same-repo/fork/unknown et dénominateurs ; replay offline par `--input`. **Co-résidence** (#15574) : bloc `co_residence` (hôte présumé du préfixe du `runner_name`, pic et moyenne de concurrence par job) et bloc `runners_inventory` (`--runners`, slots enregistrés par hôte, trois états `measured`/`unavailable`/`not_collected`, jamais un parc vide). Exit `0` mesure valide / `2` instrument ou snapshot incomplet. Procédure : [docs/ci/self-hosted-runners.md](../ci/self-hosted-runners.md) · organe : `.github/workflows/runner-coresidence-advisory.yml` | | `scripts/ci/manage_self_hosted_runner.py` + `self_hosted_runner_profiles.json` | **Cycle de vie Windows des runners éphémères isolés** (#12704) : profils distribués po-2023..po-2026 avec archive/SHA-256 épinglés ; commandes `install`, `register`, `verify`, `teardown` en dry-run par défaut, mutations uniquement avec `--apply`; compte local dédié, ACL négatives `.secrets`/SSH/gh, tokens via `ACTIONS_RUNNER_INPUT_*`, extraction anti-Zip-Slip/ADS et teardown borné par manifeste. `register --apply` est le bouton d’activation séparé, jamais lancé pendant la préparation. Procédure : [docs/ci/self-hosted-runners.md](../ci/self-hosted-runners.md) | | `scripts/ci/pool_review_verdicts.py` | **Vue de triage du pool : verdict lu dans les corps, pas `reviewDecision`** (#16926) : sous token COMMENT-only (#15511) le champ `reviewDecision` vaut `null` à perpétitude sur ~82 % du pool y compris les PRs portant un `VERDICT: LGTM` argumenté — trier dessus fabrique le faux compte « sans review ». L'organe balaye le pool en GraphQL paginé borné (page 50 + retry 5xx unique : les fenêtres `reviews`+`comments` font 504/502 au-delà, mesures 2026-09-20), lit le préfixe `VERDICT:` en début de ligne sur les **deux surfaces** (reviews[] + commentaires de persona, marqueur importé du canon `check_unaddressed_nits`), latest-wins, et distingue `SANS-REVIEW` (aucune voix) de `VOIX-SANS-VERDICT` et des états réels CHANGES_REQUESTED/APPROVED qui gouvernent. `--gradient` rend la répartition par quartile d'âge (l'enseignement #16926 : les PRs vieillissent parce qu'elles portent une réserve vivante). Advisory : exit 0, ou 2 si le balayage est illisible. Tests : `scripts/tests/test_pool_review_verdicts.py` | +| `scripts/ci/sweep_stale_pr_branches.py` | **Pilote de balayage des retards de base** (#16915, mandat user 19/09 « les MAJ de rebase doivent être automatiques ») : énumère les PRs ouvertes (GraphQL — l'API REST rend `mergeable: null` sur 211/214 PRs, mesuré 2026-09-20), écarte par métadonnées nommées (brouillon/fork/non-mergeable), mesure le retard réel (`behind_by`, compare API), sélectionne les `--max-updates` plus anciennes candidates et **délègue** la mise à jour à `update_stale_pr_branches.py` par sous-processus (`--pr` explicites) — le pilote n'écrit jamais, l'organe porte seul ses neuf gardes (SHA épinglés, registre en vol, jamais de force-push/`--rebase`). Dry-run par défaut. Câblé par `.github/workflows/stale-branch-update-sweep.yml` (cron 6 h, cap 3/run). Le contrat DWELL presupposé (update-branch content-free ≠ re-arm du plancher, #16149) est verrouillé dans `scripts/tests/test_sweep_stale_pr_branches.py` | | `scripts/mcp-maintenance/` | Maintenance MCP (config, docs, scripts) — cf `README_MCP_MAINTENANCE.md` | | `scripts/validation/dispatch.py` + `matrix.yml` | Matrice de validation / dispatch | | `scripts/genai-stack/genai.py` | GenAI Docker (ComfyUI + Qwen) + validation — cf [docs/genai/genai-services.md](../genai/genai-services.md) | diff --git a/scripts/ci/check_self_hosted_runner_policy.py b/scripts/ci/check_self_hosted_runner_policy.py index 4b35db2485..2e3eb05773 100644 --- a/scripts/ci/check_self_hosted_runner_policy.py +++ b/scripts/ci/check_self_hosted_runner_policy.py @@ -227,6 +227,14 @@ "review-coverage-advisory.yml", "slides-build-advisory.yml", "slow-lane.yml", + # stale-branch-update-sweep.yml (#16915, owner myia-po-2023:CoursIA) : + # pilote de rebase automatique des PRs quietes -- cron `23 */6 * * *` + # + workflow_dispatch, pur-Python + organe `update_stale_pr_branches.py` + # par sous-processus, aucun trigger pull_request (meme profil que + # pr-gate-stale-sweep, tranche 4 #14283). Permissions GH_TOKEN bornees + # par le workflow ; dry-run par defaut, --apply --max-updates 3. + # Rollback = revert de la PR (l'entree disparait de l'allowlist). + "stale-branch-update-sweep.yml", "stale-guard-red-sweep.yml", "translation-parity.yml", "twin-parity-cron.yml", diff --git a/scripts/ci/sweep_stale_pr_branches.py b/scripts/ci/sweep_stale_pr_branches.py new file mode 100644 index 0000000000..381587a61e --- /dev/null +++ b/scripts/ci/sweep_stale_pr_branches.py @@ -0,0 +1,412 @@ +#!/usr/bin/env python3 +"""Pilote de balayage : trouve les PRs en retard sur leur base, delegue la mise a jour. + +Pourquoi ce pilote existe (#16915) +---------------------------------- +Mandat user du 2026-09-19 : « j'ai l'impression que le CI n'aide pas avec des +MAJ de rebase successives qui sont mandatees et coutent enormement de temps a +tout le monde : il faut qu'elles soient automatiques pour la plupart ». + +L'organe `scripts/ci/update_stale_pr_branches.py` sait mettre a jour une PR +NOMMEE (`--pr`, repetable) sous neuf gardes eprouvees (TOCTOU par SHA pinne, +registre en vol sous verrou, plafond, jamais de force-push, jamais `--rebase`) +-- mais il est invoque par RIEN et n'a pas de mode decouverte : `--pr` est un +argument requis, par contrat (« Jamais un pool : l'organe n'enumere rien »). + +Ce pilote est la piece manquante, et elle ne fait QUE ca : + + 1. ENUMERE les PRs ouvertes (un appel REST pagine, champs legers) ; + 2. PREFILTRE par metadonnees peu couteuses (brouillon, fork, non + mergeable) -- une selection, pas une decision ; + 3. MESURE le retard des survivantes (meme lecture `behind_by` de + l'API de comparaison que l'organe) ; + 4. DELEEGUE les candidates a l'organe par sous-processus, `--pr` par + `--pr`, et relaie sa sortie JSON telle quelle. + +Le pilote n'ecrit JAMAIS. Il ne fusionne rien, ne pousse rien, n'appelle pas +git. Seul l'organe ecrit, et seulement sous ses propres gardes. + +Ce que le pilote n'est PAS +-------------------------- +- Il n'est pas un decisionnaire : sa mesure de retard est une HEURISTIQUE DE + SELECTION. L'organe re-epingle les SHA et re-mesure lui-meme avant toute + ecriture (gardes TOCTOU de la review 5240194972) : le pilote n'est jamais + cru pour la decision d'ecriture, et n'a pas besoin de l'etre. +- Il ne reimplemente aucune garde : conflit, base empilee, registre en vol, + plafond, tout vit dans l'organe. Une garde dupliquee ici deriverait en + silence (la lecon de la copie `is_advisory` de pr-gate-stale-sweep.yml, + verrouillee par test AST -- ici il n'y a PAS de copie du tout). +- Il ne survend pas son gisement, mais il ne le sous-estime plus non plus : + mesure du 2026-09-20 (dry-run integral, 214 PRs ouvertes) : 3 brouillons, + 163 non-mergeables (CONFLICTING ou UNKNOWN, exclus nommes, remesurees a + chaque balayage), 1 a jour, et **47 candidates a `behind_by > 0`** -- + l'estimation ~12 de l'issue etait basse d'un facteur 4. La valeur n'est pas + le decompte debloque, c'est la SUPPRESSION des allers-retours : un + commentaire, une session de worker dediee et un dossier exact-head + remplaces par un run. + +Selection : les plus anciennes d'abord +-------------------------------------- +REST `/pulls` rend les PRs par creation decroissante (plus recentes en tete), +et le pilote passe a l'organe AU PLUS `--max-updates` candidates. Servir les +plus recentes d'abord serait a l'envers : une PR jeune emet encore des +evenements `synchronize` et possede d'autres voies de rattrapage, une PR +quiete et agée n'en a aucune -- c'est exactement la lecon mesuree de +pr-gate-stale-sweep.yml (run 33169455408 : cap 8 servi aux 8 plus recentes, +les 5 agées restées BLOCKED 4 a 7 h). Les candidates sont donc triees par +numero croissant avant decoupe. + +Plancher DWELL : le contrat que ce balayage presuppose +------------------------------------------------------ +Un `update-branch` reecrit la tete ; avant #16149 il re-armait les 120 min de +plancher que le geste sert a franchir. `scripts/ci/merge_dwell.py` mesure +desormais sur `last_authoritative_committed_at`, qui EXEMPTE les fusions de +rafraichissement de base PROUVEES content-free (deux parents, second ancetre +de la base, arbre identique a l'auto-merge). Le pilote presuppose ce contrat +et le VERROUILLE par un test de non-regression dans SA suite +(test_update_branch_shape_does_not_rearm_dwell) : si merge_dwell regresse, +la suite du pilote rougit en meme temps que celle du gate -- le balayage +automatique ne peut pas reintroduire silencieusement la taxe de 2 h. + +Sortie JSON, toujours (les deux modes) : population enumeree, exclusions +nommees, candidates mesurees, et la charge de l'organe relayee INTEGRALEMENT +(chaque resultat porte action, base_kind, freshness, invalidated -- cf +l'acceptance #16915). + +Codes de sortie : + 0 -- balayage sain (candidates ou non ; mises a jour appliquees et/ou + SKIP benins de l'organe) ; + 1 -- l'organe a rendu au moins un REFUSE (conflit, tete/base qui a bouge, + echec d'appel) : un humain doit regarder -- relaye tel quel, jamais + etoufe ; + 2 -- erreur d'appelant ou d'enumeration (arguments, listing illisible). +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path +from typing import Any + +# L'organe vit dans le meme repertoire : import direct, aucune copie. En +# execution script, `scripts/ci` n'est pas sur sys.path sinon. +_HERE = Path(__file__).resolve().parent +if str(_HERE) not in sys.path: + sys.path.insert(0, str(_HERE)) + +from update_stale_pr_branches import ( # noqa: E402 + DEFAULT_MAX_UPDATES, + DEFAULT_REPO, + base_kind, + read_behind, + read_branch_sha, + run_gh, +) + +#: Chemin de l'organe delegue, unique invocable d'ecriture. +ORGAN_PATH = _HERE / "update_stale_pr_branches.py" + +#: Champs d'enumeration (GraphQL via `gh pr list --json`). Mesure du +#: 2026-09-20 : l'API REST `/pulls` rend `mergeable: null` sur 211 des 214 +#: PRs ouvertes (calcul paresseux non rafraichi a l'echelle du pool), tandis +#: que GraphQL calcule a la lecture et rend MERGEABLE/CONFLICTING/UNKNOWN -- +#: enumerer par REST rendait le prefiltre structurellement inert (0 candidate +#: sur un pool porteuses). `mergeable: UNKNOWN` (calcul en cours) est EXCLU +#: nomme, jamais devine : il revient au balayage suivant, exactement comme le +#: SKIP nomme de l'organe. +LIST_FIELDS = ( + "number,isDraft,isCrossRepository,baseRefName,headRefOid,mergeable,url" +) + +#: Population ouverte mesuree le 2026-09-19 : 213 PRs. La borne d'enumeration +#: garde une marge ; au-dela, la queue (les plus ANCIENNES, dernieres dans +#: l'ordre de `gh pr list`, plus recentes en tete) attend le balayage +#: suivant -- dit, jamais cache. +DEFAULT_LIST_LIMIT = 400 + + +def parse_args(argv: list[str] | None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__.split("\n", 1)[0]) + parser.add_argument("--repo", default=DEFAULT_REPO, help="depot cible") + parser.add_argument( + "--list-limit", + type=int, + default=DEFAULT_LIST_LIMIT, + help=f"borne de lignes enumerees (defaut {DEFAULT_LIST_LIMIT} ; " + "au-dela, la queue attend le balayage suivant)", + ) + parser.add_argument( + "--apply", + action="store_true", + help="transmettre --apply a l'organe (defaut : dry-run, aucune " + "ecriture -- ni chez le pilote, ni chez l'organe)", + ) + parser.add_argument( + "--max-updates", + type=int, + default=DEFAULT_MAX_UPDATES, + help="plafond de mises a jour par balayage, transmis a l'organe ET " + "borne la selection (defaut 3 ; 0 = sans plafond -- deconseille : " + "chaque mise a jour perime un dossier exact-head)", + ) + parser.add_argument( + "--state-dir", + default=None, + help="repertoire du registre en vol de l'organe (transmis tel quel)", + ) + args = parser.parse_args(argv) + if args.list_limit <= 0: + parser.error(f"--list-limit doit etre > 0 (recu {args.list_limit})") + if args.max_updates < 0: + parser.error(f"--max-updates ne peut pas etre negatif (recu {args.max_updates})") + return args + + +def list_open_prs(repo: str, limit: int) -> list[dict[str, Any]]: + """Enumere les PRs ouvertes en champs legers, plus recentes en tete. + + Une ligne mal formee est ecartee silencieusement mais COMPTEE par + l'ecart enumere/lu ; un echec de l'appel lui-meme remonte en GhError + (exit 2). Les noms internes (draft/fork/base/head) sont normalises ici, + une seule fois. + """ + raw = run_gh( + [ + "pr", + "list", + "--repo", + repo, + "--state", + "open", + "--limit", + str(limit), + "--json", + LIST_FIELDS, + ] + ) + data = json.loads(raw) + if not isinstance(data, list): + raise RuntimeError("`gh pr list --json` n'a pas rendu une liste") + rows: list[dict[str, Any]] = [] + for item in data: + if not isinstance(item, dict) or not isinstance(item.get("number"), int): + continue + rows.append( + { + "number": item["number"], + "draft": bool(item.get("isDraft")), + "fork": bool(item.get("isCrossRepository")), + "base": item.get("baseRefName"), + "head_ref": item.get("headRefOid"), + "head": item.get("headRefOid"), + "mergeable": item.get("mergeable"), + "url": item.get("url"), + } + ) + return rows + + +def metadata_exclusion(row: dict[str, Any]) -> str | None: + """Raison d'exclusion peu couteuse, ou None si la ligne passe. + + Brouillon, fork et non-mergeable se lisent DEJA dans la ligne + d'enumeration GraphQL (calcul de mergeabilite frais a la lecture) : les + ecarter ici evite de payer, pour chaque desesperee, un appel de ref et un + appel de comparaison que l'organe rendrait de toute facon en + REFUSE/SKIP. Ce n'est qu'une selection -- l'organe re-verifie chaque + garde sur ses propres lectures avant d'ecrire. + """ + if row.get("draft"): + return "draft" + if row.get("fork"): + return "fork" + if row.get("mergeable") != "MERGEABLE": + # CONFLICTING (conflit reel) ou UNKNOWN (calcul en cours) : exclu + # maintenant, re-mesure au balayage suivant. + return "not_mergeable" + return None + + +def measure_behind(repo: str, row: dict[str, Any]) -> int | None: + """Retard de la PR contre sa base DECLAREE, ou None si illisible. + + Reutilise les lecteurs de l'organe (aucune reimplementation). Par NOM pour + la base : c'est une lecture de SELECTION, pas une decision -- une base + empilee qui avance entre ici et l'invocation de l'organe est re-epinnee + par l'organe lui-meme (review 5240194972, finding 1). + """ + base_ref = row.get("base") + head_sha = row.get("head") + if not base_ref or not head_sha: + return None + try: + base_sha = read_branch_sha(repo, base_ref) + behind = read_behind(repo, base_sha, head_sha) + except (RuntimeError, ValueError, OSError, UnicodeError): + # GhError derive de RuntimeError : retard inconnu, fail-closed -- la + # ligne revient au balayage suivant, jamais devinee. + return None + return behind if isinstance(behind, int) else None + + +def run_organ( + repo: str, + prs: list[int], + *, + apply: bool, + max_updates: int, + state_dir: str | None, +) -> tuple[int, dict[str, Any] | None, str]: + """Invoque l'organe en sous-processus et relaie (exit, JSON, stdout brut). + + Couture unique des tests : ils remplacent ce nom, jamais subprocess. + """ + argv = [sys.executable, str(ORGAN_PATH)] + for pr in prs: + argv += ["--pr", str(pr)] + argv += ["--repo", repo] + if apply: + argv += ["--apply"] + argv += ["--max-updates", str(max_updates)] + if state_dir: + argv += ["--state-dir", state_dir] + proc = subprocess.run( + argv, capture_output=True, text=True, encoding="utf-8", errors="replace" + ) + payload: dict[str, Any] | None = None + try: + parsed = json.loads(proc.stdout) + if isinstance(parsed, dict): + payload = parsed + except ValueError: + payload = None + return proc.returncode, payload, proc.stdout + + +def _pilot_row( + row: dict[str, Any], behind: int | None, *, selected: bool, deferred: bool +) -> dict[str, Any]: + """Ligne de resultat pilote, meme contrat de champs que l'organe. + + `action`/`base_kind` toujours presents ; `freshness`/`invalidated` nuls -- + le pilote n'ecrit rien, donc ne perime rien : seules les lignes de + l'organe (relayees integralement) portent `freshness: STALE`. + """ + pr = row.get("number") + return { + "pr": pr, + "action": "SELECTED" if selected else ("DEFERRED" if deferred else "NOT_SELECTED"), + "code": "", + "reason": ( + "candidate passe a l'organe (decision finale = l'organe)" + if selected + else "candidate au-dela du plafond : au prochain balayage" + if deferred + else "retard nul ou illisible : rien a deleguer" + ), + "base": row.get("base"), + "base_kind": base_kind(row.get("base")), + "behind_by": behind, + "url": row.get("url"), + "updated": False, + "freshness": None, + "invalidated": [], + } + + +def main(argv: list[str] | None = None) -> int: + args = parse_args(argv) + + try: + rows = list_open_prs(args.repo, args.list_limit) + except RuntimeError as exc: + print( + json.dumps( + { + "repo": args.repo, + "mode": "apply" if args.apply else "dry-run", + "error": f"enumeration illisible : {exc}", + }, + indent=2, + ensure_ascii=False, + ) + ) + return 2 + + excluded = {"draft": 0, "fork": 0, "not_mergeable": 0} + measured: list[tuple[dict[str, Any], int | None]] = [] + up_to_date = 0 + behind_unknown = 0 + for row in rows: + reason = metadata_exclusion(row) + if reason: + excluded[reason] += 1 + continue + behind = measure_behind(args.repo, row) + if behind is None: + behind_unknown += 1 + measured.append((row, None)) + elif behind > 0: + measured.append((row, behind)) + else: + up_to_date += 1 + measured.append((row, 0)) + + candidates = sorted( + ((row, b) for row, b in measured if (b or 0) > 0), key=lambda rb: rb[0]["number"] + ) + cap = args.max_updates if args.max_updates > 0 else len(candidates) + selected = candidates[:cap] + deferred = candidates[cap:] + + organ_exit: int | None = None + organ_payload: dict[str, Any] | None = None + organ_stdout = "" + if selected: + organ_exit, organ_payload, organ_stdout = run_organ( + args.repo, + [row["number"] for row, _ in selected], + apply=args.apply, + max_updates=args.max_updates, + state_dir=args.state_dir, + ) + + organ_results = (organ_payload or {}).get("results") or [] + pilot_results = [ + _pilot_row(row, b, selected=False, deferred=False) + for row, b in measured + if (b or 0) <= 0 + ] + [ + _pilot_row(row, b, selected=False, deferred=True) for row, b in deferred + ] + + payload = { + "repo": args.repo, + "mode": "apply" if args.apply else "dry-run", + "max_updates": args.max_updates, + "list_limit": args.list_limit, + "enumerated": len(rows), + "excluded": {**excluded, "up_to_date": up_to_date, "behind_unknown": behind_unknown}, + "candidates": len(candidates), + "selected": [row["number"] for row, _ in selected], + "deferred": [row["number"] for row, _ in deferred], + "organ_invoked": bool(selected), + "organ_exit": organ_exit, + "organ": organ_payload, + "results": list(organ_results) + pilot_results, + } + print(json.dumps(payload, indent=2, ensure_ascii=False)) + if organ_exit is not None and organ_stdout and organ_payload is None: + # L'organe a parle mais pas en JSON : le dire plutot que maquiller un + # relais vide en balayage sain. + print("[sweep] sortie organe non-JSON (extrait) :", organ_stdout[:400], file=sys.stderr) + if organ_exit is None: + return 0 + return organ_exit + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/tests/test_sweep_stale_pr_branches.py b/scripts/tests/test_sweep_stale_pr_branches.py new file mode 100644 index 0000000000..75502ba87b --- /dev/null +++ b/scripts/tests/test_sweep_stale_pr_branches.py @@ -0,0 +1,413 @@ +"""Tests de `scripts/ci/sweep_stale_pr_branches.py` (#16915). + +Aucun appel reseau : l'enumeration passe par la couture `run_gh` de l'organe +(chargee la premiere et enregistree dans sys.modules, comme sa propre suite), +la mesure de retard par les references `read_branch_sha`/`read_behind` du +pilote, et la delegation par la couture `run_organ`. + +Le contrat verrouille ici : + + * le pilote n'ecrit JAMAIS -- seul `run_organ` invoque l'organe, et il ne + recoit `--apply` QUE si le pilote l'a recu (dry-run par defaut) ; + * l'ordre de selection est OLDEST-FIRST (les PRs quietes et agees n'ont + pas d'autre voie de rattrapage -- lecon mesuree du pr-gate-stale-sweep) ; + * la selection est bornee par --max-updates, le surplus est DEFERRED nomme ; + * les exclusions de metadonnees sont NOMMEES et comptees (jamais un + filtrage muet) ; + * le plancher DWELL ne se re-arme PAS sur un update-branch de la forme que + ce balayage produit -- le contrat #16149 que le pilote presuppose, avec + ses propres fixtures (test de non-regression exigé par l'acceptance). +""" + +import importlib.util +import json +import sys +from datetime import datetime, timezone +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +CI_DIR = HERE.parent / "ci" +ORGAN = CI_DIR / "update_stale_pr_branches.py" +PILOT = CI_DIR / "sweep_stale_pr_branches.py" + +# L'organe d'abord, enregistre sous son nom d'import : le pilote fait +# `from update_stale_pr_branches import ...` et doit trouver CE module-ci, +# pas une seconde execution de spec. +organ_spec = importlib.util.spec_from_file_location("update_stale_pr_branches", ORGAN) +organ = importlib.util.module_from_spec(organ_spec) +sys.modules.setdefault("update_stale_pr_branches", organ) +organ_spec.loader.exec_module(organ) + +pilot_spec = importlib.util.spec_from_file_location("sweep_stale_pr_branches", PILOT) +mod = importlib.util.module_from_spec(pilot_spec) +pilot_spec.loader.exec_module(mod) + +REPO = "jsboige/CoursIA" + + +def pr_row( + number, + *, + base="main", + head=None, + mergeable="MERGEABLE", + draft=False, + fork=False, + url=None, +): + return { + "number": number, + "isDraft": draft, + "isCrossRepository": fork, + "baseRefName": base, + "headRefOid": head or f"{number:040d}", + "mergeable": mergeable, + "url": url or f"https://github.com/{REPO}/pull/{number}", + } + + +class FakeGh: + """Doublure de `run_gh` : rend la reponse GraphQL de `gh pr list`.""" + + def __init__(self, rows): + self.rows = rows + + def __call__(self, args): + assert args[0] == "pr" + assert args[args.index("--json") + 1] == mod.LIST_FIELDS + return json.dumps(self.rows) + + +def patch_measure(monkeypatch, table, failures=()): + """Branche les deux lecteurs de retard sur une table par numero de PR.""" + + def fake_sha(repo, ref): + return f"base-{ref}" + + def fake_behind(repo, base_sha, head_sha): + # La tete encode le numero : {number:040d} -> int(head) + number = int(head_sha.lstrip("0") or "0") + if number in failures: + raise organ.GhError(f"compare muet pour #{number}") + if number not in table: + return 0 + return table[number] + + monkeypatch.setattr(mod, "read_branch_sha", fake_sha) + monkeypatch.setattr(mod, "read_behind", fake_behind) + + +class OrganCall: + def __init__(self): + self.argv = None + + def __call__(self, repo, prs, *, apply, max_updates, state_dir): + self.argv = { + "repo": repo, + "prs": list(prs), + "apply": apply, + "max_updates": max_updates, + "state_dir": state_dir, + } + results = [ + { + "pr": pr, + "action": "UPDATE", + "code": "OK", + "base_kind": "main", + "updated": True, + "freshness": "STALE", + "invalidated": ["checks", "reviews", "dossier"], + } + for pr in prs + ] + payload = { + "repo": repo, + "mode": "apply" if apply else "dry-run", + "max_updates": max_updates, + "updates_applied": len(results), + "results": results, + } + return 0, payload, json.dumps(payload) + + +def run_sweep(monkeypatch, capsys, rows, table, *, extra=(), failures=()): + monkeypatch.setattr(mod, "run_gh", FakeGh(rows)) + patch_measure(monkeypatch, table, failures=failures) + call = OrganCall() + monkeypatch.setattr(mod, "run_organ", call) + rc = mod.main(list(extra)) + out = json.loads(capsys.readouterr().out) + return rc, out, call + + +def test_dry_run_by_default_passes_no_apply(monkeypatch, capsys): + _, out, call = run_sweep(monkeypatch, capsys, [pr_row(101)], {101: 5}) + assert call.argv["apply"] is False + assert out["mode"] == "dry-run" + assert out["organ"]["mode"] == "dry-run" + + +def test_apply_flag_is_forwarded_to_the_organ_only(monkeypatch, capsys): + _, _, call = run_sweep(monkeypatch, capsys, [pr_row(7)], {7: 2}, extra=["--apply"]) + assert call.argv["apply"] is True + + +def test_selection_is_oldest_first_and_capped(monkeypatch, capsys): + rows = [pr_row(90), pr_row(10), pr_row(50), pr_row(30)] + table = {90: 4, 10: 9, 50: 1, 30: 2} + rc, out, call = run_sweep( + monkeypatch, capsys, rows, table, extra=["--max-updates", "2"] + ) + # 10 et 30 (les plus anciennes) passees a l'organe ; 50 et 90 deferees. + assert call.argv["prs"] == [10, 30] + assert out["selected"] == [10, 30] + assert out["deferred"] == [50, 90] + actions = {r["pr"]: r["action"] for r in out["results"]} + assert actions[50] == "DEFERRED" + assert actions[90] == "DEFERRED" + + +def test_metadata_exclusions_are_named_and_counted(monkeypatch, capsys): + rows = [ + pr_row(1, draft=True), + pr_row(2, fork=True), + pr_row(3, mergeable="UNKNOWN"), + pr_row(4, mergeable="CONFLICTING"), + pr_row(5), + ] + rc, out, _ = run_sweep(monkeypatch, capsys, rows, {}) + assert rc == 0 + assert out["excluded"] == { + "draft": 1, + "fork": 1, + "not_mergeable": 2, + "up_to_date": 1, + "behind_unknown": 0, + } + assert out["candidates"] == 0 + assert out["organ_invoked"] is False + assert out["organ"] is None + + +def test_up_to_date_and_unknown_are_not_candidates(monkeypatch, capsys): + rows = [pr_row(11), pr_row(12), pr_row(13)] + table = {11: 0, 13: 7} + rc, out, call = run_sweep(monkeypatch, capsys, rows, table, failures={12}) + assert out["selected"] == [13] + assert out["excluded"]["up_to_date"] == 1 + assert out["excluded"]["behind_unknown"] == 1 + # L'inconnue n'est PAS deleguee : fail-closed, re-essayable au prochain + # balayage -- jamais un « probablement en retard ». + assert 12 not in call.argv["prs"] + + +def test_results_carry_the_acceptance_fields_for_every_measured_pr(monkeypatch, capsys): + rows = [pr_row(21), pr_row(22), pr_row(23, base="feature/stack-base")] + table = {21: 3, 22: 0, 23: 0} + rc, out, _ = run_sweep(monkeypatch, capsys, rows, table, extra=["--apply"]) + by_pr = {r["pr"]: r for r in out["results"]} + # Ligne organe (selectionnee) : les quatre champs de l'acceptance. + assert by_pr[21]["action"] == "UPDATE" + assert by_pr[21]["base_kind"] == "main" + assert by_pr[21]["freshness"] == "STALE" + assert by_pr[21]["invalidated"] == ["checks", "reviews", "dossier"] + # Ligne pilote (non selectionnee) : meme contrat de champs, valeurs nulles. + assert by_pr[22]["action"] == "NOT_SELECTED" + assert by_pr[22]["base_kind"] == "main" + assert by_pr[22]["freshness"] is None + assert by_pr[22]["invalidated"] == [] + # Base empilee : le genre est REPORTE par le pilote, jamais decide ici. + assert by_pr[23]["base_kind"] == "stacked" + + +def test_organ_refuse_is_relayed_not_swallowed(monkeypatch, capsys): + rows = [pr_row(31)] + table = {31: 6} + + def refusing(repo, prs, **kw): + payload = { + "repo": repo, + "mode": "apply", + "results": [ + { + "pr": prs[0], + "action": "REFUSE", + "code": "HEAD_MOVED", + "base_kind": "main", + "freshness": None, + "invalidated": [], + } + ], + } + return 1, payload, json.dumps(payload) + + monkeypatch.setattr(mod, "run_gh", FakeGh(rows)) + patch_measure(monkeypatch, table) + monkeypatch.setattr(mod, "run_organ", refusing) + rc = mod.main(["--apply"]) + out = json.loads(capsys.readouterr().out) + assert rc == 1 + assert out["organ_exit"] == 1 + assert out["results"][0]["action"] == "REFUSE" + + +def test_enumeration_failure_exits_two(monkeypatch, capsys): + def broken(args): + raise organ.GhError("504 sur /pulls") + + monkeypatch.setattr(mod, "run_gh", broken) + rc = mod.main([]) + out = json.loads(capsys.readouterr().out) + assert rc == 2 + assert "enumeration illisible" in out["error"] + + +def test_list_limit_is_transmitted_to_gh(monkeypatch, capsys): + rows = [pr_row(n) for n in range(1, 5)] + seen = {} + + def fake_gh(args): + seen["limit"] = args[args.index("--limit") + 1] + return json.dumps(rows) + + monkeypatch.setattr(mod, "run_gh", fake_gh) + patch_measure(monkeypatch, {}) + monkeypatch.setattr(mod, "run_organ", OrganCall()) + mod.main(["--list-limit", "4"]) + out = json.loads(capsys.readouterr().out) + # La borne vit dans l'appel gh (troncature cote serveur), pas dans une + # re-implementation locale : la transmettre suffit. + assert seen["limit"] == "4" + assert out["list_limit"] == 4 + + +# --------------------------------------------------------------------------- +# Verrou DWELL (#16149) : le contrat que le balayage presuppose. L'acceptance +# #16915 l'exige DANS la suite du pilote -- si merge_dwell regresse et +# re-arme le plancher sur un update-branch, ce fichier rougit en meme temps +# que le gate, et le cron qui vient d'etre cable devient visible comme +# introducteur de la taxe, pas silencieux. +# --------------------------------------------------------------------------- + +def test_update_branch_shape_does_not_rearm_dwell(): + dwell_spec = importlib.util.spec_from_file_location( + "merge_dwell_for_sweep_test", CI_DIR / "merge_dwell.py" + ) + dwell = importlib.util.module_from_spec(dwell_spec) + dwell_spec.loader.exec_module(dwell) + + REPO = "jsboige/CoursIA" + M1 = "c" * 40 # dernier commit COTE PR (l'auteur) + BASE = "d" * 40 # tete de la base au moment de l'update + M2 = "e" * 40 # commit de fusion pose par gh pr update-branch + + PR_COMMITTED = "2026-09-20T00:00:00Z" + UPDATE_COMMITTED = "2026-09-20T04:00:00Z" # 4 h plus tard + + commits = { + M1: { + "parents": [{"sha": "f" * 40}], + "commit": { + "committer": {"date": PR_COMMITTED}, + "tree": {"sha": "t" * 40}, + }, + }, + M2: { + "parents": [{"sha": M1}, {"sha": BASE}], + "commit": { + "committer": {"date": UPDATE_COMMITTED}, + "tree": {"sha": "a" * 40}, + }, + }, + } + + def fetch(path): + # commits/{sha} pour M1 et M2 ; le compare qui prouve « second parent + # ancetre de la base » (ici par EGALITE directe, le cas update-branch). + for sha, payload in commits.items(): + if path == f"repos/{REPO}/commits/{sha}": + return payload + if path.startswith(f"repos/{REPO}/compare/"): + return {"status": "behind", "behind_by": 0} + raise dwell.DwellError(f"path inattendue : {path}") + + def run_git(args): + # merge-tree rend l'arbre de l'auto-merge : EGAL a l'arbre porte par + # M2 -> la fusion est PROUVEE content-free et doit etre exempte. + if args[:2] == ["merge-tree", "--write-tree"]: + return 0, "a" * 40 + "\n" + if args[0] == "cat-file": + return 0, "" + if args[0] == "merge-base": + return 0, "f" * 40 + "\n" + if args[0] == "rev-parse": + return 0, "false\n" + return 0, "" + + measured = dwell.last_authoritative_committed_at( + REPO, M2, BASE, fetch=fetch, run_git=run_git + ) + assert measured == dwell.parse_iso8601(PR_COMMITTED), ( + "un update-branch content-free doit mesurer le dernier commit AUTEUR, " + "pas la fusion serveur -- sinon chaque balayage re-arme 120 min de " + "plancher et #16915 reintroduit la taxe que #16149 a supprimee" + ) + + +def test_dwell_still_measures_a_substantive_merge(): + """Controle negatif du verrou : une fusion PORTEUSE DE CONTENU (arbre != + auto-merge, i.e. une resolution d'auteur) se mesure ELLE-MEME.""" + dwell_spec = importlib.util.spec_from_file_location( + "merge_dwell_for_sweep_neg", CI_DIR / "merge_dwell.py" + ) + dwell = importlib.util.module_from_spec(dwell_spec) + dwell_spec.loader.exec_module(dwell) + + REPO = "jsboige/CoursIA" + M1, BASE, M2 = "1" * 40, "2" * 40, "3" * 40 + PR_COMMITTED = "2026-09-20T00:00:00Z" + RESOLVED_AT = "2026-09-20T04:00:00Z" + + commits = { + M1: { + "parents": [{"sha": "f" * 40}], + "commit": { + "committer": {"date": PR_COMMITTED}, + "tree": {"sha": "t" * 40}, + }, + }, + M2: { + "parents": [{"sha": M1}, {"sha": BASE}], + "commit": { + "committer": {"date": RESOLVED_AT}, + "tree": {"sha": "b" * 40}, # DIFFERENT de l'auto-merge + }, + }, + } + + def fetch(path): + for sha, payload in commits.items(): + if path == f"repos/{REPO}/commits/{sha}": + return payload + if path.startswith(f"repos/{REPO}/compare/"): + return {"status": "behind", "behind_by": 0} + raise dwell.DwellError(f"path inattendue : {path}") + + def run_git(args): + if args[:2] == ["merge-tree", "--write-tree"]: + return 0, "a" * 40 + "\n" # auto-merge != arbre porte par M2 + if args[0] == "cat-file": + return 0, "" + if args[0] == "merge-base": + return 0, "f" * 40 + "\n" + if args[0] == "rev-parse": + return 0, "false\n" + return 0, "" + + measured = dwell.last_authoritative_committed_at( + REPO, M2, BASE, fetch=fetch, run_git=run_git + ) + assert measured == dwell.parse_iso8601(RESOLVED_AT)