howzit 2.1.21 → 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 +15 -0
- data/lib/howzit/buildnote.rb +83 -11
- data/lib/howzit/prompt.rb +188 -0
- data/lib/howzit/version.rb +1 -1
- data/spec/buildnote_spec.rb +102 -0
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 4c45e148fc771ca280dbf21202a25f3b6192eb0534f88692103630749de801c9
|
|
4
|
+
data.tar.gz: 2567392e9e6389845be4809aca60ccb347848b2c7575f0c6f4c9232f4a4fe4f0
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 9e22a71fee828ebfcd96e2f1ea5d4f27ad5e6a0401cc6a73f8344313006a160b10db160c6eb71b7f528df8b23233871a6c914c8ccdb48d57a88b60fb25d4e287
|
|
7
|
+
data.tar.gz: 98411cc9c877f08309c26df629c713510514afeb740a85e741700bd8d9f96b373c5b40affe540e371dd93321aeec6c088fd65d6b953550e4b3161829aa7c0a58
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,18 @@
|
|
|
1
|
+
### 2.1.22
|
|
2
|
+
|
|
3
|
+
2025-12-13 06:14
|
|
4
|
+
|
|
5
|
+
#### NEW
|
|
6
|
+
|
|
7
|
+
- Template selection menu when creating new build notes
|
|
8
|
+
- Prompt for required template variables during note creation
|
|
9
|
+
- Gum support as fallback for menus and text input
|
|
10
|
+
|
|
11
|
+
#### IMPROVED
|
|
12
|
+
|
|
13
|
+
- Fuzzy matching for template names when fzf unavailable
|
|
14
|
+
- Text input uses Readline for proper line editing (backspace, ctrl-a/e)
|
|
15
|
+
|
|
1
16
|
### 2.1.21
|
|
2
17
|
|
|
3
18
|
2025-12-13 05:03
|
data/lib/howzit/buildnote.rb
CHANGED
|
@@ -293,27 +293,37 @@ module Howzit
|
|
|
293
293
|
if default
|
|
294
294
|
input = title
|
|
295
295
|
else
|
|
296
|
-
|
|
297
|
-
printf "{bw}Project name {xg}[#{title}]{bw}: {x}".c
|
|
298
|
-
input = $stdin.gets.chomp
|
|
299
|
-
title = input unless input.empty?
|
|
296
|
+
title = Prompt.get_line('{bw}Project name{x}'.c, default: title)
|
|
300
297
|
end
|
|
301
298
|
summary = ''
|
|
302
299
|
unless default
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
300
|
+
summary = Prompt.get_line('{bw}Project summary{x}'.c)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Template selection
|
|
304
|
+
selected_templates = []
|
|
305
|
+
template_metadata = {}
|
|
306
|
+
unless default
|
|
307
|
+
selected_templates, template_metadata = select_templates_for_note(title)
|
|
306
308
|
end
|
|
307
309
|
|
|
308
310
|
fname = 'buildnotes.md'
|
|
309
311
|
unless default
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
312
|
+
fname = Prompt.get_line("{bw}Build notes filename{x}\n(must begin with 'howzit' or 'build')".c, default: fname)
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Build metadata section
|
|
316
|
+
metadata_lines = []
|
|
317
|
+
unless selected_templates.empty?
|
|
318
|
+
metadata_lines << "template: #{selected_templates.join(',')}"
|
|
319
|
+
end
|
|
320
|
+
template_metadata.each do |key, value|
|
|
321
|
+
metadata_lines << "#{key}: #{value}"
|
|
313
322
|
end
|
|
323
|
+
metadata_section = metadata_lines.empty? ? '' : "#{metadata_lines.join("\n")}\n\n"
|
|
314
324
|
|
|
315
325
|
note = <<~EOBUILDNOTES
|
|
316
|
-
# #{title}
|
|
326
|
+
#{metadata_section}# #{title}
|
|
317
327
|
|
|
318
328
|
#{summary}
|
|
319
329
|
|
|
@@ -369,6 +379,68 @@ module Howzit
|
|
|
369
379
|
|
|
370
380
|
private
|
|
371
381
|
|
|
382
|
+
##
|
|
383
|
+
## Select templates for a new build note
|
|
384
|
+
##
|
|
385
|
+
## @param project_title [String] The project title for prompts
|
|
386
|
+
##
|
|
387
|
+
## @return [Array<Array, Hash>] Array of [selected_template_names, required_vars_hash]
|
|
388
|
+
##
|
|
389
|
+
def select_templates_for_note(project_title)
|
|
390
|
+
template_dir = Howzit.config.template_folder
|
|
391
|
+
template_glob = File.join(template_dir, '*.md')
|
|
392
|
+
template_files = Dir.glob(template_glob)
|
|
393
|
+
|
|
394
|
+
return [[], {}] if template_files.empty?
|
|
395
|
+
|
|
396
|
+
# Get basenames without extension for menu
|
|
397
|
+
template_names = template_files.map { |f| File.basename(f, '.md') }.sort
|
|
398
|
+
|
|
399
|
+
# Show multi-select menu
|
|
400
|
+
selected = Prompt.choose_templates(template_names, prompt_text: 'Select templates to include')
|
|
401
|
+
return [[], {}] if selected.empty?
|
|
402
|
+
|
|
403
|
+
# Prompt for required variables from each template
|
|
404
|
+
required_vars = {}
|
|
405
|
+
selected.each do |template_name|
|
|
406
|
+
template_path = File.join(template_dir, "#{template_name}.md")
|
|
407
|
+
next unless File.exist?(template_path)
|
|
408
|
+
|
|
409
|
+
vars = parse_template_required_vars(template_path)
|
|
410
|
+
vars.each do |var|
|
|
411
|
+
next if required_vars.key?(var)
|
|
412
|
+
|
|
413
|
+
value = Prompt.get_line("{bw}[#{template_name}] requires {by}#{var}{x}".c)
|
|
414
|
+
required_vars[var] = value unless value.empty?
|
|
415
|
+
end
|
|
416
|
+
end
|
|
417
|
+
|
|
418
|
+
[selected, required_vars]
|
|
419
|
+
end
|
|
420
|
+
|
|
421
|
+
##
|
|
422
|
+
## Parse a template file for required variables
|
|
423
|
+
##
|
|
424
|
+
## @param template_path [String] Path to the template file
|
|
425
|
+
##
|
|
426
|
+
## @return [Array] Array of required variable names
|
|
427
|
+
##
|
|
428
|
+
def parse_template_required_vars(template_path)
|
|
429
|
+
content = File.read(template_path)
|
|
430
|
+
|
|
431
|
+
# Look for required: in the metadata at the top of the file
|
|
432
|
+
# Metadata is before the first # heading
|
|
433
|
+
meta_section = content.split(/^#/)[0]
|
|
434
|
+
return [] if meta_section.nil? || meta_section.strip.empty?
|
|
435
|
+
|
|
436
|
+
# Find the required: line
|
|
437
|
+
match = meta_section.match(/^required:\s*(.+)$/i)
|
|
438
|
+
return [] unless match
|
|
439
|
+
|
|
440
|
+
# Split by comma and strip whitespace
|
|
441
|
+
match[1].split(',').map(&:strip).reject(&:empty?)
|
|
442
|
+
end
|
|
443
|
+
|
|
372
444
|
def topic_search_terms_from_cli
|
|
373
445
|
args = Howzit.cli_args || []
|
|
374
446
|
raw = args.join(' ').strip
|
data/lib/howzit/prompt.rb
CHANGED
|
@@ -100,6 +100,10 @@ module Howzit
|
|
|
100
100
|
return fzf_result(res)
|
|
101
101
|
end
|
|
102
102
|
|
|
103
|
+
if Util.command_exist?('gum')
|
|
104
|
+
return gum_choose(matches, query: query, multi: true)
|
|
105
|
+
end
|
|
106
|
+
|
|
103
107
|
tty_menu(matches, query: query)
|
|
104
108
|
end
|
|
105
109
|
|
|
@@ -199,6 +203,190 @@ module Howzit
|
|
|
199
203
|
end
|
|
200
204
|
line == '' ? 1 : line.to_i
|
|
201
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
|
|
202
390
|
end
|
|
203
391
|
end
|
|
204
392
|
end
|
data/lib/howzit/version.rb
CHANGED
data/spec/buildnote_spec.rb
CHANGED
|
@@ -134,4 +134,106 @@ describe Howzit::BuildNote do
|
|
|
134
134
|
expect(how.send(:topic_search_terms_from_cli)).to eq(['release, deploy', 'topic balogna'])
|
|
135
135
|
end
|
|
136
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([])
|
|
237
|
+
end
|
|
238
|
+
end
|
|
137
239
|
end
|