diff --git a/internal/scorer/scorer.go b/internal/scorer/scorer.go new file mode 100644 index 0000000..c91aaf1 --- /dev/null +++ b/internal/scorer/scorer.go @@ -0,0 +1,133 @@ +package scorer + +import ( + "fmt" + "strings" + "time" + + "github.com/veggiemonk/awesome-docker/internal/cache" + "github.com/veggiemonk/awesome-docker/internal/checker" +) + +// Status represents the health status of an entry. +type Status string + +const ( + StatusHealthy Status = "healthy" + StatusInactive Status = "inactive" // 1-2 years since last push + StatusStale Status = "stale" // 2+ years since last push + StatusArchived Status = "archived" + StatusDead Status = "dead" // disabled or 404 +) + +// ScoredEntry is a repo with its computed health status. +type ScoredEntry struct { + URL string + Name string + Status Status + Stars int + Forks int + LastPush time.Time +} + +// Score computes the health status of a GitHub repo. +func Score(info checker.RepoInfo) Status { + if info.IsDisabled { + return StatusDead + } + if info.IsArchived { + return StatusArchived + } + + twoYearsAgo := time.Now().AddDate(-2, 0, 0) + oneYearAgo := time.Now().AddDate(-1, 0, 0) + + if info.PushedAt.Before(twoYearsAgo) { + return StatusStale + } + if info.PushedAt.Before(oneYearAgo) { + return StatusInactive + } + return StatusHealthy +} + +// ScoreAll scores a batch of repo infos. +func ScoreAll(infos []checker.RepoInfo) []ScoredEntry { + results := make([]ScoredEntry, len(infos)) + for i, info := range infos { + results[i] = ScoredEntry{ + URL: info.URL, + Name: fmt.Sprintf("%s/%s", info.Owner, info.Name), + Status: Score(info), + Stars: info.Stars, + Forks: info.Forks, + LastPush: info.PushedAt, + } + } + return results +} + +// ToCacheEntries converts scored entries to cache format. +func ToCacheEntries(scored []ScoredEntry) []cache.HealthEntry { + entries := make([]cache.HealthEntry, len(scored)) + now := time.Now().UTC() + for i, s := range scored { + entries[i] = cache.HealthEntry{ + URL: s.URL, + Name: s.Name, + Status: string(s.Status), + Stars: s.Stars, + Forks: s.Forks, + LastPush: s.LastPush, + CheckedAt: now, + } + } + return entries +} + +// GenerateReport produces a Markdown health report. +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) + } + + 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, "## 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])) + + writeSection := func(title string, status Status, limit int) { + 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] { + 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) + + return b.String() +} diff --git a/internal/scorer/scorer_test.go b/internal/scorer/scorer_test.go new file mode 100644 index 0000000..1aab44a --- /dev/null +++ b/internal/scorer/scorer_test.go @@ -0,0 +1,100 @@ +package scorer + +import ( + "strings" + "testing" + "time" + + "github.com/veggiemonk/awesome-docker/internal/checker" +) + +func TestScoreHealthy(t *testing.T) { + info := checker.RepoInfo{ + PushedAt: time.Now().AddDate(0, -3, 0), + IsArchived: false, + Stars: 100, + HasLicense: true, + } + status := Score(info) + if status != StatusHealthy { + t.Errorf("status = %q, want %q", status, StatusHealthy) + } +} + +func TestScoreInactive(t *testing.T) { + info := checker.RepoInfo{ + PushedAt: time.Now().AddDate(-1, -6, 0), + IsArchived: false, + } + status := Score(info) + if status != StatusInactive { + t.Errorf("status = %q, want %q", status, StatusInactive) + } +} + +func TestScoreStale(t *testing.T) { + info := checker.RepoInfo{ + PushedAt: time.Now().AddDate(-3, 0, 0), + IsArchived: false, + } + status := Score(info) + if status != StatusStale { + t.Errorf("status = %q, want %q", status, StatusStale) + } +} + +func TestScoreArchived(t *testing.T) { + info := checker.RepoInfo{ + PushedAt: time.Now(), + IsArchived: true, + } + status := Score(info) + if status != StatusArchived { + t.Errorf("status = %q, want %q", status, StatusArchived) + } +} + +func TestScoreDisabled(t *testing.T) { + info := checker.RepoInfo{ + IsDisabled: true, + } + status := Score(info) + if status != StatusDead { + t.Errorf("status = %q, want %q", status, StatusDead) + } +} + +func TestGenerateReport(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: StatusArchived, Stars: 50, LastPush: time.Now()}, + {URL: "https://github.com/c/c", Name: "c/c", Status: StatusStale, Stars: 10, LastPush: time.Now().AddDate(-3, 0, 0)}, + } + report := GenerateReport(results) + if !strings.Contains(report, "Healthy: 1") { + t.Error("report should contain 'Healthy: 1'") + } + if !strings.Contains(report, "Archived: 1") { + t.Error("report should contain 'Archived: 1'") + } + if !strings.Contains(report, "Stale") { + t.Error("report should contain 'Stale'") + } +} + +func TestScoreAll(t *testing.T) { + infos := []checker.RepoInfo{ + {Owner: "a", Name: "a", PushedAt: time.Now(), Stars: 10}, + {Owner: "b", Name: "b", PushedAt: time.Now().AddDate(-3, 0, 0), Stars: 5}, + } + scored := ScoreAll(infos) + if len(scored) != 2 { + t.Fatalf("scored = %d, want 2", len(scored)) + } + if scored[0].Status != StatusHealthy { + t.Errorf("first = %q, want healthy", scored[0].Status) + } + if scored[1].Status != StatusStale { + t.Errorf("second = %q, want stale", scored[1].Status) + } +}