Files
tiennm99 fcfec9a11b chore: small hygiene fixes from code review
- I3 — update FetchOptions doc to describe zero-value vs CLI-flag defaults.
- I5 — release workflow gates docker/binaries on a test job; tags no
  longer ship broken artifacts.
- N1 — replace handwritten joinErrs with strings.Join.
- N3 — truncate() now backs up to a UTF-8 rune boundary so error
  messages never end on a split codepoint.
- N4 — pin Docker base images (golang:1.26-alpine, alpine:3.21) to
  SHA256 digests.
- N5 — pin third-party GitHub Actions to commit SHAs with version
  comments for readability.
- N9 — drop the "(non-fork)" qualifier from the stats card label; the
  underlying GraphQL doesn't actually filter forks, so the phrasing
  was misleading.
2026-04-18 22:43:14 +07:00

191 lines
5.9 KiB
Go

package github
import (
"context"
"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 {
ContributionYears []int `json:"contributionYears"`
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"`
}
// FetchOptions tunes which repos contribute to the profile's aggregates.
// Zero value excludes both forks and private repos; the CLI flips both to
// true by default (private is a no-op when the token lacks repo scope, so
// it's safe to opt in). Callers using FetchOptions{} literal get the
// conservative behavior regardless of CLI defaults.
type FetchOptions struct {
IncludeForks bool
IncludePrivate bool
}
// 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. Forks and private repos are filtered client-side per opts.
func (c *Client) FetchProfile(ctx context.Context, login string, opts FetchOptions) (*Profile, error) {
if login == "" {
return nil, errors.New("empty user")
}
p := &Profile{Login: login}
reposPerLang := map[string]int64{}
langColor := map[string]string{}
publicRepoCount := 0
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(ctx, 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.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.TotalContributionsLastYear = cc.ContributionCalendar.TotalContributions + cc.RestrictedContributionsCount
p.ContributionYears = append([]int(nil), cc.ContributionYears...)
// 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 {
if r.IsFork && !opts.IncludeForks {
continue
}
if r.IsPrivate && !opts.IncludePrivate {
continue
}
publicRepoCount++
p.TotalStars += r.StargazerCount
p.TotalForks += r.ForkCount
info := r.toRepoInfo(login)
if r.PrimaryLanguage != nil {
reposPerLang[r.PrimaryLanguage.Name]++
if _, ok := langColor[r.PrimaryLanguage.Name]; !ok {
langColor[r.PrimaryLanguage.Name] = r.PrimaryLanguage.Color
}
}
for _, e := range r.Languages.Edges {
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)
p.PublicRepos = publicRepoCount
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
}