superthread 0.9.0 → 0.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6e15ca652fe609604e401611f3359af9d094872c537a8fb817048631cf5a0cf1
4
- data.tar.gz: a55736ee16a8ae5b232e0a8c72b3acc42dad25a1bef131b33e50a6dd6f3df950
3
+ metadata.gz: 764ae1eedbe67d7c951c99b3cf3269cbeb11d51ab8e905348a52a18124459877
4
+ data.tar.gz: 01e80e6cb854aeb2a58f0c864a18ef3c0fbe26a56bb43bc10c5058b8c3d96216
5
5
  SHA512:
6
- metadata.gz: e215412fbe73aaa60fb213fa028d78a3eb933f04023fe9889bc7cf20ad9fd969e5b88091929224883b49d06a20ff3db9581a34b7a7c8831e923d7ede8a72a08a
7
- data.tar.gz: 1f322daa33b43c271173051cbac4b4d414eea4bfc52c90d6216baa74ba569596f8e812a078b4be7226a6e93d8e0ab0281a8f12d368ba4cde05332c79242393d8
6
+ metadata.gz: 58bbc5745f81aa529127ae86148ddc0e0d55334af6c73896633e5ed02ffd8762ff00ab7991fb3df3a0c7ca15ebc9f269e05c90e55a48ad282ce71992477332b1
7
+ data.tar.gz: ca562a028c48552b3ca6ba1d146dc0f02c9544ae91a33b0f252b8018b4a77abc315743f1c258e95e93d596a38eb3cc6f990d7a9b7b76be307429a1a266357f84
@@ -89,9 +89,9 @@ module Superthread
89
89
  }
90
90
 
91
91
  cards.each do |card|
92
- created_ts = normalize_timestamp(card.time_created)
93
- updated_ts = normalize_timestamp(card.time_updated)
94
- completed_ts = normalize_timestamp(card.completed_date)
92
+ created_ts = TimeUtils.normalize_timestamp(card.time_created)
93
+ updated_ts = TimeUtils.normalize_timestamp(card.time_updated)
94
+ completed_ts = TimeUtils.normalize_timestamp(card.completed_date)
95
95
 
96
96
  # Check for completion (takes precedence)
97
97
  if completed_ts && completed_ts >= since_ts
@@ -113,14 +113,6 @@ module Superthread
113
113
  activity
114
114
  end
115
115
 
116
- # Normalize timestamp to seconds (API sometimes returns milliseconds).
117
- #
118
- # @param ts [Integer, nil] Timestamp
119
- # @return [Integer, nil] Normalized timestamp in seconds
120
- def normalize_timestamp(ts)
121
- Formatter.normalize_timestamp(ts)
122
- end
123
-
124
116
  # Output activity as JSON.
125
117
  #
126
118
  # @param activity [Hash] the categorized activity data with :created, :updated, :completed keys
@@ -41,10 +41,11 @@ module Superthread
41
41
  end
42
42
 
43
43
  opts[:list_id] = resolve_list(options[:list]) if options[:list]
44
- cards = client.cards.list(workspace_id, **opts)
44
+ cards = client.cards.list(workspace_id, **opts).to_a
45
45
 
46
46
  # Client-side date filtering (API doesn't support time-based filters)
47
- cards = apply_date_filters(cards)
47
+ cards = filter_by_date(cards, field: :time_created, since: parse_date(options[:since])) if options[:since]
48
+ cards = filter_by_date(cards, field: :time_updated, since: parse_date(options[:updated_since])) if options[:updated_since]
48
49
 
49
50
  enrich_members(cards)
50
51
  output_list cards, columns: %i[id title priority list_title members],
@@ -107,10 +108,8 @@ module Superthread
107
108
  # @return [void]
108
109
  def get(card_id)
109
110
  handle_error do
110
- begin
111
- card = client.cards.find(workspace_id, card_id)
112
- rescue Superthread::ForbiddenError, Superthread::NotFoundError
113
- raise Thor::Error, "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
111
+ card = with_not_found("Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards.") do
112
+ client.cards.find(workspace_id, card_id)
114
113
  end
115
114
 
116
115
  enrich_members(card)
@@ -224,54 +223,63 @@ module Superthread
224
223
  def update(card_id)
225
224
  handle_error do
226
225
  require_space_for_sprint!
226
+ not_found_msg = "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
227
227
 
