// github_config.go defines configuration types for GitHub repository setup. // // Configuration is loaded from .core/github.yaml and supports environment // variable expansion using ${VAR} or ${VAR:-default} syntax. package setup import ( "fmt" "os" "path/filepath" "regexp" "strings" coreio "github.com/host-uk/core/pkg/io" "gopkg.in/yaml.v3" ) // GitHubConfig represents the full GitHub setup configuration. type GitHubConfig struct { Version int `yaml:"version"` Labels []LabelConfig `yaml:"labels"` Webhooks map[string]WebhookConfig `yaml:"webhooks"` BranchProtection []BranchProtectionConfig `yaml:"branch_protection"` Security SecurityConfig `yaml:"security"` } // LabelConfig defines a GitHub issue/PR label. type LabelConfig struct { Name string `yaml:"name"` Color string `yaml:"color"` Description string `yaml:"description"` } // WebhookConfig defines a GitHub webhook configuration. type WebhookConfig struct { URL string `yaml:"url"` // Webhook URL (supports ${ENV_VAR}) ContentType string `yaml:"content_type"` // json or form (default: json) Secret string `yaml:"secret"` // Optional secret (supports ${ENV_VAR}) Events []string `yaml:"events"` // Events to trigger on Active *bool `yaml:"active"` // Whether webhook is active (default: true) } // BranchProtectionConfig defines branch protection rules. type BranchProtectionConfig struct { Branch string `yaml:"branch"` RequiredReviews int `yaml:"required_reviews"` DismissStale bool `yaml:"dismiss_stale"` RequireCodeOwnerReviews bool `yaml:"require_code_owner_reviews"` RequiredStatusChecks []string `yaml:"required_status_checks"` RequireLinearHistory bool `yaml:"require_linear_history"` AllowForcePushes bool `yaml:"allow_force_pushes"` AllowDeletions bool `yaml:"allow_deletions"` EnforceAdmins bool `yaml:"enforce_admins"` RequireConversationResolution bool `yaml:"require_conversation_resolution"` } // SecurityConfig defines repository security settings. type SecurityConfig struct { DependabotAlerts bool `yaml:"dependabot_alerts"` DependabotSecurityUpdates bool `yaml:"dependabot_security_updates"` SecretScanning bool `yaml:"secret_scanning"` SecretScanningPushProtection bool `yaml:"push_protection"` } // LoadGitHubConfig reads and parses a GitHub configuration file. func LoadGitHubConfig(path string) (*GitHubConfig, error) { data, err := coreio.Local.Read(path) if err != nil { return nil, fmt.Errorf("failed to read config file: %w", err) } // Expand environment variables before parsing expanded := expandEnvVars(data) var config GitHubConfig if err := yaml.Unmarshal([]byte(expanded), &config); err != nil { return nil, fmt.Errorf("failed to parse config file: %w", err) } // Set defaults for i := range config.Webhooks { wh := config.Webhooks[i] if wh.ContentType == "" { wh.ContentType = "json" } if wh.Active == nil { active := true wh.Active = &active } config.Webhooks[i] = wh } return &config, nil } // envVarPattern matches ${VAR} or ${VAR:-default} patterns. var envVarPattern = regexp.MustCompile(`\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}`) // expandEnvVars expands environment variables in the input string. // Supports ${VAR} and ${VAR:-default} syntax. func expandEnvVars(input string) string { return envVarPattern.ReplaceAllStringFunc(input, func(match string) string { // Parse the match submatch := envVarPattern.FindStringSubmatch(match) if len(submatch) < 2 { return match } varName := submatch[1] defaultValue := "" if len(submatch) >= 3 { defaultValue = submatch[2] } // Look up the environment variable if value, ok := os.LookupEnv(varName); ok { return value } return defaultValue }) } // FindGitHubConfig searches for github.yaml in common locations. // Search order: // 1. Specified path (if non-empty) // 2. .core/github.yaml (relative to registry) // 3. github.yaml (relative to registry) func FindGitHubConfig(registryDir, specifiedPath string) (string, error) { if specifiedPath != "" { if coreio.Local.IsFile(specifiedPath) { return specifiedPath, nil } return "", fmt.Errorf("config file not found: %s", specifiedPath) } // Search in common locations (using filepath.Join for OS-portable paths) candidates := []string{ filepath.Join(registryDir, ".core", "github.yaml"), filepath.Join(registryDir, "github.yaml"), } for _, path := range candidates { if coreio.Local.IsFile(path) { return path, nil } } return "", fmt.Errorf("github.yaml not found in %s/.core/ or %s/", registryDir, registryDir) } // Validate checks the configuration for errors. func (c *GitHubConfig) Validate() error { if c.Version != 1 { return fmt.Errorf("unsupported config version: %d (expected 1)", c.Version) } // Validate labels for i, label := range c.Labels { if label.Name == "" { return fmt.Errorf("label %d: name is required", i+1) } if label.Color == "" { return fmt.Errorf("label %q: color is required", label.Name) } // Validate color format (hex without #) if !isValidHexColor(label.Color) { return fmt.Errorf("label %q: invalid color %q (expected 6-digit hex without #)", label.Name, label.Color) } } // Validate webhooks (skip those with empty URLs - allows optional webhooks via env vars) for name, wh := range c.Webhooks { if wh.URL == "" { // Empty URL is allowed - webhook will be skipped during sync continue } if len(wh.Events) == 0 { return fmt.Errorf("webhook %q: at least one event is required", name) } } // Validate branch protection for i, bp := range c.BranchProtection { if bp.Branch == "" { return fmt.Errorf("branch_protection %d: branch is required", i+1) } } return nil } // isValidHexColor checks if a string is a valid 6-digit hex color (without #). func isValidHexColor(color string) bool { if len(color) != 6 { return false } for _, c := range strings.ToLower(color) { if (c < '0' || c > '9') && (c < 'a' || c > 'f') { return false } } return true }