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>
551 lines
15 KiB
Go
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,
|
|
}
|
|
}
|