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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user