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:
tiennm99 committed 2026-04-18 18:39:51 +07:00
1 parent a91d964a1d
commit ff4975fae2
19 files changed
+1312 -135

No files matched your search

+83
View File
@@ -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 := "&lt;a &amp; &quot;b&quot; &apos;c&apos;&gt;"
if got != want {
t.Errorf("escapeXML=%q want %q", got, want)
}
}
+72 -7
View File
@@ -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
}
+67 -8
View File
@@ -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
}
+63 -7
View File
@@ -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
View File
@@ -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
}
+66
View File
@@ -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(
"&", "&amp;",
"<", "&lt;",
">", "&gt;",
`"`, "&quot;",
"'", "&apos;",
)
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
View File
@@ -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
}
+68
View File
@@ -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"`
}
+81
View File
@@ -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
}
+140
View File
@@ -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
}
+34
View File
@@ -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)
}
}
+77
View File
@@ -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
View File
@@ -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
}