mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-01-26 11:14:04 +00:00
* feat: rename internal/errors to internal/apperrors * fix(tests): clear env values before using in tests * feat: rename internal/errors to internal/apperrors * chore(deps): update go and all dependencies * chore: remove renovate from pre-commit, formatting * chore: sonarcloud fixes * feat: consolidate constants to appconstants/constants.go * chore: sonarcloud fixes * feat: simplification, deduplication, test utils * chore: sonarcloud fixes * chore: sonarcloud fixes * chore: sonarcloud fixes * chore: sonarcloud fixes * chore: clean up * fix: config discovery, const deduplication * chore: fixes
305 lines
9.1 KiB
Go
305 lines
9.1 KiB
Go
package internal
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/spf13/viper"
|
|
|
|
"github.com/ivuorinen/gh-action-readme/appconstants"
|
|
)
|
|
|
|
// ConfigurationLoader handles loading and merging configuration from multiple sources.
|
|
type ConfigurationLoader struct {
|
|
// sources tracks which sources are enabled
|
|
sources map[appconstants.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 []appconstants.ConfigurationSource
|
|
}
|
|
|
|
// NewConfigurationLoader creates a new configuration loader with default options.
|
|
func NewConfigurationLoader() *ConfigurationLoader {
|
|
return &ConfigurationLoader{
|
|
sources: map[appconstants.ConfigurationSource]bool{
|
|
appconstants.SourceDefaults: true,
|
|
appconstants.SourceGlobal: true,
|
|
appconstants.SourceRepoOverride: true,
|
|
appconstants.SourceRepoConfig: true,
|
|
appconstants.SourceActionConfig: true,
|
|
appconstants.SourceEnvironment: true,
|
|
appconstants.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[appconstants.ConfigurationSource]bool),
|
|
viper: viper.New(),
|
|
}
|
|
|
|
// Set default sources if none specified
|
|
if len(opts.EnabledSources) == 0 {
|
|
opts.EnabledSources = []appconstants.ConfigurationSource{
|
|
appconstants.SourceDefaults, appconstants.SourceGlobal, appconstants.SourceRepoOverride,
|
|
appconstants.SourceRepoConfig, appconstants.SourceActionConfig, appconstants.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
|
|
}
|
|
|
|
// 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() []appconstants.ConfigurationSource {
|
|
var sources []appconstants.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 appconstants.ConfigurationSource) {
|
|
cl.sources[source] = true
|
|
}
|
|
|
|
// DisableSource disables a specific configuration source.
|
|
func (cl *ConfigurationLoader) DisableSource(source appconstants.ConfigurationSource) {
|
|
cl.sources[source] = false
|
|
}
|
|
|
|
// loadDefaultsStep loads default configuration values.
|
|
func (cl *ConfigurationLoader) loadDefaultsStep(config *AppConfig) {
|
|
if cl.sources[appconstants.SourceDefaults] {
|
|
defaults := DefaultAppConfig()
|
|
*config = *defaults
|
|
}
|
|
}
|
|
|
|
// loadGlobalStep loads global configuration.
|
|
func (cl *ConfigurationLoader) loadGlobalStep(config *AppConfig, configFile string) error {
|
|
if !cl.sources[appconstants.SourceGlobal] {
|
|
return nil
|
|
}
|
|
|
|
globalConfig, err := cl.loadGlobalConfig(configFile)
|
|
if err != nil {
|
|
return fmt.Errorf(appconstants.ErrFailedToLoadGlobalConfig, 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[appconstants.SourceRepoOverride] || repoRoot == "" {
|
|
return
|
|
}
|
|
|
|
cl.applyRepoOverrides(config, repoRoot)
|
|
}
|
|
|
|
// loadRepoConfigStep loads repository root configuration.
|
|
func (cl *ConfigurationLoader) loadRepoConfigStep(config *AppConfig, repoRoot string) error {
|
|
if !cl.sources[appconstants.SourceRepoConfig] || repoRoot == "" {
|
|
return nil
|
|
}
|
|
|
|
repoConfig, err := cl.loadRepoConfig(repoRoot)
|
|
if err != nil {
|
|
return fmt.Errorf(appconstants.ErrFailedToLoadRepoConfig, 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[appconstants.SourceActionConfig] || actionDir == "" {
|
|
return nil
|
|
}
|
|
|
|
actionConfig, err := cl.loadActionConfig(actionDir)
|
|
if err != nil {
|
|
return fmt.Errorf(appconstants.ErrFailedToLoadActionConfig, 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[appconstants.SourceEnvironment] {
|
|
cl.applyEnvironmentOverrides(config)
|
|
}
|
|
}
|
|
|
|
// loadGlobalConfig initializes and loads the global configuration using Viper.
|
|
func (cl *ConfigurationLoader) loadGlobalConfig(configFile string) (*AppConfig, error) {
|
|
v, err := initializeViperInstance()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return loadAndUnmarshalConfig(configFile, v)
|
|
}
|
|
|
|
// loadRepoConfig loads repository-level configuration from hidden config files.
|
|
func (cl *ConfigurationLoader) loadRepoConfig(repoRoot string) (*AppConfig, error) {
|
|
return loadRepoConfigInternal(repoRoot)
|
|
}
|
|
|
|
// loadActionConfig loads action-level configuration from config.yaml.
|
|
func (cl *ConfigurationLoader) loadActionConfig(actionDir string) (*AppConfig, error) {
|
|
return loadActionConfigInternal(actionDir)
|
|
}
|
|
|
|
// 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 := loadGitHubTokenFromEnv(); 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)
|
|
}
|
|
|
|
// validateTheme validates that a theme exists and is supported.
|
|
func (cl *ConfigurationLoader) validateTheme(theme string) error {
|
|
if theme == "" {
|
|
return errors.New("theme cannot be empty")
|
|
}
|
|
|
|
// Check if it's a built-in theme
|
|
if containsString(appconstants.GetSupportedThemes(), 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(appconstants.GetSupportedThemes(), ", "))
|
|
}
|