Merge remote-tracking branch 'origin/polecat/Doof'

This commit is contained in:
Steve Yegge
2025-12-19 17:42:05 -08:00
9 changed files with 1230 additions and 346 deletions

View File

@@ -14,6 +14,7 @@ func BuiltinMolecules() []BuiltinMolecule {
EngineerInBoxMolecule(),
QuickFixMolecule(),
ResearchMolecule(),
InstallGoBinaryMolecule(),
}
}
@@ -97,6 +98,31 @@ Needs: investigate`,
}
}
// InstallGoBinaryMolecule returns the install-go-binary molecule definition.
// This is a single step to rebuild and install the gt binary after code changes.
func InstallGoBinaryMolecule() BuiltinMolecule {
return BuiltinMolecule{
ID: "mol-install-go-binary",
Title: "Install Go Binary",
Description: `Single step to rebuild and install the gt binary after code changes.
## Step: install
Build and install the gt binary locally.
Run from the rig directory:
` + "```" + `
go build -o gt ./cmd/gt
go install ./cmd/gt
` + "```" + `
Verify the installed binary is updated:
` + "```" + `
which gt
gt --version # if version command exists
` + "```",
}
}
// SeedBuiltinMolecules creates all built-in molecules in the beads database.
// It skips molecules that already exist (by title match).
// Returns the number of molecules created.

View File

