From e04d6025dc411b187e0ef7d0a6429f8ad1998cd0 Mon Sep 17 00:00:00 2001 From: Snider Date: Sun, 1 Feb 2026 03:37:06 +0000 Subject: [PATCH] feat(setup): add github command for repo configuration (#59) * feat(setup): add github command for repo configuration (#45) Implements `core setup github` to configure GitHub repos with org standards including labels, webhooks, branch protection, and security settings. Supports dry-run mode, per-repo or all-repos operation, and selective sync of specific settings. Co-Authored-By: Claude Opus 4.5 * fix(setup): address CodeRabbit feedback on github command - Sort map keys for deterministic diff output in github_diff.go - Preserve partial results by adding changes before continue on errors - Reject conflicting --repo and --all flags with clear error message - Allow empty webhook URLs (skip instead of error) for optional env vars - Add content_type comparison in webhook sync - Add required_status_checks comparison in branch protection sync - Add DisableDependabotSecurityUpdates for bidirectional security control Co-Authored-By: Claude Opus 4.5 * fix(setup): address additional CodeRabbit feedback - Use filepath.Join for OS-portable path construction in github_config.go - Fix stringSliceEqual to use frequency counting for proper duplicate handling - Simplify change accumulation with variadic append Co-Authored-By: Claude Opus 4.5 --------- Co-authored-by: Claude Opus 4.5 --- pkg/i18n/locales/en_GB.json | 33 +++- pkg/setup/cmd_github.go | 229 +++++++++++++++++++++++++ pkg/setup/cmd_setup.go | 2 + pkg/setup/github_config.go | 203 ++++++++++++++++++++++ pkg/setup/github_diff.go | 278 ++++++++++++++++++++++++++++++ pkg/setup/github_labels.go | 152 +++++++++++++++++ pkg/setup/github_protection.go | 299 +++++++++++++++++++++++++++++++++ pkg/setup/github_security.go | 281 +++++++++++++++++++++++++++++++ pkg/setup/github_webhooks.go | 263 +++++++++++++++++++++++++++++ pkg/workspace/cmd_workspace.go | 1 - 10 files changed, 1737 insertions(+), 4 deletions(-) create mode 100644 pkg/setup/cmd_github.go create mode 100644 pkg/setup/github_config.go create mode 100644 pkg/setup/github_diff.go create mode 100644 pkg/setup/github_labels.go create mode 100644 pkg/setup/github_protection.go create mode 100644 pkg/setup/github_security.go create mode 100644 pkg/setup/github_webhooks.go diff --git a/pkg/i18n/locales/en_GB.json b/pkg/i18n/locales/en_GB.json index 0b82d791..c59a6c94 100644 --- a/pkg/i18n/locales/en_GB.json +++ b/pkg/i18n/locales/en_GB.json @@ -237,7 +237,31 @@ "bootstrap_mode": "Bootstrap mode (no repos.yaml found)", "nothing_to_clone": "Nothing to clone.", "wizard.select_packages": "Select packages to clone", - "wizard.what_to_do": "What would you like to do?" + "wizard.what_to_do": "What would you like to do?", + "github.short": "Configure GitHub repos with org standards", + "github.long": "Configure GitHub repositories with organisation standards including labels, webhooks, branch protection, and security settings.", + "github.flag.repo": "Specific repo to setup", + "github.flag.all": "Setup all repos in registry", + "github.flag.labels": "Only sync labels", + "github.flag.webhooks": "Only sync webhooks", + "github.flag.protection": "Only sync branch protection", + "github.flag.security": "Only sync security settings", + "github.flag.check": "Dry-run: show what would change", + "github.flag.config": "Path to github.yaml config", + "github.dry_run_mode": "(dry run) no changes will be made", + "github.no_repos_specified": "No repos specified.", + "github.usage_hint": "Use --repo for a single repo, or --all for all repos", + "github.no_changes": "no changes needed", + "github.repos_checked": "Repos checked", + "github.repos_with_changes": "Repos with changes", + "github.all_up_to_date": "All repos are up to date", + "github.to_create": "To create", + "github.to_update": "To update", + "github.to_delete": "To delete", + "github.run_without_check": "Run without --check to apply changes", + "github.error.not_authenticated": "GitHub CLI not authenticated. Run: gh auth login", + "github.error.config_not_found": "GitHub config file not found", + "github.error.conflicting_flags": "Cannot use --repo and --all together" }, "test": { "short": "Run Go tests with coverage" @@ -261,12 +285,15 @@ "fix": "Fix:", "install": "Install:", "summary": "Summary:", - "setup": "Setup:" + "setup": "Setup:", + "config": "Config:", + "repo": "Repo:" }, "flag": { "fix": "Auto-fix issues where possible", "diff": "Show diff of changes", - "json": "Output as JSON" + "json": "Output as JSON", + "verbose": "Show detailed output" }, "progress": { "running": "Running {{.Task}}...", diff --git a/pkg/setup/cmd_github.go b/pkg/setup/cmd_github.go new file mode 100644 index 00000000..47a20e03 --- /dev/null +++ b/pkg/setup/cmd_github.go @@ -0,0 +1,229 @@ +// cmd_github.go implements the 'setup github' command for configuring +// GitHub repositories with organization standards. +// +// Usage: +// core setup github [flags] +// +// Flags: +// -r, --repo string Specific repo to setup +// -a, --all Setup all repos in registry +// -l, --labels Only sync labels +// -w, --webhooks Only sync webhooks +// -p, --protection Only sync branch protection +// -s, --security Only sync security settings +// -c, --check Dry-run: show what would change +// --config string Path to github.yaml config +// --verbose Show detailed output + +package setup + +import ( + "errors" + "os/exec" + "path/filepath" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/i18n" + "github.com/host-uk/core/pkg/repos" + "github.com/spf13/cobra" +) + +// GitHub command flags +var ( + ghRepo string + ghAll bool + ghLabels bool + ghWebhooks bool + ghProtection bool + ghSecurity bool + ghCheck bool + ghConfigPath string + ghVerbose bool +) + +// addGitHubCommand adds the 'github' subcommand to the setup command. +func addGitHubCommand(parent *cobra.Command) { + ghCmd := &cobra.Command{ + Use: "github", + Aliases: []string{"gh"}, + Short: i18n.T("cmd.setup.github.short"), + Long: i18n.T("cmd.setup.github.long"), + RunE: func(cmd *cobra.Command, args []string) error { + return runGitHubSetup() + }, + } + + ghCmd.Flags().StringVarP(&ghRepo, "repo", "r", "", i18n.T("cmd.setup.github.flag.repo")) + ghCmd.Flags().BoolVarP(&ghAll, "all", "a", false, i18n.T("cmd.setup.github.flag.all")) + ghCmd.Flags().BoolVarP(&ghLabels, "labels", "l", false, i18n.T("cmd.setup.github.flag.labels")) + ghCmd.Flags().BoolVarP(&ghWebhooks, "webhooks", "w", false, i18n.T("cmd.setup.github.flag.webhooks")) + ghCmd.Flags().BoolVarP(&ghProtection, "protection", "p", false, i18n.T("cmd.setup.github.flag.protection")) + ghCmd.Flags().BoolVarP(&ghSecurity, "security", "s", false, i18n.T("cmd.setup.github.flag.security")) + ghCmd.Flags().BoolVarP(&ghCheck, "check", "c", false, i18n.T("cmd.setup.github.flag.check")) + ghCmd.Flags().StringVar(&ghConfigPath, "config", "", i18n.T("cmd.setup.github.flag.config")) + ghCmd.Flags().BoolVarP(&ghVerbose, "verbose", "v", false, i18n.T("common.flag.verbose")) + + parent.AddCommand(ghCmd) +} + +func runGitHubSetup() error { + // Check gh is available + if _, err := exec.LookPath("gh"); err != nil { + return errors.New(i18n.T("error.gh_not_found")) + } + + // Check gh is authenticated + if !cli.GhAuthenticated() { + return errors.New(i18n.T("cmd.setup.github.error.not_authenticated")) + } + + // Find registry + registryPath, err := repos.FindRegistry() + if err != nil { + return cli.Wrap(err, i18n.T("error.registry_not_found")) + } + + reg, err := repos.LoadRegistry(registryPath) + if err != nil { + return cli.Wrap(err, "failed to load registry") + } + + registryDir := filepath.Dir(registryPath) + + // Find GitHub config + configPath, err := FindGitHubConfig(registryDir, ghConfigPath) + if err != nil { + return cli.Wrap(err, i18n.T("cmd.setup.github.error.config_not_found")) + } + + config, err := LoadGitHubConfig(configPath) + if err != nil { + return cli.Wrap(err, "failed to load GitHub config") + } + + if err := config.Validate(); err != nil { + return cli.Wrap(err, "invalid GitHub config") + } + + // Print header + cli.Print("%s %s\n", dimStyle.Render(i18n.Label("registry")), registryPath) + cli.Print("%s %s\n", dimStyle.Render(i18n.Label("config")), configPath) + + if ghCheck { + cli.Print("%s\n", warningStyle.Render(i18n.T("cmd.setup.github.dry_run_mode"))) + } + + // Determine which repos to process + var reposToProcess []*repos.Repo + + // Reject conflicting flags + if ghRepo != "" && ghAll { + return errors.New(i18n.T("cmd.setup.github.error.conflicting_flags")) + } + + if ghRepo != "" { + // Single repo mode + repo, ok := reg.Get(ghRepo) + if !ok { + return errors.New(i18n.T("error.repo_not_found", map[string]interface{}{"Name": ghRepo})) + } + reposToProcess = []*repos.Repo{repo} + } else if ghAll { + // All repos mode + reposToProcess = reg.List() + } else { + // No repos specified + cli.Print("\n%s\n", i18n.T("cmd.setup.github.no_repos_specified")) + cli.Print(" %s\n", i18n.T("cmd.setup.github.usage_hint")) + return nil + } + + // Determine which operations to run + runAll := !ghLabels && !ghWebhooks && !ghProtection && !ghSecurity + runLabels := runAll || ghLabels + runWebhooks := runAll || ghWebhooks + runProtection := runAll || ghProtection + runSecurity := runAll || ghSecurity + + // Process each repo + aggregate := NewAggregate() + + for i, repo := range reposToProcess { + repoFullName := cli.Sprintf("%s/%s", reg.Org, repo.Name) + + // Show progress + cli.Print("\033[2K\r%s %d/%d %s", + dimStyle.Render(i18n.T("common.progress.checking")), + i+1, len(reposToProcess), repo.Name) + + changes := NewChangeSet(repo.Name) + + // Sync labels + if runLabels { + labelChanges, err := SyncLabels(repoFullName, config, ghCheck) + if err != nil { + cli.Print("\033[2K\r") + cli.Print("%s %s: %s\n", errorStyle.Render(cli.Glyph(":cross:")), repo.Name, err) + aggregate.Add(changes) // Preserve partial results + continue + } + changes.Changes = append(changes.Changes, labelChanges.Changes...) + } + + // Sync webhooks + if runWebhooks { + webhookChanges, err := SyncWebhooks(repoFullName, config, ghCheck) + if err != nil { + cli.Print("\033[2K\r") + cli.Print("%s %s: %s\n", errorStyle.Render(cli.Glyph(":cross:")), repo.Name, err) + aggregate.Add(changes) // Preserve partial results + continue + } + changes.Changes = append(changes.Changes, webhookChanges.Changes...) + } + + // Sync branch protection + if runProtection { + protectionChanges, err := SyncBranchProtection(repoFullName, config, ghCheck) + if err != nil { + cli.Print("\033[2K\r") + cli.Print("%s %s: %s\n", errorStyle.Render(cli.Glyph(":cross:")), repo.Name, err) + aggregate.Add(changes) // Preserve partial results + continue + } + changes.Changes = append(changes.Changes, protectionChanges.Changes...) + } + + // Sync security settings + if runSecurity { + securityChanges, err := SyncSecuritySettings(repoFullName, config, ghCheck) + if err != nil { + cli.Print("\033[2K\r") + cli.Print("%s %s: %s\n", errorStyle.Render(cli.Glyph(":cross:")), repo.Name, err) + aggregate.Add(changes) // Preserve partial results + continue + } + changes.Changes = append(changes.Changes, securityChanges.Changes...) + } + + aggregate.Add(changes) + } + + // Clear progress line + cli.Print("\033[2K\r") + + // Print results + for _, cs := range aggregate.Sets { + cs.Print(ghVerbose || ghCheck) + } + + // Print summary + aggregate.PrintSummary() + + // Suggest permission fix if needed + if ghCheck { + cli.Print("\n%s\n", i18n.T("cmd.setup.github.run_without_check")) + } + + return nil +} diff --git a/pkg/setup/cmd_setup.go b/pkg/setup/cmd_setup.go index fb6766b8..adfc28ff 100644 --- a/pkg/setup/cmd_setup.go +++ b/pkg/setup/cmd_setup.go @@ -12,6 +12,7 @@ var ( repoNameStyle = cli.RepoStyle successStyle = cli.SuccessStyle errorStyle = cli.ErrorStyle + warningStyle = cli.WarningStyle dimStyle = cli.DimStyle ) @@ -53,5 +54,6 @@ func initSetupFlags() { // AddSetupCommand adds the 'setup' command to the given parent command. func AddSetupCommand(root *cobra.Command) { initSetupFlags() + addGitHubCommand(setupCmd) root.AddCommand(setupCmd) } diff --git a/pkg/setup/github_config.go b/pkg/setup/github_config.go new file mode 100644 index 00000000..3c673450 --- /dev/null +++ b/pkg/setup/github_config.go @@ -0,0 +1,203 @@ +// 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" + + "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 := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + // Expand environment variables before parsing + expanded := expandEnvVars(string(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 _, err := os.Stat(specifiedPath); err == nil { + 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 _, err := os.Stat(path); err == nil { + 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 +} diff --git a/pkg/setup/github_diff.go b/pkg/setup/github_diff.go new file mode 100644 index 00000000..8e64c3f8 --- /dev/null +++ b/pkg/setup/github_diff.go @@ -0,0 +1,278 @@ +// github_diff.go provides change tracking for dry-run output. + +package setup + +import ( + "fmt" + "sort" + "strings" + + "github.com/host-uk/core/pkg/cli" + "github.com/host-uk/core/pkg/i18n" +) + +// ChangeType indicates the type of change being made. +type ChangeType string + +const ( + ChangeCreate ChangeType = "create" + ChangeUpdate ChangeType = "update" + ChangeDelete ChangeType = "delete" + ChangeSkip ChangeType = "skip" +) + +// ChangeCategory groups changes by type. +type ChangeCategory string + +const ( + CategoryLabel ChangeCategory = "label" + CategoryWebhook ChangeCategory = "webhook" + CategoryProtection ChangeCategory = "protection" + CategorySecurity ChangeCategory = "security" +) + +// Change represents a single change to be made. +type Change struct { + Category ChangeCategory + Type ChangeType + Name string + Description string + Details map[string]string // Key-value details about the change +} + +// ChangeSet tracks all changes for a repository. +type ChangeSet struct { + Repo string + Changes []Change +} + +// NewChangeSet creates a new change set for a repository. +func NewChangeSet(repo string) *ChangeSet { + return &ChangeSet{ + Repo: repo, + Changes: make([]Change, 0), + } +} + +// Add adds a change to the set. +func (cs *ChangeSet) Add(category ChangeCategory, changeType ChangeType, name, description string) { + cs.Changes = append(cs.Changes, Change{ + Category: category, + Type: changeType, + Name: name, + Description: description, + Details: make(map[string]string), + }) +} + +// AddWithDetails adds a change with additional details. +func (cs *ChangeSet) AddWithDetails(category ChangeCategory, changeType ChangeType, name, description string, details map[string]string) { + cs.Changes = append(cs.Changes, Change{ + Category: category, + Type: changeType, + Name: name, + Description: description, + Details: details, + }) +} + +// HasChanges returns true if there are any non-skip changes. +func (cs *ChangeSet) HasChanges() bool { + for _, c := range cs.Changes { + if c.Type != ChangeSkip { + return true + } + } + return false +} + +// Count returns the number of changes by type. +func (cs *ChangeSet) Count() (creates, updates, deletes, skips int) { + for _, c := range cs.Changes { + switch c.Type { + case ChangeCreate: + creates++ + case ChangeUpdate: + updates++ + case ChangeDelete: + deletes++ + case ChangeSkip: + skips++ + } + } + return +} + +// CountByCategory returns changes grouped by category. +func (cs *ChangeSet) CountByCategory() map[ChangeCategory]int { + counts := make(map[ChangeCategory]int) + for _, c := range cs.Changes { + if c.Type != ChangeSkip { + counts[c.Category]++ + } + } + return counts +} + +// Print outputs the change set to the console. +func (cs *ChangeSet) Print(verbose bool) { + creates, updates, deletes, skips := cs.Count() + + // Print header + fmt.Printf("\n%s %s\n", dimStyle.Render(i18n.Label("repo")), repoNameStyle.Render(cs.Repo)) + + if !cs.HasChanges() { + fmt.Printf(" %s\n", dimStyle.Render(i18n.T("cmd.setup.github.no_changes"))) + return + } + + // Print summary + var parts []string + if creates > 0 { + parts = append(parts, successStyle.Render(fmt.Sprintf("+%d", creates))) + } + if updates > 0 { + parts = append(parts, warningStyle.Render(fmt.Sprintf("~%d", updates))) + } + if deletes > 0 { + parts = append(parts, errorStyle.Render(fmt.Sprintf("-%d", deletes))) + } + if skips > 0 && verbose { + parts = append(parts, dimStyle.Render(fmt.Sprintf("=%d", skips))) + } + fmt.Printf(" %s\n", strings.Join(parts, " ")) + + // Print details if verbose + if verbose { + cs.printByCategory(CategoryLabel, "Labels") + cs.printByCategory(CategoryWebhook, "Webhooks") + cs.printByCategory(CategoryProtection, "Branch protection") + cs.printByCategory(CategorySecurity, "Security") + } +} + +func (cs *ChangeSet) printByCategory(category ChangeCategory, title string) { + var categoryChanges []Change + for _, c := range cs.Changes { + if c.Category == category && c.Type != ChangeSkip { + categoryChanges = append(categoryChanges, c) + } + } + + if len(categoryChanges) == 0 { + return + } + + fmt.Printf("\n %s:\n", dimStyle.Render(title)) + for _, c := range categoryChanges { + icon := getChangeIcon(c.Type) + style := getChangeStyle(c.Type) + fmt.Printf(" %s %s", style.Render(icon), c.Name) + if c.Description != "" { + fmt.Printf(" %s", dimStyle.Render(c.Description)) + } + fmt.Println() + + // Print details (sorted for deterministic output) + keys := make([]string, 0, len(c.Details)) + for k := range c.Details { + keys = append(keys, k) + } + sort.Strings(keys) + for _, k := range keys { + fmt.Printf(" %s: %s\n", dimStyle.Render(k), c.Details[k]) + } + } +} + +func getChangeIcon(t ChangeType) string { + switch t { + case ChangeCreate: + return "+" + case ChangeUpdate: + return "~" + case ChangeDelete: + return "-" + default: + return "=" + } +} + +func getChangeStyle(t ChangeType) *cli.AnsiStyle { + switch t { + case ChangeCreate: + return successStyle + case ChangeUpdate: + return warningStyle + case ChangeDelete: + return errorStyle + default: + return dimStyle + } +} + +// Aggregate combines multiple change sets into a summary. +type Aggregate struct { + Sets []*ChangeSet +} + +// NewAggregate creates a new aggregate. +func NewAggregate() *Aggregate { + return &Aggregate{ + Sets: make([]*ChangeSet, 0), + } +} + +// Add adds a change set to the aggregate. +func (a *Aggregate) Add(cs *ChangeSet) { + a.Sets = append(a.Sets, cs) +} + +// TotalChanges returns the total number of changes across all sets. +func (a *Aggregate) TotalChanges() (creates, updates, deletes, skips int) { + for _, cs := range a.Sets { + c, u, d, s := cs.Count() + creates += c + updates += u + deletes += d + skips += s + } + return +} + +// ReposWithChanges returns the number of repos that have changes. +func (a *Aggregate) ReposWithChanges() int { + count := 0 + for _, cs := range a.Sets { + if cs.HasChanges() { + count++ + } + } + return count +} + +// PrintSummary outputs the aggregate summary. +func (a *Aggregate) PrintSummary() { + creates, updates, deletes, _ := a.TotalChanges() + reposWithChanges := a.ReposWithChanges() + + fmt.Println() + fmt.Printf("%s\n", dimStyle.Render(i18n.Label("summary"))) + fmt.Printf(" %s: %d\n", i18n.T("cmd.setup.github.repos_checked"), len(a.Sets)) + + if reposWithChanges == 0 { + fmt.Printf(" %s\n", dimStyle.Render(i18n.T("cmd.setup.github.all_up_to_date"))) + return + } + + fmt.Printf(" %s: %d\n", i18n.T("cmd.setup.github.repos_with_changes"), reposWithChanges) + if creates > 0 { + fmt.Printf(" %s: %s\n", i18n.T("cmd.setup.github.to_create"), successStyle.Render(fmt.Sprintf("%d", creates))) + } + if updates > 0 { + fmt.Printf(" %s: %s\n", i18n.T("cmd.setup.github.to_update"), warningStyle.Render(fmt.Sprintf("%d", updates))) + } + if deletes > 0 { + fmt.Printf(" %s: %s\n", i18n.T("cmd.setup.github.to_delete"), errorStyle.Render(fmt.Sprintf("%d", deletes))) + } +} diff --git a/pkg/setup/github_labels.go b/pkg/setup/github_labels.go new file mode 100644 index 00000000..c936eb04 --- /dev/null +++ b/pkg/setup/github_labels.go @@ -0,0 +1,152 @@ +// github_labels.go implements GitHub label synchronization. +// +// Uses the gh CLI for label operations: +// - gh label list --repo {repo} --json name,color,description +// - gh label create --repo {repo} {name} --color {color} --description {desc} +// - gh label edit --repo {repo} {name} --color {color} --description {desc} + +package setup + +import ( + "encoding/json" + "os/exec" + "strings" + + "github.com/host-uk/core/pkg/cli" +) + +// GitHubLabel represents a label as returned by the GitHub API. +type GitHubLabel struct { + Name string `json:"name"` + Color string `json:"color"` + Description string `json:"description"` +} + +// ListLabels fetches all labels for a repository. +func ListLabels(repoFullName string) ([]GitHubLabel, error) { + args := []string{ + "label", "list", + "--repo", repoFullName, + "--json", "name,color,description", + "--limit", "200", + } + + cmd := exec.Command("gh", args...) + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, cli.Err("%s", strings.TrimSpace(string(exitErr.Stderr))) + } + return nil, err + } + + var labels []GitHubLabel + if err := json.Unmarshal(output, &labels); err != nil { + return nil, err + } + + return labels, nil +} + +// CreateLabel creates a new label in a repository. +func CreateLabel(repoFullName string, label LabelConfig) error { + args := []string{ + "label", "create", + "--repo", repoFullName, + label.Name, + "--color", label.Color, + } + + if label.Description != "" { + args = append(args, "--description", label.Description) + } + + cmd := exec.Command("gh", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return cli.Err("%s", strings.TrimSpace(string(output))) + } + return nil +} + +// EditLabel updates an existing label in a repository. +func EditLabel(repoFullName string, label LabelConfig) error { + args := []string{ + "label", "edit", + "--repo", repoFullName, + label.Name, + "--color", label.Color, + } + + if label.Description != "" { + args = append(args, "--description", label.Description) + } + + cmd := exec.Command("gh", args...) + output, err := cmd.CombinedOutput() + if err != nil { + return cli.Err("%s", strings.TrimSpace(string(output))) + } + return nil +} + +// SyncLabels synchronizes labels for a repository. +// Returns a ChangeSet describing what was changed (or would be changed in dry-run mode). +func SyncLabels(repoFullName string, config *GitHubConfig, dryRun bool) (*ChangeSet, error) { + changes := NewChangeSet(repoFullName) + + // Get existing labels + existing, err := ListLabels(repoFullName) + if err != nil { + return nil, cli.Wrap(err, "failed to list labels") + } + + // Build lookup map + existingMap := make(map[string]GitHubLabel) + for _, label := range existing { + existingMap[strings.ToLower(label.Name)] = label + } + + // Process each configured label + for _, wantLabel := range config.Labels { + key := strings.ToLower(wantLabel.Name) + existing, exists := existingMap[key] + + if !exists { + // Create new label + changes.Add(CategoryLabel, ChangeCreate, wantLabel.Name, wantLabel.Description) + if !dryRun { + if err := CreateLabel(repoFullName, wantLabel); err != nil { + return changes, cli.Wrap(err, "failed to create label "+wantLabel.Name) + } + } + continue + } + + // Check if update is needed + needsUpdate := false + details := make(map[string]string) + + if !strings.EqualFold(existing.Color, wantLabel.Color) { + needsUpdate = true + details["color"] = existing.Color + " -> " + wantLabel.Color + } + if existing.Description != wantLabel.Description { + needsUpdate = true + details["description"] = "updated" + } + + if needsUpdate { + changes.AddWithDetails(CategoryLabel, ChangeUpdate, wantLabel.Name, "", details) + if !dryRun { + if err := EditLabel(repoFullName, wantLabel); err != nil { + return changes, cli.Wrap(err, "failed to update label "+wantLabel.Name) + } + } + } else { + changes.Add(CategoryLabel, ChangeSkip, wantLabel.Name, "up to date") + } + } + + return changes, nil +} diff --git a/pkg/setup/github_protection.go b/pkg/setup/github_protection.go new file mode 100644 index 00000000..916ffe83 --- /dev/null +++ b/pkg/setup/github_protection.go @@ -0,0 +1,299 @@ +// github_protection.go implements GitHub branch protection synchronization. +// +// Uses the gh api command for branch protection operations: +// - gh api repos/{owner}/{repo}/branches/{branch}/protection --method GET +// - gh api repos/{owner}/{repo}/branches/{branch}/protection --method PUT + +package setup + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/host-uk/core/pkg/cli" +) + +// GitHubBranchProtection represents branch protection rules from the GitHub API. +type GitHubBranchProtection struct { + RequiredStatusChecks *RequiredStatusChecks `json:"required_status_checks"` + RequiredPullRequestReviews *RequiredPullRequestReviews `json:"required_pull_request_reviews"` + EnforceAdmins *EnforceAdmins `json:"enforce_admins"` + RequiredLinearHistory *RequiredLinearHistory `json:"required_linear_history"` + AllowForcePushes *AllowForcePushes `json:"allow_force_pushes"` + AllowDeletions *AllowDeletions `json:"allow_deletions"` + RequiredConversationResolution *RequiredConversationResolution `json:"required_conversation_resolution"` +} + +// RequiredStatusChecks defines required CI checks. +type RequiredStatusChecks struct { + Strict bool `json:"strict"` + Contexts []string `json:"contexts"` +} + +// RequiredPullRequestReviews defines review requirements. +type RequiredPullRequestReviews struct { + DismissStaleReviews bool `json:"dismiss_stale_reviews"` + RequireCodeOwnerReviews bool `json:"require_code_owner_reviews"` + RequiredApprovingReviewCount int `json:"required_approving_review_count"` +} + +// EnforceAdmins indicates if admins are subject to rules. +type EnforceAdmins struct { + Enabled bool `json:"enabled"` +} + +// RequiredLinearHistory indicates if linear history is required. +type RequiredLinearHistory struct { + Enabled bool `json:"enabled"` +} + +// AllowForcePushes indicates if force pushes are allowed. +type AllowForcePushes struct { + Enabled bool `json:"enabled"` +} + +// AllowDeletions indicates if branch deletion is allowed. +type AllowDeletions struct { + Enabled bool `json:"enabled"` +} + +// RequiredConversationResolution indicates if conversation resolution is required. +type RequiredConversationResolution struct { + Enabled bool `json:"enabled"` +} + +// GetBranchProtection fetches branch protection rules for a branch. +func GetBranchProtection(repoFullName, branch string) (*GitHubBranchProtection, error) { + parts := strings.Split(repoFullName, "/") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid repo format: %s", repoFullName) + } + + endpoint := fmt.Sprintf("repos/%s/%s/branches/%s/protection", parts[0], parts[1], branch) + cmd := exec.Command("gh", "api", endpoint) + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := strings.TrimSpace(string(exitErr.Stderr)) + // Branch protection not enabled returns 404 + if strings.Contains(stderr, "404") || strings.Contains(stderr, "Branch not protected") { + return nil, nil // No protection set + } + if strings.Contains(stderr, "403") { + return nil, cli.Err("insufficient permissions to manage branch protection (requires admin)") + } + return nil, cli.Err("%s", stderr) + } + return nil, err + } + + var protection GitHubBranchProtection + if err := json.Unmarshal(output, &protection); err != nil { + return nil, err + } + + return &protection, nil +} + +// SetBranchProtection sets branch protection rules for a branch. +func SetBranchProtection(repoFullName, branch string, config BranchProtectionConfig) error { + parts := strings.Split(repoFullName, "/") + if len(parts) != 2 { + return fmt.Errorf("invalid repo format: %s", repoFullName) + } + + // Build the protection payload + payload := map[string]interface{}{ + "enforce_admins": config.EnforceAdmins, + "required_linear_history": config.RequireLinearHistory, + "allow_force_pushes": config.AllowForcePushes, + "allow_deletions": config.AllowDeletions, + "required_conversation_resolution": config.RequireConversationResolution, + } + + // Required pull request reviews + if config.RequiredReviews > 0 { + payload["required_pull_request_reviews"] = map[string]interface{}{ + "dismiss_stale_reviews": config.DismissStale, + "require_code_owner_reviews": config.RequireCodeOwnerReviews, + "required_approving_review_count": config.RequiredReviews, + } + } else { + payload["required_pull_request_reviews"] = nil + } + + // Required status checks + if len(config.RequiredStatusChecks) > 0 { + payload["required_status_checks"] = map[string]interface{}{ + "strict": true, + "contexts": config.RequiredStatusChecks, + } + } else { + payload["required_status_checks"] = nil + } + + // Restrictions (required but can be empty for non-org repos) + payload["restrictions"] = nil + + payloadJSON, err := json.Marshal(payload) + if err != nil { + return err + } + + endpoint := fmt.Sprintf("repos/%s/%s/branches/%s/protection", parts[0], parts[1], branch) + cmd := exec.Command("gh", "api", endpoint, "--method", "PUT", "--input", "-") + cmd.Stdin = strings.NewReader(string(payloadJSON)) + output, err := cmd.CombinedOutput() + if err != nil { + return cli.Err("%s", strings.TrimSpace(string(output))) + } + return nil +} + +// SyncBranchProtection synchronizes branch protection for a repository. +func SyncBranchProtection(repoFullName string, config *GitHubConfig, dryRun bool) (*ChangeSet, error) { + changes := NewChangeSet(repoFullName) + + // Skip if no branch protection configured + if len(config.BranchProtection) == 0 { + return changes, nil + } + + // Process each configured branch + for _, wantProtection := range config.BranchProtection { + branch := wantProtection.Branch + + // Get existing protection + existing, err := GetBranchProtection(repoFullName, branch) + if err != nil { + // If permission denied, note it but don't fail + if strings.Contains(err.Error(), "insufficient permissions") { + changes.Add(CategoryProtection, ChangeSkip, branch, "insufficient permissions") + continue + } + return nil, cli.Wrap(err, "failed to get protection for "+branch) + } + + // Check if protection needs to be created or updated + if existing == nil { + // Create new protection + changes.Add(CategoryProtection, ChangeCreate, branch, describeProtection(wantProtection)) + if !dryRun { + if err := SetBranchProtection(repoFullName, branch, wantProtection); err != nil { + return changes, cli.Wrap(err, "failed to set protection for "+branch) + } + } + continue + } + + // Compare and check if update is needed + needsUpdate := false + details := make(map[string]string) + + // Check required reviews + existingReviews := 0 + existingDismissStale := false + existingCodeOwner := false + if existing.RequiredPullRequestReviews != nil { + existingReviews = existing.RequiredPullRequestReviews.RequiredApprovingReviewCount + existingDismissStale = existing.RequiredPullRequestReviews.DismissStaleReviews + existingCodeOwner = existing.RequiredPullRequestReviews.RequireCodeOwnerReviews + } + + if existingReviews != wantProtection.RequiredReviews { + needsUpdate = true + details["required_reviews"] = fmt.Sprintf("%d -> %d", existingReviews, wantProtection.RequiredReviews) + } + if existingDismissStale != wantProtection.DismissStale { + needsUpdate = true + details["dismiss_stale"] = fmt.Sprintf("%v -> %v", existingDismissStale, wantProtection.DismissStale) + } + if existingCodeOwner != wantProtection.RequireCodeOwnerReviews { + needsUpdate = true + details["code_owner_reviews"] = fmt.Sprintf("%v -> %v", existingCodeOwner, wantProtection.RequireCodeOwnerReviews) + } + + // Check enforce admins + existingEnforceAdmins := false + if existing.EnforceAdmins != nil { + existingEnforceAdmins = existing.EnforceAdmins.Enabled + } + if existingEnforceAdmins != wantProtection.EnforceAdmins { + needsUpdate = true + details["enforce_admins"] = fmt.Sprintf("%v -> %v", existingEnforceAdmins, wantProtection.EnforceAdmins) + } + + // Check linear history + existingLinear := false + if existing.RequiredLinearHistory != nil { + existingLinear = existing.RequiredLinearHistory.Enabled + } + if existingLinear != wantProtection.RequireLinearHistory { + needsUpdate = true + details["linear_history"] = fmt.Sprintf("%v -> %v", existingLinear, wantProtection.RequireLinearHistory) + } + + // Check force pushes + existingForcePush := false + if existing.AllowForcePushes != nil { + existingForcePush = existing.AllowForcePushes.Enabled + } + if existingForcePush != wantProtection.AllowForcePushes { + needsUpdate = true + details["allow_force_pushes"] = fmt.Sprintf("%v -> %v", existingForcePush, wantProtection.AllowForcePushes) + } + + // Check deletions + existingDeletions := false + if existing.AllowDeletions != nil { + existingDeletions = existing.AllowDeletions.Enabled + } + if existingDeletions != wantProtection.AllowDeletions { + needsUpdate = true + details["allow_deletions"] = fmt.Sprintf("%v -> %v", existingDeletions, wantProtection.AllowDeletions) + } + + // Check required status checks + var existingStatusChecks []string + if existing.RequiredStatusChecks != nil { + existingStatusChecks = existing.RequiredStatusChecks.Contexts + } + if !stringSliceEqual(existingStatusChecks, wantProtection.RequiredStatusChecks) { + needsUpdate = true + details["status_checks"] = fmt.Sprintf("%v -> %v", existingStatusChecks, wantProtection.RequiredStatusChecks) + } + + if needsUpdate { + changes.AddWithDetails(CategoryProtection, ChangeUpdate, branch, "", details) + if !dryRun { + if err := SetBranchProtection(repoFullName, branch, wantProtection); err != nil { + return changes, cli.Wrap(err, "failed to update protection for "+branch) + } + } + } else { + changes.Add(CategoryProtection, ChangeSkip, branch, "up to date") + } + } + + return changes, nil +} + +// describeProtection returns a human-readable description of protection rules. +func describeProtection(p BranchProtectionConfig) string { + var parts []string + if p.RequiredReviews > 0 { + parts = append(parts, fmt.Sprintf("%d review(s)", p.RequiredReviews)) + } + if p.DismissStale { + parts = append(parts, "dismiss stale") + } + if p.EnforceAdmins { + parts = append(parts, "enforce admins") + } + if len(parts) == 0 { + return "basic protection" + } + return strings.Join(parts, ", ") +} diff --git a/pkg/setup/github_security.go b/pkg/setup/github_security.go new file mode 100644 index 00000000..07c828cf --- /dev/null +++ b/pkg/setup/github_security.go @@ -0,0 +1,281 @@ +// github_security.go implements GitHub security settings synchronization. +// +// Uses the gh api command for security settings: +// - gh api repos/{owner}/{repo}/vulnerability-alerts --method GET (check if enabled) +// - gh api repos/{owner}/{repo}/vulnerability-alerts --method PUT (enable) +// - gh api repos/{owner}/{repo}/automated-security-fixes --method PUT (enable dependabot updates) +// - gh api repos/{owner}/{repo} --method PATCH (security_and_analysis settings) + +package setup + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/host-uk/core/pkg/cli" +) + +// GitHubSecurityStatus represents the security settings status of a repository. +type GitHubSecurityStatus struct { + DependabotAlerts bool + DependabotSecurityUpdates bool + SecretScanning bool + SecretScanningPushProtection bool +} + +// GitHubRepoResponse contains security-related fields from repo API. +type GitHubRepoResponse struct { + SecurityAndAnalysis *SecurityAndAnalysis `json:"security_and_analysis"` +} + +// SecurityAndAnalysis contains security feature settings. +type SecurityAndAnalysis struct { + SecretScanning *SecurityFeature `json:"secret_scanning"` + SecretScanningPushProtection *SecurityFeature `json:"secret_scanning_push_protection"` + DependabotSecurityUpdates *SecurityFeature `json:"dependabot_security_updates"` +} + +// SecurityFeature represents a single security feature status. +type SecurityFeature struct { + Status string `json:"status"` // "enabled" or "disabled" +} + +// GetSecuritySettings fetches current security settings for a repository. +func GetSecuritySettings(repoFullName string) (*GitHubSecurityStatus, error) { + parts := strings.Split(repoFullName, "/") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid repo format: %s", repoFullName) + } + + status := &GitHubSecurityStatus{} + + // Check Dependabot alerts (vulnerability alerts) + endpoint := fmt.Sprintf("repos/%s/%s/vulnerability-alerts", parts[0], parts[1]) + cmd := exec.Command("gh", "api", endpoint, "--method", "GET") + _, err := cmd.Output() + if err == nil { + status.DependabotAlerts = true + } else if exitErr, ok := err.(*exec.ExitError); ok { + stderr := string(exitErr.Stderr) + // 404 means alerts are disabled, 204 means enabled + if strings.Contains(stderr, "403") { + return nil, cli.Err("insufficient permissions to check security settings") + } + // Other errors (like 404) mean alerts are disabled + status.DependabotAlerts = false + } + + // Get repo security_and_analysis settings + endpoint = fmt.Sprintf("repos/%s/%s", parts[0], parts[1]) + cmd = exec.Command("gh", "api", endpoint) + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, cli.Err("%s", strings.TrimSpace(string(exitErr.Stderr))) + } + return nil, err + } + + var repo GitHubRepoResponse + if err := json.Unmarshal(output, &repo); err != nil { + return nil, err + } + + if repo.SecurityAndAnalysis != nil { + if repo.SecurityAndAnalysis.SecretScanning != nil { + status.SecretScanning = repo.SecurityAndAnalysis.SecretScanning.Status == "enabled" + } + if repo.SecurityAndAnalysis.SecretScanningPushProtection != nil { + status.SecretScanningPushProtection = repo.SecurityAndAnalysis.SecretScanningPushProtection.Status == "enabled" + } + if repo.SecurityAndAnalysis.DependabotSecurityUpdates != nil { + status.DependabotSecurityUpdates = repo.SecurityAndAnalysis.DependabotSecurityUpdates.Status == "enabled" + } + } + + return status, nil +} + +// EnableDependabotAlerts enables Dependabot vulnerability alerts. +func EnableDependabotAlerts(repoFullName string) error { + parts := strings.Split(repoFullName, "/") + if len(parts) != 2 { + return fmt.Errorf("invalid repo format: %s", repoFullName) + } + + endpoint := fmt.Sprintf("repos/%s/%s/vulnerability-alerts", parts[0], parts[1]) + cmd := exec.Command("gh", "api", endpoint, "--method", "PUT") + output, err := cmd.CombinedOutput() + if err != nil { + return cli.Err("%s", strings.TrimSpace(string(output))) + } + return nil +} + +// EnableDependabotSecurityUpdates enables automated Dependabot security updates. +func EnableDependabotSecurityUpdates(repoFullName string) error { + parts := strings.Split(repoFullName, "/") + if len(parts) != 2 { + return fmt.Errorf("invalid repo format: %s", repoFullName) + } + + endpoint := fmt.Sprintf("repos/%s/%s/automated-security-fixes", parts[0], parts[1]) + cmd := exec.Command("gh", "api", endpoint, "--method", "PUT") + output, err := cmd.CombinedOutput() + if err != nil { + return cli.Err("%s", strings.TrimSpace(string(output))) + } + return nil +} + +// DisableDependabotSecurityUpdates disables automated Dependabot security updates. +func DisableDependabotSecurityUpdates(repoFullName string) error { + parts := strings.Split(repoFullName, "/") + if len(parts) != 2 { + return fmt.Errorf("invalid repo format: %s", repoFullName) + } + + endpoint := fmt.Sprintf("repos/%s/%s/automated-security-fixes", parts[0], parts[1]) + cmd := exec.Command("gh", "api", endpoint, "--method", "DELETE") + output, err := cmd.CombinedOutput() + if err != nil { + return cli.Err("%s", strings.TrimSpace(string(output))) + } + return nil +} + +// UpdateSecurityAndAnalysis updates security_and_analysis settings. +func UpdateSecurityAndAnalysis(repoFullName string, secretScanning, pushProtection bool) error { + parts := strings.Split(repoFullName, "/") + if len(parts) != 2 { + return fmt.Errorf("invalid repo format: %s", repoFullName) + } + + // Build the payload + payload := map[string]interface{}{ + "security_and_analysis": map[string]interface{}{ + "secret_scanning": map[string]string{ + "status": boolToStatus(secretScanning), + }, + "secret_scanning_push_protection": map[string]string{ + "status": boolToStatus(pushProtection), + }, + }, + } + + payloadJSON, err := json.Marshal(payload) + if err != nil { + return err + } + + endpoint := fmt.Sprintf("repos/%s/%s", parts[0], parts[1]) + cmd := exec.Command("gh", "api", endpoint, "--method", "PATCH", "--input", "-") + cmd.Stdin = strings.NewReader(string(payloadJSON)) + output, err := cmd.CombinedOutput() + if err != nil { + errStr := strings.TrimSpace(string(output)) + // Some repos (private without GHAS) don't support these features + if strings.Contains(errStr, "secret scanning") || strings.Contains(errStr, "not available") { + return nil // Silently skip unsupported features + } + return cli.Err("%s", errStr) + } + return nil +} + +func boolToStatus(b bool) string { + if b { + return "enabled" + } + return "disabled" +} + +// SyncSecuritySettings synchronizes security settings for a repository. +func SyncSecuritySettings(repoFullName string, config *GitHubConfig, dryRun bool) (*ChangeSet, error) { + changes := NewChangeSet(repoFullName) + + // Get current settings + existing, err := GetSecuritySettings(repoFullName) + if err != nil { + // If permission denied, note it but don't fail + if strings.Contains(err.Error(), "insufficient permissions") { + changes.Add(CategorySecurity, ChangeSkip, "all", "insufficient permissions") + return changes, nil + } + return nil, cli.Wrap(err, "failed to get security settings") + } + + wantConfig := config.Security + + // Check Dependabot alerts + if wantConfig.DependabotAlerts && !existing.DependabotAlerts { + changes.Add(CategorySecurity, ChangeCreate, "dependabot_alerts", "enable") + if !dryRun { + if err := EnableDependabotAlerts(repoFullName); err != nil { + return changes, cli.Wrap(err, "failed to enable dependabot alerts") + } + } + } else if !wantConfig.DependabotAlerts && existing.DependabotAlerts { + changes.Add(CategorySecurity, ChangeSkip, "dependabot_alerts", "cannot disable via API") + } else { + changes.Add(CategorySecurity, ChangeSkip, "dependabot_alerts", "up to date") + } + + // Check Dependabot security updates + if wantConfig.DependabotSecurityUpdates && !existing.DependabotSecurityUpdates { + changes.Add(CategorySecurity, ChangeCreate, "dependabot_security_updates", "enable") + if !dryRun { + if err := EnableDependabotSecurityUpdates(repoFullName); err != nil { + // This might fail if alerts aren't enabled first + return changes, cli.Wrap(err, "failed to enable dependabot security updates") + } + } + } else if !wantConfig.DependabotSecurityUpdates && existing.DependabotSecurityUpdates { + changes.Add(CategorySecurity, ChangeDelete, "dependabot_security_updates", "disable") + if !dryRun { + if err := DisableDependabotSecurityUpdates(repoFullName); err != nil { + return changes, cli.Wrap(err, "failed to disable dependabot security updates") + } + } + } else { + changes.Add(CategorySecurity, ChangeSkip, "dependabot_security_updates", "up to date") + } + + // Check secret scanning and push protection + needsSecurityUpdate := false + if wantConfig.SecretScanning != existing.SecretScanning { + needsSecurityUpdate = true + if wantConfig.SecretScanning { + changes.Add(CategorySecurity, ChangeCreate, "secret_scanning", "enable") + } else { + changes.Add(CategorySecurity, ChangeDelete, "secret_scanning", "disable") + } + } else { + changes.Add(CategorySecurity, ChangeSkip, "secret_scanning", "up to date") + } + + if wantConfig.SecretScanningPushProtection != existing.SecretScanningPushProtection { + needsSecurityUpdate = true + if wantConfig.SecretScanningPushProtection { + changes.Add(CategorySecurity, ChangeCreate, "push_protection", "enable") + } else { + changes.Add(CategorySecurity, ChangeDelete, "push_protection", "disable") + } + } else { + changes.Add(CategorySecurity, ChangeSkip, "push_protection", "up to date") + } + + // Apply security_and_analysis changes + if needsSecurityUpdate && !dryRun { + if err := UpdateSecurityAndAnalysis(repoFullName, wantConfig.SecretScanning, wantConfig.SecretScanningPushProtection); err != nil { + // Don't fail on unsupported features + if !strings.Contains(err.Error(), "not available") { + return changes, cli.Wrap(err, "failed to update security settings") + } + } + } + + return changes, nil +} diff --git a/pkg/setup/github_webhooks.go b/pkg/setup/github_webhooks.go new file mode 100644 index 00000000..11d395d0 --- /dev/null +++ b/pkg/setup/github_webhooks.go @@ -0,0 +1,263 @@ +// github_webhooks.go implements GitHub webhook synchronization. +// +// Uses the gh api command for webhook operations: +// - gh api repos/{owner}/{repo}/hooks --method GET +// - gh api repos/{owner}/{repo}/hooks --method POST + +package setup + +import ( + "encoding/json" + "fmt" + "os/exec" + "strings" + + "github.com/host-uk/core/pkg/cli" +) + +// GitHubWebhook represents a webhook as returned by the GitHub API. +type GitHubWebhook struct { + ID int `json:"id"` + Name string `json:"name"` + Active bool `json:"active"` + Events []string `json:"events"` + Config GitHubWebhookConfig `json:"config"` +} + +// GitHubWebhookConfig contains webhook configuration details. +type GitHubWebhookConfig struct { + URL string `json:"url"` + ContentType string `json:"content_type"` + InsecureSSL string `json:"insecure_ssl"` +} + +// ListWebhooks fetches all webhooks for a repository. +func ListWebhooks(repoFullName string) ([]GitHubWebhook, error) { + parts := strings.Split(repoFullName, "/") + if len(parts) != 2 { + return nil, fmt.Errorf("invalid repo format: %s", repoFullName) + } + + endpoint := fmt.Sprintf("repos/%s/%s/hooks", parts[0], parts[1]) + cmd := exec.Command("gh", "api", endpoint) + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + stderr := strings.TrimSpace(string(exitErr.Stderr)) + // Check for permission error + if strings.Contains(stderr, "Must have admin rights") || strings.Contains(stderr, "403") { + return nil, cli.Err("insufficient permissions to manage webhooks (requires admin)") + } + return nil, cli.Err("%s", stderr) + } + return nil, err + } + + var hooks []GitHubWebhook + if err := json.Unmarshal(output, &hooks); err != nil { + return nil, err + } + + return hooks, nil +} + +// CreateWebhook creates a new webhook in a repository. +func CreateWebhook(repoFullName string, name string, config WebhookConfig) error { + parts := strings.Split(repoFullName, "/") + if len(parts) != 2 { + return fmt.Errorf("invalid repo format: %s", repoFullName) + } + + // Build the webhook payload + payload := map[string]interface{}{ + "name": "web", + "active": true, + "events": config.Events, + "config": map[string]interface{}{ + "url": config.URL, + "content_type": config.ContentType, + "insecure_ssl": "0", + }, + } + + if config.Active != nil { + payload["active"] = *config.Active + } + + if config.Secret != "" { + configMap := payload["config"].(map[string]interface{}) + configMap["secret"] = config.Secret + } + + payloadJSON, err := json.Marshal(payload) + if err != nil { + return err + } + + endpoint := fmt.Sprintf("repos/%s/%s/hooks", parts[0], parts[1]) + cmd := exec.Command("gh", "api", endpoint, "--method", "POST", "--input", "-") + cmd.Stdin = strings.NewReader(string(payloadJSON)) + output, err := cmd.CombinedOutput() + if err != nil { + return cli.Err("%s", strings.TrimSpace(string(output))) + } + return nil +} + +// UpdateWebhook updates an existing webhook. +func UpdateWebhook(repoFullName string, hookID int, config WebhookConfig) error { + parts := strings.Split(repoFullName, "/") + if len(parts) != 2 { + return fmt.Errorf("invalid repo format: %s", repoFullName) + } + + payload := map[string]interface{}{ + "active": true, + "events": config.Events, + "config": map[string]interface{}{ + "url": config.URL, + "content_type": config.ContentType, + "insecure_ssl": "0", + }, + } + + if config.Active != nil { + payload["active"] = *config.Active + } + + if config.Secret != "" { + configMap := payload["config"].(map[string]interface{}) + configMap["secret"] = config.Secret + } + + payloadJSON, err := json.Marshal(payload) + if err != nil { + return err + } + + endpoint := fmt.Sprintf("repos/%s/%s/hooks/%d", parts[0], parts[1], hookID) + cmd := exec.Command("gh", "api", endpoint, "--method", "PATCH", "--input", "-") + cmd.Stdin = strings.NewReader(string(payloadJSON)) + output, err := cmd.CombinedOutput() + if err != nil { + return cli.Err("%s", strings.TrimSpace(string(output))) + } + return nil +} + +// SyncWebhooks synchronizes webhooks for a repository. +// Webhooks are matched by URL - if a webhook with the same URL exists, it's updated. +// Otherwise, a new webhook is created. +func SyncWebhooks(repoFullName string, config *GitHubConfig, dryRun bool) (*ChangeSet, error) { + changes := NewChangeSet(repoFullName) + + // Skip if no webhooks configured + if len(config.Webhooks) == 0 { + return changes, nil + } + + // Get existing webhooks + existing, err := ListWebhooks(repoFullName) + if err != nil { + // If permission denied, note it but don't fail entirely + if strings.Contains(err.Error(), "insufficient permissions") { + changes.Add(CategoryWebhook, ChangeSkip, "all", "insufficient permissions") + return changes, nil + } + return nil, cli.Wrap(err, "failed to list webhooks") + } + + // Build lookup map by URL + existingByURL := make(map[string]GitHubWebhook) + for _, hook := range existing { + existingByURL[hook.Config.URL] = hook + } + + // Process each configured webhook + for name, wantHook := range config.Webhooks { + // Skip webhooks with empty URLs (env var not set) + if wantHook.URL == "" { + changes.Add(CategoryWebhook, ChangeSkip, name, "URL not configured") + continue + } + + existingHook, exists := existingByURL[wantHook.URL] + + if !exists { + // Create new webhook + changes.Add(CategoryWebhook, ChangeCreate, name, wantHook.URL) + if !dryRun { + if err := CreateWebhook(repoFullName, name, wantHook); err != nil { + return changes, cli.Wrap(err, "failed to create webhook "+name) + } + } + continue + } + + // Check if update is needed + needsUpdate := false + details := make(map[string]string) + + // Check events + if !stringSliceEqual(existingHook.Events, wantHook.Events) { + needsUpdate = true + details["events"] = fmt.Sprintf("%v -> %v", existingHook.Events, wantHook.Events) + } + + // Check content type + if existingHook.Config.ContentType != wantHook.ContentType { + needsUpdate = true + details["content_type"] = fmt.Sprintf("%s -> %s", existingHook.Config.ContentType, wantHook.ContentType) + } + + // Check active state + wantActive := true + if wantHook.Active != nil { + wantActive = *wantHook.Active + } + if existingHook.Active != wantActive { + needsUpdate = true + details["active"] = fmt.Sprintf("%v -> %v", existingHook.Active, wantActive) + } + + if needsUpdate { + changes.AddWithDetails(CategoryWebhook, ChangeUpdate, name, "", details) + if !dryRun { + if err := UpdateWebhook(repoFullName, existingHook.ID, wantHook); err != nil { + return changes, cli.Wrap(err, "failed to update webhook "+name) + } + } + } else { + changes.Add(CategoryWebhook, ChangeSkip, name, "up to date") + } + } + + return changes, nil +} + +// stringSliceEqual compares two string slices for equality (order-independent). +// Uses frequency counting to properly handle duplicates. +func stringSliceEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + // Count frequencies in slice a + counts := make(map[string]int) + for _, s := range a { + counts[s]++ + } + // Decrement for each element in slice b + for _, s := range b { + counts[s]-- + if counts[s] < 0 { + return false + } + } + // All counts should be zero if slices are equal + for _, count := range counts { + if count != 0 { + return false + } + } + return true +} diff --git a/pkg/workspace/cmd_workspace.go b/pkg/workspace/cmd_workspace.go index c538ba10..c61b30cb 100644 --- a/pkg/workspace/cmd_workspace.go +++ b/pkg/workspace/cmd_workspace.go @@ -1,7 +1,6 @@ package workspace import ( - "fmt" "strings" "github.com/host-uk/core/pkg/cli"