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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"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() {
|
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")
|
||||||
@@ -239,9 +567,14 @@ func init() {
|
|||||||
// gate resolve flags
|
// gate resolve flags
|
||||||
gateResolveCmd.Flags().StringP("reason", "r", "", "Reason for resolving the gate")
|
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
|
// Add subcommands
|
||||||
gateCmd.AddCommand(gateListCmd)
|
gateCmd.AddCommand(gateListCmd)
|
||||||
gateCmd.AddCommand(gateResolveCmd)
|
gateCmd.AddCommand(gateResolveCmd)
|
||||||
|
gateCmd.AddCommand(gateCheckCmd)
|
||||||
|
|
||||||
rootCmd.AddCommand(gateCmd)
|
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