88b9373fa9
Closes the cost-tracking instrumentation gap: the telemetry console's Cost tab read from an empty ``llm_activity`` table because nothing in production wrote to it. The scraper walks the OpenCode session archives that ``_opencode_worker`` already writes (including subagent trees via the BFS-walked ``parentID`` chain) and emits one row per assistant turn. Folded into the existing PR-State Warmer loop so it runs on the same 30s cadence without spinning a new sidecar. Schema (v6): - ``llm_activity`` grows ``session_id`` / ``message_id`` / ``provider`` / ``parent_session_id`` / ``subagent_depth`` columns - Partial UNIQUE INDEX on ``message_id`` makes re-scrapes idempotent - v5→v6 migration ALTER-gated on column existence (safe to re-run) Scraper (``tools/llm_activity_scraper.py``): - Reads ``.dispatcher-logs/sessions/*.json``, one row per assistant turn - Folds reasoning tokens into ``tokens_out`` and cache-write into ``tokens_in`` (preserves raw breakdown in ``raw`` JSON for future cost-calc refinements) - Normalises ``subagent_depth=0`` at top level so dashboards can filter ``WHERE subagent_depth > 0`` cleanly - Batch INSERT OR IGNORE via new ``PipelineCache.upsert_llm_activity_batch`` — one fsync per archive, not per turn Warmer integration: - First tick: full backfill of the archive directory - Subsequent ticks: 1h lookback via ``since=`` filter - Scraper failures are logged and swallowed — PR-state job stays load-bearing and unaffected - ``LLM_ACTIVITY_SCRAPER_DISABLE=1`` env kill switch Renames (mechanical, atomic): - ``tools/_forgejo_cache.py`` → ``tools/_pipeline_cache.py`` - ``ForgejoCache`` class → ``PipelineCache`` - Both reflect the module's broader scope (Forgejo data + pipeline telemetry tables); on-disk filename ``forgejo.sqlite`` and ``FORGEJO_*`` env vars are kept for compatibility Verified end-to-end on real archives: 435 archives → 3595 turns ingested (2873 from subagents) across 8 models / 5 providers / 9 PRs. Re-runs insert 0, dedup 3595. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1155 lines
44 KiB
Python
Executable File
1155 lines
44 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Render pr-velocity.canvas.tsx from the local Forgejo cache.
|
||
|
||
This is the canonical, LLM-free way to refresh the PR velocity canvas.
|
||
It pulls a cache delta, computes every chart/table/stat deterministically,
|
||
fills a template with narrative auto-commentary (overrideable via a TOML
|
||
notes file), and writes the result to the canvas path.
|
||
|
||
Usage
|
||
-----
|
||
|
||
# Refresh the canvas in place (syncs cache first, ~15s + ~1s render):
|
||
python3 tools/render-pr-velocity.py
|
||
|
||
# Use the existing cache without a delta sync (sub-second):
|
||
python3 tools/render-pr-velocity.py --no-sync
|
||
|
||
# Write to a different path (e.g. stdout):
|
||
python3 tools/render-pr-velocity.py --output - | less
|
||
|
||
# Override commentary / headlines via a TOML notes file:
|
||
python3 tools/render-pr-velocity.py --notes tools/pr-velocity-notes.toml
|
||
|
||
Notes file format (all keys optional)
|
||
-------------------------------------
|
||
|
||
# tools/pr-velocity-notes.toml
|
||
[notes]
|
||
hero_title = "custom headline"
|
||
hero_body = "custom paragraph"
|
||
window_stats_note = "..."
|
||
weekly_note = "..."
|
||
monthly_note = "..."
|
||
daily_note = "..."
|
||
last_48h_note = "..."
|
||
open_by_author_note = "..."
|
||
open_age_note = "..."
|
||
top_authors_note = "..."
|
||
oldest_open_note = "..."
|
||
snapshot_body = "..."
|
||
hero_tone = "success" # success | warning | danger | info
|
||
findings_cards_jsx = \"\"\"... raw JSX ...\"\"\" # override the whole cards block
|
||
|
||
Any key not present falls back to a deterministic auto-generated sentence.
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import json
|
||
import subprocess
|
||
import sqlite3
|
||
import sys
|
||
from collections import Counter, defaultdict
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
try:
|
||
import tomllib
|
||
except ModuleNotFoundError: # Python < 3.11
|
||
tomllib = None # type: ignore
|
||
|
||
from zoneinfo import ZoneInfo
|
||
|
||
TOOLS_DIR = Path(__file__).resolve().parent
|
||
sys.path.insert(0, str(TOOLS_DIR))
|
||
|
||
from _pipeline_cache import PipelineCache, DEFAULT_CACHE_PATH # noqa: E402
|
||
|
||
EDT_ZONE = ZoneInfo("America/New_York")
|
||
TEMPLATE_PATH = TOOLS_DIR / "pr-velocity.canvas.template.tsx"
|
||
DEFAULT_NOTES_PATH = TOOLS_DIR / "pr-velocity-notes.toml"
|
||
DEFAULT_OUTPUT_PATH = (
|
||
Path.home()
|
||
/ ".cursor/projects/home-drew-repos-cleveragents-core/canvases/pr-velocity.canvas.tsx"
|
||
)
|
||
|
||
# ── identity consolidation for all-time TOP_AUTHORS ─────────────────────────
|
||
# Multiple git identities map to the same human contributor.
|
||
IDENTITY_MAP = {
|
||
"Brent E. Edwards": "Brent Edwards",
|
||
"Brent Edwards": "Brent Edwards",
|
||
"khyari hamza": "Hamza Khyari",
|
||
"Hamza Khyari": "Hamza Khyari",
|
||
"CleverThis": "CleverThis (bot/HAL)",
|
||
"clever-agent": "CleverThis (bot/HAL)",
|
||
"CleverAgents Build Agent": "CleverThis (bot/HAL)",
|
||
"HAL 9000": "CleverThis (bot/HAL)",
|
||
"HAL9000": "CleverThis (bot/HAL)",
|
||
}
|
||
|
||
|
||
# ── helpers ─────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def _iso(dt_str: str) -> datetime | None:
|
||
if not dt_str:
|
||
return None
|
||
try:
|
||
dt = datetime.fromisoformat(dt_str.replace("Z", "+00:00"))
|
||
except Exception:
|
||
return None
|
||
if dt.tzinfo is None:
|
||
dt = dt.replace(tzinfo=timezone.utc)
|
||
return dt
|
||
|
||
|
||
def _to_edt(dt: datetime) -> datetime:
|
||
return dt.astimezone(EDT_ZONE)
|
||
|
||
|
||
def _fmt_edt(dt: datetime) -> str:
|
||
return _to_edt(dt).strftime("%Y-%m-%d %H:%M %Z")
|
||
|
||
|
||
def _run_cmm(args: list[str]) -> list[dict]:
|
||
"""Invoke count-master-merges.py and parse the JSON body."""
|
||
cmd = [sys.executable, str(TOOLS_DIR / "count-master-merges.py"), "--format", "json", *args]
|
||
out = subprocess.check_output(cmd, text=True)
|
||
idx = out.find("[")
|
||
if idx < 0:
|
||
return []
|
||
return json.loads(out[idx:])
|
||
|
||
|
||
# ── data computations (all deterministic, pure functions of cache state) ───
|
||
|
||
|
||
def compute_header_stats(cache: PipelineCache, now: datetime, window_stats: dict) -> dict:
|
||
con = cache._conn
|
||
rows = con.execute("SELECT created_at FROM pulls WHERE state='open'").fetchall()
|
||
open_total = len(rows)
|
||
ages = []
|
||
for r in rows:
|
||
c = _iso(r[0])
|
||
if c:
|
||
ages.append((now - c).total_seconds() / 86400.0)
|
||
stale14 = sum(1 for a in ages if a > 14)
|
||
stale21 = sum(1 for a in ages if a > 21)
|
||
w7 = window_stats["7d"]
|
||
velocity7d = round(w7["closed"] / 7.0, 1)
|
||
|
||
# Most recently merged PR — shown in the top-row summary as a
|
||
# freshness indicator (replaced the old "Stale (>14d)" stat).
|
||
last_row = con.execute(
|
||
"SELECT merged_at, number FROM pulls "
|
||
"WHERE merged=1 AND merged_at IS NOT NULL "
|
||
"ORDER BY merged_at DESC LIMIT 1"
|
||
).fetchone()
|
||
last_merge_value = "—"
|
||
last_merge_label = "Last PR merged"
|
||
last_merge_tone = "danger"
|
||
if last_row:
|
||
dt_utc = _iso(last_row[0])
|
||
if dt_utc is not None:
|
||
dt_edt = _to_edt(dt_utc)
|
||
hrs_ago = (now - dt_utc).total_seconds() / 3600.0
|
||
# Value: weekday + time for sub-24h merges, month/day for older.
|
||
# Strip any leading zero on the hour ("05:14 AM" → "5:14 AM").
|
||
if hrs_ago < 24:
|
||
last_merge_value = dt_edt.strftime("%a %I:%M %p").replace(" 0", " ")
|
||
else:
|
||
last_merge_value = (
|
||
dt_edt.strftime("%b %d %I:%M %p").replace(" 0", " ")
|
||
)
|
||
# Relative-age suffix in the label.
|
||
if hrs_ago < 1:
|
||
ago = f"{max(int(hrs_ago * 60), 0)}m ago"
|
||
elif hrs_ago < 24:
|
||
ago = f"{hrs_ago:.1f}h ago"
|
||
else:
|
||
ago = f"{hrs_ago / 24:.1f}d ago"
|
||
last_merge_label = f"Last PR merged (#{last_row[1]}, {ago})"
|
||
# Tone telegraphs pipeline freshness: <6h fine, 6-24h watch,
|
||
# ≥24h is a real merge-flow outage.
|
||
if hrs_ago < 6:
|
||
last_merge_tone = "success"
|
||
elif hrs_ago < 24:
|
||
last_merge_tone = "warning"
|
||
else:
|
||
last_merge_tone = "danger"
|
||
|
||
return {
|
||
"open_total": open_total,
|
||
"stale14": stale14,
|
||
"stale21": stale21,
|
||
"velocity7d": velocity7d,
|
||
"last48_merges": window_stats["48h"]["merged"],
|
||
"last_merge_value": last_merge_value,
|
||
"last_merge_label": last_merge_label,
|
||
"last_merge_tone": last_merge_tone,
|
||
}
|
||
|
||
|
||
def compute_window_stats(cache: PipelineCache, now: datetime) -> dict:
|
||
"""Five-metric window stats, one entry per (24h, 48h, 7d, 30d)."""
|
||
windows = [
|
||
("24 h", "24h", timedelta(hours=24)),
|
||
("48 h", "48h", timedelta(hours=48)),
|
||
("7 d", "7d", timedelta(days=7)),
|
||
("30 d", "30d", timedelta(days=30)),
|
||
]
|
||
con = cache._conn
|
||
result: dict[str, dict] = {}
|
||
for label, key, delta in windows:
|
||
start = now - delta
|
||
end = now
|
||
s_iso, e_iso = start.isoformat(), end.isoformat()
|
||
commits = con.execute(
|
||
"SELECT COUNT(*) FROM commits WHERE committer_date >= ? AND committer_date < ?",
|
||
(s_iso, e_iso),
|
||
).fetchone()[0]
|
||
opened = con.execute(
|
||
"SELECT COUNT(*) FROM pulls WHERE created_at >= ? AND created_at < ?",
|
||
(s_iso, e_iso),
|
||
).fetchone()[0]
|
||
closed = con.execute(
|
||
"SELECT COUNT(*) FROM pulls WHERE closed_at >= ? AND closed_at < ?",
|
||
(s_iso, e_iso),
|
||
).fetchone()[0]
|
||
merged = con.execute(
|
||
"SELECT COUNT(*) FROM pulls WHERE closed_at >= ? AND closed_at < ? AND merged=1",
|
||
(s_iso, e_iso),
|
||
).fetchone()[0]
|
||
closed_no = closed - merged
|
||
# Lines of code merged in the window: sum of (additions + deletions)
|
||
# over PRs merged in [start, end). additions/deletions are populated
|
||
# for any PR enriched via Forgejo's pulls/{n} detail endpoint
|
||
# (count-master-merges enriches all in-window PRs on every run, so
|
||
# 30d coverage is essentially 100%). PRs without detail contribute 0
|
||
# via COALESCE so the figure is always defined.
|
||
loc_row = con.execute(
|
||
"SELECT COALESCE(SUM(COALESCE(additions,0) + COALESCE(deletions,0)), 0) "
|
||
"FROM pulls "
|
||
"WHERE closed_at >= ? AND closed_at < ? AND merged=1",
|
||
(s_iso, e_iso),
|
||
).fetchone()
|
||
loc = int(loc_row[0]) if loc_row and loc_row[0] is not None else 0
|
||
result[key] = {
|
||
"label": label,
|
||
"commits": commits,
|
||
"opened": opened,
|
||
"closed": closed,
|
||
"merged": merged,
|
||
"closed_no_merge": closed_no,
|
||
"lines": loc,
|
||
}
|
||
return result
|
||
|
||
|
||
def compute_weekly(cache: PipelineCache, now: datetime, n_weeks: int = 11) -> list[dict]:
|
||
"""N trailing ISO weeks ending at the ISO week containing `now`."""
|
||
today_utc = now.astimezone(timezone.utc)
|
||
this_monday = today_utc - timedelta(days=today_utc.weekday())
|
||
this_monday = this_monday.replace(hour=0, minute=0, second=0, microsecond=0)
|
||
out = []
|
||
con = cache._conn
|
||
for i in range(n_weeks - 1, -1, -1):
|
||
ws = this_monday - timedelta(days=7 * i)
|
||
we = ws + timedelta(days=7)
|
||
iso_year, iso_week, _ = ws.isocalendar()
|
||
commits = con.execute(
|
||
"SELECT COUNT(*) FROM commits WHERE committer_date >= ? AND committer_date < ?",
|
||
(ws.isoformat(), we.isoformat()),
|
||
).fetchone()[0]
|
||
merged = con.execute(
|
||
"SELECT COUNT(*) FROM pulls WHERE closed_at >= ? AND closed_at < ? AND merged=1",
|
||
(ws.isoformat(), we.isoformat()),
|
||
).fetchone()[0]
|
||
push = max(commits - merged, 0)
|
||
# Label like "W17 Apr"
|
||
month_short = ws.strftime("%b")
|
||
label = f"W{iso_week:02d} {month_short}"
|
||
out.append({"week": label, "total": commits, "pr": merged, "push": push})
|
||
return out
|
||
|
||
|
||
def compute_monthly(cache: PipelineCache, n_months: int = 6) -> tuple[list[str], list[int]]:
|
||
"""Last `n_months` months with activity, by master commits."""
|
||
con = cache._conn
|
||
monthly: defaultdict[str, int] = defaultdict(int)
|
||
for (cd,) in con.execute("SELECT committer_date FROM commits"):
|
||
dt = _iso(cd)
|
||
if not dt:
|
||
continue
|
||
monthly[dt.strftime("%Y-%m")] += 1
|
||
keys = sorted(monthly)[-n_months:]
|
||
labels = []
|
||
counts = []
|
||
for k in keys:
|
||
y, m = k.split("-")
|
||
labels.append(f"{datetime(int(y), int(m), 1).strftime('%b')} '{y[-2:]}")
|
||
counts.append(monthly[k])
|
||
return labels, counts
|
||
|
||
|
||
def compute_daily(
|
||
events30: list[dict], cache: PipelineCache, now: datetime, days: int = 30
|
||
) -> list[dict]:
|
||
"""30-day daily throughput in EDT days.
|
||
|
||
``pr`` = PR merge events, bucketed by ``pulls.merged_at`` (actual merge
|
||
moment on master). ``push`` = direct-push events, bucketed by the commit's
|
||
``committer_date`` (no PR record to key off).
|
||
|
||
We do NOT use the event's ``timestamp`` field for PR events because for
|
||
rebase merges that fast-forward, that field is the branch's last-push
|
||
time (hours before the auto-merge fires). Using ``merged_at`` avoids
|
||
shifting late-evening auto-merges into the previous EDT day.
|
||
"""
|
||
now_edt = _to_edt(now).date()
|
||
daily_pr: Counter = Counter()
|
||
daily_push: Counter = Counter()
|
||
con = cache._conn
|
||
for e in events30:
|
||
if e.get("kind") == "direct-push":
|
||
dt = _iso(e.get("timestamp", ""))
|
||
if not dt:
|
||
continue
|
||
daily_push[_to_edt(dt).date()] += 1
|
||
continue
|
||
num = e.get("pr_number")
|
||
if not num:
|
||
continue
|
||
row = con.execute(
|
||
"SELECT merged_at FROM pulls WHERE number=?",
|
||
(num,),
|
||
).fetchone()
|
||
ts_str = row[0] if row and row[0] else e.get("timestamp", "")
|
||
dt = _iso(ts_str)
|
||
if not dt:
|
||
continue
|
||
daily_pr[_to_edt(dt).date()] += 1
|
||
out = []
|
||
for i in range(days - 1, -1, -1):
|
||
d = now_edt - timedelta(days=i)
|
||
out.append(
|
||
{
|
||
"day": f"{d.strftime('%b')} {d.day}",
|
||
"pr": daily_pr.get(d, 0),
|
||
"push": daily_push.get(d, 0),
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def compute_open_age_buckets(cache: PipelineCache, now: datetime) -> list[dict]:
|
||
con = cache._conn
|
||
rows = con.execute("SELECT created_at FROM pulls WHERE state='open'").fetchall()
|
||
buckets = {"<1d": 0, "1–3d": 0, "3–7d": 0, "7–14d": 0, "14–21d": 0, ">21d": 0}
|
||
for (c,) in rows:
|
||
dt = _iso(c)
|
||
if not dt:
|
||
continue
|
||
age = (now - dt).total_seconds() / 86400.0
|
||
if age < 1:
|
||
buckets["<1d"] += 1
|
||
elif age < 3:
|
||
buckets["1–3d"] += 1
|
||
elif age < 7:
|
||
buckets["3–7d"] += 1
|
||
elif age < 14:
|
||
buckets["7–14d"] += 1
|
||
elif age < 21:
|
||
buckets["14–21d"] += 1
|
||
else:
|
||
buckets[">21d"] += 1
|
||
return [{"label": k, "value": v} for k, v in buckets.items()]
|
||
|
||
|
||
def compute_top_authors(cache: PipelineCache, n: int = 9) -> list[dict]:
|
||
con = cache._conn
|
||
counts: Counter = Counter()
|
||
total = 0
|
||
for (raw,) in con.execute("SELECT raw FROM commits"):
|
||
c = json.loads(raw)
|
||
raw_name = (
|
||
(c.get("commit") or {}).get("author", {}).get("name")
|
||
or (c.get("author") or {}).get("login")
|
||
or "unknown"
|
||
)
|
||
canon = IDENTITY_MAP.get(raw_name, raw_name)
|
||
counts[canon] += 1
|
||
total += 1
|
||
out = []
|
||
for author, cnt in counts.most_common(n):
|
||
pct = 100.0 * cnt / total if total else 0.0
|
||
out.append({"author": author, "events": cnt, "pct": f"{pct:.1f}%"})
|
||
return out
|
||
|
||
|
||
def compute_open_by_author(cache: PipelineCache, top: int = 5) -> list[dict]:
|
||
con = cache._conn
|
||
rows = con.execute(
|
||
"SELECT user_login, COUNT(*) FROM pulls WHERE state='open' "
|
||
"GROUP BY user_login ORDER BY COUNT(*) DESC LIMIT ?",
|
||
(top,),
|
||
).fetchall()
|
||
return [{"author": r[0] or "unknown", "open": r[1]} for r in rows]
|
||
|
||
|
||
def compute_oldest_open(cache: PipelineCache, now: datetime, n: int = 10) -> list[dict]:
|
||
con = cache._conn
|
||
rows = con.execute(
|
||
"SELECT number, user_login, created_at, raw FROM pulls WHERE state='open' "
|
||
"ORDER BY created_at ASC LIMIT ?",
|
||
(n,),
|
||
).fetchall()
|
||
out = []
|
||
for r in rows:
|
||
dt = _iso(r[2])
|
||
age = int((now - dt).total_seconds() / 86400.0) if dt else 0
|
||
raw = json.loads(r[3])
|
||
title = raw.get("title", "")
|
||
out.append(
|
||
{
|
||
"n": r[0],
|
||
"author": r[1] or "unknown",
|
||
"ageDays": age,
|
||
"title": title,
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def compute_driver_telemetry(cache: PipelineCache, now: datetime) -> dict:
|
||
"""Aggregate ``merge_cycle`` over the last 7 days for the canvas.
|
||
|
||
Returns a dict with summary counters, a P50 cycle duration, and a
|
||
terminal-state breakdown. When the merge driver hasn't yet emitted any
|
||
cycles, ``cycles_count == 0`` and the canvas hides this section.
|
||
"""
|
||
end = now
|
||
start = now - timedelta(days=7)
|
||
cycles = cache.merge_cycles_in_window(start, end)
|
||
cycles_count = len(cycles)
|
||
merged_states = {"merged", "merged_in_train"}
|
||
merged_count = sum(1 for c in cycles if c.get("terminal_state") in merged_states)
|
||
released_count = cycles_count - merged_count
|
||
|
||
durations = sorted(
|
||
[
|
||
float(c["total_seconds"])
|
||
for c in cycles
|
||
if c.get("total_seconds") is not None
|
||
]
|
||
)
|
||
p50_cycle_s = int(durations[len(durations) // 2]) if durations else 0
|
||
|
||
state_counts: dict[str, int] = {}
|
||
for c in cycles:
|
||
s = c.get("terminal_state") or "unknown"
|
||
state_counts[s] = state_counts.get(s, 0) + 1
|
||
breakdown = []
|
||
for state, count in sorted(state_counts.items(), key=lambda x: -x[1]):
|
||
pct = (count * 100.0 / cycles_count) if cycles_count else 0.0
|
||
breakdown.append({"state": state, "count": count, "pct": f"{pct:.0f}%"})
|
||
|
||
if cycles_count == 0:
|
||
commentary = "No driver activity recorded yet."
|
||
else:
|
||
commentary = (
|
||
f"{merged_count} merge(s), {released_count} release(s) over "
|
||
f"{cycles_count} cycle(s); P50 cycle duration ≈ {p50_cycle_s}s."
|
||
)
|
||
|
||
return {
|
||
"cycles_count": cycles_count,
|
||
"merged_count": merged_count,
|
||
"released_count": released_count,
|
||
"p50_cycle_s": p50_cycle_s,
|
||
"terminal_state_breakdown": breakdown,
|
||
"commentary": commentary,
|
||
}
|
||
|
||
|
||
def compute_conflict_drive_telemetry(
|
||
cache: PipelineCache, now: datetime, days: int = 7
|
||
) -> dict:
|
||
"""Aggregate ``conflict_drive_cycles`` over the last ``days`` for the canvas.
|
||
|
||
Returns a dict with summary counters, a per-day cycle histogram, and an
|
||
outcome breakdown. When the conflict driver hasn't yet emitted any cycles,
|
||
``cycles_count == 0`` and the canvas hides this section.
|
||
|
||
Per plan §10.A.10, the section shows: cycles per day, resolved count,
|
||
escalated count, timeout count, push-rejected count, and breakdown by
|
||
outcome.
|
||
"""
|
||
end = now
|
||
start = now - timedelta(days=days)
|
||
rows = cache.conflict_drive_cycles_in_window(start, end)
|
||
cycles_count = len(rows)
|
||
|
||
success_outcomes = {"resolved", "resolved-no-conflict"}
|
||
resolved_count = sum(1 for r in rows if r.get("outcome") in success_outcomes)
|
||
timeout_count = sum(1 for r in rows if r.get("outcome") == "timeout")
|
||
push_rejected_count = sum(
|
||
1 for r in rows if r.get("outcome") == "lease-violation"
|
||
)
|
||
# Escalations show up as PRs whose latest cycle classified as definite
|
||
# failure that exhausted the 24h budget — operationally, the count of
|
||
# rows whose ``failure_kind == 'definite'`` is a useful upper bound; the
|
||
# exact escalation flip is captured in details_json.terminal_state but
|
||
# we keep this rendering deterministic and JSON-free.
|
||
escalated_count = sum(1 for r in rows if r.get("failure_kind") == "definite")
|
||
|
||
by_day: dict[str, int] = defaultdict(int)
|
||
for r in rows:
|
||
started_at = r.get("started_at")
|
||
if not isinstance(started_at, str):
|
||
continue
|
||
dt = _iso(started_at)
|
||
if dt is None:
|
||
continue
|
||
day_label = _to_edt(dt).strftime("%Y-%m-%d")
|
||
by_day[day_label] += 1
|
||
cycles_per_day = [
|
||
{"day": day, "count": count}
|
||
for day, count in sorted(by_day.items())
|
||
]
|
||
|
||
outcome_counts: dict[str, int] = {}
|
||
for r in rows:
|
||
o = r.get("outcome") or "unknown"
|
||
outcome_counts[o] = outcome_counts.get(o, 0) + 1
|
||
outcome_breakdown = []
|
||
for outcome, count in sorted(outcome_counts.items(), key=lambda x: -x[1]):
|
||
pct = (count * 100.0 / cycles_count) if cycles_count else 0.0
|
||
outcome_breakdown.append(
|
||
{"outcome": outcome, "count": count, "pct": f"{pct:.0f}%"}
|
||
)
|
||
|
||
if cycles_count == 0:
|
||
commentary = "No conflict-driver activity recorded yet."
|
||
else:
|
||
commentary = (
|
||
f"{resolved_count} resolved, {escalated_count} definite failure(s), "
|
||
f"{timeout_count} timeout(s), {push_rejected_count} push-rejected over "
|
||
f"{cycles_count} cycle(s) in the last {days} day(s)."
|
||
)
|
||
|
||
return {
|
||
"days": days,
|
||
"cycles_count": cycles_count,
|
||
"resolved_count": resolved_count,
|
||
"escalated_count": escalated_count,
|
||
"timeout_count": timeout_count,
|
||
"push_rejected_count": push_rejected_count,
|
||
"cycles_per_day": cycles_per_day,
|
||
"outcome_breakdown": outcome_breakdown,
|
||
"commentary": commentary,
|
||
}
|
||
|
||
|
||
def compute_two_day_merges(cache: PipelineCache, events48: list[dict]) -> list[dict]:
|
||
"""48h merge list for the table.
|
||
|
||
Time column = `pulls.merged_at` (the moment Forgejo completed the merge),
|
||
NOT the event timestamp from count-master-merges (which exposes the master
|
||
commit's `committer_date`). For rebase merges that fast-forward — i.e.
|
||
master had not advanced during the PR's life — Forgejo does NOT rewrite
|
||
`committer_date`, so the commit carries the branch's last-push time, which
|
||
can precede the actual merge by hours of CI wait. Using `merged_at` makes
|
||
the table match the "Last PR merged" top stat for the same PR, and makes
|
||
the 48h in/out boundary track the real merge event.
|
||
"""
|
||
con = cache._conn
|
||
rows = []
|
||
for e in events48:
|
||
num = e.get("pr_number")
|
||
if not num:
|
||
continue
|
||
row = con.execute(
|
||
"SELECT merged_at, merged_by_login, user_login, additions, deletions, raw "
|
||
"FROM pulls WHERE number=?",
|
||
(num,),
|
||
).fetchone()
|
||
if not row or not row[0]:
|
||
continue
|
||
ts = _iso(row[0])
|
||
if ts is None:
|
||
continue
|
||
rows.append((ts, num, row))
|
||
rows.sort(key=lambda x: x[0])
|
||
out = []
|
||
for ts, num, row in rows:
|
||
raw = json.loads(row[5])
|
||
title = raw.get("title") or ""
|
||
# Normalize "by" to a short login (HAL9000 over Forgejo committer identity)
|
||
by = row[1] or row[2] or "unknown"
|
||
# Lines committed = additions + deletions (per Forgejo PR detail).
|
||
# Both are populated for in-window PRs because count-master-merges
|
||
# enriches every PR it touches; `None` only happens if a PR was
|
||
# somehow skipped, in which case we fall back to 0 so the table
|
||
# column stays an integer.
|
||
additions = int(row[3]) if row[3] is not None else 0
|
||
deletions = int(row[4]) if row[4] is not None else 0
|
||
ts_edt = _to_edt(ts)
|
||
ts_label = ts_edt.strftime("%a %I:%M %p").replace(" 0", " ")
|
||
out.append(
|
||
{
|
||
"ts": ts_label,
|
||
"pr": num,
|
||
"by": by,
|
||
"lines": additions + deletions,
|
||
"title": title[:80],
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
# ── month-band JSX for the daily chart ──────────────────────────────────────
|
||
|
||
|
||
def month_bands_jsx(daily: list[dict]) -> str:
|
||
"""Emit proportional-flex month labels under the daily bar chart.
|
||
|
||
Groups consecutive days by month and emits one <div style={{ flex: N, textAlign: "center" }}>
|
||
per month with a <Text tone="tertiary"> label inside.
|
||
"""
|
||
bands: list[tuple[str, int]] = []
|
||
current_month: str | None = None
|
||
current_count = 0
|
||
for d in daily:
|
||
month = d["day"].split(" ", 1)[0]
|
||
if month != current_month:
|
||
if current_month is not None:
|
||
bands.append((current_month, current_count))
|
||
current_month = month
|
||
current_count = 1
|
||
else:
|
||
current_count += 1
|
||
if current_month is not None:
|
||
bands.append((current_month, current_count))
|
||
lines = []
|
||
for month, count in bands:
|
||
lines.append(
|
||
f' <div style={{{{ flex: {count}, textAlign: "center" }}}}>\n'
|
||
f' <Text tone="tertiary" size="small" style={{{{ margin: 0 }}}}>{month}</Text>\n'
|
||
f" </div>"
|
||
)
|
||
return "\n".join(lines).lstrip()
|
||
|
||
|
||
# ── narrative auto-commentary ────────────────────────────────────────────────
|
||
|
||
|
||
def _signed(n: int) -> str:
|
||
return f"{n:+d}"
|
||
|
||
|
||
def narrate(data: dict) -> dict[str, str]:
|
||
"""Build deterministic narrative for every commentary slot from the data."""
|
||
ws = data["window_stats"]
|
||
hdr = data["header"]
|
||
w48 = ws["48h"]
|
||
w7 = ws["7d"]
|
||
w30 = ws["30d"]
|
||
|
||
# Merger breakdown from 48h merges
|
||
by_counter = Counter(m["by"] for m in data["two_day_merges"])
|
||
total_48 = sum(by_counter.values())
|
||
top_merger, top_count = (by_counter.most_common(1) or [("", 0)])[0]
|
||
top_pct = round(100.0 * top_count / total_48) if total_48 else 0
|
||
breakdown = " · ".join(f"{n}: {c}" for n, c in by_counter.most_common())
|
||
|
||
# Hero headline + tone
|
||
if top_count >= 10 and top_pct >= 70:
|
||
hero_tone = "success"
|
||
hero_title = (
|
||
f"{top_merger} merge automation active — "
|
||
f"{total_48} PRs merged in last 48h ({top_count} by {top_merger} = {top_pct}%)"
|
||
)
|
||
elif total_48 == 0:
|
||
hero_tone = "warning"
|
||
hero_title = "No PR merges in the last 48h"
|
||
else:
|
||
hero_tone = "info"
|
||
hero_title = (
|
||
f"{total_48} PRs merged in last 48h — "
|
||
f"top merger {top_merger} ({top_count})"
|
||
)
|
||
|
||
net7 = w7["opened"] - w7["closed"]
|
||
direction7 = (
|
||
"net-negative (closing faster than opening)"
|
||
if net7 < 0
|
||
else "net-positive (opening faster than closing)"
|
||
if net7 > 0
|
||
else "balanced"
|
||
)
|
||
merge_rate7 = (100.0 * w7["merged"] / w7["closed"]) if w7["closed"] else 0.0
|
||
merge_rate30 = (100.0 * w30["merged"] / w30["closed"]) if w30["closed"] else 0.0
|
||
|
||
hero_body = (
|
||
f"7-day view: {w7['merged']} PRs merged, "
|
||
f"{w7['closed_no_merge']} PRs closed without merging, "
|
||
f"{w7['commits']} commits to master, "
|
||
f"{w7['opened']} new PRs opened. "
|
||
f"48h: {w48['merged']} merges + {w48['closed_no_merge']} non-merge closes against {w48['opened']} newly opened. "
|
||
f"Backlog: {hdr['open_total']} open PRs ({hdr['stale14']} stale >14d, {hdr['stale21']} stale >21d)."
|
||
)
|
||
|
||
window_stats_note = (
|
||
f"7-day flow is {direction7}: {w7['opened']} opened vs {w7['closed']} closed "
|
||
f"(net {_signed(net7)}). Merge acceptance rate on closed PRs: "
|
||
f"{merge_rate7:.0f}% ({w7['merged']} / {w7['closed']}). "
|
||
f"30-day: {w30['opened']} opened vs {w30['closed']} closed "
|
||
f"(net {_signed(w30['opened'] - w30['closed'])}), {merge_rate30:.0f}% merge rate."
|
||
)
|
||
|
||
weekly = data["weekly"]
|
||
latest_wk = weekly[-1] if weekly else None
|
||
peak_wk = max(weekly, key=lambda w: w["pr"]) if weekly else None
|
||
if latest_wk and peak_wk:
|
||
weekly_note = (
|
||
f"{peak_wk['week']} is the PR-merge peak ({peak_wk['pr']} merges, "
|
||
f"{peak_wk['total']} total events). Current week {latest_wk['week']}: "
|
||
f"{latest_wk['pr']} PR merges + {latest_wk['push']} non-PR commits "
|
||
f"({latest_wk['total']} total)."
|
||
)
|
||
else:
|
||
weekly_note = "No weekly data available."
|
||
|
||
monthly_labels = data["monthly_labels"]
|
||
monthly_counts = data["monthly_counts"]
|
||
if monthly_counts:
|
||
peak_idx = monthly_counts.index(max(monthly_counts))
|
||
if peak_idx == len(monthly_counts) - 1:
|
||
monthly_note = (
|
||
f"Current month ({monthly_labels[-1]}) is the series peak at "
|
||
f"{monthly_counts[-1]:,} commits."
|
||
)
|
||
else:
|
||
monthly_note = (
|
||
f"Peak month in window: {monthly_labels[peak_idx]} with "
|
||
f"{monthly_counts[peak_idx]:,} commits. Current month: "
|
||
f"{monthly_labels[-1]} — {monthly_counts[-1]:,} commits."
|
||
)
|
||
else:
|
||
monthly_note = "No monthly data available."
|
||
|
||
daily = data["daily"]
|
||
day_totals = [(d["day"], d["pr"] + d["push"], d["pr"]) for d in daily]
|
||
peak_day = max(day_totals, key=lambda t: t[1]) if day_totals else None
|
||
today = daily[-1] if daily else None
|
||
if peak_day and today:
|
||
daily_note = (
|
||
f"30-day daily throughput. Peak day: {peak_day[0]} "
|
||
f"({peak_day[1]} events, {peak_day[2]} PR merges). "
|
||
f"Today ({today['day']}): {today['pr']} PR merges, {today['push']} non-PR commits."
|
||
)
|
||
else:
|
||
daily_note = "No daily data available."
|
||
|
||
# 48h range label
|
||
if data["two_day_merges"]:
|
||
first_ts = data["two_day_merges"][0]["ts"]
|
||
last_ts = data["two_day_merges"][-1]["ts"]
|
||
last_48h_range = f"{first_ts} – {last_ts} EDT"
|
||
else:
|
||
last_48h_range = "no merges in last 48h"
|
||
|
||
last_48h_note = f"{total_48} PRs merged. {breakdown}." if total_48 else "No PR merges in the last 48h."
|
||
|
||
open_by_author = data["open_by_author"]
|
||
if open_by_author:
|
||
top_open = open_by_author[0]
|
||
pct_of_backlog = round(100.0 * top_open["open"] / hdr["open_total"]) if hdr["open_total"] else 0
|
||
open_by_author_note = (
|
||
f"{top_open['author']} holds {top_open['open']} of "
|
||
f"{hdr['open_total']} open PRs ({pct_of_backlog}%)."
|
||
)
|
||
else:
|
||
open_by_author_note = "No open PRs."
|
||
|
||
buckets = {b["label"]: b["value"] for b in data["open_age_buckets"]}
|
||
mid_band = buckets.get("7–14d", 0) + buckets.get("14–21d", 0)
|
||
mid_pct = round(100.0 * mid_band / hdr["open_total"]) if hdr["open_total"] else 0
|
||
open_age_note = (
|
||
f"{mid_band} of {hdr['open_total']} open PRs ({mid_pct}%) in the 7–21d band. "
|
||
f"Stale (>14d): {hdr['stale14']}. Oldest cohort (>21d): {hdr['stale21']}."
|
||
)
|
||
|
||
top_authors = data["top_authors"]
|
||
jeff = next((a for a in top_authors if "Freeman" in a["author"]), None)
|
||
hal = next((a for a in top_authors if "HAL" in a["author"] or "CleverThis" in a["author"]), None)
|
||
if jeff and hal:
|
||
top_authors_note = (
|
||
f"All-time master activity: Jeffrey Phillips Freeman leads with "
|
||
f"{jeff['pct']}; HAL bot identities (CleverThis, clever-agent, HAL 9000, CleverAgents Build Agent) "
|
||
f"together account for {hal['pct']}."
|
||
)
|
||
else:
|
||
top_authors_note = "All-time master commit activity by git identity, consolidated across bot and human aliases."
|
||
|
||
oldest = data["oldest_open"]
|
||
if oldest:
|
||
max_age = max(p["ageDays"] for p in oldest)
|
||
min_age = min(p["ageDays"] for p in oldest)
|
||
oldest_open_note = (
|
||
f"Top 10 oldest open PRs ({min_age}–{max_age} days). "
|
||
f"{hdr['stale21']} PRs total above 21 days."
|
||
)
|
||
else:
|
||
oldest_open_note = "No open PRs."
|
||
|
||
snapshot_body = (
|
||
f"{total_48} PR merges in last 48h"
|
||
+ (f" ({top_count} by {top_merger} = {top_pct}%)" if total_48 else "")
|
||
+ f". {w48['commits']} total commits to master in 48h. "
|
||
f"Open backlog: {hdr['open_total']} PRs "
|
||
f"({hdr['stale14']} stale >14d, {hdr['stale21']} stale >21d). "
|
||
f"At {hdr['velocity7d']:.1f} closes/day the backlog clears in "
|
||
f"~{round(hdr['open_total'] / hdr['velocity7d'])} days absent new arrivals; "
|
||
f"net 7-day trend: {_signed(net7)}."
|
||
)
|
||
|
||
# 4 auto-cards
|
||
cards = _default_findings_cards(data, by_counter, direction7, top_merger, top_count, top_pct)
|
||
|
||
return {
|
||
"hero_tone": hero_tone,
|
||
"hero_title": hero_title,
|
||
"hero_body": hero_body,
|
||
"window_stats_note": window_stats_note,
|
||
"weekly_note": weekly_note,
|
||
"monthly_note": monthly_note,
|
||
"daily_note": daily_note,
|
||
"last_48h_range": last_48h_range,
|
||
"last_48h_note": last_48h_note,
|
||
"open_by_author_note": open_by_author_note,
|
||
"open_age_note": open_age_note,
|
||
"top_authors_note": top_authors_note,
|
||
"oldest_open_note": oldest_open_note,
|
||
"snapshot_body": snapshot_body,
|
||
"findings_cards_jsx": cards,
|
||
}
|
||
|
||
|
||
def _default_findings_cards(
|
||
data: dict,
|
||
by_counter: Counter,
|
||
direction7: str,
|
||
top_merger: str,
|
||
top_count: int,
|
||
top_pct: int,
|
||
) -> str:
|
||
hdr = data["header"]
|
||
w7 = data["window_stats"]["7d"]
|
||
w30 = data["window_stats"]["30d"]
|
||
net7 = w7["opened"] - w7["closed"]
|
||
net30 = w30["opened"] - w30["closed"]
|
||
backlog_tone = "success" if net7 < 0 else "warning"
|
||
backlog_label = "Net-negative (good)" if net7 < 0 else "Net-positive"
|
||
cards = [
|
||
(
|
||
"success",
|
||
"Active",
|
||
f"{top_merger} dominant — {top_pct}% of recent merges",
|
||
(
|
||
f"{top_merger} merged {top_count} of {sum(by_counter.values())} PRs over the past 48h. "
|
||
f"7-day totals: {w7['merged']} merges, {w7['closed_no_merge']} non-merge closes, "
|
||
f"{w7['commits']} commits. Most merges are squash or rebase — only detectable via "
|
||
f"SHA cross-reference, not commit-message patterns."
|
||
),
|
||
),
|
||
(
|
||
backlog_tone,
|
||
backlog_label,
|
||
f"7-day flow: {direction7.split(' ')[0]} ({net7:+d})",
|
||
(
|
||
f"7-day: {w7['opened']} opened vs {w7['closed']} closed (net {net7:+d}). "
|
||
f"Backlog is {hdr['open_total']} PRs. At {hdr['velocity7d']:.1f} closes/day the "
|
||
f"backlog clears in ~{round(hdr['open_total'] / hdr['velocity7d'])} days absent new arrivals. "
|
||
f"30-day trend: {net30:+d} ({w30['opened']} opened − {w30['closed']} closed)."
|
||
),
|
||
),
|
||
(
|
||
"danger" if hdr["stale14"] > 100 else "warning",
|
||
"Growing" if hdr["stale14"] > 100 else "Stable",
|
||
f"Stale PRs (>14d): {hdr['stale14']}, >21d: {hdr['stale21']}",
|
||
(
|
||
f"{hdr['stale14']} PRs have been open longer than 14 days; "
|
||
f"{hdr['stale21']} above 21 days. "
|
||
f"Intake pressure from recent weeks continues to age into the stale bands."
|
||
),
|
||
),
|
||
(
|
||
"info",
|
||
"Tooling",
|
||
"Report auto-generated (no LLM in the loop)",
|
||
(
|
||
"This canvas was produced by tools/render-pr-velocity.py: delta-sync the local "
|
||
"SQLite cache, compute every stat/chart/table from cache SQL, fill a template "
|
||
"with deterministic narrative. Re-render with python3 tools/render-pr-velocity.py; "
|
||
"override any callout via tools/pr-velocity-notes.toml."
|
||
),
|
||
),
|
||
]
|
||
jsx_parts = []
|
||
for tone, pill_label, header_text, body_text in cards:
|
||
jsx_parts.append(
|
||
f""" <Card>
|
||
<CardHeader trailing={{<Pill tone="{tone}" active size="sm">{pill_label}</Pill>}}>
|
||
{header_text}
|
||
</CardHeader>
|
||
<CardBody>
|
||
<Text>{body_text}</Text>
|
||
</CardBody>
|
||
</Card>"""
|
||
)
|
||
return "\n\n".join(jsx_parts)
|
||
|
||
|
||
# ── template rendering ──────────────────────────────────────────────────────
|
||
|
||
|
||
def render(data: dict, notes: dict[str, str]) -> str:
|
||
template = TEMPLATE_PATH.read_text()
|
||
narrative = narrate(data)
|
||
# Overrides
|
||
for k, v in notes.items():
|
||
if k in narrative:
|
||
narrative[k] = v
|
||
|
||
now_edt_str = data["updated_at"]
|
||
generator_note = f"rendered at {datetime.now(timezone.utc).isoformat(timespec='seconds')} UTC"
|
||
|
||
subs = {
|
||
"GENERATOR_NOTE": generator_note,
|
||
"UPDATED_AT": now_edt_str,
|
||
"WINDOW_STATS_JSON": json.dumps(
|
||
[data["window_stats"][k] for k in ("24h", "48h", "7d", "30d")],
|
||
indent=2,
|
||
),
|
||
"WEEKLY_JSON": json.dumps(data["weekly"], indent=2),
|
||
"MONTHLY_LABELS_JSON": json.dumps(data["monthly_labels"]),
|
||
"MONTHLY_COUNTS_JSON": json.dumps(data["monthly_counts"]),
|
||
"DAILY_JSON": json.dumps(data["daily"], indent=2),
|
||
"OPEN_AGE_BUCKETS_JSON": json.dumps(data["open_age_buckets"], indent=2),
|
||
"TOP_AUTHORS_JSON": json.dumps(data["top_authors"], indent=2),
|
||
"OPEN_BY_AUTHOR_JSON": json.dumps(data["open_by_author"], indent=2),
|
||
"OLDEST_OPEN_JSON": json.dumps(data["oldest_open"], indent=2),
|
||
"TWO_DAY_MERGES_JSON": json.dumps(data["two_day_merges"], indent=2),
|
||
"DRIVER_TELEMETRY_JSON": json.dumps(data["driver_telemetry"], indent=2),
|
||
"CONFLICT_DRIVE_TELEMETRY_JSON": json.dumps(
|
||
data["conflict_drive_telemetry"], indent=2
|
||
),
|
||
"OPEN_TOTAL": str(data["header"]["open_total"]),
|
||
"VELOCITY_7D": f"{data['header']['velocity7d']:.1f}",
|
||
"LAST_48H_MERGES": str(data["header"]["last48_merges"]),
|
||
"LAST_MERGE_VALUE": data["header"]["last_merge_value"],
|
||
"LAST_MERGE_LABEL": data["header"]["last_merge_label"],
|
||
"LAST_MERGE_TONE": data["header"]["last_merge_tone"],
|
||
"DAILY_MONTH_BANDS_JSX": month_bands_jsx(data["daily"]),
|
||
"HERO_TONE": narrative["hero_tone"],
|
||
"HERO_TITLE": narrative["hero_title"],
|
||
"HERO_BODY": narrative["hero_body"],
|
||
"WINDOW_STATS_NOTE": narrative["window_stats_note"],
|
||
"WEEKLY_NOTE": narrative["weekly_note"],
|
||
"MONTHLY_NOTE": narrative["monthly_note"],
|
||
"DAILY_NOTE": narrative["daily_note"],
|
||
"LAST_48H_RANGE": narrative["last_48h_range"],
|
||
"LAST_48H_NOTE": narrative["last_48h_note"],
|
||
"OPEN_BY_AUTHOR_NOTE": narrative["open_by_author_note"],
|
||
"OPEN_AGE_NOTE": narrative["open_age_note"],
|
||
"TOP_AUTHORS_NOTE": narrative["top_authors_note"],
|
||
"OLDEST_OPEN_NOTE": narrative["oldest_open_note"],
|
||
"SNAPSHOT_BODY": narrative["snapshot_body"],
|
||
"FINDINGS_CARDS_JSX": narrative["findings_cards_jsx"],
|
||
}
|
||
|
||
rendered = template
|
||
for k, v in subs.items():
|
||
rendered = rendered.replace(f"{{{{{k}}}}}", v)
|
||
|
||
# Fail loud if any placeholder remains un-substituted
|
||
import re
|
||
|
||
leftover = re.findall(r"\{\{[A-Z_]+\}\}", rendered)
|
||
if leftover:
|
||
raise RuntimeError(f"Unfilled template placeholders: {sorted(set(leftover))}")
|
||
|
||
return rendered
|
||
|
||
|
||
# ── notes loading ───────────────────────────────────────────────────────────
|
||
|
||
|
||
def load_notes(path: Path) -> dict[str, str]:
|
||
if not path.exists():
|
||
return {}
|
||
if tomllib is None:
|
||
print(f"# WARN: tomllib unavailable; ignoring {path}", file=sys.stderr)
|
||
return {}
|
||
with path.open("rb") as f:
|
||
data = tomllib.load(f)
|
||
notes = data.get("notes") or {}
|
||
# Normalize keys to lower_case
|
||
return {k.lower(): str(v) for k, v in notes.items()}
|
||
|
||
|
||
# ── CLI ─────────────────────────────────────────────────────────────────────
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
p = argparse.ArgumentParser(description=__doc__.split("\n\n", 1)[0])
|
||
p.add_argument("--cache", default=str(DEFAULT_CACHE_PATH), help="Path to SQLite cache.")
|
||
p.add_argument("--notes", default=str(DEFAULT_NOTES_PATH), help="Path to TOML notes file (optional).")
|
||
p.add_argument(
|
||
"--output",
|
||
default=str(DEFAULT_OUTPUT_PATH),
|
||
help="Output path for the rendered .tsx. Use '-' for stdout.",
|
||
)
|
||
p.add_argument("--no-sync", action="store_true", help="Skip cache delta-sync.")
|
||
p.add_argument("--weeks", type=int, default=11, help="Trailing weeks for the weekly chart.")
|
||
p.add_argument("--months", type=int, default=6, help="Trailing months for the monthly chart.")
|
||
p.add_argument("--days", type=int, default=30, help="Trailing days for the daily chart.")
|
||
p.add_argument("--quiet", action="store_true", help="Suppress progress messages.")
|
||
return p.parse_args()
|
||
|
||
|
||
def _log(msg: str, quiet: bool) -> None:
|
||
if not quiet:
|
||
print(f"# {msg}", file=sys.stderr)
|
||
|
||
|
||
def _load_token() -> str:
|
||
"""Load GITEA_TOKEN from .devcontainer/.env or environment."""
|
||
tok = (
|
||
None # type: ignore
|
||
)
|
||
env_path = Path.cwd() / ".devcontainer" / ".env"
|
||
if env_path.exists():
|
||
for line in env_path.read_text().splitlines():
|
||
if line.startswith("GITEA_TOKEN"):
|
||
_, _, val = line.partition("=")
|
||
tok = val.strip().strip('"').strip("'")
|
||
break
|
||
import os as _os
|
||
tok = tok or _os.environ.get("GITEA_TOKEN")
|
||
if not tok:
|
||
raise RuntimeError("GITEA_TOKEN not found in .devcontainer/.env or environment")
|
||
return tok
|
||
|
||
|
||
def main() -> None:
|
||
args = parse_args()
|
||
|
||
cache_path = Path(args.cache)
|
||
_log(f"opening cache at {cache_path}", args.quiet)
|
||
cache = PipelineCache.open(cache_path)
|
||
|
||
if not args.no_sync:
|
||
_log("delta-syncing cache ...", args.quiet)
|
||
token = _load_token()
|
||
summary = cache.sync(token, full=False, backfill_details=False, progress=False)
|
||
commits = summary.get("commits", {}).get("new_commits", 0)
|
||
pulls = summary.get("pulls", {}).get("pulls_upserted", 0)
|
||
_log(f"sync: +{commits} commits, {pulls} PRs upserted", args.quiet)
|
||
|
||
now = datetime.now(timezone.utc)
|
||
updated_at_label = _fmt_edt(now).replace("EDT", "EDT").replace("EST", "EST")
|
||
# Friendlier label: "2026-04-26 07:52 EDT (Sun)"
|
||
updated_at_label = (
|
||
_to_edt(now).strftime("%Y-%m-%d %H:%M %Z (%a)")
|
||
)
|
||
|
||
_log("computing window stats (24h/48h/7d/30d)", args.quiet)
|
||
window_stats = compute_window_stats(cache, now)
|
||
|
||
_log(f"computing weekly ({args.weeks} weeks)", args.quiet)
|
||
weekly = compute_weekly(cache, now, n_weeks=args.weeks)
|
||
|
||
_log(f"computing monthly ({args.months} months)", args.quiet)
|
||
monthly_labels, monthly_counts = compute_monthly(cache, n_months=args.months)
|
||
|
||
# Close the parent cache connection before spawning count-master-merges
|
||
# subprocesses. count-master-merges opens its own SQLite connection and
|
||
# performs writes (enrich_pr → _upsert_pr); holding an idle connection in
|
||
# the parent can race with those writes and trigger "database is locked".
|
||
# Closing + reopening guarantees a single writer at a time.
|
||
cache.close()
|
||
|
||
_log(f"running count-master-merges.py --days {args.days}", args.quiet)
|
||
events_days = _run_cmm(["--days", str(args.days), "--no-sync"])
|
||
|
||
_log("running count-master-merges.py --hours 48", args.quiet)
|
||
events_48h = _run_cmm(["--hours", "48", "--no-sync"])
|
||
|
||
# Reopen the cache for the remaining computations (open_age_buckets,
|
||
# top_authors, two_day_merges, header, …).
|
||
cache = PipelineCache.open(cache_path)
|
||
|
||
_log("computing daily throughput", args.quiet)
|
||
daily = compute_daily(events_days, cache, now, days=args.days)
|
||
|
||
_log("computing open-PR stats", args.quiet)
|
||
open_age_buckets = compute_open_age_buckets(cache, now)
|
||
open_by_author = compute_open_by_author(cache, top=5)
|
||
oldest_open = compute_oldest_open(cache, now, n=10)
|
||
|
||
_log("computing top authors (all-time)", args.quiet)
|
||
top_authors = compute_top_authors(cache, n=9)
|
||
|
||
_log("computing 48h merge detail", args.quiet)
|
||
two_day_merges = compute_two_day_merges(cache, events_48h)
|
||
|
||
_log("computing driver telemetry (Tier 1B)", args.quiet)
|
||
driver_telemetry = compute_driver_telemetry(cache, now)
|
||
|
||
_log("computing conflict-drive telemetry (Tier 1.5)", args.quiet)
|
||
conflict_drive_telemetry = compute_conflict_drive_telemetry(cache, now, days=7)
|
||
|
||
header = compute_header_stats(cache, now, window_stats)
|
||
|
||
data = {
|
||
"updated_at": updated_at_label,
|
||
"window_stats": window_stats,
|
||
"weekly": weekly,
|
||
"monthly_labels": monthly_labels,
|
||
"monthly_counts": monthly_counts,
|
||
"daily": daily,
|
||
"open_age_buckets": open_age_buckets,
|
||
"top_authors": top_authors,
|
||
"open_by_author": open_by_author,
|
||
"oldest_open": oldest_open,
|
||
"two_day_merges": two_day_merges,
|
||
"driver_telemetry": driver_telemetry,
|
||
"conflict_drive_telemetry": conflict_drive_telemetry,
|
||
"header": header,
|
||
}
|
||
|
||
_log("loading notes (if any)", args.quiet)
|
||
notes = load_notes(Path(args.notes))
|
||
if notes:
|
||
_log(f" {len(notes)} note override(s) loaded", args.quiet)
|
||
|
||
_log("rendering template", args.quiet)
|
||
output = render(data, notes)
|
||
|
||
if args.output == "-":
|
||
sys.stdout.write(output)
|
||
else:
|
||
out_path = Path(args.output)
|
||
out_path.parent.mkdir(parents=True, exist_ok=True)
|
||
out_path.write_text(output)
|
||
_log(f"wrote {len(output):,} chars to {out_path}", args.quiet)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|