mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-09-02 14:20:13 +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,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
|
||||
}
|
||||
Reference in New Issue
Block a user