Add --branch flag to `gt rig add` to specify a custom default branch instead of auto-detecting from remote. This supports repositories that use non-standard default branches like `develop` or `release`. Changes: - Add --branch flag to `gt rig add` command - Store default_branch in rig config.json - Propagate default branch to refinery, witness, daemon, and all commands - Rename ensureMainBranch to ensureDefaultBranch for clarity - Add Rig.DefaultBranch() method for consistent access - Update crew/manager.go and swarm/manager.go to use rig config Based on PR #49 by @kustrun - rebased and extended with additional fixes. Co-authored-by: kustrun <kustrun@users.noreply.github.com> 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
291 lines
7.4 KiB
Go
291 lines
7.4 KiB
Go
package swarm
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"os/exec"
|
|
"strings"
|
|
|
|
"github.com/steveyegge/gastown/internal/rig"
|
|
)
|
|
|
|
// Common errors
|
|
var (
|
|
ErrSwarmNotFound = errors.New("swarm not found")
|
|
ErrSwarmExists = errors.New("swarm already exists")
|
|
ErrInvalidState = errors.New("invalid state transition")
|
|
ErrNoReadyTasks = errors.New("no ready tasks")
|
|
ErrBeadsNotFound = errors.New("beads not available")
|
|
)
|
|
|
|
// Manager handles swarm lifecycle operations.
|
|
// Manager is stateless - all swarm state is discovered from beads.
|
|
type Manager struct {
|
|
rig *rig.Rig
|
|
beadsDir string // Path for beads operations (git-synced)
|
|
gitDir string // Path for git operations (rig root)
|
|
}
|
|
|
|
// NewManager creates a new swarm manager for a rig.
|
|
func NewManager(r *rig.Rig) *Manager {
|
|
return &Manager{
|
|
rig: r,
|
|
beadsDir: r.BeadsPath(), // Use BeadsPath() for git-synced beads operations
|
|
gitDir: r.Path, // Use rig root for git operations
|
|
}
|
|
}
|
|
|
|
// LoadSwarm loads swarm state from beads by querying the epic.
|
|
// This is the canonical way to get swarm state - no in-memory caching.
|
|
func (m *Manager) LoadSwarm(epicID string) (*Swarm, error) {
|
|
// Query beads for the epic
|
|
cmd := exec.Command("bd", "show", epicID, "--json")
|
|
cmd.Dir = m.beadsDir
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("bd show: %s", strings.TrimSpace(stderr.String()))
|
|
}
|
|
|
|
// Parse the epic
|
|
var epic struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
MolType string `json:"mol_type"`
|
|
CreatedAt string `json:"created_at"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &epic); err != nil {
|
|
return nil, fmt.Errorf("parsing epic: %w", err)
|
|
}
|
|
|
|
// Verify it's a swarm molecule
|
|
if epic.MolType != "swarm" {
|
|
return nil, fmt.Errorf("epic %s is not a swarm (mol_type=%s)", epicID, epic.MolType)
|
|
}
|
|
|
|
// Get current git commit as base
|
|
baseCommit, _ := m.getGitHead()
|
|
if baseCommit == "" {
|
|
baseCommit = "unknown"
|
|
}
|
|
|
|
// Map status to swarm state
|
|
state := SwarmActive
|
|
if epic.Status == "closed" {
|
|
state = SwarmLanded
|
|
}
|
|
|
|
swarm := &Swarm{
|
|
ID: epicID,
|
|
RigName: m.rig.Name,
|
|
EpicID: epicID,
|
|
BaseCommit: baseCommit,
|
|
Integration: fmt.Sprintf("swarm/%s", epicID),
|
|
TargetBranch: m.rig.DefaultBranch(),
|
|
State: state,
|
|
Workers: []string{}, // Discovered from active tasks
|
|
Tasks: []SwarmTask{},
|
|
}
|
|
|
|
// Load tasks from beads (children of the epic)
|
|
tasks, err := m.loadTasksFromBeads(epicID)
|
|
if err == nil {
|
|
swarm.Tasks = tasks
|
|
// Discover workers from assigned tasks
|
|
for _, task := range tasks {
|
|
if task.Assignee != "" {
|
|
swarm.Workers = appendUnique(swarm.Workers, task.Assignee)
|
|
}
|
|
}
|
|
}
|
|
|
|
return swarm, nil
|
|
}
|
|
|
|
// appendUnique appends s to slice if not already present.
|
|
func appendUnique(slice []string, s string) []string {
|
|
for _, v := range slice {
|
|
if v == s {
|
|
return slice
|
|
}
|
|
}
|
|
return append(slice, s)
|
|
}
|
|
|
|
// GetSwarm loads a swarm from beads. Alias for LoadSwarm for compatibility.
|
|
func (m *Manager) GetSwarm(id string) (*Swarm, error) {
|
|
return m.LoadSwarm(id)
|
|
}
|
|
|
|
// GetReadyTasks returns tasks ready to be assigned by querying beads.
|
|
func (m *Manager) GetReadyTasks(swarmID string) ([]SwarmTask, error) {
|
|
// Use bd swarm status to get ready front
|
|
cmd := exec.Command("bd", "swarm", "status", swarmID, "--json")
|
|
cmd.Dir = m.beadsDir
|
|
|
|
var stdout bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, ErrSwarmNotFound
|
|
}
|
|
|
|
var status struct {
|
|
Ready []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
} `json:"ready"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &status); err != nil {
|
|
return nil, fmt.Errorf("parsing status: %w", err)
|
|
}
|
|
|
|
if len(status.Ready) == 0 {
|
|
return nil, ErrNoReadyTasks
|
|
}
|
|
|
|
tasks := make([]SwarmTask, len(status.Ready))
|
|
for i, r := range status.Ready {
|
|
tasks[i] = SwarmTask{
|
|
IssueID: r.ID,
|
|
Title: r.Title,
|
|
State: TaskPending,
|
|
}
|
|
}
|
|
return tasks, nil
|
|
}
|
|
|
|
// IsComplete checks if all tasks are closed by querying beads.
|
|
func (m *Manager) IsComplete(swarmID string) (bool, error) {
|
|
cmd := exec.Command("bd", "swarm", "status", swarmID, "--json")
|
|
cmd.Dir = m.beadsDir
|
|
|
|
var stdout bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return false, ErrSwarmNotFound
|
|
}
|
|
|
|
var status struct {
|
|
Ready []struct{ ID string } `json:"ready"`
|
|
Active []struct{ ID string } `json:"active"`
|
|
Blocked []struct{ ID string } `json:"blocked"`
|
|
}
|
|
if err := json.Unmarshal(stdout.Bytes(), &status); err != nil {
|
|
return false, fmt.Errorf("parsing status: %w", err)
|
|
}
|
|
|
|
// Complete if nothing is ready, active, or blocked
|
|
return len(status.Ready) == 0 && len(status.Active) == 0 && len(status.Blocked) == 0, nil
|
|
}
|
|
|
|
// isValidTransition checks if a state transition is allowed.
|
|
func isValidTransition(from, to SwarmState) bool {
|
|
transitions := map[SwarmState][]SwarmState{
|
|
SwarmCreated: {SwarmActive, SwarmCanceled},
|
|
SwarmActive: {SwarmMerging, SwarmFailed, SwarmCanceled},
|
|
SwarmMerging: {SwarmLanded, SwarmFailed, SwarmCanceled},
|
|
SwarmLanded: {}, // Terminal
|
|
SwarmFailed: {}, // Terminal
|
|
SwarmCanceled: {}, // Terminal
|
|
}
|
|
|
|
allowed, ok := transitions[from]
|
|
if !ok {
|
|
return false
|
|
}
|
|
|
|
for _, s := range allowed {
|
|
if s == to {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// loadTasksFromBeads loads child issues from beads CLI.
|
|
func (m *Manager) loadTasksFromBeads(epicID string) ([]SwarmTask, error) {
|
|
// Run: bd show <epicID> --json to get epic with children
|
|
cmd := exec.Command("bd", "show", epicID, "--json")
|
|
cmd.Dir = m.beadsDir
|
|
|
|
var stdout, stderr bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
cmd.Stderr = &stderr
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return nil, fmt.Errorf("bd show: %s", strings.TrimSpace(stderr.String()))
|
|
}
|
|
|
|
// Parse JSON output - bd show returns an array
|
|
var issues []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
Dependents []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
Assignee string `json:"assignee"`
|
|
DependencyType string `json:"dependency_type"`
|
|
} `json:"dependents"`
|
|
}
|
|
|
|
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
|
|
return nil, fmt.Errorf("parsing bd output: %w", err)
|
|
}
|
|
|
|
if len(issues) == 0 {
|
|
return nil, fmt.Errorf("epic not found: %s", epicID)
|
|
}
|
|
|
|
// Extract dependents as tasks (issues that depend on/are blocked by this epic)
|
|
// Accept both "parent-child" and "blocks" relationships
|
|
var tasks []SwarmTask
|
|
for _, dep := range issues[0].Dependents {
|
|
if dep.DependencyType != "parent-child" && dep.DependencyType != "blocks" {
|
|
continue
|
|
}
|
|
|
|
state := TaskPending
|
|
switch dep.Status {
|
|
case "in_progress", "hooked":
|
|
state = TaskInProgress
|
|
case "closed":
|
|
state = TaskMerged
|
|
}
|
|
|
|
tasks = append(tasks, SwarmTask{
|
|
IssueID: dep.ID,
|
|
Title: dep.Title,
|
|
State: state,
|
|
Assignee: dep.Assignee,
|
|
})
|
|
}
|
|
|
|
return tasks, nil
|
|
}
|
|
|
|
// getGitHead returns the current HEAD commit.
|
|
func (m *Manager) getGitHead() (string, error) {
|
|
cmd := exec.Command("git", "rev-parse", "HEAD")
|
|
cmd.Dir = m.gitDir
|
|
|
|
var stdout bytes.Buffer
|
|
cmd.Stdout = &stdout
|
|
|
|
if err := cmd.Run(); err != nil {
|
|
return "", err
|
|
}
|
|
|
|
return strings.TrimSpace(stdout.String()), nil
|
|
}
|