Files
gh-action-readme/internal/generator.go
Ismo Vuorinen 4f12c4d3dd feat(lint): add many linters, make all the tests run fast! (#23)
* chore(lint): added nlreturn, run linting

* chore(lint): replace some fmt.Sprintf calls

* chore(lint): replace fmt.Sprintf with strconv

* chore(lint): add goconst, use http lib for status codes, and methods

* chore(lint): use errors lib, errCodes from internal/errors

* chore(lint): dupl, thelper and usetesting

* chore(lint): fmt.Errorf %v to %w, more linters

* chore(lint): paralleltest, where possible

* perf(test): optimize test performance by 78%

- Implement shared binary building with package-level cache to eliminate redundant builds
- Add strategic parallelization to 15+ tests while preserving environment variable isolation
- Implement thread-safe fixture caching with RWMutex to reduce I/O operations
- Remove unnecessary working directory changes by leveraging embedded templates
- Add embedded template system with go:embed directive for reliable template resolution
- Fix linting issues: rename sharedBinaryError to errSharedBinary, add nolint directive

Performance improvements:
- Total test execution time: 12+ seconds → 2.7 seconds (78% faster)
- Binary build overhead: 14+ separate builds → 1 shared build (93% reduction)
- Parallel execution: Limited → 15+ concurrent tests (60-70% better CPU usage)
- I/O operations: 66+ fixture reads → cached with sync.RWMutex (50% reduction)

All tests maintain 100% success rate and coverage while running nearly 4x faster.
2025-08-06 15:28:09 +03:00

585 lines
17 KiB
Go

// Package internal contains the core generator functionality.
package internal
import (
"errors"
"fmt"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/google/go-github/v57/github"
"github.com/schollz/progressbar/v3"
"github.com/ivuorinen/gh-action-readme/internal/cache"
"github.com/ivuorinen/gh-action-readme/internal/dependencies"
errCodes "github.com/ivuorinen/gh-action-readme/internal/errors"
"github.com/ivuorinen/gh-action-readme/internal/git"
)
// Output format constants.
const (
OutputFormatHTML = "html"
OutputFormatMD = "md"
OutputFormatJSON = "json"
OutputFormatASCIIDoc = "asciidoc"
)
// Generator orchestrates the documentation generation process.
// It uses focused interfaces to reduce coupling and improve testability.
type Generator struct {
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: output,
Progress: progress,
}
}
// CreateDependencyAnalyzer creates a dependency analyzer with GitHub client and cache.
func (g *Generator) CreateDependencyAnalyzer() (*dependencies.Analyzer, error) {
// Get git info
repoRoot, err := git.FindRepositoryRoot(".")
if err != nil {
return nil, fmt.Errorf("failed to find repository root: %w", err)
}
gitInfo, err := git.DetectRepository(repoRoot)
if err != nil {
return nil, fmt.Errorf("failed to detect repository info: %w", err)
}
// Create GitHub client if token is available
var githubClient *github.Client
if g.Config.GitHubToken != "" {
clientWrapper, err := NewGitHubClient(g.Config.GitHubToken)
if err != nil {
return nil, fmt.Errorf("failed to create GitHub client: %w", err)
}
githubClient = clientWrapper.Client
}
// Create cache
depCache, err := cache.NewCache(cache.DefaultConfig())
if err != nil {
// Continue without cache
depCache = nil
}
// Create cache adapter
var cacheAdapter dependencies.DependencyCache
if depCache != nil {
cacheAdapter = dependencies.NewCacheAdapter(depCache)
} else {
cacheAdapter = dependencies.NewNoOpCache()
}
return dependencies.NewAnalyzer(githubClient, *gitInfo, cacheAdapter), nil
}
// GenerateFromFile processes a single action.yml file and generates documentation.
func (g *Generator) GenerateFromFile(actionPath string) error {
if g.Config.Verbose {
g.Output.Progress("Processing file: %s", actionPath)
}
action, err := g.parseAndValidateAction(actionPath)
if err != nil {
return err
}
outputDir := g.determineOutputDir(actionPath)
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)
if err != nil {
return nil, fmt.Errorf("failed to parse action file %s: %w", actionPath, 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,
)
}
}
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 action, nil
}
// 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 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)
}
}
// generateMarkdown creates a README.md file using the template.
func (g *Generator) generateMarkdown(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: templatePath,
Format: "md",
}
// 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 markdown template: %w", err)
}
outputPath := g.resolveOutputPath(outputDir, "README.md")
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)
}
g.Output.Success("Generated README.md: %s", outputPath)
return nil
}
// generateHTML creates an HTML file using the template and optional header/footer.
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: templatePath,
HeaderPath: g.Config.Header,
FooterPath: g.Config.Footer,
Format: "html",
}
// 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)
}
// Use HTMLWriter for consistent HTML output
writer := &HTMLWriter{
Header: "", // Header/footer are handled by template options
Footer: "",
}
defaultFilename := action.Name + ".html"
outputPath := g.resolveOutputPath(outputDir, defaultFilename)
if err := writer.Write(content, outputPath); err != nil {
return fmt.Errorf("failed to write HTML to %s: %w", outputPath, err)
}
g.Output.Success("Generated HTML: %s", outputPath)
return nil
}
// generateJSON creates a JSON file with structured documentation data.
func (g *Generator) generateJSON(action *ActionYML, outputDir string) error {
writer := NewJSONWriter(g.Config)
outputPath := g.resolveOutputPath(outputDir, "action-docs.json")
if err := writer.Write(action, outputPath); err != nil {
return fmt.Errorf("failed to write JSON to %s: %w", outputPath, err)
}
g.Output.Success("Generated JSON: %s", outputPath)
return nil
}
// generateASCIIDoc creates an AsciiDoc file using the template.
func (g *Generator) generateASCIIDoc(action *ActionYML, outputDir, actionPath string) error {
// Use AsciiDoc template
templatePath := resolveTemplatePath("templates/themes/asciidoc/readme.adoc")
opts := TemplateOptions{
TemplatePath: templatePath,
Format: "asciidoc",
}
// 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 := g.resolveOutputPath(outputDir, "README.adoc")
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)
}
g.Output.Success("Generated AsciiDoc: %s", outputPath)
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
successCount := 0
for _, path := range paths {
if err := g.GenerateFromFile(path); err != nil {
errorMsg := fmt.Sprintf("failed to process %s: %v", path, err)
errors = append(errors, errorMsg)
if g.Config.Verbose {
g.Output.Error("%s", errorMsg)
}
} else {
successCount++
}
g.Progress.UpdateProgressBar(bar)
}
return errors, successCount
}
// reportResults displays processing summary.
func (g *Generator) reportResults(successCount int, errors []string) {
if g.Config.Quiet {
return
}
g.Output.Bold("\nProcessing complete: %d successful, %d failed", successCount, len(errors))
if len(errors) > 0 && g.Config.Verbose {
g.Output.Error("\nErrors encountered:")
for _, errMsg := range errors {
g.Output.Printf(" - %s\n", errMsg)
}
}
}
// 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++
}
}
if len(errors) > 0 || validationFailures > 0 {
totalFailures := len(errors) + validationFailures
return fmt.Errorf("validation failed for %d files", totalFailures)
}
return nil
}
// validateFiles processes each file for validation.
func (g *Generator) validateFiles(paths []string, bar *progressbar.ProgressBar) ([]ValidationResult, []string) {
allResults := make([]ValidationResult, 0, len(paths))
var errors []string
for _, path := range paths {
if g.Config.Verbose && bar == nil {
g.Output.Progress("Validating: %s", path)
}
action, err := ParseActionYML(path)
if err != nil {
errorMsg := fmt.Sprintf("failed to parse %s: %v", path, err)
errors = append(errors, errorMsg)
continue
}
result := ValidateActionYML(action)
result.MissingFields = append([]string{"file: " + path}, result.MissingFields...)
allResults = append(allResults, result)
g.Progress.UpdateProgressBar(bar)
}
return allResults, errors
}
// reportValidationResults provides a summary of validation results.
func (g *Generator) reportValidationResults(results []ValidationResult, errors []string) {
totalFiles := len(results) + len(errors)
validFiles, totalIssues := g.countValidationStats(results)
g.showValidationSummary(totalFiles, validFiles, totalIssues, len(results), len(errors))
g.showDetailedIssues(results, totalIssues)
g.showParseErrors(errors)
}
// countValidationStats counts valid files and total issues from results.
func (g *Generator) countValidationStats(results []ValidationResult) (validFiles, totalIssues int) {
for _, result := range results {
if len(result.MissingFields) == 1 { // Only contains file path
validFiles++
} else {
totalIssues += len(result.MissingFields) - 1 // Subtract file path entry
}
}
return validFiles, totalIssues
}
// showValidationSummary displays the summary statistics.
func (g *Generator) showValidationSummary(totalFiles, validFiles, totalIssues, resultCount, errorCount int) {
g.Output.Bold("\nValidation Summary for %d files:", totalFiles)
g.Output.Printf("=" + strings.Repeat("=", 35) + "\n")
g.Output.Success("Valid files: %d", validFiles)
if resultCount-validFiles > 0 {
g.Output.Warning("Files with issues: %d", resultCount-validFiles)
}
if errorCount > 0 {
g.Output.Error("Parse errors: %d", errorCount)
}
if totalIssues > 0 {
g.Output.Info("Total validation issues: %d", totalIssues)
}
}
// showDetailedIssues displays detailed validation issues and suggestions.
func (g *Generator) showDetailedIssues(results []ValidationResult, totalIssues int) {
if totalIssues == 0 && !g.Config.Verbose {
return
}
g.Output.Bold("\nDetailed Issues & Suggestions:")
g.Output.Printf("-" + strings.Repeat("-", 35) + "\n")
for _, result := range results {
if len(result.MissingFields) > 1 || len(result.Warnings) > 0 {
g.showFileIssues(result)
}
}
}
// showFileIssues displays issues for a specific file.
func (g *Generator) showFileIssues(result ValidationResult) {
filename := result.MissingFields[0][6:] // Remove "file: " prefix
g.Output.Info("📁 File: %s", filename)
// Show missing fields
for _, field := range result.MissingFields[1:] {
g.Output.Error(" ❌ Missing required field: %s", field)
}
// Show warnings
for _, warning := range result.Warnings {
g.Output.Warning(" ⚠️ Missing recommended field: %s", warning)
}
// Show suggestions
if len(result.Suggestions) > 0 {
g.Output.Info(" 💡 Suggestions:")
for _, suggestion := range result.Suggestions {
g.Output.Printf(" • %s\n", suggestion)
}
}
g.Output.Printf("\n")
}
// showParseErrors displays parse errors if any exist.
func (g *Generator) showParseErrors(errors []string) {
if len(errors) == 0 {
return
}
g.Output.Bold("\nParse Errors:")
g.Output.Printf("-" + strings.Repeat("-", 15) + "\n")
for _, errMsg := range errors {
g.Output.Error(" - %s", errMsg)
}
}