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
+74
View File
@@ -0,0 +1,74 @@
# Sample golangci-lint config. Edit or delete to taste.
# Full reference: https://golangci-lint.run/usage/configuration/
version: "2"
run:
timeout: 5m
linters:
enable:
- bodyclose # checks HTTP response body is closed
- copyloopvar # Go 1.22+ loop var capture
- dupword # catches "the the"
- durationcheck # multiplication of two durations
- embeddedstructfieldcheck
- errcheck # unchecked errors
- errorlint # errors.Is / errors.As hygiene
- forcetypeassert # type assertions without the comma-ok form
- gocritic # opinionated diagnostics
- gosec # security scanner
- govet # stdlib vet
- ineffassign # ineffectual assignments
- intrange # prefer `for range N`
- misspell # spelling
- modernize
- nilerr # `return nil` after checking err != nil
- noctx # http requests without context
- perfsprint # fmt.Sprintf → string concat where safe
- prealloc # slice preallocation hints
- staticcheck # staticcheck.io
- unconvert # redundant type conversions
- unparam # unused function parameters
- unused # dead code
settings:
govet:
enable-all: true
disable:
- shadow
staticcheck:
checks: ["all", "-QF1001", "-QF1002"] # drop quick-fix style nags
gocritic:
enabled-tags: [diagnostic, performance]
disabled-checks:
- hugeParam
gosec:
excludes:
- G104 # covered by errcheck
- G204 # subprocess with variable — allowed in CLI tooling
- G306 # 0600 file perms — CLIs often write executables
exclusions:
generated: lax
presets: [comments, common-false-positives, legacy, std-error-handling]
rules:
# Test code owns its fixtures.
- path: '_test\.go'
linters: [forcetypeassert, dupword, errcheck, gosec, unparam]
# CLI entry point: fmt.Fprint to stdout/stderr is fire-and-forget.
- linters: [errcheck]
source: 'fmt\.Fprint(ln|f)?\('
issues:
max-issues-per-linter: 50
max-same-issues: 10
formatters:
settings:
golines:
max-len: 120
tab-len: 8
enable:
- golines
- gofmt
- goimports
+3 -5
View File
@@ -225,7 +225,6 @@ Signing, attestation, and provenance for container images.
- [plash](https://github.com/ihucos/plash) - A container run and build engine - runs inside docker.
- [podman-compose](https://github.com/containers/podman-compose) - A script to run docker-compose.yml using podman.
- [Smalte](https://github.com/roquie/smalte) Dynamically configure applications that require static configuration in docker container.
- [Stitchocker](https://github.com/alexaandrov/stitchocker) - A lightweight and fast command line utility for conveniently grouping your docker-compose multiple container services.
### Orchestration
@@ -278,7 +277,6 @@ Self-hosted and managed cloud platforms (PaaS/CaaS, deployment automation). Comm
Container networking, overlay networks, DNS/service-discovery bridges.
- [Calico][calico] - Calico is a pure layer 3 virtual network that allows containers over multiple docker-hosts to talk to each other.
- [docker-consul](https://github.com/gliderlabs/docker-consul) - Consul packaged for Docker — registers and discovers running containers.
- [docker-dns](https://github.com/bytesharky/docker-dns) - Lightweight DNS forwarder for Docker containers, resolves container names with custom suffixes (e.g. `.docker`) on the host to simplify service discovery.
- [Flannel](https://github.com/coreos/flannel/) - Flannel is a virtual network that gives a subnet to each host for use with container runtimes.
- [netshoot](https://github.com/nicolaka/netshoot) - The netshoot container has a powerful set of networking tools to help troubleshoot Docker networking issues.
@@ -287,6 +285,8 @@ Container networking, overlay networks, DNS/service-discovery bridges.
### Reverse Proxy
Container-aware reverse proxies, ingress, and TLS-terminating front-ends with auto-discovery.
- [BunkerWeb](https://github.com/bunkerity/bunkerweb) - Open-source and next-gen Web Application Firewall (WAF).
- [caddy-docker-proxy](https://github.com/lucaslorentz/caddy-docker-proxy) - Caddy-based reverse proxy, configured with service or container labels.
- [caddy-docker-upstreams](https://github.com/invzhi/caddy-docker-upstreams) - Docker upstreams module for Caddy, configured with container labels.
@@ -302,7 +302,6 @@ Container networking, overlay networks, DNS/service-discovery bridges.
## Storage & Data
- [Blockbridge](https://github.com/blockbridge/blockbridge-docker-volume) - :yen: The Blockbridge plugin is a volume plugin that provides access to an extensible set of container-based persistent storage options. It supports single and multi-host Docker environments with features that include tenant isolation, automated provisioning, encryption, secure deletion, snapshots and QoS.
- [Label Backup](https://github.com/resulgg/label-backup) - A lightweight, Docker-aware backup agent that automatically discovers and backs up containerized databases (PostgreSQL, MySQL, MongoDB, Redis) based on Docker labels. Supports local storage and S3-compatible destinations with flexible scheduling via cron expressions.
- [Docker Volume Backup](https://github.com/offen/docker-volume-backup) Backup Docker volumes locally or to any S3 compatible storage.
- [Netshare](https://github.com/ContainX/docker-volume-netshare) Docker NFS, AWS EFS, Ceph & Samba/CIFS Volume Plugin.
@@ -345,10 +344,10 @@ Container hardening, runtime security, policy, compliance, and forensics. Self-h
- [buildcage](https://github.com/dash14/buildcage) - Restricts outbound network access during Docker builds to prevent supply chain attacks, working as a drop-in BuildKit remote driver for Docker Buildx, with ready-to-use GitHub Actions.
- [CetusGuard](https://github.com/hectorm/cetusguard) - CetusGuard is a tool that protects the Docker daemon socket by filtering calls to its API endpoints.
- [Checkov](https://github.com/bridgecrewio/checkov) - Static analysis for infrastructure as code manifests (Terraform, Kubernetes, Cloudformation, Helm, Dockerfile, Kustomize) find security misconfiguration and fix them.
- [container-explorer](https://github.com/google/container-explorer) - Forensic utility to explore Docker and containerd container details from mounted disk images.
- [Deepfence Threat Mapper](https://github.com/deepfence/ThreatMapper) - Powerful runtime vulnerability scanner for kubernetes, virtual machines and serverless.
- [Den](https://github.com/us/den) - Self-hosted sandbox runtime for AI agents with Docker containers, security hardening, REST API and WebSocket support.
- [docker-bench-security](https://github.com/docker/docker-bench-security) - Script that checks for dozens of common best-practices around deploying Docker containers in production.
- [docker-explorer](https://github.com/google/docker-explorer) - A tool to help forensicate offline docker acquisitions.
- [docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy) - HAProxy-based fine-grained filter for the Docker API socket; widely used to expose a restricted socket to reverse proxies and homelab stacks.
- [KICS](https://github.com/checkmarx/kics) - An infrastructure-as-code scanning tool, find security vulnerabilities, compliance issues, and infrastructure misconfigurations early in the development cycle. Can be extended for additional policies.
- [Prisma Cloud](https://www.paloaltonetworks.com/prisma/cloud) - :yen: (Previously Twistlock Security Suite) detects vulnerabilities, hardens container images, and enforces security policies across the lifecycle of applications.
@@ -377,7 +376,6 @@ TUIs, CLI tools, and shell integrations for Docker.
- [dctl](https://github.com/FabienD/docker-stack) - Dctl is a Cli tool that helps developers by allowing them to execute all docker compose commands anywhere in the terminal, and more.
- [decompose](https://github.com/s0rg/decompose) - Reverse-engineering tool for docker environments.
- [dive](https://github.com/wagoodman/dive) - A tool for exploring each layer in a docker image.
- [dockdash](https://github.com/byrnedo/dockdash) - Detailed Docker container stats.
- [docker pushrm](https://github.com/christian-korneck/docker-pushrm) - A Docker CLI plugin that lets you push the README.md file from the current directory to Docker Hub. Also supports Quay and Harbor.
- [docker-captain](https://github.com/lucabello/docker-captain) - A friendly CLI to manage multiple Docker Compose deployments with style — powered by Typer, Rich, questionary, and sh.
- [dockerfile-mode](https://github.com/spotify/dockerfile-mode) - An Emacs mode for handling Dockerfiles.
+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
}
+1711 -2634
View File
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -1,6 +1,6 @@
module github.com/veggiemonk/awesome-docker
go 1.26.0
go 1.26.3
require (
charm.land/bubbletea/v2 v2.0.6
@@ -14,7 +14,7 @@ require (
require (
github.com/charmbracelet/colorprofile v0.4.3 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 // indirect
github.com/charmbracelet/x/ansi v0.11.7 // indirect
github.com/charmbracelet/x/term v0.2.2 // indirect
github.com/charmbracelet/x/termios v0.1.1 // indirect
@@ -30,5 +30,5 @@ require (
github.com/spf13/pflag v1.0.10 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.43.0 // indirect
golang.org/x/sys v0.44.0 // indirect
)
+4
View File
@@ -8,6 +8,8 @@ github.com/charmbracelet/colorprofile v0.4.3 h1:QPa1IWkYI+AOB+fE+mg/5/4HRMZcaXex
github.com/charmbracelet/colorprofile v0.4.3/go.mod h1:/zT4BhpD5aGFpqQQqw7a+VtHCzu+zrQtt1zhMt9mR4Q=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468 h1:Q9fO0y1Zo5KB/5Vu8JZoLGm1N3RzF9bNj3Ao3xoR+Ac=
github.com/charmbracelet/ultraviolet v0.0.0-20260416155717-489999b90468/go.mod h1:bAAz7dh/FTYfC+oiHavL4mX1tOIBZ0ZwYjSi3qE6ivM=
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3 h1:pxGjlWZFcRQMWAdtjRelpL3Gbu8iYIyuO3Eqbd037Ow=
github.com/charmbracelet/ultraviolet v0.0.0-20260511121909-c840852527f3/go.mod h1:SnKWaPaTnkTNXJgdgdquu66de12V8pW/b/qlTGaF9xg=
github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI=
github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ=
github.com/charmbracelet/x/exp/golden v0.0.0-20250806222409-83e3a29d542f h1:pk6gmGpCE7F3FcjaOEKYriCvpmIN4+6OS/RD0vm4uIA=
@@ -56,6 +58,8 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
+7 -2
View File
@@ -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
+14 -12
View File
@@ -38,17 +38,17 @@ func LoadExcludeList(path string) (*ExcludeList, error) {
// HealthEntry stores metadata about a single entry.
type HealthEntry struct {
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"` // 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"`
CheckedAt time.Time `yaml:"checked_at"`
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)
}
}
}
+8 -10
View File
@@ -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),
}
+8 -1
View File
@@ -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,
)
}
}
}
+3 -3
View File
@@ -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 {
+21 -7
View File
@@ -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()
}
+6 -2
View File
@@ -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,
),
})
}
}
+5 -1
View File
@@ -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)
+3 -3
View File
@@ -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
}
+221
View File
@@ -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()
}
+173
View File
@@ -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
View File
@@ -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{
+11 -4
View File
@@ -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")
}
}
+40 -70
View File
@@ -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 {
filterText string
roots []*TreeNode
flatTree []FlatNode
currentEntries []cache.HealthEntry
activePanel panel
treeCursor int
treeOffset int
listCursor int
listOffset int
currentEntries []cache.HealthEntry
width int
height int
filtering bool
filterText string
width, height int
}
// 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()}
}
+4 -1
View File
@@ -13,7 +13,10 @@ var (
BorderForeground(lipgloss.Color("#555555"))
// Tree styles
treeSelectedStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FF79C6")).Background(lipgloss.Color("#3B2D50"))
treeSelectedStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FF79C6")).
Background(lipgloss.Color("#3B2D50"))
treeNormalStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#CCCCCC"))
// Entry styles
+6 -5
View File
@@ -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
+6 -1
View File
@@ -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"},
}