mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-09-05 06:16:53 +00:00
docs: add project documentation set
Seven canonical docs under docs/ per the project structure convention: - project-overview-pdr.md users, non-goals, requirements - codebase-summary.md directory layout, module responsibilities - system-architecture.md runtime phases, GraphQL flow, SVG primitives - code-standards.md YAGNI/KISS/DRY, Go conventions, commit rules - design-guidelines.md frame dimensions, theme roles, per-card specs - deployment-guide.md Action/binary/Docker paths, release process - project-roadmap.md done phases (0-5), planned phases (6-9) All files under the 800-line cap. Each leans on tables; grammar sacrificed for concision per project rules.
This commit is contained in:
@@ -0,0 +1,91 @@
|
||||
# Code Standards
|
||||
|
||||
## Principles
|
||||
|
||||
Applied in order: **YAGNI → KISS → DRY**.
|
||||
|
||||
- No feature flags, no plugin systems, no abstractions for hypothetical callers.
|
||||
- One way to do a thing. A helper emerges only after the second call site, never before.
|
||||
- Three similar lines beats a premature abstraction. Extract when a fourth arrives.
|
||||
|
||||
## Go conventions
|
||||
|
||||
### File naming
|
||||
|
||||
- Multi-word files use `snake_case` (Go ecosystem standard): `repos_per_language.go`, `contributions_all_time.go`.
|
||||
- Single-word files stay single-word: `client.go`, `model.go`, `profile.go`.
|
||||
- Test files adjacent to the unit under test with `_test.go` suffix.
|
||||
|
||||
### Package structure
|
||||
|
||||
- `main.go` at repo root — the CLI wrapper only.
|
||||
- All reusable code under `internal/` so it can't accidentally become an API.
|
||||
- Each package owns one concern: `github` = network, `card` = render, `theme` = palette data.
|
||||
|
||||
### Error handling
|
||||
|
||||
- Wrap errors with `fmt.Errorf("%w: …", err)` when adding context; bare return when the caller already has enough context.
|
||||
- Sentinel errors (`errors.New`) only when callers need to type-check. We have none today — don't invent them.
|
||||
- Network/API errors in fetchers bubble up; `main.go` decides whether to exit or warn.
|
||||
|
||||
### No hidden state
|
||||
|
||||
- Profile fetchers mutate the `*Profile` argument in-place (`FetchContributionsAllTime(p, opts)`) — explicit ownership, no package-level caches.
|
||||
- Renderers are pure functions of `(*Profile, theme.Theme)`. No side effects, no goroutines.
|
||||
|
||||
### Comments
|
||||
|
||||
Default: **no comments**. Exceptions:
|
||||
|
||||
- **Why non-obvious code is non-obvious.** Example: the `scaleFactor = 10_000` constant — a comment explains why fixed-point and not float.
|
||||
- **Hidden invariant or constraint.** Example: the comment on `contributionYearQuery` noting the `maxRepositories: 100` cap.
|
||||
- **Workaround for an upstream behavior.** Example: clamping the current year's `to` to `now` so GitHub doesn't reject future timestamps.
|
||||
|
||||
Never write comments that describe what well-named code does (`// increment counter`). Never reference "the recent fix" or "for issue #42" — that belongs in git.
|
||||
|
||||
### Function length
|
||||
|
||||
- No hard limit. 200-line functions are fine when the logic is linear.
|
||||
- Extract a helper only when (a) the same shape repeats twice, or (b) a block needs an independent name to be read at the call site.
|
||||
|
||||
### Exported vs unexported
|
||||
|
||||
- Start unexported. Export only when a test file or another package needs the symbol.
|
||||
- Types on the public API: `Profile`, `RepoInfo`, `LangStat`, `LangEdge`, `DailyContribution`, `FetchOptions`, `Client`, `Theme`, `Card`.
|
||||
- Everything else stays lowercase.
|
||||
|
||||
## SVG output standards
|
||||
|
||||
- Always XML-escape user-controlled strings through `escapeXML` (`&`, `<`, `>`, `"`, `'`).
|
||||
- Numbers formatted via `formatInt` with thousands separators.
|
||||
- Stable viewbox per card (`500×220` for most, `500×220` for profile too).
|
||||
- No `<script>` tags, no event handlers. Cards are pure markup.
|
||||
|
||||
## Testing
|
||||
|
||||
- Unit tests in the same package, no mocking of http.Client — we test rendering and pure helpers.
|
||||
- Network tests are omitted; integration verification is manual (`./ghstats -token ...`).
|
||||
- `go vet ./...` clean before every commit.
|
||||
|
||||
## Commit conventions
|
||||
|
||||
- Conventional commit prefixes: `feat:`, `fix:`, `refactor:`, `docs:`, `test:`, `build:`, `ci:`, `chore:`.
|
||||
- Scope is optional: `refactor(card): …`.
|
||||
- Never use `chore:` or `docs:` for `.claude/` directory changes (project rule).
|
||||
- No AI references. No "generated by" footers. Keep the subject ≤ 72 chars.
|
||||
|
||||
## Pre-commit checklist
|
||||
|
||||
```
|
||||
go vet ./...
|
||||
go test ./...
|
||||
go build ./...
|
||||
```
|
||||
|
||||
All three must pass. If a test is failing, fix the test before committing — don't skip.
|
||||
|
||||
## Dependency policy
|
||||
|
||||
- **Stdlib only.** `go.mod` lists no `require` entries.
|
||||
- If a future feature needs a third-party module, add it in a dedicated commit with justification in the message.
|
||||
- Keep binary size under 15 MB.
|
||||
@@ -0,0 +1,109 @@
|
||||
# Codebase Summary
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
ghstats/
|
||||
├── main.go # CLI entry point; wires flags → fetchers → renderers
|
||||
├── action.yml # GitHub Action metadata
|
||||
├── entrypoint.sh # Action runtime; maps INPUT_* env → CLI flags
|
||||
├── Dockerfile # Multi-stage build for the Action image
|
||||
├── go.mod # Module declaration; no external deps
|
||||
├── internal/
|
||||
│ ├── github/ # GraphQL client + fetchers + models
|
||||
│ │ ├── client.go # HTTP POST to /graphql, error decoding
|
||||
│ │ ├── queries.go # profileQuery, commitHistoryQuery, contributionYearQuery
|
||||
│ │ ├── model.go # Profile, RepoInfo, LangStat, LangEdge, DailyContribution
|
||||
│ │ ├── profile.go # FetchProfile — user + owned repos + stats + calendar
|
||||
│ │ ├── productive.go # FetchProductive — commit history → hour histogram + lang buckets
|
||||
│ │ ├── contributions_all_time.go # FetchContributionsAllTime — per-year loop → seed list + daily series
|
||||
│ │ └── profile_test.go # sortLangStats tiebreak
|
||||
│ ├── card/ # SVG renderers; one file per card
|
||||
│ │ ├── card.go # Card interface, RenderAll, allCards slice
|
||||
│ │ ├── svg.go # escapeXML, formatInt, header, footer
|
||||
│ │ ├── axis.go # niceTicks (d3-style 1/2/5 × 10^k), formatTick
|
||||
│ │ ├── icons.go # Octicon path strings
|
||||
│ │ ├── profile.go # 0-profile-details
|
||||
│ │ ├── repos_per_language.go # 1-repos-per-language
|
||||
│ │ ├── most_commit_language.go # 2-most-commit-language
|
||||
│ │ ├── most_commit_language_all_time.go # 6-most-commit-language-all-time
|
||||
│ │ ├── stats.go # 3-stats
|
||||
│ │ ├── productive.go # 4-productive-time + 7-*-all-time
|
||||
│ │ ├── contributions.go # 5-contributions + 8-*-all-time
|
||||
│ │ ├── donut_chart.go # renderDonutCard — shared by language cards
|
||||
│ │ └── card_test.go # Rendering + escape + format tests
|
||||
│ └── theme/
|
||||
│ └── theme.go # 61-palette map ported from github-profile-summary-cards
|
||||
├── .github/workflows/
|
||||
│ ├── ci.yml # go vet + go test on push/PR
|
||||
│ └── release.yml # GHCR image + cross-platform binaries on tag
|
||||
├── docs/ # This directory
|
||||
├── plans/ # Research reports + implementation plans
|
||||
└── output/dracula/ # Sample committed; other themes gitignored
|
||||
```
|
||||
|
||||
## Module responsibilities
|
||||
|
||||
### `internal/github`
|
||||
|
||||
All network I/O. Exposes a `*Client` with three fetchers:
|
||||
|
||||
| Fetcher | Input | Populates |
|
||||
| --- | --- | --- |
|
||||
| `FetchProfile(login, opts)` | username, visibility flags | Profile basics, totals, owned-repos aggregation, last-year daily calendar, `TopRepos` |
|
||||
| `FetchContributionsAllTime(p, opts)` | Profile | `SeedRepos`, `DailyContributionsAllTime`, `TotalCommitsAllTime` |
|
||||
| `FetchProductive(p, repos, loc, cap)` | Profile + seed + tz + cap | `Productive`, `CommitsByLanguage`, `ProductiveAllTime`, `CommitsByLanguageAllTime` |
|
||||
|
||||
Call order in `main.go`: Profile → AllTime → Productive.
|
||||
|
||||
### `internal/card`
|
||||
|
||||
Pure rendering. Every card implements the `Card` interface:
|
||||
|
||||
```go
|
||||
type Card interface {
|
||||
Filename() string
|
||||
SVG(*github.Profile, theme.Theme) ([]byte, error)
|
||||
}
|
||||
```
|
||||
|
||||
`RenderAll` iterates `allCards`, writes each to `<outDir>/<themeID>/<Filename>`.
|
||||
|
||||
Shared helpers:
|
||||
- `renderDonutCard` — language donut + legend (used by 3 language cards)
|
||||
- `renderProductiveTime` — 24h bar chart (used by both productive cards)
|
||||
- `renderContributions` — smooth area chart (used by both contributions cards)
|
||||
- `header`, `footer` — SVG chrome
|
||||
- `niceTicks`, `formatTick` — axis math
|
||||
|
||||
### `internal/theme`
|
||||
|
||||
Static map of 61 themes. Each theme specifies title/text/background/stroke/accent/muted plus `StrokeOpacity` for correct light-theme borders.
|
||||
|
||||
## Card ↔ data flow
|
||||
|
||||
```
|
||||
profileQuery ─────► Profile.{identity, owned repos, totals, last-year calendar}
|
||||
│
|
||||
contributionYearQuery ─┬──► SeedRepos + DailyContributionsAllTime + TotalCommitsAllTime
|
||||
│
|
||||
└─ seed into ─►
|
||||
│
|
||||
commitHistoryQuery ──► Productive + CommitsByLanguage (+ AllTime variants)
|
||||
│
|
||||
▼
|
||||
9 SVG files per theme
|
||||
```
|
||||
|
||||
## Test coverage
|
||||
|
||||
- `internal/card/card_test.go` — RenderAll produces 9 valid SVGs; escape + formatInt spot-checks.
|
||||
- `internal/github/profile_test.go` — `sortLangStats` ordering and tiebreak.
|
||||
|
||||
No network-touching tests; real runs verified via `-token` + local build.
|
||||
|
||||
## Naming conventions
|
||||
|
||||
- Go files use snake_case for multi-word names (`repos_per_language.go`, `contributions_all_time.go`).
|
||||
- Cards' `Filename()` returns the numbered SVG output name — consumers sort lexicographically.
|
||||
- Themes in snake_case to match upstream (`github_dark`, `nord_bright`).
|
||||
@@ -0,0 +1,137 @@
|
||||
# Deployment Guide
|
||||
|
||||
Three consumption paths: **GitHub Action**, **prebuilt binaries**, **go install**.
|
||||
|
||||
## 1. GitHub Action (recommended for README auto-updates)
|
||||
|
||||
### Workflow template
|
||||
|
||||
File: `.github/workflows/ghstats.yml` in your profile repo.
|
||||
|
||||
```yaml
|
||||
name: ghstats
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: "0 0 * * *" # daily at 00:00 UTC
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write # needed for commit_changes
|
||||
|
||||
jobs:
|
||||
cards:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v5
|
||||
- uses: tiennm99/ghstats@v1
|
||||
with:
|
||||
user: ${{ github.repository_owner }}
|
||||
token: ${{ secrets.GHSTATS_TOKEN }}
|
||||
themes: dracula,github_dark,tokyonight
|
||||
tz: Asia/Saigon
|
||||
include_forks: "false"
|
||||
include_private: "false"
|
||||
commit_changes: "true"
|
||||
```
|
||||
|
||||
### Required secrets
|
||||
|
||||
`GHSTATS_TOKEN`: a **classic** personal access token with at minimum:
|
||||
|
||||
| Scope | Needed for |
|
||||
| --- | --- |
|
||||
| `read:user` | Basic profile fields, contribution calendar |
|
||||
| `repo` | Only if `include_private: "true"` |
|
||||
|
||||
Fine-grained PATs and the default `${{ github.token }}` lack the introspection scope for contribution calendars in many orgs, so a classic PAT is recommended.
|
||||
|
||||
Create one at <https://github.com/settings/tokens> → "Generate new token (classic)" → select `read:user` (+ `repo` if needed) → save as repo secret `GHSTATS_TOKEN`.
|
||||
|
||||
### Embedding in README
|
||||
|
||||
```md
|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||

