Files
beads/cmd/bd/gate.go
nux d69bf4faa8 Fix bd gate list: Separate closed gates into own section
Previously, displayGates() always showed 'Open Gates' header even when
closed gates were included via --all flag. Also, closed gates would
appear mixed with open gates under the misleading 'Open Gates' header.

Changes:
- Modified displayGates() to accept showAll parameter
- Separates gates into 'Open Gates' and 'Closed Gates' sections
- Closed gates only shown when --all flag is used
- Fixed handleGateList RPC handler to use ExcludeStatus instead of
  Status filter for consistency with CLI behavior

Fixes gas-town issue go-47m
2026-01-13 00:46:50 -08:00

997 lines
29 KiB
Go

package main
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"os/exec"
"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"
)
// 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)
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:
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 <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, allFlag)
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, allFlag)
},
}
// displayGates formats and displays gate issues, separating open and closed gates
func displayGates(gates []*types.Issue, showAll bool) {
if len(gates) == 0 {
fmt.Println("No gates found.")
return
}
// Separate open and closed gates
var openGates, closedGates []*types.Issue
for _, gate := range gates {
if gate.Status == types.StatusClosed {
closedGates = append(closedGates, gate)
} else {
openGates = append(openGates, gate)
}
}
// Display open gates
if len(openGates) > 0 {
fmt.Printf("\n%s Open Gates (%d):\n\n", ui.RenderAccent("⏳"), len(openGates))
for _, gate := range openGates {
displaySingleGate(gate)
}
}
// Display closed gates only if --all was used
if showAll && len(closedGates) > 0 {
fmt.Printf("\n%s Closed Gates (%d):\n\n", ui.RenderMuted("●"), len(closedGates))
for _, gate := range closedGates {
displaySingleGate(gate)
}
}
if len(openGates) == 0 && (!showAll || len(closedGates) == 0) {
fmt.Println("No gates found.")
return
}
fmt.Printf("To resolve a gate: bd close <gate-id>\n")
}
// displaySingleGate formats and displays a single gate issue
func displaySingleGate(gate *types.Issue) {
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()
}
// gateAddWaiterCmd adds a waiter to a gate
var gateAddWaiterCmd = &cobra.Command{
Use: "add-waiter <gate-id> <waiter>",
Short: "Add a waiter to a gate",
Long: `Register an agent as a waiter on a gate bead.
When the gate closes, the waiter will receive a wake notification via 'gt gate wake'.
The waiter is typically the polecat's address (e.g., "gastown/polecats/Toast").
This is used by 'gt done --phase-complete' to register for gate wake notifications.`,
Args: cobra.ExactArgs(2),
Run: func(cmd *cobra.Command, args []string) {
CheckReadonly("gate add-waiter")
gateID := args[0]
waiter := args[1]
ctx := rootCtx
// Get the gate issue
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)
}
// Check if waiter is already registered
for _, w := range issue.Waiters {
if w == waiter {
fmt.Printf("Waiter already registered on gate %s\n", gateID)
return
}
}
// Add waiter to the waiters list
newWaiters := append(issue.Waiters, waiter)
// Update the gate
if daemonClient != nil {
updateArgs := &rpc.UpdateArgs{
ID: gateID,
Waiters: newWaiters,
}
resp, uerr := daemonClient.Update(updateArgs)
if uerr != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", uerr)
os.Exit(1)
}
if !resp.Success {
fmt.Fprintf(os.Stderr, "Error: %s\n", resp.Error)
os.Exit(1)
}
} else {
updates := map[string]interface{}{
"waiters": newWaiters,
}
if err := store.UpdateIssue(ctx, gateID, updates, actor); err != nil {
fmt.Fprintf(os.Stderr, "Error updating gate: %v\n", err)
os.Exit(1)
}
markDirtyAndScheduleFlush()
}
fmt.Printf("%s Added waiter to gate %s: %s\n", ui.RenderPass("✓"), gateID, waiter)
},
}
// gateShowCmd shows a gate issue
var gateShowCmd = &cobra.Command{
Use: "show <gate-id>",
Short: "Show a gate issue",
Long: `Display details of a gate issue including its waiters.
This is similar to 'bd show' but validates that the issue is a gate.`,
Args: cobra.ExactArgs(1),
Run: func(cmd *cobra.Command, args []string) {
gateID := args[0]
ctx := rootCtx
// Get the gate issue
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)
}
if jsonOutput {
outputJSON(issue)
return
}
// Display gate details
statusSym := "○"
if issue.Status == types.StatusClosed {
statusSym = "●"
}
fmt.Printf("%s %s - %s\n", statusSym, ui.RenderID(issue.ID), issue.Title)
fmt.Printf(" Status: %s\n", issue.Status)
fmt.Printf(" Await Type: %s\n", issue.AwaitType)
if issue.AwaitID != "" {
fmt.Printf(" Await ID: %s\n", issue.AwaitID)
}
if issue.Timeout > 0 {
fmt.Printf(" Timeout: %s\n", issue.Timeout)
}
if len(issue.Waiters) > 0 {
fmt.Printf(" Waiters:\n")
for _, w := range issue.Waiters {
fmt.Printf(" - %s\n", w)
}
}
if issue.Description != "" {
fmt.Printf(" Description: %s\n", issue.Description)
}
},
}
// 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)
}
},
}
// gateCheckCmd evaluates gates and closes those that are resolved
var gateCheckCmd = &cobra.Command{
Use: "check",
Short: "Evaluate gates and close resolved ones",
Long: `Evaluate gate conditions and automatically close resolved gates.
By default, checks all open gates. Use --type to filter by gate type.
Gate types:
gh - Check all GitHub gates (gh:run and gh:pr)
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:
- gh:run checks 'gh run view <id> --json status,conclusion'
- gh:pr checks 'gh pr view <id> --json state,merged'
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, canceled)
- gh:pr: state=CLOSED AND merged=false
Examples:
bd gate check # Check all 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=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) {
CheckReadonly("gate check")
gateTypeFilter, _ := cmd.Flags().GetString("type")
dryRun, _ := cmd.Flags().GetBool("dry-run")
escalateFlag, _ := cmd.Flags().GetBool("escalate")
limit, _ := cmd.Flags().GetInt("limit")
// Get open gates
gateType := types.TypeGate
filter := types.IssueFilter{
IssueType: &gateType,
ExcludeStatus: []types.Status{types.StatusClosed},
Limit: limit,
}
ctx := rootCtx
var gates []*types.Issue
var err error
if daemonClient != nil {
listArgs := &rpc.ListArgs{
IssueType: "gate",
ExcludeStatus: []string{"closed"},
Limit: limit,
}
resp, rerr := daemonClient.List(listArgs)
if rerr != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", rerr)
os.Exit(1)
}
if uerr := json.Unmarshal(resp.Data, &gates); uerr != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", uerr)
os.Exit(1)
}
} else {
gates, err = store.SearchIssues(ctx, "", filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// Filter by type if specified
var filteredGates []*types.Issue
for _, gate := range gates {
if shouldCheckGate(gate, gateTypeFilter) {
filteredGates = append(filteredGates, gate)
}
}
if len(filteredGates) == 0 {
if gateTypeFilter != "" {
fmt.Printf("No open gates of type '%s' found.\n", gateTypeFilter)
} else {
fmt.Println("No open gates found.")
}
return
}
// Results tracking
type checkResult struct {
gate *types.Issue
resolved bool
escalated bool
reason string
err error
}
results := make([]checkResult, 0, len(filteredGates))
// Check each gate
now := time.Now()
for _, gate := range filteredGates {
result := checkResult{gate: gate}
switch {
case strings.HasPrefix(gate.AwaitType, "gh:run"):
result.resolved, result.escalated, result.reason, result.err = checkGHRun(gate)
case strings.HasPrefix(gate.AwaitType, "gh:pr"):
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
}
results = append(results, result)
}
// Process results
resolvedCount := 0
escalatedCount := 0
errorCount := 0
for _, r := range results {
if r.err != nil {
errorCount++
fmt.Fprintf(os.Stderr, "%s %s: error checking - %v\n",
ui.RenderFail("✗"), r.gate.ID, r.err)
continue
}
if r.resolved {
resolvedCount++
if dryRun {
fmt.Printf("%s %s: would resolve - %s\n",
ui.RenderPass("✓"), r.gate.ID, r.reason)
} else {
// Close the gate
closeErr := closeGate(ctx, r.gate.ID, r.reason)
if closeErr != nil {
fmt.Fprintf(os.Stderr, "%s %s: error closing - %v\n",
ui.RenderFail("✗"), r.gate.ID, closeErr)
errorCount++
} else {
fmt.Printf("%s %s: resolved - %s\n",
ui.RenderPass("✓"), r.gate.ID, r.reason)
}
}
} else if r.escalated {
escalatedCount++
if dryRun {
fmt.Printf("%s %s: would escalate - %s\n",
ui.RenderWarn("⚠"), r.gate.ID, r.reason)
} else {
fmt.Printf("%s %s: ESCALATE - %s\n",
ui.RenderWarn("⚠"), r.gate.ID, r.reason)
// Actually escalate if flag is set
if escalateFlag {
escalateGate(r.gate, r.reason)
}
}
} else {
// Still pending
fmt.Printf("%s %s: pending - %s\n",
ui.RenderAccent("○"), r.gate.ID, r.reason)
}
}
// Summary
fmt.Println()
fmt.Printf("Checked %d gates: %d resolved, %d escalated, %d errors\n",
len(results), resolvedCount, escalatedCount, errorCount)
if jsonOutput {
summary := map[string]interface{}{
"checked": len(results),
"resolved": resolvedCount,
"escalated": escalatedCount,
"errors": errorCount,
"dry_run": dryRun,
}
outputJSON(summary)
}
},
}
// shouldCheckGate returns true if the gate matches the type filter
func shouldCheckGate(gate *types.Issue, typeFilter string) bool {
if typeFilter == "" || typeFilter == "all" {
return true
}
if typeFilter == "gh" {
return strings.HasPrefix(gate.AwaitType, "gh:")
}
return gate.AwaitType == typeFilter
}
// ghRunStatus holds the JSON response from 'gh run view'
type ghRunStatus struct {
Status string `json:"status"`
Conclusion string `json:"conclusion"`
Name string `json:"name"`
}
// ghPRStatus holds the JSON response from 'gh pr view'
type ghPRStatus struct {
State string `json:"state"`
Merged bool `json:"merged"`
Title string `json:"title"`
}
// isNumericID returns true if the string contains only digits (a GitHub run ID)
func isNumericID(s string) bool {
if s == "" {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
// queryGitHubRunsForWorkflow queries recent runs for a specific workflow using gh CLI.
// Returns runs sorted newest-first (GitHub API default).
func queryGitHubRunsForWorkflow(workflow string, limit int) ([]GHWorkflowRun, error) {
if _, err := exec.LookPath("gh"); err != nil {
return nil, fmt.Errorf("gh CLI not found: install from https://cli.github.com")
}
args := []string{
"run", "list",
"--workflow", workflow,
"--json", "databaseId,name,status,conclusion,createdAt,workflowName",
"--limit", fmt.Sprintf("%d", limit),
}
cmd := exec.Command("gh", args...)
output, err := cmd.Output()
if err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("gh run list --workflow=%s failed: %s", workflow, string(exitErr.Stderr))
}
return nil, fmt.Errorf("gh run list: %w", err)
}
var runs []GHWorkflowRun
if err := json.Unmarshal(output, &runs); err != nil {
return nil, fmt.Errorf("parse gh output: %w", err)
}
return runs, nil
}
// discoverRunIDByWorkflowName queries GitHub for the most recent run of a workflow.
// Returns (runID, error). This is ZFC-compliant: "most recent run" is deterministic.
func discoverRunIDByWorkflowName(workflowHint string) (string, error) {
// Query GitHub directly for this workflow (efficient, avoids limit issues)
runs, err := queryGitHubRunsForWorkflow(workflowHint, 5)
if err != nil {
return "", fmt.Errorf("failed to query workflow runs: %w", err)
}
if len(runs) == 0 {
return "", fmt.Errorf("no runs found for workflow '%s'", workflowHint)
}
// Take the most recent run (gh returns newest-first)
// This is deterministic: "most recent" is a total ordering by creation time
return fmt.Sprintf("%d", runs[0].DatabaseID), nil
}
// checkGHRun checks a GitHub Actions workflow run gate
func checkGHRun(gate *types.Issue) (resolved, escalated bool, reason string, err error) {
if gate.AwaitID == "" {
return false, false, "no run ID specified - set await_id or use workflow name hint", nil
}
runID := gate.AwaitID
// If await_id is a workflow name hint (non-numeric), auto-discover the run ID
if !isNumericID(gate.AwaitID) {
discoveredID, discoverErr := discoverRunIDByWorkflowName(gate.AwaitID)
if discoverErr != nil {
return false, false, fmt.Sprintf("workflow hint '%s': %v", gate.AwaitID, discoverErr), nil
}
// Update the gate with the discovered run ID
if updateErr := updateGateAwaitID(nil, gate.ID, discoveredID); updateErr != nil {
return false, false, "", fmt.Errorf("failed to update gate with discovered run ID: %w", updateErr)
}
runID = discoveredID
}
// Run: gh run view <id> --json status,conclusion,name
cmd := exec.Command("gh", "run", "view", runID, "--json", "status,conclusion,name") // #nosec G204 -- runID is a validated GitHub run ID
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if runErr := cmd.Run(); runErr != nil {
// Check if gh CLI is not found
if strings.Contains(stderr.String(), "command not found") ||
strings.Contains(runErr.Error(), "executable file not found") {
return false, false, "", fmt.Errorf("gh CLI not installed")
}
// Check if run not found
if strings.Contains(stderr.String(), "not found") {
return false, true, "workflow run not found", nil
}
return false, false, "", fmt.Errorf("gh run view failed: %s", stderr.String())
}
var status ghRunStatus
if parseErr := json.Unmarshal(stdout.Bytes(), &status); parseErr != nil {
return false, false, "", fmt.Errorf("failed to parse gh output: %w", parseErr)
}
// Evaluate status
switch status.Status {
case "completed":
switch status.Conclusion {
case "success":
return true, false, fmt.Sprintf("workflow '%s' succeeded", status.Name), nil
case "failure":
return false, true, fmt.Sprintf("workflow '%s' failed", status.Name), nil
case "cancelled", "canceled":
return false, true, fmt.Sprintf("workflow '%s' was canceled", status.Name), nil
case "skipped":
return true, false, fmt.Sprintf("workflow '%s' was skipped", status.Name), nil
default:
return false, true, fmt.Sprintf("workflow '%s' concluded with %s", status.Name, status.Conclusion), nil
}
case "in_progress", "queued", "pending", "waiting":
return false, false, fmt.Sprintf("workflow '%s' is %s", status.Name, status.Status), nil
default:
return false, false, fmt.Sprintf("workflow '%s' status: %s", status.Name, status.Status), nil
}
}
// checkGHPR checks a GitHub pull request gate
func checkGHPR(gate *types.Issue) (resolved, escalated bool, reason string, err error) {
if gate.AwaitID == "" {
return false, false, "no PR number specified", nil
}
// Run: gh pr view <id> --json state,merged,title
cmd := exec.Command("gh", "pr", "view", gate.AwaitID, "--json", "state,merged,title") // #nosec G204 -- gate.AwaitID is a validated GitHub PR number
var stdout, stderr bytes.Buffer
cmd.Stdout = &stdout
cmd.Stderr = &stderr
if runErr := cmd.Run(); runErr != nil {
// Check if gh CLI is not found
if strings.Contains(stderr.String(), "command not found") ||
strings.Contains(runErr.Error(), "executable file not found") {
return false, false, "", fmt.Errorf("gh CLI not installed")
}
// Check if PR not found
if strings.Contains(stderr.String(), "not found") || strings.Contains(stderr.String(), "Could not resolve") {
return false, true, "pull request not found", nil
}
return false, false, "", fmt.Errorf("gh pr view failed: %s", stderr.String())
}
var status ghPRStatus
if parseErr := json.Unmarshal(stdout.Bytes(), &status); parseErr != nil {
return false, false, "", fmt.Errorf("failed to parse gh output: %w", parseErr)
}
// Evaluate status
switch status.State {
case "MERGED":
return true, false, fmt.Sprintf("PR '%s' was merged", status.Title), nil
case "CLOSED":
if status.Merged {
return true, false, fmt.Sprintf("PR '%s' was merged", status.Title), nil
}
return false, true, fmt.Sprintf("PR '%s' was closed without merging", status.Title), nil
case "OPEN":
return false, false, fmt.Sprintf("PR '%s' is still open", status.Title), nil
default:
return false, false, fmt.Sprintf("PR '%s' state: %s", status.Title, status.State), nil
}
}
// checkTimer checks a timer gate for expiration
// Note: timers resolve but never escalate (escalated is always false by design)
func checkTimer(gate *types.Issue, now time.Time) (resolved, escalated bool, reason string, err error) { //nolint:unparam // escalated intentionally always false
if gate.Timeout == 0 {
return false, false, "timer gate without timeout configured", fmt.Errorf("no timeout set")
}
expiresAt := gate.CreatedAt.Add(gate.Timeout)
if now.After(expiresAt) {
expired := now.Sub(expiresAt).Round(time.Second)
return true, false, fmt.Sprintf("timer expired %s ago", expired), nil
}
remaining := expiresAt.Sub(now).Round(time.Second)
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
func closeGate(_ interface{}, gateID, reason string) error {
if daemonClient != nil {
closeArgs := &rpc.CloseArgs{
ID: gateID,
Reason: reason,
}
resp, err := daemonClient.CloseIssue(closeArgs)
if err != nil {
return err
}
if !resp.Success {
return fmt.Errorf("%s", resp.Error)
}
return nil
}
if err := store.CloseIssue(rootCtx, gateID, reason, actor, ""); err != nil {
return err
}
markDirtyAndScheduleFlush()
return nil
}
// escalateGate sends an escalation for a failed/expired gate
func escalateGate(gate *types.Issue, reason string) {
topic := fmt.Sprintf("Gate escalation: %s", gate.ID)
message := fmt.Sprintf("Gate %s needs attention.\nType: %s\nReason: %s\nCreated: %s",
gate.ID,
gate.AwaitType,
reason,
gate.CreatedAt.Format(time.RFC3339))
// Call gt escalate if available
escalateCmd := exec.Command("gt", "escalate", topic, "-s", "HIGH", "-m", message)
escalateCmd.Stdout = os.Stdout
escalateCmd.Stderr = os.Stderr
if err := escalateCmd.Run(); err != nil {
fmt.Fprintf(os.Stderr, "Warning: escalation failed for %s: %v\n", gate.ID, err)
}
}
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")
// gate check flags
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", "l", 100, "Limit results (default 100)")
// Issue ID completions
gateShowCmd.ValidArgsFunction = issueIDCompletion
gateResolveCmd.ValidArgsFunction = issueIDCompletion
gateAddWaiterCmd.ValidArgsFunction = issueIDCompletion
// Add subcommands
gateCmd.AddCommand(gateListCmd)
gateCmd.AddCommand(gateShowCmd)
gateCmd.AddCommand(gateResolveCmd)
gateCmd.AddCommand(gateCheckCmd)
gateCmd.AddCommand(gateAddWaiterCmd)
rootCmd.AddCommand(gateCmd)
}