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