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>
419 lines
12 KiB
Go
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
|
|
}
|