superthread 0.7.2

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.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +4 -0
  3. data/LICENSE +21 -0
  4. data/README.md +492 -0
  5. data/exe/suth +19 -0
  6. data/lib/superthread/cli/accounts.rb +240 -0
  7. data/lib/superthread/cli/activity.rb +210 -0
  8. data/lib/superthread/cli/base.rb +355 -0
  9. data/lib/superthread/cli/boards.rb +131 -0
  10. data/lib/superthread/cli/cards.rb +530 -0
  11. data/lib/superthread/cli/checklists.rb +223 -0
  12. data/lib/superthread/cli/comments.rb +86 -0
  13. data/lib/superthread/cli/completion.rb +306 -0
  14. data/lib/superthread/cli/concerns/board_resolvable.rb +70 -0
  15. data/lib/superthread/cli/concerns/confirmable.rb +55 -0
  16. data/lib/superthread/cli/concerns/date_parsable.rb +196 -0
  17. data/lib/superthread/cli/concerns/list_resolvable.rb +53 -0
  18. data/lib/superthread/cli/concerns/space_resolvable.rb +52 -0
  19. data/lib/superthread/cli/concerns/sprint_resolvable.rb +55 -0
  20. data/lib/superthread/cli/concerns/tag_resolvable.rb +49 -0
  21. data/lib/superthread/cli/concerns/user_resolvable.rb +52 -0
  22. data/lib/superthread/cli/concerns/workspace_resolvable.rb +83 -0
  23. data/lib/superthread/cli/config.rb +129 -0
  24. data/lib/superthread/cli/formatter.rb +388 -0
  25. data/lib/superthread/cli/lists.rb +85 -0
  26. data/lib/superthread/cli/main.rb +121 -0
  27. data/lib/superthread/cli/members.rb +19 -0
  28. data/lib/superthread/cli/notes.rb +64 -0
  29. data/lib/superthread/cli/pages.rb +128 -0
  30. data/lib/superthread/cli/projects.rb +124 -0
  31. data/lib/superthread/cli/replies.rb +94 -0
  32. data/lib/superthread/cli/search.rb +34 -0
  33. data/lib/superthread/cli/setup.rb +253 -0
  34. data/lib/superthread/cli/spaces.rb +141 -0
  35. data/lib/superthread/cli/sprints.rb +32 -0
  36. data/lib/superthread/cli/tags.rb +86 -0
  37. data/lib/superthread/cli/ui/gum_prompt.rb +58 -0
  38. data/lib/superthread/cli/ui/plain_prompt.rb +73 -0
  39. data/lib/superthread/cli/ui.rb +263 -0
  40. data/lib/superthread/cli/workspaces.rb +105 -0
  41. data/lib/superthread/cli.rb +12 -0
  42. data/lib/superthread/client.rb +207 -0
  43. data/lib/superthread/configuration.rb +354 -0
  44. data/lib/superthread/connection.rb +57 -0
  45. data/lib/superthread/error.rb +164 -0
  46. data/lib/superthread/mention_formatter.rb +96 -0
  47. data/lib/superthread/model.rb +178 -0
  48. data/lib/superthread/models/board.rb +59 -0
  49. data/lib/superthread/models/card.rb +321 -0
  50. data/lib/superthread/models/checklist.rb +91 -0
  51. data/lib/superthread/models/checklist_item.rb +69 -0
  52. data/lib/superthread/models/comment.rb +71 -0
  53. data/lib/superthread/models/concerns/archivable.rb +32 -0
  54. data/lib/superthread/models/concerns/presentable.rb +113 -0
  55. data/lib/superthread/models/concerns/timestampable.rb +91 -0
  56. data/lib/superthread/models/list.rb +67 -0
  57. data/lib/superthread/models/member.rb +40 -0
  58. data/lib/superthread/models/note.rb +56 -0
  59. data/lib/superthread/models/page.rb +70 -0
  60. data/lib/superthread/models/project.rb +83 -0
  61. data/lib/superthread/models/space.rb +71 -0
  62. data/lib/superthread/models/sprint.rb +53 -0
  63. data/lib/superthread/models/tag.rb +52 -0
  64. data/lib/superthread/models/team.rb +68 -0
  65. data/lib/superthread/models/user.rb +76 -0
  66. data/lib/superthread/models.rb +12 -0
  67. data/lib/superthread/object.rb +285 -0
  68. data/lib/superthread/objects/collection.rb +179 -0
  69. data/lib/superthread/resources/base.rb +204 -0
  70. data/lib/superthread/resources/boards.rb +150 -0
  71. data/lib/superthread/resources/cards.rb +363 -0
  72. data/lib/superthread/resources/comments.rb +163 -0
  73. data/lib/superthread/resources/notes.rb +61 -0
  74. data/lib/superthread/resources/pages.rb +110 -0
  75. data/lib/superthread/resources/projects.rb +117 -0
  76. data/lib/superthread/resources/search.rb +46 -0
  77. data/lib/superthread/resources/spaces.rb +104 -0
  78. data/lib/superthread/resources/sprints.rb +37 -0
  79. data/lib/superthread/resources/tags.rb +52 -0
  80. data/lib/superthread/resources/users.rb +29 -0
  81. data/lib/superthread/version.rb +6 -0
  82. data/lib/superthread/version_checker.rb +174 -0
  83. data/lib/superthread.rb +30 -0
  84. metadata +259 -0
