superthread 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (84) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +4 -0
  3. data/LICENSE +21 -0
  4. data/README.md +492 -0
  5. data/exe/suth +19 -0
  6. data/lib/superthread/cli/accounts.rb +240 -0
  7. data/lib/superthread/cli/activity.rb +210 -0
  8. data/lib/superthread/cli/base.rb +355 -0
  9. data/lib/superthread/cli/boards.rb +131 -0
  10. data/lib/superthread/cli/cards.rb +530 -0
  11. data/lib/superthread/cli/checklists.rb +223 -0
  12. data/lib/superthread/cli/comments.rb +86 -0
  13. data/lib/superthread/cli/completion.rb +306 -0
  14. data/lib/superthread/cli/concerns/board_resolvable.rb +70 -0
  15. data/lib/superthread/cli/concerns/confirmable.rb +55 -0
  16. data/lib/superthread/cli/concerns/date_parsable.rb +196 -0
  17. data/lib/superthread/cli/concerns/list_resolvable.rb +53 -0
  18. data/lib/superthread/cli/concerns/space_resolvable.rb +52 -0
  19. data/lib/superthread/cli/concerns/sprint_resolvable.rb +55 -0
  20. data/lib/superthread/cli/concerns/tag_resolvable.rb +49 -0
  21. data/lib/superthread/cli/concerns/user_resolvable.rb +52 -0
  22. data/lib/superthread/cli/concerns/workspace_resolvable.rb +83 -0
  23. data/lib/superthread/cli/config.rb +129 -0
  24. data/lib/superthread/cli/formatter.rb +388 -0
  25. data/lib/superthread/cli/lists.rb +85 -0
  26. data/lib/superthread/cli/main.rb +121 -0
  27. data/lib/superthread/cli/members.rb +19 -0
  28. data/lib/superthread/cli/notes.rb +64 -0
  29. data/lib/superthread/cli/pages.rb +128 -0
  30. data/lib/superthread/cli/projects.rb +124 -0
  31. data/lib/superthread/cli/replies.rb +94 -0
  32. data/lib/superthread/cli/search.rb +34 -0
  33. data/lib/superthread/cli/setup.rb +253 -0
  34. data/lib/superthread/cli/spaces.rb +141 -0
  35. data/lib/superthread/cli/sprints.rb +32 -0
  36. data/lib/superthread/cli/tags.rb +86 -0
  37. data/lib/superthread/cli/ui/gum_prompt.rb +58 -0
  38. data/lib/superthread/cli/ui/plain_prompt.rb +73 -0
  39. data/lib/superthread/cli/ui.rb +263 -0
  40. data/lib/superthread/cli/workspaces.rb +105 -0
  41. data/lib/superthread/cli.rb +12 -0
  42. data/lib/superthread/client.rb +207 -0
  43. data/lib/superthread/configuration.rb +354 -0
  44. data/lib/superthread/connection.rb +57 -0
  45. data/lib/superthread/error.rb +164 -0
  46. data/lib/superthread/mention_formatter.rb +96 -0
  47. data/lib/superthread/model.rb +178 -0
  48. data/lib/superthread/models/board.rb +59 -0
  49. data/lib/superthread/models/card.rb +321 -0
  50. data/lib/superthread/models/checklist.rb +91 -0
  51. data/lib/superthread/models/checklist_item.rb +69 -0
  52. data/lib/superthread/models/comment.rb +71 -0
  53. data/lib/superthread/models/concerns/archivable.rb +32 -0
  54. data/lib/superthread/models/concerns/presentable.rb +113 -0
  55. data/lib/superthread/models/concerns/timestampable.rb +91 -0
  56. data/lib/superthread/models/list.rb +67 -0
  57. data/lib/superthread/models/member.rb +40 -0
  58. data/lib/superthread/models/note.rb +56 -0
  59. data/lib/superthread/models/page.rb +70 -0
  60. data/lib/superthread/models/project.rb +83 -0
  61. data/lib/superthread/models/space.rb +71 -0
  62. data/lib/superthread/models/sprint.rb +53 -0
  63. data/lib/superthread/models/tag.rb +52 -0
  64. data/lib/superthread/models/team.rb +68 -0
  65. data/lib/superthread/models/user.rb +76 -0
  66. data/lib/superthread/models.rb +12 -0
  67. data/lib/superthread/object.rb +285 -0
  68. data/lib/superthread/objects/collection.rb +179 -0
  69. data/lib/superthread/resources/base.rb +204 -0
  70. data/lib/superthread/resources/boards.rb +150 -0
  71. data/lib/superthread/resources/cards.rb +363 -0
  72. data/lib/superthread/resources/comments.rb +163 -0
  73. data/lib/superthread/resources/notes.rb +61 -0
  74. data/lib/superthread/resources/pages.rb +110 -0
  75. data/lib/superthread/resources/projects.rb +117 -0
  76. data/lib/superthread/resources/search.rb +46 -0
  77. data/lib/superthread/resources/spaces.rb +104 -0
  78. data/lib/superthread/resources/sprints.rb +37 -0
  79. data/lib/superthread/resources/tags.rb +52 -0
  80. data/lib/superthread/resources/users.rb +29 -0
  81. data/lib/superthread/version.rb +6 -0
  82. data/lib/superthread/version_checker.rb +174 -0
  83. data/lib/superthread.rb +30 -0
  84. metadata +259 -0
