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.