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)
This commit is contained in:
2026-08-25 15:24:10 +02:00
parent bc451f9830
commit 3625229056
11 changed files with 662 additions and 275 deletions
+6 -6
View File
@@ -1,12 +1,12 @@
[project] [project]
name = "launchpad-weekly-activity" name = "weekly-activity"
version = "0.1.0" version = "0.2.0"
description = "Weekly Launchpad activity report (merge proposals, branches, bugs) for a user." description = "Weekly activity report (merge proposals, pull requests, bugs, issues) for Launchpad and GitHub."
requires-python = ">=3.11" requires-python = ">=3.11"
dependencies = ["launchpadlib>=2.1.0", "keyring>=25"] dependencies = ["launchpadlib>=2.1.0", "keyring>=25", "httpx>=0.27"]
[project.scripts] [project.scripts]
launchpad-weekly-activity = "launchpad_weekly_activity.cli:main" weekly-activity = "weekly_activity.cli:main"
[build-system] [build-system]
requires = ["hatchling"] requires = ["hatchling"]
@@ -16,7 +16,7 @@ build-backend = "hatchling.build"
dev = ["ruff>=0.6", "ty>=0.0.1"] dev = ["ruff>=0.6", "ty>=0.0.1"]
[tool.hatch.build.targets.wheel] [tool.hatch.build.targets.wheel]
packages = ["src/launchpad_weekly_activity"] packages = ["src/weekly_activity"]
[tool.ruff] [tool.ruff]
target-version = "py311" target-version = "py311"
@@ -1,3 +0,0 @@
"""Weekly Launchpad activity report generator."""
__version__ = "0.1.0"
-180
View File
@@ -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()
+3
View File
@@ -0,0 +1,3 @@
"""Weekly activity report generator for multiple platforms."""
__version__ = "0.2.0"
+102
View File
@@ -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()
+43
View File
@@ -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
+34
View File
@@ -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)
+17
View File
@@ -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: ...
+270
View File
@@ -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,
)
@@ -1,19 +1,15 @@
"""Typed facade over the (dynamic) launchpadlib web service client. """Launchpad activity source — merge proposals, git repositories, and bugs."""
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.
"""
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field, replace from dataclasses import dataclass, field, replace
from datetime import UTC, datetime, timedelta from datetime import datetime
from typing import Any from typing import Any
from launchpadlib.launchpad import Launchpad from launchpadlib.launchpad import Launchpad
from weekly_activity.model import ActivityReport, Section
# All BranchMergeProposal queue statuses, per the Launchpad web service docs. # All BranchMergeProposal queue statuses, per the Launchpad web service docs.
MP_STATUSES: tuple[str, ...] = ( MP_STATUSES: tuple[str, ...] = (
"Work in progress", "Work in progress",
@@ -27,6 +23,9 @@ MP_STATUSES: tuple[str, ...] = (
) )
# --- Source-specific dataclasses (internal) ---
@dataclass(frozen=True, slots=True) @dataclass(frozen=True, slots=True)
class MergeProposal: class MergeProposal:
web_link: str web_link: str
@@ -58,32 +57,7 @@ class BugRef:
roles: frozenset[str] = field(default_factory=frozenset) roles: frozenset[str] = field(default_factory=frozenset)
@dataclass(frozen=True, slots=True) # --- Helpers ---
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
def _to_naive_utc(value: object) -> datetime | None: 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): if not isinstance(value, datetime):
return None return None
if value.tzinfo is not None: if value.tzinfo is not None:
return value.astimezone(UTC).replace(tzinfo=None) return value.astimezone(value.tzinfo).replace(tzinfo=None)
return value return value
@@ -156,18 +130,12 @@ def _bug_from(bug: Any, role: str) -> BugRef:
def _summarize_error(exc: BaseException) -> str: def _summarize_error(exc: BaseException) -> str:
"""Produce a concise warning string from a launchpadlib exception. """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.
"""
response = getattr(exc, "response", None) response = getattr(exc, "response", None)
if response is not None: if response is not None:
status = getattr(response, "status", None) status = getattr(response, "status", None)
reason = getattr(response, "reason", "") reason = getattr(response, "reason", "")
parts = [f"{type(exc).__name__}: HTTP {status} {reason}".rstrip()] parts = [f"{type(exc).__name__}: HTTP {status} {reason}".rstrip()]
# Extract OOPS ID from response headers (x-lazr-oopsid).
try: try:
oops = response.get("x-lazr-oopsid") or response.get("X-lazr-oopsid") oops = response.get("x-lazr-oopsid") or response.get("X-lazr-oopsid")
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
@@ -175,20 +143,33 @@ def _summarize_error(exc: BaseException) -> str:
if oops: if oops:
parts.append(str(oops) if str(oops).startswith("OOPS") else f"OOPS-{oops}") parts.append(str(oops) if str(oops).startswith("OOPS") else f"OOPS-{oops}")
return " ".join(parts) 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__ first_line = str(exc).splitlines()[0] if str(exc) else type(exc).__name__
return f"{type(exc).__name__}: {first_line}" return f"{type(exc).__name__}: {first_line}"
class LaunchpadClient: def _fmt_date(dt: datetime | None) -> str:
"""Read-only Launchpad client wrapping launchpadlib.""" 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__( def __init__(
self, self,
*, *,
service: str, service: str = "production",
anonymous: bool, anonymous: bool = False,
application_name: str, application_name: str = "weekly-activity",
credentials_file: str | None = None, credentials_file: str | None = None,
) -> None: ) -> None:
self._anonymous = anonymous self._anonymous = anonymous
@@ -216,7 +197,7 @@ class LaunchpadClient:
except KeyError as exc: except KeyError as exc:
raise LookupError(f"no Launchpad user named {username!r}") from 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 = self.get_person(username)
person_link = _attr_str(person, "self_link") person_link = _attr_str(person, "self_link")
iso_since = since.isoformat() iso_since = since.isoformat()
@@ -224,13 +205,13 @@ class LaunchpadClient:
warnings: list[str] = [] warnings: list[str] = []
def safe(label: str, fn: Any) -> Any: def safe(label: str, fn: Any) -> Any:
"""Run a query; on failure record a warning and return None."""
try: try:
return fn() return fn()
except Exception as exc: # noqa: BLE001 - degrade section, keep report except Exception as exc: # noqa: BLE001 - degrade section, keep report
warnings.append(f"{label}: {_summarize_error(exc)}") warnings.append(f"{label}: {_summarize_error(exc)}")
return None return None
# --- Merge proposals authored ---
authored_mps: list[MergeProposal] = [] authored_mps: list[MergeProposal] = []
result = safe( result = safe(
"merge proposals", "merge proposals",
@@ -241,10 +222,16 @@ class LaunchpadClient:
if result is not None: if result is not None:
authored_mps = [_mp_from(e) for e in result] 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] = [] new_review_requests: list[MergeProposal] = []
performed_reviews: 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( result = safe(
"review requests", "review requests",
lambda: list(person.getRequestedReviews(status=list(MP_STATUSES))), lambda: list(person.getRequestedReviews(status=list(MP_STATUSES))),
@@ -257,8 +244,20 @@ class LaunchpadClient:
if _in_window(mp.date_reviewed, since, until): if _in_window(mp.date_reviewed, since, until):
performed_reviews.append(mp) 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] = [] git_repositories: list[GitRepository] = []
# Query git repositories by owner + modified_since_date.
result = safe( result = safe(
"git repositories", "git repositories",
lambda: list( lambda: list(
@@ -275,6 +274,15 @@ class LaunchpadClient:
): ):
git_repositories.append(repo) 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] = {} bugs: dict[str, BugRef] = {}
def add_bug(task: Any, role: str) -> None: 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("commented", bug_commenter=person_link, modified_since=iso_since)
collect_bug_tasks("assigned", assignee=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, username=username,
since=since, since=since,
until=until, until=until,
authored_mps=authored_mps, sections=[
new_review_requests=new_review_requests, Section("Merge proposals authored", mp_entries),
performed_reviews=performed_reviews, Section("New review requests received", review_entries),
git_repositories=git_repositories, Section("Reviews performed", perf_entries),
bugs=list(bugs.values()), Section("Repositories modified", repo_entries),
Section("Bugs", bug_entries),
],
warnings=warnings, 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.",
) )
Generated
+106 -27
View File
@@ -2,6 +2,19 @@ version = 1
revision = 3 revision = 3
requires-python = ">=3.11" 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]] [[package]]
name = "backports-tarfile" name = "backports-tarfile"
version = "1.2.0" 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" }, { 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]] [[package]]
name = "cffi" name = "cffi"
version = "2.1.1" 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" }, { 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]] [[package]]
name = "httplib2" name = "httplib2"
version = "0.32.0" 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" }, { 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]] [[package]]
name = "importlib-metadata" name = "importlib-metadata"
version = "9.0.0" 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" }, { 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]] [[package]]
name = "launchpadlib" name = "launchpadlib"
version = "2.1.0" 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" }, { 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]] [[package]]
name = "wadllib" name = "wadllib"
version = "2.1.0" 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" }, { 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]] [[package]]
name = "zipp" name = "zipp"
version = "4.1.0" version = "4.1.0"