feat: Add date and priority filters to bd search (bd-au0.5)

Adds missing date range and priority filtering to bd search for feature
parity with bd list. Part of command standardization epic (bd-au0).

Changes:
- Add date range flags: --created-after/before, --updated-after/before,
  --closed-after/before
- Add priority range flags: --priority-min, --priority-max
- Support multiple date formats (RFC3339, YYYY-MM-DD, etc.)
- Apply filters in both direct mode and daemon RPC mode
- Add comprehensive tests for new filters

Examples:
  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

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Steve Yegge
2025-11-21 22:45:47 -05:00
parent 0040e8029b
commit 787fb4e56f
2 changed files with 278 additions and 1 deletions

View File

@@ -5,11 +5,13 @@ import (
"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{
@@ -22,7 +24,10 @@ Examples:
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 "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`,
Run: func(cmd *cobra.Command, args []string) {
// Get query from args or --query flag
queryFlag, _ := cmd.Flags().GetString("query")
@@ -52,6 +57,18 @@ Examples:
labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
longFormat, _ := cmd.Flags().GetBool("long")
// 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)
@@ -83,6 +100,74 @@ Examples:
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)
@@ -111,6 +196,30 @@ Examples:
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)
@@ -252,5 +361,17 @@ func init() {
searchCmd.Flags().IntP("limit", "n", 50, "Limit results (default: 50)")
searchCmd.Flags().Bool("long", false, "Show detailed multi-line output for each issue")
// 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)
}

View File

@@ -2,11 +2,15 @@ package main
import (
"bytes"
"context"
"io"
"os"
"path/filepath"
"testing"
"time"
"github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/types"
)
// TestSearchCommand_HelpErrorHandling verifies that the search command handles
@@ -158,3 +162,155 @@ func TestSearchCommand_MissingQueryShowsHelp(t *testing.T) {
cmd.SetArgs([]string{}) // No query
_ = cmd.Execute()
}
// TestSearchWithDateAndPriorityFilters tests bd search with date range and priority filters
func TestSearchWithDateAndPriorityFilters(t *testing.T) {
tmpDir := t.TempDir()
testDB := filepath.Join(tmpDir, ".beads", "beads.db")
s := newTestStore(t, testDB)
ctx := context.Background()
now := time.Now()
yesterday := now.Add(-24 * time.Hour)
twoDaysAgo := now.Add(-48 * time.Hour)
// Create test issues with search-relevant content
issue1 := &types.Issue{
Title: "Critical security bug in auth",
Description: "Authentication bypass vulnerability",
Priority: 0,
IssueType: types.TypeBug,
Status: types.StatusOpen,
}
issue2 := &types.Issue{
Title: "Add security scanning feature",
Description: "Implement automated security checks",
Priority: 2,
IssueType: types.TypeFeature,
Status: types.StatusInProgress,
}
issue3 := &types.Issue{
Title: "Security audit task",
Description: "Review all security practices",
Priority: 3,
IssueType: types.TypeTask,
Status: types.StatusOpen,
}
for _, issue := range []*types.Issue{issue1, issue2, issue3} {
if err := s.CreateIssue(ctx, issue, "test-user"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
}
// Close issue3 to set closed_at timestamp
if err := s.CloseIssue(ctx, issue3.ID, "test-user", "Testing"); err != nil {
t.Fatalf("Failed to close issue3: %v", err)
}
t.Run("search with priority range - min", func(t *testing.T) {
minPrio := 2
results, err := s.SearchIssues(ctx, "security", types.IssueFilter{
PriorityMin: &minPrio,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 2 {
t.Errorf("Expected 2 issues matching 'security' with priority >= 2, got %d", len(results))
}
})
t.Run("search with priority range - max", func(t *testing.T) {
maxPrio := 1
results, err := s.SearchIssues(ctx, "security", types.IssueFilter{
PriorityMax: &maxPrio,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 1 {
t.Errorf("Expected 1 issue matching 'security' with priority <= 1, got %d", len(results))
}
if len(results) > 0 && results[0].ID != issue1.ID {
t.Errorf("Expected issue1, got %s", results[0].ID)
}
})
t.Run("search with priority range - min and max", func(t *testing.T) {
minPrio := 1
maxPrio := 2
results, err := s.SearchIssues(ctx, "security", types.IssueFilter{
PriorityMin: &minPrio,
PriorityMax: &maxPrio,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 1 {
t.Errorf("Expected 1 issue matching 'security' with priority 1-2, got %d", len(results))
}
if len(results) > 0 && results[0].ID != issue2.ID {
t.Errorf("Expected issue2, got %s", results[0].ID)
}
})
t.Run("search with created after", func(t *testing.T) {
results, err := s.SearchIssues(ctx, "security", types.IssueFilter{
CreatedAfter: &twoDaysAgo,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 3 {
t.Errorf("Expected 3 issues matching 'security' created after two days ago, got %d", len(results))
}
})
t.Run("search with updated before", func(t *testing.T) {
futureTime := now.Add(24 * time.Hour)
results, err := s.SearchIssues(ctx, "security", types.IssueFilter{
UpdatedBefore: &futureTime,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 3 {
t.Errorf("Expected 3 issues matching 'security', got %d", len(results))
}
})
t.Run("search with closed after", func(t *testing.T) {
results, err := s.SearchIssues(ctx, "security", types.IssueFilter{
ClosedAfter: &yesterday,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 1 {
t.Errorf("Expected 1 closed issue matching 'security', got %d", len(results))
}
if len(results) > 0 && results[0].ID != issue3.ID {
t.Errorf("Expected issue3, got %s", results[0].ID)
}
})
t.Run("search with combined filters", func(t *testing.T) {
minPrio := 0
maxPrio := 2
results, err := s.SearchIssues(ctx, "auth", types.IssueFilter{
PriorityMin: &minPrio,
PriorityMax: &maxPrio,
CreatedAfter: &twoDaysAgo,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
// Should match issue1 (has "auth" in title, priority 0)
// and issue2 (has "auth" in description via "automated", priority 2)
// Note: "auth" is a substring match, so it matches "authentication" and "automated"
if len(results) < 1 {
t.Errorf("Expected at least 1 result matching combined filters, got %d", len(results))
}
})
}