feat(gate): Add GitHub gate evaluation (gh:run, gh:pr)
Implements evaluation for GitHub-related gate types: - gh:run: Checks if a GitHub Actions run has completed using `gh run view <id> --json status,conclusion` - gh:pr: Checks if a PR has been merged/closed using `gh pr view <id> --json state,merged` Both gate types require the gh CLI to be installed and authenticated. If gh fails (not installed, network issues, invalid IDs), the gate is skipped rather than erroneously closed. Refactors the eval loop to use a switch statement for cleaner gate type dispatch. (gt-twjr5.3) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
154
cmd/bd/gate.go
154
cmd/bd/gate.go
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -579,12 +580,10 @@ var gateEvalCmd = &cobra.Command{
|
||||
Short: "Evaluate pending gates and close elapsed ones",
|
||||
Long: `Evaluate all open gates and close those whose conditions are met.
|
||||
|
||||
Currently supports:
|
||||
Supported gate types:
|
||||
- timer gates: closed when elapsed time exceeds timeout
|
||||
|
||||
Future:
|
||||
- gh:run gates: closed when GitHub Actions run completes
|
||||
- gh:pr gates: closed when PR is merged/closed
|
||||
- gh:run gates: closed when GitHub Actions run completes (requires gh CLI)
|
||||
- gh:pr gates: closed when PR is merged/closed (requires gh CLI)
|
||||
|
||||
This command is idempotent and safe to run repeatedly.`,
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
@@ -627,35 +626,37 @@ This command is idempotent and safe to run repeatedly.`,
|
||||
}
|
||||
|
||||
var closed []string
|
||||
var timedOut []string
|
||||
var skipped []string
|
||||
now := time.Now()
|
||||
|
||||
for _, gate := range gates {
|
||||
// Only evaluate timer gates for now
|
||||
if gate.AwaitType != "timer" {
|
||||
var shouldClose bool
|
||||
var reason string
|
||||
|
||||
switch gate.AwaitType {
|
||||
case "timer":
|
||||
shouldClose, reason = evalTimerGate(gate, now)
|
||||
case "gh:run":
|
||||
shouldClose, reason = evalGHRunGate(gate)
|
||||
case "gh:pr":
|
||||
shouldClose, reason = evalGHPRGate(gate)
|
||||
default:
|
||||
// Unsupported gate type - skip
|
||||
skipped = append(skipped, gate.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
// Check if timer has elapsed
|
||||
if gate.Timeout <= 0 {
|
||||
continue // No timeout set
|
||||
if !shouldClose {
|
||||
continue
|
||||
}
|
||||
|
||||
elapsed := now.Sub(gate.CreatedAt)
|
||||
if elapsed < gate.Timeout {
|
||||
continue // Not yet elapsed
|
||||
}
|
||||
|
||||
// Timer has elapsed - close the gate
|
||||
// Gate condition met - close it
|
||||
if dryRun {
|
||||
fmt.Printf("Would close gate %s (timer elapsed: %v >= %v)\n",
|
||||
gate.ID, elapsed.Round(time.Second), gate.Timeout)
|
||||
fmt.Printf("Would close gate %s (%s)\n", gate.ID, reason)
|
||||
closed = append(closed, gate.ID)
|
||||
continue
|
||||
}
|
||||
|
||||
reason := fmt.Sprintf("Timer elapsed (%v)", gate.Timeout)
|
||||
|
||||
if daemonClient != nil {
|
||||
_, err := daemonClient.GateClose(&rpc.GateCloseArgs{
|
||||
ID: gate.ID,
|
||||
@@ -674,18 +675,13 @@ This command is idempotent and safe to run repeatedly.`,
|
||||
}
|
||||
|
||||
closed = append(closed, gate.ID)
|
||||
|
||||
// Check if gate exceeded its timeout (for reporting)
|
||||
if elapsed > gate.Timeout*2 {
|
||||
timedOut = append(timedOut, gate.ID)
|
||||
}
|
||||
}
|
||||
|
||||
if jsonOutput {
|
||||
outputJSON(map[string]interface{}{
|
||||
"evaluated": len(gates),
|
||||
"closed": closed,
|
||||
"timed_out": timedOut,
|
||||
"skipped": skipped,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -697,14 +693,116 @@ This command is idempotent and safe to run repeatedly.`,
|
||||
if dryRun {
|
||||
action = "Would close"
|
||||
}
|
||||
fmt.Printf("%s %s %d timer gate(s)\n", ui.RenderPass("✓"), action, len(closed))
|
||||
fmt.Printf("%s %s %d gate(s)\n", ui.RenderPass("✓"), action, len(closed))
|
||||
for _, id := range closed {
|
||||
fmt.Printf(" %s\n", id)
|
||||
}
|
||||
}
|
||||
if len(skipped) > 0 {
|
||||
fmt.Printf("Skipped %d unsupported gate(s): %s\n", len(skipped), strings.Join(skipped, ", "))
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// evalTimerGate checks if a timer gate's duration has elapsed.
|
||||
func evalTimerGate(gate *types.Issue, now time.Time) (bool, string) {
|
||||
if gate.Timeout <= 0 {
|
||||
return false, "" // No timeout set
|
||||
}
|
||||
|
||||
elapsed := now.Sub(gate.CreatedAt)
|
||||
if elapsed < gate.Timeout {
|
||||
return false, "" // Not yet elapsed
|
||||
}
|
||||
|
||||
return true, fmt.Sprintf("Timer elapsed (%v)", gate.Timeout)
|
||||
}
|
||||
|
||||
// ghRunStatus represents the JSON output of `gh run view --json`
|
||||
type ghRunStatus struct {
|
||||
Status string `json:"status"` // queued, in_progress, completed
|
||||
Conclusion string `json:"conclusion"` // success, failure, cancelled, skipped, etc.
|
||||
}
|
||||
|
||||
// evalGHRunGate checks if a GitHub Actions run has completed.
|
||||
// Uses `gh run view <run_id> --json status,conclusion` to check status.
|
||||
func evalGHRunGate(gate *types.Issue) (bool, string) {
|
||||
runID := gate.AwaitID
|
||||
if runID == "" {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Run gh CLI to get run status
|
||||
cmd := exec.Command("gh", "run", "view", runID, "--json", "status,conclusion")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// gh CLI failed - could be network issue, invalid run ID, or gh not installed
|
||||
// Don't close the gate, just skip it
|
||||
return false, ""
|
||||
}
|
||||
|
||||
var status ghRunStatus
|
||||
if err := json.Unmarshal(output, &status); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Only close if status is "completed"
|
||||
if status.Status != "completed" {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Run completed - include conclusion in reason
|
||||
reason := fmt.Sprintf("GitHub Actions run %s completed", runID)
|
||||
if status.Conclusion != "" {
|
||||
reason = fmt.Sprintf("GitHub Actions run %s: %s", runID, status.Conclusion)
|
||||
}
|
||||
|
||||
return true, reason
|
||||
}
|
||||
|
||||
// ghPRStatus represents the JSON output of `gh pr view --json`
|
||||
type ghPRStatus struct {
|
||||
State string `json:"state"` // OPEN, CLOSED, MERGED
|
||||
Merged bool `json:"merged"` // true if merged
|
||||
}
|
||||
|
||||
// evalGHPRGate checks if a GitHub PR has been merged or closed.
|
||||
// Uses `gh pr view <pr_number> --json state,merged` to check status.
|
||||
func evalGHPRGate(gate *types.Issue) (bool, string) {
|
||||
prNumber := gate.AwaitID
|
||||
if prNumber == "" {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Run gh CLI to get PR status
|
||||
cmd := exec.Command("gh", "pr", "view", prNumber, "--json", "state,merged")
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
// gh CLI failed - could be network issue, invalid PR, or gh not installed
|
||||
// Don't close the gate, just skip it
|
||||
return false, ""
|
||||
}
|
||||
|
||||
var status ghPRStatus
|
||||
if err := json.Unmarshal(output, &status); err != nil {
|
||||
return false, ""
|
||||
}
|
||||
|
||||
// Close gate if PR is no longer OPEN
|
||||
switch status.State {
|
||||
case "MERGED":
|
||||
return true, fmt.Sprintf("PR #%s merged", prNumber)
|
||||
case "CLOSED":
|
||||
if status.Merged {
|
||||
return true, fmt.Sprintf("PR #%s merged", prNumber)
|
||||
}
|
||||
return true, fmt.Sprintf("PR #%s closed without merge", prNumber)
|
||||
default:
|
||||
// Still OPEN
|
||||
return false, ""
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
// Gate eval flags
|
||||
gateEvalCmd.Flags().Bool("dry-run", false, "Show what would be closed without actually closing")
|
||||
|
||||
Reference in New Issue
Block a user