superthread 0.7.3 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 4cf0f4679210af65b63f13141b5c432b8fa2999468842808a58ba2c71d15b7f1
4
- data.tar.gz: 922afee485ffb459bb189fcedb92af8649e8422d52baf3230d19c47288cd63bf
3
+ metadata.gz: eb38a8ab1c8ba081e66e8bda3bc4fabcb8aa9732e1e8cfc5ce1bdff0b14f94b2
4
+ data.tar.gz: e97e7bffe8dc9bfbd86b424a6762455d1d9979b07a60651b3997ad5feaedddf5
5
5
  SHA512:
6
- metadata.gz: c0e8b5023fd60313bcd7b82cbb650ef3520f11befe1bbef9bf7427feed115003ed3afe287d63c48d5939177a3d689850f9398a3bbf9cb534ac00b021db68f7b7
7
- data.tar.gz: b9ceb55ed32d01d2f7010ff234427fbe527f54c749c27f1dc3b03bb17c24d6aed15c82cb010c6a848831c4d0977e232602c3a7c23950cbb8a201a24ab7318897
6
+ metadata.gz: b2c5dfabc604f08c7aae0eedb7a94fb8f702912c5ccc2730c9d388d6f404dbdff8aa9f3fa835288732612f84d7755e4a2a5283f1d266747b3e9727aa09fdbe5f
7
+ data.tar.gz: 847b4c2868387c94b08bbbffab9db1d80dac9b4991884941258eb801347996f11257cdc1079d192848ae5208936352da2b894f95a1288914403279395b50cf61
data/README.md CHANGED
@@ -328,6 +328,16 @@ Common options have short forms:
328
328
  > - Use `me` as a shortcut: `suth cards assigned me`
329
329
  > - Priority levels: 1=Urgent, 2=High, 3=Medium, 4=Low
330
330
 
331
+ ### Mentions
332
+
333
+ Use `{{@Name}}` to tag workspace members in comments, replies, and checklist items:
334
+
335
+ ```bash
336
+ suth comments create -c CARD --content "{{@Stacey}} Ready for review."
337
+ ```
338
+
339
+ The name is matched case-insensitively against member display names. Unresolved mentions produce a warning.
340
+
331
341
  ## Library Usage
332
342
 
333
343
  You can also use Superthread as a Ruby gem in your code:
@@ -137,7 +137,7 @@ module Superthread
137
137
  def output_list(items, columns: nil, headers: {})
138
138
  all_items = items.respond_to?(:items) ? items.items : Array(items)
139
139
  limit = effective_limit
140
- truncated = all_items.length > limit
140
+ truncated = limit && all_items.length > limit
141
141
  visible = truncated ? all_items.first(limit) : all_items
142
142
 
143
143
  if json_output?
@@ -52,6 +52,52 @@ module Superthread
52
52
  end
53
53
  end
54
54
 
55
+ desc "search TERM", "Search for cards across boards and spaces"
56
+ option :space, type: :string, aliases: "-s", desc: "Space to filter by (ID or name)"
57
+ option :status, type: :string, desc: "Status filter (comma-separated)"
58
+ option :field, type: :string, enum: %w[title content], desc: "Search in title or content"
59
+ option :include_archived, type: :boolean, desc: "Include archived cards"
60
+ # Search for cards by keyword with rich output.
61
+ #
62
+ # @param term [String] the search term
63
+ # @return [void]
64
+ def search(term)
65
+ handle_error do
66
+ statuses = options[:status]&.split(",")&.map(&:strip)
67
+ results = client.search.query(
68
+ workspace_id,
69
+ query: term,
70
+ types: ["card"],
71
+ statuses: statuses,
72
+ field: options[:field],
73
+ space_id: (space_id if options[:space]),
74
+ archived: options[:include_archived],
75
+ limit: effective_limit
76
+ )
77
+
78
+ card_ids = results.map { |r| r[:id] }.compact
79
+ if card_ids.empty?
80
+ say "No cards found matching '#{term}'.", :yellow unless options[:quiet]
81
+ return
82
+ end
83
+
84
+ cards = card_ids.filter_map do |id|
85
+ client.cards.find(workspace_id, id)
86
+ rescue Superthread::NotFoundError, Superthread::ForbiddenError
87
+ nil
88
+ end
89
+
90
+ if cards.empty?
91
+ say "No cards found matching '#{term}'.", :yellow unless options[:quiet]
92
+ return
93
+ end
94
+
95
+ enrich_members(cards)
96
+ output_list cards, columns: %i[id title priority list_title board_title members],
97
+ headers: {id: "CARD_ID", list_title: "LIST", board_title: "BOARD"}
98
+ end
99
+ end
100
+
55
101
  desc "get CARD", "Get card details"
