From 939fcdc33a709497b73b49f787cdf770c8da0faa Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Thu, 13 Aug 2026 13:20:41 +0700 Subject: [PATCH] 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. --- README.md | 16 ++- action.yml | 7 + docs/system-architecture.md | 14 +- entrypoint.sh | 4 +- internal/card/card_test.go | 4 +- internal/card/profile.go | 2 +- internal/github/contribution_scope_test.go | 94 +++++++++++++ internal/github/contributions_all_time.go | 149 ++++++++++++++------- internal/github/model.go | 24 ++-- internal/github/profile.go | 44 +++++- internal/github/queries.go | 9 +- main.go | 6 +- 12 files changed, 294 insertions(+), 79 deletions(-) create mode 100644 internal/github/contribution_scope_test.go diff --git a/README.md b/README.md index 94a2e243..1e169ca7 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Cards rendered: | # | Card | What it shows | | --- | --- | --- | -| 0 | Profile details | Login (Name) title + Octicon-labelled rows for company, location, link, join date (with age), followers/following, public repos | +| 0 | Profile details | Login (Name) title + Octicon-labelled rows for company, location, link, join date (with age), followers/following, repo count | | 1 | Repos per language | Donut + legend: how many owned non-fork repos use each language as primary | | 2 | Most commit language (last year) | Donut + legend: last-year commits byte-weighted across each repo's language breakdown | | 3 | Stats | Star, commit (lifetime + last-year), PR, issue, PR-review, contributed-to totals | @@ -35,7 +35,7 @@ Cards rendered: ## Preview — dracula theme -Live render against the author's profile, committed by [`.github/workflows/demo.yml`](./.github/workflows/demo.yml) on every push to `main`. Rendered with `start_of_week: monday` so the heatmap rows and weekday bars start on Mon. Other 64 themes in the [**demo gallery**](./demo). +Live render against the author's profile, committed by [`.github/workflows/demo.yml`](./.github/workflows/demo.yml) on every push to `main`. Rendered with `start_of_week: monday` so the heatmap rows and weekday bars start on Mon, and `include_org_repos` on so repos under the author's orgs count toward the repo and language totals. Other 64 themes in the [**demo gallery**](./demo).
@@ -124,6 +124,7 @@ Then embed the cards in your `README.md`: | `commits_per_repo` | `500` | Max commits sampled per repo (covers last-year and all-time aggregates) | | `include_forks` | `true` | Include forked repos in stats and commit probing | | `include_private` | `true` | Include private repos (requires PAT with `repo` scope; silently no-op otherwise) | +| `include_org_repos`| `false` | Count org-owned repos you administer toward stars, repo count, languages, top-starred (needs `read:org`) | | `commit_changes` | `false` | Commit generated cards back to the repo | | `commit_message` | `chore: update ghstats cards` | Commit message | | `commit_branch` | *(current ref)* | Target branch for auto-commit | @@ -163,11 +164,14 @@ ghstats -user tiennm99 -themes dracula,github_dark -tz Asia/Saigon -out output | `-commits-per-repo` | `500` | Max commits sampled per repo | | `-include-forks` | `true` | Include forked repos in the stats | | `-include-private` | `true` | Include private repos (requires `repo` PAT scope; silently no-op otherwise) | +| `-include-org-repos`| `false` | Count org-owned repos you administer toward stars, repo count, languages, top-starred | | `-list-themes` | | Print available theme ids and exit | ## 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. +**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. + +**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. **Commit-to-language** is byte-weighted: each commit credits every language in the repo, proportional to linguist's byte share. A commit to a 60% Go / 40% Python repo adds 0.6 to Go and 0.4 to Python, regardless of which file was touched. Caveats: @@ -175,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 + ~1 query per active year + ~50 commit-history pages ≈ **50-70 GraphQL calls**. +- ~1 profile query + ~4 queries per active year + ~50 commit-history pages ≈ **80-100 GraphQL calls**. - Zero REST calls. Well under the 5000 points/hr budget. ## Themes @@ -224,6 +228,10 @@ defaults to `true` so those commits are counted automatically once the token has `repo` scope; pass `include_private: "false"` if you want to keep private work out of the rendered cards even when the token can see it. +Enabling `include_org_repos` additionally needs `read:org` on the token, and +SSO authorization for any org that enforces it — otherwise those repos stay +invisible and the input silently changes nothing. + ## Credits & inspiration - [**github-profile-summary-cards**](https://github.com/vn7n24fzkq/github-profile-summary-cards) by [@vn7n24fzkq](https://github.com/vn7n24fzkq) — card layout, chart styles, theme palette, Octicon selection, and output structure. diff --git a/action.yml b/action.yml index 5e484fd6..2bcc0d96 100644 --- a/action.yml +++ b/action.yml @@ -47,6 +47,13 @@ inputs: description: Include private repos (requires PAT with repo scope; silently no-op otherwise) required: false default: "true" + include_org_repos: + description: | + Count org-owned repos you administer toward stars, repo count, repos-per-language + and top-starred. Off by default because it changes those totals; commit-based cards + already include org repos either way. Needs a PAT with `read:org`. + required: false + default: "false" commit_changes: description: Whether to commit the generated cards back to the repo required: false diff --git a/docs/system-architecture.md b/docs/system-architecture.md index d96f822e..33d980f1 100644 --- a/docs/system-architecture.md +++ b/docs/system-architecture.md @@ -26,6 +26,8 @@ main.go ▼ FetchProfile(ctx, login, opts) │ profileQuery × N pages (owned repos, STARGAZERS desc, 100/page) + │ ownerAffiliations = [OWNER] (+ ORGANIZATION_MEMBER when + │ opts.IncludeOrgRepos; non-ADMIN org repos dropped client-side) │ yields: Profile.{identity, stars, forks, PRs, issues, │ TopRepos, ReposByLanguage, │ ContributionYears, @@ -34,10 +36,12 @@ FetchProfile(ctx, login, opts) │ ▼ FetchContributionsAllTime(ctx, profile, opts) - │ contributionYearQuery × len(ContributionYears) - │ per year: totalCommitContributions + + │ contributionYearQuery × 4 quarters × len(ContributionYears) + │ 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 │ yields: SeedRepos (deduped), │ DailyContributionsAllTime, │ TotalCommitsAllTime @@ -64,14 +68,14 @@ 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-year calendar + seed list | 1 call per active year (typically 1–10) | +| `contributionYearQuery` | Per-quarter calendar + seed list | 4 calls per active year (typically 4–40) | | `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): - profile: 1 call -- year loop: 8 calls +- quarter loop: 8 × 4 = 32 calls - commit history: 30 × 1 = 30 calls -- **≈ 39 GraphQL calls, 0 REST calls** +- **≈ 63 GraphQL calls, 0 REST calls** ## Attribution model diff --git a/entrypoint.sh b/entrypoint.sh index bf12acf4..77f77b23 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -16,6 +16,7 @@ top_repos="${INPUT_TOP_REPOS:-0}" commits_per_repo="${INPUT_COMMITS_PER_REPO:-500}" include_forks="${INPUT_INCLUDE_FORKS:-true}" include_private="${INPUT_INCLUDE_PRIVATE:-true}" +include_org_repos="${INPUT_INCLUDE_ORG_REPOS:-false}" commit_changes="${INPUT_COMMIT_CHANGES:-false}" commit_message="${INPUT_COMMIT_MESSAGE:-chore: update ghstats cards}" commit_branch="${INPUT_COMMIT_BRANCH:-}" @@ -40,7 +41,8 @@ ghstats \ -top-repos "$top_repos" \ -commits-per-repo "$commits_per_repo" \ -include-forks="$include_forks" \ - -include-private="$include_private" + -include-private="$include_private" \ + -include-org-repos="$include_org_repos" if [ "$commit_changes" = "true" ]; then workspace="${GITHUB_WORKSPACE:-/github/workspace}" diff --git a/internal/card/card_test.go b/internal/card/card_test.go index e00594ec..374ec3d6 100644 --- a/internal/card/card_test.go +++ b/internal/card/card_test.go @@ -23,7 +23,7 @@ func TestRenderAll(t *testing.T) { Company: "VNG & ", Followers: 12, Following: 7, - PublicRepos: 42, + RepoCount: 42, TotalStars: 1234, ReposByLanguage: []github.LangStat{ {Name: "Go", Color: "#00ADD8", Value: 5}, @@ -376,7 +376,7 @@ func adversarialProfile() *github.Profile { Website: "https://example-with-a-very-long-domain.example.com/profile", Followers: 1_234_567, Following: 98_765, - PublicRepos: 4_321, + RepoCount: 4_321, TotalStars: 10_000_000, TotalCommits: 123_456, TotalCommitsAllTime: 9_876_543, diff --git a/internal/card/profile.go b/internal/card/profile.go index 2b06d791..3ee2cc0e 100644 --- a/internal/card/profile.go +++ b/internal/card/profile.go @@ -95,7 +95,7 @@ func buildProfileRows(p *github.Profile) []profileRow { }) rows = append(rows, profileRow{ icon: iconRepos, - value: fmt.Sprintf("%s public repos", formatInt(p.PublicRepos)), + value: fmt.Sprintf("%s repos", formatInt(p.RepoCount)), }) return rows } diff --git a/internal/github/contribution_scope_test.go b/internal/github/contribution_scope_test.go new file mode 100644 index 00000000..ddd0a8f7 --- /dev/null +++ b/internal/github/contribution_scope_test.go @@ -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) + } + } +} diff --git a/internal/github/contributions_all_time.go b/internal/github/contributions_all_time.go index b033e952..6c2f06ea 100644 --- a/internal/github/contributions_all_time.go +++ b/internal/github/contributions_all_time.go @@ -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 } diff --git a/internal/github/model.go b/internal/github/model.go index b4de013a..1126da94 100644 --- a/internal/github/model.go +++ b/internal/github/model.go @@ -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 { diff --git a/internal/github/profile.go b/internal/github/profile.go index 5d432186..66540c9a 100644 --- a/internal/github/profile.go +++ b/internal/github/profile.go @@ -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 } diff --git a/internal/github/queries.go b/internal/github/queries.go index d3850947..f03279ea 100644 --- a/internal/github/queries.go +++ b/internal/github/queries.go @@ -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 diff --git a/main.go b/main.go index 80fde102..1767be16 100644 --- a/main.go +++ b/main.go @@ -27,6 +27,7 @@ func main() { perRepo = flag.Int("commits-per-repo", 500, "max commits sampled per repo (covers both last-year and all-time aggregates)") includeForks = flag.Bool("include-forks", true, "include forked repos in stats and commit probing") includePrivate = flag.Bool("include-private", true, "include private repos (requires PAT with repo scope; silently no-op otherwise)") + includeOrgs = flag.Bool("include-org-repos", false, "count org-owned repos you administer toward stars, repo count, repos-per-language and top-starred") timeout = flag.Duration("timeout", 30*time.Minute, "overall deadline for fetch phase (0 = no limit)") startOfWeek = flag.String("start-of-week", "sunday", "first day of week for heatmap rows and weekday bars (sunday|monday|tuesday|…)") listThemes = flag.Bool("list-themes", false, "print available theme ids and exit") @@ -65,8 +66,9 @@ func main() { } opts := github.FetchOptions{ - IncludeForks: *includeForks, - IncludePrivate: *includePrivate, + IncludeForks: *includeForks, + IncludePrivate: *includePrivate, + IncludeOrgRepos: *includeOrgs, } // Overall fetch budget. Ctrl-C cancels in-flight HTTP requests cleanly.