jirametrics 2.13 → 2.22

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 (55) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aging_work_bar_chart.rb +176 -134
  3. data/lib/jirametrics/anonymizer.rb +8 -6
  4. data/lib/jirametrics/atlassian_document_format.rb +8 -4
  5. data/lib/jirametrics/bar_chart_range.rb +17 -0
  6. data/lib/jirametrics/board.rb +4 -0
  7. data/lib/jirametrics/board_config.rb +4 -1
  8. data/lib/jirametrics/change_item.rb +11 -4
  9. data/lib/jirametrics/chart_base.rb +36 -2
  10. data/lib/jirametrics/cycletime_config.rb +22 -4
  11. data/lib/jirametrics/cycletime_histogram.rb +3 -1
  12. data/lib/jirametrics/cycletime_scatterplot.rb +36 -17
  13. data/lib/jirametrics/daily_view.rb +49 -42
  14. data/lib/jirametrics/daily_wip_by_age_chart.rb +3 -4
  15. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +13 -3
  16. data/lib/jirametrics/daily_wip_chart.rb +1 -1
  17. data/lib/jirametrics/data_quality_report.rb +8 -3
  18. data/lib/jirametrics/dependency_chart.rb +4 -1
  19. data/lib/jirametrics/downloader.rb +34 -99
  20. data/lib/jirametrics/downloader_for_cloud.rb +202 -0
  21. data/lib/jirametrics/downloader_for_data_center.rb +94 -0
  22. data/lib/jirametrics/examples/standard_project.rb +9 -9
  23. data/lib/jirametrics/expedited_chart.rb +1 -1
  24. data/lib/jirametrics/exporter.rb +12 -5
  25. data/lib/jirametrics/file_system.rb +24 -1
  26. data/lib/jirametrics/fix_version.rb +13 -0
  27. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  28. data/lib/jirametrics/groupable_issue_chart.rb +7 -1
  29. data/lib/jirametrics/html/aging_work_bar_chart.erb +2 -1
  30. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
  31. data/lib/jirametrics/html/aging_work_table.erb +2 -0
  32. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  33. data/lib/jirametrics/html/cycletime_histogram.erb +4 -2
  34. data/lib/jirametrics/html/cycletime_scatterplot.erb +6 -6
  35. data/lib/jirametrics/html/daily_wip_chart.erb +2 -0
  36. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -0
  37. data/lib/jirametrics/html/expedited_chart.erb +3 -1
  38. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -0
  39. data/lib/jirametrics/html/index.css +21 -9
  40. data/lib/jirametrics/html/index.erb +3 -35
  41. data/lib/jirametrics/html/index.js +114 -0
  42. data/lib/jirametrics/html/sprint_burndown.erb +11 -3
  43. data/lib/jirametrics/html/throughput_chart.erb +2 -2
  44. data/lib/jirametrics/html_generator.rb +31 -0
  45. data/lib/jirametrics/html_report_config.rb +8 -25
  46. data/lib/jirametrics/issue.rb +127 -22
  47. data/lib/jirametrics/jira_gateway.rb +55 -17
  48. data/lib/jirametrics/project_config.rb +42 -5
  49. data/lib/jirametrics/raw_javascript.rb +13 -0
  50. data/lib/jirametrics/settings.json +3 -1
  51. data/lib/jirametrics/sprint.rb +12 -0
  52. data/lib/jirametrics/sprint_burndown.rb +6 -2
  53. data/lib/jirametrics/stitcher.rb +75 -0
  54. data/lib/jirametrics.rb +26 -70
  55. metadata +10 -3
@@ -67,10 +67,21 @@
67
67
  --sprint-burndown-sprint-color-4: red;
68
68
  --sprint-burndown-sprint-color-5: brown;
69
69
 
70
+ --sprint-color: lightblue;
71
+
70
72
  --daily-view-selected-issue-background: lightgray;
71
73
  --daily-view-issue-border: green;
72
74
  --daily-view-selected-issue-border: red;
73
75
 
