Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

95 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

gh-widgets

Self-hosted alternative to github-readme-stats and friends. Renders six static SVGs about your GitHub profile on a cron — no JavaScript, no third-party service, no flakiness.

stats
streak
languages
external
impact
responsiveness

Why

The popular widget services (vercel-hosted readme-stats, heroku streak counters, etc.) routinely break — Vercel rate-limits them, the Heroku ones sleep, GitHub camo caches the broken state, your README looks half-empty.

Render the SVGs yourself instead:

  • Static output. A cron job (or systemd timer) writes stats.svg, streak.svg, languages.svg to a directory. Your web server serves them as plain files. No request-time API calls, no runtime dependencies.
  • Fails gracefully. If a refresh fails (API rate limit, network), the previous SVGs keep serving. They can't 404 unless you delete them.
  • No JS, no fonts loaded by the SVG. Uses JetBrains Mono, monospace with system fallback so embedding into a GitHub README looks right without pulling Google Fonts.
  • Pure Python stdlib. urllib + json + argparse. Python 3.9+. No pip install needed.

Install

sudo ./install.sh              # -> /usr/local/bin, or pass a directory

# token: a classic PAT with public_repo is enough; read:user is NOT required
# (nothing here reads your email). Save it to a file with mode 600.
echo "ghp_xxx..." | sudo tee /etc/gh-widgets.token
sudo chmod 600 /etc/gh-widgets.token

# customise the service unit (username, output dir, theme)
sudo cp examples/gh-widgets.service /etc/systemd/system/
sudo cp examples/gh-widgets.timer   /etc/systemd/system/
sudoedit /etc/systemd/system/gh-widgets.service   # set GH_USER, OUT_DIR

sudo systemctl daemon-reload
sudo systemctl enable --now gh-widgets.timer

# verify
sudo systemctl start gh-widgets.service
ls -la /var/www/example.com/widgets/

Then add the nginx snippet from examples/nginx.conf (CORS + cache-control).

Why install.sh rather than cp

install.sh stages a five-file deployment set: the three renderer entry points, their existing ghwidgets_common.py runtime dependency, and the separately installed ghwidgets_data.py public API for pinned consumers. The renderers assert ghwidgets_common.py's COMMON_VERSION on startup; they do not import ghwidgets_data.py. The installer moves the complete set into place and starts every entry point to prove the renderer deployment is coherent. A hand-rolled copy that omits ghwidgets_common.py can leave a renderer refusing to run, deliberately preventing rendering from a stale shared module.

Note the rename: render.py installs as render-gh-widgets.py (the name the units use). The scripts find the shared module relative to their own path, so the rename is safe.

Who counts as "you"

The external-contribution and impact cards need to know which repos are yours and which lines are yours. Both are derived from the token's own accountlogin, databaseId, and the org list — so joining an organisation needs no config change. Ownership of a line is decided by exact match against the account's GitHub noreply addresses, not by pattern-matching a name.

Two escape hatches, both additive — neither can remove something the API reported, because a stale override is exactly the drift this replaced:

Variable Use it for
GH_EXTRA_INSIDERS Owners to treat as yours that the API will not report — e.g. an org whose membership is private. Comma-separated.
GH_EXTRA_EMAILS Commit-author addresses that are yours but are not a GitHub noreply address — e.g. the address you use on a workstation. Comma-separated.

Tuning the impact score (render-impact.py)

Each table ranks repos by WilsonLowerBound(mine/total, z) * mine**gamma. The defaults are the validated values; override only if you know why.

Variable Default Effect
IMPACT_Z 2.58 Confidence level of the lower bound. Higher = more sceptical of small samples.
IMPACT_PR_GAMMA 1.0 How much raw PR volume counts against share.
IMPACT_ISSUE_GAMMA 1.75 Same, for issues.
IMPACT_LOC_GAMMA 0.5 Same, for surviving lines.

An unparseable value aborts the run rather than silently reverting to the default — a mis-typed knob that quietly renders wrong numbers is worse than a failed run.

Tuning the responsiveness score (render-responsiveness.py)

The board ranks repos by n**gamma * 1/(1 + t/half_life), where n is your merged PRs in the repo and t is the trimmed-mean hours from PR creation to merge (mean of the p10..p90 range, inclusive; plain mean for fewer than 4 PRs).

Variable Default Effect
RESP_GAMMA 0.5 How much raw PR volume counts against turnaround.
RESP_HALF_LIFE_H 24.0 Turnaround at which the speed factor is 0.5 (0.25 at three times it).
RESP_MIN_PRS 3 Merged PRs a repo needs before it is ranked at all.

