mirror of
https://github.com/ivuorinen/gh-action-readme.git
synced 2026-01-26 03:04:10 +00:00
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:
552
CLAUDE.md
552
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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
533
internal/template_test.go
Normal 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")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -28,6 +28,14 @@
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
{{if and .Permissions (gt (len .Permissions) 0)}}
|
||||
## Permissions
|
||||
|
||||
{{range $key, $value := .Permissions}}
|
||||
- `{{$key}}`: `{{$value}}`
|
||||
{{end}}
|
||||
{{end}}
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
|
||||
@@ -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
|
||||
|
||||
3
testdata/example-action/action.yml
vendored
3
testdata/example-action/action.yml
vendored
@@ -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'
|
||||
|
||||
Reference in New Issue
Block a user