jirametrics 2.4 → 2.11

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 (61) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +9 -4
  3. data/lib/jirametrics/aging_work_bar_chart.rb +13 -11
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
  5. data/lib/jirametrics/aging_work_table.rb +54 -7
  6. data/lib/jirametrics/blocked_stalled_change.rb +1 -1
  7. data/lib/jirametrics/board.rb +44 -15
  8. data/lib/jirametrics/board_config.rb +7 -3
  9. data/lib/jirametrics/board_movement_calculator.rb +147 -0
  10. data/lib/jirametrics/change_item.rb +19 -6
  11. data/lib/jirametrics/chart_base.rb +63 -27
  12. data/lib/jirametrics/css_variable.rb +1 -1
  13. data/lib/jirametrics/cycletime_config.rb +59 -8
  14. data/lib/jirametrics/cycletime_histogram.rb +68 -3
  15. data/lib/jirametrics/cycletime_scatterplot.rb +3 -6
  16. data/lib/jirametrics/daily_wip_by_age_chart.rb +2 -4
  17. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +2 -2
  18. data/lib/jirametrics/daily_wip_by_parent_chart.rb +0 -4
  19. data/lib/jirametrics/daily_wip_chart.rb +7 -9
  20. data/lib/jirametrics/data_quality_report.rb +219 -41
  21. data/lib/jirametrics/dependency_chart.rb +37 -10
  22. data/lib/jirametrics/download_config.rb +12 -0
  23. data/lib/jirametrics/downloader.rb +68 -50
  24. data/lib/jirametrics/estimate_accuracy_chart.rb +1 -2
  25. data/lib/jirametrics/examples/aggregated_project.rb +7 -21
  26. data/lib/jirametrics/examples/standard_project.rb +18 -34
  27. data/lib/jirametrics/expedited_chart.rb +8 -9
  28. data/lib/jirametrics/exporter.rb +28 -11
  29. data/lib/jirametrics/file_config.rb +23 -6
  30. data/lib/jirametrics/file_system.rb +39 -3
  31. data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
  32. data/lib/jirametrics/groupable_issue_chart.rb +1 -3
  33. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
  34. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
  35. data/lib/jirametrics/html/aging_work_table.erb +6 -4
  36. data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
  37. data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
  38. data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
  39. data/lib/jirametrics/html/expedited_chart.erb +1 -10
  40. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
  41. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  42. data/lib/jirametrics/html/index.css +28 -5
  43. data/lib/jirametrics/html/index.erb +8 -4
  44. data/lib/jirametrics/html/sprint_burndown.erb +1 -10
  45. data/lib/jirametrics/html/throughput_chart.erb +1 -10
  46. data/lib/jirametrics/html_report_config.rb +33 -23
  47. data/lib/jirametrics/issue.rb +232 -47
  48. data/lib/jirametrics/jira_gateway.rb +16 -3
  49. data/lib/jirametrics/project_config.rb +245 -134
  50. data/lib/jirametrics/rules.rb +2 -2
  51. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  52. data/lib/jirametrics/settings.json +5 -2
  53. data/lib/jirametrics/sprint_burndown.rb +3 -3
  54. data/lib/jirametrics/status.rb +84 -19
  55. data/lib/jirametrics/status_collection.rb +77 -39
  56. data/lib/jirametrics/throughput_chart.rb +1 -1
  57. data/lib/jirametrics/value_equality.rb +2 -2
  58. data/lib/jirametrics.rb +22 -6
  59. metadata +10 -13
  60. data/lib/jirametrics/discard_changes_before.rb +0 -37
  61. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -5,16 +5,15 @@ require 'jirametrics/self_or_issue_dispatcher'
5
5
 
6
6
  class HtmlReportConfig
7
7
  include SelfOrIssueDispatcher
8
- include DiscardChangesBefore
9
8
 
10
- attr_reader :file_config, :sections
9
+ attr_reader :file_config, :sections, :charts
11
10
 
12
11
  def self.define_chart name:, classname:, deprecated_warning: nil, deprecated_date: nil
13
12
  lines = []
14
13
  lines << "def #{name} &block"
15
14
  lines << ' block = ->(_) {} unless block'
16
15
  if deprecated_warning
17
- lines << " deprecated date: #{deprecated_date.inspect}, message: #{deprecated_warning.inspect}"
16
+ lines << " file_system.deprecated date: #{deprecated_date.inspect}, message: #{deprecated_warning.inspect}"
18
17
  end
19
18
  lines << " execute_chart #{classname}.new(block)"
