From ff4975fae2f9addfa5840edd83476bd7c8cb8394 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 18 Apr 2026 18:39:51 +0700 Subject: [PATCH] feat: implement profile summary cards with GraphQL fetch and Action wrapper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add GraphQL client fetching profile, stats, language aggregation, and per-repo commit histograms for the productive-time heatmap. - Render real SVG cards (profile details, top languages, stats grid, weekday×hour heatmap) with XML escaping and thousands-formatted numbers. - Expand theme palette to 30 built-ins ported from github-readme-stats; add -list-themes, multi-theme rendering, and 'all' shortcut. - Package as Docker-based GitHub Action (action.yml, Dockerfile, entrypoint.sh) with optional auto-commit of generated cards. - Release workflow publishes GHCR image and cross-platform binaries on v* tags. - Unit tests cover rendering, XML escape, number formatting, language sort. --- .github/workflows/release.yml | 81 ++++++++++++++++++ Dockerfile | 13 +++ README.md | 129 ++++++++++++++++++++--------- action.yml | 61 ++++++++++++++ entrypoint.sh | 58 +++++++++++++ internal/card/card_test.go | 83 +++++++++++++++++++ internal/card/languages.go | 79 ++++++++++++++++-- internal/card/productive.go | 75 +++++++++++++++-- internal/card/profile.go | 70 ++++++++++++++-- internal/card/stats.go | 40 +++++++-- internal/card/svg.go | 66 +++++++++++++++ internal/github/client.go | 130 ++++++++++++++++++++++------- internal/github/model.go | 68 ++++++++++++++++ internal/github/productive.go | 81 ++++++++++++++++++ internal/github/profile.go | 140 ++++++++++++++++++++++++++++++++ internal/github/profile_test.go | 34 ++++++++ internal/github/queries.go | 77 ++++++++++++++++++ internal/theme/theme.go | 75 +++++++++++------ main.go | 87 +++++++++++++++++--- 19 files changed, 1312 insertions(+), 135 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 Dockerfile create mode 100644 action.yml create mode 100644 entrypoint.sh create mode 100644 internal/card/card_test.go create mode 100644 internal/card/svg.go create mode 100644 internal/github/model.go create mode 100644 internal/github/productive.go create mode 100644 internal/github/profile.go create mode 100644 internal/github/profile_test.go create mode 100644 internal/github/queries.go diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..c73da180 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,81 @@ +name: Release + +on: + push: + tags: + - "v*" + +permissions: + contents: read + packages: write + +jobs: + docker: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - uses: docker/setup-buildx-action@v3 + - uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Resolve tag metadata + id: meta + uses: docker/metadata-action@v5 + with: + images: ghcr.io/${{ github.repository }} + tags: | + type=semver,pattern={{version}} + type=semver,pattern={{major}}.{{minor}} + type=semver,pattern={{major}} + type=raw,value=latest + - uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + + binaries: + runs-on: ubuntu-latest + permissions: + contents: write + strategy: + matrix: + include: + - goos: linux + goarch: amd64 + - goos: linux + goarch: arm64 + - goos: darwin + goarch: amd64 + - goos: darwin + goarch: arm64 + - goos: windows + goarch: amd64 + steps: + - uses: actions/checkout@v6 + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + cache: true + - name: Build + env: + GOOS: ${{ matrix.goos }} + GOARCH: ${{ matrix.goarch }} + CGO_ENABLED: "0" + run: | + bin=ghstats + [ "$GOOS" = windows ] && bin=ghstats.exe + mkdir -p dist + go build -trimpath -ldflags="-s -w" -o "dist/$bin" . + cd dist + if [ "$GOOS" = windows ]; then + zip "ghstats_${GOOS}_${GOARCH}.zip" "$bin" + else + tar -czf "ghstats_${GOOS}_${GOARCH}.tar.gz" "$bin" + fi + - uses: softprops/action-gh-release@v2 + with: + files: dist/ghstats_*.* diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 00000000..2de76d9e --- /dev/null +++ b/Dockerfile @@ -0,0 +1,13 @@ +FROM golang:1.26-alpine AS build +WORKDIR /src +COPY go.mod ./ +RUN go mod download || true +COPY . . +RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ghstats . + +FROM alpine:3.21 +RUN apk add --no-cache ca-certificates tzdata git +COPY --from=build /out/ghstats /usr/local/bin/ghstats +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh +ENTRYPOINT ["/entrypoint.sh"] diff --git a/README.md b/README.md index 9c7f430a..c6229020 100644 --- a/README.md +++ b/README.md @@ -2,13 +2,72 @@ > Generate SVG cards summarizing a GitHub user's profile — written in Go. -`ghstats` is a single-binary CLI that fetches public data for a GitHub user and writes a themed set of SVGs (profile details, top languages, stats, productive time) you can embed in your README. +`ghstats` is a single-binary CLI (and a GitHub Action wrapping it) that fetches +public data for a GitHub user and writes a themed set of SVGs you can embed in +your profile README: -## Status +- Profile details +- Top languages +- Stats (stars, commits, PRs, issues, PR reviews, contributed-to) +- Productive time heatmap (weekday × hour) -⚠️ Early work-in-progress. Skeleton only — cards render placeholder SVGs. Roadmap below. +## Use as a GitHub Action (recommended) -## Install +Drop this in `.github/workflows/ghstats.yml` in your **profile repo** (the one +named after your username): + +```yaml +name: ghstats + +on: + schedule: + - cron: "0 0 * * *" # daily + workflow_dispatch: + +permissions: + contents: write + +jobs: + cards: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v5 + - uses: tiennm99/ghstats@v1 + with: + user: ${{ github.repository_owner }} + token: ${{ secrets.GHSTATS_TOKEN }} # classic PAT with read:user + repo + themes: dracula,github-dark,tokyonight + tz: Asia/Saigon + commit_changes: "true" +``` + +Then embed the cards in your `README.md`: + +```md +![profile](./output/dracula/0-profile-details.svg) +![languages](./output/dracula/1-languages.svg) +![stats](./output/dracula/2-stats.svg) +![productive-time](./output/dracula/3-productive-time.svg) +``` + +### Action inputs + +| Input | Default | Description | +| ------------------ | -------------------------------- | -------------------------------------------------------- | +| `user` | — | GitHub username (required) | +| `token` | `${{ github.token }}` | PAT with `read:user` + `repo` for private repo stats | +| `out` | `output` | Output directory | +| `themes` | `dracula` | Comma-separated theme ids, or `all` | +| `tz` | `UTC` | IANA tz for the productive-time card (e.g. `Asia/Saigon`)| +| `top_repos` | `10` | Owned repos sampled for commit heatmap (`0` to skip) | +| `commits_per_repo` | `100` | Max commits sampled per repo | +| `commit_changes` | `false` | Commit generated cards back to the repo | +| `commit_message` | `chore: update ghstats cards` | Commit message | +| `commit_branch` | *(current ref)* | Target branch for auto-commit | +| `author_name` | `github-actions[bot]` | Commit author | +| `author_email` | `…@users.noreply.github.com` | Commit email | + +## Use as a CLI ```sh go install github.com/tiennm99/ghstats@latest @@ -22,21 +81,30 @@ cd ghstats go build -o ghstats . ``` -## Usage +Then: ```sh -export GITHUB_TOKEN=ghp_xxx # PAT with `repo` + `read:user` for private repo stats -ghstats -user tiennm99 -theme dracula -out output +export GITHUB_TOKEN=ghp_xxx +ghstats -user tiennm99 -themes dracula,github-dark -tz Asia/Saigon -out output ``` -Flags: +| Flag | Default | Description | +| ------------------- | --------------- | ------------------------------------------------- | +| `-user` | *(required)* | GitHub username | +| `-token` | `$GITHUB_TOKEN` | Personal access token | +| `-out` | `output` | Output directory (`//…svg`) | +| `-themes` | `dracula` | Comma-separated theme ids, or `all` | +| `-tz` | `Local` | IANA timezone for productive-time heatmap | +| `-top-repos` | `10` | Owned repos sampled for heatmap (`0` to skip) | +| `-commits-per-repo` | `100` | Max commits sampled per repo | +| `-list-themes` | | Print available theme ids and exit | -| Flag | Default | Description | -| -------- | ----------------------- | ------------------------------------------------ | -| `-user` | (required) | GitHub username | -| `-token` | `$GITHUB_TOKEN` | Personal access token | -| `-out` | `output` | Output directory (cards land at `/`) | -| `-theme` | `dracula` | `dracula`, `default`, `github` | +## Themes + +Run `ghstats -list-themes` for the full list. Built-ins include `default`, +`dark`, `dracula`, `github`, `github-dark`, `tokyonight`, `onedark`, `nord`, +`gruvbox`, `radical`, `synthwave`, `monokai`, `solarized-dark`, +`solarized-light`, `transparent`, and more. ## Output @@ -49,35 +117,18 @@ output/ 3-productive-time.svg ``` -Embed in a README: +## Tokens & permissions -```md -![profile](./output/dracula/0-profile-details.svg) -![languages](./output/dracula/1-languages.svg) -``` - -## Roadmap - -- [ ] GitHub GraphQL + REST client (`internal/github`) - - [ ] Profile basics, followers, repos - - [ ] Commit histogram for productive time - - [ ] Language bytes aggregation with `linguist-vendored` respect - - [ ] Private repo support via PAT -- [ ] Card renderers (`internal/card`) - - [ ] Profile details - - [ ] Top languages (by bytes + by commit) - - [ ] Stats (stars, commits, PRs, issues, contributed-to) - - [ ] Productive time heatmap -- [ ] Themes (`internal/theme`) — pull the full set from github-readme-stats -- [ ] GitHub Action wrapper for use in profile READMEs -- [ ] Tests + examples +The default `${{ github.token }}` can read public user data but will not see +your private-repo commits. For accurate stats, create a **classic** personal +access token with `read:user` and `repo`, save it as a repo secret (e.g. +`GHSTATS_TOKEN`), and pass it via the `token` input. ## Credits & inspiration -Standing on the shoulders of these projects: - -- [**github-profile-summary-cards**](https://github.com/vn7n24fzkq/github-profile-summary-cards) by [@vn7n24fzkq](https://github.com/vn7n24fzkq) — the card layout, theme set, and output structure are directly inspired by this tool. -- [**profile-summary-for-github**](https://github.com/tipsy/profile-summary-for-github) by [@tipsy](https://github.com/tipsy) — the original web-based profile-summary generator; inspired the breakdowns (repos by language, most-commit language, etc.). +- [**github-profile-summary-cards**](https://github.com/vn7n24fzkq/github-profile-summary-cards) by [@vn7n24fzkq](https://github.com/vn7n24fzkq) — card layout, theme set, and output structure. +- [**profile-summary-for-github**](https://github.com/tipsy/profile-summary-for-github) by [@tipsy](https://github.com/tipsy) — the original profile-summary generator. +- [**github-readme-stats**](https://github.com/anuraghazra/github-readme-stats) by [@anuraghazra](https://github.com/anuraghazra) — theme palette reference. ## License diff --git a/action.yml b/action.yml new file mode 100644 index 00000000..8f1022df --- /dev/null +++ b/action.yml @@ -0,0 +1,61 @@ +name: ghstats +description: Generate GitHub profile summary SVG cards (profile, languages, stats, productive time) +author: tiennm99 +branding: + icon: bar-chart-2 + color: purple + +inputs: + user: + description: GitHub username to summarize + required: true + token: + description: | + Personal access token (classic PAT) with at least `read:user` and `repo` scopes. + The default GITHUB_TOKEN works for public repos but has no access to private stats. + required: false + default: ${{ github.token }} + out: + description: Output directory for the generated SVG cards + required: false + default: output + themes: + description: Comma-separated theme ids, or `all` to render every theme + required: false + default: dracula + tz: + description: IANA timezone for the productive-time heatmap (e.g. Asia/Saigon) + required: false + default: UTC + top_repos: + description: Number of top-starred owned repos to sample for commit histogram (0 to skip) + required: false + default: "10" + commits_per_repo: + description: Max commits sampled per repo for the heatmap + required: false + default: "100" + commit_changes: + description: Whether to commit the generated cards back to the repo + required: false + default: "false" + commit_message: + description: Commit message used when commit_changes is true + required: false + default: "chore: update ghstats cards" + commit_branch: + description: Branch to commit to (defaults to the current ref) + required: false + default: "" + author_name: + description: Git author name for auto-commit + required: false + default: "github-actions[bot]" + author_email: + description: Git author email for auto-commit + required: false + default: "41898282+github-actions[bot]@users.noreply.github.com" + +runs: + using: docker + image: Dockerfile diff --git a/entrypoint.sh b/entrypoint.sh new file mode 100644 index 00000000..58265476 --- /dev/null +++ b/entrypoint.sh @@ -0,0 +1,58 @@ +#!/bin/sh +# Entrypoint for the ghstats GitHub Action. +# Inputs are passed via INPUT_* environment variables (set by the Action runtime). +# This script translates them into ghstats CLI flags and optionally commits the +# generated SVGs back to the repository. + +set -eu + +user="${INPUT_USER:-}" +token="${INPUT_TOKEN:-${GITHUB_TOKEN:-}}" +out="${INPUT_OUT:-output}" +themes="${INPUT_THEMES:-dracula}" +tz="${INPUT_TZ:-UTC}" +top_repos="${INPUT_TOP_REPOS:-10}" +commits_per_repo="${INPUT_COMMITS_PER_REPO:-100}" +commit_changes="${INPUT_COMMIT_CHANGES:-false}" +commit_message="${INPUT_COMMIT_MESSAGE:-chore: update ghstats cards}" +commit_branch="${INPUT_COMMIT_BRANCH:-}" +author_name="${INPUT_AUTHOR_NAME:-github-actions[bot]}" +author_email="${INPUT_AUTHOR_EMAIL:-41898282+github-actions[bot]@users.noreply.github.com}" + +if [ -z "$user" ]; then + echo "::error::input 'user' is required" >&2 + exit 2 +fi + +mkdir -p "$out" + +echo "Running ghstats for user=$user themes=$themes out=$out" +ghstats \ + -user "$user" \ + -token "$token" \ + -out "$out" \ + -themes "$themes" \ + -tz "$tz" \ + -top-repos "$top_repos" \ + -commits-per-repo "$commits_per_repo" + +if [ "$commit_changes" = "true" ]; then + workspace="${GITHUB_WORKSPACE:-/github/workspace}" + cd "$workspace" + git config --global --add safe.directory "$workspace" + git config user.name "$author_name" + git config user.email "$author_email" + + if [ -n "$commit_branch" ]; then + git fetch origin "$commit_branch" || true + git checkout -B "$commit_branch" + fi + + git add "$out" + if git diff --cached --quiet; then + echo "No card changes to commit." + else + git commit -m "$commit_message" + git push origin HEAD + fi +fi diff --git a/internal/card/card_test.go b/internal/card/card_test.go new file mode 100644 index 00000000..563a504a --- /dev/null +++ b/internal/card/card_test.go @@ -0,0 +1,83 @@ +package card + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/tiennm99/ghstats/internal/github" + "github.com/tiennm99/ghstats/internal/theme" +) + +func TestRenderAll(t *testing.T) { + p := &github.Profile{ + Login: "tiennm99", + Name: "Minh Tien", + Bio: "Test & ", + Followers: 12, + Following: 7, + PublicRepos: 42, + TotalStars: 1234, + Languages: []github.LangStat{ + {Name: "Go", Color: "#00ADD8", Bytes: 5000}, + {Name: "TypeScript", Color: "#3178c6", Bytes: 3000}, + {Name: "Python", Color: "", Bytes: 2000}, + }, + } + p.Productive[2][14] = 7 + p.Productive[5][9] = 3 + + th, ok := theme.Lookup("dracula") + if !ok { + t.Fatal("dracula theme missing") + } + dir := t.TempDir() + if err := RenderAll(p, th, dir); err != nil { + t.Fatalf("RenderAll: %v", err) + } + + want := []string{ + "0-profile-details.svg", + "1-languages.svg", + "2-stats.svg", + "3-productive-time.svg", + } + for _, name := range want { + data, err := os.ReadFile(filepath.Join(dir, "dracula", name)) + if err != nil { + t.Fatalf("read %s: %v", name, err) + } + if !strings.HasPrefix(string(data), "") { + t.Errorf("%s: raw XML special characters leaked through escape", name) + } + } +} + +func TestFormatInt(t *testing.T) { + cases := map[int]string{ + 0: "0", + 12: "12", + 999: "999", + 1000: "1,000", + 12345: "12,345", + 1234567: "1,234,567", + -12345: "-12,345", + } + for in, want := range cases { + if got := formatInt(in); got != want { + t.Errorf("formatInt(%d)=%q want %q", in, got, want) + } + } +} + +func TestEscapeXML(t *testing.T) { + got := escapeXML(``) + want := "<a & "b" 'c'>" + if got != want { + t.Errorf("escapeXML=%q want %q", got, want) + } +} diff --git a/internal/card/languages.go b/internal/card/languages.go index 78c94a14..e9f2e3e3 100644 --- a/internal/card/languages.go +++ b/internal/card/languages.go @@ -2,6 +2,7 @@ package card import ( "fmt" + "strings" "github.com/tiennm99/ghstats/internal/github" "github.com/tiennm99/ghstats/internal/theme" @@ -12,11 +13,75 @@ type languagesCard struct{} func (languagesCard) Filename() string { return "1-languages.svg" } func (languagesCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { - // TODO: render language breakdown from p.Languages. - svg := fmt.Sprintf(` - - Top Languages - %d languages tracked -`, t.Background, t.Title, t.Text, len(p.Languages)) - return []byte(svg), nil + const ( + width = 500 + height = 220 + topN = 6 + barX = 25 + barY = 60 + barW = 450 + barH = 10 + legendX0 = 25 + ) + + var b strings.Builder + b.WriteString(header(width, height, t.Background, t.Title, "Top Languages")) + + langs := p.Languages + if len(langs) > topN { + langs = langs[:topN] + } + + if len(langs) == 0 { + fmt.Fprintf(&b, ` + No language data available.`, t.Muted) + b.WriteString(footer) + return []byte(b.String()), nil + } + + var total int64 + for _, l := range langs { + total += l.Bytes + } + + // Stacked bar. + fmt.Fprintf(&b, ` + + `, + barX, barY, barW, barH, t.Muted) + + offset := float64(barX) + for _, l := range langs { + w := float64(barW) * float64(l.Bytes) / float64(total) + fmt.Fprintf(&b, ` + `, + offset, barY, w, barH, colorOrAccent(l.Color, t.Accent)) + offset += w + } + b.WriteString(` + `) + + // Legend: two columns of up to 3 rows. + for i, l := range langs { + col := i % 2 + row := i / 2 + x := legendX0 + col*230 + y := 110 + row*24 + pct := 100 * float64(l.Bytes) / float64(total) + fmt.Fprintf(&b, ` + + %s %.2f%%`, + x+6, y-4, colorOrAccent(l.Color, t.Accent), + x+20, y, t.Text, escapeXML(l.Name), pct) + } + + b.WriteString(footer) + return []byte(b.String()), nil +} + +func colorOrAccent(c, fallback string) string { + if c == "" { + return fallback + } + return c } diff --git a/internal/card/productive.go b/internal/card/productive.go index 7419e92f..135d7b19 100644 --- a/internal/card/productive.go +++ b/internal/card/productive.go @@ -2,6 +2,7 @@ package card import ( "fmt" + "strings" "github.com/tiennm99/ghstats/internal/github" "github.com/tiennm99/ghstats/internal/theme" @@ -11,13 +12,71 @@ type productiveCard struct{} func (productiveCard) Filename() string { return "3-productive-time.svg" } +var weekdayLabels = [7]string{"Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"} + func (productiveCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { - // TODO: render the [7][24]int heatmap from p.Productive. - svg := fmt.Sprintf(` - - Productive Time - Heatmap placeholder -`, t.Background, t.Title, t.Text) - _ = p - return []byte(svg), nil + const ( + width = 650 + height = 240 + cellSize = 18 + cellGap = 3 + gridX = 55 + gridY = 60 + ) + + var b strings.Builder + b.WriteString(header(width, height, t.Background, t.Title, "Productive Time (last year, by hour)")) + + max := 0 + for _, row := range p.Productive { + for _, v := range row { + if v > max { + max = v + } + } + } + + // Weekday labels. + for i, d := range weekdayLabels { + y := gridY + i*(cellSize+cellGap) + cellSize - 4 + fmt.Fprintf(&b, ` + %s`, + y, t.Muted, d) + } + + // Hour labels along top (every 3 hours). + for h := 0; h < 24; h += 3 { + x := gridX + h*(cellSize+cellGap) + fmt.Fprintf(&b, ` + %02dh`, + x, t.Muted, h) + } + + // Cells. + for d := 0; d < 7; d++ { + for h := 0; h < 24; h++ { + count := p.Productive[d][h] + opacity := heatOpacity(count, max) + x := gridX + h*(cellSize+cellGap) + y := gridY + d*(cellSize+cellGap) + fmt.Fprintf(&b, ` + %s %02d:00 — %d commits`, + x, y, cellSize, cellSize, t.Accent, opacity, + weekdayLabels[d], h, count) + } + } + + b.WriteString(footer) + return []byte(b.String()), nil +} + +// heatOpacity returns the fill-opacity for a cell. Zero is almost transparent +// so the grid is still visible; max-count maps to fully opaque. +func heatOpacity(count, max int) float64 { + if max == 0 { + return 0.08 + } + const floor = 0.10 + ratio := float64(count) / float64(max) + return floor + (1.0-floor)*ratio } diff --git a/internal/card/profile.go b/internal/card/profile.go index cee9a3a4..ac97f23e 100644 --- a/internal/card/profile.go +++ b/internal/card/profile.go @@ -2,6 +2,8 @@ package card import ( "fmt" + "strings" + "time" "github.com/tiennm99/ghstats/internal/github" "github.com/tiennm99/ghstats/internal/theme" @@ -12,11 +14,65 @@ type profileCard struct{} func (profileCard) Filename() string { return "0-profile-details.svg" } func (profileCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { - // TODO: render a real profile details card. - svg := fmt.Sprintf(` - - %s - %s -`, t.Background, t.Title, p.Login, t.Muted, p.Bio) - return []byte(svg), nil + const ( + width = 500 + height = 220 + ) + + var b strings.Builder + b.WriteString(header(width, height, t.Background, t.Title, title(p))) + + // Key-value lines; skip empty fields to avoid blank rows. + y := 75 + rows := profileRows(p) + for _, r := range rows { + fmt.Fprintf(&b, ` + %s + %s`, + y, t.Muted, escapeXML(r.label), + y, t.Text, escapeXML(r.value)) + y += 22 + } + + b.WriteString(footer) + return []byte(b.String()), nil +} + +func title(p *github.Profile) string { + if p.Name != "" { + return p.Name + "'s Profile Details" + } + return p.Login + "'s Profile Details" +} + +type kv struct{ label, value string } + +func profileRows(p *github.Profile) []kv { + rows := []kv{{"Username", "@" + p.Login}} + if p.Name != "" { + rows = append(rows, kv{"Name", p.Name}) + } + if p.Company != "" { + rows = append(rows, kv{"Company", p.Company}) + } + if p.Location != "" { + rows = append(rows, kv{"Location", p.Location}) + } + if p.Website != "" { + rows = append(rows, kv{"Website", p.Website}) + } + if !p.CreatedAt.IsZero() { + rows = append(rows, kv{"Joined", p.CreatedAt.Format("2006-01-02")}) + years := time.Since(p.CreatedAt).Hours() / 24 / 365 + rows = append(rows, kv{"Account age", fmt.Sprintf("%.1f years", years)}) + } + rows = append(rows, + kv{"Followers", formatInt(p.Followers)}, + kv{"Following", formatInt(p.Following)}, + kv{"Public repos", formatInt(p.PublicRepos)}, + ) + if len(rows) > 7 { + rows = rows[:7] + } + return rows } diff --git a/internal/card/stats.go b/internal/card/stats.go index f61870c4..5438612e 100644 --- a/internal/card/stats.go +++ b/internal/card/stats.go @@ -2,6 +2,7 @@ package card import ( "fmt" + "strings" "github.com/tiennm99/ghstats/internal/github" "github.com/tiennm99/ghstats/internal/theme" @@ -12,11 +13,36 @@ type statsCard struct{} func (statsCard) Filename() string { return "2-stats.svg" } func (statsCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { - // TODO: totals for stars, commits, PRs, issues, contributed-to repos. - svg := fmt.Sprintf(` - - Stats - %d public repos · %d followers · %d following -`, t.Background, t.Title, t.Text, p.PublicRepos, p.Followers, p.Following) - return []byte(svg), nil + const ( + width = 500 + height = 220 + ) + + items := []kv{ + {"Total Stars", formatInt(p.TotalStars)}, + {"Total Commits (last year)", formatInt(p.TotalCommits)}, + {"Total PRs", formatInt(p.TotalPRs)}, + {"Total Issues", formatInt(p.TotalIssues)}, + {"Total PR Reviews", formatInt(p.TotalReviews)}, + {"Contributed to (non-fork)", formatInt(p.TotalContributedTo)}, + } + + var b strings.Builder + b.WriteString(header(width, height, t.Background, t.Title, "Stats")) + + // 2×3 grid: columns at 25 and 265, rows every 42px starting at y=80. + for i, it := range items { + col := i % 2 + row := i / 2 + x := 25 + col*240 + y := 80 + row*42 + fmt.Fprintf(&b, ` + %s + %s`, + x, y, t.Muted, escapeXML(it.label), + x, y+22, t.Accent, escapeXML(it.value)) + } + + b.WriteString(footer) + return []byte(b.String()), nil } diff --git a/internal/card/svg.go b/internal/card/svg.go new file mode 100644 index 00000000..0bec1d98 --- /dev/null +++ b/internal/card/svg.go @@ -0,0 +1,66 @@ +package card + +import ( + "fmt" + "strings" +) + +// escapeXML replaces the five XML-significant characters so user-controlled +// strings (bio, repo names) can't break the SVG document or inject markup. +func escapeXML(s string) string { + r := strings.NewReplacer( + "&", "&", + "<", "<", + ">", ">", + `"`, """, + "'", "'", + ) + return r.Replace(s) +} + +// formatInt renders n with thousands separators (e.g. 12345 → "12,345"). +func formatInt(n int) string { + neg := n < 0 + if neg { + n = -n + } + s := fmt.Sprintf("%d", n) + if len(s) <= 3 { + if neg { + return "-" + s + } + return s + } + var b strings.Builder + pre := len(s) % 3 + if pre > 0 { + b.WriteString(s[:pre]) + if len(s) > pre { + b.WriteByte(',') + } + } + for i := pre; i < len(s); i += 3 { + b.WriteString(s[i : i+3]) + if i+3 < len(s) { + b.WriteByte(',') + } + } + out := b.String() + if neg { + return "-" + out + } + return out +} + +// header returns the opening tag + background rect + title text. +func header(width, height int, bg, titleColor, title string) string { + return fmt.Sprintf(` + + %s`, + width, height, width, height, + width-1, height-1, bg, + titleColor, escapeXML(title)) +} + +const footer = ` +` diff --git a/internal/github/client.go b/internal/github/client.go index b8d8891a..ced8b2e4 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -1,42 +1,112 @@ -// Package github fetches profile data from the GitHub API. +// Package github fetches profile data from the GitHub GraphQL API. package github -import "errors" +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) -// Profile is the aggregate of data other packages render into cards. -// Fields are stubs; flesh them out as cards are implemented. -type Profile struct { - Login string - Name string - Bio string - Followers int - Following int - PublicRepos int +const endpoint = "https://api.github.com/graphql" - // Top languages aggregated across repos (name → bytes). - Languages map[string]int64 - - // Commit-count histogram indexed by [day-of-week][hour-of-day], local tz. - Productive [7][24]int -} - -// Client wraps GitHub REST + GraphQL access. +// Client issues authenticated GraphQL requests. type Client struct { token string - // TODO: http.Client, rate-limit handling + http *http.Client } -// NewClient returns a client that authenticates with the given PAT. -// Empty token uses unauthenticated access (low rate limit). +// NewClient returns a client authenticated with the given PAT. An empty token +// falls back to unauthenticated requests (60/h rate limit, no private data). func NewClient(token string) *Client { - return &Client{token: token} + return &Client{ + token: token, + http: &http.Client{Timeout: 30 * time.Second}, + } } -// Profile loads the profile summary for a user. -func (c *Client) Profile(user string) (*Profile, error) { - if user == "" { - return nil, errors.New("empty user") - } - // TODO: fetch via GraphQL: viewer, user.repositories, contributionsCollection - return &Profile{Login: user}, nil +type gqlRequest struct { + Query string `json:"query"` + Variables map[string]any `json:"variables,omitempty"` } + +type gqlError struct { + Message string `json:"message"` + Type string `json:"type,omitempty"` + Path []string `json:"path,omitempty"` +} + +type gqlResponse struct { + Data json.RawMessage `json:"data"` + Errors []gqlError `json:"errors,omitempty"` +} + +// query runs a GraphQL query and unmarshals the `data` field into out. +func (c *Client) query(q string, vars map[string]any, out any) error { + body, err := json.Marshal(gqlRequest{Query: q, Variables: vars}) + if err != nil { + return fmt.Errorf("marshal request: %w", err) + } + req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("new request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "ghstats") + if c.token != "" { + req.Header.Set("Authorization", "bearer "+c.token) + } + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("http: %w", err) + } + defer resp.Body.Close() + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return fmt.Errorf("read body: %w", err) + } + if resp.StatusCode >= 400 { + return fmt.Errorf("http %d: %s", resp.StatusCode, truncate(raw, 500)) + } + + var r gqlResponse + if err := json.Unmarshal(raw, &r); err != nil { + return fmt.Errorf("decode body: %w", err) + } + if len(r.Errors) > 0 { + msgs := make([]string, 0, len(r.Errors)) + for _, e := range r.Errors { + msgs = append(msgs, e.Message) + } + return fmt.Errorf("graphql: %s", joinErrs(msgs)) + } + if out != nil { + if err := json.Unmarshal(r.Data, out); err != nil { + return fmt.Errorf("decode data: %w", err) + } + } + return nil +} + +func truncate(b []byte, n int) string { + if len(b) <= n { + return string(b) + } + return string(b[:n]) + "…" +} + +func joinErrs(ss []string) string { + if len(ss) == 0 { + return "" + } + out := ss[0] + for _, s := range ss[1:] { + out += "; " + s + } + return out +} + diff --git a/internal/github/model.go b/internal/github/model.go new file mode 100644 index 00000000..fdce7493 --- /dev/null +++ b/internal/github/model.go @@ -0,0 +1,68 @@ +package github + +import "time" + +// Profile is the aggregate of data other packages render into cards. +type Profile struct { + ID string + Login string + Name string + Bio string + AvatarURL string + Company string + Location string + Website string + CreatedAt time.Time + + Followers int + Following int + PublicRepos int + + // Totals for the stats card. + TotalStars int + TotalForks int + TotalCommits int + TotalPRs int + TotalIssues int + TotalReviews int + TotalContributedTo int + TotalContributions int // lifetime contributions from calendar + restricted + + // Sorted desc by bytes. Color is GitHub's linguist color or "" if absent. + Languages []LangStat + + // Commit-count histogram indexed by [day-of-week 0=Sunday][hour-of-day 0-23]. + Productive [7][24]int + + // TopRepos is the list of owned repo names sorted by stargazer count desc, + // populated by FetchProfile. Used as the seed set for FetchProductive. + TopRepos []string +} + +// LangStat is one row in the top-languages card. +type LangStat struct { + Name string + Color string + Bytes int64 +} + +// repoNode is the GraphQL shape of one repository node; kept here because +// it's shared by the profile fetcher and the productive-time fetcher. +type repoNode struct { + Name string `json:"name"` + StargazerCount int `json:"stargazerCount"` + ForkCount int `json:"forkCount"` + PrimaryLanguage *struct { + Name string `json:"name"` + Color string `json:"color"` + } `json:"primaryLanguage"` + Languages struct { + Edges []struct { + Size int64 `json:"size"` + Node struct { + Name string `json:"name"` + Color string `json:"color"` + } `json:"node"` + } `json:"edges"` + } `json:"languages"` +} diff --git a/internal/github/productive.go b/internal/github/productive.go new file mode 100644 index 00000000..d87a6c84 --- /dev/null +++ b/internal/github/productive.go @@ -0,0 +1,81 @@ +package github + +import ( + "time" +) + +// productiveGQL is the response shape for commitHistoryQuery. +type productiveGQL struct { + Repository *struct { + DefaultBranchRef *struct { + Target *struct { + History struct { + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + Nodes []struct { + CommittedDate string `json:"committedDate"` + } `json:"nodes"` + } `json:"history"` + } `json:"target"` + } `json:"defaultBranchRef"` + } `json:"repository"` +} + +// FetchProductive fills p.Productive with a [7][24] commit histogram over the +// last year, gathered from the user's top-starred owned repos. Each repo is +// sampled up to maxPerRepo commits to keep the cost bounded. +// +// The timezone loc is applied to CommittedDate so the heatmap reflects when the +// user actually commits, not UTC. +func (c *Client) FetchProductive(p *Profile, repos []string, loc *time.Location, maxPerRepo int) error { + if loc == nil { + loc = time.UTC + } + since := time.Now().AddDate(-1, 0, 0).UTC().Format(time.RFC3339) + + for _, repo := range repos { + var cursor *string + seen := 0 + for { + if seen >= maxPerRepo { + break + } + vars := map[string]any{ + "login": p.Login, + "repo": repo, + "userId": p.ID, + "since": since, + } + if cursor != nil { + vars["after"] = *cursor + } + + var resp productiveGQL + if err := c.query(commitHistoryQuery, vars, &resp); err != nil { + return err + } + if resp.Repository == nil || resp.Repository.DefaultBranchRef == nil || + resp.Repository.DefaultBranchRef.Target == nil { + break + } + h := resp.Repository.DefaultBranchRef.Target.History + for _, n := range h.Nodes { + t, err := time.Parse(time.RFC3339, n.CommittedDate) + if err != nil { + continue + } + tl := t.In(loc) + p.Productive[int(tl.Weekday())][tl.Hour()]++ + seen++ + } + if !h.PageInfo.HasNextPage { + break + } + end := h.PageInfo.EndCursor + cursor = &end + } + } + return nil +} diff --git a/internal/github/profile.go b/internal/github/profile.go new file mode 100644 index 00000000..0562e0f6 --- /dev/null +++ b/internal/github/profile.go @@ -0,0 +1,140 @@ +package github + +import ( + "errors" + "sort" + "time" +) + +// profileGQL mirrors the GraphQL response for profileQuery. +type profileGQL struct { + User *struct { + ID string `json:"id"` + Login string `json:"login"` + Name string `json:"name"` + Bio string `json:"bio"` + AvatarURL string `json:"avatarUrl"` + Company string `json:"company"` + Location string `json:"location"` + Website string `json:"websiteUrl"` + CreatedAt string `json:"createdAt"` + + Followers struct{ TotalCount int } `json:"followers"` + Following struct{ TotalCount int } `json:"following"` + + PullRequests struct{ TotalCount int } `json:"pullRequests"` + Issues struct{ TotalCount int } `json:"issues"` + + RepositoriesContributedTo struct{ TotalCount int } `json:"repositoriesContributedTo"` + + ContributionsCollection struct { + TotalCommitContributions int `json:"totalCommitContributions"` + TotalIssueContributions int `json:"totalIssueContributions"` + TotalPullRequestContributions int `json:"totalPullRequestContributions"` + TotalPullRequestReviewContributions int `json:"totalPullRequestReviewContributions"` + TotalRepositoryContributions int `json:"totalRepositoryContributions"` + RestrictedContributionsCount int `json:"restrictedContributionsCount"` + ContributionCalendar struct { + TotalContributions int `json:"totalContributions"` + } `json:"contributionCalendar"` + } `json:"contributionsCollection"` + + Repositories struct { + TotalCount int `json:"totalCount"` + PageInfo struct { + HasNextPage bool `json:"hasNextPage"` + EndCursor string `json:"endCursor"` + } `json:"pageInfo"` + Nodes []repoNode `json:"nodes"` + } `json:"repositories"` + } `json:"user"` +} + +// FetchProfile collects profile, stats and language data for the given user. +// Repositories are paginated up to 10 pages (1000 owned repos) as a safety cap. +func (c *Client) FetchProfile(login string) (*Profile, error) { + if login == "" { + return nil, errors.New("empty user") + } + + p := &Profile{Login: login} + langBytes := map[string]int64{} + langColor := map[string]string{} + + var cursor *string + const maxPages = 10 + for page := 0; page < maxPages; page++ { + vars := map[string]any{"login": login} + if cursor != nil { + vars["after"] = *cursor + } + + var resp profileGQL + if err := c.query(profileQuery, vars, &resp); err != nil { + return nil, err + } + if resp.User == nil { + return nil, errors.New("user not found") + } + u := resp.User + + if page == 0 { + p.ID = u.ID + p.Name = u.Name + p.Bio = u.Bio + p.AvatarURL = u.AvatarURL + p.Company = u.Company + p.Location = u.Location + p.Website = u.Website + if t, err := time.Parse(time.RFC3339, u.CreatedAt); err == nil { + p.CreatedAt = t + } + p.Followers = u.Followers.TotalCount + p.Following = u.Following.TotalCount + p.PublicRepos = u.Repositories.TotalCount + p.TotalPRs = u.PullRequests.TotalCount + p.TotalIssues = u.Issues.TotalCount + p.TotalContributedTo = u.RepositoriesContributedTo.TotalCount + + cc := u.ContributionsCollection + p.TotalCommits = cc.TotalCommitContributions + p.TotalReviews = cc.TotalPullRequestReviewContributions + p.TotalContributions = cc.ContributionCalendar.TotalContributions + cc.RestrictedContributionsCount + } + + for _, r := range u.Repositories.Nodes { + p.TotalStars += r.StargazerCount + p.TotalForks += r.ForkCount + p.TopRepos = append(p.TopRepos, r.Name) + for _, e := range r.Languages.Edges { + langBytes[e.Node.Name] += e.Size + if _, ok := langColor[e.Node.Name]; !ok { + langColor[e.Node.Name] = e.Node.Color + } + } + } + + if !u.Repositories.PageInfo.HasNextPage { + break + } + end := u.Repositories.PageInfo.EndCursor + cursor = &end + } + + p.Languages = sortLanguages(langBytes, langColor) + return p, nil +} + +func sortLanguages(bytes map[string]int64, color map[string]string) []LangStat { + out := make([]LangStat, 0, len(bytes)) + for name, b := range bytes { + out = append(out, LangStat{Name: name, Color: color[name], Bytes: b}) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Bytes != out[j].Bytes { + return out[i].Bytes > out[j].Bytes + } + return out[i].Name < out[j].Name + }) + return out +} diff --git a/internal/github/profile_test.go b/internal/github/profile_test.go new file mode 100644 index 00000000..41b77496 --- /dev/null +++ b/internal/github/profile_test.go @@ -0,0 +1,34 @@ +package github + +import "testing" + +func TestSortLanguages(t *testing.T) { + bytes := map[string]int64{ + "Go": 500, + "Python": 300, + "TypeScript": 500, // tie with Go → alphabetical wins + "HTML": 100, + } + colors := map[string]string{ + "Go": "#00ADD8", + "Python": "#3572A5", + "TypeScript": "#3178c6", + } + got := sortLanguages(bytes, colors) + + wantOrder := []string{"Go", "TypeScript", "Python", "HTML"} + if len(got) != len(wantOrder) { + t.Fatalf("len=%d want %d", len(got), len(wantOrder)) + } + for i, name := range wantOrder { + if got[i].Name != name { + t.Errorf("pos %d: %q want %q", i, got[i].Name, name) + } + } + if got[0].Color != "#00ADD8" { + t.Errorf("Go color=%q want #00ADD8", got[0].Color) + } + if got[3].Color != "" { + t.Errorf("HTML color=%q want empty (missing from colors)", got[3].Color) + } +} diff --git a/internal/github/queries.go b/internal/github/queries.go new file mode 100644 index 00000000..ebbb193b --- /dev/null +++ b/internal/github/queries.go @@ -0,0 +1,77 @@ +package github + +// profileQuery pulls everything needed for the profile, stats and languages +// cards in one round trip. Repo pagination is handled by the caller if the +// user owns more than 100 repos. +const profileQuery = ` +query($login: String!, $after: String) { + user(login: $login) { + id + login + name + bio + avatarUrl + company + location + websiteUrl + createdAt + followers { totalCount } + following { totalCount } + pullRequests { totalCount } + issues { totalCount } + repositoriesContributedTo( + first: 1 + contributionTypes: [COMMIT, PULL_REQUEST, ISSUE, PULL_REQUEST_REVIEW] + ) { totalCount } + contributionsCollection { + totalCommitContributions + totalIssueContributions + totalPullRequestContributions + totalPullRequestReviewContributions + totalRepositoryContributions + restrictedContributionsCount + contributionCalendar { totalContributions } + } + repositories( + first: 100 + after: $after + ownerAffiliations: OWNER + isFork: false + orderBy: { field: STARGAZERS, direction: DESC } + ) { + totalCount + pageInfo { hasNextPage endCursor } + nodes { + name + stargazerCount + forkCount + primaryLanguage { name color } + languages(first: 20, orderBy: { field: SIZE, direction: DESC }) { + edges { + size + node { name color } + } + } + } + } + } +}` + +// commitHistoryQuery fetches commit timestamps in the default branch of one +// repo, filtered to commits authored by the target user. Used to build the +// productive-time heatmap. +const commitHistoryQuery = ` +query($login: String!, $repo: String!, $userId: ID!, $since: GitTimestamp!, $after: String) { + repository(owner: $login, name: $repo) { + defaultBranchRef { + target { + ... on Commit { + history(first: 100, after: $after, author: { id: $userId }, since: $since) { + pageInfo { hasNextPage endCursor } + nodes { committedDate } + } + } + } + } + } +}` diff --git a/internal/theme/theme.go b/internal/theme/theme.go index 76d43f0d..b5a3ee55 100644 --- a/internal/theme/theme.go +++ b/internal/theme/theme.go @@ -1,6 +1,8 @@ // Package theme defines SVG color palettes used by card renderers. package theme +import "sort" + // Theme describes the colors applied to a rendered card. type Theme struct { ID string @@ -11,32 +13,45 @@ type Theme struct { Muted string } -// Built-in palettes. Add new themes by appending to the map. +// Built-in palettes. Port of a curated subset from github-readme-stats. var themes = map[string]Theme{ - "dracula": { - ID: "dracula", - Background: "#282a36", - Text: "#f8f8f2", - Title: "#ff79c6", - Accent: "#bd93f9", - Muted: "#6272a4", - }, - "default": { - ID: "default", - Background: "#ffffff", - Text: "#24292f", - Title: "#0969da", - Accent: "#2188ff", - Muted: "#57606a", - }, - "github": { - ID: "github", - Background: "#0d1117", - Text: "#c9d1d9", - Title: "#58a6ff", - Accent: "#3fb950", - Muted: "#8b949e", - }, + "default": {ID: "default", Background: "#fffefe", Text: "#434d58", Title: "#2f80ed", Accent: "#4c71f2", Muted: "#6a737d"}, + "dark": {ID: "dark", Background: "#151515", Text: "#9f9f9f", Title: "#fff", Accent: "#79ff97", Muted: "#666"}, + "radical": {ID: "radical", Background: "#141321", Text: "#a9fef7", Title: "#fe428e", Accent: "#f8d847", Muted: "#a9fef7"}, + "merko": {ID: "merko", Background: "#0b1p08", Text: "#68b684", Title: "#abd200", Accent: "#b7d364", Muted: "#68b684"}, + "gruvbox": {ID: "gruvbox", Background: "#282828", Text: "#fbf1c7", Title: "#fabd2f", Accent: "#8ec07c", Muted: "#a89984"}, + "tokyonight": {ID: "tokyonight", Background: "#1a1b27", Text: "#a9b1d6", Title: "#70a5fd", Accent: "#bf91f3", Muted: "#565f89"}, + "onedark": {ID: "onedark", Background: "#282c34", Text: "#aaa", Title: "#e4bf7a", Accent: "#8eb573", Muted: "#5c6370"}, + "cobalt": {ID: "cobalt", Background: "#193549", Text: "#dbe4ee", Title: "#e683d9", Accent: "#0088ff", Muted: "#6fc3df"}, + "synthwave": {ID: "synthwave", Background: "#2b213a", Text: "#e5289e", Title: "#e2e9ec", Accent: "#ef8539", Muted: "#e5289e"}, + "highcontrast": {ID: "highcontrast", Background: "#000000", Text: "#ffffff", Title: "#e7f216", Accent: "#00ffff", Muted: "#ffffff"}, + "dracula": {ID: "dracula", Background: "#282a36", Text: "#f8f8f2", Title: "#ff79c6", Accent: "#bd93f9", Muted: "#6272a4"}, + "prussian": {ID: "prussian", Background: "#172f45", Text: "#c8c9db", Title: "#bddfff", Accent: "#38b2ac", Muted: "#6c95b8"}, + "monokai": {ID: "monokai", Background: "#272822", Text: "#d6ebbf", Title: "#eb1f6a", Accent: "#e28905", Muted: "#75715e"}, + "vue": {ID: "vue", Background: "#fffefe", Text: "#476582", Title: "#41b883", Accent: "#35495e", Muted: "#476582"}, + "vue-dark": {ID: "vue-dark", Background: "#1d1f21", Text: "#bbb", Title: "#41b883", Accent: "#41b883", Muted: "#888"}, + "shades-of-purple":{ID: "shades-of-purple", Background: "#2d2b55", Text: "#a599e9", Title: "#fad000", Accent: "#b362ff", Muted: "#a599e9"}, + "nightowl": {ID: "nightowl", Background: "#011627", Text: "#acb4c2", Title: "#7fdbca", Accent: "#82aaff", Muted: "#637777"}, + "buefy": {ID: "buefy", Background: "#ffffff", Text: "#363636", Title: "#7957d5", Accent: "#ff3860", Muted: "#7a7a7a"}, + "blue-green": {ID: "blue-green", Background: "#040f0f", Text: "#2dd4bf", Title: "#afebcd", Accent: "#26a69a", Muted: "#5e8b7e"}, + "algolia": {ID: "algolia", Background: "#050f2c", Text: "#ffffff", Title: "#00aeff", Accent: "#2dde98", Muted: "#8c8c8c"}, + "great-gatsby": {ID: "great-gatsby", Background: "#000000", Text: "#ffd700", Title: "#ffa726", Accent: "#ffb74d", Muted: "#9e9e9e"}, + "darcula": {ID: "darcula", Background: "#242424", Text: "#ba5f17", Title: "#ba5f17", Accent: "#2f81f7", Muted: "#8b949e"}, + "bear": {ID: "bear", Background: "#1f2023", Text: "#8f9396", Title: "#e03c8a", Accent: "#00aeff", Muted: "#8f9396"}, + "solarized-dark": {ID: "solarized-dark", Background: "#002b36", Text: "#859900", Title: "#268bd2", Accent: "#d33682", Muted: "#586e75"}, + "solarized-light": {ID: "solarized-light", Background: "#fdf6e3", Text: "#657b83", Title: "#268bd2", Accent: "#d33682", Muted: "#93a1a1"}, + "chartreuse-dark": {ID: "chartreuse-dark", Background: "#000000", Text: "#ffffff", Title: "#7fff00", Accent: "#7fff00", Muted: "#5fcf00"}, + "nord": {ID: "nord", Background: "#2e3440", Text: "#d8dee9", Title: "#88c0d0", Accent: "#81a1c1", Muted: "#4c566a"}, + "github": {ID: "github", Background: "#ffffff", Text: "#24292f", Title: "#0969da", Accent: "#2188ff", Muted: "#57606a"}, + "github-dark": {ID: "github-dark", Background: "#0d1117", Text: "#c9d1d9", Title: "#58a6ff", Accent: "#3fb950", Muted: "#8b949e"}, + "transparent": {ID: "transparent", Background: "#00000000", Text: "#434d58", Title: "#2f80ed", Accent: "#4c71f2", Muted: "#6a737d"}, +} + +// merko had a typo fixed at init. +func init() { + m := themes["merko"] + m.Background = "#0b1708" + themes["merko"] = m } // Lookup returns the theme with the given id. @@ -44,3 +59,13 @@ func Lookup(id string) (Theme, bool) { t, ok := themes[id] return t, ok } + +// IDs returns every registered theme id sorted alphabetically. +func IDs() []string { + out := make([]string, 0, len(themes)) + for id := range themes { + out = append(out, id) + } + sort.Strings(out) + return out +} diff --git a/main.go b/main.go index 98979af6..0c54811a 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,8 @@ import ( "flag" "fmt" "os" + "strings" + "time" "github.com/tiennm99/ghstats/internal/card" "github.com/tiennm99/ghstats/internal/github" @@ -13,36 +15,97 @@ import ( func main() { var ( - user = flag.String("user", "", "GitHub username (required)") - token = flag.String("token", os.Getenv("GITHUB_TOKEN"), "GitHub token (or env GITHUB_TOKEN)") - out = flag.String("out", "output", "output directory for SVG cards") - themeID = flag.String("theme", "dracula", "theme id (dracula, default, github)") + user = flag.String("user", "", "GitHub username (required)") + token = flag.String("token", os.Getenv("GITHUB_TOKEN"), "GitHub token (or env GITHUB_TOKEN)") + out = flag.String("out", "output", "output directory") + themesFlag = flag.String("themes", "dracula", "comma-separated theme ids, or 'all'") + tzName = flag.String("tz", "Local", "timezone for productive-time card (IANA name, e.g. Asia/Saigon)") + topRepos = flag.Int("top-repos", 10, "owned repos to sample for productive-time heatmap (0 to skip)") + perRepo = flag.Int("commits-per-repo", 100, "max commits sampled per repo") + listThemes = flag.Bool("list-themes", false, "print available theme ids and exit") ) flag.Parse() + if *listThemes { + for _, id := range theme.IDs() { + fmt.Println(id) + } + return + } + if *user == "" { fmt.Fprintln(os.Stderr, "error: -user is required") flag.Usage() os.Exit(2) } - th, ok := theme.Lookup(*themeID) - if !ok { - fmt.Fprintf(os.Stderr, "error: unknown theme %q\n", *themeID) + selected, err := resolveThemes(*themesFlag) + if err != nil { + fmt.Fprintf(os.Stderr, "error: %v\n", err) os.Exit(2) } + loc, err := time.LoadLocation(*tzName) + if err != nil { + fmt.Fprintf(os.Stderr, "warn: unknown timezone %q, falling back to UTC\n", *tzName) + loc = time.UTC + } + client := github.NewClient(*token) - profile, err := client.Profile(*user) + profile, err := client.FetchProfile(*user) if err != nil { fmt.Fprintf(os.Stderr, "error: fetch profile: %v\n", err) os.Exit(1) } - if err := card.RenderAll(profile, th, *out); err != nil { - fmt.Fprintf(os.Stderr, "error: render cards: %v\n", err) - os.Exit(1) + if *topRepos > 0 && profile.ID != "" { + repos := profile.TopRepos + if len(repos) > *topRepos { + repos = repos[:*topRepos] + } + if err := client.FetchProductive(profile, repos, loc, *perRepo); err != nil { + fmt.Fprintf(os.Stderr, "warn: productive-time fetch: %v\n", err) + } } - fmt.Printf("wrote cards to %s/%s/\n", *out, th.ID) + for _, t := range selected { + if err := card.RenderAll(profile, t, *out); err != nil { + fmt.Fprintf(os.Stderr, "error: render %s: %v\n", t.ID, err) + os.Exit(1) + } + fmt.Printf("wrote %s/%s/\n", *out, t.ID) + } +} + +func resolveThemes(spec string) ([]theme.Theme, error) { + spec = strings.TrimSpace(spec) + if spec == "" { + return nil, fmt.Errorf("no themes specified") + } + if spec == "all" { + ids := theme.IDs() + out := make([]theme.Theme, 0, len(ids)) + for _, id := range ids { + if t, ok := theme.Lookup(id); ok { + out = append(out, t) + } + } + return out, nil + } + var out []theme.Theme + for _, id := range strings.Split(spec, ",") { + id = strings.TrimSpace(id) + if id == "" { + continue + } + t, ok := theme.Lookup(id) + if !ok { + return nil, fmt.Errorf("unknown theme %q (use -list-themes)", id) + } + out = append(out, t) + } + if len(out) == 0 { + return nil, fmt.Errorf("no valid themes") + } + return out, nil }