228
- begin
229
- # Update content via dedicated PUT endpoint (separate from PATCH)
230
- if options[:content]
228
+ # Update content via dedicated PUT endpoint (separate from PATCH)
229
+ if options[:content]
230
+ with_not_found(not_found_msg) do
231
231
  client.cards.update_content(workspace_id, card_id, content: options[:content])
232
232
  end
233
+ end
233
234
 
234
- # Update other fields via PATCH (skip if only content was provided)
235
- has_patch_fields = options[:title] || options[:list] || options[:priority] ||
236
- !options[:archived].nil? || options[:epic] || options[:position] || options[:sprint]
237
-
238
- if has_patch_fields
239
- # WORKAROUND: API ignores title when combined with list_id,
240
- # so we make separate requests when both are provided.
241
- # TODO: Remove when API is fixed (https://superthread.com/api/known-issues)
242
- if options[:list] && (options[:title] || options[:priority] || !options[:archived].nil? || options[:epic])
243
- # First update non-move fields
244
- field_opts = symbolized_options(:title, :priority, :archived)
245
- field_opts[:epic_id] = options[:epic] if options[:epic]
246
- client.cards.update(workspace_id, card_id, **field_opts) unless field_opts.empty?
247
-
248
- # Then move the card (include sprint context)
249
- move_opts = resolve_list_with_context(options[:list], card_id)
250
- move_opts[:position] = options[:position] if options[:position]
251
- card = client.cards.update(workspace_id, card_id, **move_opts)
252
- else
253
- opts = symbolized_options(:title, :priority, :archived)
254
- opts[:epic_id] = options[:epic] if options[:epic]
255
- opts[:position] = options[:position] if options[:position]
256
-
257
- if options[:list]
258
- opts.merge!(resolve_list_with_context(options[:list], card_id))
259
- elsif options[:sprint]
260
- # Moving to a sprint without --list — default to first list
261
- opts[:sprint_id] = sprint_id
262
- opts[:project_id] = space_id
263
- sprint_obj = client.sprints.find(workspace_id, sprint_id, space_id: space_id)
264
- opts[:list_id] = sprint_obj.lists.first&.id
235
+ # Update other fields via PATCH (skip if only content was provided)
236
+ has_patch_fields = options[:title] || options[:list] || options[:priority] ||
237
+ !options[:archived].nil? || options[:epic] || options[:position] || options[:sprint]
238
+
239
+ if has_patch_fields
240
+ # WORKAROUND: API ignores title when combined with list_id,
241
+ # so we make separate requests when both are provided.
242
+ # TODO: Remove when API is fixed (https://superthread.com/api/known-issues)
243
+ if options[:list] && (options[:title] || options[:priority] || !options[:archived].nil? || options[:epic])
244
+ # First update non-move fields
245
+ field_opts = symbolized_options(:title, :priority, :archived)
246
+ field_opts[:epic_id] = options[:epic] if options[:epic]
247
+ unless field_opts.empty?
248
+ with_not_found(not_found_msg) do
249
+ client.cards.update(workspace_id, card_id, **field_opts)
265
250
  end
251
+ end
252
+
253
+ # Then move the card (include sprint context)
254
+ move_opts = resolve_list_with_context(options[:list], card_id)
255
+ move_opts[:position] = options[:position] if options[:position]
256
+ card = with_not_found(not_found_msg) do
257
+ client.cards.update(workspace_id, card_id, **move_opts)
258
+ end
259
+ else
260
+ opts = symbolized_options(:title, :priority, :archived)
261
+ opts[:epic_id] = options[:epic] if options[:epic]
262
+ opts[:position] = options[:position] if options[:position]
263
+
264
+ if options[:list]
265
+ opts.merge!(resolve_list_with_context(options[:list], card_id))
266
+ elsif options[:sprint]
267
+ # Moving to a sprint without --list — default to first list
268
+ opts[:sprint_id] = sprint_id
269
+ opts[:project_id] = space_id
270
+ sprint_obj = client.sprints.find(workspace_id, sprint_id, space_id: space_id)
271
+ opts[:list_id] = sprint_obj.lists.first&.id
272
+ end
266
273
 
267
- card = client.cards.update(workspace_id, card_id, **opts)
274
+ card = with_not_found(not_found_msg) do
275
+ client.cards.update(workspace_id, card_id, **opts)
268
276
  end
269
277
  end
278
+ end
270
279
 
