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,240 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ module Cli
5
+ # CLI commands for managing Superthread accounts.
6
+ #
7
+ # Accounts store API credentials and workspace associations. Multiple
8
+ # accounts can be configured and switched between using these commands.
9
+ class Accounts < Base
10
+ desc "list", "List all configured accounts"
11
+ # Lists all configured accounts with their workspace associations.
12
+ #
13
+ # @return [void]
14
+ def list
15
+ accounts = app_config.accounts
16
+
17
+ if accounts.empty?
18
+ Ui.warning "No accounts configured"
19
+ Ui.muted "Run 'suth setup' to add an account"
20
+ return
21
+ end
22
+
23
+ current = app_config.current_account
24
+
25
+ Ui.section "Accounts"
26
+ accounts.each do |name, _data|
27
+ state = app_config.account_state(name.to_s)
28
+ workspace_name = state&.dig(:workspace_name) || state&.dig(:workspace_id) || "(no workspace)"
29
+ marker = (name.to_s == current) ? "*" : " "
30
+ puts " #{marker} #{name.to_s.ljust(15)} #{workspace_name}"
31
+ end
32
+ puts ""
33
+ Ui.muted "Use 'suth account use <name>' to switch accounts"
34
+ end
35
+
36
+ desc "show [NAME]", "Show account details (defaults to current account)"
37
+ # Displays details for a specific account or the current account.
38
+ #
39
+ # @param name [String, nil] account name to show, or nil for current account
40
+ # @return [void]
41
+ def show(name = nil)
42
+ name ||= app_config.current_account
43
+
44
+ unless name
45
+ Ui.warning "No account selected"
46
+ Ui.muted "Run 'suth setup' to configure an account"
47
+ return
48
+ end
49
+
50
+ unless app_config.accounts.key?(name.to_sym)
51
+ Ui.error "Account '#{name}' not found"
52
+ Ui.muted "Available accounts: #{app_config.accounts.keys.join(", ")}"
53
+ return
54
+ end
55
+
56
+ account_config = app_config.accounts[name.to_sym]
57
+ state = app_config.account_state(name)
58
+ is_current = name == app_config.current_account
59
+
60
+ label = is_current ? "Account: #{name} (current)" : "Account: #{name}"
61
+ Ui.section label
62
+ puts " API Key: #{mask_api_key(account_config&.dig(:api_key))}"
63
+ puts " Workspace ID: #{state&.dig(:workspace_id) || "(not set)"}"
64
+ puts " Workspace Name: #{state&.dig(:workspace_name) || "(not set)"}"
65
+ end
66
+
67
+ desc "use NAME", "Switch to a different account"
68
+ # Switches the active account to the specified account.
69
+ #
70
+ # @param name [String] account name to switch to
71
+ # @return [void]
72
+ def use(name)
73
+ unless app_config.accounts.key?(name.to_sym)
74
+ Ui.error "Account '#{name}' not found"
75
+ Ui.muted "Available accounts: #{app_config.accounts.keys.join(", ")}"
76
+ return
77
+ end
78
+
79
+ app_config.current_account = name
80
+ state = app_config.account_state(name)
81
+ workspace_name = state&.dig(:workspace_name) || name
82
+
83
+ output_success "Switched to account '#{name}' (#{workspace_name})"
84
+ end
85
+
86
+ desc "add NAME", "Add a new account"
87
+ method_option :with_token, type: :boolean,
88
+ desc: "Read API key from standard input (for scripts and agents)"
89
+ method_option :workspace_name, type: :string,
90
+ desc: "Select workspace by name (non-interactive)"
91
+ # Adds a new account with API key and workspace selection.
92
+ #
93
+ # By default, prompts interactively for the API key. Use --with-token
94
+ # to read the key from standard input instead (like gh auth login --with-token).
95
+ #
96
+ # @param name [String] unique name for the new account
97
+ # @return [void]
98
+ def add(name)
99
+ if app_config.accounts.key?(name.to_sym)
100
+ Ui.error "Account '#{name}' already exists"
101
+ Ui.muted "Use 'suth account remove #{name}' first to replace it"
102
+ return
103
+ end
104
+
105
+ api_key = read_api_key(name)
106
+ return unless api_key
107
+
108
+ # Validate and fetch workspaces
109
+ begin
110
+ Ui.spin("Validating API key") do
111
+ temp_client = Superthread::Client.new(api_key: api_key)
112
+ @user = temp_client.users.me
113
+ end
114
+ rescue Superthread::AuthenticationError
115
+ Ui.error "Invalid API key"
116
+ return
117
+ rescue Superthread::ApiError => e
118
+ Ui.error "API error: #{e.message}"
119
+ return
120
+ end
121
+
122
+ teams = @user.teams || []
123
+ if teams.empty?
124
+ Ui.error "No workspaces found for this API key"
125
+ return
126
+ end
127
+
128
+ workspace = select_workspace(teams)
129
+ return unless workspace
130
+
131
+ # Save account
132
+ app_config.add_account(name, api_key: api_key)
133
+ app_config.save_account_state(name,
134
+ workspace_id: workspace.id,
135
+ workspace_name: workspace.team_name)
136
+ app_config.set_current_account(name)
137
+
138
+ output_success "Account '#{name}' configured (#{workspace.team_name || workspace.id})"
139
+ end
140
+
141
+ desc "remove NAME", "Remove an account"
142
+ # Removes a configured account after confirmation.
143
+ #
144
+ # @param name [String] account name to remove
145
+ # @return [void]
146
+ def remove(name)
147
+ unless app_config.accounts.key?(name.to_sym)
148
+ Ui.error "Account '#{name}' not found"
149
+ return
150
+ end
151
+
152
+ confirming("Remove account '#{name}'?") do
153
+ app_config.remove_account(name)
154
+ output_success "Account '#{name}' removed"
155
+
156
+ if app_config.accounts.empty?
157
+ Ui.muted "No accounts remaining. Run 'suth setup' to add one."
158
+ elsif app_config.current_account.nil?
159
+ first_account = app_config.accounts.keys.first
160
+ Ui.muted "Tip: Run 'suth account use #{first_account}' to select an account"
161
+ end
162
+ end
163
+ end
164
+
165
+ private
166
+
167
+ # Reads the API key from stdin (--with-token) or interactive prompt.
168
+ #
169
+ # @param account_name [String] the account name (used in interactive prompt)
170
+ # @return [String, nil] the API key, or nil if empty/missing
171
+ def read_api_key(account_name)
172
+ api_key = if options[:with_token]
173
+ $stdin.gets&.chomp
174
+ else
175
+ Ui.password("API key for '#{account_name}'")
176
+ end
177
+
178
+ if api_key.nil? || api_key.empty?
179
+ if options[:with_token]
180
+ Ui.error "No API key provided on standard input"
181
+ Ui.muted "Usage: echo $SUPERTHREAD_API_KEY | suth accounts add NAME --with-token"
182
+ else
183
+ Ui.error "API key is required"
184
+ end
185
+ return nil
186
+ end
187
+
188
+ api_key
189
+ end
190
+
191
+ # Selects a workspace from the list of teams.
192
+ #
193
+ # Uses --workspace-name if provided, auto-selects if only one,
194
+ # or prompts interactively.
195
+ #
196
+ # @param teams [Array] available workspaces
197
+ # @return [Object, nil] the selected workspace, or nil on error
198
+ def select_workspace(teams)
199
+ if teams.length == 1
200
+ return teams.first
201
+ end
202
+
203
+ if options[:workspace_name]
204
+ match = teams.find { |t| t.team_name&.downcase == options[:workspace_name].downcase }
205
+ unless match
206
+ names = teams.map { |t| t.team_name || t.id }.join(", ")
207
+ Ui.error "Workspace '#{options[:workspace_name]}' not found"
208
+ Ui.muted "Available: #{names}"
209
+ return nil
210
+ end
211
+ return match
212
+ end
213
+
214
+ if options[:with_token]
215
+ Ui.muted "Multiple workspaces found, using '#{teams.first.team_name || teams.first.id}'"
216
+ Ui.muted "Use --workspace-name to select a different workspace"
217
+ return teams.first
218
+ end
219
+
220
+ Ui.muted "Found #{teams.length} workspaces:"
221
+ teams.each_with_index do |team, i|
222
+ puts " #{i + 1}. #{team.team_name || team.id}"
223
+ end
224
+ choice = Ui.input("Select workspace (1-#{teams.length})")&.to_i || 1
225
+ teams[choice - 1] || teams.first
226
+ end
227
+
228
+ # Masks an API key for display, showing only the first 4 and last 4 characters.
229
+ #
230
+ # @param key [String, nil] the API key to mask
231
+ # @return [String] the masked key or "(not set)" if nil
232
+ def mask_api_key(key)
233
+ return "(not set)" unless key
234
+ return "***" if key.length < 10
235
+
236
+ "#{key[0..3]}...#{key[-4..]}"
237
+ end
238
+ end
239
+ end
240
+ end
@@ -0,0 +1,210 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Superthread
4
+ module Cli
5
+ # CLI commands for viewing recent activity across the workspace.
6
+ class Activity < Base
7
+ include Concerns::DateParsable
8
+
9
+ desc "show", "Show recent activity across workspace"
10
+ option :since, type: :string, default: "today", desc: "Filter by date (e.g., 'friday', '3 days ago')"
11
+ option :user, type: :string, desc: "Filter by user (ID, name, or 'me')"
12
+ option :board, type: :string, aliases: "-b", desc: "Filter by board (ID or name)"
13
+ option :space, type: :string, aliases: "-s", desc: "Space (ID or name) - helps resolve board by name"
14
+ # Display recent activity across the workspace.
15
+ #
16
+ # @return [void]
17
+ def show
18
+ handle_error do
19
+ since_ts = parse_date(options[:since])
20
+ activity = fetch_activity(since_ts)
21
+
22
+ if json_output?
23
+ output_activity_json(activity)
24
+ else
25
+ output_activity_summary(activity, options[:since])
26
+ end
27
+ end
28
+ end
29
+ default_task :show
30
+
31
+ private
32
+
33
+ # Fetch all activity data from the workspace.
34
+ # Returns categorized cards: created, updated, completed.
35
+ #
36
+ # @param since_ts [Integer] Unix timestamp for filtering
37
+ # @return [Hash] Activity data grouped by category
38
+ def fetch_activity(since_ts)
39
+ # If user filter specified, use assigned endpoint
40
+ # Otherwise, we need to get cards differently
41
+ cards = if options[:user]
42
+ user_id = resolve_user(options[:user])
43
+ opts = {user_id: user_id}
44
+ opts[:board_id] = board_id if options[:board]
45
+ client.cards.assigned(workspace_id, **opts).to_a
46
+ elsif options[:board]
47
+ client.cards.list(workspace_id, board_id: board_id).to_a
48
+ else
49
+ # Fetch cards from all boards in the workspace
50
+ fetch_all_cards
51
+ end
52
+
53
+ categorize_activity(cards, since_ts)
54
+ end
55
+
56
+ # Fetch cards from all boards in the workspace.
57
+ # This is slower but necessary when no board filter is provided.
58
+ #
59
+ # @return [Array<Card>] All cards
60
+ def fetch_all_cards
61
+ spaces = client.spaces.list(workspace_id)
62
+ all_cards = []
63
+
64
+ spaces.each do |space|
65
+ boards = client.boards.list(workspace_id, space_id: space.id)
66
+ boards.each do |board|
67
+ cards = client.cards.list(workspace_id, board_id: board.id)
68
+ all_cards.concat(cards.to_a)
69
+ rescue Superthread::ApiError
70
+ # Skip boards we can't access
71
+ end
72
+ rescue Superthread::ApiError
73
+ # Skip spaces we can't access
74
+ end
75
+
76
+ all_cards
77
+ end
78
+
79
+ # Categorize cards into activity buckets based on timestamps.
80
+ #
81
+ # @param cards [Array<Card>] the cards to sort into created, updated, and completed buckets
82
+ # @param since_ts [Integer] Filter timestamp
83
+ # @return [Hash] Categorized activity
84
+ def categorize_activity(cards, since_ts)
85
+ activity = {
86
+ created: [],
87
+ updated: [],
88
+ completed: []
89
+ }
90
+
91
+ cards.each do |card|
92
+ created_ts = normalize_timestamp(card.time_created)
93
+ updated_ts = normalize_timestamp(card.time_updated)
94
+ completed_ts = normalize_timestamp(card.completed_date)
95
+
96
+ # Check for completion (takes precedence)
97
+ if completed_ts && completed_ts >= since_ts
98
+ activity[:completed] << card
99
+ # Check for creation
100
+ elsif created_ts && created_ts >= since_ts
101
+ activity[:created] << card
102
+ # Check for update (but not if just created)
103
+ elsif updated_ts && updated_ts >= since_ts && updated_ts != created_ts
104
+ activity[:updated] << card
105
+ end
106
+ end
107
+
108
+ # Sort each category by most recent first
109
+ activity[:created].sort_by! { |c| -(c.time_created || 0) }
110
+ activity[:updated].sort_by! { |c| -(c.time_updated || 0) }
111
+ activity[:completed].sort_by! { |c| -(c.completed_date || 0) }
112
+
113
+ activity
114
+ end
115
+
116
+ # Normalize timestamp to seconds (API sometimes returns milliseconds).
117
+ #
118
+ # @param ts [Integer, nil] Timestamp
119
+ # @return [Integer, nil] Normalized timestamp in seconds
120
+ def normalize_timestamp(ts)
121
+ Formatter.normalize_timestamp(ts)
122
+ end
123
+
124
+ # Output activity as JSON.
125
+ #
126
+ # @param activity [Hash] the categorized activity data with :created, :updated, :completed keys
127
+ # @return [void]
128
+ def output_activity_json(activity)
129
+ json_data = {
130
+ created: activity[:created].map { |c| card_to_hash(c) },
131
+ updated: activity[:updated].map { |c| card_to_hash(c) },
132
+ completed: activity[:completed].map { |c| card_to_hash(c) },
133
+ summary: {
134
+ total_created: activity[:created].size,
135
+ total_updated: activity[:updated].size,
136
+ total_completed: activity[:completed].size
137
+ }
138
+ }
139
+ puts Formatter.json(json_data)
140
+ end
141
+
142
+ # Convert card to hash for JSON output.
143
+ #
144
+ # @param card [Card] the card to convert
145
+ # @return [Hash] the card data as a hash
146
+ def card_to_hash(card)
147
+ {
148
+ id: card.id,
149
+ title: card.title,
150
+ status: card.status,
151
+ priority: card.priority,
152
+ list_title: card.list_title,
153
+ board_title: card.board_title,
154
+ time_created: card.time_created,
155
+ time_updated: card.time_updated,
156
+ completed_date: card.completed_date
157
+ }
158
+ end
159
+
160
+ # Output activity as a formatted summary.
161
+ #
162
+ # @param activity [Hash] the categorized activity data
163
+ # @param since_label [String] the label describing the time period
164
+ # @return [void]
165
+ def output_activity_summary(activity, since_label)
166
+ total = activity[:created].size + activity[:updated].size + activity[:completed].size
167
+
168
+ if total == 0
169
+ say "No activity since #{since_label}", :yellow
170
+ return
171
+ end
172
+
173
+ Ui.section "Activity since #{since_label}"
174
+ puts ""
175
+
176
+ if activity[:completed].any?
177
+ output_activity_section("Completed", activity[:completed], :green)
178
+ end
179
+
180
+ if activity[:created].any?
181
+ output_activity_section("Created", activity[:created], :cyan)
182
+ end
183
+
184
+ if activity[:updated].any?
185
+ output_activity_section("Updated", activity[:updated], :yellow)
186
+ end
187
+
188
+ puts ""
189
+ say "Total: #{total} card(s) (#{activity[:completed].size} completed, " \
190
+ "#{activity[:created].size} created, #{activity[:updated].size} updated)", :white
191
+ end
192
+
193
+ # Output a section of activity.
194
+ #
195
+ # @param label [String] the section header label
196
+ # @param cards [Array<Card>] the cards to display in this section
197
+ # @param color [Symbol] the color for the section header
198
+ # @return [void]
199
+ def output_activity_section(label, cards, color)
200
+ say "#{label} (#{cards.size}):", color
201
+ cards.each do |card|
202
+ board_info = card.board_title ? " [#{card.board_title}]" : ""
203
+ list_info = card.list_title ? " -> #{card.list_title}" : ""
204
+ say " #{card.id} #{card.title}#{board_info}#{list_info}"
205
+ end
206
+ puts ""
207
+ end
208
+ end
209
+ end
210
+ end