DEV Community

Cover image for Green build, wrong numbers: five silent bugs in my GitHub stats
Lisan al Gaib
Lisan al Gaib

Posted on

Green build, wrong numbers: five silent bugs in my GitHub stats

I spent a day rebuilding my GitHub profile card and found five problems I did not expect. None of them threw an error. Every version of the script ran cleanly and produced a card. Some of those cards were wrong about me.

The foundation is Andrew Grant's github-stats-terminal, a profile README that renders as a neofetch-style terminal readout instead of the usual row of badges. It pulls your repos, commits, stars, and lines of code from GitHub's GraphQL API and writes them into an SVG. I forked it, then ended up rewriting most of the internals to add live language stats, a contribution sparkline, WakaTime hours, and Spotify tracks.

These are the five things that surprised me, roughly in order of how long they took to notice.

1. GitHub reports language bytes, not authorship

The card lists my top programming languages. The obvious implementation: ask the API for languages on each repository, sum the bytes, and rank them.

repositories(ownerAffiliations: [OWNER, COLLABORATOR, ORGANIZATION_MEMBER]) {
  nodes {
    nameWithOwner
    languages(first: 15, orderBy: {field: SIZE, direction: DESC}) {
      edges { size node { name } }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Run that, and my top languages included 12% Blade and PHP.

I have written PHP. A handful of commits on a Laravel backend at a company I worked with, a while back. What I have not done is write enough of it for it to be an eighth of my programming identity, which is what the card was claiming.

That distinction matters because it is a subtler failure than bad data.
GitHub reports a repository's language composition in full — it has no concept of whose bytes they are. My few commits were enough for that repository to appear in my list, and the API then handed over all 4.2 million bytes of Blade and PHP in it, the overwhelming majority written by other people over the years.

The number was not fabricated. It was real work, weighted as though I had written the whole codebase.

There is no per-author language endpoint. The fix has to be a heuristic: only count a repository once you have a meaningful number of commits in it.

LANGUAGE_MIN_COMMITS = 3
Enter fullscreen mode Exit fullscreen mode

At a threshold of 1, the Laravel backend still counted. At 3, it dropped out, and the numbers started describing me. It is cheap and blunt, and it does throw away real contributions. I did write that PHP. But a metric that overstates a few commits as 12% is worse than one that omits them, because the first is confidently wrong and the second is merely incomplete.

Comparing language totals at commit thresholds of 1 and 3. At 1, Blade sits at 7.2% and PHP at 4.5% from a repository with only a few of my commits. At 3, both drop out and TypeScript rises to 92%

2. Commit walkers only see the default branch

Having fixed the over-counting, I hit the opposite problem. My Vue work was reported as 0%.

I had 71 commits on one Vue product and over a hundred more across two others. The commit-counting code inherited from upstream, and completely standard walked defaultBranchRef:

repository(name: $repo, owner: $owner) {
  defaultBranchRef {
    target { ... on Commit { history(first: 100, after: $cursor) { ... } } }
  }
}
Enter fullscreen mode Exit fullscreen mode

One of those repositories does not use main as its default at all. Its default is itself a long-lived feature branch, carrying 1,469 commits, none of them mine. My 57 commits sit on a different branch that was never merged into it.

My commits per branch on that repository: zero on the default branch, then 57, 49, 47, 44 and 26 across five feature branches

This matters more than it sounds. Plenty of real work sits on branches that never land: spikes, client projects that pivoted, feature branches superseded by a rewrite. A commit walker anchored to the default branch treats all of it as though it never happened.

The fix uses an author filter across every branch, and only for repos that look empty — so it costs one extra query per borderline repository rather than one per repository:

refs(refPrefix: "refs/heads/", first: 50) {
  nodes {
    target { ... on Commit { history(author: {id: $author}) { totalCount } } }
  }
}
Enter fullscreen mode Exit fullscreen mode

Take the maximum across branches rather than the sum — branches share
ancestors, so adding them up double-counts heavily. A lower bound is enough to answer "did I meaningfully work here?"

Vue went from invisible to 25% of my languages.

Top languages before and after: TypeScript 57.6% to 69%, JavaScript 27.4% to 6%, Blade and PHP dropping out entirely, Vue rising from 2.9% to 25%

3. An organisation can silently block your token

This is the one I would most like to have read before starting.

I generated a classic personal access token with repo, read:org and read:user, which should see everything I can see. It saw 61 repositories. The GitHub CLI's token saw 73.

Diffing the two lists, all twelve missing repositories belonged to a single organisation. My first assumption was SAML SSO — the usual cause, and
self-fixable by clicking "Authorize" on the token. So I checked the response headers:

status: 403
X-GitHub-SSO: (absent)
unauthenticated visibility: 200
Enter fullscreen mode Exit fullscreen mode

No SSO challenge. And the repository was public — readable with no token
at all, but forbidden to my authenticated one.

I reasoned my way from there to the right answer, and I want to be honest about how unnecessary that was, because it is the actual lesson. I had been reading status codes and headers. I had not read the response body, where GitHub had been explaining itself the entire time:

The 403 response: no SSO header, and a message body stating that the organisation forbids access via a classic personal access token, recommending a GitHub App, OAuth App, or fine-grained token instead.

That is the whole diagnosis, in the payload, on every single failed request.
Organisations can enable "Restrict access via personal access tokens
(classic)"
, and only their owners can lift it. No scope on my side helps, but the message names the remedy: a fine-grained token, a GitHub App, or an
OAuth App. That is why the GitHub CLI could see those repositories when my token could not. The CLI is an OAuth App.

Switching is not automatically the right call. A fine-grained token requires explicit per-organisation approval for private repositories, and my classic token reads private repos across five other organisations with no approval step at all. Trading those away to recover twelve would have cost far more than it gained. I kept the classic token and accepted 6.9% fewer commits and 8.5% fewer lines of code, knowingly, which is a different thing from silently.

Two takeaways. If your stats look low and you cannot explain it, enumerate your repositories with two different credentials and diff them. And before you start theorising from headers, read the body. The API is often more forthcoming than you assume.

4. A daily build cannot honestly say "now playing"

I wanted a Spotify "now playing" row. It is a lovely idea and completely incompatible with the architecture.

The card is a static SVG, committed to a repository, regenerated once a day by a scheduled GitHub Action at 05:30 UTC. A "now playing" row in that pipeline does not report what I am listening to. It reports what happened to be playing at 5:30 in the morning, frozen for the next 24 hours.

You cannot fix that without changing what the thing is - you would need a live endpoint rendering an SVG per request, which is a service to deploy and maintain rather than a file in a repo.

So the row shows top tracks over a rolling four weeks instead. It changes slowly enough that a daily rebuild tells the truth, and it says more about me than one arbitrary track would.

The general principle: when a data source and a build cadence disagree, the build cadence wins. Pick the statistic your pipeline can state honestly.

5. One piece of ASCII art cannot serve both themes

The card ships in dark and light variants, selected by
prefers-color-scheme. I generated an ASCII portrait from my avatar and used it in both.

It looked right in dark mode and wrong in light, and it took me longer than it should have to articulate why.

Dark mode paints light glyphs on a dark ground. A dense character like @ covers more of the cell with light ink, so density reads as brightness. Mapping bright pixels to dense characters is correct.

Light mode is dark ink on a light ground. The same dense @ now covers more of the cell with dark ink, so density reads as darkness. The identical art file becomes a photographic negative: my hair rendered pale, my face rendered dark.

The fix is one line in the generator, invert luminance before mapping to the character ramp, and a second committed art file:

if invert:
    value = 1.0 - value
Enter fullscreen mode Exit fullscreen mode

Same portrait, opposite tone mapping, one file per theme.

The same ASCII portrait in both themes. On dark, the head is bright against a dark ground; on light, the tone mapping is inverted so the head still reads correctly rather than as a negative

The through-line

Every one of these produced a card. No exceptions, no failed builds, no red in CI. The language stats that credited me with PHP ran exactly as cleanly as the ones that fixed it.

That is the part worth carrying elsewhere: a metric that is easy to compute is not the same as a metric that is true. Tests would not have caught any of this, because nothing was broken. The numbers were measuring the wrong thing, confidently and without complaint.

The card now shows 3,700+ hours of tracked coding time, languages weighted by repositories I actually committed to, and top tracks from a window my build cadence can honestly report. Every row is either measured or independently verifiable.

Source: github.com/codabytez
Original project: github.com/Andrew6rant/Andrew6rant

Top comments (0)