20
19
  lines << 'end'
@@ -33,6 +32,7 @@ class HtmlReportConfig
33
32
  define_chart name: 'cycletime_histogram', classname: 'CycletimeHistogram'
34
33
  define_chart name: 'estimate_accuracy_chart', classname: 'EstimateAccuracyChart'
35
34
  define_chart name: 'hierarchy_table', classname: 'HierarchyTable'
35
+ define_chart name: 'flow_efficiency_scatterplot', classname: 'FlowEfficiencyScatterplot'
36
36
 
37
37
  define_chart name: 'daily_wip_by_type', classname: 'DailyWipChart',
38
38
  deprecated_warning: 'This is the same as daily_wip_chart. Please use that one', deprecated_date: '2024-05-23'
@@ -42,14 +42,15 @@ class HtmlReportConfig
42
42
  def initialize file_config:, block:
43
43
  @file_config = file_config
44
44
  @block = block
45
- @sections = []
45
+ @sections = [] # Where we store the chunks of text that will be assembled into the HTML
46
+ @charts = [] # Where we store all the charts we executed so we can assert against them.
46
47
  end
47
48
 
48
49
  def cycletime label = nil, &block
49
50
  @file_config.project_config.all_boards.each_value do |board|
50
51
  raise 'Multiple cycletimes not supported' if board.cycletime
51
52
 
52
- board.cycletime = CycleTimeConfig.new(parent_config: self, label: label, block: block)
53
+ board.cycletime = CycleTimeConfig.new(parent_config: self, label: label, block: block, file_system: file_system)
53
54
  end
54
55
  end
55
56
 
@@ -63,9 +64,11 @@ class HtmlReportConfig
63
64
 
64
65
  # The quality report has to be generated last because otherwise cycletime won't have been
65
66
  # set. Then we have to rotate it to the first position so it's at the top of the report.
66
- execute_chart DataQualityReport.new(@original_issue_times || {})
67
+ execute_chart DataQualityReport.new(file_config.project_config.discarded_changes_data)
67
68
  @sections.rotate!(-1)
68
69
 
70
+ html create_footer
71
+
69
72
  html_directory = "#{Pathname.new(File.realpath(__FILE__)).dirname}/html"
70
73
  css = load_css html_directory: html_directory
71
74
  erb = ERB.new file_system.load(File.join(html_directory, 'index.erb'))
@@ -98,9 +101,8 @@ class HtmlReportConfig
98
101
  base_css
99
102
  end
100
103
 
101
- def board_id id = nil
102
- @board_id = id unless id.nil?
103
- @board_id
104
+ def board_id id
105
+ @board_id = id
104
106
  end
105
107
 
106
108
  def timezone_offset
@@ -140,19 +142,6 @@ class HtmlReportConfig
140
142
  end
141
143
  end
142
144
 
143
- def discard_changes_before_hook issues_cutoff_times
144
- # raise 'Cycletime must be defined before using discard_changes_before' unless @cycletime
145
-
146
- @original_issue_times = {}
147
- issues_cutoff_times.each do |issue, cutoff_time|
148
- started = issue.board.cycletime.started_time(issue)
149
- if started && started <= cutoff_time
150
- # We only need to log this if data was discarded
151
- @original_issue_times[issue] = { cutoff_time: cutoff_time, started_time: started }
152
- end
153
- end
154
- end
155
-
156
145
  def dependency_chart &block
157
146
  execute_chart DependencyChart.new block
158
147
  end
@@ -172,7 +161,7 @@ class HtmlReportConfig
172
161
  chart.settings = settings
173
162
 
174
163
  chart.all_boards = project_config.all_boards
175
- chart.board_id = find_board_id if chart.respond_to? :board_id=
164
+ chart.board_id = find_board_id
176
165
  chart.holiday_dates = project_config.exporter.holiday_dates
177
166
 
178
167
  time_range = @file_config.project_config.time_range
@@ -181,6 +170,7 @@ class HtmlReportConfig
181
170
 
182
171
  after_init_block&.call chart
183
172
 
173
+ @charts << chart
184
174
  html chart.run
185
175
  end
186
176
 
@@ -201,4 +191,24 @@ class HtmlReportConfig
201
191
  def boards
202
192
  @file_config.project_config.board_configs.collect(&:id).collect { |id| find_board id }
203
193
  end
