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 +4 -4
- data/lib/superthread/cli/activity.rb +3 -11
- data/lib/superthread/cli/cards.rb +96 -76
- data/lib/superthread/cli/concerns/board_resolvable.rb +1 -0
- data/lib/superthread/cli/concerns/date_parsable.rb +1 -1
- data/lib/superthread/cli/formatter.rb +4 -13
- data/lib/superthread/cli/pages.rb +25 -4
- data/lib/superthread/models/card.rb +10 -30
- data/lib/superthread/resources/base.rb +14 -10
- data/lib/superthread/resources/cards.rb +51 -5
- data/lib/superthread/resources/pages.rb +16 -1
- data/lib/superthread/resources/search.rb +1 -1
- data/lib/superthread/time_utils.rb +22 -0
- data/lib/superthread/version.rb +1 -1
- metadata +2 -2
- data/lib/superthread/cli/concerns/confirmable.rb +0 -55
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 764ae1eedbe67d7c951c99b3cf3269cbeb11d51ab8e905348a52a18124459877
|
|
4
|
+
data.tar.gz: 01e80e6cb854aeb2a58f0c864a18ef3c0fbe26a56bb43bc10c5058b8c3d96216
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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 =
|
|
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
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
274
|
+
card = with_not_found(not_found_msg) do
|
|
275
|
+
client.cards.update(workspace_id, card_id, **opts)
|
|
276
|
+
end
|
|
254
277
|
end
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
|
|
270
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
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 =
|
|
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.
|
|
478
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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
|
|
527
|
-
# @
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
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
|
-
#
|
|
552
|
+
# Resolve list for a card that lives on a board (no sprint).
|
|
540
553
|
#
|
|
541
|
-
# @param
|
|
542
|
-
# @param
|
|
543
|
-
# @return [
|
|
544
|
-
def
|
|
545
|
-
|
|
546
|
-
|
|
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.
|
|
@@ -105,7 +105,7 @@ module Superthread
|
|
|
105
105
|
ts = item.send(field)
|
|
106
106
|
next false if ts.nil?
|
|
107
107
|
|
|
108
|
-
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/superthread/version.rb
CHANGED
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.
|
|
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
|