diff --git a/SURFACE.txt b/SURFACE.txt index 43c1e99..37e0af8 100644 --- a/SURFACE.txt +++ b/SURFACE.txt @@ -2353,24 +2353,17 @@ FLAG fizzy reaction rm --styled type=bool FLAG fizzy reaction rm --token type=string FLAG fizzy reaction rm --verbose type=bool FLAG fizzy search --agent type=bool -FLAG fizzy search --all type=bool FLAG fizzy search --api-url type=string -FLAG fizzy search --assignee type=string -FLAG fizzy search --board type=string FLAG fizzy search --count type=bool FLAG fizzy search --help type=bool FLAG fizzy search --ids-only type=bool -FLAG fizzy search --indexed-by type=string FLAG fizzy search --jq type=string FLAG fizzy search --json type=bool FLAG fizzy search --limit type=int FLAG fizzy search --markdown type=bool -FLAG fizzy search --page type=int FLAG fizzy search --profile type=string FLAG fizzy search --quiet type=bool -FLAG fizzy search --sort type=string FLAG fizzy search --styled type=bool -FLAG fizzy search --tag type=string FLAG fizzy search --token type=string FLAG fizzy search --verbose type=bool FLAG fizzy setup --agent type=bool diff --git a/e2e/cli_tests/search_and_auth_test.go b/e2e/cli_tests/search_and_auth_test.go index 908d37c..d4d46d7 100644 --- a/e2e/cli_tests/search_and_auth_test.go +++ b/e2e/cli_tests/search_and_auth_test.go @@ -8,9 +8,17 @@ import ( func TestSearch(t *testing.T) { h := newHarness(t) + // Single-token query assertOK(t, h.Run("search", "test")) - assertOK(t, h.Run("search", "test", "--board", fixture.BoardID)) - assertOK(t, h.Run("search", "test", "--all")) + // Multi-arg joined into a single q string + assertOK(t, h.Run("search", "login", "error")) +} + +func TestCardListWithSearch(t *testing.T) { + // Filter use cases that used to live on `fizzy search` now belong here. + h := newHarness(t) + assertOK(t, h.Run("card", "list", "--search", "test", "--board", fixture.BoardID)) + assertOK(t, h.Run("card", "list", "--search", "test", "--all")) } func TestAuthInvalidToken(t *testing.T) { diff --git a/internal/commands/help.go b/internal/commands/help.go index ef85932..78eae8f 100644 --- a/internal/commands/help.go +++ b/internal/commands/help.go @@ -405,7 +405,7 @@ var commandExamples = map[string]string{ "fizzy config show": "$ fizzy config show\n$ fizzy config show --verbose", "fizzy config explain": "$ fizzy config explain\n$ fizzy config explain --profile acme", "fizzy doctor": "$ fizzy doctor\n$ fizzy doctor --profile acme\n$ fizzy doctor --all-profiles", - "fizzy search": "$ fizzy search \"billing bug\"\n$ fizzy search \"billing bug\" --board ", + "fizzy search": "$ fizzy search \"billing bug\"\n$ fizzy search ", "fizzy notification": "$ fizzy notification tray\n$ fizzy notification list", "fizzy notification tray": "$ fizzy notification tray", "fizzy user": "$ fizzy user list\n$ fizzy user show ", diff --git a/internal/commands/search.go b/internal/commands/search.go index 5b85af5..308656f 100644 --- a/internal/commands/search.go +++ b/internal/commands/search.go @@ -2,118 +2,49 @@ package commands import ( "fmt" - "strconv" "strings" "github.com/spf13/cobra" ) -// Search flags -var searchBoard string -var searchTag string -var searchAssignee string -var searchIndexedBy string -var searchSort string -var searchPage int -var searchAll bool - var searchCmd = &cobra.Command{ - Use: "search QUERY", + Use: "search QUERY...", Short: "Search cards", - Long: "Searches cards by text. Multiple words are treated as separate terms (AND).", - Args: cobra.MinimumNArgs(1), + Long: `Searches cards using the dedicated full-text search endpoint. + +The query is sent as a single string. If the query exactly matches a card ID, +that card is returned directly. + +To filter cards by structured criteria (board, tag, assignee, status, etc.), +use 'fizzy card list' with --search and the relevant filter flags.`, + Args: cobra.MinimumNArgs(1), RunE: func(cmd *cobra.Command, args []string) error { if err := requireAuthAndAccount(); err != nil { return err } - if err := checkLimitAll(searchAll); err != nil { - return err - } query := strings.Join(args, " ") ac := getSDK() - path := "/cards.json" - - var params []string - - // Add search terms - for term := range strings.FieldsSeq(query) { - params = append(params, "terms[]="+term) + raw, _, err := ac.Search().Search(cmd.Context(), &query) + if err != nil { + return convertSDKError(err) } - // Add optional filters (search is cross-board by default; - // only scope to a board when explicitly requested via --board) - if searchBoard != "" { - params = append(params, "board_ids[]="+searchBoard) - } - if searchTag != "" { - params = append(params, "tag_ids[]="+searchTag) - } - if searchAssignee != "" { - params = append(params, "assignee_ids[]="+searchAssignee) - } - if searchIndexedBy != "" { - params = append(params, "indexed_by="+searchIndexedBy) - } - if searchSort != "" { - params = append(params, "sorted_by="+searchSort) - } - if searchPage > 0 { - params = append(params, "page="+strconv.Itoa(searchPage)) - } - - if len(params) > 0 { - path += "?" + strings.Join(params, "&") - } - - var items any - var linkNext string - - if searchAll { - pages, err := ac.GetAll(cmd.Context(), path) - if err != nil { - return convertSDKError(err) - } - items = jsonAnySlice(pages) - } else { - data, resp, err := ac.Cards().List(cmd.Context(), path) - if err != nil { - return convertSDKError(err) - } - items = normalizeAny(data) - linkNext = parseSDKLinkNext(resp) - } - - // Build summary + items := normalizeAny(raw) count := dataCount(items) - summary := fmt.Sprintf("%d results for \"%s\"", count, query) - if searchAll { - summary = fmt.Sprintf("%d results for \"%s\" (all)", count, query) - } else if searchPage > 0 { - summary = fmt.Sprintf("%d results for \"%s\" (page %d)", count, query, searchPage) - } + summary := fmt.Sprintf("%d results for %q", count, query) - // Build breadcrumbs breadcrumbs := []Breadcrumb{ breadcrumb("show", "fizzy card show ", "View card details"), - breadcrumb("narrow", fmt.Sprintf("fizzy search \"%s\" --board ", query), "Filter by board"), + breadcrumb("filter", fmt.Sprintf("fizzy card list --search %q --board ", query), "Filter cards by criteria"), } - hasNext := linkNext != "" - printListPaginated(items, searchColumns, hasNext, linkNext, searchAll, summary, breadcrumbs) + printList(items, searchColumns, summary, breadcrumbs) return nil }, } func init() { rootCmd.AddCommand(searchCmd) - - searchCmd.Flags().StringVar(&searchBoard, "board", "", "Filter by board ID") - searchCmd.Flags().StringVar(&searchTag, "tag", "", "Filter by tag ID") - searchCmd.Flags().StringVar(&searchAssignee, "assignee", "", "Filter by assignee ID") - searchCmd.Flags().StringVar(&searchIndexedBy, "indexed-by", "", "Filter by status (all, closed, maybe, not_now, golden)") - searchCmd.Flags().StringVar(&searchSort, "sort", "", "Sort order: newest, oldest, or latest (default)") - searchCmd.Flags().IntVar(&searchPage, "page", 0, "Page number") - searchCmd.Flags().BoolVar(&searchAll, "all", false, "Fetch all pages") } diff --git a/internal/commands/search_test.go b/internal/commands/search_test.go index be4feea..5271211 100644 --- a/internal/commands/search_test.go +++ b/internal/commands/search_test.go @@ -8,12 +8,12 @@ import ( ) func TestSearch(t *testing.T) { - t.Run("searches cards with single term", func(t *testing.T) { + t.Run("single-word query hits /search.json with q param", func(t *testing.T) { mock := NewMockClient() mock.GetWithPaginationResponse = &client.APIResponse{ StatusCode: 200, Data: []any{ - map[string]any{"id": "1", "title": "Bug fix"}, + map[string]any{"id": "1", "number": float64(42), "title": "Bug fix"}, }, } @@ -24,39 +24,18 @@ func TestSearch(t *testing.T) { err := searchCmd.RunE(searchCmd, []string{"bug"}) assertExitCode(t, err, 0) - if err != nil { - t.Fatalf("unexpected error: %v", err) - } if !result.Response.OK { t.Error("expected success response") } - path := mock.GetWithPaginationCalls[0].Path - if path != "/cards.json?terms[]=bug" { - t.Errorf("expected path '/cards.json?terms[]=bug', got '%s'", path) + if len(mock.GetWithPaginationCalls) != 1 { + t.Fatalf("expected 1 GET call, got %d", len(mock.GetWithPaginationCalls)) } - }) - - t.Run("searches cards with multiple terms", func(t *testing.T) { - mock := NewMockClient() - mock.GetWithPaginationResponse = &client.APIResponse{ - StatusCode: 200, - Data: []any{}, - } - - SetTestModeWithSDK(mock) - SetTestConfig("token", "account", "https://api.example.com") - defer resetTest() - - err := searchCmd.RunE(searchCmd, []string{"login error"}) - assertExitCode(t, err, 0) - - path := mock.GetWithPaginationCalls[0].Path - if path != "/cards.json?terms[]=login&terms[]=error" { - t.Errorf("expected path with multiple terms, got '%s'", path) + if got := mock.GetWithPaginationCalls[0].Path; got != "/search.json?q=bug" { + t.Errorf("expected '/search.json?q=bug', got '%s'", got) } }) - t.Run("combines search with board filter", func(t *testing.T) { + t.Run("multiple args joined into a single q string", func(t *testing.T) { mock := NewMockClient() mock.GetWithPaginationResponse = &client.APIResponse{ StatusCode: 200, @@ -67,40 +46,18 @@ func TestSearch(t *testing.T) { SetTestConfig("token", "account", "https://api.example.com") defer resetTest() - searchBoard = "123" - err := searchCmd.RunE(searchCmd, []string{"bug"}) - searchBoard = "" - + err := searchCmd.RunE(searchCmd, []string{"login", "error"}) assertExitCode(t, err, 0) - path := mock.GetWithPaginationCalls[0].Path - if path != "/cards.json?terms[]=bug&board_ids[]=123" { - t.Errorf("expected path with board filter, got '%s'", path) - } - }) - t.Run("applies sort parameter", func(t *testing.T) { - mock := NewMockClient() - mock.GetWithPaginationResponse = &client.APIResponse{ - StatusCode: 200, - Data: []any{}, + if len(mock.GetWithPaginationCalls) != 1 { + t.Fatalf("expected 1 GET call, got %d", len(mock.GetWithPaginationCalls)) } - - SetTestModeWithSDK(mock) - SetTestConfig("token", "account", "https://api.example.com") - defer resetTest() - - searchSort = "newest" - err := searchCmd.RunE(searchCmd, []string{"bug"}) - searchSort = "" - - assertExitCode(t, err, 0) - path := mock.GetWithPaginationCalls[0].Path - if path != "/cards.json?terms[]=bug&sorted_by=newest" { - t.Errorf("expected path with sort, got '%s'", path) + if got := mock.GetWithPaginationCalls[0].Path; got != "/search.json?q=login+error" { + t.Errorf("expected '/search.json?q=login+error', got '%s'", got) } }) - t.Run("applies indexed-by parameter", func(t *testing.T) { + t.Run("query with special chars is URL-encoded", func(t *testing.T) { mock := NewMockClient() mock.GetWithPaginationResponse = &client.APIResponse{ StatusCode: 200, @@ -111,42 +68,18 @@ func TestSearch(t *testing.T) { SetTestConfig("token", "account", "https://api.example.com") defer resetTest() - searchIndexedBy = "closed" - err := searchCmd.RunE(searchCmd, []string{"bug"}) - searchIndexedBy = "" - + err := searchCmd.RunE(searchCmd, []string{"foo&bar=baz"}) assertExitCode(t, err, 0) - path := mock.GetWithPaginationCalls[0].Path - if path != "/cards.json?terms[]=bug&indexed_by=closed" { - t.Errorf("expected path with indexed_by, got '%s'", path) - } - }) - t.Run("does not inject default board into search", func(t *testing.T) { - mock := NewMockClient() - mock.GetWithPaginationResponse = &client.APIResponse{ - StatusCode: 200, - Data: []any{}, - } - - SetTestModeWithSDK(mock) - SetTestConfig("token", "account", "https://api.example.com") - cfg.Board = "default-board-id" - defer resetTest() - - err := searchCmd.RunE(searchCmd, []string{"bug"}) - - assertExitCode(t, err, 0) if len(mock.GetWithPaginationCalls) != 1 { - t.Fatalf("expected 1 GetWithPagination call, got %d", len(mock.GetWithPaginationCalls)) + t.Fatalf("expected 1 GET call, got %d", len(mock.GetWithPaginationCalls)) } - path := mock.GetWithPaginationCalls[0].Path - if path != "/cards.json?terms[]=bug" { - t.Errorf("expected no board_ids in path, got '%s'", path) + if got := mock.GetWithPaginationCalls[0].Path; got != "/search.json?q=foo%26bar%3Dbaz" { + t.Errorf("expected URL-encoded q, got '%s'", got) } }) - t.Run("tag filter works cross-board with default board set", func(t *testing.T) { + t.Run("no default board injection", func(t *testing.T) { mock := NewMockClient() mock.GetWithPaginationResponse = &client.APIResponse{ StatusCode: 200, @@ -158,53 +91,42 @@ func TestSearch(t *testing.T) { cfg.Board = "default-board-id" defer resetTest() - searchTag = "tag-123" err := searchCmd.RunE(searchCmd, []string{"bug"}) - searchTag = "" - assertExitCode(t, err, 0) + if len(mock.GetWithPaginationCalls) != 1 { - t.Fatalf("expected 1 GetWithPagination call, got %d", len(mock.GetWithPaginationCalls)) + t.Fatalf("expected 1 GET call, got %d", len(mock.GetWithPaginationCalls)) } - path := mock.GetWithPaginationCalls[0].Path - if path != "/cards.json?terms[]=bug&tag_ids[]=tag-123" { - t.Errorf("expected tag filter without board_ids, got '%s'", path) + if got := mock.GetWithPaginationCalls[0].Path; got != "/search.json?q=bug" { + t.Errorf("expected no board params in path, got '%s'", got) } }) - t.Run("assignee filter works cross-board with default board set", func(t *testing.T) { - mock := NewMockClient() - mock.GetWithPaginationResponse = &client.APIResponse{ - StatusCode: 200, - Data: []any{}, + t.Run("requires at least one arg", func(t *testing.T) { + if err := searchCmd.Args(searchCmd, []string{}); err == nil { + t.Error("expected error when no query args provided") } + }) + t.Run("requires authentication", func(t *testing.T) { + mock := NewMockClient() SetTestModeWithSDK(mock) - SetTestConfig("token", "account", "https://api.example.com") - cfg.Board = "default-board-id" + SetTestConfig("", "account", "https://api.example.com") defer resetTest() - searchAssignee = "user-456" err := searchCmd.RunE(searchCmd, []string{"bug"}) - searchAssignee = "" - - assertExitCode(t, err, 0) - if len(mock.GetWithPaginationCalls) != 1 { - t.Fatalf("expected 1 GetWithPagination call, got %d", len(mock.GetWithPaginationCalls)) - } - path := mock.GetWithPaginationCalls[0].Path - if path != "/cards.json?terms[]=bug&assignee_ids[]=user-456" { - t.Errorf("expected assignee filter without board_ids, got '%s'", path) - } + assertExitCode(t, err, errors.ExitAuthFailure) }) - t.Run("requires authentication", func(t *testing.T) { + t.Run("propagates not-found from server", func(t *testing.T) { mock := NewMockClient() + mock.GetError = errors.NewNotFoundError("not found") + SetTestModeWithSDK(mock) - SetTestConfig("", "account", "https://api.example.com") + SetTestConfig("token", "account", "https://api.example.com") defer resetTest() err := searchCmd.RunE(searchCmd, []string{"bug"}) - assertExitCode(t, err, errors.ExitAuthFailure) + assertExitCode(t, err, errors.ExitNotFound) }) } diff --git a/internal/harness/agent_test.go b/internal/harness/agent_test.go index 77d4413..af1c945 100644 --- a/internal/harness/agent_test.go +++ b/internal/harness/agent_test.go @@ -16,6 +16,7 @@ func TestRegisterAgent(t *testing.T) { agent := FindAgent("test") if agent == nil { t.Fatal("expected to find registered agent") + return } if agent.Name != "Test Agent" { t.Errorf("expected name 'Test Agent', got %q", agent.Name) diff --git a/skills/fizzy/SKILL.md b/skills/fizzy/SKILL.md index 52f3437..f39696a 100644 --- a/skills/fizzy/SKILL.md +++ b/skills/fizzy/SKILL.md @@ -163,7 +163,6 @@ Commands supporting `--all` and `--page`: - `board postponed` - `board stream` - `card list` -- `search` - `comment list` - `tag list` - `user list` @@ -469,26 +468,27 @@ The `auto_postpone_period_in_days` is the account-level default. Cards are autom ### Search -Quick text search across cards. Multiple words are treated as separate terms (AND). +Full-text search across cards. The query is sent as a single string to the +dedicated search endpoint; if the query exactly matches a card ID, that card +is returned directly. ```bash -fizzy search QUERY [flags] - --board ID # Filter by board - --assignee ID # Filter by assignee user ID - --tag ID # Filter by tag ID - --indexed-by LANE # Filter: all, closed, maybe, not_now, golden - --sort ORDER # Sort: newest, oldest, or latest (default) - --page N # Page number - --all # Fetch all pages +fizzy search QUERY ``` **Examples:** ```bash fizzy search "bug" # Search for "bug" -fizzy search "login error" # Search for cards containing both "login" AND "error" -fizzy search "bug" --board BOARD_ID # Search within a specific board -fizzy search "bug" --indexed-by closed # Include closed cards -fizzy search "feature" --sort newest # Sort by newest first +fizzy search "login error" # Single-string FTS query +fizzy search 12345 # Card-ID lookup shortcut +``` + +To filter cards by structured criteria (board, tag, assignee, status, sort, +or AND-of-words term filtering), use `fizzy card list` with `--search` and +the relevant filter flags: + +```bash +fizzy card list --search "bug" --board BOARD_ID --indexed-by closed --sort newest ``` ### Activities @@ -909,11 +909,11 @@ fizzy card move 579 --to TARGET_BOARD_ID ### Search and Filter Cards ```bash -# Quick search +# Full-text search fizzy search "bug" --jq '[.data[] | {number, title}]' -# Search with filters -fizzy search "login" --board BOARD_ID --sort newest +# Filter cards by criteria (use card list, not search) +fizzy card list --search "login" --board BOARD_ID --sort newest # Find recently created cards fizzy card list --created today --sort newest