194
+
195
+ def create_footer now: DateTime.now
196
+ now = now.new_offset(timezone_offset)
197
+ version = Gem.loaded_specs['jirametrics']&.version || 'Next'
198
+
199
+ <<~HTML
200
+ <section id="footer">
201
+ Report generated on <b>#{now.strftime('%Y-%b-%d')}</b> at <b>#{now.strftime('%I:%M:%S%P %Z')}</b>
202
+ with <a href="https://jirametrics.org">JiraMetrics</a> <b>v#{version}</b>
203
+ </section>
204
+ HTML
205
+ end
206
+
207
+ def discard_changes_before status_becomes: nil, &block
208
+ file_system.deprecated(
209
+ date: '2025-01-09',
210
+ message: 'discard_changes_before is now only supported at the project level'
211
+ )
212
+ file_config.project_config.discard_changes_before status_becomes: status_becomes, &block
213
+ end
204
214
  end
@@ -13,6 +13,12 @@ class Issue
13
13
  @changes = []
14
14
  @board = board
15
15
 
16
+ # We only check for this here because if a board isn't passed in then things will fail much
17
+ # later and be hard to find. Let's find out early.
18
+ raise "No board for issue #{key}" if board.nil?
19
+
20
+ # There are cases where we create an Issue of fragments like linked issues and those won't have
21
+ # changelogs.
16
22
  return unless @raw['changelog']
17
23
 
18
24
  load_history_into_changes
@@ -43,14 +49,26 @@ class Issue
43
49
 
44
50
  def summary = @raw['fields']['summary']
45
51
 
46
- def status = Status.new(raw: @raw['fields']['status'])
47
-
48
52
  def labels = @raw['fields']['labels'] || []
49
53
 
50
54
  def author = @raw['fields']['creator']&.[]('displayName') || ''
51
55
 
52
56
  def resolution = @raw['fields']['resolution']&.[]('name')
53
57
 
58
+ def status
59
+ @status = Status.from_raw(@raw['fields']['status']) unless @status
60
+ @status
61
+ end
62
+
63
+ def status= status
64
+ @status = status
65
+ end
66
+
67
+ def due_date
68
+ text = @raw['fields']['duedate']
69
+ text.nil? ? nil : Date.parse(text)
70
+ end
71
+
54
72
  def url
55
73
  # Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
56
74
  "#{@board.server_url_prefix}/browse/#{key}"
@@ -65,35 +83,43 @@ class Issue
65
83
  end
66
84
 
67
85
  def first_time_in_status *status_names
68
- @changes.find { |change| change.current_status_matches(*status_names) }&.time
86
+ @changes.find { |change| change.current_status_matches(*status_names) }
69
87
  end
70
88
 
71
89
  def first_time_not_in_status *status_names
72
- @changes.find { |change| change.status? && status_names.include?(change.value) == false }&.time
90
+ @changes.find { |change| change.status? && status_names.include?(change.value) == false }
73
91
  end
74
92
 
75
93
  def first_time_in_or_right_of_column column_name
76
94
  first_time_in_status(*board.status_ids_in_or_right_of_column(column_name))
77
95
  end
78
96
 
97
+ def first_time_label_added *labels
98
+ @changes.each do |change|
99
+ next unless change.labels?
100
+
101
+ change_labels = change.value.split
102
+ return change if change_labels.any? { |l| labels.include?(l) }
103
+ end
104
+ nil
105
+ end
106
+
79
107
  def still_in_or_right_of_column column_name
80
108
  still_in_status(*board.status_ids_in_or_right_of_column(column_name))
81
109
  end
82
110
 
83
111
  def still_in
84
- time = nil
85
- @changes.each do |change|
86
- next unless change.status?
87
-
112
+ result = nil
113
+ status_changes.each do |change|
88
114
  current_status_matched = yield change
89
115
 
90
- if current_status_matched && time.nil?
91
- time = change.time
92
- elsif !current_status_matched && time
93
- time = nil
116
+ if current_status_matched && result.nil?
117
+ result = change
118
+ elsif !current_status_matched && result
119
+ result = nil
94
120
  end
95
121
  end
96
- time
122
+ result
97
123
  end
98
124
  private :still_in
99
125
 
@@ -106,50 +132,75 @@ class Issue
106
132
 
107
133
  # If it ever entered one of these categories and it's still there then what was the last time it entered
108
134
  def still_in_status_category *category_names
135
+ category_ids = find_status_category_ids_by_names category_names
136
+
109
137
  still_in do |change|
110
- status = find_status_by_name change.value
111
- category_names.include?(status.category_name) || category_names.include?(status.category_id)
138
+ status = find_or_create_status id: change.value_id, name: change.value
139
+ category_ids.include? status.category.id
112
140
  end
