Files
ghstats/internal/github/productive.go
T
tiennm99 76cf242ee9 fix: donut single-slice, partial-year warn, hoist per-repo total
- I1 — donut chart with a single slice (100%) now renders via two
  concentric <circle> elements instead of a degenerate SVG arc that
  drew nothing. Reproduced with a standalone probe; regression test
  added separately.
- I2 — FetchContributionsAllTime logs a warn to stderr when a year's
  query returns nil user data so callers notice partial results
  instead of rendering an empty all-time card silently.
- I6 — attributeCommit() receives the repo's byte total precomputed
  once per repo rather than re-summing language edges for every
  commit in the inner loop.
2026-04-18 22:43:24 +07:00

143 lines
4.1 KiB
Go

package github
import (
"context"
"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"`
}
// scaleFactor is the fixed-point multiplier used when distributing a single
// commit across several languages by byte share. Stored in LangStat.Value
// (int64) so the existing sort + percentage math keeps working; the absolute
// magnitude is irrelevant because the card renders percentages.
const scaleFactor = 10_000
// FetchProductive paginates the default-branch commit history (authored by
// the target user) for each repo up to maxPerRepo commits, and fills two
// parallel sets of aggregates on the Profile:
//
// - Last-year: p.Productive (24h histogram) and p.CommitsByLanguage
// - All-time: p.ProductiveAllTime and p.CommitsByLanguageAllTime
//
// One pagination pass populates both, so the all-time cards come at no extra
// API cost beyond the pages already required for the last-year bucket.
// loc is applied to CommittedDate so the heatmap reflects the user's tz.
func (c *Client) FetchProductive(ctx context.Context, p *Profile, repos []RepoInfo, loc *time.Location, maxPerRepo int) error {
if loc == nil {
loc = time.UTC
}
yearAgo := time.Now().AddDate(-1, 0, 0)
lastYearLang := map[string]int64{}
allTimeLang := map[string]int64{}
langColor := map[string]string{}
for _, repo := range repos {
// Precompute the repo's total language bytes once; attributeCommit
// reuses it for every commit instead of re-summing ~10 edges per
// call in the inner loop.
var repoTotal int64
for _, l := range repo.Languages {
repoTotal += l.Bytes
}
var cursor *string
seen := 0
for {
if seen >= maxPerRepo {
break
}
owner := repo.Owner
if owner == "" {
owner = p.Login
}
vars := map[string]any{
"owner": owner,
"repo": repo.Name,
"userId": p.ID,
}
if cursor != nil {
vars["after"] = *cursor
}
var resp productiveGQL
if err := c.query(ctx, 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.ProductiveAllTime[tl.Hour()]++
attributeCommit(repo, repoTotal, allTimeLang, langColor)
if tl.After(yearAgo) {
p.Productive[tl.Hour()]++
attributeCommit(repo, repoTotal, lastYearLang, langColor)
}
seen++
}
if !h.PageInfo.HasNextPage {
break
}
end := h.PageInfo.EndCursor
cursor = &end
}
}
p.CommitsByLanguage = sortLangStats(lastYearLang, langColor)
p.CommitsByLanguageAllTime = sortLangStats(allTimeLang, langColor)
return nil
}
// attributeCommit distributes a single commit across the repo's languages
// proportional to byte share. Callers pass in the precomputed per-repo byte
// total so this hot-path loop doesn't re-sum language edges every commit.
// Falls back to the primary language when no byte breakdown is available
// (empty repo or linguist-free repo).
func attributeCommit(repo RepoInfo, repoTotal int64, commitsByLang map[string]int64, langColor map[string]string) {
if repoTotal == 0 {
if repo.PrimaryLanguage != "" {
commitsByLang[repo.PrimaryLanguage] += scaleFactor
if _, ok := langColor[repo.PrimaryLanguage]; !ok {
langColor[repo.PrimaryLanguage] = repo.PrimaryColor
}
}
return
}
for _, l := range repo.Languages {
share := int64(scaleFactor) * l.Bytes / repoTotal
if share == 0 {
continue
}
commitsByLang[l.Name] += share
if _, ok := langColor[l.Name]; !ok {
langColor[l.Name] = l.Color
}
}
}