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:
@@ -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(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user