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
205 changes: 205 additions & 0 deletions scripts/ci/comment_char_budget.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
#!/usr/bin/env python3
"""Compteur « commentaires-pompage » sur les grandes PRs ouvertes (#15511, P5).

Cas fondateur : PR #14821 — 31 commentaires totalisant 68 101 caractères sur
5 jours pour un livrable inchangé. P2 (``soft_deadlock_detector``) détecte la
boucle par la conjonction âge + mergeable + churn + immobilité du code ; P5
mesure l'autre face du même symptôme : le **volume** de prose accumulé sur
les PRs massives — un budget qui se consomme en diagnostics au lieu d'actions.

Cet organe est une **mesure passive, jamais un gate** : il rend un compteur
agrégé et un signal ``[ALERT] commentaires-pompage`` au-delà du seuil, et
rend la main. Le signal se poste sur le dashboard workspace par la lane qui
lit la mesure ; le triage reste humain (une alerte n'est pas un verdict de
faute, c'est une invitation à trancher). Pas de câblage automatique : même
règle que P2, l'armement éventuel relève du coordinateur.

Critères (spec #15511 P5) :

1. ``state: OPEN`` (source : ``gh pr list --state open``) ;
2. taille du diff ``additions + deletions > --min-diff-lines`` (défaut
1000) — la spec parle de « PR ouvertes > 1 000 lignes » ; l'interprétation
retenue est la taille totale du diff, pas les seules additions ;
3. chars cumulés de **tous** les commentaires (humains et bots — fidélité à
la mesure fondatrice 68 101 = 31 commentaires toutes origines), avec
ventilation ``human`` / ``bot`` rendue séparément pour le triage ;
4. ``[ALERT] commentaires-pompage`` quand l'agrégat dépasse
``--alert-threshold`` (défaut 30 000) — une PR au-delà de ``--limit``
est invisible, le plafond default couvre le plateau observé.

Sémantique de sortie (même convention que ``soft_deadlock_detector``) :

* rc=0 — mesure effectuée, alerte ou non ;
* rc=1 — panne opérationnelle (``gh`` injoignable, forme inattendue) ;
* rc=2 — uniquement avec ``--fail-on-findings`` : l'alerte est levée et
l'appelant veut un code distingué.

Les appels ``gh`` passent par un ``runner`` injectable pour que les tests
rejouent des corpus réels sans réseau.
"""
from __future__ import annotations

import argparse
import json
import sys
from typing import Callable, Optional

# Bots mécaniques du dépôt — mêmes logins exacts que soft_deadlock_detector
# (leurs corps comptent dans le volume, mais la ventilation les sépare :
# un gros verdict de bot n'est pas de la pompe d'agent).
BOT_LOGINS = frozenset({
"github-actions",
"github-actions[bot]",
"dependabot[bot]",
"renovate[bot]",
"codecov[bot]",
"coveralls[bot]",
})

DEFAULT_MIN_DIFF_LINES = 1000
DEFAULT_ALERT_THRESHOLD = 30000
DEFAULT_LIMIT = 300
DEFAULT_TIMEOUT = 30

DEFAULT_REPO = "jsboige/CoursIA"


def _default_runner(cmd, **kwargs): # pragma: no cover - trivial default
import subprocess
return subprocess.run(cmd, **kwargs)


def fetch_open_prs(
repo: str,
runner: Optional[Callable] = None,
timeout: int = DEFAULT_TIMEOUT,
limit: int = DEFAULT_LIMIT,
) -> list:
"""PRs ouvertes avec commentaires et taille de diff, en une requête.

Le JSON de ``gh pr list`` porte ``additions``, ``deletions`` et
``comments`` (auteur, createdAt, body) : une seule requête suffit à
toute la mesure.
"""
if runner is None: # pragma: no cover - trivial default
runner = _default_runner
proc = runner(
["gh", "pr", "list", "--repo", repo, "--state", "open",
"--limit", str(limit), "--json",
"number,title,createdAt,additions,deletions,comments"],
capture_output=True, text=True, timeout=timeout,
)
if getattr(proc, "returncode", 1) != 0:
raise RuntimeError(f"gh pr list failed: {proc.stderr.strip()}")
data = json.loads(proc.stdout)
if not isinstance(data, list):
raise RuntimeError(
f"gh pr list: forme inattendue ({type(data).__name__})")
return data


