superthread 0.8.1 → 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: 05edf5da03e7cda08cca6e16f9813bb8f464b7847f3717a669eeb69802a67208
4
- data.tar.gz: bdb2593ece548e526dfd602ca061e40541d09a062b633919133bf5ab6cc672c7
3
+ metadata.gz: 764ae1eedbe67d7c951c99b3cf3269cbeb11d51ab8e905348a52a18124459877
4
+ data.tar.gz: 01e80e6cb854aeb2a58f0c864a18ef3c0fbe26a56bb43bc10c5058b8c3d96216
5
5
  SHA512:
6
- metadata.gz: 7800b583a4a5b07062e460c1b053e593b3c344037d0a6dbf84e576097be4dfdb67594d61f021eaf46aa1bf9ec6b16b93ce5c9c31633331dfa5ef13b1742e82f6
7
- data.tar.gz: 0d547f6b5c884715da432f8fdfa68593435f85c20cd06786c0f6594f33381ee661f30058c86912595124d3b113fd91978861146e578c3bf8513213d5cd33e269
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)
@@ -205,6 +204,7 @@ module Superthread
205
204
 
206
205
  desc "update CARD", "Update a card"
207
206
  option :title, type: :string, desc: "New title"
207
+ option :content, type: :string, desc: "Card content (HTML). Use {{@Name}} to mention users"
208
208
  option :list, type: :string, aliases: "-l", desc: "Destination list (ID or name)"
209
209
  option :board, type: :string, aliases: "-b", desc: "Board (helps resolve list name)"
210
210
  option :sprint, type: :string, desc: "Sprint to move card to (ID or name)"
@@ -215,26 +215,47 @@ module Superthread
215
215
  option :archived, type: :boolean, desc: "Archive/unarchive"
216
216
  # Update an existing card's properties.
217
217
  #
218
+ # Content is updated via a separate PUT endpoint since the standard
219
+ # PATCH endpoint does not support content changes.
220
+ #
218
221
  # @param card_id [String] the unique identifier of the card to update
219
222
  # @return [void]
220
223
  def update(card_id)
221
224
  handle_error do
222
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
+
228
+ # Update content via dedicated PUT endpoint (separate from PATCH)
229
+ if options[:content]
230
+ with_not_found(not_found_msg) do
231
+ client.cards.update_content(workspace_id, card_id, content: options[:content])
232
+ end
233
+ end
234
+
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]
223
238
 
224
- begin
239
+ if has_patch_fields
225
240
  # WORKAROUND: API ignores title when combined with list_id,
226
241
  # so we make separate requests when both are provided.
227
242
  # TODO: Remove when API is fixed (https://superthread.com/api/known-issues)
228
- if options[:list] && (options[:title] || options[:priority] || options[:archived] || options[:epic])
243
+ if options[:list] && (options[:title] || options[:priority] || !options[:archived].nil? || options[:epic])
229
244
  # First update non-move fields
230
245
  field_opts = symbolized_options(:title, :priority, :archived)
231
246
  field_opts[:epic_id] = options[:epic] if options[:epic]
232
- client.cards.update(workspace_id, card_id, **field_opts) unless field_opts.empty?
247
+ unless field_opts.empty?
248
+ with_not_found(not_found_msg) do
249
+ client.cards.update(workspace_id, card_id, **field_opts)
250
+ end
251
+ end
233
252
 
234
253
  # Then move the card (include sprint context)
235
254
  move_opts = resolve_list_with_context(options[:list], card_id)
236
255
  move_opts[:position] = options[:position] if options[:position]
237
- card = client.cards.update(workspace_id, card_id, **move_opts)
256
+ card = with_not_found(not_found_msg) do
257
+ client.cards.update(workspace_id, card_id, **move_opts)
258
+ end
238
259
  else
239
260
  opts = symbolized_options(:title, :priority, :archived)
240
261
  opts[:epic_id] = options[:epic] if options[:epic]
@@ -250,10 +271,15 @@ module Superthread
250
271
  opts[:list_id] = sprint_obj.lists.first&.id
251
272
  end
252
273
 
253
- 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)
276
+ end
254
277
  end
255
- rescue Superthread::ForbiddenError, Superthread::NotFoundError
256
- raise Thor::Error, "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
278
+ end
279
+
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)
257
283
  end
258
284
  output_item card, labels: {id: "Card ID"}
259
285
  end
@@ -266,10 +292,8 @@ module Superthread
266
292
  # @return [void]
267
293
  def delete(card_ref)
268
294
  handle_error do
269
- begin
270
- card = client.cards.find(workspace_id, card_ref)
271
- rescue Superthread::ForbiddenError, Superthread::NotFoundError
272
- 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)
273
297
  end
