2 Commits
Author SHA1 Message Date
tiennm99 7c081ac591 feat: let commits-per-repo 0 mean every commit
The pagination guard broke on seen >= maxPerRepo, so a cap of 0 stopped
before the first page and rendered empty productive-time,
productive-weekday and most-commit-language cards. That inverted the
meaning 0 carries for top-repos, where it already means unlimited.

Treat a cap of 0 or less as no cap. Verified against a real repo: a cap
of 100 still stops at 100, while 500 and 0 both walk the full 382-commit
history.
2026-08-13 14:19:41 +07:00
tiennm99 9e0387f307 docs: show include_org_repos in the runnable examples
The input reached both reference tables but neither example, so anyone
copying the workflow YAML or the CLI invocation would not learn the knob
exists. Add it to the Action example with a note on when to flip it, and
add a second CLI example alongside the existing one.

Add CLAUDE.md recording the five surfaces a config knob has to reach —
flag, action input, entrypoint translation, README tables *and*
examples, architecture doc — since the examples are the step that got
missed here. Also note the demo gallery's hardcoded card list and the
GitHub API ceilings the fetch pipeline works around.
2026-08-13 13:52:38 +07:00
7 changed files with 106 additions and 8 deletions

No files matched your search

+52
View File
@@ -0,0 +1,52 @@
# ghstats
Single-binary Go CLI that renders GitHub profile cards as SVG, wrapped by a
published GitHub Action. `main.go` parses flags → `internal/github` fetches →
`internal/card` renders → `internal/theme` supplies palettes.
## Adding or changing a config knob
A new flag, input, or default is not done when the code compiles. Every knob
has a surface in five places, and a change that lands in some but not all of
them ships a half-wired feature or a README that lies:
1. `main.go` — the CLI flag
2. `action.yml` — the matching Action input and its default
3. `entrypoint.sh` — the `INPUT_*` → flag translation
4. `README.md` — **both** reference tables (Action inputs, CLI flags) **and
the runnable examples**: the workflow YAML under "Use as a GitHub Action"
and the `ghstats …` command under "Use as a CLI"
5. `docs/system-architecture.md` — when the knob changes the fetch pipeline
or its query cost
The examples are the step most often missed, and they are what people copy.
Grep the flag name across the repo before calling the change complete; every
hit that is a reference table or example should mention it.
## Behavior changes are user-visible
The Action is published to the Marketplace, so existing callers get whatever
`@v1` points at. Default-off for anything that moves numbers on already-
rendered cards, and say so in the input description. Releases are tag-driven:
push `vX.Y.Z`, and `release.yml` tests, builds, and force-moves the floating
`v1` tag.
## Cards and the demo gallery
Adding a card means updating `internal/card/card.go`, the card table in
`README.md`, and the layout in `.github/workflows/demo.yml` — the demo
generator embeds a hardcoded list of SVGs, so a new card renders into every
theme directory but stays invisible in the gallery until it is added there.
Per-theme pages mirror the table layout in the author's profile README at
`tiennm99/tiennm99`.
## GitHub API constraints worth remembering
- `commitContributionsByRepository` caps at 100 repos per query and truncates
silently. Contribution years are queried by quarter, and a saturated quarter
is re-asked by month. Only widen windows with that ceiling in mind.
- `contributionCalendar` is clipped to the exact `from`/`to`, with no
week-boundary spillover, so disjoint windows never double-count days.
- Test files named `*_windows_test.go` (or any GOOS/GOARCH suffix) are
silently excluded from the build on other platforms — `go test` reports
"ok" while running nothing.
+9 -2
View File
@@ -86,6 +86,7 @@ jobs:
tz: Asia/Saigon
include_forks: "true"
include_private: "true"
include_org_repos: "false" # "true" also counts org repos you administer (token needs read:org)
commit_changes: "true"
```
@@ -121,7 +122,7 @@ Then embed the cards in your `README.md`:
| `tz` | `UTC` | IANA tz for the productive-time card (e.g. `Asia/Saigon`) |
| `start_of_week` | `sunday` | First day of week for heatmap rows and weekday bars (`sunday`…`saturday`) |
| `top_repos` | `0` | Optional cap on seed repos probed for commit history (`0` = unlimited) |
| `commits_per_repo` | `500` | Max commits sampled per repo (covers last-year and all-time aggregates) |
| `commits_per_repo` | `500` | Max commits sampled per repo, `0` = every commit (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`) |
@@ -152,6 +153,12 @@ export GITHUB_TOKEN=ghp_xxx
ghstats -user tiennm99 -themes dracula,github_dark -tz Asia/Saigon -out output
```
Add `-include-org-repos` to also count org-owned repos you administer:
```sh
ghstats -user tiennm99 -themes dracula -include-org-repos -out output
```
| Flag | Default | Description |
| ------------------- | --------------- | ---------------------------------------------------------------------- |
| `-user` | *(required)* | GitHub username |
@@ -161,7 +168,7 @@ ghstats -user tiennm99 -themes dracula,github_dark -tz Asia/Saigon -out output
| `-tz` | `Local` | IANA timezone for productive-time cards |
| `-start-of-week` | `sunday` | First day of week for heatmap rows and weekday bars (`sunday`…`saturday`) |
| `-top-repos` | `0` | Optional cap on seed repos probed (`0` = unlimited) |
| `-commits-per-repo` | `500` | Max commits sampled per repo |
| `-commits-per-repo` | `500` | Max commits sampled per repo, `0` = every commit |
| `-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 |
+1 -1
View File
@@ -36,7 +36,7 @@ inputs:
required: false
default: "0"
commits_per_repo:
description: Max commits sampled per repo (covers last-year and all-time aggregates)
description: Max commits sampled per repo, `0` for every commit (covers last-year and all-time aggregates)
required: false
default: "500"
include_forks:
+1 -1
View File
@@ -49,7 +49,7 @@ FetchContributionsAllTime(ctx, profile, opts)
│ TotalCommitsAllTime
│
▼
FetchProductive(ctx, profile, profile.SeedRepos, loc, commitsPerRepo)
FetchProductive(ctx, profile, profile.SeedRepos, loc, commitsPerRepo) // 0 = no cap
│ commitHistoryQuery × (#seeds × pages)
│ per commit: t = committedDate in loc
│ ProductiveAllTime[t.Hour]++
+27
View File
@@ -0,0 +1,27 @@
package github
import "testing"
func TestReachedCommitCap(t *testing.T) {
cases := []struct {
name string
seen int
maxPerRepo int
want bool
}{
{"under the cap", 100, 500, false},
{"at the cap", 500, 500, true},
{"past the cap", 700, 500, true},
// Zero means unlimited, so even a fresh repo keeps paginating. The
// old behavior stopped here and rendered empty commit-derived cards.
{"zero cap, nothing seen yet", 0, 0, false},
{"zero cap, deep into history", 100_000, 0, false},
{"negative cap treated as unlimited", 10, -1, false},
}
for _, tc := range cases {
if got := reachedCommitCap(tc.seen, tc.maxPerRepo); got != tc.want {
t.Errorf("%s: reachedCommitCap(%d, %d) = %v, want %v",
tc.name, tc.seen, tc.maxPerRepo, got, tc.want)
}
}
}
+15 -3
View File
@@ -30,9 +30,21 @@ type productiveGQL struct {
// magnitude is irrelevant because the card renders percentages.
const scaleFactor = 10_000
// reachedCommitCap reports whether a repo has given up enough commits to stop
// paginating. A cap of zero or less means no cap — keep going until the
// history runs out — matching how -top-repos treats zero. Without the guard a
// zero cap would stop before the first page and quietly empty every
// commit-derived card.
func reachedCommitCap(seen, maxPerRepo int) bool {
if maxPerRepo <= 0 {
return false
}
return seen >= maxPerRepo
}
// FetchProductive paginates the default-branch commit history (authored by
// the target user) for each repo up to maxPerRepo commits, and fills two
// parallel sets of aggregates on the Profile:
// the target user) for each repo up to maxPerRepo commits (0 = all), and
// fills two parallel sets of aggregates on the Profile:
//
// - Last-year: p.Productive (24h histogram) and p.CommitsByLanguage
// - All-time: p.ProductiveAllTime and p.CommitsByLanguageAllTime
@@ -62,7 +74,7 @@ func (c *Client) FetchProductive(ctx context.Context, p *Profile, repos []RepoIn
var cursor *string
seen := 0
for {
if seen >= maxPerRepo {
if reachedCommitCap(seen, maxPerRepo) {
break
}
owner := repo.Owner
+1 -1
View File
@@ -24,7 +24,7 @@ func main() {
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)")
perRepo = flag.Int("commits-per-repo", 500, "max commits sampled per repo, 0 = every commit (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")