mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-08-31 22:23:52 +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:
+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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
Reference in New Issue
Block a user