creer_depots.py

Python · 11 Ko · 19 septembre 2026

Télécharger Ouvrir le fichier

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MIG8110 — création des dépôts étudiants sur GitLab
───────────────────────────────────────────────────────────────────────────
Pour chaque étudiant d'une liste : crée un projet privé dans le groupe du
cours à partir du projet gabarit, puis ajoute l'étudiant comme mainteneur.

  # 1. écrire la liste
  python3 creer_depots.py --exemple > etudiants.csv
  # ... compléter etudiants.csv ...

  # 2. voir ce qui serait fait, sans rien créer
  python3 creer_depots.py etudiants.csv --groupe mig8110-a2026 --essai

  # 3. créer pour de vrai
  python3 creer_depots.py etudiants.csv --groupe mig8110-a2026

Prérequis : python3 seulement.
Jeton GitLab de portée « api », dans la variable GITLAB_TOKEN.

Le script est rejouable : un dépôt déjà présent est laissé tel quel, et un
étudiant déjà membre n'est pas ajouté deux fois.
"""
import argparse, csv, json, os, sys, time, unicodedata
import urllib.error, urllib.parse, urllib.request

EXEMPLE = """code_ms;nom;prenom;courriel
ab12345678;Balde;Abdoulaye;balde.abdoulaye@courrier.uqam.ca
cd87654321;Amoussou;Saidath;amoussou.saidath@courrier.uqam.ca
;Hountondji;Mahouton;hountondji.mahouton@courrier.uqam.ca
"""

C = dict(bleu="\033[36m", vert="\033[32m", jaune="\033[33m", rouge="\033[31m",
         gris="\033[90m", gras="\033[1m", fin="\033[0m")
if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
    for k in C: C[k] = ""

def erreur(m): print(f"{C['rouge']}{m}{C['fin']}", file=sys.stderr)

def slug(t):
    """Transforme « Déo-Florès » en « deo-flores » : GitLab n'accepte
    ni accents ni espaces dans le chemin d'un projet."""
    t = unicodedata.normalize("NFD", t)
    t = "".join(c for c in t if unicodedata.category(c) != "Mn")
    t = t.lower().replace("'", "").replace("’", "")
    t = "".join(c if (c.isalnum() or c in "-_") else "-" for c in t)
    while "--" in t: t = t.replace("--", "-")
    return t.strip("-")

class GitLab:
    def __init__(self, base, jeton):
        self.base = base.rstrip("/") + "/api/v4"
        self.jeton = jeton
    def appel(self, methode, chemin, corps=None):
        url = f"{self.base}/{chemin}"
        donnees = json.dumps(corps).encode() if corps is not None else None
        req = urllib.request.Request(url, data=donnees, method=methode,
              headers={"PRIVATE-TOKEN": self.jeton, "Content-Type": "application/json"})
        try:
            with urllib.request.urlopen(req, timeout=40) as r:
                t = r.read().decode("utf-8")
                return json.loads(t) if t.strip() else {}
        except urllib.error.HTTPError as e:
            détail = ""
            try: détail = e.read().decode("utf-8")[:300]
            except Exception: pass
            if e.code == 404: return None
            raise RuntimeError(f"{e.code} sur {methode} /{chemin} — {détail}") from None
        except urllib.error.URLError as e:
            raise RuntimeError(f"Serveur injoignable : {e.reason}") from None

    def groupe(self, chemin):
        return self.appel("GET", f"groups/{urllib.parse.quote(chemin, safe='')}")
    def projets(self, gid):
        out, page = [], 1
        while True:
            lot = self.appel("GET", f"groups/{gid}/projects?per_page=100&page={page}")
            if not lot: break
            out += lot
            if len(lot) < 100: break
            page += 1
        return out
    def utilisateur(self, identifiant):
        """Cherche par nom d'utilisateur, puis par courriel."""
        for champ in ("username", "search"):
            r = self.appel("GET", f"users?{champ}={urllib.parse.quote(identifiant)}")
            if r:
                exact = [u for u in r if u.get("username", "").lower() == identifiant.lower()
                         or u.get("email", "").lower() == identifiant.lower()]
                return (exact or r)[0]
        return None
    def creerDepuisModele(self, gid, nom, chemin, modele_id):
        return self.appel("POST", "projects", {
            "name": nom, "path": chemin, "namespace_id": gid,
            "visibility": "private", "use_custom_template": True,
            "template_project_id": modele_id,
            "description": "Dépôt personnel MIG8110 — productions du trimestre"})
    def forker(self, modele_id, gid, nom, chemin):
        return self.appel("POST", f"projects/{modele_id}/fork", {
            "namespace_id": gid, "name": nom, "path": chemin, "visibility": "private"})
    def membres(self, pid):
        return self.appel("GET", f"projects/{pid}/members") or []
    def ajouterMembre(self, pid, uid, niveau=40):
        return self.appel("POST", f"projects/{pid}/members",
                          {"user_id": uid, "access_level": niveau})
    def couperLienFork(self, pid):
        try: self.appel("DELETE", f"projects/{pid}/fork")
        except RuntimeError: pass

