Extract normalizeLabels to internal/util/strings.go
- Created internal/util/strings.go with NormalizeLabels function - Added comprehensive tests in internal/util/strings_test.go - Updated internal/rpc/server_issues_epics.go to use util.NormalizeLabels - Updated cmd/bd/list.go and cmd/bd/ready.go to use util.NormalizeLabels - Updated cmd/bd/list_test.go to use util.NormalizeLabels - Removed duplicate implementations - All tests pass Fixes bd-fb95094c.6 Amp-Thread-ID: https://ampcode.com/threads/T-edb3c286-cd60-4231-94cd-edaf75d84a3d Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
@@ -14,26 +14,9 @@ import (
|
|||||||
"github.com/steveyegge/beads/internal/rpc"
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
"github.com/steveyegge/beads/internal/storage"
|
"github.com/steveyegge/beads/internal/storage"
|
||||||
"github.com/steveyegge/beads/internal/types"
|
"github.com/steveyegge/beads/internal/types"
|
||||||
|
"github.com/steveyegge/beads/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTimeFlag parses time strings in multiple formats
|
// parseTimeFlag parses time strings in multiple formats
|
||||||
func parseTimeFlag(s string) (time.Time, error) {
|
func parseTimeFlag(s string) (time.Time, error) {
|
||||||
formats := []string{
|
formats := []string{
|
||||||
@@ -91,8 +74,8 @@ var listCmd = &cobra.Command{
|
|||||||
// Use global jsonOutput set by PersistentPreRun
|
// Use global jsonOutput set by PersistentPreRun
|
||||||
|
|
||||||
// Normalize labels: trim, dedupe, remove empty
|
// Normalize labels: trim, dedupe, remove empty
|
||||||
labels = normalizeLabels(labels)
|
labels = util.NormalizeLabels(labels)
|
||||||
labelsAny = normalizeLabels(labelsAny)
|
labelsAny = util.NormalizeLabels(labelsAny)
|
||||||
|
|
||||||
filter := types.IssueFilter{
|
filter := types.IssueFilter{
|
||||||
Limit: limit,
|
Limit: limit,
|
||||||
@@ -123,7 +106,7 @@ var listCmd = &cobra.Command{
|
|||||||
filter.TitleSearch = titleSearch
|
filter.TitleSearch = titleSearch
|
||||||
}
|
}
|
||||||
if idFilter != "" {
|
if idFilter != "" {
|
||||||
ids := normalizeLabels(strings.Split(idFilter, ","))
|
ids := util.NormalizeLabels(strings.Split(idFilter, ","))
|
||||||
if len(ids) > 0 {
|
if len(ids) > 0 {
|
||||||
filter.IDs = ids
|
filter.IDs = ids
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"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"
|
||||||
|
"github.com/steveyegge/beads/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
// listTestHelper provides test setup and assertion methods
|
// listTestHelper provides test setup and assertion methods
|
||||||
@@ -155,7 +156,7 @@ func TestListCommand(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("normalize labels", func(t *testing.T) {
|
t.Run("normalize labels", func(t *testing.T) {
|
||||||
labels := []string{" bug ", "critical", "", "bug", " feature "}
|
labels := []string{" bug ", "critical", "", "bug", " feature "}
|
||||||
normalized := normalizeLabels(labels)
|
normalized := util.NormalizeLabels(labels)
|
||||||
expected := []string{"bug", "critical", "feature"}
|
expected := []string{"bug", "critical", "feature"}
|
||||||
h.assertCount(len(normalized), len(expected), "normalized labels")
|
h.assertCount(len(normalized), len(expected), "normalized labels")
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"github.com/steveyegge/beads/internal/rpc"
|
"github.com/steveyegge/beads/internal/rpc"
|
||||||
"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"
|
||||||
|
"github.com/steveyegge/beads/internal/util"
|
||||||
)
|
)
|
||||||
var readyCmd = &cobra.Command{
|
var readyCmd = &cobra.Command{
|
||||||
Use: "ready",
|
Use: "ready",
|
||||||
@@ -22,8 +23,8 @@ var readyCmd = &cobra.Command{
|
|||||||
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
|
// Use global jsonOutput set by PersistentPreRun (respects config.yaml + env vars)
|
||||||
|
|
||||||
// Normalize labels: trim, dedupe, remove empty
|
// Normalize labels: trim, dedupe, remove empty
|
||||||
labels = normalizeLabels(labels)
|
labels = util.NormalizeLabels(labels)
|
||||||
labelsAny = normalizeLabels(labelsAny)
|
labelsAny = util.NormalizeLabels(labelsAny)
|
||||||
|
|
||||||
filter := types.WorkFilter{
|
filter := types.WorkFilter{
|
||||||
// Leave Status empty to get both 'open' and 'in_progress' (bd-165)
|
// Leave Status empty to get both 'open' and 'in_progress' (bd-165)
|
||||||
|
|||||||
@@ -8,27 +8,10 @@ import (
|
|||||||
|
|
||||||
"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"
|
||||||
|
"github.com/steveyegge/beads/internal/util"
|
||||||
"github.com/steveyegge/beads/internal/utils"
|
"github.com/steveyegge/beads/internal/utils"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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
|
|
||||||
}
|
|
||||||
|
|
||||||
// parseTimeRPC parses time strings in multiple formats (RFC3339, YYYY-MM-DD, etc.)
|
// parseTimeRPC parses time strings in multiple formats (RFC3339, YYYY-MM-DD, etc.)
|
||||||
// Matches the parseTimeFlag behavior in cmd/bd/list.go for CLI parity
|
// Matches the parseTimeFlag behavior in cmd/bd/list.go for CLI parity
|
||||||
func parseTimeRPC(s string) (time.Time, error) {
|
func parseTimeRPC(s string) (time.Time, error) {
|
||||||
@@ -354,8 +337,8 @@ func (s *Server) handleList(req *Request) Response {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Normalize and apply label filters
|
// Normalize and apply label filters
|
||||||
labels := normalizeLabels(listArgs.Labels)
|
labels := util.NormalizeLabels(listArgs.Labels)
|
||||||
labelsAny := normalizeLabels(listArgs.LabelsAny)
|
labelsAny := util.NormalizeLabels(listArgs.LabelsAny)
|
||||||
// Support both old single Label and new Labels array (backward compat)
|
// 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
|
||||||
@@ -366,7 +349,7 @@ func (s *Server) handleList(req *Request) Response {
|
|||||||
filter.LabelsAny = labelsAny
|
filter.LabelsAny = labelsAny
|
||||||
}
|
}
|
||||||
if len(listArgs.IDs) > 0 {
|
if len(listArgs.IDs) > 0 {
|
||||||
ids := normalizeLabels(listArgs.IDs)
|
ids := util.NormalizeLabels(listArgs.IDs)
|
||||||
if len(ids) > 0 {
|
if len(ids) > 0 {
|
||||||
filter.IDs = ids
|
filter.IDs = ids
|
||||||
}
|
}
|
||||||
@@ -616,8 +599,8 @@ func (s *Server) handleReady(req *Request) Response {
|
|||||||
Priority: readyArgs.Priority,
|
Priority: readyArgs.Priority,
|
||||||
Limit: readyArgs.Limit,
|
Limit: readyArgs.Limit,
|
||||||
SortPolicy: types.SortPolicy(readyArgs.SortPolicy),
|
SortPolicy: types.SortPolicy(readyArgs.SortPolicy),
|
||||||
Labels: normalizeLabels(readyArgs.Labels),
|
Labels: util.NormalizeLabels(readyArgs.Labels),
|
||||||
LabelsAny: normalizeLabels(readyArgs.LabelsAny),
|
LabelsAny: util.NormalizeLabels(readyArgs.LabelsAny),
|
||||||
}
|
}
|
||||||
if readyArgs.Assignee != "" {
|
if readyArgs.Assignee != "" {
|
||||||
wf.Assignee = &readyArgs.Assignee
|
wf.Assignee = &readyArgs.Assignee
|
||||||
|
|||||||
22
internal/util/strings.go
Normal file
22
internal/util/strings.go
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// NormalizeLabels trims whitespace, removes empty strings, and deduplicates labels
|
||||||
|
// while preserving order.
|
||||||
|
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
|
||||||
|
}
|
||||||
104
internal/util/strings_test.go
Normal file
104
internal/util/strings_test.go
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNormalizeLabels(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input []string
|
||||||
|
expected []string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "empty slice",
|
||||||
|
input: []string{},
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nil slice",
|
||||||
|
input: nil,
|
||||||
|
expected: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "single label",
|
||||||
|
input: []string{"bug"},
|
||||||
|
expected: []string{"bug"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "multiple labels",
|
||||||
|
input: []string{"bug", "critical", "frontend"},
|
||||||
|
expected: []string{"bug", "critical", "frontend"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "labels with whitespace",
|
||||||
|
input: []string{" bug ", " critical", "frontend "},
|
||||||
|
expected: []string{"bug", "critical", "frontend"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicate labels",
|
||||||
|
input: []string{"bug", "bug", "critical"},
|
||||||
|
expected: []string{"bug", "critical"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "duplicates after trimming",
|
||||||
|
input: []string{"bug", " bug ", " bug"},
|
||||||
|
expected: []string{"bug"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty strings",
|
||||||
|
input: []string{"bug", "", "critical"},
|
||||||
|
expected: []string{"bug", "critical"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "whitespace-only strings",
|
||||||
|
input: []string{"bug", " ", "critical", "\t", "\n"},
|
||||||
|
expected: []string{"bug", "critical"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "preserves order",
|
||||||
|
input: []string{"zebra", "apple", "banana"},
|
||||||
|
expected: []string{"zebra", "apple", "banana"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "complex case with all issues",
|
||||||
|
input: []string{" bug ", "", "bug", "critical", " ", "frontend", "critical", " frontend "},
|
||||||
|
expected: []string{"bug", "critical", "frontend"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "unicode labels",
|
||||||
|
input: []string{"🐛 bug", " 🐛 bug ", "🚀 feature"},
|
||||||
|
expected: []string{"🐛 bug", "🚀 feature"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "case-sensitive",
|
||||||
|
input: []string{"Bug", "bug", "BUG"},
|
||||||
|
expected: []string{"Bug", "bug", "BUG"},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "labels with internal spaces",
|
||||||
|
input: []string{"needs review", " needs review ", "in progress"},
|
||||||
|
expected: []string{"needs review", "in progress"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
result := NormalizeLabels(tt.input)
|
||||||
|
if !reflect.DeepEqual(result, tt.expected) {
|
||||||
|
t.Errorf("NormalizeLabels(%v) = %v, want %v", tt.input, result, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeLabels_PreservesCapacity(t *testing.T) {
|
||||||
|
input := []string{"bug", "critical", "frontend"}
|
||||||
|
result := NormalizeLabels(input)
|
||||||
|
|
||||||
|
// Result should have reasonable capacity (not excessive allocation)
|
||||||
|
if cap(result) > len(input)*2 {
|
||||||
|
t.Errorf("NormalizeLabels capacity too large: got %d, input len %d", cap(result), len(input))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user