diff --git a/cmd/awesome-docker/main.go b/cmd/awesome-docker/main.go index e9267ab..254028d 100644 --- a/cmd/awesome-docker/main.go +++ b/cmd/awesome-docker/main.go @@ -240,7 +240,8 @@ func buildCmd() *cobra.Command { } func reportCmd() *cobra.Command { - return &cobra.Command{ + var jsonOutput bool + cmd := &cobra.Command{ Use: "report", Short: "Generate health report from cache", RunE: func(cmd *cobra.Command, args []string) error { @@ -263,11 +264,23 @@ func reportCmd() *cobra.Command { }) } + if jsonOutput { + payload, err := scorer.GenerateJSONReport(scored) + if err != nil { + return fmt.Errorf("json report: %w", err) + } + fmt.Println(string(payload)) + return nil + } + report := scorer.GenerateReport(scored) fmt.Print(report) return nil }, } + + cmd.Flags().BoolVar(&jsonOutput, "json", false, "Output full health report as JSON") + return cmd } func validateCmd() *cobra.Command { diff --git a/go.mod b/go.mod index b72fbff..6a38142 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/veggiemonk/awesome-docker -go 1.25.0 +go 1.26.0 require github.com/spf13/cobra v1.10.2 diff --git a/internal/scorer/scorer.go b/internal/scorer/scorer.go index c91aaf1..f2f34d6 100644 --- a/internal/scorer/scorer.go +++ b/internal/scorer/scorer.go @@ -1,6 +1,7 @@ package scorer import ( + "encoding/json" "fmt" "strings" "time" @@ -30,6 +31,24 @@ type ScoredEntry struct { LastPush time.Time } +// ReportSummary contains grouped status counts. +type ReportSummary struct { + Healthy int `json:"healthy"` + Inactive int `json:"inactive"` + Stale int `json:"stale"` + Archived int `json:"archived"` + Dead int `json:"dead"` +} + +// ReportData is the full machine-readable report model. +type ReportData struct { + GeneratedAt time.Time `json:"generated_at"` + Total int `json:"total"` + Summary ReportSummary `json:"summary"` + Entries []ScoredEntry `json:"entries"` + ByStatus map[Status][]ScoredEntry `json:"by_status"` +} + // Score computes the health status of a GitHub repo. func Score(info checker.RepoInfo) Status { if info.IsDisabled { @@ -89,45 +108,64 @@ func ToCacheEntries(scored []ScoredEntry) []cache.HealthEntry { func GenerateReport(scored []ScoredEntry) string { var b strings.Builder - groups := map[Status][]ScoredEntry{} - for _, s := range scored { - groups[s.Status] = append(groups[s.Status], s) - } + data := BuildReportData(scored) + groups := data.ByStatus fmt.Fprintf(&b, "# Health Report\n\n") - fmt.Fprintf(&b, "**Generated:** %s\n\n", time.Now().UTC().Format(time.RFC3339)) - fmt.Fprintf(&b, "**Total:** %d repositories\n\n", len(scored)) + fmt.Fprintf(&b, "**Generated:** %s\n\n", data.GeneratedAt.Format(time.RFC3339)) + fmt.Fprintf(&b, "**Total:** %d repositories\n\n", data.Total) fmt.Fprintf(&b, "## Summary\n\n") - fmt.Fprintf(&b, "- Healthy: %d\n", len(groups[StatusHealthy])) - fmt.Fprintf(&b, "- Inactive (1-2 years): %d\n", len(groups[StatusInactive])) - fmt.Fprintf(&b, "- Stale (2+ years): %d\n", len(groups[StatusStale])) - fmt.Fprintf(&b, "- Archived: %d\n", len(groups[StatusArchived])) - fmt.Fprintf(&b, "- Dead: %d\n\n", len(groups[StatusDead])) + fmt.Fprintf(&b, "- Healthy: %d\n", data.Summary.Healthy) + fmt.Fprintf(&b, "- Inactive (1-2 years): %d\n", data.Summary.Inactive) + fmt.Fprintf(&b, "- Stale (2+ years): %d\n", data.Summary.Stale) + fmt.Fprintf(&b, "- Archived: %d\n", data.Summary.Archived) + fmt.Fprintf(&b, "- Dead: %d\n\n", data.Summary.Dead) - writeSection := func(title string, status Status, limit int) { + writeSection := func(title string, status Status) { entries := groups[status] if len(entries) == 0 { return } fmt.Fprintf(&b, "## %s\n\n", title) - count := len(entries) - if limit > 0 && count > limit { - count = limit - } - for _, e := range entries[:count] { + for _, e := range entries { fmt.Fprintf(&b, "- [%s](%s) - Stars: %d - Last push: %s\n", e.Name, e.URL, e.Stars, e.LastPush.Format("2006-01-02")) } - if len(entries) > count { - fmt.Fprintf(&b, "\n... and %d more\n", len(entries)-count) - } b.WriteString("\n") } - writeSection("Archived (should mark :skull:)", StatusArchived, 0) - writeSection("Stale (2+ years inactive)", StatusStale, 50) - writeSection("Inactive (1-2 years)", StatusInactive, 30) + writeSection("Archived (should mark :skull:)", StatusArchived) + writeSection("Stale (2+ years inactive)", StatusStale) + writeSection("Inactive (1-2 years)", StatusInactive) return b.String() } + +// BuildReportData returns full report data for machine-readable and markdown rendering. +func BuildReportData(scored []ScoredEntry) ReportData { + groups := map[Status][]ScoredEntry{} + for _, s := range scored { + groups[s.Status] = append(groups[s.Status], s) + } + + return ReportData{ + GeneratedAt: time.Now().UTC(), + Total: len(scored), + Summary: ReportSummary{ + Healthy: len(groups[StatusHealthy]), + Inactive: len(groups[StatusInactive]), + Stale: len(groups[StatusStale]), + Archived: len(groups[StatusArchived]), + Dead: len(groups[StatusDead]), + }, + Entries: scored, + ByStatus: groups, + } +} + +// GenerateJSONReport returns the full report as pretty-printed JSON. +func GenerateJSONReport(scored []ScoredEntry) ([]byte, error) { + data := BuildReportData(scored) + return json.MarshalIndent(data, "", " ") +} diff --git a/internal/scorer/scorer_test.go b/internal/scorer/scorer_test.go index 1aab44a..1d32515 100644 --- a/internal/scorer/scorer_test.go +++ b/internal/scorer/scorer_test.go @@ -1,6 +1,8 @@ package scorer import ( + "encoding/json" + "fmt" "strings" "testing" "time" @@ -82,6 +84,68 @@ func TestGenerateReport(t *testing.T) { } } +func TestGenerateReportShowsAllEntries(t *testing.T) { + var results []ScoredEntry + for i := 0; i < 55; i++ { + results = append(results, ScoredEntry{ + URL: fmt.Sprintf("https://github.com/stale/%d", i), + Name: fmt.Sprintf("stale/%d", i), + Status: StatusStale, + Stars: i, + LastPush: time.Now().AddDate(-3, 0, 0), + }) + } + + report := GenerateReport(results) + if strings.Contains(report, "... and") { + t.Fatal("report should not be truncated") + } + if !strings.Contains(report, "stale/54") { + t.Fatal("report should contain all entries") + } +} + +func TestGenerateJSONReport(t *testing.T) { + results := []ScoredEntry{ + { + URL: "https://github.com/a/a", + Name: "a/a", + Status: StatusHealthy, + Stars: 100, + LastPush: time.Now(), + }, + { + URL: "https://github.com/b/b", + Name: "b/b", + Status: StatusStale, + Stars: 50, + LastPush: time.Now().AddDate(-3, 0, 0), + }, + } + + data, err := GenerateJSONReport(results) + if err != nil { + t.Fatalf("GenerateJSONReport() error = %v", err) + } + + var report ReportData + if err := json.Unmarshal(data, &report); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if report.Total != 2 { + t.Fatalf("report.Total = %d, want 2", report.Total) + } + if report.Summary.Healthy != 1 || report.Summary.Stale != 1 { + t.Fatalf("summary = %+v, want healthy=1 stale=1", report.Summary) + } + if len(report.Entries) != 2 { + t.Fatalf("len(report.Entries) = %d, want 2", len(report.Entries)) + } + if len(report.ByStatus[StatusStale]) != 1 { + t.Fatalf("len(report.ByStatus[stale]) = %d, want 1", len(report.ByStatus[StatusStale])) + } +} + func TestScoreAll(t *testing.T) { infos := []checker.RepoInfo{ {Owner: "a", Name: "a", PushedAt: time.Now(), Stars: 10},