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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +4 -0
- data/LICENSE +21 -0
- data/README.md +492 -0
- data/exe/suth +19 -0
- data/lib/superthread/cli/accounts.rb +240 -0
- data/lib/superthread/cli/activity.rb +210 -0
- data/lib/superthread/cli/base.rb +355 -0
- data/lib/superthread/cli/boards.rb +131 -0
- data/lib/superthread/cli/cards.rb +530 -0
- data/lib/superthread/cli/checklists.rb +223 -0
- data/lib/superthread/cli/comments.rb +86 -0
- data/lib/superthread/cli/completion.rb +306 -0
- data/lib/superthread/cli/concerns/board_resolvable.rb +70 -0
- data/lib/superthread/cli/concerns/confirmable.rb +55 -0
- data/lib/superthread/cli/concerns/date_parsable.rb +196 -0
- data/lib/superthread/cli/concerns/list_resolvable.rb +53 -0
- data/lib/superthread/cli/concerns/space_resolvable.rb +52 -0
- data/lib/superthread/cli/concerns/sprint_resolvable.rb +55 -0
- data/lib/superthread/cli/concerns/tag_resolvable.rb +49 -0
- data/lib/superthread/cli/concerns/user_resolvable.rb +52 -0
- data/lib/superthread/cli/concerns/workspace_resolvable.rb +83 -0
- data/lib/superthread/cli/config.rb +129 -0
- data/lib/superthread/cli/formatter.rb +388 -0
- data/lib/superthread/cli/lists.rb +85 -0
- data/lib/superthread/cli/main.rb +121 -0
- data/lib/superthread/cli/members.rb +19 -0
- data/lib/superthread/cli/notes.rb +64 -0
- data/lib/superthread/cli/pages.rb +128 -0
- data/lib/superthread/cli/projects.rb +124 -0
- data/lib/superthread/cli/replies.rb +94 -0
- data/lib/superthread/cli/search.rb +34 -0
- data/lib/superthread/cli/setup.rb +253 -0
- data/lib/superthread/cli/spaces.rb +141 -0
- data/lib/superthread/cli/sprints.rb +32 -0
- data/lib/superthread/cli/tags.rb +86 -0
- data/lib/superthread/cli/ui/gum_prompt.rb +58 -0
- data/lib/superthread/cli/ui/plain_prompt.rb +73 -0
- data/lib/superthread/cli/ui.rb +263 -0
- data/lib/superthread/cli/workspaces.rb +105 -0
- data/lib/superthread/cli.rb +12 -0
- data/lib/superthread/client.rb +207 -0
- data/lib/superthread/configuration.rb +354 -0
- data/lib/superthread/connection.rb +57 -0
- data/lib/superthread/error.rb +164 -0
- data/lib/superthread/mention_formatter.rb +96 -0
- data/lib/superthread/model.rb +178 -0
- data/lib/superthread/models/board.rb +59 -0
- data/lib/superthread/models/card.rb +321 -0
- data/lib/superthread/models/checklist.rb +91 -0
- data/lib/superthread/models/checklist_item.rb +69 -0
- data/lib/superthread/models/comment.rb +71 -0
- data/lib/superthread/models/concerns/archivable.rb +32 -0
- data/lib/superthread/models/concerns/presentable.rb +113 -0
- data/lib/superthread/models/concerns/timestampable.rb +91 -0
- data/lib/superthread/models/list.rb +67 -0
- data/lib/superthread/models/member.rb +40 -0
- data/lib/superthread/models/note.rb +56 -0
- data/lib/superthread/models/page.rb +70 -0
- data/lib/superthread/models/project.rb +83 -0
- data/lib/superthread/models/space.rb +71 -0
- data/lib/superthread/models/sprint.rb +53 -0
- data/lib/superthread/models/tag.rb +52 -0
- data/lib/superthread/models/team.rb +68 -0
- data/lib/superthread/models/user.rb +76 -0
- data/lib/superthread/models.rb +12 -0
- data/lib/superthread/object.rb +285 -0
- data/lib/superthread/objects/collection.rb +179 -0
- data/lib/superthread/resources/base.rb +204 -0
- data/lib/superthread/resources/boards.rb +150 -0
- data/lib/superthread/resources/cards.rb +363 -0
- data/lib/superthread/resources/comments.rb +163 -0
- data/lib/superthread/resources/notes.rb +61 -0
- data/lib/superthread/resources/pages.rb +110 -0
- data/lib/superthread/resources/projects.rb +117 -0
- data/lib/superthread/resources/search.rb +46 -0
- data/lib/superthread/resources/spaces.rb +104 -0
- data/lib/superthread/resources/sprints.rb +37 -0
- data/lib/superthread/resources/tags.rb +52 -0
- data/lib/superthread/resources/users.rb +29 -0
- data/lib/superthread/version.rb +6 -0
- data/lib/superthread/version_checker.rb +174 -0
- data/lib/superthread.rb +30 -0
- 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
|