mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-02-08 18:47:19 +00:00
chore: even more linting, test fixes (#24)
* chore(lint): funcorder * chore(lint): yamlfmt, ignored broken test yaml files * chore(tests): tests do not output garbage, add coverage * chore(lint): fix editorconfig violations * chore(lint): move from eclint to editorconfig-checker * chore(lint): add pre-commit, run and fix * chore(ci): we use renovate to manage updates
This commit is contained in:
38
internal/cache/cache.go
vendored
38
internal/cache/cache.go
vendored
@@ -196,6 +196,25 @@ func (c *Cache) Close() error {
|
||||
return c.saveToDisk()
|
||||
}
|
||||
|
||||
// GetOrSet retrieves a value from cache or sets it if not found.
|
||||
func (c *Cache) GetOrSet(key string, getter func() (any, error)) (any, error) {
|
||||
// Try to get from cache first
|
||||
if value, exists := c.Get(key); exists {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// Not in cache, get from source
|
||||
value, err := getter()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store in cache
|
||||
_ = c.Set(key, value) // Log error but don't fail - we have the value
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// cleanupLoop runs periodically to remove expired entries.
|
||||
func (c *Cache) cleanupLoop() {
|
||||
for {
|
||||
@@ -289,22 +308,3 @@ func (c *Cache) estimateSize(value any) int64 {
|
||||
|
||||
return int64(len(jsonData))
|
||||
}
|
||||
|
||||
// GetOrSet retrieves a value from cache or sets it if not found.
|
||||
func (c *Cache) GetOrSet(key string, getter func() (any, error)) (any, error) {
|
||||
// Try to get from cache first
|
||||
if value, exists := c.Get(key); exists {
|
||||
return value, nil
|
||||
}
|
||||
|
||||
// Not in cache, get from source
|
||||
value, err := getter()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Store in cache
|
||||
_ = c.Set(key, value) // Log error but don't fail - we have the value
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
@@ -108,6 +108,77 @@ func (cl *ConfigurationLoader) LoadConfiguration(configFile, repoRoot, actionDir
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// LoadGlobalConfig loads only the global configuration.
|
||||
func (cl *ConfigurationLoader) LoadGlobalConfig(configFile string) (*AppConfig, error) {
|
||||
return cl.loadGlobalConfig(configFile)
|
||||
}
|
||||
|
||||
// ValidateConfiguration validates a configuration for consistency and required values.
|
||||
func (cl *ConfigurationLoader) ValidateConfiguration(config *AppConfig) error {
|
||||
if config == nil {
|
||||
return errors.New("configuration cannot be nil")
|
||||
}
|
||||
|
||||
// Validate output format
|
||||
validFormats := []string{"md", "html", "json", "asciidoc"}
|
||||
if !containsString(validFormats, config.OutputFormat) {
|
||||
return fmt.Errorf("invalid output format '%s', must be one of: %s",
|
||||
config.OutputFormat, strings.Join(validFormats, ", "))
|
||||
}
|
||||
|
||||
// Validate theme (if set)
|
||||
if config.Theme != "" {
|
||||
if err := cl.validateTheme(config.Theme); err != nil {
|
||||
return fmt.Errorf("invalid theme: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate output directory
|
||||
if config.OutputDir == "" {
|
||||
return errors.New("output directory cannot be empty")
|
||||
}
|
||||
|
||||
// Validate mutually exclusive flags
|
||||
if config.Verbose && config.Quiet {
|
||||
return errors.New("verbose and quiet flags are mutually exclusive")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// containsString checks if a slice contains a string.
|
||||
func containsString(slice []string, str string) bool {
|
||||
for _, s := range slice {
|
||||
if s == str {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetConfigurationSources returns the currently enabled configuration sources.
|
||||
func (cl *ConfigurationLoader) GetConfigurationSources() []ConfigurationSource {
|
||||
var sources []ConfigurationSource
|
||||
for source, enabled := range cl.sources {
|
||||
if enabled {
|
||||
sources = append(sources, source)
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
// EnableSource enables a specific configuration source.
|
||||
func (cl *ConfigurationLoader) EnableSource(source ConfigurationSource) {
|
||||
cl.sources[source] = true
|
||||
}
|
||||
|
||||
// DisableSource disables a specific configuration source.
|
||||
func (cl *ConfigurationLoader) DisableSource(source ConfigurationSource) {
|
||||
cl.sources[source] = false
|
||||
}
|
||||
|
||||
// loadDefaultsStep loads default configuration values.
|
||||
func (cl *ConfigurationLoader) loadDefaultsStep(config *AppConfig) {
|
||||
if cl.sources[SourceDefaults] {
|
||||
@@ -177,44 +248,6 @@ func (cl *ConfigurationLoader) loadEnvironmentStep(config *AppConfig) {
|
||||
}
|
||||
}
|
||||
|
||||
// LoadGlobalConfig loads only the global configuration.
|
||||
func (cl *ConfigurationLoader) LoadGlobalConfig(configFile string) (*AppConfig, error) {
|
||||
return cl.loadGlobalConfig(configFile)
|
||||
}
|
||||
|
||||
// ValidateConfiguration validates a configuration for consistency and required values.
|
||||
func (cl *ConfigurationLoader) ValidateConfiguration(config *AppConfig) error {
|
||||
if config == nil {
|
||||
return errors.New("configuration cannot be nil")
|
||||
}
|
||||
|
||||
// Validate output format
|
||||
validFormats := []string{"md", "html", "json", "asciidoc"}
|
||||
if !containsString(validFormats, config.OutputFormat) {
|
||||
return fmt.Errorf("invalid output format '%s', must be one of: %s",
|
||||
config.OutputFormat, strings.Join(validFormats, ", "))
|
||||
}
|
||||
|
||||
// Validate theme (if set)
|
||||
if config.Theme != "" {
|
||||
if err := cl.validateTheme(config.Theme); err != nil {
|
||||
return fmt.Errorf("invalid theme: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate output directory
|
||||
if config.OutputDir == "" {
|
||||
return errors.New("output directory cannot be empty")
|
||||
}
|
||||
|
||||
// Validate mutually exclusive flags
|
||||
if config.Verbose && config.Quiet {
|
||||
return errors.New("verbose and quiet flags are mutually exclusive")
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadGlobalConfig initializes and loads the global configuration using Viper.
|
||||
func (cl *ConfigurationLoader) loadGlobalConfig(configFile string) (*AppConfig, error) {
|
||||
v := viper.New()
|
||||
@@ -396,39 +429,6 @@ func (cl *ConfigurationLoader) validateTheme(theme string) error {
|
||||
theme, strings.Join(supportedThemes, ", "))
|
||||
}
|
||||
|
||||
// containsString checks if a slice contains a string.
|
||||
func containsString(slice []string, str string) bool {
|
||||
for _, s := range slice {
|
||||
if s == str {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// GetConfigurationSources returns the currently enabled configuration sources.
|
||||
func (cl *ConfigurationLoader) GetConfigurationSources() []ConfigurationSource {
|
||||
var sources []ConfigurationSource
|
||||
for source, enabled := range cl.sources {
|
||||
if enabled {
|
||||
sources = append(sources, source)
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
// EnableSource enables a specific configuration source.
|
||||
func (cl *ConfigurationLoader) EnableSource(source ConfigurationSource) {
|
||||
cl.sources[source] = true
|
||||
}
|
||||
|
||||
// DisableSource disables a specific configuration source.
|
||||
func (cl *ConfigurationLoader) DisableSource(source ConfigurationSource) {
|
||||
cl.sources[source] = false
|
||||
}
|
||||
|
||||
// String returns a string representation of a ConfigurationSource.
|
||||
func (s ConfigurationSource) String() string {
|
||||
switch s {
|
||||
|
||||
@@ -168,6 +168,85 @@ func (a *Analyzer) AnalyzeActionFileWithProgress(
|
||||
return a.processCompositeSteps(action.Runs.Steps, progressCallback)
|
||||
}
|
||||
|
||||
// CheckOutdated analyzes dependencies and finds those with newer versions available.
|
||||
func (a *Analyzer) CheckOutdated(deps []Dependency) ([]OutdatedDependency, error) {
|
||||
var outdated []OutdatedDependency
|
||||
|
||||
for _, dep := range deps {
|
||||
if dep.IsShellScript || dep.IsLocalAction {
|
||||
continue // Skip shell scripts and local actions
|
||||
}
|
||||
|
||||
owner, repo, currentVersion, _ := a.parseUsesStatement(dep.Uses)
|
||||
if owner == "" || repo == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
latestVersion, latestSHA, err := a.getLatestVersion(owner, repo)
|
||||
if err != nil {
|
||||
continue // Skip on error, don't fail the whole operation
|
||||
}
|
||||
|
||||
updateType := a.compareVersions(currentVersion, latestVersion)
|
||||
if updateType != updateTypeNone {
|
||||
outdated = append(outdated, OutdatedDependency{
|
||||
Current: dep,
|
||||
LatestVersion: latestVersion,
|
||||
LatestSHA: latestSHA,
|
||||
UpdateType: updateType,
|
||||
IsSecurityUpdate: updateType == updateTypeMajor, // Assume major updates might be security
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return outdated, nil
|
||||
}
|
||||
|
||||
// GeneratePinnedUpdate creates a pinned update for a dependency.
|
||||
func (a *Analyzer) GeneratePinnedUpdate(
|
||||
actionPath string,
|
||||
dep Dependency,
|
||||
latestVersion, latestSHA string,
|
||||
) (*PinnedUpdate, error) {
|
||||
if latestSHA == "" {
|
||||
return nil, fmt.Errorf("no commit SHA available for %s", dep.Uses)
|
||||
}
|
||||
|
||||
// Create the new pinned uses string: "owner/repo@sha # version"
|
||||
owner, repo, currentVersion, _ := a.parseUsesStatement(dep.Uses)
|
||||
newUses := fmt.Sprintf("%s/%s@%s # %s", owner, repo, latestSHA, latestVersion)
|
||||
|
||||
updateType := a.compareVersions(currentVersion, latestVersion)
|
||||
|
||||
return &PinnedUpdate{
|
||||
FilePath: actionPath,
|
||||
OldUses: dep.Uses,
|
||||
NewUses: newUses,
|
||||
CommitSHA: latestSHA,
|
||||
Version: latestVersion,
|
||||
UpdateType: updateType,
|
||||
LineNumber: 0, // Will be determined during file update
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ApplyPinnedUpdates applies pinned updates to action files.
|
||||
func (a *Analyzer) ApplyPinnedUpdates(updates []PinnedUpdate) error {
|
||||
// Group updates by file path
|
||||
updatesByFile := make(map[string][]PinnedUpdate)
|
||||
for _, update := range updates {
|
||||
updatesByFile[update.FilePath] = append(updatesByFile[update.FilePath], update)
|
||||
}
|
||||
|
||||
// Apply updates to each file
|
||||
for filePath, fileUpdates := range updatesByFile {
|
||||
if err := a.updateActionFile(filePath, fileUpdates); err != nil {
|
||||
return fmt.Errorf("failed to update %s: %w", filePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateAndCheckComposite validates action type and checks if it's composite.
|
||||
func (a *Analyzer) validateAndCheckComposite(
|
||||
action *ActionWithComposite,
|
||||
@@ -244,8 +323,6 @@ func (a *Analyzer) processStep(step CompositeStep, stepNumber int) *Dependency {
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseCompositeAction is implemented in parser.go
|
||||
|
||||
// analyzeActionDependency analyzes a single action dependency.
|
||||
func (a *Analyzer) analyzeActionDependency(step CompositeStep, _ int) (*Dependency, error) {
|
||||
// Parse the uses statement
|
||||
@@ -405,40 +482,6 @@ func (a *Analyzer) convertWithParams(with map[string]any) map[string]string {
|
||||
return params
|
||||
}
|
||||
|
||||
// CheckOutdated analyzes dependencies and finds those with newer versions available.
|
||||
func (a *Analyzer) CheckOutdated(deps []Dependency) ([]OutdatedDependency, error) {
|
||||
var outdated []OutdatedDependency
|
||||
|
||||
for _, dep := range deps {
|
||||
if dep.IsShellScript || dep.IsLocalAction {
|
||||
continue // Skip shell scripts and local actions
|
||||
}
|
||||
|
||||
owner, repo, currentVersion, _ := a.parseUsesStatement(dep.Uses)
|
||||
if owner == "" || repo == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
latestVersion, latestSHA, err := a.getLatestVersion(owner, repo)
|
||||
if err != nil {
|
||||
continue // Skip on error, don't fail the whole operation
|
||||
}
|
||||
|
||||
updateType := a.compareVersions(currentVersion, latestVersion)
|
||||
if updateType != updateTypeNone {
|
||||
outdated = append(outdated, OutdatedDependency{
|
||||
Current: dep,
|
||||
LatestVersion: latestVersion,
|
||||
LatestSHA: latestSHA,
|
||||
UpdateType: updateType,
|
||||
IsSecurityUpdate: updateType == updateTypeMajor, // Assume major updates might be security
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return outdated, nil
|
||||
}
|
||||
|
||||
// getLatestVersion fetches the latest release/tag for a repository.
|
||||
func (a *Analyzer) getLatestVersion(owner, repo string) (version, sha string, err error) {
|
||||
if a.GitHubClient == nil {
|
||||
@@ -584,51 +627,6 @@ func (a *Analyzer) determineUpdateType(currentParts, latestParts []string) strin
|
||||
return updateTypeNone
|
||||
}
|
||||
|
||||
// GeneratePinnedUpdate creates a pinned update for a dependency.
|
||||
func (a *Analyzer) GeneratePinnedUpdate(
|
||||
actionPath string,
|
||||
dep Dependency,
|
||||
latestVersion, latestSHA string,
|
||||
) (*PinnedUpdate, error) {
|
||||
if latestSHA == "" {
|
||||
return nil, fmt.Errorf("no commit SHA available for %s", dep.Uses)
|
||||
}
|
||||
|
||||
// Create the new pinned uses string: "owner/repo@sha # version"
|
||||
owner, repo, currentVersion, _ := a.parseUsesStatement(dep.Uses)
|
||||
newUses := fmt.Sprintf("%s/%s@%s # %s", owner, repo, latestSHA, latestVersion)
|
||||
|
||||
updateType := a.compareVersions(currentVersion, latestVersion)
|
||||
|
||||
return &PinnedUpdate{
|
||||
FilePath: actionPath,
|
||||
OldUses: dep.Uses,
|
||||
NewUses: newUses,
|
||||
CommitSHA: latestSHA,
|
||||
Version: latestVersion,
|
||||
UpdateType: updateType,
|
||||
LineNumber: 0, // Will be determined during file update
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ApplyPinnedUpdates applies pinned updates to action files.
|
||||
func (a *Analyzer) ApplyPinnedUpdates(updates []PinnedUpdate) error {
|
||||
// Group updates by file path
|
||||
updatesByFile := make(map[string][]PinnedUpdate)
|
||||
for _, update := range updates {
|
||||
updatesByFile[update.FilePath] = append(updatesByFile[update.FilePath], update)
|
||||
}
|
||||
|
||||
// Apply updates to each file
|
||||
for filePath, fileUpdates := range updatesByFile {
|
||||
if err := a.updateActionFile(filePath, fileUpdates); err != nil {
|
||||
return fmt.Errorf("failed to update %s: %w", filePath, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// updateActionFile applies updates to a single action file.
|
||||
func (a *Analyzer) updateActionFile(filePath string, updates []PinnedUpdate) error {
|
||||
// Read the file
|
||||
|
||||
@@ -34,9 +34,39 @@ type Generator struct {
|
||||
Progress ProgressManager
|
||||
}
|
||||
|
||||
// isUnitTestEnvironment detects if we're running unit tests (not integration tests).
|
||||
func isUnitTestEnvironment() bool {
|
||||
// Only enable for unit tests, not integration tests
|
||||
// Integration tests need real output to verify CLI behavior
|
||||
|
||||
// Check if we're in the internal package tests
|
||||
if strings.Contains(os.Args[0], "internal.test") ||
|
||||
strings.Contains(os.Args[0], "T/go-build") && strings.Contains(os.Args[0], "internal") {
|
||||
return true
|
||||
}
|
||||
|
||||
// Check for explicit unit test environment variable
|
||||
if os.Getenv("UNIT_TEST_MODE") != "" {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// NewGenerator creates a new generator instance with the provided configuration.
|
||||
// This constructor maintains backward compatibility by using concrete implementations.
|
||||
// In unit test environments, it automatically uses NullOutput to suppress output.
|
||||
func NewGenerator(config *AppConfig) *Generator {
|
||||
// Use null output in unit test environments to keep tests clean
|
||||
// Integration tests need real output to verify CLI behavior
|
||||
if isUnitTestEnvironment() {
|
||||
return NewGeneratorWithDependencies(
|
||||
config,
|
||||
NewNullOutput(),
|
||||
NewNullProgressManager(),
|
||||
)
|
||||
}
|
||||
|
||||
return NewGeneratorWithDependencies(
|
||||
config,
|
||||
NewColoredOutput(config.Quiet),
|
||||
@@ -115,76 +145,116 @@ func (g *Generator) GenerateFromFile(actionPath string) error {
|
||||
return g.generateByFormat(action, outputDir, actionPath)
|
||||
}
|
||||
|
||||
// parseAndValidateAction parses and validates an action.yml file.
|
||||
func (g *Generator) parseAndValidateAction(actionPath string) (*ActionYML, error) {
|
||||
action, err := ParseActionYML(actionPath)
|
||||
// DiscoverActionFiles finds action.yml and action.yaml files in the given directory
|
||||
// using the centralized parser function and adds verbose logging.
|
||||
func (g *Generator) DiscoverActionFiles(dir string, recursive bool) ([]string, error) {
|
||||
actionFiles, err := DiscoverActionFiles(dir, recursive)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse action file %s: %w", actionPath, err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
validationResult := ValidateActionYML(action)
|
||||
if len(validationResult.MissingFields) > 0 {
|
||||
// Check for critical validation errors that cannot be fixed with defaults
|
||||
for _, field := range validationResult.MissingFields {
|
||||
// All core required fields should cause validation failure
|
||||
if field == "name" || field == "description" || field == "runs" || field == "runs.using" {
|
||||
// Required fields missing - cannot be fixed with defaults, must fail
|
||||
return nil, fmt.Errorf(
|
||||
"action file %s has invalid configuration, missing required field(s): %v",
|
||||
actionPath,
|
||||
validationResult.MissingFields,
|
||||
)
|
||||
// Add verbose logging
|
||||
if g.Config.Verbose {
|
||||
for _, file := range actionFiles {
|
||||
if recursive {
|
||||
g.Output.Info("Discovered action file: %s", file)
|
||||
} else {
|
||||
g.Output.Info("Found action file: %s", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if g.Config.Verbose {
|
||||
g.Output.Warning("Missing fields in %s: %v", actionPath, validationResult.MissingFields)
|
||||
}
|
||||
FillMissing(action, g.Config.Defaults)
|
||||
if g.Config.Verbose {
|
||||
g.Output.Info("Applied default values for missing fields")
|
||||
return actionFiles, nil
|
||||
}
|
||||
|
||||
// DiscoverActionFilesWithValidation discovers action files with centralized error handling and validation.
|
||||
// This function consolidates the duplicated file discovery logic across the codebase.
|
||||
func (g *Generator) DiscoverActionFilesWithValidation(dir string, recursive bool, context string) ([]string, error) {
|
||||
// Discover action files
|
||||
actionFiles, err := g.DiscoverActionFiles(dir, recursive)
|
||||
if err != nil {
|
||||
g.Output.ErrorWithContext(
|
||||
errCodes.ErrCodeFileNotFound,
|
||||
"failed to discover action files for "+context,
|
||||
map[string]string{
|
||||
"directory": dir,
|
||||
"recursive": strconv.FormatBool(recursive),
|
||||
"context": context,
|
||||
ContextKeyError: err.Error(),
|
||||
},
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if any files were found
|
||||
if len(actionFiles) == 0 {
|
||||
contextMsg := "no GitHub Action files found for " + context
|
||||
g.Output.ErrorWithContext(
|
||||
errCodes.ErrCodeNoActionFiles,
|
||||
contextMsg,
|
||||
map[string]string{
|
||||
"directory": dir,
|
||||
"recursive": strconv.FormatBool(recursive),
|
||||
"context": context,
|
||||
"suggestion": "Please run this command in a directory containing GitHub Action files (action.yml or action.yaml)",
|
||||
},
|
||||
)
|
||||
|
||||
return nil, fmt.Errorf("no action files found in directory: %s", dir)
|
||||
}
|
||||
|
||||
return actionFiles, nil
|
||||
}
|
||||
|
||||
// ProcessBatch processes multiple action.yml files.
|
||||
func (g *Generator) ProcessBatch(paths []string) error {
|
||||
if len(paths) == 0 {
|
||||
return errors.New("no action files to process")
|
||||
}
|
||||
|
||||
bar := g.Progress.CreateProgressBarForFiles("Processing files", paths)
|
||||
errors, successCount := g.processFiles(paths, bar)
|
||||
g.Progress.FinishProgressBarWithNewline(bar)
|
||||
g.reportResults(successCount, errors)
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("encountered %d errors during batch processing", len(errors))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateFiles validates multiple action.yml files and reports results.
|
||||
func (g *Generator) ValidateFiles(paths []string) error {
|
||||
if len(paths) == 0 {
|
||||
return errors.New("no action files to validate")
|
||||
}
|
||||
|
||||
bar := g.Progress.CreateProgressBarForFiles("Validating files", paths)
|
||||
allResults, errors := g.validateFiles(paths, bar)
|
||||
g.Progress.FinishProgressBarWithNewline(bar)
|
||||
|
||||
if !g.Config.Quiet {
|
||||
g.reportValidationResults(allResults, errors)
|
||||
}
|
||||
|
||||
// Count validation failures (files with missing required fields)
|
||||
validationFailures := 0
|
||||
for _, result := range allResults {
|
||||
// Each result starts with "file: <path>" so check if there are actual missing fields beyond that
|
||||
if len(result.MissingFields) > 1 {
|
||||
validationFailures++
|
||||
}
|
||||
}
|
||||
|
||||
return action, nil
|
||||
}
|
||||
if len(errors) > 0 || validationFailures > 0 {
|
||||
totalFailures := len(errors) + validationFailures
|
||||
|
||||
// determineOutputDir calculates the output directory for generated files.
|
||||
func (g *Generator) determineOutputDir(actionPath string) string {
|
||||
if g.Config.OutputDir == "" || g.Config.OutputDir == "." {
|
||||
return filepath.Dir(actionPath)
|
||||
return fmt.Errorf("validation failed for %d files", totalFailures)
|
||||
}
|
||||
|
||||
return g.Config.OutputDir
|
||||
}
|
||||
|
||||
// resolveOutputPath resolves the final output path, considering custom filename.
|
||||
func (g *Generator) resolveOutputPath(outputDir, defaultFilename string) string {
|
||||
if g.Config.OutputFilename != "" {
|
||||
if filepath.IsAbs(g.Config.OutputFilename) {
|
||||
return g.Config.OutputFilename
|
||||
}
|
||||
|
||||
return filepath.Join(outputDir, g.Config.OutputFilename)
|
||||
}
|
||||
|
||||
return filepath.Join(outputDir, defaultFilename)
|
||||
}
|
||||
|
||||
// generateByFormat generates documentation in the specified format.
|
||||
func (g *Generator) generateByFormat(action *ActionYML, outputDir, actionPath string) error {
|
||||
switch g.Config.OutputFormat {
|
||||
case "md":
|
||||
return g.generateMarkdown(action, outputDir, actionPath)
|
||||
case OutputFormatHTML:
|
||||
return g.generateHTML(action, outputDir, actionPath)
|
||||
case OutputFormatJSON:
|
||||
return g.generateJSON(action, outputDir)
|
||||
case OutputFormatASCIIDoc:
|
||||
return g.generateASCIIDoc(action, outputDir, actionPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", g.Config.OutputFormat)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// generateMarkdown creates a README.md file using the template.
|
||||
@@ -311,86 +381,6 @@ func (g *Generator) generateASCIIDoc(action *ActionYML, outputDir, actionPath st
|
||||
return nil
|
||||
}
|
||||
|
||||
// DiscoverActionFiles finds action.yml and action.yaml files in the given directory
|
||||
// using the centralized parser function and adds verbose logging.
|
||||
func (g *Generator) DiscoverActionFiles(dir string, recursive bool) ([]string, error) {
|
||||
actionFiles, err := DiscoverActionFiles(dir, recursive)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Add verbose logging
|
||||
if g.Config.Verbose {
|
||||
for _, file := range actionFiles {
|
||||
if recursive {
|
||||
g.Output.Info("Discovered action file: %s", file)
|
||||
} else {
|
||||
g.Output.Info("Found action file: %s", file)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return actionFiles, nil
|
||||
}
|
||||
|
||||
// DiscoverActionFilesWithValidation discovers action files with centralized error handling and validation.
|
||||
// This function consolidates the duplicated file discovery logic across the codebase.
|
||||
func (g *Generator) DiscoverActionFilesWithValidation(dir string, recursive bool, context string) ([]string, error) {
|
||||
// Discover action files
|
||||
actionFiles, err := g.DiscoverActionFiles(dir, recursive)
|
||||
if err != nil {
|
||||
g.Output.ErrorWithContext(
|
||||
errCodes.ErrCodeFileNotFound,
|
||||
"failed to discover action files for "+context,
|
||||
map[string]string{
|
||||
"directory": dir,
|
||||
"recursive": strconv.FormatBool(recursive),
|
||||
"context": context,
|
||||
ContextKeyError: err.Error(),
|
||||
},
|
||||
)
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Check if any files were found
|
||||
if len(actionFiles) == 0 {
|
||||
contextMsg := "no GitHub Action files found for " + context
|
||||
g.Output.ErrorWithContext(
|
||||
errCodes.ErrCodeNoActionFiles,
|
||||
contextMsg,
|
||||
map[string]string{
|
||||
"directory": dir,
|
||||
"recursive": strconv.FormatBool(recursive),
|
||||
"context": context,
|
||||
"suggestion": "Please run this command in a directory containing GitHub Action files (action.yml or action.yaml)",
|
||||
},
|
||||
)
|
||||
|
||||
return nil, fmt.Errorf("no action files found in directory: %s", dir)
|
||||
}
|
||||
|
||||
return actionFiles, nil
|
||||
}
|
||||
|
||||
// ProcessBatch processes multiple action.yml files.
|
||||
func (g *Generator) ProcessBatch(paths []string) error {
|
||||
if len(paths) == 0 {
|
||||
return errors.New("no action files to process")
|
||||
}
|
||||
|
||||
bar := g.Progress.CreateProgressBarForFiles("Processing files", paths)
|
||||
errors, successCount := g.processFiles(paths, bar)
|
||||
g.Progress.FinishProgressBarWithNewline(bar)
|
||||
g.reportResults(successCount, errors)
|
||||
|
||||
if len(errors) > 0 {
|
||||
return fmt.Errorf("encountered %d errors during batch processing", len(errors))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// processFiles processes each file and tracks results.
|
||||
func (g *Generator) processFiles(paths []string, bar *progressbar.ProgressBar) ([]string, int) {
|
||||
var errors []string
|
||||
@@ -429,36 +419,76 @@ func (g *Generator) reportResults(successCount int, errors []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateFiles validates multiple action.yml files and reports results.
|
||||
func (g *Generator) ValidateFiles(paths []string) error {
|
||||
if len(paths) == 0 {
|
||||
return errors.New("no action files to validate")
|
||||
// parseAndValidateAction parses and validates an action.yml file.
|
||||
func (g *Generator) parseAndValidateAction(actionPath string) (*ActionYML, error) {
|
||||
action, err := ParseActionYML(actionPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse action file %s: %w", actionPath, err)
|
||||
}
|
||||
|
||||
bar := g.Progress.CreateProgressBarForFiles("Validating files", paths)
|
||||
allResults, errors := g.validateFiles(paths, bar)
|
||||
g.Progress.FinishProgressBarWithNewline(bar)
|
||||
validationResult := ValidateActionYML(action)
|
||||
if len(validationResult.MissingFields) > 0 {
|
||||
// Check for critical validation errors that cannot be fixed with defaults
|
||||
for _, field := range validationResult.MissingFields {
|
||||
// All core required fields should cause validation failure
|
||||
if field == "name" || field == "description" || field == "runs" || field == "runs.using" {
|
||||
// Required fields missing - cannot be fixed with defaults, must fail
|
||||
return nil, fmt.Errorf(
|
||||
"action file %s has invalid configuration, missing required field(s): %v",
|
||||
actionPath,
|
||||
validationResult.MissingFields,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if !g.Config.Quiet {
|
||||
g.reportValidationResults(allResults, errors)
|
||||
}
|
||||
|
||||
// Count validation failures (files with missing required fields)
|
||||
validationFailures := 0
|
||||
for _, result := range allResults {
|
||||
// Each result starts with "file: <path>" so check if there are actual missing fields beyond that
|
||||
if len(result.MissingFields) > 1 {
|
||||
validationFailures++
|
||||
if g.Config.Verbose {
|
||||
g.Output.Warning("Missing fields in %s: %v", actionPath, validationResult.MissingFields)
|
||||
}
|
||||
FillMissing(action, g.Config.Defaults)
|
||||
if g.Config.Verbose {
|
||||
g.Output.Info("Applied default values for missing fields")
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) > 0 || validationFailures > 0 {
|
||||
totalFailures := len(errors) + validationFailures
|
||||
return action, nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("validation failed for %d files", totalFailures)
|
||||
// determineOutputDir calculates the output directory for generated files.
|
||||
func (g *Generator) determineOutputDir(actionPath string) string {
|
||||
if g.Config.OutputDir == "" || g.Config.OutputDir == "." {
|
||||
return filepath.Dir(actionPath)
|
||||
}
|
||||
|
||||
return nil
|
||||
return g.Config.OutputDir
|
||||
}
|
||||
|
||||
// resolveOutputPath resolves the final output path, considering custom filename.
|
||||
func (g *Generator) resolveOutputPath(outputDir, defaultFilename string) string {
|
||||
if g.Config.OutputFilename != "" {
|
||||
if filepath.IsAbs(g.Config.OutputFilename) {
|
||||
return g.Config.OutputFilename
|
||||
}
|
||||
|
||||
return filepath.Join(outputDir, g.Config.OutputFilename)
|
||||
}
|
||||
|
||||
return filepath.Join(outputDir, defaultFilename)
|
||||
}
|
||||
|
||||
// generateByFormat generates documentation in the specified format.
|
||||
func (g *Generator) generateByFormat(action *ActionYML, outputDir, actionPath string) error {
|
||||
switch g.Config.OutputFormat {
|
||||
case "md":
|
||||
return g.generateMarkdown(action, outputDir, actionPath)
|
||||
case OutputFormatHTML:
|
||||
return g.generateHTML(action, outputDir, actionPath)
|
||||
case OutputFormatJSON:
|
||||
return g.generateJSON(action, outputDir)
|
||||
case OutputFormatASCIIDoc:
|
||||
return g.generateASCIIDoc(action, outputDir, actionPath)
|
||||
default:
|
||||
return fmt.Errorf("unsupported output format: %s", g.Config.OutputFormat)
|
||||
}
|
||||
}
|
||||
|
||||
// validateFiles processes each file for validation.
|
||||
|
||||
@@ -563,19 +563,18 @@ func TestGenerator_WithDifferentThemes(t *testing.T) {
|
||||
t.Parallel()
|
||||
themes := []string{"default", "github", "gitlab", "minimal", "professional"}
|
||||
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
// Set up test templates
|
||||
testutil.SetupTestTemplates(t, tmpDir)
|
||||
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
|
||||
for _, theme := range themes {
|
||||
t.Run("theme_"+theme, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
// Templates are now embedded, no working directory changes needed
|
||||
// Create separate temp directory for each theme test
|
||||
tmpDir, cleanup := testutil.TempDir(t)
|
||||
defer cleanup()
|
||||
|
||||
// Set up test templates for this theme test
|
||||
testutil.SetupTestTemplates(t, tmpDir)
|
||||
|
||||
actionPath := filepath.Join(tmpDir, "action.yml")
|
||||
testutil.WriteTestFile(t, actionPath, testutil.MustReadFixture("actions/javascript/simple.yml"))
|
||||
|
||||
config := &AppConfig{
|
||||
Theme: theme,
|
||||
@@ -596,11 +595,6 @@ func TestGenerator_WithDifferentThemes(t *testing.T) {
|
||||
if len(readmeFiles) == 0 {
|
||||
t.Errorf("no output file was created for theme %s", theme)
|
||||
}
|
||||
|
||||
// Clean up for next test
|
||||
for _, file := range readmeFiles {
|
||||
_ = os.Remove(file)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -78,35 +78,34 @@ func TestProgressBarManager_CreateProgressBarForFiles(t *testing.T) {
|
||||
|
||||
func TestProgressBarManager_FinishProgressBar(t *testing.T) {
|
||||
t.Parallel()
|
||||
pm := NewProgressBarManager(false)
|
||||
// Use quiet mode to avoid cluttering test output
|
||||
pm := NewProgressBarManager(true)
|
||||
|
||||
// Test with nil bar (should not panic)
|
||||
pm.FinishProgressBar(nil)
|
||||
|
||||
// Test with actual bar
|
||||
// Test with actual bar (will be nil in quiet mode)
|
||||
bar := pm.CreateProgressBar("Test", 5)
|
||||
if bar != nil {
|
||||
pm.FinishProgressBar(bar)
|
||||
}
|
||||
pm.FinishProgressBar(bar) // Should handle nil gracefully
|
||||
}
|
||||
|
||||
func TestProgressBarManager_UpdateProgressBar(t *testing.T) {
|
||||
t.Parallel()
|
||||
pm := NewProgressBarManager(false)
|
||||
// Use quiet mode to avoid cluttering test output
|
||||
pm := NewProgressBarManager(true)
|
||||
|
||||
// Test with nil bar (should not panic)
|
||||
pm.UpdateProgressBar(nil)
|
||||
|
||||
// Test with actual bar
|
||||
// Test with actual bar (will be nil in quiet mode)
|
||||
bar := pm.CreateProgressBar("Test", 5)
|
||||
if bar != nil {
|
||||
pm.UpdateProgressBar(bar)
|
||||
}
|
||||
pm.UpdateProgressBar(bar) // Should handle nil gracefully
|
||||
}
|
||||
|
||||
func TestProgressBarManager_ProcessWithProgressBar(t *testing.T) {
|
||||
t.Parallel()
|
||||
pm := NewProgressBarManager(false)
|
||||
// Use NullProgressManager to avoid cluttering test output
|
||||
pm := NewNullProgressManager()
|
||||
items := []string{"item1", "item2", "item3"}
|
||||
|
||||
processedItems := make([]string, 0)
|
||||
|
||||
120
internal/testoutput.go
Normal file
120
internal/testoutput.go
Normal file
@@ -0,0 +1,120 @@
|
||||
package internal
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/schollz/progressbar/v3"
|
||||
|
||||
"github.com/ivuorinen/gh-action-readme/internal/errors"
|
||||
)
|
||||
|
||||
// NullOutput is a no-op implementation of CompleteOutput for testing.
|
||||
// All methods are no-ops to prevent cluttering test output.
|
||||
type NullOutput struct{}
|
||||
|
||||
// Compile-time interface checks.
|
||||
var (
|
||||
_ MessageLogger = (*NullOutput)(nil)
|
||||
_ ErrorReporter = (*NullOutput)(nil)
|
||||
_ ErrorFormatter = (*NullOutput)(nil)
|
||||
_ ProgressReporter = (*NullOutput)(nil)
|
||||
_ OutputConfig = (*NullOutput)(nil)
|
||||
_ CompleteOutput = (*NullOutput)(nil)
|
||||
)
|
||||
|
||||
// NewNullOutput creates a new null output instance for testing.
|
||||
func NewNullOutput() *NullOutput {
|
||||
return &NullOutput{}
|
||||
}
|
||||
|
||||
// IsQuiet returns true as null output is always quiet.
|
||||
func (no *NullOutput) IsQuiet() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// Success is a no-op.
|
||||
func (no *NullOutput) Success(_ string, _ ...any) {}
|
||||
|
||||
// Error is a no-op.
|
||||
func (no *NullOutput) Error(_ string, _ ...any) {}
|
||||
|
||||
// Warning is a no-op.
|
||||
func (no *NullOutput) Warning(_ string, _ ...any) {}
|
||||
|
||||
// Info is a no-op.
|
||||
func (no *NullOutput) Info(_ string, _ ...any) {}
|
||||
|
||||
// Progress is a no-op.
|
||||
func (no *NullOutput) Progress(_ string, _ ...any) {}
|
||||
|
||||
// Bold is a no-op.
|
||||
func (no *NullOutput) Bold(_ string, _ ...any) {}
|
||||
|
||||
// Printf is a no-op.
|
||||
func (no *NullOutput) Printf(_ string, _ ...any) {}
|
||||
|
||||
// Fprintf is a no-op.
|
||||
func (no *NullOutput) Fprintf(_ *os.File, _ string, _ ...any) {}
|
||||
|
||||
// ErrorWithSuggestions is a no-op.
|
||||
func (no *NullOutput) ErrorWithSuggestions(_ *errors.ContextualError) {}
|
||||
|
||||
// ErrorWithContext is a no-op.
|
||||
func (no *NullOutput) ErrorWithContext(
|
||||
_ errors.ErrorCode,
|
||||
_ string,
|
||||
_ map[string]string,
|
||||
) {
|
||||
}
|
||||
|
||||
// ErrorWithSimpleFix is a no-op.
|
||||
func (no *NullOutput) ErrorWithSimpleFix(_, _ string) {}
|
||||
|
||||
// FormatContextualError returns empty string.
|
||||
func (no *NullOutput) FormatContextualError(_ *errors.ContextualError) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// NullProgressManager is a no-op implementation of ProgressManager for testing.
|
||||
type NullProgressManager struct{}
|
||||
|
||||
// Compile-time interface check.
|
||||
var _ ProgressManager = (*NullProgressManager)(nil)
|
||||
|
||||
// NewNullProgressManager creates a new null progress manager for testing.
|
||||
func NewNullProgressManager() *NullProgressManager {
|
||||
return &NullProgressManager{}
|
||||
}
|
||||
|
||||
// CreateProgressBar returns nil to suppress progress bars.
|
||||
func (npm *NullProgressManager) CreateProgressBar(_ string, _ int) *progressbar.ProgressBar {
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateProgressBarForFiles returns nil to suppress progress bars.
|
||||
func (npm *NullProgressManager) CreateProgressBarForFiles(
|
||||
_ string,
|
||||
_ []string,
|
||||
) *progressbar.ProgressBar {
|
||||
return nil
|
||||
}
|
||||
|
||||
// FinishProgressBar is a no-op.
|
||||
func (npm *NullProgressManager) FinishProgressBar(_ *progressbar.ProgressBar) {}
|
||||
|
||||
// FinishProgressBarWithNewline is a no-op.
|
||||
func (npm *NullProgressManager) FinishProgressBarWithNewline(_ *progressbar.ProgressBar) {}
|
||||
|
||||
// UpdateProgressBar is a no-op.
|
||||
func (npm *NullProgressManager) UpdateProgressBar(_ *progressbar.ProgressBar) {}
|
||||
|
||||
// ProcessWithProgressBar executes the function for each item without progress display.
|
||||
func (npm *NullProgressManager) ProcessWithProgressBar(
|
||||
_ string,
|
||||
items []string,
|
||||
processFunc func(item string, bar *progressbar.ProgressBar),
|
||||
) {
|
||||
for _, item := range items {
|
||||
processFunc(item, nil)
|
||||
}
|
||||
}
|
||||
@@ -55,6 +55,32 @@ func (e *ConfigExporter) ExportConfig(config *internal.AppConfig, format ExportF
|
||||
}
|
||||
}
|
||||
|
||||
// GetSupportedFormats returns the list of supported export formats.
|
||||
func (e *ConfigExporter) GetSupportedFormats() []ExportFormat {
|
||||
return []ExportFormat{FormatYAML, FormatJSON, FormatTOML}
|
||||
}
|
||||
|
||||
// GetDefaultOutputPath returns the default output path for a given format.
|
||||
func (e *ConfigExporter) GetDefaultOutputPath(format ExportFormat) (string, error) {
|
||||
configPath, err := internal.GetConfigPath()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get config directory: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(configPath)
|
||||
|
||||
switch format {
|
||||
case FormatYAML:
|
||||
return filepath.Join(dir, "config.yaml"), nil
|
||||
case FormatJSON:
|
||||
return filepath.Join(dir, "config.json"), nil
|
||||
case FormatTOML:
|
||||
return filepath.Join(dir, "config.toml"), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported format: %s", format)
|
||||
}
|
||||
}
|
||||
|
||||
// exportYAML exports configuration as YAML.
|
||||
func (e *ConfigExporter) exportYAML(config *internal.AppConfig, outputPath string) error {
|
||||
// Create a clean config without sensitive data for export
|
||||
@@ -260,29 +286,3 @@ func (e *ConfigExporter) writeVariablesSection(file *os.File, config *internal.A
|
||||
_, _ = fmt.Fprintf(file, "%s = %q\n", key, value)
|
||||
}
|
||||
}
|
||||
|
||||
// GetSupportedFormats returns the list of supported export formats.
|
||||
func (e *ConfigExporter) GetSupportedFormats() []ExportFormat {
|
||||
return []ExportFormat{FormatYAML, FormatJSON, FormatTOML}
|
||||
}
|
||||
|
||||
// GetDefaultOutputPath returns the default output path for a given format.
|
||||
func (e *ConfigExporter) GetDefaultOutputPath(format ExportFormat) (string, error) {
|
||||
configPath, err := internal.GetConfigPath()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to get config directory: %w", err)
|
||||
}
|
||||
|
||||
dir := filepath.Dir(configPath)
|
||||
|
||||
switch format {
|
||||
case FormatYAML:
|
||||
return filepath.Join(dir, "config.yaml"), nil
|
||||
case FormatJSON:
|
||||
return filepath.Join(dir, "config.json"), nil
|
||||
case FormatTOML:
|
||||
return filepath.Join(dir, "config.toml"), nil
|
||||
default:
|
||||
return "", fmt.Errorf("unsupported format: %s", format)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -109,6 +109,33 @@ func (v *ConfigValidator) ValidateField(fieldName, value string) *ValidationResu
|
||||
return result
|
||||
}
|
||||
|
||||
// DisplayValidationResult displays validation results to the user.
|
||||
func (v *ConfigValidator) DisplayValidationResult(result *ValidationResult) {
|
||||
if result.Valid {
|
||||
v.output.Success("✅ Configuration is valid")
|
||||
} else {
|
||||
v.output.Error("❌ Configuration has errors")
|
||||
}
|
||||
|
||||
// Display errors
|
||||
for _, err := range result.Errors {
|
||||
v.output.Error(" • %s: %s (value: %s)", err.Field, err.Message, err.Value)
|
||||
}
|
||||
|
||||
// Display warnings
|
||||
for _, warning := range result.Warnings {
|
||||
v.output.Warning(" ⚠️ %s: %s", warning.Field, warning.Message)
|
||||
}
|
||||
|
||||
// Display suggestions
|
||||
if len(result.Suggestions) > 0 {
|
||||
v.output.Info("\nSuggestions:")
|
||||
for _, suggestion := range result.Suggestions {
|
||||
v.output.Printf(" 💡 %s", suggestion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validateOrganization validates the organization field.
|
||||
func (v *ConfigValidator) validateOrganization(org string, result *ValidationResult) {
|
||||
if org == "" {
|
||||
@@ -478,30 +505,3 @@ func (v *ConfigValidator) isValidVariableName(name string) bool {
|
||||
|
||||
return matched
|
||||
}
|
||||
|
||||
// DisplayValidationResult displays validation results to the user.
|
||||
func (v *ConfigValidator) DisplayValidationResult(result *ValidationResult) {
|
||||
if result.Valid {
|
||||
v.output.Success("✅ Configuration is valid")
|
||||
} else {
|
||||
v.output.Error("❌ Configuration has errors")
|
||||
}
|
||||
|
||||
// Display errors
|
||||
for _, err := range result.Errors {
|
||||
v.output.Error(" • %s: %s (value: %s)", err.Field, err.Message, err.Value)
|
||||
}
|
||||
|
||||
// Display warnings
|
||||
for _, warning := range result.Warnings {
|
||||
v.output.Warning(" ⚠️ %s: %s", warning.Field, warning.Message)
|
||||
}
|
||||
|
||||
// Display suggestions
|
||||
if len(result.Suggestions) > 0 {
|
||||
v.output.Info("\nSuggestions:")
|
||||
for _, suggestion := range result.Suggestions {
|
||||
v.output.Printf(" 💡 %s", suggestion)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user