diff --git a/README.md b/README.md index 1e169ca7..d358e9b4 100644 --- a/README.md +++ b/README.md @@ -169,7 +169,7 @@ ghstats -user tiennm99 -themes dracula,github_dark -tz Asia/Saigon -out output ## How attribution works -**Repo sampling** uses a seed list built from `contributionsCollection.commitContributionsByRepository`, unioned across every active contribution year. This catches every repo you've committed in — not just your top-starred ones. Each year is queried a quarter at a time: the API caps that field at 100 repos per query and drops the rest without saying so, which a prolific year hits easily. +**Repo sampling** uses a seed list built from `contributionsCollection.commitContributionsByRepository`, unioned across every active contribution year. This catches every repo you've committed in — not just your top-starred ones. Each year is queried a quarter at a time: the API caps that field at 100 repos per query and drops the rest without saying so, which a prolific year hits easily. A quarter that still comes back at the cap is re-asked month by month to recover the tail. **Which repos count where.** The commit-driven cards (most-commit-language, productive time, productive weekday, and everything derived from the contribution calendar) cover repos in *any* namespace you committed to — your own, your orgs', and upstream repos you sent PRs to. The repo-driven cards (stars, repo count, repos-per-language, top-starred) look only at repos you own. Set `include_org_repos` / `-include-org-repos` to also count org-owned repos where your permission is `ADMIN`; org repos you merely have read or write access to are never counted. @@ -179,7 +179,7 @@ ghstats -user tiennm99 -themes dracula,github_dark -tz Asia/Saigon -out output - For per-file accuracy, a future `-accurate-languages` mode is planned (per-commit REST + go-enry). **Cost per run** (current defaults, typical user): -- ~1 profile query + ~4 queries per active year + ~50 commit-history pages ≈ **80-100 GraphQL calls**. +- ~1 profile query + ~4 queries per active year (+3 for any quarter that saturates) + ~50 commit-history pages ≈ **80-100 GraphQL calls**. - Zero REST calls. Well under the 5000 points/hr budget. ## Themes diff --git a/docs/system-architecture.md b/docs/system-architecture.md index 33d980f1..9bc74e7f 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -40,8 +40,10 @@ FetchContributionsAllTime(ctx, profile, opts) │ per quarter: totalCommitContributions + │ contributionCalendar.weeks + │ commitContributionsByRepository(maxRepositories: 100) - │ quarters keep each window under the 100-repo ceiling, which a - │ year-wide window silently truncates at + │ quarters keep most windows under the 100-repo ceiling, which a + │ year-wide window silently truncates at; a quarter that still + │ saturates is re-queried per month for repos only (its days and + │ totals are already folded in) │ yields: SeedRepos (deduped), │ DailyContributionsAllTime, │ TotalCommitsAllTime @@ -68,7 +70,7 @@ All three queries live in `internal/github/queries.go`. | Query | Purpose | Cost estimate | | --- | --- | --- | | `profileQuery` | Profile identity + totals + owned repos + last-year calendar | 1–10 calls (100 repos/page × ≤10 pages safety cap) | -| `contributionYearQuery` | Per-quarter calendar + seed list | 4 calls per active year (typically 4–40) | +| `contributionYearQuery` | Per-quarter calendar + seed list | 4 calls per active year, +3 per saturated quarter | | `commitHistoryQuery` | Authored commits on default branch | 1 call per 100 commits per seed repo | Typical run (8 active years, 30 seed repos, avg 50 commits each): diff --git a/internal/github/contribution_scope_test.go b/internal/github/contribution_scope_test.go index ddd0a8f7..892a31d1 100644 --- a/internal/github/contribution_scope_test.go +++ b/internal/github/contribution_scope_test.go @@ -92,3 +92,37 @@ func TestRepoAffiliationsAndOwnership(t *testing.T) { } } } + +func TestMonthWindowsCoverQuarterExactly(t *testing.T) { + from := time.Date(2026, 4, 1, 0, 0, 0, 0, time.UTC) + to := time.Date(2026, 6, 30, 23, 59, 59, 0, time.UTC) + got := monthWindows(from, to) + if len(got) != 3 { + t.Fatalf("want 3 months in a quarter, got %d", len(got)) + } + if !got[0][0].Equal(from) { + t.Errorf("first month starts at %s, want %s", got[0][0], from) + } + if !got[len(got)-1][1].Equal(to) { + t.Errorf("last month ends at %s, want the quarter's end %s", got[len(got)-1][1], to) + } + for i := 1; i < len(got); i++ { + if !got[i][0].After(got[i-1][1]) { + t.Errorf("month %d overlaps month %d", i, i-1) + } + } +} + +func TestMonthWindowsClampPartialFinalMonth(t *testing.T) { + // A current quarter clamped to "now" mid-month must not hand back a + // window running past it. + from := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC) + now := time.Date(2026, 8, 13, 10, 30, 0, 0, time.UTC) + got := monthWindows(from, now) + if len(got) != 2 { + t.Fatalf("want July + partial August, got %d", len(got)) + } + if !got[1][1].Equal(now) { + t.Errorf("final month ends at %s, want clamped to %s", got[1][1], now) + } +} diff --git a/internal/github/contributions_all_time.go b/internal/github/contributions_all_time.go index 6c2f06ea..5c36b477 100644 --- a/internal/github/contributions_all_time.go +++ b/internal/github/contributions_all_time.go @@ -41,9 +41,10 @@ const maxRepositoriesPerWindow = 100 // // 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 keep each window well under -// the ceiling. The API clips contributionCalendar to the exact window (no -// week-boundary spillover), so concatenating quarters yields each day once. +// 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++ { @@ -79,24 +80,58 @@ func (c *Client) FetchContributionsAllTime(ctx context.Context, p *Profile, opts for _, y := range years { for _, w := range contributionWindows(y, now) { - if err := c.fetchContributionWindow(ctx, p, opts, w[0], w[1], seen); err != nil { + 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. Split out from FetchContributionsAllTime so the quarter loop -// stays readable. +// 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, -) error { + foldCalendar bool, +) (bool, error) { vars := map[string]any{ "login": p.Login, "from": from.Format(time.RFC3339), @@ -104,7 +139,7 @@ func (c *Client) fetchContributionWindow( } var resp contributionYearGQL if err := c.query(ctx, contributionYearQuery, vars, &resp); err != nil { - return err + return false, err } if resp.User == nil { // Don't abort the run — other windows may still yield data — but @@ -112,28 +147,33 @@ func (c *Client) fetchContributionWindow( // 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 nil + return false, nil } cc := resp.User.ContributionsCollection - 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 + 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, + }) } - p.DailyContributionsAllTime = append(p.DailyContributionsAllTime, DailyContribution{ - Date: t, - Count: d.ContributionCount, - }) } } - // Surface a hit ceiling rather than pretending the list is complete. - if len(cc.CommitContributionsByRepository) >= maxRepositoriesPerWindow { + 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; some repos are missing from commit probing\n", + "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) } @@ -153,5 +193,5 @@ func (c *Client) fetchContributionWindow( seen[key] = len(p.SeedRepos) p.SeedRepos = append(p.SeedRepos, info) } - return nil + return saturated, nil }