@@ -0,0 +1,530 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ module Cli
5
+ # CLI commands for managing Superthread cards.
6
+ #
7
+ # Provides subcommands for listing, creating, updating, and deleting cards,
8
+ # as well as managing card assignments, relationships, checklists, and tags.
9
+ class Cards < Base
10
+ include Concerns::DateParsable
11
+
12
+ # Valid relationship types for linking cards.
13
+ LINK_RELATION_TYPES = %w[blocks blocked_by related duplicates].freeze
14
+
15
+ desc "list", "List cards on a board or sprint"
16
+ option :board, type: :string, aliases: "-b", desc: "Board (ID or name, required unless --sprint)"
17
+ option :sprint, type: :string, desc: "Sprint (ID or name, required unless --board)"
18
+ option :space, type: :string, aliases: "-s", desc: "Space (required for --sprint, helps resolve board name)"
19
+ option :list, type: :string, aliases: "-l", desc: "List to filter by (ID or name)"
20
+ option :include_archived, type: :boolean, desc: "Include archived cards"
21
+ option :since, type: :string, desc: "Filter by created date (e.g., 'friday', '3 days ago', '2026-01-20')"
22
+ option :updated_since, type: :string, desc: "Filter by updated date"
23
+ # List cards on a board or sprint with optional filtering.
24
+ #
25
+ # @return [void]
26
+ # @raise [Thor::Error] if neither --board nor --sprint is provided
27
+ # @raise [Thor::Error] if --sprint is used without --space
28
+ def list
29
+ handle_error do
30
+ raise Thor::Error, "Either --board or --sprint is required" unless options[:board] || options[:sprint]
31
+ require_space_for_sprint!
32
+
33
+ opts = {}
34
+ opts[:archived] = options[:include_archived] if options[:include_archived]
35
+
36
+ if options[:sprint]
37
+ opts[:sprint_id] = sprint_id
38
+ opts[:project_id] = space_id
39
+ else
40
+ opts[:board_id] = board_id
41
+ end
42
+
43
+ opts[:list_id] = resolve_list(options[:list]) if options[:list]
44
+ cards = client.cards.list(workspace_id, **opts)
45
+
46
+ # Client-side date filtering (API doesn't support time-based filters)
47
+ cards = apply_date_filters(cards)
48
+
49
+ enrich_members(cards)
50
+ output_list cards, columns: %i[id title priority list_title members],
51
+ headers: {id: "CARD_ID", list_title: "LIST"}
52
+ end
53
+ end
54
+
55
+ desc "get CARD", "Get card details"
56
+ option :raw, type: :boolean, desc: "Show raw content without markdown rendering"
57
+ option :no_content, type: :boolean, desc: "Hide content, show only metadata"
58
+ # Display detailed information about a specific card.
59
+ #
60
+ # @param card_id [String] the unique identifier of the card to retrieve
61
+ # @return [void]
62
+ def get(card_id)
63
+ handle_error do
64
+ begin
65
+ card = client.cards.find(workspace_id, card_id)
66
+ rescue Superthread::ForbiddenError, Superthread::NotFoundError
67
+ raise Thor::Error, "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
68
+ end
69
+
70
+ enrich_members(card)
71
+
72
+ if json_output?
73
+ fields = %i[id title status priority list_title board_title
74
+ members start_date due_date time_created time_updated
75
+ parent_card child_cards linked_cards checklists]
76
+ fields.insert(2, :content) unless options[:no_content]
77
+ output_item card, fields: fields, labels: {
78
+ id: "Card ID",
79
+ list_title: "List Title",
80
+ board_title: "Board Title",
81
+ parent_card: "Parent Card",
82
+ child_cards: "Child Cards",
83
+ linked_cards: "Linked Cards",
84
+ start_date: "Start Date",
85
+ due_date: "Due Date",
86
+ time_created: "Time Created",
87
+ time_updated: "Time Updated"
88
+ }
89
+ else
90
+ # Output metadata fields (status used for coloring list, not shown separately)
91
+ output_item card,
92
+ fields: %i[id title priority list_title board_title
93
+ members start_date due_date time_created time_updated],
94
+ labels: {
95
+ id: "Card ID",
96
+ list_title: "List",
97
+ board_title: "Board",
98
+ start_date: "Start Date",
99
+ due_date: "Due Date",
100
+ time_created: "Time Created",
101
+ time_updated: "Time Updated"
102
+ }
103
+
104
+ # Output card relationships
105
+ output_card_relationships(card)
106
+
107
+ # Output checklists
108
+ output_card_checklists(card)
109
+
110
+ # Render content separately with markdown formatting
111
+ if !options[:no_content] && card.content && !card.content.empty?
112
+ puts ""
113
+ Ui.section "Content"
114
+ if options[:raw]
115
+ puts card.content
116
+ else
117
+ puts Ui.render_markdown(card.content)
118
+ end
119
+ end
120
+ end
121
+ end
122
+ end
123
+
124
+ desc "create", "Create a new card"
125
+ option :title, type: :string, required: true, desc: "Card title"
126
+ option :list, type: :string, required: true, aliases: "-l", desc: "Destination list (ID or name)"
127
+ option :board, type: :string, aliases: "-b", desc: "Board (ID or name, required unless --sprint)"
128
+ option :space, type: :string, aliases: "-s", desc: "Space (required for --sprint, helps resolve board name)"
129
+ option :sprint, type: :string, desc: "Sprint (ID or name, required unless --board)"
130
+ option :content, type: :string, desc: "Card content (HTML)"
131
+ option :project, type: :string, desc: "Project ID"
132
+ option :start_date, type: :numeric, desc: "Start date (Unix timestamp)"
133
+ option :due_date, type: :numeric, desc: "Due date (Unix timestamp)"
134
+ option :priority, type: :numeric, desc: "Priority level (1=low, 4=urgent)"
135
+ option :parent_card, type: :string, desc: "Parent card ID"
136
+ option :epic, type: :string, desc: "Epic ID"
137
+ option :owner, type: :string, aliases: "-o", desc: "Owner (user ID, name, or email)"
138
+ # Create a new card on a board or sprint.
139
+ #
140
+ # @return [void]
141
+ # @raise [Thor::Error] if neither --board nor --sprint is provided
142
+ def create
143
+ handle_error do
144
+ raise Thor::Error, "Either --board or --sprint is required" unless options[:board] || options[:sprint]
145
+ require_space_for_sprint!
146
+
147
+ opts = symbolized_options(:title, :content, :start_date, :due_date, :priority)
148
+ opts[:list_id] = resolve_list(options[:list])
149
+ opts[:board_id] = board_id if options[:board]
150
+ opts[:sprint_id] = sprint_id if options[:sprint]
151
+ opts[:project_id] = options[:project] || (space_id if options[:sprint])
152
+ opts[:parent_card_id] = options[:parent_card] if options[:parent_card]
153
+ opts[:epic_id] = options[:epic] if options[:epic]
154
+ opts[:owner_id] = resolve_user(options[:owner]) if options[:owner]
155
+ card = client.cards.create(workspace_id, **opts)
156
+ output_item card, labels: {id: "Card ID"}
157
+ end
158
+ end
159
+
160
+ desc "update CARD", "Update a card"
161
+ option :title, type: :string, desc: "New title"
162
+ option :list, type: :string, aliases: "-l", desc: "Destination list (ID or name)"
163
+ option :board, type: :string, aliases: "-b", desc: "Board (helps resolve list name)"
164
+ option :sprint, type: :string, desc: "Sprint to move card to (ID or name)"
165
+ option :space, type: :string, aliases: "-s", desc: "Space (required for --sprint, helps resolve board/list name)"
166
+ option :position, type: :numeric, desc: "Position within the list (0 = top)"
167
+ option :priority, type: :numeric, desc: "Priority level (1=low, 4=urgent)"
168
+ option :epic, type: :string, desc: "Epic ID"
169
+ option :archived, type: :boolean, desc: "Archive/unarchive"
170
+ # Update an existing card's properties.
171
+ #
172
+ # @param card_id [String] the unique identifier of the card to update
173
+ # @return [void]
174
+ def update(card_id)
175
+ handle_error do
176
+ require_space_for_sprint!
177
+
178
+ begin
179
+ # WORKAROUND: API ignores title when combined with list_id,
180
+ # so we make separate requests when both are provided.
181
+ # TODO: Remove when API is fixed (https://superthread.com/api/known-issues)
182
+ if options[:list] && (options[:title] || options[:priority] || options[:archived] || options[:epic])
183
+ # First update non-move fields
184
+ field_opts = symbolized_options(:title, :priority, :archived)
185
+ field_opts[:epic_id] = options[:epic] if options[:epic]
186
+ client.cards.update(workspace_id, card_id, **field_opts) unless field_opts.empty?
187
+
188
+ # Then move the card (include sprint context)
189
+ move_opts = resolve_list_with_context(options[:list], card_id)
190
+ move_opts[:position] = options[:position] if options[:position]
191
+ card = client.cards.update(workspace_id, card_id, **move_opts)
192
+ else
193
+ opts = symbolized_options(:title, :priority, :archived)
194
+ opts[:epic_id] = options[:epic] if options[:epic]
195
+ opts[:position] = options[:position] if options[:position]
196
+
197
+ if options[:list]
198
+ opts.merge!(resolve_list_with_context(options[:list], card_id))
199
+ elsif options[:sprint]
200
+ # Moving to a sprint without --list — default to first list
201
+ opts[:sprint_id] = sprint_id
202
+ opts[:project_id] = space_id
203
+ sprint_obj = client.sprints.find(workspace_id, sprint_id, space_id: space_id)
204
+ opts[:list_id] = sprint_obj.lists.first&.id
205
+ end
206
+
207
+ card = client.cards.update(workspace_id, card_id, **opts)
208
+ end
209
+ rescue Superthread::ForbiddenError, Superthread::NotFoundError
210
+ raise Thor::Error, "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
211
+ end
212
+ output_item card, labels: {id: "Card ID"}
213
+ end
214
+ end
215
+
216
+ desc "delete CARD", "Delete a card"
217
+ # Permanently delete a card after confirmation.
218
+ #
219
+ # @param card_ref [String] the card ID or reference to delete
220
+ # @return [void]
221
+ def delete(card_ref)
222
+ handle_error do
223
+ begin
224
+ card = client.cards.find(workspace_id, card_ref)
225
+ rescue Superthread::ForbiddenError, Superthread::NotFoundError
226
+ raise Thor::Error, "Card not found: '#{card_ref}'. Use 'suth cards list -b BOARD' to see available cards."
227
+ end
228
+ confirming("Delete card '#{card.title}' (#{card.id})?") do
229
+ client.cards.destroy(workspace_id, card.id)
230
+ output_success "Card '#{card.title}' deleted"
231
+ end
232
+ end
233
+ end
234
+
235
+ desc "duplicate CARD", "Duplicate a card"
236
+ option :title, type: :string, desc: "Title for the duplicated card"
237
+ option :project, type: :string, required: true, desc: "Destination project ID"
238
+ option :board, type: :string, required: true, aliases: "-b", desc: "Destination board (ID or name)"
239
+ option :list, type: :string, required: true, aliases: "-l", desc: "Destination list (ID or name)"
240
+ option :space, type: :string, aliases: "-s", desc: "Space (helps resolve board name)"
241
+ # Create a copy of an existing card in a specified location.
242
+ #
243
+ # @param card_id [String] the unique identifier of the card to duplicate
244
+ # @return [void]
245
+ def duplicate(card_id)
246
+ handle_error do
247
+ begin
248
+ opts = symbolized_options(:title)
249
+ opts[:project_id] = options[:project]
250
+ opts[:board_id] = board_id
251
+ opts[:list_id] = resolve_list(options[:list])
252
+ card = client.cards.duplicate(workspace_id, card_id, **opts)
253
+ rescue Superthread::ForbiddenError, Superthread::NotFoundError
254
+ raise Thor::Error, "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
255
+ end
256
+ output_item card, labels: {id: "Card ID"}
257
+ end
258
+ end
259
+
260
+ desc "assigned USER", "Get cards assigned to a user"
261
+ option :board, type: :string, aliases: "-b", desc: "Board to filter by (ID or name)"
262
+ option :space, type: :string, aliases: "-s", desc: "Space (helps resolve board name)"
263
+ option :project, type: :string, desc: "Project to filter by (ID)"
264
+ option :include_archived, type: :boolean, desc: "Include archived cards"
265
+ option :since, type: :string, desc: "Filter by created date (e.g., 'friday', '3 days ago', '2026-01-20')"
266
+ option :updated_since, type: :string, desc: "Filter by updated date"
267
+ # List all cards assigned to a specific user.
268
+ #
269
+ # @param user_ref [String] the user ID, name, or email to look up assignments for
270
+ # @return [void]
271
+ def assigned(user_ref)
272
+ handle_error do
273
+ opts = {}
274
+ opts[:archived] = options[:include_archived] if options[:include_archived]
275
+ opts[:user_id] = resolve_user(user_ref)
276
+ opts[:board_id] = board_id if options[:board]
277
+ opts[:project_id] = options[:project] if options[:project]
278
+ cards = client.cards.assigned(workspace_id, **opts)
279
+
280
+ # Client-side date filtering (API doesn't support time-based filters)
281
+ cards = apply_date_filters(cards)
282
+
283
+ enrich_members(cards)
284
+ output_list cards, columns: %i[id title priority list_title members],
285
+ headers: {id: "CARD_ID", list_title: "LIST"}
286
+ rescue Superthread::ForbiddenError, Superthread::NotFoundError
287
+ raise Thor::Error, "User not found: '#{user_ref}'. Use 'suth members list' to see available users."
288
+ end
289
+ end
290
+
291
+ desc "assign CARD_ID USERS", "Assign user(s) to a card (comma-separated)"
292
+ option :role, type: :string, default: "member", desc: "Member role"
293
+ # Add one or more users as members of a card.
294
+ #
295
+ # @param card_id [String] the unique identifier of the card
296
+ # @param user_refs [String] comma-separated user IDs, names, or emails to assign
297
+ # @return [void]
298
+ def assign(card_id, user_refs)
299
+ handle_error do
300
+ users = user_refs.split(",").map(&:strip)
301
+ users.each do |user_ref|
302
+ user_id = resolve_user(user_ref)
303
+ client.cards.add_member(workspace_id, card_id, user_id: user_id, role: options[:role])
304
+ end
305
+ output_success "Assigned #{users.size} user(s) to card #{card_id}"
306
+ end
307
+ end
308
+
309
+ desc "unassign CARD_ID USERS", "Unassign user(s) from a card (comma-separated)"
310
+ # Remove one or more users from a card.
311
+ #
312
+ # @param card_id [String] the unique identifier of the card
313
+ # @param user_refs [String] comma-separated user IDs, names, or emails to unassign
314
+ # @return [void]
315
+ def unassign(card_id, user_refs)
316
+ handle_error do
317
+ users = user_refs.split(",").map(&:strip)
318
+ users.each do |user_ref|
319
+ user_id = resolve_user(user_ref)
320
+ client.cards.remove_member(workspace_id, card_id, user_id)
321
+ end
322
+ output_success "Unassigned #{users.size} user(s) from card #{card_id}"
323
+ end
324
+ end
325
+
326
+ desc "link", "Link two cards"
327
+ option :card, type: :string, required: true, aliases: "-c", desc: "Card ID"
328
+ option :related, type: :string, required: true, aliases: "-r", desc: "Related card ID"
329
+ option :type, type: :string, required: true,
330
+ desc: "Relationship type (blocks, blocked_by, related, duplicates)"
331
+ # Create a relationship between two cards.
332
+ #
333
+ # @return [void]
334
+ def link
335
+ handle_error do
336
+ unless LINK_RELATION_TYPES.include?(options[:type])
337
+ if %w[parent child].include?(options[:type])
338
+ raise Thor::Error,
339
+ "Parent/child relationships are set via --parent-card on 'suth cards create' or 'suth cards update'"
340
+ end
341
+ raise Thor::Error,
342
+ "Expected '--type' to be one of #{LINK_RELATION_TYPES.join(", ")}; got #{options[:type]}"
343
+ end
344
+ client.cards.add_related(
345
+ workspace_id, options[:card],
346
+ related_card_id: options[:related],
347
+ relation_type: options[:type]
348
+ )
349
+ output_success "Linked #{options[:card]} -> #{options[:related]} (#{options[:type]})"
350
+ end
351
+ end
352
+
353
+ desc "unlink", "Remove card relationship"
354
+ option :card, type: :string, required: true, aliases: "-c", desc: "Card ID"
355
+ option :related, type: :string, required: true, aliases: "-r", desc: "Related card ID"
356
+ # Remove an existing relationship between two cards.
357
+ #
358
+ # @return [void]
359
+ def unlink
360
+ handle_error do
361
+ client.cards.remove_related(workspace_id, options[:card], options[:related])
362
+ output_success "Unlinked #{options[:card]} from #{options[:related]}"
363
+ end
364
+ end
365
+
366
+ desc "tag CARD_ID TAGS", "Add tags to card (comma-separated IDs or names)"
367
+ # Apply one or more tags to a card.
368
+ #
369
+ # @param card_id [String] the unique identifier of the card
370
+ # @param tag_refs [String] comma-separated tag IDs or names to add
371
+ # @return [void]
372
+ def tag(card_id, tag_refs)
373
+ handle_error do
374
+ ids = tag_refs.split(",").map { |ref| resolve_tag(ref.strip) }
375
+ client.cards.add_tags(workspace_id, card_id, tag_ids: ids)
376
+ output_success "Added #{ids.count} tag(s) to card #{card_id}"
377
+ end
378
+ end
379
+
380
+ desc "untag CARD_ID TAG", "Remove tag from card"
381
+ # Remove a tag from a card.
382
+ #
383
+ # @param card_id [String] the unique identifier of the card
384
+ # @param tag_ref [String] the tag ID or name to remove
385
+ # @return [void]
386
+ def untag(card_id, tag_ref)
387
+ handle_error do
388
+ tag_id = resolve_tag(tag_ref)
389
+ client.cards.remove_tag(workspace_id, card_id, tag_id)
390
+ output_success "Removed tag #{tag_ref} from card #{card_id}"
391
+ end
392
+ end
393
+
394
+ private
395
+
396
+ # Enrich card members with display names from workspace users.
397
+ #
398
+ # The API returns members with only user_id. This fetches the workspace
399
+ # member list once and sets display_name on each member object.
400
+ #
401
+ # @param cards [Array<Card>, Card] card(s) whose members to enrich
402
+ # @return [void]
403
+ def enrich_members(cards)
404
+ cards = Array(cards)
405
+ return unless cards.any? { |c| c.members.any? }
406
+
407
+ user_names = workspace_users.each_with_object({}) do |u, h|
408
+ h[u.user_identifier] = u.display_name
409
+ end
410
+
411
+ cards.each do |card|
412
+ card.members.each do |member|
413
+ member.display_name ||= user_names[member.user_id]
414
+ end
415
+ end
416
+ end
417
+
418
+ # Resolve a list reference and include sprint context when needed.
419
+ #
420
+ # The API requires sprint_id and project_id alongside list_id when moving
421
+ # cards within a sprint. This method handles three scenarios:
422
+ # 1. --board or --sprint given: resolve list name with that context
423
+ # 2. Neither given: fetch the card to discover its sprint/board context
424
+ # 3. Card is on a board (no sprint): resolve normally
425
+ #
426
+ # @param list_ref [String] the list ID or name to resolve
427
+ # @param card_id [String] the card identifier to look up for context
428
+ # @return [Hash] params hash with :list_id and optional :sprint_id, :project_id
429
+ def resolve_list_with_context(list_ref, card_id)
430
+ result = {}
431
+
432
+ if options[:board] || options[:sprint]
433
+ result[:list_id] = resolve_list(list_ref)
434
+ if options[:sprint]
435
+ result[:sprint_id] = sprint_id
436
+ result[:project_id] = space_id
437
+ end
438
+ else
439
+ existing = client.cards.find(workspace_id, card_id)
440
+ if existing.sprint_id
441
+ sprint_obj = client.sprints.find(workspace_id, existing.sprint_id,
442
+ space_id: existing.project_id)
443
+ list = sprint_obj.lists&.find { |l| l.title&.downcase == list_ref.downcase }
444
+ result[:list_id] = list ? list.id : list_ref
445
+ result[:sprint_id] = existing.sprint_id
446
+ result[:project_id] = existing.project_id
447
+ else
448
+ result[:list_id] = resolve_list(list_ref)
449
+ end
450
+ end
451
+
452
+ result
453
+ end
454
+
455
+ # Apply date filters to a collection of cards.
456
+ # The API doesn't support time-based filtering, so we filter client-side.
457
+ #
458
+ # @param cards [Enumerable] the cards to apply --since and --updated_since filters to
459
+ # @return [Array] Filtered cards
460
+ def apply_date_filters(cards)
461
+ since_ts = parse_date(options[:since]) if options[:since]
462
+ updated_ts = parse_date(options[:updated_since]) if options[:updated_since]
463
+ return cards.to_a unless since_ts || updated_ts
464
+
465
+ cards.to_a.select do |card|
466
+ (since_ts.nil? || meets_date_threshold?(card.time_created, since_ts)) &&
467
+ (updated_ts.nil? || meets_date_threshold?(card.time_updated, updated_ts))
468
+ end
469
+ end
470
+
471
+ # Check if a timestamp meets a minimum threshold after normalization.
472
+ #
473
+ # @param timestamp [Object] the raw timestamp value from the model
474
+ # @param threshold [Integer] the minimum Unix timestamp
475
+ # @return [Boolean] true if the normalized timestamp meets or exceeds the threshold
476
+ def meets_date_threshold?(timestamp, threshold)
477
+ ts = Formatter.normalize_timestamp(timestamp)
478
+ ts && ts >= threshold
479
+ end
480
+
481
+ # Output card relationships (parent, children, links) in human-readable format.
482
+ #
483
+ # @param card [Card] the card object containing relationship data
484
+ # @return [void]
485
+ def output_card_relationships(card)
486
+ has_relationships = card.parent || card.children.any? || card.links.any?
487
+ return unless has_relationships
488
+
489
+ puts ""
490
+ Ui.section "Relationships"
491
+
492
+ Ui.kv("Parent", card.parent.to_s) if card.parent
493
+
494
+ if card.children.any?
495
+ Ui.kv("Children", "")
496
+ card.children.each { |child| puts " #{child}" }
497
+ end
498
+
499
+ if card.links.any?
500
+ card.links_by_type.each do |type, links|
501
+ label = type.tr("_", " ").capitalize
502
+ Ui.kv(label, "")
503
+ links.each { |link| puts " #{link}" }
504
+ end
505
+ end
506
+ end
507
+
508
+ # Output card checklists with items in human-readable format.
509
+ #
510
+ # @param card [Card] the card object containing checklist data
511
+ # @return [void]
512
+ def output_card_checklists(card)
513
+ return unless card.checklists&.any?
514
+
515
+ puts ""
516
+ Ui.section "Checklists"
517
+
518
+ card.checklists.each do |checklist|
519
+ progress = "(#{checklist.completed_count}/#{checklist.total_count})"
520
+ Ui.kv(checklist.title, progress)
521
+ checklist.items&.each do |item|
522
+ marker = item.checked? ? "✓" : "○"
523
+ title = item.title.gsub(/<[^>]*>/, "").strip
524
+ puts " #{marker} #{title}"
525
+ end
526
+ end
527
+ end
528
+ end
529
+ end
530
+ end