jirametrics 2.14 → 2.20.1

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 (33) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/anonymizer.rb +8 -6
  3. data/lib/jirametrics/atlassian_document_format.rb +3 -3
  4. data/lib/jirametrics/board_config.rb +2 -1
  5. data/lib/jirametrics/change_item.rb +2 -2
  6. data/lib/jirametrics/chart_base.rb +3 -2
  7. data/lib/jirametrics/cycletime_config.rb +22 -3
  8. data/lib/jirametrics/cycletime_histogram.rb +3 -1
  9. data/lib/jirametrics/daily_view.rb +6 -20
  10. data/lib/jirametrics/data_quality_report.rb +6 -3
  11. data/lib/jirametrics/dependency_chart.rb +4 -1
  12. data/lib/jirametrics/downloader.rb +34 -99
  13. data/lib/jirametrics/downloader_for_cloud.rb +202 -0
  14. data/lib/jirametrics/downloader_for_data_center.rb +94 -0
  15. data/lib/jirametrics/examples/standard_project.rb +9 -9
  16. data/lib/jirametrics/expedited_chart.rb +1 -1
  17. data/lib/jirametrics/exporter.rb +10 -5
  18. data/lib/jirametrics/file_system.rb +24 -1
  19. data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
  20. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +1 -1
  21. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  22. data/lib/jirametrics/html/cycletime_histogram.erb +2 -2
  23. data/lib/jirametrics/html/index.css +0 -10
  24. data/lib/jirametrics/html/index.erb +2 -34
  25. data/lib/jirametrics/html/index.js +90 -0
  26. data/lib/jirametrics/html/sprint_burndown.erb +5 -3
  27. data/lib/jirametrics/html_report_config.rb +5 -3
  28. data/lib/jirametrics/issue.rb +29 -16
  29. data/lib/jirametrics/jira_gateway.rb +55 -17
  30. data/lib/jirametrics/project_config.rb +10 -0
  31. data/lib/jirametrics/settings.json +3 -1
  32. data/lib/jirametrics.rb +19 -70
  33. metadata +6 -3
@@ -0,0 +1,94 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DownloaderForDataCenter < Downloader
4
+ def jira_instance_type
5
+ 'Jira DataCenter'
6
+ end
7
+
8
+ def download_issues board:
9
+ log " Downloading primary issues for board #{board.id}", both: true
10
+ path = File.join(@target_path, "#{file_prefix}_issues/")
11
+ unless Dir.exist?(path)
12
+ log " Creating path #{path}"
13
+ Dir.mkdir(path)
14
+ end
15
+
16
+ filter_id = board_id_to_filter_id[board.id]
17
+ jql = make_jql(filter_id: filter_id)
18
+ jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
19
+
20
+ log " Downloading linked issues for board #{board.id}", both: true
21
+ loop do
22
+ @issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
23
+ break if @issue_keys_pending_download.empty?
24
+
25
+ keys_to_request = @issue_keys_pending_download[0..99]
26
+ @issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
27
+ jql = "key in (#{keys_to_request.join(', ')})"
28
+ jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
29
+ end
30
+ end
31
+
32
+ def jira_search_by_jql jql:, initial_query:, board:, path:
33
+ intercept_jql = @download_config.project_config.settings['intercept_jql']
34
+ jql = intercept_jql.call jql if intercept_jql
35
+
36
+ log " JQL: #{jql}"
37
+ escaped_jql = CGI.escape jql
38
+
39
+ max_results = 100
40
+ start_at = 0
41
+ total = 1
42
+ while start_at < total
43
+ json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
44
+ "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
45
+
46
+ json['issues'].each do |issue_json|
47
+ issue_json['exporter'] = {
48
+ 'in_initial_query' => initial_query
49
+ }
50
+ identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
51
+ file = "#{issue_json['key']}-#{board.id}.json"
52
+
53
+ @file_system.save_json(json: issue_json, filename: File.join(path, file))
54
+ end
55
+
56
+ total = json['total'].to_i
57
+ max_results = json['maxResults']
58
+
59
+ message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
60
+ log message, both: true
61
+
62
+ start_at += json['issues'].size
63
+ end
64
+ end
65
+
66
+ def make_jql filter_id:, today: Date.today
67
+ segments = []
68
+ segments << "filter=#{filter_id}"
69
+
70
+ start_date = @download_config.start_date today: today
71
+
72
+ if start_date
73
+ @download_date_range = start_date..today.to_date
74
+
75
+ # For an incremental download, we want to query from the end of the previous one, not from the
76
+ # beginning of the full range.
77
+ @start_date_in_query = metadata['date_end'] || @download_date_range.begin
78
+ log " Incremental download only. Pulling from #{@start_date_in_query}", both: true if metadata['date_end']
79
+
80
+ # Catch-all to pick up anything that's been around since before the range started but hasn't
81
+ # had an update during the range.
82
+ catch_all = '((status changed OR Sprint is not EMPTY) AND statusCategory != Done)'
83
+
84
+ # Pick up any issues that had a status change in the range
85
+ start_date_text = @start_date_in_query.strftime '%Y-%m-%d'
86
+ # find_in_range = %((status changed DURING ("#{start_date_text} 00:00","#{end_date_text} 23:59")))
87
+ find_in_range = %(updated >= "#{start_date_text} 00:00")
88
+
89
+ segments << "(#{find_in_range} OR #{catch_all})"
90
+ end
91
+
92
+ segments.join ' AND '
93
+ end
94
+ end
@@ -15,15 +15,6 @@ class Exporter
15
15
  self.anonymize if anonymize
