jirametrics 1.0.0

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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/bin/jirametrics +4 -0
  3. data/lib/jirametrics/aggregate_config.rb +89 -0
  4. data/lib/jirametrics/aging_work_bar_chart.rb +235 -0
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +148 -0
  6. data/lib/jirametrics/aging_work_table.rb +149 -0
  7. data/lib/jirametrics/anonymizer.rb +186 -0
  8. data/lib/jirametrics/blocked_stalled_change.rb +43 -0
  9. data/lib/jirametrics/board.rb +85 -0
  10. data/lib/jirametrics/board_column.rb +14 -0
  11. data/lib/jirametrics/board_config.rb +31 -0
  12. data/lib/jirametrics/change_item.rb +80 -0
  13. data/lib/jirametrics/chart_base.rb +239 -0
  14. data/lib/jirametrics/columns_config.rb +42 -0
  15. data/lib/jirametrics/cycletime_config.rb +69 -0
  16. data/lib/jirametrics/cycletime_histogram.rb +74 -0
  17. data/lib/jirametrics/cycletime_scatterplot.rb +128 -0
  18. data/lib/jirametrics/daily_wip_by_age_chart.rb +88 -0
  19. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +77 -0
  20. data/lib/jirametrics/daily_wip_chart.rb +123 -0
  21. data/lib/jirametrics/data_quality_report.rb +278 -0
  22. data/lib/jirametrics/dependency_chart.rb +217 -0
  23. data/lib/jirametrics/discard_changes_before.rb +37 -0
  24. data/lib/jirametrics/download_config.rb +41 -0
  25. data/lib/jirametrics/downloader.rb +337 -0
  26. data/lib/jirametrics/examples/aggregated_project.rb +36 -0
  27. data/lib/jirametrics/examples/standard_project.rb +111 -0
  28. data/lib/jirametrics/expedited_chart.rb +169 -0
  29. data/lib/jirametrics/experimental/generator.rb +209 -0
  30. data/lib/jirametrics/experimental/info.rb +77 -0
  31. data/lib/jirametrics/exporter.rb +127 -0
  32. data/lib/jirametrics/file_config.rb +119 -0
  33. data/lib/jirametrics/fix_version.rb +21 -0
  34. data/lib/jirametrics/groupable_issue_chart.rb +44 -0
  35. data/lib/jirametrics/grouping_rules.rb +13 -0
  36. data/lib/jirametrics/hierarchy_table.rb +31 -0
  37. data/lib/jirametrics/html/aging_work_bar_chart.erb +72 -0
  38. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +52 -0
  39. data/lib/jirametrics/html/aging_work_table.erb +60 -0
  40. data/lib/jirametrics/html/collapsible_issues_panel.erb +32 -0
  41. data/lib/jirametrics/html/cycletime_histogram.erb +41 -0
  42. data/lib/jirametrics/html/cycletime_scatterplot.erb +103 -0
  43. data/lib/jirametrics/html/daily_wip_chart.erb +63 -0
  44. data/lib/jirametrics/html/data_quality_report.erb +126 -0
  45. data/lib/jirametrics/html/expedited_chart.erb +67 -0
  46. data/lib/jirametrics/html/hierarchy_table.erb +29 -0
  47. data/lib/jirametrics/html/index.erb +66 -0
  48. data/lib/jirametrics/html/sprint_burndown.erb +116 -0
  49. data/lib/jirametrics/html/story_point_accuracy_chart.erb +57 -0
  50. data/lib/jirametrics/html/throughput_chart.erb +65 -0
  51. data/lib/jirametrics/html_report_config.rb +217 -0
  52. data/lib/jirametrics/issue.rb +521 -0
  53. data/lib/jirametrics/issue_link.rb +60 -0
  54. data/lib/jirametrics/json_file_loader.rb +9 -0
  55. data/lib/jirametrics/project_config.rb +442 -0
  56. data/lib/jirametrics/rules.rb +34 -0
  57. data/lib/jirametrics/self_or_issue_dispatcher.rb +15 -0
  58. data/lib/jirametrics/sprint.rb +43 -0
  59. data/lib/jirametrics/sprint_burndown.rb +335 -0
  60. data/lib/jirametrics/sprint_issue_change_data.rb +31 -0
  61. data/lib/jirametrics/status.rb +26 -0
  62. data/lib/jirametrics/status_collection.rb +67 -0
  63. data/lib/jirametrics/story_point_accuracy_chart.rb +139 -0
  64. data/lib/jirametrics/throughput_chart.rb +91 -0
  65. data/lib/jirametrics/tree_organizer.rb +96 -0
  66. data/lib/jirametrics/trend_line_calculator.rb +74 -0
  67. data/lib/jirametrics.rb +85 -0
  68. metadata +167 -0
