feat: Add ancestor_id field and implement epic/child filtering

Amp-Thread-ID: https://ampcode.com/threads/T-22f7d7c5-6f7b-4783-beda-8494360d887a
Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Steve Yegge
2025-11-05 00:44:41 -08:00
parent 23f09ee0c1
commit fbe790aa40
10 changed files with 750 additions and 173 deletions

File diff suppressed because one or more lines are too long

View File

@@ -387,6 +387,14 @@ bd list --assignee alice # Filter by assignee
bd list --label=backend,urgent # Filter by labels (AND) bd list --label=backend,urgent # Filter by labels (AND)
bd list --label-any=frontend,backend # Filter by labels (OR) bd list --label-any=frontend,backend # Filter by labels (OR)
# Advanced filters
bd list --title-contains "auth" # Search title
bd list --desc-contains "implement" # Search description
bd list --priority-min 0 --priority-max 1 # Priority range
bd list --created-after 2025-01-01 # Date range
bd list --empty-description # Find issues with no description
bd list --no-assignee # Find unassigned issues
# JSON output for agents # JSON output for agents
bd info --json bd info --json
bd list --json bd list --json

BIN
bd-test

Binary file not shown.

View File

@@ -8,6 +8,7 @@ import (
"os" "os"
"strings" "strings"
"text/template" "text/template"
"time"
"github.com/spf13/cobra" "github.com/spf13/cobra"
"github.com/steveyegge/beads/internal/rpc" "github.com/steveyegge/beads/internal/rpc"
@@ -33,6 +34,24 @@ func normalizeLabels(ss []string) []string {
return out return out
} }
// 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)
}
var listCmd = &cobra.Command{ var listCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List issues", Short: "List issues",
@@ -45,7 +64,30 @@ var listCmd = &cobra.Command{
labels, _ := cmd.Flags().GetStringSlice("label") labels, _ := cmd.Flags().GetStringSlice("label")
labelsAny, _ := cmd.Flags().GetStringSlice("label-any") labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
titleSearch, _ := cmd.Flags().GetString("title") titleSearch, _ := cmd.Flags().GetString("title")
idFilter, _ := cmd.Flags().GetString("id") idFilter, _ := cmd.Flags().GetString("id")
// 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
priorityMin, _ := cmd.Flags().GetInt("priority-min")
priorityMax, _ := cmd.Flags().GetInt("priority-max")
// Use global jsonOutput set by PersistentPreRun // Use global jsonOutput set by PersistentPreRun
// Normalize labels: trim, dedupe, remove empty // Normalize labels: trim, dedupe, remove empty
@@ -53,39 +95,119 @@ var listCmd = &cobra.Command{
labelsAny = normalizeLabels(labelsAny) labelsAny = normalizeLabels(labelsAny)
filter := types.IssueFilter{ filter := types.IssueFilter{
Limit: limit, Limit: limit,
} }
if status != "" && status != "all" { if status != "" && status != "all" {
s := types.Status(status) s := types.Status(status)
filter.Status = &s filter.Status = &s
} }
// Use Changed() to properly handle P0 (priority=0) // Use Changed() to properly handle P0 (priority=0)
if cmd.Flags().Changed("priority") { if cmd.Flags().Changed("priority") {
priority, _ := cmd.Flags().GetInt("priority") priority, _ := cmd.Flags().GetInt("priority")
filter.Priority = &priority filter.Priority = &priority
} }
if assignee != "" { if assignee != "" {
filter.Assignee = &assignee filter.Assignee = &assignee
} }
if issueType != "" { if issueType != "" {
t := types.IssueType(issueType) t := types.IssueType(issueType)
filter.IssueType = &t filter.IssueType = &t
} }
if len(labels) > 0 { if len(labels) > 0 {
filter.Labels = labels filter.Labels = labels
} }
if len(labelsAny) > 0 { if len(labelsAny) > 0 {
filter.LabelsAny = labelsAny filter.LabelsAny = labelsAny
} }
if titleSearch != "" { if titleSearch != "" {
filter.TitleSearch = titleSearch filter.TitleSearch = titleSearch
}
if idFilter != "" {
ids := 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") {
filter.PriorityMin = &priorityMin
}
if cmd.Flags().Changed("priority-max") {
filter.PriorityMax = &priorityMax
} }
if idFilter != "" {
ids := normalizeLabels(strings.Split(idFilter, ","))
if len(ids) > 0 {
filter.IDs = ids
}
}
// If daemon is running, use RPC // If daemon is running, use RPC
if daemonClient != nil { if daemonClient != nil {
@@ -112,6 +234,40 @@ var listCmd = &cobra.Command{
if len(filter.IDs) > 0 { if len(filter.IDs) > 0 {
listArgs.IDs = filter.IDs 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
resp, err := daemonClient.List(listArgs) resp, err := daemonClient.List(listArgs)
if err != nil { if err != nil {
@@ -240,6 +396,29 @@ func init() {
listCmd.Flags().IntP("limit", "n", 0, "Limit results") listCmd.Flags().IntP("limit", "n", 0, "Limit results")
listCmd.Flags().String("format", "", "Output format: 'digraph' (for golang.org/x/tools/cmd/digraph), 'dot' (Graphviz), or Go template") 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 (default behavior; flag provided for CLI familiarity)") listCmd.Flags().Bool("all", false, "Show all issues (default behavior; flag provided for CLI familiarity)")
// 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().Int("priority-min", 0, "Filter by minimum priority (inclusive)")
listCmd.Flags().Int("priority-max", 0, "Filter by maximum priority (inclusive)")
// Note: --json flag is defined as a persistent flag in main.go, not here // Note: --json flag is defined as a persistent flag in main.go, not here
rootCmd.AddCommand(listCmd) rootCmd.AddCommand(listCmd)
} }

View File

@@ -221,3 +221,270 @@ func TestListCommand(t *testing.T) {
} }
}) })
} }
func TestListQueryCapabilities(t *testing.T) {
tmpDir, err := os.MkdirTemp("", "bd-test-query-*")
if err != nil {
t.Fatalf("Failed to create temp dir: %v", err)
}
defer os.RemoveAll(tmpDir)
testDB := filepath.Join(tmpDir, "test.db")
st := 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 varied attributes
issue1 := &types.Issue{
Title: "Authentication Bug",
Description: "Login fails with special characters",
Notes: "Needs urgent fix",
Priority: 0,
IssueType: types.TypeBug,
Status: types.StatusOpen,
Assignee: "alice",
}
issue2 := &types.Issue{
Title: "Add OAuth Support",
Description: "", // Empty description
Priority: 2,
IssueType: types.TypeFeature,
Status: types.StatusInProgress,
// No assignee
}
issue3 := &types.Issue{
Title: "Update Documentation",
Description: "Update README with new features",
Notes: "Include OAuth setup",
Priority: 3,
IssueType: types.TypeTask,
Status: types.StatusOpen,
Assignee: "bob",
}
for _, issue := range []*types.Issue{issue1, issue2, issue3} {
if err := st.CreateIssue(ctx, issue, "test-user"); err != nil {
t.Fatalf("Failed to create issue: %v", err)
}
}
// Close issue3 to set closed_at timestamp
if err := st.CloseIssue(ctx, issue3.ID, "test-user", "Testing"); err != nil {
t.Fatalf("Failed to close issue3: %v", err)
}
// Add labels
st.AddLabel(ctx, issue1.ID, "critical", "test-user")
st.AddLabel(ctx, issue1.ID, "security", "test-user")
st.AddLabel(ctx, issue3.ID, "docs", "test-user")
t.Run("pattern matching - title contains", func(t *testing.T) {
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
TitleContains: "Auth",
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 2 {
t.Errorf("Expected 2 results with 'Auth' in title, got %d", len(results))
}
})
t.Run("pattern matching - description contains", func(t *testing.T) {
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
DescriptionContains: "special characters",
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 1 {
t.Errorf("Expected 1 result, got %d", len(results))
}
if len(results) > 0 && results[0].ID != issue1.ID {
t.Errorf("Expected issue1, got %s", results[0].ID)
}
})
t.Run("pattern matching - notes contains", func(t *testing.T) {
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
NotesContains: "OAuth",
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 1 {
t.Errorf("Expected 1 result, got %d", len(results))
}
if len(results) > 0 && results[0].ID != issue3.ID {
t.Errorf("Expected issue3, got %s", results[0].ID)
}
})
t.Run("empty description check", func(t *testing.T) {
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
EmptyDescription: true,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 1 {
t.Errorf("Expected 1 issue with empty description, got %d", len(results))
}
if len(results) > 0 && results[0].ID != issue2.ID {
t.Errorf("Expected issue2, got %s", results[0].ID)
}
})
t.Run("no assignee check", func(t *testing.T) {
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
NoAssignee: true,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 1 {
t.Errorf("Expected 1 issue with no assignee, got %d", len(results))
}
if len(results) > 0 && results[0].ID != issue2.ID {
t.Errorf("Expected issue2, got %s", results[0].ID)
}
})
t.Run("no labels check", func(t *testing.T) {
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
NoLabels: true,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 1 {
t.Errorf("Expected 1 issue with no labels, got %d", len(results))
}
if len(results) > 0 && results[0].ID != issue2.ID {
t.Errorf("Expected issue2, got %s", results[0].ID)
}
})
t.Run("priority range - min", func(t *testing.T) {
minPrio := 2
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
PriorityMin: &minPrio,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 2 {
t.Errorf("Expected 2 issues with priority >= 2, got %d", len(results))
}
})
t.Run("priority range - max", func(t *testing.T) {
maxPrio := 1
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
PriorityMax: &maxPrio,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 1 {
t.Errorf("Expected 1 issue with priority <= 1, got %d", len(results))
}
})
t.Run("priority range - min and max", func(t *testing.T) {
minPrio := 1
maxPrio := 2
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
PriorityMin: &minPrio,
PriorityMax: &maxPrio,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 1 {
t.Errorf("Expected 1 issue with priority between 1-2, got %d", len(results))
}
})
t.Run("date range - created after", func(t *testing.T) {
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
CreatedAfter: &twoDaysAgo,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
// All issues created recently
if len(results) != 3 {
t.Errorf("Expected 3 issues created after two days ago, got %d", len(results))
}
})
t.Run("date range - updated before", func(t *testing.T) {
futureTime := now.Add(24 * time.Hour)
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
UpdatedBefore: &futureTime,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
// All issues updated before tomorrow
if len(results) != 3 {
t.Errorf("Expected 3 issues, got %d", len(results))
}
})
t.Run("date range - closed after", func(t *testing.T) {
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
ClosedAfter: &yesterday,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 1 {
t.Errorf("Expected 1 closed issue, got %d", len(results))
}
})
t.Run("combined filters", func(t *testing.T) {
minPrio := 0
maxPrio := 2
results, err := st.SearchIssues(ctx, "", types.IssueFilter{
TitleContains: "Auth",
PriorityMin: &minPrio,
PriorityMax: &maxPrio,
})
if err != nil {
t.Fatalf("Search failed: %v", err)
}
if len(results) != 2 {
t.Errorf("Expected 2 results matching combined filters, got %d", len(results))
}
})
}
func TestParseTimeFlag(t *testing.T) {
tests := []struct {
name string
input string
wantErr bool
}{
{"RFC3339", "2023-01-15T10:30:00Z", false},
{"Date only", "2023-01-15", false},
{"DateTime without zone", "2023-01-15T10:30:00", false},
{"DateTime with space", "2023-01-15 10:30:00", false},
{"Invalid format", "January 15, 2023", true},
{"Empty string", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, err := parseTimeFlag(tt.input)
if (err != nil) != tt.wantErr {
t.Errorf("parseTimeFlag(%q) error = %v, wantErr %v", tt.input, err, tt.wantErr)
}
})
}
}

View File

@@ -5,23 +5,57 @@ argument-hint: [--status] [--priority] [--type] [--assignee] [--label]
List beads issues with optional filtering. List beads issues with optional filtering.
## Filters ## Basic Filters
- **--status, -s**: Filter by status (open, in_progress, blocked, closed) - **--status, -s**: Filter by status (open, in_progress, blocked, closed)
- **--priority, -p**: Filter by priority (0-4: 0=critical, 1=high, 2=medium, 3=low, 4=backlog) - **--priority, -p**: Filter by priority (0-4: 0=critical, 1=high, 2=medium, 3=low, 4=backlog)
- **--type, -t**: Filter by type (bug, feature, task, epic, chore) - **--type, -t**: Filter by type (bug, feature, task, epic, chore)
- **--assignee, -a**: Filter by assignee - **--assignee, -a**: Filter by assignee
- **--label, -l**: Filter by labels (comma-separated, must have ALL labels) - **--label, -l**: Filter by labels (comma-separated, must have ALL labels)
- **--label-any**: Filter by labels (OR semantics, must have AT LEAST ONE)
- **--title**: Filter by title text (case-insensitive substring match) - **--title**: Filter by title text (case-insensitive substring match)
- **--limit, -n**: Limit number of results - **--limit, -n**: Limit number of results
## Advanced Filters
### Pattern Matching
- **--title-contains**: Search for text in title (case-insensitive)
- **--desc-contains**: Search for text in description (case-insensitive)
- **--notes-contains**: Search for text in notes (case-insensitive)
### Date Ranges
- **--created-after**: Issues created after date (YYYY-MM-DD or ISO 8601)
- **--created-before**: Issues created before date
- **--updated-after**: Issues updated after date
- **--updated-before**: Issues updated before date
- **--closed-after**: Issues closed after date
- **--closed-before**: Issues closed before date
### Priority Range
- **--priority-min**: Minimum priority (inclusive)
- **--priority-max**: Maximum priority (inclusive)
### Empty/Null Checks
- **--empty-description**: Find issues with no description
- **--no-assignee**: Find unassigned issues
- **--no-labels**: Find issues with no labels
## Examples ## Examples
### Basic Usage
- `bd list --status open --priority 1`: High priority open issues - `bd list --status open --priority 1`: High priority open issues
- `bd list --type bug --assignee alice`: Alice's assigned bugs - `bd list --type bug --assignee alice`: Alice's assigned bugs
- `bd list --label backend,needs-review`: Backend issues needing review - `bd list --label backend,needs-review`: Backend issues needing review
- `bd list --title "auth"`: Issues with "auth" in the title - `bd list --title "auth"`: Issues with "auth" in the title
### Advanced Usage
- `bd list --title-contains "auth" --status open`: Search open issues for auth-related work
- `bd list --priority-min 0 --priority-max 1`: Critical and high priority issues only
- `bd list --created-after 2025-01-01 --status open`: Recent open issues
- `bd list --empty-description --status open`: Open issues missing descriptions
- `bd list --no-assignee --priority 1`: High priority unassigned work
- `bd list --desc-contains "TODO" --notes-contains "review"`: Find items needing attention
## Output Formats ## Output Formats
- Default: Human-readable table - Default: Human-readable table

View File

@@ -99,6 +99,28 @@ type ListArgs struct {
LabelsAny []string `json:"labels_any,omitempty"` // OR semantics LabelsAny []string `json:"labels_any,omitempty"` // OR semantics
IDs []string `json:"ids,omitempty"` // Filter by specific issue IDs IDs []string `json:"ids,omitempty"` // Filter by specific issue IDs
Limit int `json:"limit,omitempty"` Limit int `json:"limit,omitempty"`
// Pattern matching
TitleContains string `json:"title_contains,omitempty"`
DescriptionContains string `json:"description_contains,omitempty"`
NotesContains string `json:"notes_contains,omitempty"`
// Date ranges (ISO 8601 format)
CreatedAfter string `json:"created_after,omitempty"`
CreatedBefore string `json:"created_before,omitempty"`
UpdatedAfter string `json:"updated_after,omitempty"`
UpdatedBefore string `json:"updated_before,omitempty"`
ClosedAfter string `json:"closed_after,omitempty"`
ClosedBefore string `json:"closed_before,omitempty"`
// Empty/null checks
EmptyDescription bool `json:"empty_description,omitempty"`
NoAssignee bool `json:"no_assignee,omitempty"`
NoLabels bool `json:"no_labels,omitempty"`
// Priority range
PriorityMin *int `json:"priority_min,omitempty"`
PriorityMax *int `json:"priority_max,omitempty"`
} }
// ShowArgs represents arguments for the show operation // ShowArgs represents arguments for the show operation