def _comment_chars(comment: dict) -> int:
return len(comment.get("body") or "")


def _is_bot(comment: dict) -> bool:
login = ((comment.get("author") or {}).get("login") or "")
return login in BOT_LOGINS


def analyze(
prs: list,
min_diff_lines: int = DEFAULT_MIN_DIFF_LINES,
alert_threshold: int = DEFAULT_ALERT_THRESHOLD,
) -> dict:
"""Agrège les chars de commentaires des PRs de plus de ``min_diff_lines``.

Rend un dictionnaire : ``aggregate_chars`` (toutes origines),
``human_chars`` / ``bot_chars`` (ventilation), ``alert`` (booléen),
``prs`` (per-PR, triées par chars décroissants) et ``skipped_small``
(PRs sous le seuil de taille, hors mesure — une petite PR qui discute
n'est pas le symptôme fondateur).
"""
per_pr = []
aggregate = 0
human_total = 0
bot_total = 0
skipped_small = 0
for pr in prs:
additions = pr.get("additions") or 0
deletions = pr.get("deletions") or 0
if additions + deletions <= min_diff_lines:
skipped_small += 1
continue
comments = pr.get("comments") or []
human = sum(_comment_chars(c) for c in comments if not _is_bot(c))
bot = sum(_comment_chars(c) for c in comments if _is_bot(c))
total = human + bot
aggregate += total
human_total += human
bot_total += bot
per_pr.append({
"number": pr.get("number"),
"title": pr.get("title"),
"diff_lines": additions + deletions,
"comments": len(comments),
"chars": total,
"chars_human": human,
"chars_bot": bot,
})
per_pr.sort(key=lambda e: e["chars"], reverse=True)
return {
"measured_prs": len(per_pr),
"skipped_small": skipped_small,
"aggregate_chars": aggregate,
"human_chars": human_total,
"bot_chars": bot_total,
"alert": aggregate > alert_threshold,
"alert_threshold": alert_threshold,
"prs": per_pr,
}


def main(argv: Optional[list] = None) -> int:
parser = argparse.ArgumentParser(
description="Compteur commentaires-pompage sur grandes PRs "
"ouvertes (#15511 P5) — mesure passive, jamais un gate.")
parser.add_argument("--repo", default=DEFAULT_REPO)
parser.add_argument("--min-diff-lines", type=int,
default=DEFAULT_MIN_DIFF_LINES)
parser.add_argument("--alert-threshold", type=int,
default=DEFAULT_ALERT_THRESHOLD)
parser.add_argument("--limit", type=int, default=DEFAULT_LIMIT)
parser.add_argument("--json", action="store_true", dest="as_json",
help="sortie machine-lisible (défaut : ligne humaine)")
parser.add_argument("--fail-on-findings", action="store_true",
help="rc=2 si l'alerte est levée")
args = parser.parse_args(argv)

try:
prs = fetch_open_prs(args.repo, limit=args.limit)
result = analyze(prs, args.min_diff_lines, args.alert_threshold)
except (RuntimeError, json.JSONDecodeError, OSError) as exc:
print(f"ERREUR mesure plateau: {exc}", file=sys.stderr)
return 1

if args.as_json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
status = "[ALERT] commentaires-pompage" if result["alert"] else "OK"
print(f"{status} -- {result['aggregate_chars']} chars cumules sur "
f"{result['measured_prs']} PRs > {args.min_diff_lines} lignes "
f"(seuil {args.alert_threshold} ; "
f"{result['skipped_small']} petites PRs hors mesure)")
for entry in result["prs"]:
if entry["chars"] == 0:
continue
print(f" #{entry['number']} {entry['chars']:>7} chars "
f"({entry['comments']} commentaires, "
f"diff {entry['diff_lines']} lignes) "
f"{(entry['title'] or '')[:60]}")
return 2 if (args.fail_on_findings and result["alert"]) else 0


