#!/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())