fix(gate): handle workflow name hints in gh:run gate discovery (bd-m8ew)

When a formula specifies a gate with id="release.yml", the AwaitID
now properly functions as a workflow name hint:

- gate discover: finds gates where AwaitID is empty OR non-numeric
- gate discover: filters matching runs by workflow name when hint present
- gate check: gracefully handles non-numeric AwaitID with clear message

Added isNumericRunID/isNumericID helpers and tests for the new behavior.

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

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

Executed-By: beads/crew/dave
Rig: beads
Role: crew
This commit is contained in:
dave
2026-01-06 23:34:09 -08:00
committed by Steve Yegge
parent 90c5be3a13
commit 8528fcaa4e
3 changed files with 172 additions and 13 deletions

View File

@@ -638,10 +638,29 @@ type ghPRStatus struct {
Title string `json:"title"`
}
// isNumericID returns true if the string is a valid numeric ID
func isNumericID(s string) bool {
if s == "" {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
// 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
return false, false, "no run ID specified - run 'bd gate discover' first", nil
}
// Check if AwaitID is a numeric run ID or a workflow name hint
if !isNumericID(gate.AwaitID) {
// Non-numeric AwaitID is a workflow name hint, needs discovery
return false, false, fmt.Sprintf("awaiting discovery (workflow hint: %s) - run 'bd gate discover'", gate.AwaitID), nil
}
// Run: gh run view <id> --json status,conclusion,name

View File

@@ -79,7 +79,7 @@ func runGateDiscover(cmd *cobra.Command, args []string) {
}
if len(gates) == 0 {
fmt.Println("No pending gh:run gates found (all gates have await_id set)")
fmt.Println("No pending gh:run gates found (all gates have numeric run IDs)")
return
}
@@ -145,7 +145,40 @@ func runGateDiscover(cmd *cobra.Command, args []string) {
}
}
// findPendingGates returns open gh:run gates that have no await_id set
// isNumericRunID returns true if the string looks like a GitHub numeric run ID
func isNumericRunID(s string) bool {
if s == "" {
return false
}
for _, c := range s {
if c < '0' || c > '9' {
return false
}
}
return true
}
// needsDiscovery returns true if a gh:run gate needs run ID discovery.
// This is true when AwaitID is empty OR contains a non-numeric workflow name hint.
func needsDiscovery(g *types.Issue) bool {
if g.AwaitType != "gh:run" {
return false
}
// Empty AwaitID or non-numeric (workflow name hint) needs discovery
return g.AwaitID == "" || !isNumericRunID(g.AwaitID)
}
// getWorkflowNameHint extracts the workflow name hint from AwaitID if present.
// Returns empty string if AwaitID is empty or numeric (already resolved).
func getWorkflowNameHint(g *types.Issue) string {
if g.AwaitID == "" || isNumericRunID(g.AwaitID) {
return ""
}
return g.AwaitID
}
// findPendingGates returns open gh:run gates that need run ID discovery.
// This includes gates with empty AwaitID OR non-numeric AwaitID (workflow name hint).
func findPendingGates() ([]*types.Issue, error) {
var gates []*types.Issue
@@ -165,9 +198,9 @@ func findPendingGates() ([]*types.Issue, error) {
return nil, fmt.Errorf("parse gates: %w", err)
}
// Filter to gh:run gates without await_id
// Filter to gh:run gates that need discovery
for _, g := range allGates {
if g.AwaitType == "gh:run" && g.AwaitID == "" {
if needsDiscovery(g) {
gates = append(gates, g)
}
}
@@ -185,7 +218,7 @@ func findPendingGates() ([]*types.Issue, error) {
}
for _, g := range allGates {
if g.AwaitType == "gh:run" && g.AwaitID == "" {
if needsDiscovery(g) {
gates = append(gates, g)
}
}
@@ -249,11 +282,13 @@ func queryGitHubRuns(branch string, limit int) ([]GHWorkflowRun, error) {
return runs, nil
}
// matchGateToRun finds the best matching run for a gate using heuristics
// matchGateToRun finds the best matching run for a gate using heuristics.
// If the gate has a workflow name hint in AwaitID, only runs matching that workflow are considered.
func matchGateToRun(gate *types.Issue, runs []GHWorkflowRun, maxAge time.Duration) *GHWorkflowRun {
now := time.Now()
currentCommit := getGitCommitForGateDiscovery()
currentBranch := getGitBranchForGateDiscovery()
workflowHint := getWorkflowNameHint(gate)
var bestMatch *GHWorkflowRun
var bestScore int
@@ -267,7 +302,21 @@ func matchGateToRun(gate *types.Issue, runs []GHWorkflowRun, maxAge time.Duratio
continue
}
// Heuristic 1: Commit SHA match (strongest signal)
// If gate has a workflow name hint, require matching workflow
// Match against both WorkflowName (display name) and Name (filename)
if workflowHint != "" {
workflowMatches := strings.EqualFold(run.WorkflowName, workflowHint) ||
strings.EqualFold(run.Name, workflowHint) ||
strings.EqualFold(run.WorkflowName, strings.TrimSuffix(workflowHint, ".yml")) ||
strings.EqualFold(run.WorkflowName, strings.TrimSuffix(workflowHint, ".yaml"))
if !workflowMatches {
continue // Skip runs that don't match the workflow hint
}
// Workflow match is a strong signal
score += 200
}
// Heuristic 1: Commit SHA match (strongest signal after workflow match)
if currentCommit != "" && run.HeadSha == currentCommit {
score += 100
}
@@ -288,11 +337,7 @@ func matchGateToRun(gate *types.Issue, runs []GHWorkflowRun, maxAge time.Duratio
score += 10
}
// Heuristic 4: Workflow name match (if gate has workflow specified)
// The gate's AwaitType might include workflow info in the future
// For now, we prioritize any recent run
// Heuristic 5: Prefer in_progress or queued runs (more likely to be current)
// Heuristic 4: Prefer in_progress or queued runs (more likely to be current)
if run.Status == "in_progress" || run.Status == "queued" {
score += 5
}
@@ -304,6 +349,8 @@ func matchGateToRun(gate *types.Issue, runs []GHWorkflowRun, maxAge time.Duratio
}
// Require at least some confidence in the match
// With workflow hint, workflow match (200) alone is sufficient
// Without workflow hint, require branch or commit match (30+ from time proximity)
if bestScore >= 30 {
return bestMatch
}

View File

@@ -199,6 +199,99 @@ func TestCheckBeadGate_TargetClosed(t *testing.T) {
t.Log("Full integration testing requires routes.jsonl setup")
}
func TestIsNumericID(t *testing.T) {
tests := []struct {
input string
want bool
}{
// Numeric IDs
{"12345", true},
{"12345678901234567890", true},
{"0", true},
{"1", true},
// Non-numeric (workflow names, etc.)
{"", false},
{"release.yml", false},
{"CI", false},
{"release", false},
{"123abc", false},
{"abc123", false},
{"12.34", false},
{"-123", false},
{"123-456", false},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
got := isNumericID(tt.input)
if got != tt.want {
t.Errorf("isNumericID(%q) = %v, want %v", tt.input, got, tt.want)
}
})
}
}
func TestNeedsDiscovery(t *testing.T) {
tests := []struct {
name string
awaitType string
awaitID string
want bool
}{
// gh:run gates
{"gh:run empty await_id", "gh:run", "", true},
{"gh:run workflow name hint", "gh:run", "release.yml", true},
{"gh:run workflow name without ext", "gh:run", "CI", true},
{"gh:run numeric run ID", "gh:run", "12345", false},
{"gh:run large numeric ID", "gh:run", "12345678901234567890", false},
// Other gate types should not need discovery
{"gh:pr gate", "gh:pr", "", false},
{"timer gate", "timer", "", false},
{"human gate", "human", "", false},
{"bead gate", "bead", "rig:id", false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gate := &types.Issue{
AwaitType: tt.awaitType,
AwaitID: tt.awaitID,
}
got := needsDiscovery(gate)
if got != tt.want {
t.Errorf("needsDiscovery(%q, %q) = %v, want %v",
tt.awaitType, tt.awaitID, got, tt.want)
}
})
}
}
func TestGetWorkflowNameHint(t *testing.T) {
tests := []struct {
name string
awaitID string
want string
}{
{"empty", "", ""},
{"numeric ID", "12345", ""},
{"workflow name", "release.yml", "release.yml"},
{"workflow name yaml", "ci.yaml", "ci.yaml"},
{"workflow name no ext", "CI", "CI"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gate := &types.Issue{AwaitID: tt.awaitID}
got := getWorkflowNameHint(gate)
if got != tt.want {
t.Errorf("getWorkflowNameHint(%q) = %q, want %q", tt.awaitID, got, tt.want)
}
})
}
}
// gateTestContainsIgnoreCase checks if haystack contains needle (case-insensitive)
func gateTestContainsIgnoreCase(haystack, needle string) bool {
return gateTestContains(gateTestLowerCase(haystack), gateTestLowerCase(needle))