Add convenience aliases for common type names: - mr → merge-request - feat → feature - mol → molecule Applied to bd list, bd ready, and bd export commands. Case-insensitive matching (MR, Mr, mr all work). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1137 lines
34 KiB
Go
1137 lines
34 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/types"
|
|
"github.com/steveyegge/beads/internal/ui"
|
|
"github.com/steveyegge/beads/internal/util"
|
|
"github.com/steveyegge/beads/internal/validation"
|
|
)
|
|
|
|
// parseTimeFlag parses time strings in multiple formats
|
|
func parseTimeFlag(s string) (time.Time, error) {
|
|
formats := []string{
|
|
time.RFC3339,
|
|
"2006-01-02",
|
|
"2006-01-02T15:04:05",
|
|
"2006-01-02 15:04:05",
|
|
}
|
|
|
|
for _, format := range formats {
|
|
if t, err := time.Parse(format, s); err == nil {
|
|
return t, nil
|
|
}
|
|
}
|
|
|
|
return time.Time{}, fmt.Errorf("unable to parse time %q (try formats: 2006-01-02, 2006-01-02T15:04:05, or RFC3339)", s)
|
|
}
|
|
|
|
// pinIndicator returns a pushpin emoji prefix for pinned issues
|
|
func pinIndicator(issue *types.Issue) string {
|
|
if issue.Pinned {
|
|
return "📌 "
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// Priority symbols for pretty output (GH#654)
|
|
var prioritySymbols = map[int]string{
|
|
0: "🔴", // P0 - Critical
|
|
1: "🟠", // P1 - High
|
|
2: "🟡", // P2 - Medium (default)
|
|
3: "🔵", // P3 - Low
|
|
4: "⚪", // P4 - Lowest
|
|
}
|
|
|
|
// Status symbols for pretty output (GH#654)
|
|
var statusSymbols = map[types.Status]string{
|
|
"open": "○",
|
|
"in_progress": "◐",
|
|
"blocked": "⊗",
|
|
"deferred": "◇",
|
|
"closed": "●",
|
|
}
|
|
|
|
// formatPrettyIssue formats a single issue for pretty output
|
|
func formatPrettyIssue(issue *types.Issue) string {
|
|
prioritySym := prioritySymbols[issue.Priority]
|
|
if prioritySym == "" {
|
|
prioritySym = "⚪"
|
|
}
|
|
statusSym := statusSymbols[issue.Status]
|
|
if statusSym == "" {
|
|
statusSym = "○"
|
|
}
|
|
|
|
typeBadge := ""
|
|
switch issue.IssueType {
|
|
case "epic":
|
|
typeBadge = "[EPIC] "
|
|
case "feature":
|
|
typeBadge = "[FEAT] "
|
|
case "bug":
|
|
typeBadge = "[BUG] "
|
|
}
|
|
|
|
return fmt.Sprintf("%s %s %s - %s%s", statusSym, prioritySym, issue.ID, typeBadge, issue.Title)
|
|
}
|
|
|
|
// buildIssueTree builds parent-child tree structure from issues
|
|
func buildIssueTree(issues []*types.Issue) (roots []*types.Issue, childrenMap map[string][]*types.Issue) {
|
|
issueMap := make(map[string]*types.Issue)
|
|
childrenMap = make(map[string][]*types.Issue)
|
|
|
|
for _, issue := range issues {
|
|
issueMap[issue.ID] = issue
|
|
}
|
|
|
|
for _, issue := range issues {
|
|
// Check if this is a hierarchical subtask (e.g., "parent.1")
|
|
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)
|
|
continue
|
|
}
|
|
}
|
|
roots = append(roots, issue)
|
|
}
|
|
|
|
return roots, childrenMap
|
|
}
|
|
|
|
// printPrettyTree recursively prints the issue tree
|
|
func printPrettyTree(childrenMap map[string][]*types.Issue, parentID string, prefix string) {
|
|
children := childrenMap[parentID]
|
|
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)
|
|
func displayPrettyList(issues []*types.Issue, showHeader bool) {
|
|
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 := buildIssueTree(issues)
|
|
|
|
for i, issue := range roots {
|
|
fmt.Println(formatPrettyIssue(issue))
|
|
printPrettyTree(childrenMap, issue.ID, "")
|
|
if i < len(roots)-1 {
|
|
fmt.Println()
|
|
}
|
|
}
|
|
|
|
// 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("Legend: ○ open | ◐ in progress | ⊗ blocked | 🔴 P0 | 🟠 P1 | 🟡 P2 | 🔵 P3 | ⚪ P4")
|
|
}
|
|
|
|
// 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
|
|
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)
|
|
}
|
|
status := string(issue.Status)
|
|
if status == "closed" {
|
|
line := fmt.Sprintf("%s%s [P%d] [%s] %s%s%s - %s",
|
|
pinIndicator(issue), issue.ID, issue.Priority,
|
|
issue.IssueType, status, assigneeStr, labelsStr, issue.Title)
|
|
buf.WriteString(ui.RenderClosedLine(line))
|
|
buf.WriteString("\n")
|
|
} else {
|
|
buf.WriteString(fmt.Sprintf("%s%s [%s] [%s] %s%s%s - %s\n",
|
|
pinIndicator(issue),
|
|
ui.RenderID(issue.ID),
|
|
ui.RenderPriority(issue.Priority),
|
|
ui.RenderType(string(issue.IssueType)),
|
|
ui.RenderStatus(status),
|
|
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
|
|
parentID, _ := cmd.Flags().GetString("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
|
|
}
|
|
|
|
// Pretty and watch flags (GH#654)
|
|
prettyFormat, _ := cmd.Flags().GetBool("pretty")
|
|
watchMode, _ := cmd.Flags().GetBool("watch")
|
|
|
|
// Pager control (bd-jdz3)
|
|
noPager, _ := cmd.Flags().GetBool("no-pager")
|
|
|
|
// 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,
|
|
}
|
|
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 {
|
|
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
|
|
}
|
|
|
|
// 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 {
|
|
listArgs := &rpc.ListArgs{
|
|
Status: status,
|
|
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))
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
// 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 {
|
|
displayPrettyList(issues, false)
|
|
// 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)")
|
|
|
|
// Molecule type filtering
|
|
listCmd.Flags().String("mol-type", "", "Filter by molecule type: swarm, patrol, or work")
|
|
|
|
// Pretty and watch flags (GH#654)
|
|
listCmd.Flags().Bool("pretty", false, "Display issues in a tree format with status/priority symbols")
|
|
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")
|
|
|
|
// 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
|
|
}
|