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:
6
.env.example
Normal file
6
.env.example
Normal 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
11
.gitignore
vendored
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
.env
|
||||||
|
*.xlsx
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
.venv/
|
||||||
|
venv/
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.egg-info/
|
||||||
|
.DS_Store
|
||||||
57
README.md
Normal file
57
README.md
Normal 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
69
aggregator.py
Normal 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
103
exporter.py
Normal 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
142
gitlab_client.py
Normal 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
92
main.py
Normal 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
4
requirements.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
requests>=2.31.0
|
||||||
|
openpyxl>=3.1.0
|
||||||
|
python-dotenv>=1.0.0
|
||||||
|
click>=8.1.0
|
||||||
Reference in New Issue
Block a user