|
10 | 10 | "pour **un ou plusieurs dépôts**, les regroupe par auteur et par semaine sur l'année écoulée,\n", |
11 | 11 | "puis affiche le résultat sous forme de graphique.\n", |
12 | 12 | "\n", |
| 13 | + "Les données récupérées sont **mises en cache** localement (un fichier CSV par dépôt).\n", |
| 14 | + "Lors des exécutions suivantes, seules les PR plus récentes que la dernière date mise en cache\n", |
| 15 | + "sont requêtées, ce qui réduit considérablement le nombre d'appels à l'API.\n", |
| 16 | + "\n", |
13 | 17 | "**Dépendances :** `requests`, `pandas`, `matplotlib`.\n", |
14 | 18 | "\n", |
15 | 19 | "**Token GitHub :** l'API GitHub limite les appels non authentifiés à 60 requêtes par heure.\n", |
|
31 | 35 | "source": [ |
32 | 36 | "import os\n", |
33 | 37 | "import datetime\n", |
| 38 | + "import pathlib\n", |
34 | 39 | "import requests\n", |
35 | 40 | "import pandas as pd\n", |
36 | 41 | "import matplotlib.pyplot as plt\n", |
|
43 | 48 | "source": [ |
44 | 49 | "## Paramètres\n", |
45 | 50 | "\n", |
46 | | - "Modifiez `REPOS` pour lister les dépôts à analyser sous la forme\n", |
47 | | - "`[(owner, repo), ...]`. Vous pouvez ajouter autant de dépôts que vous le souhaitez." |
| 51 | + "* `REPOS` — liste de dépôts à analyser sous la forme `[(owner, repo), ...]`.\n", |
| 52 | + "* `CACHE_DIR` — répertoire où sont stockés les fichiers CSV de cache (un par dépôt).\n", |
| 53 | + " Utilisez `\".\"` pour enregistrer les fichiers à côté du notebook." |
48 | 54 | ] |
49 | 55 | }, |
50 | 56 | { |
|
58 | 64 | " # (\"sdpython\", \"onnx-extended\"), # ajoutez d'autres dépôts ici\n", |
59 | 65 | "]\n", |
60 | 66 | "\n", |
| 67 | + "# Répertoire de cache (créé automatiquement si nécessaire)\n", |
| 68 | + "CACHE_DIR = pathlib.Path(\".\")\n", |
| 69 | + "\n", |
61 | 70 | "# Jeton d'authentification GitHub (optionnel mais recommandé)\n", |
62 | 71 | "GITHUB_TOKEN = os.environ.get(\"GITHUB_TOKEN\", \"\")" |
63 | 72 | ] |
|
66 | 75 | "cell_type": "markdown", |
67 | 76 | "metadata": {}, |
68 | 77 | "source": [ |
69 | | - "## Récupération des PR fusionnées via l'API GitHub\n", |
| 78 | + "## Récupération des PR fusionnées via l'API GitHub (avec cache)\n", |
70 | 79 | "\n", |
71 | | - "L'API REST GitHub expose le point d'accès `/repos/{owner}/{repo}/pulls`\n", |
72 | | - "avec `state=closed`. On filtre ensuite les PR dont le champ `merged_at` est renseigné\n", |
73 | | - "et dont la date de fusion est dans les 12 derniers mois.\n", |
| 80 | + "Pour chaque dépôt :\n", |
74 | 81 | "\n", |
75 | | - "La pagination est gérée via le paramètre `page`.\n", |
76 | | - "La boucle principale itère sur chaque dépôt listé dans `REPOS`." |
| 82 | + "1. On charge le fichier CSV de cache s'il existe (`prs_cache_{owner}_{repo}.csv`).\n", |
| 83 | + "2. On détermine la date la plus récente déjà présente dans le cache.\n", |
| 84 | + "3. On ne récupère auprès de l'API que les PR fusionnées **après** cette date\n", |
| 85 | + " (ou toutes si le cache est vide).\n", |
| 86 | + "4. On fusionne les nouvelles PR avec le cache, on supprime les doublons\n", |
| 87 | + " et on élague les entrées datant de plus de 365 jours.\n", |
| 88 | + "5. On sauvegarde le cache mis à jour sur disque." |
77 | 89 | ] |
78 | 90 | }, |
79 | 91 | { |
|
82 | 94 | "metadata": {}, |
83 | 95 | "outputs": [], |
84 | 96 | "source": [ |
85 | | - "def fetch_merged_prs(owner: str, repo: str, token: str = \"\") -> list[dict]:\n", |
86 | | - " \"\"\"Récupère toutes les PR fusionnées au cours de l'année écoulée pour un dépôt.\n", |
| 97 | + "CACHE_DATE_FMT = \"%Y-%m-%dT%H:%M:%S%z\"\n", |
| 98 | + "\n", |
| 99 | + "\n", |
| 100 | + "def _cache_path(cache_dir: pathlib.Path, owner: str, repo: str) -> pathlib.Path:\n", |
| 101 | + " \"\"\"Retourne le chemin du fichier CSV de cache pour un dépôt.\"\"\"\n", |
| 102 | + " safe = f\"{owner}_{repo}\".replace(\"/\", \"_\")\n", |
| 103 | + " return cache_dir / f\"prs_cache_{safe}.csv\"\n", |
| 104 | + "\n", |
| 105 | + "\n", |
| 106 | + "def load_cache(\n", |
| 107 | + " cache_dir: pathlib.Path, owner: str, repo: str\n", |
| 108 | + ") -> pd.DataFrame:\n", |
| 109 | + " \"\"\"Charge le cache CSV pour un dépôt (retourne un DataFrame vide si absent).\"\"\"\n", |
| 110 | + " path = _cache_path(cache_dir, owner, repo)\n", |
| 111 | + " if not path.exists():\n", |
| 112 | + " return pd.DataFrame(columns=[\"author\", \"merged_at\", \"repo\"])\n", |
| 113 | + " df = pd.read_csv(path, parse_dates=[\"merged_at\"])\n", |
| 114 | + " # S'assurer que la colonne est bien tz-aware (UTC)\n", |
| 115 | + " if df[\"merged_at\"].dt.tz is None:\n", |
| 116 | + " df[\"merged_at\"] = df[\"merged_at\"].dt.tz_localize(\"UTC\")\n", |
| 117 | + " else:\n", |
| 118 | + " df[\"merged_at\"] = df[\"merged_at\"].dt.tz_convert(\"UTC\")\n", |
| 119 | + " return df\n", |
| 120 | + "\n", |
| 121 | + "\n", |
| 122 | + "def save_cache(\n", |
| 123 | + " cache_dir: pathlib.Path, owner: str, repo: str, df: pd.DataFrame\n", |
| 124 | + ") -> None:\n", |
| 125 | + " \"\"\"Sauvegarde le DataFrame dans le fichier CSV de cache.\"\"\"\n", |
| 126 | + " cache_dir.mkdir(parents=True, exist_ok=True)\n", |
| 127 | + " path = _cache_path(cache_dir, owner, repo)\n", |
| 128 | + " df.to_csv(path, index=False, date_format=CACHE_DATE_FMT)\n", |
| 129 | + "\n", |
| 130 | + "\n", |
| 131 | + "def fetch_merged_prs(\n", |
| 132 | + " owner: str,\n", |
| 133 | + " repo: str,\n", |
| 134 | + " token: str = \"\",\n", |
| 135 | + " fetch_since: datetime.datetime | None = None,\n", |
| 136 | + ") -> list[dict]:\n", |
| 137 | + " \"\"\"Récupère les PR fusionnées pour un dépôt à partir d'une date donnée.\n", |
87 | 138 | "\n", |
88 | 139 | " :param owner: propriétaire du dépôt GitHub\n", |
89 | 140 | " :param repo: nom du dépôt GitHub\n", |
90 | 141 | " :param token: jeton d'authentification GitHub (optionnel)\n", |
91 | | - " :return: liste de dictionnaires avec les champs ``author``, ``merged_at``, ``repo``\n", |
| 142 | + " :param fetch_since: si fourni, on s'arrête dès que ``merged_at`` est antérieur\n", |
| 143 | + " à cette date (les PR plus anciennes sont déjà en cache).\n", |
| 144 | + " Si ``None``, on remonte jusqu'à 365 jours en arrière.\n", |
| 145 | + " :return: liste de dictionnaires ``{author, merged_at, repo}``\n", |
92 | 146 | " \"\"\"\n", |
93 | 147 | " headers = {\"Accept\": \"application/vnd.github+json\"}\n", |
94 | 148 | " if token:\n", |
95 | 149 | " headers[\"Authorization\"] = f\"Bearer {token}\"\n", |
96 | 150 | "\n", |
97 | | - " since = datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=365)\n", |
| 151 | + " cutoff = fetch_since if fetch_since is not None else (\n", |
| 152 | + " datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=365)\n", |
| 153 | + " )\n", |
98 | 154 | "\n", |
99 | 155 | " results = []\n", |
100 | 156 | " page = 1\n", |
|
135 | 191 | " if not merged_at:\n", |
136 | 192 | " continue\n", |
137 | 193 | " merged_dt = datetime.datetime.fromisoformat(merged_at.replace(\"Z\", \"+00:00\"))\n", |
138 | | - " if merged_dt < since:\n", |
| 194 | + " if merged_dt <= cutoff:\n", |
139 | 195 | " stop = True\n", |
140 | 196 | " break\n", |
141 | 197 | " author = (pr.get(\"user\") or {}).get(\"login\", \"unknown\")\n", |
|
149 | 205 | " return results\n", |
150 | 206 | "\n", |
151 | 207 | "\n", |
152 | | - "merged_prs = []\n", |
| 208 | + "def load_prs_with_cache(\n", |
| 209 | + " owner: str, repo: str, token: str = \"\", cache_dir: pathlib.Path = pathlib.Path(\".\")\n", |
| 210 | + ") -> pd.DataFrame:\n", |
| 211 | + " \"\"\"Charge les PR fusionnées pour un dépôt en utilisant le cache local.\n", |
| 212 | + "\n", |
| 213 | + " * Si le cache existe, seules les PR plus récentes que la dernière entrée\n", |
| 214 | + " mise en cache sont récupérées via l'API.\n", |
| 215 | + " * Le cache est élagué pour ne conserver que les 365 derniers jours.\n", |
| 216 | + " * Le cache mis à jour est sauvegardé sur disque.\n", |
| 217 | + "\n", |
| 218 | + " :return: DataFrame avec les colonnes ``author``, ``merged_at``, ``repo``\n", |
| 219 | + " \"\"\"\n", |
| 220 | + " now = datetime.datetime.now(datetime.timezone.utc)\n", |
| 221 | + " cutoff_365 = now - datetime.timedelta(days=365)\n", |
| 222 | + "\n", |
| 223 | + " cached_df = load_cache(cache_dir, owner, repo)\n", |
| 224 | + "\n", |
| 225 | + " if cached_df.empty:\n", |
| 226 | + " fetch_since = None # récupérer toute l'année\n", |
| 227 | + " print(f\" {owner}/{repo} : cache vide, récupération complète…\")\n", |
| 228 | + " else:\n", |
| 229 | + " # Relancer depuis le début de la journée du dernier enregistrement\n", |
| 230 | + " # pour ne pas manquer de PR fusionnées en cours de journée.\n", |
| 231 | + " latest = cached_df[\"merged_at\"].max()\n", |
| 232 | + " fetch_since = latest.replace(hour=0, minute=0, second=0, microsecond=0)\n", |
| 233 | + " print(\n", |
| 234 | + " f\" {owner}/{repo} : cache chargé ({len(cached_df)} entrées), \"\n", |
| 235 | + " f\"récupération des PR depuis {fetch_since.date()}…\"\n", |
| 236 | + " )\n", |
| 237 | + "\n", |
| 238 | + " new_prs = fetch_merged_prs(owner, repo, token, fetch_since=fetch_since)\n", |
| 239 | + " print(f\" → {len(new_prs)} nouvelle(s) PR(s) récupérée(s) via l'API.\")\n", |
| 240 | + "\n", |
| 241 | + " if new_prs:\n", |
| 242 | + " new_df = pd.DataFrame(new_prs)\n", |
| 243 | + " combined = pd.concat([cached_df, new_df], ignore_index=True)\n", |
| 244 | + " else:\n", |
| 245 | + " combined = cached_df.copy()\n", |
| 246 | + "\n", |
| 247 | + " # Dédoublonnage et élagage\n", |
| 248 | + " combined.drop_duplicates(subset=[\"repo\", \"author\", \"merged_at\"], inplace=True)\n", |
| 249 | + " combined = combined[combined[\"merged_at\"] >= cutoff_365].copy()\n", |
| 250 | + " combined.sort_values(\"merged_at\", inplace=True)\n", |
| 251 | + " combined.reset_index(drop=True, inplace=True)\n", |
| 252 | + "\n", |
| 253 | + " save_cache(cache_dir, owner, repo, combined)\n", |
| 254 | + " print(f\" → cache mis à jour ({len(combined)} entrées au total).\")\n", |
| 255 | + "\n", |
| 256 | + " return combined\n", |
| 257 | + "\n", |
| 258 | + "\n", |
| 259 | + "merged_prs_frames = []\n", |
153 | 260 | "for owner, repo in REPOS:\n", |
154 | | - " prs = fetch_merged_prs(owner, repo, GITHUB_TOKEN)\n", |
155 | | - " print(f\" {owner}/{repo} : {len(prs)} PR(s) fusionnée(s)\")\n", |
156 | | - " merged_prs.extend(prs)\n", |
| 261 | + " repo_df = load_prs_with_cache(owner, repo, GITHUB_TOKEN, CACHE_DIR)\n", |
| 262 | + " merged_prs_frames.append(repo_df)\n", |
157 | 263 | "\n", |
158 | | - "print(f\"Total : {len(merged_prs)} PR(s) fusionnée(s) sur l'ensemble des dépôts.\")" |
| 264 | + "merged_prs = pd.concat(merged_prs_frames, ignore_index=True) if merged_prs_frames else pd.DataFrame()\n", |
| 265 | + "print(f\"\\nTotal : {len(merged_prs)} PR(s) fusionnée(s) sur l'ensemble des dépôts.\")" |
159 | 266 | ] |
160 | 267 | }, |
161 | 268 | { |
|
171 | 278 | "metadata": {}, |
172 | 279 | "outputs": [], |
173 | 280 | "source": [ |
174 | | - "df = pd.DataFrame(merged_prs)\n", |
| 281 | + "df = merged_prs.copy() if not merged_prs.empty else pd.DataFrame(columns=[\"author\", \"merged_at\", \"repo\"])\n", |
175 | 282 | "\n", |
176 | 283 | "if df.empty:\n", |
177 | 284 | " print(\"Aucune donnée à afficher.\")\n", |
|
0 commit comments