Files
beads/cmd/bd/gate_discover.go
collins 7cf67153de refactor(types): remove Gas Town type constants from beads core (bd-w2zz4)
Remove Gas Town-specific type constants (TypeMolecule, TypeGate, TypeConvoy,
TypeMergeRequest, TypeSlot, TypeAgent, TypeRole, TypeRig, TypeEvent, TypeMessage)
from internal/types/types.go.

Beads now only has core work types built-in:
- bug, feature, task, epic, chore

All Gas Town types are now purely custom types with no special handling in beads.
Use string literals like "gate" or "molecule" when needed, and configure
types.custom in config.yaml for validation.

Changes:
- Remove Gas Town type constants from types.go
- Remove mr/mol aliases from Normalize()
- Update bd types command to only show core types
- Replace all constant usages with string literals throughout codebase
- Update tests to use string literals

This decouples beads from Gas Town, making it a generic issue tracker.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-21 10:36:59 -08:00

419 lines
12 KiB
Go

package main
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"strconv"
"strings"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/beads"
"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 numeric run IDs)")
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)
}
}
// isNumericRunID returns true if the string looks like a GitHub numeric run ID.
// This is a local alias for consistency - the canonical implementation is isNumericID in gate.go.
func isNumericRunID(s string) bool {
return isNumericID(s)
}
// 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
}
// workflowNameMatches checks if a workflow hint matches a GitHub workflow run.
// It handles various naming conventions:
// - Exact match (case-insensitive)
// - Hint with .yml/.yaml suffix vs display name without
// - Hint without suffix vs filename with .yml/.yaml
func workflowNameMatches(hint, workflowName, runName string) bool {
// Normalize hint by removing .yml/.yaml suffix for comparison
hintBase := strings.TrimSuffix(strings.TrimSuffix(hint, ".yml"), ".yaml")
// Exact matches (case-insensitive)
if strings.EqualFold(workflowName, hint) || strings.EqualFold(runName, hint) {
return true
}
// Match hint base against workflow display name
if strings.EqualFold(workflowName, hintBase) {
return true
}
// Match hint (with suffix added) against run filename
if strings.EqualFold(runName, hintBase+".yml") || strings.EqualFold(runName, hintBase+".yaml") {
return true
}
return false
}
// 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
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 that need discovery
for _, g := range allGates {
if needsDiscovery(g) {
gates = append(gates, g)
}
}
} else {
// Direct mode
gateType := types.IssueType("gate")
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 needsDiscovery(g) {
gates = append(gates, g)
}
}
}
return gates, nil
}
// getGitBranchForGateDiscovery returns the current git branch name
// Uses CWD repo context since this is for user's project CI discovery
func getGitBranchForGateDiscovery() string {
rc, err := beads.GetRepoContext()
if err != nil {
return "main" // Default fallback
}
cmd := rc.GitCmdCWD(context.Background(), "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
// Uses CWD repo context since this is for user's project CI discovery
func getGitCommitForGateDiscovery() string {
rc, err := beads.GetRepoContext()
if err != nil {
return ""
}
cmd := rc.GitCmdCWD(context.Background(), "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.
// 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
for i := range runs {
run := &runs[i]
score := 0
// Skip runs that are too old
if now.Sub(run.CreatedAt) > maxAge {
continue
}
// If gate has a workflow name hint, require matching workflow
// Match against both WorkflowName (display name) and Name (filename)
if workflowHint != "" {
workflowMatches := workflowNameMatches(workflowHint, run.WorkflowName, run.Name)
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
}
// 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: 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
// 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
}
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
}