56
102
  option :raw, type: :boolean, desc: "Show raw content without markdown rendering"
57
103
  option :no_content, type: :boolean, desc: "Hide content, show only metadata"
@@ -393,6 +439,16 @@ module Superthread
393
439
 
394
440
  private
395
441
 
442
+ # Override Base#effective_limit for search-specific defaults.
443
+ # Returns nil for --limit 0 (unlimited), 30 for search default.
444
+ #
445
+ # @return [Integer, nil] the limit (nil = unlimited when --limit 0)
446
+ def effective_limit
447
+ limit = options[:limit]
448
+ return nil if limit == 0
449
+ (limit.is_a?(Integer) && limit > 0) ? limit : 30
450
+ end
451
+
396
452
  # Enrich card members with display names from workspace users.
397
453
  #
398
454
  # The API returns members with only user_id. This fetches the workspace
@@ -441,11 +497,23 @@ module Superthread
441
497
  sprint_obj = client.sprints.find(workspace_id, existing.sprint_id,
442
498
  space_id: existing.project_id)
443
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
444
504
  result[:list_id] = list ? list.id : list_ref
445
505
  result[:sprint_id] = existing.sprint_id
446
506
  result[:project_id] = existing.project_id
507
+ elsif looks_like_id?(list_ref)
508
+ result[:list_id] = list_ref
447
509
  else
448
- result[:list_id] = resolve_list(list_ref)
510
+ board = client.boards.find(workspace_id, existing.board_id)
511
+ list = board.lists&.find { |l| l.title&.downcase == list_ref.downcase }
512
+ unless list
513
+ available = board.lists&.map(&:title)&.join(", ") || "none"
514
+ raise Thor::Error, "List '#{list_ref}' not found on board. Available: #{available}"
515
+ end
516
+ result[:list_id] = list.id
449
517
  end
450
518
  end
451
519
 
@@ -518,7 +586,7 @@ module Superthread
518
586
  card.checklists.each do |checklist|
519
587
  progress = "(#{checklist.completed_count}/#{checklist.total_count})"
520
588
  Ui.kv(checklist.title, progress)
521
- checklist.items&.each do |item|
589
+ checklist.sorted_items.each do |item|
522
590
  marker = item.checked? ? "✓" : "○"
523
591
  title = item.title.gsub(/<[^>]*>/, "").strip
524
592
  puts " #{marker} #{title}"
@@ -57,7 +57,7 @@ module Superthread
57
57
  if checklist.items&.any?
58
58
  puts ""
59
59
  Ui.section "Items"
60
- checklist.items.each do |item|
60
+ checklist.sorted_items.each do |item|
61
61
  marker = item.checked? ? "✓" : "○"
62
62
  puts " #{marker} #{item.title} (#{item.id})"
63
63
  end
@@ -179,43 +179,45 @@ module Superthread
179
179
  end
180
180
  end
181
181
 
182
- desc "check ITEM_ID", "Mark a checklist item as checked"
182
+ desc "check ITEM_ID [ITEM_ID...]", "Mark checklist item(s) as checked"
183
183
  option :card, type: :string, required: true, aliases: "-c", desc: "Parent card ID"
184
184
  option :checklist, type: :string, required: true, desc: "Parent checklist ID"
185
- # Mark a checklist item as completed.
185
+ # Mark one or more checklist items as completed.
186
186
  #
187
- # @param item_id [String] the unique identifier of the item
187
+ # @param item_ids [Array<String>] the unique identifiers of the items
188
188
  # @return [void]
189
- def check(item_id)
189
+ def check(*item_ids)
190
190
  handle_error do
191
- item = client.cards.update_checklist_item(
192
- workspace_id, options[:card], options[:checklist], item_id,
193
- checked: true
194
- )
195
- output_item item, fields: %i[id title checked checklist_id], labels: {
196
- id: "Item ID",
197
- checklist_id: "Checklist ID"
198
- }
191
+ raise Thor::Error, "No item IDs provided" if item_ids.empty?
192
+
193
+ item_ids.each do |item_id|
194
+ client.cards.update_checklist_item(
195
+ workspace_id, options[:card], options[:checklist], item_id,
196
+ checked: true
197
+ )
198
+ end
199
+ output_success "Checked #{item_ids.size} item(s)"
199
200
  end
