feat: add health scorer with status classification and report generation
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
133
internal/scorer/scorer.go
Normal file
133
internal/scorer/scorer.go
Normal file
@@ -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()
|
||||
}
|
||||
100
internal/scorer/scorer_test.go
Normal file
100
internal/scorer/scorer_test.go
Normal file
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user