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
This commit is contained in:
2026-03-04 18:27:01 +00:00
commit 066539c89c
8 changed files with 484 additions and 0 deletions

6
.env.example Normal file
View File

@@ -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

11
.gitignore vendored Normal file
View File

@@ -0,0 +1,11 @@
.env
*.xlsx
__pycache__/
*.pyc
*.pyo
.venv/
venv/
dist/
build/
*.egg-info/
.DS_Store

57
README.md Normal file
View File

@@ -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ę.

69
aggregator.py Normal file
View File

@@ -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,
)

103
exporter.py Normal file
View File

@@ -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)")

142
gitlab_client.py Normal file
View File

@@ -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

92
main.py Normal file
View File

@@ -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()

4
requirements.txt Normal file
View File

@@ -0,0 +1,4 @@
requests>=2.31.0
openpyxl>=3.1.0
python-dotenv>=1.0.0
click>=8.1.0