mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-09-01 04:19:52 +00:00
Quarterly windows were not enough for the busiest quarter: a live run warned that 2026-04-01..2026-06-30 still came back at the 100-repo ceiling, so its seed list was truncated and those repos never got probed for commit history. Re-ask a saturated quarter month by month and merge only the repo lists. The quarter has already contributed its calendar days and commit totals, so the month passes skip that fold; verified by forcing the ceiling down to 20 and confirming all six calendar-derived cards stay byte-identical. Warn only when a month itself saturates, since there is no finer window left to ask for.
198 lines
6.4 KiB
Go
198 lines
6.4 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"time"
|
|
)
|
|
|
|
// contributionYearGQL mirrors contributionYearQuery.
|
|
type contributionYearGQL struct {
|
|
User *struct {
|
|
ContributionsCollection struct {
|
|
TotalCommitContributions int `json:"totalCommitContributions"`
|
|
ContributionCalendar struct {
|
|
Weeks []struct {
|
|
ContributionDays []struct {
|
|
ContributionCount int `json:"contributionCount"`
|
|
Date string `json:"date"`
|
|
} `json:"contributionDays"`
|
|
} `json:"weeks"`
|
|
} `json:"contributionCalendar"`
|
|
CommitContributionsByRepository []struct {
|
|
Contributions struct {
|
|
TotalCount int `json:"totalCount"`
|
|
} `json:"contributions"`
|
|
Repository repoNode `json:"repository"`
|
|
} `json:"commitContributionsByRepository"`
|
|
} `json:"contributionsCollection"`
|
|
} `json:"user"`
|
|
}
|
|
|
|
// maxRepositoriesPerWindow mirrors the maxRepositories argument in
|
|
// contributionYearQuery. GitHub caps it at 100, and a window that returns
|
|
// exactly that many repos is almost certainly truncated.
|
|
const maxRepositoriesPerWindow = 100
|
|
|
|
// contributionWindows splits one calendar year into quarters, dropping any
|
|
// quarter that starts after now and clamping the last one to now.
|
|
//
|
|
// A year-wide window silently loses repos once the user commits in more than
|
|
// maxRepositoriesPerWindow of them in that year — the query returns the top
|
|
// 100 and says nothing about the rest. Quarters bring most years under the
|
|
// ceiling; a quarter that still saturates gets split again by month. The API
|
|
// clips contributionCalendar to the exact window (no week-boundary
|
|
// spillover), so concatenating quarters yields each day once.
|
|
func contributionWindows(year int, now time.Time) [][2]time.Time {
|
|
var out [][2]time.Time
|
|
for q := 0; q < 4; q++ {
|
|
from := time.Date(year, time.Month(q*3+1), 1, 0, 0, 0, 0, time.UTC)
|
|
if from.After(now) {
|
|
break
|
|
}
|
|
to := from.AddDate(0, 3, 0).Add(-time.Second)
|
|
if to.After(now) {
|
|
to = now
|
|
}
|
|
out = append(out, [2]time.Time{from, to})
|
|
}
|
|
return out
|
|
}
|
|
|
|
// FetchContributionsAllTime iterates p.ContributionYears and issues one
|
|
// contributionsCollection query per quarter. Each window's payload
|
|
// contributes:
|
|
//
|
|
// - Days → p.DailyContributionsAllTime
|
|
// - Commit count → p.TotalCommitsAllTime
|
|
// - Repos the user committed in → p.SeedRepos (deduplicated by owner/name)
|
|
//
|
|
// Fork and private repos are filtered client-side per opts so the caller can
|
|
// run the same pipeline with different visibility policies.
|
|
func (c *Client) FetchContributionsAllTime(ctx context.Context, p *Profile, opts FetchOptions) error {
|
|
years := append([]int(nil), p.ContributionYears...)
|
|
sort.Ints(years) // ascending so the concatenated series is oldest→newest
|
|
|
|
seen := map[string]int{} // "owner/name" → index in p.SeedRepos
|
|
now := time.Now().UTC()
|
|
|
|
for _, y := range years {
|
|
for _, w := range contributionWindows(y, now) {
|
|
saturated, err := c.fetchContributionWindow(ctx, p, opts, w[0], w[1], seen, true)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if !saturated {
|
|
continue
|
|
}
|
|
// The quarter came back at the ceiling, so its repo list is
|
|
// truncated. Re-ask month by month to recover the tail. Only the
|
|
// repo lists are merged — the quarter already contributed its
|
|
// days and commit totals, and folding them again would
|
|
// double-count.
|
|
for _, m := range monthWindows(w[0], w[1]) {
|
|
if _, err := c.fetchContributionWindow(ctx, p, opts, m[0], m[1], seen, false); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// monthWindows splits an arbitrary window into calendar months, clamped to the
|
|
// window's own bounds so the pieces cover exactly the same span.
|
|
func monthWindows(from, to time.Time) [][2]time.Time {
|
|
var out [][2]time.Time
|
|
start := from
|
|
for start.Before(to) || start.Equal(to) {
|
|
next := time.Date(start.Year(), start.Month(), 1, 0, 0, 0, 0, time.UTC).AddDate(0, 1, 0)
|
|
end := next.Add(-time.Second)
|
|
if end.After(to) {
|
|
end = to
|
|
}
|
|
out = append(out, [2]time.Time{start, end})
|
|
start = next
|
|
}
|
|
return out
|
|
}
|
|
|
|
// fetchContributionWindow folds a single contributionsCollection window into
|
|
// the profile and reports whether its repo list came back at the ceiling (and
|
|
// so is truncated). foldCalendar is false for the month re-queries that follow
|
|
// a saturated quarter: those exist only to recover repos, and re-folding their
|
|
// days would count the same contributions twice.
|
|
func (c *Client) fetchContributionWindow(
|
|
ctx context.Context,
|
|
p *Profile,
|
|
opts FetchOptions,
|
|
from, to time.Time,
|
|
seen map[string]int,
|
|
foldCalendar bool,
|
|
) (bool, error) {
|
|
vars := map[string]any{
|
|
"login": p.Login,
|
|
"from": from.Format(time.RFC3339),
|
|
"to": to.Format(time.RFC3339),
|
|
}
|
|
var resp contributionYearGQL
|
|
if err := c.query(ctx, contributionYearQuery, vars, &resp); err != nil {
|
|
return false, err
|
|
}
|
|
if resp.User == nil {
|
|
// Don't abort the run — other windows may still yield data — but
|
|
// make the partial-data case visible instead of rendering an
|
|
// empty all-time card silently.
|
|
fmt.Fprintf(os.Stderr, "warn: contributions %s..%s returned no user data\n",
|
|
from.Format("2006-01-02"), to.Format("2006-01-02"))
|
|
return false, nil
|
|
}
|
|
|
|
cc := resp.User.ContributionsCollection
|
|
if foldCalendar {
|
|
p.TotalCommitsAllTime += cc.TotalCommitContributions
|
|
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.DailyContributionsAllTime = append(p.DailyContributionsAllTime, DailyContribution{
|
|
Date: t,
|
|
Count: d.ContributionCount,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
saturated := len(cc.CommitContributionsByRepository) >= maxRepositoriesPerWindow
|
|
if saturated && !foldCalendar {
|
|
// A month at the ceiling has nowhere finer to go, so this is as
|
|
// complete as the API will answer. Say so instead of implying the
|
|
// list covers everything.
|
|
fmt.Fprintf(os.Stderr,
|
|
"warn: contributions %s..%s hit the %d-repo ceiling even at month granularity; some repos are missing from commit probing\n",
|
|
from.Format("2006-01-02"), to.Format("2006-01-02"), maxRepositoriesPerWindow)
|
|
}
|
|
|
|
for _, cr := range cc.CommitContributionsByRepository {
|
|
r := cr.Repository
|
|
if r.IsFork && !opts.IncludeForks {
|
|
continue
|
|
}
|
|
if r.IsPrivate && !opts.IncludePrivate {
|
|
continue
|
|
}
|
|
info := r.toRepoInfo(p.Login)
|
|
key := info.Owner + "/" + info.Name
|
|
if _, ok := seen[key]; ok {
|
|
continue
|
|
}
|
|
seen[key] = len(p.SeedRepos)
|
|
p.SeedRepos = append(p.SeedRepos, info)
|
|
}
|
|
return saturated, nil
|
|
}
|