if __name__ == "__main__":
sys.exit(main())
87 changes: 87 additions & 0 deletions scripts/secrets/migrate_env_to_keyring.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
"""Migration des consommateurs docker-compose vers MyIA-Keys (#17558, phase 4).

Lit un `.env` cible, extrait les cles partagees (celles du coffre), et ecrit
un `.env` minimal contenant UNIQUEMENT les cles par instance + config service.
Les cles partagees sont retirees du fichier : elles viennent desormais du
coffre via `agent_keyring.py run -- <cmd>` au moment de l'appel.

Usage : python scripts/secrets/migrate_env_to_keyring.py <path.env> [--apply]
"""
from __future__ import annotations

import argparse
import sys
from pathlib import Path

# Clefs partagees (dans le coffre MyIA-Keys, c.1526) -- a retirer des .env
SHARED_KEYS = frozenset({
"ANTHROPIC_API_KEY", "CIVITAI_TOKEN", "CLAUDISH_PROXY_KEY",
"COMFYUI_API_TOKEN", "COMFYUI_AUTH_TOKEN", "COMFYUI_VIDEO_TOKEN",
"DEMUCS_API_KEY", "FUNASR_API_KEY", "GITHUB_ACCESS_TOKEN", "GITHUB_TOKEN",
"HF_TOKEN", "HUGGINGFACE_TOKEN", "MINIMAX_GENAI_API_KEY",
"MUSICGEN_API_KEY", "OPENAI_API_KEY", "OPENAI_BASE_URL",
"OPENROUTER_API_KEY", "QDRANT_API_KEY", "QWEN_API_KEY", "QWEN_API_TOKEN",
"QWEN_ASR_API_KEY", "SECRET_KEY", "TTS_API_KEY", "TTS_GATEWAY_API_KEY",
"VLLM_API_KEY", "VLLM_API_KEY_MEDIUM", "WHISPER_API_KEY",
})


def migrate(env_path: Path, apply: bool = False) -> dict:
"""Extrait les cles partagees, ecrit le .env minimal si apply."""
lines = env_path.read_text(encoding="utf-8").splitlines()
shared_found = []
kept_lines = []
for line in lines:
stripped = line.strip()
if not stripped or stripped.startswith("#"):
kept_lines.append(line)
continue
if "=" in stripped:
key = stripped.split("=", 1)[0].strip()
if key in SHARED_KEYS:
shared_found.append(key)
continue # retire la cle partagee du fichier
kept_lines.append(line)

result = {
"path": str(env_path),
"shared_removed": sorted(shared_found),
"shared_count": len(shared_found),
"lines_kept": len(kept_lines),
"lines_removed": len(lines) - len(kept_lines),
}
if apply:
env_path.write_text("\n".join(kept_lines) + "\n", encoding="utf-8")
result["written"] = True
else:
result["written"] = False
return result


def main(argv=None) -> int:
parser = argparse.ArgumentParser(
description="Migration .env -> MyIA-Keys (#17558 phase 4)")
parser.add_argument("env_file", type=Path)
parser.add_argument("--apply", action="store_true",
help="ecrit le fichier (sinon dry-run)")
args = parser.parse_args(argv)

if not args.env_file.exists():
print(f"ERREUR: {args.env_file} non trouve", file=sys.stderr)
return 1

result = migrate(args.env_file, apply=args.apply)
mode = "ECRIT" if result["written"] else "DRY-RUN"
print(f"{mode}: {result['path']}")
print(f" {result['shared_count']} cles partagees retirees: "
f"{result['shared_removed']}")
print(f" {result['lines_kept']} lignes conservees "
f"({result['lines_removed']} retirees)")
if not args.apply:
print(" Relancer avec --apply pour ecrire le fichier.")
return 0


if __name__ == "__main__":
sys.exit(main())
Loading
Loading