superthread 0.9.0 → 0.9.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.
- checksums.yaml +4 -4
- data/README.md +1 -1
- data/lib/superthread/cli/activity.rb +3 -11
- data/lib/superthread/cli/cards.rb +111 -109
- data/lib/superthread/cli/concerns/board_resolvable.rb +1 -0
- data/lib/superthread/cli/concerns/date_parsable.rb +1 -1
- data/lib/superthread/cli/concerns/user_resolvable.rb +10 -0
- data/lib/superthread/cli/formatter.rb +4 -13
- data/lib/superthread/cli/projects.rb +2 -2
- data/lib/superthread/models/card.rb +10 -30
- data/lib/superthread/resources/base.rb +0 -10
- data/lib/superthread/resources/cards.rb +33 -4
- 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: 19963c25ec59017aeaa48f22fd6bbc61ff354c82a404bc5705b3b38ae7d34952
|
|
4
|
+
data.tar.gz: e1f03e1cda195ae9b696cd31862044f66b8a08cd1aac29aca86ea21b88bdf6f7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 122e2e4481e6da0544b311ca0c7009fb85de3f7133a514eee13d9d9be9154ebbe246515e42fcd634d0029e17de1c509688c7d63ab3f19663212476dfabf0174e
|
|
7
|
+
data.tar.gz: 0553d576f89d0cfc827c137a2f238fef23fb664a21a1cd413b1203eeb782e643a3c82bb86efd8ff93202186f5226a79dfad5158289820e9e6140a6baa1c41323
|
data/README.md
CHANGED
|
@@ -325,7 +325,7 @@ Common options have short forms:
|
|
|
325
325
|
> - Most commands accept **names or IDs** for spaces, boards, lists, sprints, users, and tags
|
|
326
326
|
> - Use `-s SPACE` to help when board or list names are unclear
|
|
327
327
|
> - Use `--json` for scripted output: `suth cards assigned me --json`
|
|
328
|
-
> - Use `me`
|
|
328
|
+
> - Use `me` in any user argument: `suth cards assigned me`, `suth cards assign CARD me`, `--owner me`
|
|
329
329
|
> - Priority levels: 1=Urgent, 2=High, 3=Medium, 4=Low
|
|
330
330
|
|
|
331
331
|
### Mentions
|
|
@@ -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)
|
|
@@ -180,7 +179,7 @@ module Superthread
|
|
|
180
179
|
option :priority, type: :numeric, desc: "Priority level (1=low, 4=urgent)"
|
|
181
180
|
option :parent_card, type: :string, desc: "Parent card ID"
|
|
182
181
|
option :epic, type: :string, desc: "Epic ID"
|
|
183
|
-
option :owner, type: :string, aliases: "-o", desc: "Owner (user ID, name, or
|
|
182
|
+
option :owner, type: :string, aliases: "-o", desc: "Owner (user ID, name, email, or 'me')"
|
|
184
183
|
# Create a new card on a board or sprint.
|
|
185
184
|
#
|
|
186
185
|
# @return [void]
|
|
@@ -224,54 +223,63 @@ module Superthread
|
|
|
224
223
|
def update(card_id)
|
|
225
224
|
handle_error do
|
|
226
225
|
require_space_for_sprint!
|
|
226
|
+
not_found_msg = "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
|
|
227
227
|
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
228
|
+
# Update content via dedicated PUT endpoint (separate from PATCH)
|
|
229
|
+
if options[:content]
|
|
230
|
+
with_not_found(not_found_msg) do
|
|
231
231
|
client.cards.update_content(workspace_id, card_id, content: options[:content])
|
|
232
232
|
end
|
|
233
|
+
end
|
|
233
234
|
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
move_opts = resolve_list_with_context(options[:list], card_id)
|
|
250
|
-
move_opts[:position] = options[:position] if options[:position]
|
|
251
|
-
card = client.cards.update(workspace_id, card_id, **move_opts)
|
|
252
|
-
else
|
|
253
|
-
opts = symbolized_options(:title, :priority, :archived)
|
|
254
|
-
opts[:epic_id] = options[:epic] if options[:epic]
|
|
255
|
-
opts[:position] = options[:position] if options[:position]
|
|
256
|
-
|
|
257
|
-
if options[:list]
|
|
258
|
-
opts.merge!(resolve_list_with_context(options[:list], card_id))
|
|
259
|
-
elsif options[:sprint]
|
|
260
|
-
# Moving to a sprint without --list — default to first list
|
|
261
|
-
opts[:sprint_id] = sprint_id
|
|
262
|
-
opts[:project_id] = space_id
|
|
263
|
-
sprint_obj = client.sprints.find(workspace_id, sprint_id, space_id: space_id)
|
|
264
|
-
opts[:list_id] = sprint_obj.lists.first&.id
|
|
235
|
+
# Update other fields via PATCH (skip if only content was provided)
|
|
236
|
+
has_patch_fields = options[:title] || options[:list] || options[:priority] ||
|
|
237
|
+
!options[:archived].nil? || options[:epic] || options[:position] || options[:sprint]
|
|
238
|
+
|
|
239
|
+
if has_patch_fields
|
|
240
|
+
# WORKAROUND: API ignores title when combined with list_id,
|
|
241
|
+
# so we make separate requests when both are provided.
|
|
242
|
+
# TODO: Remove when API is fixed (https://superthread.com/api/known-issues)
|
|
243
|
+
if options[:list] && (options[:title] || options[:priority] || !options[:archived].nil? || options[:epic])
|
|
244
|
+
# First update non-move fields
|
|
245
|
+
field_opts = symbolized_options(:title, :priority, :archived)
|
|
246
|
+
field_opts[:epic_id] = options[:epic] if options[:epic]
|
|
247
|
+
unless field_opts.empty?
|
|
248
|
+
with_not_found(not_found_msg) do
|
|
249
|
+
client.cards.update(workspace_id, card_id, **field_opts)
|
|
265
250
|
end
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
# Then move the card (include sprint context)
|
|
254
|
+
move_opts = resolve_list_with_context(options[:list], card_id)
|
|
255
|
+
move_opts[:position] = options[:position] if options[:position]
|
|
256
|
+
card = with_not_found(not_found_msg) do
|
|
257
|
+
client.cards.update(workspace_id, card_id, **move_opts)
|
|
258
|
+
end
|
|
259
|
+
else
|
|
260
|
+
opts = symbolized_options(:title, :priority, :archived)
|
|
261
|
+
opts[:epic_id] = options[:epic] if options[:epic]
|
|
262
|
+
opts[:position] = options[:position] if options[:position]
|
|
263
|
+
|
|
264
|
+
if options[:list]
|
|
265
|
+
opts.merge!(resolve_list_with_context(options[:list], card_id))
|
|
266
|
+
elsif options[:sprint]
|
|
267
|
+
# Moving to a sprint without --list — default to first list
|
|
268
|
+
opts[:sprint_id] = sprint_id
|
|
269
|
+
opts[:project_id] = space_id
|
|
270
|
+
sprint_obj = client.sprints.find(workspace_id, sprint_id, space_id: space_id)
|
|
271
|
+
opts[:list_id] = sprint_obj.lists.first&.id
|
|
272
|
+
end
|
|
266
273
|
|
|
267
|
-
|
|
274
|
+
card = with_not_found(not_found_msg) do
|
|
275
|
+
client.cards.update(workspace_id, card_id, **opts)
|
|
268
276
|
end
|
|
269
277
|
end
|
|
278
|
+
end
|
|
270
279
|
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
raise Thor::Error, "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
|
|
280
|
+
# If only content was updated, fetch the card for output
|
|
281
|
+
card ||= with_not_found(not_found_msg) do
|
|
282
|
+
client.cards.find(workspace_id, card_id)
|
|
275
283
|
end
|
|
276
284
|
output_item card, labels: {id: "Card ID"}
|
|
277
285
|
end
|
|
@@ -284,10 +292,8 @@ module Superthread
|
|
|
284
292
|
# @return [void]
|
|
285
293
|
def delete(card_ref)
|
|
286
294
|
handle_error do
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
rescue Superthread::ForbiddenError, Superthread::NotFoundError
|
|
290
|
-
raise Thor::Error, "Card not found: '#{card_ref}'. Use 'suth cards list -b BOARD' to see available cards."
|
|
295
|
+
card = with_not_found("Card not found: '#{card_ref}'. Use 'suth cards list -b BOARD' to see available cards.") do
|
|
296
|
+
client.cards.find(workspace_id, card_ref)
|
|
291
297
|
end
|
|
292
298
|
confirming("Delete card '#{card.title}' (#{card.id})?") do
|
|
293
299
|
client.cards.destroy(workspace_id, card.id)
|
|
@@ -308,14 +314,12 @@ module Superthread
|
|
|
308
314
|
# @return [void]
|
|
309
315
|
def duplicate(card_id)
|
|
310
316
|
handle_error do
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
rescue Superthread::ForbiddenError, Superthread::NotFoundError
|
|
318
|
-
raise Thor::Error, "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
|
|
317
|
+
opts = symbolized_options(:title)
|
|
318
|
+
opts[:project_id] = options[:project]
|
|
319
|
+
opts[:board_id] = board_id
|
|
320
|
+
opts[:list_id] = resolve_list(options[:list])
|
|
321
|
+
card = with_not_found("Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards.") do
|
|
322
|
+
client.cards.duplicate(workspace_id, card_id, **opts)
|
|
319
323
|
end
|
|
320
324
|
output_item card, labels: {id: "Card ID"}
|
|
321
325
|
end
|
|
@@ -339,10 +343,11 @@ module Superthread
|
|
|
339
343
|
opts[:user_id] = resolve_user(user_ref)
|
|
340
344
|
opts[:board_id] = board_id if options[:board]
|
|
341
345
|
opts[:project_id] = options[:project] if options[:project]
|
|
342
|
-
cards = client.cards.assigned(workspace_id, **opts)
|
|
346
|
+
cards = client.cards.assigned(workspace_id, **opts).to_a
|
|
343
347
|
|
|
344
348
|
# Client-side date filtering (API doesn't support time-based filters)
|
|
345
|
-
cards =
|
|
349
|
+
cards = filter_by_date(cards, field: :time_created, since: parse_date(options[:since])) if options[:since]
|
|
350
|
+
cards = filter_by_date(cards, field: :time_updated, since: parse_date(options[:updated_since])) if options[:updated_since]
|
|
346
351
|
|
|
347
352
|
enrich_members(cards)
|
|
348
353
|
output_list cards, columns: %i[id title priority list_title members],
|
|
@@ -492,76 +497,73 @@ module Superthread
|
|
|
492
497
|
# Resolve a list reference and include sprint context when needed.
|
|
493
498
|
#
|
|
494
499
|
# The API requires sprint_id and project_id alongside list_id when moving
|
|
495
|
-
# cards within a sprint.
|
|
496
|
-
#
|
|
497
|
-
# 2. Neither given: fetch the card to discover its sprint/board context
|
|
498
|
-
# 3. Card is on a board (no sprint): resolve normally
|
|
500
|
+
# cards within a sprint. Dispatches to one of three strategies based on
|
|
501
|
+
# whether explicit context (--board/--sprint) was provided.
|
|
499
502
|
#
|
|
500
503
|
# @param list_ref [String] the list ID or name to resolve
|
|
501
504
|
# @param card_id [String] the card identifier to look up for context
|
|
502
505
|
# @return [Hash] params hash with :list_id and optional :sprint_id, :project_id
|
|
503
506
|
def resolve_list_with_context(list_ref, card_id)
|
|
504
|
-
result = {}
|
|
505
|
-
|
|
506
507
|
if options[:board] || options[:sprint]
|
|
507
|
-
|
|
508
|
-
if options[:sprint]
|
|
509
|
-
result[:sprint_id] = sprint_id
|
|
510
|
-
result[:project_id] = space_id
|
|
511
|
-
end
|
|
508
|
+
resolve_list_with_explicit_context(list_ref)
|
|
512
509
|
else
|
|
513
510
|
existing = client.cards.find(workspace_id, card_id)
|
|
514
511
|
if existing.sprint_id
|
|
515
|
-
|
|
516
|
-
space_id: existing.project_id)
|
|
517
|
-
list = sprint_obj.lists&.find { |l| l.title&.downcase == list_ref.downcase }
|
|
518
|
-
unless list || looks_like_id?(list_ref)
|
|
519
|
-
available = sprint_obj.lists&.map(&:title)&.join(", ") || "none"
|
|
520
|
-
raise Thor::Error, "List '#{list_ref}' not found in sprint. Available: #{available}"
|
|
521
|
-
end
|
|
522
|
-
result[:list_id] = list ? list.id : list_ref
|
|
523
|
-
result[:sprint_id] = existing.sprint_id
|
|
524
|
-
result[:project_id] = existing.project_id
|
|
525
|
-
elsif looks_like_id?(list_ref)
|
|
526
|
-
result[:list_id] = list_ref
|
|
512
|
+
resolve_list_in_sprint_context(existing, list_ref)
|
|
527
513
|
else
|
|
528
|
-
|
|
529
|
-
list = board.lists&.find { |l| l.title&.downcase == list_ref.downcase }
|
|
530
|
-
unless list
|
|
531
|
-
available = board.lists&.map(&:title)&.join(", ") || "none"
|
|
532
|
-
raise Thor::Error, "List '#{list_ref}' not found on board. Available: #{available}"
|
|
533
|
-
end
|
|
534
|
-
result[:list_id] = list.id
|
|
514
|
+
resolve_list_on_board(existing, list_ref)
|
|
535
515
|
end
|
|
536
516
|
end
|
|
517
|
+
end
|
|
537
518
|
|
|
519
|
+
# Resolve list when --board or --sprint was explicitly provided.
|
|
520
|
+
#
|
|
521
|
+
# @param list_ref [String] the list ID or name
|
|
522
|
+
# @return [Hash] params hash with :list_id and optional :sprint_id, :project_id
|
|
523
|
+
def resolve_list_with_explicit_context(list_ref)
|
|
524
|
+
result = {list_id: resolve_list(list_ref)}
|
|
525
|
+
if options[:sprint]
|
|
526
|
+
result[:sprint_id] = sprint_id
|
|
527
|
+
result[:project_id] = space_id
|
|
528
|
+
end
|
|
538
529
|
result
|
|
539
530
|
end
|
|
540
531
|
|
|
541
|
-
#
|
|
542
|
-
# The API doesn't support time-based filtering, so we filter client-side.
|
|
532
|
+
# Resolve list for a card that lives in a sprint.
|
|
543
533
|
#
|
|
544
|
-
# @param
|
|
545
|
-
# @
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
534
|
+
# @param existing [Card] the current card with sprint context
|
|
535
|
+
# @param list_ref [String] the list ID or name
|
|
536
|
+
# @return [Hash] params hash with :list_id, :sprint_id, :project_id
|
|
537
|
+
def resolve_list_in_sprint_context(existing, list_ref)
|
|
538
|
+
sprint_obj = client.sprints.find(workspace_id, existing.sprint_id,
|
|
539
|
+
space_id: existing.project_id)
|
|
540
|
+
list = sprint_obj.lists&.find { |l| l.title&.downcase == list_ref.downcase }
|
|
541
|
+
unless list || looks_like_id?(list_ref)
|
|
542
|
+
available = sprint_obj.lists&.map(&:title)&.join(", ") || "none"
|
|
543
|
+
raise Thor::Error, "List '#{list_ref}' not found in sprint. Available: #{available}"
|
|
554
544
|
end
|
|
545
|
+
{
|
|
546
|
+
list_id: list ? list.id : list_ref,
|
|
547
|
+
sprint_id: existing.sprint_id,
|
|
548
|
+
project_id: existing.project_id
|
|
549
|
+
}
|
|
555
550
|
end
|
|
556
551
|
|
|
557
|
-
#
|
|
552
|
+
# Resolve list for a card that lives on a board (no sprint).
|
|
558
553
|
#
|
|
559
|
-
# @param
|
|
560
|
-
# @param
|
|
561
|
-
# @return [
|
|
562
|
-
def
|
|
563
|
-
|
|
564
|
-
|
|
554
|
+
# @param existing [Card] the current card with board context
|
|
555
|
+
# @param list_ref [String] the list ID or name
|
|
556
|
+
# @return [Hash] params hash with :list_id
|
|
557
|
+
def resolve_list_on_board(existing, list_ref)
|
|
558
|
+
return {list_id: list_ref} if looks_like_id?(list_ref)
|
|
559
|
+
|
|
560
|
+
board = client.boards.find(workspace_id, existing.board_id)
|
|
561
|
+
list = board.lists&.find { |l| l.title&.downcase == list_ref.downcase }
|
|
562
|
+
unless list
|
|
563
|
+
available = board.lists&.map(&:title)&.join(", ") || "none"
|
|
564
|
+
raise Thor::Error, "List '#{list_ref}' not found on board. Available: #{available}"
|
|
565
|
+
end
|
|
566
|
+
{list_id: list.id}
|
|
565
567
|
end
|
|
566
568
|
|
|
567
569
|
# Output card relationships (parent, children, links) in human-readable format.
|
|
@@ -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)
|
|
@@ -21,6 +21,9 @@ module Superthread
|
|
|
21
21
|
# @raise [Thor::Error] if name/email is provided but not found
|
|
22
22
|
def resolve_user(ref)
|
|
23
23
|
return ref if ref.nil?
|
|
24
|
+
|
|
25
|
+
return resolve_me if ref.downcase == "me"
|
|
26
|
+
|
|
24
27
|
return ref if looks_like_id?(ref)
|
|
25
28
|
|
|
26
29
|
user = find_user_by_name(ref)
|
|
@@ -29,6 +32,13 @@ module Superthread
|
|
|
29
32
|
raise Thor::Error, "User not found: '#{ref}'. Use 'suth members list' to see available users."
|
|
30
33
|
end
|
|
31
34
|
|
|
35
|
+
# Resolve 'me' to the current user's identifier (cached).
|
|
36
|
+
#
|
|
37
|
+
# @return [String] the current user's identifier
|
|
38
|
+
def resolve_me
|
|
39
|
+
@me_cache ||= client.users.me.user_identifier
|
|
40
|
+
end
|
|
41
|
+
|
|
32
42
|
# Get cached workspace members list.
|
|
33
43
|
#
|
|
34
44
|
# @return [Array<Superthread::Models::User>] all workspace members
|
|
@@ -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.
|
|
@@ -42,7 +42,7 @@ module Superthread
|
|
|
42
42
|
option :content, type: :string, desc: "Project description"
|
|
43
43
|
option :start_date, type: :numeric, desc: "Start date (Unix timestamp)"
|
|
44
44
|
option :due_date, type: :numeric, desc: "Due date (Unix timestamp)"
|
|
45
|
-
option :owner, type: :string, aliases: "-o", desc: "Owner (user ID, name, or
|
|
45
|
+
option :owner, type: :string, aliases: "-o", desc: "Owner (user ID, name, email, or 'me')"
|
|
46
46
|
option :priority, type: :numeric, desc: "Priority level"
|
|
47
47
|
# Creates a new project on a board list.
|
|
48
48
|
#
|
|
@@ -60,7 +60,7 @@ module Superthread
|
|
|
60
60
|
option :list, type: :string, aliases: "-l", desc: "Destination list (ID or name, requires --board)"
|
|
61
61
|
option :board, type: :string, aliases: "-b", desc: "Board (helps resolve list name)"
|
|
62
62
|
option :space, type: :string, aliases: "-s", desc: "Space (helps resolve board name)"
|
|
63
|
-
option :owner, type: :string, aliases: "-o", desc: "New owner (user ID, name, or
|
|
63
|
+
option :owner, type: :string, aliases: "-o", desc: "New owner (user ID, name, email, or 'me')"
|
|
64
64
|
option :start_date, type: :numeric, desc: "Start date"
|
|
65
65
|
option :due_date, type: :numeric, desc: "Due date"
|
|
66
66
|
option :priority, type: :numeric, desc: "Priority"
|
|
@@ -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
|
|
@@ -197,16 +197,6 @@ module Superthread
|
|
|
197
197
|
MentionFormatter.new(@client, workspace_id).format(content)
|
|
198
198
|
end
|
|
199
199
|
|
|
200
|
-
# Builds an API path prefixed with the workspace ID.
|
|
201
|
-
#
|
|
202
|
-
# @param workspace_id [String] the workspace identifier
|
|
203
|
-
# @param path [String] additional path segments to append
|
|
204
|
-
# @return [String] the full API path (e.g., "/ws-123/cards")
|
|
205
|
-
def workspace_path(workspace_id, path = "")
|
|
206
|
-
ws = safe_id("workspace_id", workspace_id)
|
|
207
|
-
"/#{ws}#{path}"
|
|
208
|
-
end
|
|
209
|
-
|
|
210
200
|
# Returns a success response object for delete operations.
|
|
211
201
|
#
|
|
212
202
|
# @return [Superthread::Object] a response object with success: true
|
|
@@ -7,6 +7,9 @@ module Superthread
|
|
|
7
7
|
# Provides methods for creating, updating, listing, and managing cards
|
|
8
8
|
# including their members, checklists, tags, and relationships.
|
|
9
9
|
class Cards < Base
|
|
10
|
+
# Safety cap on pagination requests to prevent runaway loops.
|
|
11
|
+
MAX_PAGES = 100
|
|
12
|
+
|
|
10
13
|
# Creates a new card on a board or in a sprint.
|
|
11
14
|
#
|
|
12
15
|
# @param workspace_id [String] the workspace identifier
|
|
@@ -132,8 +135,7 @@ module Superthread
|
|
|
132
135
|
ws = safe_id("workspace_id", workspace_id)
|
|
133
136
|
body = {type: "card", card_filters: build_card_filters(filters)}
|
|
134
137
|
|
|
135
|
-
|
|
136
|
-
item_class: Models::Card, items_key: :cards)
|
|
138
|
+
paginated_card_list("/#{ws}/views/preview", body: body)
|
|
137
139
|
end
|
|
138
140
|
|
|
139
141
|
# Gets cards assigned to a user.
|
|
@@ -150,8 +152,7 @@ module Superthread
|
|
|
150
152
|
ws = safe_id("workspace_id", workspace_id)
|
|
151
153
|
body = {type: "card", card_filters: build_card_filters(filters, members: [user_id])}
|
|
152
154
|
|
|
153
|
-
|
|
154
|
-
item_class: Models::Card, items_key: :cards)
|
|
155
|
+
paginated_card_list("/#{ws}/views/preview", body: body)
|
|
155
156
|
end
|
|
156
157
|
|
|
157
158
|
# Links two cards with a relationship.
|
|
@@ -358,6 +359,34 @@ module Superthread
|
|
|
358
359
|
|
|
359
360
|
private
|
|
360
361
|
|
|
362
|
+
# Fetches all pages from the views/preview endpoint.
|
|
363
|
+
#
|
|
364
|
+
# The API returns max 25 cards per page with a cursor for pagination.
|
|
365
|
+
# The cursor must be passed as a query parameter on subsequent requests.
|
|
366
|
+
#
|
|
367
|
+
# @param path [String] the API endpoint path
|
|
368
|
+
# @param body [Hash] the request body with type and card_filters
|
|
369
|
+
# @return [Superthread::Objects::Collection<Superthread::Models::Card>] all matching cards
|
|
370
|
+
def paginated_card_list(path, body:)
|
|
371
|
+
all_cards = []
|
|
372
|
+
cursor = nil
|
|
373
|
+
pages = 0
|
|
374
|
+
|
|
375
|
+
loop do
|
|
376
|
+
params = cursor ? {cursor: cursor} : nil
|
|
377
|
+
response = @client.request(method: :post, path: path, params: params, body: body)
|
|
378
|
+
|
|
379
|
+
all_cards.concat(response[:cards] || [])
|
|
380
|
+
|
|
381
|
+
cursor = response[:cursor]
|
|
382
|
+
pages += 1
|
|
383
|
+
break if cursor.nil? || cursor.empty?
|
|
384
|
+
break if pages >= MAX_PAGES
|
|
385
|
+
end
|
|
386
|
+
|
|
387
|
+
Objects::Collection.from_response(all_cards, item_class: Models::Card)
|
|
388
|
+
end
|
|
389
|
+
|
|
361
390
|
# Builds the card_filters hash for the views/preview endpoint.
|
|
362
391
|
#
|
|
363
392
|
# @param filters [Hash{Symbol => Object}] filter options
|
|
@@ -28,7 +28,7 @@ module Superthread
|
|
|
28
28
|
# @return [Superthread::Objects::Collection] the search results
|
|
29
29
|
def query(workspace_id, query:, limit: nil, **params)
|
|
30
30
|
ws = safe_id("workspace_id", workspace_id)
|
|
31
|
-
grouped = params.fetch(:grouped, false)
|
|
31
|
+
grouped = params.fetch(:grouped, false) || false
|
|
32
32
|
all_results = []
|
|
33
33
|
cursor = nil
|
|
34
34
|
pages = 0
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superthread
|
|
4
|
+
# Shared timestamp utilities used across layers.
|
|
5
|
+
module TimeUtils
|
|
6
|
+
# Threshold for detecting millisecond timestamps vs second timestamps.
|
|
7
|
+
# Timestamps above this value are assumed to be in milliseconds.
|
|
8
|
+
MAX_SECONDS_TIMESTAMP = 9_999_999_999
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
# Normalizes a timestamp to seconds (API sometimes returns milliseconds).
|
|
13
|
+
#
|
|
14
|
+
# @param ts [Integer, nil] timestamp in seconds or milliseconds
|
|
15
|
+
# @return [Integer, nil] timestamp in seconds, or nil if nil/zero
|
|
16
|
+
def normalize_timestamp(ts)
|
|
17
|
+
return nil if ts.nil? || ts == 0
|
|
18
|
+
|
|
19
|
+
(ts > MAX_SECONDS_TIMESTAMP) ? ts / 1000 : ts
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
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.9.
|
|
4
|
+
version: 0.9.2
|
|
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
|