feat(config): add merge_queue section to rig config schema

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 <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-12-19 00:29:40 -08:00
parent e799fe5491
commit 3f924234ad
3 changed files with 360 additions and 0 deletions

View File

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

View File

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

View File

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