Files
gastown/internal/cmd/sling_convoy.go
coma a1f843c11d fix(convoy): ensure custom types before convoy creation
Add EnsureCustomTypes call to createAutoConvoy (sling) and
executeConvoyFormula (formula) to ensure the 'convoy' type is
registered before attempting to create convoy beads.

This fixes "validation failed: invalid issue type: convoy" errors
that occurred when convoy creation was attempted before custom types
were configured in the beads database.

Fixes: hq-ledua

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-26 12:37:49 -08:00

197 lines
5.9 KiB
Go

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: <issue-title>"
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
}