From b80ecfce92ad9ffa2d9bd9de48e00e0b2df459cd Mon Sep 17 00:00:00 2001 From: Ismo Vuorinen Date: Wed, 6 Aug 2025 23:44:32 +0300 Subject: [PATCH] chore: even more linting, test fixes (#24) * chore(lint): funcorder * chore(lint): yamlfmt, ignored broken test yaml files * chore(tests): tests do not output garbage, add coverage * chore(lint): fix editorconfig violations * chore(lint): move from eclint to editorconfig-checker * chore(lint): add pre-commit, run and fix * chore(ci): we use renovate to manage updates --- .eclintignore | 36 -- .editorconfig | 3 + .editorconfig-checker.json | 36 ++ .ghreadme.yaml | 1 + .github/dependabot.yml | 66 ---- .github/renovate.json | 8 +- .github/workflows/ci.yml | 1 + .github/workflows/release.yml | 1 + .gitignore | 1 + .golangci.yml | 3 +- .goreleaser.yaml | 8 +- .mega-linter.yml | 2 +- .pre-commit-config.yaml | 63 ++-- .prettierignore | 5 + .yamlfmt.yml | 18 + .yamlignore | 6 +- Makefile | 156 +++++--- config.yml | 1 + internal/cache/cache.go | 38 +- internal/configuration_loader.go | 142 +++---- internal/dependencies/analyzer.go | 160 ++++---- internal/generator.go | 348 ++++++++++-------- internal/generator_test.go | 24 +- internal/progress_test.go | 21 +- internal/testoutput.go | 120 ++++++ internal/wizard/exporter.go | 52 +-- internal/wizard/validator.go | 54 +-- scripts/release.sh | 8 +- testdata/composite-action/action.yml | 1 + testdata/example-action/action.yml | 1 + testdata/example-action/config.yaml | 1 + .../yaml-fixtures/actions/composite/basic.yml | 1 + .../actions/composite/complex-workflow.yml | 1 + .../actions/composite/with-dependencies.yml | 1 + .../yaml-fixtures/actions/docker/basic.yml | 1 + .../actions/docker/with-environment.yml | 1 + .../actions/javascript/node16.yml | 1 + .../actions/javascript/simple.yml | 1 + .../actions/javascript/with-all-fields.yml | 1 + .../yaml-fixtures/complex-global-config.yml | 1 + testdata/yaml-fixtures/composite-action.yml | 1 + .../configs/global/comprehensive.yml | 1 + .../yaml-fixtures/configs/global/default.yml | 1 + .../configs/repo-specific/github-theme.yml | 1 + testdata/yaml-fixtures/docker-action.yml | 1 + testdata/yaml-fixtures/global-config.yml | 1 + testdata/yaml-fixtures/minimal-action.yml | 1 + testdata/yaml-fixtures/minimal-config.yml | 1 + testdata/yaml-fixtures/my-new-action.yml | 1 + .../yaml-fixtures/professional-config.yml | 1 + testdata/yaml-fixtures/repo-config.yml | 1 + .../scenarios/test-scenarios.yml | 1 + testdata/yaml-fixtures/simple-action.yml | 1 + .../yaml-fixtures/test-composite-action.yml | 1 + .../yaml-fixtures/test-project-action.yml | 1 + .../yaml-fixtures/validation/valid-action.yml | 1 + 56 files changed, 809 insertions(+), 601 deletions(-) delete mode 100644 .eclintignore create mode 100644 .editorconfig-checker.json delete mode 100644 .github/dependabot.yml create mode 100644 .prettierignore create mode 100644 .yamlfmt.yml create mode 100644 internal/testoutput.go mode change 100644 => 100755 scripts/release.sh diff --git a/.eclintignore b/.eclintignore deleted file mode 100644 index b7f3c44..0000000 --- a/.eclintignore +++ /dev/null @@ -1,36 +0,0 @@ -# Ignore patterns for eclint - -# Build artifacts and binaries -gh-action-readme -dist/ -coverage.out -*.exe -*.bin -*.dll -*.so -*.dylib - -# Git directory -.git/ - -# Node modules -node_modules/ - -# OS-specific files -.DS_Store -Thumbs.db - -# IDE files -.vscode/ -.idea/ - -# Temporary files -*.tmp -*.temp - -# Log files -*.log - -# Cache directories -.cache/ -.npm/ diff --git a/.editorconfig b/.editorconfig index 78b4728..5cbf5fa 100644 --- a/.editorconfig +++ b/.editorconfig @@ -31,3 +31,6 @@ max_line_length = 200 [Makefile] indent_style = tab tab_width = 2 + +[{go.sum,go.mod}] +max_line_length = 300 diff --git a/.editorconfig-checker.json b/.editorconfig-checker.json new file mode 100644 index 0000000..f35f0a5 --- /dev/null +++ b/.editorconfig-checker.json @@ -0,0 +1,36 @@ +{ + "Verbose": false, + "Debug": false, + "IgnoreDefaults": false, + "SpacesAfterTabs": false, + "NoColor": false, + "Exclude": [ + "\\.git", + "node_modules", + "coverage\\.out", + "coverage\\.html", + "\\.DS_Store", + "Thumbs\\.db", + "\\.vscode", + "\\.idea", + "\\.cache", + "\\.npm", + "\\.tmp$", + "\\.temp$", + "\\.log$", + "\\.exe$", + "\\.bin$", + "\\.dll$", + "\\.so$", + "\\.dylib$", + "gh-action-readme$", + "dist/", + "testutil\\.test$", + "test_.*", + "testdata/yaml-fixtures/validation/invalid-yaml\\.yml$", + "testdata/yaml-fixtures/validation/missing-.*", + "testdata/yaml-fixtures/actions/invalid/.*", + "testdata/yaml-fixtures/invalid-action\\.yml$", + "testdata/yaml-fixtures/.*-template\\.yml$" + ] +} diff --git a/.ghreadme.yaml b/.ghreadme.yaml index 2081ce4..d1007b7 100644 --- a/.ghreadme.yaml +++ b/.ghreadme.yaml @@ -1,3 +1,4 @@ +--- # Repository-level configuration for gh-action-readme organization: "ivuorinen" repository: "gh-action-readme" diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 973ea04..0000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,66 +0,0 @@ -version: 2 - -updates: - # Go modules - - package-ecosystem: "gomod" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "06:00" - open-pull-requests-limit: 10 - reviewers: - - "ivuorinen" - assignees: - - "ivuorinen" - commit-message: - prefix: "chore(deps)" - include: "scope" - labels: - - "dependencies" - - "security" - # Group security updates - groups: - security-updates: - patterns: - - "*" - update-types: - - "security-update" - - # GitHub Actions - - package-ecosystem: "github-actions" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "06:00" - open-pull-requests-limit: 5 - reviewers: - - "ivuorinen" - assignees: - - "ivuorinen" - commit-message: - prefix: "fix(github-action)" - include: "scope" - labels: - - "dependencies" - - "github-actions" - - # Docker - - package-ecosystem: "docker" - directory: "/" - schedule: - interval: "weekly" - day: "monday" - time: "06:00" - open-pull-requests-limit: 3 - reviewers: - - "ivuorinen" - assignees: - - "ivuorinen" - commit-message: - prefix: "fix(docker)" - include: "scope" - labels: - - "dependencies" - - "docker" diff --git a/.github/renovate.json b/.github/renovate.json index e46316f..f02f654 100644 --- a/.github/renovate.json +++ b/.github/renovate.json @@ -1,6 +1,6 @@ { - "$schema": "https://docs.renovatebot.com/renovate-schema.json", - "extends": [ - "github>ivuorinen/renovate-config" - ] + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>ivuorinen/renovate-config" + ] } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6c32ae1..553eafd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,3 +1,4 @@ +--- name: CI on: push: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 12b247c..5030208 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,3 +1,4 @@ +--- name: Release on: diff --git a/.gitignore b/.gitignore index 77fffe3..0c1bdba 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ go.sum testdata/**/*.md testdata/**/*.html testdata/**/*.json +coverage.* diff --git a/.golangci.yml b/.golangci.yml index 5986afd..7a4e1f8 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -1,3 +1,4 @@ +--- # yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json version: "2" @@ -19,7 +20,7 @@ linters: - errname - exhaustive - forcetypeassert - # - funcorder + - funcorder - goconst - gocritic - gocyclo diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 857b2f3..6add098 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -1,3 +1,4 @@ +--- # GoReleaser configuration for gh-action-readme # See: https://goreleaser.com @@ -52,12 +53,7 @@ archives: - goos: windows format: zip name_template: >- - {{ .ProjectName }}_ - {{- title .Os }}_ - {{- if eq .Arch "amd64" }}x86_64 - {{- else if eq .Arch "386" }}i386 - {{- else }}{{ .Arch }}{{ end }} - {{- if .Arm }}v{{ .Arm }}{{ end }} + {{ .ProjectName }}_ {{- title .Os }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }} files: - README.md - LICENSE* diff --git a/.mega-linter.yml b/.mega-linter.yml index 82e546d..e9a3a10 100644 --- a/.mega-linter.yml +++ b/.mega-linter.yml @@ -31,5 +31,5 @@ MARKDOWN_MARKDOWNLINT_CONFIG_FILE: .markdownlint.json JAVASCRIPT_ES_CONFIG_FILE: .eslintrc.json TYPESCRIPT_ES_CONFIG_FILE: .eslintrc.json -FILTER_REGEX_EXCLUDE: > +FILTER_REGEX_EXCLUDE: >- (node_modules|\.automation/test|docs/json-schemas|\.github/workflows) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ccfa22d..717235d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,12 +1,13 @@ --- repos: + # Built-in pre-commit hooks - repo: https://github.com/pre-commit/pre-commit-hooks rev: v5.0.0 hooks: - - id: requirements-txt-fixer - id: detect-private-key - id: trailing-whitespace args: [--markdown-linebreak-ext=md] + exclude: "^testdata/" - id: check-case-conflict - id: check-merge-conflict - id: check-executables-have-shebangs @@ -16,48 +17,54 @@ repos: - id: check-xml - id: check-yaml args: [--allow-multiple-documents] + exclude: "^testdata/" - id: end-of-file-fixer + exclude: "^testdata/" - id: mixed-line-ending args: [--fix=auto] - id: pretty-format-json args: [--autofix, --no-sort-keys] - - repo: https://github.com/igorshubovych/markdownlint-cli - rev: v0.44.0 + # YAML formatting with yamlfmt (replaces yamllint for formatting) + - repo: https://github.com/google/yamlfmt + rev: v0.17.2 hooks: - - id: markdownlint - args: [-c, .markdownlint.json, --fix] + - id: yamlfmt + exclude: "^testdata/" - - repo: https://github.com/adrienverge/yamllint - rev: v1.37.0 + # Markdown linting with markdownlint-cli2 (excluding legacy files) + - repo: https://github.com/DavidAnson/markdownlint-cli2 + rev: v0.18.1 hooks: - - id: yamllint + - id: markdownlint-cli2 + exclude: '^(testdata/|CHANGELOG\.md|TODO\.md|\.github/|CLAUDE\.md|README\.md|SECURITY\.md)' + # EditorConfig checking + - repo: https://github.com/editorconfig-checker/editorconfig-checker + rev: v3.3.0 + hooks: + - id: editorconfig-checker + alias: ec + + # Go formatting, imports, and linting + - repo: https://github.com/TekWizely/pre-commit-golang + rev: v1.0.0-rc.2 + hooks: + - id: go-imports-repo + args: [-w] + - id: go-mod-tidy + - id: golangci-lint-repo-mod + args: [--fix] + + # Shell formatting and linting - repo: https://github.com/scop/pre-commit-shfmt - rev: v3.11.0-1 + rev: v3.12.0-2 hooks: - id: shfmt - - repo: https://github.com/koalaman/shellcheck-precommit - rev: v0.10.0 - hooks: - - id: shellcheck - args: ['--severity=warning'] - + # GitHub Actions linting - repo: https://github.com/rhysd/actionlint rev: v1.7.7 hooks: - id: actionlint - args: ['-shellcheck='] - - - repo: https://github.com/renovatebot/pre-commit-hooks - rev: 39.227.2 - hooks: - - id: renovate-config-validator - - - repo: https://github.com/bridgecrewio/checkov.git - rev: '3.2.400' - hooks: - - id: checkov - args: - - '--quiet' + args: ["-shellcheck="] diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..76cea55 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +testdata/yaml-fixtures/validation/invalid-yaml.yml +testdata/yaml-fixtures/validation/missing-* +testdata/yaml-fixtures/actions/invalid/* +testdata/yaml-fixtures/invalid-action.yml +testdata/yaml-fixtures/*-template.yml diff --git a/.yamlfmt.yml b/.yamlfmt.yml new file mode 100644 index 0000000..f4eb0d3 --- /dev/null +++ b/.yamlfmt.yml @@ -0,0 +1,18 @@ +--- +# yaml-language-server: $schema=https://raw.githubusercontent.com/google/yamlfmt/main/schema.json +doublestar: true +formatter: + type: basic + include_document_start: true + retain_line_breaks: true + retain_line_breaks_single: true + trim_trailing_whitespace: true + eof_newline: true + max_line_length: 300 +gitignore_excludes: true +exclude: + - testdata/yaml-fixtures/validation/invalid-yaml.yml + - testdata/yaml-fixtures/validation/missing-* + - testdata/yaml-fixtures/actions/invalid/* + - testdata/yaml-fixtures/invalid-action.yml + - testdata/yaml-fixtures/*-template.yml diff --git a/.yamlignore b/.yamlignore index 8b13789..1cee2b5 100644 --- a/.yamlignore +++ b/.yamlignore @@ -1 +1,5 @@ - +testdata/yaml-fixtures/validation/invalid-yaml.yml +testdata/yaml-fixtures/validation/missing-* +testdata/yaml-fixtures/actions/invalid/* +testdata/yaml-fixtures/invalid-action.ym +testdata/yaml-fixtures/*-template.yml diff --git a/Makefile b/Makefile index 4772eb0..8f3ee6e 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,6 @@ -.PHONY: help test lint run example clean readme config-verify security vulncheck audit snyk trivy gitleaks \ - editorconfig editorconfig-fix format +.PHONY: help test test-coverage test-coverage-html lint build run example \ + clean readme config-verify security vulncheck audit snyk trivy gitleaks \ + editorconfig editorconfig-fix format devtools pre-commit-install pre-commit-update all: help @@ -10,18 +11,66 @@ help: ## Show this help message awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' @echo "" @echo "Common workflows:" - @echo " make test lint # Run tests and linting" - @echo " make format # Format code and fix EditorConfig issues" - @echo " make security # Run all security scans" + @echo " make devtools # Install all development tools" + @echo " make pre-commit-install # Install pre-commit hooks (run once)" + @echo " make build # Build the application binary" + @echo " make test lint # Run tests and all linters via pre-commit" + @echo " make test-coverage # Run tests with coverage analysis" + @echo " make pre-commit-update # Update pre-commit hooks to latest versions" + @echo " make security # Run all security scans" test: ## Run all tests go test ./... -lint: format ## Run linter (after formatting) - golangci-lint run \ - --max-issues-per-linter 100 \ - --max-same-issues 50 \ - --output.tab.path stdout || true +test-coverage: ## Run tests with coverage and display in CLI + @echo "Running tests with coverage analysis..." + @go test ./... -coverprofile=coverage.out -covermode=atomic + @echo "" + @echo "=== Coverage Summary ===" + @go tool cover -func=coverage.out | tail -1 + @echo "" + @echo "=== Package Coverage Details ===" + @go tool cover -func=coverage.out | grep -v "total:" | \ + awk '{printf "%-50s %s\n", $$1, $$3}' | \ + sort -k2 -nr + @echo "" + @echo "Coverage report saved to: coverage.out" + @echo "Run 'make test-coverage-html' to generate HTML report" + +test-coverage-html: test-coverage ## Generate HTML coverage report and open in browser + @echo "Generating HTML coverage report..." + @go tool cover -html=coverage.out -o coverage.html + @echo "HTML coverage report generated: coverage.html" + @if command -v open >/dev/null 2>&1; then \ + echo "Opening coverage report in browser..."; \ + open coverage.html; \ + elif command -v xdg-open >/dev/null 2>&1; then \ + echo "Opening coverage report in browser..."; \ + xdg-open coverage.html; \ + else \ + echo "Open coverage.html in your browser to view detailed coverage"; \ + fi + +lint: ## Run all linters via pre-commit + @echo "Running all linters via pre-commit..." + @command -v pre-commit >/dev/null 2>&1 || \ + { echo "Please install pre-commit or run 'make devtools'"; exit 1; } + pre-commit run --all-files + +pre-commit-install: ## Install pre-commit hooks + @echo "Installing pre-commit hooks..." + @command -v pre-commit >/dev/null 2>&1 || \ + { echo "Please install pre-commit or run 'make devtools'"; exit 1; } + pre-commit install + +pre-commit-update: ## Update pre-commit hooks to latest versions + @echo "Updating pre-commit hooks..." + @command -v pre-commit >/dev/null 2>&1 || \ + { echo "Please install pre-commit or run 'make devtools'"; exit 1; } + pre-commit autoupdate + +build: ## Build the application + go build -o gh-action-readme . config-verify: ## Verify golangci-lint configuration golangci-lint config verify --verbose @@ -37,6 +86,7 @@ readme: ## Generate project README clean: ## Clean build artifacts rm -rf dist/ + rm -f gh-action-readme coverage.out coverage.html # Code formatting and EditorConfig targets format: editorconfig-fix ## Format code and fix EditorConfig issues @@ -48,43 +98,59 @@ format: editorconfig-fix ## Format code and fix EditorConfig issues editorconfig: ## Check EditorConfig compliance @echo "Checking EditorConfig compliance..." - @command -v eclint >/dev/null 2>&1 || \ - { echo "Please install eclint: npm install -g eclint"; exit 1; } - @echo "Checking files for EditorConfig compliance..." - @find . -type f \( \ - -name "*.go" -o \ - -name "*.yml" -o \ - -name "*.yaml" -o \ - -name "*.json" -o \ - -name "*.md" -o \ - -name "Makefile" -o \ - -name ".snyk" -o \ - -name "*.tmpl" -o \ - -name "*.adoc" -o \ - -name "*.sh" \ - \) -not -path "./gh-action-readme" -not -path "./coverage*" \ - -not -path "./testutil.test" -not -path "./test_*" | \ - xargs eclint check + @command -v editorconfig-checker >/dev/null 2>&1 || \ + { echo "Please install editorconfig-checker or run 'make devtools'"; exit 1; } + editorconfig-checker editorconfig-fix: ## Fix EditorConfig violations - @echo "Fixing EditorConfig violations..." - @command -v eclint >/dev/null 2>&1 || \ - { echo "Please install eclint: npm install -g eclint"; exit 1; } - @echo "Fixing files for EditorConfig compliance..." - @find . -type f \( \ - -name "*.go" -o \ - -name "*.yml" -o \ - -name "*.yaml" -o \ - -name "*.json" -o \ - -name "*.md" -o \ - -name "Makefile" -o \ - -name ".snyk" -o \ - -name "*.tmpl" -o \ - -name "*.adoc" -o \ - -name "*.sh" \ - \) -not -path "./gh-action-readme" -not -path "./coverage*" \ - -not -path "./testutil.test" -not -path "./test_*" | \ - xargs eclint fix + @echo "EditorConfig violations cannot be automatically fixed by editorconfig-checker" + @echo "Please fix the reported issues manually or use your editor's EditorConfig plugin" + @echo "Running check to show issues..." + @command -v editorconfig-checker >/dev/null 2>&1 || \ + { echo "Please install editorconfig-checker or run 'make devtools'"; exit 1; } + editorconfig-checker + +# Development tools installation +devtools: ## Install all development tools + @echo "Installing development tools..." + @echo "" + @echo "=== Go Tools ===" + @command -v golangci-lint >/dev/null 2>&1 || \ + { echo "Installing golangci-lint..."; \ + curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | \ + sh -s -- -b $(go env GOPATH)/bin; } + @command -v govulncheck >/dev/null 2>&1 || \ + { echo "Installing govulncheck..."; go install golang.org/x/vuln/cmd/govulncheck@latest; } + @command -v editorconfig-checker >/dev/null 2>&1 || \ + { echo "Installing editorconfig-checker..."; \ + go install github.com/editorconfig-checker/editorconfig-checker/v3/cmd/editorconfig-checker@latest; } + @command -v yamlfmt >/dev/null 2>&1 || \ + { echo "Installing yamlfmt..."; go install github.com/google/yamlfmt/cmd/yamlfmt@latest; } + @echo "✓ Go tools installed" + @echo "" + @echo "=== Node.js Tools ===" + @command -v npm >/dev/null 2>&1 || \ + { echo "❌ npm not found. Please install Node.js first."; exit 1; } + @command -v snyk >/dev/null 2>&1 || \ + { echo "Installing snyk..."; npm install -g snyk; } + @echo "✓ Node.js tools installed" + @echo "" + @echo "=== Python Tools ===" + @command -v python3 >/dev/null 2>&1 || \ + { echo "❌ python3 not found. Please install Python 3 first."; exit 1; } + @command -v pre-commit >/dev/null 2>&1 || \ + { echo "Installing pre-commit..."; pip install pre-commit; } + @echo "✓ Python tools installed" + @echo "" + @echo "=== System Tools ===" + @command -v trivy >/dev/null 2>&1 || \ + { echo "❌ trivy not found. Please install manually: https://aquasecurity.github.io/trivy/"; } + @command -v gitleaks >/dev/null 2>&1 || \ + { echo "❌ gitleaks not found. Please install manually: https://github.com/gitleaks/gitleaks"; } + @echo "✓ System tools check completed" + @echo "" + @echo "🎉 Development tools installation completed!" + @echo " Run 'make test lint' to verify everything works." # Security targets security: vulncheck snyk trivy gitleaks ## Run all security scans diff --git a/config.yml b/config.yml index 75ff2b5..11c9e84 100644 --- a/config.yml +++ b/config.yml @@ -1,3 +1,4 @@ +--- # Default configuration for gh-action-readme defaults: name: "GitHub Action" diff --git a/internal/cache/cache.go b/internal/cache/cache.go index aaca161..a54ba7c 100644 --- a/internal/cache/cache.go +++ b/internal/cache/cache.go @@ -196,6 +196,25 @@ func (c *Cache) Close() error { return c.saveToDisk() } +// GetOrSet retrieves a value from cache or sets it if not found. +func (c *Cache) GetOrSet(key string, getter func() (any, error)) (any, error) { + // Try to get from cache first + if value, exists := c.Get(key); exists { + return value, nil + } + + // Not in cache, get from source + value, err := getter() + if err != nil { + return nil, err + } + + // Store in cache + _ = c.Set(key, value) // Log error but don't fail - we have the value + + return value, nil +} + // cleanupLoop runs periodically to remove expired entries. func (c *Cache) cleanupLoop() { for { @@ -289,22 +308,3 @@ func (c *Cache) estimateSize(value any) int64 { return int64(len(jsonData)) } - -// GetOrSet retrieves a value from cache or sets it if not found. -func (c *Cache) GetOrSet(key string, getter func() (any, error)) (any, error) { - // Try to get from cache first - if value, exists := c.Get(key); exists { - return value, nil - } - - // Not in cache, get from source - value, err := getter() - if err != nil { - return nil, err - } - - // Store in cache - _ = c.Set(key, value) // Log error but don't fail - we have the value - - return value, nil -} diff --git a/internal/configuration_loader.go b/internal/configuration_loader.go index ed826ee..22a97f0 100644 --- a/internal/configuration_loader.go +++ b/internal/configuration_loader.go @@ -108,6 +108,77 @@ func (cl *ConfigurationLoader) LoadConfiguration(configFile, repoRoot, actionDir return config, nil } +// LoadGlobalConfig loads only the global configuration. +func (cl *ConfigurationLoader) LoadGlobalConfig(configFile string) (*AppConfig, error) { + return cl.loadGlobalConfig(configFile) +} + +// ValidateConfiguration validates a configuration for consistency and required values. +func (cl *ConfigurationLoader) ValidateConfiguration(config *AppConfig) error { + if config == nil { + return errors.New("configuration cannot be nil") + } + + // Validate output format + validFormats := []string{"md", "html", "json", "asciidoc"} + if !containsString(validFormats, config.OutputFormat) { + return fmt.Errorf("invalid output format '%s', must be one of: %s", + config.OutputFormat, strings.Join(validFormats, ", ")) + } + + // Validate theme (if set) + if config.Theme != "" { + if err := cl.validateTheme(config.Theme); err != nil { + return fmt.Errorf("invalid theme: %w", err) + } + } + + // Validate output directory + if config.OutputDir == "" { + return errors.New("output directory cannot be empty") + } + + // Validate mutually exclusive flags + if config.Verbose && config.Quiet { + return errors.New("verbose and quiet flags are mutually exclusive") + } + + return nil +} + +// containsString checks if a slice contains a string. +func containsString(slice []string, str string) bool { + for _, s := range slice { + if s == str { + return true + } + } + + return false +} + +// GetConfigurationSources returns the currently enabled configuration sources. +func (cl *ConfigurationLoader) GetConfigurationSources() []ConfigurationSource { + var sources []ConfigurationSource + for source, enabled := range cl.sources { + if enabled { + sources = append(sources, source) + } + } + + return sources +} + +// EnableSource enables a specific configuration source. +func (cl *ConfigurationLoader) EnableSource(source ConfigurationSource) { + cl.sources[source] = true +} + +// DisableSource disables a specific configuration source. +func (cl *ConfigurationLoader) DisableSource(source ConfigurationSource) { + cl.sources[source] = false +} + // loadDefaultsStep loads default configuration values. func (cl *ConfigurationLoader) loadDefaultsStep(config *AppConfig) { if cl.sources[SourceDefaults] { @@ -177,44 +248,6 @@ func (cl *ConfigurationLoader) loadEnvironmentStep(config *AppConfig) { } } -// LoadGlobalConfig loads only the global configuration. -func (cl *ConfigurationLoader) LoadGlobalConfig(configFile string) (*AppConfig, error) { - return cl.loadGlobalConfig(configFile) -} - -// ValidateConfiguration validates a configuration for consistency and required values. -func (cl *ConfigurationLoader) ValidateConfiguration(config *AppConfig) error { - if config == nil { - return errors.New("configuration cannot be nil") - } - - // Validate output format - validFormats := []string{"md", "html", "json", "asciidoc"} - if !containsString(validFormats, config.OutputFormat) { - return fmt.Errorf("invalid output format '%s', must be one of: %s", - config.OutputFormat, strings.Join(validFormats, ", ")) - } - - // Validate theme (if set) - if config.Theme != "" { - if err := cl.validateTheme(config.Theme); err != nil { - return fmt.Errorf("invalid theme: %w", err) - } - } - - // Validate output directory - if config.OutputDir == "" { - return errors.New("output directory cannot be empty") - } - - // Validate mutually exclusive flags - if config.Verbose && config.Quiet { - return errors.New("verbose and quiet flags are mutually exclusive") - } - - return nil -} - // loadGlobalConfig initializes and loads the global configuration using Viper. func (cl *ConfigurationLoader) loadGlobalConfig(configFile string) (*AppConfig, error) { v := viper.New() @@ -396,39 +429,6 @@ func (cl *ConfigurationLoader) validateTheme(theme string) error { theme, strings.Join(supportedThemes, ", ")) } -// containsString checks if a slice contains a string. -func containsString(slice []string, str string) bool { - for _, s := range slice { - if s == str { - return true - } - } - - return false -} - -// GetConfigurationSources returns the currently enabled configuration sources. -func (cl *ConfigurationLoader) GetConfigurationSources() []ConfigurationSource { - var sources []ConfigurationSource - for source, enabled := range cl.sources { - if enabled { - sources = append(sources, source) - } - } - - return sources -} - -// EnableSource enables a specific configuration source. -func (cl *ConfigurationLoader) EnableSource(source ConfigurationSource) { - cl.sources[source] = true -} - -// DisableSource disables a specific configuration source. -func (cl *ConfigurationLoader) DisableSource(source ConfigurationSource) { - cl.sources[source] = false -} - // String returns a string representation of a ConfigurationSource. func (s ConfigurationSource) String() string { switch s { diff --git a/internal/dependencies/analyzer.go b/internal/dependencies/analyzer.go index 0f1ff09..29395fb 100644 --- a/internal/dependencies/analyzer.go +++ b/internal/dependencies/analyzer.go @@ -168,6 +168,85 @@ func (a *Analyzer) AnalyzeActionFileWithProgress( return a.processCompositeSteps(action.Runs.Steps, progressCallback) } +// CheckOutdated analyzes dependencies and finds those with newer versions available. +func (a *Analyzer) CheckOutdated(deps []Dependency) ([]OutdatedDependency, error) { + var outdated []OutdatedDependency + + for _, dep := range deps { + if dep.IsShellScript || dep.IsLocalAction { + continue // Skip shell scripts and local actions + } + + owner, repo, currentVersion, _ := a.parseUsesStatement(dep.Uses) + if owner == "" || repo == "" { + continue + } + + latestVersion, latestSHA, err := a.getLatestVersion(owner, repo) + if err != nil { + continue // Skip on error, don't fail the whole operation + } + + updateType := a.compareVersions(currentVersion, latestVersion) + if updateType != updateTypeNone { + outdated = append(outdated, OutdatedDependency{ + Current: dep, + LatestVersion: latestVersion, + LatestSHA: latestSHA, + UpdateType: updateType, + IsSecurityUpdate: updateType == updateTypeMajor, // Assume major updates might be security + }) + } + } + + return outdated, nil +} + +// GeneratePinnedUpdate creates a pinned update for a dependency. +func (a *Analyzer) GeneratePinnedUpdate( + actionPath string, + dep Dependency, + latestVersion, latestSHA string, +) (*PinnedUpdate, error) { + if latestSHA == "" { + return nil, fmt.Errorf("no commit SHA available for %s", dep.Uses) + } + + // Create the new pinned uses string: "owner/repo@sha # version" + owner, repo, currentVersion, _ := a.parseUsesStatement(dep.Uses) + newUses := fmt.Sprintf("%s/%s@%s # %s", owner, repo, latestSHA, latestVersion) + + updateType := a.compareVersions(currentVersion, latestVersion) + + return &PinnedUpdate{ + FilePath: actionPath, + OldUses: dep.Uses, + NewUses: newUses, + CommitSHA: latestSHA, + Version: latestVersion, + UpdateType: updateType, + LineNumber: 0, // Will be determined during file update + }, nil +} + +// ApplyPinnedUpdates applies pinned updates to action files. +func (a *Analyzer) ApplyPinnedUpdates(updates []PinnedUpdate) error { + // Group updates by file path + updatesByFile := make(map[string][]PinnedUpdate) + for _, update := range updates { + updatesByFile[update.FilePath] = append(updatesByFile[update.FilePath], update) + } + + // Apply updates to each file + for filePath, fileUpdates := range updatesByFile { + if err := a.updateActionFile(filePath, fileUpdates); err != nil { + return fmt.Errorf("failed to update %s: %w", filePath, err) + } + } + + return nil +} + // validateAndCheckComposite validates action type and checks if it's composite. func (a *Analyzer) validateAndCheckComposite( action *ActionWithComposite, @@ -244,8 +323,6 @@ func (a *Analyzer) processStep(step CompositeStep, stepNumber int) *Dependency { return nil } -// parseCompositeAction is implemented in parser.go - // analyzeActionDependency analyzes a single action dependency. func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependency, error) { // Parse the uses statement @@ -405,40 +482,6 @@ func (a *Analyzer) convertWithParams(with map[string]any) map[string]string { return params } -// CheckOutdated analyzes dependencies and finds those with newer versions available. -func (a *Analyzer) CheckOutdated(deps []Dependency) ([]OutdatedDependency, error) { - var outdated []OutdatedDependency - - for _, dep := range deps { - if dep.IsShellScript || dep.IsLocalAction { - continue // Skip shell scripts and local actions - } - - owner, repo, currentVersion, _ := a.parseUsesStatement(dep.Uses) - if owner == "" || repo == "" { - continue - } - - latestVersion, latestSHA, err := a.getLatestVersion(owner, repo) - if err != nil { - continue // Skip on error, don't fail the whole operation - } - - updateType := a.compareVersions(currentVersion, latestVersion) - if updateType != updateTypeNone { - outdated = append(outdated, OutdatedDependency{ - Current: dep, - LatestVersion: latestVersion, - LatestSHA: latestSHA, - UpdateType: updateType, - IsSecurityUpdate: updateType == updateTypeMajor, // Assume major updates might be security - }) - } - } - - return outdated, nil -} - // getLatestVersion fetches the latest release/tag for a repository. func (a *Analyzer) getLatestVersion(owner, repo string) (version, sha string, err error) { if a.GitHubClient == nil { @@ -584,51 +627,6 @@ func (a *Analyzer) determineUpdateType(currentParts, latestParts []string) strin return updateTypeNone } -// GeneratePinnedUpdate creates a pinned update for a dependency. -func (a *Analyzer) GeneratePinnedUpdate( - actionPath string, - dep Dependency, - latestVersion, latestSHA string, -) (*PinnedUpdate, error) { - if latestSHA == "" { - return nil, fmt.Errorf("no commit SHA available for %s", dep.Uses) - } - - // Create the new pinned uses string: "owner/repo@sha # version" - owner, repo, currentVersion, _ := a.parseUsesStatement(dep.Uses) - newUses := fmt.Sprintf("%s/%s@%s # %s", owner, repo, latestSHA, latestVersion) - - updateType := a.compareVersions(currentVersion, latestVersion) - - return &PinnedUpdate{ - FilePath: actionPath, - OldUses: dep.Uses, - NewUses: newUses, - CommitSHA: latestSHA, - Version: latestVersion, - UpdateType: updateType, - LineNumber: 0, // Will be determined during file update - }, nil -} - -// ApplyPinnedUpdates applies pinned updates to action files. -func (a *Analyzer) ApplyPinnedUpdates(updates []PinnedUpdate) error { - // Group updates by file path - updatesByFile := make(map[string][]PinnedUpdate) - for _, update := range updates { - updatesByFile[update.FilePath] = append(updatesByFile[update.FilePath], update) - } - - // Apply updates to each file - for filePath, fileUpdates := range updatesByFile { - if err := a.updateActionFile(filePath, fileUpdates); err != nil { - return fmt.Errorf("failed to update %s: %w", filePath, err) - } - } - - return nil -} - // updateActionFile applies updates to a single action file. func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) error { // Read the file diff --git a/internal/generator.go b/internal/generator.go index ec3bf3e..ffdb471 100644 --- a/internal/generator.go +++ b/internal/generator.go @@ -34,9 +34,39 @@ type Generator struct { Progress ProgressManager } +// isUnitTestEnvironment detects if we're running unit tests (not integration tests). +func isUnitTestEnvironment() bool { + // Only enable for unit tests, not integration tests + // Integration tests need real output to verify CLI behavior + + // Check if we're in the internal package tests + if strings.Contains(os.Args[0], "internal.test") || + strings.Contains(os.Args[0], "T/go-build") && strings.Contains(os.Args[0], "internal") { + return true + } + + // Check for explicit unit test environment variable + if os.Getenv("UNIT_TEST_MODE") != "" { + return true + } + + return false +} + // NewGenerator creates a new generator instance with the provided configuration. // This constructor maintains backward compatibility by using concrete implementations. +// In unit test environments, it automatically uses NullOutput to suppress output. func NewGenerator(config *AppConfig) *Generator { + // Use null output in unit test environments to keep tests clean + // Integration tests need real output to verify CLI behavior + if isUnitTestEnvironment() { + return NewGeneratorWithDependencies( + config, + NewNullOutput(), + NewNullProgressManager(), + ) + } + return NewGeneratorWithDependencies( config, NewColoredOutput(config.Quiet), @@ -115,76 +145,116 @@ func (g *Generator) GenerateFromFile(actionPath string) error { return g.generateByFormat(action, outputDir, actionPath) } -// parseAndValidateAction parses and validates an action.yml file. -func (g *Generator) parseAndValidateAction(actionPath string) (*ActionYML, error) { - action, err := ParseActionYML(actionPath) +// DiscoverActionFiles finds action.yml and action.yaml files in the given directory +// using the centralized parser function and adds verbose logging. +func (g *Generator) DiscoverActionFiles(dir string, recursive bool) ([]string, error) { + actionFiles, err := DiscoverActionFiles(dir, recursive) if err != nil { - return nil, fmt.Errorf("failed to parse action file %s: %w", actionPath, err) + return nil, err } - validationResult := ValidateActionYML(action) - if len(validationResult.MissingFields) > 0 { - // Check for critical validation errors that cannot be fixed with defaults - for _, field := range validationResult.MissingFields { - // All core required fields should cause validation failure - if field == "name" || field == "description" || field == "runs" || field == "runs.using" { - // Required fields missing - cannot be fixed with defaults, must fail - return nil, fmt.Errorf( - "action file %s has invalid configuration, missing required field(s): %v", - actionPath, - validationResult.MissingFields, - ) + // Add verbose logging + if g.Config.Verbose { + for _, file := range actionFiles { + if recursive { + g.Output.Info("Discovered action file: %s", file) + } else { + g.Output.Info("Found action file: %s", file) } } + } - if g.Config.Verbose { - g.Output.Warning("Missing fields in %s: %v", actionPath, validationResult.MissingFields) - } - FillMissing(action, g.Config.Defaults) - if g.Config.Verbose { - g.Output.Info("Applied default values for missing fields") + return actionFiles, nil +} + +// DiscoverActionFilesWithValidation discovers action files with centralized error handling and validation. +// This function consolidates the duplicated file discovery logic across the codebase. +func (g *Generator) DiscoverActionFilesWithValidation(dir string, recursive bool, context string) ([]string, error) { + // Discover action files + actionFiles, err := g.DiscoverActionFiles(dir, recursive) + if err != nil { + g.Output.ErrorWithContext( + errCodes.ErrCodeFileNotFound, + "failed to discover action files for "+context, + map[string]string{ + "directory": dir, + "recursive": strconv.FormatBool(recursive), + "context": context, + ContextKeyError: err.Error(), + }, + ) + + return nil, err + } + + // Check if any files were found + if len(actionFiles) == 0 { + contextMsg := "no GitHub Action files found for " + context + g.Output.ErrorWithContext( + errCodes.ErrCodeNoActionFiles, + contextMsg, + map[string]string{ + "directory": dir, + "recursive": strconv.FormatBool(recursive), + "context": context, + "suggestion": "Please run this command in a directory containing GitHub Action files (action.yml or action.yaml)", + }, + ) + + return nil, fmt.Errorf("no action files found in directory: %s", dir) + } + + return actionFiles, nil +} + +// ProcessBatch processes multiple action.yml files. +func (g *Generator) ProcessBatch(paths []string) error { + if len(paths) == 0 { + return errors.New("no action files to process") + } + + bar := g.Progress.CreateProgressBarForFiles("Processing files", paths) + errors, successCount := g.processFiles(paths, bar) + g.Progress.FinishProgressBarWithNewline(bar) + g.reportResults(successCount, errors) + + if len(errors) > 0 { + return fmt.Errorf("encountered %d errors during batch processing", len(errors)) + } + + return nil +} + +// ValidateFiles validates multiple action.yml files and reports results. +func (g *Generator) ValidateFiles(paths []string) error { + if len(paths) == 0 { + return errors.New("no action files to validate") + } + + bar := g.Progress.CreateProgressBarForFiles("Validating files", paths) + allResults, errors := g.validateFiles(paths, bar) + g.Progress.FinishProgressBarWithNewline(bar) + + if !g.Config.Quiet { + g.reportValidationResults(allResults, errors) + } + + // Count validation failures (files with missing required fields) + validationFailures := 0 + for _, result := range allResults { + // Each result starts with "file: " so check if there are actual missing fields beyond that + if len(result.MissingFields) > 1 { + validationFailures++ } } - return action, nil -} + if len(errors) > 0 || validationFailures > 0 { + totalFailures := len(errors) + validationFailures -// determineOutputDir calculates the output directory for generated files. -func (g *Generator) determineOutputDir(actionPath string) string { - if g.Config.OutputDir == "" || g.Config.OutputDir == "." { - return filepath.Dir(actionPath) + return fmt.Errorf("validation failed for %d files", totalFailures) } - return g.Config.OutputDir -} - -// resolveOutputPath resolves the final output path, considering custom filename. -func (g *Generator) resolveOutputPath(outputDir, defaultFilename string) string { - if g.Config.OutputFilename != "" { - if filepath.IsAbs(g.Config.OutputFilename) { - return g.Config.OutputFilename - } - - return filepath.Join(outputDir, g.Config.OutputFilename) - } - - return filepath.Join(outputDir, defaultFilename) -} - -// generateByFormat generates documentation in the specified format. -func (g *Generator) generateByFormat(action *ActionYML, outputDir, actionPath string) error { - switch g.Config.OutputFormat { - case "md": - return g.generateMarkdown(action, outputDir, actionPath) - case OutputFormatHTML: - return g.generateHTML(action, outputDir, actionPath) - case OutputFormatJSON: - return g.generateJSON(action, outputDir) - case OutputFormatASCIIDoc: - return g.generateASCIIDoc(action, outputDir, actionPath) - default: - return fmt.Errorf("unsupported output format: %s", g.Config.OutputFormat) - } + return nil } // generateMarkdown creates a README.md file using the template. @@ -311,86 +381,6 @@ func (g *Generator) generateASCIIDoc(action *ActionYML, outputDir, actionPath st return nil } -// DiscoverActionFiles finds action.yml and action.yaml files in the given directory -// using the centralized parser function and adds verbose logging. -func (g *Generator) DiscoverActionFiles(dir string, recursive bool) ([]string, error) { - actionFiles, err := DiscoverActionFiles(dir, recursive) - if err != nil { - return nil, err - } - - // Add verbose logging - if g.Config.Verbose { - for _, file := range actionFiles { - if recursive { - g.Output.Info("Discovered action file: %s", file) - } else { - g.Output.Info("Found action file: %s", file) - } - } - } - - return actionFiles, nil -} - -// DiscoverActionFilesWithValidation discovers action files with centralized error handling and validation. -// This function consolidates the duplicated file discovery logic across the codebase. -func (g *Generator) DiscoverActionFilesWithValidation(dir string, recursive bool, context string) ([]string, error) { - // Discover action files - actionFiles, err := g.DiscoverActionFiles(dir, recursive) - if err != nil { - g.Output.ErrorWithContext( - errCodes.ErrCodeFileNotFound, - "failed to discover action files for "+context, - map[string]string{ - "directory": dir, - "recursive": strconv.FormatBool(recursive), - "context": context, - ContextKeyError: err.Error(), - }, - ) - - return nil, err - } - - // Check if any files were found - if len(actionFiles) == 0 { - contextMsg := "no GitHub Action files found for " + context - g.Output.ErrorWithContext( - errCodes.ErrCodeNoActionFiles, - contextMsg, - map[string]string{ - "directory": dir, - "recursive": strconv.FormatBool(recursive), - "context": context, - "suggestion": "Please run this command in a directory containing GitHub Action files (action.yml or action.yaml)", - }, - ) - - return nil, fmt.Errorf("no action files found in directory: %s", dir) - } - - return actionFiles, nil -} - -// ProcessBatch processes multiple action.yml files. -func (g *Generator) ProcessBatch(paths []string) error { - if len(paths) == 0 { - return errors.New("no action files to process") - } - - bar := g.Progress.CreateProgressBarForFiles("Processing files", paths) - errors, successCount := g.processFiles(paths, bar) - g.Progress.FinishProgressBarWithNewline(bar) - g.reportResults(successCount, errors) - - if len(errors) > 0 { - return fmt.Errorf("encountered %d errors during batch processing", len(errors)) - } - - return nil -} - // processFiles processes each file and tracks results. func (g *Generator) processFiles(paths []string, bar *progressbar.ProgressBar) ([]string, int) { var errors []string @@ -429,36 +419,76 @@ func (g *Generator) reportResults(successCount int, errors []string) { } } -// ValidateFiles validates multiple action.yml files and reports results. -func (g *Generator) ValidateFiles(paths []string) error { - if len(paths) == 0 { - return errors.New("no action files to validate") +// parseAndValidateAction parses and validates an action.yml file. +func (g *Generator) parseAndValidateAction(actionPath string) (*ActionYML, error) { + action, err := ParseActionYML(actionPath) + if err != nil { + return nil, fmt.Errorf("failed to parse action file %s: %w", actionPath, err) } - bar := g.Progress.CreateProgressBarForFiles("Validating files", paths) - allResults, errors := g.validateFiles(paths, bar) - g.Progress.FinishProgressBarWithNewline(bar) + validationResult := ValidateActionYML(action) + if len(validationResult.MissingFields) > 0 { + // Check for critical validation errors that cannot be fixed with defaults + for _, field := range validationResult.MissingFields { + // All core required fields should cause validation failure + if field == "name" || field == "description" || field == "runs" || field == "runs.using" { + // Required fields missing - cannot be fixed with defaults, must fail + return nil, fmt.Errorf( + "action file %s has invalid configuration, missing required field(s): %v", + actionPath, + validationResult.MissingFields, + ) + } + } - if !g.Config.Quiet { - g.reportValidationResults(allResults, errors) - } - - // Count validation failures (files with missing required fields) - validationFailures := 0 - for _, result := range allResults { - // Each result starts with "file: " so check if there are actual missing fields beyond that - if len(result.MissingFields) > 1 { - validationFailures++ + if g.Config.Verbose { + g.Output.Warning("Missing fields in %s: %v", actionPath, validationResult.MissingFields) + } + FillMissing(action, g.Config.Defaults) + if g.Config.Verbose { + g.Output.Info("Applied default values for missing fields") } } - if len(errors) > 0 || validationFailures > 0 { - totalFailures := len(errors) + validationFailures + return action, nil +} - return fmt.Errorf("validation failed for %d files", totalFailures) +// determineOutputDir calculates the output directory for generated files. +func (g *Generator) determineOutputDir(actionPath string) string { + if g.Config.OutputDir == "" || g.Config.OutputDir == "." { + return filepath.Dir(actionPath) } - return nil + return g.Config.OutputDir +} + +// resolveOutputPath resolves the final output path, considering custom filename. +func (g *Generator) resolveOutputPath(outputDir, defaultFilename string) string { + if g.Config.OutputFilename != "" { + if filepath.IsAbs(g.Config.OutputFilename) { + return g.Config.OutputFilename + } + + return filepath.Join(outputDir, g.Config.OutputFilename) + } + + return filepath.Join(outputDir, defaultFilename) +} + +// generateByFormat generates documentation in the specified format. +func (g *Generator) generateByFormat(action *ActionYML, outputDir, actionPath string) error { + switch g.Config.OutputFormat { + case "md": + return g.generateMarkdown(action, outputDir, actionPath) + case OutputFormatHTML: + return g.generateHTML(action, outputDir, actionPath) + case OutputFormatJSON: + return g.generateJSON(action, outputDir) + case OutputFormatASCIIDoc: + return g.generateASCIIDoc(action, outputDir, actionPath) + default: + return fmt.Errorf("unsupported output format: %s", g.Config.OutputFormat) + } } // validateFiles processes each file for validation. diff --git a/internal/generator_test.go b/internal/generator_test.go index 6ded318..49d39f9 100644 --- a/internal/generator_test.go +++ b/internal/generator_test.go @@ -563,19 +563,18 @@ func TestGenerator_WithDifferentThemes(t *testing.T) { t.Parallel() themes := []string{"default", "github", "gitlab", "minimal", "professional"} - tmpDir, cleanup := testutil.TempDir(t) - defer cleanup() - - // Set up test templates - testutil.SetupTestTemplates(t, tmpDir) - - actionPath := filepath.Join(tmpDir, "action.yml") - testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml")) - for _, theme := range themes { t.Run("theme_"+theme, func(t *testing.T) { t.Parallel() - // Templates are now embedded, no working directory changes needed + // Create separate temp directory for each theme test + tmpDir, cleanup := testutil.TempDir(t) + defer cleanup() + + // Set up test templates for this theme test + testutil.SetupTestTemplates(t, tmpDir) + + actionPath := filepath.Join(tmpDir, "action.yml") + testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml")) config := &AppConfig{ Theme: theme, @@ -596,11 +595,6 @@ func TestGenerator_WithDifferentThemes(t *testing.T) { if len(readmeFiles) == 0 { t.Errorf("no output file was created for theme %s", theme) } - - // Clean up for next test - for _, file := range readmeFiles { - _ = os.Remove(file) - } }) } } diff --git a/internal/progress_test.go b/internal/progress_test.go index 445a145..55ef54c 100644 --- a/internal/progress_test.go +++ b/internal/progress_test.go @@ -78,35 +78,34 @@ func TestProgressBarManager_CreateProgressBarForFiles(t *testing.T) { func TestProgressBarManager_FinishProgressBar(t *testing.T) { t.Parallel() - pm := NewProgressBarManager(false) + // Use quiet mode to avoid cluttering test output + pm := NewProgressBarManager(true) // Test with nil bar (should not panic) pm.FinishProgressBar(nil) - // Test with actual bar + // Test with actual bar (will be nil in quiet mode) bar := pm.CreateProgressBar("Test", 5) - if bar != nil { - pm.FinishProgressBar(bar) - } + pm.FinishProgressBar(bar) // Should handle nil gracefully } func TestProgressBarManager_UpdateProgressBar(t *testing.T) { t.Parallel() - pm := NewProgressBarManager(false) + // Use quiet mode to avoid cluttering test output + pm := NewProgressBarManager(true) // Test with nil bar (should not panic) pm.UpdateProgressBar(nil) - // Test with actual bar + // Test with actual bar (will be nil in quiet mode) bar := pm.CreateProgressBar("Test", 5) - if bar != nil { - pm.UpdateProgressBar(bar) - } + pm.UpdateProgressBar(bar) // Should handle nil gracefully } func TestProgressBarManager_ProcessWithProgressBar(t *testing.T) { t.Parallel() - pm := NewProgressBarManager(false) + // Use NullProgressManager to avoid cluttering test output + pm := NewNullProgressManager() items := []string{"item1", "item2", "item3"} processedItems := make([]string, 0) diff --git a/internal/testoutput.go b/internal/testoutput.go new file mode 100644 index 0000000..81de95f --- /dev/null +++ b/internal/testoutput.go @@ -0,0 +1,120 @@ +package internal + +import ( + "os" + + "github.com/schollz/progressbar/v3" + + "github.com/ivuorinen/gh-action-readme/internal/errors" +) + +// NullOutput is a no-op implementation of CompleteOutput for testing. +// All methods are no-ops to prevent cluttering test output. +type NullOutput struct{} + +// Compile-time interface checks. +var ( + _ MessageLogger = (*NullOutput)(nil) + _ ErrorReporter = (*NullOutput)(nil) + _ ErrorFormatter = (*NullOutput)(nil) + _ ProgressReporter = (*NullOutput)(nil) + _ OutputConfig = (*NullOutput)(nil) + _ CompleteOutput = (*NullOutput)(nil) +) + +// NewNullOutput creates a new null output instance for testing. +func NewNullOutput() *NullOutput { + return &NullOutput{} +} + +// IsQuiet returns true as null output is always quiet. +func (no *NullOutput) IsQuiet() bool { + return true +} + +// Success is a no-op. +func (no *NullOutput) Success(_ string, _ ...any) {} + +// Error is a no-op. +func (no *NullOutput) Error(_ string, _ ...any) {} + +// Warning is a no-op. +func (no *NullOutput) Warning(_ string, _ ...any) {} + +// Info is a no-op. +func (no *NullOutput) Info(_ string, _ ...any) {} + +// Progress is a no-op. +func (no *NullOutput) Progress(_ string, _ ...any) {} + +// Bold is a no-op. +func (no *NullOutput) Bold(_ string, _ ...any) {} + +// Printf is a no-op. +func (no *NullOutput) Printf(_ string, _ ...any) {} + +// Fprintf is a no-op. +func (no *NullOutput) Fprintf(_ *os.File, _ string, _ ...any) {} + +// ErrorWithSuggestions is a no-op. +func (no *NullOutput) ErrorWithSuggestions(_ *errors.ContextualError) {} + +// ErrorWithContext is a no-op. +func (no *NullOutput) ErrorWithContext( + _ errors.ErrorCode, + _ string, + _ map[string]string, +) { +} + +// ErrorWithSimpleFix is a no-op. +func (no *NullOutput) ErrorWithSimpleFix(_, _ string) {} + +// FormatContextualError returns empty string. +func (no *NullOutput) FormatContextualError(_ *errors.ContextualError) string { + return "" +} + +// NullProgressManager is a no-op implementation of ProgressManager for testing. +type NullProgressManager struct{} + +// Compile-time interface check. +var _ ProgressManager = (*NullProgressManager)(nil) + +// NewNullProgressManager creates a new null progress manager for testing. +func NewNullProgressManager() *NullProgressManager { + return &NullProgressManager{} +} + +// CreateProgressBar returns nil to suppress progress bars. +func (npm *NullProgressManager) CreateProgressBar(_ string, _ int) *progressbar.ProgressBar { + return nil +} + +// CreateProgressBarForFiles returns nil to suppress progress bars. +func (npm *NullProgressManager) CreateProgressBarForFiles( + _ string, + _ []string, +) *progressbar.ProgressBar { + return nil +} + +// FinishProgressBar is a no-op. +func (npm *NullProgressManager) FinishProgressBar(_ *progressbar.ProgressBar) {} + +// FinishProgressBarWithNewline is a no-op. +func (npm *NullProgressManager) FinishProgressBarWithNewline(_ *progressbar.ProgressBar) {} + +// UpdateProgressBar is a no-op. +func (npm *NullProgressManager) UpdateProgressBar(_ *progressbar.ProgressBar) {} + +// ProcessWithProgressBar executes the function for each item without progress display. +func (npm *NullProgressManager) ProcessWithProgressBar( + _ string, + items []string, + processFunc func(item string, bar *progressbar.ProgressBar), +) { + for _, item := range items { + processFunc(item, nil) + } +} diff --git a/internal/wizard/exporter.go b/internal/wizard/exporter.go index 744f40b..22311e2 100644 --- a/internal/wizard/exporter.go +++ b/internal/wizard/exporter.go @@ -55,6 +55,32 @@ func (e *ConfigExporter) ExportConfig(config *internal.AppConfig, format ExportF } } +// GetSupportedFormats returns the list of supported export formats. +func (e *ConfigExporter) GetSupportedFormats() []ExportFormat { + return []ExportFormat{FormatYAML, FormatJSON, FormatTOML} +} + +// GetDefaultOutputPath returns the default output path for a given format. +func (e *ConfigExporter) GetDefaultOutputPath(format ExportFormat) (string, error) { + configPath, err := internal.GetConfigPath() + if err != nil { + return "", fmt.Errorf("failed to get config directory: %w", err) + } + + dir := filepath.Dir(configPath) + + switch format { + case FormatYAML: + return filepath.Join(dir, "config.yaml"), nil + case FormatJSON: + return filepath.Join(dir, "config.json"), nil + case FormatTOML: + return filepath.Join(dir, "config.toml"), nil + default: + return "", fmt.Errorf("unsupported format: %s", format) + } +} + // exportYAML exports configuration as YAML. func (e *ConfigExporter) exportYAML(config *internal.AppConfig, outputPath string) error { // Create a clean config without sensitive data for export @@ -260,29 +286,3 @@ func (e *ConfigExporter) writeVariablesSection(file *os.File, config *internal.A _, _ = fmt.Fprintf(file, "%s = %q\n", key, value) } } - -// GetSupportedFormats returns the list of supported export formats. -func (e *ConfigExporter) GetSupportedFormats() []ExportFormat { - return []ExportFormat{FormatYAML, FormatJSON, FormatTOML} -} - -// GetDefaultOutputPath returns the default output path for a given format. -func (e *ConfigExporter) GetDefaultOutputPath(format ExportFormat) (string, error) { - configPath, err := internal.GetConfigPath() - if err != nil { - return "", fmt.Errorf("failed to get config directory: %w", err) - } - - dir := filepath.Dir(configPath) - - switch format { - case FormatYAML: - return filepath.Join(dir, "config.yaml"), nil - case FormatJSON: - return filepath.Join(dir, "config.json"), nil - case FormatTOML: - return filepath.Join(dir, "config.toml"), nil - default: - return "", fmt.Errorf("unsupported format: %s", format) - } -} diff --git a/internal/wizard/validator.go b/internal/wizard/validator.go index 34910ab..6a1278b 100644 --- a/internal/wizard/validator.go +++ b/internal/wizard/validator.go @@ -109,6 +109,33 @@ func (v *ConfigValidator) ValidateField(fieldName, value string) *ValidationResu return result } +// DisplayValidationResult displays validation results to the user. +func (v *ConfigValidator) DisplayValidationResult(result *ValidationResult) { + if result.Valid { + v.output.Success("✅ Configuration is valid") + } else { + v.output.Error("❌ Configuration has errors") + } + + // Display errors + for _, err := range result.Errors { + v.output.Error(" • %s: %s (value: %s)", err.Field, err.Message, err.Value) + } + + // Display warnings + for _, warning := range result.Warnings { + v.output.Warning(" ⚠️ %s: %s", warning.Field, warning.Message) + } + + // Display suggestions + if len(result.Suggestions) > 0 { + v.output.Info("\nSuggestions:") + for _, suggestion := range result.Suggestions { + v.output.Printf(" 💡 %s", suggestion) + } + } +} + // validateOrganization validates the organization field. func (v *ConfigValidator) validateOrganization(org string, result *ValidationResult) { if org == "" { @@ -478,30 +505,3 @@ func (v *ConfigValidator) isValidVariableName(name string) bool { return matched } - -// DisplayValidationResult displays validation results to the user. -func (v *ConfigValidator) DisplayValidationResult(result *ValidationResult) { - if result.Valid { - v.output.Success("✅ Configuration is valid") - } else { - v.output.Error("❌ Configuration has errors") - } - - // Display errors - for _, err := range result.Errors { - v.output.Error(" • %s: %s (value: %s)", err.Field, err.Message, err.Value) - } - - // Display warnings - for _, warning := range result.Warnings { - v.output.Warning(" ⚠️ %s: %s", warning.Field, warning.Message) - } - - // Display suggestions - if len(result.Suggestions) > 0 { - v.output.Info("\nSuggestions:") - for _, suggestion := range result.Suggestions { - v.output.Printf(" 💡 %s", suggestion) - } - } -} diff --git a/scripts/release.sh b/scripts/release.sh old mode 100644 new mode 100755 index f99e9ca..111c9c4 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -44,13 +44,13 @@ fi # Get version from command line or prompt VERSION="$1" -if [[ -z "$VERSION" ]]; then +if [[ -z $VERSION ]]; then echo -n "Enter version (e.g., v1.0.0): " read -r VERSION fi # Validate version format -if [[ ! "$VERSION" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then +if [[ ! $VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then log_error "Version must be in format vX.Y.Z (e.g., v1.0.0)" exit 1 fi @@ -59,11 +59,11 @@ log_info "Preparing release $VERSION" # Check if we're on main branch CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) -if [[ "$CURRENT_BRANCH" != "main" ]]; then +if [[ $CURRENT_BRANCH != "main" ]]; then log_warning "You're not on the main branch (current: $CURRENT_BRANCH)" echo -n "Continue anyway? (y/N): " read -r CONTINUE - if [[ "$CONTINUE" != "y" && "$CONTINUE" != "Y" ]]; then + if [[ $CONTINUE != "y" && $CONTINUE != "Y" ]]; then log_info "Aborted" exit 0 fi diff --git a/testdata/composite-action/action.yml b/testdata/composite-action/action.yml index fbb6acb..328cc8b 100644 --- a/testdata/composite-action/action.yml +++ b/testdata/composite-action/action.yml @@ -1,3 +1,4 @@ +--- name: Composite Example Action description: 'Test Composite Action for gh-action-readme dependency analysis' inputs: diff --git a/testdata/example-action/action.yml b/testdata/example-action/action.yml index 2aa0e1c..d859260 100644 --- a/testdata/example-action/action.yml +++ b/testdata/example-action/action.yml @@ -1,3 +1,4 @@ +--- name: Example Action description: 'Test Action for gh-action-readme' inputs: diff --git a/testdata/example-action/config.yaml b/testdata/example-action/config.yaml index 0fa00b0..8d4ea7e 100644 --- a/testdata/example-action/config.yaml +++ b/testdata/example-action/config.yaml @@ -1,3 +1,4 @@ +--- # Action-specific configuration theme: "github" variables: diff --git a/testdata/yaml-fixtures/actions/composite/basic.yml b/testdata/yaml-fixtures/actions/composite/basic.yml index 1042e57..20d830a 100644 --- a/testdata/yaml-fixtures/actions/composite/basic.yml +++ b/testdata/yaml-fixtures/actions/composite/basic.yml @@ -1,3 +1,4 @@ +--- name: 'Basic Composite Action' description: 'A simple composite action with basic steps' inputs: diff --git a/testdata/yaml-fixtures/actions/composite/complex-workflow.yml b/testdata/yaml-fixtures/actions/composite/complex-workflow.yml index 48e3705..3a4e5b7 100644 --- a/testdata/yaml-fixtures/actions/composite/complex-workflow.yml +++ b/testdata/yaml-fixtures/actions/composite/complex-workflow.yml @@ -1,3 +1,4 @@ +--- name: 'Complex Composite Workflow' description: 'A complex composite action demonstrating advanced features' inputs: diff --git a/testdata/yaml-fixtures/actions/composite/with-dependencies.yml b/testdata/yaml-fixtures/actions/composite/with-dependencies.yml index fad1a08..1fd2484 100644 --- a/testdata/yaml-fixtures/actions/composite/with-dependencies.yml +++ b/testdata/yaml-fixtures/actions/composite/with-dependencies.yml @@ -1,3 +1,4 @@ +--- name: 'Composite Action with Dependencies' description: 'A composite action that uses external actions' inputs: diff --git a/testdata/yaml-fixtures/actions/docker/basic.yml b/testdata/yaml-fixtures/actions/docker/basic.yml index 3d8bddb..703de1e 100644 --- a/testdata/yaml-fixtures/actions/docker/basic.yml +++ b/testdata/yaml-fixtures/actions/docker/basic.yml @@ -1,3 +1,4 @@ +--- name: 'Basic Docker Action' description: 'A simple Docker-based action' inputs: diff --git a/testdata/yaml-fixtures/actions/docker/with-environment.yml b/testdata/yaml-fixtures/actions/docker/with-environment.yml index df94403..d64243e 100644 --- a/testdata/yaml-fixtures/actions/docker/with-environment.yml +++ b/testdata/yaml-fixtures/actions/docker/with-environment.yml @@ -1,3 +1,4 @@ +--- name: 'Docker Action with Environment' description: 'Docker action with environment variables and advanced configuration' inputs: diff --git a/testdata/yaml-fixtures/actions/javascript/node16.yml b/testdata/yaml-fixtures/actions/javascript/node16.yml index bea4f0b..e740eda 100644 --- a/testdata/yaml-fixtures/actions/javascript/node16.yml +++ b/testdata/yaml-fixtures/actions/javascript/node16.yml @@ -1,3 +1,4 @@ +--- name: 'Node.js 16 Action' description: 'JavaScript action running on Node.js 16' inputs: diff --git a/testdata/yaml-fixtures/actions/javascript/simple.yml b/testdata/yaml-fixtures/actions/javascript/simple.yml index ae8ee19..098eb11 100644 --- a/testdata/yaml-fixtures/actions/javascript/simple.yml +++ b/testdata/yaml-fixtures/actions/javascript/simple.yml @@ -1,3 +1,4 @@ +--- name: 'Simple JavaScript Action' description: 'A simple JavaScript action for testing' inputs: diff --git a/testdata/yaml-fixtures/actions/javascript/with-all-fields.yml b/testdata/yaml-fixtures/actions/javascript/with-all-fields.yml index 5b2278d..9d348ed 100644 --- a/testdata/yaml-fixtures/actions/javascript/with-all-fields.yml +++ b/testdata/yaml-fixtures/actions/javascript/with-all-fields.yml @@ -1,3 +1,4 @@ +--- name: 'Comprehensive JavaScript Action' description: 'A JavaScript action with all possible fields for testing' author: 'Test Author ' diff --git a/testdata/yaml-fixtures/complex-global-config.yml b/testdata/yaml-fixtures/complex-global-config.yml index 6d069cb..496ee0d 100644 --- a/testdata/yaml-fixtures/complex-global-config.yml +++ b/testdata/yaml-fixtures/complex-global-config.yml @@ -1,3 +1,4 @@ +--- theme: default output_format: md repo_overrides: diff --git a/testdata/yaml-fixtures/composite-action.yml b/testdata/yaml-fixtures/composite-action.yml index d10c3f2..39280fd 100644 --- a/testdata/yaml-fixtures/composite-action.yml +++ b/testdata/yaml-fixtures/composite-action.yml @@ -1,3 +1,4 @@ +--- name: 'Composite Action' description: 'A composite action with multiple steps' inputs: diff --git a/testdata/yaml-fixtures/configs/global/comprehensive.yml b/testdata/yaml-fixtures/configs/global/comprehensive.yml index 23e6304..7a1fc44 100644 --- a/testdata/yaml-fixtures/configs/global/comprehensive.yml +++ b/testdata/yaml-fixtures/configs/global/comprehensive.yml @@ -1,3 +1,4 @@ +--- theme: professional output_format: html output_dir: docs diff --git a/testdata/yaml-fixtures/configs/global/default.yml b/testdata/yaml-fixtures/configs/global/default.yml index afd81b8..04355ed 100644 --- a/testdata/yaml-fixtures/configs/global/default.yml +++ b/testdata/yaml-fixtures/configs/global/default.yml @@ -1,3 +1,4 @@ +--- theme: default output_format: md output_dir: . diff --git a/testdata/yaml-fixtures/configs/repo-specific/github-theme.yml b/testdata/yaml-fixtures/configs/repo-specific/github-theme.yml index 0cda3e9..09280ca 100644 --- a/testdata/yaml-fixtures/configs/repo-specific/github-theme.yml +++ b/testdata/yaml-fixtures/configs/repo-specific/github-theme.yml @@ -1,3 +1,4 @@ +--- theme: github output_format: md output_dir: docs diff --git a/testdata/yaml-fixtures/docker-action.yml b/testdata/yaml-fixtures/docker-action.yml index 149e447..82592f0 100644 --- a/testdata/yaml-fixtures/docker-action.yml +++ b/testdata/yaml-fixtures/docker-action.yml @@ -1,3 +1,4 @@ +--- name: 'Docker Action' description: 'A Docker-based GitHub Action' inputs: diff --git a/testdata/yaml-fixtures/global-config.yml b/testdata/yaml-fixtures/global-config.yml index a1db034..1f40288 100644 --- a/testdata/yaml-fixtures/global-config.yml +++ b/testdata/yaml-fixtures/global-config.yml @@ -1,3 +1,4 @@ +--- theme: default output_format: md github_token: global-token diff --git a/testdata/yaml-fixtures/minimal-action.yml b/testdata/yaml-fixtures/minimal-action.yml index 7b8f5bd..d0c1a55 100644 --- a/testdata/yaml-fixtures/minimal-action.yml +++ b/testdata/yaml-fixtures/minimal-action.yml @@ -1,3 +1,4 @@ +--- name: 'Minimal Action' description: 'Minimal test action' runs: diff --git a/testdata/yaml-fixtures/minimal-config.yml b/testdata/yaml-fixtures/minimal-config.yml index 733c4c3..de69a89 100644 --- a/testdata/yaml-fixtures/minimal-config.yml +++ b/testdata/yaml-fixtures/minimal-config.yml @@ -1,2 +1,3 @@ +--- theme: minimal github_token: config-token diff --git a/testdata/yaml-fixtures/my-new-action.yml b/testdata/yaml-fixtures/my-new-action.yml index 57917a2..aeb7757 100644 --- a/testdata/yaml-fixtures/my-new-action.yml +++ b/testdata/yaml-fixtures/my-new-action.yml @@ -1,3 +1,4 @@ +--- name: 'My New Action' description: 'A brand new GitHub Action' inputs: diff --git a/testdata/yaml-fixtures/professional-config.yml b/testdata/yaml-fixtures/professional-config.yml index 0375dc7..39509da 100644 --- a/testdata/yaml-fixtures/professional-config.yml +++ b/testdata/yaml-fixtures/professional-config.yml @@ -1,3 +1,4 @@ +--- theme: professional output_format: html output_dir: docs diff --git a/testdata/yaml-fixtures/repo-config.yml b/testdata/yaml-fixtures/repo-config.yml index 0cda3e9..09280ca 100644 --- a/testdata/yaml-fixtures/repo-config.yml +++ b/testdata/yaml-fixtures/repo-config.yml @@ -1,3 +1,4 @@ +--- theme: github output_format: md output_dir: docs diff --git a/testdata/yaml-fixtures/scenarios/test-scenarios.yml b/testdata/yaml-fixtures/scenarios/test-scenarios.yml index 9f173c6..f8e7e45 100644 --- a/testdata/yaml-fixtures/scenarios/test-scenarios.yml +++ b/testdata/yaml-fixtures/scenarios/test-scenarios.yml @@ -1,3 +1,4 @@ +--- scenarios: # JavaScript Action Scenarios - id: "simple-javascript" diff --git a/testdata/yaml-fixtures/simple-action.yml b/testdata/yaml-fixtures/simple-action.yml index 9bb9732..9a1b1a8 100644 --- a/testdata/yaml-fixtures/simple-action.yml +++ b/testdata/yaml-fixtures/simple-action.yml @@ -1,3 +1,4 @@ +--- name: 'Simple Action' description: 'A simple GitHub Action for testing' inputs: diff --git a/testdata/yaml-fixtures/test-composite-action.yml b/testdata/yaml-fixtures/test-composite-action.yml index afb3a97..f52c84c 100644 --- a/testdata/yaml-fixtures/test-composite-action.yml +++ b/testdata/yaml-fixtures/test-composite-action.yml @@ -1,3 +1,4 @@ +--- name: 'Test Composite Action' description: 'Test action for update testing' runs: diff --git a/testdata/yaml-fixtures/test-project-action.yml b/testdata/yaml-fixtures/test-project-action.yml index 3f56e4a..df9ef2f 100644 --- a/testdata/yaml-fixtures/test-project-action.yml +++ b/testdata/yaml-fixtures/test-project-action.yml @@ -1,3 +1,4 @@ +--- name: 'Test Project Action' description: 'A GitHub Action for testing project functionality' inputs: diff --git a/testdata/yaml-fixtures/validation/valid-action.yml b/testdata/yaml-fixtures/validation/valid-action.yml index 3c5d319..7777080 100644 --- a/testdata/yaml-fixtures/validation/valid-action.yml +++ b/testdata/yaml-fixtures/validation/valid-action.yml @@ -1,3 +1,4 @@ +--- name: Test Action description: A test action runs: