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
This commit is contained in:
2025-08-06 23:44:32 +03:00
committed by GitHub
parent c5a7ced768
commit b80ecfce92
56 changed files with 809 additions and 601 deletions

View File

@@ -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/

View File

@@ -31,3 +31,6 @@ max_line_length = 200
[Makefile] [Makefile]
indent_style = tab indent_style = tab
tab_width = 2 tab_width = 2
[{go.sum,go.mod}]
max_line_length = 300

View File

@@ -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$"
]
}

View File

@@ -1,3 +1,4 @@
---
# Repository-level configuration for gh-action-readme # Repository-level configuration for gh-action-readme
organization: "ivuorinen" organization: "ivuorinen"
repository: "gh-action-readme" repository: "gh-action-readme"

View File

@@ -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"

View File

@@ -1,6 +1,6 @@
{ {
"$schema": "https://docs.renovatebot.com/renovate-schema.json", "$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": [ "extends": [
"github>ivuorinen/renovate-config" "github>ivuorinen/renovate-config"
] ]
} }

View File

@@ -1,3 +1,4 @@
---
name: CI name: CI
on: on:
push: push:

View File

@@ -1,3 +1,4 @@
---
name: Release name: Release
on: on:

1
.gitignore vendored
View File

@@ -32,3 +32,4 @@ go.sum
testdata/**/*.md testdata/**/*.md
testdata/**/*.html testdata/**/*.html
testdata/**/*.json testdata/**/*.json
coverage.*

View File

@@ -1,3 +1,4 @@
---
# yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json # yaml-language-server: $schema=https://golangci-lint.run/jsonschema/golangci.jsonschema.json
version: "2" version: "2"
@@ -19,7 +20,7 @@ linters:
- errname - errname
- exhaustive - exhaustive
- forcetypeassert - forcetypeassert
# - funcorder - funcorder
- goconst - goconst
- gocritic - gocritic
- gocyclo - gocyclo

View File

@@ -1,3 +1,4 @@
---
# GoReleaser configuration for gh-action-readme # GoReleaser configuration for gh-action-readme
# See: https://goreleaser.com # See: https://goreleaser.com
@@ -52,12 +53,7 @@ archives:
- goos: windows - goos: windows
format: zip format: zip
name_template: >- name_template: >-
{{ .ProjectName }}_ {{ .ProjectName }}_ {{- title .Os }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} {{- if .Arm }}v{{ .Arm }}{{ end }}
{{- title .Os }}_
{{- if eq .Arch "amd64" }}x86_64
{{- else if eq .Arch "386" }}i386
{{- else }}{{ .Arch }}{{ end }}
{{- if .Arm }}v{{ .Arm }}{{ end }}
files: files:
- README.md - README.md
- LICENSE* - LICENSE*

View File

@@ -31,5 +31,5 @@ MARKDOWN_MARKDOWNLINT_CONFIG_FILE: .markdownlint.json
JAVASCRIPT_ES_CONFIG_FILE: .eslintrc.json JAVASCRIPT_ES_CONFIG_FILE: .eslintrc.json
TYPESCRIPT_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) (node_modules|\.automation/test|docs/json-schemas|\.github/workflows)

View File

