Files
beads/cmd/bd/gate.go
jasper b73085962c feat: Implement Step.Gate evaluation (Phase 1: Human Gates)
This implements Phase 1 of the Step.Gate feature (bd-7zka.2):

- bd cook now creates gate issues for steps with gate fields
- Gate issues have type=gate and block the gated step via dependency
- bd list filters out gate issues by default (use --include-gates to show)
- New bd gate command with list and resolve subcommands

Gate types supported in Phase 1:
- human: Manual closure via bd close or bd gate resolve

Implementation details:
- createGateIssue() in cook.go creates gate issues with proper metadata
- collectSteps() creates gate dependencies when processing gated steps
- IssueFilter.ExcludeTypes added to storage layer for type-based filtering
- Gate command provides dedicated UX for gate management

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-01 16:25:59 -08:00

248 lines
6.0 KiB
Go

package main
import (
"encoding/json"
"fmt"
"os"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
)
// gateCmd is the parent command for gate operations
var gateCmd = &cobra.Command{
Use: "gate",
GroupID: "issues",
Short: "Manage async coordination gates",
Long: `Gates are async wait conditions that block workflow steps.
Gates are created automatically when a formula step has a gate field.
They must be closed (manually or via watchers) for the blocked step to proceed.
Gate types:
human - Requires manual bd close (Phase 1)
timer - Expires after timeout (Phase 2)
gh:run - Waits for GitHub workflow (Phase 3)
gh:pr - Waits for PR merge (Phase 3)
Examples:
bd gate list # Show all open gates
bd gate list --all # Show all gates including closed
bd gate resolve <id> # Close a gate manually`,
}
// gateListCmd lists gate issues
var gateListCmd = &cobra.Command{
Use: "list",
Short: "List gate issues",
Long: `List all gate issues in the current beads database.
By default, shows only open gates. Use --all to include closed gates.`,
Run: func(cmd *cobra.Command, args []string) {
allFlag, _ := cmd.Flags().GetBool("all")
limit, _ := cmd.Flags().GetInt("limit")
// Build filter for gate type issues
gateType := types.TypeGate
filter := types.IssueFilter{
IssueType: &gateType,
Limit: limit,
}
// By default, exclude closed gates
if !allFlag {
filter.ExcludeStatus = []types.Status{types.StatusClosed}
}
ctx := rootCtx
// If daemon is running, use RPC
if daemonClient != nil {
listArgs := &rpc.ListArgs{
IssueType: "gate",
Limit: limit,
}
if !allFlag {
listArgs.ExcludeStatus = []string{"closed"}
}
resp, err := daemonClient.List(listArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
var issues []*types.Issue
if err := json.Unmarshal(resp.Data, &issues); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
if jsonOutput {
outputJSON(issues)
return
}
displayGates(issues)
return
}
// Direct mode
issues, err := store.SearchIssues(ctx, "", filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
outputJSON(issues)
return
}
displayGates(issues)
},
}
// displayGates formats and displays gate issues
func displayGates(gates []*types.Issue) {
if len(gates) == 0 {
fmt.Println("No gates found.")
return
}
fmt.Printf("\n%s Open Gates (%d):\n\n", ui.RenderAccent("⏳"), len(gates))
for _, gate := range gates {
statusSym := "○"
if gate.Status == types.StatusClosed {
statusSym = "●"
}
// Format gate info
gateInfo := gate.AwaitType
if gate.AwaitID != "" {
gateInfo = fmt.Sprintf("%s %s", gate.AwaitType, gate.AwaitID)
}
// Format timeout if present
timeoutStr := ""
if gate.Timeout > 0 {
timeoutStr = fmt.Sprintf(" (timeout: %s)", gate.Timeout)
}
// Find blocked step from ID (gate ID format: parent.gate-stepid)
blockedStep := ""
if strings.Contains(gate.ID, ".gate-") {
parts := strings.Split(gate.ID, ".gate-")
if len(parts) == 2 {
blockedStep = fmt.Sprintf("%s.%s", parts[0], parts[1])
}
}
fmt.Printf("%s %s - %s%s\n", statusSym, ui.RenderID(gate.ID), gateInfo, timeoutStr)
if blockedStep != "" {
fmt.Printf(" Blocks: %s\n", blockedStep)
}
fmt.Println()
}
fmt.Printf("To resolve a gate: bd close <gate-id>\n")
}
// gateResolveCmd manually closes a gate
var gateResolveCmd = &cobra.Command{
Use: "resolve <gate-id>",
Short: "Manually resolve (close) a gate",
Long: `Close a gate issue to unblock the step waiting on it.
This is equivalent to 'bd close <gate-id>' but with a more explicit name.
Use --reason to provide context for why the gate was resolved.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("gate resolve")
gateID := args[0]
reason, _ := cmd.Flags().GetString("reason")
// Verify it's a gate issue
ctx := rootCtx
var issue *types.Issue
var err error
if daemonClient != nil {
resp, rerr := daemonClient.Show(&rpc.ShowArgs{ID: gateID})
if rerr != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", rerr)
os.Exit(1)
}
if !resp.Success {
fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error)
os.Exit(1)
}
var details types.IssueDetails
if uerr := json.Unmarshal(resp.Data, &details); uerr != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", uerr)
os.Exit(1)
}
issue = &details.Issue
} else {
issue, err = store.GetIssue(ctx, gateID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: gate not found: %s\n", gateID)
os.Exit(1)
}
}
if issue.IssueType != types.TypeGate {
fmt.Fprintf(os.Stderr, "Error: %s is not a gate issue (type=%s)\n", gateID, issue.IssueType)
os.Exit(1)
}
// Close the gate
if daemonClient != nil {
closeArgs := &rpc.CloseArgs{
ID: gateID,
Reason: reason,
}
resp, cerr := daemonClient.CloseIssue(closeArgs)
if cerr != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", cerr)
os.Exit(1)
}
if !resp.Success {
fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error)
os.Exit(1)
}
} else {
if err := store.CloseIssue(ctx, gateID, reason, actor, ""); err != nil {
fmt.Fprintf(os.Stderr, "Error closing gate: %v\n", err)
os.Exit(1)
}
markDirtyAndScheduleFlush()
}
fmt.Printf("%s Gate resolved: %s\n", ui.RenderPass("✓"), gateID)
if reason != "" {
fmt.Printf(" Reason: %s\n", reason)
}
},
}
func init() {
// gate list flags
gateListCmd.Flags().BoolP("all", "a", false, "Show all gates including closed")
gateListCmd.Flags().IntP("limit", "n", 50, "Limit results (default 50)")
// gate resolve flags
gateResolveCmd.Flags().StringP("reason", "r", "", "Reason for resolving the gate")
// Add subcommands
gateCmd.AddCommand(gateListCmd)
gateCmd.AddCommand(gateResolveCmd)
rootCmd.AddCommand(gateCmd)
}