From a89f47cdf2bee86a24ddb1911b36b608e7e3b0b2 Mon Sep 17 00:00:00 2001 From: onyx Date: Fri, 2 Jan 2026 16:12:48 -0800 Subject: [PATCH] feat(gate): add bd gate discover for gh:run await_id auto-discovery (bd-z6kw) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements automatic discovery of GitHub workflow run IDs for gates awaiting CI/CD completion. This enables the Refinery patrol to auto-populate await_id for gh:run gates that were created without one. Changes: - Add `bd gate discover` command that: - Finds open gh:run gates without await_id - Queries recent GitHub workflow runs via gh CLI - Matches runs to gates using heuristics (branch, commit, time) - Updates gates with discovered run IDs - Add `--await-id` flag to `bd update` for manual setting - Add AwaitID to UpdateArgs in RPC protocol - Add await_id to allowedUpdateFields in storage layer Matching heuristics (scored, highest match wins): - Commit SHA match: +100 points - Branch match: +50 points - Time proximity (<5min: +30, <10min: +20, <30min: +10) - In-progress/queued status: +5 points Usage: bd gate discover # Auto-discover for all matching gates bd gate discover --dry-run # Preview without updating bd gate discover --branch main --limit 10 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- cmd/bd/gate_discover.go | 340 ++++++++++++++++++++++++++++ cmd/bd/update.go | 11 + internal/rpc/protocol.go | 2 + internal/rpc/server_issues_epics.go | 4 + internal/storage/sqlite/queries.go | 2 + 5 files changed, 359 insertions(+) create mode 100644 cmd/bd/gate_discover.go diff --git a/cmd/bd/gate_discover.go b/cmd/bd/gate_discover.go new file mode 100644 index 00000000..62909dd5 --- /dev/null +++ b/cmd/bd/gate_discover.go @@ -0,0 +1,340 @@ +package main + +import ( + "encoding/json" + "fmt" + "os" + "os/exec" + "strconv" + "strings" + "time" + + "github.com/spf13/cobra" + "github.com/steveyegge/beads/internal/rpc" + "github.com/steveyegge/beads/internal/types" + "github.com/steveyegge/beads/internal/ui" +) + +// GHWorkflowRun represents a GitHub workflow run from `gh run list --json` +type GHWorkflowRun struct { + DatabaseID int64 `json:"databaseId"` + DisplayTitle string `json:"displayTitle"` + HeadBranch string `json:"headBranch"` + HeadSha string `json:"headSha"` + Name string `json:"name"` + Status string `json:"status"` + Conclusion string `json:"conclusion,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + WorkflowName string `json:"workflowName"` + URL string `json:"url"` +} + +// gateDiscoverCmd discovers GitHub run IDs for gh:run gates +var gateDiscoverCmd = &cobra.Command{ + Use: "discover", + Short: "Discover await_id for gh:run gates", + Long: `Discovers GitHub workflow run IDs for gates awaiting CI/CD completion. + +This command finds open gates with await_type="gh:run" that don't have an await_id, +queries recent GitHub workflow runs, and matches them using heuristics: + - Branch name matching + - Commit SHA matching + - Time proximity (runs within 5 minutes of gate creation) + +Once matched, the gate's await_id is updated with the GitHub run ID, enabling +subsequent polling to check the run's status. + +Examples: + bd gate discover # Auto-discover run IDs for all matching gates + bd gate discover --dry-run # Preview what would be matched (no updates) + bd gate discover --branch main --limit 10 # Only match runs on 'main' branch`, + Run: runGateDiscover, +} + +func init() { + gateDiscoverCmd.Flags().BoolP("dry-run", "n", false, "Preview mode: show matches without updating") + gateDiscoverCmd.Flags().StringP("branch", "b", "", "Filter runs by branch (default: current branch)") + gateDiscoverCmd.Flags().IntP("limit", "l", 10, "Max runs to query from GitHub") + gateDiscoverCmd.Flags().DurationP("max-age", "a", 30*time.Minute, "Max age for gate/run matching") + + gateCmd.AddCommand(gateDiscoverCmd) +} + +func runGateDiscover(cmd *cobra.Command, args []string) { + CheckReadonly("gate discover") + + dryRun, _ := cmd.Flags().GetBool("dry-run") + branchFilter, _ := cmd.Flags().GetString("branch") + limit, _ := cmd.Flags().GetInt("limit") + maxAge, _ := cmd.Flags().GetDuration("max-age") + + ctx := rootCtx + + // Step 1: Find open gh:run gates without await_id + gates, err := findPendingGates() + if err != nil { + fmt.Fprintf(os.Stderr, "Error finding gates: %v\n", err) + os.Exit(1) + } + + if len(gates) == 0 { + fmt.Println("No pending gh:run gates found (all gates have await_id set)") + return + } + + fmt.Printf("%s Found %d gate(s) awaiting run ID discovery\n\n", ui.RenderAccent("🔍"), len(gates)) + + // Get current branch if not specified + if branchFilter == "" { + branchFilter = getGitBranchForGateDiscovery() + } + + // Step 2: Query recent GitHub workflow runs + runs, err := queryGitHubRuns(branchFilter, limit) + if err != nil { + fmt.Fprintf(os.Stderr, "Error querying GitHub runs: %v\n", err) + os.Exit(1) + } + + if len(runs) == 0 { + fmt.Println("No recent workflow runs found on GitHub") + return + } + + fmt.Printf("Found %d recent workflow run(s) on branch '%s'\n\n", len(runs), branchFilter) + + // Step 3: Match runs to gates + matchCount := 0 + for _, gate := range gates { + match := matchGateToRun(gate, runs, maxAge) + if match == nil { + if jsonOutput { + continue + } + fmt.Printf(" %s %s - no matching run found\n", + ui.RenderFail("✗"), ui.RenderID(gate.ID)) + continue + } + + matchCount++ + runIDStr := strconv.FormatInt(match.DatabaseID, 10) + + if dryRun { + fmt.Printf(" %s %s → run %s (%s) [dry-run]\n", + ui.RenderPass("✓"), ui.RenderID(gate.ID), runIDStr, match.Status) + continue + } + + // Step 4: Update gate with discovered run ID + if err := updateGateAwaitID(ctx, gate.ID, runIDStr); err != nil { + fmt.Fprintf(os.Stderr, " %s %s - update failed: %v\n", + ui.RenderFail("✗"), ui.RenderID(gate.ID), err) + continue + } + + fmt.Printf(" %s %s → run %s (%s)\n", + ui.RenderPass("✓"), ui.RenderID(gate.ID), runIDStr, match.Status) + } + + fmt.Println() + if dryRun { + fmt.Printf("Would update %d gate(s). Run without --dry-run to apply.\n", matchCount) + } else { + fmt.Printf("Updated %d gate(s) with discovered run IDs.\n", matchCount) + } +} + +// findPendingGates returns open gh:run gates that have no await_id set +func findPendingGates() ([]*types.Issue, error) { + var gates []*types.Issue + + if daemonClient != nil { + listArgs := &rpc.ListArgs{ + IssueType: "gate", + ExcludeStatus: []string{"closed"}, + } + + resp, err := daemonClient.List(listArgs) + if err != nil { + return nil, fmt.Errorf("list gates: %w", err) + } + + var allGates []*types.Issue + if err := json.Unmarshal(resp.Data, &allGates); err != nil { + return nil, fmt.Errorf("parse gates: %w", err) + } + + // Filter to gh:run gates without await_id + for _, g := range allGates { + if g.AwaitType == "gh:run" && g.AwaitID == "" { + gates = append(gates, g) + } + } + } else { + // Direct mode + gateType := types.TypeGate + filter := types.IssueFilter{ + IssueType: &gateType, + ExcludeStatus: []types.Status{types.StatusClosed}, + } + + allGates, err := store.SearchIssues(rootCtx, "", filter) + if err != nil { + return nil, fmt.Errorf("search gates: %w", err) + } + + for _, g := range allGates { + if g.AwaitType == "gh:run" && g.AwaitID == "" { + gates = append(gates, g) + } + } + } + + return gates, nil +} + +// getGitBranchForGateDiscovery returns the current git branch name +func getGitBranchForGateDiscovery() string { + cmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD") + output, err := cmd.Output() + if err != nil { + return "main" // Default fallback + } + return strings.TrimSpace(string(output)) +} + +// getGitCommitForGateDiscovery returns the current git commit SHA +func getGitCommitForGateDiscovery() string { + cmd := exec.Command("git", "rev-parse", "HEAD") + output, err := cmd.Output() + if err != nil { + return "" + } + return strings.TrimSpace(string(output)) +} + +// queryGitHubRuns queries recent workflow runs from GitHub using gh CLI +func queryGitHubRuns(branch string, limit int) ([]GHWorkflowRun, error) { + // Check if gh CLI is available + if _, err := exec.LookPath("gh"); err != nil { + return nil, fmt.Errorf("gh CLI not found: install from https://cli.github.com") + } + + // Build gh run list command with JSON output + args := []string{ + "run", "list", + "--json", "databaseId,displayTitle,headBranch,headSha,name,status,conclusion,createdAt,updatedAt,workflowName,url", + "--limit", strconv.Itoa(limit), + } + + if branch != "" { + args = append(args, "--branch", branch) + } + + cmd := exec.Command("gh", args...) + output, err := cmd.Output() + if err != nil { + if exitErr, ok := err.(*exec.ExitError); ok { + return nil, fmt.Errorf("gh run list failed: %s", string(exitErr.Stderr)) + } + return nil, fmt.Errorf("gh run list: %w", err) + } + + var runs []GHWorkflowRun + if err := json.Unmarshal(output, &runs); err != nil { + return nil, fmt.Errorf("parse gh output: %w", err) + } + + return runs, nil +} + +// matchGateToRun finds the best matching run for a gate using heuristics +func matchGateToRun(gate *types.Issue, runs []GHWorkflowRun, maxAge time.Duration) *GHWorkflowRun { + now := time.Now() + currentCommit := getGitCommitForGateDiscovery() + currentBranch := getGitBranchForGateDiscovery() + + var bestMatch *GHWorkflowRun + var bestScore int + + for i := range runs { + run := &runs[i] + score := 0 + + // Skip runs that are too old + if now.Sub(run.CreatedAt) > maxAge { + continue + } + + // Heuristic 1: Commit SHA match (strongest signal) + if currentCommit != "" && run.HeadSha == currentCommit { + score += 100 + } + + // Heuristic 2: Branch match + if run.HeadBranch == currentBranch { + score += 50 + } + + // Heuristic 3: Time proximity to gate creation + // Closer in time = higher score + timeDiff := run.CreatedAt.Sub(gate.CreatedAt).Abs() + if timeDiff < 5*time.Minute { + score += 30 + } else if timeDiff < 10*time.Minute { + score += 20 + } else if timeDiff < 30*time.Minute { + 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) + if run.Status == "in_progress" || run.Status == "queued" { + score += 5 + } + + if score > bestScore { + bestScore = score + bestMatch = run + } + } + + // Require at least some confidence in the match + if bestScore >= 30 { + return bestMatch + } + + return nil +} + +// updateGateAwaitID updates a gate's await_id field +func updateGateAwaitID(_ interface{}, gateID, runID string) error { + if daemonClient != nil { + updateArgs := &rpc.UpdateArgs{ + ID: gateID, + AwaitID: &runID, + } + + resp, err := daemonClient.Update(updateArgs) + if err != nil { + return err + } + if !resp.Success { + return fmt.Errorf("%s", resp.Error) + } + } else { + updates := map[string]interface{}{ + "await_id": runID, + } + if err := store.UpdateIssue(rootCtx, gateID, updates, actor); err != nil { + return err + } + markDirtyAndScheduleFlush() + } + + return nil +} diff --git a/cmd/bd/update.go b/cmd/bd/update.go index b6aaaddf..9874cb9c 100644 --- a/cmd/bd/update.go +++ b/cmd/bd/update.go @@ -134,6 +134,11 @@ create, update, show, or close operation).`, } updates["issue_type"] = issueType } + // Gate fields (bd-z6kw) + if cmd.Flags().Changed("await-id") { + awaitID, _ := cmd.Flags().GetString("await-id") + updates["await_id"] = awaitID + } // Time-based scheduling flags (GH#820) if cmd.Flags().Changed("due") { dueStr, _ := cmd.Flags().GetString("due") @@ -264,6 +269,10 @@ create, update, show, or close operation).`, if parent, ok := updates["parent"].(string); ok { updateArgs.Parent = &parent } + // Gate fields (bd-z6kw) + if awaitID, ok := updates["await_id"].(string); ok { + updateArgs.AwaitID = &awaitID + } // Time-based scheduling (GH#820) if dueAt, ok := updates["due_at"].(time.Time); ok { s := dueAt.Format(time.RFC3339) @@ -591,5 +600,7 @@ func init() { // --defer="" Clear defer (show in bd ready immediately) updateCmd.Flags().String("due", "", "Due date/time (empty to clear). Formats: +6h, +1d, +2w, tomorrow, next monday, 2025-01-15") updateCmd.Flags().String("defer", "", "Defer until date (empty to clear). Issue hidden from bd ready until then") + // Gate fields (bd-z6kw) + updateCmd.Flags().String("await-id", "", "Set gate await_id (e.g., GitHub run ID for gh:run gates)") rootCmd.AddCommand(updateCmd) } diff --git a/internal/rpc/protocol.go b/internal/rpc/protocol.go index c584694c..acf9fc03 100644 --- a/internal/rpc/protocol.go +++ b/internal/rpc/protocol.go @@ -161,6 +161,8 @@ type UpdateArgs struct { // Time-based scheduling fields (GH#820) DueAt *string `json:"due_at,omitempty"` // Relative or ISO format due date DeferUntil *string `json:"defer_until,omitempty"` // Relative or ISO format defer date + // Gate fields (bd-z6kw: support await_id updates for gate discovery) + AwaitID *string `json:"await_id,omitempty"` // Condition identifier for gates (run ID, PR number, etc.) } // CloseArgs represents arguments for the close operation diff --git a/internal/rpc/server_issues_epics.go b/internal/rpc/server_issues_epics.go index b89a5633..e65391fa 100644 --- a/internal/rpc/server_issues_epics.go +++ b/internal/rpc/server_issues_epics.go @@ -135,6 +135,10 @@ func updatesFromArgs(a UpdateArgs) map[string]interface{} { if a.EventPayload != nil { u["event_payload"] = *a.EventPayload } + // Gate fields (bd-z6kw: support await_id updates for gate discovery) + if a.AwaitID != nil { + u["await_id"] = *a.AwaitID + } return u } diff --git a/internal/storage/sqlite/queries.go b/internal/storage/sqlite/queries.go index f69fbbec..9d216254 100644 --- a/internal/storage/sqlite/queries.go +++ b/internal/storage/sqlite/queries.go @@ -702,6 +702,8 @@ var allowedUpdateFields = map[string]bool{ // Time-based scheduling fields (GH#820) "due_at": true, "defer_until": true, + // Gate fields (bd-z6kw: support await_id updates for gate discovery) + "await_id": true, } // validatePriority validates a priority value