113
141
  end
114
142
 
115
143
  def most_recent_status_change
116
- changes.reverse.find { |change| change.status? }
144
+ # Any issue that we loaded from its own file will always have a status as we artificially insert a status
145
+ # change to represent creation. Issues that were just fragments referenced from a main issue (ie a linked issue)
146
+ # may not have any status changes as we have no idea when it was created. This will be nil in that case
147
+ status_changes.last
117
148
  end
118
149
 
119
- # Are we currently in this status? If yes, then return the time of the most recent status change.
150
+ # Are we currently in this status? If yes, then return the most recent status change.
120
151
  def currently_in_status *status_names
121
152
  change = most_recent_status_change
122
153
  return false if change.nil?
123
154
 
124
- change.time if change.current_status_matches(*status_names)
155
+ change if change.current_status_matches(*status_names)
125
156
  end
126
157
 
127
- # Are we currently in this status category? If yes, then return the time of the most recent status change.
158
+ # Are we currently in this status category? If yes, then return the most recent status change.
128
159
  def currently_in_status_category *category_names
160
+ category_ids = find_status_category_ids_by_names category_names
161
+
129
162
  change = most_recent_status_change
130
163
  return false if change.nil?
131
164
 
132
- status = find_status_by_name change.value
133
- change.time if status && category_names.include?(status.category_name)
165
+ status = find_or_create_status id: change.value_id, name: change.value
166
+ change if status && category_ids.include?(status.category.id)
134
167
  end
135
168
 
136
- def find_status_by_name name
137
- status = board.possible_statuses.find_by_name(name)
138
- return status if status
169
+ def find_or_create_status id:, name:
170
+ status = board.possible_statuses.find_by_id(id)
139
171
 
140
- raise "Status name #{name.inspect} for issue #{key} not found in #{board.possible_statuses.collect(&:name).inspect}"
172
+ unless status
173
+ # Have to pull this list before the call to fabricate or else the warning will incorrectly
174
+ # list this status as one it actually found
175
+ found_statuses = board.possible_statuses.to_s
176
+
177
+ status = board.possible_statuses.fabricate_status_for id: id, name: name
178
+
179
+ message = +'The history for issue '
180
+ message << key
181
+ message << ' references the status ('
182
+ message << "#{name.inspect}:#{id.inspect}"
183
+ message << ') that can\'t be found. We are guessing that this belongs to the '
184
+ message << status.category.to_s
185
+ message << ' status category but that may be wrong. See https://jirametrics.org/faq/#q1 for more '
186
+ message << 'details on defining statuses.'
187
+ board.project_config.file_system.warning message, more: "The statuses we did find are: #{found_statuses}"
188
+ end
189
+
190
+ status
141
191
  end
142
192
 
143
193
  def first_status_change_after_created
144
- @changes.find { |change| change.status? && change.artificial? == false }&.time
194
+ status_changes.find { |change| change.artificial? == false }
145
195
  end
146
196
 
147
197
  def first_time_in_status_category *category_names
148
- @changes.each do |change|
149
- next unless change.status?
198
+ category_ids = find_status_category_ids_by_names category_names
150
199
 
151
- category = find_status_by_name(change.value).category_name
152
- return change.time if category_names.include? category
200
+ status_changes.each do |change|
201
+ to_status = find_or_create_status(id: change.value_id, name: change.value)
202
+ id = to_status.category.id
203
+ return change if category_ids.include? id
153
204
  end
154
205
  nil
155
206
  end
@@ -168,11 +219,11 @@ class Issue
168
219
  end
169
220
 
170
221
  def first_resolution
171
- @changes.find { |change| change.resolution? }&.time
222
+ @changes.find { |change| change.resolution? }
172
223
  end
173
224
 
174
225
  def last_resolution
175
- @changes.reverse.find { |change| change.resolution? }&.time
226
+ @changes.reverse.find { |change| change.resolution? }
176
227
  end
177
228
 
178
229
  def assigned_to
@@ -251,6 +302,7 @@ class Issue
251
302
 
252
303
  blocked_link_texts = settings['blocked_link_text']
253
304
  stalled_threshold = settings['stalled_threshold_days']
305
+ flagged_means_blocked = !!settings['flagged_means_blocked'] # rubocop:disable Style/DoubleNegation
254
306
 
255
307
  blocking_issue_keys = []
256
308
 
@@ -273,7 +325,7 @@ class Issue
273
325
  blocking_stalled_changes: result
