diff --git a/cmd/bd/gate.go b/cmd/bd/gate.go index 5012163b..97deae06 100644 --- a/cmd/bd/gate.go +++ b/cmd/bd/gate.go @@ -1,9 +1,11 @@ package main import ( + "bytes" "encoding/json" "fmt" "os" + "os/exec" "strings" "github.com/spf13/cobra" @@ -231,6 +233,332 @@ Use --reason to provide context for why the gate was resolved.`, }, } +// 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 (Phase 2) + all - Check all gate types + +GitHub gates use the 'gh' CLI to query status: + - gh:run checks 'gh run view --json status,conclusion' + - gh:pr checks 'gh pr view --json state,merged' + +A gate is resolved when: + - gh:run: status=completed AND conclusion=success + - gh:pr: state=MERGED + +A gate is escalated when: + - gh:run: status=completed AND conclusion in (failure, cancelled) + - 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 --dry-run # Show what would happen without changes`, + Run: func(cmd *cobra.Command, args []string) { + CheckReadonly("gate check") + + gateTypeFilter, _ := cmd.Flags().GetString("type") + dryRun, _ := cmd.Flags().GetBool("dry-run") + + // Get open gates + gateType := types.TypeGate + filter := types.IssueFilter{ + IssueType: &gateType, + ExcludeStatus: []types.Status{types.StatusClosed}, + } + + ctx := rootCtx + var gates []*types.Issue + var err error + + if daemonClient != nil { + listArgs := &rpc.ListArgs{ + IssueType: "gate", + ExcludeStatus: []string{"closed"}, + } + 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 + 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) + default: + // Skip unsupported gate types + 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) + } + } 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"` +} + +// 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", nil + } + + // Run: gh run view --json status,conclusion,name + cmd := exec.Command("gh", "run", "view", gate.AwaitID, "--json", "status,conclusion,name") + 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": + return false, true, fmt.Sprintf("workflow '%s' was cancelled", 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 --json state,merged,title + cmd := exec.Command("gh", "pr", "view", gate.AwaitID, "--json", "state,merged,title") + 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 + } +} + +// closeGate closes a gate issue with the given reason +func closeGate(ctx 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 +} + func init() { // gate list flags gateListCmd.Flags().BoolP("all", "a", false, "Show all gates including closed") @@ -239,9 +567,14 @@ func init() { // 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, all)") + gateCheckCmd.Flags().Bool("dry-run", false, "Show what would happen without making changes") + // Add subcommands gateCmd.AddCommand(gateListCmd) gateCmd.AddCommand(gateResolveCmd) + gateCmd.AddCommand(gateCheckCmd) rootCmd.AddCommand(gateCmd) } diff --git a/cmd/bd/gate_test.go b/cmd/bd/gate_test.go new file mode 100644 index 00000000..4ef0cd45 --- /dev/null +++ b/cmd/bd/gate_test.go @@ -0,0 +1,54 @@ +package main + +import ( + "testing" + + "github.com/steveyegge/beads/internal/types" +) + +func TestShouldCheckGate(t *testing.T) { + tests := []struct { + name string + awaitType string + typeFilter string + want bool + }{ + // Empty filter matches all + {"empty filter matches gh:run", "gh:run", "", true}, + {"empty filter matches gh:pr", "gh:pr", "", true}, + {"empty filter matches timer", "timer", "", true}, + {"empty filter matches human", "human", "", true}, + + // "all" filter matches all + {"all filter matches gh:run", "gh:run", "all", true}, + {"all filter matches gh:pr", "gh:pr", "all", true}, + {"all filter matches timer", "timer", "all", true}, + + // "gh" filter matches all GitHub types + {"gh filter matches gh:run", "gh:run", "gh", true}, + {"gh filter matches gh:pr", "gh:pr", "gh", true}, + {"gh filter does not match timer", "timer", "gh", false}, + {"gh filter does not match human", "human", "gh", false}, + + // Exact type filters + {"gh:run filter matches gh:run", "gh:run", "gh:run", true}, + {"gh:run filter does not match gh:pr", "gh:pr", "gh:run", false}, + {"gh:pr filter matches gh:pr", "gh:pr", "gh:pr", true}, + {"gh:pr filter does not match gh:run", "gh:run", "gh:pr", false}, + {"timer filter matches timer", "timer", "timer", true}, + {"timer filter does not match gh:run", "gh:run", "timer", false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + gate := &types.Issue{ + AwaitType: tt.awaitType, + } + got := shouldCheckGate(gate, tt.typeFilter) + if got != tt.want { + t.Errorf("shouldCheckGate(%q, %q) = %v, want %v", + tt.awaitType, tt.typeFilter, got, tt.want) + } + }) + } +}