mirror of
https://github.com/tiennm99/ghstats.git
synced 2026-08-30 16:20:17 +00:00
feat: count org repos and fix contribution-window truncation
Split each contribution year into quarterly windows. commitContributionsByRepository caps at 100 repos per query and drops the remainder without signalling it, which a prolific year hits: 2026 returned exactly 100 where the quarterly union returns 108. Warn when a window still comes back at the ceiling instead of reporting a complete list. Add -include-org-repos / include_org_repos so org-owned repos the user administers count toward stars, repo count, repos-per-language and top-starred. Off by default, since enabling it moves those totals for every existing caller. Commit-driven cards already covered org repos through the seed list. Org repos carrying only read or write access are dropped via viewerPermission. Rename Profile.PublicRepos to RepoCount and drop "public" from the card label: the count tracks the fetch filters and includes private repos whenever the token can see them.
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
package github
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestContributionWindowsFullYear(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC)
|
||||
got := contributionWindows(2024, now)
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("want 4 quarters for a completed year, got %d", len(got))
|
||||
}
|
||||
|
||||
wantStarts := []string{"2024-01-01", "2024-04-01", "2024-07-01", "2024-10-01"}
|
||||
wantEnds := []string{"2024-03-31", "2024-06-30", "2024-09-30", "2024-12-31"}
|
||||
for i, w := range got {
|
||||
if s := w[0].Format("2006-01-02"); s != wantStarts[i] {
|
||||
t.Errorf("quarter %d start = %s, want %s", i, s, wantStarts[i])
|
||||
}
|
||||
if e := w[1].Format("2006-01-02"); e != wantEnds[i] {
|
||||
t.Errorf("quarter %d end = %s, want %s", i, e, wantEnds[i])
|
||||
}
|
||||
}
|
||||
|
||||
// Windows must not overlap, or days would be counted twice in the
|
||||
// all-time contribution series.
|
||||
for i := 1; i < len(got); i++ {
|
||||
if !got[i][0].After(got[i-1][1]) {
|
||||
t.Errorf("quarter %d starts at/before the end of quarter %d", i, i-1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContributionWindowsCurrentYearStopsAtNow(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 10, 30, 0, 0, time.UTC)
|
||||
got := contributionWindows(2026, now)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("want 3 quarters through August, got %d", len(got))
|
||||
}
|
||||
if last := got[2][1]; !last.Equal(now) {
|
||||
t.Errorf("final window end = %s, want clamped to now (%s)", last, now)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContributionWindowsFutureYearIsEmpty(t *testing.T) {
|
||||
now := time.Date(2026, 8, 13, 0, 0, 0, 0, time.UTC)
|
||||
if got := contributionWindows(2027, now); len(got) != 0 {
|
||||
t.Fatalf("want no windows for a future year, got %d", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRepoAffiliationsAndOwnership(t *testing.T) {
|
||||
orgAdmin := repoNode{Name: "chambai", ViewerPermission: "ADMIN"}
|
||||
orgAdmin.Owner = &struct {
|
||||
Login string `json:"login"`
|
||||
}{Login: "miti99dev"}
|
||||
|
||||
orgWrite := orgAdmin
|
||||
orgWrite.ViewerPermission = "WRITE"
|
||||
|
||||
own := repoNode{Name: "ghstats"}
|
||||
own.Owner = &struct {
|
||||
Login string `json:"login"`
|
||||
}{Login: "tiennm99"}
|
||||
|
||||
off := FetchOptions{}
|
||||
on := FetchOptions{IncludeOrgRepos: true}
|
||||
|
||||
if got := repoAffiliations(off); len(got) != 1 || got[0] != "OWNER" {
|
||||
t.Errorf("affiliations with org repos off = %v, want [OWNER]", got)
|
||||
}
|
||||
if got := repoAffiliations(on); len(got) != 2 {
|
||||
t.Errorf("affiliations with org repos on = %v, want OWNER + ORGANIZATION_MEMBER", got)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
node repoNode
|
||||
opts FetchOptions
|
||||
want bool
|
||||
}{
|
||||
{"own repo, org off", own, off, true},
|
||||
{"own repo, org on", own, on, true},
|
||||
{"org admin repo, org off", orgAdmin, off, false},
|
||||
{"org admin repo, org on", orgAdmin, on, true},
|
||||
{"org write-only repo, org on", orgWrite, on, false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ownedByUser(tc.node, "tiennm99", tc.opts); got != tc.want {
|
||||
t.Errorf("%s: ownedByUser = %v, want %v", tc.name, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,8 +31,38 @@ type contributionYearGQL struct {
|
||||
} `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 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.
|
||||
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 year. Each year's payload contributes:
|
||||
// contributionsCollection query per quarter. Each window's payload
|
||||
// contributes:
|
||||
//
|
||||
// - Days → p.DailyContributionsAllTime
|
||||
// - Commit count → p.TotalCommitsAllTime
|
||||
@@ -45,62 +75,83 @@ func (c *Client) FetchContributionsAllTime(ctx context.Context, p *Profile, opts
|
||||
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 {
|
||||
from := time.Date(y, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
to := time.Date(y, 12, 31, 23, 59, 59, 0, time.UTC)
|
||||
if now := time.Now().UTC(); to.After(now) {
|
||||
to = now
|
||||
}
|
||||
|
||||
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 err
|
||||
}
|
||||
if resp.User == nil {
|
||||
// Don't abort the run — other years 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: contribution year %d returned no user data\n", y)
|
||||
continue
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
p.DailyContributionsAllTime = append(p.DailyContributionsAllTime, DailyContribution{
|
||||
Date: t,
|
||||
Count: d.ContributionCount,
|
||||
})
|
||||
for _, w := range contributionWindows(y, now) {
|
||||
if err := c.fetchContributionWindow(ctx, p, opts, w[0], w[1], seen); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, cr := range cc.CommitContributionsByRepository {
|
||||
r := cr.Repository
|
||||
if r.IsFork && !opts.IncludeForks {
|
||||
// fetchContributionWindow folds a single contributionsCollection window into
|
||||
// the profile. Split out from FetchContributionsAllTime so the quarter loop
|
||||
// stays readable.
|
||||
func (c *Client) fetchContributionWindow(
|
||||
ctx context.Context,
|
||||
p *Profile,
|
||||
opts FetchOptions,
|
||||
from, to time.Time,
|
||||
seen map[string]int,
|
||||
) 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 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 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 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)
|
||||
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 {
|
||||
fmt.Fprintf(os.Stderr,
|
||||
"warn: contributions %s..%s hit the %d-repo ceiling; 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 nil
|
||||
}
|
||||
|
||||
@@ -14,9 +14,12 @@ type Profile struct {
|
||||
Website string
|
||||
CreatedAt time.Time
|
||||
|
||||
Followers int
|
||||
Following int
|
||||
PublicRepos int
|
||||
Followers int
|
||||
Following int
|
||||
// RepoCount is how many repos survived the fetch filters, so it tracks
|
||||
// -include-forks / -include-private / -include-org-repos rather than
|
||||
// GitHub's public-only publicRepos count.
|
||||
RepoCount int
|
||||
|
||||
// Totals for the stats card.
|
||||
TotalStars int
|
||||
@@ -132,12 +135,15 @@ type LangEdge struct {
|
||||
// repoNode is the GraphQL shape of one repository node; kept here because
|
||||
// it's shared by the profile fetcher and the productive-time fetcher.
|
||||
type repoNode struct {
|
||||
Name string `json:"name"`
|
||||
IsPrivate bool `json:"isPrivate"`
|
||||
IsFork bool `json:"isFork"`
|
||||
StargazerCount int `json:"stargazerCount"`
|
||||
ForkCount int `json:"forkCount"`
|
||||
Owner *struct {
|
||||
Name string `json:"name"`
|
||||
IsPrivate bool `json:"isPrivate"`
|
||||
IsFork bool `json:"isFork"`
|
||||
StargazerCount int `json:"stargazerCount"`
|
||||
ForkCount int `json:"forkCount"`
|
||||
// ViewerPermission is only requested by profileQuery, where it separates
|
||||
// org repos the user administers from ones they merely have access to.
|
||||
ViewerPermission string `json:"viewerPermission"`
|
||||
Owner *struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"owner"`
|
||||
PrimaryLanguage *struct {
|
||||
|
||||
@@ -66,6 +66,36 @@ type profileGQL struct {
|
||||
type FetchOptions struct {
|
||||
IncludeForks bool
|
||||
IncludePrivate bool
|
||||
// IncludeOrgRepos widens the repo-owned aggregates (stars, forks, repo
|
||||
// count, repos-per-language, top-starred) beyond the user's own namespace
|
||||
// to org-owned repos they administer. Off by default: turning it on
|
||||
// changes every one of those numbers, so it stays an explicit opt-in.
|
||||
IncludeOrgRepos bool
|
||||
}
|
||||
|
||||
// adminPermission is the viewerPermission value that marks an org repo as
|
||||
// effectively the user's own. Members with WRITE/READ on a company repo they
|
||||
// never created would otherwise inflate every owned-repo aggregate.
|
||||
const adminPermission = "ADMIN"
|
||||
|
||||
// repoAffiliations maps opts onto the ownerAffiliations argument. OWNER alone
|
||||
// is GitHub's "repos in your own namespace"; ORGANIZATION_MEMBER adds repos
|
||||
// owned by orgs the user belongs to, which viewerPermission then narrows.
|
||||
func repoAffiliations(opts FetchOptions) []string {
|
||||
if opts.IncludeOrgRepos {
|
||||
return []string{"OWNER", "ORGANIZATION_MEMBER"}
|
||||
}
|
||||
return []string{"OWNER"}
|
||||
}
|
||||
|
||||
// ownedByUser reports whether a repo node counts as the user's own for the
|
||||
// repo-derived cards: anything in their namespace, plus org repos they
|
||||
// administer when opts allows it.
|
||||
func ownedByUser(r repoNode, login string, opts FetchOptions) bool {
|
||||
if r.Owner == nil || r.Owner.Login == login {
|
||||
return true
|
||||
}
|
||||
return opts.IncludeOrgRepos && r.ViewerPermission == adminPermission
|
||||
}
|
||||
|
||||
// FetchProfile collects profile, stats and repos-per-language data for the
|
||||
@@ -79,12 +109,15 @@ func (c *Client) FetchProfile(ctx context.Context, login string, opts FetchOptio
|
||||
p := &Profile{Login: login}
|
||||
reposPerLang := map[string]int64{}
|
||||
langColor := map[string]string{}
|
||||
publicRepoCount := 0
|
||||
repoCount := 0
|
||||
|
||||
var cursor *string
|
||||
const maxPages = 10
|
||||
for page := 0; page < maxPages; page++ {
|
||||
vars := map[string]any{"login": login}
|
||||
vars := map[string]any{
|
||||
"login": login,
|
||||
"affiliations": repoAffiliations(opts),
|
||||
}
|
||||
if cursor != nil {
|
||||
vars["after"] = *cursor
|
||||
}
|
||||
@@ -137,13 +170,16 @@ func (c *Client) FetchProfile(ctx context.Context, login string, opts FetchOptio
|
||||
}
|
||||
|
||||
for _, r := range u.Repositories.Nodes {
|
||||
if !ownedByUser(r, login, opts) {
|
||||
continue
|
||||
}
|
||||
if r.IsFork && !opts.IncludeForks {
|
||||
continue
|
||||
}
|
||||
if r.IsPrivate && !opts.IncludePrivate {
|
||||
continue
|
||||
}
|
||||
publicRepoCount++
|
||||
repoCount++
|
||||
p.TotalStars += r.StargazerCount
|
||||
p.TotalForks += r.ForkCount
|
||||
|
||||
@@ -170,7 +206,7 @@ func (c *Client) FetchProfile(ctx context.Context, login string, opts FetchOptio
|
||||
}
|
||||
|
||||
p.ReposByLanguage = sortLangStats(reposPerLang, langColor)
|
||||
p.PublicRepos = publicRepoCount
|
||||
p.RepoCount = repoCount
|
||||
return p, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,8 +3,11 @@ package github
|
||||
// profileQuery pulls everything needed for the profile, stats and languages
|
||||
// cards in one round trip. Fork/private filtering is done client-side so one
|
||||
// query handles all combinations of -include-forks / -include-private.
|
||||
// $affiliations decides whether org-owned repos are in scope at all; repos the
|
||||
// user only has read/write access to are dropped client-side via
|
||||
// viewerPermission.
|
||||
const profileQuery = `
|
||||
query($login: String!, $after: String) {
|
||||
query($login: String!, $after: String, $affiliations: [RepositoryAffiliation]) {
|
||||
user(login: $login) {
|
||||
id
|
||||
login
|
||||
@@ -44,13 +47,15 @@ query($login: String!, $after: String) {
|
||||
repositories(
|
||||
first: 100
|
||||
after: $after
|
||||
ownerAffiliations: OWNER
|
||||
ownerAffiliations: $affiliations
|
||||
orderBy: { field: STARGAZERS, direction: DESC }
|
||||
) {
|
||||
totalCount
|
||||
pageInfo { hasNextPage endCursor }
|
||||
nodes {
|
||||
name
|
||||
owner { login }
|
||||
viewerPermission
|
||||
isPrivate
|
||||
isFork
|
||||
stargazerCount
|
||||
|
||||
Reference in New Issue
Block a user