274
326
  )
275
327
 
276
- if change.flagged?
328
+ if change.flagged? && flagged_means_blocked
277
329
  flag = change.value
278
330
  flag = nil if change.value == ''
279
331
  elsif change.status?
@@ -362,6 +414,45 @@ class Issue
362
414
  inserted_stalled
363
415
  end
364
416
 
417
+ # return [number of active seconds, total seconds] that this issue had up to the end_time.
418
+ # It does not include data before issue start or after issue end
419
+ def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
420
+ issue_start, issue_stop = @board.cycletime.started_stopped_times(self)
421
+ return [0.0, 0.0] if !issue_start || issue_start > end_time
422
+
423
+ value_add_time = 0.0
424
+ end_time = issue_stop if issue_stop && issue_stop < end_time
425
+
426
+ active_start = nil
427
+ blocked_stalled_changes(end_time: end_time, settings: settings).each_with_index do |change, index|
428
+ break if change.time > end_time
429
+
430
+ if index.zero?
431
+ active_start = change.time if change.active?
432
+ next
433
+ end
434
+
435
+ # Already active and we just got another active.
436
+ next if active_start && change.active?
437
+
438
+ if change.active?
439
+ active_start = change.time
440
+ elsif active_start && change.time >= issue_start
441
+ # Not active now but we have been. Record the active time.
442
+ change_delta = change.time - [issue_start, active_start].max
443
+ value_add_time += change_delta
444
+ active_start = nil
445
+ end
446
+ end
447
+
448
+ if active_start
449
+ change_delta = end_time - [issue_start, active_start].max
450
+ value_add_time += change_delta if change_delta.positive?
451
+ end
452
+
453
+ [value_add_time, end_time - issue_start]
454
+ end
455
+
365
456
  def all_subtask_activity_times
366
457
  subtask_activity_times = []
367
458
  @subtasks.each do |subtask|
@@ -371,7 +462,9 @@ class Issue
371
462
  end
372
463
 
373
464
  def expedited?
374
- names = @board&.expedited_priority_names
465
+ return false unless @board&.project_config
466
+
467
+ names = @board.project_config.settings['expedited_priority_names']
375
468
  return false unless names
376
469
 
377
470
  current_priority = raw['fields']['priority']&.[]('name')
@@ -380,7 +473,9 @@ class Issue
380
473
 
381
474
  def expedited_on_date? date
382
475
  expedited_start = nil
383
- expedited_names = @board&.expedited_priority_names
476
+ return false unless @board&.project_config
477
+
478
+ expedited_names = @board.project_config.settings['expedited_priority_names']
384
479
 
385
480
  changes.each do |change|
386
481
  next unless change.priority?
@@ -433,6 +528,10 @@ class Issue
433
528
  @fix_versions
434
529
  end
435
530
 
531
+ def looks_like_issue_key? key
532
+ !!(key.is_a?(String) && key =~ /^[^-]+-\d+$/)
533
+ end
534
+
436
535
  def parent_key project_config: @board.project_config
437
536
  # Although Atlassian is trying to standardize on one way to determine the parent, today it's a mess.
438
537
  # We try a variety of ways to get the parent and hopefully one of them will work. See this link:
@@ -454,8 +553,13 @@ class Issue
454
553
 
455
554
  custom_field_names&.each do |field_name|
456
555
  parent = fields[field_name]
457
- # A break would be more appropriate than a return but the runtime caused an error when we do that
458
- return parent if parent
556
+ next if parent.nil?
557
+ break if looks_like_issue_key? parent
558
+
559
+ project_config.file_system.log(
560
+ "Custom field #{field_name.inspect} should point to a parent id but found #{parent.inspect}"
561
+ )
562
+ parent = nil
459
563
  end
460
564
  end
461
565
 
@@ -480,6 +584,20 @@ class Issue
480
584
  comparison
481
585
  end
482
586
 
587
+ def discard_changes_before cutoff_time
588
+ rejected_any = false
589
+ @changes.reject! do |change|
590
+ reject = change.status? && change.time <= cutoff_time && change.artificial? == false
591
+ if reject
592
+ (@discarded_changes ||= []) << change
593
+ rejected_any = true
594
+ end
595
+ reject
596
+ end
597
+
598
+ (@discarded_change_times ||= []) << cutoff_time if rejected_any
599
+ end
600
+
483
601
  def dump
484
602
  result = +''
485
603
  result << "#{key} (#{type}): #{compact_text summary, 200}\n"
