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"`
|
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
|
// checkGHRun checks a GitHub Actions workflow run gate
|
||||||
func checkGHRun(gate *types.Issue) (resolved, escalated bool, reason string, err error) {
|
func checkGHRun(gate *types.Issue) (resolved, escalated bool, reason string, err error) {
|
||||||
if gate.AwaitID == "" {
|
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
|
// Run: gh run view <id> --json status,conclusion,name
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func runGateDiscover(cmd *cobra.Command, args []string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if len(gates) == 0 {
|
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
|
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) {
|
func findPendingGates() ([]*types.Issue, error) {
|
||||||
var gates []*types.Issue
|
var gates []*types.Issue
|
||||||
|
|
||||||
@@ -165,9 +198,9 @@ func findPendingGates() ([]*types.Issue, error) {
|
|||||||
return nil, fmt.Errorf("parse gates: %w", err)
|
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 {
|
for _, g := range allGates {
|
||||||
if g.AwaitType == "gh:run" && g.AwaitID == "" {
|
if needsDiscovery(g) {
|
||||||
gates = append(gates, g)
|
gates = append(gates, g)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -185,7 +218,7 @@ func findPendingGates() ([]*types.Issue, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for _, g := range allGates {
|
for _, g := range allGates {
|
||||||
if g.AwaitType == "gh:run" && g.AwaitID == "" {
|
if needsDiscovery(g) {
|
||||||
gates = append(gates, g)
|
gates = append(gates, g)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -249,11 +282,13 @@ func queryGitHubRuns(branch string, limit int) ([]GHWorkflowRun, error) {
|
|||||||
return runs, nil
|
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 {
|
func matchGateToRun(gate *types.Issue, runs []GHWorkflowRun, maxAge time.Duration) *GHWorkflowRun {
|
||||||
now := time.Now()
|
now := time.Now()
|
||||||
currentCommit := getGitCommitForGateDiscovery()
|
currentCommit := getGitCommitForGateDiscovery()
|
||||||
currentBranch := getGitBranchForGateDiscovery()
|
currentBranch := getGitBranchForGateDiscovery()
|
||||||
|
workflowHint := getWorkflowNameHint(gate)
|
||||||
|
|
||||||
var bestMatch *GHWorkflowRun
|
var bestMatch *GHWorkflowRun
|
||||||
var bestScore int
|
var bestScore int
|
||||||
@@ -267,7 +302,21 @@ func matchGateToRun(gate *types.Issue, runs []GHWorkflowRun, maxAge time.Duratio
|
|||||||
continue
|
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 {
|
if currentCommit != "" && run.HeadSha == currentCommit {
|
||||||
score += 100
|
score += 100
|
||||||
}
|
}
|
||||||
@@ -288,11 +337,7 @@ func matchGateToRun(gate *types.Issue, runs []GHWorkflowRun, maxAge time.Duratio
|
|||||||
score += 10
|
score += 10
|
||||||
}
|
}
|
||||||
|
|
||||||
// Heuristic 4: Workflow name match (if gate has workflow specified)
|
// Heuristic 4: Prefer in_progress or queued runs (more likely to be current)
|
||||||
// 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)
|
|
||||||
if run.Status == "in_progress" || run.Status == "queued" {
|
if run.Status == "in_progress" || run.Status == "queued" {
|
||||||
score += 5
|
score += 5
|
||||||
}
|
}
|
||||||
@@ -304,6 +349,8 @@ func matchGateToRun(gate *types.Issue, runs []GHWorkflowRun, maxAge time.Duratio
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Require at least some confidence in the match
|
// 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 {
|
if bestScore >= 30 {
|
||||||
return bestMatch
|
return bestMatch
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -199,6 +199,99 @@ func TestCheckBeadGate_TargetClosed(t *testing.T) {
|
|||||||
t.Log("Full integration testing requires routes.jsonl setup")
|
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)
|
// gateTestContainsIgnoreCase checks if haystack contains needle (case-insensitive)
|
||||||
func gateTestContainsIgnoreCase(haystack, needle string) bool {
|
func gateTestContainsIgnoreCase(haystack, needle string) bool {
|
||||||
return gateTestContains(gateTestLowerCase(haystack), gateTestLowerCase(needle))
|
return gateTestContains(gateTestLowerCase(haystack), gateTestLowerCase(needle))
|
||||||
|
|||||||
Reference in New Issue
Block a user