diff --git a/CLAUDE.md b/CLAUDE.md index 6ad3247..2d4bd73 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,8 @@ internal/ - **Clone strategy**: Shallow clone (depth 1, single branch, no tags) to temp dir, deleted after analysis. - **scc initialization**: `processor.ProcessConstants()` called via `sync.Once` since scc requires global initialization. - **AI estimation**: When `--ai-estimate` is used, a second pass fetches commit history via provider REST APIs. `provider.CommitLister` interface provides `ListCommits` and `CommitStats`. `aidetect.Detect` classifies commits, `aiestimate.Estimate` orchestrates per-repo. Results attach to existing report model as optional fields. -- **Health classification**: When `--health` is used, repos are classified as Active (<180d), Maintained (180-365d), or Abandoned (>365d) based on last commit date. `--health-details` adds deep analysis: per-window author counts, code churn, bus factor, and velocity trend. Uses the same `CommitLister` interface. +- **Health classification**: When `--health` is used, repos are classified as Active (<180d), Maintained (180-365d), or Abandoned (>365d) based on last commit date. Repos where commit history cannot be fetched (API errors, permissions) are classified as Failed with the error message stored in `RepoHealth.Error`. `--health-details` adds deep analysis: per-window author counts, code churn, bus factor, and velocity trend. Uses the same `CommitLister` interface. +- **Error logging**: API errors from health, health-details, AI estimation, and partial commit stat failures are collected and written to `.error.log` (derived from the report path, e.g. `report.error.log` for `report.json`) when any errors occur. Each line is prefixed with a category for easy filtering. `AnalyzeDetails` and `aiestimate.Estimate` return `(result, []string, error)` where `[]string` contains partial error messages. - **Vendor/generated filtering**: Always-on filtering using `go-enry` to skip vendor, generated, and binary files during analysis. `FilteredFiles` count is tracked per repo and in report totals. - **License detection**: After analysis, `license.Detect` scans the cloned repo directory for SPDX license identifiers (e.g., "MIT", "Apache-2.0"). Results appear in the per-repo License column. - **Code churn / hotspots**: Opt-in via `--churn` flag. Uses provider REST APIs to fetch per-file change data (`--churn-limit N` sets max commits, default 500). `churn.Analyze` collects per-file change frequencies; `churn.ComputeHotspots` ranks files by churn x complexity. Top 20 hotspots shown per repo. diff --git a/README.md b/README.md index c1f0f46..5edd513 100644 --- a/README.md +++ b/README.md @@ -111,12 +111,6 @@ export CODEMIUM_GITHUB_TOKEN=your_personal_access_token ``` This prompts for your token, verifies it against the GitLab API, and stores it at `~/.config/codemium/credentials.json`. -For self-hosted GitLab instances, set `CODEMIUM_GITLAB_URL`: -```bash -export CODEMIUM_GITLAB_URL=https://gitlab.example.com -codemium auth login --provider gitlab -``` - **Option 2: glab CLI** If you have the [GitLab CLI](https://gitlab.com/gitlab-org/cli) installed and authenticated, codemium can use its token automatically: @@ -183,9 +177,6 @@ codemium analyze --provider gitlab --group myorg/mysubgroup # Specific repos codemium analyze --provider gitlab --group mygroup --repos api,frontend - -# Self-hosted GitLab -CODEMIUM_GITLAB_URL=https://gitlab.example.com codemium analyze --provider gitlab --group mygroup ``` ### Analyze trends over time @@ -267,7 +258,7 @@ This is especially useful when Bitbucket project codes or repo naming convention ### Repository health classification -Classify repositories as Active, Maintained, or Abandoned based on last commit date: +Classify repositories as Active, Maintained, Abandoned, or Failed based on commit history: ```bash # Quick health check (1 API call per repo, no cloning) @@ -284,6 +275,9 @@ Health categories: - **Active**: last commit < 180 days ago - **Maintained**: 180–365 days ago - **Abandoned**: > 365 days ago +- **Failed**: commit history could not be fetched (API error, permissions, etc.) + +When API errors occur during health classification, AI estimation, or detailed analysis, an error log is automatically written next to the JSON report (e.g., `output/report.error.log` for `output/report.json`). Each line is prefixed with a category (`[health]`, `[health-details]`, `[ai-estimate]`, `[ai-estimate-detail]`) for easy filtering with `grep`. ### Additional flags diff --git a/cmd/codemium/main.go b/cmd/codemium/main.go index 67e9f43..03ec477 100644 --- a/cmd/codemium/main.go +++ b/cmd/codemium/main.go @@ -12,6 +12,7 @@ import ( "path/filepath" "sort" "strings" + "sync" "time" tea "github.com/charmbracelet/bubbletea" @@ -20,11 +21,11 @@ import ( "github.com/dsablic/codemium/internal/aiestimate" "github.com/dsablic/codemium/internal/analyzer" - "github.com/dsablic/codemium/internal/churn" - "github.com/dsablic/codemium/internal/license" "github.com/dsablic/codemium/internal/auth" + "github.com/dsablic/codemium/internal/churn" "github.com/dsablic/codemium/internal/health" "github.com/dsablic/codemium/internal/history" + "github.com/dsablic/codemium/internal/license" "github.com/dsablic/codemium/internal/model" "github.com/dsablic/codemium/internal/narrative" "github.com/dsablic/codemium/internal/output" @@ -39,6 +40,13 @@ var ( date = "unknown" ) +// errorEntry represents a diagnostic error for the error log. +type errorEntry struct { + Category string + Repo string + Message string +} + func main() { root := &cobra.Command{ Use: "codemium", @@ -441,6 +449,10 @@ func runAnalyze(cmd *cobra.Command, args []string) error { program = nil } + // Diagnostic error collection (written to error.log if non-empty) + var diagErrors []errorEntry + var diagMu sync.Mutex + // AI estimation phase aiEstimateFlag, _ := cmd.Flags().GetBool("ai-estimate") aiCommitLimit, _ := cmd.Flags().GetInt("ai-commit-limit") @@ -471,7 +483,14 @@ func runAnalyze(cmd *cobra.Command, args []string) error { } aiResults := worker.RunWithProgress(ctx, repoList, concurrency, func(ctx context.Context, repo model.Repo) (*model.RepoStats, error) { - est, err := aiestimate.Estimate(ctx, commitLister, repo, aiCommitLimit) + est, partialErrs, err := aiestimate.Estimate(ctx, commitLister, repo, aiCommitLimit) + if len(partialErrs) > 0 { + diagMu.Lock() + for _, pe := range partialErrs { + diagErrors = append(diagErrors, errorEntry{Category: "ai-estimate-detail", Repo: repo.Slug, Message: pe}) + } + diagMu.Unlock() + } if err != nil { return nil, err } @@ -491,7 +510,11 @@ func runAnalyze(cmd *cobra.Command, args []string) error { // Attach AI estimates to analysis results aiByRepo := make(map[string]*model.AIEstimate) for _, r := range aiResults { - if r.Err == nil && r.Stats != nil && r.Stats.AIEstimate != nil { + if r.Err != nil { + diagErrors = append(diagErrors, errorEntry{Category: "ai-estimate", Repo: r.Repo.Slug, Message: r.Err.Error()}) + continue + } + if r.Stats != nil && r.Stats.AIEstimate != nil { aiByRepo[r.Repo.Slug] = r.Stats.AIEstimate } } @@ -548,16 +571,40 @@ func runAnalyze(cmd *cobra.Command, args []string) error { healthResults := worker.RunWithProgress(ctx, repoList, concurrency, func(ctx context.Context, repo model.Repo) (*model.RepoStats, error) { commits, err := commitLister.ListCommits(ctx, repo, commitLimit) if err != nil { - return nil, err + diagMu.Lock() + diagErrors = append(diagErrors, errorEntry{Category: "health", Repo: repo.Slug, Message: err.Error()}) + diagMu.Unlock() + return &model.RepoStats{ + Repository: repo.Slug, + Health: &model.RepoHealth{ + Category: model.HealthFailed, + DaysSinceCommit: -1, + Error: err.Error(), + }, + }, nil } h := health.ClassifyFromCommits(commits, now) var details *model.RepoHealthDetails if healthDetailsFlag && len(commits) > 0 { - details, err = health.AnalyzeDetails(ctx, commitLister, repo, commits, now) + var partialErrs []string + details, partialErrs, err = health.AnalyzeDetails(ctx, commitLister, repo, commits, now) + if len(partialErrs) > 0 { + diagMu.Lock() + for _, pe := range partialErrs { + diagErrors = append(diagErrors, errorEntry{Category: "health-details", Repo: repo.Slug, Message: pe}) + } + diagMu.Unlock() + } if err != nil { - return nil, err + diagMu.Lock() + diagErrors = append(diagErrors, errorEntry{Category: "health-details", Repo: repo.Slug, Message: err.Error()}) + diagMu.Unlock() + return &model.RepoStats{ + Repository: repo.Slug, + Health: h, + }, nil } } @@ -648,6 +695,24 @@ func runAnalyze(cmd *cobra.Command, args []string) error { } } + // Write error.log if there were any diagnostic errors + if len(diagErrors) > 0 { + ext := filepath.Ext(outputPath) + errorLogPath := strings.TrimSuffix(outputPath, ext) + ".error.log" + if err := os.MkdirAll(filepath.Dir(errorLogPath), 0o755); err != nil { + return fmt.Errorf("create error log directory: %w", err) + } + f, err := os.Create(errorLogPath) + if err != nil { + return fmt.Errorf("create error log: %w", err) + } + for _, e := range diagErrors { + fmt.Fprintf(f, "[%s] %s | %s\n", e.Category, e.Repo, e.Message) + } + f.Close() + fmt.Fprintf(os.Stderr, "Error log written to %s (%d entries)\n", errorLogPath, len(diagErrors)) + } + // Build report — use user/group as organization in metadata when set reportOrg := org if user != "" { diff --git a/internal/aiestimate/estimate.go b/internal/aiestimate/estimate.go index 3dac743..c9c8a04 100644 --- a/internal/aiestimate/estimate.go +++ b/internal/aiestimate/estimate.go @@ -2,6 +2,7 @@ package aiestimate import ( "context" + "fmt" "sync" "github.com/dsablic/codemium/internal/aidetect" @@ -12,10 +13,11 @@ import ( const statsConcurrency = 10 // Estimate computes AI attribution metrics for a single repo. -func Estimate(ctx context.Context, cl provider.CommitLister, repo model.Repo, commitLimit int) (*model.AIEstimate, error) { +// It returns the estimate, a list of partial error messages (per-commit stat failures), and a fatal error. +func Estimate(ctx context.Context, cl provider.CommitLister, repo model.Repo, commitLimit int) (*model.AIEstimate, []string, error) { commits, err := cl.ListCommits(ctx, repo, commitLimit) if err != nil { - return nil, err + return nil, nil, err } est := &model.AIEstimate{ @@ -68,10 +70,12 @@ func Estimate(ctx context.Context, cl provider.CommitLister, repo model.Repo, co } wg.Wait() + var partialErrors []string for i, fc := range flagged { d := details[i] if d.err != nil { - continue // partial failure — skip this commit's stats + partialErrors = append(partialErrors, fmt.Sprintf("CommitStats %s: %v", fc.info.Hash, d.err)) + continue } est.AIAdditions += d.additions @@ -95,5 +99,5 @@ func Estimate(ctx context.Context, cl provider.CommitLister, repo model.Repo, co }) } - return est, nil + return est, partialErrors, nil } diff --git a/internal/aiestimate/estimate_test.go b/internal/aiestimate/estimate_test.go index 7805649..df8fa11 100644 --- a/internal/aiestimate/estimate_test.go +++ b/internal/aiestimate/estimate_test.go @@ -40,7 +40,7 @@ func TestEstimateBasic(t *testing.T) { } repo := model.Repo{Slug: "test-repo", URL: "https://github.com/org/test-repo"} - estimate, err := aiestimate.Estimate(context.Background(), mock, repo, 500) + estimate, _, err := aiestimate.Estimate(context.Background(), mock, repo, 500) if err != nil { t.Fatalf("Estimate: %v", err) } @@ -68,7 +68,7 @@ func TestEstimateNoAICommits(t *testing.T) { } repo := model.Repo{Slug: "test-repo", URL: "https://github.com/org/test-repo"} - estimate, err := aiestimate.Estimate(context.Background(), mock, repo, 500) + estimate, _, err := aiestimate.Estimate(context.Background(), mock, repo, 500) if err != nil { t.Fatalf("Estimate: %v", err) } @@ -95,7 +95,7 @@ func TestEstimatePercentCalculation(t *testing.T) { } repo := model.Repo{Slug: "r", URL: "https://github.com/o/r"} - est, err := aiestimate.Estimate(context.Background(), mock, repo, 500) + est, _, err := aiestimate.Estimate(context.Background(), mock, repo, 500) if err != nil { t.Fatalf("Estimate: %v", err) } @@ -116,7 +116,7 @@ func TestEstimateFirstLineMessage(t *testing.T) { } repo := model.Repo{Slug: "r", URL: "https://github.com/o/r"} - est, err := aiestimate.Estimate(context.Background(), mock, repo, 500) + est, _, err := aiestimate.Estimate(context.Background(), mock, repo, 500) if err != nil { t.Fatalf("Estimate: %v", err) } diff --git a/internal/auth/gitlab.go b/internal/auth/gitlab.go index 326195f..23b532b 100644 --- a/internal/auth/gitlab.go +++ b/internal/auth/gitlab.go @@ -9,7 +9,7 @@ import ( // GlabCLIToken attempts to get a GitLab token from the glab CLI tool. // Returns the token and true if successful, or empty string and false otherwise. func GlabCLIToken() (string, bool) { - out, err := exec.Command("glab", "auth", "token").Output() + out, err := exec.Command("glab", "config", "get", "token", "--host", "gitlab.com").Output() if err != nil { return "", false } diff --git a/internal/health/details.go b/internal/health/details.go index 6f7f062..c3b795d 100644 --- a/internal/health/details.go +++ b/internal/health/details.go @@ -3,6 +3,7 @@ package health import ( "context" + "fmt" "strings" "sync" "time" @@ -21,12 +22,13 @@ const ( ) // AnalyzeDetails performs deep health analysis on a repo's commits. -func AnalyzeDetails(ctx context.Context, lister provider.CommitLister, repo model.Repo, commits []provider.CommitInfo, now time.Time) (*model.RepoHealthDetails, error) { +// It returns the details, a list of partial error messages (e.g. per-commit stat failures), and a fatal error. +func AnalyzeDetails(ctx context.Context, lister provider.CommitLister, repo model.Repo, commits []provider.CommitInfo, now time.Time) (*model.RepoHealthDetails, []string, error) { if len(commits) == 0 { return &model.RepoHealthDetails{ AuthorsByWindow: map[string]int{}, ChurnByWindow: map[string]model.WindowChurnStats{}, - }, nil + }, nil, nil } sixMoAgo := now.AddDate(0, -6, 0) @@ -63,7 +65,7 @@ func AnalyzeDetails(ctx context.Context, lister provider.CommitLister, repo mode sem := make(chan struct{}, statsConcurrency) var wg sync.WaitGroup var mu sync.Mutex - var firstErr error + var partialErrors []string for i, c := range commits { if ctx.Err() != nil { @@ -78,9 +80,7 @@ func AnalyzeDetails(ctx context.Context, lister provider.CommitLister, repo mode adds, dels, err := lister.CommitStats(ctx, repo, hash) if err != nil { mu.Lock() - if firstErr == nil { - firstErr = err - } + partialErrors = append(partialErrors, fmt.Sprintf("CommitStats %s: %v", hash, err)) mu.Unlock() return } @@ -145,7 +145,7 @@ func AnalyzeDetails(ctx context.Context, lister provider.CommitLister, repo mode ChurnByWindow: churnByWindow, BusFactor: busFactor, VelocityTrend: velocityTrend, - }, nil + }, partialErrors, nil } func commitWindow(date time.Time, sixMoAgo, twelveMoAgo time.Time) string { diff --git a/internal/health/health.go b/internal/health/health.go index 5f5d4b5..05e3249 100644 --- a/internal/health/health.go +++ b/internal/health/health.go @@ -37,11 +37,13 @@ func Classify(lastCommitDate, now time.Time) *model.RepoHealth { } } -// ClassifyFromCommits classifies a repo based on a list of commits. -// It finds the most recent commit date. Returns nil if commits is empty. func ClassifyFromCommits(commits []provider.CommitInfo, now time.Time) *model.RepoHealth { if len(commits) == 0 { - return nil + return &model.RepoHealth{ + Category: model.HealthAbandoned, + LastCommitDate: "", + DaysSinceCommit: -1, + } } latest := commits[0].Date @@ -52,7 +54,11 @@ func ClassifyFromCommits(commits []provider.CommitInfo, now time.Time) *model.Re } if latest.IsZero() { - return nil + return &model.RepoHealth{ + Category: model.HealthAbandoned, + LastCommitDate: "", + DaysSinceCommit: -1, + } } return Classify(latest, now) diff --git a/internal/health/health_test.go b/internal/health/health_test.go index 938038f..25758bd 100644 --- a/internal/health/health_test.go +++ b/internal/health/health_test.go @@ -2,6 +2,8 @@ package health import ( "context" + "fmt" + "strings" "testing" "time" @@ -42,8 +44,14 @@ func TestClassifyBoundaries(t *testing.T) { func TestClassifyFromCommitsEmpty(t *testing.T) { now := time.Date(2026, 2, 23, 0, 0, 0, 0, time.UTC) result := ClassifyFromCommits(nil, now) - if result != nil { - t.Error("expected nil for empty commits") + if result == nil { + t.Fatal("expected non-nil result for empty commits") + } + if result.Category != model.HealthAbandoned { + t.Errorf("expected abandoned for empty commits, got %s", result.Category) + } + if result.DaysSinceCommit != -1 { + t.Errorf("expected -1 days for empty commits, got %d", result.DaysSinceCommit) } } @@ -107,6 +115,38 @@ func TestSummarizeMixed(t *testing.T) { } } +func TestSummarizeWithFailed(t *testing.T) { + repos := []model.RepoStats{ + { + Repository: "active-repo", + Totals: model.Stats{Code: 1000}, + Health: &model.RepoHealth{Category: model.HealthActive}, + }, + { + Repository: "failed-repo", + Totals: model.Stats{Code: 500}, + Health: &model.RepoHealth{Category: model.HealthFailed, DaysSinceCommit: -1}, + }, + } + + summary := Summarize(repos) + if summary == nil { + t.Fatal("expected non-nil summary") + } + if summary.Active.Repos != 1 { + t.Errorf("expected 1 active repo, got %d", summary.Active.Repos) + } + if summary.Failed.Repos != 1 { + t.Errorf("expected 1 failed repo, got %d", summary.Failed.Repos) + } + if summary.Failed.Code != 500 { + t.Errorf("expected 500 failed code, got %d", summary.Failed.Code) + } + if summary.Active.CodePercent != 100.0 { + t.Errorf("expected 100%% active code (failed excluded from total), got %.1f%%", summary.Active.CodePercent) + } +} + func TestSummarizeNoHealth(t *testing.T) { repos := []model.RepoStats{ {Repository: "repo-1", Totals: model.Stats{Code: 1000}}, @@ -119,9 +159,9 @@ func TestSummarizeNoHealth(t *testing.T) { // mockCommitLister implements provider.CommitLister for testing. type mockCommitLister struct { - commits []provider.CommitInfo - statsMap map[string][2]int64 // hash -> [additions, deletions] - statsErr error + commits []provider.CommitInfo + statsMap map[string][2]int64 // hash -> [additions, deletions] + statsErr error } func (m *mockCommitLister) ListCommits(_ context.Context, _ model.Repo, _ int) ([]provider.CommitInfo, error) { @@ -158,10 +198,13 @@ func TestAnalyzeDetails(t *testing.T) { } repo := model.Repo{Slug: "test-repo", URL: "https://github.com/org/test-repo"} - details, err := AnalyzeDetails(context.Background(), lister, repo, commits, now) + details, partialErrs, err := AnalyzeDetails(context.Background(), lister, repo, commits, now) if err != nil { t.Fatalf("AnalyzeDetails: %v", err) } + if len(partialErrs) > 0 { + t.Errorf("unexpected partial errors: %v", partialErrs) + } // 0-6mo: Alice, Bob (2 authors, 2 commits) if details.AuthorsByWindow[Window0to6] != 2 { @@ -207,7 +250,7 @@ func TestAnalyzeDetailsEmpty(t *testing.T) { lister := &mockCommitLister{} repo := model.Repo{Slug: "empty-repo"} - details, err := AnalyzeDetails(context.Background(), lister, repo, nil, now) + details, _, err := AnalyzeDetails(context.Background(), lister, repo, nil, now) if err != nil { t.Fatalf("AnalyzeDetails: %v", err) } @@ -218,3 +261,33 @@ func TestAnalyzeDetailsEmpty(t *testing.T) { t.Errorf("expected empty authors, got %v", details.AuthorsByWindow) } } + +func TestAnalyzeDetailsPartialErrors(t *testing.T) { + now := time.Date(2026, 2, 23, 0, 0, 0, 0, time.UTC) + commits := []provider.CommitInfo{ + {Hash: "a1", Author: "Alice ", Date: now.AddDate(0, -1, 0)}, + } + + lister := &mockCommitLister{ + commits: commits, + statsErr: fmt.Errorf("gitlab commit detail API returned status 403"), + } + + repo := model.Repo{Slug: "test-repo", URL: "https://github.com/org/test-repo"} + details, partialErrs, err := AnalyzeDetails(context.Background(), lister, repo, commits, now) + if err != nil { + t.Fatalf("AnalyzeDetails: %v", err) + } + if details == nil { + t.Fatal("expected non-nil details") + } + if len(partialErrs) != 1 { + t.Fatalf("expected 1 partial error, got %d", len(partialErrs)) + } + if !strings.Contains(partialErrs[0], "CommitStats a1") { + t.Errorf("expected partial error to reference hash a1, got %q", partialErrs[0]) + } + if !strings.Contains(partialErrs[0], "403") { + t.Errorf("expected partial error to contain status code, got %q", partialErrs[0]) + } +} diff --git a/internal/health/summary.go b/internal/health/summary.go index ff8e916..afc8c4d 100644 --- a/internal/health/summary.go +++ b/internal/health/summary.go @@ -17,18 +17,23 @@ func Summarize(repos []model.RepoStats) *model.HealthSummary { continue } hasHealth = true - totalCode += r.Totals.Code switch r.Health.Category { case model.HealthActive: summary.Active.Repos++ summary.Active.Code += r.Totals.Code + totalCode += r.Totals.Code case model.HealthMaintained: summary.Maintained.Repos++ summary.Maintained.Code += r.Totals.Code + totalCode += r.Totals.Code case model.HealthAbandoned: summary.Abandoned.Repos++ summary.Abandoned.Code += r.Totals.Code + totalCode += r.Totals.Code + case model.HealthFailed: + summary.Failed.Repos++ + summary.Failed.Code += r.Totals.Code } } diff --git a/internal/model/model.go b/internal/model/model.go index be32cd7..3d46c5b 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -114,6 +114,7 @@ const ( HealthActive HealthCategory = "active" HealthMaintained HealthCategory = "maintained" HealthAbandoned HealthCategory = "abandoned" + HealthFailed HealthCategory = "failed" ) // RepoHealth holds the health classification for a repository. @@ -121,6 +122,7 @@ type RepoHealth struct { Category HealthCategory `json:"category"` LastCommitDate string `json:"last_commit_date"` DaysSinceCommit int `json:"days_since_commit"` + Error string `json:"error,omitempty"` } // RepoHealthDetails holds deep health analysis for a repository. @@ -151,6 +153,7 @@ type HealthSummary struct { Active HealthCategorySummary `json:"active"` Maintained HealthCategorySummary `json:"maintained"` Abandoned HealthCategorySummary `json:"abandoned"` + Failed HealthCategorySummary `json:"failed"` } // Filters records what filters were applied to the analysis. diff --git a/internal/output/markdown.go b/internal/output/markdown.go index f11e4de..408ae1c 100644 --- a/internal/output/markdown.go +++ b/internal/output/markdown.go @@ -69,6 +69,10 @@ func WriteMarkdown(w io.Writer, report model.Report) error { report.HealthSummary.Maintained.Repos, report.HealthSummary.Maintained.Code, report.HealthSummary.Maintained.CodePercent) fmt.Fprintf(w, "| Abandoned (>365d) | %d | %d | %.1f%% |\n", report.HealthSummary.Abandoned.Repos, report.HealthSummary.Abandoned.Code, report.HealthSummary.Abandoned.CodePercent) + if report.HealthSummary.Failed.Repos > 0 { + fmt.Fprintf(w, "| Failed (error) | %d | %d | — |\n", + report.HealthSummary.Failed.Repos, report.HealthSummary.Failed.Code) + } fmt.Fprintln(w) } @@ -111,7 +115,14 @@ func WriteMarkdown(w io.Writer, report model.Report) error { if hasHealth { healthStr := "\u2014" if repo.Health != nil { - healthStr = fmt.Sprintf("%s (%dd)", capitalize(string(repo.Health.Category)), repo.Health.DaysSinceCommit) + switch { + case repo.Health.Category == model.HealthFailed: + healthStr = "Failed (error)" + case repo.Health.DaysSinceCommit >= 0: + healthStr = fmt.Sprintf("%s (%dd)", capitalize(string(repo.Health.Category)), repo.Health.DaysSinceCommit) + default: + healthStr = fmt.Sprintf("%s (no commits)", capitalize(string(repo.Health.Category))) + } } fmt.Fprintf(w, " | %s", healthStr) } diff --git a/internal/provider/gitlab.go b/internal/provider/gitlab.go index 8c85802..782c86c 100644 --- a/internal/provider/gitlab.go +++ b/internal/provider/gitlab.go @@ -52,8 +52,6 @@ type gitlabProject struct { } `json:"namespace"` } -// ListRepos fetches all projects in a GitLab group, handling -// pagination automatically. func (g *GitLab) ListRepos(ctx context.Context, opts ListOpts) ([]model.Repo, error) { var allRepos []model.Repo @@ -99,7 +97,7 @@ func (g *GitLab) doGet(ctx context.Context, reqURL string) (*http.Response, erro if err != nil { return nil, err } - req.Header.Set("PRIVATE-TOKEN", g.token) + req.Header.Set("Authorization", "Bearer "+g.token) return g.client.Do(req) } @@ -217,11 +215,11 @@ func gitlabProjectID(repoURL string) string { } type gitlabCommit struct { - ID string `json:"id"` - AuthorName string `json:"author_name"` - AuthorEmail string `json:"author_email"` - Message string `json:"message"` - CommittedDate string `json:"committed_date"` + ID string `json:"id"` + AuthorName string `json:"author_name"` + AuthorEmail string `json:"author_email"` + Message string `json:"message"` + CommittedDate string `json:"committed_date"` } // ListCommits fetches up to limit commits for a repo via the GitLab API. diff --git a/internal/provider/gitlab_test.go b/internal/provider/gitlab_test.go index 4959c85..f9df085 100644 --- a/internal/provider/gitlab_test.go +++ b/internal/provider/gitlab_test.go @@ -153,8 +153,8 @@ func TestGitLabExcludeSlugFilter(t *testing.T) { func TestGitLabAuthHeader(t *testing.T) { server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if got := r.Header.Get("PRIVATE-TOKEN"); got != "my-pat" { - t.Errorf("expected PRIVATE-TOKEN header my-pat, got %q", got) + if got := r.Header.Get("Authorization"); got != "Bearer my-pat" { + t.Errorf("expected Authorization header 'Bearer my-pat', got %q", got) } json.NewEncoder(w).Encode([]map[string]any{}) }))