diff --git a/internal/cmd/convoy.go b/internal/cmd/convoy.go index 77ff29b4..2b73fa4c 100644 --- a/internal/cmd/convoy.go +++ b/internal/cmd/convoy.go @@ -1310,104 +1310,58 @@ type trackedIssueInfo struct { WorkerAge string `json:"worker_age,omitempty"` // How long worker has been on this issue } -// 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. +// getTrackedIssues uses bd dep list to get issues tracked by a convoy. +// Returns issue details including status, type, and worker info. func getTrackedIssues(townBeads, convoyID string) []trackedIssueInfo { - dbPath := filepath.Join(townBeads, "beads.db") - - // Query tracked dependencies from SQLite - // Escape single quotes to prevent SQL injection - safeConvoyID := strings.ReplaceAll(convoyID, "'", "''") - queryCmd := exec.Command("sqlite3", "-json", dbPath, - fmt.Sprintf(`SELECT depends_on_id, type FROM dependencies WHERE issue_id = '%s' AND type = 'tracks'`, safeConvoyID)) + // Use bd dep list to get tracked dependencies + // Run from town root (parent of .beads) so bd routes correctly + townRoot := filepath.Dir(townBeads) + depCmd := exec.Command("bd", "--no-daemon", "dep", "list", convoyID, "--direction=down", "--type=tracks", "--json") + depCmd.Dir = townRoot var stdout bytes.Buffer - queryCmd.Stdout = &stdout - if err := queryCmd.Run(); err != nil { + depCmd.Stdout = &stdout + if err := depCmd.Run(); err != nil { return nil } + // Parse the JSON output - bd dep list returns full issue details var deps []struct { - DependsOnID string `json:"depends_on_id"` - Type string `json:"type"` + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + IssueType string `json:"issue_type"` + Assignee string `json:"assignee"` + DependencyType string `json:"dependency_type"` + Labels []string `json:"labels"` } if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil { return nil } - // First pass: collect all issue IDs and track which rig they belong to - type issueRef struct { - ID string - RigName string // empty for local issues, rig name for external - } - issueRefs := make([]issueRef, 0, len(deps)) - idToDepType := make(map[string]string) - + // Collect non-closed issue IDs for worker lookup + openIssueIDs := make([]string, 0, len(deps)) for _, dep := range deps { - issueID := dep.DependsOnID - rigName := "" // Local issue by default - - // Handle external reference format: external:rig:issue-id - if strings.HasPrefix(issueID, "external:") { - parts := strings.SplitN(issueID, ":", 3) - if len(parts) == 3 { - rigName = parts[1] // Extract rig name - issueID = parts[2] // Extract the actual issue ID - } - } - - issueRefs = append(issueRefs, issueRef{ID: issueID, RigName: rigName}) - idToDepType[issueID] = dep.Type - } - - // Query issues, grouped by rig - detailsMap := make(map[string]*issueDetails) - for _, ref := range issueRefs { - var details *issueDetails - if ref.RigName != "" { - // External reference: query the rig database - details = getExternalIssueDetails(townBeads, ref.RigName, ref.ID) - } else { - // Local reference: query town database - details = getIssueDetails(ref.ID) - } - if details != nil { - detailsMap[ref.ID] = details - } - } - - // Get workers for these issues (only for non-closed issues) - openIssueIDs := make([]string, 0) - for _, ref := range issueRefs { - id := ref.ID - if details, ok := detailsMap[id]; ok && details.Status != "closed" { - openIssueIDs = append(openIssueIDs, id) + if dep.Status != "closed" { + openIssueIDs = append(openIssueIDs, dep.ID) } } workersMap := getWorkersForIssues(openIssueIDs) - // Second pass: build result using the batch lookup + // Build result var tracked []trackedIssueInfo - for _, ref := range issueRefs { - issueID := ref.ID + for _, dep := range deps { 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 - info.Assignee = details.Assignee - } else { - info.Title = "(external)" - info.Status = "unknown" + ID: dep.ID, + Title: dep.Title, + Status: dep.Status, + Type: dep.DependencyType, + IssueType: dep.IssueType, + Assignee: dep.Assignee, } // Add worker info if available - if worker, ok := workersMap[issueID]; ok { + if worker, ok := workersMap[dep.ID]; ok { info.Worker = worker.Worker info.WorkerAge = worker.Age } diff --git a/internal/cmd/sling_convoy.go b/internal/cmd/sling_convoy.go index 8982ae4e..51429a84 100644 --- a/internal/cmd/sling_convoy.go +++ b/internal/cmd/sling_convoy.go @@ -3,6 +3,7 @@ package cmd import ( "crypto/rand" "encoding/base32" + "encoding/json" "fmt" "os" "os/exec" @@ -29,32 +30,34 @@ func isTrackedByConvoy(beadID string) string { return "" } - // Query town beads for any convoy that tracks this issue - // Convoys use "tracks" dependency type: convoy -> tracked issue - townBeads := filepath.Join(townRoot, ".beads") - dbPath := filepath.Join(townBeads, "beads.db") + // 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 - // Query dependencies where this bead is being tracked - // Also check for external reference format: external:rig:issue-id - query := fmt.Sprintf(` - SELECT d.issue_id - FROM dependencies d - JOIN issues i ON d.issue_id = i.id - WHERE d.type = 'tracks' - AND i.issue_type = 'convoy' - AND i.status = 'open' - AND (d.depends_on_id = '%s' OR d.depends_on_id LIKE '%%:%s') - LIMIT 1 - `, beadID, beadID) - - queryCmd := exec.Command("sqlite3", dbPath, query) - out, err := queryCmd.Output() + out, err := depCmd.Output() if err != nil { return "" } - convoyID := strings.TrimSpace(string(out)) - return convoyID + // 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.