diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a69e2720..d768b3f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,8 @@ jobs: build: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: "1.26" cache: true diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c73da180..8c570255 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -10,19 +10,31 @@ permissions: packages: write jobs: - docker: + test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v6 - - uses: docker/setup-buildx-action@v3 - - uses: docker/login-action@v3 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 + with: + go-version: "1.26" + cache: true + - run: go vet ./... + - run: go test ./... + + docker: + needs: [test] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + - uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Resolve tag metadata id: meta - uses: docker/metadata-action@v5 + uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5 with: images: ghcr.io/${{ github.repository }} tags: | @@ -30,7 +42,7 @@ jobs: type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} type=raw,value=latest - - uses: docker/build-push-action@v6 + - uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: context: . push: true @@ -38,6 +50,7 @@ jobs: labels: ${{ steps.meta.outputs.labels }} binaries: + needs: [test] runs-on: ubuntu-latest permissions: contents: write @@ -55,8 +68,8 @@ jobs: - goos: windows goarch: amd64 steps: - - uses: actions/checkout@v6 - - uses: actions/setup-go@v6 + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6 with: go-version: "1.26" cache: true @@ -76,6 +89,6 @@ jobs: else tar -czf "ghstats_${GOOS}_${GOARCH}.tar.gz" "$bin" fi - - uses: softprops/action-gh-release@v2 + - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: files: dist/ghstats_*.* diff --git a/Dockerfile b/Dockerfile index 2de76d9e..3cce60e9 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,11 @@ -FROM golang:1.26-alpine AS build +FROM golang:1.26-alpine@sha256:f85330846cde1e57ca9ec309382da3b8e6ae3ab943d2739500e08c86393a21b1 AS build WORKDIR /src COPY go.mod ./ RUN go mod download || true COPY . . RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ghstats . -FROM alpine:3.21 +FROM alpine:3.21@sha256:48b0309ca019d89d40f670aa1bc06e426dc0931948452e8491e3d65087abc07d RUN apk add --no-cache ca-certificates tzdata git COPY --from=build /out/ghstats /usr/local/bin/ghstats COPY entrypoint.sh /entrypoint.sh diff --git a/internal/card/stats.go b/internal/card/stats.go index ed5c6cb5..e0a81832 100644 --- a/internal/card/stats.go +++ b/internal/card/stats.go @@ -37,7 +37,7 @@ func (statsCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) { {iconPR, "Total PRs", formatInt(p.TotalPRs)}, {iconIssue, "Total Issues", formatInt(p.TotalIssues)}, {iconReview, "Total PR Reviews", formatInt(p.TotalReviews)}, - {iconRepos, "Contributed to (non-fork)", formatInt(p.TotalContributedTo)}, + {iconRepos, "Contributed to", formatInt(p.TotalContributedTo)}, } var b strings.Builder diff --git a/internal/github/client.go b/internal/github/client.go index ced8b2e4..ae12edd6 100644 --- a/internal/github/client.go +++ b/internal/github/client.go @@ -3,11 +3,16 @@ package github import ( "bytes" + "context" "encoding/json" "fmt" "io" "net/http" + "os" + "strconv" + "strings" "time" + "unicode/utf8" ) const endpoint = "https://api.github.com/graphql" @@ -43,70 +48,132 @@ type gqlResponse struct { Errors []gqlError `json:"errors,omitempty"` } +// maxRateLimitSleep caps how long we're willing to wait for a rate-limit +// reset before giving up — a 1-hour reset window is better handled by the +// caller (reschedule the Action) than by sleeping through it. +const maxRateLimitSleep = 5 * time.Minute + // query runs a GraphQL query and unmarshals the `data` field into out. -func (c *Client) query(q string, vars map[string]any, out any) error { +// Respects ctx deadlines so pagination loops can abort early when the +// caller's overall budget expires. On a primary-rate-limit 403, honors +// Retry-After / X-RateLimit-Reset once before retrying. +func (c *Client) query(ctx context.Context, q string, vars map[string]any, out any) error { body, err := json.Marshal(gqlRequest{Query: q, Variables: vars}) if err != nil { return fmt.Errorf("marshal request: %w", err) } - req, err := http.NewRequest(http.MethodPost, endpoint, bytes.NewReader(body)) - if err != nil { - return fmt.Errorf("new request: %w", err) - } - req.Header.Set("Content-Type", "application/json") - req.Header.Set("User-Agent", "ghstats") - if c.token != "" { - req.Header.Set("Authorization", "bearer "+c.token) - } - resp, err := c.http.Do(req) - if err != nil { - return fmt.Errorf("http: %w", err) - } - defer resp.Body.Close() - - raw, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("read body: %w", err) - } - if resp.StatusCode >= 400 { - return fmt.Errorf("http %d: %s", resp.StatusCode, truncate(raw, 500)) - } - - var r gqlResponse - if err := json.Unmarshal(raw, &r); err != nil { - return fmt.Errorf("decode body: %w", err) - } - if len(r.Errors) > 0 { - msgs := make([]string, 0, len(r.Errors)) - for _, e := range r.Errors { - msgs = append(msgs, e.Message) + for attempt := 0; attempt < 2; attempt++ { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("new request: %w", err) } - return fmt.Errorf("graphql: %s", joinErrs(msgs)) - } - if out != nil { - if err := json.Unmarshal(r.Data, out); err != nil { - return fmt.Errorf("decode data: %w", err) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "ghstats") + if c.token != "" { + req.Header.Set("Authorization", "bearer "+c.token) } + + resp, err := c.http.Do(req) + if err != nil { + return fmt.Errorf("http: %w", err) + } + + raw, err := io.ReadAll(resp.Body) + resp.Body.Close() + if err != nil { + return fmt.Errorf("read body: %w", err) + } + + if rateLimited(resp) && attempt == 0 { + wait := rateLimitWait(resp) + if wait > maxRateLimitSleep { + return fmt.Errorf("http %d: rate limit resets in %s (>%s max wait)", resp.StatusCode, wait, maxRateLimitSleep) + } + fmt.Fprintf(os.Stderr, "warn: rate-limited, sleeping %s before retry\n", wait.Round(time.Second)) + select { + case <-time.After(wait): + case <-ctx.Done(): + return ctx.Err() + } + continue + } + if resp.StatusCode >= 400 { + return fmt.Errorf("http %d: %s", resp.StatusCode, truncate(raw, 500)) + } + + var r gqlResponse + if err := json.Unmarshal(raw, &r); err != nil { + return fmt.Errorf("decode body: %w", err) + } + if len(r.Errors) > 0 { + msgs := make([]string, 0, len(r.Errors)) + for _, e := range r.Errors { + msgs = append(msgs, e.Message) + } + return fmt.Errorf("graphql: %s", strings.Join(msgs, "; ")) + } + if out != nil { + if err := json.Unmarshal(r.Data, out); err != nil { + return fmt.Errorf("decode data: %w", err) + } + } + return nil } - return nil + return fmt.Errorf("http: exceeded retry attempts") } +// rateLimited returns true when the response indicates a GitHub primary or +// secondary rate-limit hit (429, or 403 with remaining=0). +func rateLimited(resp *http.Response) bool { + if resp.StatusCode == http.StatusTooManyRequests { + return true + } + if resp.StatusCode == http.StatusForbidden { + if remaining := resp.Header.Get("X-RateLimit-Remaining"); remaining == "0" { + return true + } + } + return false +} + +// rateLimitWait derives a sleep duration from response headers: Retry-After +// (secondary rate limits) takes precedence over X-RateLimit-Reset (primary). +// Returns a 60s floor if neither header is usable, capped at maxRateLimitSleep. +func rateLimitWait(resp *http.Response) time.Duration { + if v := resp.Header.Get("Retry-After"); v != "" { + if secs, err := strconv.Atoi(v); err == nil && secs > 0 { + return clampDuration(time.Duration(secs) * time.Second) + } + } + if v := resp.Header.Get("X-RateLimit-Reset"); v != "" { + if ts, err := strconv.ParseInt(v, 10, 64); err == nil { + wait := time.Until(time.Unix(ts, 0)) + if wait > 0 { + return clampDuration(wait + time.Second) // +1s buffer + } + } + } + return 60 * time.Second +} + +func clampDuration(d time.Duration) time.Duration { + if d > maxRateLimitSleep { + return maxRateLimitSleep + } + return d +} + +// truncate shortens b to at most n bytes, backing up to the last valid UTF-8 +// rune boundary so the result is always well-formed. func truncate(b []byte, n int) string { if len(b) <= n { return string(b) } - return string(b[:n]) + "…" -} - -func joinErrs(ss []string) string { - if len(ss) == 0 { - return "" + cut := n + for cut > 0 && !utf8.RuneStart(b[cut]) { + cut-- } - out := ss[0] - for _, s := range ss[1:] { - out += "; " + s - } - return out + return string(b[:cut]) + "…" } diff --git a/internal/github/profile.go b/internal/github/profile.go index 075c3e9e..5d432186 100644 --- a/internal/github/profile.go +++ b/internal/github/profile.go @@ -1,6 +1,7 @@ package github import ( + "context" "errors" "sort" "time" @@ -58,8 +59,10 @@ type profileGQL struct { } // 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. +// Zero value excludes both forks and private repos; the CLI flips both to +// true by default (private is a no-op when the token lacks repo scope, so +// it's safe to opt in). Callers using FetchOptions{} literal get the +// conservative behavior regardless of CLI defaults. type FetchOptions struct { IncludeForks bool IncludePrivate bool @@ -68,7 +71,7 @@ type FetchOptions struct { // 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. Forks and private repos are filtered client-side per opts. -func (c *Client) FetchProfile(login string, opts FetchOptions) (*Profile, error) { +func (c *Client) FetchProfile(ctx context.Context, login string, opts FetchOptions) (*Profile, error) { if login == "" { return nil, errors.New("empty user") } @@ -87,7 +90,7 @@ func (c *Client) FetchProfile(login string, opts FetchOptions) (*Profile, error) } var resp profileGQL - if err := c.query(profileQuery, vars, &resp); err != nil { + if err := c.query(ctx, profileQuery, vars, &resp); err != nil { return nil, err } if resp.User == nil { @@ -115,7 +118,7 @@ func (c *Client) FetchProfile(login string, opts FetchOptions) (*Profile, error) cc := u.ContributionsCollection p.TotalCommits = cc.TotalCommitContributions p.TotalReviews = cc.TotalPullRequestReviewContributions - p.TotalContributions = cc.ContributionCalendar.TotalContributions + cc.RestrictedContributionsCount + p.TotalContributionsLastYear = cc.ContributionCalendar.TotalContributions + cc.RestrictedContributionsCount p.ContributionYears = append([]int(nil), cc.ContributionYears...) // Flatten week → day into a linear daily series sorted by date.