feat: add prune subcommand, drop archived/stale entries (#1441)
Deploy to GitHub Pages / build (push) Failing after 51s
Deploy to GitHub Pages / deploy (push) Has been skipped
Pull Requests / Weekly QA / test (push) Failing after 1m13s
Broken Links Report / check-links (push) Failing after 45s

* 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:
Julien Bisconti
2026-05-18 23:46:32 +02:00
committed by GitHub
parent 503e5bd7c7
commit 29222bfcb5
23 changed files with 2548 additions and 2803 deletions
+199 -24
View File
@@ -2,6 +2,7 @@ package main
import (
"context"
"errors"
"fmt"
"os"
"strconv"
@@ -13,6 +14,7 @@ import (
"github.com/veggiemonk/awesome-docker/internal/checker"
"github.com/veggiemonk/awesome-docker/internal/linter"
"github.com/veggiemonk/awesome-docker/internal/parser"
"github.com/veggiemonk/awesome-docker/internal/pruner"
"github.com/veggiemonk/awesome-docker/internal/scorer"
"github.com/veggiemonk/awesome-docker/internal/tui"
)
@@ -27,11 +29,11 @@ const (
)
type checkSummary struct {
ExternalTotal int
GitHubTotal int
Broken []checker.LinkResult
Redirected []checker.LinkResult
GitHubErrors []error
ExternalTotal int
GitHubTotal int
GitHubSkipped bool
}
@@ -52,6 +54,7 @@ func main() {
validateCmd(),
ciCmd(),
browseCmd(),
pruneCmd(),
)
if err := root.Execute(); err != nil {
@@ -154,7 +157,7 @@ func runLinkChecks(prMode bool) (checkSummary, error) {
func runHealth(ctx context.Context) error {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
return fmt.Errorf("GITHUB_TOKEN environment variable is required")
return errors.New("GITHUB_TOKEN environment variable is required")
}
doc, err := parseReadme()
@@ -174,9 +177,12 @@ func runHealth(ctx context.Context) error {
}
if len(infos) == 0 {
if len(errs) > 0 {
return fmt.Errorf("failed to fetch GitHub metadata for all repositories (%d errors); check network/DNS and GITHUB_TOKEN", len(errs))
return fmt.Errorf(
"failed to fetch GitHub metadata for all repositories (%d errors); check network/DNS and GITHUB_TOKEN",
len(errs),
)
}
return fmt.Errorf("no GitHub repositories found in README")
return errors.New("no GitHub repositories found in README")
}
scored := scorer.ScoreAll(infos)
@@ -211,11 +217,12 @@ func scoredFromCache() ([]scorer.ScoredEntry, error) {
return nil, fmt.Errorf("load cache: %w", err)
}
if len(hc.Entries) == 0 {
return nil, fmt.Errorf("no cache data, run 'health' first")
return nil, errors.New("no cache data, run 'health' first")
}
scored := make([]scorer.ScoredEntry, 0, len(hc.Entries))
for _, e := range hc.Entries {
for i := range hc.Entries {
e := &hc.Entries[i]
scored = append(scored, scorer.ScoredEntry{
URL: e.URL,
Name: e.Name,
@@ -409,7 +416,11 @@ func checkCmd() *cobra.Command {
}
}
if len(summary.Broken) > 0 && len(summary.GitHubErrors) > 0 {
return fmt.Errorf("found %d broken links and %d GitHub API errors", len(summary.Broken), len(summary.GitHubErrors))
return fmt.Errorf(
"found %d broken links and %d GitHub API errors",
len(summary.Broken),
len(summary.GitHubErrors),
)
}
if len(summary.Broken) > 0 {
return fmt.Errorf("found %d broken links", len(summary.Broken))
@@ -560,20 +571,40 @@ func ciBrokenLinksCmd() *cobra.Command {
}
}
if err := writeGitHubOutput(githubOutput, "has_errors", strconv.FormatBool(hasErrors)); err != nil {
if err := writeGitHubOutput(
githubOutput,
"has_errors",
strconv.FormatBool(hasErrors),
); err != nil {
return err
}
if err := writeGitHubOutput(githubOutput, "check_exit_code", strconv.Itoa(exitCode)); err != nil {
if err := writeGitHubOutput(
githubOutput,
"check_exit_code",
strconv.Itoa(exitCode),
); err != nil {
return err
}
if err := writeGitHubOutput(githubOutput, "broken_count", strconv.Itoa(len(summary.Broken))); err != nil {
if err := writeGitHubOutput(
githubOutput,
"broken_count",
strconv.Itoa(len(summary.Broken)),
); err != nil {
return err
}
if err := writeGitHubOutput(githubOutput, "github_error_count", strconv.Itoa(len(summary.GitHubErrors))); err != nil {
if err := writeGitHubOutput(
githubOutput,
"github_error_count",
strconv.Itoa(len(summary.GitHubErrors)),
); err != nil {
return err
}
if runErr != nil {
if err := writeGitHubOutput(githubOutput, "run_error", sanitizeOutputValue(runErr.Error())); err != nil {
if err := writeGitHubOutput(
githubOutput,
"run_error",
sanitizeOutputValue(runErr.Error()),
); err != nil {
return err
}
}
@@ -582,7 +613,11 @@ func ciBrokenLinksCmd() *cobra.Command {
fmt.Printf("CI broken-links run error: %v\n", runErr)
}
if hasErrors {
fmt.Printf("CI broken-links found %d broken links and %d GitHub errors\n", len(summary.Broken), len(summary.GitHubErrors))
fmt.Printf(
"CI broken-links found %d broken links and %d GitHub errors\n",
len(summary.Broken),
len(summary.GitHubErrors),
)
} else {
fmt.Println("CI broken-links found no errors")
}
@@ -592,7 +627,11 @@ func ciBrokenLinksCmd() *cobra.Command {
return runErr
}
if hasErrors {
return fmt.Errorf("found %d broken links and %d GitHub API errors", len(summary.Broken), len(summary.GitHubErrors))
return fmt.Errorf(
"found %d broken links and %d GitHub API errors",
len(summary.Broken),
len(summary.GitHubErrors),
)
}
}
return nil
@@ -600,7 +639,8 @@ func ciBrokenLinksCmd() *cobra.Command {
}
cmd.Flags().StringVar(&issueFile, "issue-file", "broken_links_issue.md", "Path to write issue markdown body")
cmd.Flags().StringVar(&githubOutput, "github-output", "", "Path to GitHub output file (typically $GITHUB_OUTPUT)")
cmd.Flags().
StringVar(&githubOutput, "github-output", "", "Path to GitHub output file (typically $GITHUB_OUTPUT)")
cmd.Flags().BoolVar(&strict, "strict", false, "Return non-zero when errors are found")
return cmd
}
@@ -629,25 +669,49 @@ func ciHealthReportCmd() *cobra.Command {
}
}
if err := writeGitHubOutput(githubOutput, "has_report", strconv.FormatBool(hasReport)); err != nil {
if err := writeGitHubOutput(
githubOutput,
"has_report",
strconv.FormatBool(hasReport),
); err != nil {
return err
}
if err := writeGitHubOutput(githubOutput, "health_ok", strconv.FormatBool(healthOK)); err != nil {
if err := writeGitHubOutput(
githubOutput,
"health_ok",
strconv.FormatBool(healthOK),
); err != nil {
return err
}
if err := writeGitHubOutput(githubOutput, "report_ok", strconv.FormatBool(reportOK)); err != nil {
if err := writeGitHubOutput(
githubOutput,
"report_ok",
strconv.FormatBool(reportOK),
); err != nil {
return err
}
if err := writeGitHubOutput(githubOutput, "has_errors", strconv.FormatBool(hasErrors)); err != nil {
if err := writeGitHubOutput(
githubOutput,
"has_errors",
strconv.FormatBool(hasErrors),
); err != nil {
return err
}
if healthErr != nil {
if err := writeGitHubOutput(githubOutput, "health_error", sanitizeOutputValue(healthErr.Error())); err != nil {
if err := writeGitHubOutput(
githubOutput,
"health_error",
sanitizeOutputValue(healthErr.Error()),
); err != nil {
return err
}
}
if reportErr != nil {
if err := writeGitHubOutput(githubOutput, "report_error", sanitizeOutputValue(reportErr.Error())); err != nil {
if err := writeGitHubOutput(
githubOutput,
"report_error",
sanitizeOutputValue(reportErr.Error()),
); err != nil {
return err
}
}
@@ -677,7 +741,8 @@ func ciHealthReportCmd() *cobra.Command {
}
cmd.Flags().StringVar(&issueFile, "issue-file", "health_report.txt", "Path to write health issue markdown body")
cmd.Flags().StringVar(&githubOutput, "github-output", "", "Path to GitHub output file (typically $GITHUB_OUTPUT)")
cmd.Flags().
StringVar(&githubOutput, "github-output", "", "Path to GitHub output file (typically $GITHUB_OUTPUT)")
cmd.Flags().BoolVar(&strict, "strict", false, "Return non-zero when health/report fails")
return cmd
}
@@ -693,7 +758,7 @@ func browseCmd() *cobra.Command {
return fmt.Errorf("load cache: %w", err)
}
if len(hc.Entries) == 0 {
return fmt.Errorf("no cache data; run 'awesome-docker health' first")
return errors.New("no cache data; run 'awesome-docker health' first")
}
return tui.Run(hc.Entries)
},
@@ -701,3 +766,113 @@ func browseCmd() *cobra.Command {
cmd.Flags().StringVar(&cachePath, "cache", healthCachePath, "Path to health cache YAML")
return cmd
}
func pruneCmd() *cobra.Command {
var (
statuses []string
dryRun bool
keepCache bool
fromReport string
)
cmd := &cobra.Command{
Use: "prune",
Short: "Remove README entries by health status (default: archived, stale)",
Long: `Remove entries from README.md whose repository health status matches the
given list (default: "archived,stale"). The matching cache entries are also
dropped so the next health refresh starts from a clean slate.
By default the target URL list is read from the local health cache. Refresh
it first with:
awesome-docker health
If the local cache is outdated (e.g. you only have the markdown report from
a CI-generated issue), point --from-report at the saved markdown report file
and the URLs will be parsed from its section headings instead.
Use --dry-run to preview what would be removed without writing files.`,
RunE: func(cmd *cobra.Command, args []string) error {
var (
targets map[string]cache.HealthEntry
hc *cache.HealthCache
)
if fromReport != "" {
f, err := os.Open(fromReport) //nolint:gosec
if err != nil {
return fmt.Errorf("open report: %w", err)
}
defer f.Close()
targets, err = pruner.TargetsFromReport(f, statuses)
if err != nil {
return fmt.Errorf("parse report: %w", err)
}
} else {
var err error
hc, err = cache.LoadHealthCache(healthCachePath)
if err != nil {
return fmt.Errorf("load cache: %w", err)
}
if len(hc.Entries) == 0 {
return errors.New("no cache data; run 'awesome-docker health' first")
}
targets = pruner.TargetURLs(hc, statuses)
}
if len(targets) == 0 {
fmt.Printf("No entries match statuses %v; nothing to do\n", statuses)
return nil
}
res, err := pruner.PruneREADME(readmePath, targets, dryRun)
if err != nil {
return fmt.Errorf("prune readme: %w", err)
}
byStatus := map[string]int{}
for _, r := range res.Removed {
byStatus[r.Status]++
}
action := "Removed"
if dryRun {
action = "Would remove"
}
fmt.Printf("%s %d entries from %s\n", action, len(res.Removed), readmePath)
for s, n := range byStatus {
fmt.Printf(" %s: %d\n", s, n)
}
for _, r := range res.Removed {
fmt.Printf(" - [%s] %s (%s)\n", r.Status, r.Name, r.URL)
}
if len(res.NotFound) > 0 {
fmt.Printf(
"\n%d target URLs not found in README (already pruned or URL drift):\n",
len(res.NotFound),
)
for _, u := range res.NotFound {
fmt.Printf(" %s\n", u)
}
}
if !keepCache && hc != nil {
dropped, err := pruner.PruneCache(healthCachePath, hc, targets, dryRun)
if err != nil {
return fmt.Errorf("prune cache: %w", err)
}
cacheAction := "Removed"
if dryRun {
cacheAction = "Would remove"
}
fmt.Printf("\n%s %d cache entries from %s\n", cacheAction, dropped, healthCachePath)
}
return nil
},
}
cmd.Flags().
StringSliceVar(&statuses, "status", []string{"archived", "stale"}, "Comma-separated health statuses to prune (archived,stale,inactive,dead)")
cmd.Flags().BoolVar(&dryRun, "dry-run", false, "Print what would be removed without writing files")
cmd.Flags().BoolVar(&keepCache, "keep-cache", false, "Do not remove pruned URLs from the health cache")
cmd.Flags().
StringVar(&fromReport, "from-report", "", "Read target URLs from a markdown health report file instead of the cache")
return cmd
}