mirror of
https://github.com/tiennm99/ghglance.git
synced 2026-10-11 03:13:20 +00:00
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.
227 lines
13 KiB
Markdown
227 lines
13 KiB
Markdown
# 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](#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's `A` command from point P back to P draws nothing.
|
||
- `renderProductiveTime(title, hours, theme)` — 24 bars + both axes + tick math from `niceTicks`
|
||
- `renderWeekday(title, data, theme)` — 7-bar day-of-week chart mirroring the productive-time layout; peak bar uses `theme.Accent`, others `mixHex(Background, Accent, 0.55)`
|
||
- `renderHeatmap(title, days, theme)` — 7×53 calendar grid with a 5-bucket intensity ramp synthesised from `theme.Background → theme.Accent`
|
||
- `renderContributions(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`), guaranteeing `yMax ≥ dataMax` — bar heights can never exceed `chartH` and collide with the title row.
|
||
- `formatTick` abbreviates ≥ 1000 to `k` / `M` / `B` so 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 in `width − 24` at a 0.6 char-width estimate, so long titles like `Commits 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>.svg` plus
|
||
`meta.json` (generated time, options, `public`/`private` scope; 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` (default `24h`) 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,
|
||
`-workers` concurrent, `-timeout` each, capacity 64. `SIGTERM` stops 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
|
||
(`viewer` query: login, a one-repo `privacy: PRIVATE` probe, and a
|
||
classic token's `X-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: `-serve` exits at startup unless `-oauth-client-id`,
|
||
`-oauth-client-secret` and `-public-url` are all set. Scopes come from
|
||
the ticked options (`read:user`; `repo` for private; `read:org` on top
|
||
for org repos). `/auth/start` parks the validated submission under a
|
||
256-bit random `state` (10-minute TTL, 1,000 entries max) and binds it to
|
||
the browser with an `HttpOnly`, `SameSite=Lax` cookie on `/`, named
|
||
`__Host-ghglance_oauth` and `Secure` when the public URL is https so a
|
||
sibling subdomain cannot plant it (plain `ghglance_oauth` over 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; `Stop` revokes 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 from `OAuthConfig.WebURL`/`APIURL`, so tests run
|
||
against `httptest`. The page CSP adds GitHub's origin to `form-action`,
|
||
since browsers check a form submission's redirect against it.
|
||
- **Partial fetches.** The web path runs `github.Collect` with `Strict`, 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'` + `sandbox`
|
||
on SVGs, `nosniff`, 16 KiB form limit, `http.CrossOriginProtection` on 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 `Card` interface, add to `allCards` in `card.go`.
|
||
- **New theme**: add entry to `themes` map in `theme.go`.
|
||
- **New fetcher mode** (e.g., REST per-commit): add a new method on `*Client`, call from `github.Collect`, wire to new `Profile` fields.
|