mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-05-14 12:58:48 +00:00
d0d3862780
New 5-contributions.svg renders the last year's contribution calendar as a monthly smooth-filled area chart. Pure Go SVG; no extra API calls — one additional contributionCalendar.weeks block in the existing profile GraphQL query carries the data. - Y-axis mirrored on both sides with nice ticks. - X-axis labels in YY/MM format, every other month to avoid overlap. - Smooth curve via Catmull-Rom interpolation converted to cubic Bezier (d3.curveCatmullRom default tension 0.5). - Missing months between first and last are inserted as zero-count so the chart stays time-continuous.
180 lines
5.4 KiB
Go
180 lines
5.4 KiB
Go
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"`
|
|
Weeks []struct {
|
|
ContributionDays []struct {
|
|
ContributionCount int `json:"contributionCount"`
|
|
Date string `json:"date"`
|
|
} `json:"contributionDays"`
|
|
} `json:"weeks"`
|
|
} `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 repos-per-language data for the
|
|
// given user. Owned repos are paginated up to 10 pages (1000 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}
|
|
reposPerLang := 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
|
|
|
|
// Flatten week → day into a linear daily series sorted by date.
|
|
for _, w := range cc.ContributionCalendar.Weeks {
|
|
for _, d := range w.ContributionDays {
|
|
t, err := time.Parse("2006-01-02", d.Date)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
p.DailyContributions = append(p.DailyContributions, DailyContribution{
|
|
Date: t,
|
|
Count: d.ContributionCount,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
for _, r := range u.Repositories.Nodes {
|
|
p.TotalStars += r.StargazerCount
|
|
p.TotalForks += r.ForkCount
|
|
|
|
info := RepoInfo{Name: r.Name}
|
|
if r.PrimaryLanguage != nil {
|
|
info.PrimaryLanguage = r.PrimaryLanguage.Name
|
|
info.PrimaryColor = r.PrimaryLanguage.Color
|
|
reposPerLang[r.PrimaryLanguage.Name]++
|
|
if _, ok := langColor[r.PrimaryLanguage.Name]; !ok {
|
|
langColor[r.PrimaryLanguage.Name] = r.PrimaryLanguage.Color
|
|
}
|
|
}
|
|
// Carry the repo's full language-bytes breakdown so commit
|
|
// attribution can distribute each commit across all languages
|
|
// the repo contains, not just the primary.
|
|
for _, e := range r.Languages.Edges {
|
|
info.Languages = append(info.Languages, LangEdge{
|
|
Name: e.Node.Name,
|
|
Color: e.Node.Color,
|
|
Bytes: e.Size,
|
|
})
|
|
if _, ok := langColor[e.Node.Name]; !ok {
|
|
langColor[e.Node.Name] = e.Node.Color
|
|
}
|
|
}
|
|
p.TopRepos = append(p.TopRepos, info)
|
|
}
|
|
|
|
if !u.Repositories.PageInfo.HasNextPage {
|
|
break
|
|
}
|
|
end := u.Repositories.PageInfo.EndCursor
|
|
cursor = &end
|
|
}
|
|
|
|
p.ReposByLanguage = sortLangStats(reposPerLang, langColor)
|
|
return p, nil
|
|
}
|
|
|
|
// sortLangStats returns a slice sorted desc by value; ties break alphabetically.
|
|
func sortLangStats(values map[string]int64, color map[string]string) []LangStat {
|
|
out := make([]LangStat, 0, len(values))
|
|
for name, v := range values {
|
|
out = append(out, LangStat{Name: name, Color: color[name], Value: v})
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Value != out[j].Value {
|
|
return out[i].Value > out[j].Value
|
|
}
|
|
return out[i].Name < out[j].Name
|
|
})
|
|
return out
|
|
}
|