feat(gate): add cross-rig bead gate support (Phase 4)
Add bead gate type for cross-rig bead dependencies: - await_type=bead with await_id=<rig>:<bead-id> format - Add `bd gate check` command to evaluate open gates - Support --type=bead to check only bead gates - Support --dry-run to preview without closing - Gate resolves when target bead is closed Closes bd-w3rh 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -2,6 +2,8 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
@@ -9,7 +11,12 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/ncruces/go-sqlite3/driver"
|
||||||
|
_ "github.com/ncruces/go-sqlite3/embed"
|
||||||
"github.com/spf13/cobra"
|
"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/rpc"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
"github.com/steveyegge/beads/internal/ui"
|
"github.com/steveyegge/beads/internal/ui"
|
||||||
@@ -30,10 +37,15 @@ Gate types:
|
|||||||
timer - Expires after timeout (Phase 2)
|
timer - Expires after timeout (Phase 2)
|
||||||
gh:run - Waits for GitHub workflow (Phase 3)
|
gh:run - Waits for GitHub workflow (Phase 3)
|
||||||
gh:pr - Waits for PR merge (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 <rig>:<bead-id> (e.g., "gastown:gt-abc123").
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
bd gate list # Show all open gates
|
bd gate list # Show all open gates
|
||||||
bd gate list --all # Show all gates including closed
|
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 <id> # Close a gate manually`,
|
bd gate resolve <id> # Close a gate manually`,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,6 +259,7 @@ Gate types:
|
|||||||
gh:run - Check GitHub Actions workflow runs
|
gh:run - Check GitHub Actions workflow runs
|
||||||
gh:pr - Check pull request merge status
|
gh:pr - Check pull request merge status
|
||||||
timer - Check timer gates (auto-expire based on timeout)
|
timer - Check timer gates (auto-expire based on timeout)
|
||||||
|
bead - Check cross-rig bead gates
|
||||||
all - Check all gate types
|
all - Check all gate types
|
||||||
|
|
||||||
GitHub gates use the 'gh' CLI to query status:
|
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:run: status=completed AND conclusion=success
|
||||||
- gh:pr: state=MERGED
|
- gh:pr: state=MERGED
|
||||||
- timer: current time > created_at + timeout
|
- timer: current time > created_at + timeout
|
||||||
|
- bead: target bead status=closed
|
||||||
|
|
||||||
A gate is escalated when:
|
A gate is escalated when:
|
||||||
- gh:run: status=completed AND conclusion in (failure, cancelled)
|
- 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 # Check only GitHub gates
|
||||||
bd gate check --type=gh:run # Check only workflow run 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=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 --dry-run # Show what would happen without changes
|
||||||
bd gate check --escalate # Escalate expired/failed gates`,
|
bd gate check --escalate # Escalate expired/failed gates`,
|
||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
@@ -351,6 +366,8 @@ Examples:
|
|||||||
result.resolved, result.escalated, result.reason, result.err = checkGHPR(gate)
|
result.resolved, result.escalated, result.reason, result.err = checkGHPR(gate)
|
||||||
case gate.AwaitType == "timer":
|
case gate.AwaitType == "timer":
|
||||||
result.resolved, result.escalated, result.reason, result.err = checkTimer(gate, now)
|
result.resolved, result.escalated, result.reason, result.err = checkTimer(gate, now)
|
||||||
|
case gate.AwaitType == "bead":
|
||||||
|
result.resolved, result.reason = checkBeadGate(ctx, gate.AwaitID)
|
||||||
default:
|
default:
|
||||||
// Skip unsupported gate types (human gates need manual resolution)
|
// Skip unsupported gate types (human gates need manual resolution)
|
||||||
continue
|
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
|
return false, false, fmt.Sprintf("expires in %s", remaining), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkBeadGate checks if a cross-rig bead gate is satisfied.
|
||||||
|
// await_id format: <rig>:<bead-id> (e.g., "gastown:gt-abc123")
|
||||||
|
// Returns (satisfied, reason).
|
||||||
|
func checkBeadGate(ctx context.Context, awaitID string) (bool, string) {
|
||||||
|
// Parse await_id format: <rig>:<bead-id>
|
||||||
|
parts := strings.SplitN(awaitID, ":", 2)
|
||||||
|
if len(parts) != 2 {
|
||||||
|
return false, fmt.Sprintf("invalid await_id format: expected <rig>:<bead-id>, 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
|
// closeGate closes a gate issue with the given reason
|
||||||
func closeGate(ctx interface{}, gateID, reason string) error {
|
func closeGate(ctx interface{}, gateID, reason string) error {
|
||||||
if daemonClient != nil {
|
if daemonClient != nil {
|
||||||
@@ -617,10 +696,10 @@ func init() {
|
|||||||
gateResolveCmd.Flags().StringP("reason", "r", "", "Reason for resolving the gate")
|
gateResolveCmd.Flags().StringP("reason", "r", "", "Reason for resolving the gate")
|
||||||
|
|
||||||
// gate check flags
|
// 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().Bool("dry-run", false, "Show what would happen without making changes")
|
||||||
gateCheckCmd.Flags().BoolP("escalate", "e", false, "Escalate failed/expired gates")
|
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
|
// Add subcommands
|
||||||
gateCmd.AddCommand(gateListCmd)
|
gateCmd.AddCommand(gateListCmd)
|
||||||
|
|||||||
@@ -1,8 +1,15 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/ncruces/go-sqlite3/driver"
|
||||||
|
_ "github.com/ncruces/go-sqlite3/embed"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"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 gh:pr", "gh:pr", "", true},
|
||||||
{"empty filter matches timer", "timer", "", true},
|
{"empty filter matches timer", "timer", "", true},
|
||||||
{"empty filter matches human", "human", "", true},
|
{"empty filter matches human", "human", "", true},
|
||||||
|
{"empty filter matches bead", "bead", "", true},
|
||||||
|
|
||||||
// "all" filter matches all
|
// "all" filter matches all
|
||||||
{"all filter matches gh:run", "gh:run", "all", true},
|
{"all filter matches gh:run", "gh:run", "all", true},
|
||||||
{"all filter matches gh:pr", "gh:pr", "all", true},
|
{"all filter matches gh:pr", "gh:pr", "all", true},
|
||||||
{"all filter matches timer", "timer", "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 all GitHub types
|
||||||
{"gh filter matches gh:run", "gh:run", "gh", true},
|
{"gh filter matches gh:run", "gh:run", "gh", true},
|
||||||
{"gh filter matches gh:pr", "gh:pr", "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 timer", "timer", "gh", false},
|
||||||
{"gh filter does not match human", "human", "gh", false},
|
{"gh filter does not match human", "human", "gh", false},
|
||||||
|
{"gh filter does not match bead", "bead", "gh", false},
|
||||||
|
|
||||||
// Exact type filters
|
// Exact type filters
|
||||||
{"gh:run filter matches gh:run", "gh:run", "gh:run", true},
|
{"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},
|
{"gh:pr filter does not match gh:run", "gh:run", "gh:pr", false},
|
||||||
{"timer filter matches timer", "timer", "timer", true},
|
{"timer filter matches timer", "timer", "timer", true},
|
||||||
{"timer filter does not match gh:run", "gh:run", "timer", false},
|
{"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 {
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user