0bc734c020
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>
787 lines
28 KiB
Python
Executable File
787 lines
28 KiB
Python
Executable File
#!/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()
|