`ghglance -serve :8080` runs a web UI from the same binary: submit a username, a background job renders every card in every theme, and /u/<user> shows them again with a theme picker and embed URLs. - Cards are stored on disk with atomic symlink publishing and deleted after -retention (default 24h); -cooldown limits token-less regeneration. - Token-less jobs use a public-only server token with private and org scope forced off; a submitter's own token is used for that job only and never stored or logged. The form links to GitHub's new-token page with the needed scopes pre-ticked. - The CLI and web share one fetch helper; CLI output is unchanged. - compose.yml deploys to Coolify with a data volume and /healthz check.
11 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 /generate ─► validate ─► rate limit / cooldown ─► Queue (dedup per user)
│ -workers goroutines
▼
github.Collect ─► Store.Publish (every theme)
│
GET /u/{user} ◄── meta.json + card list ◄─────────────────┘
GET /u/{user}/{theme}/{card}.svg ◄── os.Root read
- 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. 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). Token-less jobs use the server'sGITHUB_TOKEN(identified once and cached) with private and org-repo scope forced off; they are refused when that token can read private repos, and for the token owner's own login. A submitter's token keeps its scope and skips the cooldown only for its own login; for anyone else it is refused if private-capable, otherwise forced to public scope under the cooldown. It lives only on the job and is cleared when it ends. - 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 POST, 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.