Add GitLab source for gitlab.com and self-hosted instances (Salsa)

This commit is contained in:
2026-08-25 18:02:52 +02:00
parent fadb15ccf7
commit 5b89313c4a
3 changed files with 373 additions and 11 deletions
+28 -11
View File
@@ -1,6 +1,6 @@
# weekly-activity # 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 ## Usage
@@ -13,21 +13,37 @@ uv run weekly-activity launchpad --anonymous <username>
uv run weekly-activity github <username> uv run weekly-activity github <username>
uv run weekly-activity github --token ghp_xxx <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) # Custom date range (ISO dates, UTC)
uv run weekly-activity launchpad --since 2025-08-01 --until 2025-08-15 <username> 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 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 ## What it reports
| Section | Launchpad | GitHub | | Section | Launchpad | GitHub | GitLab |
|---|---|---| |---|---|---|---|
| Merge proposals / PRs authored | `getMergeProposals` (created in range) | `/search/issues` (`author:` + `type:pr`) | | 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`) | | Review requests received | `getRequestedReviews` (filtered by `date_review_requested`) | `/search/issues` (`reviewed-by:` + `type:pr`) | — |
| Reviews performed | `getRequestedReviews` (filtered by `date_reviewed`) | (same query) | | Reviews performed | `getRequestedReviews` (filtered by `date_reviewed`) | (same query) | events (`action=approved`) |
| Issues / Bugs | `searchTasks` (reporter, commenter, assignee) | `/search/issues` (author, commenter, assignee) | | 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`) | | 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) | | Commits pushed | — (not in Launchpad REST API) | `/repos/{owner}/{repo}/commits` (per active repo) | events (`action=pushed`) |
### Default time window ### Default time window
@@ -39,11 +55,12 @@ Rolling 7 days ending today (inclusive). Override with `--since` / `--until` (IS
src/weekly_activity/ src/weekly_activity/
model.py ActivityReport, Section, last_week_window model.py ActivityReport, Section, last_week_window
report.py format_report() — source-agnostic text renderer report.py format_report() — source-agnostic text renderer
cli.py subcommand dispatch (launchpad | github) cli.py subcommand dispatch (launchpad | github | gitlab)
sources/ sources/
__init__.py ActivitySource protocol __init__.py ActivitySource protocol
launchpad.py LaunchpadSource (launchpadlib) launchpad.py LaunchpadSource (launchpadlib)
github.py GitHubSource (httpx) 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. 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 ## Dependencies
- **launchpadlib** — Launchpad REST API client - **launchpadlib** — Launchpad REST API client
- **httpx** — GitHub REST API client - **httpx** — GitHub & GitLab REST API client
- **ruff** — format + lint (dev) - **ruff** — format + lint (dev)
- **ty** — type check (dev) - **ty** — type check (dev)
+22
View File
@@ -10,6 +10,7 @@ from datetime import UTC, datetime
from weekly_activity.model import last_week_window from weekly_activity.model import last_week_window
from weekly_activity.report import format_report from weekly_activity.report import format_report
from weekly_activity.sources.github import GitHubSource from weekly_activity.sources.github import GitHubSource
from weekly_activity.sources.gitlab import GitLabSource
from weekly_activity.sources.launchpad import LaunchpadSource 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("username", help="GitHub username.")
gh.add_argument("--token", default=None, help="GitHub token (or set GITHUB_TOKEN env var).") 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) return parser.parse_args(argv)
@@ -84,6 +104,8 @@ def main() -> None:
) )
elif args.source == "github": elif args.source == "github":
source = GitHubSource(token=args.token) source = GitHubSource(token=args.token)
elif args.source == "gitlab":
source = GitLabSource(url=args.url, token=args.token, token_env=args.token_env)
else: else:
raise SystemExit(f"error: unknown source {args.source!r}") raise SystemExit(f"error: unknown source {args.source!r}")
+323
View File
@@ -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,
)