200
201
  end
201
202
 
202
- desc "uncheck ITEM_ID", "Mark a checklist item as unchecked"
203
+ desc "uncheck ITEM_ID [ITEM_ID...]", "Mark checklist item(s) as unchecked"
203
204
  option :card, type: :string, required: true, aliases: "-c", desc: "Parent card ID"
204
205
  option :checklist, type: :string, required: true, desc: "Parent checklist ID"
205
- # Mark a checklist item as not completed.
206
+ # Mark one or more checklist items as not completed.
206
207
  #
207
- # @param item_id [String] the unique identifier of the item
208
+ # @param item_ids [Array<String>] the unique identifiers of the items
208
209
  # @return [void]
209
- def uncheck(item_id)
210
+ def uncheck(*item_ids)
210
211
  handle_error do
211
- item = client.cards.update_checklist_item(
212
- workspace_id, options[:card], options[:checklist], item_id,
213
- checked: false
214
- )
215
- output_item item, fields: %i[id title checked checklist_id], labels: {
216
- id: "Item ID",
217
- checklist_id: "Checklist ID"
218
- }
212
+ raise Thor::Error, "No item IDs provided" if item_ids.empty?
213
+
214
+ item_ids.each do |item_id|
215
+ client.cards.update_checklist_item(
216
+ workspace_id, options[:card], options[:checklist], item_id,
217
+ checked: false
218
+ )
219
+ end
220
+ output_success "Unchecked #{item_ids.size} item(s)"
219
221
  end
220
222
  end
221
223
  end
@@ -34,7 +34,7 @@ module Superthread
34
34
  end
35
35
 
36
36
  desc "create", "Create a comment"
37
- option :content, type: :string, required: true, desc: "Comment content (HTML)"
37
+ option :content, type: :string, required: true, desc: "Comment content (HTML). Use {{@Name}} to mention users"
38
38
  option :card, type: :string, aliases: "-c", desc: "Parent card ID (required unless --page)"
39
39
  option :page, type: :string, aliases: "-p", desc: "Parent page ID (required unless --card)"
40
40
  # Create a new comment on a card or page.
@@ -49,7 +49,7 @@ module Superthread
49
49
  end
50
50
 
51
51
  desc "update COMMENT_ID", "Update a comment"
52
- option :content, type: :string, desc: "New content"
52
+ option :content, type: :string, desc: "New content (HTML). Use {{@Name}} to mention users"
53
53
  option :status, type: :string, enum: %w[resolved open orphaned], desc: "Comment status"
54
54
  # Update an existing comment's content or status.
55
55
  #
@@ -44,7 +44,7 @@ module Superthread
44
44
 
45
45
  desc "create", "Reply to a comment"
46
46
  option :comment, type: :string, required: true, desc: "Parent comment ID"
47
- option :content, type: :string, required: true, desc: "Reply content"
47
+ option :content, type: :string, required: true, desc: "Reply content (HTML). Use {{@Name}} to mention users"
48
48
  # Add a threaded reply to an existing comment.
49
49
  #
50
50
  # @return [void]
@@ -57,7 +57,7 @@ module Superthread
57
57
 
58
58
  desc "update REPLY_ID", "Update a reply"
59
59
  option :comment, type: :string, required: true, desc: "Parent comment ID"
60
- option :content, type: :string, desc: "New content"
60
+ option :content, type: :string, desc: "New content (HTML). Use {{@Name}} to mention users"
61
61
  option :status, type: :string, enum: %w[resolved open orphaned], desc: "Status"
62
62
  # Update an existing reply's content or status.
63
63
  #
@@ -7,6 +7,7 @@ module Superthread
7
7
  desc "query SEARCH_TERM", "Search across workspace"
8
8
  option :field, type: :string, enum: %w[title content], desc: "Field to search in"
9
9
  option :types, type: :string, desc: "Entity types to search (comma-separated: board,card,page,project,epic,note)"
10
+ option :status, type: :string, desc: "Status filter (comma-separated, e.g., open,started)"
10
11
  option :space, type: :string, aliases: "-s", desc: "Space to filter by (ID or name)"
11
12
  option :include_archived, type: :boolean, desc: "Include archived items"
12
13
  option :grouped, type: :boolean, desc: "Group results by type"
@@ -17,18 +18,32 @@ module Superthread
17
18
  def query(search_term)
18
19
  handle_error do
19
20
  types = options[:types]&.split(",")&.map(&:strip)
