feat: seed-based repo scanning + fork/private visibility flags

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.
This commit is contained in:
tiennm99 committed 2026-04-18 22:01:26 +07:00
1 parent 0f54d5e885
commit 2943db667c
8 files changed
+179 -57

No files matched your search

+33 -6
View File
@@ -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
}
+46 -3
View File
@@ -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
}
+5 -1
View File
@@ -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,
}
+20 -14
View File
@@ -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
}
+28 -9
View File
@@ -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 }
}
}
}
}
}
}
}`