mirror of
https://github.com/tiennm99/ghglance.git
synced 2026-10-11 03:13:20 +00:00
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:
1 parent
0f54d5e885
commit
2943db667c
8 files changed
+179
-57
No files matched your search
+12
-4
@@ -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
|
||||
|
||||
+7
-3
@@ -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}"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user