@@ -5,8 +5,8 @@ import "testing"
func TestBuiltinMolecules(t *testing.T) {
molecules := BuiltinMolecules()
if len(molecules) != 3 {
t.Errorf("expected 3 built-in molecules, got %d", len(molecules))
if len(molecules) != 4 {
t.Errorf("expected 4 built-in molecules, got %d", len(molecules))
}
// Verify each molecule can be parsed and validated
@@ -141,3 +141,34 @@ func TestResearchMolecule(t *testing.T) {
t.Errorf("document should need investigate, got %v", steps[1].Needs)
}
}
func TestInstallGoBinaryMolecule(t *testing.T) {
mol := InstallGoBinaryMolecule()
if mol.ID != "mol-install-go-binary" {
t.Errorf("expected ID 'mol-install-go-binary', got %q", mol.ID)
}
if mol.Title != "Install Go Binary" {
t.Errorf("expected Title 'Install Go Binary', got %q", mol.Title)
}
steps, err := ParseMoleculeSteps(mol.Description)
if err != nil {
t.Fatalf("failed to parse: %v", err)
}
// Should have 1 step: install
if len(steps) != 1 {
t.Errorf("expected 1 step, got %d", len(steps))
}
if steps[0].Ref != "install" {
t.Errorf("expected ref 'install', got %q", steps[0].Ref)
}
// install has no deps
if len(steps[0].Needs) != 0 {
t.Errorf("install should have no deps, got %v", steps[0].Needs)
}
}

245
internal/cmd/deacon.go Normal file
View File

@@ -0,0 +1,245 @@
package cmd
import (
"errors"
"fmt"
"os/exec"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/tmux"
"github.com/steveyegge/gastown/internal/workspace"
)
// DeaconSessionName is the tmux session name for the Deacon.
const DeaconSessionName = "gt-deacon"
var deaconCmd = &cobra.Command{
Use: "deacon",
Aliases: []string{"dea"},
Short: "Manage the Deacon session",
Long: `Manage the Deacon tmux session.
The Deacon is the hierarchical health-check orchestrator for Gas Town.
It monitors the Mayor and Witnesses, handles lifecycle requests, and
keeps the town running. Use the subcommands to start, stop, attach,
and check status.`,
}
var deaconStartCmd = &cobra.Command{
Use: "start",
Short: "Start the Deacon session",
Long: `Start the Deacon tmux session.
Creates a new detached tmux session for the Deacon and launches Claude.
The session runs in the workspace root directory.`,
RunE: runDeaconStart,
}
var deaconStopCmd = &cobra.Command{
Use: "stop",
Short: "Stop the Deacon session",
Long: `Stop the Deacon tmux session.
Attempts graceful shutdown first (Ctrl-C), then kills the tmux session.`,
RunE: runDeaconStop,
}
var deaconAttachCmd = &cobra.Command{
Use: "attach",
Aliases: []string{"at"},
Short: "Attach to the Deacon session",
Long: `Attach to the running Deacon tmux session.
Attaches the current terminal to the Deacon's tmux session.
Detach with Ctrl-B D.`,
RunE: runDeaconAttach,
}
var deaconStatusCmd = &cobra.Command{
Use: "status",
Short: "Check Deacon session status",
Long: `Check if the Deacon tmux session is currently running.`,
RunE: runDeaconStatus,
}
var deaconRestartCmd = &cobra.Command{
Use: "restart",
Short: "Restart the Deacon session",
Long: `Restart the Deacon tmux session.
Stops the current session (if running) and starts a fresh one.`,
RunE: runDeaconRestart,
}
func init() {
deaconCmd.AddCommand(deaconStartCmd)
deaconCmd.AddCommand(deaconStopCmd)
deaconCmd.AddCommand(deaconAttachCmd)
deaconCmd.AddCommand(deaconStatusCmd)
deaconCmd.AddCommand(deaconRestartCmd)
rootCmd.AddCommand(deaconCmd)
}
func runDeaconStart(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
// Check if session already exists
running, err := t.HasSession(DeaconSessionName)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if running {
return fmt.Errorf("Deacon session already running. Attach with: gt deacon attach")
}
if err := startDeaconSession(t); err != nil {
return err
}
fmt.Printf("%s Deacon session started. Attach with: %s\n",
style.Bold.Render("✓"),
style.Dim.Render("gt deacon attach"))
return nil
}
// startDeaconSession creates and initializes the Deacon tmux session.
func startDeaconSession(t *tmux.Tmux) error {
// Find workspace root
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return fmt.Errorf("not in a Gas Town workspace: %w", err)
}
// Create session in workspace root
fmt.Println("Starting Deacon session...")
if err := t.NewSession(DeaconSessionName, townRoot); err != nil {
return fmt.Errorf("creating session: %w", err)
}
// Set environment
_ = t.SetEnvironment(DeaconSessionName, "GT_ROLE", "deacon")
// Launch Claude in a respawn loop - session survives restarts
// The startup hook handles context loading automatically
// Use SendKeysDelayed to allow shell initialization after NewSession
loopCmd := `while true; do echo "⛪ Starting Deacon session..."; claude --dangerously-skip-permissions; echo ""; echo "Deacon exited. Restarting in 2s... (Ctrl-C to stop)"; sleep 2; done`
if err := t.SendKeysDelayed(DeaconSessionName, loopCmd, 200); err != nil {
return fmt.Errorf("sending command: %w", err)
}
return nil
}
func runDeaconStop(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
// Check if session exists
running, err := t.HasSession(DeaconSessionName)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if !running {
return errors.New("Deacon session is not running")
}
fmt.Println("Stopping Deacon session...")
// Try graceful shutdown first
_ = t.SendKeysRaw(DeaconSessionName, "C-c")
time.Sleep(100 * time.Millisecond)
// Kill the session
if err := t.KillSession(DeaconSessionName); err != nil {
return fmt.Errorf("killing session: %w", err)
}
fmt.Printf("%s Deacon session stopped.\n", style.Bold.Render("✓"))
return nil
}
func runDeaconAttach(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
// Check if session exists
running, err := t.HasSession(DeaconSessionName)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if !running {
// Auto-start if not running
fmt.Println("Deacon session not running, starting...")
if err := startDeaconSession(t); err != nil {
return err
}
}
// Session uses a respawn loop, so Claude restarts automatically if it exits
// Use exec to replace current process with tmux attach
tmuxPath, err := exec.LookPath("tmux")
if err != nil {
return fmt.Errorf("tmux not found: %w", err)
}
return execCommand(tmuxPath, "attach-session", "-t", DeaconSessionName)
}
func runDeaconStatus(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
running, err := t.HasSession(DeaconSessionName)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if running {
// Get session info for more details
info, err := t.GetSessionInfo(DeaconSessionName)
if err == nil {
status := "detached"
if info.Attached {
status = "attached"
}
fmt.Printf("%s Deacon session is %s\n",
style.Bold.Render("●"),
style.Bold.Render("running"))
fmt.Printf(" Status: %s\n", status)
fmt.Printf(" Created: %s\n", info.Created)
fmt.Printf("\nAttach with: %s\n", style.Dim.Render("gt deacon attach"))
} else {
fmt.Printf("%s Deacon session is %s\n",
style.Bold.Render("●"),
style.Bold.Render("running"))
}
} else {
fmt.Printf("%s Deacon session is %s\n",
style.Dim.Render("○"),
"not running")
fmt.Printf("\nStart with: %s\n", style.Dim.Render("gt deacon start"))
}
return nil
}
func runDeaconRestart(cmd *cobra.Command, args []string) error {
t := tmux.NewTmux()
running, err := t.HasSession(DeaconSessionName)
if err != nil {
return fmt.Errorf("checking session: %w", err)
}
if running {
// Graceful restart: send Ctrl-C to exit Claude, loop will restart it
fmt.Println("Restarting Deacon (sending Ctrl-C to trigger respawn loop)...")
_ = t.SendKeysRaw(DeaconSessionName, "C-c")
fmt.Printf("%s Deacon will restart automatically. Session stays attached.\n", style.Bold.Render("✓"))
return nil
}
// Not running, start fresh
return runDeaconStart(cmd, args)
}

