superthread 0.8.0 → 0.9.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: eb38a8ab1c8ba081e66e8bda3bc4fabcb8aa9732e1e8cfc5ce1bdff0b14f94b2
4
- data.tar.gz: e97e7bffe8dc9bfbd86b424a6762455d1d9979b07a60651b3997ad5feaedddf5
3
+ metadata.gz: 6e15ca652fe609604e401611f3359af9d094872c537a8fb817048631cf5a0cf1
4
+ data.tar.gz: a55736ee16a8ae5b232e0a8c72b3acc42dad25a1bef131b33e50a6dd6f3df950
5
5
  SHA512:
6
- metadata.gz: b2c5dfabc604f08c7aae0eedb7a94fb8f702912c5ccc2730c9d388d6f404dbdff8aa9f3fa835288732612f84d7755e4a2a5283f1d266747b3e9727aa09fdbe5f
7
- data.tar.gz: 847b4c2868387c94b08bbbffab9db1d80dac9b4991884941258eb801347996f11257cdc1079d192848ae5208936352da2b894f95a1288914403279395b50cf61
6
+ metadata.gz: e215412fbe73aaa60fb213fa028d78a3eb933f04023fe9889bc7cf20ad9fd969e5b88091929224883b49d06a20ff3db9581a34b7a7c8831e923d7ede8a72a08a
7
+ data.tar.gz: 1f322daa33b43c271173051cbac4b4d414eea4bfc52c90d6216baa74ba569596f8e812a078b4be7226a6e93d8e0ab0281a8f12d368ba4cde05332c79242393d8
@@ -173,7 +173,7 @@ module Superthread
173
173
  option :board, type: :string, aliases: "-b", desc: "Board (ID or name, required unless --sprint)"
174
174
  option :space, type: :string, aliases: "-s", desc: "Space (required for --sprint, helps resolve board name)"
175
175
  option :sprint, type: :string, desc: "Sprint (ID or name, required unless --board)"
176
- option :content, type: :string, desc: "Card content (HTML)"
176
+ option :content, type: :string, desc: "Card content (HTML). Use {{@Name}} to mention users"
177
177
  option :project, type: :string, desc: "Project ID"
178
178
  option :start_date, type: :numeric, desc: "Start date (Unix timestamp)"
179
179
  option :due_date, type: :numeric, desc: "Due date (Unix timestamp)"
@@ -205,6 +205,7 @@ module Superthread
205
205
 
206
206
  desc "update CARD", "Update a card"
207
207
  option :title, type: :string, desc: "New title"
208
+ option :content, type: :string, desc: "Card content (HTML). Use {{@Name}} to mention users"
208
209
  option :list, type: :string, aliases: "-l", desc: "Destination list (ID or name)"
209
210
  option :board, type: :string, aliases: "-b", desc: "Board (helps resolve list name)"
210
211
  option :sprint, type: :string, desc: "Sprint to move card to (ID or name)"
@@ -215,6 +216,9 @@ module Superthread
215
216
  option :archived, type: :boolean, desc: "Archive/unarchive"
216
217
  # Update an existing card's properties.
217
218
  #
219
+ # Content is updated via a separate PUT endpoint since the standard
220
+ # PATCH endpoint does not support content changes.
221
+ #
218
222
  # @param card_id [String] the unique identifier of the card to update
219
223
  # @return [void]
220
224
  def update(card_id)
@@ -222,36 +226,50 @@ module Superthread
222
226
  require_space_for_sprint!
223
227
 
224
228
  begin
225
- # WORKAROUND: API ignores title when combined with list_id,
226
- # so we make separate requests when both are provided.
227
- # TODO: Remove when API is fixed (https://superthread.com/api/known-issues)
228
- if options[:list] && (options[:title] || options[:priority] || options[:archived] || options[:epic])
229
- # First update non-move fields
230
- field_opts = symbolized_options(:title, :priority, :archived)
231
- field_opts[:epic_id] = options[:epic] if options[:epic]
232
- client.cards.update(workspace_id, card_id, **field_opts) unless field_opts.empty?
233
-
234
- # Then move the card (include sprint context)
235
- move_opts = resolve_list_with_context(options[:list], card_id)
236
- move_opts[:position] = options[:position] if options[:position]
237
- card = client.cards.update(workspace_id, card_id, **move_opts)
238
- else
239
- opts = symbolized_options(:title, :priority, :archived)
240
- opts[:epic_id] = options[:epic] if options[:epic]
241
- opts[:position] = options[:position] if options[:position]
242
-
243
- if options[:list]
244
- opts.merge!(resolve_list_with_context(options[:list], card_id))
245
- elsif options[:sprint]
246
- # Moving to a sprint without --list — default to first list
247
- opts[:sprint_id] = sprint_id
248
- opts[:project_id] = space_id
249
- sprint_obj = client.sprints.find(workspace_id, sprint_id, space_id: space_id)
250
- opts[:list_id] = sprint_obj.lists.first&.id
251
- end
229
+ # Update content via dedicated PUT endpoint (separate from PATCH)
230
+ if options[:content]
231
+ client.cards.update_content(workspace_id, card_id, content: options[:content])
232
+ end
252
233
 