@@ -0,0 +1,209 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'random-word'
4
+ require 'require_all'
5
+ require_all 'lib'
6
+
7
+ def to_time date
8
+ Time.new date.year, date.month, date.day, rand(0..23), rand(0..59), rand(0..59)
9
+ end
10
+
11
+ class FakeIssue
12
+ @@issue_number = 1
13
+ attr_reader :effort, :raw, :worker
14
+
15
+ def initialize date:, type:, worker:
16
+ @raw = {
17
+ key: "FAKE-#{@@issue_number += 1}",
18
+ changelog: {
19
+ histories: []
20
+ },
21
+ fields: {
22
+ created: to_time(date),
23
+ updated: to_time(date),
24
+ creator: {
25
+ displayName: 'George Jetson'
26
+ },
27
+ issuetype: {
28
+ name: type
29
+ },
30
+ status: {
31
+ name: 'To Do',
32
+ id: 1,
33
+ statusCategory: {
34
+ id: 2,
35
+ name: 'To Do'
36
+ }
37
+ },
38
+ priority: {
39
+ name: ''
40
+ },
41
+ summary: RandomWord.phrases.next.gsub(/_/, ' '),
42
+ issuelinks: [],
43
+ fixVersions: []
44
+ }
45
+ }
46
+
47
+ @workers = [worker]
48
+ @effort = case type
49
+ when 'Story'
50
+ [1, 2, 3, 3, 3, 3, 4, 4, 4, 5, 6].sample
51
+ else
52
+ [1, 2, 3].sample
53
+ end
54
+ unblock
55
+ @done = false
56
+ @last_status = 'To Do'
57
+ @last_status_id = 1
58
+ change_status new_status: 'In Progress', new_status_id: 3, date: date
59
+ end
60
+
61
+ def blocked? = @blocked
62
+ def block = @blocked = true
63
+ def unblock = @blocked = false
64
+
65
+ def key = @raw[:key]
66
+
67
+ def do_work date:, effort:
68
+ raise 'Already done' if done?
69
+
70
+ @effort -= effort
71
+ return unless done?
72
+
73
+ change_status new_status: 'Done', new_status_id: 5, date: date
74
+ # fix_change_timestamps
75
+ end
76
+
77
+ def fix_change_timestamps
78
+ # since the timestamps have random hours, it's possible for them to be issued out of order. Sort them now
79
+ changes = @raw[:changelog][:histories]
80
+ times = [@raw[:fields][:created]] + changes.collect { |change| change[:created] }
81
+ times.sort!
82
+
83
+ @raw[:fields][:created] = times.shift
84
+ @raw[:fields][:updated] = times[-1]
85
+ changes.each do |change|
86
+ change[:created] = times.shift
87
+ end
88
+ end
89
+
90
+ def done? = @effort <= 0
91
+
92
+ def change_status date:, new_status:, new_status_id:
93
+ @raw[:changelog][:histories] << {
94
+ author: {
95
+ emailAddress: 'george@jetson.com',
96
+ displayName: 'George Jetson'
97
+ },
98
+ created: to_time(date),
99
+ items: [
100
+ {
101
+ field: 'status',
102
+ fieldtype: 'jira',
103
+ fieldId: 'status',
104
+ from: @last_status_id,
105
+ fromString: @last_status,
106
+ to: new_status_id,
107
+ toString: new_status
108
+ }
109
+ ]
110
+ }
111
+
112
+ @last_status = new_status
113
+ @last_status_id = new_status_id
114
+ end
115
+ end
116
+
117
+ class Worker
118
+ attr_accessor :issue
119
+ end
120
+
121
+ class Generator
122
+ def initialize
123
+ @random = Random.new
124
+ @file_prefix = 'fake'
125
+ @target_path = 'target/'
126
+
127
+ # @probability_work_will_be_pushed = 20
128
+ @probability_unblocked_work_becomes_blocked = 20
129
+ @probability_blocked_work_becomes_unblocked = 20
130
+ @date_range = (Date.today - 500)..Date.today
131
+ @issues = []
132
+ @workers = []
133
+ 5.times { @workers << Worker.new }
134
+ end
135
+
136
+ def run
137
+ remove_old_files
138
+ @date_range.each_with_index do |date, day|
139
+ yield date, day if block_given?
140
+ process_date(date, day) if (1..5).include? date.wday # Weekday
141
+ end
142
+
143
+ @issues.each do |issue|
144
+ issue.fix_change_timestamps
145
+ File.open "target/fake_issues/#{issue.key}.json", 'w' do |file|
146
+ file.puts JSON.pretty_generate(issue.raw)
147
+ end
148
+ end
149
+
150
+ File.write 'target/fake_meta.json', JSON.pretty_generate({
151
+ time_start: (@date_range.end - 90).to_time,
152
+ time_end: @date_range.end.to_time,
153
+ 'no-download': true
154
+ })
155
+ puts "Created #{@issues.size} fake issues"
156
+ end
157
+
158
+ def remove_old_files
159
+ path = "#{@target_path}#{@file_prefix}_issues"
160
+ Dir.foreach path do |file|
161
+ next unless file =~ /-\d+\.json$/
162
+
163
+ filename = "#{path}/#{file}"
164
+ File.unlink filename
165
+ end
166
+ end
167
+
168
+ def lucky? probability
169
+ @random.rand(1..100) <= probability
170
+ end
171
+
172
+ def next_issue_for worker:, date:, type:
173
+ # First look for something I already started
174
+ issue = @issues.find { |i| i.worker == worker && !i.done? && !i.blocked? }
175
+
176
+ # Then look for something that someone else started
177
+ issue = @issues.find { |i| i.worker != worker && !i.done? && !i.blocked? } if issue.nil? && lucky?(40)
178
+
179
+ # Then start new work
180
+ issue = FakeIssue.new(date: date, type: type, worker: worker) if issue.nil?
181
+
182
+ issue
183
+ end
184
+
185
+ def process_date date, _simulation_day
186
+ @issues.each do |issue|
187
+ if issue.blocked?
188
+ issue.unblock if lucky? @probability_blocked_work_becomes_unblocked
189
+ elsif lucky? @probability_unblocked_work_becomes_blocked
190
+ issue.block
191
+ end
192
+ end
193
+
194
+ @workers.each do |worker|
195
+ worker_capacity = [0, 1, 1, 1, 2].sample
196
+ if worker.issue.nil? || worker.issue.done?
197
+ type = lucky?(89) ? 'Story' : 'Bug'
198
+ worker.issue = next_issue_for worker: worker, date: date, type: type
199
+ @issues << worker.issue
200
+ end
201
+
202
+ worker.issue = next_issue_for worker: worker, date: date, type: type if worker.issue.blocked?
203
+ worker.issue.do_work date: date, effort: worker_capacity
204
+ worker.issue = nil if worker.issue.done?
205
+ end
206
+ end
207
+ end
208
+
209
+ Generator.new.run if __FILE__ == $PROGRAM_NAME
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'require_all'
4
+ require_all 'lib'
5
+
6
+ class InfoDumper
7
+ def initialize
8
+ @target_dir = 'target/'
9
+ end
10
+
11
+ def run key
12
+ find_file_prefixes.each do |prefix|
13
+ path = "#{@target_dir}#{prefix}_issues/#{key}.json"
14
+ path = "#{@target_dir}#{prefix}_issues"
15
+ Dir.foreach path do |file|
16
+ if file =~ /^#{key}.+\.json$/
17
+ issue = Issue.new raw: JSON.parse(File.read(File.join(path, file))), board: nil
18
+ dump issue
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ def find_file_prefixes
25
+ prefixes = []
26
+ Dir.foreach @target_dir do |file|
27
+ prefixes << $1 if file =~ /^(.+)_issues$/
28
+ end
29
+ prefixes
30
+ end
31
+
32
+ def dump issue
33
+ puts "#{issue.key} (#{issue.type}): #{compact_text issue.summary, 200}"
34
+
35
+ assignee = issue.raw['fields']['assignee']
36
+ puts " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>" unless assignee.nil?
37
+
38
+ issue.raw['fields']['issuelinks'].each do |link|
39
+ puts " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}" if link['outwardIssue']
40
+ puts " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}" if link['inwardIssue']
41
+ end
42
+ issue.changes.each do |change|
43
+ value = change.value
44
+ old_value = change.old_value
45
+
46
+ # Description fields get pretty verbose so reduce the clutter
47
+ if change.field == 'description' || change.field == 'summary'
48
+ value = compact_text value
49
+ old_value = compact_text old_value
50
+ end
51
+
52
+ author = change.author
53
+ author = "(#{author})" if author
54
+ message = " [change] #{change.time} [#{change.field}] "
55
+ message << "#{compact_text(old_value).inspect} -> " unless old_value.nil? || old_value.empty?
56
+ message << compact_text(value).inspect
57
+ message << " #{author}" if author
58
+ message << ' <<artificial entry>>' if change.artificial?
59
+ puts message
60
+ end
61
+ puts ''
62
+ end
63
+
64
+ def compact_text text, max = 60
65
+ return nil if text.nil?
66
+
67
+ text = text.gsub(/\s+/, ' ').strip
68
+ text = "#{text[0..max]}..." if text.length > max
69
+ text
70
+ end
71
+ end
72
+
73
+ if __FILE__ == $PROGRAM_NAME
74
+ ARGV.each do |key|
75
+ InfoDumper.new.run key
76
+ end
77
+ end
@@ -0,0 +1,127 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'fileutils'
4
+
5
+ class Object
6
+ def deprecated message:, date: nil
7
+ text = String.new
8
+ text << 'Deprecated'
9
+ text << "(#{date})"
10
+ text << ': '
11
+ text << message
12
+ text << "\n-> Called from #{caller[0]}"
13
+ warn text
14
+ end
15
+
16
+ def assert_jira_behaviour_true condition, &block
17
+ block.call if ENV['RACK_ENV'] == 'test' # Always expand the block if we're running in a test
18
+ failed_jira_behaviour(block) unless condition
19
+ end
20
+
21
+ def assert_jira_behaviour_false condition, &block
22
+ block.call if ENV['RACK_ENV'] == 'test' # Always expand the block if we're running in a test
23
+ failed_jira_behaviour(block) if condition
24
+ end
25
+
26
+ def failed_jira_behaviour block
27
+ text = String.new
28
+ text << 'Jira not doing what we expected. Results may be incorrect: '
29
+ text << block.call
30
+ text << "\n-> Called from #{caller[1]}"
31
+ warn text
32
+ end
33
+ end
34
+
35
+ class Exporter
36
+ attr_reader :project_configs
37
+
38
+ def self.configure &block
39
+ exporter = Exporter.new
40
+ exporter.instance_eval(&block)
41
+ @@instance = exporter
42
+ end
43
+
44
+ def self.instance = @@instance
45
+
46
+ def initialize
47
+ @project_configs = []
48
+ @timezone_offset = '+00:00'
49
+ @target_path = '.'
50
+ @holiday_dates = []
51
+ @downloading = false
52
+ end
53
+
54
+ def export
55
+ @project_configs.each do |project|
56
+ project.evaluate_next_level
57
+ project.run
58
+ end
59
+ end
60
+
61
+ def download
62
+ @downloading = true
63
+ logfile_name = 'downloader.log'
64
+ File.open logfile_name, 'w' do |logfile|
65
+ @project_configs.each do |project|
66
+ project.evaluate_next_level
67
+ next if project.aggregated_project?
68
+
69
+ project.download_config.run
70
+ downloader = Downloader.new(download_config: project.download_config)
71
+ downloader.logfile = logfile
72
+ downloader.logfile_name = logfile_name
73
+ downloader.run
74
+ end
75
+ end
76
+ puts "Full output from downloader in #{logfile_name}"
77
+ end
78
+
79
+ def downloading?
80
+ @downloading
81
+ end
82
+
83
+ def project name: nil, &block
84
+ raise 'target_path was never set!' if @target_path.nil?
85
+ raise 'jira_config not set' if @jira_config.nil?
86
+
87
+ @project_configs << ProjectConfig.new(
88
+ exporter: self, target_path: @target_path, jira_config: @jira_config, block: block, name: name
89
+ )
90
+ end
91
+
92
+ def xproject *args; end
93
+
94
+ def target_path path = nil
95
+ unless path.nil?
96
+ @target_path = path
97
+ @target_path += File::SEPARATOR unless @target_path.end_with? File::SEPARATOR
98
+ FileUtils.mkdir_p @target_path
99
+ end
100
+ @target_path
101
+ end
102
+
103
+ def jira_config filename = nil
104
+ @jira_config = JsonFileLoader.new.load(filename) unless filename.nil?
105
+ @jira_config
106
+ end
107
+
108
+ def timezone_offset offset = nil
109
+ @timezone_offset = offset unless offset.nil?
110
+ @timezone_offset
111
+ end
112
+
113
+ def holiday_dates *args
114
+ unless args.empty?
115
+ dates = []
116
+ args.each do |arg|
117
+ if arg =~ /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/
118
+ Date.parse($1).upto(Date.parse($2)).each { |date| dates << date }
119
+ else
120
+ dates << Date.parse(arg)
121
+ end
122
+ end
123
+ @holiday_dates = dates
124
+ end
125
+ @holiday_dates
126
+ end
127
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'csv'
4
+
5
+ class FileConfig
6
+ attr_reader :project_config, :issues
7
+
8
+ def initialize project_config:, block:
9
+ @project_config = project_config
10
+ @block = block
11
+ @columns = nil
12
+ end
13
+
14
+ def run
15
+ @issues = project_config.issues.dup
16
+ instance_eval(&@block)
17
+
18
+ if @columns
19
+ all_lines = prepare_grid
20
+
21
+ File.open(output_filename, 'w') do |file|
22
+ all_lines.each do |output_line|
23
+ file.puts CSV.generate_line(output_line)
24
+ end
25
+ end
26
+ elsif @html_report
27
+ @html_report.run
28
+ else
29
+ raise 'Must specify one of "columns" or "html_report"'
30
+ end
31
+ end
32
+
33
+ def prepare_grid
34
+ @columns.run
35
+
36
+ all_lines = issues.collect do |issue|
37
+ line = []
38
+ @columns.columns.each do |type, _name, block|
39
+ # Invoke the block that will retrieve the result from Issue
40
+ result = instance_exec(issue, &block)
41
+ # Convert that result to the appropriate type
42
+ line << __send__(:"to_#{type}", result)
43
+ end
44
+ line
45
+ end
46
+
47
+ all_lines = all_lines.select(&@only_use_row_if) if @only_use_row_if
48
+ all_lines = sort_output(all_lines)
49
+
50
+ if @columns.write_headers
51
+ line = @columns.columns.collect { |_type, label, _proc| label }
52
+ all_lines.insert 0, line
53
+ end
54
+
55
+ all_lines
56
+ end
57
+
58
+ def output_filename
59
+ segments = []
60
+ segments << project_config.target_path
61
+ segments << project_config.file_prefix
62
+ segments << (@file_suffix || "-#{Date.today}.csv")
63
+ segments.join
64
+ end
65
+
66
+ # We'll probably make sorting configurable at some point but for now it's hard coded for our
67
+ # most common usecase - the Team Dashboard from FocusedObjective.com. The rule for that one
68
+ # is that all empty values in the first column should be at the bottom.
69
+ def sort_output all_lines
70
+ all_lines.sort do |a, b|
71
+ if a[0].nil?
72
+ 1
73
+ elsif b[0].nil?
74
+ -1
75
+ else
76
+ a[0] <=> b[0]
77
+ end
78
+ end
79
+ end
80
+
81
+ def columns &block
82
+ assert_only_one_filetype_config_set
83
+ @columns = ColumnsConfig.new file_config: self, block: block
84
+ end
85
+
86
+ def html_report &block
87
+ assert_only_one_filetype_config_set
88
+ @html_report = HtmlReportConfig.new file_config: self, block: block
89
+ end
90
+
91
+ def assert_only_one_filetype_config_set
92
+ raise 'Can only have one columns or html_report declaration inside a file' if @columns || @html_report
93
+ end
94
+
95
+ def only_use_row_if &block
96
+ @only_use_row_if = block
97
+ end
98
+
99
+ def to_date object
100
+ to_datetime(object)&.to_date
101
+ end
102
+
103
+ def to_datetime object
104
+ return nil if object.nil?
105
+
106
+ object = object.to_datetime
107
+ object = object.new_offset(@timezone_offset) if @timezone_offset
108
+ object
109
+ end
110
+
111
+ def to_string object
112
+ object.to_s
113
+ end
114
+
115
+ def file_suffix suffix = nil
116
+ @file_suffix = suffix unless suffix.nil?
117
+ @file_suffix
118
+ end
119
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ class FixVersion
4
+ attr_reader :raw
5
+
6
+ def initialize raw
7
+ @raw = raw
8
+ end
9
+
10
+ def name
11
+ @raw['name']
12
+ end
13
+
14
+ def id
15
+ @raw['id'].to_i
16
+ end
17
+
18
+ def released?
19
+ @raw['released']
20
+ end
21
+ end
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/rules'
4
+ require 'jirametrics/grouping_rules'
5
+
6
+ module GroupableIssueChart
7
+ def init_configuration_block user_provided_block, &default_block
8
+ # The user provided a block but it's using the old deprecated style
9
+ if user_provided_block && user_provided_block.arity == 1
10
+ deprecated date: '2022-10-02', message: "#{self.class}: Use the new grouping_rules syntax"
11
+ grouping_rules do |issue, rules|
12
+ rules.label, rules.color = user_provided_block.call(issue)
13
+ end
14
+ return
15
+ end
16
+
17
+ if user_provided_block
18
+ instance_eval(&user_provided_block)
19
+ return if @group_by_block
20
+ end
21
+
22
+ instance_eval(&default_block)
23
+ end
24
+
25
+ def grouping_rules &block
26
+ @group_by_block = block
27
+ end
28
+
29
+ def group_issues completed_issues
30
+ result = {}
31
+ completed_issues.each do |issue|
32
+ rules = GroupingRules.new
33
+ @group_by_block.call(issue, rules)
34
+ next if rules.ignored?
35
+
36
+ (result[rules] ||= []) << issue
37
+ end
38
+
39
+ result.each_key do |rules|
40
+ rules.color = random_color if rules.color.nil?
41
+ end
42
+ result
43
+ end
44
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ class GroupingRules < Rules
4
+ attr_accessor :label, :color
5
+
6
+ def eql? other
7
+ other.label == @label && other.color == @color
8
+ end
9
+
10
+ def group
11
+ [@label, @color]
12
+ end
13
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/chart_base'
4
+
5
+ class HierarchyTable < ChartBase
6
+ def initialize block = nil
7
+ super()
8
+
9
+ header_text 'Hierarchy Table'
10
+ description_text <<-HTML
11
+ <p>content goes here</p>
12
+ HTML
13
+
14
+ instance_eval(&block) if block
15
+ end
16
+
17
+ def run
18
+ tree_organizer = TreeOrganizer.new issues: @issues
19
+ unless tree_organizer.cyclical_links.empty?
20
+ message = String.new
21
+ message << '<p>Found cyclical links in the parent hierarchy. This is an error and should be '
22
+ message << 'fixed.</p><ul>'
23
+ tree_organizer.cyclical_links.each do |link|
24
+ message << '<li>' << link.join(' > ') << '</ul>'
25
+ end
26
+ message << '</ul>'
27
+ @description_text += message
28
+ end
29
+ wrap_and_render(binding, __FILE__)
30
+ end
31
+ end