Add label filtering to bd list with AND/OR semantics

- Add --label flag for AND filtering (must have ALL labels)
- Add --label-any flag for OR filtering (must have AT LEAST ONE label)
- Add normalizeLabels() helper to trim, dedupe, and clean inputs
- Fix RPC title filtering parity bug (forward via Query field)
- Add comprehensive tests for label filtering including combined AND+OR
- Update documentation in README and CHANGELOG
- Improve flag help text to clarify combined semantics

Closes bd-161
This commit is contained in:
Steve Yegge
2025-10-19 23:03:02 -07:00
parent f1ec927a7c
commit 422c102f46
9 changed files with 346 additions and 181 deletions

File diff suppressed because one or more lines are too long

View File

@@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased] ## [Unreleased]
### Added ### Added
- **Label Filtering**: Enhanced `bd list` command with label-based filtering (bd-161)
- `--label` (or `-l`): Filter by multiple labels with AND semantics (must have ALL)
- `--label-any`: Filter by multiple labels with OR semantics (must have AT LEAST ONE)
- Examples:
- `bd list --label backend,urgent`: Issues with both 'backend' AND 'urgent'
- `bd list --label-any frontend,backend`: Issues with either 'frontend' OR 'backend'
- Works in both daemon and direct modes
- Includes comprehensive test coverage
- **Log Rotation**: Automatic daemon log rotation with configurable limits (bd-154) - **Log Rotation**: Automatic daemon log rotation with configurable limits (bd-154)
- Prevents unbounded log file growth for long-running daemons - Prevents unbounded log file growth for long-running daemons
- Configurable via environment variables: `BEADS_DAEMON_LOG_MAX_SIZE`, `BEADS_DAEMON_LOG_MAX_BACKUPS`, `BEADS_DAEMON_LOG_MAX_AGE` - Configurable via environment variables: `BEADS_DAEMON_LOG_MAX_SIZE`, `BEADS_DAEMON_LOG_MAX_BACKUPS`, `BEADS_DAEMON_LOG_MAX_AGE`

View File