271
- # If only content was updated, fetch the card for output
272
- card ||= client.cards.find(workspace_id, card_id)
273
- rescue Superthread::ForbiddenError, Superthread::NotFoundError
274
- raise Thor::Error, "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
280
+ # If only content was updated, fetch the card for output
281
+ card ||= with_not_found(not_found_msg) do
282
+ client.cards.find(workspace_id, card_id)
275
283
  end
276
284
  output_item card, labels: {id: "Card ID"}
277
285
  end
@@ -284,10 +292,8 @@ module Superthread
284
292
  # @return [void]
285
293
  def delete(card_ref)
286
294
  handle_error do
287
- begin
288
- card = client.cards.find(workspace_id, card_ref)
289
- rescue Superthread::ForbiddenError, Superthread::NotFoundError
290
- raise Thor::Error, "Card not found: '#{card_ref}'. Use 'suth cards list -b BOARD' to see available cards."
295
+ card = with_not_found("Card not found: '#{card_ref}'. Use 'suth cards list -b BOARD' to see available cards.") do
296
+ client.cards.find(workspace_id, card_ref)
291
297
  end
292
298
  confirming("Delete card '#{card.title}' (#{card.id})?") do
293
299
  client.cards.destroy(workspace_id, card.id)
@@ -308,14 +314,12 @@ module Superthread
308
314
  # @return [void]
309
315
  def duplicate(card_id)
310
316
  handle_error do
311
- begin
312
- opts = symbolized_options(:title)
313
- opts[:project_id] = options[:project]
314
- opts[:board_id] = board_id
315
- opts[:list_id] = resolve_list(options[:list])
316
- card = client.cards.duplicate(workspace_id, card_id, **opts)
317
- rescue Superthread::ForbiddenError, Superthread::NotFoundError
318
- raise Thor::Error, "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
317
+ opts = symbolized_options(:title)
318
+ opts[:project_id] = options[:project]
319
+ opts[:board_id] = board_id
320
+ opts[:list_id] = resolve_list(options[:list])
321
+ card = with_not_found("Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards.") do
322
+ client.cards.duplicate(workspace_id, card_id, **opts)
319
323
  end
320
324
  output_item card, labels: {id: "Card ID"}
321
325
  end
@@ -339,10 +343,11 @@ module Superthread
339
343
  opts[:user_id] = resolve_user(user_ref)
340
344
  opts[:board_id] = board_id if options[:board]
341
345
  opts[:project_id] = options[:project] if options[:project]
342
- cards = client.cards.assigned(workspace_id, **opts)
346
+ cards = client.cards.assigned(workspace_id, **opts).to_a
343
347
 
344
348
  # Client-side date filtering (API doesn't support time-based filters)
345
- cards = apply_date_filters(cards)
349
+ cards = filter_by_date(cards, field: :time_created, since: parse_date(options[:since])) if options[:since]
350
+ cards = filter_by_date(cards, field: :time_updated, since: parse_date(options[:updated_since])) if options[:updated_since]
346
351
 
347
352
  enrich_members(cards)
348
353
  output_list cards, columns: %i[id title priority list_title members],
@@ -492,76 +497,73 @@ module Superthread
492
497
  # Resolve a list reference and include sprint context when needed.
493
498
  #
494
499
  # The API requires sprint_id and project_id alongside list_id when moving
495
- # cards within a sprint. This method handles three scenarios:
496
- # 1. --board or --sprint given: resolve list name with that context
497
- # 2. Neither given: fetch the card to discover its sprint/board context
498
- # 3. Card is on a board (no sprint): resolve normally
500
+ # cards within a sprint. Dispatches to one of three strategies based on
501
+ # whether explicit context (--board/--sprint) was provided.
499
502
  #
500
503
  # @param list_ref [String] the list ID or name to resolve
501
504
  # @param card_id [String] the card identifier to look up for context
502
505
  # @return [Hash] params hash with :list_id and optional :sprint_id, :project_id
503
506
  def resolve_list_with_context(list_ref, card_id)
504
- result = {}
505
-
506
507
  if options[:board] || options[:sprint]
507
- result[:list_id] = resolve_list(list_ref)
508
- if options[:sprint]
509
- result[:sprint_id] = sprint_id
510
- result[:project_id] = space_id
511
- end
508
+ resolve_list_with_explicit_context(list_ref)
512
509
  else