274
298
  confirming("Delete card '#{card.title}' (#{card.id})?") do
275
299
  client.cards.destroy(workspace_id, card.id)
@@ -290,14 +314,12 @@ module Superthread
290
314
  # @return [void]
291
315
  def duplicate(card_id)
292
316
  handle_error do
293
- begin
294
- opts = symbolized_options(:title)
295
- opts[:project_id] = options[:project]
296
- opts[:board_id] = board_id
297
- opts[:list_id] = resolve_list(options[:list])
298
- card = client.cards.duplicate(workspace_id, card_id, **opts)
299
- rescue Superthread::ForbiddenError, Superthread::NotFoundError
300
- 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)
301
323
  end
302
324
  output_item card, labels: {id: "Card ID"}
303
325
  end
@@ -321,10 +343,11 @@ module Superthread
321
343
  opts[:user_id] = resolve_user(user_ref)
322
344
  opts[:board_id] = board_id if options[:board]
323
345
  opts[:project_id] = options[:project] if options[:project]
324
- cards = client.cards.assigned(workspace_id, **opts)
346
+ cards = client.cards.assigned(workspace_id, **opts).to_a
325
347
 
326
348
  # Client-side date filtering (API doesn't support time-based filters)
327
- 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]
328
351
 
329
352
  enrich_members(cards)
330
353
  output_list cards, columns: %i[id title priority list_title members],
@@ -474,76 +497,73 @@ module Superthread
474
497
  # Resolve a list reference and include sprint context when needed.
475
498
  #
476
499
  # The API requires sprint_id and project_id alongside list_id when moving
477
- # cards within a sprint. This method handles three scenarios:
478
- # 1. --board or --sprint given: resolve list name with that context
479
- # 2. Neither given: fetch the card to discover its sprint/board context
480
- # 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.
481
502
  #
482
503
  # @param list_ref [String] the list ID or name to resolve
483
504
  # @param card_id [String] the card identifier to look up for context
484
505
  # @return [Hash] params hash with :list_id and optional :sprint_id, :project_id
485
506
  def resolve_list_with_context(list_ref, card_id)
486
- result = {}
487
-
488
507
  if options[:board] || options[:sprint]
489
- result[:list_id] = resolve_list(list_ref)
490
- if options[:sprint]
491
- result[:sprint_id] = sprint_id
492
- result[:project_id] = space_id
493
- end
508
+ resolve_list_with_explicit_context(list_ref)
494
509
  else
495
510
  existing = client.cards.find(workspace_id, card_id)
496
511
  if existing.sprint_id
497
- sprint_obj = client.sprints.find(workspace_id, existing.sprint_id,
498
- space_id: existing.project_id)
499
- list = sprint_obj.lists&.find { |l| l.title&.downcase == list_ref.downcase }
500
- unless list || looks_like_id?(list_ref)
501
- available = sprint_obj.lists&.map(&:title)&.join(", ") || "none"
502
- raise Thor::Error, "List '#{list_ref}' not found in sprint. Available: #{available}"
503
- end
504
- result[:list_id] = list ? list.id : list_ref
505
- result[:sprint_id] = existing.sprint_id
506
- result[:project_id] = existing.project_id
507
- elsif looks_like_id?(list_ref)
508
- result[:list_id] = list_ref
512
+ resolve_list_in_sprint_context(existing, list_ref)
509
513
  else
510
- board = client.boards.find(workspace_id, existing.board_id)
511
- list = board.lists&.find { |l| l.title&.downcase == list_ref.downcase }
512
- unless list
513
- available = board.lists&.map(&:title)&.join(", ") || "none"
514
- raise Thor::Error, "List '#{list_ref}' not found on board. Available: #{available}"
515
- end
516
- result[:list_id] = list.id
514
+ resolve_list_on_board(existing, list_ref)
517
515
  end
518
516
  end
517
+ end
519
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
520
529
  result
521
530
  end
522
531
 
523
- # Apply date filters to a collection of cards.
524
- # The API doesn't support time-based filtering, so we filter client-side.
532
+ # Resolve list for a card that lives in a sprint.
525
533
  #
526
- # @param cards [Enumerable] the cards to apply --since and --updated_since filters to
527
- # @return [Array] Filtered cards
528
- def apply_date_filters(cards)
529
- since_ts = parse_date(options[:since]) if options[:since]
530
- updated_ts = parse_date(options[:updated_since]) if options[:updated_since]
531
- return cards.to_a unless since_ts || updated_ts
532
-
533
- cards.to_a.select do |card|
534
- (since_ts.nil? || meets_date_threshold?(card.time_created, since_ts)) &&
535
- (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}"
536
544
  end
545
+ {
546
+ list_id: list ? list.id : list_ref,
547
+ sprint_id: existing.sprint_id,
548
+ project_id: existing.project_id
549
+ }
537
550
  end
