feat(convoy): add epic filtering flags to convoy list

Add three new flags for filtering convoys by epic relationship:
- --orphans: show only convoys without a parent epic
- --epic <id>: show only convoys under a specific epic
- --by-epic: group convoys by parent epic

These support the Goals Layer feature (Phase 3) for hierarchical
focus management.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
gastown/crew/octane
2026-01-22 19:32:54 -08:00
committed by John Ogle
parent 9cf012c0d5
commit 39401e3606

View File

@@ -1266,6 +1266,7 @@ func runConvoyList(cmd *cobra.Command, args []string) error {
convoys = filtered
}
if convoyListJSON {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
@@ -1400,59 +1401,38 @@ func printConvoyTreeFromItems(townBeads string, convoys []convoyListItem) error
return nil
}
// printConvoyTree displays convoys with their child issues in a tree format.
func printConvoyTree(townBeads string, convoys []struct {
// getEpicTitles fetches titles for the given epic IDs.
func getEpicTitles(epicIDs []string) map[string]string {
result := make(map[string]string)
if len(epicIDs) == 0 {
return result
}
// Use bd show to get epic details (handles routing automatically)
args := append([]string{"show"}, epicIDs...)
args = append(args, "--json")
showCmd := exec.Command("bd", args...)
var stdout bytes.Buffer
showCmd.Stdout = &stdout
if err := showCmd.Run(); err != nil {
return result
}
var issues []struct {
ID string `json:"id"`
Title string `json:"title"`
Status string `json:"status"`
CreatedAt string `json:"created_at"`
}) error {
for _, c := range convoys {
// Get tracked issues for this convoy
tracked := getTrackedIssues(townBeads, c.ID)
// Count completed
completed := 0
for _, t := range tracked {
if t.Status == "closed" {
completed++
}
if err := json.Unmarshal(stdout.Bytes(), &issues); err != nil {
return result
}
// Print convoy header with progress
total := len(tracked)
progress := ""
if total > 0 {
progress = fmt.Sprintf(" (%d/%d)", completed, total)
}
fmt.Printf("🚚 %s: %s%s\n", c.ID, c.Title, progress)
// Print tracked issues as tree children
for i, t := range tracked {
// Determine tree connector
isLast := i == len(tracked)-1
connector := "├──"
if isLast {
connector = "└──"
for _, issue := range issues {
result[issue.ID] = issue.Title
}
// Status symbol: ✓ closed, ▶ in_progress/hooked, ○ other
status := "○"
switch t.Status {
case "closed":
status = "✓"
case "in_progress", "hooked":
status = "▶"
}
fmt.Printf("%s %s %s: %s\n", connector, status, t.ID, t.Title)
}
// Add blank line between convoys
fmt.Println()
}
return nil
return result
}
func formatConvoyStatus(status string) string {
@@ -1468,6 +1448,61 @@ func formatConvoyStatus(status string) string {
}
}
// getConvoyParentEpics returns a map from convoy ID to parent epic ID.
// Convoys link to epics via child_of dependency type.
// Uses a single batched query for efficiency.
func getConvoyParentEpics(townBeads string, convoyIDs []string) map[string]string {
result := make(map[string]string)
if len(convoyIDs) == 0 {
return result
}
dbPath := filepath.Join(townBeads, "beads.db")
// Build IN clause with properly escaped IDs
var quotedIDs []string
for _, id := range convoyIDs {
safeID := strings.ReplaceAll(id, "'", "''")
quotedIDs = append(quotedIDs, fmt.Sprintf("'%s'", safeID))
}
inClause := strings.Join(quotedIDs, ", ")
// Query child_of dependencies for all convoys at once
query := fmt.Sprintf(
`SELECT issue_id, depends_on_id FROM dependencies WHERE issue_id IN (%s) AND type = 'child_of'`,
inClause)
queryCmd := exec.Command("sqlite3", "-json", dbPath, query)
var stdout bytes.Buffer
queryCmd.Stdout = &stdout
if err := queryCmd.Run(); err != nil {
return result
}
var deps []struct {
IssueID string `json:"issue_id"`
DependsOnID string `json:"depends_on_id"`
}
if err := json.Unmarshal(stdout.Bytes(), &deps); err != nil {
return result
}
for _, dep := range deps {
epicID := dep.DependsOnID
// Handle external reference format: external:rig:issue-id
if strings.HasPrefix(epicID, "external:") {
parts := strings.SplitN(epicID, ":", 3)
if len(parts) == 3 {
epicID = parts[2] // Extract the actual issue ID
}
}
result[dep.IssueID] = epicID
}
return result
}
// trackedIssueInfo holds info about an issue being tracked by a convoy.
type trackedIssueInfo struct {
ID string `json:"id"`