From 3f924234adbb63ea1e9baace1ac66bb4f8f79b57 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Fri, 19 Dec 2025 00:29:40 -0800 Subject: [PATCH] feat(config): add merge_queue section to rig config schema MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add MergeQueueConfig struct and RigConfig type with: - All merge queue settings (enabled, target_branch, on_conflict, etc.) - Default values via DefaultMergeQueueConfig() - Validation for on_conflict strategy and poll_interval duration - Load/Save/Validate functions following existing config patterns - Comprehensive tests for round-trip, custom config, and validation Implements gt-h5n.8. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/config/loader.go | 102 +++++++++++++++++ internal/config/loader_test.go | 193 +++++++++++++++++++++++++++++++++ internal/config/types.go | 65 +++++++++++ 3 files changed, 360 insertions(+) diff --git a/internal/config/loader.go b/internal/config/loader.go index 4c123da2..885d279f 100644 --- a/internal/config/loader.go +++ b/internal/config/loader.go @@ -6,6 +6,7 @@ import ( "fmt" "os" "path/filepath" + "time" ) var ( @@ -186,3 +187,104 @@ func validateAgentState(s *AgentState) error { } return nil } + +// LoadRigConfig loads and validates a rig configuration file. +func LoadRigConfig(path string) (*RigConfig, error) { + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, fmt.Errorf("%w: %s", ErrNotFound, path) + } + return nil, fmt.Errorf("reading config: %w", err) + } + + var config RigConfig + if err := json.Unmarshal(data, &config); err != nil { + return nil, fmt.Errorf("parsing config: %w", err) + } + + if err := validateRigConfig(&config); err != nil { + return nil, err + } + + return &config, nil +} + +// SaveRigConfig saves a rig configuration to a file. +func SaveRigConfig(path string, config *RigConfig) error { + if err := validateRigConfig(config); err != nil { + return err + } + + if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { + return fmt.Errorf("creating directory: %w", err) + } + + data, err := json.MarshalIndent(config, "", " ") + if err != nil { + return fmt.Errorf("encoding config: %w", err) + } + + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing config: %w", err) + } + + return nil +} + +// validateRigConfig validates a RigConfig. +func validateRigConfig(c *RigConfig) error { + if c.Type != "rig" && c.Type != "" { + return fmt.Errorf("%w: expected type 'rig', got '%s'", ErrInvalidType, c.Type) + } + if c.Version > CurrentRigConfigVersion { + return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentRigConfigVersion) + } + + // Validate merge queue config if present + if c.MergeQueue != nil { + if err := validateMergeQueueConfig(c.MergeQueue); err != nil { + return err + } + } + + return nil +} + +// ErrInvalidOnConflict indicates an invalid on_conflict strategy. +var ErrInvalidOnConflict = errors.New("invalid on_conflict strategy") + +// validateMergeQueueConfig validates a MergeQueueConfig. +func validateMergeQueueConfig(c *MergeQueueConfig) error { + // Validate on_conflict strategy + if c.OnConflict != "" && c.OnConflict != OnConflictAssignBack && c.OnConflict != OnConflictAutoRebase { + return fmt.Errorf("%w: got '%s', want '%s' or '%s'", + ErrInvalidOnConflict, c.OnConflict, OnConflictAssignBack, OnConflictAutoRebase) + } + + // Validate poll_interval if specified + if c.PollInterval != "" { + if _, err := time.ParseDuration(c.PollInterval); err != nil { + return fmt.Errorf("invalid poll_interval: %w", err) + } + } + + // Validate non-negative values + if c.RetryFlakyTests < 0 { + return fmt.Errorf("%w: retry_flaky_tests must be non-negative", ErrMissingField) + } + if c.MaxConcurrent < 0 { + return fmt.Errorf("%w: max_concurrent must be non-negative", ErrMissingField) + } + + return nil +} + +// NewRigConfig creates a new RigConfig with defaults. +func NewRigConfig() *RigConfig { + return &RigConfig{ + Type: "rig", + Version: CurrentRigConfigVersion, + MergeQueue: DefaultMergeQueueConfig(), + } +} diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index d00fbe94..f318e274 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -130,3 +130,196 @@ func TestValidationErrors(t *testing.T) { t.Error("expected error for missing role") } } + +func TestRigConfigRoundTrip(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + original := NewRigConfig() + + if err := SaveRigConfig(path, original); err != nil { + t.Fatalf("SaveRigConfig: %v", err) + } + + loaded, err := LoadRigConfig(path) + if err != nil { + t.Fatalf("LoadRigConfig: %v", err) + } + + if loaded.Type != "rig" { + t.Errorf("Type = %q, want 'rig'", loaded.Type) + } + if loaded.Version != CurrentRigConfigVersion { + t.Errorf("Version = %d, want %d", loaded.Version, CurrentRigConfigVersion) + } + if loaded.MergeQueue == nil { + t.Fatal("MergeQueue is nil") + } + if !loaded.MergeQueue.Enabled { + t.Error("MergeQueue.Enabled = false, want true") + } + if loaded.MergeQueue.TargetBranch != "main" { + t.Errorf("MergeQueue.TargetBranch = %q, want 'main'", loaded.MergeQueue.TargetBranch) + } + if loaded.MergeQueue.OnConflict != OnConflictAssignBack { + t.Errorf("MergeQueue.OnConflict = %q, want %q", loaded.MergeQueue.OnConflict, OnConflictAssignBack) + } +} + +func TestRigConfigWithCustomMergeQueue(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.json") + + original := &RigConfig{ + Type: "rig", + Version: 1, + MergeQueue: &MergeQueueConfig{ + Enabled: true, + TargetBranch: "develop", + IntegrationBranches: false, + OnConflict: OnConflictAutoRebase, + RunTests: true, + TestCommand: "make test", + DeleteMergedBranches: false, + RetryFlakyTests: 3, + PollInterval: "1m", + MaxConcurrent: 2, + }, + } + + if err := SaveRigConfig(path, original); err != nil { + t.Fatalf("SaveRigConfig: %v", err) + } + + loaded, err := LoadRigConfig(path) + if err != nil { + t.Fatalf("LoadRigConfig: %v", err) + } + + mq := loaded.MergeQueue + if mq.TargetBranch != "develop" { + t.Errorf("TargetBranch = %q, want 'develop'", mq.TargetBranch) + } + if mq.OnConflict != OnConflictAutoRebase { + t.Errorf("OnConflict = %q, want %q", mq.OnConflict, OnConflictAutoRebase) + } + if mq.TestCommand != "make test" { + t.Errorf("TestCommand = %q, want 'make test'", mq.TestCommand) + } + if mq.RetryFlakyTests != 3 { + t.Errorf("RetryFlakyTests = %d, want 3", mq.RetryFlakyTests) + } + if mq.PollInterval != "1m" { + t.Errorf("PollInterval = %q, want '1m'", mq.PollInterval) + } + if mq.MaxConcurrent != 2 { + t.Errorf("MaxConcurrent = %d, want 2", mq.MaxConcurrent) + } +} + +func TestRigConfigValidation(t *testing.T) { + tests := []struct { + name string + config *RigConfig + wantErr bool + }{ + { + name: "valid config", + config: &RigConfig{ + Type: "rig", + Version: 1, + MergeQueue: DefaultMergeQueueConfig(), + }, + wantErr: false, + }, + { + name: "valid config without merge queue", + config: &RigConfig{ + Type: "rig", + Version: 1, + }, + wantErr: false, + }, + { + name: "wrong type", + config: &RigConfig{ + Type: "wrong", + Version: 1, + }, + wantErr: true, + }, + { + name: "invalid on_conflict", + config: &RigConfig{ + Type: "rig", + Version: 1, + MergeQueue: &MergeQueueConfig{ + OnConflict: "invalid", + }, + }, + wantErr: true, + }, + { + name: "invalid poll_interval", + config: &RigConfig{ + Type: "rig", + Version: 1, + MergeQueue: &MergeQueueConfig{ + PollInterval: "not-a-duration", + }, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := validateRigConfig(tt.config) + if (err != nil) != tt.wantErr { + t.Errorf("validateRigConfig() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestDefaultMergeQueueConfig(t *testing.T) { + cfg := DefaultMergeQueueConfig() + + if !cfg.Enabled { + t.Error("Enabled should be true by default") + } + if cfg.TargetBranch != "main" { + t.Errorf("TargetBranch = %q, want 'main'", cfg.TargetBranch) + } + if !cfg.IntegrationBranches { + t.Error("IntegrationBranches should be true by default") + } + if cfg.OnConflict != OnConflictAssignBack { + t.Errorf("OnConflict = %q, want %q", cfg.OnConflict, OnConflictAssignBack) + } + if !cfg.RunTests { + t.Error("RunTests should be true by default") + } + if cfg.TestCommand != "go test ./..." { + t.Errorf("TestCommand = %q, want 'go test ./...'", cfg.TestCommand) + } + if !cfg.DeleteMergedBranches { + t.Error("DeleteMergedBranches should be true by default") + } + if cfg.RetryFlakyTests != 1 { + t.Errorf("RetryFlakyTests = %d, want 1", cfg.RetryFlakyTests) + } + if cfg.PollInterval != "30s" { + t.Errorf("PollInterval = %q, want '30s'", cfg.PollInterval) + } + if cfg.MaxConcurrent != 1 { + t.Errorf("MaxConcurrent = %d, want 1", cfg.MaxConcurrent) + } +} + +func TestLoadRigConfigNotFound(t *testing.T) { + _, err := LoadRigConfig("/nonexistent/path.json") + if err == nil { + t.Fatal("expected error for nonexistent file") + } +} diff --git a/internal/config/types.go b/internal/config/types.go index 0c6a6933..fa77896e 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -43,3 +43,68 @@ const CurrentTownVersion = 1 // CurrentRigsVersion is the current schema version for RigsConfig. const CurrentRigsVersion = 1 + +// CurrentRigConfigVersion is the current schema version for RigConfig. +const CurrentRigConfigVersion = 1 + +// RigConfig represents the per-rig configuration (rig/config.json). +type RigConfig struct { + Type string `json:"type"` // "rig" + Version int `json:"version"` // schema version + MergeQueue *MergeQueueConfig `json:"merge_queue,omitempty"` // merge queue settings +} + +// MergeQueueConfig represents merge queue settings for a rig. +type MergeQueueConfig struct { + // Enabled controls whether the merge queue is active. + Enabled bool `json:"enabled"` + + // TargetBranch is the default branch to merge into (usually "main"). + TargetBranch string `json:"target_branch"` + + // IntegrationBranches enables integration branch workflow for epics. + IntegrationBranches bool `json:"integration_branches"` + + // OnConflict specifies conflict resolution strategy: "assign_back" or "auto_rebase". + OnConflict string `json:"on_conflict"` + + // RunTests controls whether to run tests before merging. + RunTests bool `json:"run_tests"` + + // TestCommand is the command to run for tests. + TestCommand string `json:"test_command,omitempty"` + + // DeleteMergedBranches controls whether to delete branches after merging. + DeleteMergedBranches bool `json:"delete_merged_branches"` + + // RetryFlakyTests is the number of times to retry flaky tests. + RetryFlakyTests int `json:"retry_flaky_tests"` + + // PollInterval is how often to poll for new merge requests (e.g., "30s"). + PollInterval string `json:"poll_interval"` + + // MaxConcurrent is the maximum number of concurrent merges. + MaxConcurrent int `json:"max_concurrent"` +} + +// OnConflict strategy constants. +const ( + OnConflictAssignBack = "assign_back" + OnConflictAutoRebase = "auto_rebase" +) + +// DefaultMergeQueueConfig returns a MergeQueueConfig with sensible defaults. +func DefaultMergeQueueConfig() *MergeQueueConfig { + return &MergeQueueConfig{ + Enabled: true, + TargetBranch: "main", + IntegrationBranches: true, + OnConflict: OnConflictAssignBack, + RunTests: true, + TestCommand: "go test ./...", + DeleteMergedBranches: true, + RetryFlakyTests: 1, + PollInterval: "30s", + MaxConcurrent: 1, + } +}