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>
1487 lines
47 KiB
Go
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
|
|
}
|