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,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Superthread
6
+ module Cli
7
+ # Shared behavior modules for CLI commands.
8
+ module Concerns
9
+ # Provides confirmation prompts for destructive actions.
10
+ # Respects the --force flag to skip confirmation.
11
+ #
12
+ # @example
13
+ # class Cards < Base
14
+ # include Concerns::Confirmable
15
+ #
16
+ # def delete(card_id)
17
+ # handle_error do
18
+ # confirming("Delete card #{card_id}?") do
19
+ # client.cards.destroy(workspace_id, card_id)
20
+ # Ui.success "Card #{card_id} deleted"
21
+ # end
22
+ # end
23
+ # end
24
+ # end
25
+ module Confirmable
26
+ extend ActiveSupport::Concern
27
+
28
+ # Execute a block with optional confirmation prompt.
29
+ #
30
+ # Skips confirmation if --force flag is set, otherwise prompts user.
31
+ #
32
+ # @param message [String] the confirmation question to display
33
+ # @yield the block to execute if user confirms
34
+ # @return [Object, nil] the block's return value, or nil if cancelled
35
+ def confirming(message)
36
+ if options[:force] || confirm_action(message)
37
+ yield
38
+ else
39
+ Ui.muted "Cancelled."
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ # Prompt user for yes/no confirmation via the UI.
46
+ #
47
+ # @param message [String] the confirmation question to display
48
+ # @return [Boolean] true if user confirms, false otherwise
49
+ def confirm_action(message)
50
+ Ui.confirm(message)
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,196 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Superthread
6
+ module Cli
7
+ module Concerns
8
+ # Provides date parsing utilities for CLI commands.
9
+ # Parses natural language dates into Unix timestamps for API filtering.
10
+ #
11
+ # @example
12
+ # class Cards < Base
13
+ # include Concerns::DateParsable
14
+ #
15
+ # def list
16
+ # since_timestamp = parse_date(options[:since])
17
+ # # Filter cards by since_timestamp...
18
+ # end
19
+ # end
20
+ module DateParsable
21
+ extend ActiveSupport::Concern
22
+
23
+ # Number of seconds in a day (24 hours).
24
+ SECONDS_PER_DAY = 86_400
25
+
26
+ # Number of seconds in a week (7 days).
27
+ SECONDS_PER_WEEK = 604_800
28
+
29
+ # Parse a date string into a Unix timestamp (seconds).
30
+ # Supports various natural language formats.
31
+ #
32
+ # @param date_string [String, nil] Date expression to parse
33
+ # @return [Integer, nil] Unix timestamp (seconds), or nil if no date provided
34
+ # @raise [Thor::Error] If date string is invalid or unparseable
35
+ #
36
+ # @example Natural language dates
37
+ # parse_date("today") # => Start of today
38
+ # parse_date("yesterday") # => Start of yesterday
39
+ # parse_date("friday") # => Last Friday (or this Friday if today is earlier)
40
+ # parse_date("last friday") # => Last Friday
41
+ # parse_date("monday") # => Last Monday
42
+ # parse_date("3 days ago") # => 3 days ago
43
+ # parse_date("1 week ago") # => 7 days ago
44
+ # parse_date("2 weeks ago") # => 14 days ago
45
+ # parse_date("last week") # => Start of last week
46
+ # parse_date("this week") # => Start of this week
47
+ # parse_date("2026-01-20") # => Specific date
48
+ def parse_date(date_string)
49
+ return nil if date_string.nil? || date_string.empty?
50
+
51
+ normalized = date_string.to_s.strip.downcase
52
+
53
+ time = case normalized
54
+ when "today", "now"
55
+ beginning_of_day(Date.today)
56
+ when "yesterday"
57
+ beginning_of_day(Date.today - 1)
58
+ when "this week"
59
+ beginning_of_week(Date.today)
60
+ when "last week"
61
+ beginning_of_week(Date.today - 7)
62
+ when /\A(\d+)\s+days?\s+ago\z/
63
+ beginning_of_day(Date.today - Integer(::Regexp.last_match(1)))
64
+ when /\A(\d+)\s+weeks?\s+ago\z/
65
+ beginning_of_day(Date.today - (Integer(::Regexp.last_match(1)) * 7))
66
+ when /\A(\d+)\s+months?\s+ago\z/
67
+ beginning_of_day(Date.today << Integer(::Regexp.last_match(1)))
68
+ when /\Alast\s+(#{day_names_pattern})\z/
69
+ parse_weekday(::Regexp.last_match(1), force_past: true)
70
+ when /\A(#{day_names_pattern})\z/
71
+ parse_weekday(::Regexp.last_match(1))
72
+ else
73
+ # Try parsing as a date (YYYY-MM-DD, MM/DD/YYYY, etc.)
74
+ parse_explicit_date(date_string)
75
+ end
76
+
77
+ time&.to_i
78
+ rescue ArgumentError, TypeError => e
79
+ raise Thor::Error, "Invalid date: '#{date_string}'. #{e.message}"
80
+ end
81
+
82
+ # Parse a date string into a Time object at end of day.
83
+ # Useful for "until" or "before" filters.
84
+ #
85
+ # @param date_string [String, nil] Date expression to parse
86
+ # @return [Integer, nil] Unix timestamp at end of day, or nil
87
+ def parse_date_end(date_string)
88
+ timestamp = parse_date(date_string)
89
+ return nil unless timestamp
90
+
91
+ # Add 23:59:59 to get end of day
92
+ timestamp + SECONDS_PER_DAY - 1
93
+ end
94
+
95
+ # Filter a collection of items by timestamp field.
96
+ # Used for client-side filtering when API doesn't support date filters.
97
+ #
98
+ # @param items [Enumerable] Collection to filter
99
+ # @param field [Symbol] Timestamp field name (e.g., :time_created, :time_updated)
100
+ # @param since [Integer, nil] Minimum timestamp (inclusive)
101
+ # @param until_time [Integer, nil] Maximum timestamp (inclusive)
102
+ # @return [Array] Filtered items
103
+ def filter_by_date(items, field:, since: nil, until_time: nil)
104
+ items.select do |item|
105
+ ts = item.send(field)
106
+ next false if ts.nil?
107
+
108
+ ts = Formatter.normalize_timestamp(ts)
109
+ next false if ts.nil?
110
+
111
+ (since.nil? || ts >= since) && (until_time.nil? || ts <= until_time)
112
+ end
113
+ end
114
+
115
+ private
116
+
117
+ # Convert Date to Time at beginning of day (midnight).
118
+ #
119
+ # @param date [Date] the date to convert
120
+ # @return [Time] midnight on the given date
121
+ def beginning_of_day(date)
122
+ Time.new(date.year, date.month, date.day, 0, 0, 0)
123
+ end
124
+
125
+ # Get the beginning of the week for a date (Monday).
126
+ #
127
+ # @param date [Date] the date to find the week start for
128
+ # @return [Time] midnight on Monday of the given date's week
129
+ def beginning_of_week(date)
130
+ # wday: 0=Sunday, 1=Monday, ... 6=Saturday
131
+ # We want Monday as start of week
132
+ days_since_monday = (date.wday - 1) % 7
133
+ beginning_of_day(date - days_since_monday)
134
+ end
135
+
136
+ # Pattern matching day names (full and abbreviated).
137
+ def day_names_pattern
138
+ "sunday|sun|monday|mon|tuesday|tue|tues|wednesday|wed|thursday|thu|thurs|friday|fri|saturday|sat"
139
+ end
140
+
141
+ # Parse a weekday name into a Time object.
142
+ #
143
+ # @param day_name [String] Day name (e.g., "friday", "mon")
144
+ # @param force_past [Boolean] Always return a date in the past
145
+ # @return [Time] Start of that day
146
+ def parse_weekday(day_name, force_past: false)
147
+ target_wday = weekday_number(day_name)
148
+ today = Date.today
149
+ current_wday = today.wday
150
+
151
+ # Calculate days back to the target weekday
152
+ if force_past || target_wday >= current_wday
153
+ # Need to go to last week's occurrence
154
+ days_back = (current_wday - target_wday + 7) % 7
155
+ days_back = 7 if days_back == 0 && force_past
156
+ else
157
+ # Target day is earlier this week
158
+ days_back = current_wday - target_wday
159
+ end
160
+
161
+ beginning_of_day(today - days_back)
162
+ end
163
+
164
+ # Convert day name to weekday number (0=Sunday, 6=Saturday).
165
+ #
166
+ # @param day_name [String] the day name (full or abbreviated)
167
+ # @return [Integer] weekday number where 0=Sunday and 6=Saturday
168
+ def weekday_number(day_name)
169
+ case day_name.downcase
170
+ when "sunday", "sun" then 0
171
+ when "monday", "mon" then 1
172
+ when "tuesday", "tue", "tues" then 2
173
+ when "wednesday", "wed" then 3
174
+ when "thursday", "thu", "thurs" then 4
175
+ when "friday", "fri" then 5
176
+ when "saturday", "sat" then 6
177
+ else
178
+ raise ArgumentError, "Unknown day: #{day_name}"
179
+ end
180
+ end
181
+
182
+ # Parse an explicit date string (YYYY-MM-DD, etc.).
183
+ #
184
+ # @param date_string [String] the date string in an explicit format
185
+ # @return [Time] midnight on the parsed date
186
+ def parse_explicit_date(date_string)
187
+ beginning_of_day(Date.parse(date_string))
188
+ rescue ArgumentError
189
+ raise Thor::Error,
190
+ "Could not parse date: '#{date_string}'. " \
191
+ "Try formats like '2026-01-20', 'friday', '3 days ago', or 'last week'."
192
+ end
193
+ end
194
+ end
195
+ end
196
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Superthread
6
+ module Cli
7
+ module Concerns
8
+ # Resolves list references (ID or name) to list IDs.
9
+ #
10
+ # List resolution is context-dependent: it searches within the current
11
+ # board's lists or the current sprint's lists, depending on which
12
+ # option is provided.
13
+ module ListResolvable
14
+ extend ActiveSupport::Concern
15
+
16
+ private
17
+
18
+ # Resolve a list reference (ID or name) to its ID.
19
+ #
20
+ # @param ref [String, nil] the list ID or name to resolve
21
+ # @return [String, nil] the resolved list ID
22
+ # @raise [Thor::Error] if name is provided but not found
23
+ def resolve_list(ref)
24
+ return ref if ref.nil?
25
+ return ref if looks_like_id?(ref)
26
+
27
+ list = find_list_by_name(ref)
28
+ return list.id if list
29
+
30
+ raise Thor::Error, "List not found: '#{ref}'. Specify --board or --sprint to search by list name."
31
+ end
32
+
33
+ # Find a list by name within the current board or sprint context.
34
+ #
35
+ # @param name [String] the list name to search for (case-insensitive)
36
+ # @return [Superthread::Models::List, nil] the list object or nil if not found
37
+ def find_list_by_name(name)
38
+ lists = if options[:board]
39
+ board = client.boards.find(workspace_id, board_id)
40
+ board.lists
41
+ elsif options[:sprint]
42
+ sprint = client.sprints.find(workspace_id, sprint_id, space_id: space_id)
43
+ sprint.lists
44
+ end
45
+
46
+ return nil unless lists
47
+
48
+ lists.find { |l| l.title&.downcase == name.downcase }
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Superthread
6
+ module Cli
7
+ module Concerns
8
+ # Resolves space references (ID or name) to space IDs.
9
+ #
10
+ # Space resolution tries name lookup first, then falls back to treating
11
+ # the reference as a direct ID. The spaces list is cached per command
12
+ # invocation.
13
+ module SpaceResolvable
14
+ extend ActiveSupport::Concern
15
+
16
+ private
17
+
18
+ # Get the space ID from --space option, resolving name if needed.
19
+ #
20
+ # @return [String, nil] the resolved space ID, or nil if not specified
21
+ def space_id
22
+ resolve_space(options[:space])
23
+ end
24
+
25
+ # Resolve a space reference (ID or name) to its ID.
26
+ #
27
+ # @param ref [String, nil] the space ID or name to resolve
28
+ # @return [String, nil] the resolved space ID
29
+ # @raise [Thor::Error] if name is provided but not found
30
+ def resolve_space(ref)
31
+ return ref if ref.nil?
32
+
33
+ # Try name resolution first, then fall back to assuming it's an ID
34
+ space = find_space_by_name(ref)
35
+ return space.id if space
36
+
37
+ # Assume it's an ID if name lookup failed
38
+ ref
39
+ end
40
+
41
+ # Find a space by name from the cached list.
42
+ #
43
+ # @param name [String] the space name to search for (case-insensitive)
44
+ # @return [Superthread::Models::Space, nil] the space object or nil if not found
45
+ def find_space_by_name(name)
46
+ @spaces_cache ||= client.spaces.list(workspace_id)
47
+ @spaces_cache.find { |s| s.title&.downcase == name.downcase }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Superthread
6
+ module Cli
7
+ module Concerns
8
+ # Resolves sprint references (ID or name) to sprint IDs.
9
+ #
10
+ # Sprint resolution requires a --space context for name lookups,
11
+ # since sprints belong to spaces.
12
+ module SprintResolvable
13
+ extend ActiveSupport::Concern
14
+
15
+ private
16
+
17
+ # Get the sprint ID from --sprint option, resolving name if needed.
18
+ #
19
+ # @return [String, nil] the resolved sprint ID, or nil if not specified
20
+ def sprint_id
21
+ resolve_sprint(options[:sprint])
22
+ end
23
+
24
+ # Resolve a sprint reference (ID or name) to its ID.
25
+ #
26
+ # @param ref [String, nil] the sprint ID or name to resolve
27
+ # @return [String, nil] the resolved sprint ID
28
+ # @raise [Thor::Error] if name is provided but not found
29
+ def resolve_sprint(ref)
30
+ return ref if ref.nil?
31
+ return ref if looks_like_id?(ref)
32
+
33
+ sprint = find_sprint_by_name(ref)
34
+ return sprint.id if sprint
35
+
36
+ raise Thor::Error, "Sprint not found: '#{ref}'. Use 'suth sprints list -s <space>' to see available sprints."
37
+ end
38
+
39
+ # Find a sprint by name within the current space context.
40
+ #
41
+ # @param name [String] the sprint name to search for (case-insensitive)
42
+ # @return [Superthread::Models::Sprint, nil] the sprint object or nil if not found
43
+ # @raise [Thor::Error] if --space is not provided
44
+ def find_sprint_by_name(name)
45
+ unless options[:space]
46
+ raise Thor::Error, "--space is required to resolve sprint name '#{name}'"
47
+ end
48
+
49
+ @sprints_cache ||= client.sprints.list(workspace_id, space_id: space_id)
50
+ @sprints_cache.find { |s| s.title&.downcase == name.downcase }
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Superthread
6
+ module Cli
7
+ module Concerns
8
+ # Resolves tag references (ID or name) to tag IDs.
9
+ #
10
+ # Tag names are often short alphanumeric strings (e.g., "bug", "feature")
11
+ # that would otherwise look like IDs, so name resolution is tried first.
12
+ module TagResolvable
13
+ extend ActiveSupport::Concern
14
+
15
+ private
16
+
17
+ # Resolve a tag reference (ID or name) to its ID.
18
+ #
19
+ # Tag names are often short alphanumeric strings (e.g., "bug", "feature")
20
+ # that would otherwise look like IDs, so we try name resolution first.
21
+ #
22
+ # @param ref [String, nil] the tag ID or name to resolve
23
+ # @return [String, nil] the resolved tag ID
24
+ # @raise [Thor::Error] if ref doesn't match a tag name and doesn't look like an ID
25
+ def resolve_tag(ref)
26
+ return ref if ref.nil?
27
+
28
+ # Try name resolution first (tags commonly have simple names)
29
+ tag = find_tag_by_name(ref)
30
+ return tag.id if tag
31
+
32
+ # If not found by name, assume it's an ID
33
+ return ref if looks_like_id?(ref)
34
+
35
+ raise Thor::Error, "Tag not found: '#{ref}'. Use 'suth tags list' to see available tags."
36
+ end
37
+
38
+ # Find a tag by name from the cached list.
39
+ #
40
+ # @param name [String] the tag name to search for (case-insensitive)
41
+ # @return [Superthread::Models::Tag, nil] the tag object or nil if not found
42
+ def find_tag_by_name(name)
43
+ @tags_cache ||= client.cards.tags(workspace_id, all: true)
44
+ @tags_cache.find { |t| t.name&.downcase == name.downcase }
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Superthread
6
+ module Cli
7
+ module Concerns
8
+ # Resolves user references (ID, name, or email) to user identifiers.
9
+ #
10
+ # User resolution matches against display_name or email and returns
11
+ # the user_identifier (not the standard id field).
12
+ module UserResolvable
13
+ extend ActiveSupport::Concern
14
+
15
+ private
16
+
17
+ # Resolve a user reference (ID, name, or email) to their user identifier.
18
+ #
19
+ # @param ref [String, nil] the user ID, display name, or email to resolve
20
+ # @return [String, nil] the resolved user identifier
21
+ # @raise [Thor::Error] if name/email is provided but not found
22
+ def resolve_user(ref)
23
+ return ref if ref.nil?
24
+ return ref if looks_like_id?(ref)
25
+
26
+ user = find_user_by_name(ref)
27
+ return user.user_identifier if user
28
+
29
+ raise Thor::Error, "User not found: '#{ref}'. Use 'suth members list' to see available users."
30
+ end
31
+
32
+ # Get cached workspace members list.
33
+ #
34
+ # @return [Array<Superthread::Models::User>] all workspace members
35
+ def workspace_users
36
+ @users_cache ||= client.users.members(workspace_id)
37
+ end
38
+
39
+ # Find a user by display name or email from the cached member list.
40
+ #
41
+ # @param name [String] the display name or email to search for (case-insensitive)
42
+ # @return [Superthread::Models::User, nil] the user object or nil if not found
43
+ def find_user_by_name(name)
44
+ workspace_users.find do |u|
45
+ u.display_name&.downcase == name.downcase ||
46
+ u.email&.downcase == name.downcase
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/concern"
4
+
5
+ module Superthread
6
+ module Cli
7
+ module Concerns
8
+ # Resolves workspace references (ID or name) to workspace IDs.
9
+ #
10
+ # Workspace IDs have a distinct pattern (alphanumeric, starts with letter,
11
+ # 6-21 chars). Name resolution fetches the current user's teams via the
12
+ # API and matches by team_name.
13
+ module WorkspaceResolvable
14
+ extend ActiveSupport::Concern
15
+
16
+ private
17
+
18
+ # Get the current workspace ID from options, config, or raise error.
19
+ #
20
+ # @return [String] the resolved workspace ID
21
+ # @raise [Thor::Error] if no workspace is configured
22
+ def workspace_id
23
+ ws = options[:workspace] || client.default_workspace
24
+ return resolve_workspace(ws) if ws
25
+
26
+ raise Thor::Error,
27
+ "Workspace required. Use --workspace or set SUPERTHREAD_WORKSPACE_ID " \
28
+ "or add workspace to ~/.config/superthread/config.yaml"
29
+ end
30
+
31
+ # Resolve a workspace reference (ID or name) to its ID.
32
+ #
33
+ # @param ref [String, nil] the workspace ID or name to resolve
34
+ # @return [String, nil] the resolved workspace ID, or nil if ref is nil
35
+ def resolve_workspace(ref)
36
+ return ref if ref.nil?
37
+
38
+ # Workspace IDs look like: t4k7Wa2e (8 chars, alphanumeric, starts with letter)
39
+ # Skip resolution for ID-like values to avoid unnecessary API calls
40
+ return ref if looks_like_workspace_id?(ref)
41
+
42
+ # Try name resolution for name-like values
43
+ workspace = find_workspace_by_name(ref)
44
+ return workspace[:id] if workspace
45
+
46
+ # Fall back to treating it as an ID (might still work)
47
+ ref
48
+ end
49
+
50
+ # Check if a value looks like a workspace ID (alphanumeric, starts with letter).
51
+ #
52
+ # @param value [String] the value to check
53
+ # @return [Boolean] true if it matches workspace ID pattern
54
+ def looks_like_workspace_id?(value)
55
+ # Workspace IDs are typically 8 chars, start with a letter, alphanumeric
56
+ # Also accept test IDs like "test_workspace"
57
+ value.match?(/\A[a-zA-Z][a-zA-Z0-9_]{5,20}\z/)
58
+ end
59
+
60
+ # Find a workspace by name from the cached list.
61
+ #
62
+ # @param name [String] the workspace name to search for (case-insensitive)
63
+ # @return [Hash, nil] the workspace hash with :id and :name, or nil if not found
64
+ def find_workspace_by_name(name)
65
+ @workspaces_cache ||= extract_workspaces_from_user
66
+ @workspaces_cache.find { |w| w[:name]&.downcase == name.downcase }
67
+ end
68
+
69
+ # Extract workspace list from the current user's teams.
70
+ #
71
+ # @return [Array<Hash{Symbol => String}>] array of workspace hashes with :id and :name
72
+ def extract_workspaces_from_user
73
+ user = client.users.me
74
+ return [] unless user.teams
75
+
76
+ user.teams.map do |team|
77
+ {id: team.id, name: team.team_name}
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
83
+ end