76
+ /* The first five are the standard priorities that Jira creates by default. */
77
+ --priority-color-highest: #dc2626; /* red-600 - urgent red */
78
+ --priority-color-high: #ea580c; /* orange-600 - warning orange */
79
+ --priority-color-medium: #9ca3af; /* gray-400 - neutral light gray */
80
+ --priority-color-low: #0891b2; /* cyan-600 - calm blue */
81
+ --priority-color-lowest: #64748b; /* slate-500 - muted slate */
82
+ /* Then here are some values we've seen in multiple instances. */
83
+ --priority-color-notset: gray;
84
+ --priority-color-critical: red;
74
85
  }
75
86
 
76
87
  body {
@@ -78,11 +89,6 @@ body {
78
89
  color: var(--default-text-color);
79
90
  }
80
91
 
81
- h1 {
82
- border: 1px solid black;
83
- background: lightgray;
84
- padding-left: 0.2em;
85
- }
86
92
  dl, dd, dt {
87
93
  padding: 0;
88
94
  margin: 0;
@@ -191,6 +197,11 @@ div.daily_issue {
191
197
  padding-right: 0.2em;
192
198
  border-radius: 0.2em;
193
199
  }
200
+ h1 {
201
+ border: none;
202
+ background: none;
203
+ padding-left: 0;
204
+ }
194
205
  margin-bottom: 0.5em;
195
206
  }
196
207
  div.child_issue:hover {
@@ -237,11 +248,12 @@ div.child_issue {
237
248
  --wip-chart-duration-more-than-four-weeks-color: #8e0000;
238
249
 
239
250
  --daily-view-selected-issue-background: #474747;
240
- }
241
251
 
242
- h1 {
243
- color: #e0e0e0;
244
- background-color: #656565;
252
+ --priority-color-highest: #ef4444; /* red-500 - bright urgent red */
253
+ --priority-color-high: #f97316; /* orange-500 - bright orange */
254
+ --priority-color-medium: #9ca3af; /* gray-400 - neutral light gray */
255
+ --priority-color-low: #06b6d4; /* cyan-500 - bright calm blue */
256
+ --priority-color-lowest: #94a3b8; /* slate-400 - muted light slate */
245
257
  }
246
258
 
247
259
  a[href] {
@@ -5,41 +5,9 @@
5
5
  <script src="https://cdn.jsdelivr.net/npm/moment@2.29.1/moment.js"></script>
6
6
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
7
7
  <script src="https://cdn.jsdelivr.net/npm/chartjs-adapter-moment@^1"></script>
8
- <script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-annotation/1.2.2/chartjs-plugin-annotation.min.js" integrity="sha512-HycvvBSFvDEVyJ0tjE2rPmymkt6XqsP/Zo96XgLRjXwn6SecQqsn+6V/7KYev66OshZZ9+f9AttCGmYqmzytiw==" crossorigin="anonymous" referrerpolicy="no-referrer"></script>
8
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/chartjs-plugin-annotation/3.1.0/chartjs-plugin-annotation.min.js"></script>
9
9
  <script type="text/javascript">
10
- function expand_collapse(link_id, issues_id) {
11
- link_text = document.getElementById(link_id).textContent
12
- if( link_text == 'Show details') {
13
- document.getElementById(link_id).textContent = 'Hide details'
14
- document.getElementById(issues_id).style.display = 'block'
15
- }
16
- else {
17
- document.getElementById(link_id).textContent = 'Show details'
18
- document.getElementById(issues_id).style.display = 'none'
19
- }
20
- }
21
-
22
- function toggle_visibility(open_link_id, close_link_id, toggleable_id) {
23
- let open_link = document.getElementById(open_link_id)
24
- let close_link = document.getElementById(close_link_id)
25
- let toggleable_element = document.getElementById(toggleable_id)
26
-
27
- if(open_link.style.display == 'none') {
28
- open_link.style.display = 'block'
29
- close_link.style.display = 'none'
30
- toggleable_element.style.display = 'none'
31
- }
32
- else {
33
- open_link.style.display = 'none'
34
- close_link.style.display = 'block'
35
- toggleable_element.style.display = 'block'
36
- }
37
- }
38
- // If we switch between light/dark mode then force a refresh so all charts will redraw correctly
39
- // in the other colour scheme.
40
- window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
41
- location.reload()
42
- })
10
+ <%= javascript %>
43
11
  </script>
44
12
  <style>
45
13
  <%= css %>
@@ -55,6 +23,6 @@
55
23
  </div>
56
24
  </noscript>
57
25
  <%= "\n" + @sections.collect { |text, type| text if type == :header }.compact.join("\n\n") %>
58
- <%= "\n" + @sections.collect { |text, type| text if type == :body }.compact.join("\n\n") %>
26
+ <%= "\n" + @sections.collect { |text, type| text if type != :header }.compact.join("\n\n") %>
59
27
  </body>
60
28
  </html>
@@ -0,0 +1,114 @@
1
+ function makeFoldable() {
2
+ // Get all elements with the "foldable" class
3
+ const foldableElements = document.querySelectorAll('.foldable');
4
+
5
+ if (foldableElements.length === 0) {
6
+ return; // No foldable elements found
7
+ }
8
+
9
+ // Process each foldable element
10
+ foldableElements.forEach((element, index) => {
11
+ // Skip if this is the footer element
12
+ if (element.id === 'footer') {
13
+ return;
14
+ }
15
+
16
+ // Create a unique ID for this section
17
+ const sectionId = `foldable-section-${index}`;
18
+ const toggleId = `foldable-toggle-${index}`;
19
+
20
+ // Create a container div for the foldable element and its content
21
+ const container = document.createElement('div');
22
+ container.className = 'foldable-section';
23
+ container.id = sectionId;
24
+
25
+ // Create a toggle button
26
+ const toggleButton = document.createElement(element.tagName); //'button');
27
+ toggleButton.id = toggleId;
28
+ toggleButton.className = 'foldable-toggle-btn';
29
+ toggleButton.innerHTML = '▼ ' + element.textContent;
30
+
31
+ // Create a content container
32
+ const contentContainer = document.createElement('div');
33
+ contentContainer.className = 'foldable-content';
34
+ contentContainer.style.cssText = `
35
+ border-left: 2px solid #ccc;
36
+ padding-left: 15px;
37
+ `;
38
+
39
+ // Move the foldable element into the container and replace it with the toggle button
40
+ element.parentNode.insertBefore(container, element);
41
+ container.appendChild(toggleButton);
42
+ container.appendChild(contentContainer);
43
+
44
+ // Move all elements between this foldable element and the next foldable element (or end of document) into the content container
45
+ let nextElement = element.nextElementSibling;
46
+ while (nextElement && !nextElement.classList.contains('foldable')) {
47
+ // Skip the footer element
48
+ if (nextElement.id === 'footer') {
49
+ break;
50
+ }
51
+
52
+ const temp = nextElement.nextElementSibling;
53
+ contentContainer.appendChild(nextElement);
54
+ nextElement = temp;
55
+ }
56
+
57
+ // Remove the original foldable element
58
+ element.remove();
59
+
60
+ // Add click event to toggle visibility
61
+ toggleButton.addEventListener('click', function() {
62
+ const content = this.nextElementSibling;
63
+ if (content.style.display === 'none') {
64
+ content.style.display = 'block';
65
+ this.innerHTML = '▼ ' + this.innerHTML.substring(2);
66
+ } else {
67
+ content.style.display = 'none';
68
+ this.innerHTML = '▶ ' + this.innerHTML.substring(2);
69
+ }
70
+ });
71
+
72
+ // Initially show the content (you can change this to 'none' if you want sections collapsed by default)
73
+ contentContainer.style.display = 'block';
74
+ if(element.classList.contains('startFolded')) {
75
+ toggleButton.click();
76
+ }
77
+ });
78
+ }
79
+
80
+ // Auto-initialize when DOM is loaded
81
+ document.addEventListener('DOMContentLoaded', function() {
82
+ makeFoldable();
83
+ });
84
+
85
+
86
+ // If we switch between light/dark mode then force a refresh so all charts will redraw correctly
87
+ // in the other colour scheme.
88
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
89
+ location.reload()
90
+ })
91
+
92
+ // Draw a diagonal pattern to highlight sections of a bar chart. Based on code found at:
93
+ // https://stackoverflow.com/questions/28569667/fill-chart-js-bar-chart-with-diagonal-stripes-or-other-patterns
94
+ function createDiagonalPattern(color = 'black') {
95
+ // create a 5x5 px canvas for the pattern's base shape
96
+ let shape = document.createElement('canvas')
97
+ shape.width = 5
98
+ shape.height = 5
99
+ // get the context for drawing
100
+ let c = shape.getContext('2d')
101
+ // draw 1st line of the shape
102
+ c.strokeStyle = color
103
+ c.beginPath()
104
+ c.moveTo(1, 0)
105
+ c.lineTo(5, 4)
106
+ c.stroke()
107
+ // draw 2nd line of the shape
108
+ c.beginPath()
109
+ c.moveTo(0, 4)
110
+ c.lineTo(1, 5)
111
+ c.stroke()
112
+ // create the pattern from the shape
113
+ return c.createPattern(shape, 'repeat')
114
+ }
@@ -1,5 +1,6 @@
1
1
  <h2>Burndown by <%= y_axis_title %></h2>
