Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 33 additions & 4 deletions lib/superthread/resources/cards.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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<Superthread::Models::Card>] 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
Expand Down
2 changes: 1 addition & 1 deletion lib/superthread/resources/search.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
52 changes: 52 additions & 0 deletions spec/superthread/resources/cards_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions spec/superthread/resources/search_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down