Implements evaluation for GitHub-related gate types: - gh:run: Checks if a GitHub Actions run has completed using `gh run view <id> --json status,conclusion` - gh:pr: Checks if a PR has been merged/closed using `gh pr view <id> --json state,merged` Both gate types require the gh CLI to be installed and authenticated. If gh fails (not installed, network issues, invalid IDs), the gate is skipped rather than erroneously closed. Refactors the eval loop to use a switch statement for cleaner gate type dispatch. (gt-twjr5.3) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
844 lines
23 KiB
Go
844 lines
23 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"os"
|
|
"os/exec"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/steveyegge/beads/internal/rpc"
|
|
"github.com/steveyegge/beads/internal/storage/sqlite"
|
|
"github.com/steveyegge/beads/internal/types"
|
|
"github.com/steveyegge/beads/internal/ui"
|
|
"github.com/steveyegge/beads/internal/utils"
|
|
)
|
|
|
|
// Gate commands - async coordination primitives for agent workflows (bd-udsi)
|
|
//
|
|
// Gates are wisp issues that block until external conditions are met.
|
|
// They enable agents to wait on:
|
|
// - GitHub Actions run completion (gh:run:<id>)
|
|
// - Pull request merge/close (gh:pr:<id>)
|
|
// - Simple timer delay (timer:<duration>)
|
|
// - Human approval (human:<prompt>)
|
|
// - Mail matching a pattern (mail:<pattern>)
|
|
//
|
|
// Usage:
|
|
// bd gate create --await gh:run:123 --timeout 30m --notify beads/dave
|
|
// bd gate show <id>
|
|
// bd gate list
|
|
// bd gate close <id> --reason "completed"
|
|
// bd gate wait <id> --notify beads/alice
|
|
|
|
var gateCmd = &cobra.Command{
|
|
Use: "gate",
|
|
Short: "Gate commands (async coordination)",
|
|
Long: `Manage gates - async coordination primitives for agent workflows.
|
|
|
|
Gates are ephemeral (wisp) issues that block until external conditions are met.
|
|
The orchestrator is responsible for monitoring and closing gates.
|
|
|
|
Await types:
|
|
gh:run:<id> Wait for GitHub Actions run to complete
|
|
gh:pr:<id> Wait for pull request to be merged/closed
|
|
timer:<duration> Simple delay (e.g., timer:30m, timer:1h)
|
|
human:<prompt> Wait for human approval
|
|
mail:<pattern> Wait for mail matching pattern
|
|
|
|
Commands:
|
|
create Create a new gate
|
|
show Show gate details
|
|
list List open gates
|
|
close Close a gate
|
|
wait Add a waiter to an existing gate`,
|
|
}
|
|
|
|
var gateCreateCmd = &cobra.Command{
|
|
Use: "create",
|
|
Short: "Create a new gate",
|
|
Long: `Create a new gate to wait on an external condition.
|
|
|
|
The gate will be created as a wisp issue (ephemeral). The orchestrator
|
|
is responsible for monitoring gates and closing them when conditions are met.
|
|
|
|
Examples:
|
|
bd gate create --await gh:run:123456789 --timeout 30m --notify beads/dave
|
|
bd gate create --await gh:pr:42 --timeout 1h --notify beads/dave beads/alice
|
|
bd gate create --await timer:15m --notify beads/dave
|
|
bd gate create --await human:approve-deploy --timeout 4h --notify beads/dave`,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
CheckReadonly("gate create")
|
|
ctx := rootCtx
|
|
|
|
awaitSpec, _ := cmd.Flags().GetString("await")
|
|
timeoutStr, _ := cmd.Flags().GetString("timeout")
|
|
notifyAddrs, _ := cmd.Flags().GetStringSlice("notify")
|
|
title, _ := cmd.Flags().GetString("title")
|
|
|
|
if awaitSpec == "" {
|
|
fmt.Fprintf(os.Stderr, "Error: --await is required\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Parse await spec into type and ID
|
|
awaitType, awaitID := parseAwaitSpec(awaitSpec)
|
|
if awaitType == "" {
|
|
fmt.Fprintf(os.Stderr, "Error: invalid await spec %q, expected format: type:id\n", awaitSpec)
|
|
fmt.Fprintf(os.Stderr, "Valid types: gh:run, gh:pr, timer, human, mail\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Parse timeout
|
|
var timeout time.Duration
|
|
if timeoutStr != "" {
|
|
var err error
|
|
timeout, err = time.ParseDuration(timeoutStr)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: invalid timeout %q: %v\n", timeoutStr, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// For timer gates, the await_id IS the duration - use it as timeout if not explicitly set
|
|
if awaitType == "timer" && timeout == 0 {
|
|
var err error
|
|
timeout, err = time.ParseDuration(awaitID)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: invalid timer duration %q: %v\n", awaitID, err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
// Generate title if not provided
|
|
if title == "" {
|
|
title = fmt.Sprintf("Gate: %s:%s", awaitType, awaitID)
|
|
}
|
|
|
|
var gate *types.Issue
|
|
|
|
// Try daemon first, fall back to direct store access
|
|
if daemonClient != nil {
|
|
resp, err := daemonClient.GateCreate(&rpc.GateCreateArgs{
|
|
Title: title,
|
|
AwaitType: awaitType,
|
|
AwaitID: awaitID,
|
|
Timeout: timeout,
|
|
Waiters: notifyAddrs,
|
|
})
|
|
if err != nil {
|
|
FatalError("gate create: %v", err)
|
|
}
|
|
|
|
// Parse the gate ID from response and fetch full gate
|
|
var result rpc.GateCreateResult
|
|
if err := json.Unmarshal(resp.Data, &result); err != nil {
|
|
FatalError("failed to parse gate create result: %v", err)
|
|
}
|
|
|
|
// Get the full gate for output
|
|
showResp, err := daemonClient.GateShow(&rpc.GateShowArgs{ID: result.ID})
|
|
if err != nil {
|
|
FatalError("failed to fetch created gate: %v", err)
|
|
}
|
|
if err := json.Unmarshal(showResp.Data, &gate); err != nil {
|
|
FatalError("failed to parse gate: %v", err)
|
|
}
|
|
} else if store != nil {
|
|
now := time.Now()
|
|
gate = &types.Issue{
|
|
// ID will be generated by CreateIssue
|
|
Title: title,
|
|
IssueType: types.TypeGate,
|
|
Status: types.StatusOpen,
|
|
Priority: 1, // Gates are typically high priority
|
|
// Assignee left empty - orchestrator decides who processes gates
|
|
Wisp: true, // Gates are wisps (ephemeral)
|
|
AwaitType: awaitType,
|
|
AwaitID: awaitID,
|
|
Timeout: timeout,
|
|
Waiters: notifyAddrs,
|
|
CreatedAt: now,
|
|
UpdatedAt: now,
|
|
}
|
|
gate.ContentHash = gate.ComputeContentHash()
|
|
|
|
if err := store.CreateIssue(ctx, gate, actor); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error creating gate: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
markDirtyAndScheduleFlush()
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(gate)
|
|
return
|
|
}
|
|
|
|
fmt.Printf("%s Created gate: %s\n", ui.RenderPass("✓"), gate.ID)
|
|
fmt.Printf(" Await: %s:%s\n", awaitType, awaitID)
|
|
if timeout > 0 {
|
|
fmt.Printf(" Timeout: %v\n", timeout)
|
|
}
|
|
if len(notifyAddrs) > 0 {
|
|
fmt.Printf(" Notify: %s\n", strings.Join(notifyAddrs, ", "))
|
|
}
|
|
},
|
|
}
|
|
|
|
// parseAwaitSpec parses an await specification like "gh:run:123" or "timer:30m"
|
|
// Returns (awaitType, awaitID) or ("", "") if invalid
|
|
func parseAwaitSpec(spec string) (string, string) {
|
|
// Handle compound types like gh:run:123 and gh:pr:42
|
|
if strings.HasPrefix(spec, "gh:run:") {
|
|
return "gh:run", strings.TrimPrefix(spec, "gh:run:")
|
|
}
|
|
if strings.HasPrefix(spec, "gh:pr:") {
|
|
return "gh:pr", strings.TrimPrefix(spec, "gh:pr:")
|
|
}
|
|
|
|
// Handle simple types like timer:30m, human:approve, mail:pattern
|
|
parts := strings.SplitN(spec, ":", 2)
|
|
if len(parts) != 2 {
|
|
return "", ""
|
|
}
|
|
|
|
awaitType := parts[0]
|
|
awaitID := parts[1]
|
|
|
|
// Validate type
|
|
validTypes := map[string]bool{
|
|
"timer": true,
|
|
"human": true,
|
|
"mail": true,
|
|
}
|
|
if !validTypes[awaitType] {
|
|
return "", ""
|
|
}
|
|
|
|
return awaitType, awaitID
|
|
}
|
|
|
|
var gateShowCmd = &cobra.Command{
|
|
Use: "show <gate-id>",
|
|
Short: "Show gate details",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
ctx := rootCtx
|
|
|
|
var gate *types.Issue
|
|
|
|
// Try daemon first, fall back to direct store access
|
|
if daemonClient != nil {
|
|
resp, err := daemonClient.GateShow(&rpc.GateShowArgs{ID: args[0]})
|
|
if err != nil {
|
|
FatalError("gate show: %v", err)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &gate); err != nil {
|
|
FatalError("failed to parse gate: %v", err)
|
|
}
|
|
} else if store != nil {
|
|
gateID, err := utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
gate, err = store.GetIssue(ctx, gateID)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if gate == nil {
|
|
fmt.Fprintf(os.Stderr, "Error: gate %s not found\n", gateID)
|
|
os.Exit(1)
|
|
}
|
|
if gate.IssueType != types.TypeGate {
|
|
fmt.Fprintf(os.Stderr, "Error: %s is not a gate (type: %s)\n", gateID, gate.IssueType)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(gate)
|
|
return
|
|
}
|
|
|
|
fmt.Printf("\n%s Gate: %s\n", ui.RenderAccent("🚧"), gate.ID)
|
|
fmt.Printf(" Title: %s\n", gate.Title)
|
|
fmt.Printf(" Status: %s\n", gate.Status)
|
|
fmt.Printf(" Await: %s:%s\n", gate.AwaitType, gate.AwaitID)
|
|
if gate.Timeout > 0 {
|
|
elapsed := time.Since(gate.CreatedAt)
|
|
remaining := gate.Timeout - elapsed
|
|
if remaining < 0 {
|
|
remaining = 0
|
|
}
|
|
fmt.Printf(" Timeout: %v (remaining: %v)\n", gate.Timeout, remaining.Round(time.Second))
|
|
}
|
|
if len(gate.Waiters) > 0 {
|
|
fmt.Printf(" Waiters: %s\n", strings.Join(gate.Waiters, ", "))
|
|
}
|
|
fmt.Printf(" Created: %s\n", gate.CreatedAt.Format("2006-01-02 15:04:05"))
|
|
if gate.CloseReason != "" {
|
|
fmt.Printf(" Close reason: %s\n", gate.CloseReason)
|
|
}
|
|
fmt.Println()
|
|
},
|
|
}
|
|
|
|
var gateListCmd = &cobra.Command{
|
|
Use: "list",
|
|
Short: "List open gates",
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
ctx := rootCtx
|
|
showAll, _ := cmd.Flags().GetBool("all")
|
|
|
|
var issues []*types.Issue
|
|
|
|
// Try daemon first, fall back to direct store access
|
|
if daemonClient != nil {
|
|
resp, err := daemonClient.GateList(&rpc.GateListArgs{All: showAll})
|
|
if err != nil {
|
|
FatalError("gate list: %v", err)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &issues); err != nil {
|
|
FatalError("failed to parse gates: %v", err)
|
|
}
|
|
} else if store != nil {
|
|
// Build filter for gates
|
|
gateType := types.TypeGate
|
|
filter := types.IssueFilter{
|
|
IssueType: &gateType,
|
|
}
|
|
if !showAll {
|
|
openStatus := types.StatusOpen
|
|
filter.Status = &openStatus
|
|
}
|
|
|
|
var err error
|
|
issues, err = store.SearchIssues(ctx, "", filter)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error listing gates: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(issues)
|
|
return
|
|
}
|
|
|
|
if len(issues) == 0 {
|
|
fmt.Println("No gates found")
|
|
return
|
|
}
|
|
|
|
fmt.Printf("\n%s Gates (%d):\n\n", ui.RenderAccent("🚧"), len(issues))
|
|
for _, gate := range issues {
|
|
statusIcon := "⏳"
|
|
if gate.Status == types.StatusClosed {
|
|
statusIcon = "✓"
|
|
}
|
|
awaitSpec := fmt.Sprintf("%s:%s", gate.AwaitType, gate.AwaitID)
|
|
timeoutInfo := ""
|
|
if gate.Timeout > 0 {
|
|
elapsed := time.Since(gate.CreatedAt)
|
|
remaining := gate.Timeout - elapsed
|
|
if remaining < 0 {
|
|
timeoutInfo = " (timed out)"
|
|
} else {
|
|
timeoutInfo = fmt.Sprintf(" (%v left)", remaining.Round(time.Second))
|
|
}
|
|
}
|
|
fmt.Printf(" %s %s: %s%s\n", statusIcon, gate.ID, awaitSpec, timeoutInfo)
|
|
if len(gate.Waiters) > 0 {
|
|
fmt.Printf(" Waiters: %s\n", strings.Join(gate.Waiters, ", "))
|
|
}
|
|
}
|
|
fmt.Println()
|
|
},
|
|
}
|
|
|
|
var gateCloseCmd = &cobra.Command{
|
|
Use: "close <gate-id>",
|
|
Short: "Close a gate",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
CheckReadonly("gate close")
|
|
ctx := rootCtx
|
|
reason, _ := cmd.Flags().GetString("reason")
|
|
if reason == "" {
|
|
reason = "Gate closed"
|
|
}
|
|
|
|
var closedGate *types.Issue
|
|
var gateID string
|
|
|
|
// Try daemon first, fall back to direct store access
|
|
if daemonClient != nil {
|
|
resp, err := daemonClient.GateClose(&rpc.GateCloseArgs{
|
|
ID: args[0],
|
|
Reason: reason,
|
|
})
|
|
if err != nil {
|
|
FatalError("gate close: %v", err)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &closedGate); err != nil {
|
|
FatalError("failed to parse gate: %v", err)
|
|
}
|
|
gateID = closedGate.ID
|
|
} else if store != nil {
|
|
var err error
|
|
gateID, err = utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Verify it's a gate
|
|
gate, err := store.GetIssue(ctx, gateID)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if gate == nil {
|
|
fmt.Fprintf(os.Stderr, "Error: gate %s not found\n", gateID)
|
|
os.Exit(1)
|
|
}
|
|
if gate.IssueType != types.TypeGate {
|
|
fmt.Fprintf(os.Stderr, "Error: %s is not a gate (type: %s)\n", gateID, gate.IssueType)
|
|
os.Exit(1)
|
|
}
|
|
|
|
if err := store.CloseIssue(ctx, gateID, reason, actor); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error closing gate: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
markDirtyAndScheduleFlush()
|
|
closedGate, _ = store.GetIssue(ctx, gateID)
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(closedGate)
|
|
return
|
|
}
|
|
|
|
fmt.Printf("%s Closed gate: %s\n", ui.RenderPass("✓"), gateID)
|
|
fmt.Printf(" Reason: %s\n", reason)
|
|
},
|
|
}
|
|
|
|
var gateWaitCmd = &cobra.Command{
|
|
Use: "wait <gate-id>",
|
|
Short: "Add a waiter to an existing gate",
|
|
Args: cobra.ExactArgs(1),
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
CheckReadonly("gate wait")
|
|
ctx := rootCtx
|
|
notifyAddrs, _ := cmd.Flags().GetStringSlice("notify")
|
|
|
|
if len(notifyAddrs) == 0 {
|
|
fmt.Fprintf(os.Stderr, "Error: --notify is required\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
var addedCount int
|
|
var gateID string
|
|
var newWaiters []string
|
|
|
|
// Try daemon first, fall back to direct store access
|
|
if daemonClient != nil {
|
|
resp, err := daemonClient.GateWait(&rpc.GateWaitArgs{
|
|
ID: args[0],
|
|
Waiters: notifyAddrs,
|
|
})
|
|
if err != nil {
|
|
FatalError("gate wait: %v", err)
|
|
}
|
|
var result rpc.GateWaitResult
|
|
if err := json.Unmarshal(resp.Data, &result); err != nil {
|
|
FatalError("failed to parse gate wait result: %v", err)
|
|
}
|
|
addedCount = result.AddedCount
|
|
gateID = args[0] // Use the input ID for display
|
|
// For daemon mode, we don't know exactly which waiters were added
|
|
// Just report the count
|
|
newWaiters = nil
|
|
} else if store != nil {
|
|
var err error
|
|
gateID, err = utils.ResolvePartialID(ctx, store, args[0])
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Get existing gate
|
|
gate, err := store.GetIssue(ctx, gateID)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
if gate == nil {
|
|
fmt.Fprintf(os.Stderr, "Error: gate %s not found\n", gateID)
|
|
os.Exit(1)
|
|
}
|
|
if gate.IssueType != types.TypeGate {
|
|
fmt.Fprintf(os.Stderr, "Error: %s is not a gate (type: %s)\n", gateID, gate.IssueType)
|
|
os.Exit(1)
|
|
}
|
|
if gate.Status == types.StatusClosed {
|
|
fmt.Fprintf(os.Stderr, "Error: gate %s is already closed\n", gateID)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Add new waiters (avoiding duplicates)
|
|
waiterSet := make(map[string]bool)
|
|
for _, w := range gate.Waiters {
|
|
waiterSet[w] = true
|
|
}
|
|
for _, addr := range notifyAddrs {
|
|
if !waiterSet[addr] {
|
|
newWaiters = append(newWaiters, addr)
|
|
waiterSet[addr] = true
|
|
}
|
|
}
|
|
|
|
addedCount = len(newWaiters)
|
|
|
|
if addedCount == 0 {
|
|
fmt.Println("All specified waiters are already registered on this gate")
|
|
return
|
|
}
|
|
|
|
// Update waiters - need to use SQLite directly for Waiters field
|
|
sqliteStore, ok := store.(*sqlite.SQLiteStorage)
|
|
if !ok {
|
|
fmt.Fprintf(os.Stderr, "Error: gate wait requires SQLite storage\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
allWaiters := append(gate.Waiters, newWaiters...)
|
|
waitersJSON, _ := json.Marshal(allWaiters)
|
|
|
|
// Use raw SQL to update the waiters field
|
|
_, err = sqliteStore.UnderlyingDB().ExecContext(ctx, `UPDATE issues SET waiters = ?, updated_at = ? WHERE id = ?`,
|
|
string(waitersJSON), time.Now(), gateID)
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error adding waiters: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
markDirtyAndScheduleFlush()
|
|
|
|
if jsonOutput {
|
|
updatedGate, _ := store.GetIssue(ctx, gateID)
|
|
outputJSON(updatedGate)
|
|
return
|
|
}
|
|
} else {
|
|
fmt.Fprintf(os.Stderr, "Error: no database connection\n")
|
|
os.Exit(1)
|
|
}
|
|
|
|
if addedCount == 0 {
|
|
fmt.Println("All specified waiters are already registered on this gate")
|
|
return
|
|
}
|
|
|
|
if jsonOutput {
|
|
// For daemon mode, output the result
|
|
outputJSON(map[string]interface{}{"added_count": addedCount, "gate_id": gateID})
|
|
return
|
|
}
|
|
|
|
fmt.Printf("%s Added %d waiter(s) to gate %s\n", ui.RenderPass("✓"), addedCount, gateID)
|
|
for _, addr := range newWaiters {
|
|
fmt.Printf(" + %s\n", addr)
|
|
}
|
|
},
|
|
}
|
|
|
|
var gateEvalCmd = &cobra.Command{
|
|
Use: "eval",
|
|
Short: "Evaluate pending gates and close elapsed ones",
|
|
Long: `Evaluate all open gates and close those whose conditions are met.
|
|
|
|
Supported gate types:
|
|
- timer gates: closed when elapsed time exceeds timeout
|
|
- gh:run gates: closed when GitHub Actions run completes (requires gh CLI)
|
|
- gh:pr gates: closed when PR is merged/closed (requires gh CLI)
|
|
|
|
This command is idempotent and safe to run repeatedly.`,
|
|
Run: func(cmd *cobra.Command, args []string) {
|
|
CheckReadonly("gate eval")
|
|
ctx := rootCtx
|
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
|
|
|
var gates []*types.Issue
|
|
|
|
// Get all open gates
|
|
if daemonClient != nil {
|
|
resp, err := daemonClient.GateList(&rpc.GateListArgs{All: false})
|
|
if err != nil {
|
|
FatalError("gate eval: %v", err)
|
|
}
|
|
if err := json.Unmarshal(resp.Data, &gates); err != nil {
|
|
FatalError("failed to parse gates: %v", err)
|
|
}
|
|
} else if store != nil {
|
|
gateType := types.TypeGate
|
|
openStatus := types.StatusOpen
|
|
filter := types.IssueFilter{
|
|
IssueType: &gateType,
|
|
Status: &openStatus,
|
|
}
|
|
var err error
|
|
gates, err = store.SearchIssues(ctx, "", filter)
|
|
if err != nil {
|
|
FatalError("listing gates: %v", err)
|
|
}
|
|
} else {
|
|
FatalError("no database connection")
|
|
}
|
|
|
|
if len(gates) == 0 {
|
|
if !jsonOutput {
|
|
fmt.Println("No open gates to evaluate")
|
|
}
|
|
return
|
|
}
|
|
|
|
var closed []string
|
|
var skipped []string
|
|
now := time.Now()
|
|
|
|
for _, gate := range gates {
|
|
var shouldClose bool
|
|
var reason string
|
|
|
|
switch gate.AwaitType {
|
|
case "timer":
|
|
shouldClose, reason = evalTimerGate(gate, now)
|
|
case "gh:run":
|
|
shouldClose, reason = evalGHRunGate(gate)
|
|
case "gh:pr":
|
|
shouldClose, reason = evalGHPRGate(gate)
|
|
default:
|
|
// Unsupported gate type - skip
|
|
skipped = append(skipped, gate.ID)
|
|
continue
|
|
}
|
|
|
|
if !shouldClose {
|
|
continue
|
|
}
|
|
|
|
// Gate condition met - close it
|
|
if dryRun {
|
|
fmt.Printf("Would close gate %s (%s)\n", gate.ID, reason)
|
|
closed = append(closed, gate.ID)
|
|
continue
|
|
}
|
|
|
|
if daemonClient != nil {
|
|
_, err := daemonClient.GateClose(&rpc.GateCloseArgs{
|
|
ID: gate.ID,
|
|
Reason: reason,
|
|
})
|
|
if err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to close gate %s: %v\n", gate.ID, err)
|
|
continue
|
|
}
|
|
} else if store != nil {
|
|
if err := store.CloseIssue(ctx, gate.ID, reason, actor); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Warning: failed to close gate %s: %v\n", gate.ID, err)
|
|
continue
|
|
}
|
|
markDirtyAndScheduleFlush()
|
|
}
|
|
|
|
closed = append(closed, gate.ID)
|
|
}
|
|
|
|
if jsonOutput {
|
|
outputJSON(map[string]interface{}{
|
|
"evaluated": len(gates),
|
|
"closed": closed,
|
|
"skipped": skipped,
|
|
})
|
|
return
|
|
}
|
|
|
|
if len(closed) == 0 {
|
|
fmt.Printf("Evaluated %d gates, none ready to close\n", len(gates))
|
|
} else {
|
|
action := "Closed"
|
|
if dryRun {
|
|
action = "Would close"
|
|
}
|
|
fmt.Printf("%s %s %d gate(s)\n", ui.RenderPass("✓"), action, len(closed))
|
|
for _, id := range closed {
|
|
fmt.Printf(" %s\n", id)
|
|
}
|
|
}
|
|
if len(skipped) > 0 {
|
|
fmt.Printf("Skipped %d unsupported gate(s): %s\n", len(skipped), strings.Join(skipped, ", "))
|
|
}
|
|
},
|
|
}
|
|
|
|
// evalTimerGate checks if a timer gate's duration has elapsed.
|
|
func evalTimerGate(gate *types.Issue, now time.Time) (bool, string) {
|
|
if gate.Timeout <= 0 {
|
|
return false, "" // No timeout set
|
|
}
|
|
|
|
elapsed := now.Sub(gate.CreatedAt)
|
|
if elapsed < gate.Timeout {
|
|
return false, "" // Not yet elapsed
|
|
}
|
|
|
|
return true, fmt.Sprintf("Timer elapsed (%v)", gate.Timeout)
|
|
}
|
|
|
|
// ghRunStatus represents the JSON output of `gh run view --json`
|
|
type ghRunStatus struct {
|
|
Status string `json:"status"` // queued, in_progress, completed
|
|
Conclusion string `json:"conclusion"` // success, failure, cancelled, skipped, etc.
|
|
}
|
|
|
|
// evalGHRunGate checks if a GitHub Actions run has completed.
|
|
// Uses `gh run view <run_id> --json status,conclusion` to check status.
|
|
func evalGHRunGate(gate *types.Issue) (bool, string) {
|
|
runID := gate.AwaitID
|
|
if runID == "" {
|
|
return false, ""
|
|
}
|
|
|
|
// Run gh CLI to get run status
|
|
cmd := exec.Command("gh", "run", "view", runID, "--json", "status,conclusion")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
// gh CLI failed - could be network issue, invalid run ID, or gh not installed
|
|
// Don't close the gate, just skip it
|
|
return false, ""
|
|
}
|
|
|
|
var status ghRunStatus
|
|
if err := json.Unmarshal(output, &status); err != nil {
|
|
return false, ""
|
|
}
|
|
|
|
// Only close if status is "completed"
|
|
if status.Status != "completed" {
|
|
return false, ""
|
|
}
|
|
|
|
// Run completed - include conclusion in reason
|
|
reason := fmt.Sprintf("GitHub Actions run %s completed", runID)
|
|
if status.Conclusion != "" {
|
|
reason = fmt.Sprintf("GitHub Actions run %s: %s", runID, status.Conclusion)
|
|
}
|
|
|
|
return true, reason
|
|
}
|
|
|
|
// ghPRStatus represents the JSON output of `gh pr view --json`
|
|
type ghPRStatus struct {
|
|
State string `json:"state"` // OPEN, CLOSED, MERGED
|
|
Merged bool `json:"merged"` // true if merged
|
|
}
|
|
|
|
// evalGHPRGate checks if a GitHub PR has been merged or closed.
|
|
// Uses `gh pr view <pr_number> --json state,merged` to check status.
|
|
func evalGHPRGate(gate *types.Issue) (bool, string) {
|
|
prNumber := gate.AwaitID
|
|
if prNumber == "" {
|
|
return false, ""
|
|
}
|
|
|
|
// Run gh CLI to get PR status
|
|
cmd := exec.Command("gh", "pr", "view", prNumber, "--json", "state,merged")
|
|
output, err := cmd.Output()
|
|
if err != nil {
|
|
// gh CLI failed - could be network issue, invalid PR, or gh not installed
|
|
// Don't close the gate, just skip it
|
|
return false, ""
|
|
}
|
|
|
|
var status ghPRStatus
|
|
if err := json.Unmarshal(output, &status); err != nil {
|
|
return false, ""
|
|
}
|
|
|
|
// Close gate if PR is no longer OPEN
|
|
switch status.State {
|
|
case "MERGED":
|
|
return true, fmt.Sprintf("PR #%s merged", prNumber)
|
|
case "CLOSED":
|
|
if status.Merged {
|
|
return true, fmt.Sprintf("PR #%s merged", prNumber)
|
|
}
|
|
return true, fmt.Sprintf("PR #%s closed without merge", prNumber)
|
|
default:
|
|
// Still OPEN
|
|
return false, ""
|
|
}
|
|
}
|
|
|
|
func init() {
|
|
// Gate eval flags
|
|
gateEvalCmd.Flags().Bool("dry-run", false, "Show what would be closed without actually closing")
|
|
gateEvalCmd.Flags().Bool("json", false, "Output JSON format")
|
|
|
|
// Gate create flags
|
|
gateCreateCmd.Flags().String("await", "", "Await spec: gh:run:<id>, gh:pr:<id>, timer:<duration>, human:<prompt>, mail:<pattern> (required)")
|
|
gateCreateCmd.Flags().String("timeout", "", "Timeout duration (e.g., 30m, 1h)")
|
|
gateCreateCmd.Flags().StringSlice("notify", nil, "Mail addresses to notify when gate clears (repeatable)")
|
|
gateCreateCmd.Flags().String("title", "", "Custom title for the gate")
|
|
gateCreateCmd.Flags().Bool("json", false, "Output JSON format")
|
|
|
|
// Gate show flags
|
|
gateShowCmd.Flags().Bool("json", false, "Output JSON format")
|
|
|
|
// Gate list flags
|
|
gateListCmd.Flags().Bool("all", false, "Show all gates including closed")
|
|
gateListCmd.Flags().Bool("json", false, "Output JSON format")
|
|
|
|
// Gate close flags
|
|
gateCloseCmd.Flags().StringP("reason", "r", "", "Reason for closing")
|
|
gateCloseCmd.Flags().Bool("json", false, "Output JSON format")
|
|
|
|
// Gate wait flags
|
|
gateWaitCmd.Flags().StringSlice("notify", nil, "Mail addresses to add as waiters (repeatable, required)")
|
|
gateWaitCmd.Flags().Bool("json", false, "Output JSON format")
|
|
|
|
// Add subcommands to gate command
|
|
gateCmd.AddCommand(gateCreateCmd)
|
|
gateCmd.AddCommand(gateShowCmd)
|
|
gateCmd.AddCommand(gateListCmd)
|
|
gateCmd.AddCommand(gateCloseCmd)
|
|
gateCmd.AddCommand(gateWaitCmd)
|
|
gateCmd.AddCommand(gateEvalCmd)
|
|
|
|
// Add gate command to root
|
|
rootCmd.AddCommand(gateCmd)
|
|
}
|