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:
333
cmd/bd/gate.go
333
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 <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
54
cmd/bd/gate_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user