forked from vhaudiquet/weekly-activity
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
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
# weekly-activity
|
# 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
|
## Usage
|
||||||
|
|
||||||
@@ -19,10 +19,14 @@ uv run weekly-activity gitlab <username>
|
|||||||
uv run weekly-activity gitlab --url https://salsa.debian.org <username>
|
uv run weekly-activity gitlab --url https://salsa.debian.org <username>
|
||||||
uv run weekly-activity gitlab --url https://salsa.debian.org --token glpat-xxx <username>
|
uv run weekly-activity gitlab --url https://salsa.debian.org --token glpat-xxx <username>
|
||||||
|
|
||||||
|
# Debian BTS (bugs.debian.org; identify by the email used on bugs)
|
||||||
|
uv run weekly-activity bts <email>
|
||||||
|
|
||||||
# Custom date range (ISO dates, UTC)
|
# Custom date range (ISO dates, UTC)
|
||||||
uv run weekly-activity launchpad --since 2025-08-01 --until 2025-08-15 <username>
|
uv run weekly-activity launchpad --since 2025-08-01 --until 2025-08-15 <username>
|
||||||
uv run weekly-activity github --since 2025-08-01 --until 2025-08-15 <username>
|
uv run weekly-activity github --since 2025-08-01 --until 2025-08-15 <username>
|
||||||
uv run weekly-activity gitlab --since 2025-08-01 --until 2025-08-15 <username>
|
uv run weekly-activity gitlab --since 2025-08-01 --until 2025-08-15 <username>
|
||||||
|
uv run weekly-activity bts --since 2025-08-01 --until 2025-08-15 <email>
|
||||||
```
|
```
|
||||||
|
|
||||||
### GitLab instances
|
### GitLab instances
|
||||||
@@ -36,14 +40,15 @@ Any GitLab instance works via `--url`. Token discovery order:
|
|||||||
|
|
||||||
## What it reports
|
## What it reports
|
||||||
|
|
||||||
| Section | Launchpad | GitHub | GitLab |
|
| 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) |
|
| Merge proposals / PRs authored | `getMergeProposals` (created in range) | `/search/issues` (`author:` + `type:pr`) | `/merge_requests` (`author_id`, created range) | — |
|
||||||
| Review requests received | `getRequestedReviews` (filtered by `date_review_requested`) | `/search/issues` (`reviewed-by:` + `type:pr`) | — |
|
| Review requests received | `getRequestedReviews` (filtered by `date_review_requested`) | `/search/issues` (`reviewed-by:` + `type:pr`) | — | — |
|
||||||
| Reviews performed | `getRequestedReviews` (filtered by `date_reviewed`) | (same query) | events (`action=approved`) |
|
| 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`) |
|
| Issues / Bugs | `searchTasks` (reporter, commenter, assignee) | `/search/issues` (author, commenter, assignee) | `/issues` (`author_id`, `assignee_id`) | `get_bugs` (`submitter`) + `get_status` (created in range) |
|
||||||
| Repositories modified | `git_repositories.getRepositories` (`modified_since_date`) | `/users/{user}/repos` (filtered by `pushed_at`) | — (covered by push events) |
|
| Bugs owned, updated | — | — | — | `get_bugs` (`owner`) + `get_status` (`log_modified`) |
|
||||||
| Commits pushed | — (not in Launchpad REST API) | `/repos/{owner}/{repo}/commits` (per active repo) | events (`action=pushed`) |
|
| 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
|
### Default time window
|
||||||
|
|
||||||
@@ -55,12 +60,13 @@ Rolling 7 days ending today (inclusive). Override with `--since` / `--until` (IS
|
|||||||
src/weekly_activity/
|
src/weekly_activity/
|
||||||
model.py ActivityReport, Section, last_week_window
|
model.py ActivityReport, Section, last_week_window
|
||||||
report.py format_report() — source-agnostic text renderer
|
report.py format_report() — source-agnostic text renderer
|
||||||
cli.py subcommand dispatch (launchpad | github | gitlab)
|
cli.py subcommand dispatch (launchpad | github | gitlab | bts)
|
||||||
sources/
|
sources/
|
||||||
__init__.py ActivitySource protocol
|
__init__.py ActivitySource protocol
|
||||||
launchpad.py LaunchpadSource (launchpadlib)
|
launchpad.py LaunchpadSource (launchpadlib)
|
||||||
github.py GitHubSource (httpx)
|
github.py GitHubSource (httpx)
|
||||||
gitlab.py GitLabSource (httpx, REST v4 — gitlab.com or self-hosted)
|
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.
|
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
|
- **launchpadlib** — Launchpad REST API client
|
||||||
- **httpx** — GitHub & GitLab REST API client
|
- **httpx** — GitHub & GitLab REST API client
|
||||||
|
- **python-debianbts** — Debian BTS SOAP client
|
||||||
- **ruff** — format + lint (dev)
|
- **ruff** — format + lint (dev)
|
||||||
- **ty** — type check (dev)
|
- **ty** — type check (dev)
|
||||||
|
|||||||
+6
-1
@@ -3,7 +3,12 @@ name = "weekly-activity"
|
|||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
description = "Weekly activity report (merge proposals, pull requests, bugs, issues) for Launchpad and GitHub."
|
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", "httpx>=0.27"]
|
dependencies = [
|
||||||
|
"launchpadlib>=2.1.0",
|
||||||
|
"keyring>=25",
|
||||||
|
"httpx>=0.27",
|
||||||
|
"python-debianbts>=4.1.1",
|
||||||
|
]
|
||||||
|
|
||||||
[project.scripts]
|
[project.scripts]
|
||||||
weekly-activity = "weekly_activity.cli:main"
|
weekly-activity = "weekly_activity.cli:main"
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from datetime import UTC, datetime
|
|||||||
|
|
||||||
from weekly_activity.model import last_week_window
|
from weekly_activity.model import last_week_window
|
||||||
from weekly_activity.report import format_report
|
from weekly_activity.report import format_report
|
||||||
|
from weekly_activity.sources.debian_bts import DebianBtsSource
|
||||||
from weekly_activity.sources.github import GitHubSource
|
from weekly_activity.sources.github import GitHubSource
|
||||||
from weekly_activity.sources.gitlab import GitLabSource
|
from weekly_activity.sources.gitlab import GitLabSource
|
||||||
from weekly_activity.sources.launchpad import LaunchpadSource
|
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).",
|
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)
|
return parser.parse_args(argv)
|
||||||
|
|
||||||
|
|
||||||
@@ -106,6 +110,8 @@ def main() -> None:
|
|||||||
source = GitHubSource(token=args.token)
|
source = GitHubSource(token=args.token)
|
||||||
elif args.source == "gitlab":
|
elif args.source == "gitlab":
|
||||||
source = GitLabSource(url=args.url, token=args.token, token_env=args.token_env)
|
source = GitLabSource(url=args.url, token=args.token, token_env=args.token_env)
|
||||||
|
elif args.source == "bts":
|
||||||
|
source = DebianBtsSource()
|
||||||
else:
|
else:
|
||||||
raise SystemExit(f"error: unknown source {args.source!r}")
|
raise SystemExit(f"error: unknown source {args.source!r}")
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "pywin32-ctypes"
|
name = "pywin32-ctypes"
|
||||||
version = "0.2.3"
|
version = "0.2.3"
|
||||||
@@ -461,6 +470,7 @@ dependencies = [
|
|||||||
{ name = "httpx" },
|
{ name = "httpx" },
|
||||||
{ name = "keyring" },
|
{ name = "keyring" },
|
||||||
{ name = "launchpadlib" },
|
{ name = "launchpadlib" },
|
||||||
|
{ name = "python-debianbts" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dev-dependencies]
|
[package.dev-dependencies]
|
||||||
@@ -474,6 +484,7 @@ requires-dist = [
|
|||||||
{ name = "httpx", specifier = ">=0.27" },
|
{ name = "httpx", specifier = ">=0.27" },
|
||||||
{ name = "keyring", specifier = ">=25" },
|
{ name = "keyring", specifier = ">=25" },
|
||||||
{ name = "launchpadlib", specifier = ">=2.1.0" },
|
{ name = "launchpadlib", specifier = ">=2.1.0" },
|
||||||
|
{ name = "python-debianbts", specifier = ">=4.1.1" },
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.metadata.requires-dev]
|
[package.metadata.requires-dev]
|
||||||
|
|||||||
Reference in New Issue
Block a user