16
16
  self.settings.merge! settings
17
17
 
18
- status_category_mappings.each do |status, category|
19
- status_category_mapping status: status, category: category
20
- end
21
-
22
- download do
23
- self.rolling_date_count(rolling_date_count) if rolling_date_count
24
- self.no_earlier_than(no_earlier_than) if no_earlier_than
25
- end
26
-
27
18
  boards.each_key do |board_id|
28
19
  block = boards[board_id]
29
20
  if block == :default
@@ -37,6 +28,15 @@ class Exporter
37
28
  end
38
29
  end
39
30
 
31
+ status_category_mappings.each do |status, category|
32
+ status_category_mapping status: status, category: category
33
+ end
34
+
35
+ download do
36
+ self.rolling_date_count(rolling_date_count) if rolling_date_count
37
+ self.no_earlier_than(no_earlier_than) if no_earlier_than
38
+ end
39
+
40
40
  issues.reject! do |issue|
41
41
  ignore_types.include? issue.type
42
42
  end
@@ -48,7 +48,7 @@ class ExpeditedChart < ChartBase
48
48
  end
49
49
 
50
50
  if data_sets.empty?
51
- '<h1>Expedited work</h1>There is no expedited work in this time period.'
51
+ '<h1 class="foldable">Expedited work</h1><p>There is no expedited work in this time period.</p>'
52
52
  else
53
53
  wrap_and_render(binding, __FILE__)
54
54
  end
@@ -50,24 +50,29 @@ class Exporter
50
50
  end
51
51
 
52
52
  project.download_config.run