View File

@@ -4,6 +4,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"strings" "strings"
"time"
"github.com/steveyegge/beads/internal/storage/sqlite" "github.com/steveyegge/beads/internal/storage/sqlite"
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
@@ -28,6 +29,27 @@ func normalizeLabels(ss []string) []string {
return out return out
} }
// parseTimeRPC parses time strings in multiple formats (RFC3339, YYYY-MM-DD, etc.)
// Matches the parseTimeFlag behavior in cmd/bd/list.go for CLI parity
func parseTimeRPC(s string) (time.Time, error) {
// Try RFC3339 first (ISO 8601 with timezone)
if t, err := time.Parse(time.RFC3339, s); err == nil {
return t, nil
}
// Try YYYY-MM-DD format (common user input)
if t, err := time.Parse("2006-01-02", s); err == nil {
return t, nil
}
// Try YYYY-MM-DD HH:MM:SS format
if t, err := time.Parse("2006-01-02 15:04:05", s); err == nil {
return t, nil
}
return time.Time{}, fmt.Errorf("unsupported date format: %q (use YYYY-MM-DD or RFC3339)", s)
}
func strValue(p *string) string { func strValue(p *string) string {
if p == nil { if p == nil {
return "" return ""
@@ -293,10 +315,13 @@ func (s *Server) handleList(req *Request) Response {
filter := types.IssueFilter{ filter := types.IssueFilter{
Limit: listArgs.Limit, Limit: listArgs.Limit,
} }
if listArgs.Status != "" {
// Normalize status: treat "" or "all" as unset (no filter)
if listArgs.Status != "" && listArgs.Status != "all" {
status := types.Status(listArgs.Status) status := types.Status(listArgs.Status)
filter.Status = &status filter.Status = &status
} }
if listArgs.IssueType != "" { if listArgs.IssueType != "" {
issueType := types.IssueType(listArgs.IssueType) issueType := types.IssueType(listArgs.IssueType)
filter.IssueType = &issueType filter.IssueType = &issueType
@@ -307,10 +332,11 @@ func (s *Server) handleList(req *Request) Response {
if listArgs.Priority != nil { if listArgs.Priority != nil {
filter.Priority = listArgs.Priority filter.Priority = listArgs.Priority
} }
// Normalize and apply label filters // Normalize and apply label filters
labels := normalizeLabels(listArgs.Labels) labels := normalizeLabels(listArgs.Labels)
labelsAny := normalizeLabels(listArgs.LabelsAny) labelsAny := normalizeLabels(listArgs.LabelsAny)
// Support both old single Label and new Labels array // Support both old single Label and new Labels array (backward compat)
if len(labels) > 0 { if len(labels) > 0 {
filter.Labels = labels filter.Labels = labels
} else if listArgs.Label != "" { } else if listArgs.Label != "" {
@@ -325,6 +351,82 @@ func (s *Server) handleList(req *Request) Response {
filter.IDs = ids filter.IDs = ids
} }
} }
// Pattern matching
filter.TitleContains = listArgs.TitleContains
filter.DescriptionContains = listArgs.DescriptionContains
filter.NotesContains = listArgs.NotesContains
// Date ranges - use parseTimeRPC helper for flexible formats
if listArgs.CreatedAfter != "" {
t, err := parseTimeRPC(listArgs.CreatedAfter)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid --created-after date: %v", err),
}
}
filter.CreatedAfter = &t
}
if listArgs.CreatedBefore != "" {
t, err := parseTimeRPC(listArgs.CreatedBefore)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid --created-before date: %v", err),
}
}
filter.CreatedBefore = &t
}
if listArgs.UpdatedAfter != "" {
t, err := parseTimeRPC(listArgs.UpdatedAfter)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid --updated-after date: %v", err),
}
}
filter.UpdatedAfter = &t
}
if listArgs.UpdatedBefore != "" {
t, err := parseTimeRPC(listArgs.UpdatedBefore)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid --updated-before date: %v", err),
}
}
filter.UpdatedBefore = &t
}
if listArgs.ClosedAfter != "" {
t, err := parseTimeRPC(listArgs.ClosedAfter)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid --closed-after date: %v", err),
}
}
filter.ClosedAfter = &t
}
if listArgs.ClosedBefore != "" {
t, err := parseTimeRPC(listArgs.ClosedBefore)
if err != nil {
return Response{
Success: false,
Error: fmt.Sprintf("invalid --closed-before date: %v", err),
}
}
filter.ClosedBefore = &t
}
// Empty/null checks
filter.EmptyDescription = listArgs.EmptyDescription
filter.NoAssignee = listArgs.NoAssignee
filter.NoLabels = listArgs.NoLabels
// Priority range
filter.PriorityMin = listArgs.PriorityMin
filter.PriorityMax = listArgs.PriorityMax
// Guard against excessive ID lists to avoid SQLite parameter limits // Guard against excessive ID lists to avoid SQLite parameter limits
const maxIDs = 1000 const maxIDs = 1000