View File

@@ -543,8 +543,10 @@ func runMQList(cmd *cobra.Command, args []string) error {
b := beads.New(r.Path)
// Build list options - query for merge-request type
// Priority -1 means no priority filter (otherwise 0 would filter to P0 only)
opts := beads.ListOptions{
Type: "merge-request",
Type: "merge-request",
Priority: -1,
}
// Apply status filter if specified

View File

@@ -3,7 +3,9 @@ package daemon
import (
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
@@ -119,6 +121,11 @@ func (d *Daemon) executeLifecycleAction(request *LifecycleRequest) error {
d.logger.Printf("Executing %s for session %s", request.Action, sessionName)
// Verify agent state shows requesting_<action>=true before killing
if err := d.verifyAgentRequestingState(request.From, request.Action); err != nil {
return fmt.Errorf("state verification failed: %w", err)
}
// Check if session exists
running, err := d.tmux.HasSession(sessionName)
if err != nil {
@@ -229,3 +236,60 @@ func (d *Daemon) closeMessage(id string) error {
cmd.Dir = d.config.TownRoot
return cmd.Run()
}
// verifyAgentRequestingState verifies that the agent has set requesting_<action>=true
// in its state.json before we kill its session. This ensures the agent is actually
// ready to be killed and has completed its pre-shutdown tasks (git clean, handoff mail, etc).
func (d *Daemon) verifyAgentRequestingState(identity string, action LifecycleAction) error {
stateFile := d.identityToStateFile(identity)
if stateFile == "" {
// If we can't determine state file, log warning but allow action
// This maintains backwards compatibility with agents that don't support state files yet
d.logger.Printf("Warning: cannot determine state file for %s, skipping verification", identity)
return nil
}
data, err := os.ReadFile(stateFile)
if err != nil {
if os.IsNotExist(err) {
return fmt.Errorf("agent state file not found: %s (agent must set requesting_%s=true before lifecycle request)", stateFile, action)
}
return fmt.Errorf("reading agent state: %w", err)
}
var state map[string]interface{}
if err := json.Unmarshal(data, &state); err != nil {
return fmt.Errorf("parsing agent state: %w", err)
}
// Check for requesting_<action>=true
key := "requesting_" + string(action)
val, ok := state[key]
if !ok {
return fmt.Errorf("agent state missing %s field (agent must set this before lifecycle request)", key)
}
requesting, ok := val.(bool)
if !ok || !requesting {
return fmt.Errorf("agent state %s is not true (got: %v)", key, val)
}
d.logger.Printf("Verified agent %s has %s=true", identity, key)
return nil
}
// identityToStateFile maps an agent identity to its state.json file path.
func (d *Daemon) identityToStateFile(identity string) string {
switch identity {
case "mayor":
return filepath.Join(d.config.TownRoot, "mayor", "state.json")
default:
// Pattern: <rig>-witness → <townRoot>/<rig>/witness/state.json
if strings.HasSuffix(identity, "-witness") {
rigName := strings.TrimSuffix(identity, "-witness")
return filepath.Join(d.config.TownRoot, rigName, "witness", "state.json")
}
// Unknown identity - can't determine state file
return ""
}
}

View File

@@ -0,0 +1,150 @@
package daemon
import (
"encoding/json"
"log"
"os"
"path/filepath"
"testing"
)
func TestIdentityToStateFile(t *testing.T) {
d := &Daemon{
config: &Config{
TownRoot: "/test/town",
},
}
tests := []struct {
identity string
want string
}{
{"mayor", "/test/town/mayor/state.json"},
{"gastown-witness", "/test/town/gastown/witness/state.json"},
{"anotherrig-witness", "/test/town/anotherrig/witness/state.json"},
{"unknown", ""}, // Unknown identity returns empty
{"polecat", ""}, // Polecats not handled by daemon
{"gastown-refinery", ""}, // Refinery not handled by daemon
}
for _, tt := range tests {
t.Run(tt.identity, func(t *testing.T) {
got := d.identityToStateFile(tt.identity)
if got != tt.want {
t.Errorf("identityToStateFile(%q) = %q, want %q", tt.identity, got, tt.want)
}
})
}
}
func TestVerifyAgentRequestingState(t *testing.T) {
// Create temp directory for test
tmpDir, err := os.MkdirTemp("", "daemon-test-*")
if err != nil {
t.Fatal(err)
}
defer os.RemoveAll(tmpDir)
d := &Daemon{
config: &Config{
TownRoot: tmpDir,
},
logger: log.New(os.Stderr, "[test] ", log.LstdFlags),
}
// Create mayor directory
mayorDir := filepath.Join(tmpDir, "mayor")
if err := os.MkdirAll(mayorDir, 0755); err != nil {
t.Fatal(err)
}
stateFile := filepath.Join(mayorDir, "state.json")
t.Run("missing state file", func(t *testing.T) {
// Remove any existing state file
os.Remove(stateFile)
err := d.verifyAgentRequestingState("mayor", ActionCycle)
if err == nil {
t.Error("expected error for missing state file")
}
})
t.Run("missing requesting_cycle field", func(t *testing.T) {
state := map[string]interface{}{
"some_other_field": true,
}
writeStateFile(t, stateFile, state)
err := d.verifyAgentRequestingState("mayor", ActionCycle)
if err == nil {
t.Error("expected error for missing requesting_cycle field")
}
})
t.Run("requesting_cycle is false", func(t *testing.T) {
state := map[string]interface{}{
"requesting_cycle": false,
}
writeStateFile(t, stateFile, state)
err := d.verifyAgentRequestingState("mayor", ActionCycle)
if err == nil {
t.Error("expected error when requesting_cycle is false")
}
})
t.Run("requesting_cycle is true", func(t *testing.T) {
state := map[string]interface{}{
"requesting_cycle": true,
}
writeStateFile(t, stateFile, state)
err := d.verifyAgentRequestingState("mayor", ActionCycle)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
t.Run("requesting_shutdown is true", func(t *testing.T) {
state := map[string]interface{}{
"requesting_shutdown": true,
}
writeStateFile(t, stateFile, state)
err := d.verifyAgentRequestingState("mayor", ActionShutdown)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
t.Run("requesting_restart is true", func(t *testing.T) {
state := map[string]interface{}{
"requesting_restart": true,
}
writeStateFile(t, stateFile, state)
err := d.verifyAgentRequestingState("mayor", ActionRestart)
if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
t.Run("unknown identity skips verification", func(t *testing.T) {
// Unknown identities should not cause error (backwards compatibility)
err := d.verifyAgentRequestingState("unknown-agent", ActionCycle)
if err != nil {
t.Errorf("unexpected error for unknown identity: %v", err)
}
})
}
func writeStateFile(t *testing.T, path string, state map[string]interface{}) {
data, err := json.MarshalIndent(state, "", " ")
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, data, 0644); err != nil {
t.Fatal(err)
}
}

View File

@@ -2,11 +2,14 @@
package refinery
import (
"bytes"
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"time"
"github.com/steveyegge/gastown/internal/beads"
@@ -280,7 +283,7 @@ type ProcessResult struct {
}
// ProcessMR processes a single merge request.
// This is a placeholder that will be fully implemented in gt-3x1.2.
// It fetches the branch, checks for conflicts, and executes the merge.
func (e *Engineer) ProcessMR(ctx context.Context, mr *beads.Issue) ProcessResult {
// Parse MR fields from description
mrFields := beads.ParseMRFields(mr)
@@ -291,18 +294,32 @@ func (e *Engineer) ProcessMR(ctx context.Context, mr *beads.Issue) ProcessResult
}
}
// For now, just log what we would do
// Full implementation in gt-3x1.2: Fetch and conflict check
fmt.Printf("[Engineer] Would process:\n")
if mrFields.Branch == "" {
return ProcessResult{
Success: false,
Error: "branch field is required in merge request",
}
}
fmt.Printf("[Engineer] Processing MR:\n")
fmt.Printf(" Branch: %s\n", mrFields.Branch)
fmt.Printf(" Target: %s\n", mrFields.Target)
fmt.Printf(" Worker: %s\n", mrFields.Worker)
// Return failure for now - actual implementation in gt-3x1.2
return ProcessResult{
Success: false,
Error: "ProcessMR not fully implemented (see gt-3x1.2)",
// Step 1: Fetch the source branch
fmt.Printf("[Engineer] Fetching branch origin/%s\n", mrFields.Branch)
if err := e.gitRun("fetch", "origin", mrFields.Branch); err != nil {
return ProcessResult{
Success: false,
Error: fmt.Sprintf("fetch failed: %v", err),
}
}
// Step 2: Check for conflicts before attempting merge (optional pre-check)
// This is done implicitly during the merge step in ExecuteMerge
// Step 3: Execute the merge, test, and push
return e.ExecuteMerge(ctx, mr, mrFields)
}
// handleFailure handles a failed merge request.
@@ -319,3 +336,190 @@ func (e *Engineer) handleFailure(mr *beads.Issue, result ProcessResult) {
// Full failure handling (assign back to worker, labels) in gt-3x1.4
}
// ExecuteMerge performs the actual git merge, test, and push operations.
// Steps:
// 1. git checkout <target>
// 2. git merge <branch> --no-ff -m 'Merge <branch>: <title>'
// 3. If config.run_tests: run test_command, if failed: reset and return failure
// 4. git push origin <target> (with retry logic)
// 5. Return Success with merge_commit SHA
func (e *Engineer) ExecuteMerge(ctx context.Context, mr *beads.Issue, mrFields *beads.MRFields) ProcessResult {
target := mrFields.Target
if target == "" {
target = e.config.TargetBranch
}
branch := mrFields.Branch
fmt.Printf("[Engineer] Merging %s → %s\n", branch, target)
// 1. Checkout target branch
if err := e.gitRun("checkout", target); err != nil {
return ProcessResult{
Success: false,
Error: fmt.Sprintf("checkout target failed: %v", err),
}
}
// Pull latest from target to ensure we're up to date
if err := e.gitRun("pull", "origin", target); err != nil {
// Non-fatal warning - target might not exist on remote yet
fmt.Printf("[Engineer] Warning: pull failed (may be expected): %v\n", err)
}
// 2. Merge the branch
mergeMsg := fmt.Sprintf("Merge %s: %s", branch, mr.Title)
err := e.gitRun("merge", "origin/"+branch, "--no-ff", "-m", mergeMsg)
if err != nil {
errStr := err.Error()
if strings.Contains(errStr, "CONFLICT") || strings.Contains(errStr, "conflict") {
// Abort the merge to clean up
_ = e.gitRun("merge", "--abort")
return ProcessResult{
Success: false,
Error: "merge conflict",
Conflict: true,
}
}
return ProcessResult{
Success: false,
Error: fmt.Sprintf("merge failed: %v", err),
}
}
// 3. Run tests if configured
if e.config.RunTests {
testCmd := e.config.TestCommand
if testCmd == "" {
testCmd = "go test ./..."
}
fmt.Printf("[Engineer] Running tests: %s\n", testCmd)
if err := e.runTests(testCmd); err != nil {
// Reset to before merge
fmt.Printf("[Engineer] Tests failed, resetting merge\n")
_ = e.gitRun("reset", "--hard", "HEAD~1")
return ProcessResult{
Success: false,
Error: fmt.Sprintf("tests failed: %v", err),
TestsFailed: true,
}
}
fmt.Printf("[Engineer] Tests passed\n")
}
// 4. Push with retry logic
if err := e.pushWithRetry(target); err != nil {
// Reset to before merge on push failure
fmt.Printf("[Engineer] Push failed, resetting merge\n")
_ = e.gitRun("reset", "--hard", "HEAD~1")
return ProcessResult{
Success: false,
Error: fmt.Sprintf("push failed: %v", err),
}
}
// 5. Get merge commit SHA
mergeCommit, err := e.gitOutput("rev-parse", "HEAD")
if err != nil {
mergeCommit = "unknown"
}
fmt.Printf("[Engineer] Merged successfully: %s\n", mergeCommit)
return ProcessResult{
Success: true,
MergeCommit: mergeCommit,
}
}
// pushWithRetry pushes to the target branch with exponential backoff retry.
// Uses 3 retries with 1s base delay by default.
func (e *Engineer) pushWithRetry(targetBranch string) error {
const maxRetries = 3
baseDelay := time.Second
var lastErr error
delay := baseDelay
for attempt := 0; attempt <= maxRetries; attempt++ {
if attempt > 0 {
fmt.Printf("[Engineer] Push retry %d/%d after %v\n", attempt, maxRetries, delay)
time.Sleep(delay)
delay *= 2 // Exponential backoff
}
err := e.gitRun("push", "origin", targetBranch)
if err == nil {
return nil
}
lastErr = err
}
return fmt.Errorf("push failed after %d retries: %v", maxRetries, lastErr)
}
// runTests executes the test command.
func (e *Engineer) runTests(testCmd string) error {
parts := strings.Fields(testCmd)
if len(parts) == 0 {
return nil
}
cmd := exec.Command(parts[0], parts[1:]...)
cmd.Dir = e.workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
output := strings.TrimSpace(stderr.String())
if output == "" {
output = strings.TrimSpace(stdout.String())
}
if output != "" {
return fmt.Errorf("%v: %s", err, output)
}
return err
}
return nil
}
// gitRun executes a git command in the work directory.
func (e *Engineer) gitRun(args ...string) error {
cmd := exec.Command("git", args...)
cmd.Dir = e.workDir
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return fmt.Errorf("%s", errMsg)
}
return err
}
return nil
}
// gitOutput executes a git command and returns stdout.
func (e *Engineer) gitOutput(args ...string) (string, error) {
cmd := exec.Command("git", args...)
cmd.Dir = e.workDir
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
errMsg := strings.TrimSpace(stderr.String())
if errMsg != "" {
return "", fmt.Errorf("%s", errMsg)
}
return "", err
}
return strings.TrimSpace(stdout.String()), nil
}

View File

@@ -7,6 +7,7 @@ import (
"testing"
"time"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/rig"
)
@@ -207,3 +208,164 @@ func TestNewEngineer(t *testing.T) {
t.Error("expected stopCh to be initialized")
}
}
func TestProcessMR_NoMRFields(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "engineer-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
r := &rig.Rig{
Name: "test-rig",
Path: tmpDir,
}
e := NewEngineer(r)
// Create an issue without MR fields
issue := &beads.Issue{
ID: "gt-mr-test",
Title: "Test MR",
Type: "merge-request",
Description: "This issue has no MR fields",
}
result := e.ProcessMR(nil, issue)
if result.Success {
t.Error("expected failure when MR fields are missing")
}
if result.Error != "no MR fields found in description" {
t.Errorf("expected 'no MR fields found in description', got %q", result.Error)
}
}
func TestProcessMR_MissingBranch(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "engineer-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
r := &rig.Rig{
Name: "test-rig",
Path: tmpDir,
}
e := NewEngineer(r)
// Create an issue with MR fields but no branch
issue := &beads.Issue{
ID: "gt-mr-test",
Title: "Test MR",
Type: "merge-request",
Description: "target: main\nworker: TestWorker",
}
result := e.ProcessMR(nil, issue)
if result.Success {
t.Error("expected failure when branch field is missing")
}
if result.Error != "branch field is required in merge request" {
t.Errorf("expected 'branch field is required in merge request', got %q", result.Error)
}
}
func TestProcessResult_Fields(t *testing.T) {
// Test that ProcessResult can represent various failure states
tests := []struct {
name string
result ProcessResult
}{
{
name: "success",
result: ProcessResult{
Success: true,
MergeCommit: "abc123",
},
},
{
name: "conflict",
result: ProcessResult{
Success: false,
Error: "merge conflict",
Conflict: true,
},
},
{
name: "tests_failed",
result: ProcessResult{
Success: false,
Error: "tests failed",
TestsFailed: true,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Just verify the struct fields work as expected
if tt.result.Success && tt.result.MergeCommit == "" && !tt.result.Conflict && !tt.result.TestsFailed {
// This is fine for a non-failing success case
}
})
}
}
func TestEngineer_RunTestsEmptyCommand(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "engineer-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
r := &rig.Rig{
Name: "test-rig",
Path: tmpDir,
}
e := NewEngineer(r)
// Empty test command should not error
if err := e.runTests(""); err != nil {
t.Errorf("empty test command should not error, got: %v", err)
}
}
func TestEngineer_RunTestsSuccess(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "engineer-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
r := &rig.Rig{
Name: "test-rig",
Path: tmpDir,
}
e := NewEngineer(r)
// Run a simple command that should succeed
if err := e.runTests("true"); err != nil {
t.Errorf("expected 'true' command to succeed, got: %v", err)
}
}
func TestEngineer_RunTestsFailure(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "engineer-test-*")
if err != nil {
t.Fatal(err)
}
defer func() { _ = os.RemoveAll(tmpDir) }()
r := &rig.Rig{
Name: "test-rig",
Path: tmpDir,
}
e := NewEngineer(r)
// Run a command that should fail
err = e.runTests("false")
if err == nil {
t.Error("expected 'false' command to fail")
}
}