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:
parent
75d4057fe0
commit
11b47cb07f
10 changed files with 1737 additions and 4 deletions
|
|
@ -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
229
pkg/setup/cmd_github.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
203
pkg/setup/github_config.go
Normal 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
278
pkg/setup/github_diff.go
Normal 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
152
pkg/setup/github_labels.go
Normal 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
|
||||
}
|
||||
299
pkg/setup/github_protection.go
Normal file
299
pkg/setup/github_protection.go
Normal 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, ", ")
|
||||
}
|
||||
281
pkg/setup/github_security.go
Normal file
281
pkg/setup/github_security.go
Normal 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
|
||||
}
|
||||
263
pkg/setup/github_webhooks.go
Normal file
263
pkg/setup/github_webhooks.go
Normal 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
|
||||
}
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
package workspace
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/host-uk/core/pkg/cli"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue