Files
beads/cmd/bd/list.go
Oliver Jägle d929c8f974 feat: add hierarchical tree display for --tree --parent combination (#1211)
Motivation:
The existing --parent flag only shows direct children in a flat list,
but users often need to see the complete hierarchy including grandchildren
and deeper levels. This limitation made it difficult to understand the
full scope of work under an epic or parent issue.

Key changes:
- Enhanced list command to detect --tree --parent combination
- Implemented recursive parent filtering instead of GetDependencyTree
- Added DRY refactoring with withStorage() and getHierarchicalChildren() helpers
- Eliminated duplication between daemon and direct modes
- Added comprehensive test coverage with TestHierarchicalChildren
- Fixed cross-repository compatibility issues

Side-effects:
- No breaking changes: existing --parent behavior unchanged
- --tree --parent now shows hierarchical tree instead of flat list
- Parent issue is included as root of the displayed tree
- Works consistently across all repositories and storage modes
- Improved code maintainability with DRY architecture
- Better test coverage ensures reliability and prevents regressions
2026-01-20 14:06:17 -08:00

1461 lines
46 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)
}
}
return roots, childrenMap
}
// 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 (ascending: P0 before P1 before P2...)
slices.SortFunc(children, func(a, b *types.Issue) int {
return cmp.Compare(a.Priority, b.Priority)
})
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, types.TypeGate)
}
// 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)
if watchMode {
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)
// Populate labels for JSON output
for _, issue := range issues {
issue.Labels = labelsMap[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
}