Files
beads/cmd/bd/list.go
aleiby 9e85b9f5d7 perf(list): optimize bd list --json to fetch only needed dependencies (#1316)
Add GetDependencyRecordsForIssues method to storage interface that
fetches dependencies only for specified issue IDs instead of all
dependencies in the database.

This optimizes bd list --json which previously called
GetAllDependencyRecords() even when displaying only a few issues
(e.g., bd list --limit 10).

- Add GetDependencyRecordsForIssues to Storage interface
- Implement in SQLite, Dolt, and Memory backends
- Update list.go JSON output to use targeted method
- Update mock storage in tests

Origin: Mayor's review of PR #1296

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-25 17:59:53 -08:00

1487 lines
47 KiB
Go

package main
import (
"bytes"
"cmp"
"context"
"encoding/json"
"fmt"
"os"
"os/signal"
"path/filepath"
"slices"
"strings"
"syscall"
"text/template"
"time"
"github.com/fsnotify/fsnotify"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/config"
"github.com/steveyegge/beads/internal/rpc"
"github.com/steveyegge/beads/internal/storage"
"github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/timeparsing"
"github.com/steveyegge/beads/internal/types"
"github.com/steveyegge/beads/internal/ui"
"github.com/steveyegge/beads/internal/util"
"github.com/steveyegge/beads/internal/validation"
)
// storageExecutor handles operations that need to work with both direct store and daemon mode
type storageExecutor func(store storage.Storage) error
// withStorage executes an operation with either the direct store or a read-only store in daemon mode
func withStorage(ctx context.Context, store storage.Storage, dbPath string, lockTimeout time.Duration, fn storageExecutor) error {
if store != nil {
return fn(store)
} else if dbPath != "" {
// Daemon mode: open read-only connection
roStore, err := sqlite.NewReadOnlyWithTimeout(ctx, dbPath, lockTimeout)
if err != nil {
return err
}
defer func() { _ = roStore.Close() }()
return fn(roStore)
}
return fmt.Errorf("no storage available")
}
// getHierarchicalChildren handles the --tree --parent combination logic
func getHierarchicalChildren(ctx context.Context, store storage.Storage, dbPath string, lockTimeout time.Duration, parentID string) ([]*types.Issue, error) {
// First verify that the parent issue exists
var parentIssue *types.Issue
err := withStorage(ctx, store, dbPath, lockTimeout, func(s storage.Storage) error {
var err error
parentIssue, err = s.GetIssue(ctx, parentID)
return err
})
if err != nil {
return nil, fmt.Errorf("error checking parent issue: %v", err)
}
if parentIssue == nil {
return nil, fmt.Errorf("parent issue '%s' not found", parentID)
}
// Use recursive search to find all descendants using the same logic as --parent filter
// This works around issues with GetDependencyTree not finding all dependents properly
allDescendants := make(map[string]*types.Issue)
// Always include the parent
allDescendants[parentID] = parentIssue
// Recursively find all descendants
err = findAllDescendants(ctx, store, dbPath, lockTimeout, parentID, allDescendants, 0, 10) // max depth 10
if err != nil {
return nil, fmt.Errorf("error finding descendants: %v", err)
}
// Convert map to slice for display
treeIssues := make([]*types.Issue, 0, len(allDescendants))
for _, issue := range allDescendants {
treeIssues = append(treeIssues, issue)
}
return treeIssues, nil
}
// findAllDescendants recursively finds all descendants using parent filtering
func findAllDescendants(ctx context.Context, store storage.Storage, dbPath string, lockTimeout time.Duration, parentID string, result map[string]*types.Issue, currentDepth, maxDepth int) error {
if currentDepth >= maxDepth {
return nil // Prevent infinite recursion
}
// Get direct children using the same filter logic as regular --parent
var children []*types.Issue
err := withStorage(ctx, store, dbPath, lockTimeout, func(s storage.Storage) error {
filter := types.IssueFilter{
ParentID: &parentID,
}
var err error
children, err = s.SearchIssues(ctx, "", filter)
return err
})
if err != nil {
return err
}
// Add children and recursively find their descendants
for _, child := range children {
if _, exists := result[child.ID]; !exists {
result[child.ID] = child
// Recursively find this child's descendants
err = findAllDescendants(ctx, store, dbPath, lockTimeout, child.ID, result, currentDepth+1, maxDepth)
if err != nil {
return err
}
}
}
return nil
}
// parseTimeFlag parses time strings using the layered time parsing architecture.
// Supports compact durations (+6h, -1d), natural language (tomorrow, next monday),
// and absolute formats (2006-01-02, RFC3339).
func parseTimeFlag(s string) (time.Time, error) {
return timeparsing.ParseRelativeTime(s, time.Now())
}
// pinIndicator returns a pushpin emoji prefix for pinned issues
func pinIndicator(issue *types.Issue) string {
if issue.Pinned {
return "📌 "
}
return ""
}
// Priority tags for pretty output - simple text, semantic colors applied via ui package
// Design principle: only P0/P1 get color for attention, P2-P4 are neutral
func renderPriorityTag(priority int) string {
return ui.RenderPriority(priority)
}
// renderStatusIcon returns the status icon with semantic coloring applied
// Delegates to the shared ui.RenderStatusIcon for consistency across commands
func renderStatusIcon(status types.Status) string {
return ui.RenderStatusIcon(string(status))
}
// formatPrettyIssue formats a single issue for pretty output
// Uses semantic colors: status icon colored, priority P0/P1 colored, rest neutral
func formatPrettyIssue(issue *types.Issue) string {
// Use shared helpers from ui package
statusIcon := ui.RenderStatusIcon(string(issue.Status))
priorityTag := renderPriorityTag(issue.Priority)
// Type badge - only show for notable types
typeBadge := ""
switch issue.IssueType {
case "epic":
typeBadge = ui.TypeEpicStyle.Render("[epic]") + " "
case "bug":
typeBadge = ui.TypeBugStyle.Render("[bug]") + " "
}
// Format: STATUS_ICON ID PRIORITY [Type] Title
// Priority uses ● icon with color, no brackets needed
// Closed issues: entire line is muted
if issue.Status == types.StatusClosed {
return fmt.Sprintf("%s %s %s %s%s",
statusIcon,
ui.RenderMuted(issue.ID),
ui.RenderMuted(fmt.Sprintf("● P%d", issue.Priority)),
ui.RenderMuted(string(issue.IssueType)),
ui.RenderMuted(" "+issue.Title))
}
return fmt.Sprintf("%s %s %s %s%s", statusIcon, issue.ID, priorityTag, typeBadge, issue.Title)
}
// buildIssueTree builds parent-child tree structure from issues
// Uses actual parent-child dependencies from the database when store is provided
func buildIssueTree(issues []*types.Issue) (roots []*types.Issue, childrenMap map[string][]*types.Issue) {
return buildIssueTreeWithDeps(issues, nil)
}
// buildIssueTreeWithDeps builds parent-child tree using dependency records
// If allDeps is nil, falls back to dotted ID hierarchy (e.g., "parent.1")
// Treats any dependency on an epic as a parent-child relationship
func buildIssueTreeWithDeps(issues []*types.Issue, allDeps map[string][]*types.Dependency) (roots []*types.Issue, childrenMap map[string][]*types.Issue) {
issueMap := make(map[string]*types.Issue)
childrenMap = make(map[string][]*types.Issue)
isChild := make(map[string]bool)
// Build issue map and identify epics
epicIDs := make(map[string]bool)
for _, issue := range issues {
issueMap[issue.ID] = issue
if issue.IssueType == "epic" {
epicIDs[issue.ID] = true
}
}
// If we have dependency records, use them to find parent-child relationships
if allDeps != nil {
for issueID, deps := range allDeps {
for _, dep := range deps {
parentID := dep.DependsOnID
// Only include if both parent and child are in the issue set
child, childOk := issueMap[issueID]
_, parentOk := issueMap[parentID]
if !childOk || !parentOk {
continue
}
// Treat as parent-child if:
// 1. Explicit parent-child dependency type, OR
// 2. Any dependency where the target is an epic
if dep.Type == types.DepParentChild || epicIDs[parentID] {
childrenMap[parentID] = append(childrenMap[parentID], child)
isChild[issueID] = true
}
}
}
}
// Fallback: check for hierarchical subtask IDs (e.g., "parent.1")
for _, issue := range issues {
if isChild[issue.ID] {
continue // Already a child via dependency
}
if strings.Contains(issue.ID, ".") {
parts := strings.Split(issue.ID, ".")
parentID := strings.Join(parts[:len(parts)-1], ".")
if _, exists := issueMap[parentID]; exists {
childrenMap[parentID] = append(childrenMap[parentID], issue)
isChild[issue.ID] = true
continue
}
}
}
// Roots are issues that aren't children of any other issue
for _, issue := range issues {
if !isChild[issue.ID] {
roots = append(roots, issue)
}
}
// Sort roots for stable tree ordering (fixes unstable --tree output)
// Use same sorting logic as children for consistency
slices.SortFunc(roots, compareIssuesByPriority)
// Sort children within each parent for stable ordering in data structure
for parentID := range childrenMap {
slices.SortFunc(childrenMap[parentID], compareIssuesByPriority)
}
return roots, childrenMap
}
// compareIssuesByPriority provides stable sorting for tree display
// Primary sort: priority (P0 before P1 before P2...)
// Secondary sort: ID for deterministic ordering when priorities match
func compareIssuesByPriority(a, b *types.Issue) int {
// Primary: priority (ascending: P0 before P1 before P2...)
if result := cmp.Compare(a.Priority, b.Priority); result != 0 {
return result
}
// Secondary: ID for deterministic order when priorities match
return cmp.Compare(a.ID, b.ID)
}
// printPrettyTree recursively prints the issue tree
// Children are sorted by priority (P0 first) for intuitive reading
func printPrettyTree(childrenMap map[string][]*types.Issue, parentID string, prefix string) {
children := childrenMap[parentID]
// Sort children by priority using same comparison as roots for consistency
slices.SortFunc(children, compareIssuesByPriority)
for i, child := range children {
isLast := i == len(children)-1
connector := "├── "
if isLast {
connector = "└── "
}
fmt.Printf("%s%s%s\n", prefix, connector, formatPrettyIssue(child))
extension := "│ "
if isLast {
extension = " "
}
printPrettyTree(childrenMap, child.ID, prefix+extension)
}
}
// displayPrettyList displays issues in pretty tree format (GH#654)
// Uses buildIssueTree which only supports dotted ID hierarchy
func displayPrettyList(issues []*types.Issue, showHeader bool) {
displayPrettyListWithDeps(issues, showHeader, nil)
}
// displayPrettyListWithDeps displays issues in tree format using dependency data
func displayPrettyListWithDeps(issues []*types.Issue, showHeader bool, allDeps map[string][]*types.Dependency) {
if showHeader {
// Clear screen and show header
fmt.Print("\033[2J\033[H")
fmt.Println(strings.Repeat("=", 80))
fmt.Printf("Beads - Open & In Progress (%s)\n", time.Now().Format("15:04:05"))
fmt.Println(strings.Repeat("=", 80))
fmt.Println()
}
if len(issues) == 0 {
fmt.Println("No issues found.")
return
}
roots, childrenMap := buildIssueTreeWithDeps(issues, allDeps)
for _, issue := range roots {
fmt.Println(formatPrettyIssue(issue))
printPrettyTree(childrenMap, issue.ID, "")
}
// Summary
fmt.Println()
fmt.Println(strings.Repeat("-", 80))
openCount := 0
inProgressCount := 0
for _, issue := range issues {
switch issue.Status {
case "open":
openCount++
case "in_progress":
inProgressCount++
}
}
fmt.Printf("Total: %d issues (%d open, %d in progress)\n", len(issues), openCount, inProgressCount)
fmt.Println()
fmt.Println("Status: ○ open ◐ in_progress ● blocked ✓ closed ❄ deferred")
}
// watchIssues starts watching for changes and re-displays (GH#654)
func watchIssues(ctx context.Context, store storage.Storage, filter types.IssueFilter, sortBy string, reverse bool) {
// Find .beads directory
beadsDir := ".beads"
if _, err := os.Stat(beadsDir); os.IsNotExist(err) {
fmt.Fprintf(os.Stderr, "Error: .beads directory not found\n")
return
}
watcher, err := fsnotify.NewWatcher()
if err != nil {
fmt.Fprintf(os.Stderr, "Error creating watcher: %v\n", err)
return
}
defer func() { _ = watcher.Close() }()
// Watch the .beads directory
if err := watcher.Add(beadsDir); err != nil {
fmt.Fprintf(os.Stderr, "Error watching directory: %v\n", err)
return
}
// Initial display
issues, _ := store.SearchIssues(ctx, "", filter)
sortIssues(issues, sortBy, reverse)
displayPrettyList(issues, true)
fmt.Fprintf(os.Stderr, "\nWatching for changes... (Press Ctrl+C to exit)\n")
// Handle Ctrl+C
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, os.Interrupt, syscall.SIGTERM)
// Debounce timer
var debounceTimer *time.Timer
debounceDelay := 500 * time.Millisecond
for {
select {
case <-sigChan:
fmt.Fprintf(os.Stderr, "\nStopped watching.\n")
return
case event, ok := <-watcher.Events:
if !ok {
return
}
// Only react to writes on issues.jsonl or database files
if event.Has(fsnotify.Write) {
basename := filepath.Base(event.Name)
if basename == "issues.jsonl" || strings.HasSuffix(basename, ".db") {
// Debounce rapid changes
if debounceTimer != nil {
debounceTimer.Stop()
}
debounceTimer = time.AfterFunc(debounceDelay, func() {
issues, _ := store.SearchIssues(ctx, "", filter)
sortIssues(issues, sortBy, reverse)
displayPrettyList(issues, true)
fmt.Fprintf(os.Stderr, "\nWatching for changes... (Press Ctrl+C to exit)\n")
})
}
}
case err, ok := <-watcher.Errors:
if !ok {
return
}
fmt.Fprintf(os.Stderr, "Watcher error: %v\n", err)
}
}
}
// sortIssues sorts a slice of issues by the specified field and direction
func sortIssues(issues []*types.Issue, sortBy string, reverse bool) {
if sortBy == "" {
return
}
slices.SortFunc(issues, func(a, b *types.Issue) int {
var result int
switch sortBy {
case "priority":
// Lower priority numbers come first (P0 > P1 > P2 > P3 > P4)
result = cmp.Compare(a.Priority, b.Priority)
case "created":
// Default: newest first (descending)
result = b.CreatedAt.Compare(a.CreatedAt)
case "updated":
// Default: newest first (descending)
result = b.UpdatedAt.Compare(a.UpdatedAt)
case "closed":
// Default: newest first (descending)
// Handle nil ClosedAt values
if a.ClosedAt == nil && b.ClosedAt == nil {
result = 0
} else if a.ClosedAt == nil {
result = 1 // nil sorts last
} else if b.ClosedAt == nil {
result = -1 // non-nil sorts before nil
} else {
result = b.ClosedAt.Compare(*a.ClosedAt)
}
case "status":
result = cmp.Compare(a.Status, b.Status)
case "id":
result = cmp.Compare(a.ID, b.ID)
case "title":
result = cmp.Compare(strings.ToLower(a.Title), strings.ToLower(b.Title))
case "type":
result = cmp.Compare(a.IssueType, b.IssueType)
case "assignee":
result = cmp.Compare(a.Assignee, b.Assignee)
default:
// Unknown sort field, no sorting
result = 0
}
if reverse {
return -result
}
return result
})
}
// formatIssueLong formats a single issue in long format to a buffer
func formatIssueLong(buf *strings.Builder, issue *types.Issue, labels []string) {
status := string(issue.Status)
if status == "closed" {
line := fmt.Sprintf("%s%s [P%d] [%s] %s\n %s",
pinIndicator(issue), issue.ID, issue.Priority,
issue.IssueType, status, issue.Title)
buf.WriteString(ui.RenderClosedLine(line))
buf.WriteString("\n")
} else {
buf.WriteString(fmt.Sprintf("%s%s [%s] [%s] %s\n",
pinIndicator(issue),
ui.RenderID(issue.ID),
ui.RenderPriority(issue.Priority),
ui.RenderType(string(issue.IssueType)),
ui.RenderStatus(status)))
buf.WriteString(fmt.Sprintf(" %s\n", issue.Title))
}
if issue.Assignee != "" {
buf.WriteString(fmt.Sprintf(" Assignee: %s\n", issue.Assignee))
}
if len(labels) > 0 {
buf.WriteString(fmt.Sprintf(" Labels: %v\n", labels))
}
buf.WriteString("\n")
}
// formatAgentIssue formats a single issue in ultra-compact agent mode format
// Output: just "ID: Title" - no colors, no emojis, no brackets
func formatAgentIssue(buf *strings.Builder, issue *types.Issue) {
buf.WriteString(fmt.Sprintf("%s: %s\n", issue.ID, issue.Title))
}
// formatIssueCompact formats a single issue in compact format to a buffer
// Uses status icons for better scanability - consistent with bd graph
// Format: [icon] [pin] ID [Priority] [Type] @assignee [labels] - Title
func formatIssueCompact(buf *strings.Builder, issue *types.Issue, labels []string) {
labelsStr := ""
if len(labels) > 0 {
labelsStr = fmt.Sprintf(" %v", labels)
}
assigneeStr := ""
if issue.Assignee != "" {
assigneeStr = fmt.Sprintf(" @%s", issue.Assignee)
}
// Get styled status icon
statusIcon := renderStatusIcon(issue.Status)
if issue.Status == types.StatusClosed {
// Closed issues: entire line muted (fades visually)
line := fmt.Sprintf("%s %s%s [P%d] [%s]%s%s - %s",
statusIcon, pinIndicator(issue), issue.ID, issue.Priority,
issue.IssueType, assigneeStr, labelsStr, issue.Title)
buf.WriteString(ui.RenderClosedLine(line))
buf.WriteString("\n")
} else {
// Active issues: status icon + semantic colors for priority/type
buf.WriteString(fmt.Sprintf("%s %s%s [%s] [%s]%s%s - %s\n",
statusIcon,
pinIndicator(issue),
ui.RenderID(issue.ID),
ui.RenderPriority(issue.Priority),
ui.RenderType(string(issue.IssueType)),
assigneeStr, labelsStr, issue.Title))
}
}
var listCmd = &cobra.Command{
Use: "list",
GroupID: "issues",
Short: "List issues",
Run: func(cmd *cobra.Command, args []string) {
status, _ := cmd.Flags().GetString("status")
assignee, _ := cmd.Flags().GetString("assignee")
issueType, _ := cmd.Flags().GetString("type")
issueType = util.NormalizeIssueType(issueType) // Expand aliases (mr→merge-request, etc.)
limit, _ := cmd.Flags().GetInt("limit")
allFlag, _ := cmd.Flags().GetBool("all")
formatStr, _ := cmd.Flags().GetString("format")
labels, _ := cmd.Flags().GetStringSlice("label")
labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
titleSearch, _ := cmd.Flags().GetString("title")
idFilter, _ := cmd.Flags().GetString("id")
longFormat, _ := cmd.Flags().GetBool("long")
sortBy, _ := cmd.Flags().GetString("sort")
reverse, _ := cmd.Flags().GetBool("reverse")
// Pattern matching flags
titleContains, _ := cmd.Flags().GetString("title-contains")
descContains, _ := cmd.Flags().GetString("desc-contains")
notesContains, _ := cmd.Flags().GetString("notes-contains")
// Date range flags
createdAfter, _ := cmd.Flags().GetString("created-after")
createdBefore, _ := cmd.Flags().GetString("created-before")
updatedAfter, _ := cmd.Flags().GetString("updated-after")
updatedBefore, _ := cmd.Flags().GetString("updated-before")
closedAfter, _ := cmd.Flags().GetString("closed-after")
closedBefore, _ := cmd.Flags().GetString("closed-before")
// Empty/null check flags
emptyDesc, _ := cmd.Flags().GetBool("empty-description")
noAssignee, _ := cmd.Flags().GetBool("no-assignee")
noLabels, _ := cmd.Flags().GetBool("no-labels")
// Priority range flags
priorityMinStr, _ := cmd.Flags().GetString("priority-min")
priorityMaxStr, _ := cmd.Flags().GetString("priority-max")
// Pinned filtering flags
pinnedFlag, _ := cmd.Flags().GetBool("pinned")
noPinnedFlag, _ := cmd.Flags().GetBool("no-pinned")
// Template filtering
includeTemplates, _ := cmd.Flags().GetBool("include-templates")
// Gate filtering (bd-7zka.2)
includeGates, _ := cmd.Flags().GetBool("include-gates")
// Parent filtering (--filter-parent is alias for --parent)
parentID, _ := cmd.Flags().GetString("parent")
if parentID == "" {
parentID, _ = cmd.Flags().GetString("filter-parent")
}
// Molecule type filtering
molTypeStr, _ := cmd.Flags().GetString("mol-type")
var molType *types.MolType
if molTypeStr != "" {
mt := types.MolType(molTypeStr)
if !mt.IsValid() {
fmt.Fprintf(os.Stderr, "Error: invalid mol-type %q (must be swarm, patrol, or work)\n", molTypeStr)
os.Exit(1)
}
molType = &mt
}
// Time-based scheduling filters (GH#820)
deferredFlag, _ := cmd.Flags().GetBool("deferred")
deferAfter, _ := cmd.Flags().GetString("defer-after")
deferBefore, _ := cmd.Flags().GetString("defer-before")
dueAfter, _ := cmd.Flags().GetString("due-after")
dueBefore, _ := cmd.Flags().GetString("due-before")
overdueFlag, _ := cmd.Flags().GetBool("overdue")
// Pretty and watch flags (GH#654)
prettyFormat, _ := cmd.Flags().GetBool("pretty")
treeFormat, _ := cmd.Flags().GetBool("tree")
prettyFormat = prettyFormat || treeFormat // --tree is alias for --pretty
watchMode, _ := cmd.Flags().GetBool("watch")
// Pager control (bd-jdz3)
noPager, _ := cmd.Flags().GetBool("no-pager")
// Ready filter (bd-ihu31)
readyFlag, _ := cmd.Flags().GetBool("ready")
// Watch mode implies pretty format
if watchMode {
prettyFormat = true
}
// Use global jsonOutput set by PersistentPreRun
// Normalize labels: trim, dedupe, remove empty
labels = util.NormalizeLabels(labels)
labelsAny = util.NormalizeLabels(labelsAny)
// Apply directory-aware label scoping if no labels explicitly provided (GH#541)
if len(labels) == 0 && len(labelsAny) == 0 {
if dirLabels := config.GetDirectoryLabels(); len(dirLabels) > 0 {
labelsAny = dirLabels
}
}
// Handle limit: --limit 0 means unlimited (explicit override)
// Otherwise use the value (default 50 or user-specified)
// Agent mode uses lower default (20) for context efficiency
effectiveLimit := limit
if cmd.Flags().Changed("limit") && limit == 0 {
effectiveLimit = 0 // Explicit unlimited
} else if !cmd.Flags().Changed("limit") && ui.IsAgentMode() {
effectiveLimit = 20 // Agent mode default
}
filter := types.IssueFilter{
Limit: effectiveLimit,
}
// --ready flag: show only open issues (excludes hooked/in_progress/blocked/deferred) (bd-ihu31)
if readyFlag {
s := types.StatusOpen
filter.Status = &s
} else if status != "" && status != "all" {
s := types.Status(status)
filter.Status = &s
}
// Default to non-closed issues unless --all or explicit --status (GH#788)
if status == "" && !allFlag && !readyFlag {
filter.ExcludeStatus = []types.Status{types.StatusClosed}
}
// Use Changed() to properly handle P0 (priority=0)
if cmd.Flags().Changed("priority") {
priorityStr, _ := cmd.Flags().GetString("priority")
priority, err := validation.ValidatePriority(priorityStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
filter.Priority = &priority
}
if assignee != "" {
filter.Assignee = &assignee
}
if issueType != "" {
t := types.IssueType(issueType)
filter.IssueType = &t
}
if len(labels) > 0 {
filter.Labels = labels
}
if len(labelsAny) > 0 {
filter.LabelsAny = labelsAny
}
if titleSearch != "" {
filter.TitleSearch = titleSearch
}
if idFilter != "" {
ids := util.NormalizeLabels(strings.Split(idFilter, ","))
if len(ids) > 0 {
filter.IDs = ids
}
}
// Pattern matching
if titleContains != "" {
filter.TitleContains = titleContains
}
if descContains != "" {
filter.DescriptionContains = descContains
}
if notesContains != "" {
filter.NotesContains = notesContains
}
// Date ranges
if createdAfter != "" {
t, err := parseTimeFlag(createdAfter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing --created-after: %v\n", err)
os.Exit(1)
}
filter.CreatedAfter = &t
}
if createdBefore != "" {
t, err := parseTimeFlag(createdBefore)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing --created-before: %v\n", err)
os.Exit(1)
}
filter.CreatedBefore = &t
}
if updatedAfter != "" {
t, err := parseTimeFlag(updatedAfter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing --updated-after: %v\n", err)
os.Exit(1)
}
filter.UpdatedAfter = &t
}
if updatedBefore != "" {
t, err := parseTimeFlag(updatedBefore)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing --updated-before: %v\n", err)
os.Exit(1)
}
filter.UpdatedBefore = &t
}
if closedAfter != "" {
t, err := parseTimeFlag(closedAfter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing --closed-after: %v\n", err)
os.Exit(1)
}
filter.ClosedAfter = &t
}
if closedBefore != "" {
t, err := parseTimeFlag(closedBefore)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing --closed-before: %v\n", err)
os.Exit(1)
}
filter.ClosedBefore = &t
}
// Empty/null checks
if emptyDesc {
filter.EmptyDescription = true
}
if noAssignee {
filter.NoAssignee = true
}
if noLabels {
filter.NoLabels = true
}
// Priority ranges
if cmd.Flags().Changed("priority-min") {
priorityMin, err := validation.ValidatePriority(priorityMinStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing --priority-min: %v\n", err)
os.Exit(1)
}
filter.PriorityMin = &priorityMin
}
if cmd.Flags().Changed("priority-max") {
priorityMax, err := validation.ValidatePriority(priorityMaxStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing --priority-max: %v\n", err)
os.Exit(1)
}
filter.PriorityMax = &priorityMax
}
// Pinned filtering: --pinned and --no-pinned are mutually exclusive
if pinnedFlag && noPinnedFlag {
fmt.Fprintf(os.Stderr, "Error: --pinned and --no-pinned are mutually exclusive\n")
os.Exit(1)
}
if pinnedFlag {
pinned := true
filter.Pinned = &pinned
} else if noPinnedFlag {
pinned := false
filter.Pinned = &pinned
}
// Template filtering: exclude templates by default
// Use --include-templates to show all issues including templates
if !includeTemplates {
isTemplate := false
filter.IsTemplate = &isTemplate
}
// Gate filtering: exclude gate issues by default (bd-7zka.2)
// Use --include-gates or --type gate to show gate issues
if !includeGates && issueType != "gate" {
filter.ExcludeTypes = append(filter.ExcludeTypes, "gate")
}
// Parent filtering: filter children by parent issue
if parentID != "" {
filter.ParentID = &parentID
}
// Molecule type filtering
if molType != nil {
filter.MolType = molType
}
// Time-based scheduling filters (GH#820)
if deferredFlag {
filter.Deferred = true
}
if deferAfter != "" {
t, err := parseTimeFlag(deferAfter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing --defer-after: %v\n", err)
os.Exit(1)
}
filter.DeferAfter = &t
}
if deferBefore != "" {
t, err := parseTimeFlag(deferBefore)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing --defer-before: %v\n", err)
os.Exit(1)
}
filter.DeferBefore = &t
}
if dueAfter != "" {
t, err := parseTimeFlag(dueAfter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing --due-after: %v\n", err)
os.Exit(1)
}
filter.DueAfter = &t
}
if dueBefore != "" {
t, err := parseTimeFlag(dueBefore)
if err != nil {
fmt.Fprintf(os.Stderr, "Error parsing --due-before: %v\n", err)
os.Exit(1)
}
filter.DueBefore = &t
}
if overdueFlag {
filter.Overdue = true
}
// Check database freshness before reading
// Skip check when using daemon (daemon auto-imports on staleness)
ctx := rootCtx
if daemonClient == nil {
if err := ensureDatabaseFresh(ctx); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
// If daemon is running, use RPC
if daemonClient != nil {
// Determine effective status for RPC (--ready overrides to "open")
effectiveStatus := status
if readyFlag {
effectiveStatus = "open"
}
listArgs := &rpc.ListArgs{
Status: effectiveStatus,
IssueType: issueType,
Assignee: assignee,
Limit: effectiveLimit,
}
if cmd.Flags().Changed("priority") {
priorityStr, _ := cmd.Flags().GetString("priority")
priority, err := validation.ValidatePriority(priorityStr)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
listArgs.Priority = &priority
}
if len(labels) > 0 {
listArgs.Labels = labels
}
if len(labelsAny) > 0 {
listArgs.LabelsAny = labelsAny
}
// Forward title search via Query field (searches title/description/id)
if titleSearch != "" {
listArgs.Query = titleSearch
}
if len(filter.IDs) > 0 {
listArgs.IDs = filter.IDs
}
// Pattern matching
listArgs.TitleContains = titleContains
listArgs.DescriptionContains = descContains
listArgs.NotesContains = notesContains
// Date ranges
if filter.CreatedAfter != nil {
listArgs.CreatedAfter = filter.CreatedAfter.Format(time.RFC3339)
}
if filter.CreatedBefore != nil {
listArgs.CreatedBefore = filter.CreatedBefore.Format(time.RFC3339)
}
if filter.UpdatedAfter != nil {
listArgs.UpdatedAfter = filter.UpdatedAfter.Format(time.RFC3339)
}
if filter.UpdatedBefore != nil {
listArgs.UpdatedBefore = filter.UpdatedBefore.Format(time.RFC3339)
}
if filter.ClosedAfter != nil {
listArgs.ClosedAfter = filter.ClosedAfter.Format(time.RFC3339)
}
if filter.ClosedBefore != nil {
listArgs.ClosedBefore = filter.ClosedBefore.Format(time.RFC3339)
}
// Empty/null checks
listArgs.EmptyDescription = filter.EmptyDescription
listArgs.NoAssignee = filter.NoAssignee
listArgs.NoLabels = filter.NoLabels
// Priority range
listArgs.PriorityMin = filter.PriorityMin
listArgs.PriorityMax = filter.PriorityMax
// Pinned filtering
listArgs.Pinned = filter.Pinned
// Template filtering
listArgs.IncludeTemplates = includeTemplates
// Parent filtering
listArgs.ParentID = parentID
// Status exclusion (GH#788)
if len(filter.ExcludeStatus) > 0 {
for _, s := range filter.ExcludeStatus {
listArgs.ExcludeStatus = append(listArgs.ExcludeStatus, string(s))
}
}
// Type exclusion (bd-7zka.2)
if len(filter.ExcludeTypes) > 0 {
for _, t := range filter.ExcludeTypes {
listArgs.ExcludeTypes = append(listArgs.ExcludeTypes, string(t))
}
}
// Time-based scheduling filters (GH#820)
listArgs.Deferred = filter.Deferred
if filter.DeferAfter != nil {
listArgs.DeferAfter = filter.DeferAfter.Format(time.RFC3339)
}
if filter.DeferBefore != nil {
listArgs.DeferBefore = filter.DeferBefore.Format(time.RFC3339)
}
if filter.DueAfter != nil {
listArgs.DueAfter = filter.DueAfter.Format(time.RFC3339)
}
if filter.DueBefore != nil {
listArgs.DueBefore = filter.DueBefore.Format(time.RFC3339)
}
listArgs.Overdue = filter.Overdue
// Pass through --allow-stale flag for resilient queries (bd-dpkdm)
listArgs.AllowStale = allowStale
resp, err := daemonClient.List(listArgs)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if jsonOutput {
// For JSON output, preserve the full response with counts
var issuesWithCounts []*types.IssueWithCounts
if err := json.Unmarshal(resp.Data, &issuesWithCounts); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
outputJSON(issuesWithCounts)
return
}
// Show upgrade notification if needed
maybeShowUpgradeNotification()
var issues []*types.Issue
if err := json.Unmarshal(resp.Data, &issues); err != nil {
fmt.Fprintf(os.Stderr, "Error parsing response: %v\n", err)
os.Exit(1)
}
// Apply sorting
sortIssues(issues, sortBy, reverse)
// Handle watch mode (GH#654)
// Watch mode requires direct store access for file watching
if watchMode {
if err := ensureDirectMode("watch mode requires direct database access"); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
watchIssues(ctx, store, filter, sortBy, reverse)
return
}
// Handle pretty/tree format (GH#654)
if prettyFormat {
// Special handling for --tree --parent combination (hierarchical descendants)
if parentID != "" {
treeIssues, err := getHierarchicalChildren(ctx, store, dbPath, lockTimeout, parentID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if len(treeIssues) == 0 {
fmt.Printf("Issue '%s' has no children\n", parentID)
return
}
// Load all dependencies for tree building
var allDeps map[string][]*types.Dependency
err = withStorage(ctx, store, dbPath, lockTimeout, func(s storage.Storage) error {
allDeps, err = s.GetAllDependencyRecords(ctx)
return err
})
if err != nil {
fmt.Fprintf(os.Stderr, "Error getting dependencies for display: %v\n", err)
os.Exit(1)
}
displayPrettyListWithDeps(treeIssues, false, allDeps)
return
}
// Regular tree display (no parent filter)
// Load dependencies for tree structure
// In daemon mode, open a read-only store to get dependencies
var allDeps map[string][]*types.Dependency
if store != nil {
allDeps, _ = store.GetAllDependencyRecords(ctx)
} else if dbPath != "" {
// Daemon mode: open read-only connection for tree deps
if roStore, err := sqlite.NewReadOnlyWithTimeout(ctx, dbPath, lockTimeout); err == nil {
allDeps, _ = roStore.GetAllDependencyRecords(ctx)
_ = roStore.Close()
}
}
displayPrettyListWithDeps(issues, false, allDeps)
if effectiveLimit > 0 && len(issues) == effectiveLimit {
fmt.Fprintf(os.Stderr, "\nShowing %d issues (use --limit 0 for all)\n", effectiveLimit)
}
return
}
// Build output in buffer for pager support (bd-jdz3)
var buf strings.Builder
if ui.IsAgentMode() {
// Agent mode: ultra-compact, no colors, no pager
for _, issue := range issues {
formatAgentIssue(&buf, issue)
}
fmt.Print(buf.String())
return
} else if longFormat {
// Long format: multi-line with details
buf.WriteString(fmt.Sprintf("\nFound %d issues:\n\n", len(issues)))
for _, issue := range issues {
formatIssueLong(&buf, issue, issue.Labels)
}
} else {
// Compact format: one line per issue
for _, issue := range issues {
formatIssueCompact(&buf, issue, issue.Labels)
}
}
// Output with pager support
if err := ui.ToPager(buf.String(), ui.PagerOptions{NoPager: noPager}); err != nil {
if _, writeErr := fmt.Fprint(os.Stdout, buf.String()); writeErr != nil {
fmt.Fprintf(os.Stderr, "Error writing output: %v\n", writeErr)
}
}
// Show truncation hint if we hit the limit (GH#788)
if effectiveLimit > 0 && len(issues) == effectiveLimit {
fmt.Fprintf(os.Stderr, "\nShowing %d issues (use --limit 0 for all)\n", effectiveLimit)
}
return
}
// Direct mode
// ctx already created above for staleness check
issues, err := store.SearchIssues(ctx, "", filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
// If no issues found, check if git has issues and auto-import
if len(issues) == 0 {
if checkAndAutoImport(ctx, store) {
// Re-run the query after import
issues, err = store.SearchIssues(ctx, "", filter)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
}
}
// Apply sorting
sortIssues(issues, sortBy, reverse)
// Handle watch mode (GH#654) - must be before other output modes
if watchMode {
watchIssues(ctx, store, filter, sortBy, reverse)
return
}
// Handle pretty format (GH#654)
if prettyFormat {
// Special handling for --tree --parent combination (hierarchical descendants)
if parentID != "" {
treeIssues, err := getHierarchicalChildren(ctx, store, "", 0, parentID)
if err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
if len(treeIssues) == 0 {
fmt.Printf("Issue '%s' has no children\n", parentID)
return
}
// Load dependencies for tree structure
allDeps, _ := store.GetAllDependencyRecords(ctx)
displayPrettyListWithDeps(treeIssues, false, allDeps)
return
}
// Regular tree display (no parent filter)
// Load dependencies for tree structure
allDeps, _ := store.GetAllDependencyRecords(ctx)
displayPrettyListWithDeps(issues, false, allDeps)
// Show truncation hint if we hit the limit (GH#788)
if effectiveLimit > 0 && len(issues) == effectiveLimit {
fmt.Fprintf(os.Stderr, "\nShowing %d issues (use --limit 0 for all)\n", effectiveLimit)
}
return
}
// Handle format flag
if formatStr != "" {
if err := outputFormattedList(ctx, store, issues, formatStr); err != nil {
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
os.Exit(1)
}
return
}
if jsonOutput {
// Get labels and dependency counts in bulk (single query instead of N queries)
issueIDs := make([]string, len(issues))
for i, issue := range issues {
issueIDs[i] = issue.ID
}
labelsMap, _ := store.GetLabelsForIssues(ctx, issueIDs)
depCounts, _ := store.GetDependencyCounts(ctx, issueIDs)
allDeps, _ := store.GetDependencyRecordsForIssues(ctx, issueIDs)
// Populate labels and dependencies for JSON output
for _, issue := range issues {
issue.Labels = labelsMap[issue.ID]
issue.Dependencies = allDeps[issue.ID]
}
// Build response with counts
issuesWithCounts := make([]*types.IssueWithCounts, len(issues))
for i, issue := range issues {
counts := depCounts[issue.ID]
if counts == nil {
counts = &types.DependencyCounts{DependencyCount: 0, DependentCount: 0}
}
issuesWithCounts[i] = &types.IssueWithCounts{
Issue: issue,
DependencyCount: counts.DependencyCount,
DependentCount: counts.DependentCount,
}
}
outputJSON(issuesWithCounts)
return
}
// Show upgrade notification if needed
maybeShowUpgradeNotification()
// Load labels in bulk for display
issueIDs := make([]string, len(issues))
for i, issue := range issues {
issueIDs[i] = issue.ID
}
labelsMap, _ := store.GetLabelsForIssues(ctx, issueIDs)
// Build output in buffer for pager support (bd-jdz3)
var buf strings.Builder
if ui.IsAgentMode() {
// Agent mode: ultra-compact, no colors, no pager
for _, issue := range issues {
formatAgentIssue(&buf, issue)
}
fmt.Print(buf.String())
return
} else if longFormat {
// Long format: multi-line with details
buf.WriteString(fmt.Sprintf("\nFound %d issues:\n\n", len(issues)))
for _, issue := range issues {
labels := labelsMap[issue.ID]
formatIssueLong(&buf, issue, labels)
}
} else {
// Compact format: one line per issue
for _, issue := range issues {
labels := labelsMap[issue.ID]
formatIssueCompact(&buf, issue, labels)
}
}
// Output with pager support
if err := ui.ToPager(buf.String(), ui.PagerOptions{NoPager: noPager}); err != nil {
if _, writeErr := fmt.Fprint(os.Stdout, buf.String()); writeErr != nil {
fmt.Fprintf(os.Stderr, "Error writing output: %v\n", writeErr)
}
}
// Show truncation hint if we hit the limit (GH#788)
if effectiveLimit > 0 && len(issues) == effectiveLimit {
fmt.Fprintf(os.Stderr, "\nShowing %d issues (use --limit 0 for all)\n", effectiveLimit)
}
// Show tip after successful list (direct mode only)
maybeShowTip(store)
},
}
func init() {
listCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)")
registerPriorityFlag(listCmd, "")
listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule, gate, convoy). Aliases: mr→merge-request, feat→feature, mol→molecule")
listCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL). Can combine with --label-any")
listCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE). Can combine with --label")
listCmd.Flags().String("title", "", "Filter by title text (case-insensitive substring match)")
listCmd.Flags().String("id", "", "Filter by specific issue IDs (comma-separated, e.g., bd-1,bd-5,bd-10)")
listCmd.Flags().IntP("limit", "n", 50, "Limit results (default 50, use 0 for unlimited)")
listCmd.Flags().String("format", "", "Output format: 'digraph' (for golang.org/x/tools/cmd/digraph), 'dot' (Graphviz), or Go template")
listCmd.Flags().Bool("all", false, "Show all issues including closed (overrides default filter)")
listCmd.Flags().Bool("long", false, "Show detailed multi-line output for each issue")
listCmd.Flags().String("sort", "", "Sort by field: priority, created, updated, closed, status, id, title, type, assignee")
listCmd.Flags().BoolP("reverse", "r", false, "Reverse sort order")
// Pattern matching
listCmd.Flags().String("title-contains", "", "Filter by title substring (case-insensitive)")
listCmd.Flags().String("desc-contains", "", "Filter by description substring (case-insensitive)")
listCmd.Flags().String("notes-contains", "", "Filter by notes substring (case-insensitive)")
// Date ranges
listCmd.Flags().String("created-after", "", "Filter issues created after date (YYYY-MM-DD or RFC3339)")
listCmd.Flags().String("created-before", "", "Filter issues created before date (YYYY-MM-DD or RFC3339)")
listCmd.Flags().String("updated-after", "", "Filter issues updated after date (YYYY-MM-DD or RFC3339)")
listCmd.Flags().String("updated-before", "", "Filter issues updated before date (YYYY-MM-DD or RFC3339)")
listCmd.Flags().String("closed-after", "", "Filter issues closed after date (YYYY-MM-DD or RFC3339)")
listCmd.Flags().String("closed-before", "", "Filter issues closed before date (YYYY-MM-DD or RFC3339)")
// Empty/null checks
listCmd.Flags().Bool("empty-description", false, "Filter issues with empty or missing description")
listCmd.Flags().Bool("no-assignee", false, "Filter issues with no assignee")
listCmd.Flags().Bool("no-labels", false, "Filter issues with no labels")
// Priority ranges
listCmd.Flags().String("priority-min", "", "Filter by minimum priority (inclusive, 0-4 or P0-P4)")
listCmd.Flags().String("priority-max", "", "Filter by maximum priority (inclusive, 0-4 or P0-P4)")
// Pinned filtering
listCmd.Flags().Bool("pinned", false, "Show only pinned issues")
listCmd.Flags().Bool("no-pinned", false, "Exclude pinned issues")
// Template filtering: exclude templates by default
listCmd.Flags().Bool("include-templates", false, "Include template molecules in output")
// Gate filtering: exclude gate issues by default (bd-7zka.2)
listCmd.Flags().Bool("include-gates", false, "Include gate issues in output (normally hidden)")
// Parent filtering: filter children by parent issue
listCmd.Flags().String("parent", "", "Filter by parent issue ID (shows children of specified issue)")
listCmd.Flags().String("filter-parent", "", "Alias for --parent")
// Molecule type filtering
listCmd.Flags().String("mol-type", "", "Filter by molecule type: swarm, patrol, or work")
// Time-based scheduling filters (GH#820)
listCmd.Flags().Bool("deferred", false, "Show only issues with defer_until set")
listCmd.Flags().String("defer-after", "", "Filter issues deferred after date (supports relative: +6h, tomorrow)")
listCmd.Flags().String("defer-before", "", "Filter issues deferred before date (supports relative: +6h, tomorrow)")
listCmd.Flags().String("due-after", "", "Filter issues due after date (supports relative: +6h, tomorrow)")
listCmd.Flags().String("due-before", "", "Filter issues due before date (supports relative: +6h, tomorrow)")
listCmd.Flags().Bool("overdue", false, "Show only issues with due_at in the past (not closed)")
// Pretty and watch flags (GH#654)
listCmd.Flags().Bool("pretty", false, "Display issues in a tree format with status/priority symbols")
listCmd.Flags().Bool("tree", false, "Alias for --pretty: hierarchical tree format")
listCmd.Flags().BoolP("watch", "w", false, "Watch for changes and auto-update display (implies --pretty)")
// Pager control (bd-jdz3)
listCmd.Flags().Bool("no-pager", false, "Disable pager output")
// Ready filter: show only issues ready to be worked on (bd-ihu31)
listCmd.Flags().Bool("ready", false, "Show only ready issues (status=open, excludes hooked/in_progress/blocked/deferred)")
// Note: --json flag is defined as a persistent flag in main.go, not here
rootCmd.AddCommand(listCmd)
}
// outputDotFormat outputs issues in Graphviz DOT format
func outputDotFormat(ctx context.Context, store storage.Storage, issues []*types.Issue) error {
fmt.Println("digraph dependencies {")
fmt.Println(" rankdir=TB;")
fmt.Println(" node [shape=box, style=rounded];")
fmt.Println()
// Build map of all issues for quick lookup
issueMap := make(map[string]*types.Issue)
for _, issue := range issues {
issueMap[issue.ID] = issue
}
// Output nodes with labels including ID, type, priority, and status
for _, issue := range issues {
// Build label with ID, type, priority, and title (using actual newlines)
label := fmt.Sprintf("%s\n[%s P%d]\n%s\n(%s)",
issue.ID,
issue.IssueType,
issue.Priority,
issue.Title,
issue.Status)
// Color by status only - keep it simple
fillColor := "white"
fontColor := "black"
switch issue.Status {
case "closed":
fillColor = "lightgray"
fontColor = "dimgray"
case "in_progress":
fillColor = "lightyellow"
case "blocked":
fillColor = "lightcoral"
}
fmt.Printf(" %q [label=%q, style=\"rounded,filled\", fillcolor=%q, fontcolor=%q];\n",
issue.ID, label, fillColor, fontColor)
}
fmt.Println()
// Output edges with labels for dependency type
for _, issue := range issues {
deps, err := store.GetDependencyRecords(ctx, issue.ID)
if err != nil {
continue
}
for _, dep := range deps {
// Only output edges where both nodes are in the filtered list
if issueMap[dep.DependsOnID] != nil {
// Color code by dependency type
color := "black"
style := "solid"
switch dep.Type {
case "blocks":
color = "red"
style = "bold"
case "parent-child":
color = "blue"
case "discovered-from":
color = "green"
style = "dashed"
case "related":
color = "gray"
style = "dashed"
}
fmt.Printf(" %q -> %q [label=%q, color=%s, style=%s];\n",
issue.ID, dep.DependsOnID, dep.Type, color, style)
}
}
}
fmt.Println("}")
return nil
}
// outputFormattedList outputs issues in a custom format (preset or Go template)
func outputFormattedList(ctx context.Context, store storage.Storage, issues []*types.Issue, formatStr string) error {
// Handle special 'dot' format (Graphviz output)
if formatStr == "dot" {
return outputDotFormat(ctx, store, issues)
}
// Built-in format presets
presets := map[string]string{
"digraph": "{{.IssueID}} {{.DependsOnID}}",
}
// Check if it's a preset
templateStr, isPreset := presets[formatStr]
if !isPreset {
templateStr = formatStr
}
// Parse template
tmpl, err := template.New("format").Parse(templateStr)
if err != nil {
return fmt.Errorf("invalid format template: %w", err)
}
// Build map of all issues for quick lookup
issueMap := make(map[string]bool)
for _, issue := range issues {
issueMap[issue.ID] = true
}
// For each issue, output its dependencies using the template
for _, issue := range issues {
deps, err := store.GetDependencyRecords(ctx, issue.ID)
if err != nil {
continue
}
for _, dep := range deps {
// Only output edges where both nodes are in the filtered list
if issueMap[dep.DependsOnID] {
// Template data includes both issue and dependency info
data := map[string]interface{}{
"IssueID": issue.ID,
"DependsOnID": dep.DependsOnID,
"Type": dep.Type,
"Issue": issue,
"Dependency": dep,
}
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return fmt.Errorf("template execution error: %w", err)
}
fmt.Println(buf.String())
}
}
}
return nil
}