@@ -290,11 +290,14 @@ Draft multiple issues in a markdown file with `bd create -f file.md`. Format: `#
### Viewing Issues ### Viewing Issues
```bash ```bash
bd show bd-1 # Show full details bd show bd-1 # Show full details
bd list # List all issues bd list # List all issues
bd list --status open # Filter by status bd list --status open # Filter by status
bd list --priority 1 # Filter by priority bd list --priority 1 # Filter by priority
bd list --assignee alice # Filter by assignee bd list --assignee alice # Filter by assignee
bd list --label=backend,urgent # Filter by labels (AND: must have ALL)
bd list --label-any=frontend,backend # Filter by labels (OR: must have AT LEAST ONE)
bd list --priority 1 --label=backend # Combine filters
# JSON output for agents # JSON output for agents
bd list --json bd list --json

View File

@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"fmt" "fmt"
"os" "os"
"strings"
"text/template" "text/template"
"github.com/spf13/cobra" "github.com/spf13/cobra"
@@ -14,6 +15,24 @@ import (
"github.com/steveyegge/beads/internal/types" "github.com/steveyegge/beads/internal/types"
) )
// normalizeLabels trims whitespace, removes empty strings, and deduplicates labels
func normalizeLabels(ss []string) []string {
seen := make(map[string]struct{})
out := make([]string, 0, len(ss))
for _, s := range ss {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
var listCmd = &cobra.Command{ var listCmd = &cobra.Command{
Use: "list", Use: "list",
Short: "List issues", Short: "List issues",
@@ -24,8 +43,13 @@ var listCmd = &cobra.Command{
limit, _ := cmd.Flags().GetInt("limit") limit, _ := cmd.Flags().GetInt("limit")
formatStr, _ := cmd.Flags().GetString("format") formatStr, _ := cmd.Flags().GetString("format")
labels, _ := cmd.Flags().GetStringSlice("label") labels, _ := cmd.Flags().GetStringSlice("label")
labelsAny, _ := cmd.Flags().GetStringSlice("label-any")
titleSearch, _ := cmd.Flags().GetString("title") titleSearch, _ := cmd.Flags().GetString("title")
// Normalize labels: trim, dedupe, remove empty
labels = normalizeLabels(labels)
labelsAny = normalizeLabels(labelsAny)
filter := types.IssueFilter{ filter := types.IssueFilter{
Limit: limit, Limit: limit,
} }
@@ -48,6 +72,9 @@ var listCmd = &cobra.Command{
if len(labels) > 0 { if len(labels) > 0 {
filter.Labels = labels filter.Labels = labels
} }
if len(labelsAny) > 0 {
filter.LabelsAny = labelsAny
}
if titleSearch != "" { if titleSearch != "" {
filter.TitleSearch = titleSearch filter.TitleSearch = titleSearch
} }
@@ -65,7 +92,14 @@ var listCmd = &cobra.Command{
listArgs.Priority = &priority listArgs.Priority = &priority
} }
if len(labels) > 0 { if len(labels) > 0 {
listArgs.Label = labels[0] // TODO: daemon protocol needs to support multiple labels 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
} }
resp, err := daemonClient.List(listArgs) resp, err := daemonClient.List(listArgs)
@@ -148,7 +182,8 @@ func init() {
listCmd.Flags().IntP("priority", "p", 0, "Filter by priority (0-4: 0=critical, 1=high, 2=medium, 3=low, 4=backlog)") listCmd.Flags().IntP("priority", "p", 0, "Filter by priority (0-4: 0=critical, 1=high, 2=medium, 3=low, 4=backlog)")
listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee") listCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)") listCmd.Flags().StringP("type", "t", "", "Filter by type (bug, feature, task, epic, chore)")
listCmd.Flags().StringSliceP("label", "l", []string{}, "Filter by labels (comma-separated, must have ALL labels)") 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("title", "", "Filter by title text (case-insensitive substring match)")
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")

View File

@@ -81,13 +81,15 @@ type CloseArgs struct {
// ListArgs represents arguments for the list operation // ListArgs represents arguments for the list operation
type ListArgs struct { type ListArgs struct {
Query string `json:"query,omitempty"` Query string `json:"query,omitempty"`
Status string `json:"status,omitempty"` Status string `json:"status,omitempty"`
Priority *int `json:"priority,omitempty"` Priority *int `json:"priority,omitempty"`
IssueType string `json:"issue_type,omitempty"` IssueType string `json:"issue_type,omitempty"`
Assignee string `json:"assignee,omitempty"` Assignee string `json:"assignee,omitempty"`
Label string `json:"label,omitempty"` Label string `json:"label,omitempty"` // Deprecated: use Labels
Limit int `json:"limit,omitempty"` Labels []string `json:"labels,omitempty"` // AND semantics
LabelsAny []string `json:"labels_any,omitempty"` // OR semantics
Limit int `json:"limit,omitempty"`
} }
// ShowArgs represents arguments for the show operation // ShowArgs represents arguments for the show operation

View File

@@ -28,6 +28,24 @@ import (
// It's set as a var so it can be initialized from main // It's set as a var so it can be initialized from main
var ServerVersion = "0.9.10" var ServerVersion = "0.9.10"
// normalizeLabels trims whitespace, removes empty strings, and deduplicates labels
func normalizeLabels(ss []string) []string {
seen := make(map[string]struct{})
out := make([]string, 0, len(ss))
for _, s := range ss {
s = strings.TrimSpace(s)
if s == "" {
continue
}
if _, ok := seen[s]; ok {
continue
}
seen[s] = struct{}{}
out = append(out, s)
}
return out
}
// StorageCacheEntry holds a cached storage with metadata for eviction // StorageCacheEntry holds a cached storage with metadata for eviction
type StorageCacheEntry struct { type StorageCacheEntry struct {
store storage.Storage store storage.Storage
@@ -899,6 +917,18 @@ 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
labels := normalizeLabels(listArgs.Labels)
labelsAny := normalizeLabels(listArgs.LabelsAny)
// Support both old single Label and new Labels array
if len(labels) > 0 {
filter.Labels = labels
} else if listArgs.Label != "" {
filter.Labels = []string{strings.TrimSpace(listArgs.Label)}
}
if len(labelsAny) > 0 {
filter.LabelsAny = labelsAny
}
ctx := s.reqCtx(req) ctx := s.reqCtx(req)
issues, err := store.SearchIssues(ctx, listArgs.Query, filter) issues, err := store.SearchIssues(ctx, listArgs.Query, filter)

View File

@@ -1673,6 +1673,16 @@ func (s *SQLiteStorage) SearchIssues(ctx context.Context, query string, filter t
} }
} }
// Label filtering (OR): issue must have AT LEAST ONE of these labels
if len(filter.LabelsAny) > 0 {
placeholders := make([]string, len(filter.LabelsAny))
for i, label := range filter.LabelsAny {
placeholders[i] = "?"
args = append(args, label)
}
whereClauses = append(whereClauses, fmt.Sprintf("id IN (SELECT issue_id FROM labels WHERE label IN (%s))", strings.Join(placeholders, ", ")))
}
whereSQL := "" whereSQL := ""
if len(whereClauses) > 0 { if len(whereClauses) > 0 {
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ") whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")

View File

@@ -896,6 +896,80 @@ func TestSearchIssues(t *testing.T) {
if len(results) != 1 { if len(results) != 1 {
t.Errorf("Expected 1 P0 issue, got %d", len(results)) t.Errorf("Expected 1 P0 issue, got %d", len(results))
} }
// Test label filtering (AND semantics)
err = store.AddLabel(ctx, issues[0].ID, "backend", "test-user")
if err != nil {
t.Fatalf("AddLabel failed: %v", err)
}
err = store.AddLabel(ctx, issues[0].ID, "urgent", "test-user")
if err != nil {
t.Fatalf("AddLabel failed: %v", err)
}
err = store.AddLabel(ctx, issues[1].ID, "backend", "test-user")
if err != nil {
t.Fatalf("AddLabel failed: %v", err)
}
// Filter by single label
results, err = store.SearchIssues(ctx, "", types.IssueFilter{Labels: []string{"backend"}})
if err != nil {
t.Fatalf("SearchIssues with label filter failed: %v", err)
}
if len(results) != 2 {
t.Errorf("Expected 2 issues with 'backend' label, got %d", len(results))
}
// Filter by multiple labels (AND semantics - must have ALL)
results, err = store.SearchIssues(ctx, "", types.IssueFilter{Labels: []string{"backend", "urgent"}})
if err != nil {
t.Fatalf("SearchIssues with multiple labels failed: %v", err)
}
if len(results) != 1 {
t.Errorf("Expected 1 issue with both 'backend' AND 'urgent' labels, got %d", len(results))
}
// Test label filtering (OR semantics)
err = store.AddLabel(ctx, issues[2].ID, "frontend", "test-user")
if err != nil {
t.Fatalf("AddLabel failed: %v", err)
}
results, err = store.SearchIssues(ctx, "", types.IssueFilter{LabelsAny: []string{"frontend", "urgent"}})
if err != nil {
t.Fatalf("SearchIssues with LabelsAny filter failed: %v", err)
}
if len(results) != 2 {
t.Errorf("Expected 2 issues with 'frontend' OR 'urgent' labels, got %d", len(results))
}
// Test combined AND + OR filtering
results, err = store.SearchIssues(ctx, "", types.IssueFilter{
Labels: []string{"backend"},
LabelsAny: []string{"urgent", "frontend"},
})
if err != nil {
t.Fatalf("SearchIssues with combined Labels and LabelsAny failed: %v", err)
}
// Should return issue[0] (has backend AND urgent)
// issue[1] has backend but not urgent/frontend, so excluded
if len(results) != 1 {
t.Errorf("Expected 1 issue with 'backend' AND ('urgent' OR 'frontend'), got %d", len(results))
}
if len(results) > 0 && results[0].ID != issues[0].ID {
t.Errorf("Expected issue %s, got %s", issues[0].ID, results[0].ID)
}
// Test whitespace trimming in labels
results, err = store.SearchIssues(ctx, "", types.IssueFilter{Labels: []string{" backend ", " urgent "}})
if err != nil {
t.Fatalf("SearchIssues with whitespace labels failed: %v", err)
}
// This won't match because storage layer doesn't trim - that's CLI's job
// But let's verify the storage layer accepts it without error
if len(results) != 0 {
t.Logf("Note: Storage layer doesn't auto-trim labels (expected - trimming is CLI responsibility)")
}
} }
func TestGetStatistics(t *testing.T) { func TestGetStatistics(t *testing.T) {

View File

@@ -209,7 +209,8 @@ type IssueFilter struct {
Priority *int Priority *int
IssueType *IssueType IssueType *IssueType
Assignee *string Assignee *string
Labels []string Labels []string // AND semantics: issue must have ALL these labels
LabelsAny []string // OR semantics: issue must have AT LEAST ONE of these labels
TitleSearch string TitleSearch string
Limit int Limit int
} }