2
2
 
3
+ <%= seam_start %>
3
4
  <div class="chart">
4
5
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
5
6
  </div>
@@ -63,16 +64,20 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
63
64
  }
64
65
  });
65
66
  </script>
67
+ <%= seam_end %>
66
68
 
67
69
  <%
68
70
  link_id = next_id
69
71
  issues_id = next_id
70
72
  %>
71
- [<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
72
- <div id="<%= issues_id %>" style="display: none;">
73
+ <section>
74
+ <div class='foldable startFolded'>Show statistics</div>
75
+ <div id="<%= issues_id %>">
76
+ <%= seam_start 'stats_table' %>
73
77
  <table class='standard' style="margin-left: 1em;">
74
78
  <thead>
75
79
  <th>Sprint</th>
80
+ <th>Length</th>
76
81
  <th>State</th>
77
82
  <th>Started</th>
78
83
  <th>Completed</th>
@@ -85,6 +90,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
85
90
  <% @summary_stats.keys.sort_by(&:start_time).each do |sprint| %>
86
91
  <tr>
87
92
  <td><%= sprint.name %></td>
93
+ <td><%= sprint.day_count %></td>
88
94
  <td><%= sprint.raw['state'] %></td>
89
95
  <% stats = @summary_stats[sprint] %>
90
96
  <td><%= stats.started %></td>
@@ -101,6 +107,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
101
107
  <% end %>
102
108
  </tbody>
103
109
  </table>
110
+ <%= seam_end 'stats_table' %>
104
111
 
105
112
  <p>Legend:
106
113
  <ul>
@@ -109,4 +116,5 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
109
116
  <% end %>
110
117
  </ul>
111
118
  </p>
112
- </div>
119
+ </div>
120
+ </section>
@@ -1,4 +1,4 @@
1
-
1
+ <%= seam_start %>
2
2
  <div class="chart">
3
3
  <canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
4
4
  </div>
@@ -59,4 +59,4 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
59
59
  }
