#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MIG8110 — collecte des remises déposées sur GitLab
───────────────────────────────────────────────────────────────────────────
Clone ou met à jour tous les dépôts du groupe du cours, puis dresse le
tableau des remises avec la date enregistrée par le serveur GitLab.
python3 collecte_remises.py toutes les remises
python3 collecte_remises.py --remise 5 une seule
python3 collecte_remises.py --remise 5 --echeance 2026-02-11
python3 collecte_remises.py --csv remises.csv export pour le compilateur
python3 collecte_remises.py --sans-clone tableau seulement
Prérequis : python3 et git. Rien d'autre.
Jeton d'accès GitLab avec la portée read_api, dans la variable
d'environnement GITLAB_TOKEN.
"""
import argparse, csv, json, os, subprocess, sys, urllib.error, urllib.parse, urllib.request
from datetime import datetime, timezone
PRODUCTIONS = [
("01", "Fiche d'intention", 2, "C1"),
("02", "Carte de positionnement", 3, "C1"),
("03", "Fiche de cycle de vie des données", 4, "C1"),
("04", "Dossier de processus", 5, "C1"),
("05", "Charte de projet", 6, "C1"),
("06", "Résumé des sujets présentés", 9, "C3"),
("07", "Dossier de veille et bibliographie", 9, "C1"),
("08", "Registre des risques", 11, "C1"),
("09", "Plan de travail complet", 12, "C1"),
("10", "Dossier de comparaison technologique", 12, "C4"),
("11", "Document de proposition de projet", 13, "C5"),
]
C = dict(bleu="\033[36m", vert="\033[32m", jaune="\033[33m", rouge="\033[31m",
gris="\033[90m", gras="\033[1m", fin="\033[0m")
def sansCouleur():
for k in C: C[k] = ""
if not sys.stdout.isatty() or os.environ.get("NO_COLOR"):
sansCouleur()
def erreur(m):
print(f"{C['rouge']}{m}{C['fin']}", file=sys.stderr)
class GitLab:
def __init__(self, base, jeton):
self.base = base.rstrip("/") + "/api/v4"
self.jeton = jeton
def get(self, chemin):
req = urllib.request.Request(f"{self.base}/{chemin}",
headers={"PRIVATE-TOKEN": self.jeton})
try:
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode("utf-8"))
except urllib.error.HTTPError as e:
if e.code == 404: return None
raise RuntimeError(f"GitLab a répondu {e.code} pour /{chemin}") from None
except urllib.error.URLError as e:
raise RuntimeError(f"Serveur injoignable : {e.reason}") from None
def projets(self, groupe):
q = urllib.parse.quote(groupe, safe="")
out, page = [], 1
while True:
lot = self.get(f"groups/{q}/projects?per_page=100&page={page}"
f"&include_subgroups=true&archived=false")
if not lot: break
out.extend(lot)
if len(lot) < 100: break
page += 1
return out
def etiquette(self, pid, nom):
q = urllib.parse.quote(nom, safe="")
return self.get(f"projects/{pid}/repository/tags/{q}")
def dateServeur(tag):
"""Date retenue : celle enregistrée par GitLab, et non la date de
validation, qui est déclarative et modifiable par l'étudiant."""
if not tag: return None, None
c = tag.get("commit") or {}
# created_at du commit tel que le serveur l'a reçu
brute = c.get("created_at") or c.get("committed_date") or tag.get("created_at")
if not brute: return None, None
try:
d = datetime.fromisoformat(brute.replace("Z", "+00:00"))
if d.tzinfo is None: d = d.replace(tzinfo=timezone.utc)
return d, brute
except ValueError:
return None, brute
def majDepot(url, dossier, cloner=True):
os.makedirs(os.path.dirname(dossier), exist_ok=True)
if os.path.isdir(os.path.join(dossier, ".git")):
r = subprocess.run(["git", "-C", dossier, "fetch", "--all", "--tags", "--quiet"],
capture_output=True, text=True)
return ("maj", "") if r.returncode == 0 else ("echec", r.stderr.strip()[:90])
if not cloner: return ("absent", "")
r = subprocess.run(["git", "clone", "--quiet", url, dossier],
capture_output=True, text=True)
return ("clone", "") if r.returncode == 0 else ("echec", r.stderr.strip()[:90])
def main():
p = argparse.ArgumentParser(description="Collecte des remises MIG8110 sur GitLab.")
p.add_argument("--gitlab", default=os.environ.get("GITLAB", "https://gitlab.info.uqam.ca"))
p.add_argument("--groupe", default=os.environ.get("GROUPE", "mig8110-h2026"))
p.add_argument("--dest", default=os.environ.get("DEST",
os.path.join(os.path.expanduser("~"), "MIG8110", "depots")))
p.add_argument("--remise", help="numéro de la remise, par exemple 5 ou 05")
p.add_argument("--echeance", help="date limite, AAAA-MM-JJ, pour signaler les retards")
p.add_argument("--csv", help="fichier CSV où écrire le tableau")
p.add_argument("--sans-clone", action="store_true", help="ne pas cloner ni mettre à jour")
a = p.parse_args()
jeton = os.environ.get("GITLAB_TOKEN", "")
if not jeton:
erreur("Définissez GITLAB_TOKEN : un jeton d'accès GitLab de portée read_api.")
erreur("Dans GitLab : photo de profil → Préférences → Jetons d'accès.")
return 1
if subprocess.run(["git", "--version"], capture_output=True).returncode != 0:
erreur("git est introuvable."); return 1
gl = GitLab(a.gitlab, jeton)
print(f"{C['gras']}{C['bleu']}MIG8110 — collecte des remises{C['fin']}")
print(f"{C['gris']}Groupe {a.groupe} sur {a.gitlab}{C['fin']}\n")
try:
projets = gl.projets(a.groupe)
except RuntimeError as e:
erreur(str(e)); return 1
if projets is None or not projets:
erreur(f"Aucun projet dans le groupe « {a.groupe} », ou jeton refusé.")
return 1
projets.sort(key=lambda x: (x.get("name") or "").lower())
print(f"{len(projets)} dépôt(s) trouvé(s).\n")
if not a.sans_clone:
print(f"{C['gras']}Copies locales{C['fin']}")
for pr in projets:
etat, msg = majDepot(pr["http_url_to_repo"],
os.path.join(a.dest, pr["path"]))
sym = {"clone": (C["bleu"], "+"), "maj": (C["vert"], "✓"),
"absent": (C["gris"], "·"), "echec": (C["rouge"], "✗")}[etat]
print(f" {sym[0]}{sym[1]}{C['fin']} {pr['path']}" + (f" {C['rouge']}{msg}{C['fin']}" if msg else ""))
print()
ech = None
if a.echeance:
try:
ech = datetime.fromisoformat(a.echeance + "T23:59:59").replace(tzinfo=timezone.utc)
except ValueError:
erreur("Échéance illisible : utilisez AAAA-MM-JJ."); return 1
cible = a.remise.zfill(2) if a.remise else None
lignes = []
for num, titre, seance, comp in PRODUCTIONS:
if cible and num != cible: continue
print(f"{C['gras']}{C['bleu']}Remise {num} — {titre}{C['fin']} "
f"{C['gris']}(séance {seance} · {comp}){C['fin']}")
faits = 0
for pr in projets:
nom = pr.get("name") or pr["path"]
try:
tag = gl.etiquette(pr["id"], f"remise-{num}")
except RuntimeError as e:
print(f" {C['rouge']}✗{C['fin']} {nom:<30} {e}"); continue
d, brute = dateServeur(tag)
if not tag:
print(f" {C['gris']}○{C['fin']} {nom:<30} {C['gris']}aucune remise{C['fin']}")
lignes.append([num, titre, seance, comp, nom, pr["path"], "", "non remis"])
continue
faits += 1
court = d.strftime("%Y-%m-%d %H:%M") if d else (brute or "date inconnue")
if ech and d:
if d <= ech:
print(f" {C['vert']}●{C['fin']} {nom:<30} {court} {C['vert']}dans les délais{C['fin']}")
etat = "dans les délais"
else:
jours = (d - ech).days
print(f" {C['rouge']}●{C['fin']} {nom:<30} {court} "
f"{C['rouge']}en retard de {jours} j{C['fin']}")
etat = f"en retard de {jours} j"
else:
print(f" {C['jaune']}●{C['fin']} {nom:<30} {court}")
etat = "remis"
lignes.append([num, titre, seance, comp, nom, pr["path"], court, etat])
print(f" {C['gris']}{faits}/{len(projets)} remise(s){C['fin']}\n")
if a.csv:
with open(a.csv, "w", newline="", encoding="utf-8-sig") as f:
w = csv.writer(f, delimiter=";")
w.writerow(["numero", "production", "seance", "composante",
"etudiant", "depot", "date_serveur", "etat"])
w.writerows(lignes)
print(f"{C['vert']}Tableau écrit dans {a.csv}{C['fin']}")
if not a.sans_clone:
print(f"{C['gris']}Copies locales dans {a.dest}{C['fin']}")
print(f"{C['gris']}La date affichée est celle enregistrée par GitLab, "
f"non celle des validations locales.{C['fin']}")
return 0
if __name__ == "__main__":
sys.exit(main())