go/pkg/setup/github_diff.go
Snider e04d6025dc
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>
2026-02-01 03:37:06 +00:00

278 lines
6.7 KiB
Go

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