60
60
  });
61
61
  </script>
62
-
62
+ <%= seam_end %>
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class HtmlGenerator
4
+ attr_accessor :file_system, :settings
5
+
6
+ def create_html output_filename:, settings:
7
+ @settings = settings
8
+ html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
9
+ css = load_css html_directory: html_directory
10
+ javascript = file_system.load(File.join(html_directory, 'index.js'))
11
+ erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
12
+ file_system.save_file content: erb.result(binding), filename: output_filename
13
+ end
14
+
15
+ def load_css html_directory:
16
+ base_css_filename = File.join(html_directory, 'index.css')
17
+ base_css = file_system.load(base_css_filename)
18
+
19
+ extra_css_filename = settings['include_css']
20
+ if extra_css_filename
21
+ if File.exist?(extra_css_filename)
22
+ base_css << "\n\n" << file_system.load(extra_css_filename)
23
+ log("Loaded CSS: #{extra_css_filename}")
24
+ else
25
+ log("Unable to find specified CSS file: #{extra_css_filename}")
26
+ end
27
+ end
28
+
29
+ base_css
30
+ end
31
+ end
@@ -3,7 +3,7 @@
3
3
  require 'erb'
4
4
  require 'jirametrics/self_or_issue_dispatcher'
5
5
 
6
- class HtmlReportConfig
6
+ class HtmlReportConfig < HtmlGenerator
7
7
  include SelfOrIssueDispatcher
8
8
 
