From 27c8279a2a6a27657730825207b80a96e33ccab7 Mon Sep 17 00:00:00 2001 From: Bob Date: Mon, 14 Sep 2026 09:26:48 +0000 Subject: [PATCH] feat: month-frozen lifetime stats ledger for Bob Public metrics (merged/opened PRs, issues, default-branch commits) from GitHub search, private aggregates (brain commits, sessions) sent from Bob's host via repository_dispatch. Months freeze at month_end + 3 days and are never rewritten. Backfilled 2024-11 through 2026-09. --- .github/workflows/collect.yml | 68 ++++++ .github/workflows/test.yml | 18 ++ .gitignore | 4 + LIFETIME.md | 68 ++++++ README.md | 76 ++++++ charts/commits.svg | 447 ++++++++++++++++++++++++++++++++++ charts/lifetime_prs.svg | 261 ++++++++++++++++++++ charts/prs_merged_monthly.svg | 432 ++++++++++++++++++++++++++++++++ collect_private.py | 190 +++++++++++++++ collect_public.py | 126 ++++++++++ data/ledger.csv | 93 +++++++ data/lifetime.json | 219 +++++++++++++++++ ledger.py | 285 ++++++++++++++++++++++ pyproject.toml | 9 + render.py | 319 ++++++++++++++++++++++++ requirements.txt | 3 + tests/test_ledger.py | 253 +++++++++++++++++++ 17 files changed, 2871 insertions(+) create mode 100644 .github/workflows/collect.yml create mode 100644 .github/workflows/test.yml create mode 100644 .gitignore create mode 100644 LIFETIME.md create mode 100644 README.md create mode 100644 charts/commits.svg create mode 100644 charts/lifetime_prs.svg create mode 100644 charts/prs_merged_monthly.svg create mode 100755 collect_private.py create mode 100755 collect_public.py create mode 100644 data/ledger.csv create mode 100644 data/lifetime.json create mode 100644 ledger.py create mode 100644 pyproject.toml create mode 100755 render.py create mode 100644 requirements.txt create mode 100644 tests/test_ledger.py diff --git a/.github/workflows/collect.yml b/.github/workflows/collect.yml new file mode 100644 index 0000000..a0f91a9 --- /dev/null +++ b/.github/workflows/collect.yml @@ -0,0 +1,68 @@ +name: collect + +on: + schedule: + - cron: "23 5 * * *" + workflow_dispatch: + # Bob's host sends private monthly aggregates (brain_commits, sessions) here; + # see collect_private.py --emit and scripts/stats/bob-stats-sync.sh in the brain repo. + repository_dispatch: + types: [private-aggregates] + +permissions: + contents: write + +concurrency: + group: collect + cancel-in-progress: false + +jobs: + collect: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -r requirements.txt + - run: pytest -q + - name: Save private aggregates payload + if: github.event_name == 'repository_dispatch' + env: + PAYLOAD: ${{ toJSON(github.event.client_payload) }} + run: printf '%s' "$PAYLOAD" > "$RUNNER_TEMP/private.json" + - name: Collect, render, commit if changed + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + EVENT: ${{ github.event_name }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + for attempt in 1 2 3; do + git fetch -q origin master + git reset -q --hard origin/master + rc=0 + if [ "$EVENT" = "repository_dispatch" ]; then + python collect_private.py --apply "$RUNNER_TEMP/private.json" || rc=$? + msg="chore(data): update private aggregates" + else + python collect_public.py || rc=$? + msg="chore(data): update public metrics" + fi + # rc=3: a frozen row would have changed; drift is logged, commit it, then fail. + if [ "$rc" -ne 0 ] && [ "$rc" -ne 3 ]; then exit "$rc"; fi + python render.py + if [ -z "$(git status --porcelain)" ]; then + echo "No changes" + exit "$rc" + fi + git add data charts LIFETIME.md README.md + git commit -q -m "$msg" + if git push -q origin HEAD:master; then + exit "$rc" + fi + echo "Push raced another run; retrying" + sleep $((attempt * 15)) + done + exit 1 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..4046fac --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,18 @@ +name: test + +on: + push: + branches: [master] + pull_request: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + - run: pip install -r requirements.txt + - run: pytest -q diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e29f283 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +__pycache__/ +.pytest_cache/ +.venv/ +*.csv.tmp diff --git a/LIFETIME.md b/LIFETIME.md new file mode 100644 index 0000000..c786503 --- /dev/null +++ b/LIFETIME.md @@ -0,0 +1,68 @@ +# Bob's lifetime numbers + +> **Quote these; don't re-estimate.** Generated by `render.py` from [`data/ledger.csv`](data/ledger.csv), a month-frozen ledger. Do not edit by hand. + +How to cite: + +> As of 2026-09-14: 4,038 merged public PRs (https://github.com/TimeToBuildBob/stats) + +## Lifetime totals + +| Metric | Lifetime | Frozen total (through) | Current month (partial) | As of | Scope | Since | +|---|---:|---:|---:|---|---|---| +| Merged public PRs (`prs_merged_public`) | **4,038** | 3,774 (2026-08) | 264 (2026-09) | 2026-09-14 | public | 2024-11 | +| Opened public PRs (`prs_opened_public`) | **4,386** | 4,100 (2026-08) | 286 (2026-09) | 2026-09-14 | public | 2024-11 | +| Opened public issues (`issues_opened_public`) | **349** | 328 (2026-08) | 21 (2026-09) | 2026-09-14 | public | 2024-11 | +| Public commits (default branches) (`commits_public_default_branch`) | **8,223** | 7,843 (2026-08) | 380 (2026-09) | 2026-09-14 | public | 2024-11 | +| Brain repo commits (private) (`brain_commits`) | **89,128** | 80,879 (2026-08) | 8,249 (2026-09) | 2026-09-14 | private | 2025-08 | +| Agent sessions (private, approximate) (`sessions`) | **18,708** | 16,571 (2026-08) | 2,137 (2026-09) | 2026-09-14 | private | 2026-06 | + +## Definitions + +- **`prs_merged_public`** (public, since 2024-11): PRs authored by TimeToBuildBob in public repos, by merge month (GitHub issue search). +- **`prs_opened_public`** (public, since 2024-11): PRs authored by TimeToBuildBob in public repos, by creation month (any final state). +- **`issues_opened_public`** (public, since 2024-11): Issues authored by TimeToBuildBob in public repos, by creation month. +- **`commits_public_default_branch`** (public, since 2024-11): Commits authored by TimeToBuildBob in public repos by committer date (GitHub commit search). Commit search indexes default branches only; squash merges count as one commit. +- **`brain_commits`** (private, since 2025-08): Commits on master of Bob's private brain repo whose author name is 'Bob' (bob@superuserlabs.org, plus a few early/misconfigured Bob identities), by committer date. Starts 2025-08: earlier Bob work was committed under Erik's identity and cannot be separated. Excludes Erik, other agents, and test-fixture identities (Test etc.). +- **`sessions`** (private, since 2026-06): Bob agent sessions that ran to a result record (state/sessions/*.result.json): agent_id 'bob-*', status 'completed' with reason clean_exit/nonzero_exit/timeout and duration >= 60s, by completed_at month, deduplicated by session_id. Excludes lock/coordination skips and sub-minute startup failures (never did work), watchdog stubs, and ''/test/mock/unknown models. Starts 2026-06: result records begin 2026-05-20, so 2026-05 is incomplete and omitted. + +The exact query or command for every value is stored per row in the ledger's `query` column. + +## Caveats + +- **Month-frozen.** A month is recomputed daily until 3 days after it ends, then frozen and never rewritten. Lifetime = sum of all months (frozen months + the open current month). +- **Public and private are separate scopes.** Never add `brain_commits` to `commits_public_default_branch`, and never add private numbers to public ones. +- **Commit search sees default branches only.** Squash merges collapse a PR's commits into one, so `commits_public_default_branch` undercounts commits actually written. +- **Search counts reflect visibility at compute time.** A repo that goes private or is deleted later does not change frozen months. +- **Private metrics are lower bounds.** `brain_commits` starts 2025-08 (earlier Bob work was committed under Erik's identity); `sessions` starts 2026-06 (no reliable records before) and is approximate. +- Months and date ranges are UTC. "As of" = the date the metric's value last changed. + +## Monthly values + +`*` = open month (still recomputed daily); all other values are frozen. + +| Month | `prs_merged_public` | `prs_opened_public` | `issues_opened_public` | `commits_public_default_branch` | `brain_commits` | `sessions` | +|---|---:|---:|---:|---:|---:|---:| +| 2026-09 | 264* | 286* | 21* | 380* | 8,249* | 2,137* | +| 2026-08 | 472 | 535 | 48 | 675 | 15,663 | 4,754 | +| 2026-07 | 517 | 553 | 51 | 798 | 14,056 | 5,011 | +| 2026-06 | 390 | 411 | 14 | 912 | 16,431 | 6,806 | +| 2026-05 | 559 | 605 | 49 | 1,379 | 15,195 | | +| 2026-04 | 464 | 484 | 30 | 1,024 | 5,479 | | +| 2026-03 | 555 | 575 | 42 | 1,108 | 4,788 | | +| 2026-02 | 378 | 433 | 20 | 811 | 2,046 | | +| 2026-01 | 141 | 155 | 27 | 222 | 1,748 | | +| 2025-12 | 136 | 153 | 28 | 269 | 1,532 | | +| 2025-11 | 73 | 83 | 3 | 309 | 2,097 | | +| 2025-10 | 81 | 103 | 15 | 289 | 1,829 | | +| 2025-09 | 2 | 3 | 0 | 9 | 14 | | +| 2025-08 | 0 | 0 | 0 | 0 | 1 | | +| 2025-07 | 3 | 3 | 0 | 13 | | | +| 2025-06 | 0 | 1 | 0 | 2 | | | +| 2025-05 | 0 | 0 | 0 | 2 | | | +| 2025-04 | 0 | 0 | 1 | 0 | | | +| 2025-03 | 1 | 1 | 0 | 5 | | | +| 2025-02 | 0 | 0 | 0 | 0 | | | +| 2025-01 | 2 | 2 | 0 | 0 | | | +| 2024-12 | 0 | 0 | 0 | 4 | | | +| 2024-11 | 0 | 0 | 0 | 12 | | | diff --git a/README.md b/README.md new file mode 100644 index 0000000..049ed75 --- /dev/null +++ b/README.md @@ -0,0 +1,76 @@ +# Bob's lifetime stats + +Lifetime numbers for [Bob](https://github.com/TimeToBuildBob) (TimeToBuildBob), an autonomous AI agent: PRs, issues, commits and sessions. + +**This repo is the source of truth for Bob's lifetime numbers. Quote them; don't re-estimate them.** + +Estimates like "how many PRs has Bob merged?" used to come out differently depending on when and how they were computed. This repo stores them in a **month-frozen ledger**, so a quoted number stays consistent with every earlier and later quote. + +## Latest numbers + + +| Metric | Lifetime | Current month (partial) | As of | Scope | +|---|---:|---:|---|---| +| Merged public PRs (`prs_merged_public`) | **4,038** | 264 (2026-09) | 2026-09-14 | public | +| Opened public PRs (`prs_opened_public`) | **4,386** | 286 (2026-09) | 2026-09-14 | public | +| Opened public issues (`issues_opened_public`) | **349** | 21 (2026-09) | 2026-09-14 | public | +| Public commits (default branches) (`commits_public_default_branch`) | **8,223** | 380 (2026-09) | 2026-09-14 | public | +| Brain repo commits (private) (`brain_commits`) | **89,128** | 8,249 (2026-09) | 2026-09-14 | private | +| Agent sessions (private, approximate) (`sessions`) | **18,708** | 2,137 (2026-09) | 2026-09-14 | private | + +Definitions and caveats: [LIFETIME.md](LIFETIME.md). Public and private rows are never summed. + + +![Merged public PRs per month](charts/prs_merged_monthly.svg) +![Lifetime public PRs](charts/lifetime_prs.svg) +![Commits per month](charts/commits.svg) + +## How to cite + +Always give the date, and link here: + +> As of YYYY-MM-DD: N merged public PRs (https://github.com/TimeToBuildBob/stats) + +Take `N` and the date from [LIFETIME.md](LIFETIME.md) or [`data/lifetime.json`](data/lifetime.json). If you need a number that isn't here, add a metric instead of estimating it ad hoc. + +## Method: a month-frozen ledger + +[`data/ledger.csv`](data/ledger.csv) holds one row per metric and month: + +``` +metric,month,value,frozen,source,query,computed_at +``` + +- **Recompute only while open.** A month's value is recomputed daily until `month_end + 3 days` (UTC). After that the row is `frozen=true`. +- **Frozen rows never change.** If a recompute of a frozen month gives a different value, the collector refuses to write it. It logs the difference to `data/drift.log` and exits non-zero. +- **Definition changes get a new name.** For example, `prs_merged_public_v2`. History is never rewritten. +- **Lifetime = sum over months.** It is the frozen months plus the current open month. +- **Every row records its exact query**, so any value can be re-derived. + +These rules are enforced in [`ledger.py`](ledger.py) and covered by [`tests/`](tests/). + +## Sources + +| Collector | Where it runs | Metrics | +|---|---|---| +| [`collect_public.py`](collect_public.py) | GitHub Actions, [daily](.github/workflows/collect.yml); uses `GITHUB_TOKEN` only | `prs_merged_public`, `prs_opened_public`, `issues_opened_public`, `commits_public_default_branch` | +| [`collect_private.py`](collect_private.py) | Computed on Bob's host by a daily systemd timer, which reads the private brain repo and sends the totals as a `repository_dispatch` event. Applied by the same workflow | `brain_commits`, `sessions` | + +Every public query includes `is:public`. Private collection sends monthly integer totals only: no repo names, titles or content leave the host. The workflow validates each payload and refuses anything but private integer rows. Only the Actions workflow writes the ledger, and each collector writes only its own metrics. + +[`render.py`](render.py) regenerates the charts, [LIFETIME.md](LIFETIME.md), [`data/lifetime.json`](data/lifetime.json) (for websites and profile READMEs) and the table above. The output is deterministic, so a commit only happens when a number changes. + +## Caveats + +- **Public and private are never summed.** `brain_commits` (a private repo) and `commits_public_default_branch` measure different things. +- **Commit search sees default branches only.** Squash merges collapse a PR into one commit, so public commits undercount the commits Bob actually wrote. +- **`sessions` is approximate and a lower bound.** It starts at 2026-06, the first month with reliable session records. `brain_commits` starts at 2025-08, when Bob began committing under his own identity. +- **Frozen months are fixed at freeze time.** A repo made private or deleted later doesn't change them. + +The exact per-metric definitions are in [LIFETIME.md](LIFETIME.md). + +## Related + +- [gptme/stats](https://github.com/gptme/stats): project stats for gptme (stars, downloads, releases). That is a separate ledger, and its numbers are never mixed with Bob's. +- [Bob's timeline](https://timetobuildbob.github.io/timeline/): month-by-month highlights. The source is [`_data/timeline.yml`](https://github.com/TimeToBuildBob/TimeToBuildBob.github.io/blob/master/_data/timeline.yml). +- [Bob's profile README](https://github.com/TimeToBuildBob) diff --git a/charts/commits.svg b/charts/commits.svg new file mode 100644 index 0000000..315c08d --- /dev/null +++ b/charts/commits.svg @@ -0,0 +1,447 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.2, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + 250 + + + + + + + + + + + + + 500 + + + + + + + + + + + + + 750 + + + + + + + + + + + + + 1,000 + + + + + + + + + + + + + 1,250 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Public commits, default branches (GitHub commit search) + + + + + + + + + + + 2025-01 + + + + + + 2025-04 + + + + + + 2025-07 + + + + + + 2025-10 + + + + + + 2026-01 + + + + + + 2026-04 + + + + + + 2026-07 + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + 5,000 + + + + + + + + + + + + + 10,000 + + + + + + + + + + + + + 15,000 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Brain repo commits (private repo, Bob identities only) + + + + Commits per month — two separate scopes, never summed (last point = open month) + + + + + + + + + + + diff --git a/charts/lifetime_prs.svg b/charts/lifetime_prs.svg new file mode 100644 index 0000000..1598d3f --- /dev/null +++ b/charts/lifetime_prs.svg @@ -0,0 +1,261 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.2, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + 2025-01 + + + + + + 2025-04 + + + + + + 2025-07 + + + + + + 2025-10 + + + + + + 2026-01 + + + + + + 2026-04 + + + + + + 2026-07 + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + 1,000 + + + + + + + + + + + + + 2,000 + + + + + + + + + + + + + 3,000 + + + + + + + + + + + + + 4,000 + + + + + + + + + + + + + + 4,038 + + + 4,386 + + + TimeToBuildBob · sum of monthly ledger rows · as of 2026-09-14 + + + Lifetime public PRs (cumulative) + + + + + + + Merged public PRs + + + + + + Opened public PRs + + + + + + + + + + diff --git a/charts/prs_merged_monthly.svg b/charts/prs_merged_monthly.svg new file mode 100644 index 0000000..53a872e --- /dev/null +++ b/charts/prs_merged_monthly.svg @@ -0,0 +1,432 @@ + + + + + + + + image/svg+xml + + + Matplotlib v3.11.2, https://matplotlib.org/ + + + + + + + + + + + + + + + + + + + + + 2025-01 + + + + + + 2025-04 + + + + + + 2025-07 + + + + + + 2025-10 + + + + + + 2026-01 + + + + + + 2026-04 + + + + + + 2026-07 + + + + + + + + + + + + + + + + + + 0 + + + + + + + + + + + + + 100 + + + + + + + + + + + + + 200 + + + + + + + + + + + + + 300 + + + + + + + + + + + + + 400 + + + + + + + + + + + + + 500 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 559 + + + 264 (open) + + + TimeToBuildBob · hatched = open month, still recomputed · as of 2026-09-14 + + + Merged public PRs per month + + + + + + + + + + + + + + + diff --git a/collect_private.py b/collect_private.py new file mode 100755 index 0000000..43ac2c7 --- /dev/null +++ b/collect_private.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 +"""Collect Bob's private aggregates (brain repo) for data/ledger.csv, in two halves. + +``--emit`` runs ON Bob's host (GitHub Actions cannot read the private brain repo): +it computes every month the ledger still accepts and writes a repository_dispatch +body. Only monthly integer totals leave the host: no repo names, titles, paths or +content. + +``--apply`` runs in the collect workflow: it validates the dispatched payload +(private metrics, non-negative integers, sane months, nothing else) and records it +under the ledger's freeze rules. Frozen rows are never modified; a drift is logged +and the exit code is 3. Only private metric rows are touched. +""" + +from __future__ import annotations + +import argparse +import glob +import json +import re +import subprocess +import sys +from collections import defaultdict +from datetime import timedelta +from pathlib import Path + +import ledger as L + +# Author names/emails Bob has committed under on the brain repo's master. +# Matched with --perl-regexp against "Name "; name "Bob" covers +# bob@superuserlabs.org (canonical), bob@example.com (2025-10 misconfig), +# bob@gptme.org and one "Bob ". Erik, Alice and test-fixture +# identities (Test , Test User, AI Assistant) are excluded. +BOB_AUTHOR_REGEX = "^Bob <" +BRAIN_REF = "master" + +SESSION_REASONS = {"clean_exit", "nonzero_exit", "timeout"} +SESSION_MIN_SECONDS = 60 +BAD_MODEL = re.compile(r"|\b(test|mock|unknown|fake|dummy)\b", re.IGNORECASE) + + +def brain_commits(brain: Path, month: str) -> tuple[int, str]: + since = L.iso(L.month_start(month)) + until = L.iso(L.month_end(month) - timedelta(seconds=1)) + args = [ + "rev-list", + "--count", + "--perl-regexp", + f"--author={BOB_AUTHOR_REGEX}", + f"--since={since}", + f"--until={until}", + BRAIN_REF, + ] + out = subprocess.run(["git", "-C", str(brain), *args], check=True, capture_output=True, text=True) + query = "git -C " + " ".join(a if " " not in a else f"'{a}'" for a in args) + return int(out.stdout.strip()), query + + +def session_counts(brain: Path) -> dict[str, int]: + """Monthly counts of real sessions from state/sessions/*.result.json.""" + seen: dict[str, str] = {} + for p in glob.glob(str(brain / "state" / "sessions" / "*.result.json")): + try: + with open(p) as f: + d = json.load(f) + except (OSError, ValueError): + continue + if not include_session(d): + continue + sid = d.get("session_id") or Path(p).name + seen.setdefault(sid, d["completed_at"][:7]) + counts: dict[str, int] = defaultdict(int) + for month in seen.values(): + counts[month] += 1 + return counts + + +def include_session(d: dict) -> bool: + if not str(d.get("agent_id") or "").startswith("bob-"): + return False + if d.get("status") != "completed" or d.get("reason") not in SESSION_REASONS: + return False + if not d.get("completed_at"): + return False + if (d.get("duration_seconds") or 0) < SESSION_MIN_SECONDS: + return False + model = d.get("model") + return model is None or not BAD_MODEL.search(str(model)) + + +SESSIONS_QUERY = ( + "state/sessions/*.result.json where agent_id~^bob- and status=completed and " + "reason in (clean_exit,nonzero_exit,timeout) and duration_seconds>=60 and " + "model !~ |\\b(test|mock|unknown|fake|dummy)\\b; month(completed_at UTC); dedup session_id" +) + + +EVENT_TYPE = "private-aggregates" +MAX_QUERY_LEN = 1000 + + +def compute_rows(brain: Path, led: L.Ledger, now, verify_frozen: bool = False) -> list[dict]: + """Compute private rows for every month the ledger still accepts (missing or open).""" + rows = [] + sessions = None + for metric in L.PRIVATE_METRICS: + for month in L.months_range(L.METRICS[metric]["first_month"], L.month_of(now)): + if not led.needs_compute(metric, month) and not verify_frozen: + continue + if metric == "brain_commits": + value, query = brain_commits(brain, month) + elif metric == "sessions": + if sessions is None: + sessions = session_counts(brain) + value, query = sessions.get(month, 0), SESSIONS_QUERY + else: # pragma: no cover + raise KeyError(metric) + rows.append({"metric": metric, "month": month, "value": value, "query": query}) + return rows + + +def validate_rows(payload: object, now) -> list[dict]: + """Validate a dispatched payload. Only private metrics with integer totals are accepted.""" + if not isinstance(payload, dict) or not isinstance(payload.get("rows"), list): + raise ValueError("payload must be an object with a 'rows' list") + rows = [] + for r in payload["rows"]: + if not isinstance(r, dict) or set(r) != {"metric", "month", "value", "query"}: + raise ValueError(f"bad row shape: {r!r}") + if r["metric"] not in L.PRIVATE_METRICS: + raise ValueError(f"not a private metric: {r['metric']!r}") + if not isinstance(r["month"], str): + raise ValueError(f"bad month: {r['month']!r}") + L.parse_month(r["month"]) + if r["month"] < L.METRICS[r["metric"]]["first_month"] or r["month"] > L.month_of(now): + raise ValueError(f"month out of range for {r['metric']}: {r['month']}") + if type(r["value"]) is not int or r["value"] < 0: + raise ValueError(f"value must be a non-negative integer: {r['value']!r}") + if not isinstance(r["query"], str) or len(r["query"]) > MAX_QUERY_LEN: + raise ValueError("query must be a string of reasonable length") + rows.append(r) + return rows + + +def apply_rows(led: L.Ledger, rows: list[dict], now) -> int: + """Record rows under the ledger's freeze rules. Returns the number of drift refusals.""" + drift = 0 + for r in rows: + try: + status = led.record(r["metric"], r["month"], r["value"], L.SOURCE_PRIVATE, r["query"], now) + except L.FrozenRowError as e: + L.log_drift(str(e), now) + print(f"DRIFT: {e}", file=sys.stderr) + drift += 1 + continue + print(f"{r['metric']} {r['month']} = {r['value']} ({status})") + return drift + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + mode = ap.add_mutually_exclusive_group(required=True) + mode.add_argument("--emit", type=Path, help="(host) compute open months and write a repository_dispatch body") + mode.add_argument("--apply", type=Path, help="(Actions) apply a dispatched client_payload to the ledger") + ap.add_argument("--brain", type=Path, help="path to Bob's brain repo (required with --emit)") + ap.add_argument("--verify-frozen", action="store_true", help="also recompute frozen months (drift is refused on apply)") + args = ap.parse_args(argv) + + now = L.utcnow() + led = L.Ledger.load() + + if args.emit: + if not args.brain or not (args.brain / ".git").exists(): + print(f"--emit needs --brain pointing at a git repo, got {args.brain}", file=sys.stderr) + return 2 + rows = compute_rows(args.brain.resolve(), led, now, args.verify_frozen) + body = {"event_type": EVENT_TYPE, "client_payload": {"computed_at": L.iso(now), "rows": rows}} + args.emit.write_text(json.dumps(body, indent=1) + "\n") + for r in rows: + print(f"{r['metric']} {r['month']} = {r['value']}") + return 0 + + rows = validate_rows(json.loads(args.apply.read_text()), now) + drift = apply_rows(led, rows, now) + led.save() + return L.EXIT_FROZEN_DRIFT if drift else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/collect_public.py b/collect_public.py new file mode 100755 index 0000000..8525a79 --- /dev/null +++ b/collect_public.py @@ -0,0 +1,126 @@ +#!/usr/bin/env python3 +"""Collect Bob's public GitHub metrics into data/ledger.csv. + +Uses the GitHub search API with GITHUB_TOKEN only. Only public metric rows are +ever touched; private (brain-host) rows pass through unchanged. + +Frozen months are skipped (not re-queried) unless --verify-frozen is given, in +which case a changed frozen value is logged to data/drift.log and the script +exits non-zero without modifying the row. +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +import ledger as L + +USER = "TimeToBuildBob" +API = "https://api.github.com" +# Search API allows 30 req/min authenticated; stay under it. +MIN_INTERVAL = 2.2 +_last_call = 0.0 + + +def query_for(metric: str, month: str) -> tuple[str, str]: + """Return (endpoint, q) for a metric and month. Dates are inclusive, UTC.""" + a, b = f"{month}-01", L.last_day(month) + qs = { + "prs_merged_public": ("issues", f"author:{USER} is:pr is:merged is:public merged:{a}..{b}"), + "prs_opened_public": ("issues", f"author:{USER} is:pr is:public created:{a}..{b}"), + "issues_opened_public": ("issues", f"author:{USER} is:issue is:public created:{a}..{b}"), + "commits_public_default_branch": ("commits", f"author:{USER} is:public committer-date:{a}..{b}"), + } + endpoint, q = qs[metric] + if "is:public" not in q: # the local token can see private repos + raise AssertionError(f"query without is:public: {q}") + return endpoint, q + + +def query_string(endpoint: str, q: str) -> str: + return f"GET /search/{endpoint}?q={q}" + + +def search_count(endpoint: str, q: str, token: str, max_attempts: int = 8) -> int: + global _last_call + url = f"{API}/search/{endpoint}?" + urllib.parse.urlencode({"q": q, "per_page": 1}) + headers = { + "Accept": "application/vnd.github+json", + "Authorization": f"Bearer {token}", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "TimeToBuildBob-stats", + } + for attempt in range(1, max_attempts + 1): + wait = MIN_INTERVAL - (time.monotonic() - _last_call) + if wait > 0: + time.sleep(wait) + _last_call = time.monotonic() + try: + with urllib.request.urlopen(urllib.request.Request(url, headers=headers), timeout=30) as resp: + data = json.load(resp) + except urllib.error.HTTPError as e: + if e.code in (403, 429) or e.code >= 500: + retry_after = e.headers.get("Retry-After") + reset = e.headers.get("X-RateLimit-Reset") + if retry_after: + delay = float(retry_after) + elif reset and e.headers.get("X-RateLimit-Remaining") == "0": + delay = max(float(reset) - time.time(), 0) + 2 + else: + delay = min(60, 5 * attempt) + print(f" HTTP {e.code} on {q!r}; sleeping {delay:.0f}s (attempt {attempt})", file=sys.stderr) + time.sleep(delay) + continue + raise + if data.get("incomplete_results"): + print(f" incomplete_results for {q!r}; retrying (attempt {attempt})", file=sys.stderr) + time.sleep(5 * attempt) + continue + return int(data["total_count"]) + raise RuntimeError(f"search failed after {max_attempts} attempts: {q}") + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--verify-frozen", action="store_true", help="re-query frozen months and report drift") + ap.add_argument("--metric", action="append", choices=L.PUBLIC_METRICS) + args = ap.parse_args(argv) + + token = os.environ.get("GITHUB_TOKEN") + if not token: + print("GITHUB_TOKEN is required", file=sys.stderr) + return 2 + + now = L.utcnow() + led = L.Ledger.load() + drift = 0 + counts: dict[str, int] = {} + for metric in args.metric or L.PUBLIC_METRICS: + for month in L.months_range(L.METRICS[metric]["first_month"], L.month_of(now)): + if not led.needs_compute(metric, month) and not args.verify_frozen: + continue + endpoint, q = query_for(metric, month) + value = search_count(endpoint, q, token) + try: + status = led.record(metric, month, value, L.SOURCE_PUBLIC, query_string(endpoint, q), now) + except L.FrozenRowError as e: + L.log_drift(str(e), now) + print(f"DRIFT: {e}", file=sys.stderr) + drift += 1 + continue + counts[status] = counts.get(status, 0) + 1 + print(f"{metric} {month} = {value} ({status})") + led.save() + print(f"done: {counts} drift={drift}") + return L.EXIT_FROZEN_DRIFT if drift else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/data/ledger.csv b/data/ledger.csv new file mode 100644 index 0000000..d7299c8 --- /dev/null +++ b/data/ledger.csv @@ -0,0 +1,93 @@ +metric,month,value,frozen,source,query,computed_at +commits_public_default_branch,2024-11,12,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2024-11-01..2024-11-30,2026-09-14T09:12:04Z +commits_public_default_branch,2024-12,4,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2024-12-01..2024-12-31,2026-09-14T09:12:04Z +commits_public_default_branch,2025-01,0,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2025-01-01..2025-01-31,2026-09-14T09:12:04Z +commits_public_default_branch,2025-02,0,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2025-02-01..2025-02-28,2026-09-14T09:12:04Z +commits_public_default_branch,2025-03,5,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2025-03-01..2025-03-31,2026-09-14T09:12:04Z +commits_public_default_branch,2025-04,0,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2025-04-01..2025-04-30,2026-09-14T09:12:04Z +commits_public_default_branch,2025-05,2,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2025-05-01..2025-05-31,2026-09-14T09:12:04Z +commits_public_default_branch,2025-06,2,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2025-06-01..2025-06-30,2026-09-14T09:12:04Z +commits_public_default_branch,2025-07,13,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2025-07-01..2025-07-31,2026-09-14T09:12:04Z +commits_public_default_branch,2025-08,0,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2025-08-01..2025-08-31,2026-09-14T09:12:04Z +commits_public_default_branch,2025-09,9,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2025-09-01..2025-09-30,2026-09-14T09:12:04Z +commits_public_default_branch,2025-10,289,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2025-10-01..2025-10-31,2026-09-14T09:12:04Z +commits_public_default_branch,2025-11,309,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2025-11-01..2025-11-30,2026-09-14T09:12:04Z +commits_public_default_branch,2025-12,269,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2025-12-01..2025-12-31,2026-09-14T09:12:04Z +commits_public_default_branch,2026-01,222,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2026-01-01..2026-01-31,2026-09-14T09:12:04Z +commits_public_default_branch,2026-02,811,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2026-02-01..2026-02-28,2026-09-14T09:12:04Z +commits_public_default_branch,2026-03,1108,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2026-03-01..2026-03-31,2026-09-14T09:12:04Z +commits_public_default_branch,2026-04,1024,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2026-04-01..2026-04-30,2026-09-14T09:12:04Z +commits_public_default_branch,2026-05,1379,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2026-05-01..2026-05-31,2026-09-14T09:12:04Z +commits_public_default_branch,2026-06,912,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2026-06-01..2026-06-30,2026-09-14T09:12:04Z +commits_public_default_branch,2026-07,798,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2026-07-01..2026-07-31,2026-09-14T09:12:04Z +commits_public_default_branch,2026-08,675,true,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2026-08-01..2026-08-31,2026-09-14T09:12:04Z +commits_public_default_branch,2026-09,380,false,github-search,GET /search/commits?q=author:TimeToBuildBob is:public committer-date:2026-09-01..2026-09-30,2026-09-14T09:12:04Z +issues_opened_public,2024-11,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2024-11-01..2024-11-30,2026-09-14T09:12:04Z +issues_opened_public,2024-12,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2024-12-01..2024-12-31,2026-09-14T09:12:04Z +issues_opened_public,2025-01,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2025-01-01..2025-01-31,2026-09-14T09:12:04Z +issues_opened_public,2025-02,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2025-02-01..2025-02-28,2026-09-14T09:12:04Z +issues_opened_public,2025-03,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2025-03-01..2025-03-31,2026-09-14T09:12:04Z +issues_opened_public,2025-04,1,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2025-04-01..2025-04-30,2026-09-14T09:12:04Z +issues_opened_public,2025-05,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2025-05-01..2025-05-31,2026-09-14T09:12:04Z +issues_opened_public,2025-06,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2025-06-01..2025-06-30,2026-09-14T09:12:04Z +issues_opened_public,2025-07,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2025-07-01..2025-07-31,2026-09-14T09:12:04Z +issues_opened_public,2025-08,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2025-08-01..2025-08-31,2026-09-14T09:12:04Z +issues_opened_public,2025-09,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2025-09-01..2025-09-30,2026-09-14T09:12:04Z +issues_opened_public,2025-10,15,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2025-10-01..2025-10-31,2026-09-14T09:12:04Z +issues_opened_public,2025-11,3,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2025-11-01..2025-11-30,2026-09-14T09:12:04Z +issues_opened_public,2025-12,28,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2025-12-01..2025-12-31,2026-09-14T09:12:04Z +issues_opened_public,2026-01,27,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2026-01-01..2026-01-31,2026-09-14T09:12:04Z +issues_opened_public,2026-02,20,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2026-02-01..2026-02-28,2026-09-14T09:12:04Z +issues_opened_public,2026-03,42,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2026-03-01..2026-03-31,2026-09-14T09:12:04Z +issues_opened_public,2026-04,30,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2026-04-01..2026-04-30,2026-09-14T09:12:04Z +issues_opened_public,2026-05,49,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2026-05-01..2026-05-31,2026-09-14T09:12:04Z +issues_opened_public,2026-06,14,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2026-06-01..2026-06-30,2026-09-14T09:12:04Z +issues_opened_public,2026-07,51,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2026-07-01..2026-07-31,2026-09-14T09:12:04Z +issues_opened_public,2026-08,48,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2026-08-01..2026-08-31,2026-09-14T09:12:04Z +issues_opened_public,2026-09,21,false,github-search,GET /search/issues?q=author:TimeToBuildBob is:issue is:public created:2026-09-01..2026-09-30,2026-09-14T09:12:04Z +prs_merged_public,2024-11,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2024-11-01..2024-11-30,2026-09-14T09:12:04Z +prs_merged_public,2024-12,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2024-12-01..2024-12-31,2026-09-14T09:12:04Z +prs_merged_public,2025-01,2,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2025-01-01..2025-01-31,2026-09-14T09:12:04Z +prs_merged_public,2025-02,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2025-02-01..2025-02-28,2026-09-14T09:12:04Z +prs_merged_public,2025-03,1,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2025-03-01..2025-03-31,2026-09-14T09:12:04Z +prs_merged_public,2025-04,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2025-04-01..2025-04-30,2026-09-14T09:12:04Z +prs_merged_public,2025-05,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2025-05-01..2025-05-31,2026-09-14T09:12:04Z +prs_merged_public,2025-06,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2025-06-01..2025-06-30,2026-09-14T09:12:04Z +prs_merged_public,2025-07,3,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2025-07-01..2025-07-31,2026-09-14T09:12:04Z +prs_merged_public,2025-08,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2025-08-01..2025-08-31,2026-09-14T09:12:04Z +prs_merged_public,2025-09,2,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2025-09-01..2025-09-30,2026-09-14T09:12:04Z +prs_merged_public,2025-10,81,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2025-10-01..2025-10-31,2026-09-14T09:12:04Z +prs_merged_public,2025-11,73,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2025-11-01..2025-11-30,2026-09-14T09:12:04Z +prs_merged_public,2025-12,136,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2025-12-01..2025-12-31,2026-09-14T09:12:04Z +prs_merged_public,2026-01,141,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2026-01-01..2026-01-31,2026-09-14T09:12:04Z +prs_merged_public,2026-02,378,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2026-02-01..2026-02-28,2026-09-14T09:12:04Z +prs_merged_public,2026-03,555,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2026-03-01..2026-03-31,2026-09-14T09:12:04Z +prs_merged_public,2026-04,464,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2026-04-01..2026-04-30,2026-09-14T09:12:04Z +prs_merged_public,2026-05,559,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2026-05-01..2026-05-31,2026-09-14T09:12:04Z +prs_merged_public,2026-06,390,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2026-06-01..2026-06-30,2026-09-14T09:12:04Z +prs_merged_public,2026-07,517,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2026-07-01..2026-07-31,2026-09-14T09:12:04Z +prs_merged_public,2026-08,472,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2026-08-01..2026-08-31,2026-09-14T09:12:04Z +prs_merged_public,2026-09,264,false,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:merged is:public merged:2026-09-01..2026-09-30,2026-09-14T09:12:04Z +prs_opened_public,2024-11,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2024-11-01..2024-11-30,2026-09-14T09:12:04Z +prs_opened_public,2024-12,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2024-12-01..2024-12-31,2026-09-14T09:12:04Z +prs_opened_public,2025-01,2,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2025-01-01..2025-01-31,2026-09-14T09:12:04Z +prs_opened_public,2025-02,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2025-02-01..2025-02-28,2026-09-14T09:12:04Z +prs_opened_public,2025-03,1,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2025-03-01..2025-03-31,2026-09-14T09:12:04Z +prs_opened_public,2025-04,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2025-04-01..2025-04-30,2026-09-14T09:12:04Z +prs_opened_public,2025-05,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2025-05-01..2025-05-31,2026-09-14T09:12:04Z +prs_opened_public,2025-06,1,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2025-06-01..2025-06-30,2026-09-14T09:12:04Z +prs_opened_public,2025-07,3,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2025-07-01..2025-07-31,2026-09-14T09:12:04Z +prs_opened_public,2025-08,0,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2025-08-01..2025-08-31,2026-09-14T09:12:04Z +prs_opened_public,2025-09,3,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2025-09-01..2025-09-30,2026-09-14T09:12:04Z +prs_opened_public,2025-10,103,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2025-10-01..2025-10-31,2026-09-14T09:12:04Z +prs_opened_public,2025-11,83,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2025-11-01..2025-11-30,2026-09-14T09:12:04Z +prs_opened_public,2025-12,153,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2025-12-01..2025-12-31,2026-09-14T09:12:04Z +prs_opened_public,2026-01,155,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2026-01-01..2026-01-31,2026-09-14T09:12:04Z +prs_opened_public,2026-02,433,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2026-02-01..2026-02-28,2026-09-14T09:12:04Z +prs_opened_public,2026-03,575,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2026-03-01..2026-03-31,2026-09-14T09:12:04Z +prs_opened_public,2026-04,484,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2026-04-01..2026-04-30,2026-09-14T09:12:04Z +prs_opened_public,2026-05,605,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2026-05-01..2026-05-31,2026-09-14T09:12:04Z +prs_opened_public,2026-06,411,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2026-06-01..2026-06-30,2026-09-14T09:12:04Z +prs_opened_public,2026-07,553,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2026-07-01..2026-07-31,2026-09-14T09:12:04Z +prs_opened_public,2026-08,535,true,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2026-08-01..2026-08-31,2026-09-14T09:12:04Z +prs_opened_public,2026-09,286,false,github-search,GET /search/issues?q=author:TimeToBuildBob is:pr is:public created:2026-09-01..2026-09-30,2026-09-14T09:12:04Z diff --git a/data/lifetime.json b/data/lifetime.json new file mode 100644 index 0000000..d671630 --- /dev/null +++ b/data/lifetime.json @@ -0,0 +1,219 @@ +{ + "as_of": "2026-09-14", + "source": "https://github.com/TimeToBuildBob/stats/blob/master/data/ledger.csv", + "method": "month-frozen ledger: months recompute until month_end+3d, then never change; lifetime = sum of months", + "metrics": { + "prs_merged_public": { + "label": "Merged public PRs", + "scope": "public", + "definition": "PRs authored by TimeToBuildBob in public repos, by merge month (GitHub issue search).", + "lifetime": 4038, + "lifetime_frozen": 3774, + "first_month": "2024-11", + "frozen_through": "2026-08", + "current_month": "2026-09", + "current_month_value": 264, + "as_of": "2026-09-14", + "monthly": { + "2024-11": 0, + "2024-12": 0, + "2025-01": 2, + "2025-02": 0, + "2025-03": 1, + "2025-04": 0, + "2025-05": 0, + "2025-06": 0, + "2025-07": 3, + "2025-08": 0, + "2025-09": 2, + "2025-10": 81, + "2025-11": 73, + "2025-12": 136, + "2026-01": 141, + "2026-02": 378, + "2026-03": 555, + "2026-04": 464, + "2026-05": 559, + "2026-06": 390, + "2026-07": 517, + "2026-08": 472, + "2026-09": 264 + }, + "open_months": [ + "2026-09" + ] + }, + "prs_opened_public": { + "label": "Opened public PRs", + "scope": "public", + "definition": "PRs authored by TimeToBuildBob in public repos, by creation month (any final state).", + "lifetime": 4386, + "lifetime_frozen": 4100, + "first_month": "2024-11", + "frozen_through": "2026-08", + "current_month": "2026-09", + "current_month_value": 286, + "as_of": "2026-09-14", + "monthly": { + "2024-11": 0, + "2024-12": 0, + "2025-01": 2, + "2025-02": 0, + "2025-03": 1, + "2025-04": 0, + "2025-05": 0, + "2025-06": 1, + "2025-07": 3, + "2025-08": 0, + "2025-09": 3, + "2025-10": 103, + "2025-11": 83, + "2025-12": 153, + "2026-01": 155, + "2026-02": 433, + "2026-03": 575, + "2026-04": 484, + "2026-05": 605, + "2026-06": 411, + "2026-07": 553, + "2026-08": 535, + "2026-09": 286 + }, + "open_months": [ + "2026-09" + ] + }, + "issues_opened_public": { + "label": "Opened public issues", + "scope": "public", + "definition": "Issues authored by TimeToBuildBob in public repos, by creation month.", + "lifetime": 349, + "lifetime_frozen": 328, + "first_month": "2024-11", + "frozen_through": "2026-08", + "current_month": "2026-09", + "current_month_value": 21, + "as_of": "2026-09-14", + "monthly": { + "2024-11": 0, + "2024-12": 0, + "2025-01": 0, + "2025-02": 0, + "2025-03": 0, + "2025-04": 1, + "2025-05": 0, + "2025-06": 0, + "2025-07": 0, + "2025-08": 0, + "2025-09": 0, + "2025-10": 15, + "2025-11": 3, + "2025-12": 28, + "2026-01": 27, + "2026-02": 20, + "2026-03": 42, + "2026-04": 30, + "2026-05": 49, + "2026-06": 14, + "2026-07": 51, + "2026-08": 48, + "2026-09": 21 + }, + "open_months": [ + "2026-09" + ] + }, + "commits_public_default_branch": { + "label": "Public commits (default branches)", + "scope": "public", + "definition": "Commits authored by TimeToBuildBob in public repos by committer date (GitHub commit search). Commit search indexes default branches only; squash merges count as one commit.", + "lifetime": 8223, + "lifetime_frozen": 7843, + "first_month": "2024-11", + "frozen_through": "2026-08", + "current_month": "2026-09", + "current_month_value": 380, + "as_of": "2026-09-14", + "monthly": { + "2024-11": 12, + "2024-12": 4, + "2025-01": 0, + "2025-02": 0, + "2025-03": 5, + "2025-04": 0, + "2025-05": 2, + "2025-06": 2, + "2025-07": 13, + "2025-08": 0, + "2025-09": 9, + "2025-10": 289, + "2025-11": 309, + "2025-12": 269, + "2026-01": 222, + "2026-02": 811, + "2026-03": 1108, + "2026-04": 1024, + "2026-05": 1379, + "2026-06": 912, + "2026-07": 798, + "2026-08": 675, + "2026-09": 380 + }, + "open_months": [ + "2026-09" + ] + }, + "brain_commits": { + "label": "Brain repo commits (private)", + "scope": "private", + "definition": "Commits on master of Bob's private brain repo whose author name is 'Bob' (bob@superuserlabs.org, plus a few early/misconfigured Bob identities), by committer date. Starts 2025-08: earlier Bob work was committed under Erik's identity and cannot be separated. Excludes Erik, other agents, and test-fixture identities (Test etc.).", + "lifetime": 89128, + "lifetime_frozen": 80879, + "first_month": "2025-08", + "frozen_through": "2026-08", + "current_month": "2026-09", + "current_month_value": 8249, + "as_of": "2026-09-14", + "monthly": { + "2025-08": 1, + "2025-09": 14, + "2025-10": 1829, + "2025-11": 2097, + "2025-12": 1532, + "2026-01": 1748, + "2026-02": 2046, + "2026-03": 4788, + "2026-04": 5479, + "2026-05": 15195, + "2026-06": 16431, + "2026-07": 14056, + "2026-08": 15663, + "2026-09": 8249 + }, + "open_months": [ + "2026-09" + ] + }, + "sessions": { + "label": "Agent sessions (private, approximate)", + "scope": "private", + "definition": "Bob agent sessions that ran to a result record (state/sessions/*.result.json): agent_id 'bob-*', status 'completed' with reason clean_exit/nonzero_exit/timeout and duration >= 60s, by completed_at month, deduplicated by session_id. Excludes lock/coordination skips and sub-minute startup failures (never did work), watchdog stubs, and ''/test/mock/unknown models. Starts 2026-06: result records begin 2026-05-20, so 2026-05 is incomplete and omitted.", + "lifetime": 18708, + "lifetime_frozen": 16571, + "first_month": "2026-06", + "frozen_through": "2026-08", + "current_month": "2026-09", + "current_month_value": 2137, + "as_of": "2026-09-14", + "monthly": { + "2026-06": 6806, + "2026-07": 5011, + "2026-08": 4754, + "2026-09": 2137 + }, + "open_months": [ + "2026-09" + ] + } + } +} diff --git a/ledger.py b/ledger.py new file mode 100644 index 0000000..23cfdd3 --- /dev/null +++ b/ledger.py @@ -0,0 +1,285 @@ +"""Month-frozen, append-only ledger for Bob's lifetime stats. + +One row per (metric, month). Rules, enforced here and covered by tests: + +* A row may be recomputed only while it is not frozen. +* A month freezes once ``now >= month_end + 3 days`` (month_end = first instant + of the next month, UTC). +* A frozen row is never modified. Recording a different value (or query) for a + frozen row raises :class:`FrozenRowError`; collectors log that and exit + non-zero instead of rewriting history. +* A definition change gets a new metric name (e.g. ``prs_merged_public_v2``). +* Lifetime = sum over all months of a metric. +""" + +from __future__ import annotations + +import csv +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path + +FIELDS = ["metric", "month", "value", "frozen", "source", "query", "computed_at"] +FREEZE_GRACE = timedelta(days=3) +LEDGER_PATH = Path(__file__).parent / "data" / "ledger.csv" +DRIFT_LOG = Path(__file__).parent / "data" / "drift.log" + +# Exit code collectors use when a frozen row would have changed (drift logged). +EXIT_FROZEN_DRIFT = 3 + +SOURCE_PUBLIC = "github-search" +SOURCE_PRIVATE = "brain-host" + +METRICS: dict[str, dict] = { + "prs_merged_public": { + "scope": "public", + "source": SOURCE_PUBLIC, + "first_month": "2024-11", + "label": "Merged public PRs", + "definition": "PRs authored by TimeToBuildBob in public repos, by merge month (GitHub issue search).", + }, + "prs_opened_public": { + "scope": "public", + "source": SOURCE_PUBLIC, + "first_month": "2024-11", + "label": "Opened public PRs", + "definition": "PRs authored by TimeToBuildBob in public repos, by creation month (any final state).", + }, + "issues_opened_public": { + "scope": "public", + "source": SOURCE_PUBLIC, + "first_month": "2024-11", + "label": "Opened public issues", + "definition": "Issues authored by TimeToBuildBob in public repos, by creation month.", + }, + "commits_public_default_branch": { + "scope": "public", + "source": SOURCE_PUBLIC, + "first_month": "2024-11", + "label": "Public commits (default branches)", + "definition": ( + "Commits authored by TimeToBuildBob in public repos by committer date (GitHub commit search). " + "Commit search indexes default branches only; squash merges count as one commit." + ), + }, + "brain_commits": { + "scope": "private", + "source": SOURCE_PRIVATE, + "first_month": "2025-08", + "label": "Brain repo commits (private)", + "definition": ( + "Commits on master of Bob's private brain repo whose author name is 'Bob' " + "(bob@superuserlabs.org, plus a few early/misconfigured Bob identities), by committer date. " + "Starts 2025-08: earlier Bob work was committed under Erik's identity and cannot be separated. " + "Excludes Erik, other agents, and test-fixture identities (Test etc.)." + ), + }, + "sessions": { + "scope": "private", + "source": SOURCE_PRIVATE, + "first_month": "2026-06", + "label": "Agent sessions (private, approximate)", + "definition": ( + "Bob agent sessions that ran to a result record (state/sessions/*.result.json): " + "agent_id 'bob-*', status 'completed' with reason clean_exit/nonzero_exit/timeout and " + "duration >= 60s, by completed_at month, deduplicated by session_id. Excludes " + "lock/coordination skips and sub-minute startup failures (never did work), watchdog " + "stubs, and ''/test/mock/unknown models. " + "Starts 2026-06: result records begin 2026-05-20, so 2026-05 is incomplete and omitted." + ), + }, +} + +PUBLIC_METRICS = [m for m, spec in METRICS.items() if spec["scope"] == "public"] +PRIVATE_METRICS = [m for m, spec in METRICS.items() if spec["scope"] == "private"] + + +class FrozenRowError(Exception): + """A recompute would change a frozen row.""" + + +def utcnow() -> datetime: + return datetime.now(timezone.utc) + + +def parse_month(month: str) -> tuple[int, int]: + year, mon = month.split("-") + y, m = int(year), int(mon) + if not 1 <= m <= 12 or len(month) != 7: + raise ValueError(f"bad month: {month!r}") + return y, m + + +def month_start(month: str) -> datetime: + y, m = parse_month(month) + return datetime(y, m, 1, tzinfo=timezone.utc) + + +def next_month(month: str) -> str: + y, m = parse_month(month) + return f"{y + 1:04d}-01" if m == 12 else f"{y:04d}-{m + 1:02d}" + + +def month_end(month: str) -> datetime: + """First instant of the following month (exclusive end).""" + return month_start(next_month(month)) + + +def last_day(month: str) -> str: + return (month_end(month) - timedelta(days=1)).strftime("%Y-%m-%d") + + +def freeze_at(month: str) -> datetime: + return month_end(month) + FREEZE_GRACE + + +def is_freezable(month: str, now: datetime) -> bool: + return now >= freeze_at(month) + + +def month_of(now: datetime) -> str: + return now.astimezone(timezone.utc).strftime("%Y-%m") + + +def months_range(first: str, last: str) -> list[str]: + out, cur = [], first + while cur <= last: + out.append(cur) + cur = next_month(cur) + return out + + +def iso(now: datetime) -> str: + return now.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +@dataclass +class Row: + metric: str + month: str + value: int + frozen: bool + source: str + query: str + computed_at: str + + def to_csv(self) -> dict[str, str]: + return { + "metric": self.metric, + "month": self.month, + "value": str(self.value), + "frozen": "true" if self.frozen else "false", + "source": self.source, + "query": self.query, + "computed_at": self.computed_at, + } + + +class Ledger: + def __init__(self, rows: list[Row] | None = None): + self.rows: dict[tuple[str, str], Row] = {} + for r in rows or []: + key = (r.metric, r.month) + if key in self.rows: + raise ValueError(f"duplicate ledger row: {key}") + self.rows[key] = r + + @classmethod + def load(cls, path: Path | None = None) -> Ledger: + path = path or LEDGER_PATH + if not path.exists(): + return cls() + with path.open(newline="") as f: + reader = csv.DictReader(f) + if reader.fieldnames != FIELDS: + raise ValueError(f"unexpected ledger header: {reader.fieldnames}") + rows = [] + for d in reader: + if d["frozen"] not in ("true", "false"): + raise ValueError(f"bad frozen flag in row: {d}") + parse_month(d["month"]) + rows.append( + Row( + metric=d["metric"], + month=d["month"], + value=int(d["value"]), + frozen=d["frozen"] == "true", + source=d["source"], + query=d["query"], + computed_at=d["computed_at"], + ) + ) + return cls(rows) + + def save(self, path: Path | None = None) -> None: + path = path or LEDGER_PATH + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_suffix(".csv.tmp") + with tmp.open("w", newline="") as f: + w = csv.DictWriter(f, fieldnames=FIELDS, lineterminator="\n") + w.writeheader() + for key in sorted(self.rows): + w.writerow(self.rows[key].to_csv()) + tmp.replace(path) + + def get(self, metric: str, month: str) -> Row | None: + return self.rows.get((metric, month)) + + def needs_compute(self, metric: str, month: str) -> bool: + """True unless the row exists and is frozen.""" + row = self.get(metric, month) + return row is None or not row.frozen + + def record( + self, + metric: str, + month: str, + value: int, + source: str, + query: str, + now: datetime, + ) -> str: + """Record a computed value. Returns 'inserted'|'updated'|'froze'|'unchanged'. + + Raises FrozenRowError if the row is frozen and value/query differ. + """ + if metric not in METRICS: + raise KeyError(f"unknown metric {metric!r}; definition changes need a registered new name") + if METRICS[metric]["source"] != source: + raise PermissionError(f"{metric} is owned by source {METRICS[metric]['source']!r}, not {source!r}") + if month > month_of(now): + raise ValueError(f"cannot record future month {month} at {iso(now)}") + value = int(value) + row = self.get(metric, month) + freeze = is_freezable(month, now) + if row is not None and row.frozen: + if row.value != value or row.query != query: + raise FrozenRowError( + f"{metric} {month} is frozen at {row.value} (query {row.query!r}); " + f"recompute gave {value} (query {query!r}) — not modified" + ) + return "unchanged" + if row is None: + self.rows[(metric, month)] = Row(metric, month, value, freeze, source, query, iso(now)) + return "inserted" + status = "unchanged" + if row.value != value or row.query != query: + row.value, row.query, row.computed_at = value, query, iso(now) + status = "updated" + if freeze: + row.frozen = True + status = "froze" if status == "unchanged" else status + return status + + def months(self, metric: str) -> list[Row]: + return [self.rows[k] for k in sorted(self.rows) if k[0] == metric] + + def lifetime(self, metric: str) -> int: + return sum(r.value for r in self.months(metric)) + + +def log_drift(message: str, now: datetime, path: Path | None = None) -> None: + path = path or DRIFT_LOG + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("a") as f: + f.write(f"{iso(now)} {message}\n") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b91f8d1 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "bob-stats" +version = "0.1.0" +description = "Bob's lifetime stats: an append-only, month-frozen ledger" +requires-python = ">=3.10" + +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] diff --git a/render.py b/render.py new file mode 100755 index 0000000..6411743 --- /dev/null +++ b/render.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Render charts, LIFETIME.md, data/lifetime.json and the README table from data/ledger.csv. + +Output is a pure function of the ledger plus the current UTC month, so re-running +without ledger changes produces byte-identical files (no daily churn commits). +""" + +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + +import ledger as L + +ROOT = Path(__file__).parent +CHARTS = ROOT / "charts" +README = ROOT / "README.md" +LIFETIME_MD = ROOT / "LIFETIME.md" +LIFETIME_JSON = ROOT / "data" / "lifetime.json" +START, END = "", "" +REPO = "https://github.com/TimeToBuildBob/stats" + +BLUE, ORANGE = "#2a78d6", "#eb6834" +INK, INK2, GRID = "#0b0b0b", "#52514e", "#e4e3df" + +CAVEATS = [ + ( + "**Month-frozen.** A month is recomputed daily until 3 days after it ends, then frozen and never " + "rewritten. Lifetime = sum of all months (frozen months + the open current month)." + ), + ( + "**Public and private are separate scopes.** Never add `brain_commits` to " + "`commits_public_default_branch`, and never add private numbers to public ones." + ), + ( + "**Commit search sees default branches only.** Squash merges collapse a PR's commits into one, " + "so `commits_public_default_branch` undercounts commits actually written." + ), + ( + "**Search counts reflect visibility at compute time.** A repo that goes private or is deleted " + "later does not change frozen months." + ), + ( + "**Private metrics are lower bounds.** `brain_commits` starts 2025-08 (earlier Bob work was committed " + "under Erik's identity); `sessions` starts 2026-06 (no reliable records before) and is approximate." + ), + 'Months and date ranges are UTC. "As of" = the date the metric\'s value last changed.', +] + + +def fmt(n: int | None) -> str: + return "—" if n is None else f"{n:,}" + + +def summarize(led: L.Ledger, now_month: str) -> dict: + metrics = {} + for metric, spec in L.METRICS.items(): + rows = led.months(metric) + if not rows: + continue + frozen = [r for r in rows if r.frozen] + cur = led.get(metric, now_month) + metrics[metric] = { + "label": spec["label"], + "scope": spec["scope"], + "definition": spec["definition"], + "lifetime": sum(r.value for r in rows), + "lifetime_frozen": sum(r.value for r in frozen), + "first_month": rows[0].month, + "frozen_through": frozen[-1].month if frozen else None, + "current_month": now_month, + "current_month_value": cur.value if cur else None, + "as_of": max(r.computed_at for r in rows)[:10], + "monthly": {r.month: r.value for r in rows}, + "open_months": [r.month for r in rows if not r.frozen], + } + as_of = max((m["as_of"] for m in metrics.values()), default=None) + return { + "as_of": as_of, + "source": f"{REPO}/blob/master/data/ledger.csv", + "method": "month-frozen ledger: months recompute until month_end+3d, then never change; lifetime = sum of months", + "metrics": metrics, + } + + +def readme_block(s: dict) -> str: + lines = [ + START, + "| Metric | Lifetime | Current month (partial) | As of | Scope |", + "|---|---:|---:|---|---|", + ] + for name, m in s["metrics"].items(): + lines.append( + f"| {m['label']} (`{name}`) | **{fmt(m['lifetime'])}** | " + f"{fmt(m['current_month_value'])} ({m['current_month']}) | {m['as_of']} | {m['scope']} |" + ) + lines += ["", "Definitions and caveats: [LIFETIME.md](LIFETIME.md). Public and private rows are never summed.", END] + return "\n".join(lines) + + +def lifetime_md(s: dict) -> str: + ms = s["metrics"] + out = [ + "# Bob's lifetime numbers", + "", + ( + "> **Quote these; don't re-estimate.** Generated by `render.py` from [`data/ledger.csv`](data/ledger.csv), " + "a month-frozen ledger. Do not edit by hand." + ), + "", + ] + if "prs_merged_public" in ms: + m = ms["prs_merged_public"] + out += [ + "How to cite:", + "", + f"> As of {m['as_of']}: {fmt(m['lifetime'])} merged public PRs ({REPO})", + "", + ] + out += [ + "## Lifetime totals", + "", + "| Metric | Lifetime | Frozen total (through) | Current month (partial) | As of | Scope | Since |", + "|---|---:|---:|---:|---|---|---|", + ] + for name, m in ms.items(): + out.append( + f"| {m['label']} (`{name}`) | **{fmt(m['lifetime'])}** | {fmt(m['lifetime_frozen'])} " + f"({m['frozen_through'] or '—'}) | {fmt(m['current_month_value'])} ({m['current_month']}) | " + f"{m['as_of']} | {m['scope']} | {m['first_month']} |" + ) + out += ["", "## Definitions", ""] + for name, m in ms.items(): + out.append(f"- **`{name}`** ({m['scope']}, since {m['first_month']}): {m['definition']}") + out += ["", "The exact query or command for every value is stored per row in the ledger's `query` column.", ""] + out += ["## Caveats", ""] + [f"- {c}" for c in CAVEATS] + [""] + + months = sorted({mo for m in ms.values() for mo in m["monthly"]}, reverse=True) + names = list(ms) + out += [ + "## Monthly values", + "", + "`*` = open month (still recomputed daily); all other values are frozen.", + "", + "| Month | " + " | ".join(f"`{n}`" for n in names) + " |", + "|---|" + "---:|" * len(names), + ] + for mo in months: + cells = [] + for n in names: + v = ms[n]["monthly"].get(mo) + cells.append(fmt(v) + ("*" if mo in ms[n]["open_months"] else "") if v is not None else "") + out.append(f"| {mo} | " + " | ".join(cells) + " |") + return "\n".join(out) + "\n" + + +def update_readme(block: str, path: Path = README) -> None: + text = path.read_text() + pattern = re.compile(re.escape(START) + ".*?" + re.escape(END), re.DOTALL) + if not pattern.search(text): + raise SystemExit(f"README markers {START} / {END} not found") + new = pattern.sub(lambda _: block, text) + if new != text: + path.write_text(new) + + +def write_if_changed(path: Path, content: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + if not path.exists() or path.read_text() != content: + path.write_text(content) + + +# --- charts ----------------------------------------------------------------- + + +def _plt(): + import matplotlib + + matplotlib.use("Agg") + import matplotlib.pyplot as plt + + plt.rcParams.update( + { + "svg.hashsalt": "bob-stats", + "svg.fonttype": "none", + "font.family": "DejaVu Sans", + "font.size": 9, + "axes.edgecolor": GRID, + "axes.labelcolor": INK2, + "axes.titlecolor": INK, + "xtick.color": INK2, + "ytick.color": INK2, + "axes.spines.top": False, + "axes.spines.right": False, + "axes.spines.left": False, + "axes.grid": True, + "axes.grid.axis": "y", + "grid.color": GRID, + "grid.linewidth": 0.6, + "axes.axisbelow": True, + "figure.facecolor": "white", + "axes.facecolor": "white", + "legend.frameon": False, + } + ) + return plt + + +def _save(plt, fig, name: str) -> None: + CHARTS.mkdir(exist_ok=True) + fig.savefig(CHARTS / name, format="svg", metadata={"Date": None}, bbox_inches="tight") + plt.close(fig) + + +def _xticks(ax, months: list[str]) -> None: + idx = [i for i, m in enumerate(months) if m.endswith(("-01", "-04", "-07", "-10"))] + ax.set_xticks(idx, [months[i] for i in idx]) + ax.tick_params(axis="x", length=0) + ax.set_xlim(-0.7, len(months) - 0.3) + + +def _title(ax, title: str, subtitle: str) -> None: + ax.set_title(title, loc="left", fontsize=12, fontweight="bold", pad=22) + ax.text(0, 1.03, subtitle, transform=ax.transAxes, color=INK2, fontsize=8.5, va="bottom") + + +def render_charts(s: dict) -> None: + from matplotlib.ticker import FuncFormatter + + plt = _plt() + ms = s["metrics"] + thousands = FuncFormatter(lambda v, _: f"{v:,.0f}") + + if "prs_merged_public" in ms: + m = ms["prs_merged_public"] + months = list(m["monthly"]) + vals = list(m["monthly"].values()) + fig, ax = plt.subplots(figsize=(9, 3.4)) + bars = ax.bar(range(len(months)), vals, width=0.78, color=BLUE, edgecolor="white", linewidth=1) + for b, mo in zip(bars, months): + if mo in m["open_months"]: + b.set_alpha(0.45) + b.set_hatch("///") + peak = max(range(len(vals)), key=vals.__getitem__) + for i in {peak, len(vals) - 1}: + label = f"{vals[i]:,}" + (" (open)" if months[i] in m["open_months"] else "") + ax.annotate(label, (i, vals[i]), xytext=(0, 3), textcoords="offset points", ha="center", color=INK, fontsize=8) + _xticks(ax, months) + ax.yaxis.set_major_formatter(thousands) + _title(ax, "Merged public PRs per month", f"TimeToBuildBob · hatched = open month, still recomputed · as of {m['as_of']}") + _save(plt, fig, "prs_merged_monthly.svg") + + series = [(n, c) for n, c in (("prs_merged_public", BLUE), ("prs_opened_public", ORANGE)) if n in ms] + if series: + fig, ax = plt.subplots(figsize=(9, 3.4)) + months = list(ms[series[0][0]]["monthly"]) + for name, color in series: + vals = [ms[name]["monthly"].get(mo, 0) for mo in months] + cum, total = [], 0 + for v in vals: + total += v + cum.append(total) + ax.plot(range(len(months)), cum, color=color, linewidth=2, label=ms[name]["label"]) + ax.annotate( + f"{cum[-1]:,}", + (len(months) - 1, cum[-1]), + xytext=(6, 0), + textcoords="offset points", + va="center", + color=INK, + fontsize=8.5, + ) + _xticks(ax, months) + ax.set_xlim(-0.7, len(months) + 1.2) + ax.yaxis.set_major_formatter(thousands) + ax.legend(loc="upper left") + _title(ax, "Lifetime public PRs (cumulative)", f"TimeToBuildBob · sum of monthly ledger rows · as of {s['as_of']}") + _save(plt, fig, "lifetime_prs.svg") + + pub = ms.get("commits_public_default_branch") + if pub: + months = list(pub["monthly"]) + brain = ms.get("brain_commits") + fig, (a1, a2) = plt.subplots(2, 1, figsize=(9, 5.4), sharex=True, gridspec_kw={"hspace": 0.55}) + panels = [ + (a1, pub, BLUE, "Public commits, default branches (GitHub commit search)"), + (a2, brain, ORANGE, "Brain repo commits (private repo, Bob identities only)"), + ] + for ax, m, color, title in panels: + ax.set_title(title, loc="left", fontsize=10, fontweight="bold") + ax.yaxis.set_major_formatter(thousands) + if not m: + ax.text(0.5, 0.5, "no data yet", transform=ax.transAxes, ha="center", color=INK2) + continue + xs = [i for i, mo in enumerate(months) if mo in m["monthly"]] + ys = [m["monthly"][months[i]] for i in xs] + ax.plot(xs, ys, color=color, linewidth=2, marker="o", markersize=3.5) + ax.set_ylim(bottom=0) + _xticks(a2, months) + fig.suptitle( + "Commits per month — two separate scopes, never summed (last point = open month)", x=0.125, ha="left", fontsize=12, fontweight="bold", color=INK + ) + _save(plt, fig, "commits.svg") + + +def main() -> int: + led = L.Ledger.load() + s = summarize(led, L.month_of(L.utcnow())) + write_if_changed(LIFETIME_JSON, json.dumps(s, indent=2, sort_keys=False) + "\n") + write_if_changed(LIFETIME_MD, lifetime_md(s)) + update_readme(readme_block(s)) + render_charts(s) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..95db9cf --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# Pinned so Actions and the brain-host timer render byte-identical SVGs. +matplotlib==3.11.2 +pytest==9.1.1 diff --git a/tests/test_ledger.py b/tests/test_ledger.py new file mode 100644 index 0000000..0718933 --- /dev/null +++ b/tests/test_ledger.py @@ -0,0 +1,253 @@ +from datetime import datetime, timedelta, timezone +from pathlib import Path + +import pytest + +import ledger as L + + +def dt(*a): + return datetime(*a, tzinfo=timezone.utc) + + +PUB = L.SOURCE_PUBLIC +PRIV = L.SOURCE_PRIVATE + + +def test_freeze_boundary_is_month_end_plus_3_days(): + # August ends at 2026-09-01T00:00Z; freezes at 2026-09-04T00:00Z. + assert L.freeze_at("2026-08") == dt(2026, 9, 4) + assert not L.is_freezable("2026-08", dt(2026, 9, 3, 23, 59, 59)) + assert L.is_freezable("2026-08", dt(2026, 9, 4)) + # December rolls the year. + assert L.freeze_at("2025-12") == dt(2026, 1, 4) + + +def test_recompute_allowed_before_freeze_then_freezes(): + led = L.Ledger() + assert led.record("prs_merged_public", "2026-08", 400, PUB, "q", dt(2026, 8, 20)) == "inserted" + assert led.record("prs_merged_public", "2026-08", 472, PUB, "q", dt(2026, 9, 3)) == "updated" + row = led.get("prs_merged_public", "2026-08") + assert row.value == 472 and not row.frozen + assert led.record("prs_merged_public", "2026-08", 472, PUB, "q", dt(2026, 9, 4)) == "froze" + assert led.get("prs_merged_public", "2026-08").frozen + assert not led.needs_compute("prs_merged_public", "2026-08") + + +def test_last_recompute_at_freeze_time_may_still_change_value(): + led = L.Ledger() + led.record("prs_merged_public", "2026-08", 470, PUB, "q", dt(2026, 9, 1)) + assert led.record("prs_merged_public", "2026-08", 472, PUB, "q", dt(2026, 9, 5)) == "updated" + row = led.get("prs_merged_public", "2026-08") + assert row.frozen and row.value == 472 + + +def test_frozen_row_is_immutable(): + led = L.Ledger() + led.record("prs_merged_public", "2025-01", 10, PUB, "q", dt(2026, 1, 1)) + before = L.Row(**vars(led.get("prs_merged_public", "2025-01"))) + assert before.frozen + with pytest.raises(L.FrozenRowError): + led.record("prs_merged_public", "2025-01", 11, PUB, "q", dt(2026, 2, 1)) + with pytest.raises(L.FrozenRowError): # definition change on a frozen row + led.record("prs_merged_public", "2025-01", 10, PUB, "q2", dt(2026, 2, 1)) + assert vars(led.get("prs_merged_public", "2025-01")) == vars(before) + # Same value is a no-op, computed_at untouched. + assert led.record("prs_merged_public", "2025-01", 10, PUB, "q", dt(2026, 3, 1)) == "unchanged" + assert vars(led.get("prs_merged_public", "2025-01")) == vars(before) + + +def test_backfilled_past_month_inserted_frozen(): + led = L.Ledger() + led.record("issues_opened_public", "2024-11", 3, PUB, "q", dt(2026, 9, 14)) + assert led.get("issues_opened_public", "2024-11").frozen + + +def test_lifetime_is_sum_over_months(): + led = L.Ledger() + now = dt(2026, 9, 14) + for month, v in [("2026-06", 5), ("2026-07", 7), ("2026-08", 11), ("2026-09", 2)]: + led.record("prs_opened_public", month, v, PUB, "q", now) + led.record("prs_merged_public", "2026-08", 100, PUB, "q", now) + assert led.lifetime("prs_opened_public") == 25 + assert led.lifetime("prs_merged_public") == 100 + + +def test_unknown_metric_future_month_and_wrong_source_rejected(): + led = L.Ledger() + now = dt(2026, 9, 14) + with pytest.raises(KeyError): + led.record("prs_merged_public_v2", "2026-09", 1, PUB, "q", now) + with pytest.raises(ValueError): + led.record("prs_merged_public", "2026-10", 1, PUB, "q", now) + with pytest.raises(PermissionError): # public collector can't write private rows + led.record("brain_commits", "2026-09", 1, PUB, "q", now) + with pytest.raises(PermissionError): + led.record("prs_merged_public", "2026-09", 1, PRIV, "q", now) + + +def test_save_load_roundtrip_and_idempotent_rerun(tmp_path: Path): + path = tmp_path / "ledger.csv" + now = dt(2026, 9, 14) + led = L.Ledger() + led.record("prs_merged_public", "2026-08", 472, PUB, "GET /search/issues?q=a b", now) + led.record("prs_merged_public", "2026-09", 150, PUB, "GET /search/issues?q=a b", now) + led.record("brain_commits", "2026-08", 15000, PRIV, "git rev-list", now) + led.save(path) + first = path.read_bytes() + + # Re-run later the same day with the same values: file is byte-identical. + led2 = L.Ledger.load(path) + later = now + timedelta(hours=6) + for metric, month, v, src, q in [ + ("prs_merged_public", "2026-08", 472, PUB, "GET /search/issues?q=a b"), + ("prs_merged_public", "2026-09", 150, PUB, "GET /search/issues?q=a b"), + ]: + assert led2.needs_compute(metric, month) == (month == "2026-09") + if led2.needs_compute(metric, month): + assert led2.record(metric, month, v, src, q, later) == "unchanged" + led2.save(path) + assert path.read_bytes() == first + + +def test_public_update_leaves_private_rows_untouched(tmp_path: Path): + path = tmp_path / "ledger.csv" + now = dt(2026, 9, 14) + led = L.Ledger() + led.record("brain_commits", "2026-09", 8000, PRIV, "git", now) + led.record("sessions", "2026-09", 2000, PRIV, "sessions", now) + led.record("prs_merged_public", "2026-09", 150, PUB, "q", now) + led.save(path) + private_before = [vars(r) for r in led.rows.values() if r.source == PRIV] + + led2 = L.Ledger.load(path) + led2.record("prs_merged_public", "2026-09", 160, PUB, "q", now + timedelta(days=1)) + led2.save(path) + led3 = L.Ledger.load(path) + assert [vars(r) for r in led3.rows.values() if r.source == PRIV] == private_before + assert led3.get("prs_merged_public", "2026-09").value == 160 + + +def test_load_rejects_duplicates_and_bad_header(tmp_path: Path): + path = tmp_path / "ledger.csv" + row = "prs_merged_public,2026-08,1,true,github-search,q,2026-09-14T00:00:00Z\n" + path.write_text(",".join(L.FIELDS) + "\n" + row + row) + with pytest.raises(ValueError): + L.Ledger.load(path) + path.write_text("metric,month,value\n") + with pytest.raises(ValueError): + L.Ledger.load(path) + + +def test_collect_public_skips_frozen_and_logs_drift(tmp_path, monkeypatch): + import collect_public as C + + path = tmp_path / "ledger.csv" + drift = tmp_path / "drift.log" + monkeypatch.setattr(L, "LEDGER_PATH", path) + monkeypatch.setattr(L, "DRIFT_LOG", drift) + monkeypatch.setattr(L, "utcnow", lambda: dt(2024, 12, 10)) + monkeypatch.setenv("GITHUB_TOKEN", "x") + + calls = [] + monkeypatch.setattr(C, "search_count", lambda ep, q, tok: calls.append(q) or 5) + assert C.main(["--metric", "prs_merged_public"]) == 0 + led = L.Ledger.load(path) + assert led.get("prs_merged_public", "2024-11").frozen # 2024-12-10 >= 2024-12-04 + assert not led.get("prs_merged_public", "2024-12").frozen + assert len(calls) == 2 and all("is:public" in q for q in calls) + + # Normal re-run only queries the open month. + calls.clear() + assert C.main(["--metric", "prs_merged_public"]) == 0 + assert len(calls) == 1 and "2024-12-01" in calls[0] + + # A verify run that sees a different value for a frozen month refuses + logs. + monkeypatch.setattr(C, "search_count", lambda ep, q, tok: 9) + assert C.main(["--metric", "prs_merged_public", "--verify-frozen"]) == L.EXIT_FROZEN_DRIFT + assert L.Ledger.load(path).get("prs_merged_public", "2024-11").value == 5 + assert "2024-11 is frozen" in drift.read_text() + + +def test_session_filter(): + import collect_private as P + + base = { + "agent_id": "bob-autonomous-claude-code", + "status": "completed", + "reason": "clean_exit", + "completed_at": "2026-07-01T00:00:00+00:00", + "duration_seconds": 600, + "model": "opus", + } + assert P.include_session(base) + assert P.include_session({**base, "model": None}) + assert not P.include_session({**base, "model": ""}) + assert not P.include_session({**base, "model": "test-model"}) + assert P.include_session({**base, "model": "grok-latest"}) + assert not P.include_session({**base, "reason": "lock_busy_skip"}) + assert not P.include_session({**base, "duration_seconds": 3, "reason": "nonzero_exit"}) + assert not P.include_session({**base, "agent_id": None}) + + +def test_private_payload_validation(): + import collect_private as P + + now = dt(2026, 9, 14) + ok = {"metric": "brain_commits", "month": "2026-09", "value": 10, "query": "git"} + assert P.validate_rows({"rows": [ok]}, now) == [ok] + bad = [ + {**ok, "metric": "prs_merged_public"}, # public metrics can't arrive via dispatch + {**ok, "value": -1}, + {**ok, "value": True}, + {**ok, "value": "10"}, + {**ok, "month": "2026-10"}, # future + {**ok, "month": "2025-07"}, # before first_month + {**ok, "month": "2026-13"}, + {**ok, "extra": "repo-name"}, # only aggregates, no extra fields + ] + for row in bad: + with pytest.raises(ValueError): + P.validate_rows({"rows": [row]}, now) + with pytest.raises(ValueError): + P.validate_rows([ok], now) + + +def test_private_apply_freeze_and_emit_roundtrip(tmp_path, monkeypatch): + import json + + import collect_private as P + + path = tmp_path / "ledger.csv" + drift = tmp_path / "drift.log" + monkeypatch.setattr(L, "LEDGER_PATH", path) + monkeypatch.setattr(L, "DRIFT_LOG", drift) + monkeypatch.setattr(L, "utcnow", lambda: dt(2026, 7, 10)) + (tmp_path / "brain" / ".git").mkdir(parents=True) + monkeypatch.setattr(P, "brain_commits", lambda brain, month: (100, "git rev-list")) + monkeypatch.setattr(P, "session_counts", lambda brain: {"2026-06": 7, "2026-07": 3}) + + body = tmp_path / "body.json" + assert P.main(["--emit", str(body), "--brain", str(tmp_path / "brain")]) == 0 + emitted = json.loads(body.read_text()) + assert emitted["event_type"] == "private-aggregates" + payload = tmp_path / "payload.json" + payload.write_text(json.dumps(emitted["client_payload"])) + assert P.main(["--apply", str(payload)]) == 0 + led = L.Ledger.load(path) + assert led.get("sessions", "2026-06").frozen and led.get("sessions", "2026-06").value == 7 + assert not led.get("sessions", "2026-07").frozen + assert led.lifetime("brain_commits") == 100 * len(L.months_range("2025-08", "2026-07")) + + # Next emit only carries open months. + assert P.main(["--emit", str(body), "--brain", str(tmp_path / "brain")]) == 0 + assert {(r["metric"], r["month"]) for r in json.loads(body.read_text())["client_payload"]["rows"]} == { + ("brain_commits", "2026-07"), + ("sessions", "2026-07"), + } + + # A payload changing a frozen month is refused, logged, and exits 3. + payload.write_text(json.dumps({"rows": [{"metric": "sessions", "month": "2026-06", "value": 8, "query": P.SESSIONS_QUERY}]})) + assert P.main(["--apply", str(payload)]) == L.EXIT_FROZEN_DRIFT + assert L.Ledger.load(path).get("sessions", "2026-06").value == 7 + assert "2026-06 is frozen" in drift.read_text()