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,129 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Superthread
|
|
6
|
+
module Cli
|
|
7
|
+
# CLI commands for managing Superthread configuration files.
|
|
8
|
+
# Handles config file creation, viewing settings, and updating values.
|
|
9
|
+
class Config < Base
|
|
10
|
+
desc "init", "Create config file at ~/.config/superthread/config.yaml"
|
|
11
|
+
# Creates the Superthread configuration file with default settings.
|
|
12
|
+
#
|
|
13
|
+
# @return [void]
|
|
14
|
+
def init
|
|
15
|
+
config_path = Superthread::Configuration.new.config_path
|
|
16
|
+
config_dir = File.dirname(config_path)
|
|
17
|
+
|
|
18
|
+
if File.exist?(config_path)
|
|
19
|
+
say_warning "Config file already exists at #{config_path}"
|
|
20
|
+
return
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
FileUtils.mkdir_p(config_dir)
|
|
24
|
+
|
|
25
|
+
File.write(config_path, <<~YAML)
|
|
26
|
+
# Superthread CLI Configuration
|
|
27
|
+
# See: https://github.com/steveclarke/superthread
|
|
28
|
+
|
|
29
|
+
# Accounts are configured via 'suth setup' or 'suth account add'
|
|
30
|
+
# accounts:
|
|
31
|
+
# personal:
|
|
32
|
+
# api_key: stp_xxxxxxxxxxxx
|
|
33
|
+
# work:
|
|
34
|
+
# api_key: stp_yyyyyyyyyyyy
|
|
35
|
+
|
|
36
|
+
# Output format: json or table
|
|
37
|
+
format: table
|
|
38
|
+
YAML
|
|
39
|
+
|
|
40
|
+
output_success "Created config file at #{config_path}"
|
|
41
|
+
say_info "Run 'suth setup' to configure your account"
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
desc "path", "Show config file path"
|
|
45
|
+
# Displays the path to the configuration file.
|
|
46
|
+
#
|
|
47
|
+
# @return [void]
|
|
48
|
+
def path
|
|
49
|
+
puts Superthread::Configuration.new.config_path
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
desc "show", "Show current configuration"
|
|
53
|
+
# Displays the current configuration settings and account information.
|
|
54
|
+
#
|
|
55
|
+
# @return [void]
|
|
56
|
+
def show
|
|
57
|
+
cfg = Superthread::Configuration.new
|
|
58
|
+
|
|
59
|
+
puts "Config file: #{cfg.config_path}"
|
|
60
|
+
puts " exists: #{File.exist?(cfg.config_path)}"
|
|
61
|
+
puts ""
|
|
62
|
+
puts "State file: #{cfg.state_path}"
|
|
63
|
+
puts " exists: #{File.exist?(cfg.state_path)}"
|
|
64
|
+
puts ""
|
|
65
|
+
puts "Current settings:"
|
|
66
|
+
puts " current_account: #{cfg.current_account || "(not set)"}"
|
|
67
|
+
puts " api_key: #{cfg.api_key ? "#{cfg.api_key[0..6]}..." : "(not set)"}"
|
|
68
|
+
puts " workspace: #{cfg.workspace || "(not set)"}"
|
|
69
|
+
puts " format: #{cfg.format}"
|
|
70
|
+
puts ""
|
|
71
|
+
|
|
72
|
+
if cfg.accounts.any?
|
|
73
|
+
puts "Accounts:"
|
|
74
|
+
cfg.accounts.each do |name, _data|
|
|
75
|
+
state = cfg.account_state(name.to_s)
|
|
76
|
+
workspace = state&.dig(:workspace_name) || state&.dig(:workspace_id) || "(no workspace)"
|
|
77
|
+
marker = (name.to_s == cfg.current_account) ? "*" : " "
|
|
78
|
+
puts " #{marker} #{name}: #{workspace}"
|
|
79
|
+
end
|
|
80
|
+
else
|
|
81
|
+
puts "Accounts: (none configured)"
|
|
82
|
+
puts " Run 'suth setup' to add an account"
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
desc "set KEY VALUE", "Set a configuration value"
|
|
87
|
+
long_desc <<~DESC
|
|
88
|
+
Set a configuration value in the config file.
|
|
89
|
+
|
|
90
|
+
Supported keys:
|
|
91
|
+
format - Output format: json or table
|
|
92
|
+
base_url - API base URL (advanced)
|
|
93
|
+
|
|
94
|
+
Note: API keys are managed per-account. Use 'suth account add' to configure accounts.
|
|
95
|
+
|
|
96
|
+
Examples:
|
|
97
|
+
suth config set format json
|
|
98
|
+
suth config set format table
|
|
99
|
+
DESC
|
|
100
|
+
# Sets a configuration value in the config file.
|
|
101
|
+
#
|
|
102
|
+
# @param key [String] configuration key to set (format or base_url)
|
|
103
|
+
# @param value [String] value to assign to the configuration key
|
|
104
|
+
# @return [void]
|
|
105
|
+
def set(key, value)
|
|
106
|
+
cfg = Superthread::Configuration.new
|
|
107
|
+
|
|
108
|
+
unless %w[format base_url].include?(key)
|
|
109
|
+
raise Thor::Error, "Unknown config key: #{key}. Valid keys: format, base_url"
|
|
110
|
+
end
|
|
111
|
+
|
|
112
|
+
if key == "format" && !%w[json table].include?(value)
|
|
113
|
+
raise Thor::Error, "Invalid format: #{value}. Valid values: json, table"
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
case key
|
|
117
|
+
when "format"
|
|
118
|
+
cfg.format = value
|
|
119
|
+
when "base_url"
|
|
120
|
+
cfg.base_url = value
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
cfg.save_config_file
|
|
124
|
+
|
|
125
|
+
output_success "Set #{key} = #{value}"
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -0,0 +1,388 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "active_support/core_ext/string/inflections"
|
|
4
|
+
require "unicode/display_width"
|
|
5
|
+
|
|
6
|
+
module Superthread
|
|
7
|
+
module Cli
|
|
8
|
+
# Formatter for CLI output in gh-style format.
|
|
9
|
+
# Provides colored tables, key-value displays, and JSON output.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# formatter = Formatter.new(color: true)
|
|
13
|
+
# formatter.table(cards, columns: [:id, :title, :status])
|
|
14
|
+
# formatter.detail(card, fields: [:id, :title, :status, :priority])
|
|
15
|
+
module Formatter
|
|
16
|
+
# ANSI color codes
|
|
17
|
+
COLORS = {
|
|
18
|
+
reset: "\e[0m",
|
|
19
|
+
bold: "\e[1m",
|
|
20
|
+
dim: "\e[2m",
|
|
21
|
+
red: "\e[31m",
|
|
22
|
+
green: "\e[32m",
|
|
23
|
+
yellow: "\e[33m",
|
|
24
|
+
blue: "\e[34m",
|
|
25
|
+
magenta: "\e[35m",
|
|
26
|
+
cyan: "\e[36m",
|
|
27
|
+
white: "\e[37m",
|
|
28
|
+
gray: "\e[90m"
|
|
29
|
+
}.freeze
|
|
30
|
+
|
|
31
|
+
# Status colors for different states
|
|
32
|
+
STATUS_COLORS = {
|
|
33
|
+
"started" => :yellow,
|
|
34
|
+
"in_progress" => :yellow,
|
|
35
|
+
"done" => :green,
|
|
36
|
+
"completed" => :green,
|
|
37
|
+
"closed" => :green,
|
|
38
|
+
"blocked" => :red,
|
|
39
|
+
"active" => :green,
|
|
40
|
+
"planned" => :cyan,
|
|
41
|
+
"archived" => :gray
|
|
42
|
+
}.freeze
|
|
43
|
+
|
|
44
|
+
# Priority colors and labels (4=urgent, 3=high, 2=medium, 1=low)
|
|
45
|
+
PRIORITY_COLORS = {
|
|
46
|
+
4 => :red, # urgent
|
|
47
|
+
3 => :yellow, # high
|
|
48
|
+
2 => :blue, # medium
|
|
49
|
+
1 => :gray # low
|
|
50
|
+
}.freeze
|
|
51
|
+
|
|
52
|
+
# Human-readable labels for priority levels.
|
|
53
|
+
# @return [Hash{Integer => String}]
|
|
54
|
+
PRIORITY_LABELS = {
|
|
55
|
+
4 => "urgent",
|
|
56
|
+
3 => "high",
|
|
57
|
+
2 => "medium",
|
|
58
|
+
1 => "low"
|
|
59
|
+
}.freeze
|
|
60
|
+
|
|
61
|
+
# Threshold for detecting millisecond timestamps vs second timestamps.
|
|
62
|
+
# Timestamps above this value are assumed to be in milliseconds.
|
|
63
|
+
MAX_SECONDS_TIMESTAMP = 9_999_999_999
|
|
64
|
+
|
|
65
|
+
module_function
|
|
66
|
+
|
|
67
|
+
# Strips HTML tags from content and normalizes whitespace.
|
|
68
|
+
#
|
|
69
|
+
# @param content [String, nil] HTML content to strip
|
|
70
|
+
# @return [String] plain text with tags removed and whitespace normalized
|
|
71
|
+
def strip_html(content)
|
|
72
|
+
content.to_s.gsub(/<[^>]+>/, " ").gsub(/\s+/, " ").strip
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# Normalizes a timestamp to seconds (API sometimes returns milliseconds).
|
|
76
|
+
#
|
|
77
|
+
# @param ts [Integer, nil] timestamp in seconds or milliseconds
|
|
78
|
+
# @return [Integer, nil] timestamp in seconds, or nil if nil/zero
|
|
79
|
+
def normalize_timestamp(ts)
|
|
80
|
+
return nil if ts.nil? || ts == 0
|
|
81
|
+
|
|
82
|
+
(ts > MAX_SECONDS_TIMESTAMP) ? ts / 1000 : ts
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# Truncates a string to a maximum length with an ellipsis indicator.
|
|
86
|
+
#
|
|
87
|
+
# @param str [String] the source string to truncate
|
|
88
|
+
# @param max_length [Integer] the maximum character length for the result
|
|
89
|
+
# @param omission [String] the suffix to append when truncation occurs
|
|
90
|
+
# @return [String] the truncated string, or original if within limit
|
|
91
|
+
def truncate(str, max_length, omission: "...")
|
|
92
|
+
str = str.to_s
|
|
93
|
+
return str if str.length <= max_length
|
|
94
|
+
|
|
95
|
+
"#{str[0, max_length - omission.length]}#{omission}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# Applies ANSI color codes to text for terminal display.
|
|
99
|
+
#
|
|
100
|
+
# @param text [String] the plain text to wrap with color codes
|
|
101
|
+
# @param color [Symbol] the color name from COLORS constant (e.g., :red, :green)
|
|
102
|
+
# @param enabled [Boolean] whether to apply color or return plain text
|
|
103
|
+
# @return [String] text wrapped with ANSI codes if enabled, otherwise plain text
|
|
104
|
+
def colorize(text, color, enabled: true)
|
|
105
|
+
return text.to_s unless enabled && COLORS.key?(color)
|
|
106
|
+
|
|
107
|
+
"#{COLORS[color]}#{text}#{COLORS[:reset]}"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# Formats a workflow status with semantic color coding.
|
|
111
|
+
#
|
|
112
|
+
# @param status [String] the status value (e.g., "started", "done", "blocked")
|
|
113
|
+
# @param color_enabled [Boolean] whether to apply ANSI color codes
|
|
114
|
+
# @return [String] the status with appropriate color for its state
|
|
115
|
+
def format_status(status, color_enabled: true)
|
|
116
|
+
return "-" if status.nil?
|
|
117
|
+
|
|
118
|
+
color = STATUS_COLORS.fetch(status.to_s.downcase, :white)
|
|
119
|
+
colorize(status, color, enabled: color_enabled)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Formats a priority level with human-readable label and color.
|
|
123
|
+
#
|
|
124
|
+
# @param priority [Integer] the priority value (1=low, 2=medium, 3=high, 4=urgent)
|
|
125
|
+
# @param color_enabled [Boolean] whether to apply ANSI color codes
|
|
126
|
+
# @return [String] the priority label with appropriate urgency color
|
|
127
|
+
def format_priority(priority, color_enabled: true)
|
|
128
|
+
return "-" if priority.nil?
|
|
129
|
+
|
|
130
|
+
label = PRIORITY_LABELS.fetch(priority, priority.to_s)
|
|
131
|
+
color = PRIORITY_COLORS.fetch(priority, :white)
|
|
132
|
+
colorize(label, color, enabled: color_enabled)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# Formats a timestamp as a relative time or absolute date string.
|
|
136
|
+
#
|
|
137
|
+
# @param timestamp [Integer, Time] Unix timestamp in seconds or a Time object
|
|
138
|
+
# @param relative [Boolean] whether to use relative format (e.g., "2d ago") or absolute
|
|
139
|
+
# @return [String] the formatted time string, or "-" if nil/zero
|
|
140
|
+
def format_time(timestamp, relative: true)
|
|
141
|
+
return "-" if timestamp.nil? || timestamp == 0
|
|
142
|
+
|
|
143
|
+
time = timestamp.is_a?(Time) ? timestamp : Time.at(timestamp)
|
|
144
|
+
|
|
145
|
+
if relative
|
|
146
|
+
format_relative_time(time)
|
|
147
|
+
else
|
|
148
|
+
time.strftime("%Y-%m-%d %H:%M")
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Formats a Time object as a human-readable relative time string.
|
|
153
|
+
#
|
|
154
|
+
# @param time [Time] the timestamp to format relative to now
|
|
155
|
+
# @return [String] relative time like "just now", "5m ago", "3d ago", or a date
|
|
156
|
+
def format_relative_time(time)
|
|
157
|
+
diff = Time.now - time
|
|
158
|
+
|
|
159
|
+
case diff.abs
|
|
160
|
+
when 0..59 then "just now"
|
|
161
|
+
when 60..3599 then "#{(diff / 60).to_i}m ago"
|
|
162
|
+
when 3600..86_399 then "#{(diff / 3600).to_i}h ago"
|
|
163
|
+
when 86_400..604_799 then "#{(diff / 86_400).to_i}d ago"
|
|
164
|
+
else
|
|
165
|
+
time.strftime("%Y-%m-%d")
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
# Formats a boolean value as a colored yes/no string.
|
|
170
|
+
#
|
|
171
|
+
# @param value [Boolean] the boolean to format
|
|
172
|
+
# @param color_enabled [Boolean] whether to apply green/gray ANSI color
|
|
173
|
+
# @return [String] "yes" (green) for true, "no" (gray) for false
|
|
174
|
+
def format_boolean(value, color_enabled: true)
|
|
175
|
+
if value
|
|
176
|
+
colorize("yes", :green, enabled: color_enabled)
|
|
177
|
+
else
|
|
178
|
+
colorize("no", :gray, enabled: color_enabled)
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# Formats data as an aligned text table with headers.
|
|
183
|
+
#
|
|
184
|
+
# @param data [Array<Hash>, Collection] the items to display as rows
|
|
185
|
+
# @param columns [Array<Symbol>] the field names to include as columns
|
|
186
|
+
# @param headers [Hash{Symbol => String}] custom header labels keyed by column name
|
|
187
|
+
# @param color_enabled [Boolean] whether to apply ANSI color to values
|
|
188
|
+
# @return [String] the formatted table with header row and data rows
|
|
189
|
+
def table(data, columns:, headers: {}, color_enabled: true)
|
|
190
|
+
items = data.respond_to?(:items) ? data.items : Array(data)
|
|
191
|
+
return "" if items.empty?
|
|
192
|
+
|
|
193
|
+
# Calculate column widths
|
|
194
|
+
widths = columns.map do |col|
|
|
195
|
+
header = headers.fetch(col, col.to_s.upcase)
|
|
196
|
+
values = items.map { |item| format_cell(item, col).to_s }
|
|
197
|
+
[display_width(header), values.map { |v| display_width(v) }.max || 0].max
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
lines = []
|
|
201
|
+
|
|
202
|
+
# Header row
|
|
203
|
+
header_row = columns.zip(widths).map do |col, width|
|
|
204
|
+
colorize(pad_right(headers.fetch(col, col.to_s.upcase), width), :bold, enabled: color_enabled)
|
|
205
|
+
end.join(" ")
|
|
206
|
+
lines << header_row
|
|
207
|
+
|
|
208
|
+
# Data rows
|
|
209
|
+
items.each do |item|
|
|
210
|
+
row = columns.zip(widths).map do |col, width|
|
|
211
|
+
cell = format_cell(item, col, color_enabled: color_enabled)
|
|
212
|
+
# Pad without color codes
|
|
213
|
+
padding = width - display_width(strip_ansi(cell))
|
|
214
|
+
padding = 0 if padding.negative?
|
|
215
|
+
"#{cell}#{" " * padding}"
|
|
216
|
+
end.join(" ")
|
|
217
|
+
lines << row
|
|
218
|
+
end
|
|
219
|
+
|
|
220
|
+
lines.join("\n")
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
# Formats a single item as aligned key-value pairs for detail view.
|
|
224
|
+
#
|
|
225
|
+
# @param item [Hash, Object] the item to display (hash or object with to_h)
|
|
226
|
+
# @param fields [Array<Symbol>] the field names to include
|
|
227
|
+
# @param labels [Hash{Symbol => String}] custom labels keyed by field name
|
|
228
|
+
# @param color_enabled [Boolean] whether to apply ANSI color to values
|
|
229
|
+
# @return [String] the formatted detail view with aligned labels and values
|
|
230
|
+
def detail(item, fields:, labels: {}, color_enabled: true)
|
|
231
|
+
data = item.respond_to?(:to_h) ? item.to_h : item
|
|
232
|
+
|
|
233
|
+
max_label_width = fields.map { |f| display_width(labels.fetch(f, humanize(f))) }.max
|
|
234
|
+
|
|
235
|
+
lines = fields.map do |field|
|
|
236
|
+
label = labels.fetch(field, humanize(field))
|
|
237
|
+
value = format_field(data, field, color_enabled: color_enabled)
|
|
238
|
+
"#{colorize(pad_right(label, max_label_width), :cyan, enabled: color_enabled)} #{value}"
|
|
239
|
+
end
|
|
240
|
+
|
|
241
|
+
lines.join("\n")
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
# Formats data as pretty-printed JSON for output.
|
|
245
|
+
#
|
|
246
|
+
# @param data [Object] the data to serialize (supports arrays, hashes, and objects with to_h)
|
|
247
|
+
# @return [String] the indented JSON string
|
|
248
|
+
def json(data)
|
|
249
|
+
obj = if data.is_a?(Array)
|
|
250
|
+
# Plain arrays of model objects need to be mapped to hashes
|
|
251
|
+
data.map { |item| item.respond_to?(:to_h) ? item.to_h : item }
|
|
252
|
+
elsif data.respond_to?(:to_h)
|
|
253
|
+
data.to_h
|
|
254
|
+
else
|
|
255
|
+
data
|
|
256
|
+
end
|
|
257
|
+
JSON.pretty_generate(obj)
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
# Strips ANSI escape codes from a string for width calculation.
|
|
261
|
+
#
|
|
262
|
+
# @param str [String] the string potentially containing ANSI escape sequences
|
|
263
|
+
# @return [String] the plain text without any ANSI codes
|
|
264
|
+
def strip_ansi(str)
|
|
265
|
+
str.to_s.gsub(/\e\[[0-9;]*m/, "")
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
# Returns the display width of a string, accounting for Unicode
|
|
269
|
+
# wide characters and emoji that occupy more than one column.
|
|
270
|
+
#
|
|
271
|
+
# @param str [String] the string to measure
|
|
272
|
+
# @return [Integer] the number of terminal columns the string occupies
|
|
273
|
+
def display_width(str)
|
|
274
|
+
Unicode::DisplayWidth.of(str.to_s)
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
# Right-pads a string to a target display width, accounting for
|
|
278
|
+
# Unicode wide characters.
|
|
279
|
+
#
|
|
280
|
+
# @param str [String] the string to pad
|
|
281
|
+
# @param target_width [Integer] the desired display width
|
|
282
|
+
# @return [String] the padded string
|
|
283
|
+
def pad_right(str, target_width)
|
|
284
|
+
padding = target_width - display_width(str)
|
|
285
|
+
padding = 0 if padding.negative?
|
|
286
|
+
"#{str}#{" " * padding}"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
# Converts a symbol or string to a human-readable label.
|
|
290
|
+
#
|
|
291
|
+
# @param key [Symbol, String] the field name to humanize (e.g., :time_created)
|
|
292
|
+
# @return [String] the humanized label with title case and proper acronyms
|
|
293
|
+
def humanize(key)
|
|
294
|
+
str = key.to_s
|
|
295
|
+
# Handle _id suffix specially since titleize treats "id" as an acronym and drops it
|
|
296
|
+
if str.end_with?("_id")
|
|
297
|
+
str.sub(/_id$/, "").titleize + " ID"
|
|
298
|
+
else
|
|
299
|
+
str.titleize
|
|
300
|
+
end
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Formats a cell value for display in a table column.
|
|
304
|
+
#
|
|
305
|
+
# @param item [Hash, Object] the row item containing the field value
|
|
306
|
+
# @param column [Symbol] the column name to extract and format
|
|
307
|
+
# @param color_enabled [Boolean] whether to apply semantic color formatting
|
|
308
|
+
# @return [String] the formatted cell value ready for table display
|
|
309
|
+
def format_cell(item, column, color_enabled: true)
|
|
310
|
+
value = item.respond_to?(column) ? item.send(column) : item[column]
|
|
311
|
+
status = item.respond_to?(:status) ? item.status : item[:status]
|
|
312
|
+
|
|
313
|
+
return color_by_status(value, status) if column == :list_title && color_enabled && status
|
|
314
|
+
|
|
315
|
+
format_value(value, column, color_enabled: color_enabled)
|
|
316
|
+
end
|
|
317
|
+
|
|
318
|
+
# Formats a field value for display in a detail key-value view.
|
|
319
|
+
#
|
|
320
|
+
# @param data [Hash] the hash containing field values
|
|
321
|
+
# @param field [Symbol] the field name to extract and format
|
|
322
|
+
# @param color_enabled [Boolean] whether to apply semantic color formatting
|
|
323
|
+
# @return [String] the formatted field value ready for detail display
|
|
324
|
+
def format_field(data, field, color_enabled: true)
|
|
325
|
+
value = data[field]
|
|
326
|
+
|
|
327
|
+
return color_by_status(value, data[:status]) if field == :list_title && color_enabled && data[:status]
|
|
328
|
+
|
|
329
|
+
format_value(value, field, color_enabled: color_enabled)
|
|
330
|
+
end
|
|
331
|
+
|
|
332
|
+
# Colors a value based on a workflow status.
|
|
333
|
+
#
|
|
334
|
+
# @param value [String, nil] the text to colorize
|
|
335
|
+
# @param status [String] the status used to determine color
|
|
336
|
+
# @return [String] the value with ANSI color codes applied
|
|
337
|
+
def color_by_status(value, status)
|
|
338
|
+
color = STATUS_COLORS.fetch(status.to_s.downcase, :white)
|
|
339
|
+
colorize(value || "-", color, enabled: true)
|
|
340
|
+
end
|
|
341
|
+
|
|
342
|
+
# Formats a value based on its field name with appropriate type handling.
|
|
343
|
+
#
|
|
344
|
+
# @param value [Object] the raw value to format
|
|
345
|
+
# @param name [Symbol] the field name used to determine formatting rules
|
|
346
|
+
# @param color_enabled [Boolean] whether to apply semantic color formatting
|
|
347
|
+
# @return [String] the formatted value string, or "-" if nil
|
|
348
|
+
def format_value(value, name, color_enabled: true)
|
|
349
|
+
return "-" if value.nil?
|
|
350
|
+
|
|
351
|
+
case name
|
|
352
|
+
when :status
|
|
353
|
+
format_status(value, color_enabled: color_enabled)
|
|
354
|
+
when :priority
|
|
355
|
+
format_priority(value, color_enabled: color_enabled)
|
|
356
|
+
when :archived, :is_watching, :is_bookmarked, :checked
|
|
357
|
+
format_boolean(value, color_enabled: color_enabled)
|
|
358
|
+
when :time_created, :time_updated
|
|
359
|
+
# Use relative time for creation/update timestamps
|
|
360
|
+
format_time(value, relative: true)
|
|
361
|
+
when :start_date, :due_date, :completed_date
|
|
362
|
+
# Use absolute dates for explicit date fields
|
|
363
|
+
format_time(value, relative: false)
|
|
364
|
+
when :title, :content, :description
|
|
365
|
+
truncate(value.to_s, 60)
|
|
366
|
+
when :tags
|
|
367
|
+
Array(value).map { |t| t.respond_to?(:name) ? t.name : t.to_s }.join(", ")
|
|
368
|
+
when :members
|
|
369
|
+
Array(value).map { |m|
|
|
370
|
+
if m.respond_to?(:display_name) && m.display_name
|
|
371
|
+
m.display_name
|
|
372
|
+
elsif m.respond_to?(:user_id)
|
|
373
|
+
m.user_id
|
|
374
|
+
elsif m.is_a?(Hash)
|
|
375
|
+
m[:display_name] || m["display_name"] || m[:user_id] || m["user_id"]
|
|
376
|
+
else
|
|
377
|
+
m.to_s
|
|
378
|
+
end
|
|
379
|
+
}.join(", ")
|
|
380
|
+
when Array
|
|
381
|
+
value.join(", ")
|
|
382
|
+
else
|
|
383
|
+
value.to_s
|
|
384
|
+
end
|
|
385
|
+
end
|
|
386
|
+
end
|
|
387
|
+
end
|
|
388
|
+
end
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superthread
|
|
4
|
+
module Cli
|
|
5
|
+
# CLI commands for managing board lists (columns).
|
|
6
|
+
#
|
|
7
|
+
# Lists are columns on a board that organize cards into workflow stages.
|
|
8
|
+
# This class provides commands to list, create, update, and delete lists.
|
|
9
|
+
class Lists < Base
|
|
10
|
+
desc "list", "List all lists on a board"
|
|
11
|
+
option :board, type: :string, required: true, aliases: "-b", desc: "Board to list lists from (ID or name)"
|
|
12
|
+
option :space, type: :string, aliases: "-s", desc: "Space (helps resolve board name)"
|
|
13
|
+
# List all lists (columns) on a specified board.
|
|
14
|
+
#
|
|
15
|
+
# @return [void]
|
|
16
|
+
def list
|
|
17
|
+
handle_error do
|
|
18
|
+
board = with_not_found("Board not found. Use 'suth boards list -s SPACE' to see available boards.") do
|
|
19
|
+
client.boards.find(workspace_id, board_id)
|
|
20
|
+
end
|
|
21
|
+
if board.lists.nil? || board.lists.empty?
|
|
22
|
+
say "No lists found on this board.", :yellow
|
|
23
|
+
else
|
|
24
|
+
output_list board.lists, columns: %i[id title color], headers: {id: "LIST_ID"}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
desc "create", "Create a new list on a board"
|
|
30
|
+
option :board, type: :string, required: true, aliases: "-b", desc: "Board to create list in (ID or name)"
|
|
31
|
+
option :space, type: :string, aliases: "-s", desc: "Space (helps resolve board name)"
|
|
32
|
+
option :title, type: :string, required: true, desc: "List title"
|
|
33
|
+
option :description, type: :string, desc: "List description"
|
|
34
|
+
option :icon, type: :string, desc: "Icon name (e.g., shield, rocket)"
|
|
35
|
+
option :color, type: :string, desc: "Color: #{Boards::COLORS.join(", ")}"
|
|
36
|
+
# Add a new list (column) to a board.
|
|
37
|
+
#
|
|
38
|
+
# @return [void]
|
|
39
|
+
def create
|
|
40
|
+
handle_error do
|
|
41
|
+
opts = symbolized_options(:title, :icon, :color)
|
|
42
|
+
opts[:content] = options[:description] if options[:description]
|
|
43
|
+
list = client.boards.create_list(workspace_id, board_id: board_id, **opts)
|
|
44
|
+
output_item list, fields: %i[id title color board_id], labels: {id: "List ID"}
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
desc "update LIST", "Update a list"
|
|
49
|
+
option :title, type: :string, desc: "New title"
|
|
50
|
+
option :description, type: :string, desc: "New description"
|
|
51
|
+
option :icon, type: :string, desc: "Icon name (e.g., shield, rocket)"
|
|
52
|
+
option :color, type: :string, desc: "Color: #{Boards::COLORS.join(", ")}"
|
|
53
|
+
# Update an existing list's properties.
|
|
54
|
+
#
|
|
55
|
+
# @param list_id [String] the unique identifier of the list to update
|
|
56
|
+
# @return [void]
|
|
57
|
+
def update(list_id)
|
|
58
|
+
handle_error do
|
|
59
|
+
opts = symbolized_options(:title, :icon, :color)
|
|
60
|
+
opts[:content] = options[:description] if options[:description]
|
|
61
|
+
list = with_not_found("List not found: '#{list_id}'. Use 'suth lists list -b BOARD' to see available lists.") do
|
|
62
|
+
client.boards.update_list(workspace_id, list_id, **opts)
|
|
63
|
+
end
|
|
64
|
+
output_item list, fields: %i[id title color board_id], labels: {id: "List ID"}
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
desc "delete LIST", "Delete a list"
|
|
69
|
+
# Permanently delete a list from a board after confirmation.
|
|
70
|
+
#
|
|
71
|
+
# @param list_id [String] the unique identifier of the list to delete
|
|
72
|
+
# @return [void]
|
|
73
|
+
def delete(list_id)
|
|
74
|
+
handle_error do
|
|
75
|
+
confirming("Delete list #{list_id}?") do
|
|
76
|
+
with_not_found("List not found: '#{list_id}'. Use 'suth lists list -b BOARD' to see available lists.") do
|
|
77
|
+
client.boards.delete_list(workspace_id, list_id)
|
|
78
|
+
end
|
|
79
|
+
output_success "List #{list_id} deleted"
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|