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.