cli/internal/cmd/setup/github_config.go
Snider 03c9188d79
feat: infrastructure packages and lint cleanup (#281)
* ci: consolidate duplicate workflows and merge CodeQL configs

Remove 17 duplicate workflow files that were split copies of the
combined originals. Each family (CI, CodeQL, Coverage, PR Build,
Alpha Release) had the same job duplicated across separate
push/pull_request/schedule/manual trigger files.

Merge codeql.yml and codescan.yml into a single codeql.yml with
a language matrix covering go, javascript-typescript, python,
and actions — matching the previous default setup coverage.

Remaining workflows (one per family):
- ci.yml (push + PR + manual)
- codeql.yml (push + PR + schedule, all languages)
- coverage.yml (push + PR + manual)
- alpha-release.yml (push + manual)
- pr-build.yml (PR + manual)
- release.yml (tag push)
- agent-verify.yml, auto-label.yml, auto-project.yml

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: add collect, config, crypt, plugin packages and fix all lint issues

Add four new infrastructure packages with CLI commands:
- pkg/config: layered configuration (defaults → file → env → flags)
- pkg/crypt: crypto primitives (Argon2id, AES-GCM, ChaCha20, HMAC, checksums)
- pkg/plugin: plugin system with GitHub-based install/update/remove
- pkg/collect: collection subsystem (GitHub, BitcoinTalk, market, papers, excavate)

Fix all golangci-lint issues across the entire codebase (~100 errcheck,
staticcheck SA1012/SA1019/ST1005, unused, ineffassign fixes) so that
`core go qa` passes with 0 issues.

Closes #167, #168, #170, #250, #251, #252, #253, #254, #255, #256

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-04 11:34:43 +00:00

204 lines
6.2 KiB
Go

// 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
}