diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d8ee232..4b9353a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,9 +34,9 @@ jobs: run: | echo "Building gh-oss-stats ..." mkdir -p dist - GOOS=linux GOARCH=amd64 go build -o dist/gh-oss-stats-linux-amd64 ./cmd/gh-oss-stats/*.go - GOOS=linux GOARCH=arm64 go build -o dist/gh-oss-stats-linux-arm64 ./cmd/gh-oss-stats/*.go - GOOS=darwin GOARCH=arm64 go build -o dist/gh-oss-stats-darwin-arm64 ./cmd/gh-oss-stats/*.go + GOOS=linux GOARCH=amd64 go build -o dist/gh-oss-stats-linux-amd64 -ldflags="-X github.com/mabd-dev/gh-oss-stats/internal/analytics.mixpanelToken=${{ secrets.MIXPANEL_TOKEN }}" ./cmd/gh-oss-stats + GOOS=linux GOARCH=arm64 go build -o dist/gh-oss-stats-linux-arm64 -ldflags="-X github.com/mabd-dev/gh-oss-stats/internal/analytics.mixpanelToken=${{ secrets.MIXPANEL_TOKEN }}" ./cmd/gh-oss-stats + GOOS=darwin GOARCH=arm64 go build -o dist/gh-oss-stats-darwin-arm64 -ldflags="-X github.com/mabd-dev/gh-oss-stats/internal/analytics.mixpanelToken=${{ secrets.MIXPANEL_TOKEN }}" ./cmd/gh-oss-stats - name: Trigger pkg.go.dev indexing run: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1874bac..473b231 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,10 +14,6 @@ jobs: name: Run Tests runs-on: ubuntu-latest - strategy: - matrix: - go-version: ['1.23', '1.24', '1.25'] - steps: - name: Checkout code uses: actions/checkout@v4 @@ -25,7 +21,7 @@ jobs: - name: Set up Go uses: actions/setup-go@v5 with: - go-version: ${{ matrix.go-version }} + go-version: 1.25 cache: true - name: Download dependencies @@ -59,9 +55,9 @@ jobs: run: | echo "Building gh-oss-stats ..." mkdir -p dist - GOOS=linux GOARCH=amd64 go build -o dist/gh-oss-stats-linux-amd64 ./cmd/gh-oss-stats/*.go - GOOS=linux GOARCH=arm64 go build -o dist/gh-oss-stats-linux-arm64 ./cmd/gh-oss-stats/*.go - GOOS=darwin GOARCH=arm64 go build -o dist/gh-oss-stats-darwin-arm64 ./cmd/gh-oss-stats/*.go + GOOS=linux GOARCH=amd64 go build -o dist/gh-oss-stats-linux-amd64 -ldflags="-X github.com/mabd-dev/gh-oss-stats/internal/analytics.mixpanelToken=${{ secrets.MIXPANEL_TOKEN }}" ./cmd/gh-oss-stats + GOOS=linux GOARCH=arm64 go build -o dist/gh-oss-stats-linux-arm64 -ldflags="-X github.com/mabd-dev/gh-oss-stats/internal/analytics.mixpanelToken=${{ secrets.MIXPANEL_TOKEN }}" ./cmd/gh-oss-stats + GOOS=darwin GOARCH=arm64 go build -o dist/gh-oss-stats-darwin-arm64 -ldflags="-X github.com/mabd-dev/gh-oss-stats/internal/analytics.mixpanelToken=${{ secrets.MIXPANEL_TOKEN }}" ./cmd/gh-oss-stats - name: Verify binaries run: | diff --git a/.gitignore b/.gitignore index 005c643..a0acbbf 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .claude .serena .mcp.json +claude.local.md commands.nu /gh-oss-stats scripts/ diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index 8edb248..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,189 +0,0 @@ -# CLAUDE.md - -> This file provides context for Claude Code CLI when working on this project. - -## Project Overview - -**gh-oss-stats** is a Go library + CLI tool that fetches a GitHub user's open source contributions to external repositories (repos they don't own). It outputs structured JSON for consumption by other tools (websites, badge services, etc.). - -**Organization:** `mabd-dev` -**Repository:** `github.com/mabd-dev/gh-oss-stats` -**Go Version:** 1.21+ - -## Architecture - -``` -gh-oss-stats/ -├── cmd/gh-oss-stats/main.go # CLI entry point (thin wrapper) -├── pkg/ossstats/ # PUBLIC API - importable by external projects -│ ├── client.go # Client struct + New() constructor -│ ├── contributions.go # Core logic: GetContributions() -│ ├── types.go # All exported types (Stats, Contribution, etc.) -│ └── options.go # Functional options (WithToken, WithLOC, etc.) -├── internal/github/ # PRIVATE - GitHub API implementation -│ ├── api.go # HTTP client, request helpers -│ ├── ratelimit.go # Rate limit handling, backoff -│ └── types.go # API response structs (internal only) -├── go.mod -└── README.md -``` - -**Key Principle:** Library-first design. All logic lives in `pkg/ossstats/`. The CLI in `cmd/` is just a thin wrapper that parses flags and calls the library. - -## Core Types - -```go -// pkg/ossstats/types.go - -type Stats struct { - Username string `json:"username"` - GeneratedAt time.Time `json:"generated_at"` - Summary Summary `json:"summary"` - Contributions []Contribution `json:"contributions"` -} - -type Summary struct { - TotalProjects int `json:"total_projects"` - TotalPRsMerged int `json:"total_prs_merged"` - TotalCommits int `json:"total_commits"` - TotalAdditions int `json:"total_additions"` - TotalDeletions int `json:"total_deletions"` -} - -type Contribution struct { - Repo string `json:"repo"` - Owner string `json:"owner"` - RepoName string `json:"repo_name"` - Description string `json:"description"` - RepoURL string `json:"repo_url"` - Stars int `json:"stars"` - PRsMerged int `json:"prs_merged"` - Commits int `json:"commits"` - Additions int `json:"additions"` - Deletions int `json:"deletions"` - FirstContribution time.Time `json:"first_contribution"` - LastContribution time.Time `json:"last_contribution"` -} -``` - -## GitHub API Strategy - -**Step 1: Find merged PRs to external repos** -``` -GET /search/issues?q=author:{username}+type:pr+is:merged+-user:{username}&per_page=100 -``` -*Note: Organizations can be excluded by appending `-org:{orgname}` to the query for each excluded org.* - -**Step 2: Get PR details (commits, additions, deletions)** -``` -GET /repos/{owner}/{repo}/pulls/{pull_number} -``` - -**Step 3: Get repo metadata (stars, description)** -``` -GET /repos/{owner}/{repo} -``` - -## Functional Options Pattern - -Always use this pattern for client configuration: - -```go -client := ossstats.New( - ossstats.WithToken(token), - ossstats.WithLOC(true), - ossstats.WithMinStars(100), - ossstats.WithExcludeOrgs([]string{"my-org", "my-company"}), -) -``` - -Required options to implement: -- `WithToken(string)` - GitHub PAT (required for reasonable rate limits) -- `WithLOC(bool)` - Include lines of code metrics (default: true) -- `WithPRDetails(bool)` - Include detailed PR list (default: false) -- `WithMinStars(int)` - Filter repos by minimum stars (default: 0) -- `WithMaxPRs(int)` - Limit PRs fetched (default: 500) -- `WithExcludeOrgs([]string)` - Exclude organizations from the report (default: none) -- `WithTimeout(time.Duration)` - Overall timeout (default: 5m) -- `WithLogger(Logger)` - Custom logger interface -- `WithHTTPClient(*http.Client)` - Custom HTTP client - -## CLI Flags - -``` ---user, -u string GitHub username (required) ---token, -t string GitHub token (default: $GITHUB_TOKEN) ---include-loc bool Include LOC metrics (default: true) ---include-prs bool Include PR details (default: false) ---min-stars int Minimum repo stars (default: 0) ---max-prs int Max PRs to fetch (default: 500) ---exclude-orgs string Comma-separated list of organizations to exclude ---output, -o string Output file (default: stdout) ---verbose, -v bool Verbose logging to stderr ---timeout duration Timeout (default: 5m) ---version bool Print version -``` - -## Rate Limit Handling - -GitHub limits: -- Core API: 5,000/hour (authenticated) -- Search API: 30/minute (authenticated) - -Implementation requirements: -1. Check `X-RateLimit-Remaining` and `X-RateLimit-Reset` headers -2. Exponential backoff on 429 responses -3. 2-second delay between search API calls -4. Return partial results with `ErrPartialResults` if rate limited mid-fetch - -## Error Types - -```go -type ErrRateLimited struct { ResetAt time.Time; Message string } -type ErrAuthentication struct { Message string } -type ErrNotFound struct { Username string } -type ErrPartialResults struct { Stats *Stats; Errors []error; Message string } -``` - -## Commands - -```bash -# Run tests -go test ./... - -# Build CLI -go build -o gh-oss-stats ./cmd/gh-oss-stats - -# Install locally -go install ./cmd/gh-oss-stats - -# Run CLI -./gh-oss-stats --user mabd-dev --token $GITHUB_TOKEN - -# Lint (if golangci-lint installed) -golangci-lint run -``` - -## Implementation Notes - -1. **All API calls must accept `context.Context`** for cancellation/timeout -2. **No external dependencies** in `pkg/ossstats/` - stdlib only -3. **Pagination:** GitHub returns max 100 items per page; handle `Link` header -4. **Concurrency:** Consider parallel PR fetching with `errgroup` (limit to 5 concurrent) -5. **Timestamps:** Always UTC, use `time.RFC3339` for JSON -6. **Logging:** Use the `Logger` interface, never `fmt.Print` in library code - -## Testing - -- Unit tests: Mock HTTP responses using `httptest.Server` -- Test data: Store fixtures in `pkg/ossstats/testdata/` -- Integration tests: Tag with `//go:build integration` and skip without token - -## Do NOT - -- Add external dependencies without strong justification -- Put business logic in `cmd/` - it belongs in `pkg/ossstats/` -- Make unauthenticated requests by default (rate limits too restrictive) -- Ignore context cancellation -- Return zero values on error - always return descriptive errors -- Use `log.Fatal` or `os.Exit` in library code diff --git a/README.md b/README.md index 6f9a784..5fbeeee 100644 --- a/README.md +++ b/README.md @@ -100,7 +100,7 @@ Customize your badge by passing inputs to the action: with: github-token: ${{ secrets.GITHUB_TOKEN }} badge-path: 'images/oss-badge.svg' - badge-style: 'detailed' # summary, compact, or detailed + badge-style: 'detailed' # summary, compact, or detailed badge-theme: 'nord' # dark, light, nord, dracula, gruvbox-light, gruvbox-dark, etc... badge-variant: 'text-based' # default or text-based min-stars: '100' # Filter repos by minimum stars @@ -131,6 +131,31 @@ For local development or custom integrations, see [docs/TECHNICAL.md](docs/TECHN 📖 **Full technical documentation:** [docs/TECHNICAL.md](docs/TECHNICAL.md) +*** + +## Telemetry + +`gh-oss-stats` collects anonymous usage data to help understand how the tool +is used and improve it over time. You'll see a one-time notice about this on +first run. + +**What is collected:** +- `os` — operating system (linux, windows, darwin) +- `version` — tool version being used +- `ci` — whether the tool is running in a CI environment + +Nothing personal is collected — no usernames, tokens, or file paths. +Events are sent to a [mixpanel](https://mixpanel.com/home/) (a third-party analytics service) and visible only to the maintainer. + +### Disable telemetry + +Add this to your shell config (`~/.zshrc` or `~/.bashrc`): +```sh +export GH_OSS_STATS_TELEMETRY_DISABLED=1 +``` + +*** + ## License See [LICENSE](LICENSE) file. diff --git a/cmd/gh-oss-stats/main.go b/cmd/gh-oss-stats/main.go index 9ba9678..5ec3b27 100644 --- a/cmd/gh-oss-stats/main.go +++ b/cmd/gh-oss-stats/main.go @@ -3,14 +3,17 @@ package main import ( "fmt" "os" + + "github.com/mabd-dev/gh-oss-stats/internal/telemetry" ) -const version = "0.3.2" +const version = "0.3.5" func main() { args := os.Args[1:] if len(args) == 0 { + telemetry.Send(version) runMainCmd(args) return } @@ -18,13 +21,16 @@ func main() { // Route to sub-commands, or fallback to main command switch args[0] { case "badge": + telemetry.Send(version) runBadgeCmd(args[1:]) case "demo": + telemetry.Send(version) runDemoCmd(args[1:]) case "version": fmt.Printf("gh-oss-stats v%s\n", version) os.Exit(0) default: + telemetry.Send(version) runMainCmd(args) } } diff --git a/docs/release-notes/v0.3.5.md b/docs/release-notes/v0.3.5.md new file mode 100644 index 0000000..fc672ff --- /dev/null +++ b/docs/release-notes/v0.3.5.md @@ -0,0 +1,15 @@ +# Release Notes: v0.3.5 + +Release Date: April XX, 2026 +Codename: + + +## ✨ Features + +- **NEW**: add anonymous usage telemetry via Mixpanel + +Collects anonymous usage data (os, version, ci) on each run. +Disable with GH_OSS_STATS_TELEMETRY_DISABLED=1. +See README for full details. + + diff --git a/go.mod b/go.mod index 29fa8d0..3a94ebb 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,8 @@ module github.com/mabd-dev/gh-oss-stats go 1.25.4 + +require ( + github.com/google/uuid v1.6.0 + github.com/mixpanel/mixpanel-go v1.2.1 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..679527f --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mixpanel/mixpanel-go v1.2.1 h1:iykbHKomTJjVoWU95Vt1sjZy4HLt8UOYacMEEEMFBok= +github.com/mixpanel/mixpanel-go v1.2.1/go.mod h1:mPGaNhBoZMJuLu8k7Y1KhU5n8Vw13rxQZZjHj+b9RLk= diff --git a/internal/analytics/analytics.go b/internal/analytics/analytics.go new file mode 100644 index 0000000..15fdbfe --- /dev/null +++ b/internal/analytics/analytics.go @@ -0,0 +1,39 @@ +package analytics + +import ( + "context" + + "github.com/mixpanel/mixpanel-go" +) + +var mixpanelToken = "" + +type Analytics struct { + userUUID string + Client *mixpanel.ApiClient +} + +func CreateAnalytics(userUUID string) Analytics { + mixpanelClient := mixpanel.NewApiClient(mixpanelToken) + return Analytics{ + userUUID: userUUID, + Client: mixpanelClient, + } +} + +func (analytics Analytics) Track(name string, params map[string]any) error { + ctx := context.Background() + return analytics.Client.Track(ctx, []*mixpanel.Event{ + analytics.Client.NewEvent(name, analytics.userUUID, params), + }) +} + +func (analytics Analytics) TrackToolUsage(os string, version string, ci bool) error { + params := map[string]any{ + "os": os, + "tool-version": version, + "ci": ci, + "project": "gh-oss-stats", + } + return analytics.Track("usage", params) +} diff --git a/internal/telemetry/telemetry.go b/internal/telemetry/telemetry.go new file mode 100644 index 0000000..69e9359 --- /dev/null +++ b/internal/telemetry/telemetry.go @@ -0,0 +1,135 @@ +package telemetry + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "runtime" + + "github.com/google/uuid" + + analytics "github.com/mabd-dev/gh-oss-stats/internal/analytics" + "github.com/mabd-dev/gh-oss-stats/internal/utils" +) + +var ( + toolName = "gh-oss-stats" + telemetryFileName = "telemetry.json" +) + +type Telemetry struct { + NoticeShown bool `json:"noticeShown"` + UserUUID string `json:"userUUID"` +} + +func Send(version string) { + telemetryDisabled := os.Getenv("GH_OSS_STATS_TELEMETRY_DISABLED") + if telemetryDisabled == "1" { + return + } + + telemetry, err := readOrCreateTelemetry() + if err != nil { + return + } + + isCI := os.Getenv("CI") != "" || os.Getenv("GITHUB_ACTIONS") != "" + if isCI { + sendTrackUsageEvent(telemetry.UserUUID, version, true) + return + } + + if !telemetry.NoticeShown { + printNotice() + + telemetry.NoticeShown = true + storeTelemetry(*telemetry) + } + + sendTrackUsageEvent(telemetry.UserUUID, version, false) +} + +func readOrCreateTelemetry() (*Telemetry, error) { + t, err := readTelemetry() + if err != nil { + return nil, err + } + + if t != nil { + return t, nil + } + + // Create + save new telemetry file + + userUUID := uuid.New().String() + telemetry := Telemetry{ + NoticeShown: false, + UserUUID: userUUID, + } + if err := storeTelemetry(telemetry); err != nil { + return nil, err + } + return &telemetry, nil +} + +func readTelemetry() (*Telemetry, error) { + configDir, err := os.UserConfigDir() + if err != nil { + return nil, err + } + + telemetryPath := filepath.Join(configDir, toolName, telemetryFileName) + + exists, err := utils.FileExists(telemetryPath) + if err != nil { + return nil, err + } + if !exists { + return nil, nil + } + + data, err := os.ReadFile(telemetryPath) + if err != nil { + return nil, err + } + + var telemetry Telemetry + if err := json.Unmarshal(data, &telemetry); err != nil { + return nil, err + } + + return &telemetry, nil +} + +func storeTelemetry(t Telemetry) error { + jsonData, err := json.MarshalIndent(t, "", " ") + if err != nil { + return err + } + + configDir, err := os.UserConfigDir() + if err != nil { + return err + } + + dir := filepath.Join(configDir, toolName) + if err := os.MkdirAll(dir, 0755); err != nil { + return err + } + filePath := filepath.Join(dir, telemetryFileName) + + return utils.WriteToFile(jsonData, filePath) +} + +func printNotice() { + fmt.Println("gh-oss-stats collects anonymous usage telemetry to help improve the tool.") + fmt.Println("No personal data or GitHub credentials are collected.") + fmt.Println("To disable: export GH_OSS_STATS_TELEMETRY_DISABLED=1") + fmt.Println("More info: https://github.com/mabd-dev/gh-oss-stats#telemetry") +} + +func sendTrackUsageEvent(userUUID string, version string, isCI bool) error { + analytics := analytics.CreateAnalytics(userUUID) + return analytics.TrackToolUsage(runtime.GOOS, version, isCI) +} diff --git a/internal/utils/file.go b/internal/utils/file.go new file mode 100644 index 0000000..a2bee8a --- /dev/null +++ b/internal/utils/file.go @@ -0,0 +1,81 @@ +package utils + +import ( + "errors" + "os" + "path/filepath" + "strings" +) + +// FileExists checks if a file exists at the given path. +// Returns (true, nil) if the file exists, +// (false, nil) if it does not exist, +// or (false, err) if an error other than "not exist" occurs. +func FileExists(path string) (bool, error) { + _, err := os.Stat(path) + if err == nil { + // file already exists, do nothing + return true, nil + } + + if errors.Is(err, os.ErrNotExist) { + // file not found + return false, nil + } + + return false, err +} + +// DirExists checks if a directory exists at the given path. +// Returns (true, nil) if the directory exists, +// (false, nil) if it does not exist, +// or (false, err) if an error other than "not exist" occurs. +func DirExists(path string) (bool, error) { + info, err := os.Stat(path) + if err == nil { + return info.IsDir(), nil + } + if os.IsNotExist(err) { + return false, nil + } + return false, err +} + +// WriteToFile Write data to file and create all parent folders if needed +func WriteToFile(data []byte, path string) error { + path, err := expandPath(path) + if err != nil { + return err + } + + // Create parent directories if needed + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + + return os.WriteFile(path, data, 0o644) +} + +// expandPath expands a filesystem path that may start with '~' into an +// absolute path using the current user's home directory. +// +// Examples: +// +// expandPath("~/Documents/file.txt") -> "/Users/someone/Documents/file.txt" +// expandPath("/tmp/file.txt") -> "/tmp/file.txt" +// +// Only a leading '~' is expanded. If the path does not start with '~', +// it is returned unchanged. +// +// Returns the expanded absolute path or an error if the home directory +// cannot be determined. +func expandPath(path string) (string, error) { + if strings.HasPrefix(path, "~") { + home, err := os.UserHomeDir() + if err != nil { + return "", err + } + return filepath.Join(home, path[1:]), nil + } + return path, nil +}