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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Snider 2026-02-01 03:37:06 +00:00 committed by GitHub
parent 75d4057fe0
commit 11b47cb07f
10 changed files with 1737 additions and 4 deletions

View file

@ -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 <name> 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}}...",

229
pkg/setup/cmd_github.go Normal file
View file

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

View file

@ -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)
}

203
pkg/setup/github_config.go Normal file
View file

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

278
pkg/setup/github_diff.go Normal file
View file

@ -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)))
}
}

152
pkg/setup/github_labels.go Normal file
View file

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

View file

@ -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, ", ")
}

View file

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

View file

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

View file

@ -1,7 +1,6 @@
package workspace
import (
"fmt"
"strings"
"github.com/host-uk/core/pkg/cli"