From 98eb6ab542d30874f01f0899ac18733f68fd3690 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Tue, 25 Aug 2026 19:44:56 +0200 Subject: [PATCH] Add Debian BTS source for bugs filed and owned New `bts` subcommand backed by python-debianbts (bugs.debian.org SOAP): - Identify by email address: get_bugs(submitter=...) for bugs filed, get_bugs(owner=...) for bugs owned - Filed filtered on creation date, owned on log_modified; status lookups capped at 500 bugs per collect - Per-query failures degrade to warnings like other sources --- README.md | 27 ++++--- pyproject.toml | 7 +- src/weekly_activity/cli.py | 6 ++ src/weekly_activity/sources/debian_bts.py | 95 +++++++++++++++++++++++ uv.lock | 11 +++ 5 files changed, 135 insertions(+), 11 deletions(-) create mode 100644 src/weekly_activity/sources/debian_bts.py diff --git a/README.md b/README.md index f0a7b8b..bcb3036 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # weekly-activity -Aggregate weekly activity across development platforms (Launchpad, GitHub, GitLab) into a single text report. +Aggregate weekly activity across development platforms (Launchpad, GitHub, GitLab, Debian BTS) into a single text report. ## Usage @@ -19,10 +19,14 @@ 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 +# Debian BTS (bugs.debian.org; identify by the email used on bugs) +uv run weekly-activity bts + # 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 +uv run weekly-activity bts --since 2025-08-01 --until 2025-08-15 ``` ### GitLab instances @@ -36,14 +40,15 @@ Any GitLab instance works via `--url`. Token discovery order: ## What it reports -| 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`) | +| Section | Launchpad | GitHub | GitLab | Debian BTS | +|---|---|---|---|---| +| 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`) | `get_bugs` (`submitter`) + `get_status` (created in range) | +| Bugs owned, updated | — | — | — | `get_bugs` (`owner`) + `get_status` (`log_modified`) | +| 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 @@ -55,12 +60,13 @@ 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 | gitlab) + cli.py subcommand dispatch (launchpad | github | gitlab | bts) sources/ __init__.py ActivitySource protocol launchpad.py LaunchpadSource (launchpadlib) github.py GitHubSource (httpx) gitlab.py GitLabSource (httpx, REST v4 — gitlab.com or self-hosted) + debian_bts.py DebianBtsSource (python-debianbts SOAP) ``` 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. @@ -80,5 +86,6 @@ uv run ty check # type check - **launchpadlib** — Launchpad REST API client - **httpx** — GitHub & GitLab REST API client +- **python-debianbts** — Debian BTS SOAP client - **ruff** — format + lint (dev) - **ty** — type check (dev) diff --git a/pyproject.toml b/pyproject.toml index bc86e32..d6be673 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -3,7 +3,12 @@ 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", "httpx>=0.27"] +dependencies = [ + "launchpadlib>=2.1.0", + "keyring>=25", + "httpx>=0.27", + "python-debianbts>=4.1.1", +] [project.scripts] weekly-activity = "weekly_activity.cli:main" diff --git a/src/weekly_activity/cli.py b/src/weekly_activity/cli.py index ffea967..858fea7 100644 --- a/src/weekly_activity/cli.py +++ b/src/weekly_activity/cli.py @@ -9,6 +9,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.debian_bts import DebianBtsSource from weekly_activity.sources.github import GitHubSource from weekly_activity.sources.gitlab import GitLabSource from weekly_activity.sources.launchpad import LaunchpadSource @@ -68,6 +69,9 @@ def parse_args(argv: Sequence[str] | None = None) -> argparse.Namespace: help="Env var holding the API token (see README for discovery order).", ) + bts = subparsers.add_parser("bts", parents=[base], help="Debian Bug Tracking System activity.") + bts.add_argument("username", help="Email address used on Debian bugs (submitter/owner).") + return parser.parse_args(argv) @@ -106,6 +110,8 @@ def main() -> None: source = GitHubSource(token=args.token) elif args.source == "gitlab": source = GitLabSource(url=args.url, token=args.token, token_env=args.token_env) + elif args.source == "bts": + source = DebianBtsSource() else: raise SystemExit(f"error: unknown source {args.source!r}") diff --git a/src/weekly_activity/sources/debian_bts.py b/src/weekly_activity/sources/debian_bts.py new file mode 100644 index 0000000..4280d97 --- /dev/null +++ b/src/weekly_activity/sources/debian_bts.py @@ -0,0 +1,95 @@ +"""Debian Bug Tracking System activity source — bugs filed and owned. + +Queries bugs.debian.org over SOAP via python-debianbts. Identification is +by email address (the one used on Debian bugs), not a username. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +import debianbts + +from weekly_activity.model import ActivityReport, Section + + +def _to_naive_utc(value: object) -> datetime | None: + """Normalize a datetime (naive or aware) to naive UTC.""" + if not isinstance(value, datetime): + return None + if value.tzinfo is not None: + return value.astimezone(value.tzinfo).replace(tzinfo=None) + return value + + +def _fmt_date(dt: datetime | None) -> str: + return dt.strftime("%Y-%m-%d") if dt is not None else "—" + + +class DebianBtsSource: + """Debian BTS activity source implementing ActivitySource.""" + + name = "debian-bts" + + MAX_BUGS = 500 # safety cap on per-collect status lookups + + def __init__(self, *, server: str = "https://bugs.debian.org") -> None: + self._server = server.rstrip("/") + + def collect(self, username: str, since: datetime, until: datetime) -> ActivityReport: + warnings: list[str] = [] + + def safe(label: str, fn: Any) -> Any: + try: + return fn() + except Exception as exc: # noqa: BLE001 - degrade section, keep report + warnings.append(f"{label}: {type(exc).__name__}: {exc}") + return None + + # --- Bugs filed / owned --- + filed_nums = safe("bugs filed", lambda: debianbts.get_bugs(submitter=username)) or [] + owned_nums = safe("bugs owned", lambda: debianbts.get_bugs(owner=username)) or [] + filed_set = set(filed_nums) + owned_set = set(owned_nums) + + all_nums = sorted(filed_set | owned_set)[: self.MAX_BUGS] + reports: list[Any] = [] + if all_nums: + result = safe("bug status", lambda: debianbts.get_status(all_nums)) + if result is not None: + reports = list(result) + + filed_entries: list[tuple[datetime | None, str]] = [] + owned_entries: list[tuple[datetime | None, str]] = [] + for report in reports: + num = getattr(report, "bug_num", None) + subject = getattr(report, "subject", None) or "(untitled)" + package = getattr(report, "package", None) or "(no package)" + severity = getattr(report, "severity", None) or "unknown" + pending = getattr(report, "pending", None) or "unknown" + created = _to_naive_utc(getattr(report, "date", None)) + modified = _to_naive_utc(getattr(report, "log_modified", None)) + base = f" [#{num}] {subject}\n {package} · {pending} · severity {severity}" + url = f"{self._server}/{num}" + if num in filed_set and created is not None and since <= created < until: + filed_entries.append((created, f"{base} · created {_fmt_date(created)}\n {url}")) + if num in owned_set and modified is not None and since <= modified < until: + owned_entries.append( + (modified, f"{base} · last modified {_fmt_date(modified)}\n {url}") + ) + + filed_entries.sort(key=lambda x: x[0] or datetime.min, reverse=True) + owned_entries.sort(key=lambda x: x[0] or datetime.min, reverse=True) + + return ActivityReport( + source=self.name, + username=username, + since=since, + until=until, + sections=[ + Section("Bugs filed", [entry for _, entry in filed_entries]), + Section("Bugs owned, updated", [entry for _, entry in owned_entries]), + ], + warnings=warnings, + ) diff --git a/uv.lock b/uv.lock index a1ad172..bb9da78 100644 --- a/uv.lock +++ b/uv.lock @@ -360,6 +360,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, ] +[[package]] +name = "python-debianbts" +version = "4.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/2c/10993f887ddf762d46540946e4dca6ccf03b9d1799101622b22379670ca4/python_debianbts-4.1.1.tar.gz", hash = "sha256:f443b18ce2411b373103784715e6707df034f48dad7be387a6917b16e154d793", size = 16585, upload-time = "2024-06-09T07:42:15.973Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7c/b1dfbaa44e8a4a992443dbe21d0c37e77b820ff069a9d0eb9df917934dbd/python_debianbts-4.1.1-py3-none-any.whl", hash = "sha256:9900941d94a41039520fc25847bb74cad92eacf14698c9fbd3f4d2360a1c2161", size = 11063, upload-time = "2024-06-09T07:42:13.822Z" }, +] + [[package]] name = "pywin32-ctypes" version = "0.2.3" @@ -461,6 +470,7 @@ dependencies = [ { name = "httpx" }, { name = "keyring" }, { name = "launchpadlib" }, + { name = "python-debianbts" }, ] [package.dev-dependencies] @@ -474,6 +484,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27" }, { name = "keyring", specifier = ">=25" }, { name = "launchpadlib", specifier = ">=2.1.0" }, + { name = "python-debianbts", specifier = ">=4.1.1" }, ] [package.metadata.requires-dev]