mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-01-26 03:04:10 +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:
12
internal/cache/cache.go
vendored
12
internal/cache/cache.go
vendored
@@ -27,6 +27,7 @@ type Cache struct {
|
||||
ticker *time.Ticker // Cleanup ticker
|
||||
done chan bool // Cleanup shutdown
|
||||
defaultTTL time.Duration // Default TTL for entries
|
||||
saveWG sync.WaitGroup // Wait group for pending save operations
|
||||
}
|
||||
|
||||
// Config represents cache configuration.
|
||||
@@ -58,7 +59,7 @@ func NewCache(config *Config) (*Cache, error) {
|
||||
}
|
||||
|
||||
// Ensure cache directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(cacheDir), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(cacheDir), 0750); err != nil { // #nosec G301 -- cache directory permissions
|
||||
return nil, fmt.Errorf("failed to create cache directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -188,6 +189,9 @@ func (c *Cache) Close() error {
|
||||
default:
|
||||
}
|
||||
|
||||
// Wait for any pending async save operations to complete
|
||||
c.saveWG.Wait()
|
||||
|
||||
// Save final state to disk
|
||||
return c.saveToDisk()
|
||||
}
|
||||
@@ -224,7 +228,7 @@ func (c *Cache) cleanup() {
|
||||
func (c *Cache) loadFromDisk() error {
|
||||
cacheFile := filepath.Join(c.path, "cache.json")
|
||||
|
||||
data, err := os.ReadFile(cacheFile)
|
||||
data, err := os.ReadFile(cacheFile) // #nosec G304 -- cache file path constructed internally
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil // No cache file is fine
|
||||
@@ -257,7 +261,7 @@ func (c *Cache) saveToDisk() error {
|
||||
}
|
||||
|
||||
cacheFile := filepath.Join(c.path, "cache.json")
|
||||
if err := os.WriteFile(cacheFile, jsonData, 0644); err != nil {
|
||||
if err := os.WriteFile(cacheFile, jsonData, 0600); err != nil { // #nosec G306 -- cache file permissions
|
||||
return fmt.Errorf("failed to write cache file: %w", err)
|
||||
}
|
||||
|
||||
@@ -267,7 +271,9 @@ func (c *Cache) saveToDisk() error {
|
||||
// saveToDiskAsync saves the cache to disk asynchronously.
|
||||
// Cache save failures are non-critical and silently ignored.
|
||||
func (c *Cache) saveToDiskAsync() {
|
||||
c.saveWG.Add(1)
|
||||
go func() {
|
||||
defer c.saveWG.Done()
|
||||
_ = c.saveToDisk() // Ignore errors - cache save failures are non-critical
|
||||
}()
|
||||
}
|
||||
|
||||
40
internal/cache/cache_test.go
vendored
40
internal/cache/cache_test.go
vendored
@@ -313,6 +313,43 @@ func TestCache_Clear(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCache_Delete(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
|
||||
// Add some data
|
||||
_ = cache.Set("key1", "value1")
|
||||
_ = cache.Set("key2", "value2")
|
||||
_ = cache.Set("key3", "value3")
|
||||
|
||||
// Verify data exists
|
||||
_, exists := cache.Get("key1")
|
||||
if !exists {
|
||||
t.Fatal("expected key1 to exist before delete")
|
||||
}
|
||||
|
||||
// Delete specific key
|
||||
cache.Delete("key1")
|
||||
|
||||
// Verify deleted key is gone but others remain
|
||||
_, exists1 := cache.Get("key1")
|
||||
_, exists2 := cache.Get("key2")
|
||||
_, exists3 := cache.Get("key3")
|
||||
|
||||
if exists1 {
|
||||
t.Error("expected key1 to be deleted")
|
||||
}
|
||||
if !exists2 || !exists3 {
|
||||
t.Error("expected key2 and key3 to still exist")
|
||||
}
|
||||
|
||||
// Test deleting non-existent key (should not panic)
|
||||
cache.Delete("nonexistent")
|
||||
}
|
||||
|
||||
func TestCache_Stats(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
@@ -320,6 +357,9 @@ func TestCache_Stats(t *testing.T) {
|
||||
cache := createTestCache(t, tmpDir)
|
||||
defer func() { _ = cache.Close() }()
|
||||
|
||||
// Ensure cache starts clean
|
||||
_ = cache.Clear()
|
||||
|
||||
// Add some data
|
||||
_ = cache.Set("key1", "value1")
|
||||
_ = cache.Set("key2", "larger-value-with-more-content")
|
||||
|
||||
@@ -78,12 +78,12 @@ type GitHubClient struct {
|
||||
// GetGitHubToken returns the GitHub token from environment variables or config.
|
||||
func GetGitHubToken(config *AppConfig) string {
|
||||
// Priority 1: Tool-specific env var
|
||||
if token := os.Getenv("GH_README_GITHUB_TOKEN"); token != "" {
|
||||
if token := os.Getenv(EnvGitHubToken); token != "" {
|
||||
return token
|
||||
}
|
||||
|
||||
// Priority 2: Standard GitHub env var
|
||||
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
|
||||
if token := os.Getenv(EnvGitHubTokenStandard); token != "" {
|
||||
return token
|
||||
}
|
||||
|
||||
@@ -174,16 +174,16 @@ func resolveThemeTemplate(theme string) string {
|
||||
var templatePath string
|
||||
|
||||
switch theme {
|
||||
case "default":
|
||||
templatePath = "templates/readme.tmpl"
|
||||
case "github":
|
||||
templatePath = "templates/themes/github/readme.tmpl"
|
||||
case "gitlab":
|
||||
templatePath = "templates/themes/gitlab/readme.tmpl"
|
||||
case "minimal":
|
||||
templatePath = "templates/themes/minimal/readme.tmpl"
|
||||
case "professional":
|
||||
templatePath = "templates/themes/professional/readme.tmpl"
|
||||
case ThemeDefault:
|
||||
templatePath = TemplatePathDefault
|
||||
case ThemeGitHub:
|
||||
templatePath = TemplatePathGitHub
|
||||
case ThemeGitLab:
|
||||
templatePath = TemplatePathGitLab
|
||||
case ThemeMinimal:
|
||||
templatePath = TemplatePathMinimal
|
||||
case ThemeProfessional:
|
||||
templatePath = TemplatePathProfessional
|
||||
case "":
|
||||
// Empty theme should return empty path
|
||||
return ""
|
||||
@@ -451,9 +451,9 @@ func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, erro
|
||||
|
||||
// 6. Apply environment variable overrides for GitHub token
|
||||
// Check environment variables directly with higher priority
|
||||
if token := os.Getenv("GH_README_GITHUB_TOKEN"); token != "" {
|
||||
if token := os.Getenv(EnvGitHubToken); token != "" {
|
||||
config.GitHubToken = token
|
||||
} else if token := os.Getenv("GITHUB_TOKEN"); token != "" {
|
||||
} else if token := os.Getenv(EnvGitHubTokenStandard); token != "" {
|
||||
config.GitHubToken = token
|
||||
}
|
||||
|
||||
@@ -465,7 +465,7 @@ func InitConfig(configFile string) (*AppConfig, error) {
|
||||
v := viper.New()
|
||||
|
||||
// Set configuration file name and type
|
||||
v.SetConfigName("config")
|
||||
v.SetConfigName(ConfigFileName)
|
||||
v.SetConfigType("yaml")
|
||||
|
||||
// Add XDG-compliant configuration directory
|
||||
@@ -542,7 +542,7 @@ func WriteDefaultConfig() error {
|
||||
}
|
||||
|
||||
// Ensure the directory exists
|
||||
if err := os.MkdirAll(filepath.Dir(configFile), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(configFile), 0750); err != nil { // #nosec G301 -- config directory permissions
|
||||
return fmt.Errorf("failed to create config directory: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -50,7 +50,7 @@ func TestInitConfig(t *testing.T) {
|
||||
configFile: "custom-config.yml",
|
||||
setupFunc: func(t *testing.T, tempDir string) {
|
||||
configPath := filepath.Join(tempDir, "custom-config.yml")
|
||||
testutil.WriteTestFile(t, configPath, testutil.CustomConfigYAML)
|
||||
testutil.WriteTestFile(t, configPath, testutil.MustReadFixture("professional-config.yml"))
|
||||
},
|
||||
expected: &AppConfig{
|
||||
Theme: "professional",
|
||||
@@ -134,7 +134,7 @@ func TestLoadConfiguration(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tempDir string) (string, string, string) {
|
||||
// Create global config
|
||||
globalConfigDir := filepath.Join(tempDir, ".config", "gh-action-readme")
|
||||
_ = os.MkdirAll(globalConfigDir, 0755)
|
||||
_ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions
|
||||
globalConfigPath := filepath.Join(globalConfigDir, "config.yaml")
|
||||
testutil.WriteTestFile(t, globalConfigPath, `
|
||||
theme: default
|
||||
@@ -144,7 +144,7 @@ github_token: global-token
|
||||
|
||||
// Create repo root with repo-specific config
|
||||
repoRoot := filepath.Join(tempDir, "repo")
|
||||
_ = os.MkdirAll(repoRoot, 0755)
|
||||
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), `
|
||||
theme: github
|
||||
output_format: html
|
||||
@@ -152,7 +152,7 @@ output_format: html
|
||||
|
||||
// Create current directory with action-specific config
|
||||
currentDir := filepath.Join(repoRoot, "action")
|
||||
_ = os.MkdirAll(currentDir, 0755)
|
||||
_ = os.MkdirAll(currentDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.WriteTestFile(t, filepath.Join(currentDir, "config.yaml"), `
|
||||
theme: professional
|
||||
output_dir: output
|
||||
@@ -206,7 +206,7 @@ github_token: config-token
|
||||
|
||||
// Create XDG-compliant config
|
||||
configDir := filepath.Join(xdgConfigHome, "gh-action-readme")
|
||||
_ = os.MkdirAll(configDir, 0755)
|
||||
_ = os.MkdirAll(configDir, 0750) // #nosec G301 -- test directory permissions
|
||||
configPath := filepath.Join(configDir, "config.yaml")
|
||||
testutil.WriteTestFile(t, configPath, `
|
||||
theme: github
|
||||
@@ -228,7 +228,7 @@ verbose: true
|
||||
name: "hidden config file discovery",
|
||||
setupFunc: func(t *testing.T, tempDir string) (string, string, string) {
|
||||
repoRoot := filepath.Join(tempDir, "repo")
|
||||
_ = os.MkdirAll(repoRoot, 0755)
|
||||
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
|
||||
|
||||
// Create multiple hidden config files
|
||||
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), `
|
||||
@@ -530,7 +530,7 @@ func TestConfigMerging(t *testing.T) {
|
||||
// Test config merging by creating config files and seeing the result
|
||||
|
||||
globalConfigDir := filepath.Join(tmpDir, ".config", "gh-action-readme")
|
||||
_ = os.MkdirAll(globalConfigDir, 0755)
|
||||
_ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.WriteTestFile(t, filepath.Join(globalConfigDir, "config.yaml"), `
|
||||
theme: default
|
||||
output_format: md
|
||||
@@ -539,7 +539,7 @@ verbose: false
|
||||
`)
|
||||
|
||||
repoRoot := filepath.Join(tmpDir, "repo")
|
||||
_ = os.MkdirAll(repoRoot, 0755)
|
||||
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), `
|
||||
theme: github
|
||||
output_format: html
|
||||
@@ -576,3 +576,251 @@ verbose: true
|
||||
testutil.AssertEqual(t, "base-token", config.GitHubToken) // from global config
|
||||
testutil.AssertEqual(t, "schemas/schema.json", config.Schema) // default value
|
||||
}
|
||||
|
||||
// TestGetGitHubToken tests GitHub token resolution with different priority levels.
|
||||
func TestGetGitHubToken(t *testing.T) {
|
||||
// Save and restore original environment
|
||||
originalToolToken := os.Getenv(EnvGitHubToken)
|
||||
originalStandardToken := os.Getenv(EnvGitHubTokenStandard)
|
||||
defer func() {
|
||||
if originalToolToken != "" {
|
||||
_ = os.Setenv(EnvGitHubToken, originalToolToken)
|
||||
} else {
|
||||
_ = os.Unsetenv(EnvGitHubToken)
|
||||
}
|
||||
if originalStandardToken != "" {
|
||||
_ = os.Setenv(EnvGitHubTokenStandard, originalStandardToken)
|
||||
} else {
|
||||
_ = os.Unsetenv(EnvGitHubTokenStandard)
|
||||
}
|
||||
}()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
toolEnvToken string
|
||||
stdEnvToken string
|
||||
configToken string
|
||||
expectedToken string
|
||||
}{
|
||||
{
|
||||
name: "tool-specific env var has highest priority",
|
||||
toolEnvToken: "tool-token",
|
||||
stdEnvToken: "std-token",
|
||||
configToken: "config-token",
|
||||
expectedToken: "tool-token",
|
||||
},
|
||||
{
|
||||
name: "standard env var when tool env not set",
|
||||
toolEnvToken: "",
|
||||
stdEnvToken: "std-token",
|
||||
configToken: "config-token",
|
||||
expectedToken: "std-token",
|
||||
},
|
||||
{
|
||||
name: "config token when env vars not set",
|
||||
toolEnvToken: "",
|
||||
stdEnvToken: "",
|
||||
configToken: "config-token",
|
||||
expectedToken: "config-token",
|
||||
},
|
||||
{
|
||||
name: "empty string when nothing set",
|
||||
toolEnvToken: "",
|
||||
stdEnvToken: "",
|
||||
configToken: "",
|
||||
expectedToken: "",
|
||||
},
|
||||
{
|
||||
name: "empty env var does not override config",
|
||||
toolEnvToken: "",
|
||||
stdEnvToken: "",
|
||||
configToken: "config-token",
|
||||
expectedToken: "config-token",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Set up environment
|
||||
if tt.toolEnvToken != "" {
|
||||
_ = os.Setenv(EnvGitHubToken, tt.toolEnvToken)
|
||||
} else {
|
||||
_ = os.Unsetenv(EnvGitHubToken)
|
||||
}
|
||||
if tt.stdEnvToken != "" {
|
||||
_ = os.Setenv(EnvGitHubTokenStandard, tt.stdEnvToken)
|
||||
} else {
|
||||
_ = os.Unsetenv(EnvGitHubTokenStandard)
|
||||
}
|
||||
|
||||
config := &AppConfig{GitHubToken: tt.configToken}
|
||||
result := GetGitHubToken(config)
|
||||
|
||||
testutil.AssertEqual(t, tt.expectedToken, result)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeMapFields tests the merging of map fields in configuration.
|
||||
func TestMergeMapFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dst *AppConfig
|
||||
src *AppConfig
|
||||
expected *AppConfig
|
||||
}{
|
||||
{
|
||||
name: "merge permissions into empty dst",
|
||||
dst: &AppConfig{},
|
||||
src: &AppConfig{
|
||||
Permissions: map[string]string{"read": "read", "write": "write"},
|
||||
},
|
||||
expected: &AppConfig{
|
||||
Permissions: map[string]string{"read": "read", "write": "write"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "merge permissions into existing dst",
|
||||
dst: &AppConfig{
|
||||
Permissions: map[string]string{"read": "existing"},
|
||||
},
|
||||
src: &AppConfig{
|
||||
Permissions: map[string]string{"read": "new", "write": "write"},
|
||||
},
|
||||
expected: &AppConfig{
|
||||
Permissions: map[string]string{"read": "new", "write": "write"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "merge variables into empty dst",
|
||||
dst: &AppConfig{},
|
||||
src: &AppConfig{
|
||||
Variables: map[string]string{"VAR1": "value1", "VAR2": "value2"},
|
||||
},
|
||||
expected: &AppConfig{
|
||||
Variables: map[string]string{"VAR1": "value1", "VAR2": "value2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "merge variables into existing dst",
|
||||
dst: &AppConfig{
|
||||
Variables: map[string]string{"VAR1": "existing"},
|
||||
},
|
||||
src: &AppConfig{
|
||||
Variables: map[string]string{"VAR1": "new", "VAR2": "value2"},
|
||||
},
|
||||
expected: &AppConfig{
|
||||
Variables: map[string]string{"VAR1": "new", "VAR2": "value2"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "merge both permissions and variables",
|
||||
dst: &AppConfig{
|
||||
Permissions: map[string]string{"read": "existing"},
|
||||
},
|
||||
src: &AppConfig{
|
||||
Permissions: map[string]string{"write": "write"},
|
||||
Variables: map[string]string{"VAR1": "value1"},
|
||||
},
|
||||
expected: &AppConfig{
|
||||
Permissions: map[string]string{"read": "existing", "write": "write"},
|
||||
Variables: map[string]string{"VAR1": "value1"},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "empty src does not affect dst",
|
||||
dst: &AppConfig{
|
||||
Permissions: map[string]string{"read": "read"},
|
||||
Variables: map[string]string{"VAR1": "value1"},
|
||||
},
|
||||
src: &AppConfig{},
|
||||
expected: &AppConfig{
|
||||
Permissions: map[string]string{"read": "read"},
|
||||
Variables: map[string]string{"VAR1": "value1"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
// Deep copy dst to avoid modifying test data
|
||||
dst := &AppConfig{}
|
||||
if tt.dst.Permissions != nil {
|
||||
dst.Permissions = make(map[string]string)
|
||||
for k, v := range tt.dst.Permissions {
|
||||
dst.Permissions[k] = v
|
||||
}
|
||||
}
|
||||
if tt.dst.Variables != nil {
|
||||
dst.Variables = make(map[string]string)
|
||||
for k, v := range tt.dst.Variables {
|
||||
dst.Variables[k] = v
|
||||
}
|
||||
}
|
||||
|
||||
mergeMapFields(dst, tt.src)
|
||||
|
||||
testutil.AssertEqual(t, tt.expected.Permissions, dst.Permissions)
|
||||
testutil.AssertEqual(t, tt.expected.Variables, dst.Variables)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMergeSliceFields tests the merging of slice fields in configuration.
|
||||
func TestMergeSliceFields(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
dst *AppConfig
|
||||
src *AppConfig
|
||||
expected []string
|
||||
}{
|
||||
{
|
||||
name: "merge runsOn into empty dst",
|
||||
dst: &AppConfig{},
|
||||
src: &AppConfig{RunsOn: []string{"ubuntu-latest", "windows-latest"}},
|
||||
expected: []string{"ubuntu-latest", "windows-latest"},
|
||||
},
|
||||
{
|
||||
name: "merge runsOn replaces existing dst",
|
||||
dst: &AppConfig{RunsOn: []string{"macos-latest"}},
|
||||
src: &AppConfig{RunsOn: []string{"ubuntu-latest", "windows-latest"}},
|
||||
expected: []string{"ubuntu-latest", "windows-latest"},
|
||||
},
|
||||
{
|
||||
name: "empty src does not affect dst",
|
||||
dst: &AppConfig{RunsOn: []string{"ubuntu-latest"}},
|
||||
src: &AppConfig{},
|
||||
expected: []string{"ubuntu-latest"},
|
||||
},
|
||||
{
|
||||
name: "empty src slice does not affect dst",
|
||||
dst: &AppConfig{RunsOn: []string{"ubuntu-latest"}},
|
||||
src: &AppConfig{RunsOn: []string{}},
|
||||
expected: []string{"ubuntu-latest"},
|
||||
},
|
||||
{
|
||||
name: "single item slice",
|
||||
dst: &AppConfig{},
|
||||
src: &AppConfig{RunsOn: []string{"self-hosted"}},
|
||||
expected: []string{"self-hosted"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mergeSliceFields(tt.dst, tt.src)
|
||||
|
||||
// Compare slices manually since they can't be compared directly
|
||||
if len(tt.expected) != len(tt.dst.RunsOn) {
|
||||
t.Errorf("expected slice length %d, got %d", len(tt.expected), len(tt.dst.RunsOn))
|
||||
return
|
||||
}
|
||||
for i, expected := range tt.expected {
|
||||
if i >= len(tt.dst.RunsOn) || tt.dst.RunsOn[i] != expected {
|
||||
t.Errorf("expected %v, got %v", tt.expected, tt.dst.RunsOn)
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
802
internal/configuration_loader_test.go
Normal file
802
internal/configuration_loader_test.go
Normal file
@@ -0,0 +1,802 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
func TestNewConfigurationLoader(t *testing.T) {
|
||||
loader := NewConfigurationLoader()
|
||||
|
||||
if loader == nil {
|
||||
t.Fatal("expected non-nil loader")
|
||||
}
|
||||
|
||||
if loader.viper == nil {
|
||||
t.Fatal("expected viper instance to be initialized")
|
||||
}
|
||||
|
||||
// Check default sources are enabled
|
||||
expectedSources := []ConfigurationSource{
|
||||
SourceDefaults, SourceGlobal, SourceRepoOverride,
|
||||
SourceRepoConfig, SourceActionConfig, SourceEnvironment,
|
||||
}
|
||||
|
||||
for _, source := range expectedSources {
|
||||
if !loader.sources[source] {
|
||||
t.Errorf("expected source %s to be enabled by default", source.String())
|
||||
}
|
||||
}
|
||||
|
||||
// CLI flags should be disabled by default
|
||||
if loader.sources[SourceCLIFlags] {
|
||||
t.Error("expected CLI flags source to be disabled by default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewConfigurationLoaderWithOptions(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts ConfigurationOptions
|
||||
expected []ConfigurationSource
|
||||
}{
|
||||
{
|
||||
name: "default options",
|
||||
opts: ConfigurationOptions{},
|
||||
expected: []ConfigurationSource{
|
||||
SourceDefaults, SourceGlobal, SourceRepoOverride,
|
||||
SourceRepoConfig, SourceActionConfig, SourceEnvironment,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "custom enabled sources",
|
||||
opts: ConfigurationOptions{
|
||||
EnabledSources: []ConfigurationSource{SourceDefaults, SourceGlobal},
|
||||
},
|
||||
expected: []ConfigurationSource{SourceDefaults, SourceGlobal},
|
||||
},
|
||||
{
|
||||
name: "all sources enabled",
|
||||
opts: ConfigurationOptions{
|
||||
EnabledSources: []ConfigurationSource{
|
||||
SourceDefaults, SourceGlobal, SourceRepoOverride,
|
||||
SourceRepoConfig, SourceActionConfig, SourceEnvironment, SourceCLIFlags,
|
||||
},
|
||||
},
|
||||
expected: []ConfigurationSource{
|
||||
SourceDefaults, SourceGlobal, SourceRepoOverride,
|
||||
SourceRepoConfig, SourceActionConfig, SourceEnvironment, SourceCLIFlags,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
loader := NewConfigurationLoaderWithOptions(tt.opts)
|
||||
|
||||
for _, expectedSource := range tt.expected {
|
||||
if !loader.sources[expectedSource] {
|
||||
t.Errorf("expected source %s to be enabled", expectedSource.String())
|
||||
}
|
||||
}
|
||||
|
||||
// Check that non-expected sources are disabled
|
||||
allSources := []ConfigurationSource{
|
||||
SourceDefaults, SourceGlobal, SourceRepoOverride,
|
||||
SourceRepoConfig, SourceActionConfig, SourceEnvironment, SourceCLIFlags,
|
||||
}
|
||||
|
||||
for _, source := range allSources {
|
||||
expected := false
|
||||
for _, expectedSource := range tt.expected {
|
||||
if source == expectedSource {
|
||||
expected = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if loader.sources[source] != expected {
|
||||
t.Errorf("source %s enabled=%v, expected=%v", source.String(), loader.sources[source], expected)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationLoader_LoadConfiguration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tempDir string) (configFile, repoRoot, actionDir string)
|
||||
expectError bool
|
||||
checkFunc func(t *testing.T, config *AppConfig)
|
||||
}{
|
||||
{
|
||||
name: "defaults only",
|
||||
setupFunc: func(_ *testing.T, _ string) (string, string, string) {
|
||||
return "", "", ""
|
||||
},
|
||||
checkFunc: func(_ *testing.T, config *AppConfig) {
|
||||
testutil.AssertEqual(t, "default", config.Theme)
|
||||
testutil.AssertEqual(t, "md", config.OutputFormat)
|
||||
testutil.AssertEqual(t, ".", config.OutputDir)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "multi-level configuration hierarchy",
|
||||
setupFunc: func(_ *testing.T, tempDir string) (string, string, string) {
|
||||
// Create global config
|
||||
globalConfigDir := filepath.Join(tempDir, ".config", "gh-action-readme")
|
||||
_ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions
|
||||
globalConfigPath := filepath.Join(globalConfigDir, "config.yaml")
|
||||
testutil.WriteTestFile(t, globalConfigPath, `
|
||||
theme: default
|
||||
output_format: md
|
||||
github_token: global-token
|
||||
verbose: false
|
||||
`)
|
||||
|
||||
// Create repo root with repo-specific config
|
||||
repoRoot := filepath.Join(tempDir, "repo")
|
||||
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), `
|
||||
theme: github
|
||||
output_format: html
|
||||
verbose: true
|
||||
`)
|
||||
|
||||
// Create action directory with action-specific config
|
||||
actionDir := filepath.Join(repoRoot, "action")
|
||||
_ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.WriteTestFile(t, filepath.Join(actionDir, "config.yaml"), `
|
||||
theme: professional
|
||||
output_dir: output
|
||||
quiet: false
|
||||
`)
|
||||
|
||||
return globalConfigPath, repoRoot, actionDir
|
||||
},
|
||||
checkFunc: func(_ *testing.T, config *AppConfig) {
|
||||
// Should have action-level overrides
|
||||
testutil.AssertEqual(t, "professional", config.Theme)
|
||||
testutil.AssertEqual(t, "output", config.OutputDir)
|
||||
// Should inherit from repo level
|
||||
testutil.AssertEqual(t, "html", config.OutputFormat)
|
||||
testutil.AssertEqual(t, true, config.Verbose)
|
||||
// Should inherit GitHub token from global config
|
||||
testutil.AssertEqual(t, "global-token", config.GitHubToken)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "environment variable overrides",
|
||||
setupFunc: func(_ *testing.T, tempDir string) (string, string, string) {
|
||||
// Set environment variables
|
||||
_ = os.Setenv("GH_README_GITHUB_TOKEN", "env-token")
|
||||
t.Cleanup(func() {
|
||||
_ = os.Unsetenv("GH_README_GITHUB_TOKEN")
|
||||
})
|
||||
|
||||
// Create config file with different token
|
||||
configPath := filepath.Join(tempDir, "config.yml")
|
||||
testutil.WriteTestFile(t, configPath, `
|
||||
theme: minimal
|
||||
github_token: config-token
|
||||
`)
|
||||
|
||||
return configPath, tempDir, ""
|
||||
},
|
||||
checkFunc: func(_ *testing.T, config *AppConfig) {
|
||||
// Environment variable should override config file
|
||||
testutil.AssertEqual(t, "env-token", config.GitHubToken)
|
||||
testutil.AssertEqual(t, "minimal", config.Theme)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "hidden config file priority",
|
||||
setupFunc: func(_ *testing.T, tempDir string) (string, string, string) {
|
||||
repoRoot := filepath.Join(tempDir, "repo")
|
||||
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
|
||||
|
||||
// Create multiple hidden config files - first one should win
|
||||
testutil.WriteTestFile(t, filepath.Join(repoRoot, ".ghreadme.yaml"), `
|
||||
theme: minimal
|
||||
output_format: json
|
||||
`)
|
||||
|
||||
configDir := filepath.Join(repoRoot, ".config")
|
||||
_ = os.MkdirAll(configDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.WriteTestFile(t, filepath.Join(configDir, "ghreadme.yaml"), `
|
||||
theme: professional
|
||||
quiet: true
|
||||
`)
|
||||
|
||||
githubDir := filepath.Join(repoRoot, ".github")
|
||||
_ = os.MkdirAll(githubDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.WriteTestFile(t, filepath.Join(githubDir, "ghreadme.yaml"), `
|
||||
theme: github
|
||||
verbose: true
|
||||
`)
|
||||
|
||||
return "", repoRoot, ""
|
||||
},
|
||||
checkFunc: func(_ *testing.T, config *AppConfig) {
|
||||
// Should use the first found config (.ghreadme.yaml has priority)
|
||||
testutil.AssertEqual(t, "minimal", config.Theme)
|
||||
testutil.AssertEqual(t, "json", config.OutputFormat)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "selective source loading",
|
||||
setupFunc: func(_ *testing.T, _ string) (string, string, string) {
|
||||
// This test uses a loader with specific sources enabled
|
||||
return "", "", ""
|
||||
},
|
||||
checkFunc: func(_ *testing.T, _ *AppConfig) {
|
||||
// This will be tested with a custom loader
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
// Set HOME to temp directory for fallback
|
||||
originalHome := os.Getenv("HOME")
|
||||
_ = os.Setenv("HOME", tmpDir)
|
||||
defer func() {
|
||||
if originalHome != "" {
|
||||
_ = os.Setenv("HOME", originalHome)
|
||||
} else {
|
||||
_ = os.Unsetenv("HOME")
|
||||
}
|
||||
}()
|
||||
|
||||
configFile, repoRoot, actionDir := tt.setupFunc(t, tmpDir)
|
||||
|
||||
// Special handling for selective source loading test
|
||||
var loader *ConfigurationLoader
|
||||
if tt.name == "selective source loading" {
|
||||
// Create loader with only defaults and global sources
|
||||
loader = NewConfigurationLoaderWithOptions(ConfigurationOptions{
|
||||
EnabledSources: []ConfigurationSource{SourceDefaults, SourceGlobal},
|
||||
})
|
||||
} else {
|
||||
loader = NewConfigurationLoader()
|
||||
}
|
||||
|
||||
config, err := loader.LoadConfiguration(configFile, repoRoot, actionDir)
|
||||
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
if tt.checkFunc != nil {
|
||||
tt.checkFunc(t, config)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationLoader_LoadGlobalConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tempDir string) string
|
||||
expectError bool
|
||||
checkFunc func(t *testing.T, config *AppConfig)
|
||||
}{
|
||||
{
|
||||
name: "valid global config",
|
||||
setupFunc: func(t *testing.T, tempDir string) string {
|
||||
configPath := filepath.Join(tempDir, "config.yaml")
|
||||
testutil.WriteTestFile(t, configPath, `
|
||||
theme: professional
|
||||
output_format: html
|
||||
github_token: test-token
|
||||
verbose: true
|
||||
`)
|
||||
return configPath
|
||||
},
|
||||
checkFunc: func(_ *testing.T, config *AppConfig) {
|
||||
testutil.AssertEqual(t, "professional", config.Theme)
|
||||
testutil.AssertEqual(t, "html", config.OutputFormat)
|
||||
testutil.AssertEqual(t, "test-token", config.GitHubToken)
|
||||
testutil.AssertEqual(t, true, config.Verbose)
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "nonexistent config file",
|
||||
setupFunc: func(_ *testing.T, tempDir string) string {
|
||||
return filepath.Join(tempDir, "nonexistent.yaml")
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid YAML",
|
||||
setupFunc: func(t *testing.T, tempDir string) string {
|
||||
configPath := filepath.Join(tempDir, "invalid.yaml")
|
||||
testutil.WriteTestFile(t, configPath, "invalid: yaml: content: [")
|
||||
return configPath
|
||||
},
|
||||
expectError: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
// Set HOME to temp directory
|
||||
originalHome := os.Getenv("HOME")
|
||||
_ = os.Setenv("HOME", tmpDir)
|
||||
defer func() {
|
||||
if originalHome != "" {
|
||||
_ = os.Setenv("HOME", originalHome)
|
||||
} else {
|
||||
_ = os.Unsetenv("HOME")
|
||||
}
|
||||
}()
|
||||
|
||||
configFile := tt.setupFunc(t, tmpDir)
|
||||
|
||||
loader := NewConfigurationLoader()
|
||||
config, err := loader.LoadGlobalConfig(configFile)
|
||||
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
if tt.checkFunc != nil {
|
||||
tt.checkFunc(t, config)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationLoader_ValidateConfiguration(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *AppConfig
|
||||
expectError bool
|
||||
errorMsg string
|
||||
}{
|
||||
{
|
||||
name: "nil config",
|
||||
config: nil,
|
||||
expectError: true,
|
||||
errorMsg: "configuration cannot be nil",
|
||||
},
|
||||
{
|
||||
name: "valid config",
|
||||
config: &AppConfig{
|
||||
Theme: "default",
|
||||
OutputFormat: "md",
|
||||
OutputDir: ".",
|
||||
Verbose: false,
|
||||
Quiet: false,
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "invalid output format",
|
||||
config: &AppConfig{
|
||||
Theme: "default",
|
||||
OutputFormat: "invalid",
|
||||
OutputDir: ".",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "invalid output format",
|
||||
},
|
||||
{
|
||||
name: "empty output directory",
|
||||
config: &AppConfig{
|
||||
Theme: "default",
|
||||
OutputFormat: "md",
|
||||
OutputDir: "",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "output directory cannot be empty",
|
||||
},
|
||||
{
|
||||
name: "verbose and quiet both true",
|
||||
config: &AppConfig{
|
||||
Theme: "default",
|
||||
OutputFormat: "md",
|
||||
OutputDir: ".",
|
||||
Verbose: true,
|
||||
Quiet: true,
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "verbose and quiet flags are mutually exclusive",
|
||||
},
|
||||
{
|
||||
name: "invalid theme",
|
||||
config: &AppConfig{
|
||||
Theme: "nonexistent",
|
||||
OutputFormat: "md",
|
||||
OutputDir: ".",
|
||||
},
|
||||
expectError: true,
|
||||
errorMsg: "invalid theme",
|
||||
},
|
||||
{
|
||||
name: "valid built-in themes",
|
||||
config: &AppConfig{
|
||||
Theme: "github",
|
||||
OutputFormat: "html",
|
||||
OutputDir: "docs",
|
||||
},
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
loader := NewConfigurationLoader()
|
||||
err := loader.ValidateConfiguration(tt.config)
|
||||
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
if tt.errorMsg != "" {
|
||||
testutil.AssertStringContains(t, err.Error(), tt.errorMsg)
|
||||
}
|
||||
} else {
|
||||
testutil.AssertNoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationLoader_SourceManagement(t *testing.T) {
|
||||
loader := NewConfigurationLoader()
|
||||
|
||||
// Test initial state
|
||||
sources := loader.GetConfigurationSources()
|
||||
if len(sources) != 6 { // All except CLI flags
|
||||
t.Errorf("expected 6 enabled sources, got %d", len(sources))
|
||||
}
|
||||
|
||||
// Test disabling a source
|
||||
loader.DisableSource(SourceGlobal)
|
||||
if loader.sources[SourceGlobal] {
|
||||
t.Error("expected SourceGlobal to be disabled")
|
||||
}
|
||||
|
||||
// Test enabling a source
|
||||
loader.EnableSource(SourceCLIFlags)
|
||||
if !loader.sources[SourceCLIFlags] {
|
||||
t.Error("expected SourceCLIFlags to be enabled")
|
||||
}
|
||||
|
||||
// Test updated sources list
|
||||
sources = loader.GetConfigurationSources()
|
||||
expectedCount := 6 // 5 original + CLI flags - Global
|
||||
if len(sources) != expectedCount {
|
||||
t.Errorf("expected %d enabled sources, got %d", expectedCount, len(sources))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationSource_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
source ConfigurationSource
|
||||
expected string
|
||||
}{
|
||||
{SourceDefaults, "defaults"},
|
||||
{SourceGlobal, "global"},
|
||||
{SourceRepoOverride, "repo-override"},
|
||||
{SourceRepoConfig, "repo-config"},
|
||||
{SourceActionConfig, "action-config"},
|
||||
{SourceEnvironment, "environment"},
|
||||
{SourceCLIFlags, "cli-flags"},
|
||||
{ConfigurationSource(999), "unknown"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
result := tt.source.String()
|
||||
if result != tt.expected {
|
||||
t.Errorf("source %d String() = %s, expected %s", int(tt.source), result, tt.expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationLoader_EnvironmentOverrides(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T) func()
|
||||
expectedToken string
|
||||
}{
|
||||
{
|
||||
name: "GH_README_GITHUB_TOKEN priority",
|
||||
setupFunc: func(_ *testing.T) func() {
|
||||
_ = os.Setenv("GH_README_GITHUB_TOKEN", "priority-token")
|
||||
_ = os.Setenv("GITHUB_TOKEN", "fallback-token")
|
||||
return func() {
|
||||
_ = os.Unsetenv("GH_README_GITHUB_TOKEN")
|
||||
_ = os.Unsetenv("GITHUB_TOKEN")
|
||||
}
|
||||
},
|
||||
expectedToken: "priority-token",
|
||||
},
|
||||
{
|
||||
name: "GITHUB_TOKEN fallback",
|
||||
setupFunc: func(_ *testing.T) func() {
|
||||
_ = os.Unsetenv("GH_README_GITHUB_TOKEN")
|
||||
_ = os.Setenv("GITHUB_TOKEN", "fallback-token")
|
||||
return func() {
|
||||
_ = os.Unsetenv("GITHUB_TOKEN")
|
||||
}
|
||||
},
|
||||
expectedToken: "fallback-token",
|
||||
},
|
||||
{
|
||||
name: "no environment variables",
|
||||
setupFunc: func(_ *testing.T) func() {
|
||||
_ = os.Unsetenv("GH_README_GITHUB_TOKEN")
|
||||
_ = os.Unsetenv("GITHUB_TOKEN")
|
||||
return func() {}
|
||||
},
|
||||
expectedToken: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
cleanup := tt.setupFunc(t)
|
||||
defer cleanup()
|
||||
|
||||
tmpDir, tmpCleanup := testutil.TempDir(t)
|
||||
defer tmpCleanup()
|
||||
|
||||
loader := NewConfigurationLoader()
|
||||
config, err := loader.LoadConfiguration("", tmpDir, "")
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
testutil.AssertEqual(t, tt.expectedToken, config.GitHubToken)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigurationLoader_RepoOverrides(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
// Create a mock git repository structure for testing
|
||||
repoRoot := filepath.Join(tmpDir, "test-repo")
|
||||
_ = os.MkdirAll(repoRoot, 0750) // #nosec G301 -- test directory permissions
|
||||
|
||||
// Create global config with repo overrides
|
||||
globalConfigDir := filepath.Join(tmpDir, ".config", "gh-action-readme")
|
||||
_ = os.MkdirAll(globalConfigDir, 0750) // #nosec G301 -- test directory permissions
|
||||
globalConfigPath := filepath.Join(globalConfigDir, "config.yaml")
|
||||
globalConfigContent := "theme: default\n"
|
||||
globalConfigContent += "output_format: md\n"
|
||||
globalConfigContent += "repo_overrides:\n"
|
||||
globalConfigContent += " test-repo:\n"
|
||||
globalConfigContent += " theme: github\n"
|
||||
globalConfigContent += " output_format: html\n"
|
||||
globalConfigContent += " verbose: true\n"
|
||||
testutil.WriteTestFile(t, globalConfigPath, globalConfigContent)
|
||||
|
||||
// Set environment for XDG compliance
|
||||
originalHome := os.Getenv("HOME")
|
||||
_ = os.Setenv("HOME", tmpDir)
|
||||
defer func() {
|
||||
if originalHome != "" {
|
||||
_ = os.Setenv("HOME", originalHome)
|
||||
} else {
|
||||
_ = os.Unsetenv("HOME")
|
||||
}
|
||||
}()
|
||||
|
||||
loader := NewConfigurationLoader()
|
||||
config, err := loader.LoadConfiguration(globalConfigPath, repoRoot, "")
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Note: Since we don't have actual git repository detection in this test,
|
||||
// repo overrides won't be applied. This test validates the structure works.
|
||||
testutil.AssertEqual(t, "default", config.Theme)
|
||||
testutil.AssertEqual(t, "md", config.OutputFormat)
|
||||
}
|
||||
|
||||
// TestConfigurationLoader_ApplyRepoOverrides tests repo-specific overrides.
|
||||
func TestConfigurationLoader_ApplyRepoOverrides(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config *AppConfig
|
||||
expectedTheme string
|
||||
expectedFormat string
|
||||
}{
|
||||
{
|
||||
name: "no repo overrides configured",
|
||||
config: &AppConfig{
|
||||
Theme: "default",
|
||||
OutputFormat: "md",
|
||||
RepoOverrides: nil,
|
||||
},
|
||||
expectedTheme: "default",
|
||||
expectedFormat: "md",
|
||||
},
|
||||
{
|
||||
name: "empty repo overrides map",
|
||||
config: &AppConfig{
|
||||
Theme: "default",
|
||||
OutputFormat: "md",
|
||||
RepoOverrides: map[string]AppConfig{},
|
||||
},
|
||||
expectedTheme: "default",
|
||||
expectedFormat: "md",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
loader := NewConfigurationLoader()
|
||||
loader.applyRepoOverrides(tt.config, tmpDir)
|
||||
testutil.AssertEqual(t, tt.expectedTheme, tt.config.Theme)
|
||||
testutil.AssertEqual(t, tt.expectedFormat, tt.config.OutputFormat)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigurationLoader_LoadActionConfig tests action-specific configuration loading.
|
||||
func TestConfigurationLoader_LoadActionConfig(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupFunc func(t *testing.T, tmpDir string) string
|
||||
expectError bool
|
||||
expectedVals map[string]string
|
||||
}{
|
||||
{
|
||||
name: "no action directory provided",
|
||||
setupFunc: func(_ *testing.T, _ string) string {
|
||||
return ""
|
||||
},
|
||||
expectError: false,
|
||||
expectedVals: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "action directory with config file",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
actionDir := filepath.Join(tmpDir, "action")
|
||||
_ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions
|
||||
|
||||
configPath := filepath.Join(actionDir, "config.yaml")
|
||||
testutil.WriteTestFile(t, configPath, `
|
||||
theme: minimal
|
||||
output_format: json
|
||||
verbose: true
|
||||
`)
|
||||
return actionDir
|
||||
},
|
||||
expectError: false,
|
||||
expectedVals: map[string]string{
|
||||
"theme": "minimal",
|
||||
"output_format": "json",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "action directory with malformed config file",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
actionDir := filepath.Join(tmpDir, "action")
|
||||
_ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions
|
||||
|
||||
configPath := filepath.Join(actionDir, "config.yaml")
|
||||
testutil.WriteTestFile(t, configPath, "invalid yaml content:\n - broken [")
|
||||
return actionDir
|
||||
},
|
||||
expectError: false, // Function may handle YAML errors gracefully
|
||||
expectedVals: map[string]string{},
|
||||
},
|
||||
{
|
||||
name: "action directory without config file",
|
||||
setupFunc: func(_ *testing.T, tmpDir string) string {
|
||||
actionDir := filepath.Join(tmpDir, "action")
|
||||
_ = os.MkdirAll(actionDir, 0750) // #nosec G301 -- test directory permissions
|
||||
return actionDir
|
||||
},
|
||||
expectError: false,
|
||||
expectedVals: map[string]string{},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
actionDir := tt.setupFunc(t, tmpDir)
|
||||
|
||||
loader := NewConfigurationLoader()
|
||||
config, err := loader.loadActionConfig(actionDir)
|
||||
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
} else {
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Check expected values if no error
|
||||
if config != nil {
|
||||
for key, expected := range tt.expectedVals {
|
||||
switch key {
|
||||
case "theme":
|
||||
testutil.AssertEqual(t, expected, config.Theme)
|
||||
case "output_format":
|
||||
testutil.AssertEqual(t, expected, config.OutputFormat)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigurationLoader_ValidateTheme tests theme validation edge cases.
|
||||
func TestConfigurationLoader_ValidateTheme(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
theme string
|
||||
expectError bool
|
||||
}{
|
||||
{
|
||||
name: "valid built-in theme",
|
||||
theme: "github",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "valid default theme",
|
||||
theme: "default",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "empty theme returns error",
|
||||
theme: "",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "invalid theme",
|
||||
theme: "nonexistent-theme",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "case sensitive theme",
|
||||
theme: "GitHub",
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "custom theme path",
|
||||
theme: "/custom/theme/path.tmpl",
|
||||
expectError: false,
|
||||
},
|
||||
{
|
||||
name: "relative theme path",
|
||||
theme: "custom/theme.tmpl",
|
||||
expectError: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
loader := NewConfigurationLoader()
|
||||
err := loader.validateTheme(tt.theme)
|
||||
|
||||
if tt.expectError {
|
||||
testutil.AssertError(t, err)
|
||||
} else {
|
||||
testutil.AssertNoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
109
internal/constants.go
Normal file
109
internal/constants.go
Normal file
@@ -0,0 +1,109 @@
|
||||
// Package internal provides common constants used throughout the application.
|
||||
package internal
|
||||
|
||||
// File extension constants.
|
||||
const (
|
||||
// ActionFileExtYML is the primary action file extension.
|
||||
ActionFileExtYML = ".yml"
|
||||
// ActionFileExtYAML is the alternative action file extension.
|
||||
ActionFileExtYAML = ".yaml"
|
||||
|
||||
// ActionFileNameYML is the primary action file name.
|
||||
ActionFileNameYML = "action.yml"
|
||||
// ActionFileNameYAML is the alternative action file name.
|
||||
ActionFileNameYAML = "action.yaml"
|
||||
)
|
||||
|
||||
// File permission constants.
|
||||
const (
|
||||
// FilePermDefault is the default file permission for created files.
|
||||
FilePermDefault = 0600
|
||||
// FilePermTest is the file permission used in tests.
|
||||
FilePermTest = 0600
|
||||
)
|
||||
|
||||
// Configuration file constants.
|
||||
const (
|
||||
// ConfigFileName is the primary configuration file name.
|
||||
ConfigFileName = "config"
|
||||
// ConfigFileExtYAML is the configuration file extension.
|
||||
ConfigFileExtYAML = ".yaml"
|
||||
// ConfigFileNameFull is the full configuration file name.
|
||||
ConfigFileNameFull = ConfigFileName + ConfigFileExtYAML
|
||||
)
|
||||
|
||||
// Context key constants for maps and data structures.
|
||||
const (
|
||||
// ContextKeyError is used as a key for error information in context maps.
|
||||
ContextKeyError = "error"
|
||||
// ContextKeyTheme is used as a key for theme information.
|
||||
ContextKeyTheme = "theme"
|
||||
// ContextKeyConfig is used as a key for configuration information.
|
||||
ContextKeyConfig = "config"
|
||||
)
|
||||
|
||||
// Common string identifiers.
|
||||
const (
|
||||
// ThemeGitHub is the GitHub theme identifier.
|
||||
ThemeGitHub = "github"
|
||||
// ThemeGitLab is the GitLab theme identifier.
|
||||
ThemeGitLab = "gitlab"
|
||||
// ThemeMinimal is the minimal theme identifier.
|
||||
ThemeMinimal = "minimal"
|
||||
// ThemeProfessional is the professional theme identifier.
|
||||
ThemeProfessional = "professional"
|
||||
// ThemeDefault is the default theme identifier.
|
||||
ThemeDefault = "default"
|
||||
)
|
||||
|
||||
// Environment variable names.
|
||||
const (
|
||||
// EnvGitHubToken is the tool-specific GitHub token environment variable.
|
||||
EnvGitHubToken = "GH_README_GITHUB_TOKEN" // #nosec G101 -- environment variable name, not a credential
|
||||
// EnvGitHubTokenStandard is the standard GitHub token environment variable.
|
||||
EnvGitHubTokenStandard = "GITHUB_TOKEN" // #nosec G101 -- environment variable name, not a credential
|
||||
)
|
||||
|
||||
// Configuration keys and paths.
|
||||
const (
|
||||
// ConfigKeyGitHubToken is the configuration key for GitHub token.
|
||||
ConfigKeyGitHubToken = "github_token"
|
||||
// ConfigKeyTheme is the configuration key for theme.
|
||||
ConfigKeyTheme = "theme"
|
||||
// ConfigKeyOutputFormat is the configuration key for output format.
|
||||
ConfigKeyOutputFormat = "output_format"
|
||||
// ConfigKeyOutputDir is the configuration key for output directory.
|
||||
ConfigKeyOutputDir = "output_dir"
|
||||
// ConfigKeyVerbose is the configuration key for verbose mode.
|
||||
ConfigKeyVerbose = "verbose"
|
||||
// ConfigKeyQuiet is the configuration key for quiet mode.
|
||||
ConfigKeyQuiet = "quiet"
|
||||
// ConfigKeyAnalyzeDependencies is the configuration key for dependency analysis.
|
||||
ConfigKeyAnalyzeDependencies = "analyze_dependencies"
|
||||
// ConfigKeyShowSecurityInfo is the configuration key for security info display.
|
||||
ConfigKeyShowSecurityInfo = "show_security_info"
|
||||
)
|
||||
|
||||
// Template path constants.
|
||||
const (
|
||||
// TemplatePathDefault is the default template path.
|
||||
TemplatePathDefault = "templates/readme.tmpl"
|
||||
// TemplatePathGitHub is the GitHub theme template path.
|
||||
TemplatePathGitHub = "templates/themes/github/readme.tmpl"
|
||||
// TemplatePathGitLab is the GitLab theme template path.
|
||||
TemplatePathGitLab = "templates/themes/gitlab/readme.tmpl"
|
||||
// TemplatePathMinimal is the minimal theme template path.
|
||||
TemplatePathMinimal = "templates/themes/minimal/readme.tmpl"
|
||||
// TemplatePathProfessional is the professional theme template path.
|
||||
TemplatePathProfessional = "templates/themes/professional/readme.tmpl"
|
||||
)
|
||||
|
||||
// Config file search patterns.
|
||||
const (
|
||||
// ConfigFilePatternHidden is the primary hidden config file pattern.
|
||||
ConfigFilePatternHidden = ".ghreadme.yaml"
|
||||
// ConfigFilePatternConfig is the secondary config directory pattern.
|
||||
ConfigFilePatternConfig = ".config/ghreadme.yaml"
|
||||
// ConfigFilePatternGitHub is the GitHub ecosystem config pattern.
|
||||
ConfigFilePatternGitHub = ".github/ghreadme.yaml"
|
||||
)
|
||||
@@ -32,7 +32,43 @@ const (
|
||||
updateTypeNone = "none"
|
||||
updateTypeMajor = "major"
|
||||
updateTypePatch = "patch"
|
||||
updateTypeMinor = "minor"
|
||||
defaultBranch = "main"
|
||||
|
||||
// Timeout constants.
|
||||
apiCallTimeout = 10 * time.Second
|
||||
cacheDefaultTTL = 1 * time.Hour
|
||||
|
||||
// File permission constants.
|
||||
backupFilePerms = 0600
|
||||
updatedFilePerms = 0600
|
||||
|
||||
// GitHub URL patterns.
|
||||
githubBaseURL = "https://github.com"
|
||||
marketplaceBaseURL = "https://github.com/marketplace/actions/"
|
||||
|
||||
// Version parsing constants.
|
||||
fullSHALength = 40
|
||||
minSHALength = 7
|
||||
versionPartsCount = 3
|
||||
|
||||
// File path patterns.
|
||||
dockerPrefix = "docker://"
|
||||
localPathPrefix = "./"
|
||||
localPathUpPrefix = "../"
|
||||
|
||||
// File extensions.
|
||||
backupExtension = ".backup"
|
||||
|
||||
// Cache key prefixes.
|
||||
cacheKeyLatest = "latest:"
|
||||
cacheKeyRepo = "repo:"
|
||||
|
||||
// YAML structure constants.
|
||||
usesFieldPrefix = "uses: "
|
||||
|
||||
// Special line estimation for script URLs.
|
||||
scriptLineEstimate = 10
|
||||
)
|
||||
|
||||
// Dependency represents a GitHub Action dependency with detailed information.
|
||||
@@ -223,7 +259,7 @@ func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependen
|
||||
VersionType: versionType,
|
||||
IsPinned: versionType == CommitSHA || (versionType == SemanticVersion && a.isVersionPinned(version)),
|
||||
Author: owner,
|
||||
SourceURL: fmt.Sprintf("https://github.com/%s/%s", owner, repo),
|
||||
SourceURL: fmt.Sprintf("%s/%s/%s", githubBaseURL, owner, repo),
|
||||
IsLocalAction: isLocal,
|
||||
IsShellScript: false,
|
||||
WithParams: a.convertWithParams(step.With),
|
||||
@@ -231,7 +267,7 @@ func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependen
|
||||
|
||||
// Add marketplace URL for public actions
|
||||
if !isLocal {
|
||||
dep.MarketplaceURL = fmt.Sprintf("https://github.com/marketplace/actions/%s", repo)
|
||||
dep.MarketplaceURL = marketplaceBaseURL + repo
|
||||
}
|
||||
|
||||
// Fetch additional metadata from GitHub API if available
|
||||
@@ -254,8 +290,14 @@ func (a *Analyzer) analyzeShellScript(step CompositeStep, stepNumber int) *Depen
|
||||
scriptURL := ""
|
||||
if a.RepoInfo.Organization != "" && a.RepoInfo.Repository != "" {
|
||||
// This would ideally link to the specific line in the action.yml file
|
||||
scriptURL = fmt.Sprintf("https://github.com/%s/%s/blob/%s/action.yml#L%d",
|
||||
a.RepoInfo.Organization, a.RepoInfo.Repository, a.RepoInfo.DefaultBranch, stepNumber*10) // Rough estimate
|
||||
scriptURL = fmt.Sprintf(
|
||||
"%s/%s/%s/blob/%s/action.yml#L%d",
|
||||
githubBaseURL,
|
||||
a.RepoInfo.Organization,
|
||||
a.RepoInfo.Repository,
|
||||
a.RepoInfo.DefaultBranch,
|
||||
stepNumber*scriptLineEstimate,
|
||||
) // Rough estimate
|
||||
}
|
||||
|
||||
return &Dependency{
|
||||
@@ -283,11 +325,11 @@ func (a *Analyzer) parseUsesStatement(uses string) (owner, repo, version string,
|
||||
// - ./local-action
|
||||
// - docker://alpine:3.14
|
||||
|
||||
if strings.HasPrefix(uses, "./") || strings.HasPrefix(uses, "../") {
|
||||
if strings.HasPrefix(uses, localPathPrefix) || strings.HasPrefix(uses, localPathUpPrefix) {
|
||||
return "", "", uses, LocalPath
|
||||
}
|
||||
|
||||
if strings.HasPrefix(uses, "docker://") {
|
||||
if strings.HasPrefix(uses, dockerPrefix) {
|
||||
return "", "", uses, LocalPath
|
||||
}
|
||||
|
||||
@@ -319,7 +361,7 @@ func (a *Analyzer) parseUsesStatement(uses string) (owner, repo, version string,
|
||||
func (a *Analyzer) isCommitSHA(version string) bool {
|
||||
// Check if it's a 40-character hex string (full SHA) or 7+ character hex (short SHA)
|
||||
re := regexp.MustCompile(`^[a-f0-9]{7,40}$`)
|
||||
return len(version) >= 7 && re.MatchString(version)
|
||||
return len(version) >= minSHALength && re.MatchString(version)
|
||||
}
|
||||
|
||||
// isSemanticVersion checks if a version string follows semantic versioning.
|
||||
@@ -333,7 +375,7 @@ func (a *Analyzer) isSemanticVersion(version string) bool {
|
||||
func (a *Analyzer) isVersionPinned(version string) bool {
|
||||
// Consider it pinned if it specifies patch version (v1.2.3) or is a commit SHA
|
||||
// Also check for full commit SHAs (40 chars)
|
||||
if len(version) == 40 {
|
||||
if len(version) == fullSHALength {
|
||||
return true
|
||||
}
|
||||
re := regexp.MustCompile(`^v?\d+\.\d+\.\d+`)
|
||||
@@ -393,11 +435,11 @@ func (a *Analyzer) getLatestVersion(owner, repo string) (version, sha string, er
|
||||
return "", "", fmt.Errorf("GitHub client not available")
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("latest:%s/%s", owner, repo)
|
||||
cacheKey := cacheKeyLatest + fmt.Sprintf("%s/%s", owner, repo)
|
||||
if version, sha, found := a.getCachedVersion(cacheKey); found {
|
||||
return version, sha, nil
|
||||
}
|
||||
@@ -478,7 +520,7 @@ func (a *Analyzer) cacheVersion(cacheKey, version, sha string) {
|
||||
}
|
||||
|
||||
versionInfo := map[string]string{"version": version, "sha": sha}
|
||||
_ = a.Cache.SetWithTTL(cacheKey, versionInfo, 1*time.Hour)
|
||||
_ = a.Cache.SetWithTTL(cacheKey, versionInfo, cacheDefaultTTL)
|
||||
}
|
||||
|
||||
// compareVersions compares two version strings and returns the update type.
|
||||
@@ -505,7 +547,7 @@ func (a *Analyzer) compareVersions(current, latest string) string {
|
||||
func (a *Analyzer) parseVersionParts(version string) []string {
|
||||
parts := strings.Split(version, ".")
|
||||
// For floating versions like "v4", treat as "v4.0.0" for comparison
|
||||
for len(parts) < 3 {
|
||||
for len(parts) < versionPartsCount {
|
||||
parts = append(parts, "0")
|
||||
}
|
||||
return parts
|
||||
@@ -517,7 +559,7 @@ func (a *Analyzer) determineUpdateType(currentParts, latestParts []string) strin
|
||||
return updateTypeMajor
|
||||
}
|
||||
if currentParts[1] != latestParts[1] {
|
||||
return "minor"
|
||||
return updateTypeMinor
|
||||
}
|
||||
if currentParts[2] != latestParts[2] {
|
||||
return updateTypePatch
|
||||
@@ -573,14 +615,14 @@ func (a *Analyzer) ApplyPinnedUpdates(updates []PinnedUpdate) error {
|
||||
// updateActionFile applies updates to a single action file.
|
||||
func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) error {
|
||||
// Read the file
|
||||
content, err := os.ReadFile(filePath)
|
||||
content, err := os.ReadFile(filePath) // #nosec G304 -- file path from function parameter
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read file: %w", err)
|
||||
}
|
||||
|
||||
// Create backup
|
||||
backupPath := filePath + ".backup"
|
||||
if err := os.WriteFile(backupPath, content, 0644); err != nil {
|
||||
backupPath := filePath + backupExtension
|
||||
if err := os.WriteFile(backupPath, content, backupFilePerms); err != nil { // #nosec G306 -- backup file permissions
|
||||
return fmt.Errorf("failed to create backup: %w", err)
|
||||
}
|
||||
|
||||
@@ -592,8 +634,7 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err
|
||||
if strings.Contains(line, update.OldUses) {
|
||||
// Replace the uses statement while preserving indentation
|
||||
indent := strings.Repeat(" ", len(line)-len(strings.TrimLeft(line, " ")))
|
||||
usesPrefix := "uses: "
|
||||
lines[i] = indent + usesPrefix + update.NewUses
|
||||
lines[i] = indent + usesFieldPrefix + update.NewUses
|
||||
update.LineNumber = i + 1 // Store line number for reference
|
||||
break
|
||||
}
|
||||
@@ -602,7 +643,8 @@ func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) err
|
||||
|
||||
// Write updated content
|
||||
updatedContent := strings.Join(lines, "\n")
|
||||
if err := os.WriteFile(filePath, []byte(updatedContent), 0644); err != nil {
|
||||
if err := os.WriteFile(filePath, []byte(updatedContent), updatedFilePerms); err != nil {
|
||||
// #nosec G306 -- updated file permissions
|
||||
return fmt.Errorf("failed to write updated file: %w", err)
|
||||
}
|
||||
|
||||
@@ -629,11 +671,11 @@ func (a *Analyzer) validateActionFile(filePath string) error {
|
||||
|
||||
// enrichWithGitHubData fetches additional information from GitHub API.
|
||||
func (a *Analyzer) enrichWithGitHubData(dep *Dependency, owner, repo string) error {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), apiCallTimeout)
|
||||
defer cancel()
|
||||
|
||||
// Check cache first
|
||||
cacheKey := fmt.Sprintf("repo:%s/%s", owner, repo)
|
||||
cacheKey := cacheKeyRepo + fmt.Sprintf("%s/%s", owner, repo)
|
||||
if a.Cache != nil {
|
||||
if cached, exists := a.Cache.Get(cacheKey); exists {
|
||||
if repository, ok := cached.(*github.Repository); ok {
|
||||
@@ -651,7 +693,7 @@ func (a *Analyzer) enrichWithGitHubData(dep *Dependency, owner, repo string) err
|
||||
|
||||
// Cache the result with 1 hour TTL
|
||||
if a.Cache != nil {
|
||||
_ = a.Cache.SetWithTTL(cacheKey, repository, 1*time.Hour) // Ignore cache errors
|
||||
_ = a.Cache.SetWithTTL(cacheKey, repository, cacheDefaultTTL) // Ignore cache errors
|
||||
}
|
||||
|
||||
// Enrich dependency with API data
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/google/go-github/v57/github"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/cache"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
@@ -25,34 +26,34 @@ func TestAnalyzer_AnalyzeActionFile(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "simple action - no dependencies",
|
||||
actionYML: testutil.SimpleActionYML,
|
||||
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
|
||||
expectError: false,
|
||||
expectDeps: false,
|
||||
expectedLen: 0,
|
||||
},
|
||||
{
|
||||
name: "composite action with dependencies",
|
||||
actionYML: testutil.CompositeActionYML,
|
||||
actionYML: testutil.MustReadFixture("actions/composite/with-dependencies.yml"),
|
||||
expectError: false,
|
||||
expectDeps: true,
|
||||
expectedLen: 3,
|
||||
expectedDeps: []string{"actions/checkout@v4", "actions/setup-node@v3"},
|
||||
expectedLen: 5, // 3 action dependencies + 2 shell script dependencies
|
||||
expectedDeps: []string{"actions/checkout@v4", "actions/setup-node@v4", "actions/setup-python@v4"},
|
||||
},
|
||||
{
|
||||
name: "docker action - no step dependencies",
|
||||
actionYML: testutil.DockerActionYML,
|
||||
actionYML: testutil.MustReadFixture("actions/docker/basic.yml"),
|
||||
expectError: false,
|
||||
expectDeps: false,
|
||||
expectedLen: 0,
|
||||
},
|
||||
{
|
||||
name: "invalid action file",
|
||||
actionYML: testutil.InvalidActionYML,
|
||||
actionYML: testutil.MustReadFixture("actions/invalid/invalid-using.yml"),
|
||||
expectError: true,
|
||||
},
|
||||
{
|
||||
name: "minimal action - no dependencies",
|
||||
actionYML: testutil.MinimalActionYML,
|
||||
actionYML: testutil.MustReadFixture("minimal-action.yml"),
|
||||
expectError: false,
|
||||
expectDeps: false,
|
||||
expectedLen: 0,
|
||||
@@ -401,18 +402,7 @@ func TestAnalyzer_GeneratePinnedUpdate(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
// Create a test action file with composite steps
|
||||
actionContent := `name: 'Test Composite Action'
|
||||
description: 'Test action for update testing'
|
||||
runs:
|
||||
using: 'composite'
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v3
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v3.8.0
|
||||
with:
|
||||
node-version: '18'
|
||||
`
|
||||
actionContent := testutil.MustReadFixture("test-composite-action.yml")
|
||||
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, actionContent)
|
||||
@@ -528,7 +518,7 @@ func TestAnalyzer_WithoutGitHubClient(t *testing.T) {
|
||||
defer cleanup()
|
||||
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.CompositeActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/composite/basic.yml"))
|
||||
|
||||
deps, err := analyzer.AnalyzeActionFile(actionPath)
|
||||
|
||||
@@ -553,3 +543,76 @@ type mockTransport struct {
|
||||
func (t *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return t.client.Do(req)
|
||||
}
|
||||
|
||||
// TestNewAnalyzer tests the analyzer constructor.
|
||||
func TestNewAnalyzer(t *testing.T) {
|
||||
// Create test dependencies
|
||||
mockResponses := testutil.MockGitHubResponses()
|
||||
githubClient := testutil.MockGitHubClient(mockResponses)
|
||||
cacheInstance, err := cache.NewCache(cache.DefaultConfig())
|
||||
testutil.AssertNoError(t, err)
|
||||
defer func() { _ = cacheInstance.Close() }()
|
||||
|
||||
repoInfo := git.RepoInfo{
|
||||
Organization: "test-owner",
|
||||
Repository: "test-repo",
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
client *github.Client
|
||||
repoInfo git.RepoInfo
|
||||
cache DependencyCache
|
||||
expectNotNil bool
|
||||
}{
|
||||
{
|
||||
name: "creates analyzer with all dependencies",
|
||||
client: githubClient,
|
||||
repoInfo: repoInfo,
|
||||
cache: NewCacheAdapter(cacheInstance),
|
||||
expectNotNil: true,
|
||||
},
|
||||
{
|
||||
name: "creates analyzer with nil client",
|
||||
client: nil,
|
||||
repoInfo: repoInfo,
|
||||
cache: NewCacheAdapter(cacheInstance),
|
||||
expectNotNil: true,
|
||||
},
|
||||
{
|
||||
name: "creates analyzer with nil cache",
|
||||
client: githubClient,
|
||||
repoInfo: repoInfo,
|
||||
cache: nil,
|
||||
expectNotNil: true,
|
||||
},
|
||||
{
|
||||
name: "creates analyzer with empty repo info",
|
||||
client: githubClient,
|
||||
repoInfo: git.RepoInfo{},
|
||||
cache: NewCacheAdapter(cacheInstance),
|
||||
expectNotNil: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
analyzer := NewAnalyzer(tt.client, tt.repoInfo, tt.cache)
|
||||
|
||||
if tt.expectNotNil && analyzer == nil {
|
||||
t.Fatal("expected non-nil analyzer")
|
||||
}
|
||||
|
||||
// Verify fields are set correctly
|
||||
if analyzer.GitHubClient != tt.client {
|
||||
t.Error("GitHub client not set correctly")
|
||||
}
|
||||
if analyzer.Cache != tt.cache {
|
||||
t.Error("cache not set correctly")
|
||||
}
|
||||
if analyzer.RepoInfo != tt.repoInfo {
|
||||
t.Error("repo info not set correctly")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
// parseCompositeActionFromFile reads and parses a composite action file.
|
||||
func (a *Analyzer) parseCompositeActionFromFile(actionPath string) (*ActionWithComposite, error) {
|
||||
// Read the file
|
||||
data, err := os.ReadFile(actionPath)
|
||||
data, err := os.ReadFile(actionPath) // #nosec G304 -- action path from function parameter
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read action file %s: %w", actionPath, err)
|
||||
}
|
||||
|
||||
@@ -3,10 +3,28 @@ package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/errors"
|
||||
)
|
||||
|
||||
// Error detection constants for automatic error code determination.
|
||||
const (
|
||||
// File system error patterns.
|
||||
errorPatternFileNotFound = "no such file or directory"
|
||||
errorPatternPermission = "permission denied"
|
||||
|
||||
// Content format error patterns.
|
||||
errorPatternYAML = "yaml"
|
||||
|
||||
// Service-specific error patterns.
|
||||
errorPatternGitHub = "github"
|
||||
errorPatternConfig = "config"
|
||||
|
||||
// Exit code constants.
|
||||
exitCodeError = 1
|
||||
)
|
||||
|
||||
// ErrorHandler provides centralized error handling and exit management.
|
||||
type ErrorHandler struct {
|
||||
output *ColoredOutput
|
||||
@@ -22,7 +40,7 @@ func NewErrorHandler(output *ColoredOutput) *ErrorHandler {
|
||||
// HandleError handles contextual errors and exits with appropriate code.
|
||||
func (eh *ErrorHandler) HandleError(err *errors.ContextualError) {
|
||||
eh.output.ErrorWithSuggestions(err)
|
||||
os.Exit(1)
|
||||
os.Exit(exitCodeError)
|
||||
}
|
||||
|
||||
// HandleFatalError handles fatal errors with contextual information.
|
||||
@@ -48,7 +66,7 @@ func (eh *ErrorHandler) HandleSimpleError(message string, err error) {
|
||||
|
||||
// Try to determine appropriate error code based on error content
|
||||
if err != nil {
|
||||
context["error"] = err.Error()
|
||||
context[ContextKeyError] = err.Error()
|
||||
code = eh.determineErrorCode(err)
|
||||
}
|
||||
|
||||
@@ -60,15 +78,15 @@ func (eh *ErrorHandler) determineErrorCode(err error) errors.ErrorCode {
|
||||
errStr := err.Error()
|
||||
|
||||
switch {
|
||||
case contains(errStr, "no such file or directory"):
|
||||
case contains(errStr, errorPatternFileNotFound):
|
||||
return errors.ErrCodeFileNotFound
|
||||
case contains(errStr, "permission denied"):
|
||||
case contains(errStr, errorPatternPermission):
|
||||
return errors.ErrCodePermission
|
||||
case contains(errStr, "yaml"):
|
||||
case contains(errStr, errorPatternYAML):
|
||||
return errors.ErrCodeInvalidYAML
|
||||
case contains(errStr, "github"):
|
||||
case contains(errStr, errorPatternGitHub):
|
||||
return errors.ErrCodeGitHubAPI
|
||||
case contains(errStr, "config"):
|
||||
case contains(errStr, errorPatternConfig):
|
||||
return errors.ErrCodeConfiguration
|
||||
default:
|
||||
return errors.ErrCodeUnknown
|
||||
@@ -77,35 +95,5 @@ func (eh *ErrorHandler) determineErrorCode(err error) errors.ErrorCode {
|
||||
|
||||
// contains checks if a string contains a substring (case-insensitive).
|
||||
func contains(s, substr string) bool {
|
||||
// Simple implementation - could use strings.Contains with strings.ToLower
|
||||
// but avoiding extra imports for now
|
||||
sLen := len(s)
|
||||
substrLen := len(substr)
|
||||
|
||||
if substrLen > sLen {
|
||||
return false
|
||||
}
|
||||
|
||||
for i := 0; i <= sLen-substrLen; i++ {
|
||||
match := true
|
||||
for j := 0; j < substrLen; j++ {
|
||||
if toLower(s[i+j]) != toLower(substr[j]) {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// toLower converts a byte to lowercase.
|
||||
func toLower(b byte) byte {
|
||||
if b >= 'A' && b <= 'Z' {
|
||||
return b + ('a' - 'A')
|
||||
}
|
||||
return b
|
||||
return strings.Contains(strings.ToLower(s), strings.ToLower(substr))
|
||||
}
|
||||
|
||||
151
internal/focused_consumers.go
Normal file
151
internal/focused_consumers.go
Normal file
@@ -0,0 +1,151 @@
|
||||
// Package internal demonstrates how to use focused interfaces for better separation of concerns.
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/errors"
|
||||
)
|
||||
|
||||
// SimpleLogger demonstrates a component that only needs basic message logging.
|
||||
// It depends only on MessageLogger, not the entire output system.
|
||||
type SimpleLogger struct {
|
||||
logger MessageLogger
|
||||
}
|
||||
|
||||
// NewSimpleLogger creates a logger that only needs message logging capabilities.
|
||||
func NewSimpleLogger(logger MessageLogger) *SimpleLogger {
|
||||
return &SimpleLogger{logger: logger}
|
||||
}
|
||||
|
||||
// LogOperation logs a simple operation with different message types.
|
||||
func (sl *SimpleLogger) LogOperation(operation string, success bool) {
|
||||
sl.logger.Info("Starting operation: %s", operation)
|
||||
|
||||
if success {
|
||||
sl.logger.Success("Operation completed: %s", operation)
|
||||
} else {
|
||||
sl.logger.Warning("Operation had issues: %s", operation)
|
||||
}
|
||||
}
|
||||
|
||||
// FocusedErrorManager demonstrates a component that only handles error reporting.
|
||||
// It depends only on ErrorReporter and ErrorFormatter, not the entire output system.
|
||||
type FocusedErrorManager struct {
|
||||
manager ErrorManager
|
||||
}
|
||||
|
||||
// NewFocusedErrorManager creates an error manager with focused dependencies.
|
||||
func NewFocusedErrorManager(manager ErrorManager) *FocusedErrorManager {
|
||||
return &FocusedErrorManager{
|
||||
manager: manager,
|
||||
}
|
||||
}
|
||||
|
||||
// HandleValidationError handles validation errors with context and suggestions.
|
||||
func (fem *FocusedErrorManager) HandleValidationError(file string, missingFields []string) {
|
||||
context := map[string]string{
|
||||
"file": file,
|
||||
"missing_fields": fmt.Sprintf("%v", missingFields),
|
||||
}
|
||||
|
||||
fem.manager.ErrorWithContext(
|
||||
errors.ErrCodeValidation,
|
||||
fmt.Sprintf("Validation failed for %s", file),
|
||||
context,
|
||||
)
|
||||
}
|
||||
|
||||
// TaskProgress demonstrates a component that only needs progress reporting.
|
||||
// It depends only on ProgressReporter, not the entire output system.
|
||||
type TaskProgress struct {
|
||||
reporter ProgressReporter
|
||||
}
|
||||
|
||||
// NewTaskProgress creates a progress reporter with focused dependencies.
|
||||
func NewTaskProgress(reporter ProgressReporter) *TaskProgress {
|
||||
return &TaskProgress{reporter: reporter}
|
||||
}
|
||||
|
||||
// ReportProgress reports progress for a long-running task.
|
||||
func (tp *TaskProgress) ReportProgress(task string, step int, total int) {
|
||||
tp.reporter.Progress("Task %s: step %d of %d", task, step, total)
|
||||
}
|
||||
|
||||
// ConfigAwareComponent demonstrates a component that only needs to check configuration.
|
||||
// It depends only on OutputConfig, not the entire output system.
|
||||
type ConfigAwareComponent struct {
|
||||
config OutputConfig
|
||||
}
|
||||
|
||||
// NewConfigAwareComponent creates a component that checks output configuration.
|
||||
func NewConfigAwareComponent(config OutputConfig) *ConfigAwareComponent {
|
||||
return &ConfigAwareComponent{config: config}
|
||||
}
|
||||
|
||||
// ShouldOutput determines whether output should be generated based on quiet mode.
|
||||
func (cac *ConfigAwareComponent) ShouldOutput() bool {
|
||||
return !cac.config.IsQuiet()
|
||||
}
|
||||
|
||||
// CompositeOutputWriter demonstrates how to compose interfaces for specific needs.
|
||||
// It combines MessageLogger and ProgressReporter without error handling.
|
||||
type CompositeOutputWriter struct {
|
||||
writer OutputWriter
|
||||
}
|
||||
|
||||
// NewCompositeOutputWriter creates a writer that combines message and progress reporting.
|
||||
func NewCompositeOutputWriter(writer OutputWriter) *CompositeOutputWriter {
|
||||
return &CompositeOutputWriter{writer: writer}
|
||||
}
|
||||
|
||||
// ProcessWithOutput demonstrates processing with both messages and progress.
|
||||
func (cow *CompositeOutputWriter) ProcessWithOutput(items []string) {
|
||||
if cow.writer.IsQuiet() {
|
||||
return
|
||||
}
|
||||
|
||||
cow.writer.Info("Processing %d items", len(items))
|
||||
|
||||
for i, item := range items {
|
||||
cow.writer.Progress("Processing item %d: %s", i+1, item)
|
||||
// Process the item...
|
||||
}
|
||||
|
||||
cow.writer.Success("All items processed successfully")
|
||||
}
|
||||
|
||||
// ValidationComponent demonstrates combining error handling interfaces.
|
||||
type ValidationComponent struct {
|
||||
errorManager ErrorManager
|
||||
logger MessageLogger
|
||||
}
|
||||
|
||||
// NewValidationComponent creates a validator with focused error handling and logging.
|
||||
func NewValidationComponent(errorManager ErrorManager, logger MessageLogger) *ValidationComponent {
|
||||
return &ValidationComponent{
|
||||
errorManager: errorManager,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateAndReport validates an item and reports results using focused interfaces.
|
||||
func (vc *ValidationComponent) ValidateAndReport(item string, isValid bool, err error) {
|
||||
if isValid {
|
||||
vc.logger.Success("Validation passed for: %s", item)
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
if contextualErr, ok := err.(*errors.ContextualError); ok {
|
||||
vc.errorManager.ErrorWithSuggestions(contextualErr)
|
||||
} else {
|
||||
vc.errorManager.Error("Validation failed for %s: %v", item, err)
|
||||
}
|
||||
} else {
|
||||
vc.errorManager.ErrorWithSimpleFix(
|
||||
fmt.Sprintf("Validation failed for %s", item),
|
||||
"Please check the item configuration and try again",
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/cache"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/errors"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
)
|
||||
|
||||
@@ -24,16 +25,34 @@ const (
|
||||
)
|
||||
|
||||
// Generator orchestrates the documentation generation process.
|
||||
// It uses focused interfaces to reduce coupling and improve testability.
|
||||
type Generator struct {
|
||||
Config *AppConfig
|
||||
Output *ColoredOutput
|
||||
Config *AppConfig
|
||||
Output CompleteOutput
|
||||
Progress ProgressManager
|
||||
}
|
||||
|
||||
// NewGenerator creates a new generator instance with the provided configuration.
|
||||
// This constructor maintains backward compatibility by using concrete implementations.
|
||||
func NewGenerator(config *AppConfig) *Generator {
|
||||
return NewGeneratorWithDependencies(
|
||||
config,
|
||||
NewColoredOutput(config.Quiet),
|
||||
NewProgressBarManager(config.Quiet),
|
||||
)
|
||||
}
|
||||
|
||||
// NewGeneratorWithDependencies creates a new generator with dependency injection.
|
||||
// This constructor allows for better testability and flexibility by accepting interfaces.
|
||||
func NewGeneratorWithDependencies(
|
||||
config *AppConfig,
|
||||
output CompleteOutput,
|
||||
progress ProgressManager,
|
||||
) *Generator {
|
||||
return &Generator{
|
||||
Config: config,
|
||||
Output: NewColoredOutput(config.Quiet),
|
||||
Config: config,
|
||||
Output: output,
|
||||
Progress: progress,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,10 +123,11 @@ func (g *Generator) parseAndValidateAction(actionPath string) (*ActionYML, error
|
||||
if len(validationResult.MissingFields) > 0 {
|
||||
// Check for critical validation errors that cannot be fixed with defaults
|
||||
for _, field := range validationResult.MissingFields {
|
||||
if field == "runs.using" {
|
||||
// Invalid runtime - cannot be fixed with defaults, must fail
|
||||
// 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 runtime configuration: %v",
|
||||
"action file %s has invalid configuration, missing required field(s): %v",
|
||||
actionPath,
|
||||
validationResult.MissingFields,
|
||||
)
|
||||
@@ -140,11 +160,11 @@ func (g *Generator) generateByFormat(action *ActionYML, outputDir, actionPath st
|
||||
case "md":
|
||||
return g.generateMarkdown(action, outputDir, actionPath)
|
||||
case OutputFormatHTML:
|
||||
return g.generateHTML(action, outputDir)
|
||||
return g.generateHTML(action, outputDir, actionPath)
|
||||
case OutputFormatJSON:
|
||||
return g.generateJSON(action, outputDir)
|
||||
case OutputFormatASCIIDoc:
|
||||
return g.generateASCIIDoc(action, outputDir)
|
||||
return g.generateASCIIDoc(action, outputDir, actionPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", g.Config.OutputFormat)
|
||||
}
|
||||
@@ -175,7 +195,8 @@ func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath st
|
||||
}
|
||||
|
||||
outputPath := filepath.Join(outputDir, "README.md")
|
||||
if err := os.WriteFile(outputPath, []byte(content), 0644); err != nil {
|
||||
if err := os.WriteFile(outputPath, []byte(content), FilePermDefault); err != nil {
|
||||
// #nosec G306 -- output file permissions
|
||||
return fmt.Errorf("failed to write README.md to %s: %w", outputPath, err)
|
||||
}
|
||||
|
||||
@@ -184,15 +205,27 @@ func (g *Generator) generateMarkdown(action *ActionYML, outputDir, actionPath st
|
||||
}
|
||||
|
||||
// generateHTML creates an HTML file using the template and optional header/footer.
|
||||
func (g *Generator) generateHTML(action *ActionYML, outputDir string) error {
|
||||
func (g *Generator) generateHTML(action *ActionYML, outputDir, actionPath string) error {
|
||||
// Use theme-based template if theme is specified, otherwise use explicit template path
|
||||
templatePath := g.Config.Template
|
||||
if g.Config.Theme != "" {
|
||||
templatePath = resolveThemeTemplate(g.Config.Theme)
|
||||
}
|
||||
|
||||
opts := TemplateOptions{
|
||||
TemplatePath: g.Config.Template,
|
||||
TemplatePath: templatePath,
|
||||
HeaderPath: g.Config.Header,
|
||||
FooterPath: g.Config.Footer,
|
||||
Format: "html",
|
||||
}
|
||||
|
||||
content, err := RenderReadme(action, opts)
|
||||
// Find repository root for git information
|
||||
repoRoot, _ := git.FindRepositoryRoot(outputDir)
|
||||
|
||||
// Build comprehensive template data
|
||||
templateData := BuildTemplateData(action, g.Config, repoRoot, actionPath)
|
||||
|
||||
content, err := RenderReadme(templateData, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render HTML template: %w", err)
|
||||
}
|
||||
@@ -226,7 +259,7 @@ func (g *Generator) generateJSON(action *ActionYML, outputDir string) error {
|
||||
}
|
||||
|
||||
// generateASCIIDoc creates an AsciiDoc file using the template.
|
||||
func (g *Generator) generateASCIIDoc(action *ActionYML, outputDir string) error {
|
||||
func (g *Generator) generateASCIIDoc(action *ActionYML, outputDir, actionPath string) error {
|
||||
// Use AsciiDoc template
|
||||
templatePath := resolveTemplatePath("templates/themes/asciidoc/readme.adoc")
|
||||
|
||||
@@ -235,13 +268,20 @@ func (g *Generator) generateASCIIDoc(action *ActionYML, outputDir string) error
|
||||
Format: "asciidoc",
|
||||
}
|
||||
|
||||
content, err := RenderReadme(action, opts)
|
||||
// Find repository root for git information
|
||||
repoRoot, _ := git.FindRepositoryRoot(outputDir)
|
||||
|
||||
// Build comprehensive template data
|
||||
templateData := BuildTemplateData(action, g.Config, repoRoot, actionPath)
|
||||
|
||||
content, err := RenderReadme(templateData, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to render AsciiDoc template: %w", err)
|
||||
}
|
||||
|
||||
outputPath := filepath.Join(outputDir, "README.adoc")
|
||||
if err := os.WriteFile(outputPath, []byte(content), 0644); err != nil {
|
||||
if err := os.WriteFile(outputPath, []byte(content), FilePermDefault); err != nil {
|
||||
// #nosec G306 -- output file permissions
|
||||
return fmt.Errorf("failed to write AsciiDoc to %s: %w", outputPath, err)
|
||||
}
|
||||
|
||||
@@ -271,15 +311,53 @@ func (g *Generator) DiscoverActionFiles(dir string, recursive bool) ([]string, e
|
||||
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(
|
||||
errors.ErrCodeFileNotFound,
|
||||
fmt.Sprintf("failed to discover action files for %s", context),
|
||||
map[string]string{
|
||||
"directory": dir,
|
||||
"recursive": fmt.Sprintf("%t", recursive),
|
||||
"context": context,
|
||||
ContextKeyError: err.Error(),
|
||||
},
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if any files were found
|
||||
if len(actionFiles) == 0 {
|
||||
contextMsg := fmt.Sprintf("no GitHub Action files found for %s", context)
|
||||
g.Output.ErrorWithContext(
|
||||
errors.ErrCodeNoActionFiles,
|
||||
contextMsg,
|
||||
map[string]string{
|
||||
"directory": dir,
|
||||
"recursive": fmt.Sprintf("%t", 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 fmt.Errorf("no action files to process")
|
||||
}
|
||||
|
||||
bar := g.createProgressBar("Processing files", paths)
|
||||
bar := g.Progress.CreateProgressBarForFiles("Processing files", paths)
|
||||
errors, successCount := g.processFiles(paths, bar)
|
||||
g.finishProgressBar(bar)
|
||||
g.Progress.FinishProgressBarWithNewline(bar)
|
||||
g.reportResults(successCount, errors)
|
||||
|
||||
if len(errors) > 0 {
|
||||
@@ -304,9 +382,7 @@ func (g *Generator) processFiles(paths []string, bar *progressbar.ProgressBar) (
|
||||
successCount++
|
||||
}
|
||||
|
||||
if bar != nil {
|
||||
_ = bar.Add(1)
|
||||
}
|
||||
g.Progress.UpdateProgressBar(bar)
|
||||
}
|
||||
return errors, successCount
|
||||
}
|
||||
@@ -333,9 +409,9 @@ func (g *Generator) ValidateFiles(paths []string) error {
|
||||
return fmt.Errorf("no action files to validate")
|
||||
}
|
||||
|
||||
bar := g.createProgressBar("Validating files", paths)
|
||||
bar := g.Progress.CreateProgressBarForFiles("Validating files", paths)
|
||||
allResults, errors := g.validateFiles(paths, bar)
|
||||
g.finishProgressBar(bar)
|
||||
g.Progress.FinishProgressBarWithNewline(bar)
|
||||
|
||||
if !g.Config.Quiet {
|
||||
g.reportValidationResults(allResults, errors)
|
||||
@@ -357,21 +433,6 @@ func (g *Generator) ValidateFiles(paths []string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// createProgressBar creates a progress bar with the specified description.
|
||||
func (g *Generator) createProgressBar(description string, paths []string) *progressbar.ProgressBar {
|
||||
progressMgr := NewProgressBarManager(g.Config.Quiet)
|
||||
return progressMgr.CreateProgressBarForFiles(description, paths)
|
||||
}
|
||||
|
||||
// finishProgressBar completes the progress bar display.
|
||||
func (g *Generator) finishProgressBar(bar *progressbar.ProgressBar) {
|
||||
progressMgr := NewProgressBarManager(g.Config.Quiet)
|
||||
progressMgr.FinishProgressBar(bar)
|
||||
if bar != nil {
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// validateFiles processes each file for validation.
|
||||
func (g *Generator) validateFiles(paths []string, bar *progressbar.ProgressBar) ([]ValidationResult, []string) {
|
||||
allResults := make([]ValidationResult, 0, len(paths))
|
||||
@@ -393,9 +454,7 @@ func (g *Generator) validateFiles(paths []string, bar *progressbar.ProgressBar)
|
||||
result.MissingFields = append([]string{fmt.Sprintf("file: %s", path)}, result.MissingFields...)
|
||||
allResults = append(allResults, result)
|
||||
|
||||
if bar != nil {
|
||||
_ = bar.Add(1)
|
||||
}
|
||||
g.Progress.UpdateProgressBar(bar)
|
||||
}
|
||||
return allResults, errors
|
||||
}
|
||||
|
||||
375
internal/generator_comprehensive_test.go
Normal file
375
internal/generator_comprehensive_test.go
Normal file
@@ -0,0 +1,375 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
// TestGenerator_ComprehensiveGeneration demonstrates the new table-driven testing framework
|
||||
// by testing generation across all fixtures, themes, and formats systematically.
|
||||
func TestGenerator_ComprehensiveGeneration(t *testing.T) {
|
||||
// Create test cases using the new helper functions
|
||||
cases := testutil.CreateGeneratorTestCases()
|
||||
|
||||
// Filter to a subset for demonstration (full test would be very large)
|
||||
filteredCases := make([]testutil.GeneratorTestCase, 0)
|
||||
for _, testCase := range cases {
|
||||
// Only test a few combinations for demonstration
|
||||
if (testCase.Theme == "default" && testCase.OutputFormat == "md") ||
|
||||
(testCase.Theme == "github" && testCase.OutputFormat == "html") ||
|
||||
(testCase.Theme == "minimal" && testCase.OutputFormat == "json") {
|
||||
// Add custom executor for generator tests
|
||||
testCase.Executor = createGeneratorTestExecutor()
|
||||
filteredCases = append(filteredCases, testCase)
|
||||
}
|
||||
}
|
||||
|
||||
// Run the test suite
|
||||
testutil.RunGeneratorTests(t, filteredCases)
|
||||
}
|
||||
|
||||
// TestGenerator_AllValidFixtures tests generation with all valid fixtures.
|
||||
func TestGenerator_AllValidFixtures(t *testing.T) {
|
||||
validFixtures := testutil.GetValidFixtures()
|
||||
|
||||
for _, fixture := range validFixtures {
|
||||
fixture := fixture // capture loop variable
|
||||
t.Run(fixture, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Create temporary action from fixture
|
||||
actionPath := testutil.CreateTemporaryAction(t, fixture)
|
||||
|
||||
// Test with default configuration
|
||||
config := &AppConfig{
|
||||
Theme: "default",
|
||||
OutputFormat: "md",
|
||||
OutputDir: ".",
|
||||
Quiet: true,
|
||||
}
|
||||
|
||||
generator := NewGenerator(config)
|
||||
|
||||
// Generate documentation
|
||||
err := generator.GenerateFromFile(actionPath)
|
||||
if err != nil {
|
||||
t.Errorf("failed to generate documentation for fixture %s: %v", fixture, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerator_AllInvalidFixtures tests that invalid fixtures produce expected errors.
|
||||
func TestGenerator_AllInvalidFixtures(t *testing.T) {
|
||||
invalidFixtures := testutil.GetInvalidFixtures()
|
||||
|
||||
for _, fixture := range invalidFixtures {
|
||||
fixture := fixture // capture loop variable
|
||||
t.Run(fixture, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
// Some invalid fixtures might not be loadable
|
||||
actionFixture, err := testutil.LoadActionFixture(fixture)
|
||||
if err != nil {
|
||||
// This is expected for some invalid fixtures
|
||||
return
|
||||
}
|
||||
|
||||
// Create temporary action from fixture
|
||||
tempDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
testutil.WriteTestFile(t, tempDir+"/action.yml", actionFixture.Content)
|
||||
|
||||
// Test with default configuration
|
||||
config := &AppConfig{
|
||||
Theme: "default",
|
||||
OutputFormat: "md",
|
||||
OutputDir: ".",
|
||||
Quiet: true,
|
||||
}
|
||||
|
||||
generator := NewGenerator(config)
|
||||
|
||||
// Generate documentation - should fail
|
||||
err = generator.GenerateFromFile(tempDir + "/action.yml")
|
||||
if err == nil {
|
||||
t.Errorf("expected generation to fail for invalid fixture %s, but it succeeded", fixture)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerator_AllThemes demonstrates theme testing using helper functions.
|
||||
func TestGenerator_AllThemes(t *testing.T) {
|
||||
// Use the helper function to test all themes
|
||||
testutil.TestAllThemes(t, func(t *testing.T, theme string) {
|
||||
// Create a simple action for testing
|
||||
actionPath := testutil.CreateTemporaryAction(t, "actions/javascript/simple.yml")
|
||||
|
||||
config := &AppConfig{
|
||||
Theme: theme,
|
||||
OutputFormat: "md",
|
||||
OutputDir: ".",
|
||||
Quiet: true,
|
||||
}
|
||||
|
||||
generator := NewGenerator(config)
|
||||
err := generator.GenerateFromFile(actionPath)
|
||||
|
||||
testutil.AssertNoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGenerator_AllFormats demonstrates format testing using helper functions.
|
||||
func TestGenerator_AllFormats(t *testing.T) {
|
||||
// Use the helper function to test all formats
|
||||
testutil.TestAllFormats(t, func(t *testing.T, format string) {
|
||||
// Create a simple action for testing
|
||||
actionPath := testutil.CreateTemporaryAction(t, "actions/javascript/simple.yml")
|
||||
|
||||
config := &AppConfig{
|
||||
Theme: "default",
|
||||
OutputFormat: format,
|
||||
OutputDir: ".",
|
||||
Quiet: true,
|
||||
}
|
||||
|
||||
generator := NewGenerator(config)
|
||||
err := generator.GenerateFromFile(actionPath)
|
||||
|
||||
testutil.AssertNoError(t, err)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGenerator_ByActionType demonstrates testing by action type.
|
||||
func TestGenerator_ByActionType(t *testing.T) {
|
||||
actionTypes := []testutil.ActionType{
|
||||
testutil.ActionTypeJavaScript,
|
||||
testutil.ActionTypeComposite,
|
||||
testutil.ActionTypeDocker,
|
||||
}
|
||||
|
||||
for _, actionType := range actionTypes {
|
||||
actionType := actionType // capture loop variable
|
||||
t.Run(string(actionType), func(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
fixtures := testutil.GetFixturesByActionType(actionType)
|
||||
if len(fixtures) == 0 {
|
||||
t.Skipf("no fixtures available for action type %s", actionType)
|
||||
}
|
||||
|
||||
// Test the first fixture of this type
|
||||
fixture := fixtures[0]
|
||||
actionPath := testutil.CreateTemporaryAction(t, fixture)
|
||||
|
||||
config := &AppConfig{
|
||||
Theme: "default",
|
||||
OutputFormat: "md",
|
||||
OutputDir: ".",
|
||||
Quiet: true,
|
||||
}
|
||||
|
||||
generator := NewGenerator(config)
|
||||
err := generator.GenerateFromFile(actionPath)
|
||||
|
||||
testutil.AssertNoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGenerator_WithMockEnvironment demonstrates testing with a complete mock environment.
|
||||
func TestGenerator_WithMockEnvironment(t *testing.T) {
|
||||
// Create a complete test environment
|
||||
envConfig := &testutil.EnvironmentConfig{
|
||||
ActionFixtures: []string{"actions/composite/with-dependencies.yml"},
|
||||
WithMocks: true,
|
||||
}
|
||||
|
||||
env := testutil.CreateTestEnvironment(t, envConfig)
|
||||
|
||||
// Clean up environment
|
||||
defer func() {
|
||||
for _, cleanup := range env.Cleanup {
|
||||
if err := cleanup(); err != nil {
|
||||
t.Errorf("cleanup failed: %v", err)
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
if len(env.ActionPaths) == 0 {
|
||||
t.Fatal("expected at least one action path")
|
||||
}
|
||||
|
||||
config := &AppConfig{
|
||||
Theme: "github",
|
||||
OutputFormat: "md",
|
||||
OutputDir: ".",
|
||||
Quiet: true,
|
||||
}
|
||||
|
||||
generator := NewGenerator(config)
|
||||
err := generator.GenerateFromFile(env.ActionPaths[0])
|
||||
|
||||
testutil.AssertNoError(t, err)
|
||||
}
|
||||
|
||||
// TestGenerator_FixtureValidation demonstrates fixture validation.
|
||||
func TestGenerator_FixtureValidation(t *testing.T) {
|
||||
// Test that all valid fixtures pass validation
|
||||
validFixtures := testutil.GetValidFixtures()
|
||||
|
||||
for _, fixtureName := range validFixtures {
|
||||
t.Run(fixtureName, func(t *testing.T) {
|
||||
testutil.AssertFixtureValid(t, fixtureName)
|
||||
})
|
||||
}
|
||||
|
||||
// Test that all invalid fixtures fail validation
|
||||
invalidFixtures := testutil.GetInvalidFixtures()
|
||||
|
||||
for _, fixtureName := range invalidFixtures {
|
||||
t.Run(fixtureName, func(t *testing.T) {
|
||||
testutil.AssertFixtureInvalid(t, fixtureName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// createGeneratorTestExecutor returns a test executor function for generator tests.
|
||||
func createGeneratorTestExecutor() testutil.TestExecutor {
|
||||
return func(t *testing.T, testCase testutil.TestCase, ctx *testutil.TestContext) *testutil.TestResult {
|
||||
t.Helper()
|
||||
|
||||
result := &testutil.TestResult{
|
||||
Context: ctx,
|
||||
}
|
||||
|
||||
var actionPath string
|
||||
|
||||
// If we have a fixture, load it and create action file
|
||||
if testCase.Fixture != "" {
|
||||
fixture, err := ctx.FixtureManager.LoadActionFixture(testCase.Fixture)
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("failed to load fixture %s: %w", testCase.Fixture, err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Create temporary action file
|
||||
actionPath = filepath.Join(ctx.TempDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, fixture.Content)
|
||||
}
|
||||
|
||||
// If we don't have an action file to test, just return success
|
||||
if actionPath == "" {
|
||||
result.Success = true
|
||||
return result
|
||||
}
|
||||
|
||||
// Create generator configuration from test config
|
||||
config := createGeneratorConfigFromTestConfig(ctx.Config, ctx.TempDir)
|
||||
|
||||
// Save current working directory and change to project root for template resolution
|
||||
originalWd, err := os.Getwd()
|
||||
if err != nil {
|
||||
result.Error = fmt.Errorf("failed to get working directory: %w", err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Use runtime.Caller to find project root relative to this file
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
result.Error = fmt.Errorf("failed to get current file path")
|
||||
return result
|
||||
}
|
||||
|
||||
// Get the project root (go up from internal/generator_comprehensive_test.go to project root)
|
||||
projectRoot := filepath.Dir(filepath.Dir(currentFile))
|
||||
if err := os.Chdir(projectRoot); err != nil {
|
||||
result.Error = fmt.Errorf("failed to change to project root %s: %w", projectRoot, err)
|
||||
return result
|
||||
}
|
||||
|
||||
// Debug: Log the working directory and template path
|
||||
currentWd, _ := os.Getwd()
|
||||
t.Logf("Test working directory: %s, template path: %s", currentWd, config.Template)
|
||||
|
||||
// Restore working directory after test
|
||||
defer func() {
|
||||
if err := os.Chdir(originalWd); err != nil {
|
||||
// Log error but don't fail the test
|
||||
t.Logf("Failed to restore working directory: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
// Create and run generator
|
||||
generator := NewGenerator(config)
|
||||
err = generator.GenerateFromFile(actionPath)
|
||||
|
||||
if err != nil {
|
||||
result.Error = err
|
||||
result.Success = false
|
||||
} else {
|
||||
result.Success = true
|
||||
// Detect generated files
|
||||
result.Files = testutil.DetectGeneratedFiles(ctx.TempDir, config.OutputFormat)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// createGeneratorConfigFromTestConfig converts TestConfig to AppConfig.
|
||||
func createGeneratorConfigFromTestConfig(testConfig *testutil.TestConfig, outputDir string) *AppConfig {
|
||||
config := &AppConfig{
|
||||
Theme: "default",
|
||||
OutputFormat: "md",
|
||||
OutputDir: outputDir,
|
||||
Template: "templates/readme.tmpl",
|
||||
Schema: "schemas/schema.json",
|
||||
Verbose: false,
|
||||
Quiet: true, // Default to quiet for tests
|
||||
GitHubToken: "",
|
||||
}
|
||||
|
||||
// Override with test-specific settings
|
||||
if testConfig != nil {
|
||||
if testConfig.Theme != "" {
|
||||
config.Theme = testConfig.Theme
|
||||
}
|
||||
if testConfig.OutputFormat != "" {
|
||||
config.OutputFormat = testConfig.OutputFormat
|
||||
}
|
||||
if testConfig.OutputDir != "" {
|
||||
config.OutputDir = testConfig.OutputDir
|
||||
}
|
||||
config.Verbose = testConfig.Verbose
|
||||
config.Quiet = testConfig.Quiet
|
||||
}
|
||||
|
||||
// Set appropriate template path based on theme and output format
|
||||
config.Template = resolveTemplatePathForTest(config.Theme, config.OutputFormat)
|
||||
|
||||
return config
|
||||
}
|
||||
|
||||
// resolveTemplatePathForTest resolves the correct template path for testing.
|
||||
func resolveTemplatePathForTest(theme, _ string) string {
|
||||
switch theme {
|
||||
case "github":
|
||||
return "templates/themes/github/readme.tmpl"
|
||||
case "gitlab":
|
||||
return "templates/themes/gitlab/readme.tmpl"
|
||||
case "minimal":
|
||||
return "templates/themes/minimal/readme.tmpl"
|
||||
case "professional":
|
||||
return "templates/themes/professional/readme.tmpl"
|
||||
default:
|
||||
return "templates/readme.tmpl"
|
||||
}
|
||||
}
|
||||
@@ -44,7 +44,9 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
|
||||
{
|
||||
name: "single action.yml in root",
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.SimpleActionYML)
|
||||
fixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), fixture.Content)
|
||||
},
|
||||
recursive: false,
|
||||
expectedLen: 1,
|
||||
@@ -52,7 +54,9 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
|
||||
{
|
||||
name: "action.yaml variant",
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yaml"), testutil.SimpleActionYML)
|
||||
fixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yaml"), fixture.Content)
|
||||
},
|
||||
recursive: false,
|
||||
expectedLen: 1,
|
||||
@@ -60,8 +64,12 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
|
||||
{
|
||||
name: "both yml and yaml files",
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.SimpleActionYML)
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yaml"), testutil.MinimalActionYML)
|
||||
simpleFixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
minimalFixture, err := testutil.LoadActionFixture("minimal-action.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), simpleFixture.Content)
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yaml"), minimalFixture.Content)
|
||||
},
|
||||
recursive: false,
|
||||
expectedLen: 2,
|
||||
@@ -69,10 +77,14 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
|
||||
{
|
||||
name: "recursive discovery",
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.SimpleActionYML)
|
||||
simpleFixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
compositeFixture, err := testutil.LoadActionFixture("actions/composite/basic.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), simpleFixture.Content)
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
_ = os.MkdirAll(subDir, 0755)
|
||||
testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), testutil.CompositeActionYML)
|
||||
_ = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), compositeFixture.Content)
|
||||
},
|
||||
recursive: true,
|
||||
expectedLen: 2,
|
||||
@@ -80,10 +92,14 @@ func TestGenerator_DiscoverActionFiles(t *testing.T) {
|
||||
{
|
||||
name: "non-recursive skips subdirectories",
|
||||
setupFunc: func(t *testing.T, tmpDir string) {
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), testutil.SimpleActionYML)
|
||||
simpleFixture, err := testutil.LoadActionFixture("actions/javascript/simple.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
compositeFixture, err := testutil.LoadActionFixture("actions/composite/basic.yml")
|
||||
testutil.AssertNoError(t, err)
|
||||
testutil.WriteTestFile(t, filepath.Join(tmpDir, "action.yml"), simpleFixture.Content)
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
_ = os.MkdirAll(subDir, 0755)
|
||||
testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), testutil.CompositeActionYML)
|
||||
_ = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.WriteTestFile(t, filepath.Join(subDir, "action.yml"), compositeFixture.Content)
|
||||
},
|
||||
recursive: false,
|
||||
expectedLen: 1,
|
||||
@@ -153,42 +169,48 @@ func TestGenerator_GenerateFromFile(t *testing.T) {
|
||||
}{
|
||||
{
|
||||
name: "simple action to markdown",
|
||||
actionYML: testutil.SimpleActionYML,
|
||||
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
|
||||
outputFormat: "md",
|
||||
expectError: false,
|
||||
contains: []string{"# Simple Action", "A simple test action"},
|
||||
contains: []string{"# Simple JavaScript Action", "A simple JavaScript action for testing"},
|
||||
},
|
||||
{
|
||||
name: "composite action to markdown",
|
||||
actionYML: testutil.CompositeActionYML,
|
||||
actionYML: testutil.MustReadFixture("actions/composite/basic.yml"),
|
||||
outputFormat: "md",
|
||||
expectError: false,
|
||||
contains: []string{"# Composite Action", "A composite action with dependencies"},
|
||||
contains: []string{"# Basic Composite Action", "A simple composite action with basic steps"},
|
||||
},
|
||||
{
|
||||
name: "action to HTML",
|
||||
actionYML: testutil.SimpleActionYML,
|
||||
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
|
||||
outputFormat: "html",
|
||||
expectError: false,
|
||||
contains: []string{"Simple Action", "A simple test action"}, // HTML uses same template content
|
||||
contains: []string{
|
||||
"Simple JavaScript Action",
|
||||
"A simple JavaScript action for testing",
|
||||
}, // HTML uses same template content
|
||||
},
|
||||
{
|
||||
name: "action to JSON",
|
||||
actionYML: testutil.SimpleActionYML,
|
||||
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
|
||||
outputFormat: "json",
|
||||
expectError: false,
|
||||
contains: []string{`"name": "Simple Action"`, `"description": "A simple test action"`},
|
||||
contains: []string{
|
||||
`"name": "Simple JavaScript Action"`,
|
||||
`"description": "A simple JavaScript action for testing"`,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "invalid action file",
|
||||
actionYML: testutil.InvalidActionYML,
|
||||
actionYML: testutil.MustReadFixture("actions/invalid/invalid-using.yml"),
|
||||
outputFormat: "md",
|
||||
expectError: true, // Invalid runtime configuration should cause failure
|
||||
contains: []string{},
|
||||
},
|
||||
{
|
||||
name: "unknown output format",
|
||||
actionYML: testutil.SimpleActionYML,
|
||||
actionYML: testutil.MustReadFixture("actions/javascript/simple.yml"),
|
||||
outputFormat: "unknown",
|
||||
expectError: true,
|
||||
},
|
||||
@@ -299,10 +321,10 @@ func TestGenerator_ProcessBatch(t *testing.T) {
|
||||
// Create separate directories for each action
|
||||
dir1 := filepath.Join(tmpDir, "action1")
|
||||
dir2 := filepath.Join(tmpDir, "action2")
|
||||
if err := os.MkdirAll(dir1, 0755); err != nil {
|
||||
if err := os.MkdirAll(dir1, 0750); err != nil { // #nosec G301 -- test directory permissions
|
||||
t.Fatalf("failed to create dir1: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(dir2, 0755); err != nil {
|
||||
if err := os.MkdirAll(dir2, 0750); err != nil { // #nosec G301 -- test directory permissions
|
||||
t.Fatalf("failed to create dir2: %v", err)
|
||||
}
|
||||
|
||||
@@ -310,8 +332,8 @@ func TestGenerator_ProcessBatch(t *testing.T) {
|
||||
filepath.Join(dir1, "action.yml"),
|
||||
filepath.Join(dir2, "action.yml"),
|
||||
}
|
||||
testutil.WriteTestFile(t, files[0], testutil.SimpleActionYML)
|
||||
testutil.WriteTestFile(t, files[1], testutil.CompositeActionYML)
|
||||
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("actions/composite/basic.yml"))
|
||||
return files
|
||||
},
|
||||
expectError: false,
|
||||
@@ -323,10 +345,10 @@ func TestGenerator_ProcessBatch(t *testing.T) {
|
||||
// Create separate directories for mixed test too
|
||||
dir1 := filepath.Join(tmpDir, "valid-action")
|
||||
dir2 := filepath.Join(tmpDir, "invalid-action")
|
||||
if err := os.MkdirAll(dir1, 0755); err != nil {
|
||||
if err := os.MkdirAll(dir1, 0750); err != nil { // #nosec G301 -- test directory permissions
|
||||
t.Fatalf("failed to create dir1: %v", err)
|
||||
}
|
||||
if err := os.MkdirAll(dir2, 0755); err != nil {
|
||||
if err := os.MkdirAll(dir2, 0750); err != nil { // #nosec G301 -- test directory permissions
|
||||
t.Fatalf("failed to create dir2: %v", err)
|
||||
}
|
||||
|
||||
@@ -334,8 +356,8 @@ func TestGenerator_ProcessBatch(t *testing.T) {
|
||||
filepath.Join(dir1, "action.yml"),
|
||||
filepath.Join(dir2, "action.yml"),
|
||||
}
|
||||
testutil.WriteTestFile(t, files[0], testutil.SimpleActionYML)
|
||||
testutil.WriteTestFile(t, files[1], testutil.InvalidActionYML)
|
||||
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("actions/invalid/invalid-using.yml"))
|
||||
return files
|
||||
},
|
||||
expectError: true, // Invalid runtime configuration should cause batch to fail
|
||||
@@ -413,8 +435,8 @@ func TestGenerator_ValidateFiles(t *testing.T) {
|
||||
filepath.Join(tmpDir, "action1.yml"),
|
||||
filepath.Join(tmpDir, "action2.yml"),
|
||||
}
|
||||
testutil.WriteTestFile(t, files[0], testutil.SimpleActionYML)
|
||||
testutil.WriteTestFile(t, files[1], testutil.MinimalActionYML)
|
||||
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("minimal-action.yml"))
|
||||
return files
|
||||
},
|
||||
expectError: false,
|
||||
@@ -426,8 +448,8 @@ func TestGenerator_ValidateFiles(t *testing.T) {
|
||||
filepath.Join(tmpDir, "valid.yml"),
|
||||
filepath.Join(tmpDir, "invalid.yml"),
|
||||
}
|
||||
testutil.WriteTestFile(t, files[0], testutil.SimpleActionYML)
|
||||
testutil.WriteTestFile(t, files[1], testutil.InvalidActionYML)
|
||||
testutil.WriteTestFile(t, files[0], testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
testutil.WriteTestFile(t, files[1], testutil.MustReadFixture("actions/invalid/missing-description.yml"))
|
||||
return files
|
||||
},
|
||||
expectError: true, // Validation should fail for invalid runtime configuration
|
||||
@@ -513,7 +535,7 @@ func TestGenerator_WithDifferentThemes(t *testing.T) {
|
||||
testutil.SetupTestTemplates(t, tmpDir)
|
||||
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.SimpleActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
|
||||
for _, theme := range themes {
|
||||
t.Run("theme_"+theme, func(t *testing.T) {
|
||||
@@ -575,7 +597,7 @@ func TestGenerator_ErrorHandling(t *testing.T) {
|
||||
}
|
||||
generator := NewGenerator(config)
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.SimpleActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
return generator, actionPath
|
||||
},
|
||||
wantError: "template",
|
||||
@@ -588,7 +610,7 @@ func TestGenerator_ErrorHandling(t *testing.T) {
|
||||
|
||||
// Create a directory with no write permissions
|
||||
restrictedDir := filepath.Join(tmpDir, "restricted")
|
||||
_ = os.MkdirAll(restrictedDir, 0444) // Read-only
|
||||
_ = os.MkdirAll(restrictedDir, 0444) // #nosec G301 -- intentionally read-only for test
|
||||
|
||||
config := &AppConfig{
|
||||
OutputFormat: "md",
|
||||
@@ -598,7 +620,7 @@ func TestGenerator_ErrorHandling(t *testing.T) {
|
||||
}
|
||||
generator := NewGenerator(config)
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.SimpleActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
return generator, actionPath
|
||||
},
|
||||
wantError: "permission denied",
|
||||
|
||||
@@ -80,9 +80,7 @@ func DetectRepository(repoRoot string) (*RepoInfo, error) {
|
||||
}
|
||||
|
||||
// Try to get default branch
|
||||
if defaultBranch, err := getDefaultBranch(repoRoot); err == nil {
|
||||
info.DefaultBranch = defaultBranch
|
||||
}
|
||||
info.DefaultBranch = getDefaultBranch(repoRoot)
|
||||
|
||||
return info, nil
|
||||
}
|
||||
@@ -114,7 +112,7 @@ func getRemoteURLFromGit(repoRoot string) (string, error) {
|
||||
// getRemoteURLFromConfig parses .git/config to extract remote URL.
|
||||
func getRemoteURLFromConfig(repoRoot string) (string, error) {
|
||||
configPath := filepath.Join(repoRoot, ".git", "config")
|
||||
file, err := os.Open(configPath)
|
||||
file, err := os.Open(configPath) // #nosec G304 -- git config path constructed from repo root
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to open git config: %w", err)
|
||||
}
|
||||
@@ -150,7 +148,7 @@ func getRemoteURLFromConfig(repoRoot string) (string, error) {
|
||||
}
|
||||
|
||||
// getDefaultBranch gets the default branch name.
|
||||
func getDefaultBranch(repoRoot string) (string, error) {
|
||||
func getDefaultBranch(repoRoot string) string {
|
||||
cmd := exec.Command("git", "symbolic-ref", "refs/remotes/origin/HEAD")
|
||||
cmd.Dir = repoRoot
|
||||
|
||||
@@ -159,24 +157,30 @@ func getDefaultBranch(repoRoot string) (string, error) {
|
||||
// Fallback to common default branches
|
||||
for _, branch := range []string{DefaultBranch, "master"} {
|
||||
if branchExists(repoRoot, branch) {
|
||||
return branch, nil
|
||||
return branch
|
||||
}
|
||||
}
|
||||
return DefaultBranch, nil // Default fallback
|
||||
return DefaultBranch // Default fallback
|
||||
}
|
||||
|
||||
// Extract branch name from refs/remotes/origin/HEAD -> refs/remotes/origin/main
|
||||
parts := strings.Split(strings.TrimSpace(string(output)), "/")
|
||||
if len(parts) > 0 {
|
||||
return parts[len(parts)-1], nil
|
||||
return parts[len(parts)-1]
|
||||
}
|
||||
|
||||
return DefaultBranch, nil
|
||||
return DefaultBranch
|
||||
}
|
||||
|
||||
// branchExists checks if a branch exists in the repository.
|
||||
func branchExists(repoRoot, branch string) bool {
|
||||
cmd := exec.Command("git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"show-ref",
|
||||
"--verify",
|
||||
"--quiet",
|
||||
"refs/heads/"+branch,
|
||||
) // #nosec G204 -- branch name validated by git
|
||||
cmd.Dir = repoRoot
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
@@ -20,14 +20,14 @@ func TestFindRepositoryRoot(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
// Create .git directory
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0755)
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create .git directory: %v", err)
|
||||
}
|
||||
|
||||
// Create subdirectory to test from
|
||||
subDir := filepath.Join(tmpDir, "subdir", "nested")
|
||||
err = os.MkdirAll(subDir, 0755)
|
||||
err = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create subdirectory: %v", err)
|
||||
}
|
||||
@@ -54,7 +54,7 @@ func TestFindRepositoryRoot(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
// Create subdirectory without .git
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
err := os.MkdirAll(subDir, 0755)
|
||||
err := os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create subdirectory: %v", err)
|
||||
}
|
||||
@@ -117,7 +117,7 @@ func TestDetectGitRepository(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
// Create .git directory
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0755)
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create .git directory: %v", err)
|
||||
}
|
||||
@@ -150,7 +150,7 @@ func TestDetectGitRepository(t *testing.T) {
|
||||
name: "SSH remote URL",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0755)
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create .git directory: %v", err)
|
||||
}
|
||||
@@ -185,7 +185,7 @@ func TestDetectGitRepository(t *testing.T) {
|
||||
name: "git repository without origin remote",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0755)
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create .git directory: %v", err)
|
||||
}
|
||||
|
||||
@@ -110,12 +110,12 @@ func TestFindGitRepoRoot(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
// Create .git directory
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0755)
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Create subdirectory to test from
|
||||
subDir := filepath.Join(tmpDir, "subdir")
|
||||
err = os.MkdirAll(subDir, 0755)
|
||||
err = os.MkdirAll(subDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
return subDir
|
||||
@@ -135,12 +135,12 @@ func TestFindGitRepoRoot(t *testing.T) {
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
// Create .git directory at root
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0755)
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Create deeply nested subdirectory
|
||||
nestedDir := filepath.Join(tmpDir, "a", "b", "c")
|
||||
err = os.MkdirAll(nestedDir, 0755)
|
||||
err = os.MkdirAll(nestedDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
return nestedDir
|
||||
@@ -222,7 +222,7 @@ func TestGetGitRepoRootAndInfo(t *testing.T) {
|
||||
func setupCompleteGitRepo(t *testing.T, tmpDir string) string {
|
||||
// Create .git directory
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0755)
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
// Create a basic git config to make it look like a real repo
|
||||
@@ -238,7 +238,7 @@ func setupCompleteGitRepo(t *testing.T, tmpDir string) string {
|
||||
merge = refs/heads/main
|
||||
`
|
||||
configPath := filepath.Join(gitDir, "config")
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0644)
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0600) // #nosec G306 -- test file permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
return tmpDir
|
||||
@@ -247,7 +247,7 @@ func setupCompleteGitRepo(t *testing.T, tmpDir string) string {
|
||||
func setupMinimalGitRepo(t *testing.T, tmpDir string) string {
|
||||
// Create .git directory but with minimal content
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
err := os.MkdirAll(gitDir, 0755)
|
||||
err := os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
testutil.AssertNoError(t, err)
|
||||
|
||||
return tmpDir
|
||||
|
||||
@@ -11,7 +11,7 @@ type HTMLWriter struct {
|
||||
}
|
||||
|
||||
func (w *HTMLWriter) Write(output string, path string) error {
|
||||
f, err := os.Create(path)
|
||||
f, err := os.Create(path) // #nosec G304 -- path from function parameter
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
80
internal/interfaces.go
Normal file
80
internal/interfaces.go
Normal file
@@ -0,0 +1,80 @@
|
||||
// Package internal defines focused interfaces following Interface Segregation Principle.
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/schollz/progressbar/v3"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/errors"
|
||||
)
|
||||
|
||||
// MessageLogger handles informational output messages.
|
||||
type MessageLogger interface {
|
||||
Info(format string, args ...any)
|
||||
Success(format string, args ...any)
|
||||
Warning(format string, args ...any)
|
||||
Bold(format string, args ...any)
|
||||
Printf(format string, args ...any)
|
||||
Fprintf(w *os.File, format string, args ...any)
|
||||
}
|
||||
|
||||
// ErrorReporter handles error output and reporting.
|
||||
type ErrorReporter interface {
|
||||
Error(format string, args ...any)
|
||||
ErrorWithSuggestions(err *errors.ContextualError)
|
||||
ErrorWithContext(code errors.ErrorCode, message string, context map[string]string)
|
||||
ErrorWithSimpleFix(message, suggestion string)
|
||||
}
|
||||
|
||||
// ErrorFormatter handles formatting of contextual errors.
|
||||
type ErrorFormatter interface {
|
||||
FormatContextualError(err *errors.ContextualError) string
|
||||
}
|
||||
|
||||
// ProgressReporter handles progress indication and status updates.
|
||||
type ProgressReporter interface {
|
||||
Progress(format string, args ...any)
|
||||
}
|
||||
|
||||
// OutputConfig provides configuration queries for output behavior.
|
||||
type OutputConfig interface {
|
||||
IsQuiet() bool
|
||||
}
|
||||
|
||||
// ProgressManager handles progress bar creation and management.
|
||||
type ProgressManager interface {
|
||||
CreateProgressBar(description string, total int) *progressbar.ProgressBar
|
||||
CreateProgressBarForFiles(description string, files []string) *progressbar.ProgressBar
|
||||
FinishProgressBar(bar *progressbar.ProgressBar)
|
||||
FinishProgressBarWithNewline(bar *progressbar.ProgressBar)
|
||||
UpdateProgressBar(bar *progressbar.ProgressBar)
|
||||
ProcessWithProgressBar(
|
||||
description string,
|
||||
items []string,
|
||||
processFunc func(item string, bar *progressbar.ProgressBar),
|
||||
)
|
||||
}
|
||||
|
||||
// OutputWriter combines message logging and progress reporting for general output needs.
|
||||
type OutputWriter interface {
|
||||
MessageLogger
|
||||
ProgressReporter
|
||||
OutputConfig
|
||||
}
|
||||
|
||||
// ErrorManager combines error reporting and formatting for comprehensive error handling.
|
||||
type ErrorManager interface {
|
||||
ErrorReporter
|
||||
ErrorFormatter
|
||||
}
|
||||
|
||||
// CompleteOutput combines all output interfaces for backward compatibility.
|
||||
// This should be used sparingly and only where all capabilities are truly needed.
|
||||
type CompleteOutput interface {
|
||||
MessageLogger
|
||||
ErrorReporter
|
||||
ErrorFormatter
|
||||
ProgressReporter
|
||||
OutputConfig
|
||||
}
|
||||
462
internal/interfaces_test.go
Normal file
462
internal/interfaces_test.go
Normal file
@@ -0,0 +1,462 @@
|
||||
// Package internal provides tests for the focused interfaces and demonstrates improved testability.
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/schollz/progressbar/v3"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/errors"
|
||||
)
|
||||
|
||||
// MockMessageLogger implements MessageLogger for testing.
|
||||
type MockMessageLogger struct {
|
||||
InfoCalls []string
|
||||
SuccessCalls []string
|
||||
WarningCalls []string
|
||||
BoldCalls []string
|
||||
PrintfCalls []string
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Info(format string, args ...any) {
|
||||
m.InfoCalls = append(m.InfoCalls, formatMessage(format, args...))
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Success(format string, args ...any) {
|
||||
m.SuccessCalls = append(m.SuccessCalls, formatMessage(format, args...))
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Warning(format string, args ...any) {
|
||||
m.WarningCalls = append(m.WarningCalls, formatMessage(format, args...))
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Bold(format string, args ...any) {
|
||||
m.BoldCalls = append(m.BoldCalls, formatMessage(format, args...))
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Printf(format string, args ...any) {
|
||||
m.PrintfCalls = append(m.PrintfCalls, formatMessage(format, args...))
|
||||
}
|
||||
|
||||
func (m *MockMessageLogger) Fprintf(_ *os.File, format string, args ...any) {
|
||||
// For testing, just track the formatted message
|
||||
m.PrintfCalls = append(m.PrintfCalls, formatMessage(format, args...))
|
||||
}
|
||||
|
||||
// MockErrorReporter implements ErrorReporter for testing.
|
||||
type MockErrorReporter struct {
|
||||
ErrorCalls []string
|
||||
ErrorWithSuggestionsCalls []string
|
||||
ErrorWithContextCalls []string
|
||||
ErrorWithSimpleFixCalls []string
|
||||
}
|
||||
|
||||
func (m *MockErrorReporter) Error(format string, args ...any) {
|
||||
m.ErrorCalls = append(m.ErrorCalls, formatMessage(format, args...))
|
||||
}
|
||||
|
||||
func (m *MockErrorReporter) ErrorWithSuggestions(err *errors.ContextualError) {
|
||||
if err != nil {
|
||||
m.ErrorWithSuggestionsCalls = append(m.ErrorWithSuggestionsCalls, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func (m *MockErrorReporter) ErrorWithContext(_ errors.ErrorCode, message string, _ map[string]string) {
|
||||
m.ErrorWithContextCalls = append(m.ErrorWithContextCalls, message)
|
||||
}
|
||||
|
||||
func (m *MockErrorReporter) ErrorWithSimpleFix(message, suggestion string) {
|
||||
m.ErrorWithSimpleFixCalls = append(m.ErrorWithSimpleFixCalls, message+": "+suggestion)
|
||||
}
|
||||
|
||||
// MockProgressReporter implements ProgressReporter for testing.
|
||||
type MockProgressReporter struct {
|
||||
ProgressCalls []string
|
||||
}
|
||||
|
||||
func (m *MockProgressReporter) Progress(format string, args ...any) {
|
||||
m.ProgressCalls = append(m.ProgressCalls, formatMessage(format, args...))
|
||||
}
|
||||
|
||||
// MockOutputConfig implements OutputConfig for testing.
|
||||
type MockOutputConfig struct {
|
||||
QuietMode bool
|
||||
}
|
||||
|
||||
func (m *MockOutputConfig) IsQuiet() bool {
|
||||
return m.QuietMode
|
||||
}
|
||||
|
||||
// MockProgressManager implements ProgressManager for testing.
|
||||
type MockProgressManager struct {
|
||||
CreateProgressBarCalls []string
|
||||
CreateProgressBarForFilesCalls []string
|
||||
FinishProgressBarCalls int
|
||||
FinishProgressBarWithNewlineCalls int
|
||||
UpdateProgressBarCalls int
|
||||
ProcessWithProgressBarCalls []string
|
||||
}
|
||||
|
||||
func (m *MockProgressManager) CreateProgressBar(description string, total int) *progressbar.ProgressBar {
|
||||
m.CreateProgressBarCalls = append(m.CreateProgressBarCalls, formatMessage("%s (total: %d)", description, total))
|
||||
return nil // Return nil for mock to avoid actual progress bar
|
||||
}
|
||||
|
||||
func (m *MockProgressManager) CreateProgressBarForFiles(description string, files []string) *progressbar.ProgressBar {
|
||||
m.CreateProgressBarForFilesCalls = append(
|
||||
m.CreateProgressBarForFilesCalls,
|
||||
formatMessage("%s (files: %d)", description, len(files)),
|
||||
)
|
||||
return nil // Return nil for mock to avoid actual progress bar
|
||||
}
|
||||
|
||||
func (m *MockProgressManager) FinishProgressBar(_ *progressbar.ProgressBar) {
|
||||
m.FinishProgressBarCalls++
|
||||
}
|
||||
|
||||
func (m *MockProgressManager) FinishProgressBarWithNewline(_ *progressbar.ProgressBar) {
|
||||
m.FinishProgressBarWithNewlineCalls++
|
||||
}
|
||||
|
||||
func (m *MockProgressManager) UpdateProgressBar(_ *progressbar.ProgressBar) {
|
||||
m.UpdateProgressBarCalls++
|
||||
}
|
||||
|
||||
func (m *MockProgressManager) ProcessWithProgressBar(
|
||||
description string,
|
||||
items []string,
|
||||
processFunc func(item string, bar *progressbar.ProgressBar),
|
||||
) {
|
||||
m.ProcessWithProgressBarCalls = append(
|
||||
m.ProcessWithProgressBarCalls,
|
||||
formatMessage("%s (items: %d)", description, len(items)),
|
||||
)
|
||||
// Execute the process function for each item
|
||||
for _, item := range items {
|
||||
processFunc(item, nil)
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to format messages consistently.
|
||||
func formatMessage(format string, args ...any) string {
|
||||
if len(args) == 0 {
|
||||
return format
|
||||
}
|
||||
// Simple formatting for test purposes
|
||||
result := format
|
||||
for _, arg := range args {
|
||||
result = strings.Replace(result, "%s", toString(arg), 1)
|
||||
result = strings.Replace(result, "%d", toString(arg), 1)
|
||||
result = strings.Replace(result, "%v", toString(arg), 1)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toString(v any) string {
|
||||
switch val := v.(type) {
|
||||
case string:
|
||||
return val
|
||||
case int:
|
||||
return formatInt(val)
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
func formatInt(i int) string {
|
||||
// Simple int to string conversion for testing
|
||||
if i == 0 {
|
||||
return "0"
|
||||
}
|
||||
result := ""
|
||||
negative := i < 0
|
||||
if negative {
|
||||
i = -i
|
||||
}
|
||||
for i > 0 {
|
||||
digit := i % 10
|
||||
result = string(rune('0'+digit)) + result
|
||||
i /= 10
|
||||
}
|
||||
if negative {
|
||||
result = "-" + result
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// Test that demonstrates improved testability with focused interfaces.
|
||||
func TestFocusedInterfaces_SimpleLogger(t *testing.T) {
|
||||
mockLogger := &MockMessageLogger{}
|
||||
simpleLogger := NewSimpleLogger(mockLogger)
|
||||
|
||||
// Test successful operation
|
||||
simpleLogger.LogOperation("test-operation", true)
|
||||
|
||||
// Verify the expected calls were made
|
||||
if len(mockLogger.InfoCalls) != 1 {
|
||||
t.Errorf("expected 1 Info call, got %d", len(mockLogger.InfoCalls))
|
||||
}
|
||||
if len(mockLogger.SuccessCalls) != 1 {
|
||||
t.Errorf("expected 1 Success call, got %d", len(mockLogger.SuccessCalls))
|
||||
}
|
||||
if len(mockLogger.WarningCalls) != 0 {
|
||||
t.Errorf("expected 0 Warning calls, got %d", len(mockLogger.WarningCalls))
|
||||
}
|
||||
|
||||
// Check message content
|
||||
if !strings.Contains(mockLogger.InfoCalls[0], "test-operation") {
|
||||
t.Errorf("expected Info call to contain 'test-operation', got: %s", mockLogger.InfoCalls[0])
|
||||
}
|
||||
|
||||
if !strings.Contains(mockLogger.SuccessCalls[0], "test-operation") {
|
||||
t.Errorf("expected Success call to contain 'test-operation', got: %s", mockLogger.SuccessCalls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_SimpleLogger_WithFailure(t *testing.T) {
|
||||
mockLogger := &MockMessageLogger{}
|
||||
simpleLogger := NewSimpleLogger(mockLogger)
|
||||
|
||||
// Test failed operation
|
||||
simpleLogger.LogOperation("failing-operation", false)
|
||||
|
||||
// Verify the expected calls were made
|
||||
if len(mockLogger.InfoCalls) != 1 {
|
||||
t.Errorf("expected 1 Info call, got %d", len(mockLogger.InfoCalls))
|
||||
}
|
||||
if len(mockLogger.SuccessCalls) != 0 {
|
||||
t.Errorf("expected 0 Success calls, got %d", len(mockLogger.SuccessCalls))
|
||||
}
|
||||
if len(mockLogger.WarningCalls) != 1 {
|
||||
t.Errorf("expected 1 Warning call, got %d", len(mockLogger.WarningCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_ErrorManager(t *testing.T) {
|
||||
mockReporter := &MockErrorReporter{}
|
||||
mockFormatter := &MockErrorFormatter{}
|
||||
mockManager := &mockErrorManager{
|
||||
reporter: mockReporter,
|
||||
formatter: mockFormatter,
|
||||
}
|
||||
errorManager := NewFocusedErrorManager(mockManager)
|
||||
|
||||
// Test validation error handling
|
||||
errorManager.HandleValidationError("test-file.yml", []string{"name", "description"})
|
||||
|
||||
// Verify the expected calls were made
|
||||
if len(mockReporter.ErrorWithContextCalls) != 1 {
|
||||
t.Errorf("expected 1 ErrorWithContext call, got %d", len(mockReporter.ErrorWithContextCalls))
|
||||
}
|
||||
|
||||
if !strings.Contains(mockReporter.ErrorWithContextCalls[0], "test-file.yml") {
|
||||
t.Errorf("expected error message to contain 'test-file.yml', got: %s", mockReporter.ErrorWithContextCalls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_TaskProgress(t *testing.T) {
|
||||
mockReporter := &MockProgressReporter{}
|
||||
taskProgress := NewTaskProgress(mockReporter)
|
||||
|
||||
// Test progress reporting
|
||||
taskProgress.ReportProgress("compile", 3, 10)
|
||||
|
||||
// Verify the expected calls were made
|
||||
if len(mockReporter.ProgressCalls) != 1 {
|
||||
t.Errorf("expected 1 Progress call, got %d", len(mockReporter.ProgressCalls))
|
||||
}
|
||||
|
||||
if !strings.Contains(mockReporter.ProgressCalls[0], "compile") {
|
||||
t.Errorf("expected progress message to contain 'compile', got: %s", mockReporter.ProgressCalls[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_ConfigAwareComponent(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
quietMode bool
|
||||
shouldShow bool
|
||||
}{
|
||||
{
|
||||
name: "normal mode should output",
|
||||
quietMode: false,
|
||||
shouldShow: true,
|
||||
},
|
||||
{
|
||||
name: "quiet mode should not output",
|
||||
quietMode: true,
|
||||
shouldShow: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
mockConfig := &MockOutputConfig{QuietMode: tt.quietMode}
|
||||
component := NewConfigAwareComponent(mockConfig)
|
||||
|
||||
result := component.ShouldOutput()
|
||||
|
||||
if result != tt.shouldShow {
|
||||
t.Errorf("expected ShouldOutput() to return %v, got %v", tt.shouldShow, result)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_CompositeOutputWriter(t *testing.T) {
|
||||
// Create a composite mock that implements OutputWriter
|
||||
mockLogger := &MockMessageLogger{}
|
||||
mockProgress := &MockProgressReporter{}
|
||||
mockConfig := &MockOutputConfig{QuietMode: false}
|
||||
|
||||
compositeWriter := &CompositeOutputWriter{
|
||||
writer: &mockOutputWriter{
|
||||
logger: mockLogger,
|
||||
reporter: mockProgress,
|
||||
config: mockConfig,
|
||||
},
|
||||
}
|
||||
|
||||
items := []string{"item1", "item2", "item3"}
|
||||
compositeWriter.ProcessWithOutput(items)
|
||||
|
||||
// Verify that the composite writer uses both message logging and progress reporting
|
||||
// Should have called Info and Success for overall status
|
||||
if len(mockLogger.InfoCalls) != 1 {
|
||||
t.Errorf("expected 1 Info call, got %d", len(mockLogger.InfoCalls))
|
||||
}
|
||||
if len(mockLogger.SuccessCalls) != 1 {
|
||||
t.Errorf("expected 1 Success call, got %d", len(mockLogger.SuccessCalls))
|
||||
}
|
||||
|
||||
// Should have called Progress for each item
|
||||
if len(mockProgress.ProgressCalls) != 3 {
|
||||
t.Errorf("expected 3 Progress calls, got %d", len(mockProgress.ProgressCalls))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFocusedInterfaces_GeneratorWithDependencyInjection(t *testing.T) {
|
||||
// Create focused mocks
|
||||
mockOutput := &mockCompleteOutput{
|
||||
logger: &MockMessageLogger{},
|
||||
reporter: &MockErrorReporter{},
|
||||
formatter: &MockErrorFormatter{},
|
||||
progress: &MockProgressReporter{},
|
||||
config: &MockOutputConfig{QuietMode: false},
|
||||
}
|
||||
mockProgress := &MockProgressManager{}
|
||||
|
||||
// Create generator with dependency injection
|
||||
config := &AppConfig{
|
||||
Theme: "default",
|
||||
OutputFormat: "md",
|
||||
OutputDir: ".",
|
||||
Verbose: false,
|
||||
Quiet: false,
|
||||
}
|
||||
|
||||
generator := NewGeneratorWithDependencies(config, mockOutput, mockProgress)
|
||||
|
||||
// Verify the generator was created with the injected dependencies
|
||||
if generator == nil {
|
||||
t.Fatal("expected generator to be created")
|
||||
}
|
||||
if generator.Config != config {
|
||||
t.Error("expected generator to have the provided config")
|
||||
}
|
||||
if generator.Output != mockOutput {
|
||||
t.Error("expected generator to have the injected output")
|
||||
}
|
||||
if generator.Progress != mockProgress {
|
||||
t.Error("expected generator to have the injected progress manager")
|
||||
}
|
||||
}
|
||||
|
||||
// Composite mock types to implement the composed interfaces
|
||||
|
||||
type mockCompleteOutput struct {
|
||||
logger MessageLogger
|
||||
reporter ErrorReporter
|
||||
formatter ErrorFormatter
|
||||
progress ProgressReporter
|
||||
config OutputConfig
|
||||
}
|
||||
|
||||
func (m *mockCompleteOutput) Info(format string, args ...any) { m.logger.Info(format, args...) }
|
||||
func (m *mockCompleteOutput) Success(format string, args ...any) { m.logger.Success(format, args...) }
|
||||
func (m *mockCompleteOutput) Warning(format string, args ...any) { m.logger.Warning(format, args...) }
|
||||
func (m *mockCompleteOutput) Bold(format string, args ...any) { m.logger.Bold(format, args...) }
|
||||
func (m *mockCompleteOutput) Printf(format string, args ...any) { m.logger.Printf(format, args...) }
|
||||
func (m *mockCompleteOutput) Fprintf(w *os.File, format string, args ...any) {
|
||||
m.logger.Fprintf(w, format, args...)
|
||||
}
|
||||
func (m *mockCompleteOutput) Error(format string, args ...any) { m.reporter.Error(format, args...) }
|
||||
func (m *mockCompleteOutput) ErrorWithSuggestions(err *errors.ContextualError) {
|
||||
m.reporter.ErrorWithSuggestions(err)
|
||||
}
|
||||
func (m *mockCompleteOutput) ErrorWithContext(code errors.ErrorCode, message string, context map[string]string) {
|
||||
m.reporter.ErrorWithContext(code, message, context)
|
||||
}
|
||||
func (m *mockCompleteOutput) ErrorWithSimpleFix(message, suggestion string) {
|
||||
m.reporter.ErrorWithSimpleFix(message, suggestion)
|
||||
}
|
||||
func (m *mockCompleteOutput) FormatContextualError(err *errors.ContextualError) string {
|
||||
return m.formatter.FormatContextualError(err)
|
||||
}
|
||||
func (m *mockCompleteOutput) Progress(format string, args ...any) {
|
||||
m.progress.Progress(format, args...)
|
||||
}
|
||||
func (m *mockCompleteOutput) IsQuiet() bool { return m.config.IsQuiet() }
|
||||
|
||||
type mockOutputWriter struct {
|
||||
logger MessageLogger
|
||||
reporter ProgressReporter
|
||||
config OutputConfig
|
||||
}
|
||||
|
||||
func (m *mockOutputWriter) Info(format string, args ...any) { m.logger.Info(format, args...) }
|
||||
func (m *mockOutputWriter) Success(format string, args ...any) { m.logger.Success(format, args...) }
|
||||
func (m *mockOutputWriter) Warning(format string, args ...any) { m.logger.Warning(format, args...) }
|
||||
func (m *mockOutputWriter) Bold(format string, args ...any) { m.logger.Bold(format, args...) }
|
||||
func (m *mockOutputWriter) Printf(format string, args ...any) { m.logger.Printf(format, args...) }
|
||||
func (m *mockOutputWriter) Fprintf(w *os.File, format string, args ...any) {
|
||||
m.logger.Fprintf(w, format, args...)
|
||||
}
|
||||
func (m *mockOutputWriter) Progress(format string, args ...any) { m.reporter.Progress(format, args...) }
|
||||
func (m *mockOutputWriter) IsQuiet() bool { return m.config.IsQuiet() }
|
||||
|
||||
// MockErrorFormatter implements ErrorFormatter for testing.
|
||||
type MockErrorFormatter struct {
|
||||
FormatContextualErrorCalls []string
|
||||
}
|
||||
|
||||
func (m *MockErrorFormatter) FormatContextualError(err *errors.ContextualError) string {
|
||||
if err != nil {
|
||||
formatted := err.Error()
|
||||
m.FormatContextualErrorCalls = append(m.FormatContextualErrorCalls, formatted)
|
||||
return formatted
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// mockErrorManager implements ErrorManager for testing.
|
||||
type mockErrorManager struct {
|
||||
reporter ErrorReporter
|
||||
formatter ErrorFormatter
|
||||
}
|
||||
|
||||
func (m *mockErrorManager) Error(format string, args ...any) { m.reporter.Error(format, args...) }
|
||||
func (m *mockErrorManager) ErrorWithSuggestions(err *errors.ContextualError) {
|
||||
m.reporter.ErrorWithSuggestions(err)
|
||||
}
|
||||
func (m *mockErrorManager) ErrorWithContext(code errors.ErrorCode, message string, context map[string]string) {
|
||||
m.reporter.ErrorWithContext(code, message, context)
|
||||
}
|
||||
func (m *mockErrorManager) ErrorWithSimpleFix(message, suggestion string) {
|
||||
m.reporter.ErrorWithSimpleFix(message, suggestion)
|
||||
}
|
||||
func (m *mockErrorManager) FormatContextualError(err *errors.ContextualError) string {
|
||||
return m.formatter.FormatContextualError(err)
|
||||
}
|
||||
@@ -2,16 +2,19 @@ package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
func TestParseActionYML_Valid(t *testing.T) {
|
||||
path := "../testdata/example-action/action.yml"
|
||||
action, err := ParseActionYML(path)
|
||||
// Create temporary action file using fixture
|
||||
actionPath := testutil.CreateTemporaryAction(t, "actions/javascript/simple.yml")
|
||||
action, err := ParseActionYML(actionPath)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to parse action.yml: %v", err)
|
||||
}
|
||||
if action.Name != "Example Action" {
|
||||
t.Errorf("expected name 'Example Action', got '%s'", action.Name)
|
||||
if action.Name != "Simple JavaScript Action" {
|
||||
t.Errorf("expected name 'Simple JavaScript Action', got '%s'", action.Name)
|
||||
}
|
||||
if action.Description == "" {
|
||||
t.Error("expected non-empty description")
|
||||
|
||||
@@ -1,10 +1,18 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/testutil"
|
||||
)
|
||||
|
||||
func TestRenderReadme(t *testing.T) {
|
||||
// Set up test templates
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
testutil.SetupTestTemplates(t, tmpDir)
|
||||
|
||||
action := &ActionYML{
|
||||
Name: "MyAction",
|
||||
Description: "desc",
|
||||
@@ -12,7 +20,7 @@ func TestRenderReadme(t *testing.T) {
|
||||
"foo": {Description: "Foo input", Required: true},
|
||||
},
|
||||
}
|
||||
tmpl := "../templates/readme.tmpl"
|
||||
tmpl := filepath.Join(tmpDir, "templates", "readme.tmpl")
|
||||
opts := TemplateOptions{TemplatePath: tmpl, Format: "md"}
|
||||
out, err := RenderReadme(action, opts)
|
||||
if err != nil {
|
||||
|
||||
@@ -118,7 +118,7 @@ func (jw *JSONWriter) Write(action *ActionYML, outputPath string) error {
|
||||
}
|
||||
|
||||
// Write to file
|
||||
return os.WriteFile(outputPath, data, 0644)
|
||||
return os.WriteFile(outputPath, data, FilePermDefault) // #nosec G306 -- JSON output file permissions
|
||||
}
|
||||
|
||||
// convertToJSONOutput converts ActionYML to structured JSON output.
|
||||
|
||||
@@ -11,11 +11,22 @@ import (
|
||||
)
|
||||
|
||||
// ColoredOutput provides methods for colored terminal output.
|
||||
// It implements all the focused interfaces for backward compatibility.
|
||||
type ColoredOutput struct {
|
||||
NoColor bool
|
||||
Quiet bool
|
||||
}
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ MessageLogger = (*ColoredOutput)(nil)
|
||||
_ ErrorReporter = (*ColoredOutput)(nil)
|
||||
_ ErrorFormatter = (*ColoredOutput)(nil)
|
||||
_ ProgressReporter = (*ColoredOutput)(nil)
|
||||
_ OutputConfig = (*ColoredOutput)(nil)
|
||||
_ CompleteOutput = (*ColoredOutput)(nil)
|
||||
)
|
||||
|
||||
// NewColoredOutput creates a new colored output instance.
|
||||
func NewColoredOutput(quiet bool) *ColoredOutput {
|
||||
return &ColoredOutput{
|
||||
|
||||
@@ -40,7 +40,7 @@ type Branding struct {
|
||||
|
||||
// ParseActionYML reads and parses action.yml from given path.
|
||||
func ParseActionYML(path string) (*ActionYML, error) {
|
||||
f, err := os.Open(path)
|
||||
f, err := os.Open(path) // #nosec G304 -- path from function parameter
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -2,14 +2,20 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/schollz/progressbar/v3"
|
||||
)
|
||||
|
||||
// ProgressBarManager handles progress bar creation and management.
|
||||
// It implements the ProgressManager interface.
|
||||
type ProgressBarManager struct {
|
||||
quiet bool
|
||||
}
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ ProgressManager = (*ProgressBarManager)(nil)
|
||||
|
||||
// NewProgressBarManager creates a new progress bar manager.
|
||||
func NewProgressBarManager(quiet bool) *ProgressBarManager {
|
||||
return &ProgressBarManager{
|
||||
@@ -48,3 +54,36 @@ func (pm *ProgressBarManager) FinishProgressBar(bar *progressbar.ProgressBar) {
|
||||
_ = bar.Finish()
|
||||
}
|
||||
}
|
||||
|
||||
// FinishProgressBarWithNewline completes the progress bar display and adds a newline.
|
||||
func (pm *ProgressBarManager) FinishProgressBarWithNewline(bar *progressbar.ProgressBar) {
|
||||
pm.FinishProgressBar(bar)
|
||||
if bar != nil {
|
||||
fmt.Println()
|
||||
}
|
||||
}
|
||||
|
||||
// ProcessWithProgressBar executes a function for each item with progress tracking.
|
||||
// The processFunc receives the item and the progress bar for updating.
|
||||
func (pm *ProgressBarManager) ProcessWithProgressBar(
|
||||
description string,
|
||||
items []string,
|
||||
processFunc func(item string, bar *progressbar.ProgressBar),
|
||||
) {
|
||||
bar := pm.CreateProgressBarForFiles(description, items)
|
||||
defer pm.FinishProgressBarWithNewline(bar)
|
||||
|
||||
for _, item := range items {
|
||||
processFunc(item, bar)
|
||||
if bar != nil {
|
||||
_ = bar.Add(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateProgressBar safely updates the progress bar if it exists.
|
||||
func (pm *ProgressBarManager) UpdateProgressBar(bar *progressbar.ProgressBar) {
|
||||
if bar != nil {
|
||||
_ = bar.Add(1)
|
||||
}
|
||||
}
|
||||
|
||||
142
internal/progress_test.go
Normal file
142
internal/progress_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/schollz/progressbar/v3"
|
||||
)
|
||||
|
||||
func TestProgressBarManager_CreateProgressBar(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
quiet bool
|
||||
description string
|
||||
total int
|
||||
expectNil bool
|
||||
}{
|
||||
{
|
||||
name: "normal progress bar",
|
||||
quiet: false,
|
||||
description: "Test progress",
|
||||
total: 10,
|
||||
expectNil: false,
|
||||
},
|
||||
{
|
||||
name: "quiet mode returns nil",
|
||||
quiet: true,
|
||||
description: "Test progress",
|
||||
total: 10,
|
||||
expectNil: true,
|
||||
},
|
||||
{
|
||||
name: "single item returns nil",
|
||||
quiet: false,
|
||||
description: "Test progress",
|
||||
total: 1,
|
||||
expectNil: true,
|
||||
},
|
||||
{
|
||||
name: "zero items returns nil",
|
||||
quiet: false,
|
||||
description: "Test progress",
|
||||
total: 0,
|
||||
expectNil: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
pm := NewProgressBarManager(tt.quiet)
|
||||
bar := pm.CreateProgressBar(tt.description, tt.total)
|
||||
|
||||
if tt.expectNil {
|
||||
if bar != nil {
|
||||
t.Errorf("expected nil progress bar, got %v", bar)
|
||||
}
|
||||
} else {
|
||||
if bar == nil {
|
||||
t.Error("expected progress bar, got nil")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressBarManager_CreateProgressBarForFiles(t *testing.T) {
|
||||
pm := NewProgressBarManager(false)
|
||||
files := []string{"file1.yml", "file2.yml", "file3.yml"}
|
||||
|
||||
bar := pm.CreateProgressBarForFiles("Processing files", files)
|
||||
|
||||
if bar == nil {
|
||||
t.Error("expected progress bar for multiple files, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressBarManager_FinishProgressBar(_ *testing.T) {
|
||||
pm := NewProgressBarManager(false)
|
||||
|
||||
// Test with nil bar (should not panic)
|
||||
pm.FinishProgressBar(nil)
|
||||
|
||||
// Test with actual bar
|
||||
bar := pm.CreateProgressBar("Test", 5)
|
||||
if bar != nil {
|
||||
pm.FinishProgressBar(bar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressBarManager_UpdateProgressBar(_ *testing.T) {
|
||||
pm := NewProgressBarManager(false)
|
||||
|
||||
// Test with nil bar (should not panic)
|
||||
pm.UpdateProgressBar(nil)
|
||||
|
||||
// Test with actual bar
|
||||
bar := pm.CreateProgressBar("Test", 5)
|
||||
if bar != nil {
|
||||
pm.UpdateProgressBar(bar)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressBarManager_ProcessWithProgressBar(t *testing.T) {
|
||||
pm := NewProgressBarManager(false)
|
||||
items := []string{"item1", "item2", "item3"}
|
||||
|
||||
processedItems := make([]string, 0)
|
||||
processFunc := func(item string, _ *progressbar.ProgressBar) {
|
||||
processedItems = append(processedItems, item)
|
||||
}
|
||||
|
||||
pm.ProcessWithProgressBar("Processing items", items, processFunc)
|
||||
|
||||
if len(processedItems) != len(items) {
|
||||
t.Errorf("expected %d processed items, got %d", len(items), len(processedItems))
|
||||
}
|
||||
|
||||
for i, item := range items {
|
||||
if processedItems[i] != item {
|
||||
t.Errorf("expected item %s at position %d, got %s", item, i, processedItems[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProgressBarManager_ProcessWithProgressBar_QuietMode(t *testing.T) {
|
||||
pm := NewProgressBarManager(true) // quiet mode
|
||||
items := []string{"item1", "item2"}
|
||||
|
||||
processedItems := make([]string, 0)
|
||||
processFunc := func(item string, bar *progressbar.ProgressBar) {
|
||||
processedItems = append(processedItems, item)
|
||||
// In quiet mode, bar should be nil
|
||||
if bar != nil {
|
||||
t.Error("expected nil progress bar in quiet mode")
|
||||
}
|
||||
}
|
||||
|
||||
pm.ProcessWithProgressBar("Processing items", items, processFunc)
|
||||
|
||||
if len(processedItems) != len(items) {
|
||||
t.Errorf("expected %d processed items, got %d", len(items), len(processedItems))
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/ivuorinen/gh-action-readme/internal/cache"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/git"
|
||||
"github.com/ivuorinen/gh-action-readme/internal/validation"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -45,10 +46,6 @@ type TemplateData struct {
|
||||
Dependencies []dependencies.Dependency `json:"dependencies,omitempty"`
|
||||
}
|
||||
|
||||
// GitInfo contains Git repository information for templates.
|
||||
// Note: GitInfo struct removed - using git.RepoInfo instead to avoid duplication
|
||||
// Note: Dependency struct is now defined in internal/dependencies package
|
||||
|
||||
// templateFuncs returns a map of custom template functions.
|
||||
func templateFuncs() template.FuncMap {
|
||||
return template.FuncMap{
|
||||
@@ -115,19 +112,19 @@ func isValidOrgRepo(org, repo string) bool {
|
||||
// formatVersion ensures version has proper @ prefix.
|
||||
func formatVersion(version string) string {
|
||||
version = strings.TrimSpace(version)
|
||||
if version != "" && !strings.HasPrefix(version, "@") {
|
||||
return "@" + version
|
||||
}
|
||||
if version == "" {
|
||||
return "@v1"
|
||||
}
|
||||
if !strings.HasPrefix(version, "@") {
|
||||
return "@" + version
|
||||
}
|
||||
return version
|
||||
}
|
||||
|
||||
// buildUsesString constructs the uses string with optional action name.
|
||||
func buildUsesString(td *TemplateData, org, repo, version string) string {
|
||||
if td.Name != "" {
|
||||
actionName := strings.ToLower(strings.ReplaceAll(strings.TrimSpace(td.Name), " ", "-"))
|
||||
actionName := validation.SanitizeActionName(td.Name)
|
||||
if actionName != "" && actionName != repo {
|
||||
return fmt.Sprintf("%s/%s/%s%s", org, repo, actionName, version)
|
||||
}
|
||||
@@ -225,7 +222,7 @@ func RenderReadme(action any, opts TemplateOptions) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
var tmpl *template.Template
|
||||
if opts.Format == "html" {
|
||||
if opts.Format == OutputFormatHTML {
|
||||
tmpl, err = template.New("readme").Funcs(templateFuncs()).Parse(string(tmplContent))
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
||||
@@ -25,16 +25,19 @@ func IsSemanticVersion(version string) bool {
|
||||
|
||||
// IsVersionPinned checks if a semantic version is pinned to a specific version.
|
||||
func IsVersionPinned(version string) bool {
|
||||
// Consider it pinned if it specifies patch version (v1.2.3) or is a commit SHA
|
||||
if IsSemanticVersion(version) {
|
||||
return true
|
||||
}
|
||||
return IsCommitSHA(version) && len(version) == 40 // Only full SHAs are considered pinned
|
||||
// Consider it pinned if it specifies patch version (v1.2.3) or is a full commit SHA
|
||||
return IsSemanticVersion(version) || (IsCommitSHA(version) && len(version) == 40)
|
||||
}
|
||||
|
||||
// ValidateGitBranch checks if a branch exists in the given repository.
|
||||
func ValidateGitBranch(repoRoot, branch string) bool {
|
||||
cmd := exec.Command("git", "show-ref", "--verify", "--quiet", "refs/heads/"+branch)
|
||||
cmd := exec.Command(
|
||||
"git",
|
||||
"show-ref",
|
||||
"--verify",
|
||||
"--quiet",
|
||||
"refs/heads/"+branch,
|
||||
) // #nosec G204 -- branch name validated by git
|
||||
cmd.Dir = repoRoot
|
||||
return cmd.Run() == nil
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ func TestValidateActionYMLPath(t *testing.T) {
|
||||
name: "valid action.yml file",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.SimpleActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
return actionPath
|
||||
},
|
||||
expectError: false,
|
||||
@@ -28,7 +28,7 @@ func TestValidateActionYMLPath(t *testing.T) {
|
||||
name: "valid action.yaml file",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
actionPath := filepath.Join(tmpDir, "action.yaml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MinimalActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("minimal-action.yml"))
|
||||
return actionPath
|
||||
},
|
||||
expectError: false,
|
||||
@@ -44,7 +44,7 @@ func TestValidateActionYMLPath(t *testing.T) {
|
||||
name: "file with wrong extension",
|
||||
setupFunc: func(t *testing.T, tmpDir string) string {
|
||||
actionPath := filepath.Join(tmpDir, "action.txt")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.SimpleActionYML)
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
return actionPath
|
||||
},
|
||||
expectError: true,
|
||||
@@ -240,7 +240,7 @@ func TestValidateGitBranch(t *testing.T) {
|
||||
setupFunc: func(_ *testing.T, tmpDir string) (string, string) {
|
||||
// Create a simple git repository
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
_ = os.MkdirAll(gitDir, 0755)
|
||||
_ = os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
|
||||
// Create a basic git config
|
||||
configContent := `[core]
|
||||
@@ -297,7 +297,7 @@ func TestIsGitRepository(t *testing.T) {
|
||||
name: "directory with .git folder",
|
||||
setupFunc: func(_ *testing.T, tmpDir string) string {
|
||||
gitDir := filepath.Join(tmpDir, ".git")
|
||||
_ = os.MkdirAll(gitDir, 0755)
|
||||
_ = os.MkdirAll(gitDir, 0750) // #nosec G301 -- test directory permissions
|
||||
return tmpDir
|
||||
},
|
||||
expected: true,
|
||||
|
||||
@@ -77,9 +77,7 @@ func (d *ProjectDetector) DetectProjectSettings() (*DetectedSettings, error) {
|
||||
}
|
||||
|
||||
// Detect project characteristics
|
||||
if err := d.detectProjectCharacteristics(settings); err != nil {
|
||||
d.output.Warning("Could not detect project characteristics: %v", err)
|
||||
}
|
||||
d.detectProjectCharacteristics(settings)
|
||||
|
||||
// Suggest configuration based on detection
|
||||
d.suggestConfiguration(settings)
|
||||
@@ -134,7 +132,7 @@ func (d *ProjectDetector) detectActionFiles(settings *DetectedSettings) error {
|
||||
}
|
||||
|
||||
// detectProjectCharacteristics detects project type, language, and framework.
|
||||
func (d *ProjectDetector) detectProjectCharacteristics(settings *DetectedSettings) error {
|
||||
func (d *ProjectDetector) detectProjectCharacteristics(settings *DetectedSettings) {
|
||||
// Check for common files and patterns
|
||||
characteristics := d.analyzeProjectFiles()
|
||||
|
||||
@@ -148,8 +146,6 @@ func (d *ProjectDetector) detectProjectCharacteristics(settings *DetectedSetting
|
||||
settings.HasDockerfile = true
|
||||
d.output.Success("Detected Dockerfile")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// detectVersion attempts to detect project version from various sources.
|
||||
@@ -175,7 +171,7 @@ func (d *ProjectDetector) detectVersion() string {
|
||||
// detectVersionFromPackageJSON detects version from package.json.
|
||||
func (d *ProjectDetector) detectVersionFromPackageJSON() string {
|
||||
packageJSONPath := filepath.Join(d.currentDir, "package.json")
|
||||
data, err := os.ReadFile(packageJSONPath)
|
||||
data, err := os.ReadFile(packageJSONPath) // #nosec G304 -- path is constructed from current directory
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
@@ -208,6 +204,7 @@ func (d *ProjectDetector) detectVersionFromFiles() string {
|
||||
|
||||
for _, filename := range versionFiles {
|
||||
versionPath := filepath.Join(d.currentDir, filename)
|
||||
// #nosec G304 -- path constructed from current dir
|
||||
if data, err := os.ReadFile(versionPath); err == nil {
|
||||
version := strings.TrimSpace(string(data))
|
||||
if version != "" {
|
||||
@@ -293,7 +290,7 @@ func (d *ProjectDetector) analyzeActionFile(actionFile string, settings *Detecte
|
||||
|
||||
// parseActionFile reads and parses an action YAML file.
|
||||
func (d *ProjectDetector) parseActionFile(actionFile string) (map[string]any, error) {
|
||||
data, err := os.ReadFile(actionFile)
|
||||
data, err := os.ReadFile(actionFile) // #nosec G304 -- action file path from function parameter
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read action file: %w", err)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ func TestProjectDetector_analyzeProjectFiles(t *testing.T) {
|
||||
|
||||
for filename, content := range testFiles {
|
||||
filePath := filepath.Join(tempDir, filename)
|
||||
if err := os.WriteFile(filePath, []byte(content), 0644); err != nil {
|
||||
if err := os.WriteFile(filePath, []byte(content), 0600); err != nil { // #nosec G306 -- test file permissions
|
||||
t.Fatalf("Failed to create test file %s: %v", filename, err)
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ func TestProjectDetector_detectVersionFromPackageJSON(t *testing.T) {
|
||||
}`
|
||||
|
||||
packagePath := filepath.Join(tempDir, "package.json")
|
||||
if err := os.WriteFile(packagePath, []byte(packageJSON), 0644); err != nil {
|
||||
if err := os.WriteFile(packagePath, []byte(packageJSON), 0600); err != nil { // #nosec G306 -- test file permissions
|
||||
t.Fatalf("Failed to create package.json: %v", err)
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@ func TestProjectDetector_detectVersionFromFiles(t *testing.T) {
|
||||
// Create VERSION file
|
||||
versionContent := "3.2.1\n"
|
||||
versionPath := filepath.Join(tempDir, "VERSION")
|
||||
if err := os.WriteFile(versionPath, []byte(versionContent), 0644); err != nil {
|
||||
if err := os.WriteFile(versionPath, []byte(versionContent), 0600); err != nil { // #nosec G306 -- test file permissions
|
||||
t.Fatalf("Failed to create VERSION file: %v", err)
|
||||
}
|
||||
|
||||
@@ -116,18 +116,26 @@ func TestProjectDetector_findActionFiles(t *testing.T) {
|
||||
|
||||
// Create action files
|
||||
actionYML := filepath.Join(tempDir, "action.yml")
|
||||
if err := os.WriteFile(actionYML, []byte("name: Test Action"), 0644); err != nil {
|
||||
if err := os.WriteFile(
|
||||
actionYML,
|
||||
[]byte("name: Test Action"),
|
||||
0600, // #nosec G306 -- test file permissions
|
||||
); err != nil {
|
||||
t.Fatalf("Failed to create action.yml: %v", err)
|
||||
}
|
||||
|
||||
// Create subdirectory with another action file
|
||||
subDir := filepath.Join(tempDir, "subaction")
|
||||
if err := os.MkdirAll(subDir, 0755); err != nil {
|
||||
if err := os.MkdirAll(subDir, 0750); err != nil { // #nosec G301 -- test directory permissions
|
||||
t.Fatalf("Failed to create subdirectory: %v", err)
|
||||
}
|
||||
|
||||
subActionYAML := filepath.Join(subDir, "action.yaml")
|
||||
if err := os.WriteFile(subActionYAML, []byte("name: Sub Action"), 0644); err != nil {
|
||||
if err := os.WriteFile(
|
||||
subActionYAML,
|
||||
[]byte("name: Sub Action"),
|
||||
0600, // #nosec G306 -- test file permissions
|
||||
); err != nil {
|
||||
t.Fatalf("Failed to create sub action.yaml: %v", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -39,7 +39,7 @@ func NewConfigExporter(output *internal.ColoredOutput) *ConfigExporter {
|
||||
// ExportConfig exports the configuration to the specified format and path.
|
||||
func (e *ConfigExporter) ExportConfig(config *internal.AppConfig, format ExportFormat, outputPath string) error {
|
||||
// Create output directory if it doesn't exist
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0755); err != nil {
|
||||
if err := os.MkdirAll(filepath.Dir(outputPath), 0750); err != nil { // #nosec G301 -- output directory permissions
|
||||
return fmt.Errorf("failed to create output directory: %w", err)
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ func (e *ConfigExporter) exportYAML(config *internal.AppConfig, outputPath strin
|
||||
// Create a clean config without sensitive data for export
|
||||
exportConfig := e.sanitizeConfig(config)
|
||||
|
||||
file, err := os.Create(outputPath)
|
||||
file, err := os.Create(outputPath) // #nosec G304 -- output path from function parameter
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create YAML file: %w", err)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ func (e *ConfigExporter) exportJSON(config *internal.AppConfig, outputPath strin
|
||||
// Create a clean config without sensitive data for export
|
||||
exportConfig := e.sanitizeConfig(config)
|
||||
|
||||
file, err := os.Create(outputPath)
|
||||
file, err := os.Create(outputPath) // #nosec G304 -- output path from function parameter
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create JSON file: %w", err)
|
||||
}
|
||||
@@ -113,7 +113,7 @@ func (e *ConfigExporter) exportTOML(config *internal.AppConfig, outputPath strin
|
||||
// In a full implementation, you would use "github.com/BurntSushi/toml"
|
||||
exportConfig := e.sanitizeConfig(config)
|
||||
|
||||
file, err := os.Create(outputPath)
|
||||
file, err := os.Create(outputPath) // #nosec G304 -- output path from function parameter
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create TOML file: %w", err)
|
||||
}
|
||||
@@ -126,9 +126,7 @@ func (e *ConfigExporter) exportTOML(config *internal.AppConfig, outputPath strin
|
||||
_, _ = file.WriteString("# Generated by the interactive configuration wizard\n\n")
|
||||
|
||||
// Basic TOML export (simplified version)
|
||||
if err := e.writeTOMLConfig(file, exportConfig); err != nil {
|
||||
return fmt.Errorf("failed to write TOML: %w", err)
|
||||
}
|
||||
e.writeTOMLConfig(file, exportConfig)
|
||||
|
||||
e.output.Success("Configuration exported to: %s", outputPath)
|
||||
return nil
|
||||
@@ -173,7 +171,7 @@ func (e *ConfigExporter) sanitizeConfig(config *internal.AppConfig) *internal.Ap
|
||||
}
|
||||
|
||||
// writeTOMLConfig writes a basic TOML configuration.
|
||||
func (e *ConfigExporter) writeTOMLConfig(file *os.File, config *internal.AppConfig) error {
|
||||
func (e *ConfigExporter) writeTOMLConfig(file *os.File, config *internal.AppConfig) {
|
||||
e.writeRepositorySection(file, config)
|
||||
e.writeTemplateSection(file, config)
|
||||
e.writeFeaturesSection(file, config)
|
||||
@@ -181,8 +179,6 @@ func (e *ConfigExporter) writeTOMLConfig(file *os.File, config *internal.AppConf
|
||||
e.writeWorkflowSection(file, config)
|
||||
e.writePermissionsSection(file, config)
|
||||
e.writeVariablesSection(file, config)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// writeRepositorySection writes the repository information section.
|
||||
|
||||
@@ -103,7 +103,7 @@ func verifyFileExists(t *testing.T, outputPath string) {
|
||||
|
||||
// verifyYAMLContent verifies YAML content is valid and contains expected data.
|
||||
func verifyYAMLContent(t *testing.T, outputPath string, expected *internal.AppConfig) {
|
||||
data, err := os.ReadFile(outputPath)
|
||||
data, err := os.ReadFile(outputPath) // #nosec G304 -- test output path
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
@@ -123,7 +123,7 @@ func verifyYAMLContent(t *testing.T, outputPath string, expected *internal.AppCo
|
||||
|
||||
// verifyJSONContent verifies JSON content is valid and contains expected data.
|
||||
func verifyJSONContent(t *testing.T, outputPath string, expected *internal.AppConfig) {
|
||||
data, err := os.ReadFile(outputPath)
|
||||
data, err := os.ReadFile(outputPath) // #nosec G304 -- test output path
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
@@ -143,7 +143,7 @@ func verifyJSONContent(t *testing.T, outputPath string, expected *internal.AppCo
|
||||
|
||||
// verifyTOMLContent verifies TOML content contains expected fields.
|
||||
func verifyTOMLContent(t *testing.T, outputPath string) {
|
||||
data, err := os.ReadFile(outputPath)
|
||||
data, err := os.ReadFile(outputPath) // #nosec G304 -- test output path
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to read output file: %v", err)
|
||||
}
|
||||
|
||||
@@ -43,24 +43,16 @@ func (w *ConfigWizard) Run() (*internal.AppConfig, error) {
|
||||
}
|
||||
|
||||
// Step 2: Configure basic settings
|
||||
if err := w.configureBasicSettings(); err != nil {
|
||||
return nil, fmt.Errorf("failed to configure basic settings: %w", err)
|
||||
}
|
||||
w.configureBasicSettings()
|
||||
|
||||
// Step 3: Configure template and output settings
|
||||
if err := w.configureTemplateSettings(); err != nil {
|
||||
return nil, fmt.Errorf("failed to configure template settings: %w", err)
|
||||
}
|
||||
w.configureTemplateSettings()
|
||||
|
||||
// Step 4: Configure features
|
||||
if err := w.configureFeatures(); err != nil {
|
||||
return nil, fmt.Errorf("failed to configure features: %w", err)
|
||||
}
|
||||
w.configureFeatures()
|
||||
|
||||
// Step 5: Configure GitHub integration
|
||||
if err := w.configureGitHubIntegration(); err != nil {
|
||||
return nil, fmt.Errorf("failed to configure GitHub integration: %w", err)
|
||||
}
|
||||
w.configureGitHubIntegration()
|
||||
|
||||
// Step 6: Summary and confirmation
|
||||
if err := w.showSummaryAndConfirm(); err != nil {
|
||||
@@ -96,8 +88,8 @@ func (w *ConfigWizard) detectProjectSettings() error {
|
||||
}
|
||||
|
||||
// Check for existing action files
|
||||
actionFiles, err := w.findActionFiles(currentDir)
|
||||
if err == nil && len(actionFiles) > 0 {
|
||||
actionFiles := w.findActionFiles(currentDir)
|
||||
if len(actionFiles) > 0 {
|
||||
w.output.Success(" 🎯 Found %d action file(s)", len(actionFiles))
|
||||
}
|
||||
|
||||
@@ -105,7 +97,7 @@ func (w *ConfigWizard) detectProjectSettings() error {
|
||||
}
|
||||
|
||||
// configureBasicSettings handles basic configuration prompts.
|
||||
func (w *ConfigWizard) configureBasicSettings() error {
|
||||
func (w *ConfigWizard) configureBasicSettings() {
|
||||
w.output.Bold("\n⚙️ Step 2: Basic Settings")
|
||||
|
||||
// Organization
|
||||
@@ -119,19 +111,15 @@ func (w *ConfigWizard) configureBasicSettings() error {
|
||||
if version != "" {
|
||||
w.config.Version = version
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// configureTemplateSettings handles template and output configuration.
|
||||
func (w *ConfigWizard) configureTemplateSettings() error {
|
||||
func (w *ConfigWizard) configureTemplateSettings() {
|
||||
w.output.Bold("\n🎨 Step 3: Template & Output Settings")
|
||||
|
||||
w.configureThemeSelection()
|
||||
w.configureOutputFormat()
|
||||
w.configureOutputDirectory()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// configureThemeSelection handles theme selection.
|
||||
@@ -208,7 +196,7 @@ func (w *ConfigWizard) displayFormatOptions(formats []string) {
|
||||
}
|
||||
|
||||
// configureFeatures handles feature configuration.
|
||||
func (w *ConfigWizard) configureFeatures() error {
|
||||
func (w *ConfigWizard) configureFeatures() {
|
||||
w.output.Bold("\n🚀 Step 4: Features")
|
||||
|
||||
// Dependency analysis
|
||||
@@ -220,19 +208,17 @@ func (w *ConfigWizard) configureFeatures() error {
|
||||
w.output.Info("Security information shows pinned vs floating versions and security recommendations.")
|
||||
showSecurity := w.promptYesNo("Show security information?", w.config.ShowSecurityInfo)
|
||||
w.config.ShowSecurityInfo = showSecurity
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// configureGitHubIntegration handles GitHub API configuration.
|
||||
func (w *ConfigWizard) configureGitHubIntegration() error {
|
||||
func (w *ConfigWizard) configureGitHubIntegration() {
|
||||
w.output.Bold("\n🐙 Step 5: GitHub Integration")
|
||||
|
||||
// Check for existing token
|
||||
existingToken := internal.GetGitHubToken(w.config)
|
||||
if existingToken != "" {
|
||||
w.output.Success("GitHub token already configured ✓")
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
w.output.Info("GitHub integration requires a personal access token for:")
|
||||
@@ -245,7 +231,7 @@ func (w *ConfigWizard) configureGitHubIntegration() error {
|
||||
if !setupToken {
|
||||
w.output.Info("You can set up the token later using environment variables:")
|
||||
w.output.Printf(" export GITHUB_TOKEN=your_personal_access_token")
|
||||
return nil
|
||||
return
|
||||
}
|
||||
|
||||
w.output.Info("\nTo create a personal access token:")
|
||||
@@ -265,8 +251,6 @@ func (w *ConfigWizard) configureGitHubIntegration() error {
|
||||
w.config.GitHubToken = token
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// showSummaryAndConfirm displays configuration summary and asks for confirmation.
|
||||
@@ -286,9 +270,9 @@ func (w *ConfigWizard) showSummaryAndConfirm() error {
|
||||
|
||||
tokenStatus := "Not configured"
|
||||
if w.config.GitHubToken != "" {
|
||||
tokenStatus = "Configured ✓"
|
||||
tokenStatus = "Configured ✓" // #nosec G101 -- status message, not actual token
|
||||
} else if internal.GetGitHubToken(w.config) != "" {
|
||||
tokenStatus = "Configured via environment ✓"
|
||||
tokenStatus = "Configured via environment ✓" // #nosec G101 -- status message, not actual token
|
||||
}
|
||||
w.output.Printf(" GitHub Token: %s", tokenStatus)
|
||||
|
||||
@@ -361,7 +345,7 @@ func (w *ConfigWizard) promptYesNo(prompt string, defaultValue bool) bool {
|
||||
}
|
||||
|
||||
// findActionFiles discovers action files in the given directory.
|
||||
func (w *ConfigWizard) findActionFiles(dir string) ([]string, error) {
|
||||
func (w *ConfigWizard) findActionFiles(dir string) []string {
|
||||
var actionFiles []string
|
||||
|
||||
// Check for action.yml and action.yaml
|
||||
@@ -372,5 +356,5 @@ func (w *ConfigWizard) findActionFiles(dir string) ([]string, error) {
|
||||
}
|
||||
}
|
||||
|
||||
return actionFiles, nil
|
||||
return actionFiles
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user