refactor(polecat): eliminate state.json, use beads assignee for state

Replace polecat state.json with beads assignee field for state management:

- Remove state.json read/write from polecat.Manager
- Add loadFromBeads() to derive state from issue.assignee field
- Update AssignIssue() to set issue.assignee in beads
- Update ClearIssue() to clear assignee from beads
- Update SetState() to work with beads or gracefully degrade
- Add ListByAssignee and GetAssignedIssue to beads package
- Update spawn to create beads issues for free-form tasks
- Update tests for new beads-based architecture

State derivation:
- Polecat exists: worktree directory exists
- Polecat assigned: issue.assignee = 'rig/polecatName'
- Polecat working: issue.status = open/in_progress
- Polecat done: issue.status = closed or no assignee

Fixes: gt-qp98

🤖 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 12:07:35 -08:00
parent 4048cdc373
commit bbff3b2144
4 changed files with 317 additions and 204 deletions

View File

@@ -58,10 +58,12 @@ type IssueDep struct {
// ListOptions specifies filters for listing issues. // ListOptions specifies filters for listing issues.
type ListOptions struct { type ListOptions struct {
Status string // "open", "closed", "all" Status string // "open", "closed", "all"
Type string // "task", "bug", "feature", "epic" Type string // "task", "bug", "feature", "epic"
Priority int // 0-4, -1 for no filter Priority int // 0-4, -1 for no filter
Parent string // filter by parent ID Parent string // filter by parent ID
Assignee string // filter by assignee (e.g., "gastown/Toast")
NoAssignee bool // filter for issues with no assignee
} }
// CreateOptions specifies options for creating an issue. // CreateOptions specifies options for creating an issue.
@@ -161,6 +163,12 @@ func (b *Beads) List(opts ListOptions) ([]*Issue, error) {
if opts.Parent != "" { if opts.Parent != "" {
args = append(args, "--parent="+opts.Parent) args = append(args, "--parent="+opts.Parent)
} }
if opts.Assignee != "" {
args = append(args, "--assignee="+opts.Assignee)
}
if opts.NoAssignee {
args = append(args, "--no-assignee")
}
out, err := b.run(args...) out, err := b.run(args...)
if err != nil { if err != nil {
@@ -175,6 +183,47 @@ func (b *Beads) List(opts ListOptions) ([]*Issue, error) {
return issues, nil return issues, nil
} }
// ListByAssignee returns all issues assigned to a specific assignee.
// The assignee is typically in the format "rig/polecatName" (e.g., "gastown/Toast").
func (b *Beads) ListByAssignee(assignee string) ([]*Issue, error) {
return b.List(ListOptions{
Status: "all", // Include both open and closed for state derivation
Assignee: assignee,
Priority: -1, // No priority filter
})
}
// GetAssignedIssue returns the first open issue assigned to the given assignee.
// Returns nil if no open issue is assigned.
func (b *Beads) GetAssignedIssue(assignee string) (*Issue, error) {
issues, err := b.List(ListOptions{
Status: "open",
Assignee: assignee,
Priority: -1,
})
if err != nil {
return nil, err
}
// Also check in_progress status explicitly
if len(issues) == 0 {
issues, err = b.List(ListOptions{
Status: "in_progress",
Assignee: assignee,
Priority: -1,
})
if err != nil {
return nil, err
}
}
if len(issues) == 0 {
return nil, nil
}
return issues[0], nil
}
// Ready returns issues that are ready to work (not blocked). // Ready returns issues that are ready to work (not blocked).
func (b *Beads) Ready() ([]*Issue, error) { func (b *Beads) Ready() ([]*Issue, error) {
out, err := b.run("ready", "--json") out, err := b.run("ready", "--json")

View File

@@ -184,9 +184,12 @@ func runSpawn(cmd *cobra.Command, args []string) error {
return fmt.Errorf("polecat '%s' is already working on %s", polecatName, pc.Issue) return fmt.Errorf("polecat '%s' is already working on %s", polecatName, pc.Issue)
} }
// Beads operations use mayor/rig directory (rig-level beads)
beadsPath := filepath.Join(r.Path, "mayor", "rig")
// Handle molecule instantiation if specified // Handle molecule instantiation if specified
if spawnMolecule != "" { if spawnMolecule != "" {
b := beads.New(r.Path) b := beads.New(beadsPath)
// Get the molecule // Get the molecule
mol, err := b.Show(spawnMolecule) mol, err := b.Show(spawnMolecule)
@@ -239,20 +242,28 @@ func runSpawn(cmd *cobra.Command, args []string) error {
spawnIssue = firstReadyStep.ID spawnIssue = firstReadyStep.ID
} }
// Get issue details if specified // Get or create issue
var issue *BeadsIssue var issue *BeadsIssue
var assignmentID string
if spawnIssue != "" { if spawnIssue != "" {
issue, err = fetchBeadsIssue(r.Path, spawnIssue) // Use existing issue
issue, err = fetchBeadsIssue(beadsPath, spawnIssue)
if err != nil { if err != nil {
return fmt.Errorf("fetching issue %s: %w", spawnIssue, err) return fmt.Errorf("fetching issue %s: %w", spawnIssue, err)
} }
assignmentID = spawnIssue
} else {
// Create a beads issue for free-form task
fmt.Printf("Creating beads issue for task...\n")
issue, err = createBeadsTask(beadsPath, spawnMessage)
if err != nil {
return fmt.Errorf("creating task issue: %w", err)
}
assignmentID = issue.ID
fmt.Printf("Created issue %s\n", assignmentID)
} }
// Assign issue/task to polecat // Assign issue to polecat (sets issue.assignee in beads)
assignmentID := spawnIssue
if assignmentID == "" {
assignmentID = "task:" + time.Now().Format("20060102-150405")
}
if err := polecatMgr.AssignIssue(polecatName, assignmentID); err != nil { if err := polecatMgr.AssignIssue(polecatName, assignmentID); err != nil {
return fmt.Errorf("assigning issue: %w", err) return fmt.Errorf("assigning issue: %w", err)
} }
@@ -412,6 +423,44 @@ func fetchBeadsIssue(rigPath, issueID string) (*BeadsIssue, error) {
return &issues[0], nil return &issues[0], nil
} }
// createBeadsTask creates a new beads task issue for a free-form task message.
func createBeadsTask(rigPath, message string) (*BeadsIssue, error) {
// Truncate message for title if too long
title := message
if len(title) > 60 {
title = title[:57] + "..."
}
// Use bd create to make a new task issue
cmd := exec.Command("bd", "create",
"--title="+title,
"--type=task",
"--priority=2",
"--description="+message,
"--json")
cmd.Dir = rigPath
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 nil, fmt.Errorf("%s", errMsg)
}
return nil, err
}
// bd create --json returns the created issue
var issue BeadsIssue
if err := json.Unmarshal(stdout.Bytes(), &issue); err != nil {
return nil, fmt.Errorf("parsing created issue: %w", err)
}
return &issue, nil
}
// buildSpawnContext creates the initial context message for the polecat. // buildSpawnContext creates the initial context message for the polecat.
func buildSpawnContext(issue *BeadsIssue, message string) string { func buildSpawnContext(issue *BeadsIssue, message string) string {
var sb strings.Builder var sb strings.Builder

View File

@@ -1,13 +1,13 @@
package polecat package polecat
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"os" "os"
"path/filepath" "path/filepath"
"time" "time"
"github.com/steveyegge/gastown/internal/beads"
"github.com/steveyegge/gastown/internal/git" "github.com/steveyegge/gastown/internal/git"
"github.com/steveyegge/gastown/internal/rig" "github.com/steveyegge/gastown/internal/rig"
) )
@@ -21,36 +21,42 @@ var (
// Manager handles polecat lifecycle. // Manager handles polecat lifecycle.
type Manager struct { type Manager struct {
rig *rig.Rig rig *rig.Rig
git *git.Git git *git.Git
beads *beads.Beads
} }
// NewManager creates a new polecat manager. // NewManager creates a new polecat manager.
func NewManager(r *rig.Rig, g *git.Git) *Manager { func NewManager(r *rig.Rig, g *git.Git) *Manager {
// Use the mayor's rig directory for beads operations (rig-level beads)
mayorRigPath := filepath.Join(r.Path, "mayor", "rig")
return &Manager{ return &Manager{
rig: r, rig: r,
git: g, git: g,
beads: beads.New(mayorRigPath),
} }
} }
// assigneeID returns the beads assignee identifier for a polecat.
// Format: "rig/polecatName" (e.g., "gastown/Toast")
func (m *Manager) assigneeID(name string) string {
return fmt.Sprintf("%s/%s", m.rig.Name, name)
}
// polecatDir returns the directory for a polecat. // polecatDir returns the directory for a polecat.
func (m *Manager) polecatDir(name string) string { func (m *Manager) polecatDir(name string) string {
return filepath.Join(m.rig.Path, "polecats", name) return filepath.Join(m.rig.Path, "polecats", name)
} }
// stateFile returns the state file path for a polecat.
func (m *Manager) stateFile(name string) string {
return filepath.Join(m.polecatDir(name), "state.json")
}
// exists checks if a polecat exists. // exists checks if a polecat exists.
func (m *Manager) exists(name string) bool { func (m *Manager) exists(name string) bool {
_, err := os.Stat(m.polecatDir(name)) _, err := os.Stat(m.polecatDir(name))
return err == nil return err == nil
} }
// Add creates a new polecat as a git worktree from the refinery clone. // Add creates a new polecat as a git worktree from the mayor's clone.
// This is much faster than a full clone and shares objects with the refinery. // This is much faster than a full clone and shares objects with the mayor.
// Polecat state is derived from beads assignee field, not state.json.
func (m *Manager) Add(name string) (*Polecat, error) { func (m *Manager) Add(name string) (*Polecat, error) {
if m.exists(name) { if m.exists(name) {
return nil, ErrPolecatExists return nil, ErrPolecatExists
@@ -94,25 +100,19 @@ func (m *Manager) Add(name string) (*Polecat, error) {
} }
} }
// Create polecat state - ephemeral polecats start in working state // Return polecat with derived state (no issue assigned yet = idle)
// State is derived from beads, not stored in state.json
now := time.Now() now := time.Now()
polecat := &Polecat{ polecat := &Polecat{
Name: name, Name: name,
Rig: m.rig.Name, Rig: m.rig.Name,
State: StateWorking, State: StateIdle, // No issue assigned yet
ClonePath: polecatPath, ClonePath: polecatPath,
Branch: branchName, Branch: branchName,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
} }
// Save state
if err := m.saveState(polecat); err != nil {
// Clean up worktree on failure
_ = mayorGit.WorktreeRemove(polecatPath, true)
return nil, fmt.Errorf("saving state: %w", err)
}
return polecat, nil return polecat, nil
} }
@@ -182,54 +182,115 @@ func (m *Manager) List() ([]*Polecat, error) {
} }
// Get returns a specific polecat by name. // Get returns a specific polecat by name.
// State is derived from beads assignee field:
// - If an issue is assigned to this polecat and is open/in_progress: StateWorking
// - If an issue is assigned but closed: StateDone
// - If no issue assigned: StateIdle
func (m *Manager) Get(name string) (*Polecat, error) { func (m *Manager) Get(name string) (*Polecat, error) {
if !m.exists(name) { if !m.exists(name) {
return nil, ErrPolecatNotFound return nil, ErrPolecatNotFound
} }
return m.loadState(name) return m.loadFromBeads(name)
} }
// SetState updates a polecat's state. // SetState updates a polecat's state.
// In the beads model, state is derived from issue status:
// - StateWorking/StateActive: issue status set to in_progress
// - StateDone/StateIdle: assignee cleared from issue
// - StateStuck: issue status set to blocked (if supported)
// If beads is not available, this is a no-op.
func (m *Manager) SetState(name string, state State) error { func (m *Manager) SetState(name string, state State) error {
polecat, err := m.Get(name) if !m.exists(name) {
if err != nil { return ErrPolecatNotFound
return err
} }
polecat.State = state // Find the issue assigned to this polecat
polecat.UpdatedAt = time.Now() assignee := m.assigneeID(name)
issue, err := m.beads.GetAssignedIssue(assignee)
if err != nil {
// If beads is not available, treat as no-op (state can't be changed)
return nil
}
return m.saveState(polecat) switch state {
case StateWorking, StateActive:
// Set issue to in_progress if there is one
if issue != nil {
status := "in_progress"
if err := m.beads.Update(issue.ID, beads.UpdateOptions{Status: &status}); err != nil {
return fmt.Errorf("setting issue status: %w", err)
}
}
case StateDone, StateIdle:
// Clear assignment when done/idle
if issue != nil {
empty := ""
if err := m.beads.Update(issue.ID, beads.UpdateOptions{Assignee: &empty}); err != nil {
return fmt.Errorf("clearing assignee: %w", err)
}
}
case StateStuck:
// Mark issue as blocked if supported, otherwise just note in issue
if issue != nil {
// For now, just keep the assignment - the issue's blocked_by would indicate stuck
// We could add a status="blocked" here if beads supports it
}
}
return nil
} }
// AssignIssue assigns an issue to a polecat. // AssignIssue assigns an issue to a polecat by setting the issue's assignee in beads.
func (m *Manager) AssignIssue(name, issue string) error { func (m *Manager) AssignIssue(name, issue string) error {
polecat, err := m.Get(name) if !m.exists(name) {
if err != nil { return ErrPolecatNotFound
return err
} }
polecat.Issue = issue // Set the issue's assignee to this polecat
polecat.State = StateWorking assignee := m.assigneeID(name)
polecat.UpdatedAt = time.Now() status := "in_progress"
if err := m.beads.Update(issue, beads.UpdateOptions{
Assignee: &assignee,
Status: &status,
}); err != nil {
return fmt.Errorf("setting issue assignee: %w", err)
}
return m.saveState(polecat) return nil
} }
// ClearIssue removes the issue assignment from a polecat. // ClearIssue removes the issue assignment from a polecat.
// In the ephemeral model, this transitions to Done state for cleanup. // In the ephemeral model, this transitions to Done state for cleanup.
// This clears the assignee from the currently assigned issue in beads.
// If beads is not available, this is a no-op.
func (m *Manager) ClearIssue(name string) error { func (m *Manager) ClearIssue(name string) error {
polecat, err := m.Get(name) if !m.exists(name) {
if err != nil { return ErrPolecatNotFound
return err
} }
polecat.Issue = "" // Find the issue assigned to this polecat
polecat.State = StateDone assignee := m.assigneeID(name)
polecat.UpdatedAt = time.Now() issue, err := m.beads.GetAssignedIssue(assignee)
if err != nil {
// If beads is not available, treat as no-op
return nil
}
return m.saveState(polecat) if issue == nil {
// No issue assigned, nothing to clear
return nil
}
// Clear the assignee from the issue
empty := ""
if err := m.beads.Update(issue.ID, beads.UpdateOptions{
Assignee: &empty,
}); err != nil {
return fmt.Errorf("clearing issue assignee: %w", err)
}
return nil
} }
// Wake transitions a polecat from idle to active. // Wake transitions a polecat from idle to active.
@@ -267,6 +328,7 @@ func (m *Manager) Sleep(name string) error {
} }
// Finish transitions a polecat from working/done/stuck to idle and clears the issue. // Finish transitions a polecat from working/done/stuck to idle and clears the issue.
// This clears the assignee from any assigned issue.
func (m *Manager) Finish(name string) error { func (m *Manager) Finish(name string) error {
polecat, err := m.Get(name) polecat, err := m.Get(name)
if err != nil { if err != nil {
@@ -281,65 +343,66 @@ func (m *Manager) Finish(name string) error {
return fmt.Errorf("polecat is not in a finishing state (state: %s)", polecat.State) return fmt.Errorf("polecat is not in a finishing state (state: %s)", polecat.State)
} }
polecat.Issue = "" // Clear the issue assignment
polecat.State = StateIdle return m.ClearIssue(name)
polecat.UpdatedAt = time.Now()
return m.saveState(polecat)
} }
// Reset forces a polecat to idle state regardless of current state. // Reset forces a polecat to idle state regardless of current state.
// This clears the assignee from any assigned issue.
func (m *Manager) Reset(name string) error { func (m *Manager) Reset(name string) error {
polecat, err := m.Get(name) if !m.exists(name) {
if err != nil { return ErrPolecatNotFound
return err
} }
polecat.Issue = "" // Clear the issue assignment
polecat.State = StateIdle return m.ClearIssue(name)
polecat.UpdatedAt = time.Now()
return m.saveState(polecat)
} }
// saveState persists polecat state to disk. // loadFromBeads derives polecat state from beads assignee field.
func (m *Manager) saveState(polecat *Polecat) error { // State is derived as follows:
data, err := json.MarshalIndent(polecat, "", " ") // - If an issue is assigned to this polecat and is open/in_progress: StateWorking
// - If no issue assigned: StateIdle
func (m *Manager) loadFromBeads(name string) (*Polecat, error) {
polecatPath := m.polecatDir(name)
branchName := fmt.Sprintf("polecat/%s", name)
// Query beads for assigned issue
assignee := m.assigneeID(name)
issue, err := m.beads.GetAssignedIssue(assignee)
if err != nil { if err != nil {
return fmt.Errorf("marshaling state: %w", err) // If beads query fails, return basic polecat info
// This allows the system to work even if beads is not available
return &Polecat{
Name: name,
Rig: m.rig.Name,
State: StateIdle,
ClonePath: polecatPath,
Branch: branchName,
}, nil
} }
stateFile := m.stateFile(polecat.Name) // Derive state from issue
if err := os.WriteFile(stateFile, data, 0644); err != nil { state := StateIdle
return fmt.Errorf("writing state: %w", err) issueID := ""
} if issue != nil {
issueID = issue.ID
return nil switch issue.Status {
} case "open", "in_progress":
state = StateWorking
// loadState reads polecat state from disk. case "closed":
func (m *Manager) loadState(name string) (*Polecat, error) { state = StateDone
stateFile := m.stateFile(name) default:
// Unknown status, assume working if assigned
data, err := os.ReadFile(stateFile) state = StateWorking
if err != nil {
if os.IsNotExist(err) {
// Return minimal polecat if state file missing
// Use StateWorking since ephemeral polecats are always working
return &Polecat{
Name: name,
Rig: m.rig.Name,
State: StateWorking,
ClonePath: m.polecatDir(name),
}, nil
} }
return nil, fmt.Errorf("reading state: %w", err)
} }
var polecat Polecat return &Polecat{
if err := json.Unmarshal(data, &polecat); err != nil { Name: name,
return nil, fmt.Errorf("parsing state: %w", err) Rig: m.rig.Name,
} State: state,
ClonePath: polecatPath,
return &polecat, nil Branch: branchName,
Issue: issueID,
}, nil
} }

View File

@@ -126,72 +126,72 @@ func TestPolecatDir(t *testing.T) {
} }
} }
func TestStateFile(t *testing.T) { func TestAssigneeID(t *testing.T) {
r := &rig.Rig{ r := &rig.Rig{
Name: "test-rig", Name: "test-rig",
Path: "/home/user/ai/test-rig", Path: "/home/user/ai/test-rig",
} }
m := NewManager(r, git.NewGit(r.Path)) m := NewManager(r, git.NewGit(r.Path))
file := m.stateFile("Toast") id := m.assigneeID("Toast")
expected := "/home/user/ai/test-rig/polecats/Toast/state.json" expected := "test-rig/Toast"
if file != expected { if id != expected {
t.Errorf("stateFile = %q, want %q", file, expected) t.Errorf("assigneeID = %q, want %q", id, expected)
} }
} }
func TestStatePersistence(t *testing.T) { // Note: State persistence tests removed - state is now derived from beads assignee field.
// Integration tests should verify beads-based state management.
func TestGetReturnsIdleWithoutBeads(t *testing.T) {
// When beads is not available, Get should return StateIdle
root := t.TempDir() root := t.TempDir()
polecatDir := filepath.Join(root, "polecats", "Test") polecatDir := filepath.Join(root, "polecats", "Test")
if err := os.MkdirAll(polecatDir, 0755); err != nil { if err := os.MkdirAll(polecatDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err) t.Fatalf("mkdir: %v", err)
} }
// Create mayor/rig directory for beads (but no actual beads)
mayorRigDir := filepath.Join(root, "mayor", "rig")
if err := os.MkdirAll(mayorRigDir, 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
r := &rig.Rig{ r := &rig.Rig{
Name: "test-rig", Name: "test-rig",
Path: root, Path: root,
} }
m := NewManager(r, git.NewGit(root)) m := NewManager(r, git.NewGit(root))
// Save state // Get should return polecat with StateIdle (no beads = no assignment)
polecat := &Polecat{ polecat, err := m.Get("Test")
Name: "Test",
Rig: "test-rig",
State: StateWorking,
ClonePath: polecatDir,
Issue: "gt-xyz",
}
if err := m.saveState(polecat); err != nil {
t.Fatalf("saveState: %v", err)
}
// Load state
loaded, err := m.loadState("Test")
if err != nil { if err != nil {
t.Fatalf("loadState: %v", err) t.Fatalf("Get: %v", err)
} }
if loaded.Name != "Test" { if polecat.Name != "Test" {
t.Errorf("Name = %q, want Test", loaded.Name) t.Errorf("Name = %q, want Test", polecat.Name)
} }
if loaded.State != StateWorking { if polecat.State != StateIdle {
t.Errorf("State = %v, want StateWorking", loaded.State) t.Errorf("State = %v, want StateIdle (beads not available)", polecat.State)
}
if loaded.Issue != "gt-xyz" {
t.Errorf("Issue = %q, want gt-xyz", loaded.Issue)
} }
} }
func TestListWithPolecats(t *testing.T) { func TestListWithPolecats(t *testing.T) {
root := t.TempDir() root := t.TempDir()
// Create some polecat directories with state files // Create some polecat directories (state is now derived from beads, not state files)
for _, name := range []string{"Toast", "Cheedo"} { for _, name := range []string{"Toast", "Cheedo"} {
polecatDir := filepath.Join(root, "polecats", name) polecatDir := filepath.Join(root, "polecats", name)
if err := os.MkdirAll(polecatDir, 0755); err != nil { if err := os.MkdirAll(polecatDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err) t.Fatalf("mkdir: %v", err)
} }
} }
// Create mayor/rig for beads path
mayorRig := filepath.Join(root, "mayor", "rig")
if err := os.MkdirAll(mayorRig, 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
r := &rig.Rig{ r := &rig.Rig{
Name: "test-rig", Name: "test-rig",
@@ -208,12 +208,23 @@ func TestListWithPolecats(t *testing.T) {
} }
} }
func TestSetState(t *testing.T) { // Note: TestSetState, TestAssignIssue, and TestClearIssue were removed.
// These operations now require a running beads instance and are tested
// via integration tests. The unit tests here focus on testing the basic
// polecat lifecycle operations that don't require beads.
func TestSetStateWithoutBeads(t *testing.T) {
// SetState should not error when beads is not available
root := t.TempDir() root := t.TempDir()
polecatDir := filepath.Join(root, "polecats", "Test") polecatDir := filepath.Join(root, "polecats", "Test")
if err := os.MkdirAll(polecatDir, 0755); err != nil { if err := os.MkdirAll(polecatDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err) t.Fatalf("mkdir: %v", err)
} }
// Create mayor/rig for beads path
mayorRig := filepath.Join(root, "mayor", "rig")
if err := os.MkdirAll(mayorRig, 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
r := &rig.Rig{ r := &rig.Rig{
Name: "test-rig", Name: "test-rig",
@@ -221,32 +232,25 @@ func TestSetState(t *testing.T) {
} }
m := NewManager(r, git.NewGit(root)) m := NewManager(r, git.NewGit(root))
// Initial state // SetState should succeed (no-op when no issue assigned)
if err := m.saveState(&Polecat{Name: "Test", State: StateIdle}); err != nil { err := m.SetState("Test", StateActive)
t.Fatalf("saveState: %v", err)
}
// Update state
if err := m.SetState("Test", StateActive); err != nil {
t.Fatalf("SetState: %v", err)
}
// Verify
polecat, err := m.Get("Test")
if err != nil { if err != nil {
t.Fatalf("Get: %v", err) t.Errorf("SetState: %v (expected no error when no beads/issue)", err)
}
if polecat.State != StateActive {
t.Errorf("State = %v, want StateActive", polecat.State)
} }
} }
func TestAssignIssue(t *testing.T) { func TestClearIssueWithoutAssignment(t *testing.T) {
// ClearIssue should not error when no issue is assigned
root := t.TempDir() root := t.TempDir()
polecatDir := filepath.Join(root, "polecats", "Test") polecatDir := filepath.Join(root, "polecats", "Test")
if err := os.MkdirAll(polecatDir, 0755); err != nil { if err := os.MkdirAll(polecatDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err) t.Fatalf("mkdir: %v", err)
} }
// Create mayor/rig for beads path
mayorRig := filepath.Join(root, "mayor", "rig")
if err := os.MkdirAll(mayorRig, 0755); err != nil {
t.Fatalf("mkdir mayor/rig: %v", err)
}
r := &rig.Rig{ r := &rig.Rig{
Name: "test-rig", Name: "test-rig",
@@ -254,61 +258,9 @@ func TestAssignIssue(t *testing.T) {
} }
m := NewManager(r, git.NewGit(root)) m := NewManager(r, git.NewGit(root))
// Initial state // ClearIssue should succeed even when no issue assigned
if err := m.saveState(&Polecat{Name: "Test", State: StateIdle}); err != nil { err := m.ClearIssue("Test")
t.Fatalf("saveState: %v", err)
}
// Assign issue
if err := m.AssignIssue("Test", "gt-abc"); err != nil {
t.Fatalf("AssignIssue: %v", err)
}
// Verify
polecat, err := m.Get("Test")
if err != nil { if err != nil {
t.Fatalf("Get: %v", err) t.Errorf("ClearIssue: %v (expected no error when no assignment)", err)
}
if polecat.Issue != "gt-abc" {
t.Errorf("Issue = %q, want gt-abc", polecat.Issue)
}
if polecat.State != StateWorking {
t.Errorf("State = %v, want StateWorking", polecat.State)
}
}
func TestClearIssue(t *testing.T) {
root := t.TempDir()
polecatDir := filepath.Join(root, "polecats", "Test")
if err := os.MkdirAll(polecatDir, 0755); err != nil {
t.Fatalf("mkdir: %v", err)
}
r := &rig.Rig{
Name: "test-rig",
Path: root,
}
m := NewManager(r, git.NewGit(root))
// Initial state with issue
if err := m.saveState(&Polecat{Name: "Test", State: StateWorking, Issue: "gt-abc"}); err != nil {
t.Fatalf("saveState: %v", err)
}
// Clear issue
if err := m.ClearIssue("Test"); err != nil {
t.Fatalf("ClearIssue: %v", err)
}
// Verify - in ephemeral model, ClearIssue transitions to Done
polecat, err := m.Get("Test")
if err != nil {
t.Fatalf("Get: %v", err)
}
if polecat.Issue != "" {
t.Errorf("Issue = %q, want empty", polecat.Issue)
}
if polecat.State != StateDone {
t.Errorf("State = %v, want StateDone", polecat.State)
} }
} }