howzit 2.1.18 → 2.1.22
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 +4 -4
- data/CHANGELOG.md +81 -0
- data/bin/howzit +2 -2
- data/howzit.gemspec +3 -1
- data/lib/howzit/buildnote.rb +345 -104
- data/lib/howzit/prompt.rb +207 -7
- data/lib/howzit/run_report.rb +51 -0
- data/lib/howzit/stringutils.rb +3 -1
- data/lib/howzit/task.rb +21 -2
- data/lib/howzit/topic.rb +24 -1
- data/lib/howzit/util.rb +5 -1
- data/lib/howzit/version.rb +1 -1
- data/lib/howzit.rb +11 -2
- data/spec/buildnote_spec.rb +161 -3
- data/spec/cli_spec.rb +1 -1
- data/spec/run_report_spec.rb +35 -0
- data/spec/spec_helper.rb +14 -0
- data/spec/topic_spec.rb +5 -2
- metadata +6 -15
- data/.editorconfig +0 -9
- data/.github/FUNDING.yml +0 -2
- data/.gitignore +0 -45
- data/.howzit.taskpaper.bak +0 -27
- data/.irbrc +0 -12
- data/.rspec +0 -2
- data/.rubocop.yml +0 -48
- data/.travis.yml +0 -17
- data/.yardopts +0 -6
- data/lib/.rubocop.yml +0 -1
- data/spec/.rubocop.yml +0 -4
data/lib/howzit/prompt.rb
CHANGED
|
@@ -83,21 +83,28 @@ module Howzit
|
|
|
83
83
|
## number of options, anything
|
|
84
84
|
## else gets max height for
|
|
85
85
|
## terminal)
|
|
86
|
+
## @param query [String] The search term to display in prompt
|
|
86
87
|
##
|
|
87
88
|
## @return [Array] the selected results
|
|
88
89
|
##
|
|
89
|
-
def choose(matches, height: :auto)
|
|
90
|
-
return [] if
|
|
90
|
+
def choose(matches, height: :auto, query: nil)
|
|
91
|
+
return [] if matches.count.zero?
|
|
92
|
+
return matches if matches.count == 1
|
|
93
|
+
return [] unless $stdout.isatty
|
|
91
94
|
|
|
92
95
|
if Util.command_exist?('fzf')
|
|
93
96
|
height = height == :auto ? matches.count + 3 : TTY::Screen.rows
|
|
94
97
|
|
|
95
|
-
settings = fzf_options(height)
|
|
98
|
+
settings = fzf_options(height, query: query)
|
|
96
99
|
res = `echo #{Shellwords.escape(matches.join("\n"))} | fzf #{settings.join(' ')}`.strip
|
|
97
100
|
return fzf_result(res)
|
|
98
101
|
end
|
|
99
102
|
|
|
100
|
-
|
|
103
|
+
if Util.command_exist?('gum')
|
|
104
|
+
return gum_choose(matches, query: query, multi: true)
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
tty_menu(matches, query: query)
|
|
101
108
|
end
|
|
102
109
|
|
|
103
110
|
def fzf_result(res)
|
|
@@ -108,7 +115,12 @@ module Howzit
|
|
|
108
115
|
res.split(/\n/)
|
|
109
116
|
end
|
|
110
117
|
|
|
111
|
-
def fzf_options(height)
|
|
118
|
+
def fzf_options(height, query: nil)
|
|
119
|
+
prompt = if query
|
|
120
|
+
"Select a topic for \\`#{query}\\` > "
|
|
121
|
+
else
|
|
122
|
+
'Select a topic > '
|
|
123
|
+
end
|
|
112
124
|
[
|
|
113
125
|
'-0',
|
|
114
126
|
'-1',
|
|
@@ -116,7 +128,7 @@ module Howzit
|
|
|
116
128
|
"--height=#{height}",
|
|
117
129
|
'--header="Tab: add selection, ctrl-a/d: (de)select all, return: display/run"',
|
|
118
130
|
'--bind ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all',
|
|
119
|
-
|
|
131
|
+
"--prompt=\"#{prompt}\"",
|
|
120
132
|
%(--preview="howzit --no-pager --header-format block --no-color --default --multiple first {}")
|
|
121
133
|
]
|
|
122
134
|
end
|
|
@@ -125,8 +137,9 @@ module Howzit
|
|
|
125
137
|
## Display a numeric menu on the TTY
|
|
126
138
|
##
|
|
127
139
|
## @param matches The matches from which to select
|
|
140
|
+
## @param query [String] The search term to display
|
|
128
141
|
##
|
|
129
|
-
def tty_menu(matches)
|
|
142
|
+
def tty_menu(matches, query: nil)
|
|
130
143
|
return matches if matches.count == 1
|
|
131
144
|
|
|
132
145
|
@stty_save = `stty -g`.chomp
|
|
@@ -136,6 +149,9 @@ module Howzit
|
|
|
136
149
|
exit
|
|
137
150
|
end
|
|
138
151
|
|
|
152
|
+
if query
|
|
153
|
+
puts "\nSelect a topic for `#{query}`:"
|
|
154
|
+
end
|
|
139
155
|
options_list(matches)
|
|
140
156
|
read_selection(matches)
|
|
141
157
|
end
|
|
@@ -187,6 +203,190 @@ module Howzit
|
|
|
187
203
|
end
|
|
188
204
|
line == '' ? 1 : line.to_i
|
|
189
205
|
end
|
|
206
|
+
|
|
207
|
+
##
|
|
208
|
+
## Multi-select menu for templates
|
|
209
|
+
##
|
|
210
|
+
## @param matches [Array] The options list
|
|
211
|
+
## @param prompt_text [String] The prompt to display
|
|
212
|
+
##
|
|
213
|
+
## @return [Array] the selected results (can be empty)
|
|
214
|
+
##
|
|
215
|
+
def choose_templates(matches, prompt_text: 'Select templates')
|
|
216
|
+
return [] if matches.count.zero?
|
|
217
|
+
return [] unless $stdout.isatty
|
|
218
|
+
|
|
219
|
+
if Util.command_exist?('fzf')
|
|
220
|
+
height = matches.count + 3
|
|
221
|
+
settings = fzf_template_options(height, prompt_text: prompt_text)
|
|
222
|
+
|
|
223
|
+
# Save terminal state before fzf
|
|
224
|
+
tty_state = `stty -g`.chomp
|
|
225
|
+
res = `echo #{Shellwords.escape(matches.join("\n"))} | fzf #{settings.join(' ')}`.strip
|
|
226
|
+
# Restore terminal state after fzf
|
|
227
|
+
system("stty #{tty_state}")
|
|
228
|
+
|
|
229
|
+
return res.empty? ? [] : res.split(/\n/)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
if Util.command_exist?('gum')
|
|
233
|
+
return gum_choose(matches, prompt: prompt_text, multi: true, required: false)
|
|
234
|
+
end
|
|
235
|
+
|
|
236
|
+
text_template_input(matches)
|
|
237
|
+
end
|
|
238
|
+
|
|
239
|
+
##
|
|
240
|
+
## FZF options for template selection
|
|
241
|
+
##
|
|
242
|
+
def fzf_template_options(height, prompt_text: 'Select templates')
|
|
243
|
+
[
|
|
244
|
+
'-0',
|
|
245
|
+
'-m',
|
|
246
|
+
"--height=#{height}",
|
|
247
|
+
'--header="Tab: add selection, ctrl-a/d: (de)select all, esc: skip, return: confirm"',
|
|
248
|
+
'--bind ctrl-a:select-all,ctrl-d:deselect-all,ctrl-t:toggle-all',
|
|
249
|
+
"--prompt=\"#{prompt_text} > \""
|
|
250
|
+
]
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
##
|
|
254
|
+
## Text-based template input with fuzzy matching
|
|
255
|
+
##
|
|
256
|
+
## @param available [Array] Available template names
|
|
257
|
+
##
|
|
258
|
+
## @return [Array] Matched template names
|
|
259
|
+
##
|
|
260
|
+
def text_template_input(available)
|
|
261
|
+
@stty_save = `stty -g`.chomp
|
|
262
|
+
|
|
263
|
+
trap('INT') do
|
|
264
|
+
system('stty', @stty_save)
|
|
265
|
+
exit
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
puts "\n{bw}Available templates:{x} #{available.join(', ')}".c
|
|
269
|
+
printf '{bw}Enter templates to include, comma-separated (return to skip):{x} '.c
|
|
270
|
+
input = Readline.readline('', true).strip
|
|
271
|
+
|
|
272
|
+
return [] if input.empty?
|
|
273
|
+
|
|
274
|
+
fuzzy_match_templates(input, available)
|
|
275
|
+
ensure
|
|
276
|
+
system('stty', @stty_save) if @stty_save
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
##
|
|
280
|
+
## Fuzzy match user input against available templates
|
|
281
|
+
##
|
|
282
|
+
## @param input [String] Comma-separated user input
|
|
283
|
+
## @param available [Array] Available template names
|
|
284
|
+
##
|
|
285
|
+
## @return [Array] Matched template names
|
|
286
|
+
##
|
|
287
|
+
def fuzzy_match_templates(input, available)
|
|
288
|
+
terms = input.split(',').map(&:strip).reject(&:empty?)
|
|
289
|
+
matched = []
|
|
290
|
+
|
|
291
|
+
terms.each do |term|
|
|
292
|
+
# Try exact match first (case-insensitive)
|
|
293
|
+
exact = available.find { |t| t.downcase == term.downcase }
|
|
294
|
+
if exact
|
|
295
|
+
matched << exact unless matched.include?(exact)
|
|
296
|
+
next
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
# Try fuzzy match using the same regex approach as topic matching
|
|
300
|
+
rx = term.to_rx
|
|
301
|
+
fuzzy = available.select { |t| t =~ rx }
|
|
302
|
+
|
|
303
|
+
# Prefer matches that start with the term
|
|
304
|
+
if fuzzy.length > 1
|
|
305
|
+
starts_with = fuzzy.select { |t| t.downcase.start_with?(term.downcase) }
|
|
306
|
+
fuzzy = starts_with unless starts_with.empty?
|
|
307
|
+
end
|
|
308
|
+
|
|
309
|
+
fuzzy.each { |t| matched << t unless matched.include?(t) }
|
|
310
|
+
end
|
|
311
|
+
|
|
312
|
+
matched
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
##
|
|
316
|
+
## Prompt for a single line of input
|
|
317
|
+
##
|
|
318
|
+
## @param prompt_text [String] The prompt to display
|
|
319
|
+
## @param default [String] Default value if empty
|
|
320
|
+
##
|
|
321
|
+
## @return [String] the entered value
|
|
322
|
+
##
|
|
323
|
+
def get_line(prompt_text, default: nil)
|
|
324
|
+
return (default || '') unless $stdout.isatty
|
|
325
|
+
|
|
326
|
+
if Util.command_exist?('gum')
|
|
327
|
+
result = gum_input(prompt_text, placeholder: default || '')
|
|
328
|
+
return result.empty? && default ? default : result
|
|
329
|
+
end
|
|
330
|
+
|
|
331
|
+
prompt_with_default = default ? "#{prompt_text} [#{default}]: " : "#{prompt_text}: "
|
|
332
|
+
result = Readline.readline(prompt_with_default, true).to_s.strip
|
|
333
|
+
result.empty? && default ? default : result
|
|
334
|
+
end
|
|
335
|
+
|
|
336
|
+
##
|
|
337
|
+
## Use gum for single or multi-select menu
|
|
338
|
+
##
|
|
339
|
+
## @param matches [Array] The options list
|
|
340
|
+
## @param prompt [String] The prompt text
|
|
341
|
+
## @param multi [Boolean] Allow multiple selections
|
|
342
|
+
## @param required [Boolean] Require at least one selection
|
|
343
|
+
## @param query [String] The search term for display
|
|
344
|
+
##
|
|
345
|
+
## @return [Array] Selected items
|
|
346
|
+
##
|
|
347
|
+
def gum_choose(matches, prompt: nil, multi: false, required: true, query: nil)
|
|
348
|
+
prompt_text = prompt || (query ? "Select for '#{query}'" : 'Select')
|
|
349
|
+
args = ['gum', 'choose']
|
|
350
|
+
args << '--no-limit' if multi
|
|
351
|
+
args << "--header=#{Shellwords.escape(prompt_text)}"
|
|
352
|
+
args << '--cursor.foreground=6'
|
|
353
|
+
args << '--selected.foreground=2'
|
|
354
|
+
|
|
355
|
+
tty_state = `stty -g`.chomp
|
|
356
|
+
res = `echo #{Shellwords.escape(matches.join("\n"))} | #{args.join(' ')}`.strip
|
|
357
|
+
system("stty #{tty_state}")
|
|
358
|
+
|
|
359
|
+
if res.empty?
|
|
360
|
+
if required
|
|
361
|
+
Howzit.console.info 'Cancelled'
|
|
362
|
+
Process.exit 0
|
|
363
|
+
end
|
|
364
|
+
return []
|
|
365
|
+
end
|
|
366
|
+
|
|
367
|
+
res.split(/\n/)
|
|
368
|
+
end
|
|
369
|
+
|
|
370
|
+
##
|
|
371
|
+
## Use gum for text input
|
|
372
|
+
##
|
|
373
|
+
## @param prompt_text [String] The prompt to display
|
|
374
|
+
## @param placeholder [String] Placeholder text
|
|
375
|
+
##
|
|
376
|
+
## @return [String] The entered value
|
|
377
|
+
##
|
|
378
|
+
def gum_input(prompt_text, placeholder: '')
|
|
379
|
+
args = ['gum', 'input']
|
|
380
|
+
args << "--header=#{Shellwords.escape(prompt_text)}"
|
|
381
|
+
args << "--placeholder=#{Shellwords.escape(placeholder)}" unless placeholder.empty?
|
|
382
|
+
args << '--cursor.foreground=6'
|
|
383
|
+
|
|
384
|
+
tty_state = `stty -g`.chomp
|
|
385
|
+
res = `#{args.join(' ')}`.strip
|
|
386
|
+
system("stty #{tty_state}")
|
|
387
|
+
|
|
388
|
+
res
|
|
389
|
+
end
|
|
190
390
|
end
|
|
191
391
|
end
|
|
192
392
|
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Howzit
|
|
4
|
+
# Formatter for task run summaries
|
|
5
|
+
module RunReport
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def reset
|
|
9
|
+
Howzit.run_log = []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def log(entry)
|
|
13
|
+
Howzit.run_log = [] if Howzit.run_log.nil?
|
|
14
|
+
Howzit.run_log << entry
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def entries
|
|
18
|
+
Howzit.run_log || []
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def format
|
|
22
|
+
return '' if entries.empty?
|
|
23
|
+
|
|
24
|
+
lines = entries.map { |entry| format_line(entry, Howzit.multi_topic_run) }
|
|
25
|
+
lines.map! { |line| line.rstrip }
|
|
26
|
+
widths = lines.map { |line| line.uncolor.length }
|
|
27
|
+
width = widths.max
|
|
28
|
+
top = '=' * width
|
|
29
|
+
bottom = '-' * width
|
|
30
|
+
output_lines = [top] + lines + [bottom]
|
|
31
|
+
result = output_lines.join("\n")
|
|
32
|
+
result = result.gsub(/\n[ \t]+\n/, "\n")
|
|
33
|
+
result.gsub(/\n{2,}/, "\n")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def format_line(entry, prefix_topic)
|
|
37
|
+
bullet_start = '{mb}- [{x}'
|
|
38
|
+
bullet_end = '{mb}] {x}'
|
|
39
|
+
symbol = entry[:success] ? '{bg}✓{x}' : '{br}X{x}'
|
|
40
|
+
parts = []
|
|
41
|
+
parts << "#{bullet_start}#{symbol}#{bullet_end}"
|
|
42
|
+
parts << "{bl}#{entry[:topic]}{x}: " if prefix_topic && entry[:topic] && !entry[:topic].empty?
|
|
43
|
+
parts << "{by}#{entry[:task]}{x}"
|
|
44
|
+
unless entry[:success]
|
|
45
|
+
reason = entry[:exit_status] ? "exit code #{entry[:exit_status]}" : 'failed'
|
|
46
|
+
parts << " {br}(Failed: #{reason}){x}"
|
|
47
|
+
end
|
|
48
|
+
parts.join.c
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
data/lib/howzit/stringutils.rb
CHANGED
|
@@ -183,7 +183,9 @@ module Howzit
|
|
|
183
183
|
|
|
184
184
|
# Just strip out color codes when requested
|
|
185
185
|
def uncolor
|
|
186
|
-
|
|
186
|
+
# force UTF-8 and remove invalid characters, then remove color codes
|
|
187
|
+
# and iTerm markers
|
|
188
|
+
gsub(Howzit::Color::COLORED_REGEXP, "").gsub(/\e\]1337;SetMark/, "")
|
|
187
189
|
end
|
|
188
190
|
|
|
189
191
|
# Wrap text at a specified width.
|
data/lib/howzit/task.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'English'
|
|
4
|
+
|
|
3
5
|
module Howzit
|
|
4
6
|
# Task object
|
|
5
7
|
class Task
|
|
6
|
-
attr_reader :type, :title, :action, :arguments, :parent, :optional, :default
|
|
8
|
+
attr_reader :type, :title, :action, :arguments, :parent, :optional, :default, :last_status
|
|
7
9
|
|
|
8
10
|
##
|
|
9
11
|
## Initialize a Task object
|
|
@@ -31,6 +33,7 @@ module Howzit
|
|
|
31
33
|
|
|
32
34
|
@optional = optional
|
|
33
35
|
@default = default
|
|
36
|
+
@last_status = nil
|
|
34
37
|
end
|
|
35
38
|
|
|
36
39
|
##
|
|
@@ -68,6 +71,7 @@ module Howzit
|
|
|
68
71
|
script.unlink
|
|
69
72
|
end
|
|
70
73
|
|
|
74
|
+
update_last_status(res ? 0 : 1)
|
|
71
75
|
res
|
|
72
76
|
end
|
|
73
77
|
|
|
@@ -86,6 +90,7 @@ module Howzit
|
|
|
86
90
|
Howzit.console.info("#{@prefix}{by}Running tasks from {bw}#{matches[0].title}{x}".c)
|
|
87
91
|
output.concat(matches[0].run(nested: true))
|
|
88
92
|
Howzit.console.info("{by}End include: #{matches[0].tasks.count} tasks{x}".c)
|
|
93
|
+
@last_status = nil
|
|
89
94
|
[output, matches[0].tasks.count]
|
|
90
95
|
end
|
|
91
96
|
|
|
@@ -96,7 +101,9 @@ module Howzit
|
|
|
96
101
|
title = Howzit.options[:show_all_code] ? @action : @title
|
|
97
102
|
Howzit.console.info("#{@prefix}{bg}Running {bw}#{title}{x}".c)
|
|
98
103
|
ENV['HOWZIT_SCRIPTS'] = File.expand_path('~/.config/howzit/scripts')
|
|
99
|
-
system(@action)
|
|
104
|
+
res = system(@action)
|
|
105
|
+
update_last_status(res ? 0 : 1)
|
|
106
|
+
res
|
|
100
107
|
end
|
|
101
108
|
|
|
102
109
|
##
|
|
@@ -106,6 +113,7 @@ module Howzit
|
|
|
106
113
|
title = Howzit.options[:show_all_code] ? @action : @title
|
|
107
114
|
Howzit.console.info("#{@prefix}{bg}Copied {bw}#{title}{bg} to clipboard{x}".c)
|
|
108
115
|
Util.os_copy(@action)
|
|
116
|
+
@last_status = 0
|
|
109
117
|
true
|
|
110
118
|
end
|
|
111
119
|
|
|
@@ -127,12 +135,23 @@ module Howzit
|
|
|
127
135
|
run_copy
|
|
128
136
|
when :open
|
|
129
137
|
Util.os_open(@action)
|
|
138
|
+
@last_status = 0
|
|
139
|
+
true
|
|
130
140
|
end
|
|
131
141
|
end
|
|
132
142
|
|
|
133
143
|
[output, tasks, res]
|
|
134
144
|
end
|
|
135
145
|
|
|
146
|
+
def update_last_status(default = nil)
|
|
147
|
+
status = if defined?($CHILD_STATUS) && $CHILD_STATUS
|
|
148
|
+
$CHILD_STATUS.exitstatus
|
|
149
|
+
else
|
|
150
|
+
default
|
|
151
|
+
end
|
|
152
|
+
@last_status = status
|
|
153
|
+
end
|
|
154
|
+
|
|
136
155
|
##
|
|
137
156
|
## Output terminal-formatted list item
|
|
138
157
|
##
|
data/lib/howzit/topic.rb
CHANGED
|
@@ -104,6 +104,8 @@ module Howzit
|
|
|
104
104
|
|
|
105
105
|
break unless Howzit.options[:force]
|
|
106
106
|
end
|
|
107
|
+
|
|
108
|
+
log_task_result(task, success)
|
|
107
109
|
end
|
|
108
110
|
|
|
109
111
|
total = "{bw}#{@results[:total]}{by} #{@results[:total] == 1 ? 'task' : 'tasks'}".c
|
|
@@ -119,7 +121,7 @@ module Howzit
|
|
|
119
121
|
Howzit.console.warn "{r}--run: No {br}@directive{xr} found in {bw}#{@title}{x}".c
|
|
120
122
|
end
|
|
121
123
|
|
|
122
|
-
output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested
|
|
124
|
+
output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested && !Howzit.options[:run]
|
|
123
125
|
|
|
124
126
|
puts TTY::Box.frame("{bw}#{@postreqs.join("\n\n").wrap(cols - 4)}{x}".c, width: cols) unless @postreqs.empty?
|
|
125
127
|
|
|
@@ -324,6 +326,27 @@ module Howzit
|
|
|
324
326
|
##
|
|
325
327
|
## @return [Array] array of Task objects
|
|
326
328
|
##
|
|
329
|
+
def log_task_result(task, success)
|
|
330
|
+
return unless Howzit.options[:run]
|
|
331
|
+
return if task.type == :include
|
|
332
|
+
|
|
333
|
+
Howzit.run_log ||= []
|
|
334
|
+
|
|
335
|
+
title = (task.title || '').strip
|
|
336
|
+
if title.empty?
|
|
337
|
+
action = (task.action || '').strip
|
|
338
|
+
title = action.split(/\n/).first.to_s.strip
|
|
339
|
+
end
|
|
340
|
+
title = task.type.to_s.capitalize if title.nil? || title.empty?
|
|
341
|
+
|
|
342
|
+
Howzit.run_log << {
|
|
343
|
+
topic: @title,
|
|
344
|
+
task: title,
|
|
345
|
+
success: success ? true : false,
|
|
346
|
+
exit_status: task.last_status
|
|
347
|
+
}
|
|
348
|
+
end
|
|
349
|
+
|
|
327
350
|
def gather_tasks
|
|
328
351
|
runnable = []
|
|
329
352
|
@prereqs = @content.scan(/(?<=@before\n).*?(?=\n@end)/im).map(&:strip)
|
data/lib/howzit/util.rb
CHANGED
|
@@ -145,7 +145,11 @@ module Howzit
|
|
|
145
145
|
end
|
|
146
146
|
|
|
147
147
|
read_io.close
|
|
148
|
-
|
|
148
|
+
begin
|
|
149
|
+
write_io.write(text)
|
|
150
|
+
rescue Errno::EPIPE
|
|
151
|
+
# User quit pager before we finished writing, ignore
|
|
152
|
+
end
|
|
149
153
|
write_io.close
|
|
150
154
|
|
|
151
155
|
_, status = Process.waitpid2(pid)
|
data/lib/howzit/version.rb
CHANGED
data/lib/howzit.rb
CHANGED
|
@@ -41,6 +41,7 @@ require_relative 'howzit/config'
|
|
|
41
41
|
require_relative 'howzit/task'
|
|
42
42
|
require_relative 'howzit/topic'
|
|
43
43
|
require_relative 'howzit/buildnote'
|
|
44
|
+
require_relative 'howzit/run_report'
|
|
44
45
|
|
|
45
46
|
require 'tty/screen'
|
|
46
47
|
require 'tty/box'
|
|
@@ -49,7 +50,7 @@ require 'tty/box'
|
|
|
49
50
|
# Main module for howzit
|
|
50
51
|
module Howzit
|
|
51
52
|
class << self
|
|
52
|
-
attr_accessor :arguments, :named_arguments, :cli_args
|
|
53
|
+
attr_accessor :arguments, :named_arguments, :cli_args, :run_log, :multi_topic_run
|
|
53
54
|
|
|
54
55
|
##
|
|
55
56
|
## Holds a Configuration object with methods and a @settings hash
|
|
@@ -88,10 +89,18 @@ module Howzit
|
|
|
88
89
|
@console ||= Howzit::ConsoleLogger.new(options[:log_level])
|
|
89
90
|
end
|
|
90
91
|
|
|
92
|
+
def run_log
|
|
93
|
+
@run_log ||= []
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def multi_topic_run
|
|
97
|
+
@multi_topic_run ||= false
|
|
98
|
+
end
|
|
99
|
+
|
|
91
100
|
def has_read_upstream
|
|
92
101
|
@has_read_upstream ||= false
|
|
93
102
|
end
|
|
94
103
|
|
|
95
|
-
attr_writer :has_read_upstream
|
|
104
|
+
attr_writer :has_read_upstream, :run_log
|
|
96
105
|
end
|
|
97
106
|
end
|
data/spec/buildnote_spec.rb
CHANGED
|
@@ -62,6 +62,12 @@ describe Howzit::BuildNote do
|
|
|
62
62
|
expect(matches.count).to eq 0
|
|
63
63
|
end
|
|
64
64
|
|
|
65
|
+
it "matches topics containing colon even without space" do
|
|
66
|
+
matches = how.find_topic('git:clean')
|
|
67
|
+
expect(matches.count).to eq 1
|
|
68
|
+
expect(matches[0].title).to eq 'Git: Clean Repo'
|
|
69
|
+
end
|
|
70
|
+
|
|
65
71
|
it "Handles multiple matches with best match" do
|
|
66
72
|
Howzit.options[:matching] = 'fuzzy'
|
|
67
73
|
Howzit.options[:multiple_matches] = :best
|
|
@@ -70,12 +76,164 @@ describe Howzit::BuildNote do
|
|
|
70
76
|
end
|
|
71
77
|
end
|
|
72
78
|
|
|
79
|
+
describe ".find_topic_exact" do
|
|
80
|
+
it "finds exact whole-word match" do
|
|
81
|
+
matches = how.find_topic_exact('Topic Tropic')
|
|
82
|
+
expect(matches.count).to eq 1
|
|
83
|
+
expect(matches[0].title).to eq 'Topic Tropic'
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
it "finds exact match case-insensitively" do
|
|
87
|
+
matches = how.find_topic_exact('topic tropic')
|
|
88
|
+
expect(matches.count).to eq 1
|
|
89
|
+
expect(matches[0].title).to eq 'Topic Tropic'
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it "does not match partial phrases" do
|
|
93
|
+
matches = how.find_topic_exact('topic trop')
|
|
94
|
+
expect(matches.count).to eq 0
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
it "does not match single word when phrase has multiple words" do
|
|
98
|
+
matches = how.find_topic_exact('topic')
|
|
99
|
+
expect(matches.count).to eq 0
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
it "matches single-word topics" do
|
|
103
|
+
matches = how.find_topic_exact('Happy Bgagngagnga')
|
|
104
|
+
expect(matches.count).to eq 1
|
|
105
|
+
expect(matches[0].title).to eq 'Happy Bgagngagnga'
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
it "matches topics with colons" do
|
|
109
|
+
matches = how.find_topic_exact('Git: Clean Repo')
|
|
110
|
+
expect(matches.count).to eq 1
|
|
111
|
+
expect(matches[0].title).to eq 'Git: Clean Repo'
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
|
|
73
115
|
describe ".topics" do
|
|
74
|
-
it "contains
|
|
75
|
-
expect(how.list_topics.count).to eq
|
|
116
|
+
it "contains 7 topics" do
|
|
117
|
+
expect(how.list_topics.count).to eq 7
|
|
76
118
|
end
|
|
77
119
|
it "outputs a newline-separated string for completion" do
|
|
78
|
-
expect(how.list_completions.scan(/\n/).count).to eq
|
|
120
|
+
expect(how.list_completions.scan(/\n/).count).to eq 6
|
|
121
|
+
end
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
describe "#topic_search_terms_from_cli" do
|
|
125
|
+
after { Howzit.cli_args = [] }
|
|
126
|
+
|
|
127
|
+
it "respects separators found inside topics" do
|
|
128
|
+
Howzit.cli_args = ['git:clean:blog:update post']
|
|
129
|
+
expect(how.send(:topic_search_terms_from_cli)).to eq(['git:clean', 'blog:update post'])
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
it "keeps comma inside matching topics" do
|
|
133
|
+
Howzit.cli_args = ['release, deploy,topic balogna']
|
|
134
|
+
expect(how.send(:topic_search_terms_from_cli)).to eq(['release, deploy', 'topic balogna'])
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
describe "#collect_topic_matches" do
|
|
139
|
+
before do
|
|
140
|
+
Howzit.options[:multiple_matches] = :first
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
it "collects matches for multiple search terms" do
|
|
144
|
+
search_terms = ['topic tropic', 'topic banana']
|
|
145
|
+
output = []
|
|
146
|
+
matches = how.send(:collect_topic_matches, search_terms, output)
|
|
147
|
+
expect(matches.count).to eq 2
|
|
148
|
+
expect(matches.map(&:title)).to include('Topic Tropic', 'Topic Banana')
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
it "prefers exact matches over fuzzy matches" do
|
|
152
|
+
# 'Topic Banana' should exact-match, not fuzzy match to multiple
|
|
153
|
+
search_terms = ['topic banana']
|
|
154
|
+
output = []
|
|
155
|
+
matches = how.send(:collect_topic_matches, search_terms, output)
|
|
156
|
+
expect(matches.count).to eq 1
|
|
157
|
+
expect(matches[0].title).to eq 'Topic Banana'
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
it "falls back to fuzzy match when no exact match" do
|
|
161
|
+
Howzit.options[:matching] = 'fuzzy'
|
|
162
|
+
search_terms = ['trpc'] # fuzzy for 'tropic'
|
|
163
|
+
output = []
|
|
164
|
+
matches = how.send(:collect_topic_matches, search_terms, output)
|
|
165
|
+
expect(matches.count).to eq 1
|
|
166
|
+
expect(matches[0].title).to eq 'Topic Tropic'
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
it "adds error message for unmatched terms" do
|
|
170
|
+
search_terms = ['nonexistent topic xyz']
|
|
171
|
+
output = []
|
|
172
|
+
matches = how.send(:collect_topic_matches, search_terms, output)
|
|
173
|
+
expect(matches.count).to eq 0
|
|
174
|
+
expect(output.join).to match(/no topic match found/i)
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
it "collects multiple topics from comma-separated input" do
|
|
178
|
+
Howzit.cli_args = ['topic tropic,topic banana']
|
|
179
|
+
search_terms = how.send(:topic_search_terms_from_cli)
|
|
180
|
+
output = []
|
|
181
|
+
matches = how.send(:collect_topic_matches, search_terms, output)
|
|
182
|
+
expect(matches.count).to eq 2
|
|
183
|
+
Howzit.cli_args = []
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
|
|
187
|
+
describe "#smart_split_topics" do
|
|
188
|
+
it "splits on comma when not part of topic title" do
|
|
189
|
+
result = how.send(:smart_split_topics, 'topic tropic,topic banana')
|
|
190
|
+
expect(result).to eq(['topic tropic', 'topic banana'])
|
|
191
|
+
end
|
|
192
|
+
|
|
193
|
+
it "preserves comma when part of topic title" do
|
|
194
|
+
result = how.send(:smart_split_topics, 'release, deploy,topic banana')
|
|
195
|
+
expect(result).to eq(['release, deploy', 'topic banana'])
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
it "preserves colon when part of topic title" do
|
|
199
|
+
result = how.send(:smart_split_topics, 'git:clean,blog:update post')
|
|
200
|
+
expect(result).to eq(['git:clean', 'blog:update post'])
|
|
201
|
+
end
|
|
202
|
+
|
|
203
|
+
it "handles mixed separators correctly" do
|
|
204
|
+
result = how.send(:smart_split_topics, 'git:clean:topic tropic')
|
|
205
|
+
expect(result).to eq(['git:clean', 'topic tropic'])
|
|
206
|
+
end
|
|
207
|
+
end
|
|
208
|
+
|
|
209
|
+
describe "#parse_template_required_vars" do
|
|
210
|
+
let(:template_with_required) do
|
|
211
|
+
Tempfile.new(['template', '.md']).tap do |f|
|
|
212
|
+
f.write("required: repo_url, author\n\n# Template\n\n## Section")
|
|
213
|
+
f.close
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
let(:template_without_required) do
|
|
218
|
+
Tempfile.new(['template', '.md']).tap do |f|
|
|
219
|
+
f.write("# Template\n\n## Section")
|
|
220
|
+
f.close
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
after do
|
|
225
|
+
template_with_required.unlink
|
|
226
|
+
template_without_required.unlink
|
|
227
|
+
end
|
|
228
|
+
|
|
229
|
+
it "parses required variables from template metadata" do
|
|
230
|
+
vars = how.send(:parse_template_required_vars, template_with_required.path)
|
|
231
|
+
expect(vars).to eq(['repo_url', 'author'])
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
it "returns empty array when no required metadata" do
|
|
235
|
+
vars = how.send(:parse_template_required_vars, template_without_required.path)
|
|
236
|
+
expect(vars).to eq([])
|
|
79
237
|
end
|
|
80
238
|
end
|
|
81
239
|
end
|