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

View File

@@ -81,13 +81,15 @@ type CloseArgs struct {
// ListArgs represents arguments for the list operation
type ListArgs struct {
Query string `json:"query,omitempty"`
Status string `json:"status,omitempty"`
Priority *int `json:"priority,omitempty"`
IssueType string `json:"issue_type,omitempty"`
Assignee string `json:"assignee,omitempty"`
Label string `json:"label,omitempty"`
Limit int `json:"limit,omitempty"`
Query string `json:"query,omitempty"`
Status string `json:"status,omitempty"`
Priority *int `json:"priority,omitempty"`
IssueType string `json:"issue_type,omitempty"`
Assignee string `json:"assignee,omitempty"`
Label string `json:"label,omitempty"` // Deprecated: use Labels
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

View File

@@ -28,6 +28,24 @@ import (
// It's set as a var so it can be initialized from main
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
type StorageCacheEntry struct {
store storage.Storage
@@ -899,6 +917,18 @@ func (s *Server) handleList(req *Request) Response {
if listArgs.Priority != nil {
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)
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 := ""
if len(whereClauses) > 0 {
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")

View File

@@ -896,6 +896,80 @@ func TestSearchIssues(t *testing.T) {
if len(results) != 1 {
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) {

View File

@@ -209,7 +209,8 @@ type IssueFilter struct {
Priority *int
IssueType *IssueType
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
Limit int
}