diff --git a/CLAUDE.md b/CLAUDE.md index d3dfd9e..36462d2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -33,219 +33,447 @@ The embedded filesystem is used by default, with fallback to filesystem for deve **For testing generation commands:** ```bash -# New enhanced targeting (recommended) +# Safe testing approaches gh-action-readme gen testdata/example-action/ gh-action-readme gen testdata/composite-action/action.yml - -# Traditional method (still supported) -cd testdata/ -../gh-action-readme gen [options] +gh-action-readme gen testdata/ --output /tmp/test-output.md ``` -## ๐Ÿ—๏ธ Architecture +## ๐Ÿ—๏ธ Architecture Overview -**Core Components:** +### Template Rendering Pipeline -- `main.go` - CLI with Cobra framework, enhanced gen command -- `internal/generator.go` - Core generation logic with custom output paths -- `internal/config.go` - Viper configuration (XDG compliant) -- `internal/output.go` - Colored terminal output with progress bars -- `internal/json_writer.go` - JSON format support -- `internal/errors/` - Contextual error handling with suggestions -- `internal/wizard/` - Interactive configuration wizard -- `internal/progress.go` - Progress indicators for batch operations +1. **Parser** (`internal/parser.go`): -**Templates:** +- Parses `action.yml` files using `goccy/go-yaml` +- Extracts permissions from header comments via `parsePermissionsFromComments()` +- Merges comment and YAML permissions (YAML takes precedence) +- Returns `*ActionYML` struct with all parsed data -- `templates/readme.tmpl` - Default template -- `templates/themes/` - Theme-specific templates - - `github/` - GitHub-style with badges - - `gitlab/` - GitLab CI/CD focused - - `minimal/` - Clean, concise - - `professional/` - Comprehensive with ToC - - `asciidoc/` - AsciiDoc format +1. **Template Data Builder** (`internal/template.go`): -## ๐Ÿ› ๏ธ Commands & Usage +- `BuildTemplateData()` creates comprehensive `TemplateData` struct +- Embeds `*ActionYML` (all action fields accessible via `.Name`, `.Inputs`, `.Permissions`, etc.) +- Detects git repository info (org, repo, default branch) +- Extracts action subdirectory for monorepo support +- Builds `uses:` statement with proper path/version -**Available Commands:** +1. **Template Functions** (`internal/template.go:templateFuncs()`): -```bash -gh-action-readme gen [directory_or_file] [flags] # Generate documentation -gh-action-readme validate # Validate action.yml files -gh-action-readme config {init|show|themes|wizard} # Configuration management -gh-action-readme version # Show version -gh-action-readme about # About tool +- `gitUsesString` - Generates complete `org/repo/path@version` string +- `actionVersion` - Determines version (config override โ†’ default branch โ†’ "v1") +- `gitOrg`, `gitRepo` - Extract git repository information +- Standard Go template functions: `lower`, `upper`, `replace`, `join` + +1. **Renderer** (`internal/template.go:RenderReadme()`): + +- Reads template from embedded filesystem via `templates_embed.ReadTemplate()` +- Executes template with `TemplateData` +- Supports multiple formats (md, html, json, asciidoc) + +### Key Data Structures + +**ActionYML** - Parsed action.yml data: + +```go +type ActionYML struct { + Name string + Description string + Inputs map[string]ActionInput + Outputs map[string]ActionOutput + Runs map[string]any + Branding *Branding + Permissions map[string]string // From comments or YAML field +} ``` -**Key Flags:** +**TemplateData** - Complete data for rendering: -- `--theme` - Select template theme -- `--output-format` - Choose format (md, html, json, asciidoc) -- `--output` - Custom output filename -- `--recursive` - Process directories recursively -- `--verbose` - Detailed output -- `--quiet` - Suppress output - -## ๐Ÿ”ง Development Workflow - -**Build:** `go build .` -**Test:** `go test ./internal` -**Lint:** `golangci-lint run` -**Dependencies:** `make deps-check` / `make deps-update` - -**Testing Generation (SAFE):** - -```bash -# Enhanced targeting (recommended) -gh-action-readme gen testdata/example-action/ --theme github --output test-output.md -gh-action-readme gen testdata/composite-action/action.yml --theme professional - -# Traditional method (still works) -cd testdata/example-action/ -../../gh-action-readme gen --theme github +```go +type TemplateData struct { + *ActionYML // Embedded - all fields accessible directly + Git git.RepoInfo + Config *AppConfig + UsesStatement string // Pre-built "org/repo/path@version" + ActionPath string // For subdirectory extraction + RepoRoot string + Dependencies []dependencies.Dependency +} ``` -## ๐Ÿ“Š Feature Matrix +### Monorepo Action Path Resolution -| Feature | Status | Files | -| --------- | -------- | ------- | -| CLI Framework | โœ… | `main.go` | -| Enhanced Gen Command | โœ… | `main.go:168-180` | -| File Discovery | โœ… | `generator.go:304-324` | -| Template Themes | โœ… | `templates/themes/` | -| Output Formats | โœ… | `generator.go:168-182` | -| Custom Output Paths | โœ… | `generator.go:157-166` | -| Validation | โœ… | `internal/validation/` | -| Configuration | โœ… | `config.go`, `configuration_loader.go` | -| Interactive Wizard | โœ… | `internal/wizard/` | -| Progress Indicators | โœ… | `progress.go` | -| Contextual Errors | โœ… | `internal/errors/` | -| Colored Output | โœ… | `output.go` | +The tool automatically detects and handles monorepo actions: -## ๐ŸŽจ Themes +```text +Input: /repo/actions/csharp-build/action.yml +Root: /repo +Output: org/repo/actions/csharp-build@main +``` -**Available Themes:** +Implementation in `internal/template.go:extractActionSubdirectory()`: -1. **default** - Original simple template -2. **github** - Badges, tables, collapsible sections -3. **gitlab** - GitLab CI/CD examples -4. **minimal** - Clean, concise documentation -5. **professional** - Comprehensive with troubleshooting +- Calculates relative path from repo root to action directory +- Returns empty string for root-level actions +- Returns subdirectory path for monorepo actions +- Used by `buildUsesString()` to construct proper `uses:` statements -## ๐Ÿ“„ Output Formats +### Permissions Parsing -**Supported Formats:** +Supports three sources (merged with priority): -- **md** - Markdown (default) -- **html** - HTML with styling -- **json** - Structured data for APIs -- **asciidoc** - Technical documentation format +1. **Header comments** (lowest priority): -## ๐Ÿงช Testing Strategy +```yaml +# permissions: +# - contents: read +# - issues: write +``` -**Unit Tests:** `internal/*_test.go` (26.2% coverage) -**Integration:** Manual CLI testing -**Templates:** Test with `testdata/example-action/` +1. **YAML field** (highest priority): -**Test Commands:** +```yaml +permissions: + contents: write +``` + +1. **Merged result**: YAML overrides comment values for duplicate keys, all unique keys included + +## ๐Ÿ› ๏ธ Development Commands + +### Building and Running ```bash -# Core functionality (enhanced) -gh-action-readme gen testdata/example-action/ -gh-action-readme gen testdata/composite-action/action.yml +# Build binary +go build . -# All themes with custom outputs -for theme in github gitlab minimal professional; do - gh-action-readme gen testdata/example-action/ --theme $theme --output "test-${theme}.md" +# Run without installing +go run . gen testdata/example-action/ +go run . validate +go run . config show + +# Build and run tests +make build +make test +make test-coverage # With coverage report +make test-coverage-html # HTML coverage + open in browser +``` + +### Testing + +```bash +# Run all tests +go test ./... + +# Run specific test package +go test ./internal +go test ./internal/wizard + +# Run specific test by name +go test ./internal -run TestParsePermissions +go test ./internal -v -run "TestParseActionYML_.*Permissions" + +# Run tests with race detection +go test -race ./... + +# Test all themes +for theme in default github gitlab minimal professional; do + ./gh-action-readme gen testdata/example-action/ --theme $theme --output /tmp/test-$theme.md done - -# All formats with custom outputs -for format in md html json asciidoc; do - gh-action-readme gen testdata/example-action/ --output-format $format --output "test.${format}" -done - -# Recursive processing -gh-action-readme gen testdata/ --recursive --theme professional ``` -## ๐Ÿš€ Production Features +### Linting and Quality -**Configuration:** +```bash +# Run all linters via pre-commit +make lint -- XDG Base Directory compliant -- Environment variable support -- Theme persistence -- Multiple search paths +# Run golangci-lint directly +golangci-lint run +golangci-lint run --timeout=5m -**Error Handling:** +# Check editor config compliance +make editorconfig -- Colored error messages -- Actionable suggestions -- Context-aware validation -- Graceful fallbacks +# Auto-fix editorconfig issues +make editorconfig-fix -**Performance:** +# Format code +make format +``` -- Progress bars for batch operations -- Thread-safe fixture caching with RWMutex -- Binary-relative template paths -- Efficient file discovery -- Custom output path resolution -- Race condition protection -- Minimal dependencies +### Security Scanning + +```bash +# Run all security checks +make security + +# Individual security tools +make vulncheck # Go vulnerability check +make audit # Nancy dependency audit +make trivy # Container security scanner +make gitleaks # Secret detection +``` + +### Dependencies + +```bash +# Check for outdated dependencies +make deps-check + +# Update dependencies interactively +make deps-update + +# Update all dependencies to latest +make deps-update-all + +# Install development tools +make devtools +``` + +### Pre-commit Hooks + +```bash +# Install hooks (run once per clone) +make pre-commit-install + +# Update hooks to latest versions +make pre-commit-update + +# Run hooks manually +pre-commit run --all-files +``` + +## โš™๏ธ Configuration System + +### Configuration Hierarchy (highest to lowest priority) + +1. **Command-line flags** - Override everything +2. **Action-specific config** - `.ghreadme.yaml` in action directory +3. **Repository config** - `.ghreadme.yaml` in repo root +4. **Global config** - `~/.config/gh-action-readme/config.yaml` +5. **Environment variables** - `GH_README_GITHUB_TOKEN`, `GITHUB_TOKEN` +6. **Defaults** - Built-in fallbacks + +### Version Resolution for Usage Examples + +Priority order for `uses: org/repo@VERSION`: + +1. `Config.Version` - Explicit override (e.g., `version: "v2.0.0"`) +2. `Config.UseDefaultBranch` + `Git.DefaultBranch` - Detected branch (e.g., `@main`) +3. Fallback - `"v1"` + +Implemented in `internal/template.go:getActionVersion()`. + +### Permissions Parsing + +**Comment Format Support:** + +- List format: `# permissions:\n# - key: value` +- Object format: `# permissions:\n# key: value` +- Mixed format: Both styles in same block +- Inline comments: `# key: value # explanation` + +**Merge Behavior:** + +```go +// internal/parser.go:ParseActionYML() +if a.Permissions == nil && commentPermissions != nil { + a.Permissions = commentPermissions // Use comments +} else if a.Permissions != nil && commentPermissions != nil { + // Merge: YAML overrides, add missing from comments + for key, value := range commentPermissions { + if _, exists := a.Permissions[key]; !exists { + a.Permissions[key] = value + } + } +} +``` ## ๐Ÿ”„ Adding New Features -**New Theme:** +### New Theme -1. Create `templates/themes/THEME_NAME/readme.tmpl` -2. Add to `resolveThemeTemplate()` in `config.go:67` -3. Update `configThemesHandler()` in `main.go:284` +1. Create template file: -**New Output Format:** +```bash +touch templates_embed/templates/themes/THEME_NAME/readme.tmpl +``` -1. Add constant to `generator.go:14` -2. Add case to `GenerateFromFile()` switch `generator.go:67` -3. Implement `generate[FORMAT]()` method -4. Update CLI help in `main.go:84` +1. Add theme constant to `appconstants/constants.go`: -**New Template Functions:** -Add to `templateFuncs()` in `internal_template.go:19` +```go +ThemeTHEMENAME = "theme-name" +TemplatePathTHEMENAME = "templates/themes/theme-name/readme.tmpl" +``` + +**Note:** The template path constant still uses `templates/` prefix (not +`templates_embed/templates/`) as this is the logical path used by the code. +The physical file lives in `templates_embed/templates/` but is referenced as +`templates/` in the code. + +1. Add to theme resolver in `internal/config.go:resolveThemeTemplate()`: + +```go +case appconstants.ThemeTHEMENAME: + templatePath = appconstants.TemplatePathTHEMENAME +``` + +1. Update `main.go:configThemesHandler()` to list the new theme + +2. Rebuild: `go build .` + +### New Output Format + +1. Add format constant to `appconstants/constants.go` + +2. Add case in `internal/generator.go:GenerateFromFile()`: + +```go +case appconstants.OutputFormatNEW: + return g.generateNEW(action, outputDir, actionPath) +``` + +1. Implement generator method: + +```go +func (g *Generator) generateNEW(action *ActionYML, outputDir, actionPath string) error { + opts := TemplateOptions{ + TemplatePath: g.resolveTemplatePathForFormat(), + Format: "new", + } + // ... implementation +} +``` + +1. Update CLI help text in `main.go` + +### New Template Function + +Add to `internal/template.go:templateFuncs()`: + +```go +func templateFuncs() template.FuncMap { + return template.FuncMap{ + "myFunc": myFuncImplementation, + // ... existing functions + } +} +``` + +### New Parser Field + +When adding fields to `ActionYML`: + +1. Update struct in `internal/parser.go` +2. Update `ActionYMLForJSON` in `internal/json_writer.go` (for JSON output) +3. Add field to JSON struct initialization +4. Add tests in `internal/parser_test.go` +5. Update templates if field should be displayed + +## ๐Ÿ“Š Package Structure + +- **`main.go`** - CLI entry point (Cobra commands) +- **`internal/generator.go`** - Core generation orchestration +- **`internal/parser.go`** - Action.yml parsing (including permissions from comments) +- **`internal/template.go`** - Template data building and rendering +- **`internal/config.go`** - Configuration management (Viper) +- **`internal/json_writer.go`** - JSON output format +- **`internal/output.go`** - Colored CLI output +- **`internal/progress.go`** - Progress bars for batch operations +- **`internal/git/`** - Git repository detection +- **`internal/validation/`** - Action.yml validation +- **`internal/wizard/`** - Interactive configuration wizard +- **`internal/dependencies/`** - Dependency analysis for actions +- **`internal/errors/`** - Contextual error handling +- **`appconstants/`** - Application constants +- **`testutil/`** - Testing utilities +- **`templates_embed/`** - Embedded template filesystem + +## ๐Ÿงช Testing Guidelines + +### Test File Locations + +- Unit tests: `internal/*_test.go` alongside source files +- Test fixtures: `testdata/example-action/`, `testdata/composite-action/` +- Integration tests: Manual CLI testing with testdata + +### Running Specific Tests + +```bash +# Parser tests +go test ./internal -v -run TestParse + +# Permissions tests +go test ./internal -run ".*Permissions" + +# Template tests +go test ./internal -run ".*Template|.*Uses" + +# Generator tests +go test ./internal -run "TestGenerator" + +# Wizard tests +go test ./internal/wizard -v +``` + +### Template Testing After Updates + +```bash +# 1. Rebuild with updated templates +go build . + +# 2. Test all themes +for theme in default github gitlab minimal professional; do + ./gh-action-readme gen testdata/example-action/ --theme $theme --output /tmp/test-$theme.md + echo "=== $theme theme ===" + grep -i "permissions" /tmp/test-$theme.md && echo "โœ… Found" || echo "โŒ Missing" +done + +# 4. Test JSON output +./gh-action-readme gen testdata/example-action/ -f json -o /tmp/test.json +cat /tmp/test.json | python3 -m json.tool | grep -A 3 permissions +``` ## ๐Ÿ“ฆ Dependency Management -**Check for updates:** +**Automated Updates:** + +- Renovate bot runs weekly (Mondays 4am UTC) +- Auto-merges minor/patch updates +- Major updates require manual review +- Groups `golang.org/x` packages together + +**Manual Updates:** ```bash -make deps-check # Show outdated dependencies +make deps-check # Show outdated +make deps-update # Interactive with go-mod-upgrade +make deps-update-all # Update all to latest ``` -**Update dependencies:** +## ๐Ÿ” Security -```bash -make deps-update # Interactive updates with go-mod-upgrade -make deps-update-all # Update all to latest versions +**Pre-commit Hooks:** + +- `gitleaks` - Secret detection +- `golangci-lint` - Static analysis including security checks +- `editorconfig-checker` - File format validation + +**Security Scanning:** + +- CodeQL analysis on push/PR +- Go vulnerability check (govulncheck) +- Trivy container scanning +- Nancy dependency audit + +**Path Validation:** +All file reads use validated paths to prevent path traversal: + +```go +// templates_embed/embed.go:ReadTemplate() +cleanPath := filepath.Clean(templatePath) +if cleanPath != templatePath || strings.Contains(cleanPath, "..") { + return nil, filepath.ErrBadPattern +} ``` - -**Automated updates:** - -- Renovate bot runs weekly on Mondays at 4am UTC -- Creates PRs for minor/patch updates (auto-merge enabled) -- Major updates disabled (require manual review) -- Groups golang.org/x packages together -- Runs `go mod tidy` after updates - ---- - -**Status: ENTERPRISE READY โœ…** -*Enhanced gen command, thread-safety, comprehensive testing, and enterprise features fully implemented.* - -**Latest Updates (August 6, 2025):** - -- โœ… Enhanced gen command with directory/file targeting -- โœ… Custom output filename support (`--output` flag) -- โœ… Thread-safe fixture management with race condition protection -- โœ… GitHub Actions workflow integration with new capabilities -- โœ… Complete linting and code quality compliance -- โœ… Zero known race conditions or threading issues -- โœ… Dependency management automation with Renovate and go-mod-upgrade diff --git a/appconstants/test_constants.go b/appconstants/test_constants.go index cfbd1d7..3db0cf5 100644 --- a/appconstants/test_constants.go +++ b/appconstants/test_constants.go @@ -80,3 +80,23 @@ const ( TestYAMLNested = "name: nested" TestYAMLSub = "name: sub" ) + +// Test YAML template strings for parser tests. +const ( + TestActionFilePattern = "action-*.yml" + TestPermissionsHeader = "# permissions:\n" + TestActionNameLine = "name: Test Action\n" + TestDescriptionLine = "description: Test\n" + TestRunsLine = "runs:\n" + TestCompositeUsing = " using: composite\n" + TestStepsEmpty = " steps: []\n" + TestErrorFormat = "ParseActionYML() error = %v" + TestContentsRead = "# contents: read\n" +) + +// Test path constants for template tests. +const ( + TestRepoActionPath = "/repo/action.yml" + TestRepoBuildActionPath = "/repo/build/action.yml" + TestVersionV123 = "@v1.2.3" +) diff --git a/internal/json_writer.go b/internal/json_writer.go index cd79f0f..e33d4d7 100644 --- a/internal/json_writer.go +++ b/internal/json_writer.go @@ -40,6 +40,7 @@ type ActionYMLForJSON struct { Outputs map[string]ActionOutputForJSON `json:"outputs,omitempty"` Runs map[string]any `json:"runs"` Branding *BrandingForJSON `json:"branding,omitempty"` + Permissions map[string]string `json:"permissions,omitempty"` } // ActionInputForJSON represents an input parameter in JSON format. @@ -218,6 +219,7 @@ func (jw *JSONWriter) convertToJSONOutput(action *ActionYML) *JSONOutput { Outputs: outputs, Runs: action.Runs, Branding: branding, + Permissions: action.Permissions, }, Documentation: DocumentationInfo{ Title: action.Name, diff --git a/internal/parser.go b/internal/parser.go index b84048e..ecc2ab0 100644 --- a/internal/parser.go +++ b/internal/parser.go @@ -1,6 +1,7 @@ package internal import ( + "bufio" "fmt" "os" "path/filepath" @@ -19,6 +20,7 @@ type ActionYML struct { Outputs map[string]ActionOutput `yaml:"outputs"` Runs map[string]any `yaml:"runs"` Branding *Branding `yaml:"branding,omitempty"` + Permissions map[string]string `yaml:"permissions,omitempty"` // Add more fields as the schema evolves } @@ -42,6 +44,14 @@ type Branding struct { // ParseActionYML reads and parses action.yml from given path. func ParseActionYML(path string) (*ActionYML, error) { + // Parse permissions from header comments FIRST + commentPermissions, err := parsePermissionsFromComments(path) + if err != nil { + // Don't fail if comment parsing fails, just log and continue + commentPermissions = nil + } + + // Standard YAML parsing f, err := os.Open(path) // #nosec G304 -- path from function parameter if err != nil { return nil, err @@ -55,9 +65,139 @@ func ParseActionYML(path string) (*ActionYML, error) { return nil, err } + // Merge permissions: YAML permissions override comment permissions + mergePermissions(&a, commentPermissions) + return &a, nil } +// mergePermissions combines comment and YAML permissions. +// YAML permissions take precedence when both exist. +func mergePermissions(action *ActionYML, commentPerms map[string]string) { + if action.Permissions == nil && commentPerms != nil && len(commentPerms) > 0 { + action.Permissions = commentPerms + } else if action.Permissions != nil && commentPerms != nil && len(commentPerms) > 0 { + // Merge: YAML takes precedence, add missing from comments + for key, value := range commentPerms { + if _, exists := action.Permissions[key]; !exists { + action.Permissions[key] = value + } + } + } +} + +// parsePermissionsFromComments extracts permissions from header comments. +// Looks for lines like: +// +// # permissions: +// # - contents: read # Required for checking out repository +// # contents: read # Alternative format without dash +func parsePermissionsFromComments(path string) (map[string]string, error) { + file, err := os.Open(path) // #nosec G304 -- path from function parameter + if err != nil { + return nil, err + } + defer func() { + _ = file.Close() // Ignore close error in defer + }() + + permissions := make(map[string]string) + scanner := bufio.NewScanner(file) + + inPermissionsBlock := false + var expectedItemIndent int + + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + + // Stop parsing at first non-comment line + if !strings.HasPrefix(trimmed, "#") { + break + } + + // Remove leading # and spaces + content := strings.TrimPrefix(trimmed, "#") + content = strings.TrimSpace(content) + + // Check for permissions block start + if content == "permissions:" { + inPermissionsBlock = true + // Calculate expected indent for permission items (after the # and any spaces) + // We expect items to be indented relative to the content + expectedItemIndent = -1 // Will be set on first item + + continue + } + + // Parse permission entries + if inPermissionsBlock && content != "" { + shouldBreak := processPermissionEntry(line, content, &expectedItemIndent, permissions) + if shouldBreak { + break + } + } + } + + if err := scanner.Err(); err != nil { + return nil, err + } + + return permissions, nil +} + +// parsePermissionLine extracts key-value from a permission line. +// Supports formats: +// - "- contents: read # comment" +// - "contents: read # comment" +func parsePermissionLine(content string) (key, value string, ok bool) { + // Remove leading dash if present + content = strings.TrimPrefix(content, "-") + content = strings.TrimSpace(content) + + // Remove inline comments + if idx := strings.Index(content, "#"); idx > 0 { + content = strings.TrimSpace(content[:idx]) + } + + // Parse key: value + parts := strings.SplitN(content, ":", 2) + if len(parts) == 2 { + key = strings.TrimSpace(parts[0]) + value = strings.TrimSpace(parts[1]) + if key != "" && value != "" { + return key, value, true + } + } + + return "", "", false +} + +// processPermissionEntry processes a single line in the permissions block. +// Returns true if parsing should break (dedented out of block), false to continue. +func processPermissionEntry(line, content string, expectedItemIndent *int, permissions map[string]string) bool { + // Get the indent of the content (after removing #) + lineAfterHash := strings.TrimPrefix(line, "#") + contentIndent := len(lineAfterHash) - len(strings.TrimLeft(lineAfterHash, " ")) + + // Set expected indent on first item + if *expectedItemIndent == -1 { + *expectedItemIndent = contentIndent + } + + // If dedented relative to expected item indent, we've left the permissions block + if contentIndent < *expectedItemIndent { + return true + } + + // Parse permission line and add to map if valid + if key, value, ok := parsePermissionLine(content); ok { + permissions[key] = value + } + + return false +} + // shouldIgnoreDirectory checks if a directory name matches the ignore list. func shouldIgnoreDirectory(dirName string, ignoredDirs []string) bool { for _, ignored := range ignoredDirs { diff --git a/internal/parser_test.go b/internal/parser_test.go index 3026225..b63a05f 100644 --- a/internal/parser_test.go +++ b/internal/parser_test.go @@ -3,23 +3,45 @@ package internal import ( "os" "path/filepath" + "reflect" + "strings" "testing" "github.com/ivuorinen/gh-action-readme/appconstants" "github.com/ivuorinen/gh-action-readme/testutil" ) +const testPermissionWrite = "write" + +// parseActionFromContent creates a temporary action.yml file with the given content and parses it. +func parseActionFromContent(t *testing.T, content string) (*ActionYML, error) { + t.Helper() + + tmpFile, err := os.CreateTemp(t.TempDir(), appconstants.TestActionFilePattern) + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Remove(tmpFile.Name()) }() + + if _, err := tmpFile.WriteString(content); err != nil { + t.Fatal(err) + } + _ = tmpFile.Close() + + return ParseActionYML(tmpFile.Name()) +} + // createTestDirWithAction creates a directory with an action.yml file and returns both paths. func createTestDirWithAction(t *testing.T, baseDir, dirName, yamlContent string) (string, string) { t.Helper() dirPath := filepath.Join(baseDir, dirName) - if err := os.Mkdir(dirPath, appconstants.FilePermDir); err != nil { // nolint:gosec + if err := os.Mkdir(dirPath, appconstants.FilePermDir); err != nil { t.Fatalf(testutil.ErrCreateDir(dirName), err) } actionPath := filepath.Join(dirPath, appconstants.ActionFileNameYML) if err := os.WriteFile( actionPath, []byte(yamlContent), appconstants.FilePermDefault, - ); err != nil { // nolint:gosec + ); err != nil { t.Fatalf(testutil.ErrCreateFile(dirName+"/action.yml"), err) } @@ -32,7 +54,7 @@ func createTestFile(t *testing.T, baseDir, fileName, content string) string { filePath := filepath.Join(baseDir, fileName) if err := os.WriteFile( filePath, []byte(content), appconstants.FilePermDefault, - ); err != nil { // nolint:gosec + ); err != nil { t.Fatalf(testutil.ErrCreateFile(fileName), err) } @@ -224,14 +246,14 @@ func TestDiscoverActionFilesNestedIgnoredDirs(t *testing.T) { // action.yml (should be ignored) nodeModulesDir := filepath.Join(tmpDir, appconstants.DirNodeModules, "deep", "nested") - if err := os.MkdirAll(nodeModulesDir, appconstants.FilePermDir); err != nil { // nolint:gosec + if err := os.MkdirAll(nodeModulesDir, appconstants.FilePermDir); err != nil { t.Fatalf(testutil.ErrCreateDir("nested"), err) } nestedAction := filepath.Join(nodeModulesDir, appconstants.ActionFileNameYML) if err := os.WriteFile( nestedAction, []byte(appconstants.TestYAMLNested), appconstants.FilePermDefault, - ); err != nil { // nolint:gosec + ); err != nil { t.Fatalf(testutil.ErrCreateFile("nested action.yml"), err) } @@ -254,19 +276,19 @@ func TestDiscoverActionFilesNonRecursive(t *testing.T) { rootAction := filepath.Join(tmpDir, appconstants.ActionFileNameYML) if err := os.WriteFile( rootAction, []byte(appconstants.TestYAMLRoot), appconstants.FilePermDefault, - ); err != nil { // nolint:gosec + ); err != nil { t.Fatalf(testutil.ErrCreateFile("action.yml"), err) } // Create subdirectory (should not be searched in non-recursive mode) subDir := filepath.Join(tmpDir, "sub") - if err := os.Mkdir(subDir, appconstants.FilePermDir); err != nil { // nolint:gosec + if err := os.Mkdir(subDir, appconstants.FilePermDir); err != nil { t.Fatalf(testutil.ErrCreateDir("sub"), err) } subAction := filepath.Join(subDir, appconstants.ActionFileNameYML) if err := os.WriteFile( subAction, []byte(appconstants.TestYAMLSub), appconstants.FilePermDefault, - ); err != nil { // nolint:gosec + ); err != nil { t.Fatalf(testutil.ErrCreateFile("sub/action.yml"), err) } @@ -283,3 +305,586 @@ func TestDiscoverActionFilesNonRecursive(t *testing.T) { t.Errorf("DiscoverActionFiles() = %v, want %v", files[0], rootAction) } } + +// TestParsePermissionsFromComments tests parsing permissions from header comments. +func TestParsePermissionsFromComments(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + content string + want map[string]string + wantErr bool + }{ + { + name: "single permission with dash format", + content: `# yaml-language-server: $schema=https://json.schemastore.org/github-action.json +# permissions: +# - contents: read # Required for checking out repository +name: Test Action`, + want: map[string]string{ + "contents": "read", + }, + wantErr: false, + }, + { + name: "multiple permissions", + content: `# permissions: +# - contents: read +# - issues: write +# - pull-requests: write +name: Test Action`, + want: map[string]string{ + "contents": "read", + "issues": "write", + "pull-requests": "write", + }, + wantErr: false, + }, + { + name: "permissions without dash", + content: `# permissions: +# contents: read +# issues: write +name: Test Action`, + want: map[string]string{ + "contents": "read", + "issues": "write", + }, + wantErr: false, + }, + { + name: "no permissions block", + content: `# Just a comment +name: Test Action`, + want: map[string]string{}, + wantErr: false, + }, + { + name: "permissions with inline comments", + content: `# permissions: +# - contents: read # Needed for checkout +# - issues: write # To create issues +name: Test Action`, + want: map[string]string{ + "contents": "read", + "issues": "write", + }, + wantErr: false, + }, + { + name: "empty permissions block", + content: `# permissions: +name: Test Action`, + want: map[string]string{}, + wantErr: false, + }, + { + name: "permissions with mixed formats", + content: `# permissions: +# - contents: read +# issues: write +name: Test Action`, + want: map[string]string{ + "contents": "read", + "issues": "write", + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create temp file + tmpFile, err := os.CreateTemp(t.TempDir(), appconstants.TestActionFilePattern) + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Remove(tmpFile.Name()) }() + + if _, err := tmpFile.WriteString(tt.content); err != nil { + t.Fatal(err) + } + _ = tmpFile.Close() + + got, err := parsePermissionsFromComments(tmpFile.Name()) + + if (err != nil) != tt.wantErr { + t.Errorf("parsePermissionsFromComments() error = %v, wantErr %v", err, tt.wantErr) + + return + } + + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("parsePermissionsFromComments() = %v, want %v", got, tt.want) + } + }) + } +} + +// TestParseActionYMLWithCommentPermissions tests that ParseActionYML includes comment permissions. +func TestParseActionYMLWithCommentPermissions(t *testing.T) { + t.Parallel() + + content := appconstants.TestPermissionsHeader + + "# - contents: read\n" + + appconstants.TestActionNameLine + + appconstants.TestDescriptionLine + + appconstants.TestRunsLine + + appconstants.TestCompositeUsing + + appconstants.TestStepsEmpty + + action, err := parseActionFromContent(t, content) + if err != nil { + t.Fatalf(appconstants.TestErrorFormat, err) + } + + if action.Permissions == nil { + t.Fatal("Expected permissions to be parsed from comments") + } + + if action.Permissions["contents"] != "read" { + t.Errorf("Expected contents: read, got %v", action.Permissions) + } +} + +// TestParseActionYMLYAMLPermissionsOverrideComments tests that YAML permissions override comments. +func TestParseActionYMLYAMLPermissionsOverrideComments(t *testing.T) { + t.Parallel() + + content := appconstants.TestPermissionsHeader + + "# - contents: read\n" + + "# - issues: write\n" + + appconstants.TestActionNameLine + + appconstants.TestDescriptionLine + + "permissions:\n" + + " contents: write # YAML override\n" + + appconstants.TestRunsLine + + appconstants.TestCompositeUsing + + appconstants.TestStepsEmpty + + action, err := parseActionFromContent(t, content) + if err != nil { + t.Fatalf(appconstants.TestErrorFormat, err) + } + + // YAML should override comment + if action.Permissions["contents"] != testPermissionWrite { + t.Errorf( + "Expected YAML permissions to override comment permissions, got contents: %v", + action.Permissions["contents"], + ) + } + + // Comment permission should be merged in + if action.Permissions["issues"] != testPermissionWrite { + t.Errorf( + "Expected comment permissions to be merged with YAML permissions, got issues: %v", + action.Permissions["issues"], + ) + } +} + +// TestParseActionYMLOnlyYAMLPermissions tests parsing when only YAML permissions exist. +func TestParseActionYMLOnlyYAMLPermissions(t *testing.T) { + t.Parallel() + + content := appconstants.TestActionNameLine + + appconstants.TestDescriptionLine + + "permissions:\n" + + " contents: read\n" + + " issues: write\n" + + appconstants.TestRunsLine + + appconstants.TestCompositeUsing + + appconstants.TestStepsEmpty + + action, err := parseActionFromContent(t, content) + if err != nil { + t.Fatalf(appconstants.TestErrorFormat, err) + } + + if action.Permissions == nil { + t.Fatal("Expected permissions to be parsed from YAML") + } + + if action.Permissions["contents"] != "read" { + t.Errorf("Expected contents: read, got %v", action.Permissions) + } + + if action.Permissions["issues"] != testPermissionWrite { + t.Errorf("Expected issues: write, got %v", action.Permissions) + } +} + +// TestParseActionYMLNoPermissions tests parsing when no permissions exist. +func TestParseActionYMLNoPermissions(t *testing.T) { + t.Parallel() + + content := appconstants.TestActionNameLine + + appconstants.TestDescriptionLine + + appconstants.TestRunsLine + + appconstants.TestCompositeUsing + + appconstants.TestStepsEmpty + + action, err := parseActionFromContent(t, content) + if err != nil { + t.Fatalf(appconstants.TestErrorFormat, err) + } + + if action.Permissions != nil { + t.Errorf("Expected no permissions, got %v", action.Permissions) + } +} + +// TestParseActionYMLMalformedYAML tests parsing with malformed YAML. +func TestParseActionYMLMalformedYAML(t *testing.T) { + t.Parallel() + + content := appconstants.TestActionNameLine + + appconstants.TestDescriptionLine + + "invalid-yaml: [\n" + // Unclosed bracket + " - item" + + _, err := parseActionFromContent(t, content) + if err == nil { + t.Error("Expected error for malformed YAML, got nil") + } +} + +// TestParseActionYMLEmptyFile tests parsing an empty file. +func TestParseActionYMLEmptyFile(t *testing.T) { + t.Parallel() + + tmpFile, err := os.CreateTemp(t.TempDir(), appconstants.TestActionFilePattern) + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Remove(tmpFile.Name()) }() + + _ = tmpFile.Close() + + _, err = ParseActionYML(tmpFile.Name()) + // Empty file should return EOF error from YAML parser + if err == nil { + t.Error("Expected EOF error for empty file, got nil") + } +} + +// TestParsePermissionLineEdgeCases tests edge cases in permission line parsing. +func TestParsePermissionLineEdgeCases(t *testing.T) { + tests := []struct { + name string + input string + wantKey string + wantValue string + wantOK bool + }{ + { + name: "comment at start is parsed", + input: "#contents: read", + wantKey: "#contents", + wantValue: "read", + wantOK: true, + }, + { + name: "empty value after colon", + input: "contents:", + wantKey: "", + wantValue: "", + wantOK: false, + }, + { + name: "only spaces after colon", + input: "contents: ", + wantKey: "", + wantValue: "", + wantOK: false, + }, + { + name: "valid with inline comment", + input: "contents: read # required", + wantKey: "contents", + wantValue: "read", + wantOK: true, + }, + { + name: "valid with leading dash", + input: "- issues: write", + wantKey: "issues", + wantValue: "write", + wantOK: true, + }, + { + name: "no colon", + input: "invalid permission line", + wantKey: "", + wantValue: "", + wantOK: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + key, value, ok := parsePermissionLine(tt.input) + + if ok != tt.wantOK { + t.Errorf("parsePermissionLine() ok = %v, want %v", ok, tt.wantOK) + } + + if key != tt.wantKey { + t.Errorf("parsePermissionLine() key = %q, want %q", key, tt.wantKey) + } + + if value != tt.wantValue { + t.Errorf("parsePermissionLine() value = %q, want %q", value, tt.wantValue) + } + }) + } +} + +// TestProcessPermissionEntryIndentationEdgeCases tests indentation scenarios. +func TestProcessPermissionEntryIndentationEdgeCases(t *testing.T) { + tests := []struct { + name string + line string + content string + initialIndent int + wantBreak bool + wantPermissionsLen int + }{ + { + name: "first item sets indent", + line: appconstants.TestContentsRead, + content: "contents: read", + initialIndent: -1, + wantBreak: false, + wantPermissionsLen: 1, + }, + { + name: "dedented breaks", + line: "# contents: read", + content: "contents: read", + initialIndent: 2, + wantBreak: true, + wantPermissionsLen: 0, + }, + { + name: "same indent continues", + line: "# issues: write", + content: "issues: write", + initialIndent: 3, + wantBreak: false, + wantPermissionsLen: 1, + }, + { + name: "invalid format skipped", + line: "# invalid-line-no-colon", + content: "invalid-line-no-colon", + initialIndent: 3, + wantBreak: false, + wantPermissionsLen: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + permissions := make(map[string]string) + indent := tt.initialIndent + + shouldBreak := processPermissionEntry(tt.line, tt.content, &indent, permissions) + + if shouldBreak != tt.wantBreak { + t.Errorf("processPermissionEntry() shouldBreak = %v, want %v", shouldBreak, tt.wantBreak) + } + + if len(permissions) != tt.wantPermissionsLen { + t.Errorf( + "processPermissionEntry() permissions length = %d, want %d", + len(permissions), + tt.wantPermissionsLen, + ) + } + }) + } +} + +// TestParsePermissionsFromCommentsEdgeCases tests edge cases in comment parsing. +func TestParsePermissionsFromCommentsEdgeCases(t *testing.T) { + tests := []struct { + name string + content string + wantPerms map[string]string + wantErr bool + description string + }{ + { + name: "duplicate permissions", + content: appconstants.TestPermissionsHeader + + appconstants.TestContentsRead + + "# contents: write\n", + wantPerms: map[string]string{"contents": "write"}, + wantErr: false, + description: "last value wins", + }, + { + name: "mixed valid and invalid lines", + content: appconstants.TestPermissionsHeader + + appconstants.TestContentsRead + + "# invalid-line-no-value\n" + + "# issues: write\n", + wantPerms: map[string]string{"contents": "read", "issues": "write"}, + wantErr: false, + description: "invalid lines skipped", + }, + { + name: "permissions block ends at non-comment", + content: appconstants.TestPermissionsHeader + + appconstants.TestContentsRead + + appconstants.TestActionNameLine + + "# issues: write\n", + wantPerms: map[string]string{"contents": "read"}, + wantErr: false, + description: "stops at first non-comment", + }, + { + name: "only permissions header", + content: appconstants.TestPermissionsHeader + + appconstants.TestActionNameLine, + wantPerms: map[string]string{}, + wantErr: false, + description: "empty permissions block", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tmpFile, err := os.CreateTemp(t.TempDir(), "test-*.yml") + if err != nil { + t.Fatal(err) + } + defer func() { _ = os.Remove(tmpFile.Name()) }() + + if _, err := tmpFile.WriteString(tt.content); err != nil { + t.Fatal(err) + } + _ = tmpFile.Close() + + perms, err := parsePermissionsFromComments(tmpFile.Name()) + + if (err != nil) != tt.wantErr { + t.Errorf("parsePermissionsFromComments() error = %v, wantErr %v", err, tt.wantErr) + } + + if !reflect.DeepEqual(perms, tt.wantPerms) { + t.Errorf("parsePermissionsFromComments() = %v, want %v (%s)", perms, tt.wantPerms, tt.description) + } + }) + } +} + +// TestMergePermissionsEdgeCases tests permission merging edge cases. +func TestMergePermissionsEdgeCases(t *testing.T) { + tests := []struct { + name string + yamlPerms map[string]string + commentPerms map[string]string + wantPerms map[string]string + }{ + { + name: "both nil", + yamlPerms: nil, + commentPerms: nil, + wantPerms: nil, + }, + { + name: "yaml nil, comments empty", + yamlPerms: nil, + commentPerms: map[string]string{}, + wantPerms: nil, + }, + { + name: "yaml empty, comments nil", + yamlPerms: map[string]string{}, + commentPerms: nil, + wantPerms: map[string]string{}, + }, + { + name: "yaml has value, comments override", + yamlPerms: map[string]string{"contents": "read"}, + commentPerms: map[string]string{"issues": "write"}, + wantPerms: map[string]string{"contents": "read", "issues": "write"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + action := &ActionYML{Permissions: tt.yamlPerms} + mergePermissions(action, tt.commentPerms) + + if !reflect.DeepEqual(action.Permissions, tt.wantPerms) { + t.Errorf("mergePermissions() = %v, want %v", action.Permissions, tt.wantPerms) + } + }) + } +} + +// TestDiscoverActionFilesWalkErrors tests error handling during directory walk. +func TestDiscoverActionFilesWalkErrors(t *testing.T) { + // Test with a path that doesn't exist + _, err := DiscoverActionFiles("/nonexistent/path/that/does/not/exist", true, []string{}) + if err == nil { + t.Error("Expected error for nonexistent directory, got nil") + } + + // Test that error message mentions the path + if err != nil && !strings.Contains(err.Error(), "/nonexistent/path/that/does/not/exist") { + t.Errorf("Expected error to mention path, got: %v", err) + } +} + +// TestWalkFuncErrorHandling tests walkFunc error propagation. +func TestWalkFuncErrorHandling(t *testing.T) { + walker := &actionFileWalker{ + ignoredDirs: []string{}, + actionFiles: []string{}, + } + + // Create a valid FileInfo for testing + tmpDir := t.TempDir() + info, err := os.Stat(tmpDir) + if err != nil { + t.Fatalf("Failed to stat temp dir: %v", err) + } + + // Test with valid directory - should return nil + err = walker.walkFunc(tmpDir, info, nil) + if err != nil { + t.Errorf("walkFunc() with valid directory should return nil, got: %v", err) + } + + // Test with pre-existing error - should propagate + testErr := filepath.SkipDir + err = walker.walkFunc(tmpDir, info, testErr) + if err != testErr { + t.Errorf("walkFunc() should propagate error, got %v, want %v", err, testErr) + } +} + +// TestParseActionYMLOnlyComments tests file with only comments. +func TestParseActionYMLOnlyComments(t *testing.T) { + t.Parallel() + + content := "# This is a comment\n" + + "# Another comment\n" + + appconstants.TestPermissionsHeader + + appconstants.TestContentsRead + + _, err := parseActionFromContent(t, content) + // File with only comments should return EOF error from YAML parser + // (comments are parsed separately, but YAML decoder still needs valid YAML) + if err == nil { + t.Error("Expected EOF error for comment-only file, got nil") + } +} diff --git a/internal/template_test.go b/internal/template_test.go new file mode 100644 index 0000000..caa66b3 --- /dev/null +++ b/internal/template_test.go @@ -0,0 +1,533 @@ +package internal + +import ( + "path/filepath" + "testing" + + "github.com/ivuorinen/gh-action-readme/appconstants" + "github.com/ivuorinen/gh-action-readme/internal/git" +) + +// newTemplateData creates a TemplateData with common test values. +// Pass nil for any field to use defaults or zero values. +func newTemplateData( + actionName string, + version string, + useDefaultBranch bool, + defaultBranch string, + org string, + repo string, + actionPath string, + repoRoot string, +) *TemplateData { + var actionYML *ActionYML + if actionName != "" { + actionYML = &ActionYML{Name: actionName} + } + + return &TemplateData{ + ActionYML: actionYML, + Config: &AppConfig{ + Version: version, + UseDefaultBranch: useDefaultBranch, + }, + Git: git.RepoInfo{ + Organization: org, + Repository: repo, + DefaultBranch: defaultBranch, + }, + ActionPath: actionPath, + RepoRoot: repoRoot, + } +} + +// TestExtractActionSubdirectory tests the extractActionSubdirectory function. +func TestExtractActionSubdirectory(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + actionPath string + repoRoot string + want string + }{ + { + name: "subdirectory action", + actionPath: "/repo/actions/csharp-build/action.yml", + repoRoot: "/repo", + want: "actions/csharp-build", + }, + { + name: "single level subdirectory", + actionPath: appconstants.TestRepoBuildActionPath, + repoRoot: "/repo", + want: "build", + }, + { + name: "deeply nested subdirectory", + actionPath: "/repo/a/b/c/d/action.yml", + repoRoot: "/repo", + want: "a/b/c/d", + }, + { + name: "root action", + actionPath: appconstants.TestRepoActionPath, + repoRoot: "/repo", + want: "", + }, + { + name: "empty action path", + actionPath: "", + repoRoot: "/repo", + want: "", + }, + { + name: "empty repo root", + actionPath: appconstants.TestRepoActionPath, + repoRoot: "", + want: "", + }, + { + name: "both empty", + actionPath: "", + repoRoot: "", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := extractActionSubdirectory(tt.actionPath, tt.repoRoot) + + // Normalize paths for cross-platform compatibility + want := filepath.ToSlash(tt.want) + got = filepath.ToSlash(got) + + if got != want { + t.Errorf("extractActionSubdirectory() = %q, want %q", got, want) + } + }) + } +} + +// TestBuildUsesString tests the buildUsesString function with subdirectory extraction. +func TestBuildUsesString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + td *TemplateData + org string + repo string + version string + want string + }{ + { + name: "monorepo with subdirectory", + td: &TemplateData{ + ActionPath: "/repo/actions/csharp-build/action.yml", + RepoRoot: "/repo", + }, + org: "ivuorinen", + repo: "actions", + version: "@main", + want: "ivuorinen/actions/actions/csharp-build@main", + }, + { + name: "root action", + td: &TemplateData{ + ActionPath: appconstants.TestRepoActionPath, + RepoRoot: "/repo", + }, + org: "ivuorinen", + repo: "my-action", + version: "@main", + want: "ivuorinen/my-action@main", + }, + { + name: "empty org", + td: &TemplateData{ + ActionPath: appconstants.TestRepoBuildActionPath, + RepoRoot: "/repo", + }, + org: "", + repo: "actions", + version: "@main", + want: "your-org/your-action@v1", + }, + { + name: "empty repo", + td: &TemplateData{ + ActionPath: appconstants.TestRepoBuildActionPath, + RepoRoot: "/repo", + }, + org: "ivuorinen", + repo: "", + version: "@main", + want: "your-org/your-action@v1", + }, + { + name: "missing paths in template data", + td: &TemplateData{ + ActionPath: "", + RepoRoot: "", + }, + org: "ivuorinen", + repo: "actions", + version: "@v1", + want: "ivuorinen/actions@v1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := buildUsesString(tt.td, tt.org, tt.repo, tt.version) + + // Normalize paths for cross-platform compatibility + want := filepath.ToSlash(tt.want) + got = filepath.ToSlash(got) + + if got != want { + t.Errorf("buildUsesString() = %q, want %q", got, want) + } + }) + } +} + +// TestGetActionVersion tests the getActionVersion function with priority logic. +func TestGetActionVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data any + want string + }{ + { + name: "config version override", + data: newTemplateData("", "v2.0.0", true, "main", "", "", "", ""), + want: "v2.0.0", + }, + { + name: "use default branch when enabled", + data: newTemplateData("", "", true, "main", "", "", "", ""), + want: "main", + }, + { + name: "use default branch master", + data: newTemplateData("", "", true, "master", "", "", "", ""), + want: "master", + }, + { + name: "fallback to v1 when default branch disabled", + data: newTemplateData("", "", false, "main", "", "", "", ""), + want: "v1", + }, + { + name: "fallback to v1 when default branch not detected", + data: newTemplateData("", "", true, "", "", "", "", ""), + want: "v1", + }, + { + name: "fallback to v1 when data is invalid", + data: "invalid", + want: "v1", + }, + { + name: "fallback to v1 when data is nil", + data: nil, + want: "v1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := getActionVersion(tt.data) + if got != tt.want { + t.Errorf("getActionVersion() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestGetGitUsesString tests the complete integration of gitUsesString template function. +func TestGetGitUsesString(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + data *TemplateData + want string + }{ + { + name: "monorepo action with default branch", + data: newTemplateData("C# Build", "", true, "main", "ivuorinen", "actions", + "/repo/csharp-build/action.yml", "/repo"), + want: "ivuorinen/actions/csharp-build@main", + }, + { + name: "monorepo action with explicit version", + data: newTemplateData("Build Action", "v1.0.0", true, "main", "org", "actions", + appconstants.TestRepoBuildActionPath, "/repo"), + want: "org/actions/build@v1.0.0", + }, + { + name: "root level action with default branch", + data: newTemplateData("My Action", "", true, "develop", "user", "my-action", + appconstants.TestRepoActionPath, "/repo"), + want: "user/my-action@develop", + }, + { + name: "action with use_default_branch disabled", + data: newTemplateData("Test Action", "", false, "main", "org", "test", + appconstants.TestRepoActionPath, "/repo"), + want: "org/test@v1", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := getGitUsesString(tt.data) + + // Normalize paths for cross-platform compatibility + want := filepath.ToSlash(tt.want) + got = filepath.ToSlash(got) + + if got != want { + t.Errorf("getGitUsesString() = %q, want %q", got, want) + } + }) + } +} + +// TestFormatVersion tests the formatVersion function. +func TestFormatVersion(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + version string + want string + }{ + { + name: "empty version", + version: "", + want: "@v1", + }, + { + name: "whitespace only version", + version: " ", + want: "@v1", + }, + { + name: "version without @", + version: "v1.2.3", + want: appconstants.TestVersionV123, + }, + { + name: "version with @", + version: appconstants.TestVersionV123, + want: appconstants.TestVersionV123, + }, + { + name: "main branch", + version: "main", + want: "@main", + }, + { + name: "version with @ and spaces", + version: " @v2.0.0 ", + want: "@v2.0.0", + }, + { + name: "sha version", + version: "abc123", + want: "@abc123", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := formatVersion(tt.version) + if got != tt.want { + t.Errorf("formatVersion(%q) = %q, want %q", tt.version, got, tt.want) + } + }) + } +} + +// TestBuildTemplateData tests the BuildTemplateData function. +func TestBuildTemplateData(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + action *ActionYML + config *AppConfig + repoRoot string + actionPath string + wantOrg string + wantRepo string + }{ + { + name: "basic action with config overrides", + action: &ActionYML{ + Name: "Test Action", + Description: "Test description", + }, + config: &AppConfig{ + Organization: "testorg", + Repository: "testrepo", + }, + repoRoot: ".", + actionPath: "action.yml", + wantOrg: "testorg", + wantRepo: "testrepo", + }, + { + name: "action without config overrides", + action: &ActionYML{ + Name: "Another Action", + Description: "Another description", + }, + config: &AppConfig{}, + repoRoot: ".", + actionPath: "action.yml", + wantOrg: "", + wantRepo: "", + }, + { + name: "action with dependency analysis enabled", + action: &ActionYML{ + Name: "Dep Action", + Description: "Action with deps", + }, + config: &AppConfig{ + Organization: "deporg", + Repository: "deprepo", + AnalyzeDependencies: true, + }, + repoRoot: ".", + actionPath: "../testdata/composite-action/action.yml", + wantOrg: "deporg", + wantRepo: "deprepo", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + data := BuildTemplateData(tt.action, tt.config, tt.repoRoot, tt.actionPath) + assertTemplateData(t, data, tt.action, tt.config, tt.wantOrg, tt.wantRepo) + }) + } +} + +func assertTemplateData( + t *testing.T, + data *TemplateData, + action *ActionYML, + config *AppConfig, + wantOrg, wantRepo string, +) { + t.Helper() + + if data == nil { + t.Fatal("BuildTemplateData() returned nil") + } + + if data.ActionYML != action { + t.Error("BuildTemplateData() did not preserve ActionYML") + } + + if data.Config != config { + t.Error("BuildTemplateData() did not preserve Config") + } + + if config.Organization != "" && data.Git.Organization != wantOrg { + t.Errorf("BuildTemplateData() Git.Organization = %q, want %q", data.Git.Organization, wantOrg) + } + + if config.Repository != "" && data.Git.Repository != wantRepo { + t.Errorf("BuildTemplateData() Git.Repository = %q, want %q", data.Git.Repository, wantRepo) + } + + if config.AnalyzeDependencies && data.Dependencies == nil { + t.Error("BuildTemplateData() expected Dependencies to be set when AnalyzeDependencies is true") + } +} + +// TestAnalyzeDependencies tests the analyzeDependencies function. +func TestAnalyzeDependencies(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + actionPath string + config *AppConfig + expectNil bool + }{ + { + name: "valid composite action without GitHub token", + actionPath: "../../testdata/analyzer/composite-action.yml", + config: &AppConfig{}, + expectNil: false, + }, + { + name: "nonexistent action file", + actionPath: "../../testdata/analyzer/nonexistent.yml", + config: &AppConfig{}, + expectNil: false, // Should return empty slice, not nil + }, + { + name: "docker action without token", + actionPath: "../../testdata/analyzer/docker-action.yml", + config: &AppConfig{}, + expectNil: false, + }, + { + name: "javascript action without token", + actionPath: "../../testdata/analyzer/javascript-action.yml", + config: &AppConfig{}, + expectNil: false, + }, + { + name: "invalid yaml file", + actionPath: "../../testdata/analyzer/invalid.yml", + config: &AppConfig{}, + expectNil: false, // Should gracefully handle errors and return empty slice + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + gitInfo := git.RepoInfo{ + Organization: "testorg", + Repository: "testrepo", + } + + result := analyzeDependencies(tt.actionPath, tt.config, gitInfo) + + if tt.expectNil && result != nil { + t.Errorf("analyzeDependencies() expected nil, got %v", result) + } + + if !tt.expectNil && result == nil { + t.Error("analyzeDependencies() returned nil, expected non-nil slice") + } + }) + } +} diff --git a/templates_embed/templates/readme.tmpl b/templates_embed/templates/readme.tmpl index c0537ef..527fb86 100644 --- a/templates_embed/templates/readme.tmpl +++ b/templates_embed/templates/readme.tmpl @@ -27,6 +27,24 @@ {{end}} {{end}} +{{if and .Permissions (gt (len .Permissions) 0)}} +## Permissions + +This action requires the following permissions: + +{{range $key, $value := .Permissions}} +- **`{{$key}}`**: `{{$value}}` +{{end}} + +**Usage in workflow:** +```yaml +permissions: +{{- range $key, $value := .Permissions}} + {{$key}}: {{$value}} +{{- end}} +``` +{{end}} + ## Example See the [action.yml](./action.yml) for a full reference. diff --git a/templates_embed/templates/themes/github/readme.tmpl b/templates_embed/templates/themes/github/readme.tmpl index 7b5f03d..7dbd264 100644 --- a/templates_embed/templates/themes/github/readme.tmpl +++ b/templates_embed/templates/themes/github/readme.tmpl @@ -45,6 +45,26 @@ jobs: {{- end}} {{end}} +{{if .Permissions}} +## ๐Ÿ” Permissions + +This action requires the following permissions: + +| Permission | Access Level | +|------------|--------------| +{{- range $key, $value := .Permissions}} +| `{{$key}}` | `{{$value}}` | +{{- end}} + +**Usage in workflow:** +```yaml +permissions: +{{- range $key, $value := .Permissions}} + {{$key}}: {{$value}} +{{- end}} +``` +{{end}} + ## ๐Ÿ’ก Examples
diff --git a/templates_embed/templates/themes/gitlab/readme.tmpl b/templates_embed/templates/themes/gitlab/readme.tmpl index 77e22ea..67efa74 100644 --- a/templates_embed/templates/themes/gitlab/readme.tmpl +++ b/templates_embed/templates/themes/gitlab/readme.tmpl @@ -60,6 +60,24 @@ steps: {{end}} {{end}} +{{if and .Permissions (gt (len .Permissions) 0)}} +### Required Permissions + +{{range $key, $value := .Permissions}} +#### `{{$key}}` +- **Access Level**: `{{$value}}` + +{{end}} + +**GitHub Actions Workflow:** +```yaml +permissions: +{{- range $key, $value := .Permissions}} + {{$key}}: {{$value}} +{{- end}} +``` +{{end}} + ## Usage Examples ### Basic Example diff --git a/templates_embed/templates/themes/minimal/readme.tmpl b/templates_embed/templates/themes/minimal/readme.tmpl index 199f934..6491e24 100644 --- a/templates_embed/templates/themes/minimal/readme.tmpl +++ b/templates_embed/templates/themes/minimal/readme.tmpl @@ -28,6 +28,14 @@ {{end}} {{end}} +{{if and .Permissions (gt (len .Permissions) 0)}} +## Permissions + +{{range $key, $value := .Permissions}} +- `{{$key}}`: `{{$value}}` +{{end}} +{{end}} + ## License MIT diff --git a/templates_embed/templates/themes/professional/readme.tmpl b/templates_embed/templates/themes/professional/readme.tmpl index b2eba1e..cff0e40 100644 --- a/templates_embed/templates/themes/professional/readme.tmpl +++ b/templates_embed/templates/themes/professional/readme.tmpl @@ -112,6 +112,38 @@ This action provides the following outputs that can be used in subsequent workfl ``` {{end}} +{{if .Permissions}} +## ๐Ÿ” Required Permissions + +This action requires specific GitHub permissions to function correctly. Ensure your workflow includes these permissions: + +| Permission | Access Level | Description | +|------------|--------------|-------------| +{{- range $key, $value := .Permissions}} +| `{{$key}}` | `{{$value}}` | Required for action operation | +{{- end}} + +### How to Set Permissions + +```yaml +name: My Workflow +on: [push] + +permissions: +{{- range $key, $value := .Permissions}} + {{$key}}: {{$value}} +{{- end}} + +jobs: + build: + runs-on: ubuntu-latest + steps: + - uses: {{gitUsesString .}} +``` + +**Note:** If your workflow doesn't specify permissions, GitHub uses default permissions which may not include all required permissions above. +{{end}} + ## Examples ### Basic Usage diff --git a/testdata/example-action/action.yml b/testdata/example-action/action.yml index d859260..b9b2cc8 100644 --- a/testdata/example-action/action.yml +++ b/testdata/example-action/action.yml @@ -1,3 +1,6 @@ +# yaml-language-server: $schema=https://json.schemastore.org/github-action.json +# permissions: +# - contents: read # Required for checking out repository --- name: Example Action description: 'Test Action for gh-action-readme'