The web UI now runs every job on an OAuth token from "Sign in with GitHub". The ticked options decide the requested scopes (read:user, plus repo for private repos, plus read:org for org repos); a grant wider than requested is refused. The token lives only on the job and is revoked when the job ends, on every path including shutdown. The server no longer holds a GitHub token of its own, and the pasted-token field and /generate are gone. -serve requires -oauth-client-id, -oauth-client-secret and -public-url (GHGLANCE_OAUTH_* and GHGLANCE_PUBLIC_URL), and compose.yml requires them too. The CLI and the Action keep -token unchanged.
13 KiB
System Architecture
Runtime shape
One process, three phases: flag parsing → data fetch → SVG render.
┌───────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌──────────────┐
│ flag / env│──►│ internal/github │──►│ internal/card │──►│ output/*.svg│
│ parsing │ │ (GraphQL only) │ │ (pure render) │ │ per theme │
└───────────┘ └──────────────────┘ └─────────────────┘ └──────────────┘
▲ ▲
│ │
api.github.com internal/theme
No database, no cache, no background workers in CLI mode. Stateless CLI; Action runtime just sets environment variables + runs the binary. -serve switches the same binary into the long-running web UI described under Web UI mode.
A root context.Context is built in main.go with an overall deadline (-timeout, default 30m) and cancelled on SIGINT/SIGTERM. Every fetcher and HTTP request inherits it so a slow run aborts cleanly instead of draining the 6h Action budget.
Data-fetch sequence
github.Collect owns this sequence; the CLI and every web UI job call it, so
the two paths cannot drift. Only the profile fetch is fatal; later stages
report through CollectConfig.Warnf and leave partial data.
github.Collect(ctx, client, login, cfg)
│
▼
FetchProfile(ctx, login, opts)
│ profileQuery × N pages (owned repos, STARGAZERS desc, 100/page)
│ ownerAffiliations = [OWNER] (+ ORGANIZATION_MEMBER when
│ opts.IncludeOrgRepos; non-ADMIN org repos dropped client-side)
│ yields: Profile.{identity, stars, forks, PRs, issues,
│ TopRepos, ReposByLanguage,
│ ContributionYears,
│ DailyContributions (last year),
│ TotalCommits (last year)}
│
▼
FetchContributionsAllTime(ctx, profile, opts)
│ contributionYearQuery × 4 quarters × len(ContributionYears)
│ per quarter: totalCommitContributions +
│ contributionCalendar.weeks +
│ commitContributionsByRepository(maxRepositories: 100)
│ quarters keep most windows under the 100-repo ceiling, which a
│ year-wide window silently truncates at; a quarter that still
│ saturates is re-queried per month for repos only (its days and
│ totals are already folded in)
│ yields: SeedRepos (deduped),
│ DailyContributionsAllTime,
│ TotalCommitsAllTime
│
▼
FetchProductive(ctx, profile, SeedRepos[:cfg.TopRepos], loc, commitsPerRepo) // 0 = no cap
│ commitHistoryQuery × (#seeds × pages)
│ per commit: t = committedDate in loc
│ ProductiveAllTime[t.Hour]++
│ WeekdayAllTime[t.Weekday]++ + language votes
│ if t.After(yearAgo): Productive[t.Hour]++
│ Weekday[t.Weekday]++ + language votes
│ yields: Productive, Weekday, ProductiveAllTime, WeekdayAllTime,
│ CommitsByLanguage, CommitsByLanguageAllTime
│
▼
card.RenderAll(profile, theme, outDir) × len(themes) // caller's step
GraphQL queries
All three queries live in internal/github/queries.go.
| Query | Purpose | Cost estimate |
|---|---|---|
profileQuery |
Profile identity + totals + owned repos + last-year calendar | 1–10 calls (100 repos/page × ≤10 pages safety cap) |
contributionYearQuery |
Per-quarter calendar + seed list | 4 calls per active year, +3 per saturated quarter |
commitHistoryQuery |
Authored commits on default branch | 1 call per 100 commits per seed repo |
Typical run (8 active years, 30 seed repos, avg 50 commits each):
- profile: 1 call
- quarter loop: 8 × 4 = 32 calls
- commit history: 30 × 1 = 30 calls
- ≈ 63 GraphQL calls, 0 REST calls
Attribution model
Language attribution for the "most commit language" card is byte-weighted:
for each repo R:
total_bytes = Σ R.languages[*].bytes // precomputed once per repo
for each commit C in R:
for each (lang, bytes) in R.languages:
commits_by_lang[lang] += scaleFactor × bytes / total_bytes
Implementation in internal/github/productive.go:attributeCommit. The per-repo byte total is hoisted out of the commit loop so the hot path doesn't re-sum language edges for every commit. scaleFactor = 10_000 preserves fractional precision in int64 storage — percentages rendered in the card are unaffected by magnitude.
Known distortion: linguist excludes prose types (Markdown, AsciiDoc, reST) from byte counts. Blog-style repos with 95% Markdown and 5% JS still attribute all commits to JS. Future fix: per-commit REST file classification via -accurate-languages (see roadmap).
SVG generation
Each card produces a self-contained SVG with:
- Card frame (rounded rect, theme background, theme stroke + opacity)
- Title (top-left, theme title color)
- Content layer (chart elements, text, legend)
Shared primitives:
renderDonutCard(title, stats, theme)— pie slices via polar arc math + legend with color swatches (top 7 entries, rest collapse into "Other"). Single-slice case (one language at 100%) renders as two concentric<circle>elements instead of an arc, since SVG'sAcommand from point P back to P draws nothing.renderProductiveTime(title, hours, theme)— 24 bars + both axes + tick math fromniceTicksrenderWeekday(title, data, theme)— 7-bar day-of-week chart mirroring the productive-time layout; peak bar usestheme.Accent, othersmixHex(Background, Accent, 0.55)renderHeatmap(title, days, theme)— 7×53 calendar grid with a 5-bucket intensity ramp synthesised fromtheme.Background → theme.AccentrenderContributions(title, days, theme)— monthly aggregation, Catmull-Rom → cubic Bezier area path, two-sided Y axis
Chart-geometry invariants:
niceTicks(max, 5)rounds the top tick up to the next step (last = ceil(max/step) × step), guaranteeingyMax ≥ dataMax— bar heights can never exceedchartHand collide with the title row.formatTickabbreviates ≥ 1000 tok/M/Bso y-axis labels never exceed 4 characters (10000 → "10k",1234567 → "1.2M"); keeps the left gutter ≤ 28 px for every profile.header()picks the largest title font in [11, 15] px at which the string fits inwidth − 24at a 0.6 char-width estimate, so long titles likeCommits by Weekday (last year, UTC+7.00)still fit the frame.
Catmull-Rom control-point math: for each segment P_i → P_{i+1},
C1 = P_i + (P_{i+1} - P_{i-1}) / 6
C2 = P_{i+1} - (P_{i+2} - P_i) / 6
Tension = 0.5 (d3's default).
Theme model
theme.Theme is a pure-data struct — no methods. Cards pull t.Background, t.Text, t.Title, t.Accent, t.Muted, t.Stroke, t.StrokeOpacity. The 65 palettes live in a map keyed by snake_case ID.
Light themes (default, github, nord_bright, etc.) use StrokeOpacity: 1 with a visible stroke color; dark themes often use StrokeOpacity: 0 or a stroke that blends into the background.
Failure modes
| Fault | Behavior |
|---|---|
Empty -user |
Exit 2, usage printed |
| Unknown theme | Exit 2, suggests -list-themes |
| GraphQL 4xx/5xx | Error wrapped with HTTP status and truncated (UTF-8-safe) body |
| Primary rate limit (429 / 403 + remaining=0) | Sleep up to 5 min honoring Retry-After / X-RateLimit-Reset, retry once; longer windows surface as error |
| Per-year query returns nil user | Warn to stderr; other years still contribute |
FetchProductive network error |
Warn to stderr; partial data rendered |
| Unknown timezone | Warn to stderr; fall back to UTC |
Overall timeout (-timeout) or Ctrl-C |
ctx cancels in-flight requests; partial data may render |
| User with 0 commits | Card renders "No data available" |
Web UI mode
ghglance -serve :8080 runs internal/web (stdlib net/http,
html/template, embed; vanilla JS, no build step) instead of the one-shot
CLI path.
POST /auth/start ─► validate ─► rate limit ─► pending sign-in (state, PKCE verifier; memory, 10 min)
─► 302 github.com/login/oauth/authorize (scope from ticks, state, S256 challenge)
GET /auth/callback ─► state == cookie, single use ─► POST /login/oauth/access_token
─► narrow options to granted scopes (wider than ticked: revoke, refuse)
─► Queue (dedup per user) ─► -workers goroutines
│ token owner check, cooldown
▼
github.Collect ─► Store.Publish (every theme)
│
GET /u/{user} ◄── meta.json + card list ◄──────────┘
GET /u/{user}/{theme}/{card}.svg ◄── os.Root read
job ends ─► DELETE api.github.com/applications/{client_id}/token
- Storage.
<data>/<user>(lowercased login) is a symlink into<data>/.gen/<user>-<nanos>/, which holds<theme>/<card>.svgplusmeta.json(generated time, options,public/privatescope; never the token). Publish renders into a fresh generation directory, then renames a new symlink over the old one, so a reader sees the old set or the new set, never a partial one. Startup sweeps generations no link points at. Sets older than-retention(default24h) are deleted at startup and hourly; a store mutex keeps that removal from racing a republish. - Path safety. Logins are checked against GitHub's rule (alphanumerics
and single hyphens, 1–39 chars) and themes and card names against the
registered lists before any path is built; reads go through
os.Root. - Jobs. In-process queue, at most one queued or running job per user,
-workersconcurrent,-timeouteach, capacity 64.SIGTERMstops the HTTP server, cancels running jobs and drops queued ones; nothing is published mid-render. - Tokens. The server has no GitHub token of its own: every job runs on
the token of the visitor's sign-in. GitHub folds every private
contribution a token can see into totals and calendars, so repo filters
alone cannot keep cards public. Each job first identifies its token
(
viewerquery: login, a one-repoprivacy: PRIVATEprobe, and a classic token'sX-OAuth-Scopes). Signed in as the target login, the job keeps the ticked scope and skips the cooldown; as anyone else it is refused if private-capable, otherwise forced to public scope under the cooldown. The token lives only on the job and is cleared when it ends. - Sign in with GitHub (
internal/web/oauth.go). OAuth App web flow, required:-serveexits at startup unless-oauth-client-id,-oauth-client-secretand-public-urlare all set. Scopes come from the ticked options (read:user;repofor private;read:orgon top for org repos)./auth/startparks the validated submission under a 256-bit randomstate(10-minute TTL, 1,000 entries max) and binds it to the browser with anHttpOnly,SameSite=Laxcookie on/, named__Host-ghglance_oauthandSecurewhen the public URL is https so a sibling subdomain cannot plant it (plainghglance_oauthover http, a weaker binding). The callback compares state and cookie in constant time, consumes the entry, exchanges the code with the PKCE verifier (15 s timeout), drops options whose scope GitHub did not grant, revokes and refuses a token carrying scopes the ticks did not ask for (GitHub folds earlier grants into new tokens), and hands the token to the queue. The worker revokes it before it reports the job finished;Stoprevokes the tokens of queued jobs it drops, and a callback whose job is not queued revokes at once. Revocation is best effort and logs only the status. GitHub endpoints come fromOAuthConfig.WebURL/APIURL, so tests run againsthttptest. The page CSP adds GitHub's origin toform-action, since browsers check a form submission's redirect against it. - Partial fetches. The web path runs
github.CollectwithStrict, so a failed all-time or commit-history stage fails the job, and a job whose deadline passed is failed even if the fetch returned. The CLI keeps rendering partial data with warnings. - HTTP hardening. Strict CSP on pages,
default-src 'none'+sandboxon SVGs,nosniff, 16 KiB form limit,http.CrossOriginProtectionon the POSTs, per-client token bucket (burst 5, +1 per 2 min) keyed by IPv4 address or IPv6 /64, capped at 10,000 tracked clients.
Extension points
- New card: implement
Cardinterface, add toallCardsincard.go. - New theme: add entry to
themesmap intheme.go. - New fetcher mode (e.g., REST per-commit): add a new method on
*Client, call fromgithub.Collect, wire to newProfilefields.