Files
gh-action-readme/internal/config.go
Ismo Vuorinen ce02d36929 feat: add comprehensive security scanning and EditorConfig integration
- Add govulncheck, Snyk, and Trivy vulnerability scanning
- Create security workflow for automated scanning on push/PR/schedule
- Add gitleaks for secrets detection and prevention
- Implement EditorConfig linting with eclint and editorconfig-checker
- Update Makefile with security and formatting targets
- Create SECURITY.md with vulnerability reporting guidelines
- Configure Dependabot for automated dependency updates
- Fix all EditorConfig violations across codebase
- Update Go version to 1.23.10 to address stdlib vulnerabilities
- Add tests for internal/helpers package (80% coverage)
- Remove deprecated functions and migrate to error-returning patterns
- Fix YAML indentation in test fixtures to resolve test failures
2025-08-03 20:12:18 +03:00

583 lines
17 KiB
Go

// Package internal contains the internal implementation of gh-action-readme.
package internal
import (
"context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/adrg/xdg"
"github.com/gofri/go-github-ratelimit/github_ratelimit"
"github.com/google/go-github/v57/github"
"github.com/spf13/viper"
"golang.org/x/oauth2"
"github.com/ivuorinen/gh-action-readme/internal/git"
"github.com/ivuorinen/gh-action-readme/internal/validation"
)
// AppConfig represents the application configuration that can be used at multiple levels.
type AppConfig struct {
// GitHub API (Global Only - Security)
GitHubToken string `mapstructure:"github_token" yaml:"github_token,omitempty"` // Only in global config
// Repository Information (auto-detected, overridable)
Organization string `mapstructure:"organization" yaml:"organization,omitempty"`
Repository string `mapstructure:"repository" yaml:"repository,omitempty"`
Version string `mapstructure:"version" yaml:"version,omitempty"`
// Template Settings
Theme string `mapstructure:"theme" yaml:"theme"`
OutputFormat string `mapstructure:"output_format" yaml:"output_format"`
OutputDir string `mapstructure:"output_dir" yaml:"output_dir"`
// Legacy template fields (backward compatibility)
Template string `mapstructure:"template" yaml:"template,omitempty"`
Header string `mapstructure:"header" yaml:"header,omitempty"`
Footer string `mapstructure:"footer" yaml:"footer,omitempty"`
Schema string `mapstructure:"schema" yaml:"schema,omitempty"`
// Workflow Requirements
Permissions map[string]string `mapstructure:"permissions" yaml:"permissions,omitempty"`
RunsOn []string `mapstructure:"runs_on" yaml:"runs_on,omitempty"`
// Features
AnalyzeDependencies bool `mapstructure:"analyze_dependencies" yaml:"analyze_dependencies"`
ShowSecurityInfo bool `mapstructure:"show_security_info" yaml:"show_security_info"`
// Custom Template Variables
Variables map[string]string `mapstructure:"variables" yaml:"variables,omitempty"`
// Repository-specific overrides (Global config only)
RepoOverrides map[string]AppConfig `mapstructure:"repo_overrides" yaml:"repo_overrides,omitempty"`
// Behavior
Verbose bool `mapstructure:"verbose" yaml:"verbose"`
Quiet bool `mapstructure:"quiet" yaml:"quiet"`
// Default values for action.yml files (legacy)
Defaults DefaultValues `mapstructure:"defaults" yaml:"defaults,omitempty"`
}
// DefaultValues stores configurable default values for all fields (legacy support).
type DefaultValues struct {
Name string `yaml:"name"`
Description string `yaml:"description"`
Runs map[string]any `yaml:"runs"`
Branding Branding `yaml:"branding"`
}
// GitHubClient wraps the GitHub API client with rate limiting.
type GitHubClient struct {
Client *github.Client
Token string
}
// GetGitHubToken returns the GitHub token from environment variables or config.
func GetGitHubToken(config *AppConfig) string {
// Priority 1: Tool-specific env var
if token := os.Getenv("GH_README_GITHUB_TOKEN"); token != "" {
return token
}
// Priority 2: Standard GitHub env var
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
return token
}
// Priority 3: Global config only (never repo/action configs)
if config.GitHubToken != "" {
return config.GitHubToken
}
return "" // Graceful degradation
}
// NewGitHubClient creates a new GitHub API client with rate limiting.
func NewGitHubClient(token string) (*GitHubClient, error) {
var client *github.Client
if token != "" {
ctx := context.Background()
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
tc := oauth2.NewClient(ctx, ts)
// Add rate limiting with proper error handling
rateLimiter, err := github_ratelimit.NewRateLimitWaiterClient(tc.Transport)
if err != nil {
return nil, fmt.Errorf("failed to create rate limiter: %w", err)
}
client = github.NewClient(rateLimiter)
} else {
// For no token, use basic rate limiter
rateLimiter, err := github_ratelimit.NewRateLimitWaiterClient(nil)
if err != nil {
return nil, fmt.Errorf("failed to create rate limiter: %w", err)
}
client = github.NewClient(rateLimiter)
}
return &GitHubClient{
Client: client,
Token: token,
}, nil
}
// FillMissing applies defaults for missing fields in ActionYML (legacy support).
func FillMissing(action *ActionYML, defs DefaultValues) {
if action.Name == "" {
action.Name = defs.Name
}
if action.Description == "" {
action.Description = defs.Description
}
if len(action.Runs) == 0 && len(defs.Runs) > 0 {
action.Runs = defs.Runs
}
if action.Branding == nil && defs.Branding.Icon != "" {
action.Branding = &defs.Branding
}
}
// resolveTemplatePath resolves a template path relative to the binary directory if it's not absolute.
func resolveTemplatePath(templatePath string) string {
if filepath.IsAbs(templatePath) {
return templatePath
}
// Check if template exists in current directory first (for tests)
if _, err := os.Stat(templatePath); err == nil {
return templatePath
}
binaryDir, err := validation.GetBinaryDir()
if err != nil {
// Fallback to current working directory if we can't determine binary location
return templatePath
}
resolvedPath := filepath.Join(binaryDir, templatePath)
// Check if the resolved path exists, if not, try relative to current directory as fallback
if _, err := os.Stat(resolvedPath); os.IsNotExist(err) {
return templatePath
}
return resolvedPath
}
// resolveThemeTemplate resolves the template path based on the selected theme.
func resolveThemeTemplate(theme string) string {
var templatePath string
switch theme {
case "default":
templatePath = "templates/readme.tmpl"
case "github":
templatePath = "templates/themes/github/readme.tmpl"
case "gitlab":
templatePath = "templates/themes/gitlab/readme.tmpl"
case "minimal":
templatePath = "templates/themes/minimal/readme.tmpl"
case "professional":
templatePath = "templates/themes/professional/readme.tmpl"
case "":
// Empty theme should return empty path
return ""
default:
// Unknown theme should return empty path
return ""
}
return resolveTemplatePath(templatePath)
}
// DefaultAppConfig returns the default application configuration.
func DefaultAppConfig() *AppConfig {
return &AppConfig{
// Repository Information (will be auto-detected)
Organization: "",
Repository: "",
Version: "",
// Template Settings
Theme: "default", // default, github, gitlab, minimal, professional
OutputFormat: "md",
OutputDir: ".",
// Legacy template fields (backward compatibility)
Template: resolveTemplatePath("templates/readme.tmpl"),
Header: resolveTemplatePath("templates/header.tmpl"),
Footer: resolveTemplatePath("templates/footer.tmpl"),
Schema: resolveTemplatePath("schemas/schema.json"),
// Workflow Requirements
Permissions: map[string]string{},
RunsOn: []string{"ubuntu-latest"},
// Features
AnalyzeDependencies: false,
ShowSecurityInfo: false,
// Custom Template Variables
Variables: map[string]string{},
// Repository-specific overrides (empty by default)
RepoOverrides: map[string]AppConfig{},
// Behavior
Verbose: false,
Quiet: false,
// Default values for action.yml files (legacy)
Defaults: DefaultValues{
Name: "GitHub Action",
Description: "A reusable GitHub Action.",
Runs: map[string]any{},
Branding: Branding{
Icon: "activity",
Color: "blue",
},
},
}
}
// MergeConfigs merges a source config into a destination config, excluding security-sensitive fields.
func MergeConfigs(dst *AppConfig, src *AppConfig, allowTokens bool) {
mergeStringFields(dst, src)
mergeMapFields(dst, src)
mergeSliceFields(dst, src)
mergeBooleanFields(dst, src)
mergeSecurityFields(dst, src, allowTokens)
}
// mergeStringFields merges simple string fields from src to dst if non-empty.
func mergeStringFields(dst *AppConfig, src *AppConfig) {
stringFields := []struct {
dst *string
src string
}{
{&dst.Organization, src.Organization},
{&dst.Repository, src.Repository},
{&dst.Version, src.Version},
{&dst.Theme, src.Theme},
{&dst.OutputFormat, src.OutputFormat},
{&dst.OutputDir, src.OutputDir},
{&dst.Template, src.Template},
{&dst.Header, src.Header},
{&dst.Footer, src.Footer},
{&dst.Schema, src.Schema},
}
for _, field := range stringFields {
if field.src != "" {
*field.dst = field.src
}
}
}
// mergeMapFields merges map fields from src to dst if non-empty.
func mergeMapFields(dst *AppConfig, src *AppConfig) {
if len(src.Permissions) > 0 {
if dst.Permissions == nil {
dst.Permissions = make(map[string]string)
}
for k, v := range src.Permissions {
dst.Permissions[k] = v
}
}
if len(src.Variables) > 0 {
if dst.Variables == nil {
dst.Variables = make(map[string]string)
}
for k, v := range src.Variables {
dst.Variables[k] = v
}
}
}
// mergeSliceFields merges slice fields from src to dst if non-empty.
func mergeSliceFields(dst *AppConfig, src *AppConfig) {
if len(src.RunsOn) > 0 {
dst.RunsOn = make([]string, len(src.RunsOn))
copy(dst.RunsOn, src.RunsOn)
}
}
// mergeBooleanFields merges boolean fields from src to dst if true.
func mergeBooleanFields(dst *AppConfig, src *AppConfig) {
if src.AnalyzeDependencies {
dst.AnalyzeDependencies = src.AnalyzeDependencies
}
if src.ShowSecurityInfo {
dst.ShowSecurityInfo = src.ShowSecurityInfo
}
if src.Verbose {
dst.Verbose = src.Verbose
}
if src.Quiet {
dst.Quiet = src.Quiet
}
}
// mergeSecurityFields merges security-sensitive fields if allowed.
func mergeSecurityFields(dst *AppConfig, src *AppConfig, allowTokens bool) {
if allowTokens && src.GitHubToken != "" {
dst.GitHubToken = src.GitHubToken
}
if allowTokens && len(src.RepoOverrides) > 0 {
if dst.RepoOverrides == nil {
dst.RepoOverrides = make(map[string]AppConfig)
}
for k, v := range src.RepoOverrides {
dst.RepoOverrides[k] = v
}
}
}
// LoadRepoConfig loads repository-level configuration from hidden config files.
func LoadRepoConfig(repoRoot string) (*AppConfig, error) {
// Hidden config file paths in priority order
configPaths := []string{
".ghreadme.yaml", // Primary hidden config
".config/ghreadme.yaml", // Secondary hidden config
".github/ghreadme.yaml", // GitHub ecosystem standard
}
for _, configName := range configPaths {
configPath := filepath.Join(repoRoot, configName)
if _, err := os.Stat(configPath); err == nil {
// Config file found, load it
v := viper.New()
v.SetConfigFile(configPath)
v.SetConfigType("yaml")
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read repo config %s: %w", configPath, err)
}
var config AppConfig
if err := v.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("failed to unmarshal repo config: %w", err)
}
return &config, nil
}
}
// No config found, return empty config
return &AppConfig{}, nil
}
// LoadActionConfig loads action-level configuration from config.yaml.
func LoadActionConfig(actionDir string) (*AppConfig, error) {
configPath := filepath.Join(actionDir, "config.yaml")
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return &AppConfig{}, nil // No action config is fine
}
v := viper.New()
v.SetConfigFile(configPath)
v.SetConfigType("yaml")
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read action config %s: %w", configPath, err)
}
var config AppConfig
if err := v.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("failed to unmarshal action config: %w", err)
}
return &config, nil
}
// DetectRepositoryName detects the repository name from git remote URL.
func DetectRepositoryName(repoRoot string) string {
if repoRoot == "" {
return ""
}
info, err := git.DetectRepository(repoRoot)
if err != nil {
return ""
}
return info.GetRepositoryName()
}
// LoadConfiguration loads configuration with multi-level hierarchy.
func LoadConfiguration(configFile, repoRoot, actionDir string) (*AppConfig, error) {
// 1. Start with defaults
config := DefaultAppConfig()
// 2. Load global config
globalConfig, err := InitConfig(configFile)
if err != nil {
return nil, fmt.Errorf("failed to load global config: %w", err)
}
MergeConfigs(config, globalConfig, true) // Allow tokens for global config
// 3. Apply repo-specific overrides from global config
repoName := DetectRepositoryName(repoRoot)
if repoName != "" {
if repoOverride, exists := globalConfig.RepoOverrides[repoName]; exists {
MergeConfigs(config, &repoOverride, false) // No tokens in overrides
}
}
// 4. Load repository root ghreadme.yaml
if repoRoot != "" {
repoConfig, err := LoadRepoConfig(repoRoot)
if err != nil {
return nil, fmt.Errorf("failed to load repo config: %w", err)
}
MergeConfigs(config, repoConfig, false) // No tokens in repo config
}
// 5. Load action-specific config.yaml
if actionDir != "" {
actionConfig, err := LoadActionConfig(actionDir)
if err != nil {
return nil, fmt.Errorf("failed to load action config: %w", err)
}
MergeConfigs(config, actionConfig, false) // No tokens in action config
}
// 6. Apply environment variable overrides for GitHub token
// Check environment variables directly with higher priority
if token := os.Getenv("GH_README_GITHUB_TOKEN"); token != "" {
config.GitHubToken = token
} else if token := os.Getenv("GITHUB_TOKEN"); token != "" {
config.GitHubToken = token
}
return config, nil
}
// InitConfig initializes the global configuration using Viper with XDG compliance.
func InitConfig(configFile string) (*AppConfig, error) {
v := viper.New()
// Set configuration file name and type
v.SetConfigName("config")
v.SetConfigType("yaml")
// Add XDG-compliant configuration directory
configDir, err := xdg.ConfigFile("gh-action-readme")
if err != nil {
return nil, fmt.Errorf("failed to get XDG config directory: %w", err)
}
v.AddConfigPath(filepath.Dir(configDir))
// Add additional search paths
v.AddConfigPath(".") // current directory
v.AddConfigPath("$HOME/.config/gh-action-readme") // fallback
v.AddConfigPath("/etc/gh-action-readme") // system-wide
// Set environment variable prefix
v.SetEnvPrefix("GH_ACTION_README")
v.SetEnvKeyReplacer(strings.NewReplacer("-", "_", ".", "_"))
v.AutomaticEnv()
// Set defaults
defaults := DefaultAppConfig()
v.SetDefault("organization", defaults.Organization)
v.SetDefault("repository", defaults.Repository)
v.SetDefault("version", defaults.Version)
v.SetDefault("theme", defaults.Theme)
v.SetDefault("output_format", defaults.OutputFormat)
v.SetDefault("output_dir", defaults.OutputDir)
v.SetDefault("template", defaults.Template)
v.SetDefault("header", defaults.Header)
v.SetDefault("footer", defaults.Footer)
v.SetDefault("schema", defaults.Schema)
v.SetDefault("analyze_dependencies", defaults.AnalyzeDependencies)
v.SetDefault("show_security_info", defaults.ShowSecurityInfo)
v.SetDefault("verbose", defaults.Verbose)
v.SetDefault("quiet", defaults.Quiet)
v.SetDefault("defaults.name", defaults.Defaults.Name)
v.SetDefault("defaults.description", defaults.Defaults.Description)
v.SetDefault("defaults.branding.icon", defaults.Defaults.Branding.Icon)
v.SetDefault("defaults.branding.color", defaults.Defaults.Branding.Color)
// Use specific config file if provided
if configFile != "" {
v.SetConfigFile(configFile)
}
// Read configuration
if err := v.ReadInConfig(); err != nil {
if _, ok := err.(viper.ConfigFileNotFoundError); !ok {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
// Config file not found is not an error - we'll use defaults and env vars
}
// Unmarshal configuration into struct
var config AppConfig
if err := v.Unmarshal(&config); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
// Resolve template paths relative to binary if they're not absolute
config.Template = resolveTemplatePath(config.Template)
config.Header = resolveTemplatePath(config.Header)
config.Footer = resolveTemplatePath(config.Footer)
config.Schema = resolveTemplatePath(config.Schema)
return &config, nil
}
// WriteDefaultConfig writes a default configuration file to the XDG config directory.
func WriteDefaultConfig() error {
configFile, err := xdg.ConfigFile("gh-action-readme/config.yaml")
if err != nil {
return fmt.Errorf("failed to get XDG config file path: %w", err)
}
// Ensure the directory exists
if err := os.MkdirAll(filepath.Dir(configFile), 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
v := viper.New()
v.SetConfigFile(configFile)
v.SetConfigType("yaml")
// Set default values
defaults := DefaultAppConfig()
v.Set("theme", defaults.Theme)
v.Set("output_format", defaults.OutputFormat)
v.Set("output_dir", defaults.OutputDir)
v.Set("analyze_dependencies", defaults.AnalyzeDependencies)
v.Set("show_security_info", defaults.ShowSecurityInfo)
v.Set("verbose", defaults.Verbose)
v.Set("quiet", defaults.Quiet)
v.Set("template", defaults.Template)
v.Set("header", defaults.Header)
v.Set("footer", defaults.Footer)
v.Set("schema", defaults.Schema)
v.Set("defaults", defaults.Defaults)
if err := v.WriteConfig(); err != nil {
return fmt.Errorf("failed to write default config: %w", err)
}
return nil
}
// GetConfigPath returns the path to the configuration file.
func GetConfigPath() (string, error) {
configDir, err := xdg.ConfigFile("gh-action-readme/config.yaml")
if err != nil {
return "", fmt.Errorf("failed to get XDG config file path: %w", err)
}
return configDir, nil
}