jirametrics 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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