@@ -0,0 +1,354 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module Superthread
7
+ # Manages configuration and state for the Superthread CLI.
8
+ #
9
+ # Handles multi-account management, API credentials, and persistent state.
10
+ # Configuration is stored in XDG-compliant locations:
11
+ # - Config: ~/.config/superthread/config.yaml (accounts, settings)
12
+ # - State: ~/.local/state/superthread/context.yaml (current account, workspace)
13
+ #
14
+ # Environment variables can override file-based configuration:
15
+ # - SUPERTHREAD_API_KEY - API key for authentication
16
+ # - SUPERTHREAD_WORKSPACE_ID - Default workspace ID
17
+ # - SUPERTHREAD_ACCOUNT - Account name to use
18
+ # - SUPERTHREAD_API_BASE_URL - Custom API base URL
19
+ #
20
+ # @example Basic usage
21
+ # config = Superthread::Configuration.new
22
+ # config.validate!
23
+ # config.api_key # => "sk_..."
24
+ class Configuration
25
+ # Default API base URL for the Superthread API.
26
+ DEFAULT_BASE_URL = "https://api.superthread.com/v1"
27
+
28
+ # @return [String] API base URL
29
+ attr_accessor :base_url
30
+
31
+ # @return [String] Output format for CLI (table, json, etc.)
32
+ attr_accessor :format
33
+
34
+ # @return [Integer] HTTP request timeout in seconds
35
+ attr_accessor :timeout
36
+
37
+ # @return [Integer] HTTP connection timeout in seconds
38
+ attr_accessor :open_timeout
39
+
40
+ # Creates a new Configuration instance.
41
+ #
42
+ # Loads configuration from files and environment variables in this order:
43
+ # 1. Default values
44
+ # 2. Config file (~/.config/superthread/config.yaml)
45
+ # 3. State file (~/.local/state/superthread/context.yaml)
46
+ # 4. Environment variables (highest priority)
47
+ def initialize
48
+ @base_url = DEFAULT_BASE_URL
49
+ @format = "table"
50
+ @timeout = 30
51
+ @open_timeout = 10
52
+ @config_data = {}
53
+ @state_data = {}
54
+
55
+ load_config_file
56
+ load_state_file
57
+ load_env_vars
58
+ end
59
+
60
+ # Returns the path to the configuration file.
61
+ #
62
+ # Uses XDG_CONFIG_HOME if set, otherwise defaults to ~/.config.
63
+ #
64
+ # @return [String] absolute path to config.yaml
65
+ def config_path
66
+ @config_path ||= File.join(
67
+ ENV.fetch("XDG_CONFIG_HOME", File.expand_path("~/.config")),
68
+ "superthread",
69
+ "config.yaml"
70
+ )
71
+ end
72
+
73
+ # Returns the path to the state file.
74
+ #
75
+ # Uses XDG_STATE_HOME if set, otherwise defaults to ~/.local/state.
76
+ # State includes ephemeral data like current account and workspace.
77
+ #
78
+ # @return [String] absolute path to context.yaml
79
+ def state_path
80
+ @state_path ||= File.join(
81
+ ENV.fetch("XDG_STATE_HOME", File.expand_path("~/.local/state")),
82
+ "superthread",
83
+ "context.yaml"
84
+ )
85
+ end
86
+
87
+ # Returns all configured accounts from the config file.
88
+ #
89
+ # @return [Hash{Symbol => Hash}] account names mapped to their settings
90
+ def accounts
91
+ @config_data[:accounts] || {}
92
+ end
93
+
94
+ # Returns the current account name.
95
+ #
96
+ # Checks environment variable first, then falls back to state file.
97
+ #
98
+ # @return [String, nil] current account name or nil if not set
99
+ def current_account
100
+ @env_account || @state_data[:current_account]
101
+ end
102
+
103
+ # Switches to a different account.
104
+ #
105
+ # Updates the state file with the new current account.
106
+ #
107
+ # @param name [String] account name to switch to
108
+ # @raise [ConfigurationError] if the account does not exist
109
+ def current_account=(name)
110
+ name = name.to_s
111
+ raise ConfigurationError, "Account '#{name}' not found" unless accounts.key?(name.to_sym)
112
+
113
+ @state_data[:current_account] = name
114
+ save_state_file
115
+ end
116
+
117
+ # Returns ephemeral state for an account.
118
+ #
119
+ # State includes workspace_id and workspace_name.
120
+ #
121
+ # @param name [String, nil] account name (defaults to current account)
122
+ # @return [Hash, nil] account state hash or nil if not found
123
+ def account_state(name = current_account)
124
+ return nil unless name
125
+
126
+ state_accounts = @state_data[:accounts] || {}
127
+ state_accounts[name.to_sym] || {}
128
+ end
129
+
130
+ # Adds a new account to the configuration.
131
+ #
132
+ # @param name [String] unique account name
133
+ # @param api_key [String] API key for authentication
134
+ def add_account(name, api_key:)
135
+ name = name.to_s
136
+ @config_data[:accounts] ||= {}
137
+ @config_data[:accounts][name.to_sym] = {api_key: api_key}
138
+ save_config_file
139
+ end
140
+
141
+ # Removes an account from config and state.
142
+ #
143
+ # If removing the current account, clears the current account selection.
144
+ #
145
+ # @param name [String] account name to remove
146
+ def remove_account(name)
147
+ name = name.to_s
148
+ @config_data[:accounts]&.delete(name.to_sym)
149
+ @state_data[:accounts]&.delete(name.to_sym)
150
+
151
+ # If removing current account, clear it
152
+ if @state_data[:current_account] == name
153
+ @state_data.delete(:current_account)
154
+ end
155
+
156
+ save_config_file
157
+ save_state_file
158
+ end
159
+
160
+ # Saves ephemeral state for an account.
161
+ #
162
+ # Used to persist workspace selection between sessions.
163
+ #
164
+ # @param name [String] the configured account alias (e.g., "work", "personal")
165
+ # @param workspace_id [String] workspace ID to save
166
+ # @param workspace_name [String, nil] optional workspace name for display
167
+ def save_account_state(name, workspace_id:, workspace_name: nil)
168
+ name = name.to_s
169
+ @state_data[:accounts] ||= {}
170
+ @state_data[:accounts][name.to_sym] = {
171
+ workspace_id: workspace_id,
172
+ workspace_name: workspace_name
173
+ }.compact
174
+ save_state_file
175
+ end
176
+
177
+ # Sets the current account in state.
178
+ #
179
+ # Unlike the setter, this does not validate the account exists.
180
+ #
181
+ # @param name [String] the configured account alias to make current
182
+ def set_current_account(name)
183
+ @state_data[:current_account] = name.to_s
184
+ save_state_file
185
+ end
186
+
187
+ # Returns the API key for the current context.
188
+ #
189
+ # Checks in order: manual override, environment variable, account config.
190
+ #
191
+ # @return [String, nil] API key or nil if not configured
192
+ def api_key
193
+ return @manual_api_key if @manual_api_key
194
+ return @env_api_key if @env_api_key
195
+
196
+ account = accounts[current_account&.to_sym]
197
+ account&.dig(:api_key)
198
+ end
199
+
200
+ # Sets a manual API key override.
201
+ #
202
+ # Useful for testing or scripting without modifying config files.
203
+ #
204
+ # @param key [String] Superthread API key for authentication
205
+ def api_key=(key)
206
+ @manual_api_key = key
207
+ end
208
+
209
+ # Sets a manual workspace ID override.
210
+ #
211
+ # Useful for programmatic usage without modifying config files.
212
+ #
213
+ # @param id [String] workspace ID
214
+ def workspace=(id)
215
+ @manual_workspace = id
216
+ end
217
+
218
+ # Returns the workspace ID for the current context.
219
+ #
220
+ # Checks in order: manual override, environment variable, account state.
221
+ #
222
+ # @return [String, nil] workspace ID or nil if not set
223
+ def workspace
224
+ return @manual_workspace if @manual_workspace
225
+ return @env_workspace if @env_workspace
226
+
227
+ account_state&.dig(:workspace_id)
228
+ end
229
+
230
+ # Returns the workspace name for display purposes.
231
+ #
232
+ # @return [String, nil] workspace name or nil if not set
233
+ def workspace_name
234
+ account_state&.dig(:workspace_name)
235
+ end
236
+
237
+ # Validates that required configuration is present.
238
+ #
239
+ # @return [void]
240
+ # @raise [ConfigurationError] if no account is configured
241
+ # @raise [ConfigurationError] if API key is missing
242
+ def validate!
243
+ # Allow manual or env API key to bypass account requirement
244
+ return if @manual_api_key && !@manual_api_key.empty?
245
+ return if @env_api_key && !@env_api_key.empty?
246
+
247
+ unless current_account
248
+ raise ConfigurationError,
249
+ "No account configured. Run 'suth setup' to configure an account."
250
+ end
251
+
252
+ unless api_key && !api_key.empty?
253
+ raise ConfigurationError,
254
+ "API key not found for account '#{current_account}'. " \
255
+ "Run 'suth setup' or set SUPERTHREAD_API_KEY environment variable."
256
+ end
257
+ end
258
+
259
+ # Checks if any accounts are configured.
260
+ #
261
+ # @return [Boolean] true if at least one account exists
262
+ def accounts?
263
+ accounts.any?
264
+ end
265
+
266
+ # Persists the current configuration to the config file.
267
+ #
268
+ # Creates parent directories if they don't exist.
269
+ #
270
+ # @return [void]
271
+ def save_config_file
272
+ FileUtils.mkdir_p(File.dirname(config_path))
273
+ data = {
274
+ "accounts" => stringify_keys(accounts),
275
+ "format" => @format,
276
+ "timeout" => @timeout,
277
+ "open_timeout" => @open_timeout
278
+ }
279
+ data["base_url"] = @base_url if @base_url != DEFAULT_BASE_URL
280
+ File.write(config_path, YAML.dump(data))
281
+ end
282
+
283
+ # Persists the current state to the state file.
284
+ #
285
+ # Creates parent directories if they don't exist.
286
+ #
287
+ # @return [void]
288
+ def save_state_file
289
+ FileUtils.mkdir_p(File.dirname(state_path))
290
+ data = {}
291
+ data["current_account"] = @state_data[:current_account] if @state_data[:current_account]
292
+ if @state_data[:accounts]&.any?
293
+ data["accounts"] = stringify_keys(@state_data[:accounts])
294
+ end
295
+ File.write(state_path, YAML.dump(data))
296
+ end
297
+
298
+ private
299
+
300
+ # Loads configuration from the config file.
301
+ #
302
+ # @return [void]
303
+ def load_config_file
304
+ return unless File.exist?(config_path)
305
+
306
+ config = YAML.safe_load_file(config_path, symbolize_names: true)
307
+ return unless config.is_a?(Hash)
308
+
309
+ @config_data = config
310
+ @base_url = config[:base_url] if config[:base_url]
311
+ @format = config[:format] if config[:format]
312
+ @timeout = config[:timeout] if config[:timeout]
313
+ @open_timeout = config[:open_timeout] if config[:open_timeout]
314
+ rescue Psych::SyntaxError => e
315
+ raise ConfigurationError, "Invalid YAML in #{config_path}: #{e.message}"
316
+ end
317
+
318
+ # Loads state from the state file.
319
+ #
320
+ # @return [void]
321
+ def load_state_file
322
+ return unless File.exist?(state_path)
323
+
324
+ state = YAML.safe_load_file(state_path, symbolize_names: true)
325
+ return unless state.is_a?(Hash)
326
+
327
+ @state_data = state
328
+ rescue Psych::SyntaxError => e
329
+ raise ConfigurationError, "Invalid YAML in #{state_path}: #{e.message}"
330
+ end
331
+
332
+ # Loads configuration overrides from environment variables.
333
+ #
334
+ # @return [void]
335
+ def load_env_vars
336
+ @env_api_key = ENV["SUPERTHREAD_API_KEY"] if ENV["SUPERTHREAD_API_KEY"]
337
+ @env_workspace = ENV["SUPERTHREAD_WORKSPACE_ID"] if ENV["SUPERTHREAD_WORKSPACE_ID"]
338
+ @env_account = ENV["SUPERTHREAD_ACCOUNT"] if ENV["SUPERTHREAD_ACCOUNT"]
339
+ @base_url = ENV["SUPERTHREAD_API_BASE_URL"] if ENV["SUPERTHREAD_API_BASE_URL"]
340
+ end
341
+
342
+ # Converts symbol keys to strings for YAML output.
343
+ #
344
+ # @param hash [Hash, nil] the hash to convert
345
+ # @return [Hash] the hash with string keys
346
+ def stringify_keys(hash)
347
+ return {} unless hash
348
+
349
+ hash.transform_keys(&:to_s).transform_values do |v|
350
+ v.is_a?(Hash) ? stringify_keys(v) : v
351
+ end
352
+ end
353
+ end
354
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module Superthread
7
+ # Low-level HTTP connection wrapper using Faraday.
8
+ #
9
+ # Handles the actual HTTP communication with the Superthread API,
10
+ # including authentication headers, timeouts, and JSON encoding.
11
+ #
12
+ # @api private
13
+ class Connection
14
+ # Creates a new connection with the given configuration.
15
+ #
16
+ # @param config [Superthread::Configuration] configuration with API credentials
17
+ def initialize(config)
18
+ @config = config
19
+ @connection = build_connection
20
+ end
21
+
22
+ # Performs an HTTP request to the API.
23
+ #
24
+ # @param method [Symbol] HTTP method (:get, :post, :patch, :delete)
25
+ # @param path [String] API endpoint path (leading slash is stripped)
26
+ # @param params [Hash{Symbol => Object}, nil] query parameters
27
+ # @param body [Hash{Symbol => Object}, nil] request body (will be JSON-encoded)
28
+ # @return [Faraday::Response] raw HTTP response
29
+ def request(method:, path:, params: nil, body: nil)
30
+ # Remove leading slash - Faraday treats /path as absolute from host root,
31
+ # but we want it relative to the base URL (which includes /v1)
32
+ relative_path = path.sub(%r{^/}, "")
33
+
34
+ @connection.send(method) do |req|
35
+ req.url(relative_path)
36
+ req.params = params if params
37
+ req.body = body.to_json if body
38
+ end
39
+ end
40
+
41
+ private
42
+
43
+ # Builds a configured Faraday connection for API requests.
44
+ #
45
+ # @return [Faraday::Connection] connection with auth headers and timeouts
46
+ def build_connection
47
+ Faraday.new(url: @config.base_url) do |conn|
48
+ conn.headers["Authorization"] = "Bearer #{@config.api_key}"
49
+ conn.headers["Content-Type"] = "application/json"
50
+ conn.headers["Accept"] = "application/json"
51
+ conn.options.timeout = @config.timeout
52
+ conn.options.open_timeout = @config.open_timeout
53
+ conn.adapter Faraday.default_adapter
54
+ end
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ # Base class for API errors from HTTP responses.
5
+ #
6
+ # Contains detailed information about the failed request including
7
+ # status code, response body, and the original Faraday response.
8
+ #
9
+ # @example Handling API errors
10
+ # begin
11
+ # client.cards.find(workspace_id, card_id)
12
+ # rescue Superthread::NotFoundError => e
13
+ # puts "Card not found: #{e.message}"
14
+ # rescue Superthread::ApiError => e
15
+ # puts "API error (#{e.status}): #{e.message}"
16
+ # end
17
+ class ApiError < Error
18
+ # @return [Integer, nil] HTTP status code
19
+ attr_reader :status
20
+
21
+ # @return [Hash{Symbol => Object}, String, nil] parsed response body
22
+ attr_reader :body
23
+
24
+ # @return [Faraday::Response, nil] original HTTP response
25
+ attr_reader :response
26
+
27
+ # Creates an ApiError from HTTP response data.
28
+ #
29
+ # @param message [String] human-readable error message
30
+ # @param status [Integer, nil] HTTP status code
31
+ # @param body [String, Hash{Symbol => Object}, nil] response body
32
+ # @param response [Faraday::Response, nil] original response object
33
+ def initialize(message, status: nil, body: nil, response: nil)
34
+ @status = status
35
+ @body = body
36
+ @response = response
37
+ super(build_message(message))
38
+ end
39
+
40
+ # Creates the appropriate error type from an HTTP response.
41
+ #
42
+ # Examines both status code and body content to determine the best error class.
43
+ #
44
+ # @param response [Faraday::Response] the HTTP response
45
+ # @return [Superthread::ApiError] the appropriate error subclass
46
+ def self.from_response(response)
47
+ status = response.status
48
+ body = parse_body(response.body)
49
+ message = extract_message(body)
50
+
51
+ klass = error_class_for(status, body)
52
+ klass.new(message, status: status, body: body, response: response)
53
+ end
54
+
55
+ # Determines the appropriate error class based on status and body.
56
+ #
57
+ # @param status [Integer] HTTP status code
58
+ # @param body [Hash{Symbol => Object}, String] response body
59
+ # @return [Class] error class to use
60
+ def self.error_class_for(status, body)
61
+ case status
62
+ when 400 then ValidationError
63
+ when 401 then AuthenticationError
64
+ when 403 then error_for_403(body)
65
+ when 404 then NotFoundError
66
+ when 422 then ValidationError
67
+ when 429 then RateLimitError
68
+ when 400..499 then ClientError
69
+ when 500..599 then ServerError
70
+ else ApiError
71
+ end
72
+ end
73
+
74
+ # Determines the specific error class for 403 responses.
75
+ #
76
+ # 403 errors may indicate rate limiting or permission issues depending on the body.
77
+ #
78
+ # @param body [Hash{Symbol => Object}, String] response body
79
+ # @return [Class] specific error class
80
+ def self.error_for_403(body)
81
+ message = body.is_a?(Hash) ? body[:message].to_s : body.to_s
82
+
83
+ case message.downcase
84
+ when /rate limit/i then RateLimitError
85
+ when /permission/i, /access denied/i then ForbiddenError
86
+ else ForbiddenError
87
+ end
88
+ end
89
+
90
+ # Parses the response body into a hash if possible.
91
+ #
92
+ # @param body [String, nil] raw response body
93
+ # @return [Hash{Symbol => Object}, String] parsed body or original string
94
+ def self.parse_body(body)
95
+ return {} if body.nil? || body.empty?
96
+
97
+ JSON.parse(body, symbolize_names: true)
98
+ rescue JSON::ParserError
99
+ body.to_s
100
+ end
101
+
102
+ # Extracts an error message from the response body.
103
+ #
104
+ # @param body [Hash{Symbol => Object}, String] response body
105
+ # @return [String] error message
106
+ def self.extract_message(body)
107
+ case body
108
+ when Hash
109
+ body[:message] || body[:error] || body[:error_description] || "Unknown error"
110
+ else
111
+ body.to_s.empty? ? "Unknown error" : body.to_s
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ # Builds a formatted error message with HTTP status prefix.
118
+ #
119
+ # @param message [String] the error message text
120
+ # @return [String] formatted message with status code if present
121
+ def build_message(message)
122
+ parts = []
123
+ parts << "HTTP #{@status}" if @status
124
+ parts << message
125
+ parts.join(": ")
126
+ end
127
+ end
128
+
129
+ # HTTP 4xx - Client errors (base class)
130
+ class ClientError < ApiError; end
131
+
132
+ # HTTP 5xx - Server errors
133
+ class ServerError < ApiError; end
134
+
135
+ # HTTP 401 - Invalid API key or authentication required
136
+ class AuthenticationError < ClientError; end
137
+
138
+ # HTTP 403 - Permission denied or forbidden action
139
+ class ForbiddenError < ClientError; end
140
+
141
+ # HTTP 404 - Resource not found
142
+ class NotFoundError < ClientError; end
143
+
144
+ # HTTP 400/422 - Validation error or invalid request
145
+ class ValidationError < ClientError; end
146
+
147
+ # Raised when the API rate limit is exceeded (HTTP 429).
148
+ class RateLimitError < ClientError
149
+ # Returns the number of seconds to wait before retrying.
150
+ #
151
+ # @return [Integer, nil] seconds to wait, or nil if not available
152
+ def retry_after
153
+ return nil unless @response
154
+
155
+ @response.headers["retry-after"]&.to_i
156
+ end
157
+ end
158
+
159
+ # Raised when path or ID validation fails on the client side.
160
+ #
161
+ # This error is raised before making an API request when the provided
162
+ # path or ID is invalid (e.g., contains invalid characters).
163
+ class PathValidationError < Error; end
164
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "cgi"
4
+
5
+ module Superthread
6
+ # Converts {{@mentions}} in content to Superthread's HTML user-mention tags.
7
+ #
8
+ # Superthread allows user names with spaces, punctuation, and titles
9
+ # (e.g., "John A. J. Smith the 3rd, Esq."), making traditional @mention
10
+ # syntax unreliable. The {{@Name}} template syntax provides unambiguous
11
+ # delimiters that handle any valid display name.
12
+ #
13
+ # Uses a three-pass algorithm:
14
+ # 1. Escape protected mentions: `\{{@Name}}` -> placeholder
15
+ # 2. Replace mentions: `{{@Name}}` -> `<user-mention>` HTML tag
16
+ # 3. Restore escaped text: placeholder -> `{{@Name}}`
17
+ #
18
+ # @example Basic usage
19
+ # formatter = MentionFormatter.new(client, "ws-123")
20
+ # formatter.format("Hey {{@Steve Clarke}}, check this")
21
+ # # => 'Hey <user-mention data-type="mention" user-id="u123" ...></user-mention>, check this'
22
+ #
23
+ # @example Escaping
24
+ # formatter.format('Use \{{@Username}} syntax to mention users')
25
+ # # => "Use {{@Username}} syntax to mention users"
26
+ class MentionFormatter
27
+ # @return [Regexp] pattern matching `{{@Name}}` mention templates
28
+ MENTION_PATTERN = /\{\{@([^}]+)\}\}/
29
+ # @return [Regexp] pattern matching escaped `\{{@Name}}` mentions
30
+ ESCAPE_PATTERN = /\\\{\{@([^}]+)\}\}/
31
+ # @return [Regexp] pattern matching placeholders used during escape processing
32
+ PLACEHOLDER_PATTERN = /___ESCAPED_MENTION_(.+?)___END___/
33
+
34
+ # @param client [Superthread::Client] the API client for fetching members
35
+ # @param workspace_id [String] the workspace to look up members in
36
+ def initialize(client, workspace_id)
37
+ @client = client
38
+ @workspace_id = workspace_id
39
+ end
40
+
41
+ # Formats mention templates in content to HTML user-mention tags.
42
+ #
43
+ # @param content [String, nil] text that may contain {{@Name}} patterns
44
+ # @return [String, nil] content with mentions converted to HTML tags
45
+ def format(content)
46
+ return content if content.nil? || !content.include?("{{@")
47
+
48
+ member_map = build_member_map
49
+ return content if member_map.nil?
50
+
51
+ mention_time = Time.now.to_i
52
+
53
+ # Pass 1: protect escaped mentions (store name only, not the {{@ }} delimiters)
54
+ result = content.gsub(ESCAPE_PATTERN, '___ESCAPED_MENTION_\1___END___')
55
+
56
+ # Pass 2: replace {{@Name}} with HTML tags
57
+ result = result.gsub(MENTION_PATTERN) do |match|
58
+ name = Regexp.last_match(1).strip
59
+ member = member_map[name.downcase]
60
+
61
+ if member
62
+ safe_id = CGI.escapeHTML(member[:id])
63
+ safe_name = CGI.escapeHTML(member[:name])
64
+ '<user-mention data-type="mention" ' \
65
+ "user-id=\"#{safe_id}\" " \
66
+ "mention-time=\"#{mention_time}\" " \
67
+ "user-value=\"#{safe_name}\" " \
68
+ 'denotation-char="@"></user-mention>'
69
+ else
70
+ match
71
+ end
72
+ end
73
+
74
+ # Pass 3: restore escaped mentions as literal {{@Name}} text
75
+ result.gsub(PLACEHOLDER_PATTERN, '{{@\1}}')
76
+ end
77
+
78
+ private
79
+
80
+ # Fetches workspace members and builds a case-insensitive lookup map.
81
+ #
82
+ # @return [Hash{String => Hash}, nil] map of lowercase names to {id:, name:}, or nil on failure
83
+ def build_member_map
84
+ @client.users.members(@workspace_id).each_with_object({}) do |member, map|
85
+ next unless member.display_name
86
+
87
+ map[member.display_name.downcase] = {
88
+ id: member.user_identifier,
89
+ name: member.display_name
90
+ }
91
+ end
92
+ rescue Superthread::Error
93
+ nil
94
+ end
95
+ end
96
+ end