diff --git a/cmd/bd/gate.go b/cmd/bd/gate.go index 4ff25d72..7c1e6c53 100644 --- a/cmd/bd/gate.go +++ b/cmd/bd/gate.go @@ -2,6 +2,8 @@ package main import ( "bytes" + "context" + "database/sql" "encoding/json" "fmt" "os" @@ -9,7 +11,12 @@ import ( "strings" "time" + _ "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/beads" + "github.com/steveyegge/beads/internal/configfile" + "github.com/steveyegge/beads/internal/routing" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/ui" @@ -30,10 +37,15 @@ Gate types: timer - Expires after timeout (Phase 2) gh:run - Waits for GitHub workflow (Phase 3) gh:pr - Waits for PR merge (Phase 3) + bead - Waits for cross-rig bead to close (Phase 4) + +For bead gates, await_id format is : (e.g., "gastown:gt-abc123"). Examples: bd gate list # Show all open gates bd gate list --all # Show all gates including closed + bd gate check # Evaluate all open gates + bd gate check --type=bead # Evaluate only bead gates bd gate resolve # Close a gate manually`, } @@ -247,6 +259,7 @@ Gate types: gh:run - Check GitHub Actions workflow runs gh:pr - Check pull request merge status timer - Check timer gates (auto-expire based on timeout) + bead - Check cross-rig bead gates all - Check all gate types GitHub gates use the 'gh' CLI to query status: @@ -257,6 +270,7 @@ A gate is resolved when: - gh:run: status=completed AND conclusion=success - gh:pr: state=MERGED - timer: current time > created_at + timeout + - bead: target bead status=closed A gate is escalated when: - gh:run: status=completed AND conclusion in (failure, cancelled) @@ -267,6 +281,7 @@ Examples: bd gate check --type=gh # Check only GitHub gates bd gate check --type=gh:run # Check only workflow run gates bd gate check --type=timer # Check only timer gates + bd gate check --type=bead # Check only cross-rig bead gates bd gate check --dry-run # Show what would happen without changes bd gate check --escalate # Escalate expired/failed gates`, Run: func(cmd *cobra.Command, args []string) { @@ -351,6 +366,8 @@ Examples: result.resolved, result.escalated, result.reason, result.err = checkGHPR(gate) case gate.AwaitType == "timer": result.resolved, result.escalated, result.reason, result.err = checkTimer(gate, now) + case gate.AwaitType == "bead": + result.resolved, result.reason = checkBeadGate(ctx, gate.AwaitID) default: // Skip unsupported gate types (human gates need manual resolution) continue @@ -566,6 +583,68 @@ func checkTimer(gate *types.Issue, now time.Time) (resolved, escalated bool, rea return false, false, fmt.Sprintf("expires in %s", remaining), nil } +// checkBeadGate checks if a cross-rig bead gate is satisfied. +// await_id format: : (e.g., "gastown:gt-abc123") +// Returns (satisfied, reason). +func checkBeadGate(ctx context.Context, awaitID string) (bool, string) { + // Parse await_id format: : + parts := strings.SplitN(awaitID, ":", 2) + if len(parts) != 2 { + return false, fmt.Sprintf("invalid await_id format: expected :, got %q", awaitID) + } + + rigName := parts[0] + beadID := parts[1] + + if rigName == "" || beadID == "" { + return false, "await_id missing rig name or bead ID" + } + + // Resolve the target rig's beads directory + currentBeadsDir := beads.FindBeadsDir() + if currentBeadsDir == "" { + return false, "could not find current beads directory" + } + targetBeadsDir, _, err := routing.ResolveBeadsDirForRig(rigName, currentBeadsDir) + if err != nil { + return false, fmt.Sprintf("rig %q not found: %v", rigName, err) + } + + // Load config to get database path + cfg, err := configfile.Load(targetBeadsDir) + if err != nil { + return false, fmt.Sprintf("failed to load config for rig %q: %v", rigName, err) + } + + dbPath := cfg.DatabasePath(targetBeadsDir) + + // Open the target database (read-only) + db, err := sql.Open("sqlite3", dbPath+"?mode=ro") + if err != nil { + return false, fmt.Sprintf("failed to open database for rig %q: %v", rigName, err) + } + defer func() { _ = db.Close() }() + + // Check if the target bead exists and is closed + var status string + err = db.QueryRowContext(ctx, ` + SELECT status FROM issues WHERE id = ? + `, beadID).Scan(&status) + + if err != nil { + if err == sql.ErrNoRows { + return false, fmt.Sprintf("bead %s not found in rig %s", beadID, rigName) + } + return false, fmt.Sprintf("database query failed: %v", err) + } + + if status == string(types.StatusClosed) { + return true, fmt.Sprintf("target bead %s is closed", beadID) + } + + return false, fmt.Sprintf("target bead %s status is %q (waiting for closed)", beadID, status) +} + // closeGate closes a gate issue with the given reason func closeGate(ctx interface{}, gateID, reason string) error { if daemonClient != nil { @@ -617,10 +696,10 @@ func init() { gateResolveCmd.Flags().StringP("reason", "r", "", "Reason for resolving the gate") // gate check flags - gateCheckCmd.Flags().StringP("type", "t", "", "Gate type to check (gh, gh:run, gh:pr, timer, all)") + gateCheckCmd.Flags().StringP("type", "t", "", "Gate type to check (gh, gh:run, gh:pr, timer, bead, all)") gateCheckCmd.Flags().Bool("dry-run", false, "Show what would happen without making changes") gateCheckCmd.Flags().BoolP("escalate", "e", false, "Escalate failed/expired gates") - gateCheckCmd.Flags().IntP("limit", "n", 100, "Limit results (default 100)") + gateCheckCmd.Flags().IntP("limit", "l", 100, "Limit results (default 100)") // Add subcommands gateCmd.AddCommand(gateListCmd) diff --git a/cmd/bd/gate_test.go b/cmd/bd/gate_test.go index 4ef0cd45..e6a0e670 100644 --- a/cmd/bd/gate_test.go +++ b/cmd/bd/gate_test.go @@ -1,8 +1,15 @@ package main import ( + "context" + "database/sql" + "os" + "path/filepath" "testing" + "time" + _ "github.com/ncruces/go-sqlite3/driver" + _ "github.com/ncruces/go-sqlite3/embed" "github.com/steveyegge/beads/internal/types" ) @@ -18,17 +25,20 @@ func TestShouldCheckGate(t *testing.T) { {"empty filter matches gh:pr", "gh:pr", "", true}, {"empty filter matches timer", "timer", "", true}, {"empty filter matches human", "human", "", true}, + {"empty filter matches bead", "bead", "", true}, // "all" filter matches all {"all filter matches gh:run", "gh:run", "all", true}, {"all filter matches gh:pr", "gh:pr", "all", true}, {"all filter matches timer", "timer", "all", true}, + {"all filter matches bead", "bead", "all", true}, // "gh" filter matches all GitHub types {"gh filter matches gh:run", "gh:run", "gh", true}, {"gh filter matches gh:pr", "gh:pr", "gh", true}, {"gh filter does not match timer", "timer", "gh", false}, {"gh filter does not match human", "human", "gh", false}, + {"gh filter does not match bead", "bead", "gh", false}, // Exact type filters {"gh:run filter matches gh:run", "gh:run", "gh:run", true}, @@ -37,6 +47,8 @@ func TestShouldCheckGate(t *testing.T) { {"gh:pr filter does not match gh:run", "gh:run", "gh:pr", false}, {"timer filter matches timer", "timer", "timer", true}, {"timer filter does not match gh:run", "gh:run", "timer", false}, + {"bead filter matches bead", "bead", "bead", true}, + {"bead filter does not match timer", "timer", "bead", false}, } for _, tt := range tests { @@ -52,3 +64,168 @@ func TestShouldCheckGate(t *testing.T) { }) } } + +func TestCheckBeadGate_InvalidFormat(t *testing.T) { + ctx := context.Background() + + tests := []struct { + name string + awaitID string + wantErr string + }{ + { + name: "empty", + awaitID: "", + wantErr: "invalid await_id format", + }, + { + name: "no colon", + awaitID: "gastown-gt-abc", + wantErr: "invalid await_id format", + }, + { + name: "missing rig", + awaitID: ":gt-abc", + wantErr: "await_id missing rig name", + }, + { + name: "missing bead", + awaitID: "gastown:", + wantErr: "await_id missing rig name or bead ID", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + satisfied, reason := checkBeadGate(ctx, tt.awaitID) + if satisfied { + t.Errorf("expected not satisfied for %q", tt.awaitID) + } + if reason == "" { + t.Error("expected reason to be set") + } + // Just check the error message contains the expected substring + if tt.wantErr != "" && !gateTestContainsIgnoreCase(reason, tt.wantErr) { + t.Errorf("reason %q does not contain %q", reason, tt.wantErr) + } + }) + } +} + +func TestCheckBeadGate_RigNotFound(t *testing.T) { + ctx := context.Background() + + // Create a temp directory with a minimal beads setup + tmpDir, err := os.MkdirTemp("", "gate_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Change to temp dir + origDir, _ := os.Getwd() + defer os.Chdir(origDir) + os.Chdir(tmpDir) + + // Try to check a gate for a non-existent rig + satisfied, reason := checkBeadGate(ctx, "nonexistent:some-id") + if satisfied { + t.Error("expected not satisfied for non-existent rig") + } + if reason == "" { + t.Error("expected reason to be set") + } + // The error should mention the rig not being found + if !gateTestContainsIgnoreCase(reason, "not found") && !gateTestContainsIgnoreCase(reason, "could not find") { + t.Errorf("reason should mention not found: %q", reason) + } +} + +func TestCheckBeadGate_TargetClosed(t *testing.T) { + // Create a temporary database that simulates a target rig + tmpDir, err := os.MkdirTemp("", "bead_gate_test") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpDir) + + // Create a minimal database with a closed issue + dbPath := filepath.Join(tmpDir, "beads.db") + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + t.Fatal(err) + } + + // Create minimal schema + _, err = db.Exec(` + CREATE TABLE issues ( + id TEXT PRIMARY KEY, + status TEXT, + title TEXT, + created_at TEXT, + updated_at TEXT + ) + `) + if err != nil { + t.Fatal(err) + } + + // Insert a closed issue + now := time.Now().Format(time.RFC3339) + _, err = db.Exec(` + INSERT INTO issues (id, status, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, "gt-test123", string(types.StatusClosed), "Test Issue", now, now) + if err != nil { + t.Fatal(err) + } + + // Insert an open issue + _, err = db.Exec(` + INSERT INTO issues (id, status, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, "gt-open456", string(types.StatusOpen), "Open Issue", now, now) + if err != nil { + t.Fatal(err) + } + + db.Close() + + // Note: This test can't fully exercise checkBeadGate because it relies on + // routing.ResolveBeadsDirForRig which needs a proper routes.jsonl setup. + // The full integration test would need the town/rig infrastructure. + // For now, we just verify the function signature and basic error handling. + t.Log("Database created with closed issue gt-test123 and open issue gt-open456") + t.Log("Full integration testing requires routes.jsonl setup") +} + +// gateTestContainsIgnoreCase checks if haystack contains needle (case-insensitive) +func gateTestContainsIgnoreCase(haystack, needle string) bool { + return gateTestContains(gateTestLowerCase(haystack), gateTestLowerCase(needle)) +} + +func gateTestContains(s, substr string) bool { + return len(s) >= len(substr) && gateTestFindSubstring(s, substr) >= 0 +} + +func gateTestLowerCase(s string) string { + b := []byte(s) + for i := range b { + if b[i] >= 'A' && b[i] <= 'Z' { + b[i] += 32 + } + } + return string(b) +} + +func gateTestFindSubstring(s, substr string) int { + if len(substr) == 0 { + return 0 + } + for i := 0; i <= len(s)-len(substr); i++ { + if s[i:i+len(substr)] == substr { + return i + } + } + return -1 +}