forked from vhaudiquet/weekly-activity
Add GitLab source for gitlab.com and self-hosted instances (Salsa)
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# weekly-activity
|
||||
|
||||
Aggregate weekly activity across development platforms (Launchpad, GitHub) into a single text report.
|
||||
Aggregate weekly activity across development platforms (Launchpad, GitHub, GitLab) into a single text report.
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -13,21 +13,37 @@ uv run weekly-activity launchpad --anonymous <username>
|
||||
uv run weekly-activity github <username>
|
||||
uv run weekly-activity github --token ghp_xxx <username>
|
||||
|
||||
# GitLab (gitlab.com by default; any self-hosted instance via --url, e.g. Debian Salsa;
|
||||
# uses GITLAB_TOKEN / SALSA_TOKEN env vars, anonymous works but rate-limited)
|
||||
uv run weekly-activity gitlab <username>
|
||||
uv run weekly-activity gitlab --url https://salsa.debian.org <username>
|
||||
uv run weekly-activity gitlab --url https://salsa.debian.org --token glpat-xxx <username>
|
||||
|
||||
# Custom date range (ISO dates, UTC)
|
||||
uv run weekly-activity launchpad --since 2025-08-01 --until 2025-08-15 <username>
|
||||
uv run weekly-activity github --since 2025-08-01 --until 2025-08-15 <username>
|
||||
uv run weekly-activity gitlab --since 2025-08-01 --until 2025-08-15 <username>
|
||||
```
|
||||
|
||||
### GitLab instances
|
||||
|
||||
Any GitLab instance works via `--url`. Token discovery order:
|
||||
|
||||
1. `--token` flag
|
||||
2. env var named by `--token-env`
|
||||
3. `<HOST>_TOKEN` derived from the instance host (e.g. `SALSA_TOKEN` for `salsa.debian.org`)
|
||||
4. `GITLAB_TOKEN`
|
||||
|
||||
## What it reports
|
||||
|
||||
| Section | Launchpad | GitHub |
|
||||
|---|---|---|
|
||||
| Merge proposals / PRs authored | `getMergeProposals` (created in range) | `/search/issues` (`author:` + `type:pr`) |
|
||||
| Review requests received | `getRequestedReviews` (filtered by `date_review_requested`) | `/search/issues` (`reviewed-by:` + `type:pr`) |
|
||||
| Reviews performed | `getRequestedReviews` (filtered by `date_reviewed`) | (same query) |
|
||||
| Issues / Bugs | `searchTasks` (reporter, commenter, assignee) | `/search/issues` (author, commenter, assignee) |
|
||||
| Repositories modified | `git_repositories.getRepositories` (`modified_since_date`) | `/users/{user}/repos` (filtered by `pushed_at`) |
|
||||
| Commits pushed | — (not in Launchpad REST API) | `/repos/{owner}/{repo}/commits` (per active repo) |
|
||||
| Section | Launchpad | GitHub | GitLab |
|
||||
|---|---|---|---|
|
||||
| Merge proposals / PRs authored | `getMergeProposals` (created in range) | `/search/issues` (`author:` + `type:pr`) | `/merge_requests` (`author_id`, created range) |
|
||||
| Review requests received | `getRequestedReviews` (filtered by `date_review_requested`) | `/search/issues` (`reviewed-by:` + `type:pr`) | — |
|
||||
| Reviews performed | `getRequestedReviews` (filtered by `date_reviewed`) | (same query) | events (`action=approved`) |
|
||||
| Issues / Bugs | `searchTasks` (reporter, commenter, assignee) | `/search/issues` (author, commenter, assignee) | `/issues` (`author_id`, `assignee_id`) |
|
||||
| Repositories modified | `git_repositories.getRepositories` (`modified_since_date`) | `/users/{user}/repos` (filtered by `pushed_at`) | — (covered by push events) |
|
||||
| Commits pushed | — (not in Launchpad REST API) | `/repos/{owner}/{repo}/commits` (per active repo) | events (`action=pushed`) |
|
||||
|
||||
### Default time window
|
||||
|
||||
@@ -39,11 +55,12 @@ Rolling 7 days ending today (inclusive). Override with `--since` / `--until` (IS
|
||||
src/weekly_activity/
|
||||
model.py ActivityReport, Section, last_week_window
|
||||
report.py format_report() — source-agnostic text renderer
|
||||
cli.py subcommand dispatch (launchpad | github)
|
||||
cli.py subcommand dispatch (launchpad | github | gitlab)
|
||||
sources/
|
||||
__init__.py ActivitySource protocol
|
||||
launchpad.py LaunchpadSource (launchpadlib)
|
||||
github.py GitHubSource (httpx)
|
||||
gitlab.py GitLabSource (httpx, REST v4 — gitlab.com or self-hosted)
|
||||
```
|
||||
|
||||
Each source implements `ActivitySource` — a `name` and a `collect(username, since, until) -> ActivityReport` method. Sources keep their own dataclasses internally; the shared contract is `Section` (title + pre-formatted lines) and `ActivityReport` (metadata + sections + warnings). The report formatter (`report.py`) is source-agnostic.
|
||||
@@ -62,6 +79,6 @@ uv run ty check # type check
|
||||
## Dependencies
|
||||
|
||||
- **launchpadlib** — Launchpad REST API client
|
||||
- **httpx** — GitHub REST API client
|
||||
- **httpx** — GitHub & GitLab REST API client
|
||||
- **ruff** — format + lint (dev)
|
||||
- **ty** — type check (dev)
|
||||
|
||||
@@ -10,6 +10,7 @@ from datetime import UTC, datetime
|
||||
from weekly_activity.model import last_week_window
|
||||
from weekly_activity.report import format_report
|
||||
from weekly_activity.sources.github import GitHubSource
|
||||
from weekly_activity.sources.gitlab import GitLabSource
|
||||
from weekly_activity.sources.launchpad import LaunchpadSource
|
||||
|
||||
|
||||
@@ -48,6 +49,25 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace:
|
||||
gh.add_argument("username", help="GitHub username.")
|
||||
gh.add_argument("--token", default=None, help="GitHub token (or set GITHUB_TOKEN env var).")
|
||||
|
||||
gl = subparsers.add_parser(
|
||||
"gitlab",
|
||||
parents=[base],
|
||||
help="GitLab activity (gitlab.com or any self-hosted instance, e.g. Debian Salsa).",
|
||||
)
|
||||
gl.add_argument("username", help="GitLab username.")
|
||||
gl.add_argument(
|
||||
"--url",
|
||||
default="https://gitlab.com",
|
||||
help="Base URL of the GitLab instance (default: https://gitlab.com).",
|
||||
)
|
||||
gl.add_argument("--token", default=None, help="API token (overrides env discovery).")
|
||||
gl.add_argument(
|
||||
"--token-env",
|
||||
default=None,
|
||||
metavar="VAR",
|
||||
help="Env var holding the API token (see README for discovery order).",
|
||||
)
|
||||
|
||||
return parser.parse_args(argv)
|
||||
|
||||
|
||||
@@ -84,6 +104,8 @@ def main() -> None:
|
||||
)
|
||||
elif args.source == "github":
|
||||
source = GitHubSource(token=args.token)
|
||||
elif args.source == "gitlab":
|
||||
source = GitLabSource(url=args.url, token=args.token, token_env=args.token_env)
|
||||
else:
|
||||
raise SystemExit(f"error: unknown source {args.source!r}")
|
||||
|
||||
|
||||
@@ -0,0 +1,323 @@
|
||||
"""GitLab activity source — merge requests, issues, approvals, and pushes.
|
||||
|
||||
Works against gitlab.com or any self-hosted instance (e.g. Debian Salsa)
|
||||
via the GitLab REST API v4.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
|
||||
from weekly_activity.model import ActivityReport, Section
|
||||
|
||||
|
||||
def _host_from_url(url: str) -> str:
|
||||
"""Extract the hostname from a base URL."""
|
||||
if "//" not in url:
|
||||
url = f"https://{url}"
|
||||
return urlparse(url).hostname or ""
|
||||
|
||||
|
||||
def _discover_token(base_url: str, token_env: str | None) -> str | None:
|
||||
"""Resolve an API token from the environment.
|
||||
|
||||
Order: the env var named by ``token_env``, then ``<HOST>_TOKEN`` derived
|
||||
from the instance host (e.g. ``SALSA_TOKEN`` for ``salsa.debian.org``,
|
||||
``GITLAB_TOKEN`` for ``gitlab.com``).
|
||||
"""
|
||||
candidates: list[str] = []
|
||||
if token_env:
|
||||
candidates.append(token_env)
|
||||
host = _host_from_url(base_url)
|
||||
first_label = host.split(".")[0].upper() if host else ""
|
||||
if first_label and f"{first_label}_TOKEN" not in candidates:
|
||||
candidates.append(f"{first_label}_TOKEN")
|
||||
if "GITLAB_TOKEN" not in candidates:
|
||||
candidates.append("GITLAB_TOKEN")
|
||||
for name in candidates:
|
||||
value = os.environ.get(name)
|
||||
if value:
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _parse_gl_date(s: str | None) -> datetime | None:
|
||||
if s is None:
|
||||
return None
|
||||
try:
|
||||
dt = datetime.fromisoformat(s.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None
|
||||
if dt.tzinfo is not None:
|
||||
return dt.astimezone(UTC).replace(tzinfo=None)
|
||||
return dt
|
||||
|
||||
|
||||
def _fmt_date(dt: datetime | None) -> str:
|
||||
return dt.strftime("%Y-%m-%d") if dt is not None else "—"
|
||||
|
||||
|
||||
def _iso_utc(dt: datetime) -> str:
|
||||
return dt.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
|
||||
class GitLabSource:
|
||||
"""GitLab activity source implementing ActivitySource."""
|
||||
|
||||
name = "gitlab"
|
||||
|
||||
MAX_PAGES = 10 # safety cap per endpoint: MAX_PAGES * 100 items
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
url: str = "https://gitlab.com",
|
||||
token: str | None = None,
|
||||
token_env: str | None = None,
|
||||
) -> None:
|
||||
self._base_url = url.rstrip("/")
|
||||
self._token = token or _discover_token(self._base_url, token_env)
|
||||
headers: dict[str, str] = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "weekly-activity",
|
||||
}
|
||||
if self._token:
|
||||
headers["PRIVATE-TOKEN"] = self._token
|
||||
self._client = httpx.Client(
|
||||
base_url=f"{self._base_url}/api/v4",
|
||||
headers=headers,
|
||||
timeout=30.0,
|
||||
)
|
||||
self._projects: dict[int, dict[str, Any]] = {}
|
||||
|
||||
# --- HTTP helpers ---
|
||||
|
||||
def _get(self, path: str, params: dict[str, Any] | None = None) -> httpx.Response:
|
||||
response = self._client.get(path, params=params)
|
||||
response.raise_for_status()
|
||||
return response
|
||||
|
||||
def _paginate(self, path: str, params: dict[str, Any] | None = None) -> list[dict[str, Any]]:
|
||||
items: list[dict[str, Any]] = []
|
||||
query = dict(params or {})
|
||||
query["per_page"] = 100
|
||||
query.setdefault("page", 1)
|
||||
for _ in range(self.MAX_PAGES):
|
||||
response = self._get(path, query)
|
||||
batch = response.json()
|
||||
if not isinstance(batch, list):
|
||||
break
|
||||
items.extend(batch)
|
||||
next_page = response.headers.get("x-next-page")
|
||||
if not next_page:
|
||||
break
|
||||
try:
|
||||
query["page"] = int(next_page)
|
||||
except ValueError:
|
||||
break
|
||||
return items
|
||||
|
||||
def get_user(self, username: str) -> dict[str, Any]:
|
||||
users = self._get("/users", {"username": username}).json()
|
||||
for user in users:
|
||||
if str(user.get("username", "")).lower() == username.lower():
|
||||
return user
|
||||
raise LookupError(f"no GitLab user named {username!r} on {self._base_url}")
|
||||
|
||||
def _get_project(self, project_id: int) -> dict[str, Any]:
|
||||
"""Fetch project metadata (cached); failures degrade to an empty dict."""
|
||||
if project_id not in self._projects:
|
||||
try:
|
||||
self._projects[project_id] = self._get(f"/projects/{project_id}").json()
|
||||
except Exception: # noqa: BLE001 - only used to build display strings/URLs
|
||||
self._projects[project_id] = {}
|
||||
return self._projects[project_id]
|
||||
|
||||
def _event_target_url(self, event: dict[str, Any]) -> str:
|
||||
slug = {"MergeRequest": "merge_requests", "Issue": "issues"}.get(
|
||||
event.get("target_type") or "", ""
|
||||
)
|
||||
iid = event.get("target_iid")
|
||||
project_id = event.get("project_id")
|
||||
web_url = self._get_project(project_id).get("web_url", "") if project_id else ""
|
||||
if web_url and slug and iid is not None:
|
||||
return f"{web_url}/-/{slug}/{iid}"
|
||||
return ""
|
||||
|
||||
def collect(self, username: str, since: datetime, until: datetime) -> ActivityReport:
|
||||
user = self.get_user(username)
|
||||
user_id = user.get("id")
|
||||
if user_id is None:
|
||||
raise LookupError(f"GitLab returned no id for user {username!r}")
|
||||
warnings: list[str] = []
|
||||
|
||||
def safe(label: str, fn: Any) -> Any:
|
||||
try:
|
||||
return fn()
|
||||
except httpx.HTTPStatusError as exc:
|
||||
msg = f"HTTP {exc.response.status_code}"
|
||||
if exc.response.headers.get("retry-after"):
|
||||
msg += " (rate limited)"
|
||||
warnings.append(f"{label}: {msg}")
|
||||
return None
|
||||
except Exception as exc: # noqa: BLE001 - degrade section, keep report
|
||||
warnings.append(f"{label}: {type(exc).__name__}: {exc}")
|
||||
return None
|
||||
|
||||
def in_window(value: object) -> bool:
|
||||
dt = _parse_gl_date(value if isinstance(value, str) else None)
|
||||
return dt is not None and since <= dt < until
|
||||
|
||||
# --- Merge requests authored ---
|
||||
mr_items = safe(
|
||||
"MRs authored",
|
||||
lambda: self._paginate(
|
||||
"/merge_requests",
|
||||
{
|
||||
"scope": "all",
|
||||
"author_id": user_id,
|
||||
"created_after": _iso_utc(since),
|
||||
"created_before": _iso_utc(until),
|
||||
"order_by": "created_at",
|
||||
"sort": "desc",
|
||||
},
|
||||
),
|
||||
)
|
||||
mr_entries: list[str] = []
|
||||
if mr_items:
|
||||
for mr in mr_items:
|
||||
state = mr.get("state", "unknown")
|
||||
merged_at = mr.get("merged_at")
|
||||
if merged_at:
|
||||
state = "merged"
|
||||
created = _parse_gl_date(mr.get("created_at"))
|
||||
merged = _parse_gl_date(merged_at)
|
||||
project_ref = (mr.get("references") or {}).get("full", "")
|
||||
mr_entries.append(
|
||||
f" [{state}] !{mr.get('iid', '?')} {mr.get('title', '(untitled)')}\n"
|
||||
f" {project_ref}\n"
|
||||
f" created {_fmt_date(created)} · merged {_fmt_date(merged)}\n"
|
||||
f" {mr.get('web_url', '')}"
|
||||
)
|
||||
|
||||
# --- Events (approvals + pushes) ---
|
||||
events = (
|
||||
safe(
|
||||
"events",
|
||||
lambda: self._paginate(
|
||||
f"/users/{user_id}/events",
|
||||
{
|
||||
# Widen by a day on each side; exact filtering happens client-side.
|
||||
"after": (since - timedelta(days=1)).strftime("%Y-%m-%d"),
|
||||
"before": (until + timedelta(days=1)).strftime("%Y-%m-%d"),
|
||||
"sort": "desc",
|
||||
},
|
||||
),
|
||||
)
|
||||
or []
|
||||
)
|
||||
events = [e for e in events if in_window(e.get("created_at"))]
|
||||
|
||||
approved_entries: list[str] = []
|
||||
push_entries: list[str] = []
|
||||
for event in events:
|
||||
action = event.get("action_name")
|
||||
project = self._get_project(event.get("project_id") or 0)
|
||||
path = project.get("path_with_namespace") or f"project {event.get('project_id')}"
|
||||
when = _parse_gl_date(event.get("created_at"))
|
||||
if action == "approved" and event.get("target_type") == "MergeRequest":
|
||||
approved_entries.append(
|
||||
f" {event.get('target_title', '(untitled)')}\n"
|
||||
f" {path} · approved {_fmt_date(when)}\n"
|
||||
f" {self._event_target_url(event)}"
|
||||
)
|
||||
elif action == "pushed":
|
||||
push_data = event.get("push_data") or {}
|
||||
ref = push_data.get("ref", "?")
|
||||
ref_type = push_data.get("ref_type", "")
|
||||
count = push_data.get("commit_count", 0)
|
||||
message = push_data.get("commit_title") or "(no message)"
|
||||
first_line = message.splitlines()[0]
|
||||
push_entries.append(
|
||||
f" {path}: {first_line}\n"
|
||||
f" {ref_type} {ref} · {count} commit(s) · {_fmt_date(when)}"
|
||||
)
|
||||
|
||||
# --- Issues (reported/assigned) ---
|
||||
issues: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def add_issue(item: dict[str, Any], role: str) -> None:
|
||||
url = item.get("web_url", "")
|
||||
if url in issues:
|
||||
issues[url]["roles"].add(role)
|
||||
else:
|
||||
issues[url] = {
|
||||
"ref": (item.get("references") or {}).get("full", ""),
|
||||
"title": item.get("title", "(untitled)"),
|
||||
"url": url,
|
||||
"created": _parse_gl_date(item.get("created_at")),
|
||||
"updated": _parse_gl_date(item.get("updated_at")),
|
||||
"roles": {role},
|
||||
}
|
||||
|
||||
issue_queries: list[tuple[str, str, str]] = [
|
||||
("reported", "author_id", "created"),
|
||||
("assigned", "assignee_id", "updated"),
|
||||
]
|
||||
for role, param, date_field in issue_queries:
|
||||
items = safe(
|
||||
f"issues ({role})",
|
||||
lambda p=param, d=date_field: self._paginate(
|
||||
"/issues",
|
||||
{
|
||||
"scope": "all",
|
||||
p: user_id,
|
||||
f"{d}_after": _iso_utc(since),
|
||||
f"{d}_before": _iso_utc(until),
|
||||
"order_by": "updated_at",
|
||||
"sort": "desc",
|
||||
},
|
||||
),
|
||||
)
|
||||
if items:
|
||||
for item in items:
|
||||
add_issue(item, role)
|
||||
|
||||
issue_entries = [
|
||||
f" {info['ref']} {info['title']}\n"
|
||||
f" {'/'.join(sorted(info['roles']))} · "
|
||||
f"created {_fmt_date(info['created'])} · "
|
||||
f"updated {_fmt_date(info['updated'])}\n"
|
||||
f" {info['url']}"
|
||||
for info in issues.values()
|
||||
]
|
||||
|
||||
if not self._token:
|
||||
host = _host_from_url(self._base_url)
|
||||
suggested = "GITLAB_TOKEN"
|
||||
if host and host != "gitlab.com":
|
||||
suggested = f"{host.split('.')[0].upper()}_TOKEN or GITLAB_TOKEN"
|
||||
warnings.insert(
|
||||
0,
|
||||
f"no GitLab token found (set {suggested}); "
|
||||
"unauthenticated access is heavily rate-limited",
|
||||
)
|
||||
|
||||
return ActivityReport(
|
||||
source=self.name,
|
||||
username=username,
|
||||
since=since,
|
||||
until=until,
|
||||
sections=[
|
||||
Section("Merge requests authored", mr_entries),
|
||||
Section("Merge requests approved", approved_entries),
|
||||
Section("Issues", issue_entries),
|
||||
Section("Commits pushed", push_entries),
|
||||
],
|
||||
warnings=warnings,
|
||||
)
|
||||
Reference in New Issue
Block a user