getTrackedIssues was spawning a separate bd show subprocess for each tracked issue. With 10 convoys x 5 issues = 50+ subprocesses per poll. Solution: Use bd show batch capability (bd show id1 id2 id3 --json) to fetch all issue details in a single call. Falls back to individual lookups if the batch fails (e.g., invalid IDs). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
706 lines
19 KiB
Go
706 lines
19 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,
|
|
}
|
|
|
|
var convoyAddCmd = &cobra.Command{
|
|
Use: "add <convoy-id> <issue-id> [issue-id...]",
|
|
Short: "Add issues to an existing convoy",
|
|
Long: `Add issues to an existing convoy.
|
|
|
|
If the convoy is closed, it will be automatically reopened.
|
|
|
|
Examples:
|
|
gt convoy add hq-cv-abc gt-new-issue
|
|
gt convoy add hq-cv-abc gt-issue1 gt-issue2 gt-issue3`,
|
|
Args: cobra.MinimumNArgs(2),
|
|
RunE: runConvoyAdd,
|
|
}
|
|
|
|
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)
|
|
convoyCmd.AddCommand(convoyAddCmd)
|
|
|
|
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
|
|
}
|
|
|
|
// Default --notify to mayor/ when mayor creates a convoy
|
|
if convoyNotify == "" {
|
|
if roleInfo, err := GetRole(); err == nil && roleInfo.Role == RoleMayor {
|
|
convoyNotify = "mayor/"
|
|
}
|
|
}
|
|
|
|
// 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()))
|
|
}
|
|
|
|
// Notify address is stored in description (line 166-168) and read from there
|
|
|
|
// 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 runConvoyAdd(cmd *cobra.Command, args []string) error {
|
|
convoyID := args[0]
|
|
issuesToAdd := args[1:]
|
|
|
|
townBeads, err := getTownBeadsDir()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
// Validate convoy exists and get its status
|
|
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)
|
|
}
|
|
|
|
var convoys []struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Status string `json:"status"`
|
|
Type string `json:"issue_type"`
|
|
}
|
|
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]
|
|
|
|
// Verify it's actually a convoy type
|
|
if convoy.Type != "convoy" {
|
|
return fmt.Errorf("'%s' is not a convoy (type: %s)", convoyID, convoy.Type)
|
|
}
|
|
|
|
// If convoy is closed, reopen it
|
|
reopened := false
|
|
if convoy.Status == "closed" {
|
|
reopenArgs := []string{"update", convoyID, "--status=open"}
|
|
reopenCmd := exec.Command("bd", reopenArgs...)
|
|
reopenCmd.Dir = townBeads
|
|
if err := reopenCmd.Run(); err != nil {
|
|
return fmt.Errorf("couldn't reopen convoy: %w", err)
|
|
}
|
|
reopened = true
|
|
fmt.Printf("%s Reopened convoy %s\n", style.Bold.Render("↺"), convoyID)
|
|
}
|
|
|
|
// Add 'tracks' relations for each issue
|
|
addedCount := 0
|
|
for _, issueID := range issuesToAdd {
|
|
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 add %s: %v", issueID, err)
|
|
} else {
|
|
addedCount++
|
|
}
|
|
}
|
|
|
|
// Output
|
|
if reopened {
|
|
fmt.Println()
|
|
}
|
|
fmt.Printf("%s Added %d issue(s) to convoy 🚚 %s\n", style.Bold.Render("✓"), addedCount, convoyID)
|
|
if addedCount > 0 {
|
|
fmt.Printf(" Issues: %s\n", strings.Join(issuesToAdd[:addedCount], ", "))
|
|
}
|
|
|
|
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.
|
|
// Uses batched lookup to avoid N+1 subprocess calls.
|
|
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
|
|
}
|
|
|
|
// First pass: collect all issue IDs (normalized from external refs)
|
|
issueIDs := make([]string, 0, len(deps))
|
|
idToDepType := make(map[string]string)
|
|
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
|
|
}
|
|
}
|
|
|
|
issueIDs = append(issueIDs, issueID)
|
|
idToDepType[issueID] = dep.Type
|
|
}
|
|
|
|
// Single batch call to get all issue details
|
|
detailsMap := getIssueDetailsBatch(issueIDs)
|
|
|
|
// Second pass: build result using the batch lookup
|
|
var tracked []trackedIssueInfo
|
|
for _, issueID := range issueIDs {
|
|
info := trackedIssueInfo{
|
|
ID: issueID,
|
|
Type: idToDepType[issueID],
|
|
}
|
|
|
|
if details, ok := detailsMap[issueID]; ok {
|
|
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
|
|
}
|
|
|
|
// getIssueDetailsBatch fetches details for multiple issues in a single bd show call.
|
|
// Returns a map from issue ID to details. Missing/invalid issues are omitted from the map.
|
|
func getIssueDetailsBatch(issueIDs []string) map[string]*issueDetails {
|
|
result := make(map[string]*issueDetails)
|
|
if len(issueIDs) == 0 {
|
|
return result
|
|
}
|
|
|
|
// Build args: bd show id1 id2 id3 ... --json
|
|
args := append([]string{"show"}, issueIDs...)
|
|
args = append(args, "--json")
|
|
|
|
showCmd := exec.Command("bd", args...)
|
|
var stdout bytes.Buffer
|
|
showCmd.Stdout = &stdout
|
|
|
|
if err := showCmd.Run(); err != nil {
|
|
// Batch failed - fall back to individual lookups for robustness
|
|
// This handles cases where some IDs are invalid/missing
|
|
for _, id := range issueIDs {
|
|
if details := getIssueDetails(id); details != nil {
|
|
result[id] = details
|
|
}
|
|
}
|
|
return result
|
|
}
|
|
|
|
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 {
|
|
return result
|
|
}
|
|
|
|
for _, issue := range issues {
|
|
result[issue.ID] = &issueDetails{
|
|
ID: issue.ID,
|
|
Title: issue.Title,
|
|
Status: issue.Status,
|
|
IssueType: issue.IssueType,
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// getIssueDetails fetches issue details by trying to show it via bd.
|
|
// Prefer getIssueDetailsBatch for multiple issues to avoid N+1 subprocess calls.
|
|
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,
|
|
}
|
|
}
|