53
- downloader = Downloader.new(
53
+ # load_jira_config(download_config.project_config.jira_config)
54
+ # @ignore_ssl_errors = download_config.project_config.settings['ignore_ssl_errors']
55
+ gateway = JiraGateway.new(
56
+ file_system: file_system, jira_config: project.jira_config, settings: project.settings
57
+ )
58
+ downloader = Downloader.create(
54
59
  download_config: project.download_config,
55
60
  file_system: file_system,
56
- jira_gateway: JiraGateway.new(file_system: file_system)
61
+ jira_gateway: gateway
57
62
  )
58
63
  downloader.run
59
64
  end
60
65
  puts "Full output from downloader in #{file_system.logfile_name}"
61
66
  end
62
67
 
63
- def info keys, name_filter:
68
+ def info key, name_filter:
64
69
  selected = []
65
70
  each_project_config(name_filter: name_filter) do |project|
66
71
  project.evaluate_next_level
67
72
 
68
73
  project.run load_only: true
69
74
  project.issues.each do |issue|
70
- selected << [project, issue] if keys.include? issue.key
75
+ selected << [project, issue] if key == issue.key
71
76
  end
72
77
  rescue => e # rubocop:disable Style/RescueStandardError
73
78
  # This happens when we're attempting to load an aggregated project because it hasn't been
@@ -76,7 +81,7 @@ class Exporter
76
81
  end
77
82
 
78
83
  if selected.empty?
79
- file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
84
+ file_system.log "No issues found to match #{key.inspect}"
80
85
  else
81
86
  selected.each do |project, issue|
82
87
  file_system.log "\nProject #{project.name}", also_write_to_stderr: true
@@ -5,6 +5,13 @@ require 'json'
5
5
  class FileSystem
6
6
  attr_accessor :logfile, :logfile_name
7
7
 
8
+ def initialize
9
+ # In almost all cases, this will be immediately replaced in the Exporter
10
+ # but if we fail before we get that far, this will at least let a useful
11
+ # error show up on the console.
12
+ @logfile = $stdout
13
+ end
14
+
8
15
  # Effectively the same as File.read except it forces the encoding to UTF-8
9
16
  def load filename, supress_deprecation: false
10
17
  if filename.end_with?('.json') && !supress_deprecation
@@ -31,6 +38,14 @@ class FileSystem
31
38
  File.write(filename, content)
32
39
  end
33
40
 
41
+ def mkdir path
42
+ FileUtils.mkdir_p path
43
+ end
44
+
45
+ def utime file:, time:
46
+ File.utime time, time, file
47
+ end
48
+
34
49
  def warning message, more: nil
35
50
  log "Warning: #{message}", more: more, also_write_to_stderr: true
36
51
  end
@@ -66,7 +81,15 @@ class FileSystem
66
81
  end
67
82
 
68
83
  def file_exist? filename
69
- File.exist? filename
84
+ File.exist?(filename) && File.file?(filename)
85
+ end
86
+
87
+ def dir_exist? path
88
+ File.exist?(path) && File.directory?(path)
89
+ end
90
+
91
+ def unlink filename
92
+ File.unlink filename
70
93
  end
71
94
 
72
95
  def deprecated message:, date:, depth: 2
@@ -60,7 +60,7 @@ class FlowEfficiencyScatterplot < ChartBase
60
60
  create_dataset(issues: issues, label: rules.label, color: rules.color)
61
61
  end
62
62
 
63
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
63
+ return "<h1 class='foldable'>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
64
64
 
65
65
  wrap_and_render(binding, __FILE__)
66
66
  end
@@ -40,7 +40,7 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
40
40
  color: <%= CssVariable['--grid-line-color'].to_json %>,
41
41
  z: 1 // draw the grid lines on top of the bars
42
42
  },
43
- stacked: true,
43
+ stacked: false,
44
44
  max: <%= (@max_age * 1.1).to_i %>
45
45
  }
46
46
  },
@@ -1,5 +1,5 @@
1
- [<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
2
- <table class='standard' id='<%= issues_id %>' style='display: none;'>
1
+ <div class='foldable startFolded'>Show details</div>
2
+ <table class='standard' id='<%= issues_id %>'>
3
3
  <thead>
4
4
  <tr>
5
5
  <th>Issue</th>
@@ -6,8 +6,8 @@ if show_stats
6
6
  link_id = next_id
7
7
  issues_id = next_id
8
8
  %>
9
- [<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
10
- <div id="<%= issues_id %>" style="display: none;">
9
+ <div class='foldable' style="padding-left: 1em;">Statistics</div>
10
+ <div id="<%= issues_id %>" style="padding-left: 1em;">
11
11
  <div>
12
12
  <table class="standard">
13
13
  <tr>
@@ -78,11 +78,6 @@ body {
78
78
  color: var(--default-text-color);
79
79
  }
80
80
 
81
- h1 {
82
- border: 1px solid black;
83
- background: lightgray;
84
- padding-left: 0.2em;
85
- }
86
81
  dl, dd, dt {
87
82
  padding: 0;
88
83
  margin: 0;
@@ -244,11 +239,6 @@ div.child_issue {
244
239
  --daily-view-selected-issue-background: #474747;
245
240
  }
246
241
 
247
- h1 {
248
- color: #e0e0e0;
249
- background-color: #656565;
250
- }
251
-
252
242
  a[href] {
253
243
  color: #1e8ad6;
254
244
  }
@@ -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 %>
@@ -0,0 +1,90 @@
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
+ })
@@ -68,8 +68,9 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
68
68
  link_id = next_id
69
69
  issues_id = next_id
70
70
  %>
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;">
71
+ <section>
72
+ <div class='foldable startFolded'>Show statistics</div>
73
+ <div id="<%= issues_id %>">
73
74
  <table class='standard' style="margin-left: 1em;">
74
75
  <thead>
75
76
  <th>Sprint</th>
@@ -109,4 +110,5 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
109
110
  <% end %>
110
111
  </ul>
111
112
  </p>
112
- </div>
113
+ </div>
114
+ </section>
@@ -51,7 +51,9 @@ 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
+ parent_config: self, label: label, block: block, file_system: file_system, settings: settings
56
+ )
55
57
  end
