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:
123
cmd/bd/search.go
123
cmd/bd/search.go
@@ -5,11 +5,13 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
"github.com/steveyegge/beads/internal/rpc"
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
"github.com/steveyegge/beads/internal/util"
|
"github.com/steveyegge/beads/internal/util"
|
||||||
|
"github.com/steveyegge/beads/internal/validation"
|
||||||
)
|
)
|
||||||
|
|
||||||
var searchCmd = &cobra.Command{
|
var searchCmd = &cobra.Command{
|
||||||
@@ -22,7 +24,10 @@ Examples:
|
|||||||
bd search "login" --status open
|
bd search "login" --status open
|
||||||
bd search "database" --label backend --limit 10
|
bd search "database" --label backend --limit 10
|
||||||
bd search --query "performance" --assignee alice
|
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) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
// Get query from args or --query flag
|
// Get query from args or --query flag
|
||||||
queryFlag, _ := cmd.Flags().GetString("query")
|
queryFlag, _ := cmd.Flags().GetString("query")
|
||||||
@@ -52,6 +57,18 @@ Examples:
|
|||||||
labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
|
labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
|
||||||
longFormat, _ := cmd.Flags().GetBool("long")
|
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
|
// Normalize labels
|
||||||
labels = util.NormalizeLabels(labels)
|
labels = util.NormalizeLabels(labels)
|
||||||
labelsAny = util.NormalizeLabels(labelsAny)
|
labelsAny = util.NormalizeLabels(labelsAny)
|
||||||
@@ -83,6 +100,74 @@ Examples:
|
|||||||
filter.LabelsAny = labelsAny
|
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
|
ctx := rootCtx
|
||||||
|
|
||||||
// Check database freshness before reading (skip when using daemon)
|
// Check database freshness before reading (skip when using daemon)
|
||||||
@@ -111,6 +196,30 @@ Examples:
|
|||||||
listArgs.LabelsAny = labelsAny
|
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)
|
resp, err := daemonClient.List(listArgs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
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().IntP("limit", "n", 50, "Limit results (default: 50)")
|
||||||
searchCmd.Flags().Bool("long", false, "Show detailed multi-line output for each issue")
|
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)
|
rootCmd.AddCommand(searchCmd)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,11 +2,15 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"context"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
|
"github.com/steveyegge/beads/internal/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestSearchCommand_HelpErrorHandling verifies that the search command handles
|
// TestSearchCommand_HelpErrorHandling verifies that the search command handles
|
||||||
@@ -158,3 +162,155 @@ func TestSearchCommand_MissingQueryShowsHelp(t *testing.T) {
|
|||||||
cmd.SetArgs([]string{}) // No query
|
cmd.SetArgs([]string{}) // No query
|
||||||
_ = cmd.Execute()
|
_ = 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))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user