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:
Steve Yegge
2025-12-25 22:57:39 -08:00
parent f6c739f75e
commit 07181560a7

View File

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