feat: implement Engineer main loop for merge queue processing
Adds the Engineer component that polls for ready merge-requests and processes them according to the merge queue design. Features: - Main loop that queries `bd ready` for merge-request type issues - Configurable poll_interval and max_concurrent from rig config.json - Graceful shutdown via context cancellation or Stop() method - Claims MRs via `bd update --status=in_progress` before processing - Handles success/failure with appropriate status updates Configuration (in rig config.json merge_queue section): - poll_interval: duration string (default "30s") - max_concurrent: number (default 1) - enabled, target_branch, run_tests, test_command, etc. Also adds ReadyWithType() to beads package for type-filtered queries. Note: ProcessMR() and handleFailure() are placeholders - full implementation in gt-3x1.2 and gt-3x1.4. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,209 @@
|
||||
package refinery
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/steveyegge/gastown/internal/rig"
|
||||
)
|
||||
|
||||
func TestDefaultMergeQueueConfig(t *testing.T) {
|
||||
cfg := DefaultMergeQueueConfig()
|
||||
|
||||
if !cfg.Enabled {
|
||||
t.Error("expected Enabled to be true by default")
|
||||
}
|
||||
if cfg.TargetBranch != "main" {
|
||||
t.Errorf("expected TargetBranch to be 'main', got %q", cfg.TargetBranch)
|
||||
}
|
||||
if cfg.PollInterval != 30*time.Second {
|
||||
t.Errorf("expected PollInterval to be 30s, got %v", cfg.PollInterval)
|
||||
}
|
||||
if cfg.MaxConcurrent != 1 {
|
||||
t.Errorf("expected MaxConcurrent to be 1, got %d", cfg.MaxConcurrent)
|
||||
}
|
||||
if cfg.OnConflict != "assign_back" {
|
||||
t.Errorf("expected OnConflict to be 'assign_back', got %q", cfg.OnConflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineer_LoadConfig_NoFile(t *testing.T) {
|
||||
// Create a temp directory without config.json
|
||||
tmpDir, err := os.MkdirTemp("", "engineer-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
r := &rig.Rig{
|
||||
Name: "test-rig",
|
||||
Path: tmpDir,
|
||||
}
|
||||
|
||||
e := NewEngineer(r)
|
||||
|
||||
// Should not error with missing config file
|
||||
if err := e.LoadConfig(); err != nil {
|
||||
t.Errorf("unexpected error with missing config: %v", err)
|
||||
}
|
||||
|
||||
// Should use defaults
|
||||
if e.config.PollInterval != 30*time.Second {
|
||||
t.Errorf("expected default PollInterval, got %v", e.config.PollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineer_LoadConfig_WithMergeQueue(t *testing.T) {
|
||||
// Create a temp directory with config.json
|
||||
tmpDir, err := os.MkdirTemp("", "engineer-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Write config file
|
||||
config := map[string]interface{}{
|
||||
"type": "rig",
|
||||
"version": 1,
|
||||
"name": "test-rig",
|
||||
"merge_queue": map[string]interface{}{
|
||||
"enabled": true,
|
||||
"target_branch": "develop",
|
||||
"poll_interval": "10s",
|
||||
"max_concurrent": 2,
|
||||
"run_tests": false,
|
||||
"test_command": "make test",
|
||||
},
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(config, "", " ")
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "config.json"), data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := &rig.Rig{
|
||||
Name: "test-rig",
|
||||
Path: tmpDir,
|
||||
}
|
||||
|
||||
e := NewEngineer(r)
|
||||
|
||||
if err := e.LoadConfig(); err != nil {
|
||||
t.Errorf("unexpected error loading config: %v", err)
|
||||
}
|
||||
|
||||
// Check that config values were loaded
|
||||
if e.config.TargetBranch != "develop" {
|
||||
t.Errorf("expected TargetBranch 'develop', got %q", e.config.TargetBranch)
|
||||
}
|
||||
if e.config.PollInterval != 10*time.Second {
|
||||
t.Errorf("expected PollInterval 10s, got %v", e.config.PollInterval)
|
||||
}
|
||||
if e.config.MaxConcurrent != 2 {
|
||||
t.Errorf("expected MaxConcurrent 2, got %d", e.config.MaxConcurrent)
|
||||
}
|
||||
if e.config.RunTests != false {
|
||||
t.Errorf("expected RunTests false, got %v", e.config.RunTests)
|
||||
}
|
||||
if e.config.TestCommand != "make test" {
|
||||
t.Errorf("expected TestCommand 'make test', got %q", e.config.TestCommand)
|
||||
}
|
||||
|
||||
// Check that defaults are preserved for unspecified fields
|
||||
if e.config.OnConflict != "assign_back" {
|
||||
t.Errorf("expected OnConflict default 'assign_back', got %q", e.config.OnConflict)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineer_LoadConfig_NoMergeQueueSection(t *testing.T) {
|
||||
// Create a temp directory with config.json without merge_queue
|
||||
tmpDir, err := os.MkdirTemp("", "engineer-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
// Write config file without merge_queue
|
||||
config := map[string]interface{}{
|
||||
"type": "rig",
|
||||
"version": 1,
|
||||
"name": "test-rig",
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(config, "", " ")
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "config.json"), data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := &rig.Rig{
|
||||
Name: "test-rig",
|
||||
Path: tmpDir,
|
||||
}
|
||||
|
||||
e := NewEngineer(r)
|
||||
|
||||
if err := e.LoadConfig(); err != nil {
|
||||
t.Errorf("unexpected error loading config: %v", err)
|
||||
}
|
||||
|
||||
// Should use all defaults
|
||||
if e.config.PollInterval != 30*time.Second {
|
||||
t.Errorf("expected default PollInterval, got %v", e.config.PollInterval)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineer_LoadConfig_InvalidPollInterval(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "engineer-test-*")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
|
||||
config := map[string]interface{}{
|
||||
"merge_queue": map[string]interface{}{
|
||||
"poll_interval": "not-a-duration",
|
||||
},
|
||||
}
|
||||
|
||||
data, _ := json.MarshalIndent(config, "", " ")
|
||||
if err := os.WriteFile(filepath.Join(tmpDir, "config.json"), data, 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
r := &rig.Rig{
|
||||
Name: "test-rig",
|
||||
Path: tmpDir,
|
||||
}
|
||||
|
||||
e := NewEngineer(r)
|
||||
|
||||
err = e.LoadConfig()
|
||||
if err == nil {
|
||||
t.Error("expected error for invalid poll_interval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewEngineer(t *testing.T) {
|
||||
r := &rig.Rig{
|
||||
Name: "test-rig",
|
||||
Path: "/tmp/test-rig",
|
||||
}
|
||||
|
||||
e := NewEngineer(r)
|
||||
|
||||
if e.rig != r {
|
||||
t.Error("expected rig to be set")
|
||||
}
|
||||
if e.beads == nil {
|
||||
t.Error("expected beads client to be initialized")
|
||||
}
|
||||
if e.config == nil {
|
||||
t.Error("expected config to be initialized with defaults")
|
||||
}
|
||||
if e.stopCh == nil {
|
||||
t.Error("expected stopCh to be initialized")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user