From 2943db667ca62d0bd969e42b721af148353df620 Mon Sep 17 00:00:00 2001 From: tiennm99 Date: Sat, 18 Apr 2026 22:01:26 +0700 Subject: [PATCH] feat: seed-based repo scanning + fork/private visibility flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the top-10-starred sampling with a seed list built from contributionsCollection.commitContributionsByRepository, unioned across every active contributionYear. Commit-history probes now land only on repos where the user actually committed, covering all owned repos plus forks and repos owned by others when allowed. New visibility knobs (default off — public-facing READMEs stay safe): - -include-forks / include_forks : include forked repos - -include-private / include_private: include private repos (requires PAT with repo scope) Compatibility: - -top-repos default changed 10 → 0 (unlimited); still usable as a cap for fast local runs. - commitHistoryQuery now takes $owner so probes can target forks or repos outside the user's ownership. - FetchProfile now accepts FetchOptions; PublicRepos counts only repos that pass the visibility filter. Seed-list approach mirrors github-profile-summary-cards' own repo sourcing but keeps our byte-weighted commit attribution. --- action.yml | 16 ++++++-- entrypoint.sh | 10 +++-- internal/github/contributions_all_time.go | 39 +++++++++++++++--- internal/github/model.go | 49 +++++++++++++++++++++-- internal/github/productive.go | 6 ++- internal/github/profile.go | 34 +++++++++------- internal/github/queries.go | 37 ++++++++++++----- main.go | 45 +++++++++++++-------- 8 files changed, 179 insertions(+), 57 deletions(-) diff --git a/action.yml b/action.yml index 8f1022df..75649503 100644 --- a/action.yml +++ b/action.yml @@ -28,13 +28,21 @@ inputs: required: false default: UTC top_repos: - description: Number of top-starred owned repos to sample for commit histogram (0 to skip) + description: Optional cap on seed repos probed for commit history (0 = unlimited) required: false - default: "10" + default: "0" commits_per_repo: - description: Max commits sampled per repo for the heatmap + description: Max commits sampled per repo (covers last-year and all-time aggregates) required: false - default: "100" + default: "500" + include_forks: + description: Include forked repos in stats and commit probing + required: false + default: "false" + include_private: + description: Include private repos (requires PAT with repo scope) + required: false + default: "false" commit_changes: description: Whether to commit the generated cards back to the repo required: false diff --git a/entrypoint.sh b/entrypoint.sh index 58265476..e5845915 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -11,8 +11,10 @@ token="${INPUT_TOKEN:-${GITHUB_TOKEN:-}}" out="${INPUT_OUT:-output}" themes="${INPUT_THEMES:-dracula}" tz="${INPUT_TZ:-UTC}" -top_repos="${INPUT_TOP_REPOS:-10}" -commits_per_repo="${INPUT_COMMITS_PER_REPO:-100}" +top_repos="${INPUT_TOP_REPOS:-0}" +commits_per_repo="${INPUT_COMMITS_PER_REPO:-500}" +include_forks="${INPUT_INCLUDE_FORKS:-false}" +include_private="${INPUT_INCLUDE_PRIVATE:-false}" commit_changes="${INPUT_COMMIT_CHANGES:-false}" commit_message="${INPUT_COMMIT_MESSAGE:-chore: update ghstats cards}" commit_branch="${INPUT_COMMIT_BRANCH:-}" @@ -34,7 +36,9 @@ ghstats \ -themes "$themes" \ -tz "$tz" \ -top-repos "$top_repos" \ - -commits-per-repo "$commits_per_repo" + -commits-per-repo "$commits_per_repo" \ + -include-forks="$include_forks" \ + -include-private="$include_private" if [ "$commit_changes" = "true" ]; then workspace="${GITHUB_WORKSPACE:-/github/workspace}" diff --git a/internal/github/contributions_all_time.go b/internal/github/contributions_all_time.go index 922a4e05..c7cfed9e 100644 --- a/internal/github/contributions_all_time.go +++ b/internal/github/contributions_all_time.go @@ -18,24 +18,34 @@ type contributionYearGQL struct { } `json:"contributionDays"` } `json:"weeks"` } `json:"contributionCalendar"` + CommitContributionsByRepository []struct { + Contributions struct { + TotalCount int `json:"totalCount"` + } `json:"contributions"` + Repository repoNode `json:"repository"` + } `json:"commitContributionsByRepository"` } `json:"contributionsCollection"` } `json:"user"` } // FetchContributionsAllTime iterates p.ContributionYears and issues one -// contributionsCollection query per year, concatenating the daily calendar -// into p.DailyContributionsAllTime and accumulating commit counts into -// p.TotalCommitsAllTime. +// contributionsCollection query per year. Each year's payload contributes: // -// Cost: one GraphQL call per active year (typically 1–10 per user). -func (c *Client) FetchContributionsAllTime(p *Profile) error { +// - Days → p.DailyContributionsAllTime +// - Commit count → p.TotalCommitsAllTime +// - Repos the user committed in → p.SeedRepos (deduplicated by owner/name) +// +// Fork and private repos are filtered client-side per opts so the caller can +// run the same pipeline with different visibility policies. +func (c *Client) FetchContributionsAllTime(p *Profile, opts FetchOptions) error { years := append([]int(nil), p.ContributionYears...) sort.Ints(years) // ascending so the concatenated series is oldest→newest + seen := map[string]int{} // "owner/name" → index in p.SeedRepos + 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) - // Clamp the current year's window to now so GitHub doesn't reject it. if now := time.Now().UTC(); to.After(now) { to = now } @@ -67,6 +77,23 @@ func (c *Client) FetchContributionsAllTime(p *Profile) error { }) } } + + 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 6cc11af1..0ff4d8fd 100644 --- a/internal/github/model.go +++ b/internal/github/model.go @@ -57,10 +57,17 @@ type Profile struct { // all-time area chart can show history beyond the default 1-year window. DailyContributionsAllTime []DailyContribution - // TopRepos are owned repos sorted by stargazer count desc. Populated by - // FetchProfile and consumed by FetchProductive. + // TopRepos are owned non-fork repos sorted by stargazer count desc, + // populated by FetchProfile. Used for the profile's stars/forks totals + // and the repos-per-language card. TopRepos []RepoInfo + // SeedRepos are the repos where the user actually committed, unioned + // across every contribution year via commitContributionsByRepository. + // Used by FetchProductive so commit-history probes land where there is + // signal instead of across the long tail of empty owned repos. + SeedRepos []RepoInfo + // ContributionYears lists every calendar year the user has been active // on GitHub, newest first. Used by FetchContributionsAllTime to iterate // per-year contributionsCollection queries. @@ -86,9 +93,13 @@ type LangStat struct { Value int64 } -// RepoInfo is the minimal owned-repo summary used for downstream fetches. +// RepoInfo is the minimal repo summary used for downstream fetches. Owner is +// kept so the history query can target forks / repos belonging to other users. type RepoInfo struct { + Owner string Name string + IsPrivate bool + IsFork bool PrimaryLanguage string PrimaryColor string // Languages is the repo's language byte breakdown as reported by GitHub @@ -108,8 +119,13 @@ type LangEdge struct { // 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 { + Login string `json:"login"` + } `json:"owner"` PrimaryLanguage *struct { Name string `json:"name"` Color string `json:"color"` @@ -124,3 +140,30 @@ type repoNode struct { } `json:"edges"` } `json:"languages"` } + +// toRepoInfo converts the GraphQL shape into our public RepoInfo, defaulting +// owner to the given login if the node omits it (profile query doesn't +// request owner since it's implicit on user.repositories). +func (r repoNode) toRepoInfo(defaultOwner string) RepoInfo { + info := RepoInfo{ + Owner: defaultOwner, + Name: r.Name, + IsPrivate: r.IsPrivate, + IsFork: r.IsFork, + } + if r.Owner != nil { + info.Owner = r.Owner.Login + } + if r.PrimaryLanguage != nil { + info.PrimaryLanguage = r.PrimaryLanguage.Name + info.PrimaryColor = r.PrimaryLanguage.Color + } + for _, e := range r.Languages.Edges { + info.Languages = append(info.Languages, LangEdge{ + Name: e.Node.Name, + Color: e.Node.Color, + Bytes: e.Size, + }) + } + return info +} diff --git a/internal/github/productive.go b/internal/github/productive.go index 7347568e..1b54cea0 100644 --- a/internal/github/productive.go +++ b/internal/github/productive.go @@ -56,8 +56,12 @@ func (c *Client) FetchProductive(p *Profile, repos []RepoInfo, loc *time.Locatio if seen >= maxPerRepo { break } + owner := repo.Owner + if owner == "" { + owner = p.Login + } vars := map[string]any{ - "login": p.Login, + "owner": owner, "repo": repo.Name, "userId": p.ID, } diff --git a/internal/github/profile.go b/internal/github/profile.go index efd25cd2..075c3e9e 100644 --- a/internal/github/profile.go +++ b/internal/github/profile.go @@ -57,10 +57,18 @@ type profileGQL struct { } `json:"user"` } +// FetchOptions tunes which repos contribute to the profile's aggregates. +// All defaults are conservative (no forks, no private) so public-facing +// READMEs don't accidentally leak work-repo signal. +type FetchOptions struct { + IncludeForks bool + IncludePrivate bool +} + // FetchProfile collects profile, stats and repos-per-language data for the // given user. Owned repos are paginated up to 10 pages (1000 repos) as a -// safety cap. -func (c *Client) FetchProfile(login string) (*Profile, error) { +// safety cap. Forks and private repos are filtered client-side per opts. +func (c *Client) FetchProfile(login string, opts FetchOptions) (*Profile, error) { if login == "" { return nil, errors.New("empty user") } @@ -68,6 +76,7 @@ func (c *Client) FetchProfile(login string) (*Profile, error) { p := &Profile{Login: login} reposPerLang := map[string]int64{} langColor := map[string]string{} + publicRepoCount := 0 var cursor *string const maxPages = 10 @@ -99,7 +108,6 @@ func (c *Client) FetchProfile(login string) (*Profile, error) { } p.Followers = u.Followers.TotalCount p.Following = u.Following.TotalCount - p.PublicRepos = u.Repositories.TotalCount p.TotalPRs = u.PullRequests.TotalCount p.TotalIssues = u.Issues.TotalCount p.TotalContributedTo = u.RepositoriesContributedTo.TotalCount @@ -126,27 +134,24 @@ func (c *Client) FetchProfile(login string) (*Profile, error) { } for _, r := range u.Repositories.Nodes { + if r.IsFork && !opts.IncludeForks { + continue + } + if r.IsPrivate && !opts.IncludePrivate { + continue + } + publicRepoCount++ p.TotalStars += r.StargazerCount p.TotalForks += r.ForkCount - info := RepoInfo{Name: r.Name} + info := r.toRepoInfo(login) if r.PrimaryLanguage != nil { - info.PrimaryLanguage = r.PrimaryLanguage.Name - info.PrimaryColor = r.PrimaryLanguage.Color reposPerLang[r.PrimaryLanguage.Name]++ if _, ok := langColor[r.PrimaryLanguage.Name]; !ok { langColor[r.PrimaryLanguage.Name] = r.PrimaryLanguage.Color } } - // Carry the repo's full language-bytes breakdown so commit - // attribution can distribute each commit across all languages - // the repo contains, not just the primary. for _, e := range r.Languages.Edges { - info.Languages = append(info.Languages, LangEdge{ - Name: e.Node.Name, - Color: e.Node.Color, - Bytes: e.Size, - }) if _, ok := langColor[e.Node.Name]; !ok { langColor[e.Node.Name] = e.Node.Color } @@ -162,6 +167,7 @@ func (c *Client) FetchProfile(login string) (*Profile, error) { } p.ReposByLanguage = sortLangStats(reposPerLang, langColor) + p.PublicRepos = publicRepoCount return p, nil } diff --git a/internal/github/queries.go b/internal/github/queries.go index dffd0125..d3850947 100644 --- a/internal/github/queries.go +++ b/internal/github/queries.go @@ -1,8 +1,8 @@ package github // profileQuery pulls everything needed for the profile, stats and languages -// cards in one round trip. Repo pagination is handled by the caller if the -// user owns more than 100 repos. +// cards in one round trip. Fork/private filtering is done client-side so one +// query handles all combinations of -include-forks / -include-private. const profileQuery = ` query($login: String!, $after: String) { user(login: $login) { @@ -45,13 +45,14 @@ query($login: String!, $after: String) { first: 100 after: $after ownerAffiliations: OWNER - isFork: false orderBy: { field: STARGAZERS, direction: DESC } ) { totalCount pageInfo { hasNextPage endCursor } nodes { name + isPrivate + isFork stargazerCount forkCount primaryLanguage { name color } @@ -68,10 +69,11 @@ query($login: String!, $after: String) { // commitHistoryQuery fetches commit timestamps in the default branch of one // repo, filtered to commits authored by the target user. Used to build the -// productive-time heatmap. +// productive-time heatmap. Owner is a variable because seed repos may belong +// to other users (e.g. forks the user has contributed to). const commitHistoryQuery = ` -query($login: String!, $repo: String!, $userId: ID!, $after: String) { - repository(owner: $login, name: $repo) { +query($owner: String!, $repo: String!, $userId: ID!, $after: String) { + repository(owner: $owner, name: $repo) { defaultBranchRef { target { ... on Commit { @@ -85,9 +87,10 @@ query($login: String!, $repo: String!, $userId: ID!, $after: String) { } }` -// contributionYearQuery fetches a single year's contribution calendar days -// plus the commit total for that year. Looped in Go over user.contributionYears -// to build the all-time contribution series and lifetime commit count. +// contributionYearQuery fetches one year's contribution calendar plus the +// repos the user actually committed in that year. The calendar feeds the +// all-time area chart; commitContributionsByRepository feeds the seed list +// that drives commit-history probing. const contributionYearQuery = ` query($login: String!, $from: DateTime!, $to: DateTime!) { user(login: $login) { @@ -101,6 +104,22 @@ query($login: String!, $from: DateTime!, $to: DateTime!) { } } } + commitContributionsByRepository(maxRepositories: 100) { + contributions { totalCount } + repository { + name + owner { login } + isPrivate + isFork + primaryLanguage { name color } + languages(first: 20, orderBy: { field: SIZE, direction: DESC }) { + edges { + size + node { name color } + } + } + } + } } } }` diff --git a/main.go b/main.go index 35976566..12efedeb 100644 --- a/main.go +++ b/main.go @@ -15,14 +15,16 @@ import ( func main() { var ( - user = flag.String("user", "", "GitHub username (required)") - token = flag.String("token", os.Getenv("GITHUB_TOKEN"), "GitHub token (or env GITHUB_TOKEN)") - out = flag.String("out", "output", "output directory") - themesFlag = flag.String("themes", "dracula", "comma-separated theme ids, or 'all'") - tzName = flag.String("tz", "Local", "timezone for productive-time card (IANA name, e.g. Asia/Saigon)") - topRepos = flag.Int("top-repos", 10, "owned repos to sample for productive-time heatmap (0 to skip)") - perRepo = flag.Int("commits-per-repo", 500, "max commits sampled per repo (covers both last-year and all-time aggregates)") - listThemes = flag.Bool("list-themes", false, "print available theme ids and exit") + user = flag.String("user", "", "GitHub username (required)") + token = flag.String("token", os.Getenv("GITHUB_TOKEN"), "GitHub token (or env GITHUB_TOKEN)") + out = flag.String("out", "output", "output directory") + themesFlag = flag.String("themes", "dracula", "comma-separated theme ids, or 'all'") + tzName = flag.String("tz", "Local", "timezone for productive-time card (IANA name, e.g. Asia/Saigon)") + topRepos = flag.Int("top-repos", 0, "optional cap on seed repos probed for commit history (0 = unlimited)") + 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", false, "include forked repos in stats and commit probing") + includePrivate = flag.Bool("include-private", false, "include private repos (requires PAT with repo scope)") + listThemes = flag.Bool("list-themes", false, "print available theme ids and exit") ) flag.Parse() @@ -51,28 +53,37 @@ func main() { loc = time.UTC } + opts := github.FetchOptions{ + IncludeForks: *includeForks, + IncludePrivate: *includePrivate, + } + client := github.NewClient(*token) - profile, err := client.FetchProfile(*user) + profile, err := client.FetchProfile(*user, opts) if err != nil { fmt.Fprintf(os.Stderr, "error: fetch profile: %v\n", err) os.Exit(1) } profile.UTCOffsetLabel = utcOffsetLabel(loc) - if *topRepos > 0 && profile.ID != "" { - repos := profile.TopRepos - if len(repos) > *topRepos { + // Year-loop fetch populates SeedRepos from commitContributionsByRepository + // plus the all-time contribution calendar; must precede FetchProductive so + // commit-history probes land on repos where the user actually committed. + if len(profile.ContributionYears) > 0 { + if err := client.FetchContributionsAllTime(profile, opts); err != nil { + fmt.Fprintf(os.Stderr, "warn: all-time contributions fetch: %v\n", err) + } + } + + if profile.ID != "" && len(profile.SeedRepos) > 0 { + repos := profile.SeedRepos + if *topRepos > 0 && len(repos) > *topRepos { repos = repos[:*topRepos] } if err := client.FetchProductive(profile, repos, loc, *perRepo); err != nil { fmt.Fprintf(os.Stderr, "warn: productive-time + commits-per-language fetch: %v\n", err) } } - if len(profile.ContributionYears) > 0 { - if err := client.FetchContributionsAllTime(profile); err != nil { - fmt.Fprintf(os.Stderr, "warn: all-time contributions fetch: %v\n", err) - } - } for _, t := range selected { if err := card.RenderAll(profile, t, *out); err != nil {