513
510
  existing = client.cards.find(workspace_id, card_id)
514
511
  if existing.sprint_id
515
- sprint_obj = client.sprints.find(workspace_id, existing.sprint_id,
516
- space_id: existing.project_id)
517
- list = sprint_obj.lists&.find { |l| l.title&.downcase == list_ref.downcase }
518
- unless list || looks_like_id?(list_ref)
519
- available = sprint_obj.lists&.map(&:title)&.join(", ") || "none"
520
- raise Thor::Error, "List '#{list_ref}' not found in sprint. Available: #{available}"
521
- end
522
- result[:list_id] = list ? list.id : list_ref
523
- result[:sprint_id] = existing.sprint_id
524
- result[:project_id] = existing.project_id
525
- elsif looks_like_id?(list_ref)
526
- result[:list_id] = list_ref
512
+ resolve_list_in_sprint_context(existing, list_ref)
527
513
  else
528
- board = client.boards.find(workspace_id, existing.board_id)
529
- list = board.lists&.find { |l| l.title&.downcase == list_ref.downcase }
530
- unless list
531
- available = board.lists&.map(&:title)&.join(", ") || "none"
532
- raise Thor::Error, "List '#{list_ref}' not found on board. Available: #{available}"
533
- end
534
- result[:list_id] = list.id
514
+ resolve_list_on_board(existing, list_ref)
535
515
  end
536
516
  end
517
+ end
537
518
 
519
+ # Resolve list when --board or --sprint was explicitly provided.
520
+ #
521
+ # @param list_ref [String] the list ID or name
522
+ # @return [Hash] params hash with :list_id and optional :sprint_id, :project_id
523
+ def resolve_list_with_explicit_context(list_ref)
524
+ result = {list_id: resolve_list(list_ref)}
525
+ if options[:sprint]
526
+ result[:sprint_id] = sprint_id
527
+ result[:project_id] = space_id
528
+ end
538
529
  result
539
530
  end
540
531
 
541
- # Apply date filters to a collection of cards.
542
- # The API doesn't support time-based filtering, so we filter client-side.
532
+ # Resolve list for a card that lives in a sprint.
543
533
  #
544
- # @param cards [Enumerable] the cards to apply --since and --updated_since filters to
545
- # @return [Array] Filtered cards
546
- def apply_date_filters(cards)
547
- since_ts = parse_date(options[:since]) if options[:since]
548
- updated_ts = parse_date(options[:updated_since]) if options[:updated_since]
549
- return cards.to_a unless since_ts || updated_ts
550
-
551
- cards.to_a.select do |card|
552
- (since_ts.nil? || meets_date_threshold?(card.time_created, since_ts)) &&
553
- (updated_ts.nil? || meets_date_threshold?(card.time_updated, updated_ts))
534
+ # @param existing [Card] the current card with sprint context
535
+ # @param list_ref [String] the list ID or name
536
+ # @return [Hash] params hash with :list_id, :sprint_id, :project_id
537
+ def resolve_list_in_sprint_context(existing, list_ref)
538
+ sprint_obj = client.sprints.find(workspace_id, existing.sprint_id,
539
+ space_id: existing.project_id)
540
+ list = sprint_obj.lists&.find { |l| l.title&.downcase == list_ref.downcase }
541
+ unless list || looks_like_id?(list_ref)
542
+ available = sprint_obj.lists&.map(&:title)&.join(", ") || "none"
543
+ raise Thor::Error, "List '#{list_ref}' not found in sprint. Available: #{available}"
554
544
  end
545
+ {
546
+ list_id: list ? list.id : list_ref,
547
+ sprint_id: existing.sprint_id,
548
+ project_id: existing.project_id
549
+ }
555
550
  end
556
551
 
557
- # Check if a timestamp meets a minimum threshold after normalization.
552
+ # Resolve list for a card that lives on a board (no sprint).
558
553
  #
