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,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
|