Files
ghstats/docs/system-architecture.md
T
tiennm99 939fcdc33a feat: count org repos and fix contribution-window truncation
Split each contribution year into quarterly windows.
commitContributionsByRepository caps at 100 repos per query and drops
the remainder without signalling it, which a prolific year hits: 2026
returned exactly 100 where the quarterly union returns 108. Warn when a
window still comes back at the ceiling instead of reporting a complete
list.

Add -include-org-repos / include_org_repos so org-owned repos the user
administers count toward stars, repo count, repos-per-language and
top-starred. Off by default, since enabling it moves those totals for
every existing caller. Commit-driven cards already covered org repos
through the seed list. Org repos carrying only read or write access are
dropped via viewerPermission.

Rename Profile.PublicRepos to RepoCount and drop "public" from the card
label: the count tracks the fetch filters and includes private repos
whenever the token can see them.
2026-08-13 13:20:41 +07:00

7.8 KiB
Raw Blame History

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. Stateless CLI; Action runtime just sets environment variables + runs the binary.

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

main.go
  │
  ▼
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 each window under the 100-repo ceiling, which a
  │    year-wide window silently truncates at
  │  yields: SeedRepos (deduped),
  │          DailyContributionsAllTime,
  │          TotalCommitsAllTime
  │
  ▼
FetchProductive(ctx, profile, profile.SeedRepos, loc, commitsPerRepo)
  │  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)

GraphQL queries

All three queries live in internal/github/queries.go.

Query Purpose Cost estimate
profileQuery Profile identity + totals + owned repos + last-year calendar 110 calls (100 repos/page × ≤10 pages safety cap)
contributionYearQuery Per-quarter calendar + seed list 4 calls per active year (typically 440)
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"

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 main.go, wire to new Profile fields.