- Change module from forge.lthn.ai/core/go to forge.lthn.ai/core/cli - Remove pkg/ directory (now served from core/go) - Add require + replace for forge.lthn.ai/core/go => ../go - Update go.work to include ../go workspace module - Fix all internal/cmd/* imports: pkg/ refs → forge.lthn.ai/core/go/pkg/ - Rename internal/cmd/sdk package to sdkcmd (avoids conflict with pkg/sdk) - Remove SDK library files from internal/cmd/sdk/ (now in core/go/pkg/sdk/) - Remove duplicate RAG helper functions from internal/cmd/rag/ - Remove stale cmd/core-ide/ (now in core/ide repo) - Update IDE variant to remove core-ide import - Fix test assertion for new module name - Run go mod tidy to sync dependencies core/cli is now a pure CLI application importing core/go for packages. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Claude <developers@lethean.io> Reviewed-on: #1
288 lines
7.3 KiB
Go
288 lines
7.3 KiB
Go
// github_diff.go provides change tracking for dry-run output.
|
|
|
|
package setup
|
|
|
|
import (
|
|
"fmt"
|
|
"sort"
|
|
"strings"
|
|
|
|
"forge.lthn.ai/core/go/pkg/cli"
|
|
"forge.lthn.ai/core/go/pkg/i18n"
|
|
)
|
|
|
|
// ChangeType indicates the type of change being made.
|
|
type ChangeType string
|
|
|
|
// Change type constants for GitHub configuration diffs.
|
|
const (
|
|
// ChangeCreate indicates a new resource to be created.
|
|
ChangeCreate ChangeType = "create"
|
|
// ChangeUpdate indicates an existing resource to be updated.
|
|
ChangeUpdate ChangeType = "update"
|
|
// ChangeDelete indicates a resource to be deleted.
|
|
ChangeDelete ChangeType = "delete"
|
|
// ChangeSkip indicates a resource that requires no changes.
|
|
ChangeSkip ChangeType = "skip"
|
|
)
|
|
|
|
// ChangeCategory groups changes by type.
|
|
type ChangeCategory string
|
|
|
|
// Change category constants for grouping GitHub configuration changes.
|
|
const (
|
|
// CategoryLabel indicates label-related changes.
|
|
CategoryLabel ChangeCategory = "label"
|
|
// CategoryWebhook indicates webhook-related changes.
|
|
CategoryWebhook ChangeCategory = "webhook"
|
|
// CategoryProtection indicates branch protection changes.
|
|
CategoryProtection ChangeCategory = "protection"
|
|
// CategorySecurity indicates security settings changes.
|
|
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)))
|
|
}
|
|
}
|