21
+ statuses = options[:status]&.split(",")&.map(&:strip)
20
22
  results = client.search.query(
21
23
  workspace_id,
22
24
  query: search_term,
23
25
  field: options[:field],
24
26
  types: types,
25
- space_id: space_id,
27
+ statuses: statuses,
28
+ space_id: (space_id if options[:space]),
26
29
  archived: options[:include_archived],
27
- grouped: options[:grouped]
30
+ grouped: options[:grouped],
31
+ limit: effective_limit
28
32
  )
29
33
  output_list results, columns: %i[result_type id title], headers: {id: "ID"}
30
34
  end
31
35
  end
36
+
37
+ private
38
+
39
+ # Override Base#effective_limit for search-specific defaults.
40
+ #
41
+ # @return [Integer, nil] the limit (nil = unlimited when --limit 0)
42
+ def effective_limit
43
+ limit = options[:limit]
44
+ return nil if limit == 0
45
+ (limit.is_a?(Integer) && limit > 0) ? limit : 30
46
+ end
32
47
  end
33
48
  end
34
49
  end
@@ -34,7 +34,7 @@ module Superthread
34
34
  @connection.send(method) do |req|
35
35
  req.url(relative_path)
36
36
  req.params = params if params
37
- req.body = body.to_json if body
37
+ req.body = body.to_json.encode(Encoding::UTF_8) if body
38
38
  end
39
39
  end
40
40
 
@@ -30,6 +30,8 @@ module Superthread
30
30
  ESCAPE_PATTERN = /\\\{\{@([^}]+)\}\}/
31
31
  # @return [Regexp] pattern matching placeholders used during escape processing
32
32
  PLACEHOLDER_PATTERN = /___ESCAPED_MENTION_(.+?)___END___/
33
+ # @return [Regexp] pattern matching raw HTML mention tags that should use {{@Name}} syntax
34
+ HTML_MENTION_PATTERN = /<(?:user-mention|mention-user)\b/i
33
35
 
34
36
  # @param client [Superthread::Client] the API client for fetching members
35
37
  # @param workspace_id [String] the workspace to look up members in
@@ -43,6 +45,7 @@ module Superthread
43
45
  # @param content [String, nil] text that may contain {{@Name}} patterns
44
46
  # @return [String, nil] content with mentions converted to HTML tags
45
47
  def format(content)
48
+ warn_html_mentions(content) if content
46
49
  return content if content.nil? || !content.include?("{{@")
47
50
 
48
51
  member_map = build_member_map
@@ -54,6 +57,7 @@ module Superthread
54
57
  result = content.gsub(ESCAPE_PATTERN, '___ESCAPED_MENTION_\1___END___')
55
58
 
56
59
  # Pass 2: replace {{@Name}} with HTML tags
60
+ unresolved = []
57
61
  result = result.gsub(MENTION_PATTERN) do |match|
58
62
  name = Regexp.last_match(1).strip
59
63
  member = member_map[name.downcase]
@@ -67,16 +71,34 @@ module Superthread
67
71
  "user-value=\"#{safe_name}\" " \
68
72
  'denotation-char="@"></user-mention>'
69
73
  else
74
+ unresolved << name
70
75
  match
71
76
  end
72
77
  end
73
78
 
79
+ unresolved.each do |name|
80
+ warn "Warning: Could not resolve mention: #{name}. " \
81
+ "Check the display name matches a workspace member."
82
+ end
83
+
74
84
  # Pass 3: restore escaped mentions as literal {{@Name}} text
75
85
  result.gsub(PLACEHOLDER_PATTERN, '{{@\1}}')
76
86
  end
77
87
 
78
88
  private
79
89
 
90
+ # Warns when content contains raw HTML mention tags instead of {{@Name}} syntax.
91
+ #
92
+ # @param content [String] text to check for raw HTML mention tags
93
+ # @return [void]
94
+ def warn_html_mentions(content)
95
+ return unless content.match?(HTML_MENTION_PATTERN)
96
+
97
+ warn "Warning: Raw HTML mention tags detected in content. " \
98
+ "Use {{@Name}} syntax to mention users " \
99
+ "(e.g., '{{@Steve Clarke}} check this')."
100
+ end
101
+
80
102
  # Fetches workspace members and builds a case-insensitive lookup map.
81
103
  #
82
104
  # @return [Hash{String => Hash}, nil] map of lowercase names to {id:, name:}, or nil on failure
@@ -57,6 +57,13 @@ module Superthread
57
57
 