Same contract as the impact knobs: an unparseable value aborts the run.

Usage (manual run)

GH_USER=octocat GH_TOKEN=ghp_xxx OUT_DIR=./widgets ./render.py
GH_USER=octocat GH_TOKEN_FILE=/etc/gh-widgets.token OUT_DIR=/var/www/example/widgets THEME=catppuccin ./render.py

Public snapshots

ghwidgets_data.py is the supported importable boundary for consumers such as ghpulse. Its public snapshot is an acquisition/interchange contract, not an input format for the renderer-private profile, impact, or responsiveness data.

SCHEMA_VERSION is currently 1. fetch_authored_snapshot(token, login) acquires the complete authored issue and pull-request history, following every page until GitHub reports the final page. Pagination cursor failures and an explicit safety limit fail loudly instead of returning a plausible partial snapshot. Its exact v1 top-level shape is:

{
  "schema_version": 1,
  "generated_at": "2026-01-05T00:00:00Z",
  "account": {"login": "account-login"},
  "repositories": [],
  "issues": [],
  "pull_requests": []
}

There are no membership, insiders, token, credential, or other unlisted fields. Repository records have exactly id, nameWithOwner, url, isPrivate, and owner; owner has exactly login, and isPrivate is always false. Issue records have exactly node_id, repository_id, repository, owner, repository_url, is_private, number, url, created_at, updated_at, closed_at, state, and state_reason; pull-request records have exactly the same fields except state_reason, plus exactly merged_at and merged. The PR source does not produce an issue state_reason, so adding that field to a PR is rejected. Strings are non-empty, numbers are positive integers, booleans are actual booleans, nullable outcome timestamps are RFC 3339 strings or null, and every item references a repository in the same snapshot. States and final outcomes must be internally consistent. The complete allowed issue state matrix is:

record state closed_at state_reason
issue OPEN null null or REOPENED
issue CLOSED RFC 3339 COMPLETED or NOT_PLANNED

The complete allowed pull-request state matrix is:

record state merged closed_at merged_at
pull request OPEN false null null
pull request CLOSED false RFC 3339 null
pull request MERGED true RFC 3339 RFC 3339

Those are all valid combinations. In particular, an open item cannot have a close timestamp, a closed issue cannot have a null or REOPENED reason, PRs do not have a state_reason field, a PR in MERGED state must have both outcome timestamps, and an OPEN or CLOSED PR must be unmerged with merged_at: null.

The supported public functions are normalise_issue, normalise_pull_request, fetch_authored_snapshot, load_snapshot, and write_snapshot (plus SCHEMA_VERSION). Arbitrary snapshot construction is internal (_build_snapshot) and is not a consumer API. The producer publishes public external data only: private repositories are removed, the account and explicitly public organization owners are excluded transiently during acquisition, and credentials are never serialized. Membership relationships are not part of the interchange contract. Environment-based insider and email additions are not read by the public producer.

load_snapshot() and write_snapshot() perform the same exact nested validation. Invalid data raises the stable SnapshotValidationError (with unsupported versions reported as its SnapshotVersionError subclass) before write_snapshot() invokes the locked, atomic strict writer. Lock or filesystem failures raise separately instead of silently losing a snapshot. Consumers that embed or submodule gh-widgets must pin the exact gh-widgets commit defining this contract and upgrade it deliberately with compatibility tests; an unpinned moving branch is not a supported interface.

The renderer CLIs and their SVG output remain unchanged and independently runnable; they do not consume public snapshots. cache.json and impact-cache.json remain separate private implementation caches and are not the public integration API.

Caching

GitHub rejects a full-year contribution-calendar query with RESOURCE_LIMITS_EXCEEDED once an account's history is large enough, so the renderer caches the data that cannot change and refetches only what can.

  • CACHE_FILE — cache location, default /var/lib/gh-widgets/cache.json. Missing, unreadable, corrupt, or schema-mismatched caches are not errors: the run falls back to a full fetch.
  • cache.json and impact-cache.json are private renderer implementation details. Do not use either cache as an interchange format for another process; use the versioned ghwidgets_data API instead.
  • Settled calendar days and MERGED pull requests are cached. Open and closed PRs, issues, and the trailing 7 days of calendar are refetched every run — closed items can be reopened, so they are never treated as final.
  • A cold cache backfills the year in 12 monthly windows rather than one full-year request, so the first run cannot trip the node limit.
  • If a fetch fails and a cache exists, the widgets render from cache and the card shows the cache timestamp, so staleness is visible rather than silent.
  • --resync discards the cache and rebuilds it. Run it periodically (a weekly timer is enough) so nothing depends indefinitely on MERGED being one-way.

