mirror of
https://github.com/tiennm99/ghglance.git
synced 2026-10-11 03:13:20 +00:00
chore: small hygiene fixes from code review
- I3 — update FetchOptions doc to describe zero-value vs CLI-flag defaults. - I5 — release workflow gates docker/binaries on a test job; tags no longer ship broken artifacts. - N1 — replace handwritten joinErrs with strings.Join. - N3 — truncate() now backs up to a UTF-8 rune boundary so error messages never end on a split codepoint. - N4 — pin Docker base images (golang:1.26-alpine, alpine:3.21) to SHA256 digests. - N5 — pin third-party GitHub Actions to commit SHAs with version comments for readability. - N9 — drop the "(non-fork)" qualifier from the stats card label; the underlying GraphQL doesn't actually filter forks, so the phrasing was misleading.
This commit is contained in:
1 parent
d3fc27f33f
commit
8a6a241160
6 files changed
+151
-68
No files matched your search
@@ -9,8 +9,8 @@ jobs:
|
|||||||
build:
|
build:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.26"
|
go-version: "1.26"
|
||||||
cache: true
|
cache: true
|
||||||
|
|||||||
@@ -10,19 +10,31 @@ permissions:
|
|||||||
packages: write
|
packages: write
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
docker:
|
test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
- uses: docker/setup-buildx-action@v3
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
- uses: docker/login-action@v3
|
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:
|
with:
|
||||||
registry: ghcr.io
|
registry: ghcr.io
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Resolve tag metadata
|
- name: Resolve tag metadata
|
||||||
id: meta
|
id: meta
|
||||||
uses: docker/metadata-action@v5
|
uses: docker/metadata-action@c299e40c65443455700f0fdfc63efafe5b349051 # v5
|
||||||
with:
|
with:
|
||||||
images: ghcr.io/${{ github.repository }}
|
images: ghcr.io/${{ github.repository }}
|
||||||
tags: |
|
tags: |
|
||||||
@@ -30,7 +42,7 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
type=semver,pattern={{major}}
|
type=semver,pattern={{major}}
|
||||||
type=raw,value=latest
|
type=raw,value=latest
|
||||||
- uses: docker/build-push-action@v6
|
- uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
push: true
|
push: true
|
||||||
@@ -38,6 +50,7 @@ jobs:
|
|||||||
labels: ${{ steps.meta.outputs.labels }}
|
labels: ${{ steps.meta.outputs.labels }}
|
||||||
|
|
||||||
binaries:
|
binaries:
|
||||||
|
needs: [test]
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
permissions:
|
permissions:
|
||||||
contents: write
|
contents: write
|
||||||
@@ -55,8 +68,8 @@ jobs:
|
|||||||
- goos: windows
|
- goos: windows
|
||||||
goarch: amd64
|
goarch: amd64
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v6
|
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6
|
||||||
- uses: actions/setup-go@v6
|
- uses: actions/setup-go@4a3601121dd01d1626a1e23e37211e3254c1c06c # v6
|
||||||
with:
|
with:
|
||||||
go-version: "1.26"
|
go-version: "1.26"
|
||||||
cache: true
|
cache: true
|
||||||
@@ -76,6 +89,6 @@ jobs:
|
|||||||
else
|
else
|
||||||
tar -czf "ghstats_${GOOS}_${GOARCH}.tar.gz" "$bin"
|
tar -czf "ghstats_${GOOS}_${GOARCH}.tar.gz" "$bin"
|
||||||
fi
|
fi
|
||||||
- uses: softprops/action-gh-release@v2
|
- uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
|
||||||
with:
|
with:
|
||||||
files: dist/ghstats_*.*
|
files: dist/ghstats_*.*
|
||||||
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
FROM golang:1.26-alpine AS build
|
FROM golang:1.26-alpine@sha256:f85330846cde1e57ca9ec309382da3b8e6ae3ab943d2739500e08c86393a21b1 AS build
|
||||||
WORKDIR /src
|
WORKDIR /src
|
||||||
COPY go.mod ./
|
COPY go.mod ./
|
||||||
RUN go mod download || true
|
RUN go mod download || true
|
||||||
COPY . .
|
COPY . .
|
||||||
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/ghstats .
|
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
|
RUN apk add --no-cache ca-certificates tzdata git
|
||||||
COPY --from=build /out/ghstats /usr/local/bin/ghstats
|
COPY --from=build /out/ghstats /usr/local/bin/ghstats
|
||||||
COPY entrypoint.sh /entrypoint.sh
|
COPY entrypoint.sh /entrypoint.sh
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func (statsCard) SVG(p *github.Profile, t theme.Theme) ([]byte, error) {
|
|||||||
{iconPR, "Total PRs", formatInt(p.TotalPRs)},
|
{iconPR, "Total PRs", formatInt(p.TotalPRs)},
|
||||||
{iconIssue, "Total Issues", formatInt(p.TotalIssues)},
|
{iconIssue, "Total Issues", formatInt(p.TotalIssues)},
|
||||||
{iconReview, "Total PR Reviews", formatInt(p.TotalReviews)},
|
{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
|
var b strings.Builder
|
||||||
|
|||||||
+116
-49
@@ -3,11 +3,16 @@ package github
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
const endpoint = "https://api.github.com/graphql"
|
const endpoint = "https://api.github.com/graphql"
|
||||||
@@ -43,70 +48,132 @@ type gqlResponse struct {
|
|||||||
Errors []gqlError `json:"errors,omitempty"`
|
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.
|
// 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})
|
body, err := json.Marshal(gqlRequest{Query: q, Variables: vars})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("marshal request: %w", err)
|
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)
|
for attempt := 0; attempt < 2; attempt++ {
|
||||||
if err != nil {
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||||
return fmt.Errorf("http: %w", err)
|
if err != nil {
|
||||||
}
|
return fmt.Errorf("new request: %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)
|
|
||||||
}
|
}
|
||||||
return fmt.Errorf("graphql: %s", joinErrs(msgs))
|
req.Header.Set("Content-Type", "application/json")
|
||||||
}
|
req.Header.Set("User-Agent", "ghstats")
|
||||||
if out != nil {
|
if c.token != "" {
|
||||||
if err := json.Unmarshal(r.Data, out); err != nil {
|
req.Header.Set("Authorization", "bearer "+c.token)
|
||||||
return fmt.Errorf("decode data: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 {
|
func truncate(b []byte, n int) string {
|
||||||
if len(b) <= n {
|
if len(b) <= n {
|
||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
return string(b[:n]) + "…"
|
cut := n
|
||||||
}
|
for cut > 0 && !utf8.RuneStart(b[cut]) {
|
||||||
|
cut--
|
||||||
func joinErrs(ss []string) string {
|
|
||||||
if len(ss) == 0 {
|
|
||||||
return ""
|
|
||||||
}
|
}
|
||||||
out := ss[0]
|
return string(b[:cut]) + "…"
|
||||||
for _, s := range ss[1:] {
|
|
||||||
out += "; " + s
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
package github
|
package github
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"sort"
|
"sort"
|
||||||
"time"
|
"time"
|
||||||
@@ -58,8 +59,10 @@ type profileGQL struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// FetchOptions tunes which repos contribute to the profile's aggregates.
|
// FetchOptions tunes which repos contribute to the profile's aggregates.
|
||||||
// All defaults are conservative (no forks, no private) so public-facing
|
// Zero value excludes both forks and private repos; the CLI flips both to
|
||||||
// READMEs don't accidentally leak work-repo signal.
|
// 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 {
|
type FetchOptions struct {
|
||||||
IncludeForks bool
|
IncludeForks bool
|
||||||
IncludePrivate bool
|
IncludePrivate bool
|
||||||
@@ -68,7 +71,7 @@ type FetchOptions struct {
|
|||||||
// FetchProfile collects profile, stats and repos-per-language data for the
|
// 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
|
// 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.
|
// 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 == "" {
|
if login == "" {
|
||||||
return nil, errors.New("empty user")
|
return nil, errors.New("empty user")
|
||||||
}
|
}
|
||||||
@@ -87,7 +90,7 @@ func (c *Client) FetchProfile(login string, opts FetchOptions) (*Profile, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
var resp profileGQL
|
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
|
return nil, err
|
||||||
}
|
}
|
||||||
if resp.User == nil {
|
if resp.User == nil {
|
||||||
@@ -115,7 +118,7 @@ func (c *Client) FetchProfile(login string, opts FetchOptions) (*Profile, error)
|
|||||||
cc := u.ContributionsCollection
|
cc := u.ContributionsCollection
|
||||||
p.TotalCommits = cc.TotalCommitContributions
|
p.TotalCommits = cc.TotalCommitContributions
|
||||||
p.TotalReviews = cc.TotalPullRequestReviewContributions
|
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...)
|
p.ContributionYears = append([]int(nil), cc.ContributionYears...)
|
||||||
|
|
||||||
// Flatten week → day into a linear daily series sorted by date.
|
// Flatten week → day into a linear daily series sorted by date.
|
||||||
|
|||||||
Reference in new issue
Block a user