@@ -1,12 +1,13 @@
--- ---
repos: repos:
# Built-in pre-commit hooks
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0 rev: v5.0.0
hooks: hooks:
- id: requirements-txt-fixer
- id: detect-private-key - id: detect-private-key
- id: trailing-whitespace - id: trailing-whitespace
args: [--markdown-linebreak-ext=md] args: [--markdown-linebreak-ext=md]
exclude: "^testdata/"
- id: check-case-conflict - id: check-case-conflict
- id: check-merge-conflict - id: check-merge-conflict
- id: check-executables-have-shebangs - id: check-executables-have-shebangs
@@ -16,48 +17,54 @@ repos:
- id: check-xml - id: check-xml
- id: check-yaml - id: check-yaml
args: [--allow-multiple-documents] args: [--allow-multiple-documents]
exclude: "^testdata/"
- id: end-of-file-fixer - id: end-of-file-fixer
exclude: "^testdata/"
- id: mixed-line-ending - id: mixed-line-ending
args: [--fix=auto] args: [--fix=auto]
- id: pretty-format-json - id: pretty-format-json
args: [--autofix, --no-sort-keys] args: [--autofix, --no-sort-keys]
- repo: https://github.com/igorshubovych/markdownlint-cli # YAML formatting with yamlfmt (replaces yamllint for formatting)
rev: v0.44.0 - repo: https://github.com/google/yamlfmt
rev: v0.17.2
hooks: hooks:
- id: markdownlint - id: yamlfmt
args: [-c, .markdownlint.json, --fix] exclude: "^testdata/"
- repo: https://github.com/adrienverge/yamllint # Markdown linting with markdownlint-cli2 (excluding legacy files)
rev: v1.37.0 - repo: https://github.com/DavidAnson/markdownlint-cli2
rev: v0.18.1
hooks: 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 - repo: https://github.com/scop/pre-commit-shfmt
rev: v3.11.0-1 rev: v3.12.0-2
hooks: hooks:
- id: shfmt - id: shfmt
- repo: https://github.com/koalaman/shellcheck-precommit # GitHub Actions linting
rev: v0.10.0
hooks:
- id: shellcheck
args: ['--severity=warning']
- repo: https://github.com/rhysd/actionlint - repo: https://github.com/rhysd/actionlint
rev: v1.7.7 rev: v1.7.7
hooks: hooks:
- id: actionlint - id: actionlint
args: ['-shellcheck='] 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'

5
.prettierignore Normal file
View File

@@ -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

18
.yamlfmt.yml Normal file
View File

@@ -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

View File

@@ -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

156
Makefile
View File

