GitHub team statistics in your terminal.
Statline (gh statline) is a terminal dashboard for the numbers behind your
team's pull-request workflow — who's opening, merging, reviewing, approving,
and commenting, across the repos your team actually works in.
(All names in the demo are generated by the built-in seed command — no real data.)
- Team stats — one sortable stat line per member: PRs opened and
merged, reviews given (approved / commented / changes requested), comments
given vs received, median cycle time (open → merge), median time to first
review, and median PR size.
/finds a member by login in a long table. - Charts — a 3×3 dashboard that always fits one screen: PR throughput,
review outcomes, cycle-time trend, who-reviews-whom matrix, first-review
latency, PR sizes, open-PR aging, and an activity punch card, topped by
stat tiles comparing the window to the previous one (▲/▼ deltas appear
once the cache covers both). Any card expands to a scrollable fullscreen
view; the review matrix pans with its name labels pinned, and
oon the open-PR card opens a stale PR in your browser. - Trends — where the numbers are heading: a card per headline metric showing its 12-week trajectory with week-over-week deltas, plus a movers card for the members whose recent volume rose or fell the most ("reviews 2 → 8, up 4w running"). Cards expand to fullscreen — count metrics break down into per-member weekly sparklines, movers drop the top-3 cap. Weekly buckets are fixed regardless of the time window, and the series only reaches as far back as the cache honestly covers.
- Person drill-down — headline stats, daily activity sparkline, and a per-repo breakdown for any teammate.
- Time windows — cycle 7/14/30/90-day presets with
w, or pick a custom date range withr. - Repo filter —
Rnarrows every view to a subset of the team's repos for per-repo standups and retros; the header and the export heading say which. The list scrolls,/searches it by name, anda/ncheck or clear what the search shows. - Teams your way — a setup wizard imports a GitHub org team (members +
assigned repos) into a local config you can edit freely: add contractors,
hide alumni (
min the app does it too), track repos the team isn't formally assigned. Multiple team profiles, switchable in-app witht, wherearuns the wizard again for a new one. - Local cache — incremental sync into SQLite (pure Go, no CGO): instant
startup, offline browsing, no re-fetching what you already have. A
headless
gh statline synckeeps the cache warm from cron. - Sync status —
Sin the app, orgh statline doctorfrom a script: when each repo last synced cleanly, how far back the cache honestly covers, and the error from any repo that is failing. A repo that stops syncing — renamed, made private, deleted — otherwise shows up only as numbers that quietly stop moving, so the status bar carries a persistent warning while any repo is failing. - Export —
ycopies the current view as a Markdown table for standups, retros, and 1:1 notes;gh statline exportdoes the same headlessly, as Markdown, CSV or JSON, for a spreadsheet, a dashboard or a cron job. Every format renders from the same data, so a column means the same thing wherever you read it. - Keyboard-first (vim keys + arrows) with clickable tabs and rows, wheel scrolling, and an adaptive light/dark Charm-style theme.
As a GitHub CLI extension (recommended):
gh extension install byte2pixel/gh-statline
gh statlineStandalone binaries are attached to each release
(download, rename to gh-statline, put it on your PATH), or build from
source with go install github.com/byte2pixel/gh-statline@latest.
Statline reuses your GitHub CLI credentials (gh auth login), falling back
to GITHUB_TOKEN. It needs the repo and read:org scopes.
The host follows gh: GH_HOST if set, otherwise the single host gh is
logged in to, otherwise github.com. Both the token lookup and the API
endpoint use it, so statline finds a GitHub Enterprise Server login instead
of reporting missing credentials. The queries are tested against github.com
alone, so GHES is best effort. Reports welcome.
gh statline # open the TUI (first run launches the setup wizard)
gh statline init # add another team profile (or press a in the team switcher)
gh statline sync # refresh the cache without the TUI (cron-friendly)
gh statline sync --team platform --backfill 180
gh statline doctor # per-repo sync health, straight from the cache
gh statline export # print a view as Markdown, CSV or JSON
gh statline --version # the release you are running
gh statline --config ./team.yml --db ./team.db sync # other files, on any commanddoctor reports, for every configured repo, when it last synced cleanly,
how far back the cache honestly covers, and why the last sync failed. It
never contacts GitHub, so it works offline and on local-only teams, and it
exits non-zero when any repo is failing — pair it with sync in cron to
catch a repo that has quietly stopped updating (renamed, made private, or
deleted) instead of trusting numbers that stopped moving.
gh statline export --view team --format csv --window 30d
gh statline export --view person --member alice --format json
gh statline export --view trends --output trends.md
gh statline export --view sync --format json | jq '.repos[] | select(.last_error)'| Flag | |
|---|---|
--view |
team (default), person, trends, sync |
--format |
md (default), csv, json |
--window |
days, e.g. 30d; defaults to your configured ui.window |
--from / --to |
a custom YYYY-MM-DD range instead of --window |
--member |
the login to drill into (required by --view person) |
--team |
team profile; defaults to default_team |
--output, -o |
write to a file instead of stdout |
Like doctor, it reads the cache only: offline, no GitHub call, local-only
teams included. --format md is exactly what y copies in the app. The
trends view is always the trailing 12 weeks, so it ignores --window, as
the trends page does.
For the machine formats:
- CSV and JSON name their columns identically, using stable machine keys
(
prs_opened,cycle_time_p50_seconds) rather than the display headings, so rewording a column in the UI never renames it in your spreadsheet. - Durations are whole seconds, timestamps are RFC 3339 UTC.
- "No data" is
nullin JSON and an empty CSV field, never the0or-1the metrics layer uses internally, which a dashboard would average in as a measurement. - A view made of two tables (a person's totals and their repo breakdown; the weekly trend and its movers) writes both: two JSON arrays, or two CSV blocks separated by a blank line.
For cron, sync --json prints one object instead of progress lines — what
the run updated, and the state every repo is left in — and doctor --json
prints the health report on its own. Both use the same per-repo field names
as export --view sync, and both still exit non-zero when a repo is
failing:
gh statline sync --json | jq -r '.repos[] | select(.last_error) | .repo'| Key | Action |
|---|---|
1 / 2 / 3 / tab |
Team stats / charts / trends |
enter / esc |
Drill into member / back |
j/k, arrows |
Move selection |
h/l, ←/→ |
Change sort column |
- |
Flip sort direction |
/ |
Find a member by login (enter keeps the rows, esc clears) |
f / enter |
Expand the focused card (charts, trends) |
j/k · h/l |
Scroll / pan a fullscreen card |
o |
Open the marked PR in your browser (Open PRs card) |
pgup/pgdn/space, d/u, g/G |
Page / half-page / jump in a fullscreen card |
w |
Cycle time window |
r |
Custom date range |
t |
Switch team (a there adds one, d deletes) |
m |
Show or hide members (writes hidden: to the config) |
R |
Filter repos |
s |
Sync now; again while syncing to cancel |
S |
Sync status (per-repo health) |
y |
Copy view as Markdown |
? |
Full help |
q |
Quit |
Lives at ~/.config/gh-statline/config.yml (Windows:
%AppData%\gh-statline\config.yml) — wizard-written, human-editable:
default_team: platform
exclude_bots: ["*[bot]", "dependabot*", "renovate*", "copilot*"]
teams:
- name: platform
org: acme
gh_team_slug: platform-eng # provenance of the import; optional
# no_sync: true # local-only profile; sync never touches GitHub
members:
- {login: alice}
- {login: bob, hidden: true} # kept in cache, hidden from views
repos:
- {owner: acme, name: api}
- {owner: acme, name: web}
sync: {backfill_days: 120, page_size: 25, concurrency: 3}
ui: {window: 30d, sort: prs_merged, theme: auto} # see below; theme is hand-editedexclude_bots globs (*, ?, case-insensitive, brackets literal) match
reviewer and commenter logins in addition to GitHub's own bot flag.
copilot* is in the defaults because Copilot code review reviews as
copilot-pull-request-reviewer, which has no [bot] suffix, so no other
glob catches it. A Copilot review lands within minutes of the PR opening,
and without the glob it would count as the first review whenever the
synced bot flag missed it. Defaults only fill in when the key is absent
from the file, so a config written by an older version keeps its old list.
Add new globs by hand.
sync tunes the GitHub walk: backfill_days is how far back a fresh cache
reaches, page_size is PRs per request (1–100), and concurrency is how
many repos sync in parallel, capped at 10. GitHub's secondary rate limits
trigger on concurrent requests from one token, so more workers than that
earn a block, not a faster sync.
Statline remembers how you left it: switching teams (t) updates
default_team, changing the time window (w) or the sort column
(←/→) updates ui, and showing or hiding members (m) writes their
hidden: flags, so the next launch reopens the same view. Custom date
ranges (r), the repo filter (R), the member search (/), and
--team <name> are one-shot and never persist. These in-app changes rewrite the file, so YAML comments
don't survive a session — keep notes elsewhere if you hand-edit.
Statline picks its palette from the terminal's background color, which it
asks for with an OSC 11 query. Terminals that never answer, among them
older conhost and some tmux and CI setups, keep the dark-assumed default.
On a light background that chrome is hard to read and the light chart ramps
never engage. theme: light (or dark) pins the palette and skips the
query, while auto or no key at all keeps asking. Nothing in the app
writes this key, so it survives the rewrites above.
The SQLite cache lives in the user cache dir and is safe to delete — it just re-syncs.
Both files can live elsewhere: --config <path> and --db <path> work on
every command, STATLINE_CONFIG and STATLINE_DB do the same from the
environment, and the flag wins when both are set. That is how to keep a
scratch profile, or the seeded demo team, away from your real config.
- Counts are attributed to the person acting: reviews to the reviewer, comments to their author. Commenting on your own PR never counts.
- Review-thread replies arrive as GitHub "commented" reviews; v0.1 counts them as such (a known inflation of the commented bucket).
- Reviews GitHub has since marked dismissed still count as reviews given, in their own column: the review happened, and a later push invalidating an approval shouldn't erase the reviewer's work.
- Time to first review ignores bots and the PR author.
- Hidden members and bots are excluded as actors from every number, including the charts: what the team table shows and what the charts total are the same set of people. Their activity towards a visible member still counts, so hiding a teammate never rewrites someone else's stat line.
- Medians use the lower-middle value; a
–means no data in the window. - Percent changes (tiles, trends, movers) are rounded to the nearest whole
percent, with one shared definition. A change from a zero base has no
percentage and is labelled
new; in the movers ranking, new activity sorts ahead of every percentage change, by volume. - The review matrix only counts reviews on PRs authored by humans: reviews
on bot-authored PRs are excluded, and the
(others)column (reviews on non-member PRs) doesn't influence the heat-map scale. - Windows and dates are UTC everywhere, with two deliberate exceptions. The punch card buckets by local time, so two people in different timezones read different punch cards from the same cache. Rate-limit resets print in local time with the zone attached, because they answer "when can I retry" rather than "when did this happen".
- The
updatedAt-ordered incremental walk cannot see PRs untouched since before the backfill horizon (default 120 days) — deepen withsync --backfill N. Fetched data is never deleted, so local coverage grows the longer you use statline; the tile deltas turn on per window once the cache provably covers the previous period.
go build ./... # pure Go, no C toolchain needed
go test ./...
go run .
go run . seed # dev helper: seeds a deterministic local-only "demo" team
# (38 members, 120d of history) to inspect charts at scaleTo render views headlessly (sizes, scroll states, seeded data), use the
dump harness: STATLINE_DUMP=1 go test ./internal/tui/app -run TestDumpView -v
— see dump_test.go for the STATLINE_DUMP_VIEW/_PRE/_POST options.
The README demo GIF is scripted with VHS
against seeded data — vhs/demo.tape has the Docker one-liner to regenerate
it after UI changes.
Built with Bubble Tea v2, Lip Gloss, Bubbles, ntcharts, BubbleZone, Harmonica, go-gh, and modernc.org/sqlite.
MIT