mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-09-02 08:20:34 +00:00
feat: scaffold ghstats — Go CLI for GitHub profile SVG cards
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version: "1.26"
|
||||
cache: true
|
||||
- run: go mod tidy -diff
|
||||
- run: go vet ./...
|
||||
- run: go build ./...
|
||||
- run: go test ./...
|
||||
@@ -1 +1,84 @@
|
||||
# ghstats
|
||||
# ghstats
|
||||
|
||||
> 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.
|
||||
|
||||
## Status
|
||||
|
||||
⚠️ Early work-in-progress. Skeleton only — cards render placeholder SVGs. Roadmap below.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
go install github.com/tiennm99/ghstats@latest
|
||||
```
|
||||
|
||||
Or build from source:
|
||||
|
||||
```sh
|
||||
git clone https://github.com/tiennm99/ghstats
|
||||
cd ghstats
|
||||
go build -o ghstats .
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
```sh
|
||||
export GITHUB_TOKEN=ghp_xxx # PAT with `repo` + `read:user` for private repo stats
|
||||
ghstats -user tiennm99 -theme dracula -out output
|
||||
```
|
||||
|
||||
Flags:
|
||||
|
||||
| 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` |
|
||||
|
||||
## Output
|
||||
|
||||
```
|
||||
output/
|
||||
dracula/
|
||||
0-profile-details.svg
|
||||
1-languages.svg
|
||||
2-stats.svg
|
||||
3-productive-time.svg
|
||||
```
|
||||
|
||||
Embed in a README:
|
||||
|
||||
```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
|
||||
|
||||
## 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.).
|
||||
|
||||
## License
|
||||
|
||||
Apache-2.0 — see [LICENSE](LICENSE).
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
// Package card renders Profile data into SVG cards on disk.
|
||||
package card
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/tiennm99/ghstats/internal/github"
|
||||
"github.com/tiennm99/ghstats/internal/theme"
|
||||
)
|
||||
|
||||
// Card renders one SVG for a Profile under the given theme.
|
||||
type Card interface {
|
||||
// Filename is the on-disk basename (e.g. "0-profile-details.svg").
|
||||
Filename() string
|
||||
// SVG returns the rendered SVG bytes.
|
||||
SVG(p *github.Profile, t theme.Theme) ([]byte, error)
|
||||
}
|
||||
|
||||
// allCards is the ordered list rendered by RenderAll.
|
||||
// Keep filename prefixes numeric so the output directory lists in a predictable order.
|
||||
var allCards = []Card{
|
||||
profileCard{},
|
||||
languagesCard{},
|
||||
statsCard{},
|
||||
productiveCard{},
|
||||
}
|
||||
|
||||
// RenderAll writes every card into outDir/<themeID>/.
|
||||
func RenderAll(p *github.Profile, t theme.Theme, outDir string) error {
|
||||
dir := filepath.Join(outDir, t.ID)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return fmt.Errorf("mkdir %s: %w", dir, err)
|
||||
}
|
||||
for _, c := range allCards {
|
||||
data, err := c.SVG(p, t)
|
||||
if err != nil {
|
||||
return fmt.Errorf("render %s: %w", c.Filename(), err)
|
||||
}
|
||||
path := filepath.Join(dir, c.Filename())
|
||||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||||
return fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package card
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/ghstats/internal/github"
|
||||
"github.com/tiennm99/ghstats/internal/theme"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
package card
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/ghstats/internal/github"
|
||||
"github.com/tiennm99/ghstats/internal/theme"
|
||||
)
|
||||
|
||||
type productiveCard struct{}
|
||||
|
||||
func (productiveCard) Filename() string { return "3-productive-time.svg" }
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package card
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/ghstats/internal/github"
|
||||
"github.com/tiennm99/ghstats/internal/theme"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
package card
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/tiennm99/ghstats/internal/github"
|
||||
"github.com/tiennm99/ghstats/internal/theme"
|
||||
)
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
// Package github fetches profile data from the GitHub API.
|
||||
package github
|
||||
|
||||
import "errors"
|
||||
|
||||
// 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
|
||||
|
||||
// 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.
|
||||
type Client struct {
|
||||
token string
|
||||
// TODO: http.Client, rate-limit handling
|
||||
}
|
||||
|
||||
// NewClient returns a client that authenticates with the given PAT.
|
||||
// Empty token uses unauthenticated access (low rate limit).
|
||||
func NewClient(token string) *Client {
|
||||
return &Client{token: token}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
// Package theme defines SVG color palettes used by card renderers.
|
||||
package theme
|
||||
|
||||
// Theme describes the colors applied to a rendered card.
|
||||
type Theme struct {
|
||||
ID string
|
||||
Background string
|
||||
Text string
|
||||
Title string
|
||||
Accent string
|
||||
Muted string
|
||||
}
|
||||
|
||||
// Built-in palettes. Add new themes by appending to the map.
|
||||
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",
|
||||
},
|
||||
}
|
||||
|
||||
// Lookup returns the theme with the given id.
|
||||
func Lookup(id string) (Theme, bool) {
|
||||
t, ok := themes[id]
|
||||
return t, ok
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
// ghstats generates SVG cards summarizing a GitHub user's profile.
|
||||
package main
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/tiennm99/ghstats/internal/card"
|
||||
"github.com/tiennm99/ghstats/internal/github"
|
||||
"github.com/tiennm99/ghstats/internal/theme"
|
||||
)
|
||||
|
||||
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)")
|
||||
)
|
||||
flag.Parse()
|
||||
|
||||
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)
|
||||
os.Exit(2)
|
||||
}
|
||||
|
||||
client := github.NewClient(*token)
|
||||
profile, err := client.Profile(*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)
|
||||
}
|
||||
|
||||
fmt.Printf("wrote cards to %s/%s/\n", *out, th.ID)
|
||||
}
|
||||
Reference in New Issue
Block a user