@@ -1,5 +1,6 @@
.PHONY: help test lint run example clean readme config-verify security vulncheck audit snyk trivy gitleaks \ .PHONY: help test test-coverage test-coverage-html lint build run example \
editorconfig editorconfig-fix format clean readme config-verify security vulncheck audit snyk trivy gitleaks \
editorconfig editorconfig-fix format devtools pre-commit-install pre-commit-update
all: help all: help
@@ -10,18 +11,66 @@ help: ## Show this help message
awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}' awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-20s\033[0m %s\n", $$1, $$2}'
@echo "" @echo ""
@echo "Common workflows:" @echo "Common workflows:"
@echo " make test lint # Run tests and linting" @echo " make devtools # Install all development tools"
@echo " make format # Format code and fix EditorConfig issues" @echo " make pre-commit-install # Install pre-commit hooks (run once)"
@echo " make security # Run all security scans" @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 test: ## Run all tests
go test ./... go test ./...
lint: format ## Run linter (after formatting) test-coverage: ## Run tests with coverage and display in CLI
golangci-lint run \ @echo "Running tests with coverage analysis..."
--max-issues-per-linter 100 \ @go test ./... -coverprofile=coverage.out -covermode=atomic
--max-same-issues 50 \ @echo ""
--output.tab.path stdout || true @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 config-verify: ## Verify golangci-lint configuration
golangci-lint config verify --verbose golangci-lint config verify --verbose
@@ -37,6 +86,7 @@ readme: ## Generate project README
clean: ## Clean build artifacts clean: ## Clean build artifacts
rm -rf dist/ rm -rf dist/
rm -f gh-action-readme coverage.out coverage.html
# Code formatting and EditorConfig targets # Code formatting and EditorConfig targets
format: editorconfig-fix ## Format code and fix EditorConfig issues 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 editorconfig: ## Check EditorConfig compliance
@echo "Checking EditorConfig compliance..." @echo "Checking EditorConfig compliance..."
@command -v eclint >/dev/null 2>&1 || \ @command -v editorconfig-checker >/dev/null 2>&1 || \
{ echo "Please install eclint: npm install -g eclint"; exit 1; } { echo "Please install editorconfig-checker or run 'make devtools'"; exit 1; }
@echo "Checking files for EditorConfig compliance..." editorconfig-checker
@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
editorconfig-fix: ## Fix EditorConfig violations editorconfig-fix: ## Fix EditorConfig violations
@echo "Fixing EditorConfig violations..." @echo "EditorConfig violations cannot be automatically fixed by editorconfig-checker"
@command -v eclint >/dev/null 2>&1 || \ @echo "Please fix the reported issues manually or use your editor's EditorConfig plugin"
{ echo "Please install eclint: npm install -g eclint"; exit 1; } @echo "Running check to show issues..."
@echo "Fixing files for EditorConfig compliance..." @command -v editorconfig-checker >/dev/null 2>&1 || \
@find . -type f \( \ { echo "Please install editorconfig-checker or run 'make devtools'"; exit 1; }
-name "*.go" -o \ editorconfig-checker
-name "*.yml" -o \
-name "*.yaml" -o \ # Development tools installation
-name "*.json" -o \ devtools: ## Install all development tools
-name "*.md" -o \ @echo "Installing development tools..."
-name "Makefile" -o \ @echo ""
-name ".snyk" -o \ @echo "=== Go Tools ==="
-name "*.tmpl" -o \ @command -v golangci-lint >/dev/null 2>&1 || \
-name "*.adoc" -o \ { echo "Installing golangci-lint..."; \
-name "*.sh" \ curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | \
\) -not -path "./gh-action-readme" -not -path "./coverage*" \ sh -s -- -b $(go env GOPATH)/bin; }
-not -path "./testutil.test" -not -path "./test_*" | \ @command -v govulncheck >/dev/null 2>&1 || \
xargs eclint fix { 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 targets
security: vulncheck snyk trivy gitleaks ## Run all security scans security: vulncheck snyk trivy gitleaks ## Run all security scans

View File

@@ -1,3 +1,4 @@
---
# Default configuration for gh-action-readme # Default configuration for gh-action-readme
defaults: defaults:
name: "GitHub Action" name: "GitHub Action"

View File

@@ -196,6 +196,25 @@ func (c *Cache) Close() error {
return c.saveToDisk() 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. // cleanupLoop runs periodically to remove expired entries.
func (c *Cache) cleanupLoop() { func (c *Cache) cleanupLoop() {
for { for {
@@ -289,22 +308,3 @@ func (c *Cache) estimateSize(value any) int64 {
return int64(len(jsonData)) 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
}

View File

@@ -108,6 +108,77 @@ func (cl *ConfigurationLoader) LoadConfiguration(configFile, repoRoot, actionDir
return config, nil 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. // loadDefaultsStep loads default configuration values.
func (cl *ConfigurationLoader) loadDefaultsStep(config *AppConfig) { func (cl *ConfigurationLoader) loadDefaultsStep(config *AppConfig) {
if cl.sources[SourceDefaults] { 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. // loadGlobalConfig initializes and loads the global configuration using Viper.
func (cl *ConfigurationLoader) loadGlobalConfig(configFile string) (*AppConfig, error) { func (cl *ConfigurationLoader) loadGlobalConfig(configFile string) (*AppConfig, error) {
v := viper.New() v := viper.New()
@@ -396,39 +429,6 @@ func (cl *ConfigurationLoader) validateTheme(theme string) error {
theme, strings.Join(supportedThemes, ", ")) 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. // String returns a string representation of a ConfigurationSource.
func (s ConfigurationSource) String() string { func (s ConfigurationSource) String() string {
switch s { switch s {

View File

@@ -168,6 +168,85 @@ func (a *Analyzer) AnalyzeActionFileWithProgress(
return a.processCompositeSteps(action.Runs.Steps, progressCallback) 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. // validateAndCheckComposite validates action type and checks if it's composite.
func (a *Analyzer) validateAndCheckComposite( func (a *Analyzer) validateAndCheckComposite(
action *ActionWithComposite, action *ActionWithComposite,
@@ -244,8 +323,6 @@ func (a *Analyzer) processStep(step CompositeStep, stepNumber int) *Dependency {
return nil return nil
} }
// parseCompositeAction is implemented in parser.go
// analyzeActionDependency analyzes a single action dependency. // analyzeActionDependency analyzes a single action dependency.
func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependency, error) { func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependency, error) {
// Parse the uses statement // Parse the uses statement
@@ -405,40 +482,6 @@ func (a *Analyzer) convertWithParams(with map[string]any) map[string]string {
return params 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. // getLatestVersion fetches the latest release/tag for a repository.
func (a *Analyzer) getLatestVersion(owner, repo string) (version, sha string, err error) { func (a *Analyzer) getLatestVersion(owner, repo string) (version, sha string, err error) {
if a.GitHubClient == nil { if a.GitHubClient == nil {
@@ -584,51 +627,6 @@ func (a *Analyzer) determineUpdateType(currentParts, latestParts []string) strin
return updateTypeNone 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. // updateActionFile applies updates to a single action file.
func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) error { func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) error {
// Read the file // Read the file

View File

@@ -34,9 +34,39 @@ type Generator struct {
Progress ProgressManager 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. // NewGenerator creates a new generator instance with the provided configuration.
// This constructor maintains backward compatibility by using concrete implementations. // 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 { 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( return NewGeneratorWithDependencies(
config, config,
NewColoredOutput(config.Quiet), NewColoredOutput(config.Quiet),
@@ -115,76 +145,116 @@ func (g *Generator) GenerateFromFile(actionPath string) error {
return g.generateByFormat(action, outputDir, actionPath) return g.generateByFormat(action, outputDir, actionPath)
} }
// parseAndValidateAction parses and validates an action.yml file. // DiscoverActionFiles finds action.yml and action.yaml files in the given directory
func (g *Generator) parseAndValidateAction(actionPath string) (*ActionYML, error) { // using the centralized parser function and adds verbose logging.
action, err := ParseActionYML(actionPath) func (g *Generator) DiscoverActionFiles(dir string, recursive bool) ([]string, error) {
actionFiles, err := DiscoverActionFiles(dir, recursive)
if err != nil { if err != nil {
return nil, fmt.Errorf("failed to parse action file %s: %w", actionPath, err) return nil, err
} }
validationResult := ValidateActionYML(action) // Add verbose logging
if len(validationResult.MissingFields) > 0 { if g.Config.Verbose {
// Check for critical validation errors that cannot be fixed with defaults for _, file := range actionFiles {
for _, field := range validationResult.MissingFields { if recursive {
// All core required fields should cause validation failure g.Output.Info("Discovered action file: %s", file)
if field == "name" || field == "description" || field == "runs" || field == "runs.using" { } else {
// Required fields missing - cannot be fixed with defaults, must fail g.Output.Info("Found action file: %s", file)
return nil, fmt.Errorf(
"action file %s has invalid configuration, missing required field(s): %v",
actionPath,
validationResult.MissingFields,
)
} }
} }
}
if g.Config.Verbose { return actionFiles, nil
g.Output.Warning("Missing fields in %s: %v", actionPath, validationResult.MissingFields) }
}
FillMissing(action, g.Config.Defaults) // DiscoverActionFilesWithValidation discovers action files with centralized error handling and validation.
if g.Config.Verbose { // This function consolidates the duplicated file discovery logic across the codebase.
g.Output.Info("Applied default values for missing fields") 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: <path>" 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. return fmt.Errorf("validation failed for %d files", totalFailures)
func (g *Generator) determineOutputDir(actionPath string) string {
if g.Config.OutputDir == "" || g.Config.OutputDir == "." {
return filepath.Dir(actionPath)
} }
return g.Config.OutputDir return nil
}
// 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)
}
} }
// generateMarkdown creates a README.md file using the template. // generateMarkdown creates a README.md file using the template.
@@ -311,86 +381,6 @@ func (g *Generator) generateASCIIDoc(action *ActionYML, outputDir, actionPath st
return nil 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. // processFiles processes each file and tracks results.
func (g *Generator) processFiles(paths []string, bar *progressbar.ProgressBar) ([]string, int) { func (g *Generator) processFiles(paths []string, bar *progressbar.ProgressBar) ([]string, int) {
var errors []string var errors []string
@@ -429,36 +419,76 @@ func (g *Generator) reportResults(successCount int, errors []string) {
} }
} }
// ValidateFiles validates multiple action.yml files and reports results. // parseAndValidateAction parses and validates an action.yml file.
func (g *Generator) ValidateFiles(paths []string) error { func (g *Generator) parseAndValidateAction(actionPath string) (*ActionYML, error) {
if len(paths) == 0 { action, err := ParseActionYML(actionPath)
return errors.New("no action files to validate") if err != nil {
return nil, fmt.Errorf("failed to parse action file %s: %w", actionPath, err)
} }
bar := g.Progress.CreateProgressBarForFiles("Validating files", paths) validationResult := ValidateActionYML(action)
allResults, errors := g.validateFiles(paths, bar) if len(validationResult.MissingFields) > 0 {
g.Progress.FinishProgressBarWithNewline(bar) // 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 { if g.Config.Verbose {
g.reportValidationResults(allResults, errors) g.Output.Warning("Missing fields in %s: %v", actionPath, validationResult.MissingFields)
} }
FillMissing(action, g.Config.Defaults)
// Count validation failures (files with missing required fields) if g.Config.Verbose {
validationFailures := 0 g.Output.Info("Applied default values for missing fields")
for _, result := range allResults {
// Each result starts with "file: <path>" so check if there are actual missing fields beyond that
if len(result.MissingFields) > 1 {
validationFailures++
} }
} }
if len(errors) > 0 || validationFailures > 0 { return action, nil
totalFailures := len(errors) + validationFailures }
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. // validateFiles processes each file for validation.

View File

@@ -563,19 +563,18 @@ func TestGenerator_WithDifferentThemes(t *testing.T) {
t.Parallel() t.Parallel()
themes := []string{"default", "github", "gitlab", "minimal", "professional"} 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 { for _, theme := range themes {
t.Run("theme_"+theme, func(t *testing.T) { t.Run("theme_"+theme, func(t *testing.T) {
t.Parallel() 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{ config := &AppConfig{
Theme: theme, Theme: theme,
@@ -596,11 +595,6 @@ func TestGenerator_WithDifferentThemes(t *testing.T) {
if len(readmeFiles) == 0 { if len(readmeFiles) == 0 {
t.Errorf("no output file was created for theme %s", theme) t.Errorf("no output file was created for theme %s", theme)
} }
// Clean up for next test
for _, file := range readmeFiles {
_ = os.Remove(file)
}
}) })
} }
} }

View File

@@ -78,35 +78,34 @@ func TestProgressBarManager_CreateProgressBarForFiles(t *testing.T) {
func TestProgressBarManager_FinishProgressBar(t *testing.T) { func TestProgressBarManager_FinishProgressBar(t *testing.T) {
t.Parallel() t.Parallel()
pm := NewProgressBarManager(false) // Use quiet mode to avoid cluttering test output
pm := NewProgressBarManager(true)
// Test with nil bar (should not panic) // Test with nil bar (should not panic)
pm.FinishProgressBar(nil) pm.FinishProgressBar(nil)
// Test with actual bar // Test with actual bar (will be nil in quiet mode)
bar := pm.CreateProgressBar("Test", 5) bar := pm.CreateProgressBar("Test", 5)
if bar != nil { pm.FinishProgressBar(bar) // Should handle nil gracefully
pm.FinishProgressBar(bar)
}
} }
func TestProgressBarManager_UpdateProgressBar(t *testing.T) { func TestProgressBarManager_UpdateProgressBar(t *testing.T) {
t.Parallel() t.Parallel()
pm := NewProgressBarManager(false) // Use quiet mode to avoid cluttering test output
pm := NewProgressBarManager(true)
// Test with nil bar (should not panic) // Test with nil bar (should not panic)
pm.UpdateProgressBar(nil) pm.UpdateProgressBar(nil)
// Test with actual bar // Test with actual bar (will be nil in quiet mode)
bar := pm.CreateProgressBar("Test", 5) bar := pm.CreateProgressBar("Test", 5)
if bar != nil { pm.UpdateProgressBar(bar) // Should handle nil gracefully
pm.UpdateProgressBar(bar)
}
} }
func TestProgressBarManager_ProcessWithProgressBar(t *testing.T) { func TestProgressBarManager_ProcessWithProgressBar(t *testing.T) {
t.Parallel() t.Parallel()
pm := NewProgressBarManager(false) // Use NullProgressManager to avoid cluttering test output
pm := NewNullProgressManager()
items := []string{"item1", "item2", "item3"} items := []string{"item1", "item2", "item3"}
processedItems := make([]string, 0) processedItems := make([]string, 0)

120
internal/testoutput.go Normal file
View File

@@ -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)
}
}

View File

@@ -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. // exportYAML exports configuration as YAML.
func (e *ConfigExporter) exportYAML(config *internal.AppConfig, outputPath string) error { func (e *ConfigExporter) exportYAML(config *internal.AppConfig, outputPath string) error {
// Create a clean config without sensitive data for export // 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) _, _ = 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)
}
}

View File

@@ -109,6 +109,33 @@ func (v *ConfigValidator) ValidateField(fieldName, value string) *ValidationResu
return result 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. // validateOrganization validates the organization field.
func (v *ConfigValidator) validateOrganization(org string, result *ValidationResult) { func (v *ConfigValidator) validateOrganization(org string, result *ValidationResult) {
if org == "" { if org == "" {
@@ -478,30 +505,3 @@ func (v *ConfigValidator) isValidVariableName(name string) bool {
return matched 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)
}
}
}

8
scripts/release.sh Normal file → Executable file
View File

@@ -44,13 +44,13 @@ fi
# Get version from command line or prompt # Get version from command line or prompt
VERSION="$1" VERSION="$1"
if [[ -z "$VERSION" ]]; then if [[ -z $VERSION ]]; then
echo -n "Enter version (e.g., v1.0.0): " echo -n "Enter version (e.g., v1.0.0): "
read -r VERSION read -r VERSION
fi fi
# Validate version format # 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)" log_error "Version must be in format vX.Y.Z (e.g., v1.0.0)"
exit 1 exit 1
fi fi
@@ -59,11 +59,11 @@ log_info "Preparing release $VERSION"
# Check if we're on main branch # Check if we're on main branch
CURRENT_BRANCH=$(git rev-parse --abbrev-ref HEAD) 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)" log_warning "You're not on the main branch (current: $CURRENT_BRANCH)"
echo -n "Continue anyway? (y/N): " echo -n "Continue anyway? (y/N): "
read -r CONTINUE read -r CONTINUE
if [[ "$CONTINUE" != "y" && "$CONTINUE" != "Y" ]]; then if [[ $CONTINUE != "y" && $CONTINUE != "Y" ]]; then
log_info "Aborted" log_info "Aborted"
exit 0 exit 0
fi fi

View File

@@ -1,3 +1,4 @@
---
name: Composite Example Action name: Composite Example Action
description: 'Test Composite Action for gh-action-readme dependency analysis' description: 'Test Composite Action for gh-action-readme dependency analysis'
inputs: inputs:

View File

@@ -1,3 +1,4 @@
---
name: Example Action name: Example Action
description: 'Test Action for gh-action-readme' description: 'Test Action for gh-action-readme'
inputs: inputs:

View File

@@ -1,3 +1,4 @@
---
# Action-specific configuration # Action-specific configuration
theme: "github" theme: "github"
variables: variables:

View File

@@ -1,3 +1,4 @@
---
name: 'Basic Composite Action' name: 'Basic Composite Action'
description: 'A simple composite action with basic steps' description: 'A simple composite action with basic steps'
inputs: inputs:

View File

@@ -1,3 +1,4 @@
---
name: 'Complex Composite Workflow' name: 'Complex Composite Workflow'
description: 'A complex composite action demonstrating advanced features' description: 'A complex composite action demonstrating advanced features'
inputs: inputs:

View File

@@ -1,3 +1,4 @@
---
name: 'Composite Action with Dependencies' name: 'Composite Action with Dependencies'
description: 'A composite action that uses external actions' description: 'A composite action that uses external actions'
inputs: inputs:

View File

@@ -1,3 +1,4 @@
---
name: 'Basic Docker Action' name: 'Basic Docker Action'
description: 'A simple Docker-based action' description: 'A simple Docker-based action'
inputs: inputs:

View File

@@ -1,3 +1,4 @@
---
name: 'Docker Action with Environment' name: 'Docker Action with Environment'
description: 'Docker action with environment variables and advanced configuration' description: 'Docker action with environment variables and advanced configuration'
inputs: inputs:

View File

@@ -1,3 +1,4 @@
---
name: 'Node.js 16 Action' name: 'Node.js 16 Action'
description: 'JavaScript action running on Node.js 16' description: 'JavaScript action running on Node.js 16'
inputs: inputs:

View File

@@ -1,3 +1,4 @@
---
name: 'Simple JavaScript Action' name: 'Simple JavaScript Action'
description: 'A simple JavaScript action for testing' description: 'A simple JavaScript action for testing'
inputs: inputs:

View File

@@ -1,3 +1,4 @@
---
name: 'Comprehensive JavaScript Action' name: 'Comprehensive JavaScript Action'
description: 'A JavaScript action with all possible fields for testing' description: 'A JavaScript action with all possible fields for testing'
author: 'Test Author <test@example.com>' author: 'Test Author <test@example.com>'

View File

@@ -1,3 +1,4 @@
---
theme: default theme: default
output_format: md output_format: md
repo_overrides: repo_overrides:

View File

@@ -1,3 +1,4 @@
---
name: 'Composite Action' name: 'Composite Action'
description: 'A composite action with multiple steps' description: 'A composite action with multiple steps'
inputs: inputs:

View File

@@ -1,3 +1,4 @@
---
theme: professional theme: professional
output_format: html output_format: html
output_dir: docs output_dir: docs

View File

@@ -1,3 +1,4 @@
---
theme: default theme: default
output_format: md output_format: md
output_dir: . output_dir: .

View File

@@ -1,3 +1,4 @@
---
theme: github theme: github
output_format: md output_format: md
output_dir: docs output_dir: docs

View File

@@ -1,3 +1,4 @@
---
name: 'Docker Action' name: 'Docker Action'
description: 'A Docker-based GitHub Action' description: 'A Docker-based GitHub Action'
inputs: inputs:

View File

@@ -1,3 +1,4 @@
---
theme: default theme: default
output_format: md output_format: md
github_token: global-token github_token: global-token

View File

@@ -1,3 +1,4 @@
---
name: 'Minimal Action' name: 'Minimal Action'
description: 'Minimal test action' description: 'Minimal test action'
runs: runs:

View File

@@ -1,2 +1,3 @@
---
theme: minimal theme: minimal
github_token: config-token github_token: config-token

View File

@@ -1,3 +1,4 @@
---
name: 'My New Action' name: 'My New Action'
description: 'A brand new GitHub Action' description: 'A brand new GitHub Action'
inputs: inputs:

View File

@@ -1,3 +1,4 @@
---
theme: professional theme: professional
output_format: html output_format: html
output_dir: docs output_dir: docs

View File

@@ -1,3 +1,4 @@
---
theme: github theme: github
output_format: md output_format: md
output_dir: docs output_dir: docs

View File

@@ -1,3 +1,4 @@
---
scenarios: scenarios:
# JavaScript Action Scenarios # JavaScript Action Scenarios
- id: "simple-javascript" - id: "simple-javascript"

View File

@@ -1,3 +1,4 @@
---
name: 'Simple Action' name: 'Simple Action'
description: 'A simple GitHub Action for testing' description: 'A simple GitHub Action for testing'
inputs: inputs:

View File

@@ -1,3 +1,4 @@
---
name: 'Test Composite Action' name: 'Test Composite Action'
description: 'Test action for update testing' description: 'Test action for update testing'
runs: runs:

View File

@@ -1,3 +1,4 @@
---
name: 'Test Project Action' name: 'Test Project Action'
description: 'A GitHub Action for testing project functionality' description: 'A GitHub Action for testing project functionality'
inputs: inputs:

View File

@@ -1,3 +1,4 @@
---
name: Test Action name: Test Action
description: A test action description: A test action
runs: runs: