|
#!/usr/bin/env python3 |
|
""" |
|
GitHub Stats - Statistiques de commits et lignes de code sur plusieurs années. |
|
|
|
Usage: |
|
python github_stats.py [--user USERNAME] [--years N] [--output FILE] |
|
|
|
Requires: |
|
- GitHub CLI (gh) authenticated |
|
- matplotlib: pip install matplotlib |
|
""" |
|
|
|
import subprocess |
|
import json |
|
import argparse |
|
from collections import defaultdict |
|
from datetime import datetime, timedelta |
|
from typing import Optional |
|
|
|
try: |
|
import matplotlib.pyplot as plt |
|
import matplotlib.dates as mdates |
|
except ImportError: |
|
print("matplotlib non installé. Installez-le avec: pip install matplotlib") |
|
exit(1) |
|
|
|
|
|
def run_gh(args: list[str]) -> tuple[str, str]: |
|
"""Exécute une commande gh et retourne stdout, stderr.""" |
|
result = subprocess.run(['gh'] + args, capture_output=True, text=True) |
|
return result.stdout, result.stderr |
|
|
|
|
|
def get_current_user() -> str: |
|
"""Récupère le nom d'utilisateur GitHub connecté.""" |
|
output, _ = run_gh(['api', 'user', '--jq', '.login']) |
|
return output.strip() |
|
|
|
|
|
def get_commits_by_month(username: str, since_date: str) -> dict[str, int]: |
|
""" |
|
Récupère le nombre de commits par mois via l'API GraphQL. |
|
L'API limite à 1 an par requête, donc on fait plusieurs requêtes. |
|
""" |
|
commits_by_month = defaultdict(int) |
|
|
|
start = datetime.strptime(since_date, "%Y-%m-%d") |
|
end = datetime.now() |
|
|
|
current = start |
|
while current < end: |
|
year_end = min(current + timedelta(days=365), end) |
|
|
|
query = f''' |
|
{{ |
|
user(login: "{username}") {{ |
|
contributionsCollection(from: "{current.strftime("%Y-%m-%d")}T00:00:00Z", to: "{year_end.strftime("%Y-%m-%d")}T00:00:00Z") {{ |
|
contributionCalendar {{ |
|
weeks {{ |
|
contributionDays {{ |
|
contributionCount |
|
date |
|
}} |
|
}} |
|
}} |
|
}} |
|
}} |
|
}} |
|
''' |
|
|
|
output, err = run_gh(['api', 'graphql', '-f', f'query={query}']) |
|
|
|
if output.strip(): |
|
try: |
|
data = json.loads(output) |
|
weeks = data['data']['user']['contributionsCollection']['contributionCalendar']['weeks'] |
|
for week in weeks: |
|
for day in week['contributionDays']: |
|
month = day['date'][:7] |
|
commits_by_month[month] += day['contributionCount'] |
|
except (json.JSONDecodeError, KeyError) as e: |
|
print(f"Erreur parsing GraphQL: {e}") |
|
|
|
current = year_end |
|
|
|
return dict(commits_by_month) |
|
|
|
|
|
def get_lines_by_month(username: str, since_date: str) -> dict[str, dict[str, int]]: |
|
""" |
|
Récupère les lignes ajoutées/supprimées par mois via l'API Search. |
|
""" |
|
lines_by_month = defaultdict(lambda: {'additions': 0, 'deletions': 0}) |
|
|
|
# Calculer le nombre de pages nécessaires |
|
count_output, _ = run_gh([ |
|
'api', |
|
f'search/commits?q=author:{username}+author-date:>{since_date}&per_page=1', |
|
'--jq', '.total_count' |
|
]) |
|
|
|
try: |
|
total_commits = int(count_output.strip()) |
|
except ValueError: |
|
total_commits = 0 |
|
|
|
pages = min((total_commits // 100) + 1, 10) # Max 10 pages (1000 commits) par sécurité |
|
print(f"Récupération des stats de lignes ({total_commits} commits, {pages} pages)...") |
|
|
|
for page in range(1, pages + 1): |
|
print(f" Page {page}/{pages}...", end='\r') |
|
|
|
output, _ = run_gh([ |
|
'api', |
|
f'search/commits?q=author:{username}+author-date:>{since_date}&per_page=100&page={page}&sort=author-date', |
|
'--jq', '.items[] | {sha, repo: .repository.full_name, date: .commit.author.date}' |
|
]) |
|
|
|
if not output.strip(): |
|
break |
|
|
|
for line in output.strip().split('\n'): |
|
if not line: |
|
continue |
|
try: |
|
item = json.loads(line) |
|
sha = item['sha'] |
|
repo = item['repo'] |
|
date = item['date'] |
|
month = date[:7] |
|
|
|
# Récupérer les stats du commit |
|
stats_output, _ = run_gh([ |
|
'api', |
|
f'repos/{repo}/commits/{sha}', |
|
'--jq', '{additions: .stats.additions, deletions: .stats.deletions}' |
|
]) |
|
|
|
if stats_output.strip(): |
|
stats = json.loads(stats_output) |
|
lines_by_month[month]['additions'] += stats.get('additions', 0) or 0 |
|
lines_by_month[month]['deletions'] += stats.get('deletions', 0) or 0 |
|
except (json.JSONDecodeError, KeyError): |
|
pass |
|
|
|
print() |
|
return dict(lines_by_month) |
|
|
|
|
|
def generate_chart( |
|
commits: dict[str, int], |
|
lines: dict[str, dict[str, int]], |
|
username: str, |
|
output_file: str |
|
) -> None: |
|
"""Génère les graphiques et les sauvegarde.""" |
|
|
|
# Préparer les données |
|
months = sorted(set(list(commits.keys()) + list(lines.keys()))) |
|
if not months: |
|
print("Aucune donnée à afficher") |
|
return |
|
|
|
dates = [datetime.strptime(m, "%Y-%m") for m in months] |
|
commit_counts = [commits.get(m, 0) for m in months] |
|
additions = [lines.get(m, {}).get('additions', 0) for m in months] |
|
deletions = [lines.get(m, {}).get('deletions', 0) for m in months] |
|
|
|
# Créer la figure |
|
fig, (ax1, ax2) = plt.subplots(2, 1, figsize=(14, 10), sharex=True) |
|
|
|
# Graphique 1: Commits par mois |
|
ax1.bar(dates, commit_counts, width=25, color='#2196F3', alpha=0.8, edgecolor='#1565C0') |
|
ax1.set_ylabel('Nombre de commits', fontsize=12) |
|
ax1.set_title(f'Statistiques GitHub de {username}', fontsize=14, fontweight='bold') |
|
ax1.grid(axis='y', alpha=0.3) |
|
if commit_counts: |
|
ax1.set_ylim(0, max(commit_counts) * 1.1) |
|
|
|
# Annotations pour les mois importants |
|
threshold = max(commit_counts) * 0.6 if commit_counts else 0 |
|
for date, count in zip(dates, commit_counts): |
|
if count > threshold: |
|
ax1.annotate(str(count), (date, count), textcoords="offset points", |
|
xytext=(0, 5), ha='center', fontsize=8) |
|
|
|
# Graphique 2: Lignes ajoutées/supprimées |
|
ax2.bar(dates, additions, width=20, label='Lignes ajoutées', |
|
color='#4CAF50', alpha=0.8, edgecolor='#2E7D32') |
|
ax2.bar(dates, [-d for d in deletions], width=20, label='Lignes supprimées', |
|
color='#F44336', alpha=0.8, edgecolor='#C62828') |
|
|
|
ax2.set_ylabel('Lignes de code', fontsize=12) |
|
ax2.set_xlabel('Mois', fontsize=12) |
|
ax2.legend(loc='upper left') |
|
ax2.grid(axis='y', alpha=0.3) |
|
ax2.axhline(y=0, color='black', linewidth=0.5) |
|
|
|
# Format axe X |
|
ax2.xaxis.set_major_locator(mdates.MonthLocator(interval=3)) |
|
ax2.xaxis.set_major_formatter(mdates.DateFormatter('%b %Y')) |
|
plt.xticks(rotation=45, ha='right') |
|
|
|
# Totaux |
|
total_commits = sum(commit_counts) |
|
total_additions = sum(additions) |
|
total_deletions = sum(deletions) |
|
|
|
textstr = f'Total: {total_commits:,} commits | +{total_additions:,} / -{total_deletions:,} lignes' |
|
fig.text(0.5, 0.02, textstr, ha='center', fontsize=11, |
|
bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5)) |
|
|
|
plt.tight_layout() |
|
plt.subplots_adjust(bottom=0.1) |
|
|
|
plt.savefig(output_file, dpi=150, bbox_inches='tight') |
|
print(f"Graphique sauvegardé: {output_file}") |
|
|
|
|
|
def print_summary(commits: dict[str, int], lines: dict[str, dict[str, int]]) -> None: |
|
"""Affiche un résumé des statistiques.""" |
|
total_commits = sum(commits.values()) |
|
total_additions = sum(l.get('additions', 0) for l in lines.values()) |
|
total_deletions = sum(l.get('deletions', 0) for l in lines.values()) |
|
|
|
print("\n" + "=" * 50) |
|
print("RÉSUMÉ") |
|
print("=" * 50) |
|
print(f"Total commits: {total_commits:,}") |
|
print(f"Lignes ajoutées: +{total_additions:,}") |
|
print(f"Lignes supprimées: -{total_deletions:,}") |
|
print(f"Net: {total_additions - total_deletions:+,} lignes") |
|
|
|
if commits: |
|
print("\nTop 5 mois (commits):") |
|
top_months = sorted(commits.items(), key=lambda x: x[1], reverse=True)[:5] |
|
for month, count in top_months: |
|
print(f" {month}: {count} commits") |
|
|
|
print("=" * 50) |
|
|
|
|
|
def main(): |
|
parser = argparse.ArgumentParser( |
|
description='Génère des statistiques GitHub (commits et lignes de code)' |
|
) |
|
parser.add_argument( |
|
'--user', '-u', |
|
help='Nom d\'utilisateur GitHub (par défaut: utilisateur connecté)' |
|
) |
|
parser.add_argument( |
|
'--years', '-y', |
|
type=int, |
|
default=3, |
|
help='Nombre d\'années à analyser (par défaut: 3)' |
|
) |
|
parser.add_argument( |
|
'--output', '-o', |
|
default='github_stats.png', |
|
help='Fichier de sortie pour le graphique (par défaut: github_stats.png)' |
|
) |
|
parser.add_argument( |
|
'--no-lines', |
|
action='store_true', |
|
help='Ne pas récupérer les stats de lignes (plus rapide)' |
|
) |
|
|
|
args = parser.parse_args() |
|
|
|
# Vérifier l'authentification |
|
status_output, _ = run_gh(['auth', 'status']) |
|
if 'Logged in' not in status_output: |
|
print("Erreur: Vous devez être connecté à GitHub CLI (gh auth login)") |
|
exit(1) |
|
|
|
# Récupérer l'utilisateur |
|
username = args.user or get_current_user() |
|
print(f"Utilisateur: {username}") |
|
|
|
# Calculer la date de début |
|
since_date = (datetime.now() - timedelta(days=365 * args.years)).strftime("%Y-%m-%d") |
|
print(f"Période: {since_date} → aujourd'hui ({args.years} ans)") |
|
|
|
# Récupérer les commits |
|
print("\nRécupération des commits...") |
|
commits = get_commits_by_month(username, since_date) |
|
print(f" {sum(commits.values())} commits trouvés") |
|
|
|
# Récupérer les lignes |
|
lines = {} |
|
if not args.no_lines: |
|
print("\nRécupération des lignes de code...") |
|
lines = get_lines_by_month(username, since_date) |
|
|
|
# Afficher le résumé |
|
print_summary(commits, lines) |
|
|
|
# Générer le graphique |
|
print("\nGénération du graphique...") |
|
generate_chart(commits, lines, username, args.output) |
|
|
|
|
|
if __name__ == '__main__': |
|
main() |