diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 00000000..a69e2720 --- /dev/null +++ b/.github/workflows/ci.yml @@ -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 ./... diff --git a/README.md b/README.md index 63d9599c..9c7f430a 100644 --- a/README.md +++ b/README.md @@ -1 +1,84 @@ -# ghstats \ No newline at end of file +# 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 `/`) | +| `-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 +![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 + +## 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). diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..18d7eed6 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/tiennm99/ghstats + +go 1.26 diff --git a/internal/card/card.go b/internal/card/card.go new file mode 100644 index 00000000..b268b338 --- /dev/null +++ b/internal/card/card.go @@ -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//. +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 +} diff --git a/internal/card/languages.go b/internal/card/languages.go new file mode 100644 index 00000000..78c94a14 --- /dev/null +++ b/internal/card/languages.go @@ -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(` + + Top Languages + %d languages tracked +`, t.Background, t.Title, t.Text, len(p.Languages)) + return []byte(svg), nil +} diff --git a/internal/card/productive.go b/internal/card/productive.go new file mode 100644 index 00000000..7419e92f --- /dev/null +++ b/internal/card/productive.go @@ -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(` + + Productive Time + Heatmap placeholder +`, t.Background, t.Title, t.Text) + _ = p + return []byte(svg), nil +} diff --git a/internal/card/profile.go b/internal/card/profile.go new file mode 100644 index 00000000..cee9a3a4 --- /dev/null +++ b/internal/card/profile.go @@ -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(` + + %s + %s +`, t.Background, t.Title, p.Login, t.Muted, p.Bio) + return []byte(svg), nil +} diff --git a/internal/card/stats.go b/internal/card/stats.go new file mode 100644 index 00000000..f61870c4 --- /dev/null +++ b/internal/card/stats.go @@ -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(` + + Stats + %d public repos · %d followers · %d following +`, t.Background, t.Title, t.Text, p.PublicRepos, p.Followers, p.Following) + return []byte(svg), nil +} diff --git a/internal/github/client.go b/internal/github/client.go new file mode 100644 index 00000000..b8d8891a --- /dev/null +++ b/internal/github/client.go @@ -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 +} diff --git a/internal/theme/theme.go b/internal/theme/theme.go new file mode 100644 index 00000000..76d43f0d --- /dev/null +++ b/internal/theme/theme.go @@ -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 +} diff --git a/main.go b/main.go new file mode 100644 index 00000000..98979af6 --- /dev/null +++ b/main.go @@ -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) +}