From 8ceee870aaaad704c4ece76c54348d4c60057795 Mon Sep 17 00:00:00 2001 From: Steve Clarke Date: Tue, 24 Mar 2026 13:58:36 -0230 Subject: [PATCH] fix: paginate cards list and fix search returning 0 results Cards list (#73): The views/preview API paginates at 25 items but the CLI only fetched page 1. Added cursor-based pagination that follows all pages via query parameter, matching the pattern already used by search. Both `list` and `assigned` methods now return complete results. Search (#74): The API defaults `grouped` to true when the parameter is omitted. The CLI passed `grouped: nil` which compact_params stripped, so the API returned type-grouped results while the code read from `response[:results]` (only populated when grouped=false). Now ensures grouped=false is always sent when not explicitly set to true. Closes #73, closes #74 --- lib/superthread/resources/cards.rb | 37 ++++++++++++++-- lib/superthread/resources/search.rb | 2 +- spec/superthread/resources/cards_spec.rb | 52 +++++++++++++++++++++++ spec/superthread/resources/search_spec.rb | 11 +++++ 4 files changed, 97 insertions(+), 5 deletions(-) diff --git a/lib/superthread/resources/cards.rb b/lib/superthread/resources/cards.rb index 84399e5..6e02b4d 100644 --- a/lib/superthread/resources/cards.rb +++ b/lib/superthread/resources/cards.rb @@ -7,6 +7,9 @@ module Resources # Provides methods for creating, updating, listing, and managing cards # including their members, checklists, tags, and relationships. class Cards < Base + # Safety cap on pagination requests to prevent runaway loops. + MAX_PAGES = 100 + # Creates a new card on a board or in a sprint. # # @param workspace_id [String] the workspace identifier @@ -132,8 +135,7 @@ def list(workspace_id, **filters) ws = safe_id("workspace_id", workspace_id) body = {type: "card", card_filters: build_card_filters(filters)} - post_collection("/#{ws}/views/preview", body: body, - item_class: Models::Card, items_key: :cards) + paginated_card_list("/#{ws}/views/preview", body: body) end # Gets cards assigned to a user. @@ -150,8 +152,7 @@ def assigned(workspace_id, user_id:, **filters) ws = safe_id("workspace_id", workspace_id) body = {type: "card", card_filters: build_card_filters(filters, members: [user_id])} - post_collection("/#{ws}/views/preview", body: body, - item_class: Models::Card, items_key: :cards) + paginated_card_list("/#{ws}/views/preview", body: body) end # Links two cards with a relationship. @@ -358,6 +359,34 @@ def remove_tag(workspace_id, card_id, tag_id) private + # Fetches all pages from the views/preview endpoint. + # + # The API returns max 25 cards per page with a cursor for pagination. + # The cursor must be passed as a query parameter on subsequent requests. + # + # @param path [String] the API endpoint path + # @param body [Hash] the request body with type and card_filters + # @return [Superthread::Objects::Collection] all matching cards + def paginated_card_list(path, body:) + all_cards = [] + cursor = nil + pages = 0 + + loop do + params = cursor ? {cursor: cursor} : nil + response = @client.request(method: :post, path: path, params: params, body: body) + + all_cards.concat(response[:cards] || []) + + cursor = response[:cursor] + pages += 1 + break if cursor.nil? || cursor.empty? + break if pages >= MAX_PAGES + end + + Objects::Collection.from_response(all_cards, item_class: Models::Card) + end + # Builds the card_filters hash for the views/preview endpoint. # # @param filters [Hash{Symbol => Object}] filter options diff --git a/lib/superthread/resources/search.rb b/lib/superthread/resources/search.rb index dab6b01..421731d 100644 --- a/lib/superthread/resources/search.rb +++ b/lib/superthread/resources/search.rb @@ -28,7 +28,7 @@ class Search < Base # @return [Superthread::Objects::Collection] the search results def query(workspace_id, query:, limit: nil, **params) ws = safe_id("workspace_id", workspace_id) - grouped = params.fetch(:grouped, false) + grouped = params.fetch(:grouped, false) || false all_results = [] cursor = nil pages = 0 diff --git a/spec/superthread/resources/cards_spec.rb b/spec/superthread/resources/cards_spec.rb index ce1e2bd..de8eb5d 100644 --- a/spec/superthread/resources/cards_spec.rb +++ b/spec/superthread/resources/cards_spec.rb @@ -10,6 +10,7 @@ describe "#list with sprint filter" do before do stub_api_post("test_workspace/views/preview", response: { + count: 1, cursor: "", cards: [{id: "card-s1", title: "Sprint Card", priority: 2, list_title: "To Do"}] }) end @@ -25,6 +26,57 @@ end end + describe "#list pagination" do + let(:url) { "https://api.superthread.com/v1/test_workspace/views/preview" } + + it "follows cursor to fetch all pages" do + page1 = {count: 3, cursor: "page2cursor", + cards: [{id: "card-1", title: "Card 1"}, {id: "card-2", title: "Card 2"}]} + page2 = {count: 3, cursor: "", + cards: [{id: "card-3", title: "Card 3"}]} + + stub_request(:post, url) + .with { |req| req.uri.query.nil? } + .to_return(status: 200, body: page1.to_json, headers: {"Content-Type" => "application/json"}) + stub_request(:post, url) + .with(query: {"cursor" => "page2cursor"}) + .to_return(status: 200, body: page2.to_json, headers: {"Content-Type" => "application/json"}) + + result = client.cards.list("test_workspace", board_id: "board-1") + + expect(result.count).to eq(3) + expect(result.map(&:id)).to eq(%w[card-1 card-2 card-3]) + end + + it "passes cursor as query parameter" do + page1 = {count: 2, cursor: "abc123", cards: [{id: "card-1", title: "Card 1"}]} + page2 = {count: 2, cursor: "", cards: [{id: "card-2", title: "Card 2"}]} + + stub_request(:post, url) + .with { |req| req.uri.query.nil? } + .to_return(status: 200, body: page1.to_json, headers: {"Content-Type" => "application/json"}) + stub_request(:post, url) + .with(query: {"cursor" => "abc123"}) + .to_return(status: 200, body: page2.to_json, headers: {"Content-Type" => "application/json"}) + + client.cards.list("test_workspace", board_id: "board-1") + + expect(WebMock).to have_requested(:post, url) + .with(query: {"cursor" => "abc123"}).once + end + + it "stops when cursor is empty" do + page1 = {count: 1, cursor: "", cards: [{id: "card-1", title: "Card 1"}]} + + stub_api_post("test_workspace/views/preview", response: page1) + + result = client.cards.list("test_workspace", board_id: "board-1") + + expect(result.count).to eq(1) + expect(WebMock).to have_requested(:post, url).once + end + end + describe "#update_content" do before do stub_api_put("test_workspace/cards/card-123/content", diff --git a/spec/superthread/resources/search_spec.rb b/spec/superthread/resources/search_spec.rb index 08e1cef..4edce1c 100644 --- a/spec/superthread/resources/search_spec.rb +++ b/spec/superthread/resources/search_spec.rb @@ -45,6 +45,17 @@ expect(results.map { |r| r[:title] }).to eq(["Result 1", "Result 2"]) end + it "sends grouped=false even when nil is passed" do + stub_api_get("test_workspace/search", + response: {count: 1, cursor: "", results: [{card: {id: "c1", title: "Found"}}]}, + query: {query: "test", grouped: "false"}) + + results = client.search.query("test_workspace", query: "test", grouped: nil) + + expect(results.count).to eq(1) + expect(results.first[:title]).to eq("Found") + end + it "stops at limit" do page1 = { count: 3,