mirror of
https://github.com/veggiemonk/awesome-docker.git
synced 2026-07-01 19:10:32 +02:00
feat: add prune subcommand, drop archived/stale entries (#1441)
* feat: add prune subcommand, drop archived/stale entries, add container-explorer Add a new `awesome-docker prune` subcommand that removes README entries whose repository health status matches a configurable set (default: archived,stale). URLs are read from the local health cache, or from a markdown report file via --from-report when the cache is outdated. Apply it against the issue #1439 health report to remove 5 entries that survived the recent reorg: stitchocker, docker-consul, blockbridge-docker-volume, docker-explorer, dockdash. Add google/container-explorer in the Security section as the actively maintained successor to the now-archived google/docker-explorer. Co-Authored-By: Claude <noreply@anthropic.com> * golangci-lint config * fix: address golangci-lint findings Fixes errcheck on bufio.Writer.WriteString, gocritic rangeValCopy via indexed loops with pointer locals, gosec G703 on user-supplied CLI output path, noctx by switching to exec.CommandContext with a timeout in the TUI url opener, prealloc in the scorer test, plus fieldalignment struct reorders and golines line breaks from --fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -2,8 +2,10 @@ package builder
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/yuin/goldmark"
|
||||
@@ -61,10 +63,13 @@ func Build(markdownPath, templatePath, outputPath string) error {
|
||||
}
|
||||
}
|
||||
if !replaced {
|
||||
return fmt.Errorf("template missing supported markdown placeholder")
|
||||
return errors.New("template missing supported markdown placeholder")
|
||||
}
|
||||
|
||||
if err := os.WriteFile(outputPath, []byte(output), 0o644); err != nil {
|
||||
// outputPath is a CLI-provided destination for a generated website file;
|
||||
// the user controls the destination by design, so the taint warning is expected.
|
||||
//nolint:gosec // G306,G304: user-supplied output path is intentional for this CLI
|
||||
if err := os.WriteFile(filepath.Clean(outputPath), []byte(output), 0o644); err != nil {
|
||||
return fmt.Errorf("write output: %w", err)
|
||||
}
|
||||
return nil
|
||||
|
||||
Vendored
+15
-13
@@ -38,17 +38,17 @@ func LoadExcludeList(path string) (*ExcludeList, error) {
|
||||
|
||||
// HealthEntry stores metadata about a single entry.
|
||||
type HealthEntry struct {
|
||||
URL string `yaml:"url"`
|
||||
Name string `yaml:"name"`
|
||||
Status string `yaml:"status"` // healthy, inactive, stale, archived, dead
|
||||
Stars int `yaml:"stars,omitempty"`
|
||||
Forks int `yaml:"forks,omitempty"`
|
||||
LastPush time.Time `yaml:"last_push,omitempty"`
|
||||
HasLicense bool `yaml:"has_license,omitempty"`
|
||||
HasReadme bool `yaml:"has_readme,omitempty"`
|
||||
LastPush time.Time `yaml:"last_push,omitempty"`
|
||||
CheckedAt time.Time `yaml:"checked_at"`
|
||||
URL string `yaml:"url"`
|
||||
Name string `yaml:"name"`
|
||||
Status string `yaml:"status"`
|
||||
Category string `yaml:"category,omitempty"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Stars int `yaml:"stars,omitempty"`
|
||||
Forks int `yaml:"forks,omitempty"`
|
||||
HasLicense bool `yaml:"has_license,omitempty"`
|
||||
HasReadme bool `yaml:"has_readme,omitempty"`
|
||||
}
|
||||
|
||||
// HealthCache is the full YAML cache file.
|
||||
@@ -84,15 +84,17 @@ func SaveHealthCache(path string, hc *HealthCache) error {
|
||||
// Merge updates the cache with new entries, replacing existing ones by URL.
|
||||
func (hc *HealthCache) Merge(entries []HealthEntry) {
|
||||
index := make(map[string]int)
|
||||
for i, e := range hc.Entries {
|
||||
for i := range hc.Entries {
|
||||
e := &hc.Entries[i]
|
||||
index[e.URL] = i
|
||||
}
|
||||
for _, e := range entries {
|
||||
if i, exists := index[e.URL]; exists {
|
||||
hc.Entries[i] = e
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
if j, exists := index[e.URL]; exists {
|
||||
hc.Entries[j] = *e
|
||||
} else {
|
||||
index[e.URL] = len(hc.Entries)
|
||||
hc.Entries = append(hc.Entries, e)
|
||||
hc.Entries = append(hc.Entries, *e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,15 +13,15 @@ import (
|
||||
|
||||
// RepoInfo holds metadata about a GitHub repository.
|
||||
type RepoInfo struct {
|
||||
PushedAt time.Time
|
||||
Owner string
|
||||
Name string
|
||||
URL string
|
||||
Stars int
|
||||
Forks int
|
||||
IsArchived bool
|
||||
IsDisabled bool
|
||||
IsPrivate bool
|
||||
PushedAt time.Time
|
||||
Stars int
|
||||
Forks int
|
||||
HasLicense bool
|
||||
}
|
||||
|
||||
@@ -104,19 +104,17 @@ func NewGitHubChecker(token string) *GitHubChecker {
|
||||
func (gc *GitHubChecker) CheckRepo(ctx context.Context, owner, name string) (RepoInfo, error) {
|
||||
var query struct {
|
||||
Repository struct {
|
||||
PushedAt time.Time
|
||||
LicenseInfo *struct{ Name string }
|
||||
StargazerCount int
|
||||
ForkCount int
|
||||
IsArchived bool
|
||||
IsDisabled bool
|
||||
IsPrivate bool
|
||||
PushedAt time.Time
|
||||
StargazerCount int
|
||||
ForkCount int
|
||||
LicenseInfo *struct {
|
||||
Name string
|
||||
}
|
||||
} `graphql:"repository(owner: $owner, name: $name)"`
|
||||
}
|
||||
|
||||
vars := map[string]interface{}{
|
||||
vars := map[string]any{
|
||||
"owner": githubv4.String(owner),
|
||||
"name": githubv4.String(name),
|
||||
}
|
||||
|
||||
@@ -34,7 +34,14 @@ func TestExtractGitHubRepo(t *testing.T) {
|
||||
}
|
||||
if ok {
|
||||
if owner != tt.owner || name != tt.name {
|
||||
t.Errorf("ExtractGitHubRepo(%q) = (%q, %q), want (%q, %q)", tt.url, owner, name, tt.owner, tt.name)
|
||||
t.Errorf(
|
||||
"ExtractGitHubRepo(%q) = (%q, %q), want (%q, %q)",
|
||||
tt.url,
|
||||
owner,
|
||||
name,
|
||||
tt.owner,
|
||||
tt.name,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,11 @@ const (
|
||||
// LinkResult holds the result of checking a single URL.
|
||||
type LinkResult struct {
|
||||
URL string
|
||||
OK bool
|
||||
StatusCode int
|
||||
Redirected bool
|
||||
RedirectURL string
|
||||
Error string
|
||||
StatusCode int
|
||||
OK bool
|
||||
Redirected bool
|
||||
}
|
||||
|
||||
func shouldFallbackToGET(statusCode int) bool {
|
||||
|
||||
@@ -15,7 +15,9 @@ import (
|
||||
// by [@author](url), by [@author][ref], by @author
|
||||
//
|
||||
// Also handles "Created by", "Maintained by" etc.
|
||||
var attributionRe = regexp.MustCompile(`\s+(?:(?:[Cc]reated|[Mm]aintained|[Bb]uilt)\s+)?by\s+\[@[^\]]+\](?:\([^)]*\)|\[[^\]]*\])\.?$`)
|
||||
var attributionRe = regexp.MustCompile(
|
||||
`\s+(?:(?:[Cc]reated|[Mm]aintained|[Bb]uilt)\s+)?by\s+\[@[^\]]+\](?:\([^)]*\)|\[[^\]]*\])\.?$`,
|
||||
)
|
||||
|
||||
// bareAttributionRe matches: by @author at end of line (no link).
|
||||
var bareAttributionRe = regexp.MustCompile(`\s+by\s+@\w+\.?$`)
|
||||
@@ -136,13 +138,19 @@ func FixFile(path string) (int, error) {
|
||||
|
||||
w := bufio.NewWriter(out)
|
||||
for i, line := range lines {
|
||||
w.WriteString(line)
|
||||
if _, err := w.WriteString(line); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if i < len(lines)-1 {
|
||||
w.WriteString("\n")
|
||||
if _, err := w.WriteString("\n"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
// Preserve trailing newline if original had one
|
||||
w.WriteString("\n")
|
||||
if _, err := w.WriteString("\n"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return fixCount, w.Flush()
|
||||
}
|
||||
|
||||
@@ -218,11 +226,17 @@ func SortFile(path string) (int, error) {
|
||||
|
||||
w := bufio.NewWriter(out)
|
||||
for i, line := range lines {
|
||||
w.WriteString(line)
|
||||
if _, err := w.WriteString(line); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if i < len(lines)-1 {
|
||||
w.WriteString("\n")
|
||||
if _, err := w.WriteString("\n"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
}
|
||||
}
|
||||
w.WriteString("\n")
|
||||
if _, err := w.WriteString("\n"); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return fixCount, w.Flush()
|
||||
}
|
||||
|
||||
@@ -30,9 +30,9 @@ const (
|
||||
// Issue is a single lint problem found.
|
||||
type Issue struct {
|
||||
Rule Rule
|
||||
Message string
|
||||
Severity Severity
|
||||
Line int
|
||||
Message string
|
||||
}
|
||||
|
||||
func (i Issue) String() string {
|
||||
@@ -79,7 +79,11 @@ func CheckSorted(entries []parser.Entry) []Issue {
|
||||
Rule: RuleSorted,
|
||||
Severity: SeverityError,
|
||||
Line: entries[i].Line,
|
||||
Message: fmt.Sprintf("%q should come before %q (alphabetical order)", entries[i].Name, entries[i-1].Name),
|
||||
Message: fmt.Sprintf(
|
||||
"%q should come before %q (alphabetical order)",
|
||||
entries[i].Name,
|
||||
entries[i-1].Name,
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -19,7 +19,11 @@ func TestParseEntry(t *testing.T) {
|
||||
t.Errorf("url = %q, want %q", entry.URL, "https://www.docker.com/products/docker-desktop/")
|
||||
}
|
||||
if entry.Description != "Official native app. Only for Windows and MacOS." {
|
||||
t.Errorf("description = %q, want %q", entry.Description, "Official native app. Only for Windows and MacOS.")
|
||||
t.Errorf(
|
||||
"description = %q, want %q",
|
||||
entry.Description,
|
||||
"Official native app. Only for Windows and MacOS.",
|
||||
)
|
||||
}
|
||||
if len(entry.Markers) != 0 {
|
||||
t.Errorf("markers = %v, want empty", entry.Markers)
|
||||
|
||||
@@ -15,17 +15,17 @@ type Entry struct {
|
||||
Name string
|
||||
URL string
|
||||
Description string
|
||||
Raw string
|
||||
Markers []Marker
|
||||
Line int // 1-based line number in source
|
||||
Raw string // original line text
|
||||
Line int
|
||||
}
|
||||
|
||||
// Section is a heading with optional entries and child sections.
|
||||
type Section struct {
|
||||
Title string
|
||||
Level int // heading level: 1 = #, 2 = ##, etc.
|
||||
Entries []Entry
|
||||
Children []Section
|
||||
Level int
|
||||
Line int
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
// Package pruner owns the removal of README entries by health status.
|
||||
//
|
||||
// Why it exists: maintenance regularly produces a list of archived/stale
|
||||
// projects (see scorer + cache). Pruner is the seam that translates that list
|
||||
// into a concrete edit of README.md and config/health_cache.yaml, so the README
|
||||
// stays in lockstep with the cache instead of drifting via ad-hoc edits.
|
||||
package pruner
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/veggiemonk/awesome-docker/internal/cache"
|
||||
"github.com/veggiemonk/awesome-docker/internal/parser"
|
||||
)
|
||||
|
||||
// Removed describes a single entry removed from the README.
|
||||
type Removed struct {
|
||||
URL string
|
||||
Name string
|
||||
Status string
|
||||
Line int
|
||||
}
|
||||
|
||||
// Result summarizes a prune run.
|
||||
type Result struct {
|
||||
Removed []Removed
|
||||
// URLs in the target set that didn't appear in the README (already gone,
|
||||
// non-GitHub indirection, or URL drift between cache and README).
|
||||
NotFound []string
|
||||
}
|
||||
|
||||
// TargetURLs returns the URL set selected by the given statuses from the cache.
|
||||
func TargetURLs(hc *cache.HealthCache, statuses []string) map[string]cache.HealthEntry {
|
||||
want := make(map[string]bool, len(statuses))
|
||||
for _, s := range statuses {
|
||||
want[strings.TrimSpace(strings.ToLower(s))] = true
|
||||
}
|
||||
out := make(map[string]cache.HealthEntry)
|
||||
for i := range hc.Entries {
|
||||
e := &hc.Entries[i]
|
||||
if want[strings.ToLower(e.Status)] {
|
||||
out[normalizeURL(e.URL)] = *e
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// PruneREADME removes lines whose entry URL is in targets and writes the
|
||||
// result back to path. If dryRun is true, the file is not modified.
|
||||
func PruneREADME(path string, targets map[string]cache.HealthEntry, dryRun bool) (Result, error) {
|
||||
f, err := os.Open(path) //nolint:gosec
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("open %s: %w", path, err)
|
||||
}
|
||||
lines, err := readLines(f)
|
||||
f.Close()
|
||||
if err != nil {
|
||||
return Result{}, fmt.Errorf("read %s: %w", path, err)
|
||||
}
|
||||
|
||||
var (
|
||||
kept = make([]string, 0, len(lines))
|
||||
removed []Removed
|
||||
hit = make(map[string]bool, len(targets))
|
||||
)
|
||||
|
||||
for i, line := range lines {
|
||||
entry, perr := parser.ParseEntry(line, i+1)
|
||||
if perr != nil {
|
||||
kept = append(kept, line)
|
||||
continue
|
||||
}
|
||||
key := normalizeURL(entry.URL)
|
||||
meta, ok := targets[key]
|
||||
if !ok {
|
||||
kept = append(kept, line)
|
||||
continue
|
||||
}
|
||||
hit[key] = true
|
||||
removed = append(removed, Removed{
|
||||
URL: entry.URL,
|
||||
Name: entry.Name,
|
||||
Status: meta.Status,
|
||||
Line: i + 1,
|
||||
})
|
||||
}
|
||||
|
||||
res := Result{Removed: removed}
|
||||
for k := range targets {
|
||||
if !hit[k] {
|
||||
res.NotFound = append(res.NotFound, targets[k].URL)
|
||||
}
|
||||
}
|
||||
sort.Strings(res.NotFound)
|
||||
|
||||
if dryRun || len(removed) == 0 {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
if err := writeLines(path, kept); err != nil {
|
||||
return res, fmt.Errorf("write %s: %w", path, err)
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// PruneCache drops entries whose normalized URL is in targets and writes the
|
||||
// cache back to path. Safe to call when len(targets) == 0 (no-op).
|
||||
func PruneCache(path string, hc *cache.HealthCache, targets map[string]cache.HealthEntry, dryRun bool) (int, error) {
|
||||
if len(targets) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
kept := hc.Entries[:0]
|
||||
for i := range hc.Entries {
|
||||
e := &hc.Entries[i]
|
||||
if _, drop := targets[normalizeURL(e.URL)]; drop {
|
||||
continue
|
||||
}
|
||||
kept = append(kept, *e)
|
||||
}
|
||||
dropped := len(hc.Entries) - len(kept)
|
||||
hc.Entries = kept
|
||||
if dryRun || dropped == 0 {
|
||||
return dropped, nil
|
||||
}
|
||||
if err := cache.SaveHealthCache(path, hc); err != nil {
|
||||
return dropped, err
|
||||
}
|
||||
return dropped, nil
|
||||
}
|
||||
|
||||
// reportSectionRe matches markdown health-report section headings:
|
||||
//
|
||||
// ## Archived (should mark :skull:)
|
||||
// ## Stale (2+ years inactive)
|
||||
// ## Inactive (1-2 years)
|
||||
var reportSectionRe = regexp.MustCompile(`(?i)^##\s+(archived|stale|inactive|dead|healthy)\b`)
|
||||
|
||||
// reportEntryRe matches: "- [name](url) - Stars: N - Last push: YYYY-MM-DD"
|
||||
var reportEntryRe = regexp.MustCompile(`^-\s+\[([^\]]+)\]\((https?://[^)]+)\)`)
|
||||
|
||||
// TargetsFromReport parses a markdown health report (same format as the
|
||||
// `report` subcommand emits) and returns the URL set whose section heading
|
||||
// matches one of the given statuses.
|
||||
func TargetsFromReport(r io.Reader, statuses []string) (map[string]cache.HealthEntry, error) {
|
||||
want := make(map[string]bool, len(statuses))
|
||||
for _, s := range statuses {
|
||||
want[strings.TrimSpace(strings.ToLower(s))] = true
|
||||
}
|
||||
out := make(map[string]cache.HealthEntry)
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
var current string
|
||||
for sc.Scan() {
|
||||
line := sc.Text()
|
||||
if m := reportSectionRe.FindStringSubmatch(line); m != nil {
|
||||
current = strings.ToLower(m[1])
|
||||
continue
|
||||
}
|
||||
if !want[current] {
|
||||
continue
|
||||
}
|
||||
if m := reportEntryRe.FindStringSubmatch(line); m != nil {
|
||||
url := strings.TrimSpace(m[2])
|
||||
out[normalizeURL(url)] = cache.HealthEntry{
|
||||
URL: url,
|
||||
Name: m[1],
|
||||
Status: current,
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func normalizeURL(u string) string {
|
||||
u = strings.TrimSpace(u)
|
||||
u = strings.TrimSuffix(u, "/")
|
||||
u = strings.ToLower(u)
|
||||
return u
|
||||
}
|
||||
|
||||
func readLines(r *os.File) ([]string, error) {
|
||||
var lines []string
|
||||
sc := bufio.NewScanner(r)
|
||||
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
|
||||
for sc.Scan() {
|
||||
lines = append(lines, sc.Text())
|
||||
}
|
||||
return lines, sc.Err()
|
||||
}
|
||||
|
||||
func writeLines(path string, lines []string) error {
|
||||
out, err := os.Create(path) //nolint:gosec
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer out.Close()
|
||||
w := bufio.NewWriter(out)
|
||||
for i, line := range lines {
|
||||
if _, err := w.WriteString(line); err != nil {
|
||||
return err
|
||||
}
|
||||
if i < len(lines)-1 {
|
||||
if err := w.WriteByte('\n'); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := w.WriteByte('\n'); err != nil {
|
||||
return err
|
||||
}
|
||||
return w.Flush()
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
package pruner
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/veggiemonk/awesome-docker/internal/cache"
|
||||
)
|
||||
|
||||
func TestTargetURLs(t *testing.T) {
|
||||
hc := &cache.HealthCache{Entries: []cache.HealthEntry{
|
||||
{URL: "https://github.com/A/x", Status: "archived"},
|
||||
{URL: "https://github.com/B/y", Status: "stale"},
|
||||
{URL: "https://github.com/C/z", Status: "healthy"},
|
||||
{URL: "https://github.com/D/w", Status: "inactive"},
|
||||
}}
|
||||
got := TargetURLs(hc, []string{"archived", "stale"})
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("want 2 targets, got %d", len(got))
|
||||
}
|
||||
if _, ok := got["https://github.com/a/x"]; !ok {
|
||||
t.Errorf("expected lowercased URL key for archived entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTargetsFromReport(t *testing.T) {
|
||||
r := strings.NewReader(`# Health Report
|
||||
|
||||
## Summary
|
||||
|
||||
- Stale (2+ years): 2
|
||||
|
||||
## Archived (should mark :skull:)
|
||||
|
||||
- [a/keep](https://github.com/A/Keep) - Stars: 1 - Last push: 2024-01-01
|
||||
|
||||
## Stale (2+ years inactive)
|
||||
|
||||
- [b/drop](https://github.com/b/drop) - Stars: 2 - Last push: 2020-01-01
|
||||
|
||||
## Inactive (1-2 years)
|
||||
|
||||
- [c/skip](https://github.com/c/skip) - Stars: 3 - Last push: 2025-01-01
|
||||
`)
|
||||
targets, err := TargetsFromReport(r, []string{"archived", "stale"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(targets) != 2 {
|
||||
t.Fatalf("want 2, got %d: %v", len(targets), targets)
|
||||
}
|
||||
if _, ok := targets["https://github.com/a/keep"]; !ok {
|
||||
t.Errorf("missing archived entry (case-insensitive)")
|
||||
}
|
||||
if _, ok := targets["https://github.com/b/drop"]; !ok {
|
||||
t.Errorf("missing stale entry")
|
||||
}
|
||||
if _, ok := targets["https://github.com/c/skip"]; ok {
|
||||
t.Errorf("inactive entry should not have been picked up")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneREADME(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "README.md")
|
||||
content := `# Header
|
||||
|
||||
## Tools
|
||||
|
||||
- [keep](https://github.com/keep/me) - Healthy project.
|
||||
- [drop](https://github.com/drop/me) - Stale project.
|
||||
- [also-keep](https://github.com/also/keep) - Another one.
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
targets := map[string]cache.HealthEntry{
|
||||
"https://github.com/drop/me": {URL: "https://github.com/drop/me", Status: "stale"},
|
||||
}
|
||||
res, err := PruneREADME(path, targets, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Removed) != 1 {
|
||||
t.Fatalf("want 1 removed, got %d", len(res.Removed))
|
||||
}
|
||||
if res.Removed[0].URL != "https://github.com/drop/me" {
|
||||
t.Errorf("unexpected removed URL: %s", res.Removed[0].URL)
|
||||
}
|
||||
|
||||
out, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(out), "drop/me") {
|
||||
t.Errorf("expected drop/me to be removed from README, got:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(string(out), "keep/me") || !strings.Contains(string(out), "also/keep") {
|
||||
t.Errorf("expected other entries to be preserved, got:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneREADMEDryRun(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "README.md")
|
||||
content := "## X\n\n- [drop](https://github.com/drop/me) - Stale.\n"
|
||||
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
targets := map[string]cache.HealthEntry{
|
||||
"https://github.com/drop/me": {URL: "https://github.com/drop/me", Status: "stale"},
|
||||
}
|
||||
res, err := PruneREADME(path, targets, true)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Removed) != 1 {
|
||||
t.Fatalf("want 1 removed (preview), got %d", len(res.Removed))
|
||||
}
|
||||
got, _ := os.ReadFile(path)
|
||||
if string(got) != content {
|
||||
t.Errorf("dry-run modified file: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneREADMENotFound(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "README.md")
|
||||
if err := os.WriteFile(path, []byte("## X\n\n- [k](https://github.com/k/v) - Keep.\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
targets := map[string]cache.HealthEntry{
|
||||
"https://github.com/gone/missing": {URL: "https://github.com/gone/missing", Status: "stale"},
|
||||
}
|
||||
res, err := PruneREADME(path, targets, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(res.Removed) != 0 {
|
||||
t.Errorf("want 0 removed, got %d", len(res.Removed))
|
||||
}
|
||||
if len(res.NotFound) != 1 || res.NotFound[0] != "https://github.com/gone/missing" {
|
||||
t.Errorf("want gone/missing in NotFound, got %v", res.NotFound)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPruneCache(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "cache.yaml")
|
||||
hc := &cache.HealthCache{Entries: []cache.HealthEntry{
|
||||
{URL: "https://github.com/a/keep", Status: "healthy"},
|
||||
{URL: "https://github.com/b/drop", Status: "stale"},
|
||||
}}
|
||||
if err := cache.SaveHealthCache(path, hc); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
targets := map[string]cache.HealthEntry{
|
||||
"https://github.com/b/drop": {URL: "https://github.com/b/drop", Status: "stale"},
|
||||
}
|
||||
n, err := PruneCache(path, hc, targets, false)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("want 1 dropped, got %d", n)
|
||||
}
|
||||
if len(hc.Entries) != 1 || hc.Entries[0].URL != "https://github.com/a/keep" {
|
||||
t.Errorf("unexpected remaining entries: %v", hc.Entries)
|
||||
}
|
||||
}
|
||||
+13
-10
@@ -23,15 +23,15 @@ const (
|
||||
|
||||
// ScoredEntry is a repo with its computed health status.
|
||||
type ScoredEntry struct {
|
||||
LastPush time.Time
|
||||
URL string
|
||||
Name string
|
||||
Status Status
|
||||
Category string
|
||||
Description string
|
||||
Stars int
|
||||
Forks int
|
||||
HasLicense bool
|
||||
LastPush time.Time
|
||||
Category string
|
||||
Description string
|
||||
}
|
||||
|
||||
// ReportSummary contains grouped status counts.
|
||||
@@ -46,10 +46,10 @@ type ReportSummary struct {
|
||||
// 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"`
|
||||
Entries []ScoredEntry `json:"entries"`
|
||||
Summary ReportSummary `json:"summary"`
|
||||
Total int `json:"total"`
|
||||
}
|
||||
|
||||
// Score computes the health status of a GitHub repo.
|
||||
@@ -94,7 +94,8 @@ func ScoreAll(infos []checker.RepoInfo) []ScoredEntry {
|
||||
func ToCacheEntries(scored []ScoredEntry) []cache.HealthEntry {
|
||||
entries := make([]cache.HealthEntry, len(scored))
|
||||
now := time.Now().UTC()
|
||||
for i, s := range scored {
|
||||
for i := range scored {
|
||||
s := &scored[i]
|
||||
entries[i] = cache.HealthEntry{
|
||||
URL: s.URL,
|
||||
Name: s.Name,
|
||||
@@ -135,7 +136,8 @@ func GenerateReport(scored []ScoredEntry) string {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(&b, "## %s\n\n", title)
|
||||
for _, e := range entries {
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
fmt.Fprintf(&b, "- [%s](%s) - Stars: %d - Last push: %s\n",
|
||||
e.Name, e.URL, e.Stars, e.LastPush.Format("2006-01-02"))
|
||||
}
|
||||
@@ -152,8 +154,9 @@ func GenerateReport(scored []ScoredEntry) 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)
|
||||
for i := range scored {
|
||||
s := &scored[i]
|
||||
groups[s.Status] = append(groups[s.Status], *s)
|
||||
}
|
||||
|
||||
return ReportData{
|
||||
|
||||
@@ -70,7 +70,13 @@ 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)},
|
||||
{
|
||||
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") {
|
||||
@@ -85,8 +91,9 @@ func TestGenerateReport(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerateReportShowsAllEntries(t *testing.T) {
|
||||
var results []ScoredEntry
|
||||
for i := 0; i < 55; i++ {
|
||||
const entryCount = 55
|
||||
results := make([]ScoredEntry, 0, entryCount)
|
||||
for i := range entryCount {
|
||||
results = append(results, ScoredEntry{
|
||||
URL: fmt.Sprintf("https://github.com/stale/%d", i),
|
||||
Name: fmt.Sprintf("stale/%d", i),
|
||||
@@ -100,7 +107,7 @@ func TestGenerateReportShowsAllEntries(t *testing.T) {
|
||||
if strings.Contains(report, "... and") {
|
||||
t.Fatal("report should not be truncated")
|
||||
}
|
||||
if !strings.Contains(report, "stale/54") {
|
||||
if !strings.Contains(report, fmt.Sprintf("stale/%d", entryCount-1)) {
|
||||
t.Fatal("report should contain all entries")
|
||||
}
|
||||
}
|
||||
|
||||
+43
-73
@@ -1,10 +1,12 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"runtime"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
|
||||
tea "charm.land/bubbletea/v2"
|
||||
@@ -19,25 +21,25 @@ const (
|
||||
panelList
|
||||
)
|
||||
|
||||
const entryHeight = 5 // lines rendered per entry in the list panel
|
||||
const scrollOff = 4 // minimum lines/entries kept visible above and below cursor
|
||||
const (
|
||||
entryHeight = 5 // lines rendered per entry in the list panel
|
||||
scrollOff = 4 // minimum lines/entries kept visible above and below cursor
|
||||
)
|
||||
|
||||
// Model is the top-level Bubbletea model.
|
||||
type Model struct {
|
||||
roots []*TreeNode
|
||||
flatTree []FlatNode
|
||||
|
||||
filterText string
|
||||
roots []*TreeNode
|
||||
flatTree []FlatNode
|
||||
currentEntries []cache.HealthEntry
|
||||
activePanel panel
|
||||
treeCursor int
|
||||
treeOffset int
|
||||
listCursor int
|
||||
listOffset int
|
||||
currentEntries []cache.HealthEntry
|
||||
|
||||
filtering bool
|
||||
filterText string
|
||||
|
||||
width, height int
|
||||
width int
|
||||
height int
|
||||
filtering bool
|
||||
}
|
||||
|
||||
// New creates a new Model from health cache entries.
|
||||
@@ -135,11 +137,13 @@ func (m *Model) applyFilter() {
|
||||
query := strings.ToLower(m.filterText)
|
||||
var filtered []cache.HealthEntry
|
||||
for _, root := range m.roots {
|
||||
for _, e := range root.AllEntries() {
|
||||
entries := root.AllEntries()
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
if strings.Contains(strings.ToLower(e.Name), query) ||
|
||||
strings.Contains(strings.ToLower(e.Description), query) ||
|
||||
strings.Contains(strings.ToLower(e.Category), query) {
|
||||
filtered = append(filtered, e)
|
||||
filtered = append(filtered, *e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -176,10 +180,7 @@ func (m Model) handleTreeKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
m.updateCurrentEntries()
|
||||
}
|
||||
case "ctrl+d", "pgdown":
|
||||
half := m.treePanelHeight() / 2
|
||||
if half < 1 {
|
||||
half = 1
|
||||
}
|
||||
half := max(m.treePanelHeight()/2, 1)
|
||||
m.treeCursor += half
|
||||
if m.treeCursor >= len(m.flatTree) {
|
||||
m.treeCursor = len(m.flatTree) - 1
|
||||
@@ -187,10 +188,7 @@ func (m Model) handleTreeKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
m.adjustTreeScroll()
|
||||
m.updateCurrentEntries()
|
||||
case "ctrl+u", "pgup":
|
||||
half := m.treePanelHeight() / 2
|
||||
if half < 1 {
|
||||
half = 1
|
||||
}
|
||||
half := max(m.treePanelHeight()/2, 1)
|
||||
m.treeCursor -= half
|
||||
if m.treeCursor < 0 {
|
||||
m.treeCursor = 0
|
||||
@@ -233,10 +231,7 @@ func (m Model) handleTreeKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
|
||||
func (m *Model) adjustTreeScroll() {
|
||||
visible := m.treePanelHeight()
|
||||
off := scrollOff
|
||||
if off > visible/2 {
|
||||
off = visible / 2
|
||||
}
|
||||
off := min(scrollOff, visible/2)
|
||||
if m.treeCursor < m.treeOffset+off {
|
||||
m.treeOffset = m.treeCursor - off
|
||||
}
|
||||
@@ -249,10 +244,10 @@ func (m *Model) adjustTreeScroll() {
|
||||
}
|
||||
|
||||
func (m Model) treePanelHeight() int {
|
||||
h := m.height - 6 // header, footer, borders, title
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
h := max(
|
||||
// header, footer, borders, title
|
||||
m.height-6, 1,
|
||||
)
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -269,20 +264,14 @@ func (m Model) handleListKey(msg tea.KeyPressMsg) (tea.Model, tea.Cmd) {
|
||||
m.adjustListScroll()
|
||||
}
|
||||
case "ctrl+d", "pgdown":
|
||||
half := m.visibleListEntries() / 2
|
||||
if half < 1 {
|
||||
half = 1
|
||||
}
|
||||
half := max(m.visibleListEntries()/2, 1)
|
||||
m.listCursor += half
|
||||
if m.listCursor >= len(m.currentEntries) {
|
||||
m.listCursor = len(m.currentEntries) - 1
|
||||
}
|
||||
m.adjustListScroll()
|
||||
case "ctrl+u", "pgup":
|
||||
half := m.visibleListEntries() / 2
|
||||
if half < 1 {
|
||||
half = 1
|
||||
}
|
||||
half := max(m.visibleListEntries()/2, 1)
|
||||
m.listCursor -= half
|
||||
if m.listCursor < 0 {
|
||||
m.listCursor = 0
|
||||
@@ -328,10 +317,7 @@ func (m Model) visibleListEntries() int {
|
||||
|
||||
func (m *Model) adjustListScroll() {
|
||||
visible := m.visibleListEntries()
|
||||
off := scrollOff
|
||||
if off > visible/2 {
|
||||
off = visible / 2
|
||||
}
|
||||
off := min(scrollOff, visible/2)
|
||||
if m.listCursor < m.listOffset+off {
|
||||
m.listOffset = m.listCursor - off
|
||||
}
|
||||
@@ -345,10 +331,7 @@ func (m *Model) adjustListScroll() {
|
||||
|
||||
func (m Model) listPanelHeight() int {
|
||||
// height minus header, footer, borders
|
||||
h := m.height - 4
|
||||
if h < 1 {
|
||||
h = 1
|
||||
}
|
||||
h := max(m.height-4, 1)
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -406,10 +389,7 @@ func (m Model) renderTree(width, height int) string {
|
||||
b.WriteString("\n\n")
|
||||
|
||||
linesUsed := 2
|
||||
end := m.treeOffset + height - 2
|
||||
if end > len(m.flatTree) {
|
||||
end = len(m.flatTree)
|
||||
}
|
||||
end := min(m.treeOffset+height-2, len(m.flatTree))
|
||||
for i := m.treeOffset; i < end; i++ {
|
||||
fn := m.flatTree[i]
|
||||
if linesUsed >= height {
|
||||
@@ -466,16 +446,10 @@ func (m Model) renderList(width, height int) string {
|
||||
|
||||
linesUsed := 2
|
||||
|
||||
visible := (height - 2) / entryHeight
|
||||
if visible < 1 {
|
||||
visible = 1
|
||||
}
|
||||
visible := max((height-2)/entryHeight, 1)
|
||||
|
||||
start := m.listOffset
|
||||
end := start + visible
|
||||
if end > len(m.currentEntries) {
|
||||
end = len(m.currentEntries)
|
||||
}
|
||||
end := min(start+visible, len(m.currentEntries))
|
||||
|
||||
for idx := start; idx < end; idx++ {
|
||||
if linesUsed+entryHeight > height {
|
||||
@@ -496,19 +470,16 @@ func (m Model) renderList(width, height int) string {
|
||||
}
|
||||
name := e.Name
|
||||
statsW := lipgloss.Width(stats)
|
||||
maxName := safeWidth - statsW - 2 // 2 for minimum gap
|
||||
if maxName < 4 {
|
||||
maxName = 4
|
||||
}
|
||||
maxName := max(
|
||||
// 2 for minimum gap
|
||||
safeWidth-statsW-2, 4,
|
||||
)
|
||||
if lipgloss.Width(name) > maxName {
|
||||
name = truncateToWidth(name, maxName-1) + "…"
|
||||
}
|
||||
nameStr := entryNameStyle.Render(name)
|
||||
statsStr := entryDescStyle.Render(stats)
|
||||
padding := safeWidth - lipgloss.Width(nameStr) - lipgloss.Width(statsStr)
|
||||
if padding < 1 {
|
||||
padding = 1
|
||||
}
|
||||
padding := max(safeWidth-lipgloss.Width(nameStr)-lipgloss.Width(statsStr), 1)
|
||||
line1 := nameStr + strings.Repeat(" ", padding) + statsStr
|
||||
|
||||
// Line 2: URL
|
||||
@@ -529,15 +500,12 @@ func (m Model) renderList(width, height int) string {
|
||||
statusStr := statusStyle(e.Status).Render(e.Status)
|
||||
lastPush := ""
|
||||
if !e.LastPush.IsZero() {
|
||||
lastPush = fmt.Sprintf(" Last push: %s", e.LastPush.Format("2006-01-02"))
|
||||
lastPush = " Last push: " + e.LastPush.Format("2006-01-02")
|
||||
}
|
||||
line4 := statusStr + entryDescStyle.Render(lastPush)
|
||||
|
||||
// Line 5: separator
|
||||
sepWidth := safeWidth
|
||||
if sepWidth < 1 {
|
||||
sepWidth = 1
|
||||
}
|
||||
sepWidth := max(safeWidth, 1)
|
||||
line5 := entryDescStyle.Render(strings.Repeat("─", sepWidth))
|
||||
|
||||
entry := fmt.Sprintf("%s\n%s\n%s\n%s\n%s", line1, line2, line3, line4, line5)
|
||||
@@ -573,14 +541,16 @@ type openURLMsg struct{ err error }
|
||||
|
||||
func openURL(url string) tea.Cmd {
|
||||
return func() tea.Msg {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
var cmd *exec.Cmd
|
||||
switch runtime.GOOS {
|
||||
case "darwin":
|
||||
cmd = exec.Command("open", url)
|
||||
cmd = exec.CommandContext(ctx, "open", url)
|
||||
case "windows":
|
||||
cmd = exec.Command("cmd", "/c", "start", url)
|
||||
cmd = exec.CommandContext(ctx, "cmd", "/c", "start", url)
|
||||
default:
|
||||
cmd = exec.Command("xdg-open", url)
|
||||
cmd = exec.CommandContext(ctx, "xdg-open", url)
|
||||
}
|
||||
return openURLMsg{err: cmd.Run()}
|
||||
}
|
||||
|
||||
@@ -13,8 +13,11 @@ var (
|
||||
BorderForeground(lipgloss.Color("#555555"))
|
||||
|
||||
// Tree styles
|
||||
treeSelectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF79C6")).Background(lipgloss.Color("#3B2D50"))
|
||||
treeNormalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC"))
|
||||
treeSelectedStyle = lipgloss.NewStyle().
|
||||
Bold(true).
|
||||
Foreground(lipgloss.Color("#FF79C6")).
|
||||
Background(lipgloss.Color("#3B2D50"))
|
||||
treeNormalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC"))
|
||||
|
||||
// Entry styles
|
||||
entryNameStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#50FA7B"))
|
||||
|
||||
@@ -9,11 +9,11 @@ import (
|
||||
|
||||
// TreeNode represents a node in the category tree.
|
||||
type TreeNode struct {
|
||||
Name string // display name (leaf segment, e.g. "Networking")
|
||||
Path string // full path (e.g. "Container Operations > Networking")
|
||||
Name string
|
||||
Path string
|
||||
Children []*TreeNode
|
||||
Expanded bool
|
||||
Entries []cache.HealthEntry
|
||||
Expanded bool
|
||||
}
|
||||
|
||||
// FlatNode is a visible tree node with its indentation depth.
|
||||
@@ -51,14 +51,15 @@ func BuildTree(entries []cache.HealthEntry) []*TreeNode {
|
||||
root := &TreeNode{Name: "root"}
|
||||
nodeMap := map[string]*TreeNode{}
|
||||
|
||||
for _, e := range entries {
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
cat := e.Category
|
||||
if cat == "" {
|
||||
cat = "Uncategorized"
|
||||
}
|
||||
|
||||
node := ensureNode(root, nodeMap, cat)
|
||||
node.Entries = append(node.Entries, e)
|
||||
node.Entries = append(node.Entries, *e)
|
||||
}
|
||||
|
||||
// Sort children at every level
|
||||
|
||||
@@ -11,7 +11,12 @@ func TestBuildTree(t *testing.T) {
|
||||
{URL: "https://github.com/a/b", Name: "a/b", Category: "Projects > Networking", Description: "desc1"},
|
||||
{URL: "https://github.com/c/d", Name: "c/d", Category: "Projects > Networking", Description: "desc2"},
|
||||
{URL: "https://github.com/e/f", Name: "e/f", Category: "Projects > Security", Description: "desc3"},
|
||||
{URL: "https://github.com/g/h", Name: "g/h", Category: "Docker Images > Base Tools", Description: "desc4"},
|
||||
{
|
||||
URL: "https://github.com/g/h",
|
||||
Name: "g/h",
|
||||
Category: "Docker Images > Base Tools",
|
||||
Description: "desc4",
|
||||
},
|
||||
{URL: "https://github.com/i/j", Name: "i/j", Category: "", Description: "no category"},
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user