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
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 0dbe04bbc480c61f2bb1d09cdab71e9ed47fb8265336b2a1f05551bff78dec17
4
+ data.tar.gz: 69c590731144d58f3235e72fb04f7d766b47a635c47301c5c87f1dbfc2cb13d5
5
+ SHA512:
6
+ metadata.gz: 494a2760b3f32fab17432735527e20fdd60105ca670437bb6cf382bd2844d78313f985db7cf3e1534f5bc67f7f2def4d598a58d46009018d14dbbb0ab70e2ce2
7
+ data.tar.gz: c77baf2ff091972595cc126de4a03dea02f756b01db33cf90144583d65de81d07f5b37856a920d3870d72f4b7100709a243099e29fe79f78b52d96d207641545
data/bin/jirametrics ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require 'jirametrics'
4
+ JiraMetrics.start(ARGV)
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'date'
4
+
5
+ class AggregateConfig
6
+ attr_reader :project_config
7
+
8
+ def initialize project_config:, block:
9
+ @project_config = project_config
10
+ @block = block
11
+
12
+ @included_projects = []
13
+ end
14
+
15
+ def evaluate_next_level
16
+ instance_eval(&@block)
17
+
18
+ if @included_projects.empty?
19
+ raise "#{@project_config.name}: When aggregating, you must include at least one other project"
20
+ end
21
+
22
+ # If the date range wasn't set then calculate it now
23
+ @project_config.time_range = find_time_range projects: @included_projects if @project_config.time_range.nil?
24
+
25
+ adjust_issue_links
26
+ end
27
+
28
+ def adjust_issue_links
29
+ issues = @project_config.issues
30
+ issues.each do |issue|
31
+ issue.issue_links.each do |link|
32
+ other_issue_key = link.other_issue.key
33
+ other_issue = issues.find { |i| i.key == other_issue_key }
34
+
35
+ link.other_issue = other_issue if other_issue
36
+ end
37
+ end
38
+ end
39
+
40
+ def include_issues_from project_name
41
+ project = @project_config.exporter.project_configs.find { |p| p.name == project_name }
42
+ if project.nil?
43
+ puts "Warning: Aggregated project #{@project_config.name.inspect} is attempting to load " \
44
+ "project #{project_name.inspect} but it can't be found. Is it disabled?"
45
+ return
46
+ end
47
+
48
+ @project_config.jira_url = project.jira_url if @project_config.jira_url.nil?
49
+ unless @project_config.jira_url == project.jira_url
50
+ raise 'Not allowed to aggregate projects from different Jira instances: ' \
51
+ "#{@project_config.jira_url.inspect} and #{project.jira_url.inspect}"
52
+ end
53
+
54
+ @included_projects << project
55
+ @project_config.add_issues project.issues
56
+ end
57
+
58
+ def date_range range
59
+ @project_config.time_range = date_range_to_time_range(
60
+ date_range: range, timezone_offset: project_config.exporter.timezone_offset
61
+ )
62
+ end
63
+
64
+ def date_range_to_time_range date_range:, timezone_offset:
65
+ start_of_first_day = Time.new(
66
+ date_range.begin.year, date_range.begin.month, date_range.begin.day, 0, 0, 0, timezone_offset
67
+ )
68
+ end_of_last_day = Time.new(
69
+ date_range.end.year, date_range.end.month, date_range.end.day, 23, 59, 59, timezone_offset
70
+ )
71
+
72
+ start_of_first_day..end_of_last_day
73
+ end
74
+
75
+ def find_time_range projects:
76
+ raise "Can't calculate aggregated range as no projects were included." if projects.empty?
77
+
78
+ earliest = nil
79
+ latest = nil
80
+ projects.each do |project|
81
+ range = project.time_range
82
+ earliest = range.begin if earliest.nil? || range.begin < earliest
83
+ latest = range.end if latest.nil? || range.end > latest
84
+ end
85
+
86
+ raise "Can't calculate range" if earliest.nil? || latest.nil?
87
+ earliest..latest
88
+ end
89
+ end
@@ -0,0 +1,235 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/chart_base'
4
+
5
+ class AgingWorkBarChart < ChartBase
6
+ @@next_id = 0
7
+
8
+ def initialize block = nil
9
+ super()
10
+
11
+ header_text 'Aging Work Bar Chart'
12
+ description_text <<-HTML
13
+ <p>
14
+ This chart shows all active (started but not completed) work, ordered from oldest at the top to
15
+ newest at the bottom.
16
+ </p>
17
+ <p>
18
+ There are potentially three bars for each issue, although a bar may be missing if the issue has no
19
+ information relevant to that. Hovering over any of the bars will provide more details.
20
+ <ol><li>The top bar tells you what status the issue is in at any time. Any statuses in the status
21
+ category of "To Do" will be in blue. Any in the category of "In Progress" will be in a
22
+ yellow and any in "Done" will be green.</li>
23
+ <li>The middle bar indicates blocked and stalled states. A lighter orange is stalled and a darker,
24
+ reddish colour is blocked.</li>
25
+ <li>The bottom bar indicated an expedited state.</li></ol>
26
+ </p>
27
+ <p>
28
+ The gray backgrounds indicate weekends and the red vertical line indicates the 85% point for all
29
+ items in this time period. Anything that started to the left of that is now an outlier.
30
+ </p>
31
+ HTML
32
+
33
+ # Because this one will size itself as needed, we start with a smaller default size
34
+ @canvas_height = 80
35
+
36
+ instance_eval(&block) if block
37
+ end
38
+
39
+ def run
40
+ aging_issues = @issues.select do |issue|
41
+ cycletime = issue.board.cycletime
42
+ cycletime.started_time(issue) && cycletime.stopped_time(issue).nil?
43
+ end
44
+
45
+ grow_chart_height_if_too_many_issues aging_issues.size
46
+
47
+ today = date_range.end
48
+ aging_issues.sort! do |a, b|
49
+ a.board.cycletime.age(b, today: today) <=> b.board.cycletime.age(a, today: today)
50
+ end
51
+ data_sets = []
52
+ aging_issues.each do |issue|
53
+ cycletime = issue.board.cycletime
54
+ issue_start_time = cycletime.started_time(issue)
55
+ issue_start_date = issue_start_time.to_date
56
+ issue_label = "[#{label_days cycletime.age(issue, today: today)}] #{issue.key}: #{issue.summary}"[0..60]
57
+ [
58
+ status_data_sets(issue: issue, label: issue_label, today: today),
59
+ blocked_data_sets(
60
+ issue: issue,
61
+ issue_label: issue_label,
62
+ stack: 'blocked',
63
+ issue_start_time: issue_start_time
64
+ ),
65
+ data_set_by_block(
66
+ issue: issue,
67
+ issue_label: issue_label,
68
+ title_label: 'Expedited',
69
+ stack: 'expedited',
70
+ color: 'red',
71
+ start_date: issue_start_date
72
+ ) { |day| issue.expedited_on_date?(day) }
73
+ ].compact.flatten.each do |data|
74
+ data_sets << data
75
+ end
76
+ end
77
+
78
+ percentage = calculate_percent_line
79
+ percentage_line_x = date_range.end - calculate_percent_line if percentage
80
+
81
+ wrap_and_render(binding, __FILE__)
82
+ end
83
+
84
+ def grow_chart_height_if_too_many_issues aging_issue_count
85
+ px_per_bar = 8
86
+ bars_per_issue = 3
87
+ preferred_height = aging_issue_count * px_per_bar * bars_per_issue
88
+ @canvas_height = preferred_height if @canvas_height.nil? || @canvas_height < preferred_height
89
+ end
90
+
91
+ def status_data_sets issue:, label:, today:
92
+ cycletime = issue.board.cycletime
93
+
94
+ issue_started_time = cycletime.started_time(issue)
95
+
96
+ previous_start = nil
97
+ previous_status = nil
98
+
99
+ data_sets = []
100
+ issue.changes.each do |change|
101
+ next unless change.status?
102
+
103
+ status = issue.find_status_by_name change.value
104
+
105
+ unless previous_start.nil? || previous_start < issue_started_time
106
+ hash = {
107
+ type: 'bar',
108
+ data: [{
109
+ x: [chart_format(previous_start), chart_format(change.time)],
110
+ y: label,
111
+ title: "#{issue.type} : #{change.value}"
112
+ }],
113
+ backgroundColor: status_category_color(status),
114
+ borderColor: 'white',
115
+ borderWidth: {
116
+ top: 0,
117
+ right: 1,
118
+ bottom: 0,
119
+ left: 0
120
+ },
121
+ stacked: true,
122
+ stack: 'status'
123
+ }
124
+ data_sets << hash if date_range.include?(change.time.to_date)
125
+ end
126
+
127
+ previous_start = change.time
128
+ previous_status = status
129
+ end
130
+
131
+ if previous_start
132
+ data_sets << {
133
+ type: 'bar',
134
+ data: [{
135
+ x: [chart_format(previous_start), chart_format("#{today}T00:00:00#{@timezone_offset}")],
136
+ y: label,
137
+ title: "#{issue.type} : #{previous_status.name}"
138
+ }],
139
+ backgroundColor: status_category_color(previous_status),
140
+ stacked: true,
141
+ stack: 'status'
142
+ }
143
+ end
144
+
145
+ data_sets
146
+ end
147
+
148
+ def one_block_change_data_set starting_change:, ending_time:, issue_label:, stack:, issue_start_time:
149
+ color = settings['colors']['blocked']
150
+ color = settings['colors']['stalled'] if starting_change.stalled?
151
+ {
152
+ backgroundColor: color,
153
+ data: [
154
+ {
155
+ title: starting_change.reasons,
156
+ x: [chart_format([issue_start_time, starting_change.time].max), chart_format(ending_time)],
157
+ y: issue_label
158
+ }
159
+ ],
160
+ stack: stack,
161
+ stacked: true,
162
+ type: 'bar'
163
+ }
164
+ end
165
+
166
+ def blocked_data_sets issue:, issue_label:, issue_start_time:, stack:
167
+ data_sets = []
168
+ starting_change = nil
169
+
170
+ issue.blocked_stalled_changes(end_time: time_range.end).each do |change|
171
+ if starting_change.nil? || starting_change.active?
172
+ starting_change = change
173
+ next
174
+ end
175
+
176
+ if change.time >= issue_start_time
177
+ data_sets << one_block_change_data_set(
178
+ starting_change: starting_change, ending_time: change.time,
179
+ issue_label: issue_label, stack: stack, issue_start_time: issue_start_time
180
+ )
181
+ end
182
+
183
+ starting_change = change
184
+ end
185
+
186
+ data_sets
187
+ end
188
+
189
+ def data_set_by_block(
190
+ issue:, issue_label:, title_label:, stack:, color:, start_date:, end_date: date_range.end, &block
191
+ )
192
+ started = nil
193
+ ended = nil
194
+ data = []
195
+
196
+ (start_date..end_date).each do |day|
197
+ if block.call(day)
198
+ started = day if started.nil?
199
+ ended = day
200
+ elsif ended
201
+ data << {
202
+ x: [chart_format(started), chart_format(ended)],
203
+ y: issue_label,
204
+ title: "#{issue.type} : #{title_label} #{label_days (ended - started).to_i + 1}"
205
+ }
206
+
207
+ started = nil
208
+ ended = nil
209
+ end
210
+ end
211
+
212
+ if started
213
+ data << {
214
+ x: [chart_format(started), chart_format(ended)],
215
+ y: issue_label,
216
+ title: "#{issue.type} : #{title_label} #{label_days (end_date - started).to_i + 1}"
217
+ }
218
+ end
219
+
220
+ {
221
+ type: 'bar',
222
+ data: data,
223
+ backgroundColor: color,
224
+ stacked: true,
225
+ stack: stack
226
+ }
227
+ end
228
+
229
+ def calculate_percent_line percentage: 85
230
+ days = completed_issues_in_range.collect { |issue| issue.board.cycletime.cycletime(issue) }.compact.sort
231
+ return nil if days.empty?
232
+
233
+ days[days.length * percentage / 100]
234
+ end
235
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/chart_base'
4
+ require 'jirametrics/groupable_issue_chart'
5
+
6
+ class AgingWorkInProgressChart < ChartBase
7
+ include GroupableIssueChart
8
+ attr_accessor :possible_statuses, :board_id
9
+ attr_reader :board_columns
10
+
11
+ def initialize block = nil
12
+ super()
13
+ header_text 'Aging Work in Progress'
14
+ description_text <<-HTML
15
+ <p>
16
+ This chart shows only work items that have started but not completed, grouped by the column
17
+ they're currently in. Hovering over a dot will show you the ID of that work item.
18
+ </p>
19
+ <p>
20
+ The gray area indicates the 85% mark for work items that have passed through here - 85% of
21
+ previous work items left this column while still inside the gray area. Any work items above
22
+ the gray area are outliers and they are the items that you should pay special attention to.
23
+ </p>
24
+ HTML
25
+ init_configuration_block(block) do
26
+ grouping_rules do |issue, rule|
27
+ rule.label = issue.type
28
+ rule.color = color_for type: issue.type
29
+ end
30
+ end
31
+ end
32
+
33
+ def run
34
+ determine_board_columns
35
+
36
+ @header_text += " on board: #{@all_boards[@board_id].name}"
37
+ data_sets = make_data_sets
38
+ column_headings = @board_columns.collect(&:name)
39
+
40
+ adjust_visibility_of_unmapped_status_column data_sets: data_sets, column_headings: column_headings
41
+
42
+ wrap_and_render(binding, __FILE__)
43
+ end
44
+
45
+ def determine_board_columns
46
+ unmapped_statuses = current_board.possible_statuses.collect(&:id)
47
+
48
+ columns = current_board.visible_columns
49
+ columns.each { |c| unmapped_statuses -= c.status_ids }
50
+
51
+ @fake_column = BoardColumn.new({
52
+ 'name' => '[Unmapped Statuses]',
53
+ 'statuses' => unmapped_statuses.collect { |id| { 'id' => id.to_s } }.uniq
54
+ })
55
+ @board_columns = columns + [@fake_column]
56
+ end
57
+
58
+ def make_data_sets
59
+ aging_issues = @issues.select do |issue|
60
+ board = issue.board
61
+ board.id == @board_id && board.cycletime.in_progress?(issue)
62
+ end
63
+
64
+ percentage = 85
65
+ rules_to_issues = group_issues aging_issues
66
+ data_sets = rules_to_issues.keys.collect do |rules|
67
+ {
68
+ 'type' => 'line',
69
+ 'label' => rules.label,
70
+ 'data' => rules_to_issues[rules].collect do |issue|
71
+ age = issue.board.cycletime.age(issue, today: date_range.end)
72
+ column = column_for issue: issue
73
+ next if column.nil?
74
+
75
+ { 'y' => age,
76
+ 'x' => column.name,
77
+ 'title' => ["#{issue.key} : #{issue.summary} (#{label_days age})"]
78
+ }
79
+ end.compact,
80
+ 'fill' => false,
81
+ 'showLine' => false,
82
+ 'backgroundColor' => rules.color
83
+ }
84
+ end
85
+ data_sets << {
86
+ 'type' => 'bar',
87
+ 'label' => "#{percentage}%",
88
+ 'barPercentage' => 1.0,
89
+ 'categoryPercentage' => 1.0,
90
+ 'data' => days_at_percentage_threshold_for_all_columns(percentage: percentage, issues: @issues).drop(1)
91
+ }
92
+ end
93
+
94
+ def days_at_percentage_threshold_for_all_columns percentage:, issues:
95
+ accumulated_status_ids_per_column.collect do |_column, status_ids|
96
+ ages = ages_of_issues_that_crossed_column_boundary issues: issues, status_ids: status_ids
97
+ index = ages.size * percentage / 100
98
+ ages.sort[index.to_i] || 0
99
+ end
100
+ end
101
+
102
+ def accumulated_status_ids_per_column
103
+ accumulated_status_ids = []
104
+ @board_columns.reverse.collect do |column|
105
+ next if column == @fake_column
106
+
107
+ accumulated_status_ids += column.status_ids
108
+ [column.name, accumulated_status_ids.dup]
109
+ end.compact.reverse
110
+ end
111
+
112
+ def ages_of_issues_that_crossed_column_boundary issues:, status_ids:
113
+ issues.collect do |issue|
114
+ stop = issue.first_time_in_status(*status_ids)
115
+ start = issue.board.cycletime.started_time(issue)
116
+
117
+ # Skip if either it hasn't crossed the boundary or we can't tell when it started.
118
+ next if stop.nil? || start.nil?
119
+ next if stop < start
120
+
121
+ (stop.to_date - start.to_date).to_i + 1
122
+ end.compact
123
+ end
124
+
125
+ def column_for issue:
126
+ @board_columns.find do |board_column|
127
+ board_column.status_ids.include? issue.status.id
128
+ end
129
+ end
130
+
131
+ def adjust_visibility_of_unmapped_status_column data_sets:, column_headings:
132
+ column_name = @fake_column.name
133
+
134
+ has_unmapped = data_sets.any? do |set|
135
+ set['data'].any? do |data|
136
+ data['x'] == column_name if data.is_a? Hash
137
+ end
138
+ end
139
+
140
+ if has_unmapped
141
+ @description_text += "<p>The items shown in #{column_name.inspect} are not visible on the " \
142
+ 'board but are still active. Most likely everyone has forgotten about them.</p>'
143
+ else
144
+ column_headings.pop
145
+ @board_columns.pop
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,149 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/chart_base'
4
+
5
+ class AgingWorkTable < ChartBase
6
+ attr_accessor :today, :board_id
7
+
8
+ def initialize block
9
+ super()
10
+ @blocked_icon = '🛑'
11
+ @expedited_icon = '🔥'
12
+ @stalled_icon = '🟧'
13
+ @stalled_threshold = 5
14
+ @dead_icon = '⬛'
15
+ @dead_threshold = 45
16
+ @age_cutoff = 0
17
+
18
+ instance_eval(&block) if block
19
+ end
20
+
21
+ def run
22
+ @today = date_range.end
23
+ aging_issues = select_aging_issues
24
+
25
+ expedited_but_not_started = @issues.select do |issue|
26
+ cycletime = issue.board.cycletime
27
+ cycletime.started_time(issue).nil? && cycletime.stopped_time(issue).nil? && issue.expedited?
28
+ end
29
+ aging_issues += expedited_but_not_started.sort_by(&:created)
30
+
31
+ render(binding, __FILE__)
32
+ end
33
+
34
+ def select_aging_issues
35
+ aging_issues = @issues.select do |issue|
36
+ cycletime = issue.board.cycletime
37
+ started = cycletime.started_time(issue)
38
+ stopped = cycletime.stopped_time(issue)
39
+ next false if started.nil? || stopped
40
+ next true if issue.blocked_on_date?(@today, end_time: time_range.end) || issue.expedited?
41
+
42
+ age = (@today - started.to_date).to_i + 1
43
+ age > @age_cutoff
44
+ end
45
+ @any_scrum_boards = aging_issues.any? { |issue| issue.board.scrum? }
46
+ aging_issues.sort { |a, b| b.board.cycletime.age(b, today: @today) <=> a.board.cycletime.age(a, today: @today) }
47
+ end
48
+
49
+ def icon_span title:, icon:
50
+ "<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
51
+ end
52
+
53
+ def expedited_text issue
54
+ return unless issue.expedited?
55
+
56
+ name = issue.raw['fields']['priority']['name']
57
+ icon_span(title: "Expedited: Has a priority of &quot;#{name}&quot;", icon: @expedited_icon)
58
+ end
59
+
60
+ def blocked_text issue
61
+ started_time = issue.board.cycletime.started_time(issue)
62
+ return nil if started_time.nil?
63
+
64
+ current = issue.blocked_stalled_changes(end_time: time_range.end)[-1]
65
+ if current.blocked?
66
+ icon_span title: current.reasons, icon: @blocked_icon
67
+ elsif current.stalled?
68
+ if current.stalled_days && current.stalled_days > @dead_threshold
69
+ icon_span(
70
+ title: "Dead? Hasn&apos;t had any activity in #{label_days current.stalled_days}. " \
71
+ 'Does anyone still care about this?',
72
+ icon: @dead_icon
73
+ )
74
+ else
75
+ icon_span(
76
+ title: current.reasons,
77
+ icon: @stalled_icon
78
+ )
79
+ end
80
+ end
81
+ end
82
+
83
+ def unmapped_status_text issue
84
+ icon_span(
85
+ title: "The status #{issue.status.name.inspect} is not mapped to any column and will not be visible",
86
+ icon: ' ⁉️'
87
+ )
88
+ end
89
+
90
+ def fix_versions_text issue
91
+ issue.fix_versions.collect do |fix|
92
+ if fix.released?
93
+ icon_text = icon_span title: 'Released. Likely not on the board anymore.', icon: '✅'
94
+ "#{fix.name} #{icon_text}"
95
+ else
96
+ fix.name
97
+ end
98
+ end.join('<br />')
99
+ end
100
+
101
+ def sprints_text issue
102
+ sprint_ids = []
103
+
104
+ issue.changes.each do |change|
105
+ next unless change.sprint?
106
+
107
+ sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
108
+ end
109
+ sprint_ids.flatten!
110
+
111
+ issue.board.sprints.select { |s| sprint_ids.include? s.id }.collect do |sprint|
112
+ icon_text = nil
113
+ if sprint.active?
114
+ icon_text = icon_span title: 'Active sprint', icon: '➡️'
115
+ else
116
+ icon_text = icon_span title: 'Sprint closed', icon: '✅'
117
+ end
118
+ "#{sprint.name} #{icon_text}"
119
+ end.join('<br />')
120
+ end
121
+
122
+ def current_status_visible? issue
123
+ issue.board.visible_columns.any? { |column| column.status_ids.include? issue.status.id }
124
+ end
125
+
126
+ def age_cutoff age = nil
127
+ @age_cutoff = age.to_i if age
128
+ @age_cutoff
129
+ end
130
+
131
+ def any_scrum_boards?
132
+ @any_scrum_boards
133
+ end
134
+
135
+ def parent_hierarchy issue
136
+ result = []
137
+
138
+ while issue
139
+ cyclical_parent_links = result.include? issue
140
+ result << issue
141
+
142
+ break if cyclical_parent_links
143
+
144
+ issue = issue.parent
145
+ end
146
+
147
+ result.reverse
148
+ end
149
+ end