Themes

Four built-in palettes. Pick one in THEME= or via --theme:

  • tokyonight (default)
  • catppuccin
  • gruvbox
  • github-dark

Adding a new theme is ~12 lines — just add a dict to THEMES in render.py.

Embedding

<img src="https://your-domain/widgets/stats.svg"     width="720" alt="GitHub stats" />
<img src="https://your-domain/widgets/streak.svg"    width="720" alt="Contribution streak" />
<img src="https://your-domain/widgets/languages.svg" width="720" alt="Top languages" />
<img src="https://your-domain/widgets/external.svg"  width="720" alt="External contributions" />
<img src="https://your-domain/widgets/impact.svg"    width="720" alt="External impact" />
<img src="https://your-domain/widgets/responsiveness.svg" width="720" alt="External responsiveness" />

Works in GitHub READMEs (it goes through GitHub's camo proxy — note camo caches images for ~10 minutes, so README updates lag by that much).

The external widget

external.svg answers "what have I done in other people's repos" — the number profile stats usually bury, since a repo you own and a repo you contributed to both just count as "a repo". It has two parallel rows, pull requests and issues, each with three figures.

Pull requests:

  • opened — PRs you authored in repos owned by neither you nor any org you belong to.
  • merged — how many of those were merged. Merged is what the API actually records, so that's what it says.
  • repos — how many distinct external repos you reached.

Issues (the same external predicate — repos owned by neither you nor your orgs, private repos excluded):

  • opened — issues you filed in those external repos.
  • maintainer-accepted — how many the maintainer closed as completed (state CLOSED + stateReason COMPLETED; NOT_PLANNED closures don't count). It's the issue analog of a merged PR — and the wording is deliberate: you opened the report, the maintainer accepted it, so it doesn't claim you did the fixing.
  • repos — how many distinct external repos those issues touched.

Issues come from the GraphQL user.issues connection, which returns issues only, so pull requests are never double-counted. The footer pairs the two rows' success rates: PR merge rate and the maintainer-accepted rate.

Your orgs are read from the API and excluded automatically, so it keeps working when you join or leave one. Private repos are left out — nobody looking at the SVG could verify them.

It pages through your whole PR and issue history (100 per request), so a run costs a few extra API calls. A non-issue on an hourly timer; worth knowing if you run it every minute.

The responsiveness widget

responsiveness.svg answers the other half of the external question: not just how much lands in other people's repos, but how long it takes to land. One row per repo, ranked by volume × speed — n merged PRs with diminishing returns, damped by the trimmed-mean hours from PR creation to merge.

Trimmed mean, not plain mean, on purpose. A plain mean ranks a repo by its single worst outlier — one PR parked over a holiday — rather than by what a contributor should actually expect. The p10..p90 trim keeps the body of typical PRs while dropping the holiday-weekend tail. Repos with fewer than 4 merged PRs fall back to the plain mean so the trim cannot collapse to a single value or an empty set.

A repo needs RESP_MIN_PRS merged PRs (default 3) to be ranked: a summary over one or two samples is not an estimate of anything. The excluded tail is stated on the card — repo count and PR count — rather than quietly dropped, along with the number of rows the top-N cut hides.

It fetches its own PR data — one paginated GraphQL connection, the same one render.py uses — so it needs a token and runs on its own schedule (hourly, here) rather than trailing render-impact.py. It shares that script's cache, because that is where the account's authored PRs already live: it merges its half in and leaves every other section alone, above all ourloc, the git-blame result render-impact.py produces. Both writers take a lock around the file and write it via a temp file plus os.replace, so the two can overlap without either observing or leaving a half-written cache. A failed fetch renders from the cache and says so, exactly like the other two.

Upgrading from a cache written before those timestamps existed: merged PRs are frozen in that cache, so their timestamps only arrive on a render-impact.py --resync (the weekly timer does one). Until then the card counts what it can and says how many PRs it had to leave out — run the resync once after installing if you don't want to wait.

Caveats

  • The streak counter walks newest→oldest contribution days and skips a single leading zero (today might not be logged yet, or your timezone is ahead of UTC). A second zero is a real gap and ends the streak. It does not try to be cleverer than that.
  • Token only needs read access. If you accidentally grant repo write scope, that's on you.

Tests

python3 -m unittest discover -v

Stdlib unittest, no network — the streak and external-contribution maths run against hand-built calendars. Nothing to install.

License

MIT. See LICENSE.

About

Self-hosted alternative to github-readme-stats. Static SVGs, cron-driven, no JS, no runtime API calls. Pure Python stdlib.

Topics

Resources

Contributing

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages