howzit 2.1.42 → 2.1.45
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 +75 -0
- data/Rakefile +3 -0
- data/howzit.gemspec +1 -1
- data/lib/howzit/buildnote.rb +75 -19
- data/lib/howzit/run_report.rb +20 -7
- data/lib/howzit/task.rb +11 -7
- data/lib/howzit/topic.rb +206 -114
- data/lib/howzit/version.rb +1 -1
- data/nested-topics-for-howzit.md +100 -0
- data/spec/run_report_accuracy_spec.rb +171 -0
- data/spec/subtopics_spec.rb +139 -0
- metadata +9 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3b5d2120c8484147d2d3cb8f8844073ca027e7a54655385749ee721f645d05a5
|
|
4
|
+
data.tar.gz: 3cef9cda18bf1b43b28d9288e9eda81d30b07fc9ac2505dd932e508240abf86b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 4dc4af7fce8a375a33a3dfee3ed2e6f9d914ea3069601aa3426ae552f4c5488d5aafa332522c4eef0a6b4318bd63afef20eda2d638e9c6fd09ce9784c5e6a434
|
|
7
|
+
data.tar.gz: fe60529771944221e4f7acd4bd9883669782f413c5a367ae3c03c8c7d8ce0a0e0413fcf4e086e37584868c58bbea10bb4e24ab49c28583521d901166404146be
|
data/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,78 @@
|
|
|
1
|
+
### 2.1.45
|
|
2
|
+
|
|
3
|
+
2026-09-27 12:16
|
|
4
|
+
|
|
5
|
+
#### CHANGED
|
|
6
|
+
|
|
7
|
+
- **Multi-topic runs** (comma-separated topics or `default:` metadata) now stop after the first topic with a failed task and mark the remaining topics' tasks as skipped; use --force to run every topic.
|
|
8
|
+
|
|
9
|
+
#### NEW
|
|
10
|
+
|
|
11
|
+
- **Skipped tasks** in the run report: tasks that did not run because of an earlier failure are listed with a skipped marker and "(skipped)" label.
|
|
12
|
+
- **Skipped @include tasks** are expanded to the included topic's individual tasks in the run report.
|
|
13
|
+
|
|
14
|
+
#### IMPROVED
|
|
15
|
+
|
|
16
|
+
- **Conditional topics** list remaining unconditional tasks as skipped after a failure; tasks inside unevaluated conditionals are omitted.
|
|
17
|
+
|
|
18
|
+
#### FIXED
|
|
19
|
+
|
|
20
|
+
- **Run report** now includes the task that failed and stopped a run, instead of omitting it and showing only the earlier successes.
|
|
21
|
+
- **@include** directives now fail when a task in the included topic fails, so the including topic stops instead of continuing with its remaining tasks.
|
|
22
|
+
- **Task totals for @include** reflect the included topic's actual successes and errors instead of counting every included task as a single result.
|
|
23
|
+
|
|
24
|
+
### 2.1.44
|
|
25
|
+
|
|
26
|
+
2026-09-27 11:38
|
|
27
|
+
|
|
28
|
+
#### CHANGED
|
|
29
|
+
|
|
30
|
+
- **Task failures** in a parent or subtopic stop the remaining subtopics from running unless --force is used.
|
|
31
|
+
- **@after blocks** on a parent are shown after the parent and all of its subtopics finish.
|
|
32
|
+
|
|
33
|
+
#### NEW
|
|
34
|
+
|
|
35
|
+
- **Subtopics**: deeper headers (### under ##, #### under ###, etc.) are now nested under the preceding shallower topic.
|
|
36
|
+
- **Displaying a parent topic** shows all of its subtopics after it, with subtopic headers drawn in a lighter style (cyan title, dotted rule).
|
|
37
|
+
- **Running a parent topic** runs its own tasks, then every subtopic's tasks in order (depth-first), with a single combined "Ran N tasks" summary.
|
|
38
|
+
|
|
39
|
+
#### IMPROVED
|
|
40
|
+
|
|
41
|
+
- **Showing all topics** (howzit with no arguments, or howzit -r with no default) only outputs top-level topics so subtopics are not repeated.
|
|
42
|
+
- **Selecting both a parent and its subtopic** outputs/runs the subtopic only once, as part of the parent.
|
|
43
|
+
- **Topic lists** (howzit -l and the runnable topics list) indent subtopics under their parents.
|
|
44
|
+
- **@include(Topic)** on a parent runs the parent and all of its subtopics, and task counts in include notes and menus now count subtopic tasks.
|
|
45
|
+
|
|
46
|
+
#### FIXED
|
|
47
|
+
|
|
48
|
+
- **Parent topics with no tasks of their own** no longer warn "No @directive found" when their subtopics contain tasks.
|
|
49
|
+
|
|
50
|
+
### 2.1.43
|
|
51
|
+
|
|
52
|
+
2026-09-27 11:35
|
|
53
|
+
|
|
54
|
+
#### CHANGED
|
|
55
|
+
|
|
56
|
+
- **Task failures** in a parent or subtopic stop the remaining subtopics from running unless --force is used.
|
|
57
|
+
- **@after blocks** on a parent are shown after the parent and all of its subtopics finish.
|
|
58
|
+
|
|
59
|
+
#### NEW
|
|
60
|
+
|
|
61
|
+
- **Subtopics**: deeper headers (### under ##, #### under ###, etc.) are now nested under the preceding shallower topic.
|
|
62
|
+
- **Displaying a parent topic** shows all of its subtopics after it, with subtopic headers drawn in a lighter style (cyan title, dotted rule).
|
|
63
|
+
- **Running a parent topic** runs its own tasks, then every subtopic's tasks in order (depth-first), with a single combined "Ran N tasks" summary.
|
|
64
|
+
|
|
65
|
+
#### IMPROVED
|
|
66
|
+
|
|
67
|
+
- **Showing all topics** (howzit with no arguments, or howzit -r with no default) only outputs top-level topics so subtopics are not repeated.
|
|
68
|
+
- **Selecting both a parent and its subtopic** outputs/runs the subtopic only once, as part of the parent.
|
|
69
|
+
- **Topic lists** (howzit -l and the runnable topics list) indent subtopics under their parents.
|
|
70
|
+
- **@include(Topic)** on a parent runs the parent and all of its subtopics, and task counts in include notes and menus now count subtopic tasks.
|
|
71
|
+
|
|
72
|
+
#### FIXED
|
|
73
|
+
|
|
74
|
+
- **Parent topics with no tasks of their own** no longer warn "No @directive found" when their subtopics contain tasks.
|
|
75
|
+
|
|
1
76
|
### 2.1.42
|
|
2
77
|
|
|
3
78
|
2026-06-29 06:52
|
data/Rakefile
CHANGED
data/howzit.gemspec
CHANGED
|
@@ -25,7 +25,7 @@ Gem::Specification.new do |spec|
|
|
|
25
25
|
|
|
26
26
|
spec.required_ruby_version = '>= 2.6.0'
|
|
27
27
|
|
|
28
|
-
spec.add_development_dependency 'bundler', '
|
|
28
|
+
spec.add_development_dependency 'bundler', '>= 2.2'
|
|
29
29
|
spec.add_development_dependency 'rake', '~> 13.0'
|
|
30
30
|
|
|
31
31
|
spec.add_development_dependency 'guard', '~> 2.11'
|
data/lib/howzit/buildnote.rb
CHANGED
|
@@ -169,11 +169,37 @@ module Howzit
|
|
|
169
169
|
output = []
|
|
170
170
|
output.push("{bg}Topics:{x}\n".c)
|
|
171
171
|
@topics.each do |topic|
|
|
172
|
-
output.push("- {bw}#{topic.title}{x}".c)
|
|
172
|
+
output.push("#{subtopic_indent(topic)}- {bw}#{topic.title}{x}".c)
|
|
173
173
|
end
|
|
174
174
|
output.join("\n")
|
|
175
175
|
end
|
|
176
176
|
|
|
177
|
+
##
|
|
178
|
+
## Indentation for a topic in list output, based on
|
|
179
|
+
## how many of its parent topics are in the list
|
|
180
|
+
##
|
|
181
|
+
## @param topic [Topic] The topic
|
|
182
|
+
##
|
|
183
|
+
## @return [String] leading whitespace
|
|
184
|
+
##
|
|
185
|
+
def subtopic_indent(topic)
|
|
186
|
+
' ' * topic.ancestors.count { |parent| @topics.any? { |t| t.equal?(parent) } }
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
##
|
|
190
|
+
## Topics that aren't nested under another topic in
|
|
191
|
+
## the build note (subtopics are output with their parent)
|
|
192
|
+
##
|
|
193
|
+
## @param topics [Array] Topics to filter, defaults to all topics
|
|
194
|
+
##
|
|
195
|
+
## @return [Array] array of Topics
|
|
196
|
+
##
|
|
197
|
+
def top_level_topics(topics = @topics)
|
|
198
|
+
topics.reject do |topic|
|
|
199
|
+
topic.ancestors.any? { |parent| topics.any? { |t| t.equal?(parent) } }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
|
|
177
203
|
##
|
|
178
204
|
## Return an array of topic titles
|
|
179
205
|
##
|
|
@@ -204,7 +230,7 @@ module Howzit
|
|
|
204
230
|
def list_runnable_completions
|
|
205
231
|
output = []
|
|
206
232
|
@topics.each do |topic|
|
|
207
|
-
next unless topic.
|
|
233
|
+
next unless topic.all_tasks.any?
|
|
208
234
|
|
|
209
235
|
output.push(topic.title)
|
|
210
236
|
end
|
|
@@ -222,11 +248,10 @@ module Howzit
|
|
|
222
248
|
output.push(%({bg}"Runnable" Topics:{x}\n).c)
|
|
223
249
|
|
|
224
250
|
find_topic(Howzit.options[:for_topic]).each do |topic|
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
topic.tasks.each { |task| s_out.push(task.to_list) }
|
|
251
|
+
next if topic.all_tasks.empty?
|
|
228
252
|
|
|
229
|
-
|
|
253
|
+
indent = subtopic_indent(topic)
|
|
254
|
+
s_out = topic.tasks.map { |task| "#{indent}#{task.to_list}" }
|
|
230
255
|
|
|
231
256
|
title = topic.title
|
|
232
257
|
# Show argument definitions with colorized formatting
|
|
@@ -235,8 +260,8 @@ module Howzit
|
|
|
235
260
|
title += " {l}({x}#{formatted_args}{l}){x}".c
|
|
236
261
|
end
|
|
237
262
|
|
|
238
|
-
output.push("- {g}#{title}{x}".c)
|
|
239
|
-
output.push(s_out.join("\n"))
|
|
263
|
+
output.push("#{indent}- {g}#{title}{x}".c)
|
|
264
|
+
output.push(s_out.join("\n")) unless s_out.empty?
|
|
240
265
|
end
|
|
241
266
|
|
|
242
267
|
output.join("\n")
|
|
@@ -956,11 +981,13 @@ module Howzit
|
|
|
956
981
|
|
|
957
982
|
template_topics = get_template_topics(help)
|
|
958
983
|
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
984
|
+
sections = help.split(/^(##+)/)
|
|
985
|
+
sections.shift
|
|
986
|
+
parent_stack = []
|
|
987
|
+
sections.each_slice(2) do |marks, sect|
|
|
988
|
+
next if sect.nil? || sect.strip.empty?
|
|
963
989
|
|
|
990
|
+
level = marks.length
|
|
964
991
|
lines = sect.split(/\n/)
|
|
965
992
|
title = lines.slice!(0).strip
|
|
966
993
|
prefix = ''
|
|
@@ -978,7 +1005,12 @@ module Howzit
|
|
|
978
1005
|
# Ensure source_file is always an absolute path
|
|
979
1006
|
source_file = path || note_file
|
|
980
1007
|
source_file = File.expand_path(source_file) if source_file
|
|
981
|
-
topic = Topic.new(title, prefix + lines.join("\n").strip.render_template(@metadata), @metadata,
|
|
1008
|
+
topic = Topic.new(title, prefix + lines.join("\n").strip.render_template(@metadata), @metadata,
|
|
1009
|
+
source_file: source_file, level: level)
|
|
1010
|
+
|
|
1011
|
+
parent_stack.pop while parent_stack.any? && parent_stack.last.level >= level
|
|
1012
|
+
parent_stack.last&.add_subtopic(topic)
|
|
1013
|
+
parent_stack.push(topic)
|
|
982
1014
|
|
|
983
1015
|
topics.push(topic)
|
|
984
1016
|
end
|
|
@@ -1373,13 +1405,13 @@ module Howzit
|
|
|
1373
1405
|
process_default_metadata(output)
|
|
1374
1406
|
else
|
|
1375
1407
|
Howzit.run_log = []
|
|
1376
|
-
Howzit.multi_topic_run =
|
|
1377
|
-
|
|
1408
|
+
Howzit.multi_topic_run = top_level_topics.length > 1
|
|
1409
|
+
top_level_topics.each { |k| output.push(process_topic(k, false, single: false)) }
|
|
1378
1410
|
finalize_output(output)
|
|
1379
1411
|
end
|
|
1380
1412
|
else
|
|
1381
1413
|
# Show all topics
|
|
1382
|
-
|
|
1414
|
+
top_level_topics.each { |k| output.push(process_topic(k, false, single: false)) }
|
|
1383
1415
|
finalize_output(output)
|
|
1384
1416
|
end
|
|
1385
1417
|
end
|
|
@@ -1525,9 +1557,14 @@ module Howzit
|
|
|
1525
1557
|
end
|
|
1526
1558
|
end
|
|
1527
1559
|
topic_matches.compact! # Remove any nil values from failed matches
|
|
1528
|
-
|
|
1560
|
+
# A matched subtopic is already output/run with its matched parent
|
|
1561
|
+
selected = top_level_topics(topic_matches)
|
|
1562
|
+
selected.each_with_index do |topic_match, idx|
|
|
1563
|
+
output.push(process_topic(topic_match, Howzit.options[:run], single: true))
|
|
1564
|
+
break if stop_after_failure?(selected[(idx + 1)..])
|
|
1565
|
+
end
|
|
1529
1566
|
else
|
|
1530
|
-
|
|
1567
|
+
top_level_topics.each { |k| output.push(process_topic(k, false, single: false)) }
|
|
1531
1568
|
end
|
|
1532
1569
|
|
|
1533
1570
|
finalize_output(output)
|
|
@@ -1564,7 +1601,7 @@ module Howzit
|
|
|
1564
1601
|
end
|
|
1565
1602
|
|
|
1566
1603
|
# Run each topic with its specific arguments
|
|
1567
|
-
topic_specs.
|
|
1604
|
+
topic_specs.each_with_index do |(topic_match, args), idx|
|
|
1568
1605
|
# Set arguments if provided, otherwise clear them
|
|
1569
1606
|
Howzit.arguments = if args && !args.empty?
|
|
1570
1607
|
args.split(/ *, */).map(&:render_arguments)
|
|
@@ -1572,10 +1609,29 @@ module Howzit
|
|
|
1572
1609
|
[]
|
|
1573
1610
|
end
|
|
1574
1611
|
output.push(process_topic(topic_match, Howzit.options[:run], single: true))
|
|
1612
|
+
break if stop_after_failure?(topic_specs[(idx + 1)..].map(&:first))
|
|
1575
1613
|
end
|
|
1576
1614
|
finalize_output(output)
|
|
1577
1615
|
end
|
|
1578
1616
|
|
|
1617
|
+
##
|
|
1618
|
+
## After running a topic in a multi-topic run, check whether a task
|
|
1619
|
+
## failed. If so (and --force isn't set), log the remaining topics'
|
|
1620
|
+
## tasks as skipped.
|
|
1621
|
+
##
|
|
1622
|
+
## @param remaining [Array] Topics that haven't run yet
|
|
1623
|
+
##
|
|
1624
|
+
## @return [Boolean] true if the run should stop
|
|
1625
|
+
##
|
|
1626
|
+
def stop_after_failure?(remaining)
|
|
1627
|
+
return false unless Howzit.options[:run]
|
|
1628
|
+
return false if Howzit.options[:force]
|
|
1629
|
+
return false unless Howzit.run_log.any? { |entry| !entry[:success] && !entry[:skipped] }
|
|
1630
|
+
|
|
1631
|
+
remaining.each { |topic| topic.log_skipped_tasks(topic.all_tasks) }
|
|
1632
|
+
true
|
|
1633
|
+
end
|
|
1634
|
+
|
|
1579
1635
|
##
|
|
1580
1636
|
## Parse default metadata value into individual topic specifications
|
|
1581
1637
|
##
|
data/lib/howzit/run_report.rb
CHANGED
|
@@ -26,8 +26,20 @@ module Howzit
|
|
|
26
26
|
output_lines.join("\n")
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
+
def status_symbol(entry)
|
|
30
|
+
return '⏭️' if entry[:skipped]
|
|
31
|
+
|
|
32
|
+
entry[:success] ? '✅' : '❌'
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def status_reason(entry)
|
|
36
|
+
return 'skipped' if entry[:skipped]
|
|
37
|
+
|
|
38
|
+
entry[:exit_status] ? "exit code #{entry[:exit_status]}" : 'failed'
|
|
39
|
+
end
|
|
40
|
+
|
|
29
41
|
def format_line(entry, prefix_topic)
|
|
30
|
-
symbol = entry
|
|
42
|
+
symbol = status_symbol(entry)
|
|
31
43
|
parts = ["#{symbol} "]
|
|
32
44
|
if prefix_topic && entry[:topic] && !entry[:topic].empty?
|
|
33
45
|
# Escape braces and dollar signs in topic name to prevent color code interpretation
|
|
@@ -36,10 +48,11 @@ module Howzit
|
|
|
36
48
|
end
|
|
37
49
|
# Escape braces and dollar signs in task name to prevent color code interpretation
|
|
38
50
|
task_escaped = entry[:task].gsub(/\{/, '\\{').gsub(/\}/, '\\}').gsub(/\$/, '\\$')
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
parts << "
|
|
51
|
+
if entry[:skipped]
|
|
52
|
+
parts << "{d}#{task_escaped} (skipped){x}"
|
|
53
|
+
else
|
|
54
|
+
parts << "{by}#{task_escaped} {x}"
|
|
55
|
+
parts << " {br}(#{status_reason(entry)}){x}" unless entry[:success]
|
|
43
56
|
end
|
|
44
57
|
parts.join.c
|
|
45
58
|
end
|
|
@@ -76,7 +89,7 @@ module Howzit
|
|
|
76
89
|
def format_row(entry, prefix_topic)
|
|
77
90
|
# Use plain emoji without color codes - the emoji itself provides visual meaning
|
|
78
91
|
# and complex ANSI codes interfere with mdless table rendering
|
|
79
|
-
symbol = entry
|
|
92
|
+
symbol = status_symbol(entry)
|
|
80
93
|
|
|
81
94
|
task_parts = []
|
|
82
95
|
task_parts_plain = []
|
|
@@ -94,7 +107,7 @@ module Howzit
|
|
|
94
107
|
task_parts_plain << entry[:task]
|
|
95
108
|
|
|
96
109
|
unless entry[:success]
|
|
97
|
-
reason = entry
|
|
110
|
+
reason = status_reason(entry)
|
|
98
111
|
task_parts << " {br}(#{reason}){x}"
|
|
99
112
|
task_parts_plain << " (#{reason})"
|
|
100
113
|
end
|
data/lib/howzit/task.rb
CHANGED
|
@@ -5,7 +5,8 @@ require 'English'
|
|
|
5
5
|
module Howzit
|
|
6
6
|
# Task object
|
|
7
7
|
class Task
|
|
8
|
-
attr_reader :type, :title, :action, :arguments, :parent, :optional, :default, :last_status, :log_level, :source_file
|
|
8
|
+
attr_reader :type, :title, :action, :arguments, :parent, :optional, :default, :last_status, :log_level, :source_file,
|
|
9
|
+
:include_results
|
|
9
10
|
|
|
10
11
|
##
|
|
11
12
|
## Initialize a Task object
|
|
@@ -141,7 +142,7 @@ module Howzit
|
|
|
141
142
|
##
|
|
142
143
|
## Execute an include task
|
|
143
144
|
##
|
|
144
|
-
## @return [Array] [[Array] output, [Integer] number of tasks executed]
|
|
145
|
+
## @return [Array] [[Array] output, [Integer] number of tasks executed, [Boolean] all tasks succeeded]
|
|
145
146
|
##
|
|
146
147
|
def run_include
|
|
147
148
|
output = []
|
|
@@ -150,11 +151,13 @@ module Howzit
|
|
|
150
151
|
matches = Howzit.buildnote.find_topic(action)
|
|
151
152
|
raise "Topic not found: #{action}" if matches.empty?
|
|
152
153
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
topic = matches[0]
|
|
155
|
+
Howzit.console.info("#{@prefix}{by}Running tasks from {bw}#{topic.title}{x}".c)
|
|
156
|
+
output.concat(topic.run(nested: true))
|
|
157
|
+
Howzit.console.info("{by}End include: #{topic.all_tasks.count} tasks{x}".c)
|
|
156
158
|
@last_status = nil
|
|
157
|
-
|
|
159
|
+
@include_results = topic.results.slice(:total, :success, :errors)
|
|
160
|
+
[output, @include_results[:total], @include_results[:errors].zero?]
|
|
158
161
|
end
|
|
159
162
|
|
|
160
163
|
##
|
|
@@ -287,7 +290,8 @@ module Howzit
|
|
|
287
290
|
else
|
|
288
291
|
case @type
|
|
289
292
|
when :include
|
|
290
|
-
output, tasks = run_include
|
|
293
|
+
output, tasks, include_success = run_include
|
|
294
|
+
include_success
|
|
291
295
|
when :run
|
|
292
296
|
run_run
|
|
293
297
|
when :copy
|
data/lib/howzit/topic.rb
CHANGED
|
@@ -5,9 +5,10 @@ module Howzit
|
|
|
5
5
|
class Topic
|
|
6
6
|
attr_writer :parent
|
|
7
7
|
|
|
8
|
-
attr_accessor :content
|
|
8
|
+
attr_accessor :content, :parent_topic
|
|
9
9
|
|
|
10
|
-
attr_reader :title, :tasks, :prereqs, :postreqs, :results, :named_args, :directives, :arg_definitions, :source_file
|
|
10
|
+
attr_reader :title, :tasks, :prereqs, :postreqs, :results, :named_args, :directives, :arg_definitions, :source_file,
|
|
11
|
+
:level, :subtopics
|
|
11
12
|
|
|
12
13
|
##
|
|
13
14
|
## Initialize a topic object
|
|
@@ -16,11 +17,15 @@ module Howzit
|
|
|
16
17
|
## @param content [String] The raw topic content
|
|
17
18
|
## @param metadata [Hash] Optional metadata hash
|
|
18
19
|
## @param source_file [String] Optional path to the build note file this topic came from
|
|
20
|
+
## @param level [Integer] Markdown header level (2 for ##, 3 for ###, etc.)
|
|
19
21
|
##
|
|
20
|
-
def initialize(title, content, metadata = nil, source_file: nil)
|
|
22
|
+
def initialize(title, content, metadata = nil, source_file: nil, level: 2)
|
|
21
23
|
@title = title
|
|
22
24
|
@content = content
|
|
23
25
|
@parent = nil
|
|
26
|
+
@parent_topic = nil
|
|
27
|
+
@subtopics = []
|
|
28
|
+
@level = level
|
|
24
29
|
@nest_level = 0
|
|
25
30
|
@named_args = {}
|
|
26
31
|
@metadata = metadata
|
|
@@ -32,6 +37,40 @@ module Howzit
|
|
|
32
37
|
@results = { total: 0, success: 0, errors: 0, message: ''.c }
|
|
33
38
|
end
|
|
34
39
|
|
|
40
|
+
##
|
|
41
|
+
## Nest a topic under this one (a deeper header following this topic)
|
|
42
|
+
##
|
|
43
|
+
## @param topic [Topic] The subtopic
|
|
44
|
+
##
|
|
45
|
+
def add_subtopic(topic)
|
|
46
|
+
topic.parent_topic = self
|
|
47
|
+
@subtopics << topic
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
##
|
|
51
|
+
## Tasks from this topic and all nested subtopics, in run order
|
|
52
|
+
##
|
|
53
|
+
## @return [Array] Array of Task objects
|
|
54
|
+
##
|
|
55
|
+
def all_tasks
|
|
56
|
+
@tasks + @subtopics.flat_map(&:all_tasks)
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
##
|
|
60
|
+
## Parent topics from nearest to outermost
|
|
61
|
+
##
|
|
62
|
+
## @return [Array] Array of Topic objects
|
|
63
|
+
##
|
|
64
|
+
def ancestors
|
|
65
|
+
list = []
|
|
66
|
+
node = @parent_topic
|
|
67
|
+
while node
|
|
68
|
+
list << node
|
|
69
|
+
node = node.parent_topic
|
|
70
|
+
end
|
|
71
|
+
list
|
|
72
|
+
end
|
|
73
|
+
|
|
35
74
|
# Get named arguments from title
|
|
36
75
|
# from_cli_snapshot: use Howzit.cli_topic_positional_args (argv after `--`) so earlier
|
|
37
76
|
# topics' gather_tasks cannot clobber positional binding. Re-entrant @include [a,b]
|
|
@@ -80,7 +119,7 @@ module Howzit
|
|
|
80
119
|
|
|
81
120
|
def ask_task(task)
|
|
82
121
|
note = if task.type == :include
|
|
83
|
-
task_count = Howzit.buildnote.find_topic(task.action)[0].
|
|
122
|
+
task_count = Howzit.buildnote.find_topic(task.action)[0].all_tasks.count
|
|
84
123
|
" (#{task_count} tasks)"
|
|
85
124
|
else
|
|
86
125
|
''
|
|
@@ -95,80 +134,172 @@ module Howzit
|
|
|
95
134
|
60
|
|
96
135
|
end
|
|
97
136
|
|
|
98
|
-
|
|
99
|
-
|
|
137
|
+
##
|
|
138
|
+
## Handle run command, execute directives in topic, then in each subtopic
|
|
139
|
+
##
|
|
140
|
+
## @param nested [Boolean] Suppress the summary message (topic is being included)
|
|
141
|
+
## @param as_subtopic [Boolean] Topic is being run as part of a parent topic
|
|
142
|
+
##
|
|
143
|
+
## @return [Array] output lines
|
|
144
|
+
##
|
|
145
|
+
def run(nested: false, as_subtopic: false)
|
|
146
|
+
@results = { total: 0, success: 0, errors: 0, message: ''.c }
|
|
100
147
|
output = []
|
|
101
|
-
|
|
102
148
|
cols = check_cols
|
|
103
149
|
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
150
|
+
confirm_prereqs(cols) if !@prereqs.empty? && (all_tasks.any? || sequential?)
|
|
151
|
+
|
|
152
|
+
if sequential?
|
|
153
|
+
run_sequential(output: output)
|
|
154
|
+
elsif @tasks.any?
|
|
155
|
+
run_tasks(output)
|
|
156
|
+
elsif all_tasks.empty? && !as_subtopic && !directives?
|
|
157
|
+
Howzit.console.warn "{r}--run: No {br}@directive{xr} found in {bw}#{@title}{x}".c
|
|
107
158
|
end
|
|
108
159
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
160
|
+
if halted?
|
|
161
|
+
log_skipped_tasks(@subtopics.flat_map(&:all_tasks))
|
|
162
|
+
else
|
|
163
|
+
run_subtopics(output)
|
|
164
|
+
end
|
|
112
165
|
|
|
113
|
-
if @
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
rescue Errno::EPIPE
|
|
118
|
-
# Pipe closed, ignore
|
|
119
|
-
end
|
|
120
|
-
res = Prompt.yn('Have the above prerequisites been met?', default: true)
|
|
121
|
-
Process.exit 1 unless res
|
|
166
|
+
if @results[:total].positive? || (all_tasks.any? && !sequential?)
|
|
167
|
+
@results[:message] += results_message
|
|
168
|
+
output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested && !Howzit.options[:run]
|
|
169
|
+
end
|
|
122
170
|
|
|
123
|
-
|
|
171
|
+
show_postreqs(cols)
|
|
124
172
|
|
|
125
|
-
|
|
126
|
-
|
|
173
|
+
output
|
|
174
|
+
end
|
|
127
175
|
|
|
128
|
-
|
|
176
|
+
##
|
|
177
|
+
## Whether the topic contains conditional directives and must be run sequentially
|
|
178
|
+
##
|
|
179
|
+
def sequential?
|
|
180
|
+
@directives&.any?(&:conditional?) || false
|
|
181
|
+
end
|
|
129
182
|
|
|
130
|
-
|
|
131
|
-
|
|
183
|
+
##
|
|
184
|
+
## Whether this topic or any subtopic contains directives,
|
|
185
|
+
## including non-task directives like @set_var and @log_level
|
|
186
|
+
##
|
|
187
|
+
def directives?
|
|
188
|
+
@directives&.any? || @subtopics.any?(&:directives?)
|
|
189
|
+
end
|
|
132
190
|
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
Howzit.console.warn %({bw}\u{2297} {br}Error running task {bw}"#{task.title}"{x}).c
|
|
191
|
+
def halted?
|
|
192
|
+
@results[:errors].positive? && !Howzit.options[:force]
|
|
193
|
+
end
|
|
137
194
|
|
|
138
|
-
|
|
195
|
+
def confirm_prereqs(cols)
|
|
196
|
+
begin
|
|
197
|
+
puts TTY::Box.frame("{by}#{@prereqs.join("\n\n").wrap(cols - 4)}{x}".c, width: cols)
|
|
198
|
+
rescue Errno::EPIPE
|
|
199
|
+
# Pipe closed, ignore
|
|
200
|
+
end
|
|
201
|
+
res = Prompt.yn('Have the above prerequisites been met?', default: true)
|
|
202
|
+
Process.exit 1 unless res
|
|
203
|
+
end
|
|
139
204
|
|
|
140
|
-
|
|
141
|
-
|
|
205
|
+
def show_postreqs(cols)
|
|
206
|
+
return if @postreqs.empty?
|
|
142
207
|
|
|
143
|
-
|
|
144
|
-
|
|
208
|
+
# Apply variable substitution to postreqs content, then wrap each line individually to preserve structure
|
|
209
|
+
postreqs_content = @postreqs.join("\n\n").render_arguments
|
|
210
|
+
wrapped_content = postreqs_content.split(/\n/).map { |line| line.wrap(cols - 4) }.join("\n")
|
|
211
|
+
puts TTY::Box.frame("{bw}#{wrapped_content}{x}".c, width: cols)
|
|
212
|
+
rescue Errno::EPIPE
|
|
213
|
+
# Pipe closed, ignore
|
|
214
|
+
end
|
|
145
215
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
"{br}\u{2715} {by}Ran #{total}, terminated due to error{x}".c
|
|
154
|
-
end
|
|
216
|
+
def results_message
|
|
217
|
+
total = "{bw}#{@results[:total]}{by} #{@results[:total] == 1 ? 'task' : 'tasks'}".c
|
|
218
|
+
errors = "{bw}#{@results[:errors]}{by} #{@results[:errors] == 1 ? 'error' : 'errors'}".c
|
|
219
|
+
if @results[:errors].zero?
|
|
220
|
+
"{bg}\u{2713} {by}Ran #{total}{x}".c
|
|
221
|
+
elsif Howzit.options[:force]
|
|
222
|
+
"{br}\u{2715} {by}Completed #{total} with #{errors}{x}".c
|
|
155
223
|
else
|
|
156
|
-
|
|
224
|
+
"{br}\u{2715} {by}Ran #{total}, terminated due to error{x}".c
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
##
|
|
229
|
+
## Run tasks without conditional evaluation
|
|
230
|
+
##
|
|
231
|
+
def run_tasks(output)
|
|
232
|
+
@tasks.each_with_index do |task, idx|
|
|
233
|
+
next if (task.optional || Howzit.options[:ask]) && !ask_task(task)
|
|
234
|
+
|
|
235
|
+
run_output, total, success = task.run
|
|
236
|
+
|
|
237
|
+
output.concat(run_output)
|
|
238
|
+
record_task_result(task, total, success)
|
|
239
|
+
next unless halted?
|
|
240
|
+
|
|
241
|
+
log_skipped_tasks(@tasks[(idx + 1)..])
|
|
242
|
+
break
|
|
243
|
+
end
|
|
244
|
+
output
|
|
245
|
+
end
|
|
246
|
+
|
|
247
|
+
##
|
|
248
|
+
## Add a task's results to the topic totals and the run report
|
|
249
|
+
##
|
|
250
|
+
## @param task [Task] The task that ran
|
|
251
|
+
## @param total [Integer] Number of tasks it represents
|
|
252
|
+
## @param success [Boolean] Whether it succeeded
|
|
253
|
+
##
|
|
254
|
+
def record_task_result(task, total, success)
|
|
255
|
+
if task.type == :include && task.include_results
|
|
256
|
+
%i[total success errors].each { |key| @results[key] += task.include_results[key] }
|
|
257
|
+
else
|
|
258
|
+
@results[:total] += total
|
|
259
|
+
@results[success ? :success : :errors] += total
|
|
157
260
|
end
|
|
158
261
|
|
|
159
|
-
|
|
262
|
+
log_task_result(task, success)
|
|
263
|
+
Howzit.console.warn %({bw}\u{2297} {br}Error running task {bw}"#{task.title}"{x}).c unless success
|
|
264
|
+
end
|
|
265
|
+
|
|
266
|
+
##
|
|
267
|
+
## Log tasks that won't run because an earlier task failed.
|
|
268
|
+
## Include tasks are expanded to the tasks of the included topic.
|
|
269
|
+
##
|
|
270
|
+
## @param tasks [Array] Task objects
|
|
271
|
+
## @param seen [Array] Titles of included topics already expanded
|
|
272
|
+
##
|
|
273
|
+
def log_skipped_tasks(tasks, seen = [])
|
|
274
|
+
return unless Howzit.options[:run]
|
|
160
275
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
276
|
+
Howzit.run_log ||= []
|
|
277
|
+
tasks.each do |task|
|
|
278
|
+
if task.type == :include
|
|
279
|
+
topic = Howzit.buildnote.find_topic(task.action)[0]
|
|
280
|
+
next if topic.nil? || seen.include?(topic.title)
|
|
281
|
+
|
|
282
|
+
topic.log_skipped_tasks(topic.all_tasks, seen + [topic.title])
|
|
283
|
+
next
|
|
169
284
|
end
|
|
285
|
+
|
|
286
|
+
topic_title = task.parent.is_a?(Topic) ? task.parent.title : @title
|
|
287
|
+
Howzit.run_log << { topic: topic_title, task: task_log_title(task), success: false, skipped: true }
|
|
170
288
|
end
|
|
289
|
+
end
|
|
171
290
|
|
|
291
|
+
##
|
|
292
|
+
## Run each subtopic in order, adding its results to this topic's
|
|
293
|
+
##
|
|
294
|
+
def run_subtopics(output)
|
|
295
|
+
@subtopics.each_with_index do |sub, idx|
|
|
296
|
+
output.concat(sub.run(nested: true, as_subtopic: true))
|
|
297
|
+
%i[total success errors].each { |key| @results[key] += sub.results[key] }
|
|
298
|
+
next unless halted?
|
|
299
|
+
|
|
300
|
+
log_skipped_tasks(@subtopics[(idx + 1)..].flat_map(&:all_tasks))
|
|
301
|
+
break
|
|
302
|
+
end
|
|
172
303
|
output
|
|
173
304
|
end
|
|
174
305
|
|
|
@@ -178,7 +309,7 @@ module Howzit
|
|
|
178
309
|
end
|
|
179
310
|
|
|
180
311
|
def colored_option(color, topic, keys)
|
|
181
|
-
if topic.
|
|
312
|
+
if topic.all_tasks.empty?
|
|
182
313
|
''
|
|
183
314
|
else
|
|
184
315
|
optional = keys[:optional] =~ /[?!]+/ ? true : false
|
|
@@ -302,7 +433,8 @@ module Howzit
|
|
|
302
433
|
formatted_args = @arg_definitions.map { |arg| format_arg_definition(arg) }.join('{l}, '.c)
|
|
303
434
|
header_title += " {l}({x}#{formatted_args}{l}){x}".c
|
|
304
435
|
end
|
|
305
|
-
|
|
436
|
+
header_opts = opt[:subtopic] ? { color: '{bc}', hr: "\u{2508}" } : {}
|
|
437
|
+
output.push(header_title.format_header(header_opts))
|
|
306
438
|
output.push('')
|
|
307
439
|
end
|
|
308
440
|
# Process conditional blocks first
|
|
@@ -333,6 +465,8 @@ module Howzit
|
|
|
333
465
|
end
|
|
334
466
|
Howzit.named_arguments = @named_args
|
|
335
467
|
output.push('')
|
|
468
|
+
@subtopics.each { |sub| output.concat(sub.print_out(opt.merge(header: true, subtopic: true))) }
|
|
469
|
+
output
|
|
336
470
|
end
|
|
337
471
|
|
|
338
472
|
##
|
|
@@ -447,22 +581,20 @@ module Howzit
|
|
|
447
581
|
return if task.type == :include
|
|
448
582
|
|
|
449
583
|
Howzit.run_log ||= []
|
|
450
|
-
|
|
451
|
-
title = (task.title || '').strip
|
|
452
|
-
if title.empty?
|
|
453
|
-
action = (task.action || '').strip
|
|
454
|
-
title = action.split(/\n/).first.to_s.strip
|
|
455
|
-
end
|
|
456
|
-
title = task.type.to_s.capitalize if title.nil? || title.empty?
|
|
457
|
-
|
|
458
584
|
Howzit.run_log << {
|
|
459
585
|
topic: @title,
|
|
460
|
-
task:
|
|
586
|
+
task: task_log_title(task),
|
|
461
587
|
success: success ? true : false,
|
|
462
588
|
exit_status: task.last_status
|
|
463
589
|
}
|
|
464
590
|
end
|
|
465
591
|
|
|
592
|
+
def task_log_title(task)
|
|
593
|
+
title = (task.title || '').strip
|
|
594
|
+
title = (task.action || '').strip.split(/\n/).first.to_s.strip if title.empty?
|
|
595
|
+
title.empty? ? task.type.to_s.capitalize : title
|
|
596
|
+
end
|
|
597
|
+
|
|
466
598
|
def gather_tasks
|
|
467
599
|
runnable = []
|
|
468
600
|
# Process conditional blocks first
|
|
@@ -727,7 +859,7 @@ module Howzit
|
|
|
727
859
|
##
|
|
728
860
|
## Run directives sequentially with conditional re-evaluation
|
|
729
861
|
##
|
|
730
|
-
def run_sequential(
|
|
862
|
+
def run_sequential(output: [])
|
|
731
863
|
# Initialize conditional state
|
|
732
864
|
conditional_state = {} # { index => { evaluated: bool, result: bool, matched_chain: bool } }
|
|
733
865
|
directive_index = 0
|
|
@@ -737,16 +869,6 @@ module Howzit
|
|
|
737
869
|
Howzit.named_arguments ||= {}
|
|
738
870
|
Howzit.named_arguments.merge!(@named_args) if @named_args
|
|
739
871
|
|
|
740
|
-
unless @prereqs.empty?
|
|
741
|
-
begin
|
|
742
|
-
puts TTY::Box.frame("{by}#{@prereqs.join("\n\n").wrap(cols - 4)}{x}".c, width: cols)
|
|
743
|
-
rescue Errno::EPIPE
|
|
744
|
-
# Pipe closed, ignore
|
|
745
|
-
end
|
|
746
|
-
res = Prompt.yn('Have the above prerequisites been met?', default: true)
|
|
747
|
-
Process.exit 1 unless res
|
|
748
|
-
end
|
|
749
|
-
|
|
750
872
|
# Process directives sequentially
|
|
751
873
|
while directive_index < @directives.length
|
|
752
874
|
directive = @directives[directive_index]
|
|
@@ -887,48 +1009,18 @@ module Howzit
|
|
|
887
1009
|
run_output, total, success = task.run
|
|
888
1010
|
|
|
889
1011
|
output.concat(run_output)
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
@
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
@results[:errors] += total
|
|
898
|
-
|
|
899
|
-
break unless Howzit.options[:force]
|
|
1012
|
+
record_task_result(task, total, success)
|
|
1013
|
+
if halted?
|
|
1014
|
+
# Tasks inside conditionals are omitted, since their conditions were never evaluated
|
|
1015
|
+
remaining = @directives[directive_index..].select { |d| d.task? && (d.conditional_path || []).empty? }
|
|
1016
|
+
log_skipped_tasks(remaining.filter_map { |d| d.to_task(self) })
|
|
1017
|
+
break
|
|
900
1018
|
end
|
|
901
1019
|
|
|
902
|
-
log_task_result(task, success)
|
|
903
|
-
|
|
904
1020
|
# Re-evaluate all open conditionals after task execution
|
|
905
1021
|
re_evaluate_conditionals(conditional_state, directive_index - 1, context)
|
|
906
1022
|
end
|
|
907
1023
|
|
|
908
|
-
if @results[:total].positive?
|
|
909
|
-
total = "{bw}#{@results[:total]}{by} #{@results[:total] == 1 ? 'task' : 'tasks'}".c
|
|
910
|
-
errors = "{bw}#{@results[:errors]}{by} #{@results[:errors] == 1 ? 'error' : 'errors'}".c
|
|
911
|
-
@results[:message] += if @results[:errors].zero?
|
|
912
|
-
"{bg}\u{2713} {by}Ran #{total}{x}".c
|
|
913
|
-
elsif Howzit.options[:force]
|
|
914
|
-
"{br}\u{2715} {by}Completed #{total} with #{errors}{x}".c
|
|
915
|
-
else
|
|
916
|
-
"{br}\u{2715} {by}Ran #{total}, terminated due to error{x}".c
|
|
917
|
-
end
|
|
918
|
-
output.push(@results[:message]) if Howzit.options[:log_level] < 2 && !nested && !Howzit.options[:run]
|
|
919
|
-
end
|
|
920
|
-
|
|
921
|
-
unless @postreqs.empty?
|
|
922
|
-
begin
|
|
923
|
-
# Apply variable substitution to postreqs content, then wrap each line individually to preserve structure
|
|
924
|
-
postreqs_content = @postreqs.join("\n\n").render_arguments
|
|
925
|
-
wrapped_content = postreqs_content.split(/\n/).map { |line| line.wrap(cols - 4) }.join("\n")
|
|
926
|
-
puts TTY::Box.frame("{bw}#{wrapped_content}{x}".c, width: cols)
|
|
927
|
-
rescue Errno::EPIPE
|
|
928
|
-
# Pipe closed, ignore
|
|
929
|
-
end
|
|
930
|
-
end
|
|
931
|
-
|
|
932
1024
|
output
|
|
933
1025
|
end
|
|
934
1026
|
|
data/lib/howzit/version.rb
CHANGED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Nested topics for Howzit
|
|
3
|
+
layout: post
|
|
4
|
+
tags: [howzit, ruby, markdown, cli, automation]
|
|
5
|
+
categories: [Blog,Code]
|
|
6
|
+
post_class: 'code'
|
|
7
|
+
comments: true
|
|
8
|
+
---
|
|
9
|
+
[Howzit](https://github.com/ttscoff/howzit) has always split build notes into topics using Markdown headers. What it didn't do was care *which* header level you used. An `##` and a `###` were both just "a topic," and they all got flattened into one long list. As of version 2.1.44, that's changed: deeper headers now nest under the header above them, and that nesting affects both how notes display and what happens when you run them.
|
|
10
|
+
|
|
11
|
+
### The short version
|
|
12
|
+
|
|
13
|
+
A `###` following a `##` is now a subtopic of that `##`. A `####` under the `###` is a subtopic of the `###`, and so on down. The next header at the same level (or shallower) closes out the parent.
|
|
14
|
+
|
|
15
|
+
```markdown
|
|
16
|
+
## Deploy
|
|
17
|
+
|
|
18
|
+
@run(./scripts/preflight.sh) Preflight checks
|
|
19
|
+
|
|
20
|
+
### Build assets
|
|
21
|
+
|
|
22
|
+
@run(gulp) Build
|
|
23
|
+
|
|
24
|
+
### Upload
|
|
25
|
+
|
|
26
|
+
@run(gulp sync) Sync to server
|
|
27
|
+
|
|
28
|
+
## Package management
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Here "Build assets" and "Upload" belong to "Deploy." "Package management" is back at the top level.
|
|
32
|
+
|
|
33
|
+
### Viewing a parent topic
|
|
34
|
+
|
|
35
|
+
When you pull up a parent topic, you now get the whole thing: the parent, followed by all of its subtopics.
|
|
36
|
+
|
|
37
|
+
```console
|
|
38
|
+
$ howzit deploy
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Subtopic headers get a slightly lighter treatment (a dotted rule and a cyan title) so you can see where the nesting happens without it getting noisy. You can still ask for a subtopic directly:
|
|
42
|
+
|
|
43
|
+
```console
|
|
44
|
+
$ howzit upload
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
...and you'll get just that section.
|
|
48
|
+
|
|
49
|
+
### Running a parent topic
|
|
50
|
+
|
|
51
|
+
This is the part I actually wanted. Running a parent now runs everything under it:
|
|
52
|
+
|
|
53
|
+
```console
|
|
54
|
+
$ howzit -r deploy
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
That runs Deploy's own tasks first, then each subtopic's tasks in order, depth-first. So the example above runs preflight, then the build, then the sync. You get one combined summary at the end instead of a "Ran 1 task" line for every section.
|
|
58
|
+
|
|
59
|
+
A few details worth knowing:
|
|
60
|
+
|
|
61
|
+
- **Errors stop the run.** If a task fails, the remaining subtopics are skipped. Add `--force` if you want it to push through anyway.
|
|
62
|
+
- **`@before` and `@after` still work per topic.** Each topic shows its prerequisites before its own tasks run. A parent's `@after` block shows up once the parent *and* all of its subtopics are done, which is usually where you want that "don't forget to..." reminder.
|
|
63
|
+
- **Parents don't need tasks of their own.** A parent that's just an umbrella for a few subtopics runs fine, and it won't complain about "No @directive found."
|
|
64
|
+
- **Subtopics still run on their own.** `howzit -r upload` runs just the upload step.
|
|
65
|
+
|
|
66
|
+
### Includes get the whole tree too
|
|
67
|
+
|
|
68
|
+
If another topic does `@include(Deploy)`, it now runs Deploy and all of its subtopics, same as running it directly. Task counts in include notes and menus reflect the full tree, so you're not surprised by how much is about to happen.
|
|
69
|
+
|
|
70
|
+
### Listing and "show everything"
|
|
71
|
+
|
|
72
|
+
Running `howzit` with no arguments shows every topic. It now only prints top-level topics, since subtopics come along with their parents. No more seeing the same section twice.
|
|
73
|
+
|
|
74
|
+
`howzit -l` indents subtopics under their parents, so the list reflects the structure of your note:
|
|
75
|
+
|
|
76
|
+
```console
|
|
77
|
+
$ howzit -l
|
|
78
|
+
Topics:
|
|
79
|
+
|
|
80
|
+
- Deploy
|
|
81
|
+
- Build assets
|
|
82
|
+
- Upload
|
|
83
|
+
- Package management
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Shell completions stay flat, so you can still tab-complete straight to a subtopic.
|
|
87
|
+
|
|
88
|
+
### One thing to check in your notes
|
|
89
|
+
|
|
90
|
+
If you've been using `###` headers under `##` purely as visual formatting, those are now real subtopics. Viewing the parent will include them (probably what you wanted anyway), and running the parent will run their tasks too. If you had a `###` section with tasks you *don't* want run as part of its parent, bump it up to `##`.
|
|
91
|
+
|
|
92
|
+
### Grab it
|
|
93
|
+
|
|
94
|
+
It's in 2.1.44:
|
|
95
|
+
|
|
96
|
+
```console
|
|
97
|
+
$ gem install howzit
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
The details are in the [build notes anatomy page](https://github.com/ttscoff/howzit/wiki/Anatomy-of-a-build-notes-file#subtopics) on the wiki. I've already started restructuring a few of my own notes around this. Having a single "Release" topic that fans out into build, test, and publish steps is a lot nicer than keeping them all at the same level and chaining them together with `@include`.
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
describe 'run report accuracy' do
|
|
8
|
+
let(:temp_dir) { File.expand_path(Dir.mktmpdir('howzit_run_report_test')) }
|
|
9
|
+
let(:buildnote) { Howzit::BuildNote.new }
|
|
10
|
+
|
|
11
|
+
before do
|
|
12
|
+
File.write(
|
|
13
|
+
File.join(temp_dir, 'buildnotes.md'),
|
|
14
|
+
<<~NOTE
|
|
15
|
+
# Run Report Project
|
|
16
|
+
|
|
17
|
+
## Failing
|
|
18
|
+
|
|
19
|
+
@run(true) First
|
|
20
|
+
@run(false) Second
|
|
21
|
+
@run(true) Third
|
|
22
|
+
|
|
23
|
+
## Release
|
|
24
|
+
|
|
25
|
+
@include(Failing)
|
|
26
|
+
@run(true) After include
|
|
27
|
+
|
|
28
|
+
## Early Failure
|
|
29
|
+
|
|
30
|
+
@run(false) Fails first
|
|
31
|
+
@include(Failing)
|
|
32
|
+
|
|
33
|
+
## Conditional
|
|
34
|
+
|
|
35
|
+
@if 1 == 1
|
|
36
|
+
@run(false) Conditional failure
|
|
37
|
+
@run(true) Conditional after
|
|
38
|
+
@end
|
|
39
|
+
@run(true) Unconditional after
|
|
40
|
+
|
|
41
|
+
## Parent
|
|
42
|
+
|
|
43
|
+
@run(true) Parent task
|
|
44
|
+
|
|
45
|
+
### Broken Child
|
|
46
|
+
|
|
47
|
+
@run(false) Child failure
|
|
48
|
+
@run(true) Child after
|
|
49
|
+
|
|
50
|
+
### Later Child
|
|
51
|
+
|
|
52
|
+
@run(true) Later child task
|
|
53
|
+
NOTE
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
Dir.chdir(temp_dir)
|
|
57
|
+
Howzit.instance_variable_set(:@buildnote, buildnote)
|
|
58
|
+
Howzit.options[:stack] = false
|
|
59
|
+
Howzit.options[:include_upstream] = false
|
|
60
|
+
Howzit.options[:run] = true
|
|
61
|
+
Howzit.options[:force] = false
|
|
62
|
+
Howzit.run_log = []
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
after do
|
|
66
|
+
Howzit.options[:run] = false
|
|
67
|
+
Howzit.options[:force] = false
|
|
68
|
+
Howzit.run_log = []
|
|
69
|
+
Dir.chdir(Dir.tmpdir)
|
|
70
|
+
FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
|
|
71
|
+
Howzit.instance_variable_set(:@buildnote, nil)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def report
|
|
75
|
+
Howzit.run_log.map do |entry|
|
|
76
|
+
status = if entry[:skipped]
|
|
77
|
+
:skipped
|
|
78
|
+
else
|
|
79
|
+
entry[:success] ? :ok : :failed
|
|
80
|
+
end
|
|
81
|
+
[entry[:task], status]
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
it 'logs the failed task and marks the rest as skipped' do
|
|
86
|
+
buildnote.find_topic('Failing')[0].run
|
|
87
|
+
|
|
88
|
+
expect(report).to eq([['First', :ok], ['Second', :failed], ['Third', :skipped]])
|
|
89
|
+
expect(Howzit.run_log[1][:exit_status]).to eq(1)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
it 'treats a failed include as a failure and skips the rest of the including topic' do
|
|
93
|
+
topic = buildnote.find_topic('Release')[0]
|
|
94
|
+
topic.run
|
|
95
|
+
|
|
96
|
+
expect(report).to eq([['First', :ok], ['Second', :failed], ['Third', :skipped], ['After include', :skipped]])
|
|
97
|
+
expect(topic.results).to include(total: 2, success: 1, errors: 1)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
it 'expands skipped includes into the included topic tasks' do
|
|
101
|
+
buildnote.find_topic('Early Failure')[0].run
|
|
102
|
+
|
|
103
|
+
expect(report).to eq([['Fails first', :failed], ['First', :skipped], ['Second', :skipped], ['Third', :skipped]])
|
|
104
|
+
expect(Howzit.run_log[1][:topic]).to eq('Failing')
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
it 'skips only unconditional tasks after a failure in a conditional topic' do
|
|
108
|
+
buildnote.find_topic('Conditional')[0].run
|
|
109
|
+
|
|
110
|
+
expect(report).to eq([['Conditional failure', :failed], ['Unconditional after', :skipped]])
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
it 'skips remaining subtopics after a subtopic fails' do
|
|
114
|
+
buildnote.find_topic('Parent')[0].run
|
|
115
|
+
|
|
116
|
+
expect(report).to eq([
|
|
117
|
+
['Parent task', :ok],
|
|
118
|
+
['Child failure', :failed],
|
|
119
|
+
['Child after', :skipped],
|
|
120
|
+
['Later child task', :skipped]
|
|
121
|
+
])
|
|
122
|
+
expect(Howzit.run_log.last[:topic]).to eq('Later Child')
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
it 'continues past failures with --force and logs every result' do
|
|
126
|
+
Howzit.options[:force] = true
|
|
127
|
+
topic = buildnote.find_topic('Release')[0]
|
|
128
|
+
topic.run
|
|
129
|
+
|
|
130
|
+
expect(report).to eq([['First', :ok], ['Second', :failed], ['Third', :ok], ['After include', :ok]])
|
|
131
|
+
expect(topic.results).to include(total: 4, success: 3, errors: 1)
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
describe 'multi-topic runs' do
|
|
135
|
+
before do
|
|
136
|
+
allow(buildnote).to receive(:finalize_output)
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
let(:topics) { ['Failing', 'Parent'].map { |title| buildnote.find_topic(title)[0] } }
|
|
140
|
+
|
|
141
|
+
it 'stops after a topic fails and skips the remaining topics' do
|
|
142
|
+
buildnote.send(:process_topic_matches, topics, [])
|
|
143
|
+
|
|
144
|
+
expect(report).to eq([
|
|
145
|
+
['First', :ok],
|
|
146
|
+
['Second', :failed],
|
|
147
|
+
['Third', :skipped],
|
|
148
|
+
['Parent task', :skipped],
|
|
149
|
+
['Child failure', :skipped],
|
|
150
|
+
['Child after', :skipped],
|
|
151
|
+
['Later child task', :skipped]
|
|
152
|
+
])
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
it 'runs every topic with --force' do
|
|
156
|
+
Howzit.options[:force] = true
|
|
157
|
+
buildnote.send(:process_topic_matches, topics, [])
|
|
158
|
+
|
|
159
|
+
expect(report.map(&:first)).to include('Parent task', 'Later child task')
|
|
160
|
+
expect(report.map(&:last)).not_to include(:skipped)
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
it 'formats skipped tasks in the report' do
|
|
165
|
+
buildnote.find_topic('Failing')[0].run
|
|
166
|
+
plain = Howzit::RunReport.format.uncolor
|
|
167
|
+
|
|
168
|
+
expect(plain).to include('❌ Second (exit code 1)')
|
|
169
|
+
expect(plain).to include('⏭️ Third (skipped)')
|
|
170
|
+
end
|
|
171
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'spec_helper'
|
|
4
|
+
require 'fileutils'
|
|
5
|
+
require 'tmpdir'
|
|
6
|
+
|
|
7
|
+
describe 'subtopics' do
|
|
8
|
+
let(:temp_dir) { File.expand_path(Dir.mktmpdir('howzit_subtopics_test')) }
|
|
9
|
+
let(:buildnote) { Howzit::BuildNote.new }
|
|
10
|
+
let(:parent) { buildnote.find_topic('Parent')[0] }
|
|
11
|
+
|
|
12
|
+
before do
|
|
13
|
+
File.write(
|
|
14
|
+
File.join(temp_dir, 'buildnotes.md'),
|
|
15
|
+
<<~NOTE
|
|
16
|
+
# Subtopic Project
|
|
17
|
+
|
|
18
|
+
## Parent
|
|
19
|
+
|
|
20
|
+
Parent content
|
|
21
|
+
|
|
22
|
+
@run(echo parent) Parent task
|
|
23
|
+
|
|
24
|
+
### Child One
|
|
25
|
+
|
|
26
|
+
@run(echo child1) Child one task
|
|
27
|
+
|
|
28
|
+
#### Grandchild
|
|
29
|
+
|
|
30
|
+
@run(echo grand) Grandchild task
|
|
31
|
+
|
|
32
|
+
### Child Two
|
|
33
|
+
|
|
34
|
+
@run(echo child2) Child two task
|
|
35
|
+
|
|
36
|
+
## Sibling
|
|
37
|
+
|
|
38
|
+
@run(echo sibling) Sibling task
|
|
39
|
+
NOTE
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
Dir.chdir(temp_dir)
|
|
43
|
+
Howzit.instance_variable_set(:@buildnote, nil)
|
|
44
|
+
Howzit.options[:stack] = false
|
|
45
|
+
Howzit.options[:include_upstream] = false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
after do
|
|
49
|
+
Dir.chdir(Dir.tmpdir)
|
|
50
|
+
FileUtils.rm_rf(temp_dir) if Dir.exist?(temp_dir)
|
|
51
|
+
Howzit.instance_variable_set(:@buildnote, nil)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def topic_named(title)
|
|
55
|
+
buildnote.topics.find { |t| t.title == title }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
it 'nests deeper headers under the preceding shallower header' do
|
|
59
|
+
expect(parent.subtopics.map(&:title)).to eq(['Child One', 'Child Two'])
|
|
60
|
+
expect(topic_named('Child One').subtopics.map(&:title)).to eq(['Grandchild'])
|
|
61
|
+
expect(topic_named('Grandchild').ancestors.map(&:title)).to eq(['Child One', 'Parent'])
|
|
62
|
+
expect(topic_named('Sibling').parent_topic).to be_nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
it 'keeps subtopics selectable on their own' do
|
|
66
|
+
expect(buildnote.find_topic('Child Two')[0].title).to eq('Child Two')
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
it 'collects tasks from subtopics in order' do
|
|
70
|
+
expect(parent.all_tasks.map(&:title)).to eq(['Parent task', 'Child one task', 'Grandchild task', 'Child two task'])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
it 'displays subtopics with the parent topic' do
|
|
74
|
+
output = parent.print_out.join("\n").uncolor
|
|
75
|
+
|
|
76
|
+
expect(output).to include('Parent content')
|
|
77
|
+
expect(output).to include('Child One')
|
|
78
|
+
expect(output).to include('Grandchild')
|
|
79
|
+
expect(output).to include('echo child2')
|
|
80
|
+
expect(output).not_to include('Sibling')
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
it 'only outputs top-level topics when showing all topics' do
|
|
84
|
+
expect(buildnote.top_level_topics.map(&:title)).to eq(%w[Parent Sibling])
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
it 'indents subtopics in the topic list' do
|
|
88
|
+
list = buildnote.list.uncolor
|
|
89
|
+
|
|
90
|
+
expect(list).to include("\n- Parent")
|
|
91
|
+
expect(list).to include("\n - Child One")
|
|
92
|
+
expect(list).to include("\n - Grandchild")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
describe 'running a parent topic' do
|
|
96
|
+
let(:ran) { [] }
|
|
97
|
+
|
|
98
|
+
it 'runs its own tasks followed by all subtopic tasks' do
|
|
99
|
+
ran_titles = ran
|
|
100
|
+
allow_any_instance_of(Howzit::Task).to receive(:run) do |task|
|
|
101
|
+
ran_titles << task.title
|
|
102
|
+
[[], 1, true]
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
parent.run
|
|
106
|
+
|
|
107
|
+
expect(ran).to eq(['Parent task', 'Child one task', 'Grandchild task', 'Child two task'])
|
|
108
|
+
expect(parent.results[:total]).to eq(4)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
it 'stops running subtopics after an error' do
|
|
112
|
+
ran_titles = ran
|
|
113
|
+
allow_any_instance_of(Howzit::Task).to receive(:run) do |task|
|
|
114
|
+
ran_titles << task.title
|
|
115
|
+
[[], 1, task.title != 'Child one task']
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
parent.run
|
|
119
|
+
|
|
120
|
+
expect(ran).to eq(['Parent task', 'Child one task'])
|
|
121
|
+
expect(parent.results[:errors]).to eq(1)
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
it 'continues past errors with --force' do
|
|
125
|
+
Howzit.options[:force] = true
|
|
126
|
+
ran_titles = ran
|
|
127
|
+
allow_any_instance_of(Howzit::Task).to receive(:run) do |task|
|
|
128
|
+
ran_titles << task.title
|
|
129
|
+
[[], 1, task.title != 'Child one task']
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
parent.run
|
|
133
|
+
|
|
134
|
+
expect(ran).to eq(['Parent task', 'Child one task', 'Grandchild task', 'Child two task'])
|
|
135
|
+
ensure
|
|
136
|
+
Howzit.options[:force] = false
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
metadata
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: howzit
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 2.1.
|
|
4
|
+
version: 2.1.45
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Brett Terpstra
|
|
@@ -13,14 +13,14 @@ dependencies:
|
|
|
13
13
|
name: bundler
|
|
14
14
|
requirement: !ruby/object:Gem::Requirement
|
|
15
15
|
requirements:
|
|
16
|
-
- - "
|
|
16
|
+
- - ">="
|
|
17
17
|
- !ruby/object:Gem::Version
|
|
18
18
|
version: '2.2'
|
|
19
19
|
type: :development
|
|
20
20
|
prerelease: false
|
|
21
21
|
version_requirements: !ruby/object:Gem::Requirement
|
|
22
22
|
requirements:
|
|
23
|
-
- - "
|
|
23
|
+
- - ">="
|
|
24
24
|
- !ruby/object:Gem::Version
|
|
25
25
|
version: '2.2'
|
|
26
26
|
- !ruby/object:Gem::Dependency
|
|
@@ -314,6 +314,7 @@ files:
|
|
|
314
314
|
- lib/howzit/topic.rb
|
|
315
315
|
- lib/howzit/util.rb
|
|
316
316
|
- lib/howzit/version.rb
|
|
317
|
+
- nested-topics-for-howzit.md
|
|
317
318
|
- scripts/runtests.sh
|
|
318
319
|
- spec/buildnote_spec.rb
|
|
319
320
|
- spec/cli_spec.rb
|
|
@@ -324,6 +325,7 @@ files:
|
|
|
324
325
|
- spec/log_level_spec.rb
|
|
325
326
|
- spec/prompt_spec.rb
|
|
326
327
|
- spec/ruby_gem_spec.rb
|
|
328
|
+
- spec/run_report_accuracy_spec.rb
|
|
327
329
|
- spec/run_report_spec.rb
|
|
328
330
|
- spec/script_comm_spec.rb
|
|
329
331
|
- spec/sequential_conditional_spec.rb
|
|
@@ -331,6 +333,7 @@ files:
|
|
|
331
333
|
- spec/spec_helper.rb
|
|
332
334
|
- spec/stack_mode_spec.rb
|
|
333
335
|
- spec/stringutils_spec.rb
|
|
336
|
+
- spec/subtopics_spec.rb
|
|
334
337
|
- spec/task_spec.rb
|
|
335
338
|
- spec/topic_positional_snapshot_spec.rb
|
|
336
339
|
- spec/topic_spec.rb
|
|
@@ -356,7 +359,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
356
359
|
- !ruby/object:Gem::Version
|
|
357
360
|
version: '0'
|
|
358
361
|
requirements: []
|
|
359
|
-
rubygems_version:
|
|
362
|
+
rubygems_version: 4.0.18
|
|
360
363
|
specification_version: 4
|
|
361
364
|
summary: Provides a way to access Markdown project notes by topic with query capabilities
|
|
362
365
|
and the ability to execute the tasks it describes.
|
|
@@ -370,6 +373,7 @@ test_files:
|
|
|
370
373
|
- spec/log_level_spec.rb
|
|
371
374
|
- spec/prompt_spec.rb
|
|
372
375
|
- spec/ruby_gem_spec.rb
|
|
376
|
+
- spec/run_report_accuracy_spec.rb
|
|
373
377
|
- spec/run_report_spec.rb
|
|
374
378
|
- spec/script_comm_spec.rb
|
|
375
379
|
- spec/sequential_conditional_spec.rb
|
|
@@ -377,6 +381,7 @@ test_files:
|
|
|
377
381
|
- spec/spec_helper.rb
|
|
378
382
|
- spec/stack_mode_spec.rb
|
|
379
383
|
- spec/stringutils_spec.rb
|
|
384
|
+
- spec/subtopics_spec.rb
|
|
380
385
|
- spec/task_spec.rb
|
|
381
386
|
- spec/topic_positional_snapshot_spec.rb
|
|
382
387
|
- spec/topic_spec.rb
|