Files
gh-action-readme/internal/parser.go
Ismo Vuorinen ce23f93b74 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
2026-01-04 02:48:29 +02:00

282 lines
7.9 KiB
Go

package internal
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/goccy/go-yaml"
"github.com/ivuorinen/gh-action-readme/appconstants"
)
// ActionYML models the action.yml metadata (fields are updateable as schema evolves).
type ActionYML struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Inputs map[string]ActionInput `yaml:"inputs"`
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
}
// ActionInput represents an input parameter for a GitHub Action.
type ActionInput struct {
Description string `yaml:"description"`
Required bool `yaml:"required"`
Default any `yaml:"default"`
}
// ActionOutput represents an output parameter for a GitHub Action.
type ActionOutput struct {
Description string `yaml:"description"`
}
// Branding represents the branding configuration for a GitHub Action.
type Branding struct {
Icon string `yaml:"icon"`
Color string `yaml:"color"`
}
// 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
}
defer func() {
_ = f.Close() // Ignore close error in defer
}()
var a ActionYML
dec := yaml.NewDecoder(f)
if err := dec.Decode(&a); err != nil {
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 {
if strings.HasPrefix(ignored, ".") {
// Pattern match: ".git" matches ".git", ".github", etc.
if strings.HasPrefix(dirName, ignored) {
return true
}
} else {
// Exact match for non-hidden dirs
if dirName == ignored {
return true
}
}
}
return false
}
// actionFileWalker encapsulates the logic for walking directories and finding action files.
type actionFileWalker struct {
ignoredDirs []string
actionFiles []string
}
// walkFunc is the callback function for filepath.Walk.
func (w *actionFileWalker) walkFunc(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
if shouldIgnoreDirectory(info.Name(), w.ignoredDirs) {
return filepath.SkipDir
}
return nil
}
// Check for action.yml or action.yaml files
filename := strings.ToLower(info.Name())
if filename == appconstants.ActionFileNameYML || filename == appconstants.ActionFileNameYAML {
w.actionFiles = append(w.actionFiles, path)
}
return nil
}
// DiscoverActionFiles finds action.yml and action.yaml files in the given directory.
// This consolidates the file discovery logic from both generator.go and dependencies/parser.go.
func DiscoverActionFiles(dir string, recursive bool, ignoredDirs []string) ([]string, error) {
// Check if dir exists
if _, err := os.Stat(dir); os.IsNotExist(err) {
return nil, fmt.Errorf("directory does not exist: %s", dir)
}
if recursive {
walker := &actionFileWalker{ignoredDirs: ignoredDirs}
if err := filepath.Walk(dir, walker.walkFunc); err != nil {
return nil, fmt.Errorf("failed to walk directory %s: %w", dir, err)
}
return walker.actionFiles, nil
}
// Check only the specified directory (non-recursive)
return discoverActionFilesNonRecursive(dir), nil
}
// discoverActionFilesNonRecursive finds action files in a single directory.
func discoverActionFilesNonRecursive(dir string) []string {
var actionFiles []string
for _, filename := range []string{appconstants.ActionFileNameYML, appconstants.ActionFileNameYAML} {
path := filepath.Join(dir, filename)
if _, err := os.Stat(path); err == nil {
actionFiles = append(actionFiles, path)
}
}
return actionFiles
}