#!/usr/bin/env python3
"""Audit du suivi de sprint sur les DEUX dépôts.

Pourquoi ce script existe
-------------------------
Le 10/08, on a cru le lot L4 terminé alors que quatre écrans n'existaient pas. La cause
n'était pas une négligence isolée : les **jalons n'existaient que sur le dépôt back**. Dire
« L4 = 20 issues » revenait à lire un jalon qui, par construction, ne pouvait contenir aucun
travail d'écran. Le front était hors sprint, donc invisible.

Une convention seule ne suffit pas à empêcher ça : elle se respecte jusqu'au jour où on est
pressé. Ce script transforme la convention en **contrôle**.

Ce qu'il vérifie
----------------
0. La référence croisée se met dans le **corps** de l'issue, jamais en commentaire :
   un commentaire se noie dans le fil, et aucun contrôle ne peut le lire de façon fiable.
1. Toute issue ouverte a un jalon — des deux côtés.
2. Deux issues qui se citent l'une l'autre sont dans le **même** jalon.
3. Une issue back étiquetée `repo:front` a bien une contrepartie côté front.
4. Une issue front n'est pas orpheline : elle cite une issue produit côté back.

Usage
-----
    python3 scripts/audit_sprints.py                 # tous les jalons
    python3 scripts/audit_sprints.py --jalon L4      # un lot précis
    python3 scripts/audit_sprints.py --strict        # code de sortie 1 s'il reste un écart

À lancer **au début et à la fin de chaque lot**. En `--strict`, il peut servir de garde en
intégration continue.
"""

import argparse
import json
import re
import subprocess
import sys

BACK = "AAZTEKDEV/EFEKTIVACADEMIE-BACK"
FRONT = "AAZTEKDEV/EFEKTIVACADEMIE-FRONT"

# Une issue en cite une autre : « BACK#123 », « FRONT#45 », ou une URL complète.
CITATION = re.compile(r"(BACK|FRONT)#(\d+)|EFEKTIVACADEMIE-(BACK|FRONT)/issues/(\d+)")


def issues(depot):
    """Issues ouvertes d'un dépôt, avec leur jalon, leurs libellés et leurs commentaires."""
    brut = subprocess.run(
        ["gh", "issue", "list", "--repo", depot, "--state", "open", "--limit", "300",
         "--json", "number,title,milestone,labels,body"],
        capture_output=True, text=True, check=True,
    ).stdout
    return {i["number"]: i for i in json.loads(brut)}


def citations(issue, cote_attendu):
    """Numéros d'issues de l'autre dépôt cités par celle-ci."""
    trouves = set()
    for m in CITATION.finditer(issue.get("body") or ""):
        cote, num = (m.group(1), m.group(2)) if m.group(1) else (m.group(3), m.group(4))
        if cote == cote_attendu:
            trouves.add(int(num))
    return trouves


def jalon(issue):
    return (issue.get("milestone") or {}).get("title")


def court(titre, n=62):
    return titre if len(titre) <= n else titre[: n - 1] + "…"


def main():
    a = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    a.add_argument("--jalon", help="ne regarder qu'un lot (sous-chaîne du titre, ex. « L4 »)")
    a.add_argument("--strict", action="store_true", help="sortir en erreur s'il reste un écart")
    args = a.parse_args()

    back, front = issues(BACK), issues(FRONT)
    ecarts = 0

    def concerne(issue):
        return not args.jalon or (jalon(issue) or "").find(args.jalon) >= 0

    # 1 — sans jalon
    for nom, lot in (("BACK", back), ("FRONT", front)):
        orphelines = [i for i in lot.values() if jalon(i) is None]
        # Sans filtre de jalon, une issue sans jalon est par définition hors périmètre du
        # filtre : on ne la signale que dans l'audit complet.
        if orphelines and not args.jalon:
            print(f"\n⚠️  {nom} — {len(orphelines)} issue(s) ouverte(s) SANS JALON")
            print("   Une issue sans lot n'apparaît dans aucun sprint : elle dérive.")
            for i in sorted(orphelines, key=lambda x: x["number"]):
                print(f"     #{i['number']:<5} {court(i['title'])}")
            ecarts += len(orphelines)

    # 2 — paires dont les jalons divergent
    divergentes = []
    for i in back.values():
        for n in citations(i, "FRONT"):
            f = front.get(n)
            if f and jalon(i) != jalon(f) and (concerne(i) or concerne(f)):
                divergentes.append((i, f))
    if divergentes:
        print(f"\n⚠️  {len(divergentes)} paire(s) dans des JALONS DIFFÉRENTS")
        print("   Les deux volets d'un même besoin doivent vivre dans le même lot.")
        for i, f in divergentes:
            print(f"     BACK#{i['number']} ({jalon(i) or 'aucun'})  ≠  FRONT#{f['number']} ({jalon(f) or 'aucun'})")
        ecarts += len(divergentes)

    # 3 — issues back qui annoncent du front sans contrepartie
    sans_volet = []
    for i in back.values():
        libelles = [l["name"] for l in i["labels"]]
        if "repo:front" in libelles and concerne(i) and not citations(i, "FRONT"):
            sans_volet.append(i)
    if sans_volet:
        print(f"\n📋 {len(sans_volet)} issue(s) BACK annonçant du front — À PASSER EN REVUE")
        print("   Ce n'est pas un écart : le front peut avoir été livré sous la même issue.")
        print("   Mais c'est LA question qui aurait évité de croire L4 terminé — pour chacune,")
        print("   ouvrir l'écran et vérifier qu'il existe. Une réponse par issue, pas un survol.")
        for i in sorted(sans_volet, key=lambda x: x["number"]):
            print(f"     #{i['number']:<5} {court(i['title'])}")

    # 4 — issues front orphelines de toute issue produit
    orphelines = [
        f for f in front.values()
        if concerne(f) and not citations(f, "BACK")
        and not any(f["number"] in citations(i, "FRONT") for i in back.values())
    ]
    if orphelines:
        print(f"\n📎 {len(orphelines)} issue(s) FRONT sans issue produit rattachée")
        print("   Acceptable pour de la dette purement technique ; suspect pour un écran.")
        for f in sorted(orphelines, key=lambda x: x["number"]):
            print(f"     FRONT#{f['number']:<4} {court(f['title'])}")

    if ecarts == 0:
        print("\n✅ Aucun écart de suivi entre les deux dépôts.")
    else:
        print(f"\n{ecarts} écart(s) à traiter.")

    sys.exit(1 if (args.strict and ecarts) else 0)


if __name__ == "__main__":
    main()
