feat(gate): add bd gate check for GitHub gate evaluation (bd-oos3)

Implement Phase 3 of gate evaluation: GitHub gates (gh:run and gh:pr).

## Changes

- Add `bd gate check` command to evaluate open gates
- Support --type=gh to check all GitHub gates
- Support --type=gh:run for GitHub Actions workflow run gates
- Support --type=gh:pr for pull request merge status gates
- Add --dry-run flag to preview gate resolution without closing
- Auto-close gates when conditions are met:
  - gh:run: workflow completed with success
  - gh:pr: PR merged
- Escalate when conditions fail:
  - gh:run: workflow failed or cancelled
  - gh:pr: PR closed without merging
- Add unit tests for shouldCheckGate filtering

Note: mol-refinery-patrol.formula.toml updated in gastown rig to include
check-github-gates step (version 4).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
jasper
2026-01-02 12:48:43 -08:00
committed by Steve Yegge
parent 025142d55e
commit 3c5b7414ce
2 changed files with 387 additions and 0 deletions

View File

@@ -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 <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
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 <id> --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 <id> --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)
}

54
cmd/bd/gate_test.go Normal file
View File

@@ -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)
}
})
}
}