@@ -487,24 +605,82 @@ class Issue
487
605
  assignee = raw['fields']['assignee']
488
606
  result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
489
607
 
490
- raw['fields']['issuelinks'].each do |link|
608
+ raw['fields']['issuelinks']&.each do |link|
491
609
  result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
492
610
  result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
493
611
  end
494
- changes.each do |change|
495
- value = change.value
496
- old_value = change.old_value
612
+ history = [] # time, type, detail
613
+
614
+ started_at, stopped_at = board.cycletime.started_stopped_times(self)
615
+ history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
616
+ history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
497
617
 
498
- message = " [change] #{change.time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{change.field}] "
499
- message << "#{compact_text(old_value).inspect} -> " unless old_value.nil? || old_value.empty?
500
- message << compact_text(value).inspect
501
- message << " (#{change.author})"
502
- message << ' <<artificial entry>>' if change.artificial?
503
- result << message << "\n"
618
+ @discarded_change_times&.each do |time|
619
+ history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
620
+ end
621
+
622
+ (changes + (@discarded_changes || [])).each do |change|
623
+ if change.status?
624
+ value = "#{change.value.inspect}:#{change.value_id.inspect}"
625
+ old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
626
+ else
627
+ value = compact_text(change.value).inspect
628
+ old_value = change.old_value ? compact_text(change.old_value).inspect : nil
629
+ end
630
+
631
+ message = +''
632
+ message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
633
+ message << value
634
+ if change.artificial?
635
+ message << ' (Artificial entry)' if change.artificial?
636
+ else
637
+ message << " (Author: #{change.author})"
638
+ end
639
+ history << [change.time, change.field, message, change.artificial?]
504
640
  end
641
+
642
+ result << " History:\n"
643
+ type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
644
+ history.sort! do |a, b|
645
+ if a[0] == b[0]
646
+ if a[1].nil?
647
+ 1
648
+ elsif b[1].nil?
649
+ -1
650
+ else
651
+ a[1] <=> b[1]
652
+ end
653
+ else
654
+ a[0] <=> b[0]
655
+ end
656
+ end
657
+ history.each do |time, type, detail, _artificial|
658
+ if type.nil?
659
+ type = '-' * type_width
660
+ else
661
+ type = (' ' * (type_width - type.length)) << type
662
+ end
663
+ result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
664
+ end
665
+
505
666
  result
506
667
  end
507
668
 
669
+ def done?
670
+ if artificial? || board.cycletime.nil?
671
+ # This was probably loaded as a linked issue, which means we don't know what board it really
672
+ # belonged to. The best we can do is look at the status category. This case should be rare but
673
+ # it can happen.
674
+ status.category.name == 'Done'
675
+ else
676
+ board.cycletime.done? self
677
+ end
678
+ end
679
+
680
+ def status_changes
681
+ @changes.select { |change| change.status? }
682
+ end
683
+
508
684
  private
509
685
 
510
686
  def assemble_author raw
@@ -580,4 +756,13 @@ class Issue
580
756
  'toString' => first_status
581
757
  }
582
758
  end
759
+
760
+ def find_status_category_ids_by_names category_names
761
+ category_names.filter_map do |name|
762
+ list = board.possible_statuses.find_all_categories_by_name name
763
+ raise "No status categories found for name: #{name}" if list.empty?
764
+
765
+ list
766
+ end.flatten.collect(&:id)
767
+ end
583
768
  end
@@ -14,9 +14,15 @@ class JiraGateway
14
14
  def call_url relative_url:
15
15
  command = make_curl_command url: "#{@jira_url}#{relative_url}"
16
16
  result = call_command command
17
- JSON.parse result
18
- rescue => e # rubocop:disable Style/RescueStandardError
19
- puts "Error #{e.inspect} when parsing result: #{result.inspect}"
17
+ begin
18
+ json = JSON.parse(result)
19
+ rescue # rubocop:disable Style/RescueStandardError
20
+ raise "Error when parsing result: #{result.inspect}"
21
+ end
22
+
23
+ raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)
24
+
25
+ json
20
26
  end
21
27
 
22
28
  def call_command command
@@ -61,4 +67,11 @@ class JiraGateway
61
67
  command << " --url \"#{url}\""
62
68
  command
63
69
  end
70
+
71
+ def json_successful? json
72
+ return false if json.is_a?(Hash) && (json['error'] || json['errorMessages'] || json['errorMessage'])
73
+ return false if json.is_a?(Array) && json.first == 'errorMessage'
74
+
75
+ true
76
+ end
64
77
  end