538
551
 
539
- # Check if a timestamp meets a minimum threshold after normalization.
552
+ # Resolve list for a card that lives on a board (no sprint).
540
553
  #
541
- # @param timestamp [Object] the raw timestamp value from the model
542
- # @param threshold [Integer] the minimum Unix timestamp
543
- # @return [Boolean] true if the normalized timestamp meets or exceeds the threshold
544
- def meets_date_threshold?(timestamp, threshold)
545
- ts = Formatter.normalize_timestamp(timestamp)
546
- 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}
547
567
  end
548
568
 
549
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.
@@ -55,19 +55,40 @@ module Superthread
55
55
 
56
56
  desc "update PAGE", "Update a page"
57
57
  option :title, type: :string, desc: "New title"
58
+ option :content, type: :string, desc: "Page content (HTML)"
58
59
  option :is_public, type: :boolean, desc: "Public visibility"
59
60
  option :parent_page, type: :string, desc: "Parent page ID"
60
61
  option :archived, type: :boolean, desc: "Archive/unarchive"
61
62
  # Updates an existing page's properties.
62
63
  #
64
+ # Content is updated via a separate PUT endpoint since the standard
65
+ # PATCH endpoint does not support content changes.
66
+ #
63
67
  # @param page_id [String] numeric ID or URL slug of the page to retrieve
64
68
  # @return [void]
65
69
  def update(page_id)
66
70
  handle_error do
67
- opts = symbolized_options(:title, :is_public, :archived)
68
- opts[:parent_page_id] = options[:parent_page] if options[:parent_page]
69
- page = with_not_found("Page not found: '#{page_id}'. Use 'suth pages list' to see available pages.") do
70
- client.pages.update(workspace_id, page_id, **opts)
71
+ # Update content via dedicated PUT endpoint (separate from PATCH)
72
+ if options[:content]
73
+ with_not_found("Page not found: '#{page_id}'. Use 'suth pages list' to see available pages.") do
74
+ client.pages.update_content(workspace_id, page_id, content: options[:content])
75
+ end
76
+ end
77
+
78
+ # Update other fields via PATCH (skip if only content was provided)
79
+ has_patch_fields = options[:title] || !options[:is_public].nil? || !options[:archived].nil? || options[:parent_page]
80
+
81
+ if has_patch_fields
82
+ opts = symbolized_options(:title, :is_public, :archived)
83
+ opts[:parent_page_id] = options[:parent_page] if options[:parent_page]
84
+ page = with_not_found("Page not found: '#{page_id}'. Use 'suth pages list' to see available pages.") do
85
+ client.pages.update(workspace_id, page_id, **opts)
86
+ end
87
+ end
88
+
89
+ # If only content was updated, fetch the page for output
90
+ page ||= with_not_found("Page not found: '#{page_id}'. Use 'suth pages list' to see available pages.") do
91
+ client.pages.find(workspace_id, page_id)
71
92
  end
72
93
  output_item page, labels: {id: "Page ID"}
73
94
  end
@@ -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
@@ -95,6 +95,20 @@ module Superthread
95
95
  )
96
96
  end
97
97
 
98
+ # Performs a PUT request and returns a typed object.
99
+ #
100
+ # @param path [String] the API endpoint path
101
+ # @param body [Hash{Symbol => Object}, nil] optional request body
102
+ # @param object_class [Class, nil] the model class to instantiate
103
+ # @param unwrap_key [Symbol, nil] key to extract from response before wrapping
104
+ # @return [Superthread::Object] the response wrapped in an object
105
+ def put_object(path, body: nil, object_class: nil, unwrap_key: nil)
106
+ @client.request_object(
107
+ method: :put, path: path, body: body,
108
+ object_class: object_class, unwrap_key: unwrap_key
109
+ )
110
+ end
111
+
98
112
  # Performs a DELETE request and returns a typed object.
99
113
  #
100
114
  # @param path [String] the API endpoint path
@@ -183,16 +197,6 @@ module Superthread
183
197
  MentionFormatter.new(@client, workspace_id).format(content)
184
198
  end
185
199
 
186
- # Builds an API path prefixed with the workspace ID.
187
- #
188
- # @param workspace_id [String] the workspace identifier
189
- # @param path [String] additional path segments to append
190
- # @return [String] the full API path (e.g., "/ws-123/cards")
191
- def workspace_path(workspace_id, path = "")
192
- ws = safe_id("workspace_id", workspace_id)
193
- "/#{ws}#{path}"
194
- end
195
-
196
200
  # Returns a success response object for delete operations.
197
201
  #
198
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
@@ -52,7 +55,7 @@ module Superthread
52
55
  # @option params [String] :epic_id the new epic identifier