9
9
  attr_reader :file_config, :sections, :charts
@@ -51,7 +51,10 @@ class HtmlReportConfig
51
51
  @file_config.project_config.all_boards.each_value do |board|
52
52
  raise 'Multiple cycletimes not supported' if board.cycletime
53
53
 
54
- board.cycletime = CycleTimeConfig.new(parent_config: self, label: label, block: block, file_system: file_system)
54
+ board.cycletime = CycleTimeConfig.new(
55
+ possible_statuses: file_config.project_config, label: label, block: block,
56
+ file_system: file_system, settings: settings
57
+ )
55
58
  end
56
59
  end
57
60
 
@@ -70,10 +73,7 @@ class HtmlReportConfig
70
73
 
71
74
  html create_footer
72
75
 
73
- html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
74
- css = load_css html_directory: html_directory
75
- erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
76
- file_system.save_file content: erb.result(binding), filename: @file_config.output_filename
76
+ create_html output_filename: @file_config.output_filename, settings: settings
77
77
  end
78
78
 
79
79
  def file_system
@@ -84,24 +84,6 @@ class HtmlReportConfig
84
84
  file_system.log message
85
85
  end
86
86
 
87
- def load_css html_directory:
88
- base_css_filename = File.join(html_directory, 'index.css')
89
- base_css = file_system.load(base_css_filename)
90
- log("Loaded CSS: #{base_css_filename}")
91
-
92
- extra_css_filename = settings['include_css']
93
- if extra_css_filename
94
- if File.exist?(extra_css_filename)
95
- base_css << "\n\n" << file_system.load(extra_css_filename)
96
- log("Loaded CSS: #{extra_css_filename}")
97
- else
98
- log("Unable to find specified CSS file: #{extra_css_filename}")
99
- end
100
- end
101
-
102
- base_css
103
- end
104
-
105
87
  def board_id id
106
88
  @board_id = id
107
89
  end
@@ -160,7 +142,7 @@ class HtmlReportConfig
160
142
  chart.time_range = project_config.time_range
161
143
  chart.timezone_offset = timezone_offset
162
144
  chart.settings = settings
163
- chart.users = project_config.users
145
+ chart.atlassian_document_format = project_config.atlassian_document_format
164
146
 
165
147
  chart.all_boards = project_config.all_boards
166
148
  chart.board_id = find_board_id
@@ -173,6 +155,7 @@ class HtmlReportConfig
173
155
  after_init_block&.call chart
174
156
 
175
157
  @charts << chart
158
+ chart.before_run
176
159
  html chart.run
177
160
  end
178
161
 
@@ -19,9 +19,10 @@ class Issue
19
19
 
20
20
  # There are cases where we create an Issue of fragments like linked issues and those won't have
21
21
  # changelogs.
22
- return unless @raw['changelog']
22
+ load_history_into_changes if @raw['changelog']
23
23
 
24
- load_history_into_changes
24
+ # As above with fragments, there may not be a fields section
25
+ return unless @raw['fields']
25
26
 
26
27
  # If this is an older pull of data then comments may not be there.
27
28
  load_comments_into_changes if @raw['fields']['comment']
@@ -152,7 +153,7 @@ class Issue
152
153
  # Are we currently in this status? If yes, then return the most recent status change.
153
154
  def currently_in_status *status_names
154
155
  change = most_recent_status_change
155
- return false if change.nil?
156
+ return nil if change.nil?
156
157
 
157
158
  change if change.current_status_matches(*status_names)
158
159
  end
@@ -162,7 +163,7 @@ class Issue
162
163
  category_ids = find_status_category_ids_by_names category_names
163
164
 
164
165
  change = most_recent_status_change
165
- return false if change.nil?
166
+ return nil if change.nil?
166
167
 
167
168
  status = find_or_create_status id: change.value_id, name: change.value
168
169
  change if status && category_ids.include?(status.category.id)
@@ -211,8 +212,91 @@ class Issue
211
212
  first_time_in_status(*board.visible_columns.collect(&:status_ids).flatten)
212
213
  end
213
214
 
