package main import ( "encoding/json" "fmt" "os" "strings" "time" "github.com/spf13/cobra" "github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/util" "github.com/steveyegge/beads/internal/validation" ) var searchCmd = &cobra.Command{ Use: "search [query]", GroupID: "issues", Short: "Search issues by text query", Long: `Search issues across title, description, and ID. Examples: bd search "authentication bug" bd search "login" --status open bd search "database" --label backend --limit 10 bd search --query "performance" --assignee alice bd search "bd-5q" # Search by partial ID bd search "security" --priority-min 0 --priority-max 2 bd search "bug" --created-after 2025-01-01 bd search "refactor" --updated-after 2025-01-01 --priority-min 1 bd search "bug" --sort priority bd search "task" --sort created --reverse`, Run: func(cmd *cobra.Command, args []string) { // Get query from args or --query flag queryFlag, _ := cmd.Flags().GetString("query") var query string if len(args) > 0 { query = strings.Join(args, " ") } else if queryFlag != "" { query = queryFlag } // If no query provided, show help if query == "" { fmt.Fprintf(os.Stderr, "Error: search query is required\n") if err := cmd.Help(); err != nil { fmt.Fprintf(os.Stderr, "Error displaying help: %v\n", err) } os.Exit(1) } // Get filter flags status, _ := cmd.Flags().GetString("status") assignee, _ := cmd.Flags().GetString("assignee") issueType, _ := cmd.Flags().GetString("type") limit, _ := cmd.Flags().GetInt("limit") labels, _ := cmd.Flags().GetStringSlice("label") labelsAny, _ := cmd.Flags().GetStringSlice("label-any") longFormat, _ := cmd.Flags().GetBool("long") sortBy, _ := cmd.Flags().GetString("sort") reverse, _ := cmd.Flags().GetBool("reverse") // 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") // Priority range flags priorityMinStr, _ := cmd.Flags().GetString("priority-min") priorityMaxStr, _ := cmd.Flags().GetString("priority-max") // Normalize labels labels = util.NormalizeLabels(labels) labelsAny = util.NormalizeLabels(labelsAny) // Build filter filter := types.IssueFilter{ Limit: limit, } if status != "" && status != "all" { s := types.Status(status) filter.Status = &s } 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 } // 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 } // 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 } ctx := rootCtx // Check database freshness before reading (skip when using daemon) 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{ Query: query, // This will search title/description/id with OR logic Status: status, IssueType: issueType, Assignee: assignee, Limit: limit, } if len(labels) > 0 { listArgs.Labels = labels } if len(labelsAny) > 0 { listArgs.LabelsAny = labelsAny } // 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) } // Priority range listArgs.PriorityMin = filter.PriorityMin listArgs.PriorityMax = filter.PriorityMax resp, err := daemonClient.List(listArgs) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } if jsonOutput { 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 } 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) outputSearchResults(issues, query, longFormat) return } // Direct mode - search using store // The query parameter in SearchIssues already searches across title, description, and id issues, err := store.SearchIssues(ctx, query, 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 search after import issues, err = store.SearchIssues(ctx, query, filter) if err != nil { fmt.Fprintf(os.Stderr, "Error: %v\n", err) os.Exit(1) } } } // Apply sorting sortIssues(issues, sortBy, reverse) if jsonOutput { // Get labels and dependency counts issueIDs := make([]string, len(issues)) for i, issue := range issues { issueIDs[i] = issue.ID } labelsMap, err := store.GetLabelsForIssues(ctx, issueIDs) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to get labels: %v\n", err) labelsMap = make(map[string][]string) } depCounts, err := store.GetDependencyCounts(ctx, issueIDs) if err != nil { fmt.Fprintf(os.Stderr, "Warning: failed to get dependency counts: %v\n", err) depCounts = make(map[string]*types.DependencyCounts) } // Populate labels 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 } // Load labels for display issueIDs := make([]string, len(issues)) for i, issue := range issues { issueIDs[i] = issue.ID } labelsMap, _ := store.GetLabelsForIssues(ctx, issueIDs) for _, issue := range issues { issue.Labels = labelsMap[issue.ID] } outputSearchResults(issues, query, longFormat) }, } // outputSearchResults formats and displays search results func outputSearchResults(issues []*types.Issue, query string, longFormat bool) { if len(issues) == 0 { fmt.Printf("No issues found matching '%s'\n", query) return } if longFormat { // Long format: multi-line with details fmt.Printf("\nFound %d issues matching '%s':\n\n", len(issues), query) for _, issue := range issues { fmt.Printf("%s [P%d] [%s] %s\n", issue.ID, issue.Priority, issue.IssueType, issue.Status) fmt.Printf(" %s\n", issue.Title) if issue.Assignee != "" { fmt.Printf(" Assignee: %s\n", issue.Assignee) } if len(issue.Labels) > 0 { fmt.Printf(" Labels: %v\n", issue.Labels) } fmt.Println() } } else { // Compact format: one line per issue fmt.Printf("Found %d issues matching '%s':\n", len(issues), query) for _, issue := range issues { labelsStr := "" if len(issue.Labels) > 0 { labelsStr = fmt.Sprintf(" %v", issue.Labels) } assigneeStr := "" if issue.Assignee != "" { assigneeStr = fmt.Sprintf(" @%s", issue.Assignee) } fmt.Printf("%s [P%d] [%s] %s%s%s - %s\n", issue.ID, issue.Priority, issue.IssueType, issue.Status, assigneeStr, labelsStr, issue.Title) } } } func init() { searchCmd.Flags().String("query", "", "Search query (alternative to positional argument)") searchCmd.Flags().StringP("status", "s", "", "Filter by status (open, in_progress, blocked, deferred, closed)") searchCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") searchCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore, merge-request, molecule, gate)") searchCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (AND: must have ALL)") searchCmd.Flags().StringSlice("label-any", []string{}, "Filter by labels (OR: must have AT LEAST ONE)") searchCmd.Flags().IntP("limit", "n", 50, "Limit results (default: 50)") searchCmd.Flags().Bool("long", false, "Show detailed multi-line output for each issue") searchCmd.Flags().String("sort", "", "Sort by field: priority, created, updated, closed, status, id, title, type, assignee") searchCmd.Flags().BoolP("reverse", "r", false, "Reverse sort order") // Date range flags searchCmd.Flags().String("created-after", "", "Filter issues created after date (YYYY-MM-DD or RFC3339)") searchCmd.Flags().String("created-before", "", "Filter issues created before date (YYYY-MM-DD or RFC3339)") searchCmd.Flags().String("updated-after", "", "Filter issues updated after date (YYYY-MM-DD or RFC3339)") searchCmd.Flags().String("updated-before", "", "Filter issues updated before date (YYYY-MM-DD or RFC3339)") searchCmd.Flags().String("closed-after", "", "Filter issues closed after date (YYYY-MM-DD or RFC3339)") searchCmd.Flags().String("closed-before", "", "Filter issues closed before date (YYYY-MM-DD or RFC3339)") // Priority range flags searchCmd.Flags().String("priority-min", "", "Filter by minimum priority (inclusive, 0-4 or P0-P4)") searchCmd.Flags().String("priority-max", "", "Filter by maximum priority (inclusive, 0-4 or P0-P4)") rootCmd.AddCommand(searchCmd) }