253
- card = client.cards.update(workspace_id, card_id, **opts)
234
+ # Update other fields via PATCH (skip if only content was provided)
235
+ has_patch_fields = options[:title] || options[:list] || options[:priority] ||
236
+ !options[:archived].nil? || options[:epic] || options[:position] || options[:sprint]
237
+
238
+ if has_patch_fields
239
+ # WORKAROUND: API ignores title when combined with list_id,
240
+ # so we make separate requests when both are provided.
241
+ # TODO: Remove when API is fixed (https://superthread.com/api/known-issues)
242
+ if options[:list] && (options[:title] || options[:priority] || !options[:archived].nil? || options[:epic])
243
+ # First update non-move fields
244
+ field_opts = symbolized_options(:title, :priority, :archived)
245
+ field_opts[:epic_id] = options[:epic] if options[:epic]
246
+ client.cards.update(workspace_id, card_id, **field_opts) unless field_opts.empty?
247
+
248
+ # Then move the card (include sprint context)
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
265
+ end
266
+
267
+ card = client.cards.update(workspace_id, card_id, **opts)
268
+ end
254
269
  end
270
+
271
+ # If only content was updated, fetch the card for output
272
+ card ||= client.cards.find(workspace_id, card_id)
255
273
  rescue Superthread::ForbiddenError, Superthread::NotFoundError
256
274
  raise Thor::Error, "Card not found: '#{card_id}'. Use 'suth cards list -b BOARD' to see available cards."
257
275
  end
@@ -116,7 +116,7 @@ module Superthread
116
116
 
117
117
  desc "add-item CHECKLIST", "Add item to a checklist"
118
118
  option :card, type: :string, required: true, aliases: "-c", desc: "Parent card ID"
119
- option :title, type: :string, required: true, desc: "Item title"
119
+ option :title, type: :string, required: true, desc: "Item title. Use {{@Name}} to mention users"
120
120
  option :checked, type: :boolean, default: false, desc: "Create as checked"
121
121
  # Add a new item to an existing checklist.
122
122
  #
@@ -139,7 +139,7 @@ module Superthread
139
139
  desc "update-item ITEM_ID", "Update a checklist item"
140
140
  option :card, type: :string, required: true, aliases: "-c", desc: "Parent card ID"
141
141
  option :checklist, type: :string, required: true, desc: "Parent checklist ID"
142
- option :title, type: :string, desc: "New item title"
142
+ option :title, type: :string, desc: "New item title. Use {{@Name}} to mention users"
143
143
  option :checked, type: :boolean, desc: "Mark as checked/unchecked"
144
144
  # Update the title or checked state of a checklist item.
145
145
  #
@@ -55,19 +55,40 @@ module Superthread
55
55
 
56
56
  desc "update PAGE", "Update a page"
57
57
  option :title, type: :string, desc: "New title"
58
+ option :content, type: :string, desc: "Page content (HTML)"
58
59
  option :is_public, type: :boolean, desc: "Public visibility"
59
60
  option :parent_page, type: :string, desc: "Parent page ID"
60
61
  option :archived, type: :boolean, desc: "Archive/unarchive"
61
62
  # Updates an existing page's properties.
62
63
  #
64
+ # Content is updated via a separate PUT endpoint since the standard
65
+ # PATCH endpoint does not support content changes.
66
+ #
63
67
  # @param page_id [String] numeric ID or URL slug of the page to retrieve
64
68
  # @return [void]
65
69
  def update(page_id)
66
70
  handle_error do
67
- opts = symbolized_options(:title, :is_public, :archived)
68
- opts[:parent_page_id] = options[:parent_page] if options[:parent_page]
69
- page = with_not_found("Page not found: '#{page_id}'. Use 'suth pages list' to see available pages.") do
70
- client.pages.update(workspace_id, page_id, **opts)
71
+ # Update content via dedicated PUT endpoint (separate from PATCH)
72
+ if options[:content]
73
+ with_not_found("Page not found: '#{page_id}'. Use 'suth pages list' to see available pages.") do
74
+ client.pages.update_content(workspace_id, page_id, content: options[:content])
75
+ end
76
+ end
77
+
78
+ # Update other fields via PATCH (skip if only content was provided)
79
+ has_patch_fields = options[:title] || !options[:is_public].nil? || !options[:archived].nil? || options[:parent_page]
80
+
81
+ if has_patch_fields
82
+ opts = symbolized_options(:title, :is_public, :archived)
83
+ opts[:parent_page_id] = options[:parent_page] if options[:parent_page]
84
+ page = with_not_found("Page not found: '#{page_id}'. Use 'suth pages list' to see available pages.") do
85
+ client.pages.update(workspace_id, page_id, **opts)
86
+ end
87
+ end
88
+
89
+ # If only content was updated, fetch the page for output
90
+ page ||= with_not_found("Page not found: '#{page_id}'. Use 'suth pages list' to see available pages.") do
91
+ client.pages.find(workspace_id, page_id)
71
92
  end