559
- # @param timestamp [Object] the raw timestamp value from the model
560
- # @param threshold [Integer] the minimum Unix timestamp
561
- # @return [Boolean] true if the normalized timestamp meets or exceeds the threshold
562
- def meets_date_threshold?(timestamp, threshold)
563
- ts = Formatter.normalize_timestamp(timestamp)
564
- ts && ts >= threshold
554
+ # @param existing [Card] the current card with board context
555
+ # @param list_ref [String] the list ID or name
556
+ # @return [Hash] params hash with :list_id
557
+ def resolve_list_on_board(existing, list_ref)
558
+ return {list_id: list_ref} if looks_like_id?(list_ref)
559
+
560
+ board = client.boards.find(workspace_id, existing.board_id)
561
+ list = board.lists&.find { |l| l.title&.downcase == list_ref.downcase }
562
+ unless list
563
+ available = board.lists&.map(&:title)&.join(", ") || "none"
564
+ raise Thor::Error, "List '#{list_ref}' not found on board. Available: #{available}"
565
+ end
566
+ {list_id: list.id}
565
567
  end
566
568
 
567
569
  # Output card relationships (parent, children, links) in human-readable format.
@@ -4,6 +4,7 @@ require "active_support/concern"
4
4
 
5
5
  module Superthread
6
6
  module Cli
7
+ # Shared behavior modules for CLI commands.
7
8
  module Concerns
8
9
  # Resolves board references (ID or name) to board IDs.
9
10
  #
@@ -105,7 +105,7 @@ module Superthread
105
105
  ts = item.send(field)
106
106
  next false if ts.nil?
107
107
 
108
- ts = Formatter.normalize_timestamp(ts)
108
+ ts = Superthread::TimeUtils.normalize_timestamp(ts)
109
109
  next false if ts.nil?
110
110
 
111
111
  (since.nil? || ts >= since) && (until_time.nil? || ts <= until_time)
@@ -50,17 +50,9 @@ module Superthread
50
50
  }.freeze
51
51
 
52
52
  # Human-readable labels for priority levels.
53
+ # References the canonical constant from Models::Card.
53
54
  # @return [Hash{Integer => String}]
54
- PRIORITY_LABELS = {
55
- 4 => "urgent",
56
- 3 => "high",
57
- 2 => "medium",
58
- 1 => "low"
59
- }.freeze
60
-
61
- # Threshold for detecting millisecond timestamps vs second timestamps.
62
- # Timestamps above this value are assumed to be in milliseconds.
63
- MAX_SECONDS_TIMESTAMP = 9_999_999_999
55
+ PRIORITY_LABELS = Superthread::Models::Card::PRIORITY_NAMES
64
56
 
65
57
  module_function
66
58
 
@@ -73,13 +65,12 @@ module Superthread
73
65
  end
74
66
 
75
67
  # Normalizes a timestamp to seconds (API sometimes returns milliseconds).
68
+ # Delegates to {Superthread::TimeUtils.normalize_timestamp}.
76
69
  #
77
70
  # @param ts [Integer, nil] timestamp in seconds or milliseconds
78
71
  # @return [Integer, nil] timestamp in seconds, or nil if nil/zero
79
72
  def normalize_timestamp(ts)
80
- return nil if ts.nil? || ts == 0
81
-
82
- (ts > MAX_SECONDS_TIMESTAMP) ? ts / 1000 : ts
73
+ Superthread::TimeUtils.normalize_timestamp(ts)
83
74
  end
84
75
 
85
76
  # Truncates a string to a maximum length with an ellipsis indicator.
@@ -22,6 +22,15 @@ module Superthread
22
22
  include Concerns::Presentable
23
23
  include Concerns::Timestampable
24
24
 
25
+ # Human-readable labels for priority levels.
26
+ # @return [Hash{Integer => String}]
27
+ PRIORITY_NAMES = {
28
+ 4 => "urgent",
29
+ 3 => "high",
30
+ 2 => "medium",
31
+ 1 => "low"
32
+ }.freeze
33
+
25
34
  detail_fields :id, :title, :status, :priority, :list_title, :board_title, :time_created, :time_updated
26
35
  list_columns :id, :title, :status, :priority, :list_title
27
36
 
@@ -198,7 +207,7 @@ module Superthread
198
207
  #
199
208
  # @return [String, nil] priority label (urgent, high, medium, low)
200
209
  def priority_name
201
- Cli::Formatter::PRIORITY_LABELS[priority]
210
+ PRIORITY_NAMES[priority]
202
211
  end
203
212
 
204
213
  # Returns the parent card as a CardRef, or nil if none.
@@ -233,28 +242,6 @@ module Superthread
233
242
  end
234
243
  end
235
244
 
