Remove Gas Town-specific type constants (TypeMolecule, TypeGate, TypeConvoy, TypeMergeRequest, TypeSlot, TypeAgent, TypeRole, TypeRig, TypeEvent, TypeMessage) from internal/types/types.go. Beads now only has core work types built-in: - bug, feature, task, epic, chore All Gas Town types are now purely custom types with no special handling in beads. Use string literals like "gate" or "molecule" when needed, and configure types.custom in config.yaml for validation. Changes: - Remove Gas Town type constants from types.go - Remove mr/mol aliases from Normalize() - Update bd types command to only show core types - Replace all constant usages with string literals throughout codebase - Update tests to use string literals This decouples beads from Gas Town, making it a generic issue tracker. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
997 lines
29 KiB
Go
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.IssueType("gate")
|
|
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 != "gate" {
|
|
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 != "gate" {
|
|
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 != "gate" {
|
|
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.IssueType("gate")
|
|
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)
|
|
}
|