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