mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-02-14 16:49:37 +00:00
feat: add interactive wizard, contextual errors, and code improvements
- Add interactive configuration wizard with auto-detection and multi-format export - Implement contextual error system with 14 error codes and actionable suggestions - Add centralized progress indicators with consistent theming - Fix all cyclomatic complexity issues (8 functions refactored) - Eliminate code duplication with centralized utilities and error handling - Add comprehensive test coverage for all new components - Update TODO.md with completed tasks and accurate completion dates
This commit is contained in:
493
internal/wizard/validator.go
Normal file
493
internal/wizard/validator.go
Normal file
@@ -0,0 +1,493 @@
|
||||
// Package wizard provides configuration validation functionality.
|
||||
package wizard
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal"
|
||||
)
|
||||
|
||||
// ValidationResult represents the result of configuration validation.
|
||||
type ValidationResult struct {
|
||||
Valid bool
|
||||
Errors []ValidationError
|
||||
Warnings []ValidationWarning
|
||||
Suggestions []string
|
||||
}
|
||||
|
||||
// ValidationError represents a validation error.
|
||||
type ValidationError struct {
|
||||
Field string
|
||||
Message string
|
||||
Value string
|
||||
}
|
||||
|
||||
// ValidationWarning represents a validation warning.
|
||||
type ValidationWarning struct {
|
||||
Field string
|
||||
Message string
|
||||
Value string
|
||||
}
|
||||
|
||||
// ConfigValidator handles configuration validation with immediate feedback.
|
||||
type ConfigValidator struct {
|
||||
output *internal.ColoredOutput
|
||||
}
|
||||
|
||||
// NewConfigValidator creates a new configuration validator.
|
||||
func NewConfigValidator(output *internal.ColoredOutput) *ConfigValidator {
|
||||
return &ConfigValidator{
|
||||
output: output,
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateConfig validates a complete configuration and returns detailed results.
|
||||
func (v *ConfigValidator) ValidateConfig(config *internal.AppConfig) *ValidationResult {
|
||||
result := &ValidationResult{
|
||||
Valid: true,
|
||||
Errors: []ValidationError{},
|
||||
Warnings: []ValidationWarning{},
|
||||
Suggestions: []string{},
|
||||
}
|
||||
|
||||
// Validate each field
|
||||
v.validateOrganization(config.Organization, result)
|
||||
v.validateRepository(config.Repository, result)
|
||||
v.validateVersion(config.Version, result)
|
||||
v.validateTheme(config.Theme, result)
|
||||
v.validateOutputFormat(config.OutputFormat, result)
|
||||
v.validateOutputDir(config.OutputDir, result)
|
||||
v.validateGitHubToken(config.GitHubToken, result)
|
||||
v.validatePermissions(config.Permissions, result)
|
||||
v.validateRunsOn(config.RunsOn, result)
|
||||
v.validateVariables(config.Variables, result)
|
||||
|
||||
// Set overall validity
|
||||
result.Valid = len(result.Errors) == 0
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// ValidateField validates a single field and provides immediate feedback.
|
||||
func (v *ConfigValidator) ValidateField(fieldName, value string) *ValidationResult {
|
||||
result := &ValidationResult{
|
||||
Valid: true,
|
||||
Errors: []ValidationError{},
|
||||
Warnings: []ValidationWarning{},
|
||||
Suggestions: []string{},
|
||||
}
|
||||
|
||||
switch fieldName {
|
||||
case "organization":
|
||||
v.validateOrganization(value, result)
|
||||
case "repository":
|
||||
v.validateRepository(value, result)
|
||||
case "version":
|
||||
v.validateVersion(value, result)
|
||||
case "theme":
|
||||
v.validateTheme(value, result)
|
||||
case "output_format":
|
||||
v.validateOutputFormat(value, result)
|
||||
case "output_dir":
|
||||
v.validateOutputDir(value, result)
|
||||
case "github_token":
|
||||
v.validateGitHubToken(value, result)
|
||||
default:
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: fieldName,
|
||||
Message: "Unknown field",
|
||||
Value: value,
|
||||
})
|
||||
}
|
||||
|
||||
result.Valid = len(result.Errors) == 0
|
||||
return result
|
||||
}
|
||||
|
||||
// validateOrganization validates the organization field.
|
||||
func (v *ConfigValidator) validateOrganization(org string, result *ValidationResult) {
|
||||
if org == "" {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "organization",
|
||||
Message: "Organization is empty - will use auto-detected value",
|
||||
Value: org,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// GitHub username/organization rules
|
||||
if !v.isValidGitHubName(org) {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: "organization",
|
||||
Message: "Invalid organization name format",
|
||||
Value: org,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
"Organization names can only contain alphanumeric characters and hyphens")
|
||||
}
|
||||
}
|
||||
|
||||
// validateRepository validates the repository field.
|
||||
func (v *ConfigValidator) validateRepository(repo string, result *ValidationResult) {
|
||||
if repo == "" {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "repository",
|
||||
Message: "Repository is empty - will use auto-detected value",
|
||||
Value: repo,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// GitHub repository name rules
|
||||
if !v.isValidGitHubName(repo) {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: "repository",
|
||||
Message: "Invalid repository name format",
|
||||
Value: repo,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
"Repository names can only contain alphanumeric characters, hyphens, and underscores")
|
||||
}
|
||||
}
|
||||
|
||||
// validateVersion validates the version field.
|
||||
func (v *ConfigValidator) validateVersion(version string, result *ValidationResult) {
|
||||
if version == "" {
|
||||
// Empty version is valid
|
||||
return
|
||||
}
|
||||
|
||||
// Check if it follows semantic versioning
|
||||
if !v.isValidSemanticVersion(version) {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "version",
|
||||
Message: "Version does not follow semantic versioning (x.y.z)",
|
||||
Value: version,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
"Consider using semantic versioning format (e.g., 1.0.0)")
|
||||
}
|
||||
}
|
||||
|
||||
// validateTheme validates the theme field.
|
||||
func (v *ConfigValidator) validateTheme(theme string, result *ValidationResult) {
|
||||
validThemes := []string{"default", "github", "gitlab", "minimal", "professional"}
|
||||
|
||||
found := false
|
||||
for _, validTheme := range validThemes {
|
||||
if theme == validTheme {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: "theme",
|
||||
Message: "Invalid theme",
|
||||
Value: theme,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
fmt.Sprintf("Valid themes: %s", strings.Join(validThemes, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
// validateOutputFormat validates the output format field.
|
||||
func (v *ConfigValidator) validateOutputFormat(format string, result *ValidationResult) {
|
||||
validFormats := []string{"md", "html", "json", "asciidoc"}
|
||||
|
||||
found := false
|
||||
for _, validFormat := range validFormats {
|
||||
if format == validFormat {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !found {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: "output_format",
|
||||
Message: "Invalid output format",
|
||||
Value: format,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
fmt.Sprintf("Valid formats: %s", strings.Join(validFormats, ", ")))
|
||||
}
|
||||
}
|
||||
|
||||
// validateOutputDir validates the output directory field.
|
||||
func (v *ConfigValidator) validateOutputDir(dir string, result *ValidationResult) {
|
||||
if dir == "" {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: "output_dir",
|
||||
Message: "Output directory cannot be empty",
|
||||
Value: dir,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Check if directory exists or can be created
|
||||
if !filepath.IsAbs(dir) {
|
||||
// Relative path - check if parent exists
|
||||
parent := filepath.Dir(dir)
|
||||
if parent != "." {
|
||||
if _, err := os.Stat(parent); os.IsNotExist(err) {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "output_dir",
|
||||
Message: "Parent directory does not exist",
|
||||
Value: dir,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
"Ensure the parent directory exists or will be created")
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Absolute path - check if it exists
|
||||
if _, err := os.Stat(dir); os.IsNotExist(err) {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "output_dir",
|
||||
Message: "Directory does not exist",
|
||||
Value: dir,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
"Directory will be created if it doesn't exist")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateGitHubToken validates the GitHub token field.
|
||||
func (v *ConfigValidator) validateGitHubToken(token string, result *ValidationResult) {
|
||||
if token == "" {
|
||||
// Empty token is valid (optional)
|
||||
return
|
||||
}
|
||||
|
||||
// Check token format
|
||||
if !v.isValidGitHubToken(token) {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "github_token",
|
||||
Message: "Token format looks unusual",
|
||||
Value: "[REDACTED]",
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
"GitHub tokens usually start with 'ghp_' or 'github_pat_'")
|
||||
}
|
||||
|
||||
// Security warning
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "github_token",
|
||||
Message: "Tokens should be stored securely in environment variables",
|
||||
Value: "[REDACTED]",
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
"Consider using GITHUB_TOKEN environment variable instead")
|
||||
}
|
||||
|
||||
// validatePermissions validates the permissions field.
|
||||
func (v *ConfigValidator) validatePermissions(permissions map[string]string, result *ValidationResult) {
|
||||
if len(permissions) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
validPermissions := map[string][]string{
|
||||
"actions": {"read", "write"},
|
||||
"checks": {"read", "write"},
|
||||
"contents": {"read", "write"},
|
||||
"deployments": {"read", "write"},
|
||||
"id-token": {"write"},
|
||||
"issues": {"read", "write"},
|
||||
"discussions": {"read", "write"},
|
||||
"packages": {"read", "write"},
|
||||
"pull-requests": {"read", "write"},
|
||||
"repository-projects": {"read", "write"},
|
||||
"security-events": {"read", "write"},
|
||||
"statuses": {"read", "write"},
|
||||
}
|
||||
|
||||
for permission, value := range permissions {
|
||||
// Check if permission is valid
|
||||
validValues, permissionExists := validPermissions[permission]
|
||||
if !permissionExists {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "permissions",
|
||||
Message: fmt.Sprintf("Unknown permission: %s", permission),
|
||||
Value: value,
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if value is valid
|
||||
validValue := false
|
||||
for _, validVal := range validValues {
|
||||
if value == validVal {
|
||||
validValue = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !validValue {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: "permissions",
|
||||
Message: fmt.Sprintf("Invalid value for permission %s", permission),
|
||||
Value: value,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
fmt.Sprintf("Valid values for %s: %s", permission, strings.Join(validValues, ", ")))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateRunsOn validates the runs-on field.
|
||||
func (v *ConfigValidator) validateRunsOn(runsOn []string, result *ValidationResult) {
|
||||
if len(runsOn) == 0 {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "runs_on",
|
||||
Message: "No runners specified",
|
||||
Value: "[]",
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
"Consider specifying at least one runner (e.g., ubuntu-latest)")
|
||||
return
|
||||
}
|
||||
|
||||
validRunners := []string{
|
||||
"ubuntu-latest", "ubuntu-22.04", "ubuntu-20.04",
|
||||
"windows-latest", "windows-2022", "windows-2019",
|
||||
"macos-latest", "macos-13", "macos-12", "macos-11",
|
||||
}
|
||||
|
||||
for _, runner := range runsOn {
|
||||
// Check if it's a GitHub-hosted runner
|
||||
isValid := false
|
||||
for _, validRunner := range validRunners {
|
||||
if runner == validRunner {
|
||||
isValid = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// If not a standard runner, it might be self-hosted
|
||||
if !isValid {
|
||||
if !strings.HasPrefix(runner, "self-hosted") {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "runs_on",
|
||||
Message: fmt.Sprintf("Unknown runner: %s", runner),
|
||||
Value: runner,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
"Ensure the runner is available in your GitHub organization")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateVariables validates custom variables.
|
||||
func (v *ConfigValidator) validateVariables(variables map[string]string, result *ValidationResult) {
|
||||
if len(variables) == 0 {
|
||||
return
|
||||
}
|
||||
|
||||
for key, value := range variables {
|
||||
// Check for reserved variable names
|
||||
reservedNames := []string{"GITHUB_TOKEN", "GITHUB_ACTOR", "GITHUB_REPOSITORY", "GITHUB_SHA"}
|
||||
for _, reserved := range reservedNames {
|
||||
if strings.EqualFold(key, reserved) {
|
||||
result.Warnings = append(result.Warnings, ValidationWarning{
|
||||
Field: "variables",
|
||||
Message: fmt.Sprintf("Variable name conflicts with GitHub environment variable: %s", key),
|
||||
Value: value,
|
||||
})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// Check for valid variable name format
|
||||
if !v.isValidVariableName(key) {
|
||||
result.Errors = append(result.Errors, ValidationError{
|
||||
Field: "variables",
|
||||
Message: fmt.Sprintf("Invalid variable name: %s", key),
|
||||
Value: value,
|
||||
})
|
||||
result.Suggestions = append(result.Suggestions,
|
||||
"Variable names should contain only letters, numbers, and underscores")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isValidGitHubName checks if a name follows GitHub naming rules.
|
||||
func (v *ConfigValidator) isValidGitHubName(name string) bool {
|
||||
if len(name) == 0 || len(name) > 39 {
|
||||
return false
|
||||
}
|
||||
|
||||
// GitHub names can contain alphanumeric characters and hyphens
|
||||
// Cannot start or end with hyphen
|
||||
matched, _ := regexp.MatchString(`^[a-zA-Z0-9]([a-zA-Z0-9\-_]*[a-zA-Z0-9])?$`, name)
|
||||
return matched
|
||||
}
|
||||
|
||||
// isValidSemanticVersion checks if a version follows semantic versioning.
|
||||
func (v *ConfigValidator) isValidSemanticVersion(version string) bool {
|
||||
// Basic semantic version pattern: x.y.z with optional pre-release and build metadata
|
||||
pattern := `^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)` +
|
||||
`(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?` +
|
||||
`(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`
|
||||
matched, _ := regexp.MatchString(pattern, version)
|
||||
return matched
|
||||
}
|
||||
|
||||
// isValidGitHubToken checks if a token follows GitHub token format.
|
||||
func (v *ConfigValidator) isValidGitHubToken(token string) bool {
|
||||
// GitHub personal access tokens start with ghp_ or github_pat_
|
||||
// Classic tokens are 40 characters after the prefix
|
||||
// Fine-grained tokens have different formats
|
||||
return strings.HasPrefix(token, "ghp_") ||
|
||||
strings.HasPrefix(token, "github_pat_") ||
|
||||
strings.HasPrefix(token, "gho_") ||
|
||||
strings.HasPrefix(token, "ghu_") ||
|
||||
strings.HasPrefix(token, "ghs_") ||
|
||||
strings.HasPrefix(token, "ghr_")
|
||||
}
|
||||
|
||||
// isValidVariableName checks if a variable name is valid.
|
||||
func (v *ConfigValidator) isValidVariableName(name string) bool {
|
||||
if len(name) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
// Variable names should start with letter or underscore
|
||||
// and contain only letters, numbers, and underscores
|
||||
matched, _ := regexp.MatchString(`^[a-zA-Z_][a-zA-Z0-9_]*$`, name)
|
||||
return matched
|
||||
}
|
||||
|
||||
// DisplayValidationResult displays validation results to the user.
|
||||
func (v *ConfigValidator) DisplayValidationResult(result *ValidationResult) {
|
||||
if result.Valid {
|
||||
v.output.Success("✅ Configuration is valid")
|
||||
} else {
|
||||
v.output.Error("❌ Configuration has errors")
|
||||
}
|
||||
|
||||
// Display errors
|
||||
for _, err := range result.Errors {
|
||||
v.output.Error(" • %s: %s (value: %s)", err.Field, err.Message, err.Value)
|
||||
}
|
||||
|
||||
// Display warnings
|
||||
for _, warning := range result.Warnings {
|
||||
v.output.Warning(" ⚠️ %s: %s", warning.Field, warning.Message)
|
||||
}
|
||||
|
||||
// Display suggestions
|
||||
if len(result.Suggestions) > 0 {
|
||||
v.output.Info("\nSuggestions:")
|
||||
for _, suggestion := range result.Suggestions {
|
||||
v.output.Printf(" 💡 %s", suggestion)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user