Files
gastown/internal/cmd/convoy.go
gastown/crew/joe df46e75a51 Fix: Unknown subcommands now error instead of silently showing help
Parent commands (mol, mail, crew, polecat, etc.) previously showed help
and exited 0 for unknown subcommands like "gt mol foobar". This masked
errors in scripts and confused users.

Added requireSubcommand() helper to root.go and applied it to all parent
commands. Now unknown subcommands properly error with exit code 1.

Example before: gt mol unhook → shows help, exits 0
Example after:  gt mol unhook → "Error: unknown command "unhook"", exits 1

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-30 20:52:23 -08:00

551 lines
15 KiB
Go

package cmd
import (
"bytes"
"crypto/rand"
"encoding/base32"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/spf13/cobra"
"github.com/steveyegge/gastown/internal/style"
"github.com/steveyegge/gastown/internal/workspace"
)
// generateShortID generates a short random ID (5 lowercase chars).
func generateShortID() string {
b := make([]byte, 3)
rand.Read(b)
return strings.ToLower(base32.StdEncoding.EncodeToString(b)[:5])
}
// Convoy command flags
var (
convoyMolecule string
convoyNotify string
convoyStatusJSON bool
convoyListJSON bool
convoyListStatus string
convoyListAll bool
)
var convoyCmd = &cobra.Command{
Use: "convoy",
GroupID: GroupWork,
Short: "Track batches of work across rigs",
RunE: requireSubcommand,
Long: `Manage convoys - the primary unit for tracking batched work.
A convoy is a persistent tracking unit that monitors related issues across
rigs. When you kick off work (even a single issue), a convoy tracks it so
you can see when it lands and what was included.
WHAT IS A CONVOY:
- Persistent tracking unit with an ID (hq-*)
- Tracks issues across rigs (frontend+backend, beads+gastown, etc.)
- Auto-closes when all tracked issues complete → notifies subscribers
- Can be reopened by adding more issues
WHAT IS A SWARM:
- Ephemeral: "the workers currently assigned to a convoy's issues"
- No separate ID - uses the convoy ID
- Dissolves when work completes
TRACKING SEMANTICS:
- 'tracks' relation is non-blocking (tracked issues don't block convoy)
- Cross-prefix capable (convoy in hq-* tracks issues in gt-*, bd-*)
- Landed: all tracked issues closed → notification sent to subscribers
COMMANDS:
create Create a convoy tracking specified issues
add Add issues to an existing convoy (reopens if closed)
status Show convoy progress, tracked issues, and active workers
list List convoys (the dashboard view)`,
}
var convoyCreateCmd = &cobra.Command{
Use: "create <name> [issues...]",
Short: "Create a new convoy",
Long: `Create a new convoy that tracks the specified issues.
The convoy is created in town-level beads (hq-* prefix) and can track
issues across any rig.
Examples:
gt convoy create "Deploy v2.0" gt-abc bd-xyz
gt convoy create "Release prep" gt-abc --notify mayor/
gt convoy create "Feature rollout" gt-a gt-b gt-c --molecule mol-release`,
Args: cobra.MinimumNArgs(1),
RunE: runConvoyCreate,
}
var convoyStatusCmd = &cobra.Command{
Use: "status [convoy-id]",
Short: "Show convoy status",
Long: `Show detailed status for a convoy.
Displays convoy metadata, tracked issues, and completion progress.
Without an ID, shows status of all active convoys.`,
Args: cobra.MaximumNArgs(1),
RunE: runConvoyStatus,
}
var convoyListCmd = &cobra.Command{
Use: "list",
Short: "List convoys",
Long: `List convoys, showing open convoys by default.
Examples:
gt convoy list # Open convoys only (default)
gt convoy list --all # All convoys (open + closed)
gt convoy list --status=closed # Recently landed
gt convoy list --json`,
RunE: runConvoyList,
}
func init() {
// Create flags
convoyCreateCmd.Flags().StringVar(&convoyMolecule, "molecule", "", "Associated molecule ID")
convoyCreateCmd.Flags().StringVar(&convoyNotify, "notify", "", "Address to notify on completion")
// Status flags
convoyStatusCmd.Flags().BoolVar(&convoyStatusJSON, "json", false, "Output as JSON")
// List flags
convoyListCmd.Flags().BoolVar(&convoyListJSON, "json", false, "Output as JSON")
convoyListCmd.Flags().StringVar(&convoyListStatus, "status", "", "Filter by status (open, closed)")
convoyListCmd.Flags().BoolVar(&convoyListAll, "all", false, "Show all convoys (open and closed)")
// Add subcommands
convoyCmd.AddCommand(convoyCreateCmd)
convoyCmd.AddCommand(convoyStatusCmd)
convoyCmd.AddCommand(convoyListCmd)
rootCmd.AddCommand(convoyCmd)
}
// getTownBeadsDir returns the path to town-level beads directory.
func getTownBeadsDir() (string, error) {
townRoot, err := workspace.FindFromCwdOrError()
if err != nil {
return "", fmt.Errorf("not in a Gas Town workspace: %w", err)
}
return filepath.Join(townRoot, ".beads"), nil
}
func runConvoyCreate(cmd *cobra.Command, args []string) error {
name := args[0]
trackedIssues := args[1:]
townBeads, err := getTownBeadsDir()
if err != nil {
return err
}
// Create convoy issue in town beads
description := fmt.Sprintf("Convoy tracking %d issues", len(trackedIssues))
if convoyNotify != "" {
description += fmt.Sprintf("\nNotify: %s", convoyNotify)
}
if convoyMolecule != "" {
description += fmt.Sprintf("\nMolecule: %s", convoyMolecule)
}
// Generate convoy ID with cv- prefix
convoyID := fmt.Sprintf("hq-cv-%s", generateShortID())
createArgs := []string{
"create",
"--type=convoy",
"--id=" + convoyID,
"--title=" + name,
"--description=" + description,
"--json",
}
createCmd := exec.Command("bd", createArgs...)
createCmd.Dir = townBeads
var stdout bytes.Buffer
var stderr bytes.Buffer
createCmd.Stdout = &stdout
createCmd.Stderr = &stderr
if err := createCmd.Run(); err != nil {
return fmt.Errorf("creating convoy: %w (%s)", err, strings.TrimSpace(stderr.String()))
}
// Store notify address in slot if specified (for convoy-cleanup to read)
if convoyNotify != "" {
slotArgs := []string{"slot", "set", convoyID, "notify", convoyNotify}
slotCmd := exec.Command("bd", slotArgs...)
slotCmd.Dir = townBeads
if err := slotCmd.Run(); err != nil {
style.PrintWarning("couldn't set notify slot: %v", err)
}
}
// Add 'tracks' relations for each tracked issue
trackedCount := 0
for _, issueID := range trackedIssues {
// Use --type=tracks for non-blocking tracking relation
depArgs := []string{"dep", "add", convoyID, issueID, "--type=tracks"}
depCmd := exec.Command("bd", depArgs...)
depCmd.Dir = townBeads
if err := depCmd.Run(); err != nil {
style.PrintWarning("couldn't track %s: %v", issueID, err)
} else {
trackedCount++
}
}
// Output
fmt.Printf("%s Created convoy 🚚 %s\n\n", style.Bold.Render("✓"), convoyID)
fmt.Printf(" Name: %s\n", name)
fmt.Printf(" Tracking: %d issues\n", trackedCount)
if len(trackedIssues) > 0 {
fmt.Printf(" Issues: %s\n", strings.Join(trackedIssues, ", "))
}
if convoyNotify != "" {
fmt.Printf(" Notify: %s\n", convoyNotify)
}
if convoyMolecule != "" {
fmt.Printf(" Molecule: %s\n", convoyMolecule)
}
fmt.Printf("\n %s\n", style.Dim.Render("Convoy auto-closes when all tracked issues complete"))
return nil
}
func runConvoyStatus(cmd *cobra.Command, args []string) error {
townBeads, err := getTownBeadsDir()
if err != nil {
return err
}
// If no ID provided, show all active convoys
if len(args) == 0 {
return showAllConvoyStatus(townBeads)
}
convoyID := args[0]
// Get convoy details
showArgs := []string{"show", convoyID, "--json"}
showCmd := exec.Command("bd", showArgs...)
showCmd.Dir = townBeads
var stdout bytes.Buffer
showCmd.Stdout = &stdout
if err := showCmd.Run(); err != nil {
return fmt.Errorf("convoy '%s' not found", convoyID)
}
// Parse convoy data
var convoys []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Description string `json:"description"`
CreatedAt string `json:"created_at"`
ClosedAt string `json:"closed_at,omitempty"`
DependsOn []string `json:"depends_on,omitempty"`
}
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
return fmt.Errorf("parsing convoy data: %w", err)
}
if len(convoys) == 0 {
return fmt.Errorf("convoy '%s' not found", convoyID)
}
convoy := convoys[0]
// Get tracked issues by querying SQLite directly
// (bd dep list doesn't properly show cross-rig external dependencies)
type trackedIssue struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Type string `json:"dependency_type"`
IssueType string `json:"issue_type"`
}
tracked := getTrackedIssues(townBeads, convoyID)
// Count completed
completed := 0
for _, t := range tracked {
if t.Status == "closed" {
completed++
}
}
if convoyStatusJSON {
type jsonStatus struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Tracked []trackedIssueInfo `json:"tracked"`
Completed int `json:"completed"`
Total int `json:"total"`
}
out := jsonStatus{
ID: convoy.ID,
Title: convoy.Title,
Status: convoy.Status,
Tracked: tracked,
Completed: completed,
Total: len(tracked),
}
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(out)
}
// Human-readable output
fmt.Printf("🚚 %s %s\n\n", style.Bold.Render(convoy.ID+":"), convoy.Title)
fmt.Printf(" Status: %s\n", formatConvoyStatus(convoy.Status))
fmt.Printf(" Progress: %d/%d completed\n", completed, len(tracked))
fmt.Printf(" Created: %s\n", convoy.CreatedAt)
if convoy.ClosedAt != "" {
fmt.Printf(" Closed: %s\n", convoy.ClosedAt)
}
if len(tracked) > 0 {
fmt.Printf("\n %s\n", style.Bold.Render("Tracked Issues:"))
for _, t := range tracked {
status := "○"
if t.Status == "closed" {
status = "✓"
}
issueType := t.IssueType
if issueType == "" {
issueType = "task"
}
fmt.Printf(" %s %s: %s [%s]\n", status, t.ID, t.Title, issueType)
}
}
return nil
}
func showAllConvoyStatus(townBeads string) error {
// List all convoy-type issues
listArgs := []string{"list", "--type=convoy", "--status=open", "--json"}
listCmd := exec.Command("bd", listArgs...)
listCmd.Dir = townBeads
var stdout bytes.Buffer
listCmd.Stdout = &stdout
if err := listCmd.Run(); err != nil {
return fmt.Errorf("listing convoys: %w", err)
}
var convoys []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
}
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
return fmt.Errorf("parsing convoy list: %w", err)
}
if len(convoys) == 0 {
fmt.Println("No active convoys.")
fmt.Println("Create a convoy with: gt convoy create <name> [issues...]")
return nil
}
if convoyStatusJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(convoys)
}
fmt.Printf("%s\n\n", style.Bold.Render("Active Convoys"))
for _, c := range convoys {
fmt.Printf(" 🚚 %s: %s\n", c.ID, c.Title)
}
fmt.Printf("\nUse 'gt convoy status <id>' for detailed status.\n")
return nil
}
func runConvoyList(cmd *cobra.Command, args []string) error {
townBeads, err := getTownBeadsDir()
if err != nil {
return err
}
// List convoy-type issues
listArgs := []string{"list", "--type=convoy", "--json"}
if convoyListStatus != "" {
listArgs = append(listArgs, "--status="+convoyListStatus)
} else if convoyListAll {
listArgs = append(listArgs, "--all")
}
// Default (no flags) = open only (bd's default behavior)
listCmd := exec.Command("bd", listArgs...)
listCmd.Dir = townBeads
var stdout bytes.Buffer
listCmd.Stdout = &stdout
if err := listCmd.Run(); err != nil {
return fmt.Errorf("listing convoys: %w", err)
}
var convoys []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}
if err := json.Unmarshal(stdout.Bytes(), &convoys); err != nil {
return fmt.Errorf("parsing convoy list: %w", err)
}
if convoyListJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(convoys)
}
if len(convoys) == 0 {
fmt.Println("No convoys found.")
fmt.Println("Create a convoy with: gt convoy create <name> [issues...]")
return nil
}
fmt.Printf("%s\n\n", style.Bold.Render("Convoys"))
for _, c := range convoys {
status := formatConvoyStatus(c.Status)
fmt.Printf(" 🚚 %s: %s %s\n", c.ID, c.Title, status)
}
fmt.Printf("\nUse 'gt convoy status <id>' for detailed view.\n")
return nil
}
func formatConvoyStatus(status string) string {
switch status {
case "open":
return style.Warning.Render("●")
case "closed":
return style.Success.Render("✓")
case "in_progress":
return style.Info.Render("→")
default:
return status
}
}
// trackedIssueInfo holds info about an issue being tracked by a convoy.
type trackedIssueInfo struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
Type string `json:"dependency_type"`
IssueType string `json:"issue_type"`
}
// getTrackedIssues queries SQLite directly to get issues tracked by a convoy.
// This is needed because bd dep list doesn't properly show cross-rig external dependencies.
func getTrackedIssues(townBeads, convoyID string) []trackedIssueInfo {
dbPath := filepath.Join(townBeads, "beads.db")
// Query tracked dependencies from SQLite
queryCmd := exec.Command("sqlite3", "-json", dbPath,
fmt.Sprintf(`SELECT depends_on_id, type FROM dependencies WHERE issue_id = '%s' AND type = 'tracks'`, convoyID))
var stdout bytes.Buffer
queryCmd.Stdout = &stdout
if err := queryCmd.Run(); err != nil {
return nil
}
var deps []struct {
DependsOnID string `json:"depends_on_id"`
Type string `json:"type"`
}
if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil {
return nil
}
var tracked []trackedIssueInfo
for _, dep := range deps {
issueID := dep.DependsOnID
// Handle external reference format: external:rig:issue-id
if strings.HasPrefix(issueID, "external:") {
parts := strings.SplitN(issueID, ":", 3)
if len(parts) == 3 {
issueID = parts[2] // Extract the actual issue ID
}
}
// Try to get issue details from the appropriate rig
info := trackedIssueInfo{
ID: issueID,
Type: dep.Type,
}
// Query issue status (try to find it in any known beads location)
if details := getIssueDetails(issueID); details != nil {
info.Title = details.Title
info.Status = details.Status
info.IssueType = details.IssueType
} else {
info.Title = "(external)"
info.Status = "unknown"
}
tracked = append(tracked, info)
}
return tracked
}
// issueDetails holds basic issue info.
type issueDetails struct {
ID string
Title string
Status string
IssueType string
}
// getIssueDetails fetches issue details by trying to show it via bd.
func getIssueDetails(issueID string) *issueDetails {
// Use bd show with routing - it should find the issue in the right rig
showCmd := exec.Command("bd", "show", issueID, "--json")
var stdout bytes.Buffer
showCmd.Stdout = &stdout
if err := showCmd.Run(); err != nil {
return nil
}
var issues []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
IssueType string `json:"issue_type"`
}
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil || len(issues) == 0 {
return nil
}
return &issueDetails{
ID: issues[0].ID,
Title: issues[0].Title,
Status: issues[0].Status,
IssueType: issues[0].IssueType,
}
}