diff --git a/README.md b/README.md index 09c0c03..f0a7b8b 100644 --- a/README.md +++ b/README.md @@ -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 uv run weekly-activity github uv run weekly-activity github --token ghp_xxx +# 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 +uv run weekly-activity gitlab --url https://salsa.debian.org +uv run weekly-activity gitlab --url https://salsa.debian.org --token glpat-xxx + # Custom date range (ISO dates, UTC) uv run weekly-activity launchpad --since 2025-08-01 --until 2025-08-15 uv run weekly-activity github --since 2025-08-01 --until 2025-08-15 +uv run weekly-activity gitlab --since 2025-08-01 --until 2025-08-15 ``` +### GitLab instances + +Any GitLab instance works via `--url`. Token discovery order: + +1. `--token` flag +2. env var named by `--token-env` +3. `_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) diff --git a/src/weekly_activity/cli.py b/src/weekly_activity/cli.py index 560a285..ffea967 100644 --- a/src/weekly_activity/cli.py +++ b/src/weekly_activity/cli.py @@ -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}") diff --git a/src/weekly_activity/sources/gitlab.py b/src/weekly_activity/sources/gitlab.py new file mode 100644 index 0000000..5ce7fb2 --- /dev/null +++ b/src/weekly_activity/sources/gitlab.py @@ -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 ``_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, + )