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
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