72
93
  output_item page, labels: {id: "Page ID"}
73
94
  end
@@ -32,6 +32,8 @@ module Superthread
32
32
  PLACEHOLDER_PATTERN = /___ESCAPED_MENTION_(.+?)___END___/
33
33
  # @return [Regexp] pattern matching raw HTML mention tags that should use {{@Name}} syntax
34
34
  HTML_MENTION_PATTERN = /<(?:user-mention|mention-user)\b/i
35
+ # @return [Regexp] pattern matching plain @Word that is not inside {{@...}} delimiters
36
+ PLAIN_MENTION_PATTERN = /(?<!\{\{)@(\w+)/
35
37
 
36
38
  # @param client [Superthread::Client] the API client for fetching members
37
39
  # @param workspace_id [String] the workspace to look up members in
@@ -46,6 +48,7 @@ module Superthread
46
48
  # @return [String, nil] content with mentions converted to HTML tags
47
49
  def format(content)
48
50
  warn_html_mentions(content) if content
51
+ warn_plain_mentions(content) if content
49
52
  return content if content.nil? || !content.include?("{{@")
50
53
 
51
54
  member_map = build_member_map
@@ -99,6 +102,36 @@ module Superthread
99
102
  "(e.g., '{{@Steve Clarke}} check this')."
100
103
  end
101
104
 
105
+ # Warns when content contains plain @Name mentions that match workspace members.
106
+ # This catches the common mistake of using @Name instead of {{@Name}}.
107
+ #
108
+ # @param content [String] text to check for plain @mentions
109
+ # @return [void]
110
+ def warn_plain_mentions(content)
111
+ return if content.include?("{{@")
112
+
113
+ names = content.scan(PLAIN_MENTION_PATTERN).flatten
114
+ return if names.empty?
115
+
116
+ member_map = build_member_map
117
+ return if member_map.nil?
118
+
119
+ # Also index by first name for multi-word display names
120
+ first_name_map = {}
121
+ member_map.each_value do |info|
122
+ first = info[:name].split.first&.downcase
123
+ first_name_map[first] = info[:name] if first
124
+ end
125
+
126
+ names.each do |name|
127
+ match = member_map[name.downcase]&.fetch(:name) || first_name_map[name.downcase]
128
+ if match
129
+ warn "Warning: Found @#{name} — did you mean {{@#{match}}}? " \
130
+ "Use {{@Name}} syntax to mention users and trigger notifications."
131
+ end
132
+ end
133
+ end
134
+
102
135
  # Fetches workspace members and builds a case-insensitive lookup map.
103
136
  #
104
137
  # @return [Hash{String => Hash}, nil] map of lowercase names to {id:, name:}, or nil on failure
@@ -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
@@ -52,7 +52,7 @@ module Superthread
52
52
  # @option params [String] :epic_id the new epic identifier
53
53
  # @option params [Boolean] :archived whether the card is archived
54
54
  # @return [Superthread::Models::Card] the updated card
55
- # @note Content cannot be updated via this API; it uses WebSocket collaboration.
55
+ # @note Content updates use a separate PUT endpoint; use {#update_content} instead.
56
56
  # @note parent_card_id is not supported on update; the API silently ignores it.
57
57
  def update(workspace_id, card_id, **params)
58
58
  ws = safe_id("workspace_id", workspace_id)
@@ -61,6 +61,23 @@ module Superthread
61
61
  object_class: Models::Card, unwrap_key: :card)
62
62
  end
63
63
 
64
+ # Updates a card's content (description) via the dedicated PUT endpoint.
65
+ #
66
+ # Card content cannot be updated through the standard PATCH endpoint
67
+ # because the web app uses WebSocket collaboration for real-time editing.
68
+ # This method uses the separate PUT endpoint for content updates.
69
+ #
70
+ # @param workspace_id [String] the workspace identifier
71
+ # @param card_id [String] the card identifier
72
+ # @param content [String] the new content as HTML
73
+ # @return [Superthread::Object] a response object with success: true
74
+ def update_content(workspace_id, card_id, content:)
75
+ ws = safe_id("workspace_id", workspace_id)
76
+ card = safe_id("card_id", card_id)
77
+ content = format_mentions(workspace_id, content)
78
+ put_object("/#{ws}/cards/#{card}/content", body: {content: content, is_html: true})
79
+ end
80
+
64
81
  # Gets a specific card with full details.
65
82
  #
66
83
  # @param workspace_id [String] the workspace identifier
@@ -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
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Superthread
4
4
  # Current version of the Superthread gem.
5
- VERSION = "0.8.0"
5
+ VERSION = "0.9.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.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Steve Clarke