|
||||
```
|
||||
|
||||
The Action commits SVGs to `output/<theme>/` on the default branch. GitHub serves them from the raw URL the README references.
|
||||
|
||||
## 2. Prebuilt binaries
|
||||
|
||||
Each tag under `v*` publishes:
|
||||
- Linux `amd64`, `arm64`
|
||||
- macOS `amd64`, `arm64`
|
||||
- Windows `amd64`
|
||||
|
||||
Released via `.github/workflows/release.yml` which matrixes `GOOS` × `GOARCH`, strips symbols (`-ldflags="-s -w"`), and uploads tar.gz / zip to the GitHub Release.
|
||||
|
||||
Install:
|
||||
|
||||
```sh
|
||||
# Linux x86_64 example
|
||||
curl -L https://github.com/tiennm99/ghstats/releases/latest/download/ghstats_linux_amd64.tar.gz \
|
||||
| tar xz
|
||||
./ghstats -user YOUR_USERNAME
|
||||
```
|
||||
|
||||
## 3. go install
|
||||
|
||||
```sh
|
||||
go install github.com/tiennm99/ghstats@latest
|
||||
```
|
||||
|
||||
Requires Go 1.26+. Puts the binary in `$(go env GOPATH)/bin`.
|
||||
|
||||
## Docker image
|
||||
|
||||
Published to `ghcr.io/tiennm99/ghstats:<tag>` on each `v*` release via `.github/workflows/release.yml` (buildx, multi-tag: exact version, major.minor, major, latest).
|
||||
|
||||
The Action itself uses a runner-built image by default (`image: Dockerfile` in `action.yml`). To switch to the pre-built image for faster cold starts, edit `action.yml`:
|
||||
|
||||
```yaml
|
||||
runs:
|
||||
using: docker
|
||||
image: docker://ghcr.io/tiennm99/ghstats:v1
|
||||
```
|
||||
|
||||
## Release process
|
||||
|
||||
1. Ensure `go vet ./...` and `go test ./...` pass on `main`.
|
||||
2. Tag: `git tag -a v1.2.0 -m "..." && git push origin v1.2.0`.
|
||||
3. `release.yml` handles GHCR push + binary artifacts automatically.
|
||||
4. Update any public Actions marketplace metadata if the major version changed.
|
||||
|
||||
## Rollback
|
||||
|
||||
- Revert the tag: `git push --delete origin v1.2.0`, delete GitHub release, delete GHCR tag.
|
||||
- Users pinned to `@v1` keep working because the previous patch is still tagged.
|
||||
|
||||
## Rate limit considerations
|
||||
|
||||
| Scenario | GraphQL calls per run | Notes |
|
||||
| --- | --- | --- |
|
||||
| Typical user, defaults | 15–40 | Well under 5000 pts/hr |
|
||||
| Active user (8 years, 30+ seed repos) | 40–80 | Still comfortable |
|
||||
| `-include-private=true` with 100+ work repos | 80–200 | Fine for daily cron |
|
||||
| Adversarial user with 500+ committed repos/year | Capped by `maxRepositories: 100` per year query | Long tail drops silently |
|
||||
|
||||
No REST calls today. Future `-accurate-languages` mode will push toward 1000+ REST per run; schedule that mode less frequently (weekly, not daily).
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| "error: fetch profile: graphql: Could not resolve to a User" | Username typo |
|
||||
| "http 401" | Token expired or lacks `read:user` |
|
||||
| "http 403: rate limit exceeded" | PAT scope too narrow; token quota consumed by another workflow |
|
||||
| Blank contribution chart | User has 0 contributions in their window; expected |
|
||||
| Private repo data missing | `-include-private=true` not set, or PAT lacks `repo` |
|
||||
| Nothing committed by the Action | Check `permissions: contents: write` in the workflow |
|
||||
@@ -0,0 +1,108 @@
|
||||
# Design Guidelines
|
||||
|
||||
Visual conventions for ghstats SVG cards. All cards share a single frame shape so they stack cleanly in a README.
|
||||
|
||||
## Card frame
|
||||
|
||||
| Property | Value |
|
||||
| --- | --- |
|
||||
| Width × Height | **500 × 220** |
|
||||
| Corner radius | 8 px |
|
||||
| Stroke | `theme.Stroke` at `theme.StrokeOpacity` |
|
||||
| Fill | `theme.Background` |
|
||||
| Font family | `'Segoe UI', Ubuntu, Sans-Serif` |
|
||||
| Title | 18 px, weight 600, `theme.Title`, anchored at `(25, 35)` |
|
||||
|
||||
Generated by `header(width, height, bg, stroke, strokeOpacity, titleColor, title)` in `internal/card/svg.go`.
|
||||
|
||||
## Theme role mapping
|
||||
|
||||
| Theme field | Used for |
|
||||
| --- | --- |
|
||||
| `Title` | Card title text |
|
||||
| `Text` | Primary content (values, names) |
|
||||
| `Background` | Card fill + stroke around donut slices to separate colors |
|
||||
| `Stroke` + `StrokeOpacity` | Card outline |
|
||||
| `Muted` | Axis lines, axis labels, icons, legend metadata |
|
||||
| `Accent` | Bars, area fills, stat values, fallback slice color |
|
||||
|
||||
Cards MUST NOT hardcode colors outside these fields. If a new visual needs a shade, pick the closest existing field — don't extend the schema without a strong reason.
|
||||
|
||||
## Row-based cards (profile, stats)
|
||||
|
||||
Single-column rows of `icon + label` or `icon + label + value`.
|
||||
|
||||
| Metric | Value |
|
||||
| --- | --- |
|
||||
| First row baseline (y) | 70 |
|
||||
| Row spacing | 24 px |
|
||||
| Icon scale | `14/16 = 0.875` from 16×16 Octicon viewBox |
|
||||
| Icon color | `theme.Muted` |
|
||||
| Right-aligned value anchor (stats) | `x = 475`, `text-anchor="end"` |
|
||||
| Value font weight | 600 |
|
||||
| Value color | `theme.Accent` |
|
||||
|
||||
Cap rows at what fits: 7 for profile, 7 for stats (commits row splits into lifetime + last-year).
|
||||
|
||||
## Donut cards (language breakdowns)
|
||||
|
||||
| Metric | Value |
|
||||
| --- | --- |
|
||||
| Donut centre | `(380, 120)` |
|
||||
| Outer radius | 70 |
|
||||
| Inner radius | 38 |
|
||||
| Top-N entries shown | 6 (overflow collapses into "Other") |
|
||||
| Slice stroke | `theme.Background`, 1.5 px (gap between slices) |
|
||||
| Legend origin | `(30, 70)` |
|
||||
| Legend row height | 22 px |
|
||||
| Swatch size | 12 × 12 |
|
||||
|
||||
Language colors come from linguist via GraphQL (`repo.languages.edges[].node.color`). Missing colors fall back to `theme.Accent`.
|
||||
|
||||
## Bar-chart cards (productive time)
|
||||
|
||||
| Metric | Value |
|
||||
| --- | --- |
|
||||
| Chart area | `x ∈ [50, 475]`, `y ∈ [60, 170]` (110 tall) |
|
||||
| Bars | 24 bars, 2 px gap |
|
||||
| Bar fill | `theme.Accent` |
|
||||
| Y-axis ticks | `niceTicks(max, 5)` — 1/2/5 × 10^k ladder |
|
||||
| X-axis labels | Hours 0, 6, 12, 18, 23 |
|
||||
| Axis caption | "hour of day" bottom-center |
|
||||
| Title format | `Commits by Hour (<window>, UTC±N.NN)` |
|
||||
| Hover | `<title>HH:00 — N commits</title>` inside each bar |
|
||||
|
||||
## Area-chart cards (contributions)
|
||||
|
||||
| Metric | Value |
|
||||
| --- | --- |
|
||||
| Chart area | `x ∈ [35, 465]`, `y ∈ [60, 180]` (120 tall) |
|
||||
| Curve | Catmull-Rom → cubic Bezier (tension 0.5) |
|
||||
| Fill | `theme.Accent` at 25% opacity |
|
||||
| Stroke | `theme.Accent`, 2 px |
|
||||
| Y-axis | **Both sides**, mirrored, same tick values |
|
||||
| X-axis labels | `mm/yy`, stride-thinned to ~6 labels regardless of bucket count |
|
||||
| Axis caption | "mm/yy" bottom-center |
|
||||
| First/last labels | Always printed (pinned endpoints) |
|
||||
|
||||
Missing months in the `[first, last]` range are inserted as zero-count rows to keep the curve time-continuous.
|
||||
|
||||
## Icons
|
||||
|
||||
- Sourced from [Primer Octicons](https://primer.style/octicons/) 16×16 set.
|
||||
- Stored as raw `<path d="…"/>` strings in `internal/card/icons.go`.
|
||||
- Rendered inside `<g transform="translate(x,y) scale(0.875)" fill="muted">…</g>`.
|
||||
- Used: `iconRepos`, `iconCompany`, `iconLocation`, `iconClock`, `iconLink`, `iconPeople`, `iconStar`, `iconCommit`, `iconPR`, `iconIssue`, `iconReview`.
|
||||
|
||||
Add new icons by copying the `<path>` from Octicons and appending to `icons.go`. Keep them to the same 16×16 viewBox so the existing scale math applies.
|
||||
|
||||
## Accessibility
|
||||
|
||||
- Contrast is the theme author's responsibility — we don't validate at runtime.
|
||||
- Tooltips (`<title>`) on productive-time bars let screen readers announce counts.
|
||||
- No motion, no `<animate>` elements — profile READMEs render statically.
|
||||
|
||||
## Text overflow
|
||||
|
||||
- Long strings (bio, repo names) are **not truncated**; they're XML-escaped and printed as-is.
|
||||
- If a card looks crowded at 500 px width, that's a card design problem — fix the layout, not the data.
|
||||
@@ -0,0 +1,68 @@
|
||||
# ghstats — Product Development Requirements
|
||||
|
||||
## One-liner
|
||||
|
||||
Single-binary Go CLI + GitHub Action that renders 9 themed SVG cards summarising a GitHub user's public (and optionally private) profile, for embedding in a profile README.
|
||||
|
||||
## Users
|
||||
|
||||
- **Primary**: GitHub users maintaining a profile README who want auto-updating stat cards without a self-hosted service.
|
||||
- **Secondary**: Tools integrating profile summaries (dashboards, portfolio sites).
|
||||
|
||||
## Non-goals
|
||||
|
||||
- WakaTime-style editor telemetry.
|
||||
- Cloning repos or running linguist locally (lowlighter/metrics territory). A future `-accurate-languages` mode may add per-commit REST classification; clone-mode is out of scope for v1.
|
||||
- Real-time / per-request API server. ghstats is a scheduled batch renderer.
|
||||
|
||||
## Value proposition vs alternatives
|
||||
|
||||
| Tool | Language | Runs as | Solves per-commit attribution? |
|
||||
| --- | --- | --- | --- |
|
||||
| anuraghazra/github-readme-stats | JS | hosted service | No (byte-size only) |
|
||||
| vn7n24fzkq/github-profile-summary-cards | TS | Action + hosted | No (primary-language-per-repo) |
|
||||
| lowlighter/metrics (indepth) | JS | Action | Yes (clones + linguist-js) |
|
||||
| **ghstats** | Go | Action + CLI | Partial (byte-weighted today; REST-per-commit planned) |
|
||||
|
||||
Distinguishing traits:
|
||||
- **Single binary**: no Node, no Ruby, no Docker needed for CLI usage.
|
||||
- **Seed-list sampling**: commit-history probes land on repos the user actually committed in (via `contributionsCollection.commitContributionsByRepository`), not top-starred or owned-only.
|
||||
- **All-time variants**: for every time-bounded card (most-commit-language, productive-time, contributions), there's a lifetime counterpart.
|
||||
- **Public-safe defaults**: forks and private repos are **off** by default; users opt in.
|
||||
|
||||
## Functional requirements
|
||||
|
||||
| # | Requirement |
|
||||
| --- | --- |
|
||||
| F1 | Render 9 cards per selected theme (see `docs/system-architecture.md`) |
|
||||
| F2 | Support 60+ themes ported from github-profile-summary-cards |
|
||||
| F3 | Handle the full username→profile→cards flow in a single invocation |
|
||||
| F4 | Package as GitHub Action with `commit_changes` auto-commit of output |
|
||||
| F5 | Expose `-include-forks` / `-include-private` toggles |
|
||||
| F6 | Apply byte-weighted commit-to-language attribution |
|
||||
| F7 | Render smooth area charts for time-series (Catmull-Rom) |
|
||||
| F8 | Support IANA timezones for productive-time (display `UTC±N.NN`) |
|
||||
|
||||
## Non-functional requirements
|
||||
|
||||
| Axis | Target |
|
||||
| --- | --- |
|
||||
| Runtime (scheduled Action) | < 60 s for typical user |
|
||||
| GraphQL calls per run | < 100 |
|
||||
| REST calls per run | 0 (may grow with future modes) |
|
||||
| Dependencies | stdlib only (no Go module deps required) |
|
||||
| Binary size | < 15 MB stripped |
|
||||
| SVG output correctness | XML-escaped, no script injection from user data |
|
||||
|
||||
## Success metrics
|
||||
|
||||
- Cards render identically across `dracula`, `github`, `github_dark`, `nord_bright`, `tokyonight`.
|
||||
- Test suite covers rendering, XML escaping, number formatting, language sort.
|
||||
- `go vet ./...` and `go test ./...` clean on every commit.
|
||||
|
||||
## Open questions / tracked roadmap items
|
||||
|
||||
- Per-commit REST classification (`-accurate-languages`)
|
||||
- Partial bare clone mode for lifetime all-repo language stats
|
||||
- `-exclude-repo` flag to drop known noise repos
|
||||
- Expose `ownerAffiliations` beyond OWNER (COLLABORATOR, ORGANIZATION_MEMBER)
|
||||
@@ -0,0 +1,99 @@
|
||||
# Project Roadmap
|
||||
|
||||
## Phase 0 — Skeleton (✅ done)
|
||||
|
||||
- Module layout, flag parsing, placeholder SVG renderers.
|
||||
|
||||
## Phase 1 — Five core cards (✅ done)
|
||||
|
||||
- Profile details, repos-per-language, most-commit-language, stats, productive-time.
|
||||
- GraphQL profile query + per-repo commit history.
|
||||
- Docker-based Action wrapper, release workflow, 61-theme palette.
|
||||
|
||||
## Phase 2 — Chart quality (✅ done)
|
||||
|
||||
- Match github-profile-summary-cards visual style (donuts, 24h bar chart, proper axes).
|
||||
- Octicon labels on profile + stats cards.
|
||||
- Smooth area chart for contributions (Catmull-Rom → cubic Bezier).
|
||||
|
||||
## Phase 3 — All-time variants (✅ done)
|
||||
|
||||
- Unified commit-history fetch splits into last-year and all-time buckets.
|
||||
- Per-year `contributionsCollection` loop yields `DailyContributionsAllTime` + `TotalCommitsAllTime`.
|
||||
- Three new cards: 6-most-commit-language-all-time, 7-productive-time-all-time, 8-contributions-all-time.
|
||||
- Stats card gains a lifetime commits row.
|
||||
|
||||
## Phase 4 — Accurate repo sampling (✅ done)
|
||||
|
||||
- Seed list built from `commitContributionsByRepository` across every active year.
|
||||
- `-include-forks` / `-include-private` visibility flags (default off).
|
||||
- `-top-repos` demoted to an optional cap (default 0 = unlimited).
|
||||
- Commit-history query takes `$owner` so forks and non-owned repos are probeable.
|
||||
|
||||
## Phase 5 — Byte-weighted attribution (✅ done)
|
||||
|
||||
- Each commit distributes fractionally across repo's language bytes, not just primary.
|
||||
- Improves mixed-code repo accuracy; still inaccurate for Markdown-heavy repos (linguist prose-exclusion).
|
||||
|
||||
---
|
||||
|
||||
## Phase 6 — Per-commit file classification (planned)
|
||||
|
||||
**Goal**: fix the Markdown-blog misattribution case (and any repo where linguist's byte view disagrees with what files user actually edited).
|
||||
|
||||
**Approach**: `GET /repos/{owner}/{repo}/commits/{sha}` per commit → classify each file with `go-enry`. Weight by `additions + deletions`.
|
||||
|
||||
**Cost**: ~1 REST call per commit. At current defaults (30 seed repos × 500 commits = 15,000 commits worst case) this is heavy — needs `-accurate-languages` opt-in flag, schedule weekly not daily.
|
||||
|
||||
**Research**: see `plans/reports/researcher-260418-2001-accurate-language-stats.md`.
|
||||
|
||||
**Status**: designed, not implemented.
|
||||
|
||||
## Phase 7 — Partial bare clone for lifetime all-repo stats (planned)
|
||||
|
||||
**Goal**: lifetime language stats across **every** repo a user has committed in, without the 500-commits-per-repo cap.
|
||||
|
||||
**Approach**: `git clone --filter=blob:none --bare` per seed repo + `git log --author --numstat` → go-enry.
|
||||
|
||||
**Cost**: ~5% of full-clone disk (trees only, no blobs); 3–5 minutes runtime for 100 repos; zero REST calls.
|
||||
|
||||
**Trade-off**: needs disk + git binary on runner. Lowlighter/metrics' indepth mode does similar but clones full blobs; we'd skip those.
|
||||
|
||||
**Status**: researched only; behind `-deep` flag when landed.
|
||||
|
||||
## Phase 8 — User-configurable repo exclusion (planned)
|
||||
|
||||
**Goal**: let users drop throwaway repos (experiments, forks they stashed) from stats without disabling forks globally.
|
||||
|
||||
**Approach**: `-exclude-repo owner1/name1,owner2/name2` flag. Filter seed list before probing.
|
||||
|
||||
**Cost**: negligible (client-side filter).
|
||||
|
||||
**Status**: pending user demand.
|
||||
|
||||
## Phase 9 — Expand ownerAffiliations (planned)
|
||||
|
||||
**Goal**: catch work done in org repos where user is a collaborator, not owner (e.g., company monorepos).
|
||||
|
||||
**Approach**: expose `-affiliations OWNER,COLLABORATOR,ORGANIZATION_MEMBER` flag. Requires thinking about whether to *display* private org work on a public profile card.
|
||||
|
||||
**Status**: blocked on deciding the privacy default.
|
||||
|
||||
---
|
||||
|
||||
## Known limitations (not roadmap items — by design)
|
||||
|
||||
| Limitation | Reason |
|
||||
| --- | --- |
|
||||
| Markdown/prose excluded from byte counts | Linguist's default; we defer to linguist |
|
||||
| No real-time API | Scope: scheduled batch renderer, not a server |
|
||||
| No WakaTime integration | Out of scope — WakaTime cards already exist (athul/waka-readme, anmol098/waka-readme-stats) |
|
||||
| No heatmap (7×24) variant of productive time | Simplified to 24-hour bar chart to match reference project |
|
||||
| Hard width of 500 px per card | Keeps README layout predictable; customizing width would cascade through every chart math |
|
||||
|
||||
## Tracked research reports
|
||||
|
||||
All in `plans/reports/`:
|
||||
- `researcher-260418-2001-accurate-language-stats.md` — metrics vs GRS vs go-enry feasibility
|
||||
- `researcher-260418-2012-profile-stats-survey.md` — follow-up survey across 6 more tools
|
||||
- `analysis-260418-2140-most-commit-language-all-time.md` — hand-reconstruction of tiennm99's card output, showing exactly why each language lands where
|
||||
@@ -0,0 +1,128 @@
|
||||
# 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.
|
||||
|
||||
## Data-fetch sequence
|
||||
|
||||
```
|
||||
main.go
|
||||
│
|
||||
▼
|
||||
FetchProfile(login, opts)
|
||||
│ profileQuery × N pages (owned repos, STARGAZERS desc, 100/page)
|
||||
│ yields: Profile.{identity, stars, forks, PRs, issues,
|
||||
│ TopRepos, ReposByLanguage,
|
||||
│ ContributionYears,
|
||||
│ DailyContributions (last year),
|
||||
│ TotalCommits (last year)}
|
||||
│
|
||||
▼
|
||||
FetchContributionsAllTime(profile, opts)
|
||||
│ contributionYearQuery × len(ContributionYears)
|
||||
│ per year: totalCommitContributions +
|
||||
│ contributionCalendar.weeks +
|
||||
│ commitContributionsByRepository(maxRepositories: 100)
|
||||
│ yields: SeedRepos (deduped),
|
||||
│ DailyContributionsAllTime,
|
||||
│ TotalCommitsAllTime
|
||||
│
|
||||
▼
|
||||
FetchProductive(profile, profile.SeedRepos, loc, commitsPerRepo)
|
||||
│ commitHistoryQuery × (#seeds × pages)
|
||||
│ per commit: t = committedDate in loc
|
||||
│ ProductiveAllTime[t.Hour]++ + language votes
|
||||
│ if t.After(yearAgo): Productive[t.Hour]++ + language votes
|
||||
│ yields: Productive, ProductiveAllTime,
|
||||
│ 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 | 1–10 calls (100 repos/page × ≤10 pages safety cap) |
|
||||
| `contributionYearQuery` | Per-year calendar + seed list | 1 call per active year (typically 1–10) |
|
||||
| `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
|
||||
- year loop: 8 calls
|
||||
- commit history: 30 × 1 = 30 calls
|
||||
- **≈ 39 GraphQL calls, 0 REST calls**
|
||||
|
||||
## Attribution model
|
||||
|
||||
Language attribution for the "most commit language" card is **byte-weighted**:
|
||||
|
||||
```
|
||||
for each commit C in repo R:
|
||||
total_bytes = Σ R.languages[*].bytes
|
||||
for each (lang, bytes) in R.languages:
|
||||
commits_by_lang[lang] += scaleFactor × bytes / total_bytes
|
||||
```
|
||||
|
||||
Implementation in `internal/github/productive.go:attributeCommit`. `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
|
||||
- `renderProductiveTime(title, hours, theme)` — 24 bars + both axes + tick math from `niceTicks`
|
||||
- `renderContributions(title, days, theme)` — monthly aggregation, Catmull-Rom → cubic Bezier area path, two-sided Y axis
|
||||
|
||||
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 61 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 body |
|
||||
| Rate limit | Bubbles up as a GraphQL error |
|
||||
| `FetchProductive` network error | Warn to stderr; partial data rendered |
|
||||
| Unknown timezone | Warn to stderr; fall back to UTC |
|
||||
| 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.
|
||||
Reference in New Issue
Block a user