53
56
  # @option params [Boolean] :archived whether the card is archived
54
57
  # @return [Superthread::Models::Card] the updated card
55
- # @note Content cannot be updated via this API; it uses WebSocket collaboration.
58
+ # @note Content updates use a separate PUT endpoint; use {#update_content} instead.
56
59
  # @note parent_card_id is not supported on update; the API silently ignores it.
57
60
  def update(workspace_id, card_id, **params)
58
61
  ws = safe_id("workspace_id", workspace_id)
@@ -61,6 +64,23 @@ module Superthread
61
64
  object_class: Models::Card, unwrap_key: :card)
62
65
  end
63
66
 
67
+ # Updates a card's content (description) via the dedicated PUT endpoint.
68
+ #
69
+ # Card content cannot be updated through the standard PATCH endpoint
70
+ # because the web app uses WebSocket collaboration for real-time editing.
71
+ # This method uses the separate PUT endpoint for content updates.
72
+ #
73
+ # @param workspace_id [String] the workspace identifier
74
+ # @param card_id [String] the card identifier
75
+ # @param content [String] the new content as HTML
76
+ # @return [Superthread::Object] a response object with success: true
77
+ def update_content(workspace_id, card_id, content:)
78
+ ws = safe_id("workspace_id", workspace_id)
79
+ card = safe_id("card_id", card_id)
80
+ content = format_mentions(workspace_id, content)
81
+ put_object("/#{ws}/cards/#{card}/content", body: {content: content, is_html: true})
82
+ end
83
+
64
84
  # Gets a specific card with full details.
65
85
  #
66
86
  # @param workspace_id [String] the workspace identifier
@@ -115,8 +135,7 @@ module Superthread
115
135
  ws = safe_id("workspace_id", workspace_id)
116
136
  body = {type: "card", card_filters: build_card_filters(filters)}
117
137
 
118
- post_collection("/#{ws}/views/preview", body: body,
119
- item_class: Models::Card, items_key: :cards)
138
+ paginated_card_list("/#{ws}/views/preview", body: body)
120
139
  end
121
140
 
122
141
  # Gets cards assigned to a user.
@@ -133,8 +152,7 @@ module Superthread
133
152
  ws = safe_id("workspace_id", workspace_id)
134
153
  body = {type: "card", card_filters: build_card_filters(filters, members: [user_id])}
135
154
 
136
- post_collection("/#{ws}/views/preview", body: body,
137
- item_class: Models::Card, items_key: :cards)
155
+ paginated_card_list("/#{ws}/views/preview", body: body)
138
156
  end
139
157
 
140
158
  # Links two cards with a relationship.
@@ -341,6 +359,34 @@ module Superthread
341
359
 
342
360
  private
343
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
+
344
390
  # Builds the card_filters hash for the views/preview endpoint.
345
391
  #
346
392
  # @param filters [Hash{Symbol => Object}] filter options
@@ -56,10 +56,10 @@ module Superthread
56
56
  # @param page_id [String] the page identifier
57
57
  # @param params [Hash{Symbol => Object}] the attributes to update
58
58
  # @option params [String] :title the new page title
59
- # @option params [String] :content the new page content as HTML
60
59
  # @option params [Boolean] :archived whether the page is archived
61
60
  # @option params [String] :parent_page_id the new parent page identifier
62
61
  # @return [Superthread::Models::Page] the updated page
62
+ # @note Content updates use a separate PUT endpoint; use {#update_content} instead.
63
63
  def update(workspace_id, page_id, **params)
64
64
  ws = safe_id("workspace_id", workspace_id)
65
65
  page = safe_id("page_id", page_id)
@@ -67,6 +67,21 @@ module Superthread
67
67
  object_class: Models::Page, unwrap_key: :page)
68
68
  end
69
69
 
70
+ # Updates a page's content via the dedicated PUT endpoint.
71
+ #
72
+ # Page content cannot be updated through the standard PATCH endpoint.
73
+ # This method uses the separate PUT endpoint for content updates.
74
+ #
75
+ # @param workspace_id [String] the workspace identifier
76
+ # @param page_id [String] the page identifier
77
+ # @param content [String] the new content as HTML
78
+ # @return [Superthread::Object] a response object with success: true
79
+ def update_content(workspace_id, page_id, content:)
80
+ ws = safe_id("workspace_id", workspace_id)
81
+ page = safe_id("page_id", page_id)
82
+ put_object("/#{ws}/pages/#{page}/content", body: {content: content, is_html: true})
83
+ end
84
+
70
85
  # Duplicates a page to a destination space.
71
86
  #
72
87
  # @param workspace_id [String] the workspace identifier
@@ -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.8.1"
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.8.1
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