mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-08-31 10:34:02 +00:00
feat: implement profile summary cards with GraphQL fetch and Action wrapper
- 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.
This commit is contained in:
@@ -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_*.*
|
||||
+13
@@ -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"]
|
||||
@@ -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
|
||||

|
||||

|
||||

|
||||

|
||||
```
|
||||
|
||||
### 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 (`<out>/<theme>/…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 `<out>/<theme>`) |
|
||||
| `-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
|
||||

|
||||

|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
+61
@@ -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
|
||||
@@ -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
|
||||
@@ -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 & <bio>",
|
||||
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), "<svg") {
|
||||
t.Errorf("%s: missing <svg prefix", name)
|
||||
}
|
||||
if strings.Contains(string(data), "Test & <bio>") {
|
||||
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(`<a & "b" 'c'>`)
|
||||
want := "<a & "b" 'c'>"
|
||||
if got != want {
|
||||
t.Errorf("escapeXML=%q want %q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">
|
||||
<rect width="100%%" height="100%%" fill="%s"/>
|
||||
<text x="20" y="40" fill="%s" font-family="sans-serif" font-size="20">Top Languages</text>
|
||||
<text x="20" y="80" fill="%s" font-family="sans-serif" font-size="12">%d languages tracked</text>
|
||||
</svg>`, 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, `
|
||||
<text x="25" y="90" font-size="13" fill="%s">No language data available.</text>`, 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, `
|
||||
<rect x="%d" y="%d" width="%d" height="%d" rx="5" fill="%s"/>
|
||||
<g>`,
|
||||
barX, barY, barW, barH, t.Muted)
|
||||
|
||||
offset := float64(barX)
|
||||
for _, l := range langs {
|
||||
w := float64(barW) * float64(l.Bytes) / float64(total)
|
||||
fmt.Fprintf(&b, `
|
||||
<rect x="%.2f" y="%d" width="%.2f" height="%d" fill="%s"/>`,
|
||||
offset, barY, w, barH, colorOrAccent(l.Color, t.Accent))
|
||||
offset += w
|
||||
}
|
||||
b.WriteString(`
|
||||
</g>`)
|
||||
|
||||
// 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, `
|
||||
<circle cx="%d" cy="%d" r="6" fill="%s"/>
|
||||
<text x="%d" y="%d" font-size="13" fill="%s">%s %.2f%%</text>`,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">
|
||||
<rect width="100%%" height="100%%" fill="%s"/>
|
||||
<text x="20" y="40" fill="%s" font-family="sans-serif" font-size="20">Productive Time</text>
|
||||
<text x="20" y="80" fill="%s" font-family="sans-serif" font-size="12">Heatmap placeholder</text>
|
||||
</svg>`, 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, `
|
||||
<text x="25" y="%d" font-size="11" fill="%s">%s</text>`,
|
||||
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, `
|
||||
<text x="%d" y="55" font-size="10" fill="%s">%02dh</text>`,
|
||||
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, `
|
||||
<rect x="%d" y="%d" width="%d" height="%d" rx="3" fill="%s" fill-opacity="%.2f"><title>%s %02d:00 — %d commits</title></rect>`,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">
|
||||
<rect width="100%%" height="100%%" fill="%s"/>
|
||||
<text x="20" y="40" fill="%s" font-family="sans-serif" font-size="24">%s</text>
|
||||
<text x="20" y="72" fill="%s" font-family="sans-serif" font-size="14">%s</text>
|
||||
</svg>`, 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, `
|
||||
<text x="25" y="%d" font-size="13" fill="%s">%s</text>
|
||||
<text x="140" y="%d" font-size="13" fill="%s">%s</text>`,
|
||||
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
|
||||
}
|
||||
|
||||
+33
-7
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" width="400" height="200">
|
||||
<rect width="100%%" height="100%%" fill="%s"/>
|
||||
<text x="20" y="40" fill="%s" font-family="sans-serif" font-size="20">Stats</text>
|
||||
<text x="20" y="80" fill="%s" font-family="sans-serif" font-size="12">%d public repos · %d followers · %d following</text>
|
||||
</svg>`, 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, `
|
||||
<text x="%d" y="%d" font-size="12" fill="%s">%s</text>
|
||||
<text x="%d" y="%d" font-size="18" font-weight="600" fill="%s">%s</text>`,
|
||||
x, y, t.Muted, escapeXML(it.label),
|
||||
x, y+22, t.Accent, escapeXML(it.value))
|
||||
}
|
||||
|
||||
b.WriteString(footer)
|
||||
return []byte(b.String()), nil
|
||||
}
|
||||
|
||||
@@ -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 <svg> tag + background rect + title text.
|
||||
func header(width, height int, bg, titleColor, title string) string {
|
||||
return fmt.Sprintf(`<svg xmlns="http://www.w3.org/2000/svg" width="%d" height="%d" viewBox="0 0 %d %d" font-family="'Segoe UI', Ubuntu, Sans-Serif">
|
||||
<rect x="0.5" y="0.5" width="%d" height="%d" rx="8" fill="%s" stroke="rgba(255,255,255,0.1)"/>
|
||||
<text x="25" y="35" font-size="18" font-weight="600" fill="%s">%s</text>`,
|
||||
width, height, width, height,
|
||||
width-1, height-1, bg,
|
||||
titleColor, escapeXML(title))
|
||||
}
|
||||
|
||||
const footer = `
|
||||
</svg>`
|
||||
+100
-30
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
+50
-25
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user