feat: detect permissions from actions (#137)

* feat: detect permissions from actions

* refactor(test): fix 25 SonarCloud issues by extracting test constants

Resolved all SonarCloud code quality issues for PR #137:
- Fixed 12 string duplication issues (S1192)
- Fixed 13 naming convention issues (S100)

Changes:
- Centralized test constants in appconstants/test_constants.go
  * Added 9 parser test constants for YAML templates
  * Added 3 template test constants for paths and versions
- Updated parser_test.go to use shared constants
- Updated template_test.go to use shared constants
- Renamed 13 test functions to camelCase (removed underscores)

* chore: reduce code duplication

* fix: implement cr fixes

* chore: deduplication
This commit is contained in:
2026-01-04 02:48:29 +02:00
committed by GitHub
parent 9534bf9e45
commit ce23f93b74
12 changed files with 1797 additions and 170 deletions

552
CLAUDE.md
View File

@@ -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

View File

@@ -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"
)

View File

@@ -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,

View File

@@ -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 {

View File

@@ -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")
}
}

533
internal/template_test.go Normal file
View File

@@ -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")
}
})
}
}

View File

@@ -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.

View File

@@ -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
<details>

View File

@@ -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

View File

@@ -28,6 +28,14 @@
{{end}}
{{end}}
{{if and .Permissions (gt (len .Permissions) 0)}}
## Permissions
{{range $key, $value := .Permissions}}
- `{{$key}}`: `{{$value}}`
{{end}}
{{end}}
## License
MIT

View File

@@ -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

View File

@@ -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'