215
+ # If this issue will ever be in an active sprint then return the time that it
216
+ # was first added to that sprint, whether or not the sprint was active at that
217
+ # time. Although it seems like an odd thing to calculate, it's a reasonable proxy
218
+ # for 'ready' in cases where the team doesn't have an explicit 'ready' status.
219
+ # You'd be better off with an explicit 'ready' but sometimes that's not an option.
220
+ def first_time_added_to_active_sprint
221
+ unless board.scrum?
222
+ raise 'first_time_added_to_active_sprint() can only be used with Scrum boards: ' \
223
+ "issue=#{key}, board=#{board.inspect}"
224
+ end
225
+ data_clazz = Struct.new(:sprint_id, :sprint_start, :sprint_stop, :change)
226
+
227
+ matching_changes = []
228
+ all_datas = []
229
+
230
+ @changes.each do |change|
231
+ next unless change.sprint?
232
+
233
+ added_sprint_ids = change.value_id - change.old_value_id
234
+ added_sprint_ids.each do |id|
235
+ data = data_clazz.new
236
+ data.sprint_id = id
237
+ data.change = change
238
+ data.sprint_start, data.sprint_stop = find_sprint_start_end(sprint_id: id, change: change)
239
+ all_datas << data
240
+ end
241
+
242
+ removed_sprint_ids = change.old_value_id - change.value_id
243
+ removed_sprint_ids.each do |id|
244
+ data = all_datas.find { |d| d.sprint_id == id }
245
+ # It's possible for an issue to be created inside a sprint and therefore for
246
+ # that add-to-sprint not show in the history.
247
+ next unless data
248
+
249
+ all_datas.delete(data)
250
+ next if data.sprint_start.nil? || data.sprint_start >= change.time
251
+
252
+ matching_changes << data.change
253
+ end
254
+ end
255
+
256
+ # There can't be any more removes so whatever is left is a valid option
257
+ # Now all we care about is if the sprint has started.
258
+ all_datas.each do |data|
259
+ matching_changes << data.change if data.sprint_start
260
+ end
261
+
262
+ matching_changes.min_by(&:time)
263
+ end
264
+
265
+ def find_sprint_start_end sprint_id:, change:
266
+ # There are two different places that sprint data could be found. In theory all
267
+ # sprints would be found in both places. In practice, sometimes what we need is
268
+ # in one or the other but not both.
269
+
270
+ # First look in the actual sprints json. If any issues are in this sprint then it should
271
+ # be here.
272
+ sprint = board.sprints.find { |s| s.id == sprint_id }
273
+ return [sprint.start_time, sprint.completed_time] if sprint
274
+
275
+ # Then look at the sprints inside the issue. Even though the field id may be specified,
276
+ # that custom field may not be present. This happens if it was in that sprint but was
277
+ # then removed, whether or not that sprint had ever started.
278
+ sprint_data = raw['fields'][change.field_id]&.find { |sd| sd['id'].to_i == sprint_id }
279
+ if sprint_data
280
+ start = parse_time(sprint_data['startDate'])
281
+ stop = parse_time(sprint_data['completeDate'])
282
+ return [start, stop]
283
+ end
284
+
285
+ # If we got this far then the sprint can't be found anywhere, so we pretend that it never
286
+ # started. Is this guaranteed to be true? No. In theory if all issues were removed from
287
+ # an active sprint then it would also disappear, even though it had started. Nothing we
288
+ # can do to detect that edge-case though.
289
+ [nil, nil]
290
+ end
291
+
214
292
  def parse_time text
215
- Time.parse(text).getlocal(@timezone_offset)
293
+ if text.nil?
294
+ nil
295
+ elsif text.is_a? String
296
+ Time.parse(text).getlocal(@timezone_offset)
297
+ else
298
+ Time.at(text / 1000).getlocal(@timezone_offset)
299
+ end
216
300
  end
217
301
 
218
302
  def created
@@ -220,6 +304,10 @@ class Issue
220
304
  parse_time @raw['fields']['created'] if @raw['fields']['created']
221
305
  end
222
306
 
307
+ def time_created
308
+ @changes.first
309
+ end
310
+
223
311
  def updated
224
312
  parse_time @raw['fields']['updated']
225
313
  end
@@ -233,11 +321,11 @@ class Issue
233
321
  end
234
322
 
235
323
  def assigned_to
