feat(gate): add bd gate check command for timer gate evaluation (bd-kbfn)
Implement timer gate evaluation for Witness patrol integration: - Add `bd gate check` command to evaluate gate conditions - Support `--type=timer` filter to check only timer gates - Check if `now > created_at + timeout` for timer gates - Add `--escalate` flag to trigger `gt escalate` for expired gates - JSON output support with `--json` flag The command integrates with Witness patrol via: bd gate check --type=timer --escalate 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/beads/internal/rpc"
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
@@ -245,7 +246,7 @@ Gate types:
|
|||||||
gh - Check all GitHub gates (gh:run and gh:pr)
|
gh - Check all GitHub gates (gh:run and gh:pr)
|
||||||
gh:run - Check GitHub Actions workflow runs
|
gh:run - Check GitHub Actions workflow runs
|
||||||
gh:pr - Check pull request merge status
|
gh:pr - Check pull request merge status
|
||||||
timer - Check timer gates (Phase 2)
|
timer - Check timer gates (auto-expire based on timeout)
|
||||||
all - Check all gate types
|
all - Check all gate types
|
||||||
|
|
||||||
GitHub gates use the 'gh' CLI to query status:
|
GitHub gates use the 'gh' CLI to query status:
|
||||||
@@ -255,6 +256,7 @@ GitHub gates use the 'gh' CLI to query status:
|
|||||||
A gate is resolved when:
|
A gate is resolved when:
|
||||||
- gh:run: status=completed AND conclusion=success
|
- gh:run: status=completed AND conclusion=success
|
||||||
- gh:pr: state=MERGED
|
- gh:pr: state=MERGED
|
||||||
|
- timer: current time > created_at + timeout
|
||||||
|
|
||||||
A gate is escalated when:
|
A gate is escalated when:
|
||||||
- gh:run: status=completed AND conclusion in (failure, cancelled)
|
- gh:run: status=completed AND conclusion in (failure, cancelled)
|
||||||
@@ -264,18 +266,23 @@ Examples:
|
|||||||
bd gate check # Check all gates
|
bd gate check # Check all gates
|
||||||
bd gate check --type=gh # Check only GitHub gates
|
bd gate check --type=gh # Check only GitHub gates
|
||||||
bd gate check --type=gh:run # Check only workflow run gates
|
bd gate check --type=gh:run # Check only workflow run gates
|
||||||
bd gate check --dry-run # Show what would happen without changes`,
|
bd gate check --type=timer # Check only timer 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) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
CheckReadonly("gate check")
|
CheckReadonly("gate check")
|
||||||
|
|
||||||
gateTypeFilter, _ := cmd.Flags().GetString("type")
|
gateTypeFilter, _ := cmd.Flags().GetString("type")
|
||||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||||
|
escalateFlag, _ := cmd.Flags().GetBool("escalate")
|
||||||
|
limit, _ := cmd.Flags().GetInt("limit")
|
||||||
|
|
||||||
// Get open gates
|
// Get open gates
|
||||||
gateType := types.TypeGate
|
gateType := types.TypeGate
|
||||||
filter := types.IssueFilter{
|
filter := types.IssueFilter{
|
||||||
IssueType: &gateType,
|
IssueType: &gateType,
|
||||||
ExcludeStatus: []types.Status{types.StatusClosed},
|
ExcludeStatus: []types.Status{types.StatusClosed},
|
||||||
|
Limit: limit,
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := rootCtx
|
ctx := rootCtx
|
||||||
@@ -286,6 +293,7 @@ Examples:
|
|||||||
listArgs := &rpc.ListArgs{
|
listArgs := &rpc.ListArgs{
|
||||||
IssueType: "gate",
|
IssueType: "gate",
|
||||||
ExcludeStatus: []string{"closed"},
|
ExcludeStatus: []string{"closed"},
|
||||||
|
Limit: limit,
|
||||||
}
|
}
|
||||||
resp, rerr := daemonClient.List(listArgs)
|
resp, rerr := daemonClient.List(listArgs)
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
@@ -332,6 +340,7 @@ Examples:
|
|||||||
results := make([]checkResult, 0, len(filteredGates))
|
results := make([]checkResult, 0, len(filteredGates))
|
||||||
|
|
||||||
// Check each gate
|
// Check each gate
|
||||||
|
now := time.Now()
|
||||||
for _, gate := range filteredGates {
|
for _, gate := range filteredGates {
|
||||||
result := checkResult{gate: gate}
|
result := checkResult{gate: gate}
|
||||||
|
|
||||||
@@ -340,8 +349,10 @@ Examples:
|
|||||||
result.resolved, result.escalated, result.reason, result.err = checkGHRun(gate)
|
result.resolved, result.escalated, result.reason, result.err = checkGHRun(gate)
|
||||||
case strings.HasPrefix(gate.AwaitType, "gh:pr"):
|
case strings.HasPrefix(gate.AwaitType, "gh:pr"):
|
||||||
result.resolved, result.escalated, result.reason, result.err = checkGHPR(gate)
|
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)
|
||||||
default:
|
default:
|
||||||
// Skip unsupported gate types
|
// Skip unsupported gate types (human gates need manual resolution)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +397,10 @@ Examples:
|
|||||||
} else {
|
} else {
|
||||||
fmt.Printf("%s %s: ESCALATE - %s\n",
|
fmt.Printf("%s %s: ESCALATE - %s\n",
|
||||||
ui.RenderWarn("⚠"), r.gate.ID, r.reason)
|
ui.RenderWarn("⚠"), r.gate.ID, r.reason)
|
||||||
|
// Actually escalate if flag is set
|
||||||
|
if escalateFlag {
|
||||||
|
escalateGate(r.gate, r.reason)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Still pending
|
// Still pending
|
||||||
@@ -535,6 +550,22 @@ func checkGHPR(gate *types.Issue) (resolved, escalated bool, reason string, err
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// checkTimer checks a timer gate for expiration
|
||||||
|
func checkTimer(gate *types.Issue, now time.Time) (resolved, escalated bool, reason string, err error) {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
// closeGate closes a gate issue with the given reason
|
// closeGate closes a gate issue with the given reason
|
||||||
func closeGate(ctx interface{}, gateID, reason string) error {
|
func closeGate(ctx interface{}, gateID, reason string) error {
|
||||||
if daemonClient != nil {
|
if daemonClient != nil {
|
||||||
@@ -559,6 +590,24 @@ func closeGate(ctx interface{}, gateID, reason string) error {
|
|||||||
return nil
|
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() {
|
func init() {
|
||||||
// gate list flags
|
// gate list flags
|
||||||
gateListCmd.Flags().BoolP("all", "a", false, "Show all gates including closed")
|
gateListCmd.Flags().BoolP("all", "a", false, "Show all gates including closed")
|
||||||
@@ -570,6 +619,8 @@ func init() {
|
|||||||
// gate check flags
|
// gate check flags
|
||||||
gateCheckCmd.Flags().StringP("type", "t", "", "Gate type to check (gh, gh:run, gh:pr, timer, all)")
|
gateCheckCmd.Flags().StringP("type", "t", "", "Gate type to check (gh, gh:run, gh:pr, timer, all)")
|
||||||
gateCheckCmd.Flags().Bool("dry-run", false, "Show what would happen without making changes")
|
gateCheckCmd.Flags().Bool("dry-run", false, "Show what would happen without making changes")
|
||||||
|
gateCheckCmd.Flags().BoolP("escalate", "e", false, "Escalate failed/expired gates")
|
||||||
|
gateCheckCmd.Flags().IntP("limit", "n", 100, "Limit results (default 100)")
|
||||||
|
|
||||||
// Add subcommands
|
// Add subcommands
|
||||||
gateCmd.AddCommand(gateListCmd)
|
gateCmd.AddCommand(gateListCmd)
|
||||||
|
|||||||
Reference in New Issue
Block a user