58
58
  timestamps :time_created, :time_updated
59
59
 
60
+ # Items sorted by position.
61
+ #
62
+ # @return [Array<ChecklistItem>] items in position order
63
+ def sorted_items
64
+ (items || []).sort_by { |i| i.position || 0 }
65
+ end
66
+
60
67
  # Count of completed items.
61
68
  #
62
69
  # @return [Integer] Number of checked items
@@ -41,6 +41,10 @@ module Superthread
41
41
  # @return [Boolean] whether this item is checked/completed
42
42
  attribute :checked, Shale::Type::Boolean
43
43
 
44
+ # @!attribute [rw] position
45
+ # @return [Integer] position within the checklist for ordering
46
+ attribute :position, Shale::Type::Integer
47
+
44
48
  # @!attribute [rw] time_created
45
49
  # @return [Integer] Unix timestamp when the item was created
46
50
  attribute :time_created, Shale::Type::Integer
@@ -7,10 +7,17 @@ module Superthread
7
7
  # Provides methods for searching across workspace entities
8
8
  # (cards, pages, boards, etc.) via the Superthread API.
9
9
  class Search < Base
10
+ # Safety cap on pagination requests to prevent runaway loops.
11
+ MAX_PAGES = 100
12
+
10
13
  # Searches across workspace entities.
11
14
  #
15
+ # Follows pagination cursors automatically. When limit is provided,
16
+ # stops after accumulating that many results.
17
+ #
12
18
  # @param workspace_id [String] the workspace identifier
13
19
  # @param query [String] the search query string
20
+ # @param limit [Integer, nil] max results to return (nil = no limit)
14
21
  # @param params [Hash{Symbol => Object}] optional search parameters
15
22
  # @option params [String] :field the field to search (title, content)
16
23
  # @option params [Array<String>] :types entity types to include (board, card, page, etc.)
@@ -18,28 +25,39 @@ module Superthread
18
25
  # @option params [String] :space_id the space identifier to filter by
19
26
  # @option params [Boolean] :archived when true, includes archived entities
20
27
  # @option params [Boolean] :grouped when true, groups results by type (default: false)
21
- # @option params [String] :cursor the pagination cursor for next page
22
28
  # @return [Superthread::Objects::Collection] the search results
23
- def query(workspace_id, query:, **params)
29
+ def query(workspace_id, query:, limit: nil, **params)
24
30
  ws = safe_id("workspace_id", workspace_id)
25
- # Default grouped to false so results come in a flat array
26
- # Use || instead of fetch because CLI may pass grouped: nil explicitly
27
- grouped = params[:grouped].nil? ? false : params[:grouped]
28
- search_params = compact_params(
29
- query: query,
30
- project_id: params[:space_id],
31
- grouped: grouped,
32
- **params.except(:space_id, :grouped)
33
- )
34
- response = http_get("/#{ws}/search", params: search_params)
31
+ grouped = params.fetch(:grouped, false)
32
+ all_results = []
33
+ cursor = nil
34
+ pages = 0
35
+
36
+ loop do
37
+ search_params = compact_params(
38
+ query: query,
39
+ project_id: params[:space_id],
40
+ grouped: grouped,
41
+ cursor: cursor,
42
+ **params.except(:space_id, :grouped)
43
+ )
44
+ response = http_get("/#{ws}/search", params: search_params)
45
+
46
+ results = (response[:results] || []).map do |item|
47
+ result_type, data = item.first
48
+ data.merge(result_type: result_type.to_s)
49
+ end
50
+ all_results.concat(results)
35
51
 
36
- # Unwrap results - each item is {"card": {...}} or {"board": {...}}, etc.
37
- results = (response[:results] || []).map do |item|
38
- result_type, data = item.first
39
- data.merge(result_type: result_type.to_s)
52
+ cursor = response[:cursor]
53
+ pages += 1
54
+ break if cursor.nil? || cursor.empty?
55
+ break if limit && all_results.size >= limit
56
+ break if pages >= MAX_PAGES
40
57
  end
41
58
 
42
- Objects::Collection.from_response(results)
59
+ all_results = all_results.first(limit) if limit
60
+ Objects::Collection.from_response(all_results)
43
61
  end
44
62
  end
45
63
  end
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Superthread
4
4
  # Current version of the Superthread gem.
5
- VERSION = "0.7.3"
5
+ VERSION = "0.8.0"
6
6
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: superthread
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.7.3
4
+ version: 0.8.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steve Clarke