commit 066539c89c1265011163245effd6c66dfb3bfe01 Author: Aleksander Cynarski Date: Wed Mar 4 18:27:01 2026 +0000 feat: initial project structure - GitLabClient: REST Events API z paginacją, pobieranie członków grupy - Aggregator: zliczanie commitów, MR, komentarzy per użytkownik - Exporter: generowanie pliku Excel (openpyxl) ze stylami - main.py: CLI (click) + .env support - README, .env.example, requirements.txt, .gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..70c7fde --- /dev/null +++ b/.env.example @@ -0,0 +1,6 @@ +GITLAB_URL=https://gitlab.com +GITLAB_TOKEN=your_personal_access_token +GITLAB_GROUP=your-group-path +DATE_FROM=2024-01-01 +DATE_TO=2024-12-31 +OUTPUT_FILE=raport.xlsx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a1241fa --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +.env +*.xlsx +__pycache__/ +*.pyc +*.pyo +.venv/ +venv/ +dist/ +build/ +*.egg-info/ +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..f59fbee --- /dev/null +++ b/README.md @@ -0,0 +1,57 @@ +# gitlab-contribution + +Narzędzie do eksportu statystyk aktywności użytkowników z GitLab do pliku Excel. + +## Co robi + +- Pobiera dane z GitLab GraphQL API (Events) dla zadanej grupy i zakresu dat +- Agreguje: contributions, commity, merge requesty, komentarze +- Generuje plik `.xlsx` z podsumowaniem per użytkownik + +## Wymagania + +``` +pip install -r requirements.txt +``` + +## Konfiguracja + +Skopiuj `.env.example` do `.env` i uzupełnij: + +```env +GITLAB_URL=https://gitlab.com +GITLAB_TOKEN=your_personal_access_token +GITLAB_GROUP=your-group-path +DATE_FROM=2024-01-01 +DATE_TO=2024-12-31 +OUTPUT_FILE=raport.xlsx +``` + +## Użycie + +```bash +python main.py +``` + +Lub z parametrami CLI: + +```bash +python main.py --group my-group --from 2024-01-01 --to 2024-12-31 --output raport.xlsx +``` + +## Struktura pliku Excel + +| Użytkownik | Username | Contributions | Commity | Merge Requesty | Komentarze | +|------------|----------|---------------|---------|----------------|------------| +| Jan Kowalski | jkowalski | 142 | 89 | 23 | 30 | + +## Jak działa + +1. `gitlab_client.py` — klient GraphQL, pobiera events z GitLab API +2. `aggregator.py` — agreguje dane per użytkownik +3. `exporter.py` — generuje plik Excel (openpyxl) +4. `main.py` — punkt wejścia, CLI + +## Limity GitLab API + +GitLab Events API zwraca max 100 eventów na stronę. Klient automatycznie obsługuje paginację. diff --git a/aggregator.py b/aggregator.py new file mode 100644 index 0000000..d2f39f6 --- /dev/null +++ b/aggregator.py @@ -0,0 +1,69 @@ +""" +Agregator — przetwarza eventy GitLab na statystyki per użytkownik. +""" + +from collections import defaultdict +from dataclasses import dataclass, field + + +@dataclass +class UserStats: + name: str = "" + username: str = "" + user_id: int = 0 + commits: int = 0 + merge_requests: int = 0 + comments: int = 0 + other: int = 0 + + @property + def total_contributions(self) -> int: + return self.commits + self.merge_requests + self.comments + self.other + + +class Aggregator: + def __init__(self): + self._stats: dict[int, UserStats] = defaultdict(UserStats) + + def process_events(self, events: list[dict]) -> None: + """ + Przetwarza listę eventów GitLab i agreguje statystyki. + + Typy akcji: + - pushed → commit + - merged → merge request + - commented → komentarz + - created/closed/reopened → inne + """ + for event in events: + author = event.get("author") or {} + uid = author.get("id") or event.get("author_id") + if not uid: + continue + + stats = self._stats[uid] + stats.user_id = uid + stats.name = author.get("name", stats.name) + stats.username = author.get("username", stats.username) + + action = event.get("action_name", "") + + if action == "pushed to" or action == "pushed new": + # Liczymy commity z push events + push_data = event.get("push_data") or {} + commit_count = push_data.get("commit_count", 1) + stats.commits += max(commit_count, 1) + elif action in ("accepted", "merged"): + stats.merge_requests += 1 + elif action == "commented on": + stats.comments += 1 + else: + stats.other += 1 + + def get_stats(self) -> list[UserStats]: + """Zwraca listę statystyk posortowaną malejąco po contributions.""" + return sorted( + self._stats.values(), + key=lambda s: s.total_contributions, + reverse=True, + ) diff --git a/exporter.py b/exporter.py new file mode 100644 index 0000000..dc92c04 --- /dev/null +++ b/exporter.py @@ -0,0 +1,103 @@ +""" +Eksporter — generuje plik Excel ze statystykami. +""" + +from pathlib import Path +from datetime import date + +from openpyxl import Workbook +from openpyxl.styles import Font, PatternFill, Alignment, Border, Side +from openpyxl.utils import get_column_letter + +from aggregator import UserStats + + +HEADER_FILL = PatternFill(start_color="1F4E79", end_color="1F4E79", fill_type="solid") +HEADER_FONT = Font(color="FFFFFF", bold=True, size=11) +ALT_ROW_FILL = PatternFill(start_color="D6E4F0", end_color="D6E4F0", fill_type="solid") + +THIN_BORDER = Border( + left=Side(style="thin"), + right=Side(style="thin"), + top=Side(style="thin"), + bottom=Side(style="thin"), +) + + +def export_to_excel( + stats: list[UserStats], + output_path: str, + group: str, + date_from: date, + date_to: date, +) -> None: + wb = Workbook() + ws = wb.active + ws.title = "Contributions" + + # --- Tytuł --- + ws.merge_cells("A1:G1") + title_cell = ws["A1"] + title_cell.value = ( + f"Raport aktywności GitLab — grupa: {group} " + f"({date_from} → {date_to})" + ) + title_cell.font = Font(bold=True, size=13) + title_cell.alignment = Alignment(horizontal="center") + ws.row_dimensions[1].height = 24 + + # --- Nagłówki --- + headers = [ + "Lp.", + "Imię i nazwisko", + "Username", + "Contributions (suma)", + "Commity", + "Merge Requesty", + "Komentarze", + ] + ws.append([]) # pusty wiersz 2 + ws.append(headers) # wiersz 3 + + header_row = 3 + for col_idx, header in enumerate(headers, start=1): + cell = ws.cell(row=header_row, column=col_idx) + cell.font = HEADER_FONT + cell.fill = HEADER_FILL + cell.alignment = Alignment(horizontal="center", wrap_text=True) + cell.border = THIN_BORDER + + # --- Dane --- + for i, user in enumerate(stats, start=1): + row_num = header_row + i + row_data = [ + i, + user.name, + user.username, + user.total_contributions, + user.commits, + user.merge_requests, + user.comments, + ] + ws.append(row_data) + + fill = ALT_ROW_FILL if i % 2 == 0 else None + for col_idx in range(1, len(headers) + 1): + cell = ws.cell(row=row_num, column=col_idx) + cell.border = THIN_BORDER + cell.alignment = Alignment(horizontal="center") + if fill: + cell.fill = fill + # Imię/username — wyrównaj do lewej + if col_idx == 2: + cell.alignment = Alignment(horizontal="left") + + # --- Szerokości kolumn --- + col_widths = [6, 30, 20, 22, 12, 18, 14] + for col_idx, width in enumerate(col_widths, start=1): + ws.column_dimensions[get_column_letter(col_idx)].width = width + + # --- Zapis --- + Path(output_path).parent.mkdir(parents=True, exist_ok=True) + wb.save(output_path) + print(f"✅ Zapisano: {output_path} ({len(stats)} użytkowników)") diff --git a/gitlab_client.py b/gitlab_client.py new file mode 100644 index 0000000..ab144e5 --- /dev/null +++ b/gitlab_client.py @@ -0,0 +1,142 @@ +""" +GitLab GraphQL client — pobiera events dla grupy w zadanym zakresie dat. +""" + +import requests +from datetime import date +from typing import Generator + + +EVENTS_QUERY = """ +query GroupContributions($fullPath: ID!, $after: String) { + group(fullPath: $fullPath) { + members(first: 100, after: $after) { + pageInfo { + hasNextPage + endCursor + } + nodes { + user { + id + name + username + contributionsCollection { + totalCommitContributions + totalPullRequestContributions: totalPullRequestContributions + } + } + } + } + } +} +""" + +# Events API (REST) — GraphQL nie udostępnia pełnych events per user per group +# Używamy REST Events API z paginacją +EVENTS_REST_PATH = "/api/v4/groups/{group_id}/events" + + +class GitLabClient: + def __init__(self, url: str, token: str): + self.url = url.rstrip("/") + self.token = token + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + }) + + def get_group_id(self, group_path: str) -> int: + """Pobiera numeryczne ID grupy po jej ścieżce.""" + resp = self.session.get( + f"{self.url}/api/v4/groups/{requests.utils.quote(group_path, safe='')}", + ) + resp.raise_for_status() + return resp.json()["id"] + + def get_group_members(self, group_id: int) -> list[dict]: + """Zwraca wszystkich członków grupy (z podgrupami).""" + members = [] + page = 1 + while True: + resp = self.session.get( + f"{self.url}/api/v4/groups/{group_id}/members/all", + params={"per_page": 100, "page": page}, + ) + resp.raise_for_status() + batch = resp.json() + if not batch: + break + members.extend(batch) + if len(batch) < 100: + break + page += 1 + return members + + def get_user_events( + self, + user_id: int, + date_from: date, + date_to: date, + ) -> list[dict]: + """ + Pobiera eventy użytkownika w zadanym zakresie dat. + Filtruje po: pushed (commity), merged (MR), commented, created. + """ + events = [] + page = 1 + while True: + resp = self.session.get( + f"{self.url}/api/v4/users/{user_id}/events", + params={ + "after": date_from.isoformat(), + "before": date_to.isoformat(), + "per_page": 100, + "page": page, + }, + ) + resp.raise_for_status() + batch = resp.json() + if not batch: + break + events.extend(batch) + if len(batch) < 100: + break + page += 1 + return events + + def get_group_events( + self, + group_id: int, + date_from: date, + date_to: date, + action: str | None = None, + ) -> Generator[dict, None, None]: + """ + Pobiera eventy grupy w zadanym zakresie dat (paginacja). + action: pushed | merged | created | commented | None (wszystkie) + """ + page = 1 + params = { + "after": date_from.isoformat(), + "before": date_to.isoformat(), + "per_page": 100, + "page": page, + } + if action: + params["action"] = action + + while True: + params["page"] = page + resp = self.session.get( + f"{self.url}/api/v4/groups/{group_id}/events", + params=params, + ) + resp.raise_for_status() + batch = resp.json() + if not batch: + break + yield from batch + if len(batch) < 100: + break + page += 1 diff --git a/main.py b/main.py new file mode 100644 index 0000000..e3ffddf --- /dev/null +++ b/main.py @@ -0,0 +1,92 @@ +#!/usr/bin/env python3 +""" +gitlab-contribution — eksport statystyk GitLab do Excel. + +Użycie: + python main.py + python main.py --group my-group --from 2024-01-01 --to 2024-12-31 --output raport.xlsx +""" + +import os +import sys +from datetime import date, datetime + +import click +from dotenv import load_dotenv + +from gitlab_client import GitLabClient +from aggregator import Aggregator +from exporter import export_to_excel + +load_dotenv() + + +def parse_date(value: str) -> date: + return datetime.strptime(value, "%Y-%m-%d").date() + + +@click.command() +@click.option("--group", envvar="GITLAB_GROUP", required=True, help="Ścieżka grupy GitLab (np. my-org/team)") +@click.option("--from", "date_from", envvar="DATE_FROM", default=None, help="Data od (YYYY-MM-DD)") +@click.option("--to", "date_to", envvar="DATE_TO", default=None, help="Data do (YYYY-MM-DD)") +@click.option("--output", envvar="OUTPUT_FILE", default="raport.xlsx", help="Plik wyjściowy Excel") +@click.option("--url", envvar="GITLAB_URL", default="https://gitlab.com", help="URL instancji GitLab") +@click.option("--token", envvar="GITLAB_TOKEN", required=True, help="Personal Access Token GitLab") +def main(group: str, date_from: str | None, date_to: str | None, output: str, url: str, token: str): + """Generuje raport Excel z aktywnością użytkowników w grupie GitLab.""" + + # Domyślny zakres: bieżący rok + today = date.today() + d_from = parse_date(date_from) if date_from else date(today.year, 1, 1) + d_to = parse_date(date_to) if date_to else today + + click.echo(f"🔍 Grupa: {group}") + click.echo(f"📅 Zakres: {d_from} → {d_to}") + click.echo(f"🌐 GitLab: {url}") + + client = GitLabClient(url=url, token=token) + + click.echo("⏳ Pobieranie ID grupy...") + try: + group_id = client.get_group_id(group) + except Exception as e: + click.echo(f"❌ Błąd pobierania grupy: {e}", err=True) + sys.exit(1) + + click.echo(f" → ID grupy: {group_id}") + + aggregator = Aggregator() + + click.echo("⏳ Pobieranie eventów (paginacja)...") + event_count = 0 + try: + for event in client.get_group_events(group_id, d_from, d_to): + aggregator.process_events([event]) + event_count += 1 + if event_count % 500 == 0: + click.echo(f" → przetworzone eventy: {event_count}") + except Exception as e: + click.echo(f"❌ Błąd pobierania eventów: {e}", err=True) + sys.exit(1) + + click.echo(f" → łącznie eventów: {event_count}") + + stats = aggregator.get_stats() + click.echo(f"👥 Użytkownicy z aktywnością: {len(stats)}") + + if not stats: + click.echo("⚠️ Brak danych — sprawdź zakres dat i uprawnienia tokena.") + sys.exit(0) + + click.echo(f"📊 Generowanie Excel: {output}") + export_to_excel( + stats=stats, + output_path=output, + group=group, + date_from=d_from, + date_to=d_to, + ) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..b08daef --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +requests>=2.31.0 +openpyxl>=3.1.0 +python-dotenv>=1.0.0 +click>=8.1.0