From 3625229056c97aa103f82d3559656525a51332ec Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Tue, 25 Aug 2026 15:24:10 +0200 Subject: [PATCH] Restructure to multi-source architecture; fix GitHub repo timeline filter - Rename package launchpad_weekly_activity -> weekly_activity - Split into model.py (ActivityReport, Section), report.py (source-agnostic formatter), cli.py (subcommand dispatch), sources/ (per-source modules) - Move Launchpad code to sources/launchpad.py implementing ActivitySource - Add GitHub source (sources/github.py) using httpx: PRs, issues, repos, commits - Fix GitHub 'Repositories modified' filtering on pushed_at not updated_at (metadata changes were showing stale repos with no recent code pushes) - Add --credentials-file for Launchpad OAuth (bypass keyring for headless) - Add keyring dep (launchpadlib optional dep; bare import crashes without it) - Graceful per-query error handling: degraded sections + concise warnings - Rolling 7-day window (was previous calendar week) --- pyproject.toml | 12 +- src/launchpad_weekly_activity/__init__.py | 3 - src/launchpad_weekly_activity/cli.py | 180 ------------ src/weekly_activity/__init__.py | 3 + src/weekly_activity/cli.py | 102 +++++++ src/weekly_activity/model.py | 43 +++ src/weekly_activity/report.py | 34 +++ src/weekly_activity/sources/__init__.py | 17 ++ src/weekly_activity/sources/github.py | 270 ++++++++++++++++++ .../sources/launchpad.py} | 140 +++++---- uv.lock | 133 +++++++-- 11 files changed, 662 insertions(+), 275 deletions(-) delete mode 100644 src/launchpad_weekly_activity/__init__.py delete mode 100644 src/launchpad_weekly_activity/cli.py create mode 100644 src/weekly_activity/__init__.py create mode 100644 src/weekly_activity/cli.py create mode 100644 src/weekly_activity/model.py create mode 100644 src/weekly_activity/report.py create mode 100644 src/weekly_activity/sources/__init__.py create mode 100644 src/weekly_activity/sources/github.py rename src/{launchpad_weekly_activity/api.py => weekly_activity/sources/launchpad.py} (75%) diff --git a/pyproject.toml b/pyproject.toml index ef2d05a..bc86e32 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,12 @@ [project] -name = "launchpad-weekly-activity" -version = "0.1.0" -description = "Weekly Launchpad activity report (merge proposals, branches, bugs) for a user." +name = "weekly-activity" +version = "0.2.0" +description = "Weekly activity report (merge proposals, pull requests, bugs, issues) for Launchpad and GitHub." requires-python = ">=3.11" -dependencies = ["launchpadlib>=2.1.0", "keyring>=25"] +dependencies = ["launchpadlib>=2.1.0", "keyring>=25", "httpx>=0.27"] [project.scripts] -launchpad-weekly-activity = "launchpad_weekly_activity.cli:main" +weekly-activity = "weekly_activity.cli:main" [build-system] requires = ["hatchling"] @@ -16,7 +16,7 @@ build-backend = "hatchling.build" dev = ["ruff>=0.6", "ty>=0.0.1"] [tool.hatch.build.targets.wheel] -packages = ["src/launchpad_weekly_activity"] +packages = ["src/weekly_activity"] [tool.ruff] target-version = "py311" diff --git a/src/launchpad_weekly_activity/__init__.py b/src/launchpad_weekly_activity/__init__.py deleted file mode 100644 index bce84c8..0000000 --- a/src/launchpad_weekly_activity/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -"""Weekly Launchpad activity report generator.""" - -__version__ = "0.1.0" diff --git a/src/launchpad_weekly_activity/cli.py b/src/launchpad_weekly_activity/cli.py deleted file mode 100644 index 54a7b1a..0000000 --- a/src/launchpad_weekly_activity/cli.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Command-line entry point: parse args, fetch activity, print the report.""" - -from __future__ import annotations - -import argparse -import sys -from collections.abc import Sequence -from datetime import UTC, datetime, timedelta - -from launchpad_weekly_activity.api import ( - LaunchpadClient, - MergeProposal, - WeeklyActivity, - last_week_window, -) - - -def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: - parser = argparse.ArgumentParser( - prog="launchpad-weekly-activity", - description="Summarize a Launchpad user's activity for last week (or a custom range).", - ) - parser.add_argument("username", help="Launchpad username (the ~name).") - parser.add_argument( - "--since", - help="Inclusive start as an ISO date/datetime (UTC). Default: last Monday 00:00 UTC.", - ) - parser.add_argument( - "--until", - help="Exclusive end as an ISO date/datetime (UTC). Default: this Monday 00:00 UTC.", - ) - parser.add_argument( - "--service", - choices=["production", "staging"], - default="production", - help="Launchpad service root (default: production).", - ) - parser.add_argument( - "--anonymous", - action="store_true", - help="Anonymous access: public data only, no OAuth. Default uses OAuth.", - ) - parser.add_argument( - "--credentials-file", - default=None, - help="OAuth token file path (bypasses keyring; useful headless). Default: system keyring.", - ) - return parser.parse_args(argv) - - -def _parse_dt(value: str | None) -> datetime | None: - if value is None: - return None - dt = datetime.fromisoformat(value) - if dt.tzinfo is not None: - dt = dt.astimezone(UTC).replace(tzinfo=None) - return dt - - -def resolve_period(args: argparse.Namespace) -> tuple[datetime, datetime]: - if args.since or args.until: - since = _parse_dt(args.since) - until = _parse_dt(args.until) - if since is None or until is None: - raise SystemExit("error: --since and --until must be provided together as ISO dates.") - if since >= until: - raise SystemExit("error: --since must be earlier than --until.") - return since, until - return last_week_window() - - -def _fmt_date(dt: datetime | None) -> str: - return dt.strftime("%Y-%m-%d") if dt is not None else "—" - - -def _fmt_mp(mp: MergeProposal) -> str: - message = (mp.commit_message or "(no commit message)").strip().splitlines()[0] - return f"[{mp.queue_status}] {message}" - - -def _format_section(title: str, lines: list[str]) -> str: - if not lines: - return f"{title} (0)\n (none)" - return f"{title} ({len(lines)})\n" + "\n".join(lines) - - -def format_report(activity: WeeklyActivity) -> str: - out: list[str] = [] - out.append(f"Launchpad weekly activity — ~{activity.username}") - out.append( - f"Period: {activity.since.strftime('%Y-%m-%d')} .. " - f"{(activity.until - timedelta(days=1)).strftime('%Y-%m-%d')} (UTC, inclusive)" - ) - out.append("") - - mp_lines: list[str] = [] - for mp in activity.authored_mps: - mp_lines.append( - f" {_fmt_mp(mp)}\n" - f" created {_fmt_date(mp.date_created)} · " - f"merged {_fmt_date(mp.date_merged)}\n" - f" {mp.web_link}" - ) - out.append(_format_section("Merge proposals authored", mp_lines)) - - review_lines: list[str] = [] - for mp in activity.new_review_requests: - review_lines.append( - f" {_fmt_mp(mp)}\n" - f" requested {_fmt_date(mp.date_review_requested)}\n" - f" {mp.web_link}" - ) - out.append("") - out.append(_format_section("New review requests received", review_lines)) - - perf_lines: list[str] = [] - for mp in activity.performed_reviews: - perf_lines.append( - f" {_fmt_mp(mp)}\n reviewed {_fmt_date(mp.date_reviewed)}\n {mp.web_link}" - ) - out.append("") - out.append(_format_section("Reviews performed", perf_lines)) - repo_lines: list[str] = [] - for repo in activity.git_repositories: - name = repo.display_name or repo.name or repo.web_link - branch = repo.default_branch or "—" - repo_lines.append( - f" {name}\n" - f" default branch {branch} · " - f"last modified {_fmt_date(repo.date_last_modified)}\n" - f" {repo.web_link}" - ) - out.append("") - out.append(_format_section("Repositories modified", repo_lines)) - - bug_lines: list[str] = [] - for bug in activity.bugs: - roles = "/".join(sorted(bug.roles)) or "?" - bug_id = f"#{bug.bug_id}" if bug.bug_id is not None else "#?" - bug_lines.append( - f" [{bug_id}] {bug.title}\n" - f" {roles} · created {_fmt_date(bug.date_created)} · " - f"updated {_fmt_date(bug.date_last_updated)}\n" - f" {bug.web_link}" - ) - out.append("") - out.append(_format_section("Bugs", bug_lines)) - - if activity.warnings: - out.append("") - out.append("Warnings (partial data — some queries failed):") - for w in activity.warnings: - out.append(f" {w}") - out.append("Note: commit-level history is not exposed by the Launchpad REST API;") - out.append("read the branch directly (bzr/git) for per-commit activity.") - return "\n".join(out) - - -def main() -> None: - args = parse_args() - since, until = resolve_period(args) - client = LaunchpadClient( - service=args.service, - anonymous=args.anonymous, - application_name="launchpad-weekly-activity", - credentials_file=args.credentials_file, - ) - try: - activity = client.collect(args.username, since, until) - except LookupError as exc: - print(f"error: {exc}", file=sys.stderr) - sys.exit(2) - except Exception as exc: # noqa: BLE001 - surface API/network failures cleanly - print(f"error: failed to fetch activity: {exc}", file=sys.stderr) - sys.exit(1) - print(format_report(activity)) - - -if __name__ == "__main__": - main() diff --git a/src/weekly_activity/__init__.py b/src/weekly_activity/__init__.py new file mode 100644 index 0000000..f4653b4 --- /dev/null +++ b/src/weekly_activity/__init__.py @@ -0,0 +1,3 @@ +"""Weekly activity report generator for multiple platforms.""" + +__version__ = "0.2.0" diff --git a/src/weekly_activity/cli.py b/src/weekly_activity/cli.py new file mode 100644 index 0000000..560a285 --- /dev/null +++ b/src/weekly_activity/cli.py @@ -0,0 +1,102 @@ +"""Command-line entry point: parse args, dispatch to source, print report.""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Sequence +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.launchpad import LaunchpadSource + + +def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: + base = argparse.ArgumentParser(add_help=False) + base.add_argument( + "--since", + help="Inclusive start as an ISO date/datetime (UTC). Default: 7 days ago.", + ) + base.add_argument( + "--until", + help="Inclusive end as an ISO date/datetime (UTC). Default: today.", + ) + + parser = argparse.ArgumentParser( + prog="weekly-activity", + description="Summarize a user's activity for last week (or a custom range).", + ) + subparsers = parser.add_subparsers(dest="source", required=True) + + lp = subparsers.add_parser("launchpad", parents=[base], help="Launchpad activity.") + lp.add_argument("username", help="Launchpad username (the ~name).") + lp.add_argument( + "--anonymous", + action="store_true", + help="Anonymous access: public data only, no OAuth.", + ) + lp.add_argument("--credentials-file", default=None, help="OAuth token file path.") + lp.add_argument( + "--service", + choices=["production", "staging"], + default="production", + ) + + gh = subparsers.add_parser("github", parents=[base], help="GitHub activity.") + gh.add_argument("username", help="GitHub username.") + gh.add_argument("--token", default=None, help="GitHub token (or set GITHUB_TOKEN env var).") + + return parser.parse_args(argv) + + +def _parse_dt(value: str | None) -> datetime | None: + if value is None: + return None + dt = datetime.fromisoformat(value) + if dt.tzinfo is not None: + dt = dt.astimezone(UTC).replace(tzinfo=None) + return dt + + +def resolve_period(args: argparse.Namespace) -> tuple[datetime, datetime]: + if args.since or args.until: + since = _parse_dt(args.since) + until = _parse_dt(args.until) + if since is None or until is None: + raise SystemExit("error: --since and --until must be provided together as ISO dates.") + if since >= until: + raise SystemExit("error: --since must be earlier than --until.") + return since, until + return last_week_window() + + +def main() -> None: + args = parse_args() + since, until = resolve_period(args) + + if args.source == "launchpad": + source = LaunchpadSource( + service=args.service, + anonymous=args.anonymous, + credentials_file=args.credentials_file, + ) + elif args.source == "github": + source = GitHubSource(token=args.token) + else: + raise SystemExit(f"error: unknown source {args.source!r}") + + try: + report = source.collect(args.username, since, until) + except LookupError as exc: + print(f"error: {exc}", file=sys.stderr) + sys.exit(2) + except Exception as exc: # noqa: BLE001 - surface API/network failures cleanly + print(f"error: failed to fetch activity: {exc}", file=sys.stderr) + sys.exit(1) + print(format_report(report)) + + +if __name__ == "__main__": + main() diff --git a/src/weekly_activity/model.py b/src/weekly_activity/model.py new file mode 100644 index 0000000..f315c1e --- /dev/null +++ b/src/weekly_activity/model.py @@ -0,0 +1,43 @@ +"""Shared data model for weekly activity reports from multiple sources.""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from datetime import UTC, datetime, timedelta + + +@dataclass(frozen=True, slots=True) +class Section: + """A titled group of formatted entries in the report.""" + + title: str + entries: list[str] + + +@dataclass(frozen=True, slots=True) +class ActivityReport: + """A weekly activity report produced by an ActivitySource.""" + + source: str + username: str + since: datetime + until: datetime + sections: list[Section] + warnings: list[str] = field(default_factory=list) + note: str | None = None + + +def last_week_window(now: datetime | None = None) -> tuple[datetime, datetime]: + """Return the last 7 days ending today (rolling week), naive UTC. + + The range is half-open: ``since <= event < until``. ``until`` is midnight + tomorrow, so all of today is included. + """ + today = ( + (now or datetime.now(UTC)) + .astimezone(UTC) + .replace(tzinfo=None, hour=0, minute=0, second=0, microsecond=0) + ) + since = today - timedelta(days=7) + until = today + timedelta(days=1) + return since, until diff --git a/src/weekly_activity/report.py b/src/weekly_activity/report.py new file mode 100644 index 0000000..b98d74c --- /dev/null +++ b/src/weekly_activity/report.py @@ -0,0 +1,34 @@ +"""Source-agnostic report formatter — renders ActivityReport sections to text.""" + +from __future__ import annotations + +from datetime import timedelta + +from weekly_activity.model import ActivityReport, Section + + +def _format_section(section: Section) -> str: + if not section.entries: + return f"{section.title} (0)\n (none)" + return f"{section.title} ({len(section.entries)})\n" + "\n".join(section.entries) + + +def format_report(report: ActivityReport) -> str: + out: list[str] = [] + out.append(f"Activity report — {report.source} / ~{report.username}") + out.append( + f"Period: {report.since.strftime('%Y-%m-%d')} .. " + f"{(report.until - timedelta(days=1)).strftime('%Y-%m-%d')} (UTC, inclusive)" + ) + for section in report.sections: + out.append("") + out.append(_format_section(section)) + if report.warnings: + out.append("") + out.append("Warnings (partial data — some queries failed):") + for w in report.warnings: + out.append(f" {w}") + if report.note: + out.append("") + out.append(report.note) + return "\n".join(out) diff --git a/src/weekly_activity/sources/__init__.py b/src/weekly_activity/sources/__init__.py new file mode 100644 index 0000000..70d7ec1 --- /dev/null +++ b/src/weekly_activity/sources/__init__.py @@ -0,0 +1,17 @@ +"""Activity source protocol — each source implements this interface.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Protocol, runtime_checkable + +from weekly_activity.model import ActivityReport + + +@runtime_checkable +class ActivitySource(Protocol): + """A source of weekly activity data (Launchpad, GitHub, etc.).""" + + name: str + + def collect(self, username: str, since: datetime, until: datetime) -> ActivityReport: ... diff --git a/src/weekly_activity/sources/github.py b/src/weekly_activity/sources/github.py new file mode 100644 index 0000000..fa6f88d --- /dev/null +++ b/src/weekly_activity/sources/github.py @@ -0,0 +1,270 @@ +"""GitHub activity source — pull requests, issues, repositories, and commits.""" + +from __future__ import annotations + +import os +import subprocess +from datetime import UTC, datetime +from typing import Any + +import httpx + +from weekly_activity.model import ActivityReport, Section + + +def _discover_token() -> str | None: + """Discover a GitHub token from GITHUB_TOKEN env var or `gh auth token`.""" + token = os.environ.get("GITHUB_TOKEN") + if token: + return token + try: + result = subprocess.run( + ["gh", "auth", "token"], + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return result.stdout.strip() + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + return None + + +def _parse_gh_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 "—" + + +class GitHubSource: + """GitHub activity source implementing ActivitySource.""" + + name = "github" + + def __init__(self, *, token: str | None = None) -> None: + self._token = token or _discover_token() + headers: dict[str, str] = { + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "weekly-activity", + } + if self._token: + headers["Authorization"] = f"Bearer {self._token}" + self._client = httpx.Client( + base_url="https://api.github.com", + headers=headers, + timeout=30.0, + ) + + def _search_issues(self, query: str) -> list[dict[str, Any]]: + response = self._client.get( + "/search/issues", + params={"q": query, "per_page": 100}, + ) + response.raise_for_status() + return response.json().get("items", []) + + def _get_user_repos(self, username: str) -> list[dict[str, Any]]: + response = self._client.get( + f"/users/{username}/repos", + params={"sort": "updated", "per_page": 100}, + ) + response.raise_for_status() + return response.json() + + def _get_commits( + self, owner: str, repo: str, author: str, since_iso: str, until_iso: str + ) -> list[dict[str, Any]]: + response = self._client.get( + f"/repos/{owner}/{repo}/commits", + params={"author": author, "since": since_iso, "until": until_iso, "per_page": 100}, + ) + response.raise_for_status() + return response.json() + + def collect(self, username: str, since: datetime, until: datetime) -> ActivityReport: + since_date = since.strftime("%Y-%m-%d") + until_date = until.strftime("%Y-%m-%d") + iso_since = since.isoformat() + "Z" + iso_until = until.isoformat() + "Z" + 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}" + remaining = exc.response.headers.get("x-ratelimit-remaining") + if remaining == "0": + 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 + + # --- Pull requests authored --- + pr_items = safe( + "PRs authored", + lambda: self._search_issues( + f"author:{username} type:pr created:{since_date}..{until_date}" + ), + ) + pr_entries: list[str] = [] + if pr_items: + for item in pr_items: + state = item.get("state", "unknown") + merged_at = item.get("pull_request", {}).get("merged_at") + if merged_at: + state = "merged" + created = _parse_gh_date(item.get("created_at")) + merged = _parse_gh_date(merged_at) + pr_entries.append( + f" [{state}] {item.get('title', '(untitled)')}\n" + f" created {_fmt_date(created)} · merged {_fmt_date(merged)}\n" + f" {item.get('html_url', '')}" + ) + + # --- Pull requests reviewed --- + review_items = safe( + "PRs reviewed", + lambda: self._search_issues( + f"reviewed-by:{username} type:pr updated:{since_date}..{until_date}" + ), + ) + review_entries: list[str] = [] + if review_items: + for item in review_items: + state = item.get("state", "unknown") + updated = _parse_gh_date(item.get("updated_at")) + review_entries.append( + f" [{state}] {item.get('title', '(untitled)')}\n" + f" updated {_fmt_date(updated)}\n" + f" {item.get('html_url', '')}" + ) + + # --- Issues (reported/commented/assigned) --- + issues: dict[str, dict[str, Any]] = {} + + def add_issue(item: dict[str, Any], role: str) -> None: + url = item.get("html_url", "") + if url in issues: + issues[url]["roles"].add(role) + else: + issues[url] = { + "number": item.get("number"), + "title": item.get("title", "(untitled)"), + "url": url, + "created": _parse_gh_date(item.get("created_at")), + "updated": _parse_gh_date(item.get("updated_at")), + "roles": {role}, + } + + issue_queries: list[tuple[str, str, str]] = [ + ("reported", "author", "created"), + ("commented", "commenter", "updated"), + ("assigned", "assignee", "updated"), + ] + for role, qualifier, date_field in issue_queries: + items = safe( + f"issues ({role})", + lambda r=role, q=qualifier, d=date_field: self._search_issues( + f"{q}:{username} type:issue {d}:{since_date}..{until_date}" + ), + ) + if items: + for item in items: + add_issue(item, role) + + issue_entries = [ + f" [#{info['number']}] {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() + ] + + # --- Repositories modified --- + repo_items = safe("repositories", lambda: self._get_user_repos(username)) + repo_entries: list[str] = [] + active_repos: list[dict[str, Any]] = [] + if repo_items: + for repo in repo_items: + pushed = _parse_gh_date(repo.get("pushed_at")) + if pushed is not None and since <= pushed < until: + full_name = repo.get("full_name", repo.get("name", "(unknown)")) + default_branch = repo.get("default_branch", "—") + repo_entries.append( + f" {full_name}\n" + f" default branch {default_branch} · pushed {_fmt_date(pushed)}\n" + f" {repo.get('html_url', '')}" + ) + active_repos.append(repo) + + # --- Commits pushed --- + all_commits: list[tuple[str, dict[str, Any]]] = [] + for repo in active_repos[:20]: # cap to avoid rate-limiting + full_name = repo.get("full_name", "") + if not full_name: + continue + owner, _, name = full_name.partition("/") + if not name: + continue + commits = safe( + f"commits ({full_name})", + lambda o=owner, n=name: self._get_commits(o, n, username, iso_since, iso_until), + ) + if commits: + for c in commits: + all_commits.append((full_name, c)) + + def commit_date(c: dict[str, Any]) -> datetime | None: + return _parse_gh_date(c.get("commit", {}).get("author", {}).get("date")) + + all_commits.sort(key=lambda x: commit_date(x[1]) or datetime.min, reverse=True) + + commit_entries: list[str] = [] + for full_name, c in all_commits: + message = c.get("commit", {}).get("message", "") + first_line = message.splitlines()[0] if message else "(no message)" + sha = c.get("sha", "")[:7] + date = commit_date(c) + commit_entries.append( + f" {full_name}: {first_line}\n" + f" {sha} · {_fmt_date(date)}\n" + f" {c.get('html_url', '')}" + ) + + if not self._token: + warnings.insert( + 0, + "no GitHub token found (set GITHUB_TOKEN or run `gh auth login`); " + "anonymous rate limit is 60 req/hour", + ) + + return ActivityReport( + source=self.name, + username=username, + since=since, + until=until, + sections=[ + Section("Pull requests authored", pr_entries), + Section("Pull requests reviewed", review_entries), + Section("Issues", issue_entries), + Section("Repositories modified", repo_entries), + Section("Commits pushed", commit_entries), + ], + warnings=warnings, + ) diff --git a/src/launchpad_weekly_activity/api.py b/src/weekly_activity/sources/launchpad.py similarity index 75% rename from src/launchpad_weekly_activity/api.py rename to src/weekly_activity/sources/launchpad.py index 7274961..e2c0844 100644 --- a/src/launchpad_weekly_activity/api.py +++ b/src/weekly_activity/sources/launchpad.py @@ -1,19 +1,15 @@ -"""Typed facade over the (dynamic) launchpadlib web service client. - -launchpadlib exposes ``ws.op`` operations as runtime-bound methods, so the -Launchpad entry objects are deliberately typed as :class:`typing.Any`. Every -public function here returns plain dataclasses, keeping the untyped boundary -confined to this module. -""" +"""Launchpad activity source — merge proposals, git repositories, and bugs.""" from __future__ import annotations from dataclasses import dataclass, field, replace -from datetime import UTC, datetime, timedelta +from datetime import datetime from typing import Any from launchpadlib.launchpad import Launchpad +from weekly_activity.model import ActivityReport, Section + # All BranchMergeProposal queue statuses, per the Launchpad web service docs. MP_STATUSES: tuple[str, ...] = ( "Work in progress", @@ -27,6 +23,9 @@ MP_STATUSES: tuple[str, ...] = ( ) +# --- Source-specific dataclasses (internal) --- + + @dataclass(frozen=True, slots=True) class MergeProposal: web_link: str @@ -58,32 +57,7 @@ class BugRef: roles: frozenset[str] = field(default_factory=frozenset) -@dataclass(frozen=True, slots=True) -class WeeklyActivity: - username: str - since: datetime - until: datetime - authored_mps: list[MergeProposal] - new_review_requests: list[MergeProposal] - performed_reviews: list[MergeProposal] - git_repositories: list[GitRepository] - bugs: list[BugRef] - warnings: list[str] = field(default_factory=list) - - -def last_week_window(now: datetime | None = None) -> tuple[datetime, datetime]: - """Return the last 7 days ending today (rolling week), naive UTC. - - The range is half-open: ``since <= event < until``. ``until`` is midnight - """ - today = ( - (now or datetime.now(UTC)) - .astimezone(UTC) - .replace(tzinfo=None, hour=0, minute=0, second=0, microsecond=0) - ) - since = today - timedelta(days=7) - until = today + timedelta(days=1) - return since, until +# --- Helpers --- def _to_naive_utc(value: object) -> datetime | None: @@ -91,7 +65,7 @@ def _to_naive_utc(value: object) -> datetime | None: if not isinstance(value, datetime): return None if value.tzinfo is not None: - return value.astimezone(UTC).replace(tzinfo=None) + return value.astimezone(value.tzinfo).replace(tzinfo=None) return value @@ -156,18 +130,12 @@ def _bug_from(bug: Any, role: str) -> BugRef: def _summarize_error(exc: BaseException) -> str: - """Produce a concise warning string from a launchpadlib exception. - - launchpadlib's HTTPError.__str__ dumps the full response (headers + HTML - body), which can be thousands of characters. Extract just the status, - reason, and OOPS ID. - """ + """Produce a concise warning string from a launchpadlib exception.""" response = getattr(exc, "response", None) if response is not None: status = getattr(response, "status", None) reason = getattr(response, "reason", "") parts = [f"{type(exc).__name__}: HTTP {status} {reason}".rstrip()] - # Extract OOPS ID from response headers (x-lazr-oopsid). try: oops = response.get("x-lazr-oopsid") or response.get("X-lazr-oopsid") except Exception: # noqa: BLE001 @@ -175,20 +143,33 @@ def _summarize_error(exc: BaseException) -> str: if oops: parts.append(str(oops) if str(oops).startswith("OOPS") else f"OOPS-{oops}") return " ".join(parts) - # Fallback: first line of str(exc) to avoid dumping huge payloads. first_line = str(exc).splitlines()[0] if str(exc) else type(exc).__name__ return f"{type(exc).__name__}: {first_line}" -class LaunchpadClient: - """Read-only Launchpad client wrapping launchpadlib.""" +def _fmt_date(dt: datetime | None) -> str: + return dt.strftime("%Y-%m-%d") if dt is not None else "—" + + +def _fmt_mp(mp: MergeProposal) -> str: + message = (mp.commit_message or "(no commit message)").strip().splitlines()[0] + return f"[{mp.queue_status}] {message}" + + +# --- Source --- + + +class LaunchpadSource: + """Launchpad activity source implementing ActivitySource.""" + + name = "launchpad" def __init__( self, *, - service: str, - anonymous: bool, - application_name: str, + service: str = "production", + anonymous: bool = False, + application_name: str = "weekly-activity", credentials_file: str | None = None, ) -> None: self._anonymous = anonymous @@ -216,7 +197,7 @@ class LaunchpadClient: except KeyError as exc: raise LookupError(f"no Launchpad user named {username!r}") from exc - def collect(self, username: str, since: datetime, until: datetime) -> WeeklyActivity: + def collect(self, username: str, since: datetime, until: datetime) -> ActivityReport: person = self.get_person(username) person_link = _attr_str(person, "self_link") iso_since = since.isoformat() @@ -224,13 +205,13 @@ class LaunchpadClient: warnings: list[str] = [] def safe(label: str, fn: Any) -> Any: - """Run a query; on failure record a warning and return None.""" try: return fn() except Exception as exc: # noqa: BLE001 - degrade section, keep report warnings.append(f"{label}: {_summarize_error(exc)}") return None + # --- Merge proposals authored --- authored_mps: list[MergeProposal] = [] result = safe( "merge proposals", @@ -241,10 +222,16 @@ class LaunchpadClient: if result is not None: authored_mps = [_mp_from(e) for e in result] + mp_entries = [ + f" {_fmt_mp(mp)}\n" + f" created {_fmt_date(mp.date_created)} · merged {_fmt_date(mp.date_merged)}\n" + f" {mp.web_link}" + for mp in authored_mps + ] + + # --- Review requests + performed reviews --- new_review_requests: list[MergeProposal] = [] performed_reviews: list[MergeProposal] = [] - # getRequestedReviews has no server-side date filter — it fetches all - # review requests for the person, which is expensive and may 503. result = safe( "review requests", lambda: list(person.getRequestedReviews(status=list(MP_STATUSES))), @@ -257,8 +244,20 @@ class LaunchpadClient: if _in_window(mp.date_reviewed, since, until): performed_reviews.append(mp) + review_entries = [ + f" {_fmt_mp(mp)}\n" + f" requested {_fmt_date(mp.date_review_requested)}\n" + f" {mp.web_link}" + for mp in new_review_requests + ] + + perf_entries = [ + f" {_fmt_mp(mp)}\n reviewed {_fmt_date(mp.date_reviewed)}\n {mp.web_link}" + for mp in performed_reviews + ] + + # --- Git repositories --- git_repositories: list[GitRepository] = [] - # Query git repositories by owner + modified_since_date. result = safe( "git repositories", lambda: list( @@ -275,6 +274,15 @@ class LaunchpadClient: ): git_repositories.append(repo) + repo_entries = [ + f" {repo.display_name or repo.name or repo.web_link}\n" + f" default branch {repo.default_branch or '—'} · " + f"last modified {_fmt_date(repo.date_last_modified)}\n" + f" {repo.web_link}" + for repo in git_repositories + ] + + # --- Bugs --- bugs: dict[str, BugRef] = {} def add_bug(task: Any, role: str) -> None: @@ -305,14 +313,28 @@ class LaunchpadClient: collect_bug_tasks("commented", bug_commenter=person_link, modified_since=iso_since) collect_bug_tasks("assigned", assignee=person_link, modified_since=iso_since) - return WeeklyActivity( + bug_entries = [ + f" [#{bug.bug_id}] {bug.title}\n" + f" {'/'.join(sorted(bug.roles)) or '?'} · " + f"created {_fmt_date(bug.date_created)} · " + f"updated {_fmt_date(bug.date_last_updated)}\n" + f" {bug.web_link}" + for bug in bugs.values() + ] + + return ActivityReport( + source=self.name, username=username, since=since, until=until, - authored_mps=authored_mps, - new_review_requests=new_review_requests, - performed_reviews=performed_reviews, - git_repositories=git_repositories, - bugs=list(bugs.values()), + sections=[ + Section("Merge proposals authored", mp_entries), + Section("New review requests received", review_entries), + Section("Reviews performed", perf_entries), + Section("Repositories modified", repo_entries), + Section("Bugs", bug_entries), + ], warnings=warnings, + note="Note: commit-level history is not exposed by the Launchpad REST API; " + "read the branch directly (bzr/git) for per-commit activity.", ) diff --git a/uv.lock b/uv.lock index 4298efc..a1ad172 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,19 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "anyio" +version = "4.14.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/cc/a381afa6efea9f496eff839d4a6a1aed3bfafc7b3ab4b0d1b243a12573dd/anyio-4.14.2.tar.gz", hash = "sha256:cfa139f3ed1a23ee8f88a145ddb5ac7605b8bbfd8592baacd7ce3d8bb4313c7f", size = 260176, upload-time = "2026-07-12T20:29:07.082Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/35/f2287558c17e29fafc8ef3daf819bb9834061cfa43bff8014f7df7f63bdc/anyio-4.14.2-py3-none-any.whl", hash = "sha256:9f505dda5ac9f0c8309b5e8bd445a8c2bf7246f3ce950121e45ea15bc41d1494", size = 125813, upload-time = "2026-07-12T20:29:05.763Z" }, +] + [[package]] name = "backports-tarfile" version = "1.2.0" @@ -11,6 +24,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b9/fa/123043af240e49752f1c4bd24da5053b6bd00cad78c2be53c0d1e8b975bc/backports.tarfile-1.2.0-py3-none-any.whl", hash = "sha256:77e284d754527b01fb1e6fa8a1afe577858ebe4e9dad8919e34c862cb399bc34", size = 30181, upload-time = "2024-05-28T17:01:53.112Z" }, ] +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + [[package]] name = "cffi" version = "2.1.1" @@ -131,6 +153,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/12/b3/231ffd4ab1fc9d679809f356cebee130ac7daa00d6d6f3206dd4fd137e9e/distro-1.9.0-py3-none-any.whl", hash = "sha256:7bffd925d65168f85027d8da9af6bddab658135b840670a223589bc0c8ef02b2", size = 20277, upload-time = "2023-12-24T09:54:30.421Z" }, ] +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + [[package]] name = "httplib2" version = "0.32.0" @@ -143,6 +187,30 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/33/a0/550eec327e5f5c7b732531c489f5307efec41f047b0d703bd4ca1e5ad2db/httplib2-0.32.0-py3-none-any.whl", hash = "sha256:dc6705cacdf3fb0a2aba7629fa33c90fd93e30035db0c157325826be177e4816", size = 93148, upload-time = "2026-06-26T10:13:54.985Z" }, ] +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.19" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/f7/abb373e5757eaec4b922b92f97ec8d6d7e057cf06778247604fbc4e7c3f3/idna-3.19.tar.gz", hash = "sha256:5e0811a4383b21dc5838069f801c4fb62113b7447663d2530d2bd6e77b49bf15", size = 215237, upload-time = "2026-08-18T05:14:24.27Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/57/b0/0e52c878c53f245edd3a11020f20979b3f490f245af532c7cae3027754b5/idna-3.19-py3-none-any.whl", hash = "sha256:815e7be7a7806d54abb586dc943addc79e8b2ee16915059658cbeff4b1b43bf4", size = 68550, upload-time = "2026-08-18T05:14:22.343Z" }, +] + [[package]] name = "importlib-metadata" version = "9.0.0" @@ -218,33 +286,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/81/db/e655086b7f3a705df045bf0933bdd9c2f79bb3c97bfef1384598bb79a217/keyring-25.7.0-py3-none-any.whl", hash = "sha256:be4a0b195f149690c166e850609a477c532ddbfbaed96a404d4e43f8d5e2689f", size = 39160, upload-time = "2025-11-16T16:26:08.402Z" }, ] -[[package]] -name = "launchpad-weekly-activity" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "keyring" }, - { name = "launchpadlib" }, -] - -[package.dev-dependencies] -dev = [ - { name = "ruff" }, - { name = "ty" }, -] - -[package.metadata] -requires-dist = [ - { name = "keyring", specifier = ">=25" }, - { name = "launchpadlib", specifier = ">=2.1.0" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "ruff", specifier = ">=0.6" }, - { name = "ty", specifier = ">=0.0.1" }, -] - [[package]] name = "launchpadlib" version = "2.1.0" @@ -391,6 +432,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ea/88/25333bbfea6a5dc064371d2002d3d4807db90b84d5448f9106b2712b0fbc/ty-0.0.73-py3-none-win_arm64.whl", hash = "sha256:e47068f8369dea5d641a26a2ad0a947a320b02ff87099b07e95de0323245a4dc", size = 12443573, upload-time = "2026-08-19T03:12:41.449Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, +] + [[package]] name = "wadllib" version = "2.1.0" @@ -403,6 +453,35 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/f3/00b61e720165f73c6ef3c7a6ea732314c8ca1ba95b9ed1545fd788d9af9b/wadllib-2.1.0-py3-none-any.whl", hash = "sha256:41b58db0bb5fb21e188c7452281ee7b364b2690a4f6c388afea0ceaeafed1132", size = 61997, upload-time = "2026-07-01T10:48:01.37Z" }, ] +[[package]] +name = "weekly-activity" +version = "0.2.0" +source = { editable = "." } +dependencies = [ + { name = "httpx" }, + { name = "keyring" }, + { name = "launchpadlib" }, +] + +[package.dev-dependencies] +dev = [ + { name = "ruff" }, + { name = "ty" }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "keyring", specifier = ">=25" }, + { name = "launchpadlib", specifier = ">=2.1.0" }, +] + +[package.metadata.requires-dev] +dev = [ + { name = "ruff", specifier = ">=0.6" }, + { name = "ty", specifier = ">=0.0.1" }, +] + [[package]] name = "zipp" version = "4.1.0"