Statline (gh statline) is a Go TUI that aggregates GitHub pull-request
stats per team member across configured repos: a local SQLite cache is
filled by an incremental GraphQL sync, internal/metrics computes the
numbers, and Bubble Tea renders them. The numbers are the product —
correctness of metric definitions matters more than anything else here.
go build ./... # pure Go, no CGO, works on Windows/macOS/Linux
go test ./... # full suite, no network, no real config touched
go test -race ./... # CI runs this on Linux (needs cgo; may not run on Windows)
golangci-lint run # CI parity (v2.12.2, pinned in ci.yml); errcheck
# included — bare `go vet` is NOT enough
go run golang.org/x/vuln/cmd/govulncheck@v1.1.4 ./... # CI runs this too
gofmt -l . # must be clean before pushing- Go 1.26. CI matrix: Linux/macOS/Windows build+test, Linux
-race, golangci-lint. - Tests use
db.Open(":memory:")and fakegh.Doerimplementations. Nothing needs credentials or the network. If your test touches config persistence, sett.Setenv("STATLINE_CONFIG", ...)to a temp path (seeinternal/tui/app/app_test.go: testDeps) so you never clobber the developer's realconfig.yml. STATLINE_CONFIGandSTATLINE_DBenv vars redirect the config file and cache DB — use them for any manual experiment.
The TUI can be rendered headlessly against seeded data:
go run . seed # deterministic 38-member "demo" team, 120d history
STATLINE_DUMP=1 go test ./internal/tui/app -run TestDumpView -v
# STATLINE_DUMP_VIEW=charts|trends, STATLINE_DUMP_PRE/_POST send keystrokes, e.g. PRE="w,w"
# STATLINE_DUMP_NOW=2026-09-06T00:00:00Z pins the clock so before/after dumps diff cleanlyPoint STATLINE_CONFIG/STATLINE_DB at scratch paths first so seed data
stays out of real config. The README demo GIF is scripted in vhs/demo.tape.
Data flow: gh (GraphQL) → syncer (incremental walk) → db (SQLite cache)
→ metrics (all numbers) → tui/pages + export (render).
| Package | Role | Notes |
|---|---|---|
internal/cmd |
Cobra commands: root TUI, init, sync, seed (hidden), version |
setup.go: bootstrap() is the shared startup path; seams.go holds the runProgram/newClient swap points tests rely on |
internal/config |
YAML config: teams, members, repos, bot globs, UI prefs | File is source of truth; wizard writes it; in-app changes rewrite it (comments don't survive) |
internal/gh |
Auth, GraphQL client, query documents | Doer interface is the ONE seam to GitHub; tests fake it with JSON fixtures. runner (auth.go) is the seam for the gh auth token subprocess |
internal/db |
SQLite cache: schema, migrations, writes | Cache is disposable; deleting it costs a re-sync. Pool capped at 1 conn — drain/close sql.Rows before the next query |
internal/syncer |
Incremental PR walk, rate limiting, retry | TUI-agnostic; progress via typed Event channel |
internal/metrics |
Single source of truth for every number | SQL for counts, Go for medians; golden tests pin exact values |
internal/seed |
Deterministic fake data generator | no_sync: true teams are never fetched |
internal/export |
Markdown table export | Must match metric definitions exactly |
internal/tui/* |
Bubble Tea v2 app: pages, overlays, wizard, theme, keys | app.go routes; Deps carries every outside-world seam (Doer, clock, clipboard); pages are mostly pure render from precomputed data |
- All times are unix epoch seconds UTC in the DB and in
metrics.Window(half-open[Start, End)). - Bot exclusion is read-time policy, not sync-time: synced data stays
complete. Two mechanisms:
users.is_bot(GraphQL__typename == "Bot") filtered in SQL, and the configexclude_botsglobs (config.BotMatcher) applied Go-side where individual logins are inspected (TTFR, comments received). Which applies where: author-attributed metrics get bots excluded for free via the visible-member loop inTeamStats(bot members are skipped up front); counterparty-attributed metrics (who reviewed you, who commented on you) must filter bothis_botand the glob list explicitly — seefillCommentsReceivedandttfrSamplesfor the pattern. - Self-activity never counts: reviews or comments on your own PR are
excluded everywhere (
author_login != p.author_login). - Hidden members (
hidden: true) and bot members are excluded as actors everywhere, but their data stays in the cache. Two enforcement paths, and a new metric must pick one: per-member metrics filter their result rows againstvisibleMembers(), while team-level aggregates have no such map and must addvisibleCond()to the query (it needsteam_membersjoined astmon the actor column). Activity towards a visible member still counts — a hidden teammate's comment on your PR is still a comment you received. - Medians are lower-middle (
median()in metrics.go) so the result is an actually-observed value.0/-1are the "no data" sentinels (CycleTimeP50/TTFRP50 zero, SizeP50 -1). - Sync walks by
updatedAtDESC with a per-repo watermark minus 1h overlap; the walk stops at watermark-or-backfill-horizon. A multi-page walk is verified against a post-walk probe of the list head and re-walked if the list mutated underneath it (maxWalkAttempts). Consequence: PRs untouched since before the backfill horizon are invisible.CoverageFloorgates the tile deltas and trends length so we never show numbers the cache can't back;backfill_untilrecords the depth a walk actually reached, not the configured horizon. Preserve that honesty when adding views. SavePullRequestsis delete-and-replace for a PR's reviews/comments — idempotent re-syncs, handles dismissals/deletions without diffing. Sync bookkeeping (sync_state) commits only after a verified walk (aftermaxWalkAttemptsdirty retries, the final attempt commits anyway).- Config file is source of truth for teams;
db.MirrorTeamre-mirrors it intoteams/team_members/team_reposon every startup. Never treat the DB team tables as authoritative. - DB schema changes go in a new
internal/db/migrations/NNNN_*.sqlfile (applied byPRAGMA user_versionordering). Never edit0001_init.sql.
- Review-thread replies arrive as GitHub "COMMENTED" reviews; v0.1 counts them as reviews (documented known inflation of the commented bucket).
DISMISSEDcounts as a review given, in its ownRow.Dismissedbucket. GitHub rewrites the state (usually a push invalidating an approval) but the review happened, and every count outsidefillReviewCountsis a bareCOUNT(*)that always included it.ReviewsGivenis the sum of all four state buckets.- TTFR = first non-bot, non-author review on PRs created in the window; the review itself may fall outside the window.
- Cycle time is attributed to the merge week/window; size to the created
window; TTFR to the created window. Trends (
trends.go) use fixed 7-day buckets ending "now", independent of the UI window. For new event-dated metrics (e.g. closes), attribute to the event's own timestamp window by analogy — and say so in the PR, since it is a definition choice. fillPRCountsprefilter trap: the PR queries prefilter withWHERE (p.created_at >= ? OR p.merged_at >= ?)before the per-column CASE counts. A new count keyed on another timestamp (e.g.closed_at) must also extend that OR clause, or rows created before the window are silently missed. Compiles fine, returns wrong numbers.PunchCarduses local time; everything else is UTC.- Draft PRs count in every metric except
OpenAging(which filtersis_draft = 0). Closed-unmerged PRs count toward "opened" and size but produce no cycle sample.
- Any metric change or new metric needs golden tests in
internal/metrics(seemetrics_test.go: fixture()— a hand-computed scenario with exact expected values, comments explaining each number). If a change alters a definition, say so explicitly in the PR description and update README "Metric definitions". - User-visible changes get a
CHANGELOG.mdline under## Unreleased. - Keep PRs focused; commits are squash-merged so PR title/description matter.
- Export (
internal/export) and README key tables must stay in sync with UI changes. Note:exporthas no tests, so "must match exactly" is enforced only by review — double-check it when changing metrics. - New GraphQL fields: extend the query documents in
gh/queries.go, the node structs,syncer.convertPR, the DB schema (new migration), and the store — in that order — and add a fake-Doertest insyncer.
internal/metrics/metrics_test.go— golden-value fixture: build PRs viastore.SavePullRequests, assert exact Row values. Offsets hang off the pinnedfixedNow, never the wall clock.internal/syncer/engine_test.go—pagedDoerfake serving canned JSON pages keyed on query document + cursor; asserts watermark/backfill state.internal/tui/app/app_test.go—teatestharness withemptyPageDoer; drives the real Bubble Tea program and greps rendered output.testDepspinsDeps.Nowand fakesDeps.Clipboard, so no test touches the real clipboard.internal/tui/app/persist_test.go/sync_test.go— config persistence and sync-event bridging.internal/syncer/retry_test.go+ratelimit_test.go—sleepRecorderplus the pinned engine clock: the retry ladder, Retry-After and 403 classification, and the shared rate-limit pause, no real sleeps.internal/gh/auth_test.go—isolateAuthplus a recordingrunner: thegh auth tokenfallback without spawning gh.internal/cmd/cmd_test.go—isolateswapsrunProgram/newClientand drivesrootCmdheadlessly (first-run wizard, sync exit status).
Fixed: seeinternal/ghandinternal/confighave no test filesgh/client_test.go(Classify,Actor) andconfig/config_test.goload_test.go(BotMatcher, defaults, validation, YAML round-trip). Thegh auth tokenfallback (auth.go) is covered via therunnerseam (auth_test.go).
sync_state.last_erroris written but never read by any UI — a repo can silently fail every sync (renamed/private repo) and views just go stale. Partially fixed:gh-statline syncnow exits non-zero when any repo fails (verified against the real API). The TUI still shows nothing; adoctor/statusview of sync_state is the remaining gap. Renamed repos never self-heal because targets come from config.botLogins()loads the entireuserstable into anIN (...)list per query — fine today, but it's an O(all users) pattern that will not scale and silently degrades if the list exceeds SQLite's parameter limit (modernc default is high, but the pattern is fragile).- Metric SQL strings are assembled by concatenation with positional
?args appended in matching order (seefillCommentsGiven) — correctness depends on arg-order discipline with zero compiler help. Extreme care when editing; a mismatched append compiles and returns wrong numbers. Moversflagsprior == 0asIsNew(no percentage; ranked ahead of percentage movers by volume) and volume floors are hardcoded (moverFloor) — tune with care, values are load-bearing for the Trends UI.Time-based tests use realFixed: thetime.Now()offsets; there is no clock injectionmetricsentry points takenowexplicitly (LastDays,TrendSeries,OpenAging,LoadDashboard), the syncerEngineanddb.Storecarry injectable clocks (the engine also an injectable sleep), the app readsDeps.Now, and every time-relative test pins one instant (fixedNow). New time-dependent code takesnowor reads a seam; never calltime.Now()inmetrics,syncerordb.
- Package docs explain why (see
metrics,db/open.go); keep that bar. - Comments document invariants and GitHub API quirks, not mechanics. Match the existing compact, declarative comment voice.
- Errors: wrap with context (
fmt.Errorf("fetching %s: %w", ...)); errcheck exclusions are onlyRows.Close/DB.Close/Tx.Rollback.