236
- # Represents a linked card with relationship type.
237
- #
238
- # Extends Card with linked_card_type attribute to indicate how this
239
- # card relates to another (blocks, blocked_by, related, duplicates).
240
- #
241
- # @example
242
- # linked = card.linked_cards.first
243
- # linked.relationship # => "blocks"
244
- # linked.title # => "Other Card"
245
- class LinkedCard < Card
246
- # @!attribute [rw] linked_card_type
247
- # @return [String] relationship type (blocks, blocked_by, related, duplicates)
248
- attribute :linked_card_type, Shale::Type::String
249
-
250
- # Returns the relationship type between this card and the linked card.
251
- #
252
- # @return [String] relationship type (blocks, blocked_by, related, duplicates)
253
- def relationship
254
- linked_card_type
255
- end
256
- end
257
-
258
245
  # Lightweight reference to a card (id + title only).
259
246
  #
260
247
  # Used for parent/child relationships to avoid circular model references.
@@ -309,13 +296,6 @@ module Superthread
309
296
  super
310
297
  @relationship = data["linked_card_type"] || data[:linked_card_type]
311
298
  end
312
-
313
- # Returns a human-readable string representation.
314
- #
315
- # @return [String] title with ID in parentheses
316
- def to_s
317
- "#{title} (#{id})"
318
- end
319
299
  end
320
300
  end
321
301
  end
@@ -197,16 +197,6 @@ module Superthread
197
197
  MentionFormatter.new(@client, workspace_id).format(content)
198
198
  end
199
199
 
200
- # Builds an API path prefixed with the workspace ID.
201
- #
202
- # @param workspace_id [String] the workspace identifier
203
- # @param path [String] additional path segments to append
204
- # @return [String] the full API path (e.g., "/ws-123/cards")
205
- def workspace_path(workspace_id, path = "")
206
- ws = safe_id("workspace_id", workspace_id)
207
- "/#{ws}#{path}"
208
- end
209
-
210
200
  # Returns a success response object for delete operations.
211
201
  #
212
202
  # @return [Superthread::Object] a response object with success: true
@@ -7,6 +7,9 @@ module Superthread
7
7
  # Provides methods for creating, updating, listing, and managing cards
8
8
  # including their members, checklists, tags, and relationships.
9
9
  class Cards < Base
10
+ # Safety cap on pagination requests to prevent runaway loops.
11
+ MAX_PAGES = 100
12
+
10
13
  # Creates a new card on a board or in a sprint.
11
14
  #
12
15
  # @param workspace_id [String] the workspace identifier
@@ -132,8 +135,7 @@ module Superthread
132
135
  ws = safe_id("workspace_id", workspace_id)
133
136
  body = {type: "card", card_filters: build_card_filters(filters)}
134
137
 
135
- post_collection("/#{ws}/views/preview", body: body,
136
- item_class: Models::Card, items_key: :cards)
138
+ paginated_card_list("/#{ws}/views/preview", body: body)
137
139
  end
138
140
 
139
141
  # Gets cards assigned to a user.
@@ -150,8 +152,7 @@ module Superthread
150
152
  ws = safe_id("workspace_id", workspace_id)
151
153
  body = {type: "card", card_filters: build_card_filters(filters, members: [user_id])}
152
154
 
153
- post_collection("/#{ws}/views/preview", body: body,
154
- item_class: Models::Card, items_key: :cards)
155
+ paginated_card_list("/#{ws}/views/preview", body: body)
155
156
  end
156
157
 
157
158
  # Links two cards with a relationship.
@@ -358,6 +359,34 @@ module Superthread
358
359
 
359
360
  private
360
361
 
362
+ # Fetches all pages from the views/preview endpoint.
363
+ #
364
+ # The API returns max 25 cards per page with a cursor for pagination.
365
+ # The cursor must be passed as a query parameter on subsequent requests.
366
+ #
367
+ # @param path [String] the API endpoint path
368
+ # @param body [Hash] the request body with type and card_filters
369
+ # @return [Superthread::Objects::Collection<Superthread::Models::Card>] all matching cards
370
+ def paginated_card_list(path, body:)
371
+ all_cards = []
372
+ cursor = nil
373
+ pages = 0
374
+
375
+ loop do
376
+ params = cursor ? {cursor: cursor} : nil
377
+ response = @client.request(method: :post, path: path, params: params, body: body)
378
+
379
+ all_cards.concat(response[:cards] || [])
380
+
381
+ cursor = response[:cursor]
382
+ pages += 1
383
+ break if cursor.nil? || cursor.empty?
384
+ break if pages >= MAX_PAGES
385
+ end
386
+
387
+ Objects::Collection.from_response(all_cards, item_class: Models::Card)
388
+ end
389
+
361
390
  # Builds the card_filters hash for the views/preview endpoint.
