mirror of
https://github.com/veggiemonk/awesome-docker.git
synced 2026-06-30 18:40: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:
@@ -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
|
||||
@@ -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
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
)
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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
+14
-12
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+40
-70
@@ -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()}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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