Files
gh-action-readme/internal/wizard/detector.go
Ismo Vuorinen 7f80105ff5 feat: go 1.25.5, dependency updates, renamed internal/errors (#129)
* feat: rename internal/errors to internal/apperrors

* fix(tests): clear env values before using in tests

* feat: rename internal/errors to internal/apperrors

* chore(deps): update go and all dependencies

* chore: remove renovate from pre-commit, formatting

* chore: sonarcloud fixes

* feat: consolidate constants to appconstants/constants.go

* chore: sonarcloud fixes

* feat: simplification, deduplication, test utils

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: sonarcloud fixes

* chore: clean up

* fix: config discovery, const deduplication

* chore: fixes
2026-01-01 23:17:29 +02:00

477 lines
14 KiB
Go

// Package wizard provides project setting detection functionality.
package wizard
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/goccy/go-yaml"
"github.com/ivuorinen/gh-action-readme/appconstants"
"github.com/ivuorinen/gh-action-readme/internal"
"github.com/ivuorinen/gh-action-readme/internal/git"
"github.com/ivuorinen/gh-action-readme/internal/helpers"
)
// ProjectDetector handles auto-detection of project settings.
type ProjectDetector struct {
output *internal.ColoredOutput
currentDir string
repoRoot string
}
// NewProjectDetector creates a new project detector.
func NewProjectDetector(output *internal.ColoredOutput) (*ProjectDetector, error) {
currentDir, err := helpers.GetCurrentDir()
if err != nil {
return nil, fmt.Errorf(appconstants.ErrFailedToGetCurrentDir, err)
}
return &ProjectDetector{
output: output,
currentDir: currentDir,
repoRoot: helpers.FindGitRepoRoot(currentDir),
}, nil
}
// DetectedSettings contains auto-detected project settings.
type DetectedSettings struct {
Organization string
Repository string
Version string
ActionFiles []string
IsGitHubAction bool
HasDockerfile bool
HasCompositeAction bool
SuggestedTheme string
SuggestedRunsOn []string
SuggestedPermissions map[string]string
ProjectType string
Language string
Framework string
}
// DetectProjectSettings auto-detects project settings from the current environment.
func (d *ProjectDetector) DetectProjectSettings() (*DetectedSettings, error) {
settings := &DetectedSettings{
SuggestedPermissions: make(map[string]string),
SuggestedRunsOn: []string{"ubuntu-latest"},
}
// Detect repository information
if err := d.detectRepositoryInfo(settings); err != nil {
d.output.Warning("Could not detect repository info: %v", err)
}
// Detect action files and project type
if err := d.detectActionFiles(settings); err != nil {
d.output.Warning("Could not detect action files: %v", err)
}
// Detect project characteristics
d.detectProjectCharacteristics(settings)
// Suggest configuration based on detection
d.suggestConfiguration(settings)
return settings, nil
}
// detectRepositoryInfo detects repository information from git.
func (d *ProjectDetector) detectRepositoryInfo(settings *DetectedSettings) error {
if d.repoRoot == "" {
return errors.New("not in a git repository")
}
repoInfo, err := git.DetectRepository(d.repoRoot)
if err != nil {
return fmt.Errorf("failed to detect repository: %w", err)
}
settings.Organization = repoInfo.Organization
settings.Repository = repoInfo.Repository
// Try to detect version from various sources
settings.Version = d.detectVersion()
d.output.Success("Detected repository: %s/%s", settings.Organization, settings.Repository)
return nil
}
// detectActionFiles finds and analyzes action files.
func (d *ProjectDetector) detectActionFiles(settings *DetectedSettings) error {
// Look for action files in current directory and subdirectories
actionFiles, err := d.findActionFiles(d.currentDir, true)
if err != nil {
return fmt.Errorf("failed to find action files: %w", err)
}
settings.ActionFiles = actionFiles
settings.IsGitHubAction = len(actionFiles) > 0
if len(actionFiles) > 0 {
d.output.Success("Found %d action file(s)", len(actionFiles))
// Analyze action files to determine project characteristics
for _, actionFile := range actionFiles {
if err := d.analyzeActionFile(actionFile, settings); err != nil {
d.output.Warning("Could not analyze %s: %v", actionFile, err)
}
}
}
return nil
}
// detectProjectCharacteristics detects project type, language, and framework.
func (d *ProjectDetector) detectProjectCharacteristics(settings *DetectedSettings) {
// Check for common files and patterns
characteristics := d.analyzeProjectFiles()
settings.ProjectType = characteristics["type"]
settings.Language = characteristics["language"]
settings.Framework = characteristics["framework"]
// Check for Dockerfile
dockerfilePath := filepath.Join(d.currentDir, "Dockerfile")
if _, err := os.Stat(dockerfilePath); err == nil {
settings.HasDockerfile = true
d.output.Success("Detected Dockerfile")
}
}
// detectVersion attempts to detect project version from various sources.
func (d *ProjectDetector) detectVersion() string {
// Check package.json
if version := d.detectVersionFromPackageJSON(); version != "" {
return version
}
// Check git tags
if version := d.detectVersionFromGitTags(); version != "" {
return version
}
// Check version files
if version := d.detectVersionFromFiles(); version != "" {
return version
}
return ""
}
// detectVersionFromPackageJSON detects version from package.json.
func (d *ProjectDetector) detectVersionFromPackageJSON() string {
packageJSONPath := filepath.Join(d.currentDir, appconstants.PackageJSON)
data, err := os.ReadFile(packageJSONPath) // #nosec G304 -- path is constructed from current directory
if err != nil {
return ""
}
var packageJSON struct {
Version string `json:"version"`
}
if err := json.Unmarshal(data, &packageJSON); err != nil {
return ""
}
return packageJSON.Version
}
// detectVersionFromGitTags detects version from git tags.
func (d *ProjectDetector) detectVersionFromGitTags() string {
if d.repoRoot == "" {
return ""
}
// This is a simplified version - in a full implementation,
// you would use git commands to get the latest tag
return ""
}
// detectVersionFromFiles detects version from common version files.
func (d *ProjectDetector) detectVersionFromFiles() string {
versionFiles := []string{"VERSION", "version.txt", ".version"}
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 != "" {
return version
}
}
}
return ""
}
// findActionFiles discovers action files recursively.
func (d *ProjectDetector) findActionFiles(dir string, recursive bool) ([]string, error) {
if recursive {
return d.findActionFilesRecursive(dir)
}
return d.findActionFilesInDirectory(dir)
}
// findActionFilesRecursive discovers action files recursively using filepath.Walk.
func (d *ProjectDetector) findActionFilesRecursive(dir string) ([]string, error) {
var actionFiles []string
err := filepath.Walk(dir, func(path string, info os.FileInfo, err error) error {
if err != nil {
return filepath.SkipDir // Skip errors by skipping this directory
}
if info.IsDir() {
return d.handleDirectory(info)
}
if d.isActionFile(info.Name()) {
actionFiles = append(actionFiles, path)
}
return nil
})
return actionFiles, err
}
// handleDirectory decides whether to skip a directory during recursive search.
func (d *ProjectDetector) handleDirectory(info os.FileInfo) error {
name := info.Name()
if strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor" {
return filepath.SkipDir
}
return nil
}
// findActionFilesInDirectory finds action files only in the specified directory.
func (d *ProjectDetector) findActionFilesInDirectory(dir string) ([]string, error) {
var actionFiles []string
for _, filename := range []string{appconstants.ActionFileNameYML, appconstants.ActionFileNameYAML} {
actionPath := filepath.Join(dir, filename)
if _, err := os.Stat(actionPath); err == nil {
actionFiles = append(actionFiles, actionPath)
}
}
return actionFiles, nil
}
// isActionFile checks if a filename is an action file.
func (d *ProjectDetector) isActionFile(filename string) bool {
return filename == appconstants.ActionFileNameYML || filename == appconstants.ActionFileNameYAML
}
// analyzeActionFile analyzes an action file to extract characteristics.
func (d *ProjectDetector) analyzeActionFile(actionFile string, settings *DetectedSettings) error {
action, err := d.parseActionFile(actionFile)
if err != nil {
return err
}
d.analyzeRunsSection(action, settings)
d.analyzePermissionsSection(action, settings)
return nil
}
// parseActionFile reads and parses an action YAML file.
func (d *ProjectDetector) parseActionFile(actionFile string) (map[string]any, error) {
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)
}
var action map[string]any
if err := yaml.Unmarshal(data, &action); err != nil {
return nil, fmt.Errorf("failed to parse action YAML: %w", err)
}
return action, nil
}
// analyzeRunsSection analyzes the runs section of an action file.
func (d *ProjectDetector) analyzeRunsSection(action map[string]any, settings *DetectedSettings) {
runs, ok := action["runs"].(map[string]any)
if !ok {
return
}
// Check if it's a composite action
if using, ok := runs["using"].(string); ok && using == appconstants.ActionTypeComposite {
settings.HasCompositeAction = true
}
// Analyze runs-on requirements if present
d.processRunsOnField(runs, settings)
}
// processRunsOnField processes the runs-on field from the runs section.
func (d *ProjectDetector) processRunsOnField(runs map[string]any, settings *DetectedSettings) {
runsOn, ok := runs["runs-on"]
if !ok {
return
}
if runsOnStr, ok := runsOn.(string); ok {
settings.SuggestedRunsOn = []string{runsOnStr}
} else if runsOnSlice, ok := runsOn.([]any); ok {
for _, runner := range runsOnSlice {
if runnerStr, ok := runner.(string); ok {
settings.SuggestedRunsOn = append(settings.SuggestedRunsOn, runnerStr)
}
}
}
}
// analyzePermissionsSection analyzes the permissions section of an action file.
func (d *ProjectDetector) analyzePermissionsSection(action map[string]any, settings *DetectedSettings) {
permissions, ok := action["permissions"].(map[string]any)
if !ok {
return
}
for key, value := range permissions {
if valueStr, ok := value.(string); ok {
settings.SuggestedPermissions[key] = valueStr
}
}
}
// analyzeProjectFiles analyzes project files to determine characteristics.
func (d *ProjectDetector) analyzeProjectFiles() map[string]string {
characteristics := make(map[string]string)
files, err := os.ReadDir(d.currentDir)
if err != nil {
return characteristics
}
for _, file := range files {
d.detectLanguageFromFile(file.Name(), characteristics)
d.detectFrameworkFromFile(file.Name(), characteristics)
}
d.setDefaultProjectType(characteristics)
return characteristics
}
// detectLanguageFromFile detects programming language from filename.
func (d *ProjectDetector) detectLanguageFromFile(filename string, characteristics map[string]string) {
switch filename {
case appconstants.PackageJSON:
characteristics["language"] = appconstants.LangJavaScriptTypeScript
characteristics["type"] = "Node.js Project"
case "go.mod":
characteristics["language"] = appconstants.LangGo
characteristics["type"] = "Go Module"
case "Cargo.toml":
characteristics["language"] = "Rust"
characteristics["type"] = "Rust Project"
case "pyproject.toml", "requirements.txt":
characteristics["language"] = appconstants.LangPython
characteristics["type"] = "Python Project"
case "Gemfile":
characteristics["language"] = "Ruby"
characteristics["type"] = "Ruby Project"
case "composer.json":
characteristics["language"] = "PHP"
characteristics["type"] = "PHP Project"
case "pom.xml":
characteristics["language"] = "Java"
characteristics["type"] = "Maven Project"
case "build.gradle", "build.gradle.kts":
characteristics["language"] = "Java/Kotlin"
characteristics["type"] = "Gradle Project"
}
}
// detectFrameworkFromFile detects framework from filename.
func (d *ProjectDetector) detectFrameworkFromFile(filename string, characteristics map[string]string) {
switch filename {
case "next.config.js":
characteristics["framework"] = "Next.js"
case "nuxt.config.js":
characteristics["framework"] = "Nuxt.js"
case "vue.config.js":
characteristics["framework"] = "Vue.js"
case "angular.json":
characteristics["framework"] = "Angular"
case "svelte.config.js":
characteristics["framework"] = "Svelte"
}
}
// setDefaultProjectType sets default project type if none detected.
func (d *ProjectDetector) setDefaultProjectType(characteristics map[string]string) {
if characteristics["type"] == "" && len(d.getCurrentActionFiles()) > 0 {
characteristics["type"] = "GitHub Action"
}
}
// getCurrentActionFiles gets action files in current directory only.
func (d *ProjectDetector) getCurrentActionFiles() []string {
actionFiles, _ := d.findActionFiles(d.currentDir, false)
return actionFiles
}
// suggestConfiguration suggests configuration based on detected settings.
func (d *ProjectDetector) suggestConfiguration(settings *DetectedSettings) {
d.suggestTheme(settings)
d.suggestRunsOn(settings)
d.suggestPermissions(settings)
}
// suggestTheme suggests an appropriate theme based on project characteristics.
func (d *ProjectDetector) suggestTheme(settings *DetectedSettings) {
switch {
case settings.HasCompositeAction:
settings.SuggestedTheme = "professional"
case settings.HasDockerfile:
settings.SuggestedTheme = appconstants.ThemeGitHub
case settings.Language == appconstants.LangGo:
settings.SuggestedTheme = appconstants.ThemeMinimal
case settings.Framework != "":
settings.SuggestedTheme = appconstants.ThemeGitHub
default:
settings.SuggestedTheme = "default"
}
}
// suggestRunsOn suggests appropriate runners based on language/framework.
func (d *ProjectDetector) suggestRunsOn(settings *DetectedSettings) {
if len(settings.SuggestedRunsOn) != 1 || settings.SuggestedRunsOn[0] != "ubuntu-latest" {
return
}
switch settings.Language {
case appconstants.LangJavaScriptTypeScript:
settings.SuggestedRunsOn = []string{"ubuntu-latest", "windows-latest", "macos-latest"}
case appconstants.LangGo, appconstants.LangPython:
settings.SuggestedRunsOn = []string{"ubuntu-latest"}
}
}
// suggestPermissions suggests common permissions for GitHub Actions.
func (d *ProjectDetector) suggestPermissions(settings *DetectedSettings) {
if settings.IsGitHubAction && len(settings.SuggestedPermissions) == 0 {
settings.SuggestedPermissions = map[string]string{
"contents": "read",
}
}
}