def lireListe(chemin):
    with open(chemin, newline="", encoding="utf-8-sig") as f:
        début = f.read(2048); f.seek(0)
        délim = ";" if début.count(";") >= début.count(",") else ","
        lignes = [r for r in csv.DictReader(f, delimiter=délim)]
    out = []
    for r in lignes:
        d = { (k or "").strip().lower(): (v or "").strip() for k, v in r.items() }
        nom, prenom = d.get("nom", ""), d.get("prenom", d.get("prénom", ""))
        if not nom and not prenom: continue
        out.append({ "code": d.get("code_ms", d.get("code", "")),
                     "nom": nom, "prenom": prenom,
                     "courriel": d.get("courriel", d.get("email", "")) })
    return out

def main():
    p = argparse.ArgumentParser(description="Création des dépôts étudiants MIG8110.")
    p.add_argument("liste", nargs="?", help="fichier CSV : code_ms;nom;prenom;courriel")
    p.add_argument("--gitlab", default=os.environ.get("GITLAB", "https://gitlab.info.uqam.ca"))
    p.add_argument("--groupe", default=os.environ.get("GROUPE", "mig8110-a2026"))
    p.add_argument("--modele", default="mig8110-gabarit", help="chemin du projet gabarit")
    p.add_argument("--prefixe", default="mig8110", help="préfixe du nom des dépôts")
    p.add_argument("--role", default="mainteneur", choices=["mainteneur", "developpeur"])
    p.add_argument("--essai", action="store_true", help="n'écrit rien, montre ce qui serait fait")
    p.add_argument("--exemple", action="store_true", help="écrit un CSV d'exemple sur la sortie")
    a = p.parse_args()

    if a.exemple:
        sys.stdout.write(EXEMPLE); return 0
    if not a.liste:
        erreur("Indiquez le fichier CSV, ou --exemple pour en obtenir un modèle."); return 1

    jeton = os.environ.get("GITLAB_TOKEN", "")
    if not jeton:
        erreur("Définissez GITLAB_TOKEN : un jeton GitLab de portée « api ».")
        erreur("Profil → Préférences → Jetons d'accès → portée api.")
        return 1

    try: etudiants = lireListe(a.liste)
    except OSError as e: erreur(f"Liste illisible : {e}"); return 1
    if not etudiants: erreur("Aucun étudiant dans la liste."); return 1

    gl = GitLab(a.gitlab, jeton)
    niveau = 40 if a.role == "mainteneur" else 30

    print(f"{C['gras']}{C['bleu']}MIG8110 — création des dépôts étudiants{C['fin']}")
    print(f"{C['gris']}{a.gitlab} · groupe {a.groupe} · modèle {a.modele}{C['fin']}")
    if a.essai: print(f"{C['jaune']}Mode essai : rien ne sera créé.{C['fin']}")
    print()

    try:
        g = gl.groupe(a.groupe)
        if not g: erreur(f"Groupe « {a.groupe} » introuvable, ou jeton sans accès."); return 1
        existants = gl.projets(g["id"])
    except RuntimeError as e:
        erreur(str(e)); return 1

    parChemin = { pr["path"]: pr for pr in existants }
    modele = parChemin.get(a.modele)
    if not modele:
        erreur(f"Projet gabarit « {a.modele} » introuvable dans le groupe.")
        erreur("Chemins présents : " + ", ".join(sorted(parChemin)) or "aucun")
        return 1
    print(f"Gabarit trouvé : {modele['path_with_namespace']} (id {modele['id']})")
    print(f"{len(etudiants)} étudiant(s) dans la liste.\n")

    crees = lies = deja = echecs = 0
    for e in etudiants:
        chemin = f"{a.prefixe}-{slug(e['nom'])}-{slug(e['prenom'])}".strip("-")
        nom = f"{a.prefixe.upper()} — {e['prenom']} {e['nom']}".strip()
        etiq = f"{e['prenom']} {e['nom']}".strip()
        print(f"{C['gras']}{etiq}{C['fin']}  {C['gris']}{chemin}{C['fin']}")

        # ── le projet
        pr = parChemin.get(chemin)
        if pr:
            print(f"  {C['gris']}·{C['fin']} dépôt déjà présent"); deja += 1
        elif a.essai:
            print(f"  {C['jaune']}+{C['fin']} dépôt à créer depuis le gabarit")
        else:
            try:
                pr = gl.creerDepuisModele(g["id"], nom, chemin, modele["id"])
                voie = "modèle"
                if not pr:
                    pr = gl.forker(modele["id"], g["id"], nom, chemin); voie = "duplication"
                    if pr: gl.couperLienFork(pr["id"])
                if not pr: raise RuntimeError("création refusée")
                print(f"  {C['vert']}+{C['fin']} dépôt créé par {voie}"); crees += 1
                time.sleep(1.2)          # laisser GitLab finir l'importation
            except RuntimeError as err:
                print(f"  {C['rouge']}✗{C['fin']} création impossible : {err}")
                echecs += 1; print(); continue

        # ── le membre
        ident = e["code"] or e["courriel"]
        if not ident:
            print(f"  {C['jaune']}!{C['fin']} ni code MS ni courriel : à ajouter à la main")
            print(); continue
        try:
            u = gl.utilisateur(ident)
        except RuntimeError as err:
            print(f"  {C['rouge']}✗{C['fin']} recherche impossible : {err}"); echecs += 1; print(); continue
        if not u:
            print(f"  {C['jaune']}!{C['fin']} compte introuvable pour « {ident} » — "
                  f"l'étudiant ne s'est peut-être jamais connecté au GitLab")
            print(); continue
        if a.essai:
            print(f"  {C['jaune']}+{C['fin']} {u['username']} à ajouter comme {a.role}")
        else:
            if any(m.get("id") == u["id"] for m in gl.membres(pr["id"])):
                print(f"  {C['gris']}·{C['fin']} {u['username']} est déjà membre")
            else:
                try:
                    gl.ajouterMembre(pr["id"], u["id"], niveau)
                    print(f"  {C['vert']}+{C['fin']} {u['username']} ajouté comme {a.role}")
                    lies += 1
                except RuntimeError as err:
                    print(f"  {C['rouge']}✗{C['fin']} ajout impossible : {err}"); echecs += 1
        print()

    print(f"{C['gras']}Bilan{C['fin']}  {crees} dépôt(s) créé(s) · {lies} membre(s) ajouté(s) · "
          f"{deja} déjà présent(s) · {echecs} échec(s)")
    if a.essai:
        print(f"{C['jaune']}Aucune modification n'a été faite. Relancez sans --essai.{C['fin']}")
    else:
        print(f"{C['gris']}Vérifiez la liste dans {a.gitlab}/{a.groupe}{C['fin']}")
    return 1 if echecs else 0

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