Files
cleveragents-core/tools/pr-stats.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
Applies `ruff format` to the accumulated formatting debt on this branch.
Formatting-only — no behavioral changes. Required for CI/lint's format
gate (`nox -s format -- --check`), which the branch was failing on 288
tracked files that drifted from ruff's canonical style.

In-progress WIP files are intentionally excluded so this commit stays a
clean formatting-only diff.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 00:09:17 -04:00

787 lines
28 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Canonical PR lifecycle statistics for cleveragents/cleveragents-core.
This is the single source of truth for these five metrics:
1. Commits to master (commits on master in window)
2. New PRs opened (created_at in window)
3. Total PRs closed (closed_at in window)
4. PRs merged (closed_at in window AND merged=True)
5. PRs closed without merging (closed_at in window AND merged=False)
Companion to ``tools/count-master-merges.py``. Where count-master-merges answers
"what landed on master", this script answers "what happened across the PR
pipeline": opens, closes, merges, and the raw commit count.
Cross-validation
----------------
Every run asserts the following identities and surfaces any violations. These
are the checks the script exists to catch:
I1. prs_closed_total == prs_merged + prs_closed_without_merging
(tautology by construction, but also reconciled against an independent
secondary query filtering by merged_at in window.)
I2. merged_by_closed_at_filter == merged_by_merged_at_filter
Gitea sets closed_at == merged_at for merged PRs. If these diverge for
any PR, something is wrong.
I3. commits_to_master >= prs_merged
Each merge lands at least one commit on master; rebase merges land N
commits. Direct pushes only add to the master side. Violation means
PRs are being counted as merged without any commit on master — a bug.
I4. (Optional, with ``--cross-check-merges``)
prs_merged matches count-master-merges.py Phase 1+2 event count.
Configuration
-------------
Reads the Forgejo API token from (in order):
1. ``GITEA_TOKEN`` environment variable
2. ``.devcontainer/.env`` file (``GITEA_TOKEN=...``)
3. ``.env`` file in the repo root
Usage
-----
Single window::
python tools/pr-stats.py --hours 48
python tools/pr-stats.py --days 7
python tools/pr-stats.py --since 2026-04-24T04:00:00Z
Multiple windows in one call (for dashboards)::
python tools/pr-stats.py --windows 24h,48h,7d,30d
Weekly buckets for a trailing period::
python tools/pr-stats.py --weekly 12 # last 12 ISO weeks
Output formats::
--format summary # (default) counts + reconciliation, human-readable
--format json # machine-readable; preferred for canvas/report data
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass, field, asdict
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any, Iterable
# Allow importing sibling _pipeline_cache when run as a script from any cwd.
sys.path.insert(0, str(Path(__file__).resolve().parent))
# ─── Config ─────────────────────────────────────────────────────────────────
# Env-driven so fork-mode reporting (`FORGEJO_OWNER=drew` etc.) reads
# from the fork's data. Defaults preserve canonical-mode behaviour.
REPO_OWNER = os.environ.get("FORGEJO_OWNER", "cleveragents")
REPO_NAME = os.environ.get("FORGEJO_REPO", "cleveragents-core")
API_BASE = os.environ.get(
"FORGEJO_API_BASE", "https://git.cleverthis.com/api/v1"
).rstrip("/")
REQUEST_TIMEOUT_SEC = 30
REQUEST_RETRIES = 3
RETRY_BACKOFF_SEC = 2.0
# Safety ceiling on PR pagination; far above any realistic window.
PR_PAGE_HARD_LIMIT = 500
# ─── Helpers (mirror of count-master-merges.py — intentionally kept in sync) ──
def _load_token() -> str:
token = os.environ.get("GITEA_TOKEN")
if token:
return token.strip().strip('"').strip("'")
repo_root = Path(__file__).resolve().parent.parent
for envfile in (repo_root / ".devcontainer" / ".env", repo_root / ".env"):
if not envfile.is_file():
continue
for line in envfile.read_text().splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
if key.strip() == "GITEA_TOKEN":
return value.strip().strip('"').strip("'")
sys.exit(
"ERROR: GITEA_TOKEN not found. Set it in the environment "
"or in .devcontainer/.env (or .env)."
)
def _parse_dt(s: str | None) -> datetime | None:
if not s:
return None
try:
return datetime.fromisoformat(s.replace("Z", "+00:00"))
except (TypeError, ValueError):
return None
def _api_get(url: str, token: str) -> Any:
"""GET with retries on transient network errors (see count-master-merges)."""
import time
req = urllib.request.Request(url, headers={"Authorization": f"token {token}"})
last_err: Exception | None = None
for attempt in range(1, REQUEST_RETRIES + 1):
try:
with urllib.request.urlopen(req, timeout=REQUEST_TIMEOUT_SEC) as resp:
return json.loads(resp.read())
except urllib.error.HTTPError as e:
sys.exit(
f"ERROR: HTTP {e.code} from {url}\n "
f"{e.read().decode('utf-8', 'replace')}"
)
except (urllib.error.URLError, TimeoutError, OSError) as e:
last_err = e
if attempt < REQUEST_RETRIES:
time.sleep(RETRY_BACKOFF_SEC * attempt)
continue
sys.exit(f"ERROR: cannot reach {url} after {REQUEST_RETRIES} attempts: {last_err}")
# ─── Data model ─────────────────────────────────────────────────────────────
@dataclass
class PRStats:
"""The five canonical PR-lifecycle counts + cross-validation results.
All counts are in the window [window_start, window_end).
"""
window_start: str
window_end: str
window_hours: float
# The five metrics the user asked for:
commits_to_master: int
new_prs_opened: int
prs_closed_total: int
prs_merged: int
prs_closed_without_merging: int
# Derived conveniences:
backlog_delta: int # new_prs_opened - prs_closed_total
net_merge_rate_per_day: float # prs_merged / (window_hours / 24)
# Cross-validation flags:
invariant_closed_identity: bool # I1
invariant_merged_at_matches_closed_at: bool # I2
invariant_commits_ge_merges: bool # I3
cross_check_merges_matches_cmm: bool | None # I4 (None = not checked)
# Details for discrepancy drill-down:
merged_count_via_merged_at_filter: int
cmm_reported_merges: int | None = None
# Optional: per-PR lists, only populated when ``detail=True``.
opened_prs: list[dict] = field(default_factory=list)
merged_prs: list[dict] = field(default_factory=list)
closed_only_prs: list[dict] = field(default_factory=list)
# ─── Commits on master ──────────────────────────────────────────────────────
def fetch_master_commits(
cutoff_dt: datetime,
end_dt: datetime,
token: str,
) -> list[dict]:
"""All commits on master with committer date in [cutoff_dt, end_dt).
Does not early-exit on the first old commit in a page — scans the whole
page before declaring the window exhausted. Mirrors the hardened
implementation in count-master-merges.py.
"""
out: list[dict] = []
page = 1
while True:
commits = _api_get(
f"{API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/commits"
f"?sha=master&limit=50&page={page}",
token,
)
if not commits:
break
in_window_this_page = 0
for c in commits:
committed = _parse_dt(c["commit"]["committer"]["date"])
if committed is None:
continue
if cutoff_dt <= committed < end_dt:
out.append(c)
in_window_this_page += 1
elif committed < cutoff_dt:
# Count this toward "this page saw no in-window commits" only
# if nothing in-window this page either.
pass
if in_window_this_page == 0 and all(
(_parse_dt(c["commit"]["committer"]["date"]) or cutoff_dt) < cutoff_dt
for c in commits
):
break
if len(commits) < 50:
break
page += 1
return out
# ─── Opened PRs ─────────────────────────────────────────────────────────────
def fetch_prs_opened_in_window(
cutoff_dt: datetime,
end_dt: datetime,
token: str,
) -> list[dict]:
"""All PRs (any state) whose ``created_at`` falls in [cutoff_dt, end_dt).
Sorted by ``newest`` so we can terminate as soon as created_at < cutoff.
"""
out: list[dict] = []
for page in range(1, PR_PAGE_HARD_LIMIT + 1):
prs = _api_get(
f"{API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/pulls"
f"?state=all&sort=newest&limit=50&page={page}",
token,
)
if not prs:
break
stopped = False
for pr in prs:
created = _parse_dt(pr.get("created_at"))
if created is None:
continue
if created >= end_dt:
# Created after our window end — newer than we care about.
continue
if created < cutoff_dt:
# Older than window start. With sort=newest this is terminal.
stopped = True
break
out.append(pr)
if stopped or len(prs) < 50:
break
return out
# ─── Closed PRs (the union that splits into merged vs closed-only) ─────────
def fetch_prs_closed_in_window(
cutoff_dt: datetime,
end_dt: datetime,
token: str,
) -> list[dict]:
"""All PRs with ``closed_at`` in [cutoff_dt, end_dt).
Uses ``state=closed&sort=recentupdate`` for efficient pagination.
Closing a PR updates ``updated_at``, so any PR closed in-window must
have ``updated_at >= closed_at >= cutoff_dt``. Therefore sorting by
recentupdate and terminating when we see updated_at < cutoff is safe.
However we continue through up to two consecutive "empty" pages to
guard against a single page where every PR has a post-close comment
pushing updated_at above cutoff for unrelated reasons.
"""
out: list[dict] = []
seen_numbers: set[int] = set()
consecutive_empty_pages = 0
for page in range(1, PR_PAGE_HARD_LIMIT + 1):
prs = _api_get(
f"{API_BASE}/repos/{REPO_OWNER}/{REPO_NAME}/pulls"
f"?state=closed&sort=recentupdate&limit=50&page={page}",
token,
)
if not prs:
break
any_in_window = False
min_updated_this_page: datetime | None = None
for pr in prs:
updated = _parse_dt(pr.get("updated_at"))
if updated is not None:
if min_updated_this_page is None or updated < min_updated_this_page:
min_updated_this_page = updated
closed = _parse_dt(pr.get("closed_at"))
if closed is None:
continue
if not (cutoff_dt <= closed < end_dt):
continue
num = pr.get("number")
if num in seen_numbers:
continue
if num is not None:
seen_numbers.add(num)
out.append(pr)
any_in_window = True
if any_in_window:
consecutive_empty_pages = 0
else:
consecutive_empty_pages += 1
# Hard termination: once we see a page where the MOST recently-updated
# PR is below cutoff, no subsequent page can have a closed-in-window
# PR (they're strictly older under recentupdate sort).
if min_updated_this_page is not None and min_updated_this_page < cutoff_dt:
break
if consecutive_empty_pages >= 2 and page > 3:
break
if len(prs) < 50:
break
return out
def split_merged_vs_closed_only(
closed_prs: list[dict],
cutoff_dt: datetime,
end_dt: datetime,
) -> tuple[list[dict], list[dict], int]:
"""Split a list of in-window closed PRs into (merged, closed_without_merging).
Also returns the count you'd get by independently filtering on
``merged_at in window`` — used for invariant I2.
"""
merged: list[dict] = []
closed_only: list[dict] = []
for pr in closed_prs:
was_merged = bool(pr.get("merged"))
if was_merged:
merged.append(pr)
else:
closed_only.append(pr)
# Independent count: PRs whose merged_at is in-window (same dataset).
merged_by_merged_at = sum(
1
for pr in closed_prs
if (m := _parse_dt(pr.get("merged_at"))) is not None and cutoff_dt <= m < end_dt
)
return merged, closed_only, merged_by_merged_at
# ─── Cross-check against count-master-merges ────────────────────────────────
def run_count_master_merges(cutoff_dt: datetime, end_dt: datetime) -> int | None:
"""Shell out to ``tools/count-master-merges.py --since ...`` and return
its PR-merge count. Returns None on any failure (non-fatal cross-check)."""
import subprocess
script = Path(__file__).resolve().parent / "count-master-merges.py"
if not script.is_file():
return None
since = cutoff_dt.strftime("%Y-%m-%dT%H:%M:%SZ")
try:
# NOTE: count-master-merges uses "now" as the end; we pass --since as the
# start. If end_dt < now we'll overcount. We compensate by filtering
# the returned JSON on timestamp.
proc = subprocess.run(
["python3", str(script), "--since", since, "--format", "json"],
capture_output=True,
text=True,
timeout=600,
)
if proc.returncode != 0:
return None
events = json.loads(proc.stdout)
except (subprocess.SubprocessError, json.JSONDecodeError, OSError):
return None
pr_kinds = {
"merge-commit",
"squash-rebase",
"branch-merge",
"integration-branch-merge",
}
end_iso = end_dt.strftime("%Y-%m-%dT%H:%M:%S")
return sum(
1
for e in events
if e.get("kind") in pr_kinds and e.get("timestamp", "") < end_iso
)
# ─── Orchestration ──────────────────────────────────────────────────────────
def compute_stats(
cutoff_dt: datetime,
end_dt: datetime,
token: str,
*,
cross_check_merges: bool = False,
detail: bool = False,
cache: Any | None = None,
) -> PRStats:
"""Compute the canonical stats + run cross-validation.
When ``cache`` is provided, all data comes from the local SQLite cache
(assumed already synced by the caller). Otherwise this falls back to the
live Forgejo API.
"""
if cache is not None:
commits = cache.commits_on_master(cutoff_dt, end_dt)
opened = cache.pulls_opened_in_window(cutoff_dt, end_dt)
closed = cache.pulls_closed_in_window(cutoff_dt, end_dt)
else:
commits = fetch_master_commits(cutoff_dt, end_dt, token)
opened = fetch_prs_opened_in_window(cutoff_dt, end_dt, token)
closed = fetch_prs_closed_in_window(cutoff_dt, end_dt, token)
merged, closed_only, merged_via_merged_at = split_merged_vs_closed_only(
closed,
cutoff_dt,
end_dt,
)
cmm_merges: int | None = None
cross_check_ok: bool | None = None
if cross_check_merges:
cmm_merges = run_count_master_merges(cutoff_dt, end_dt)
if cmm_merges is not None:
cross_check_ok = abs(cmm_merges - len(merged)) <= 1
# One-off tolerance: an edge-of-window PR can land on one side of
# the cutoff here and the other there due to sub-second rounding.
hours = (end_dt - cutoff_dt).total_seconds() / 3600.0
days = hours / 24.0
return PRStats(
window_start=cutoff_dt.isoformat(),
window_end=end_dt.isoformat(),
window_hours=round(hours, 4),
commits_to_master=len(commits),
new_prs_opened=len(opened),
prs_closed_total=len(closed),
prs_merged=len(merged),
prs_closed_without_merging=len(closed_only),
backlog_delta=len(opened) - len(closed),
net_merge_rate_per_day=round(len(merged) / days, 3) if days > 0 else 0.0,
invariant_closed_identity=(len(closed) == len(merged) + len(closed_only)),
invariant_merged_at_matches_closed_at=(len(merged) == merged_via_merged_at),
invariant_commits_ge_merges=(len(commits) >= len(merged)),
cross_check_merges_matches_cmm=cross_check_ok,
merged_count_via_merged_at_filter=merged_via_merged_at,
cmm_reported_merges=cmm_merges,
opened_prs=([_pr_summary(p) for p in opened] if detail else []),
merged_prs=([_pr_summary(p) for p in merged] if detail else []),
closed_only_prs=([_pr_summary(p) for p in closed_only] if detail else []),
)
def _pr_summary(pr: dict) -> dict:
"""Slim representation of a PR for embedding in output (drops bulky fields)."""
return {
"number": pr.get("number"),
"title": pr.get("title"),
"author": (pr.get("user") or {}).get("login"),
"base": (pr.get("base") or {}).get("ref"),
"created_at": pr.get("created_at"),
"closed_at": pr.get("closed_at"),
"merged_at": pr.get("merged_at"),
"merged": bool(pr.get("merged")),
"labels": [lbl["name"] for lbl in (pr.get("labels") or [])],
"additions": pr.get("additions"),
"deletions": pr.get("deletions"),
"changed_files": pr.get("changed_files"),
}
# ─── Multi-window & weekly modes ────────────────────────────────────────────
def parse_window_spec(spec: str, now: datetime) -> datetime:
"""``'48h'`` -> ``now - 48h``; ``'7d'`` -> ``now - 7d``."""
m = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([hd])\s*", spec.lower())
if not m:
raise ValueError(f"bad window spec: {spec!r} (expected e.g. '48h' or '7d')")
n = float(m.group(1))
unit = m.group(2)
delta = timedelta(hours=n) if unit == "h" else timedelta(days=n)
return now - delta
def iso_week_buckets(weeks: int, now: datetime) -> list[tuple[datetime, datetime, str]]:
"""Return ``weeks`` trailing ISO-week [start, end, label) tuples, oldest first.
Week boundary is Monday 00:00 UTC. The current (partial) week is the last
bucket.
"""
# Monday of the current ISO week, 00:00 UTC:
this_monday = now.astimezone(timezone.utc).replace(
hour=0, minute=0, second=0, microsecond=0
) - timedelta(days=now.astimezone(timezone.utc).weekday())
buckets: list[tuple[datetime, datetime, str]] = []
for i in range(weeks - 1, -1, -1):
start = this_monday - timedelta(weeks=i)
end = start + timedelta(weeks=1)
if i == 0:
end = now.astimezone(timezone.utc)
iso_year, iso_week, _ = start.isocalendar()
buckets.append(
(start, end, f"W{iso_week:02d} {start.strftime('%b')} {iso_year}")
)
return buckets
# ─── Output formatting ──────────────────────────────────────────────────────
def _fmt_summary(stats: PRStats) -> str:
lines = [
"=" * 80,
f"PR STATS REPORT ({stats.window_start} -> {stats.window_end})",
f"Window: {stats.window_hours:.1f} hours ({stats.window_hours / 24:.2f} days)",
"=" * 80,
"",
"THE FIVE CANONICAL METRICS",
"-" * 80,
f" 1. Commits to master : {stats.commits_to_master}",
f" 2. New PRs opened : {stats.new_prs_opened}",
f" 3. Total PRs closed : {stats.prs_closed_total}",
f" ├─ merged : {stats.prs_merged}",
f" └─ closed without merging : {stats.prs_closed_without_merging}",
f" 4. Merged PRs : {stats.prs_merged}",
f" 5. Closed without merging : {stats.prs_closed_without_merging}",
"",
"DERIVED",
"-" * 80,
f" Backlog delta (opened closed): {stats.backlog_delta:+d}",
f" Net merge rate : {stats.net_merge_rate_per_day:.2f} PRs/day",
"",
"CROSS-VALIDATION",
"-" * 80,
]
tick = lambda ok: "" if ok else "" # noqa: E731
lines.append(
f" {tick(stats.invariant_closed_identity)} I1 closed_total ({stats.prs_closed_total}) "
f"== merged ({stats.prs_merged}) + closed-only ({stats.prs_closed_without_merging})"
)
lines.append(
f" {tick(stats.invariant_merged_at_matches_closed_at)} I2 merged (by closed_at filter) "
f"= {stats.prs_merged}, merged (by merged_at filter) "
f"= {stats.merged_count_via_merged_at_filter}"
)
lines.append(
f" {tick(stats.invariant_commits_ge_merges)} I3 commits_to_master "
f"({stats.commits_to_master}) >= merged PRs ({stats.prs_merged})"
)
if stats.cross_check_merges_matches_cmm is None:
lines.append(" I4 (not checked — pass --cross-check-merges to enable)")
else:
lines.append(
f" {tick(stats.cross_check_merges_matches_cmm)} I4 count-master-merges.py reports "
f"{stats.cmm_reported_merges} merged PRs (expect {stats.prs_merged}, ±1 tolerance)"
)
all_ok = (
stats.invariant_closed_identity
and stats.invariant_merged_at_matches_closed_at
and stats.invariant_commits_ge_merges
and (stats.cross_check_merges_matches_cmm is not False)
)
lines.append("")
lines.append(
" STATUS: ALL INVARIANTS HOLD ✓"
if all_ok
else " STATUS: ONE OR MORE INVARIANTS VIOLATED — investigate above."
)
lines.append("")
return "\n".join(lines)
def _fmt_summary_multi(results: list[tuple[str, PRStats]]) -> str:
"""Table form for multi-window runs."""
lines = [
"=" * 96,
"PR STATS — MULTI-WINDOW REPORT",
"=" * 96,
"",
f"{'Window':<14} {'Commits':>8} {'Opened':>7} {'Closed':>7} {'Merged':>7} "
f"{'Closed!M':>9} {'ΔBacklog':>9} {'Rate/day':>9} Invariants",
"-" * 96,
]
for label, s in results:
inv = (
("I1" if s.invariant_closed_identity else "x1")
+ " "
+ ("I2" if s.invariant_merged_at_matches_closed_at else "x2")
+ " "
+ ("I3" if s.invariant_commits_ge_merges else "x3")
)
if s.cross_check_merges_matches_cmm is True:
inv += " I4"
elif s.cross_check_merges_matches_cmm is False:
inv += " x4"
lines.append(
f"{label:<14} {s.commits_to_master:>8} {s.new_prs_opened:>7} "
f"{s.prs_closed_total:>7} {s.prs_merged:>7} "
f"{s.prs_closed_without_merging:>9} {s.backlog_delta:>+9d} "
f"{s.net_merge_rate_per_day:>9.2f} {inv}"
)
lines.append("")
return "\n".join(lines)
# ─── CLI ────────────────────────────────────────────────────────────────────
def _parse_args() -> argparse.Namespace:
parser = argparse.ArgumentParser(
description="Canonical PR lifecycle statistics with cross-validation.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=__doc__,
)
mode = parser.add_mutually_exclusive_group()
mode.add_argument("--hours", type=float, help="Look back N hours from now (UTC).")
mode.add_argument("--days", type=float, help="Look back N days from now (UTC).")
mode.add_argument(
"--since",
type=str,
help="Absolute UTC cutoff, ISO 8601 (e.g. 2026-04-24T04:00:00Z).",
)
mode.add_argument(
"--windows",
type=str,
help="Comma-separated list of window specs, e.g. '24h,48h,7d,30d'.",
)
mode.add_argument(
"--weekly",
type=int,
help="Emit stats for the last N trailing ISO weeks.",
)
parser.add_argument(
"--format",
choices=("summary", "json"),
default="summary",
help="Output format (default: summary).",
)
parser.add_argument(
"--detail",
action="store_true",
help="Include per-PR summaries in JSON output (opened/merged/closed-only).",
)
parser.add_argument(
"--cross-check-merges",
action="store_true",
help="Additionally invoke count-master-merges.py to cross-check I4.",
)
parser.add_argument(
"--no-cache",
action="store_true",
help="Bypass the local SQLite cache and hit the API directly "
"(slower; useful for debugging or cross-validation).",
)
parser.add_argument(
"--no-sync",
action="store_true",
help="Use the existing cache without running a delta-sync first.",
)
return parser.parse_args()
def _resolve_window(
args: argparse.Namespace, now: datetime
) -> tuple[datetime, datetime]:
if args.since:
start = _parse_dt(args.since)
if start is None:
sys.exit("ERROR: --since must be ISO 8601 (e.g. 2026-04-24T04:00:00Z).")
if start.tzinfo is None:
start = start.replace(tzinfo=timezone.utc)
return start, now
if args.days is not None:
return now - timedelta(days=args.days), now
if args.hours is not None:
return now - timedelta(hours=args.hours), now
return now - timedelta(hours=24), now
def main() -> None:
args = _parse_args()
now = datetime.now(timezone.utc)
token = _load_token()
cache = None
if not args.no_cache:
try:
from _pipeline_cache import PipelineCache # noqa: WPS433
except ImportError as e:
print(
f"# WARN: cache module unavailable ({e}); falling back to direct API.",
file=sys.stderr,
)
else:
cache = PipelineCache.open()
if not args.no_sync:
print("# Syncing local cache (deltas only) ...", file=sys.stderr)
cache.sync(token, progress=True)
def _run(start: datetime, end: datetime) -> PRStats:
print(
f"# Computing stats for {start.isoformat()}{end.isoformat()} ...",
file=sys.stderr,
)
return compute_stats(
start,
end,
token,
cross_check_merges=args.cross_check_merges,
detail=args.detail,
cache=cache,
)
if args.windows:
specs = [s.strip() for s in args.windows.split(",") if s.strip()]
results: list[tuple[str, PRStats]] = []
for spec in specs:
start = parse_window_spec(spec, now)
results.append((spec, _run(start, now)))
if args.format == "json":
print(
json.dumps(
[{"label": lbl, "stats": asdict(s)} for lbl, s in results],
indent=2,
)
)
else:
print(_fmt_summary_multi(results))
print()
for _, s in results:
print(_fmt_summary(s))
return
if args.weekly:
buckets = iso_week_buckets(args.weekly, now)
results = [(lbl, _run(start, end)) for start, end, lbl in buckets]
if args.format == "json":
print(
json.dumps(
[{"label": lbl, "stats": asdict(s)} for lbl, s in results],
indent=2,
)
)
else:
print(_fmt_summary_multi(results))
return
start, end = _resolve_window(args, now)
stats = _run(start, end)
if args.format == "json":
print(json.dumps(asdict(stats), indent=2))
else:
print(_fmt_summary(stats))
if __name__ == "__main__":
main()