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:
File diff suppressed because one or more lines are too long
@@ -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`
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 ")
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user