56
58
  end
57
59
 
@@ -72,6 +74,7 @@ class HtmlReportConfig
72
74
 
73
75
  html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
74
76
  css = load_css html_directory: html_directory
77
+ javascript = file_system.load(File.join(html_directory, 'index.js'))
75
78
  erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
76
79
  file_system.save_file content: erb.result(binding), filename: @file_config.output_filename
77
80
  end
@@ -87,7 +90,6 @@ class HtmlReportConfig
87
90
  def load_css html_directory:
88
91
  base_css_filename = File.join(html_directory, 'index.css')
89
92
  base_css = file_system.load(base_css_filename)
90
- log("Loaded CSS: #{base_css_filename}")
91
93
 
92
94
  extra_css_filename = settings['include_css']
93
95
  if extra_css_filename
@@ -160,7 +162,7 @@ class HtmlReportConfig
160
162
  chart.time_range = project_config.time_range
161
163
  chart.timezone_offset = timezone_offset
162
164
  chart.settings = settings
163
- chart.users = project_config.users
165
+ chart.atlassian_document_format = project_config.atlassian_document_format
164
166
 
165
167
  chart.all_boards = project_config.all_boards
166
168
  chart.board_id = find_board_id
@@ -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)
@@ -212,7 +213,11 @@ class Issue
212
213
  end
213
214
 
214
215
  def parse_time text
215
- Time.parse(text).getlocal(@timezone_offset)
216
+ if text.is_a? String
217
+ Time.parse(text).getlocal(@timezone_offset)
218
+ else
219
+ Time.at(text / 1000).getlocal(@timezone_offset)
220
+ end
216
221
  end
217
222
 
218
223
  def created
@@ -233,11 +238,11 @@ class Issue
233
238
  end
234
239
 
235
240
  def assigned_to
236
- @raw['fields']&.[]('assignee')&.[]('displayName')
241
+ @raw['fields']['assignee']&.[]('displayName')
237
242
  end
238
243
 
239
244
  def assigned_to_icon_url
240
- @raw['fields']&.[]('assignee')&.[]('avatarUrls')&.[]('16x16')
245
+ @raw['fields']['assignee']&.[]('avatarUrls')&.[]('16x16')
241
246
  end
242
247
 
243
248
  # Many test failures are simply unreadable because the default inspect on this class goes
@@ -608,7 +613,7 @@ class Issue
608
613
 
609
614
  def dump
610
615
  result = +''
611
- result << "#{key} (#{type}): #{compact_text summary, 200}\n"
616
+ result << "#{key} (#{type}): #{compact_text summary, max: 200}\n"
612
617
 
613
618
  assignee = raw['fields']['assignee']
614
619
  result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
@@ -705,6 +710,19 @@ class Issue
705
710
  board.sprints.select { |s| sprint_ids.include? s.id }
706
711
  end
707
712
 
713
+ def compact_text text, max: 60
714
+ return '' if text.nil?
715
+
716
+ if text.is_a? Hash
717
+ # We can't effectively compact it but we can convert it into a string.
718
+ text = @board.project_config.atlassian_document_format.to_html(text)
719
+ else
720
+ text = text.gsub(/\s+/, ' ').strip
721
+ text = "#{text[0...max]}..." if text.length > max
722
+ end
723
+ text
724
+ end
725
+
708
726
  private
709
727
 
710
728
  def load_history_into_changes
@@ -729,14 +747,6 @@ class Issue
729
747
  end
730
748
  end
731
749
 
732
- def compact_text text, max = 60
733
- return nil if text.nil?
734
-
735
- text = text.gsub(/\s+/, ' ').strip
736
- text = "#{text[0..max]}..." if text.length > max
737
- text
738
- end
739
-
740
750
  def sort_changes!
741
751
  @changes.sort! do |a, b|
742
752
  # It's common that a resolved will happen at the same time as a status change.
@@ -754,6 +764,9 @@ class Issue
754
764
  first_status = nil
755
765
  first_status_id = nil
756
766
 
767
+ # There won't be a created timestamp in cases where this was a linked issue
768
+ return unless @raw['fields']['created']
769
+
757
770
  created_time = parse_time @raw['fields']['created']
758
771
  first_change = @changes.find { |change| change.field == field_name }
759
772
  if first_change.nil?