236
- @raw['fields']&.[]('assignee')&.[]('displayName')
324
+ @raw['fields']['assignee']&.[]('displayName')
237
325
  end
238
326
 
239
327
  def assigned_to_icon_url
240
- @raw['fields']&.[]('assignee')&.[]('avatarUrls')&.[]('16x16')
328
+ @raw['fields']['assignee']&.[]('avatarUrls')&.[]('16x16')
241
329
  end
242
330
 
243
331
  # Many test failures are simply unreadable because the default inspect on this class goes
@@ -300,9 +388,7 @@ class Issue
300
388
  results
301
389
  end
302
390
 
303
- def blocked_stalled_changes end_time:, settings: nil
304
- settings ||= @board.project_config.settings
305
-
391
+ def blocked_stalled_statuses settings
306
392
  blocked_statuses = settings['blocked_statuses']
307
393
  stalled_statuses = settings['stalled_statuses']
308
394
  unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
@@ -310,6 +396,14 @@ class Issue
310
396
  "stalled_statuses(#{stalled_statuses.inspect}) must both be arrays"
311
397
  end
312
398
 
399
+ [blocked_statuses, stalled_statuses]
400
+ end
401
+
402
+ def blocked_stalled_changes end_time:, settings: nil
403
+ settings ||= @board.project_config.settings
404
+
405
+ blocked_statuses, stalled_statuses = blocked_stalled_statuses(settings)
406
+
313
407
  blocked_link_texts = settings['blocked_link_text']
314
408
  stalled_threshold = settings['stalled_threshold_days']
315
409
  flagged_means_blocked = !!settings['flagged_means_blocked'] # rubocop:disable Style/DoubleNegation
@@ -608,7 +702,7 @@ class Issue
608
702
 
609
703
  def dump
610
704
  result = +''
611
- result << "#{key} (#{type}): #{compact_text summary, 200}\n"
705
+ result << "#{key} (#{type}): #{compact_text summary, max: 200}\n"
612
706
 
613
707
  assignee = raw['fields']['assignee']
614
708
  result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
@@ -681,9 +775,8 @@ class Issue
681
775
  def done?
682
776
  if artificial? || board.cycletime.nil?
683
777
  # This was probably loaded as a linked issue, which means we don't know what board it really
684
- # belonged to. The best we can do is look at the status category. This case should be rare but
685
- # it can happen.
686
- status.category.name == 'Done'
778
+ # belonged to. The best we can do is look at the status key
779
+ status.category.done?
687
780
  else
688
781
  board.cycletime.done? self
689
782
  end
@@ -706,6 +799,23 @@ class Issue
706
799
  board.sprints.select { |s| sprint_ids.include? s.id }
707
800
  end
708
801
 
802
+ def started_sprints
803
+ sprints.reject { |sprint| sprint.future? }
804
+ end
805
+
806
+ def compact_text text, max: 60
807
+ return '' if text.nil?
808
+
809
+ if text.is_a? Hash
810
+ # We can't effectively compact it but we can convert it into a string.
811
+ text = @board.project_config.atlassian_document_format.to_html(text)
812
+ else
813
+ text = text.gsub(/\s+/, ' ').strip
814
+ text = "#{text[0...max]}..." if text.length > max
815
+ end
816
+ text
817
+ end
818
+
709
819
  private
710
820
 
711
821
  def load_history_into_changes
@@ -730,14 +840,6 @@ class Issue
730
840
  end
731
841
  end
732
842
 
733
- def compact_text text, max = 60
734
- return nil if text.nil?
735
-
736
- text = text.gsub(/\s+/, ' ').strip
737
- text = "#{text[0..max]}..." if text.length > max
738
- text
739
- end
740
-
741
843
  def sort_changes!
742
844
  @changes.sort! do |a, b|
743
845
  # It's common that a resolved will happen at the same time as a status change.
@@ -755,6 +857,9 @@ class Issue
755
857
  first_status = nil
756
858
  first_status_id = nil
757
859
 
860
+ # There won't be a created timestamp in cases where this was a linked issue
861
+ return unless @raw['fields']['created']
862
+
758
863
  created_time = parse_time @raw['fields']['created']
759
864
  first_change = @changes.find { |change| change.field == field_name }
760
865
  if first_change.nil?