362
391
  #
363
392
  # @param filters [Hash{Symbol => Object}] filter options
@@ -28,7 +28,7 @@ module Superthread
28
28
  # @return [Superthread::Objects::Collection] the search results
29
29
  def query(workspace_id, query:, limit: nil, **params)
30
30
  ws = safe_id("workspace_id", workspace_id)
31
- grouped = params.fetch(:grouped, false)
31
+ grouped = params.fetch(:grouped, false) || false
32
32
  all_results = []
33
33
  cursor = nil
34
34
  pages = 0
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ # Shared timestamp utilities used across layers.
5
+ module TimeUtils
6
+ # Threshold for detecting millisecond timestamps vs second timestamps.
7
+ # Timestamps above this value are assumed to be in milliseconds.
8
+ MAX_SECONDS_TIMESTAMP = 9_999_999_999
9
+
10
+ module_function
11
+
12
+ # Normalizes a timestamp to seconds (API sometimes returns milliseconds).
13
+ #
14
+ # @param ts [Integer, nil] timestamp in seconds or milliseconds
15
+ # @return [Integer, nil] timestamp in seconds, or nil if nil/zero
16
+ def normalize_timestamp(ts)
17
+ return nil if ts.nil? || ts == 0
18
+
19
+ (ts > MAX_SECONDS_TIMESTAMP) ? ts / 1000 : ts
20
+ end
21
+ end
22
+ end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Superthread
4
4
  # Current version of the Superthread gem.
5
- VERSION = "0.9.0"
5
+ VERSION = "0.9.1"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: superthread
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.9.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steve Clarke
@@ -160,7 +160,6 @@ files:
160
160
  - lib/superthread/cli/comments.rb
161
161
  - lib/superthread/cli/completion.rb
162
162
  - lib/superthread/cli/concerns/board_resolvable.rb
163
- - lib/superthread/cli/concerns/confirmable.rb
164
163
  - lib/superthread/cli/concerns/date_parsable.rb
165
164
  - lib/superthread/cli/concerns/list_resolvable.rb
166
165
  - lib/superthread/cli/concerns/space_resolvable.rb
@@ -225,6 +224,7 @@ files:
225
224
  - lib/superthread/resources/sprints.rb
226
225
  - lib/superthread/resources/tags.rb
227
226
  - lib/superthread/resources/users.rb
227
+ - lib/superthread/time_utils.rb
228
228
  - lib/superthread/version.rb
229
229
  - lib/superthread/version_checker.rb
230
230
  homepage: https://github.com/steveclarke/superthread
@@ -1,55 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "active_support/concern"
4
-
5
- module Superthread
6
- module Cli
7
- # Shared behavior modules for CLI commands.
8
- module Concerns
9
- # Provides confirmation prompts for destructive actions.
10
- # Respects the --force flag to skip confirmation.
11
- #
12
- # @example
13
- # class Cards < Base
14
- # include Concerns::Confirmable
15
- #
16
- # def delete(card_id)
17
- # handle_error do
18
- # confirming("Delete card #{card_id}?") do
19
- # client.cards.destroy(workspace_id, card_id)
20
- # Ui.success "Card #{card_id} deleted"
21
- # end
22
- # end
23
- # end
24
- # end
25
- module Confirmable
26
- extend ActiveSupport::Concern
27
-
28
- # Execute a block with optional confirmation prompt.
29
- #
30
- # Skips confirmation if --force flag is set, otherwise prompts user.
31
- #
32
- # @param message [String] the confirmation question to display
33
- # @yield the block to execute if user confirms
34
- # @return [Object, nil] the block's return value, or nil if cancelled
35
- def confirming(message)
36
- if options[:force] || confirm_action(message)
37
- yield
38
- else
39
- Ui.muted "Cancelled."
40
- end
41
- end
42
-
43
- private
44
-
45
- # Prompt user for yes/no confirmation via the UI.
46
- #
47
- # @param message [String] the confirmation question to display
48
- # @return [Boolean] true if user confirms, false otherwise
49
- def confirm_action(message)
50
- Ui.confirm(message)
51
- end
52
- end
53
- end
54
- end
55
- end