mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-01-26 03:04:10 +00:00
* 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
477 lines
14 KiB
Go
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",
|
|
}
|
|
}
|
|
}
|