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,263 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "glamour"
|
|
4
|
+
require "gum"
|
|
5
|
+
require "reverse_markdown"
|
|
6
|
+
require_relative "ui/gum_prompt"
|
|
7
|
+
require_relative "ui/plain_prompt"
|
|
8
|
+
|
|
9
|
+
module Superthread
|
|
10
|
+
module Cli
|
|
11
|
+
# Terminal UI helpers using Gum for styled output.
|
|
12
|
+
# Provides consistent styling across all CLI commands.
|
|
13
|
+
#
|
|
14
|
+
# Interactive prompts delegate to a prompt backend selected based on
|
|
15
|
+
# terminal capabilities. Terminals with limited ANSI support (e.g.,
|
|
16
|
+
# macOS Terminal.app) use {PlainPrompt}; all others use {GumPrompt}.
|
|
17
|
+
#
|
|
18
|
+
# @example
|
|
19
|
+
# Ui.header("Cards")
|
|
20
|
+
# Ui.success("Card created")
|
|
21
|
+
# Ui.table(rows, columns: ["ID", "Title", "Status"])
|
|
22
|
+
module Ui
|
|
23
|
+
# Brand color - purple
|
|
24
|
+
PRIMARY = "#7D56F4"
|
|
25
|
+
|
|
26
|
+
module_function
|
|
27
|
+
|
|
28
|
+
# Returns the prompt backend for interactive widgets.
|
|
29
|
+
#
|
|
30
|
+
# Uses {PlainPrompt} for terminals with limited ANSI support,
|
|
31
|
+
# otherwise {GumPrompt}.
|
|
32
|
+
#
|
|
33
|
+
# @return [Module] the prompt backend (PlainPrompt or GumPrompt)
|
|
34
|
+
def prompt
|
|
35
|
+
@prompt ||= plain_mode? ? PlainPrompt : GumPrompt
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Resets the cached prompt backend.
|
|
39
|
+
#
|
|
40
|
+
# Useful for testing when environment variables change mid-process.
|
|
41
|
+
#
|
|
42
|
+
# @return [void]
|
|
43
|
+
def reset_prompt!
|
|
44
|
+
@prompt = nil
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Whether to use plain Ruby I/O instead of gum interactive widgets.
|
|
48
|
+
#
|
|
49
|
+
# Detects Terminal.app (which renders gum's ANSI redraws incorrectly)
|
|
50
|
+
# and respects the SUPERTHREAD_PLAIN env var as a manual override.
|
|
51
|
+
#
|
|
52
|
+
# @return [Boolean] true if interactive widgets should use plain fallbacks
|
|
53
|
+
def plain_mode?
|
|
54
|
+
return true if ENV["SUPERTHREAD_PLAIN"]
|
|
55
|
+
|
|
56
|
+
ENV["TERM_PROGRAM"] == "Apple_Terminal"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
# ========================================
|
|
60
|
+
# Output methods (always use Gum.style)
|
|
61
|
+
# ========================================
|
|
62
|
+
|
|
63
|
+
# Display a styled header with rounded border in brand color.
|
|
64
|
+
#
|
|
65
|
+
# @param text [String] the header title to display
|
|
66
|
+
def header(text)
|
|
67
|
+
puts Gum.style(
|
|
68
|
+
text,
|
|
69
|
+
foreground: PRIMARY,
|
|
70
|
+
bold: true,
|
|
71
|
+
border: :rounded,
|
|
72
|
+
border_foreground: PRIMARY,
|
|
73
|
+
padding: "0 2"
|
|
74
|
+
)
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Display a section title in bold brand color without border.
|
|
78
|
+
#
|
|
79
|
+
# @param text [String] the section title to display
|
|
80
|
+
def section(text)
|
|
81
|
+
puts Gum.style(text, foreground: PRIMARY, bold: true)
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# Display a success message with green checkmark prefix.
|
|
85
|
+
#
|
|
86
|
+
# @param text [String] the success message to display
|
|
87
|
+
def success(text)
|
|
88
|
+
puts Gum.style("✓ #{text}", foreground: "green", bold: true)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Display an error message with red X prefix.
|
|
92
|
+
#
|
|
93
|
+
# @param text [String] the error message to display
|
|
94
|
+
def error(text)
|
|
95
|
+
puts Gum.style("✗ #{text}", foreground: "red", bold: true)
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Display a warning message with yellow exclamation prefix.
|
|
99
|
+
#
|
|
100
|
+
# @param text [String] the warning message to display
|
|
101
|
+
def warning(text)
|
|
102
|
+
puts Gum.style("! #{text}", foreground: "yellow", bold: true)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Display muted text with faint styling for secondary information.
|
|
106
|
+
#
|
|
107
|
+
# @param text [String] the text to display in faint/dim style
|
|
108
|
+
def muted(text)
|
|
109
|
+
puts Gum.style(text, faint: true)
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
# Display informational text in brand purple color.
|
|
113
|
+
#
|
|
114
|
+
# @param text [String] the informational message to display
|
|
115
|
+
def info(text)
|
|
116
|
+
puts Gum.style(text, foreground: PRIMARY)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Display a key-value pair with styled label.
|
|
120
|
+
#
|
|
121
|
+
# @param key [String] the label name to display in brand color
|
|
122
|
+
# @param value [String] the value to display after the label
|
|
123
|
+
def kv(key, value)
|
|
124
|
+
label = Gum.style("#{key}:", foreground: PRIMARY, bold: true)
|
|
125
|
+
puts "#{label} #{value}"
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Display a styled table using gum with rounded borders.
|
|
129
|
+
#
|
|
130
|
+
# @param rows [Array<Array>] the table data as an array of row arrays
|
|
131
|
+
# @param columns [Array<String>] the column header labels
|
|
132
|
+
# @note Requires a TTY. For non-interactive output, use Formatter.table instead.
|
|
133
|
+
def table(rows, columns:)
|
|
134
|
+
return if rows.empty?
|
|
135
|
+
|
|
136
|
+
Gum.table(
|
|
137
|
+
rows,
|
|
138
|
+
columns: columns,
|
|
139
|
+
print: true,
|
|
140
|
+
border: :rounded,
|
|
141
|
+
header_foreground: PRIMARY
|
|
142
|
+
)
|
|
143
|
+
puts ""
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Display a blank line for visual spacing between sections.
|
|
147
|
+
#
|
|
148
|
+
# @return [void]
|
|
149
|
+
def blank
|
|
150
|
+
puts ""
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
# Display a horizontal divider line for visual separation.
|
|
154
|
+
#
|
|
155
|
+
# @return [void]
|
|
156
|
+
def divider
|
|
157
|
+
puts Gum.style("─" * 40, faint: true)
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
# Format a numeric amount as currency with negative values in red.
|
|
161
|
+
#
|
|
162
|
+
# @param amount [Numeric] the monetary amount to format
|
|
163
|
+
# @return [String] the formatted currency string (e.g., "$12.50" or "-$5.00")
|
|
164
|
+
def money(amount)
|
|
165
|
+
formatted = format("$%.2f", amount.abs)
|
|
166
|
+
if amount.negative?
|
|
167
|
+
Gum.style("-#{formatted}", foreground: "red")
|
|
168
|
+
else
|
|
169
|
+
formatted
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# ========================================
|
|
174
|
+
# Interactive methods (delegate to prompt)
|
|
175
|
+
# ========================================
|
|
176
|
+
|
|
177
|
+
# Prompt user for yes/no confirmation.
|
|
178
|
+
#
|
|
179
|
+
# @param question [String] the confirmation question to display
|
|
180
|
+
# @param default [Boolean] the default answer if user presses enter
|
|
181
|
+
# @return [Boolean] true if confirmed, false if declined
|
|
182
|
+
def confirm(question, default: true)
|
|
183
|
+
prompt.confirm(question, default: default)
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Prompt user for single-line text input.
|
|
187
|
+
#
|
|
188
|
+
# @param prompt_text [String] the prompt label shown before the input field
|
|
189
|
+
# @param placeholder [String, nil] the placeholder hint shown in empty field
|
|
190
|
+
# @return [String] the text entered by the user
|
|
191
|
+
def input(prompt_text, placeholder: nil)
|
|
192
|
+
prompt_text = "#{prompt_text} " unless prompt_text.end_with?(" ")
|
|
193
|
+
prompt.input(prompt_text, placeholder: placeholder)
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
# Prompt user for password input with hidden characters.
|
|
197
|
+
#
|
|
198
|
+
# @param prompt_text [String] the prompt label shown before the input field
|
|
199
|
+
# @return [String] the password entered by the user (not echoed to screen)
|
|
200
|
+
def password(prompt_text)
|
|
201
|
+
prompt_text = "#{prompt_text} " unless prompt_text.end_with?(" ")
|
|
202
|
+
prompt.password(prompt_text)
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
# Prompt user to choose a single item from a list with arrow keys.
|
|
206
|
+
#
|
|
207
|
+
# @param items [Array<String>] the options to choose from
|
|
208
|
+
# @param header [String, nil] the optional header text above the list
|
|
209
|
+
# @return [String] the selected item string
|
|
210
|
+
def choose(items, header: nil)
|
|
211
|
+
prompt.choose(items, header: header)
|
|
212
|
+
end
|
|
213
|
+
|
|
214
|
+
# Prompt user to filter and select from a list with fuzzy search.
|
|
215
|
+
#
|
|
216
|
+
# Falls back to numbered list selection in plain mode.
|
|
217
|
+
#
|
|
218
|
+
# @param items [Array<String>] the items available for filtering
|
|
219
|
+
# @param placeholder [String, nil] the placeholder hint for the search input
|
|
220
|
+
# @return [String] the selected item after filtering
|
|
221
|
+
def filter(items, placeholder: nil)
|
|
222
|
+
prompt.filter(items, placeholder: placeholder)
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
# Show an animated spinner while executing a block.
|
|
226
|
+
#
|
|
227
|
+
# In plain mode, prints a status line without animation.
|
|
228
|
+
#
|
|
229
|
+
# @param title [String] the status message shown next to the spinner
|
|
230
|
+
# @param block [Proc] the block to execute while the spinner is displayed
|
|
231
|
+
# @yieldreturn [Object] the result of the long-running operation
|
|
232
|
+
# @return [Object] the return value of the block
|
|
233
|
+
def spin(title, &block)
|
|
234
|
+
prompt.spin(title, &block)
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
# Render HTML or Markdown content with terminal-friendly styling.
|
|
238
|
+
#
|
|
239
|
+
# Converts HTML to Markdown first if needed, then renders with glamour
|
|
240
|
+
# for syntax highlighting and formatting.
|
|
241
|
+
#
|
|
242
|
+
# @param content [String] the HTML or Markdown content to render
|
|
243
|
+
# @param width [Integer] the word wrap width in characters
|
|
244
|
+
# @return [String] the styled content suitable for terminal display
|
|
245
|
+
def render_markdown(content, width: 80)
|
|
246
|
+
return "" if content.nil? || content.empty?
|
|
247
|
+
|
|
248
|
+
# Convert HTML to Markdown if it looks like HTML
|
|
249
|
+
markdown = if content.include?("<") && content.include?(">")
|
|
250
|
+
ReverseMarkdown.convert(content, unknown_tags: :bypass)
|
|
251
|
+
else
|
|
252
|
+
content
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
# Render with glamour
|
|
256
|
+
Glamour.render(markdown, width: width, style: "auto")
|
|
257
|
+
rescue
|
|
258
|
+
# Fall back to plain text if rendering fails
|
|
259
|
+
content
|
|
260
|
+
end
|
|
261
|
+
end
|
|
262
|
+
end
|
|
263
|
+
end
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superthread
|
|
4
|
+
module Cli
|
|
5
|
+
# CLI commands for workspace management within the current account.
|
|
6
|
+
class Workspaces < Base
|
|
7
|
+
desc "list", "List available workspaces for current account"
|
|
8
|
+
# Lists all workspaces available to the current account.
|
|
9
|
+
#
|
|
10
|
+
# @return [void]
|
|
11
|
+
def list
|
|
12
|
+
user = client.users.me
|
|
13
|
+
teams = extract_teams(user)
|
|
14
|
+
|
|
15
|
+
if teams.empty?
|
|
16
|
+
say "No workspaces found"
|
|
17
|
+
return
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
cfg = Superthread::Configuration.new
|
|
21
|
+
current = cfg.workspace
|
|
22
|
+
|
|
23
|
+
say "WORKSPACES"
|
|
24
|
+
teams.each do |team|
|
|
25
|
+
marker = (team[:id] == current) ? "*" : " "
|
|
26
|
+
role = team[:role] || "member"
|
|
27
|
+
say " #{marker} #{team[:id].to_s.ljust(20)} #{team[:name].to_s.ljust(25)} #{role}"
|
|
28
|
+
end
|
|
29
|
+
say ""
|
|
30
|
+
say "Use 'suth workspaces use <ID>' to set default workspace."
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
desc "use WORKSPACE", "Set default workspace for current account"
|
|
34
|
+
# Sets the default workspace for the current account.
|
|
35
|
+
#
|
|
36
|
+
# @param workspace_ref [String] workspace ID or name to switch to
|
|
37
|
+
# @return [void]
|
|
38
|
+
def use(workspace_ref)
|
|
39
|
+
handle_error do
|
|
40
|
+
cfg = Superthread::Configuration.new
|
|
41
|
+
|
|
42
|
+
unless cfg.current_account
|
|
43
|
+
Ui.error "No account selected"
|
|
44
|
+
Ui.muted "Run 'suth setup' to configure an account first"
|
|
45
|
+
return
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Fetch workspaces and resolve the reference
|
|
49
|
+
user = client.users.me
|
|
50
|
+
teams = extract_teams(user)
|
|
51
|
+
|
|
52
|
+
workspace = teams.find { |t| t[:id] == workspace_ref || t[:name] == workspace_ref }
|
|
53
|
+
|
|
54
|
+
unless workspace
|
|
55
|
+
Ui.error "Workspace '#{workspace_ref}' not found"
|
|
56
|
+
Ui.muted "Available workspaces:"
|
|
57
|
+
teams.each { |t| Ui.muted " #{t[:id]} - #{t[:name]}" }
|
|
58
|
+
return
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
cfg.save_account_state(cfg.current_account,
|
|
62
|
+
workspace_id: workspace[:id],
|
|
63
|
+
workspace_name: workspace[:name])
|
|
64
|
+
|
|
65
|
+
output_success "Default workspace set to: #{workspace[:name]} (#{workspace[:id]})"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
desc "current", "Show current default workspace"
|
|
70
|
+
# Displays the currently selected default workspace.
|
|
71
|
+
#
|
|
72
|
+
# @return [void]
|
|
73
|
+
def current
|
|
74
|
+
cfg = Superthread::Configuration.new
|
|
75
|
+
|
|
76
|
+
if cfg.workspace
|
|
77
|
+
say "Current workspace: #{cfg.workspace}"
|
|
78
|
+
say_info "Account: #{cfg.current_account}" if cfg.current_account
|
|
79
|
+
else
|
|
80
|
+
say "No default workspace set"
|
|
81
|
+
say_info "Use 'suth workspaces list' to see available workspaces"
|
|
82
|
+
say_info "Use 'suth workspaces use <ID>' to set a default"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
private
|
|
87
|
+
|
|
88
|
+
# Extract team/workspace information from a user object.
|
|
89
|
+
#
|
|
90
|
+
# @param user [Superthread::Models::User] the user object with teams data
|
|
91
|
+
# @return [Array<Hash{Symbol => String}>] array of workspace hashes with :id, :name, :role
|
|
92
|
+
def extract_teams(user)
|
|
93
|
+
return [] unless user.teams
|
|
94
|
+
|
|
95
|
+
user.teams.map do |team|
|
|
96
|
+
{
|
|
97
|
+
id: team.id,
|
|
98
|
+
name: team.team_name || "Unknown",
|
|
99
|
+
role: team.role
|
|
100
|
+
}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
|
|
5
|
+
module Superthread
|
|
6
|
+
# Command-line interface for Superthread project management.
|
|
7
|
+
# Provides Thor-based commands for managing cards, boards, projects, and more.
|
|
8
|
+
module Cli
|
|
9
|
+
# Error raised when a CLI command fails.
|
|
10
|
+
class CommandError < Superthread::Error; end
|
|
11
|
+
end
|
|
12
|
+
end
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Superthread
|
|
6
|
+
# HTTP client for the Superthread API.
|
|
7
|
+
#
|
|
8
|
+
# Provides access to all API resources through typed accessors.
|
|
9
|
+
# Handles authentication, request signing, and response parsing.
|
|
10
|
+
#
|
|
11
|
+
# @example Creating a client
|
|
12
|
+
# client = Superthread::Client.new(api_key: "sk_...")
|
|
13
|
+
# cards = client.cards.list(workspace_id)
|
|
14
|
+
# card = client.cards.find(workspace_id, card_id)
|
|
15
|
+
#
|
|
16
|
+
# @example Using environment configuration
|
|
17
|
+
# # Uses SUPERTHREAD_API_KEY and config files
|
|
18
|
+
# client = Superthread::Client.new
|
|
19
|
+
class Client
|
|
20
|
+
# @return [Superthread::Resources::Users] users resource
|
|
21
|
+
# @return [Superthread::Resources::Projects] projects resource
|
|
22
|
+
# @return [Superthread::Resources::Spaces] spaces resource
|
|
23
|
+
# @return [Superthread::Resources::Boards] boards resource
|
|
24
|
+
# @return [Superthread::Resources::Cards] cards resource
|
|
25
|
+
# @return [Superthread::Resources::Comments] comments resource
|
|
26
|
+
# @return [Superthread::Resources::Pages] pages resource
|
|
27
|
+
# @return [Superthread::Resources::Notes] notes resource
|
|
28
|
+
# @return [Superthread::Resources::Sprints] sprints resource
|
|
29
|
+
# @return [Superthread::Resources::Search] search resource
|
|
30
|
+
# @return [Superthread::Resources::Tags] tags resource
|
|
31
|
+
attr_reader :users, :projects, :spaces, :boards, :cards,
|
|
32
|
+
:comments, :pages, :notes, :sprints, :search, :tags
|
|
33
|
+
|
|
34
|
+
# @return [Faraday::Response, nil] the last HTTP response received
|
|
35
|
+
attr_reader :last_response
|
|
36
|
+
|
|
37
|
+
# Creates a new API client.
|
|
38
|
+
#
|
|
39
|
+
# @param api_key [String, nil] API key (overrides config file and environment)
|
|
40
|
+
# @param base_url [String, nil] custom API base URL
|
|
41
|
+
# @param workspace [String, nil] default workspace ID
|
|
42
|
+
# @raise [ConfigurationError] if no valid API key is available
|
|
43
|
+
def initialize(api_key: nil, base_url: nil, workspace: nil)
|
|
44
|
+
@config = build_config(api_key, base_url, workspace)
|
|
45
|
+
@config.validate!
|
|
46
|
+
@connection = Superthread::Connection.new(@config)
|
|
47
|
+
|
|
48
|
+
# Initialize resource accessors
|
|
49
|
+
@users = Superthread::Resources::Users.new(self)
|
|
50
|
+
@projects = Superthread::Resources::Projects.new(self)
|
|
51
|
+
@spaces = Superthread::Resources::Spaces.new(self)
|
|
52
|
+
@boards = Superthread::Resources::Boards.new(self)
|
|
53
|
+
@cards = Superthread::Resources::Cards.new(self)
|
|
54
|
+
@comments = Superthread::Resources::Comments.new(self)
|
|
55
|
+
@pages = Superthread::Resources::Pages.new(self)
|
|
56
|
+
@notes = Superthread::Resources::Notes.new(self)
|
|
57
|
+
@sprints = Superthread::Resources::Sprints.new(self)
|
|
58
|
+
@search = Superthread::Resources::Search.new(self)
|
|
59
|
+
@tags = Superthread::Resources::Tags.new(self)
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
# Returns the resolved default workspace ID.
|
|
63
|
+
#
|
|
64
|
+
# @return [String, nil] workspace ID from configuration
|
|
65
|
+
def default_workspace
|
|
66
|
+
@config.workspace
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Makes an API request and returns raw hash data.
|
|
70
|
+
#
|
|
71
|
+
# Use this when you need the raw response before object conversion.
|
|
72
|
+
#
|
|
73
|
+
# @param method [Symbol] HTTP method (:get, :post, :patch, :delete)
|
|
74
|
+
# @param path [String] API endpoint path relative to base URL (e.g., "/cards/123")
|
|
75
|
+
# @param params [Hash{Symbol => Object}, nil] query parameters to include
|
|
76
|
+
# @param body [Hash{Symbol => Object}, nil] request body to send as JSON
|
|
77
|
+
# @return [Hash{Symbol => Object}] parsed JSON response
|
|
78
|
+
# @raise [Superthread::ApiError] if the request fails
|
|
79
|
+
def request(method:, path:, params: nil, body: nil)
|
|
80
|
+
response = @connection.request(method: method, path: path, params: params, body: body)
|
|
81
|
+
@last_response = response
|
|
82
|
+
handle_response(response)
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Makes an API request and returns a typed object.
|
|
86
|
+
#
|
|
87
|
+
# This is the primary method used by resource classes.
|
|
88
|
+
#
|
|
89
|
+
# @param method [Symbol] HTTP method (:get, :post, :patch, :delete)
|
|
90
|
+
# @param path [String] API endpoint path relative to base URL
|
|
91
|
+
# @param params [Hash{Symbol => Object}, nil] query parameters to include
|
|
92
|
+
# @param body [Hash{Symbol => Object}, nil] request body to send as JSON
|
|
93
|
+
# @param object_class [Class, nil] class to instantiate for the response
|
|
94
|
+
# @param unwrap_key [Symbol, nil] key to unwrap from response (e.g., :card extracts response[:card])
|
|
95
|
+
# @return [Superthread::Object, Superthread::Model] response wrapped in appropriate object class
|
|
96
|
+
# @raise [Superthread::ApiError] if the request fails
|
|
97
|
+
def request_object(method:, path:, params: nil, body: nil, object_class: nil, unwrap_key: nil)
|
|
98
|
+
data = request(method: method, path: path, params: params, body: body)
|
|
99
|
+
convert_to_object(data, object_class: object_class, unwrap_key: unwrap_key)
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# Makes an API request and returns a collection of objects.
|
|
103
|
+
#
|
|
104
|
+
# @param method [Symbol] HTTP method (:get, :post, :patch, :delete)
|
|
105
|
+
# @param path [String] API endpoint path relative to base URL
|
|
106
|
+
# @param params [Hash{Symbol => Object}, nil] query parameters to include
|
|
107
|
+
# @param body [Hash{Symbol => Object}, nil] request body to send as JSON
|
|
108
|
+
# @param item_class [Class, nil] class to instantiate for each item
|
|
109
|
+
# @param items_key [Symbol, nil] key containing the items array (auto-detected if nil)
|
|
110
|
+
# @return [Superthread::Objects::Collection] collection of objects
|
|
111
|
+
# @raise [Superthread::ApiError] if the request fails
|
|
112
|
+
def request_collection(method:, path:, params: nil, body: nil, item_class: nil, items_key: nil)
|
|
113
|
+
data = request(method: method, path: path, params: params, body: body)
|
|
114
|
+
Superthread::Objects::Collection.from_response(data, key: items_key, item_class: item_class)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
# Converts raw hash data to a typed object.
|
|
118
|
+
#
|
|
119
|
+
# @param data [Hash{Symbol => Object}, Array<Hash>] raw response data
|
|
120
|
+
# @param object_class [Class, nil] class to instantiate
|
|
121
|
+
# @param unwrap_key [Symbol, nil] key to unwrap from response
|
|
122
|
+
# @return [Superthread::Object, Superthread::Model, Array, Object] converted object(s)
|
|
123
|
+
def convert_to_object(data, object_class: nil, unwrap_key: nil)
|
|
124
|
+
# Unwrap nested response (e.g., { card: { ... } } -> { ... })
|
|
125
|
+
data = data[unwrap_key] if unwrap_key && data.is_a?(Hash) && data.key?(unwrap_key)
|
|
126
|
+
|
|
127
|
+
if object_class
|
|
128
|
+
# Use Shale's from_response for Model subclasses
|
|
129
|
+
if shale_model?(object_class)
|
|
130
|
+
case data
|
|
131
|
+
when Array
|
|
132
|
+
object_class.from_response_array(data)
|
|
133
|
+
when Hash
|
|
134
|
+
object_class.from_response(data)
|
|
135
|
+
else
|
|
136
|
+
data
|
|
137
|
+
end
|
|
138
|
+
else
|
|
139
|
+
# Legacy Superthread::Object pattern
|
|
140
|
+
case data
|
|
141
|
+
when Array
|
|
142
|
+
data.map { |item| object_class.new(item) }
|
|
143
|
+
when Hash
|
|
144
|
+
object_class.new(data)
|
|
145
|
+
else
|
|
146
|
+
data
|
|
147
|
+
end
|
|
148
|
+
end
|
|
149
|
+
else
|
|
150
|
+
Superthread::Object.construct_from(data)
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# Checks if a class is a Shale-based Model.
|
|
155
|
+
#
|
|
156
|
+
# @param klass [Class] the class to check
|
|
157
|
+
# @return [Boolean] true if the class is a Shale model
|
|
158
|
+
def shale_model?(klass)
|
|
159
|
+
Superthread::Model.shale_class?(klass)
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
private
|
|
163
|
+
|
|
164
|
+
# Builds a configuration object with optional overrides.
|
|
165
|
+
#
|
|
166
|
+
# @param api_key [String, nil] API key override
|
|
167
|
+
# @param base_url [String, nil] base URL override
|
|
168
|
+
# @param workspace [String, nil] workspace ID override
|
|
169
|
+
# @return [Superthread::Configuration] the configured configuration object
|
|
170
|
+
def build_config(api_key, base_url, workspace)
|
|
171
|
+
config = Superthread::Configuration.new
|
|
172
|
+
config.api_key = api_key if api_key
|
|
173
|
+
config.base_url = base_url if base_url
|
|
174
|
+
config.workspace = workspace if workspace
|
|
175
|
+
config
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# Handles an HTTP response, parsing success or raising on error.
|
|
179
|
+
#
|
|
180
|
+
# @param response [Faraday::Response] the HTTP response object
|
|
181
|
+
# @return [Hash{Symbol => Object}] parsed response body
|
|
182
|
+
# @raise [Superthread::ApiError] if response status indicates failure
|
|
183
|
+
def handle_response(response)
|
|
184
|
+
case response.status
|
|
185
|
+
when 200..299
|
|
186
|
+
parse_response(response)
|
|
187
|
+
else
|
|
188
|
+
raise Superthread::ApiError.from_response(response)
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
# Parses a successful HTTP response body as JSON.
|
|
193
|
+
#
|
|
194
|
+
# @param response [Faraday::Response] the HTTP response object
|
|
195
|
+
# @return [Hash{Symbol => Object}] parsed JSON with symbol keys
|
|
196
|
+
def parse_response(response)
|
|
197
|
+
return {success: true} if response.status == 204
|
|
198
|
+
|
|
199
|
+
body = response.body.to_s
|
|
200
|
+
return {success: true} if body.empty?
|
|
201
|
+
|
|
202
|
+
JSON.parse(body, symbolize_names: true)
|
|
203
|
+
rescue JSON::ParserError
|
|
204
|
+
{success: true}
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
end
|