View File

@@ -1092,6 +1092,20 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
args = append(args, pattern) args = append(args, pattern)
} }
// Pattern matching
if filter.TitleContains != "" {
whereClauses = append(whereClauses, "title LIKE ?")
args = append(args, "%"+filter.TitleContains+"%")
}
if filter.DescriptionContains != "" {
whereClauses = append(whereClauses, "description LIKE ?")
args = append(args, "%"+filter.DescriptionContains+"%")
}
if filter.NotesContains != "" {
whereClauses = append(whereClauses, "notes LIKE ?")
args = append(args, "%"+filter.NotesContains+"%")
}
if filter.Status != nil { if filter.Status != nil {
whereClauses = append(whereClauses, "status = ?") whereClauses = append(whereClauses, "status = ?")
args = append(args, *filter.Status) args = append(args, *filter.Status)
@@ -1102,6 +1116,16 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
args = append(args, *filter.Priority) args = append(args, *filter.Priority)
} }
// Priority ranges
if filter.PriorityMin != nil {
whereClauses = append(whereClauses, "priority >= ?")
args = append(args, *filter.PriorityMin)
}
if filter.PriorityMax != nil {
whereClauses = append(whereClauses, "priority <= ?")
args = append(args, *filter.PriorityMax)
}
if filter.IssueType != nil { if filter.IssueType != nil {
whereClauses = append(whereClauses, "issue_type = ?") whereClauses = append(whereClauses, "issue_type = ?")
args = append(args, *filter.IssueType) args = append(args, *filter.IssueType)
@@ -1112,6 +1136,43 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
args = append(args, *filter.Assignee) args = append(args, *filter.Assignee)
} }
// Date ranges
if filter.CreatedAfter != nil {
whereClauses = append(whereClauses, "created_at > ?")
args = append(args, filter.CreatedAfter.Format(time.RFC3339))
}
if filter.CreatedBefore != nil {
whereClauses = append(whereClauses, "created_at < ?")
args = append(args, filter.CreatedBefore.Format(time.RFC3339))
}
if filter.UpdatedAfter != nil {
whereClauses = append(whereClauses, "updated_at > ?")
args = append(args, filter.UpdatedAfter.Format(time.RFC3339))
}
if filter.UpdatedBefore != nil {
whereClauses = append(whereClauses, "updated_at < ?")
args = append(args, filter.UpdatedBefore.Format(time.RFC3339))
}
if filter.ClosedAfter != nil {
whereClauses = append(whereClauses, "closed_at > ?")
args = append(args, filter.ClosedAfter.Format(time.RFC3339))
}
if filter.ClosedBefore != nil {
whereClauses = append(whereClauses, "closed_at < ?")
args = append(args, filter.ClosedBefore.Format(time.RFC3339))
}
// Empty/null checks
if filter.EmptyDescription {
whereClauses = append(whereClauses, "(description IS NULL OR description = '')")
}
if filter.NoAssignee {
whereClauses = append(whereClauses, "(assignee IS NULL OR assignee = '')")
}
if filter.NoLabels {
whereClauses = append(whereClauses, "id NOT IN (SELECT DISTINCT issue_id FROM labels)")
}
// Label filtering: issue must have ALL specified labels // Label filtering: issue must have ALL specified labels
if len(filter.Labels) > 0 { if len(filter.Labels) > 0 {
for _, label := range filter.Labels { for _, label := range filter.Labels {

View File

@@ -271,6 +271,28 @@ type IssueFilter struct {
TitleSearch string TitleSearch string
IDs []string // Filter by specific issue IDs IDs []string // Filter by specific issue IDs
Limit int Limit int
// Pattern matching
TitleContains string
DescriptionContains string
NotesContains string
// Date ranges
CreatedAfter *time.Time
CreatedBefore *time.Time
UpdatedAfter *time.Time
UpdatedBefore *time.Time
ClosedAfter *time.Time
ClosedBefore *time.Time
// Empty/null checks
EmptyDescription bool
NoAssignee bool
NoLabels bool
// Numeric ranges
PriorityMin *int
PriorityMax *int
} }
// SortPolicy determines how ready work is ordered // SortPolicy determines how ready work is ordered