package cmd import ( "crypto/rand" "encoding/base32" "encoding/json" "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/steveyegge/gastown/internal/beads" "github.com/steveyegge/gastown/internal/style" "github.com/steveyegge/gastown/internal/workspace" ) // slingGenerateShortID generates a short random ID (5 lowercase chars). func slingGenerateShortID() string { b := make([]byte, 3) _, _ = rand.Read(b) return strings.ToLower(base32.StdEncoding.EncodeToString(b)[:5]) } // isTrackedByConvoy checks if an issue is already being tracked by a convoy. // Returns the convoy ID if tracked, empty string otherwise. func isTrackedByConvoy(beadID string) string { townRoot, err := workspace.FindFromCwd() if err != nil { return "" } // Use bd dep list to find what tracks this issue (direction=up) // Filter for open convoys in the results depCmd := exec.Command("bd", "--no-daemon", "dep", "list", beadID, "--direction=up", "--type=tracks", "--json") depCmd.Dir = townRoot out, err := depCmd.Output() if err != nil { return "" } // Parse results and find an open convoy var trackers []struct { ID string `json:"id"` IssueType string `json:"issue_type"` Status string `json:"status"` } if err := json.Unmarshal(out, &trackers); err != nil { return "" } // Return the first open convoy that tracks this issue for _, tracker := range trackers { if tracker.IssueType == "convoy" && tracker.Status == "open" { return tracker.ID } } return "" } // createAutoConvoy creates an auto-convoy for a single issue and tracks it. // If epicID is provided, links the convoy to the parent epic. // Returns the created convoy ID. func createAutoConvoy(beadID, beadTitle string, epicID string) (string, error) { townRoot, err := workspace.FindFromCwd() if err != nil { return "", fmt.Errorf("finding town root: %w", err) } townBeads := filepath.Join(townRoot, ".beads") // Ensure custom types (including 'convoy') are registered in town beads. // This handles cases where install didn't complete or beads was initialized manually. if err := beads.EnsureCustomTypes(townBeads); err != nil { return "", fmt.Errorf("ensuring custom types: %w", err) } // Generate convoy ID with hq-cv- prefix for visual distinction // The hq-cv- prefix is registered in routes during gt install convoyID := fmt.Sprintf("hq-cv-%s", slingGenerateShortID()) // Create convoy with title "Work: " convoyTitle := fmt.Sprintf("Work: %s", beadTitle) description := fmt.Sprintf("Auto-created convoy tracking %s", beadID) if epicID != "" { description += fmt.Sprintf("\nParent-Epic: %s", epicID) } createArgs := []string{ "create", "--type=convoy", "--id=" + convoyID, "--title=" + convoyTitle, "--description=" + description, } if beads.NeedsForceForID(convoyID) { createArgs = append(createArgs, "--force") } createCmd := exec.Command("bd", append([]string{"--no-daemon"}, createArgs...)...) createCmd.Dir = townBeads createCmd.Stderr = os.Stderr if err := createCmd.Run(); err != nil { return "", fmt.Errorf("creating convoy: %w", err) } // Add tracking relation: convoy tracks the issue trackBeadID := formatTrackBeadID(beadID) depArgs := []string{"--no-daemon", "dep", "add", convoyID, trackBeadID, "--type=tracks"} depCmd := exec.Command("bd", depArgs...) depCmd.Dir = townBeads depCmd.Stderr = os.Stderr if err := depCmd.Run(); err != nil { // Convoy was created but tracking failed - log warning but continue fmt.Printf("%s Could not add tracking relation: %v\n", style.Dim.Render("Warning:"), err) } // Link convoy to parent epic if specified (Goals layer) if epicID != "" { epicDepArgs := []string{"--no-daemon", "dep", "add", convoyID, epicID, "--type=child_of"} epicDepCmd := exec.Command("bd", epicDepArgs...) epicDepCmd.Dir = townBeads epicDepCmd.Stderr = os.Stderr if err := epicDepCmd.Run(); err != nil { // Epic link failed - log warning but continue fmt.Printf("%s Could not link convoy to epic: %v\n", style.Dim.Render("Warning:"), err) } } return convoyID, nil } // addToExistingConvoy adds a bead to an existing convoy by creating a tracking relation. // Returns an error if the convoy doesn't exist or the tracking relation fails. func addToExistingConvoy(convoyID, beadID string) error { townRoot, err := workspace.FindFromCwd() if err != nil { return fmt.Errorf("finding town root: %w", err) } townBeads := filepath.Join(townRoot, ".beads") dbPath := filepath.Join(townBeads, "beads.db") // Verify convoy exists and is open query := fmt.Sprintf(` SELECT id FROM issues WHERE id = '%s' AND issue_type = 'convoy' AND status = 'open' `, convoyID) queryCmd := exec.Command("sqlite3", dbPath, query) out, err := queryCmd.Output() if err != nil || strings.TrimSpace(string(out)) == "" { return fmt.Errorf("convoy %s not found or not open", convoyID) } // Add tracking relation: convoy tracks the issue trackBeadID := formatTrackBeadID(beadID) depArgs := []string{"--no-daemon", "dep", "add", convoyID, trackBeadID, "--type=tracks"} depCmd := exec.Command("bd", depArgs...) depCmd.Dir = townBeads depCmd.Stderr = os.Stderr if err := depCmd.Run(); err != nil { return fmt.Errorf("adding tracking relation: %w", err) } return nil } // formatTrackBeadID formats a bead ID for use in convoy tracking dependencies. // Cross-rig beads (non-hq- prefixed) are formatted as external references // so the bd tool can resolve them when running from HQ context. // // Examples: // - "hq-abc123" -> "hq-abc123" (HQ beads unchanged) // - "gt-mol-xyz" -> "external:gt-mol:gt-mol-xyz" // - "beads-task-123" -> "external:beads-task:beads-task-123" func formatTrackBeadID(beadID string) string { if strings.HasPrefix(beadID, "hq-") { return beadID } parts := strings.SplitN(beadID, "-", 3) if len(parts) >= 2 { rigPrefix := parts[0] + "-" + parts[1] return fmt.Sprintf("external:%s:%s", rigPrefix, beadID) } // Fallback for malformed IDs (single segment) return beadID }