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

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

View File

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

View File

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

View File

@@ -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: <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.
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: <path>" 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.

View File

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

View File

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

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

View File

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