mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-03-13 07:00:25 +00:00
refactor: major codebase improvements and test framework overhaul
This commit represents a comprehensive refactoring of the codebase focused on improving code quality, testability, and maintainability. Key improvements: - Implement dependency injection and interface-based architecture - Add comprehensive test framework with fixtures and test suites - Fix all linting issues (errcheck, gosec, staticcheck, goconst, etc.) - Achieve full EditorConfig compliance across all files - Replace hardcoded test data with proper fixture files - Add configuration loader with hierarchical config support - Improve error handling with contextual information - Add progress indicators for better user feedback - Enhance Makefile with help system and improved editorconfig commands - Consolidate constants and remove deprecated code - Strengthen validation logic for GitHub Actions - Add focused consumer interfaces for better separation of concerns Testing improvements: - Add comprehensive integration tests - Implement test executor pattern for better test organization - Create extensive YAML fixture library for testing - Fix all failing tests and improve test coverage - Add validation test fixtures to avoid embedded YAML in Go files Build and tooling: - Update Makefile to show help by default - Fix editorconfig commands to use eclint properly - Add comprehensive help documentation to all make targets - Improve file selection patterns to avoid glob errors This refactoring maintains backward compatibility while significantly improving the internal architecture and developer experience.
This commit is contained in:
446
internal/configuration_loader.go
Normal file
446
internal/configuration_loader.go
Normal file
@@ -0,0 +1,446 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/adrg/xdg"
|
||||
"github.com/spf13/viper"
|
||||
)
|
||||
|
||||
// ConfigurationSource represents different sources of configuration.
|
||||
type ConfigurationSource int
|
||||
|
||||
// Configuration source priority order (lowest to highest priority).
|
||||
const (
|
||||
// SourceDefaults represents default configuration values.
|
||||
SourceDefaults ConfigurationSource = iota
|
||||
SourceGlobal
|
||||
SourceRepoOverride
|
||||
SourceRepoConfig
|
||||
SourceActionConfig
|
||||
SourceEnvironment
|
||||
SourceCLIFlags
|
||||
)
|
||||
|
||||
// ConfigurationLoader handles loading and merging configuration from multiple sources.
|
||||
type ConfigurationLoader struct {
|
||||
// sources tracks which sources are enabled
|
||||
sources map[ConfigurationSource]bool
|
||||
// viper instance for global configuration
|
||||
viper *viper.Viper
|
||||
}
|
||||
|
||||
// ConfigurationOptions configures how configuration loading behaves.
|
||||
type ConfigurationOptions struct {
|
||||
// ConfigFile specifies a custom global config file path
|
||||
ConfigFile string
|
||||
// AllowTokens controls whether security-sensitive fields can be loaded
|
||||
AllowTokens bool
|
||||
// EnabledSources controls which configuration sources are used
|
||||
EnabledSources []ConfigurationSource
|
||||
}
|
||||
|
||||
// NewConfigurationLoader creates a new configuration loader with default options.
|
||||
func NewConfigurationLoader() *ConfigurationLoader {
|
||||
return &ConfigurationLoader{
|
||||
sources: map[ConfigurationSource]bool{
|
||||
SourceDefaults: true,
|
||||
SourceGlobal: true,
|
||||
SourceRepoOverride: true,
|
||||
SourceRepoConfig: true,
|
||||
SourceActionConfig: true,
|
||||
SourceEnvironment: true,
|
||||
SourceCLIFlags: false, // CLI flags are applied separately
|
||||
},
|
||||
viper: viper.New(),
|
||||
}
|
||||
}
|
||||
|
||||
// NewConfigurationLoaderWithOptions creates a configuration loader with custom options.
|
||||
func NewConfigurationLoaderWithOptions(opts ConfigurationOptions) *ConfigurationLoader {
|
||||
loader := &ConfigurationLoader{
|
||||
sources: make(map[ConfigurationSource]bool),
|
||||
viper: viper.New(),
|
||||
}
|
||||
|
||||
// Set default sources if none specified
|
||||
if len(opts.EnabledSources) == 0 {
|
||||
opts.EnabledSources = []ConfigurationSource{
|
||||
SourceDefaults, SourceGlobal, SourceRepoOverride,
|
||||
SourceRepoConfig, SourceActionConfig, SourceEnvironment,
|
||||
}
|
||||
}
|
||||
|
||||
// Configure enabled sources
|
||||
for _, source := range opts.EnabledSources {
|
||||
loader.sources[source] = true
|
||||
}
|
||||
|
||||
return loader
|
||||
}
|
||||
|
||||
// LoadConfiguration loads configuration with multi-level hierarchy.
|
||||
func (cl *ConfigurationLoader) LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, error) {
|
||||
config := &AppConfig{}
|
||||
|
||||
cl.loadDefaultsStep(config)
|
||||
|
||||
if err := cl.loadGlobalStep(config, configFile); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cl.loadRepoOverrideStep(config, repoRoot)
|
||||
|
||||
if err := cl.loadRepoConfigStep(config, repoRoot); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := cl.loadActionConfigStep(config, actionDir); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
cl.loadEnvironmentStep(config)
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// loadDefaultsStep loads default configuration values.
|
||||
func (cl *ConfigurationLoader) loadDefaultsStep(config *AppConfig) {
|
||||
if cl.sources[SourceDefaults] {
|
||||
defaults := DefaultAppConfig()
|
||||
*config = *defaults
|
||||
}
|
||||
}
|
||||
|
||||
// loadGlobalStep loads global configuration.
|
||||
func (cl *ConfigurationLoader) loadGlobalStep(config *AppConfig, configFile string) error {
|
||||
if !cl.sources[SourceGlobal] {
|
||||
return nil
|
||||
}
|
||||
|
||||
globalConfig, err := cl.loadGlobalConfig(configFile)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load global config: %w", err)
|
||||
}
|
||||
cl.mergeConfigs(config, globalConfig, true) // Allow tokens for global config
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadRepoOverrideStep applies repo-specific overrides from global config.
|
||||
func (cl *ConfigurationLoader) loadRepoOverrideStep(config *AppConfig, repoRoot string) {
|
||||
if !cl.sources[SourceRepoOverride] || repoRoot == "" {
|
||||
return
|
||||
}
|
||||
|
||||
cl.applyRepoOverrides(config, repoRoot)
|
||||
}
|
||||
|
||||
// loadRepoConfigStep loads repository root configuration.
|
||||
func (cl *ConfigurationLoader) loadRepoConfigStep(config *AppConfig, repoRoot string) error {
|
||||
if !cl.sources[SourceRepoConfig] || repoRoot == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
repoConfig, err := cl.loadRepoConfig(repoRoot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load repo config: %w", err)
|
||||
}
|
||||
cl.mergeConfigs(config, repoConfig, false) // No tokens in repo config
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadActionConfigStep loads action-specific configuration.
|
||||
func (cl *ConfigurationLoader) loadActionConfigStep(config *AppConfig, actionDir string) error {
|
||||
if !cl.sources[SourceActionConfig] || actionDir == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
actionConfig, err := cl.loadActionConfig(actionDir)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load action config: %w", err)
|
||||
}
|
||||
cl.mergeConfigs(config, actionConfig, false) // No tokens in action config
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadEnvironmentStep applies environment variable overrides.
|
||||
func (cl *ConfigurationLoader) loadEnvironmentStep(config *AppConfig) {
|
||||
if cl.sources[SourceEnvironment] {
|
||||
cl.applyEnvironmentOverrides(config)
|
||||
}
|
||||
}
|
||||
|
||||
// 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 fmt.Errorf("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 fmt.Errorf("output directory cannot be empty")
|
||||
}
|
||||
|
||||
// Validate mutually exclusive flags
|
||||
if config.Verbose && config.Quiet {
|
||||
return fmt.Errorf("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()
|
||||
|
||||
// Set configuration file name and type
|
||||
v.SetConfigName(ConfigFileName)
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
// Add XDG-compliant configuration directory
|
||||
configDir, err := xdg.ConfigFile("gh-action-readme")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get XDG config directory: %w", err)
|
||||
}
|
||||
v.AddConfigPath(filepath.Dir(configDir))
|
||||
|
||||
// Add additional search paths
|
||||
v.AddConfigPath(".") // current directory
|
||||
v.AddConfigPath("$HOME/.config/gh-action-readme") // fallback
|
||||
v.AddConfigPath("/etc/gh-action-readme") // system-wide
|
||||
|
||||
// Set environment variable prefix
|
||||
v.SetEnvPrefix("GH_ACTION_README")
|
||||
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
|
||||
v.AutomaticEnv()
|
||||
|
||||
// Set defaults
|
||||
cl.setViperDefaults(v)
|
||||
|
||||
// Use specific config file if provided
|
||||
if configFile != "" {
|
||||
v.SetConfigFile(configFile)
|
||||
}
|
||||
|
||||
// Read configuration
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
|
||||
return nil, fmt.Errorf("failed to read config file: %w", err)
|
||||
}
|
||||
// Config file not found is not an error - we'll use defaults and env vars
|
||||
}
|
||||
|
||||
// Unmarshal configuration into struct
|
||||
var config AppConfig
|
||||
if err := v.Unmarshal(&config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
// Resolve template paths relative to binary if they're not absolute
|
||||
config.Template = resolveTemplatePath(config.Template)
|
||||
config.Header = resolveTemplatePath(config.Header)
|
||||
config.Footer = resolveTemplatePath(config.Footer)
|
||||
config.Schema = resolveTemplatePath(config.Schema)
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// loadRepoConfig loads repository-level configuration from hidden config files.
|
||||
func (cl *ConfigurationLoader) loadRepoConfig(repoRoot string) (*AppConfig, error) {
|
||||
// Hidden config file paths in priority order
|
||||
configPaths := []string{
|
||||
".ghreadme.yaml", // Primary hidden config
|
||||
".config/ghreadme.yaml", // Secondary hidden config
|
||||
".github/ghreadme.yaml", // GitHub ecosystem standard
|
||||
}
|
||||
|
||||
for _, configName := range configPaths {
|
||||
configPath := filepath.Join(repoRoot, configName)
|
||||
if _, err := os.Stat(configPath); err == nil {
|
||||
// Config file found, load it
|
||||
return cl.loadConfigFromFile(configPath)
|
||||
}
|
||||
}
|
||||
|
||||
// No config found, return empty config
|
||||
return &AppConfig{}, nil
|
||||
}
|
||||
|
||||
// loadActionConfig loads action-level configuration from config.yaml.
|
||||
func (cl *ConfigurationLoader) loadActionConfig(actionDir string) (*AppConfig, error) {
|
||||
configPath := filepath.Join(actionDir, "config.yaml")
|
||||
if _, err := os.Stat(configPath); os.IsNotExist(err) {
|
||||
return &AppConfig{}, nil // No action config is fine
|
||||
}
|
||||
|
||||
return cl.loadConfigFromFile(configPath)
|
||||
}
|
||||
|
||||
// loadConfigFromFile loads configuration from a specific file.
|
||||
func (cl *ConfigurationLoader) loadConfigFromFile(configPath string) (*AppConfig, error) {
|
||||
v := viper.New()
|
||||
v.SetConfigFile(configPath)
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
if err := v.ReadInConfig(); err != nil {
|
||||
return nil, fmt.Errorf("failed to read config %s: %w", configPath, err)
|
||||
}
|
||||
|
||||
var config AppConfig
|
||||
if err := v.Unmarshal(&config); err != nil {
|
||||
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// applyRepoOverrides applies repository-specific overrides from global config.
|
||||
func (cl *ConfigurationLoader) applyRepoOverrides(config *AppConfig, repoRoot string) {
|
||||
repoName := DetectRepositoryName(repoRoot)
|
||||
if repoName == "" {
|
||||
return // No repository detected
|
||||
}
|
||||
|
||||
if config.RepoOverrides == nil {
|
||||
return // No overrides configured
|
||||
}
|
||||
|
||||
if repoOverride, exists := config.RepoOverrides[repoName]; exists {
|
||||
cl.mergeConfigs(config, &repoOverride, false) // No tokens in overrides
|
||||
}
|
||||
}
|
||||
|
||||
// applyEnvironmentOverrides applies environment variable overrides.
|
||||
func (cl *ConfigurationLoader) applyEnvironmentOverrides(config *AppConfig) {
|
||||
// Check environment variables directly with higher priority
|
||||
if token := os.Getenv(EnvGitHubToken); token != "" {
|
||||
config.GitHubToken = token
|
||||
} else if token := os.Getenv(EnvGitHubTokenStandard); token != "" {
|
||||
config.GitHubToken = token
|
||||
}
|
||||
}
|
||||
|
||||
// mergeConfigs merges a source config into a destination config.
|
||||
func (cl *ConfigurationLoader) mergeConfigs(dst *AppConfig, src *AppConfig, allowTokens bool) {
|
||||
MergeConfigs(dst, src, allowTokens)
|
||||
}
|
||||
|
||||
// setViperDefaults sets default values in viper.
|
||||
func (cl *ConfigurationLoader) setViperDefaults(v *viper.Viper) {
|
||||
defaults := DefaultAppConfig()
|
||||
v.SetDefault("organization", defaults.Organization)
|
||||
v.SetDefault("repository", defaults.Repository)
|
||||
v.SetDefault("version", defaults.Version)
|
||||
v.SetDefault("theme", defaults.Theme)
|
||||
v.SetDefault("output_format", defaults.OutputFormat)
|
||||
v.SetDefault("output_dir", defaults.OutputDir)
|
||||
v.SetDefault("template", defaults.Template)
|
||||
v.SetDefault("header", defaults.Header)
|
||||
v.SetDefault("footer", defaults.Footer)
|
||||
v.SetDefault("schema", defaults.Schema)
|
||||
v.SetDefault("analyze_dependencies", defaults.AnalyzeDependencies)
|
||||
v.SetDefault("show_security_info", defaults.ShowSecurityInfo)
|
||||
v.SetDefault("verbose", defaults.Verbose)
|
||||
v.SetDefault("quiet", defaults.Quiet)
|
||||
v.SetDefault("defaults.name", defaults.Defaults.Name)
|
||||
v.SetDefault("defaults.description", defaults.Defaults.Description)
|
||||
v.SetDefault("defaults.branding.icon", defaults.Defaults.Branding.Icon)
|
||||
v.SetDefault("defaults.branding.color", defaults.Defaults.Branding.Color)
|
||||
}
|
||||
|
||||
// validateTheme validates that a theme exists and is supported.
|
||||
func (cl *ConfigurationLoader) validateTheme(theme string) error {
|
||||
if theme == "" {
|
||||
return fmt.Errorf("theme cannot be empty")
|
||||
}
|
||||
|
||||
// Check if it's a built-in theme
|
||||
supportedThemes := []string{"default", "github", "gitlab", "minimal", "professional"}
|
||||
if containsString(supportedThemes, theme) {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Check if it's a custom template path
|
||||
if filepath.IsAbs(theme) || strings.Contains(theme, "/") {
|
||||
// Assume it's a custom template path - we can't easily validate without filesystem access
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("unsupported theme '%s', must be one of: %s",
|
||||
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 {
|
||||
case SourceDefaults:
|
||||
return "defaults"
|
||||
case SourceGlobal:
|
||||
return "global"
|
||||
case SourceRepoOverride:
|
||||
return "repo-override"
|
||||
case SourceRepoConfig:
|
||||
return "repo-config"
|
||||
case SourceActionConfig:
|
||||
return "action-config"
|
||||
case SourceEnvironment:
|
||||
return "environment"
|
||||
case SourceCLIFlags:
|
||||
return "cli-flags"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user