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
@@ -4,11 +4,9 @@ require 'time'
4
4
  require 'jirametrics/status_collection'
5
5
 
6
6
  class ProjectConfig
7
- include DiscardChangesBefore
8
-
9
7
  attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
10
8
  :download_config, :file_configs, :exporter, :data_version, :name, :board_configs,
11
- :settings, :aggregate_config
9
+ :settings, :aggregate_config, :discarded_changes_data
12
10
  attr_accessor :time_range, :jira_url, :id
13
11
 
14
12
  def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
@@ -24,32 +22,42 @@ class ProjectConfig
24
22
  @all_boards = {}
25
23
  @settings = load_settings
26
24
  @id = id
25
+ @has_loaded_data = false
27
26
  end
28
27
 
29
28
  def evaluate_next_level
30
29
  instance_eval(&@block) if @block
31
30
  end
32
31
 
33
- def run
34
- unless aggregated_project?
35
- load_all_boards
36
- @id = guess_project_id
37
- load_status_category_mappings
38
- load_project_metadata
39
- load_sprints
40
- end
32
+ def data_downloaded?
33
+ file_system.file_exist? File.join(@target_path, "#{get_file_prefix}_meta.json")
34
+ end
35
+
36
+ def load_data
37
+ return if @has_loaded_data
38
+
39
+ @has_loaded_data = true
40
+ @id = guess_project_id
41
+ load_project_metadata
42
+ load_sprints
43
+ end
44
+
45
+ def run load_only: false
46
+ return if @exporter.downloading?
47
+
48
+ load_data unless aggregated_project?
41
49
  anonymize_data if @anonymizer_needed
42
50
 
43
- @board_configs.each do |board_config|
44
- board_config.run
45
- end
51
+ return if load_only
52
+
46
53
  @file_configs.each do |file_config|
47
54
  file_config.run
48
55
  end
49
56
  end
50
57
 
51
58
  def load_settings
52
- JSON.parse(file_system.load(File.join(__dir__, 'settings.json')))
59
+ # This is the weird exception that we don't ever want mocked out so we skip FileSystem entirely.
60
+ JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
53
61
  end
54
62
 
55
63
  def guess_project_id
@@ -98,90 +106,188 @@ class ProjectConfig
98
106
 
99
107
  def board id:, &block
100
108
  config = BoardConfig.new(id: id, block: block, project_config: self)
109
+ config.run if data_downloaded?
101
110
  @board_configs << config
102
111
  end
103
112
 
104
- def file_prefix prefix = nil
105
- @file_prefix = prefix unless prefix.nil?
113
+ def file_prefix prefix
114
+ # The file_prefix has to be set before almost everything else. It really should have been an attribute
115
+ # on the project declaration itself. Hindsight is 20/20.
116
+ if @file_prefix
117
+ raise "file_prefix should only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
118
+ end
119
+
120
+ @file_prefix = prefix
121
+
122
+ # Yes, this is a wierd place to be initializing this. Unfortunately, it has to happen after the file_prefix
123
+ # is set but before anything inside the project block is run. If only we had made file_prefix an attribute
124
+ # on project, we wouldn't have this ugliness. 🤷‍♂️
125
+ load_status_category_mappings
126
+ load_status_history
127
+ load_all_boards
128
+
106
129
  @file_prefix
107
130
  end
108
131
 
109
- def status_category_mapping status:, category:
110
- add_possible_status Status.new(name: status, category_name: category)
132
+ def get_file_prefix # rubocop:disable Naming/AccessorMethodName
133
+ raise 'file_prefix has not been set yet. Move it to the top of the project declaration.' unless @file_prefix
134
+
135
+ @file_prefix
111
136
  end
112
137
 
113
- def load_all_boards
114
- Dir.foreach(@target_path) do |file|
115
- next unless file =~ /^#{@file_prefix}_board_(\d+)_configuration\.json$/
138
+ # Walk across all the issues and find any status with that name. Return a list of ids that match.
139
+ def find_ids_by_status_name_across_all_issues name
140
+ ids = Set.new
116
141
 
117
- board_id = $1.to_i
118
- load_board board_id: board_id, filename: "#{@target_path}#{file}"
142
+ issues.each do |issue|
143
+ issue.changes.each do |change|
144
+ next unless change.status?
145
+
146
+ ids << change.value_id.to_i if change.value == name
147
+ ids << change.old_value_id.to_i if change.old_value == name
148
+ end
119
149
  end
120
- raise "No boards found for #{@file_prefix} in #{@target_path.inspect}" if @all_boards.empty?
150
+ ids.to_a
121
151
  end
122
152
 
123
- def load_board board_id:, filename:
124
- board = Board.new(
125
- raw: JSON.parse(file_system.load(filename)), possible_statuses: @possible_statuses
153
+ def status_category_mapping status:, category:
154
+ status, status_id = possible_statuses.parse_name_id status
155
+ category, category_id = possible_statuses.parse_name_id category
156
+
157
+ if status_id.nil?
158
+ guesses = find_ids_by_status_name_across_all_issues status
159
+ if guesses.empty?
160
+ file_system.warning "For status_category_mapping status: #{status.inspect}, category: #{category.inspect}\n" \
161
+ "Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
162
+ "histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
163
+ "going to ignore it. If you really want it, then you'll need to specify a status id."
164
+ return
165
+ end
166
+
167
+ if guesses.size > 1
168
+ raise "Cannot guess status id as there are multiple ids for the name #{status.inspect}. Perhaps it's one " \
169
+ "of #{guesses.to_a.sort.inspect}. If you need this mapping then you must specify the status_id."
170
+ end
171
+
172
+ status_id = guesses.first
173
+ file_system.log "status_category_mapping for #{status.inspect} has been mapped to id #{status_id}. " \
174
+ "If that's incorrect then specify the status_id."
175
+ end
176
+
177
+ possible_categories = possible_statuses.find_all_categories_by_name category
178
+ if possible_categories.empty?
179
+ all = possible_statuses.find_all_categories.join(', ')
180
+ raise "No status categories found for name #{category.inspect} in [#{all}]. " \
181
+ 'Either fix the name or add an ID.'
182
+ elsif possible_categories.size > 1
183
+ # Theoretically impossible and yet we've seen wierder things out of Jira so we're prepared.
184
+ raise "More than one status category found with the name #{category.inspect} in " \
185
+ "[#{possible_categories.join(', ')}]. Either fix the name or add an ID"
186
+ end
187
+
188
+ found_category = possible_categories.first
189
+
190
+ if category_id && category_id != found_category.id
191
+ raise "ID is incorrect for status category #{category.inspect}. Did you mean #{found_category.id}?"
192
+ end
193
+
194
+ add_possible_status(
195
+ Status.new(
196
+ name: status, id: status_id,
197
+ category_name: category, category_id: found_category.id, category_key: found_category.key
198
+ )
126
199
  )
127
- board.project_config = self
128
- @all_boards[board_id] = board
129
200
  end
130
201
 
131
- def raise_with_message_about_missing_category_information all_issues = @issues
132
- message = +''
133
- message << "Could not determine categories for some of the statuses used in this data set.\n" \
134
- "Use the 'status_category_mapping' declaration in your config to manually add one.\n" \
135
- 'The mappings we do know about are below:'
202
+ def add_possible_status status
203
+ existing_status = @possible_statuses.find_by_id status.id
136
204
 
137
- @possible_statuses.each do |status|
138
- message << "\n status: #{status.name.inspect}, category: #{status.category_name.inspect}"
205
+ if existing_status && existing_status.name != status.name
206
+ raise "Attempting to redefine the name for status #{status.id} from " \
207
+ "#{existing_status.name.inspect} to #{status.name.inspect}"
139
208
  end
140
209
 
141
- message << "\n\nThe ones we're missing are the following:"
210
+ # If it isn't there, add it and go.
211
+ return @possible_statuses << status unless existing_status
142
212
 
143
- find_statuses_with_no_category_information(all_issues).each do |status_name|
144
- message << "\n status: #{status_name.inspect}, category: <unknown>"
213
+ unless status == existing_status
214
+ raise "Redefining status category for status #{status}. " \
215
+ "original: #{existing_status.category}, " \
216
+ "new: #{status.category}"
145
217
  end
146
218
 
147
- raise message
219
+ # We're registering one we already knew about. This may happen if someone specified a status_category_mapping
220
+ # for something that was already returned from jira.
221
+ #
222
+ # You may be looking at this code and thinking of changing it to spit out a warning since obviously
223
+ # the user has made a mistake. Unfortunately, they may not have made any mistake. Due to inconsistency with the
224
+ # status API, it's possible for two different people to make a request to the same API at the same time and get
225
+ # back a different set of statuses. So that means that some people might need more status/categories mappings than
226
+ # other people for exactly the same instance. See this article for more on that API:
227
+ # https://agiletechnicalexcellence.com/2024/04/12/jira-api-statuses.html
228
+ existing_status
148
229
  end
149
230
 
150
- def find_statuses_with_no_category_information all_issues
151
- missing_statuses = []
152
- all_issues.each do |issue|
153
- issue.changes.each do |change|
154
- next unless change.status?
231
+ def load_all_boards
232
+ Dir.foreach(@target_path) do |file|
233
+ next unless file =~ /^#{get_file_prefix}_board_(\d+)_configuration\.json$/
155
234
 
156
- missing_statuses << change.value unless find_status(name: change.value)
157
- end
235
+ board_id = $1.to_i
236
+ load_board board_id: board_id, filename: "#{@target_path}#{file}"
158
237
  end
159
- missing_statuses.uniq
238
+ end
239
+
240
+ def load_board board_id:, filename:
241
+ board = Board.new(
242
+ raw: file_system.load_json(filename), possible_statuses: @possible_statuses
243
+ )
244
+ board.project_config = self
245
+ @all_boards[board_id] = board
160
246
  end
161
247
 
162
248
  def load_status_category_mappings
163
- filename = "#{@target_path}/#{file_prefix}_statuses.json"
164
- # We may not always have this file. Load it if we can.
165
- return unless File.exist? filename
166
-
167
- statuses = JSON.parse(file_system.load(filename))
168
- .map { |snippet| Status.new(raw: snippet) }
169
- statuses
170
- .find_all { |status| status.global? }
171
- .each { |status| add_possible_status status }
172
- statuses
173
- .find_all { |status| status.project_scoped? }
249
+ filename = File.join @target_path, "#{get_file_prefix}_statuses.json"
250
+ return unless file_system.file_exist? filename
251
+
252
+ file_system
253
+ .load_json(filename)
254
+ .map { |snippet| Status.from_raw(snippet) }
174
255
  .each { |status| add_possible_status status }
175
256
  end
176
257
 
258
+ def load_status_history
259
+ filename = File.join @target_path, "#{get_file_prefix}_status_history.json"
260
+ return unless file_system.file_exist? filename
261
+
262
+ file_system.log ' Loading historical statuses', also_write_to_stderr: true
263
+ file_system
264
+ .load_json(filename)
265
+ .map { |snippet| Status.from_raw(snippet) }
266
+ .each { |status| possible_statuses.historical_status_mappings[status.to_s] = status.category }
267
+
268
+ possible_statuses
269
+ rescue => e # rubocop:disable Style/RescueStandardError
270
+ file_system.warning "Unable to load status history due to #{e.message.inspect}. If this is because of a " \
271
+ 'malformed file then it should be fixed on the next download.'
272
+ end
273
+
177
274
  def load_sprints
178
- Dir.foreach(@target_path) do |file|
179
- next unless file =~ /#{file_prefix}_board_(\d+)_sprints_\d+/
275
+ file_system.foreach(@target_path) do |file|
276
+ next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
277
+
278
+ file_path = File.join(@target_path, file)
279
+ board = @all_boards[$1.to_i]
280
+ unless board
281
+ @exporter.file_system.log(
282
+ 'Found sprint data but can\'t find a matching board in config. ' \
283
+ "File: #{file_path}, Boards: #{@all_boards.keys.sort}"
284
+ )
285
+ next
286
+ end
180
287
 
181
- board_id = $1.to_i
182
288
  timezone_offset = exporter.timezone_offset
183
- JSON.parse(file_system.load("#{target_path}#{file}"))['values'].each do |json|
184
- @all_boards[board_id].sprints << Sprint.new(raw: json, timezone_offset: timezone_offset)
289
+ file_system.load_json(file_path)['values']&.each do |json|
290
+ board.sprints << Sprint.new(raw: json, timezone_offset: timezone_offset)
185
291
  end
186
292
  end
187
293
 
@@ -190,60 +296,30 @@ class ProjectConfig
190
296
  end
191
297
  end
192
298
 
193
- def add_possible_status status
194
- existing_status = find_status(name: status.name)
195
-
196
- if status.project_scoped?
197
- # If the project specific status doesn't change anything then we don't care whether it's
198
- # our project or not.
199
- return if existing_status && existing_status.category_name == status.category_name
299
+ def load_project_metadata
300
+ filename = File.join @target_path, "#{get_file_prefix}_meta.json"
301
+ json = file_system.load_json(filename)
200
302
 
201
- raise_ambiguous_project_id if @id.nil?
303
+ @data_version = json['version'] || 1
202
304
 
203
- # Not our project, ignore it.
204
- return unless status.project_id == @id
305
+ start = to_time(json['date_start'] || json['time_start']) # date_start is the current format. Time is the old.
306
+ stop = to_time(json['date_end'] || json['time_end'], end_of_day: true)
205
307
 
206
- # Replace the old one with this
207
- @possible_statuses.delete(existing_status)
208
- @possible_statuses << status
209
- return
308
+ # If no_earlier_than was set then make sure it's applied here.
309
+ if download_config
310
+ download_config.run
311
+ no_earlier = download_config.no_earlier_than
312
+ if no_earlier
313
+ no_earlier = to_time(no_earlier.to_s)
314
+ start = no_earlier if start < no_earlier
315
+ end
210
316
  end
211
317
 
212
- # If it isn't there, add it and go.
213
- return @possible_statuses << status unless existing_status
214
-
215
- # We're registering the same one twice. Shouldn't be possible with the new status API but it
216
- # did happen with the project specific one.
217
- return if status.category_name == existing_status.category_name
218
-
219
- # If we got this far then someone has called status_category_mapping and is attempting to
220
- # change the category.
221
- raise "Redefining status category #{status} with #{existing_status}. Was one set in the config?"
222
- end
223
-
224
- def raise_ambiguous_project_id
225
- raise 'Ambiguous project id: There is a project specific status that could affect out calculations. ' \
226
- 'We are unable to automatically detect the id of the project so you will have to set it manually ' \
227
- 'in the configuration like: "project id: 5"'
228
- end
229
-
230
- def find_status name:
231
- @possible_statuses.find_by_name name
232
- end
233
-
234
- def load_project_metadata
235
- filename = "#{@target_path}/#{file_prefix}_meta.json"
236
- json = JSON.parse(file_system.load(filename))
237
-
238
- @data_version = json['version'] || 1
239
-
240
- start = json['date_start'] || json['time_start'] # date_start is the current format. Time is the old.
241
- stop = json['date_end'] || json['time_end']
242
- @time_range = to_time(start)..to_time(stop, end_of_day: true)
318
+ @time_range = start..stop
243
319
 
244
320
  @jira_url = json['jira_url']
245
321
  rescue Errno::ENOENT
246
- puts "== Can't load files from the target directory. Did you forget to download first? =="
322
+ file_system.log "Can't load #{filename}. Have you done a download?", also_write_to_stderr: true
247
323
  raise
248
324
  end
249
325
 
@@ -259,7 +335,7 @@ class ProjectConfig
259
335
  unless all_boards&.size == 1
260
336
  message = "If the board_id isn't set then we look for all board configurations in the target" \
261
337
  ' directory. '
262
- if all_boards.nil? || all_boards.empty?
338
+ if all_boards.empty?
263
339
  message += ' In this case, we couldn\'t find any configuration files in the target directory.'
264
340
  else
265
341
  message += 'If there is only one, we use that. In this case we found configurations for' \
@@ -291,21 +367,25 @@ class ProjectConfig
291
367
  end
292
368
 
293
369
  def issues
294
- raise "issues are being loaded before boards in project #{name.inspect}" if all_boards.nil? && !aggregated_project?
295
-
296
370
  unless @issues
297
- if @aggregate_config
371
+ if aggregated_project?
298
372
  raise 'This is an aggregated project and issues should have been included with the include_issues_from ' \
299
373
  'declaration but none are here. Check your config.'
300
374
  end
301
375
 
376
+ return @issues = [] if @exporter.downloading?
377
+ raise 'No data found. Must do a download before an export' unless data_downloaded?
378
+
379
+ load_data if all_boards.empty?
380
+
302
381
  timezone_offset = exporter.timezone_offset
303
382
 
304
- issues_path = "#{@target_path}#{file_prefix}_issues/"
383
+ issues_path = File.join @target_path, "#{get_file_prefix}_issues"
305
384
  if File.exist?(issues_path) && File.directory?(issues_path)
306
385
  issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
307
386
  else
308
- puts "Can't find issues in #{path}. Has a download been done?"
387
+ file_system.log "Can't find directory #{issues_path}. Has a download been done?", also_write_to_stderr: true
388
+ return []
309
389
  end
310
390
 
311
391
  # Attach related issues
@@ -351,8 +431,8 @@ class ProjectConfig
351
431
  raise "No boards found for project #{name.inspect}" if all_boards.empty?
352
432
 
353
433
  if all_boards.size != 1
354
- puts "Multiple boards are in use for project #{name.inspect}. " \
355
- "Picked #{default_board.name.inspect} to attach issues to."
434
+ file_system.log "Multiple boards are in use for project #{name.inspect}. " \
435
+ "Picked #{default_board.name.inspect} to attach issues to.", also_write_to_stderr: true
356
436
  end
357
437
  default_board
358
438
  end
@@ -362,7 +442,7 @@ class ProjectConfig
362
442
  default_board = nil
363
443
 
364
444
  group_filenames_and_board_ids(path: path).each do |filename, board_ids|
365
- content = file_system.load(File.join(path, filename))
445
+ content = file_system.load_json(File.join(path, filename))
366
446
  if board_ids == :unknown
367
447
  boards = [(default_board ||= find_default_board)]
368
448
  else
@@ -370,7 +450,7 @@ class ProjectConfig
370
450
  end
371
451
 
372
452
  boards.each do |board|
373
- issues << Issue.new(raw: JSON.parse(content), timezone_offset: timezone_offset, board: board)
453
+ issues << Issue.new(raw: content, timezone_offset: timezone_offset, board: board)
374
454
  end
375
455
  end
376
456
 
@@ -424,21 +504,52 @@ class ProjectConfig
424
504
  Anonymizer.new(project_config: self).run
425
505
  end
426
506
 
427
- def discard_changes_before_hook issues_cutoff_times
428
- issues_cutoff_times.each do |issue, cutoff_time|
429
- days = (cutoff_time.to_date - issue.changes.first.time.to_date).to_i + 1
430
- message = "#{issue.key}(#{issue.type}) discarding #{days} "
431
- if days == 1
432
- message << "day of data on #{cutoff_time.to_date}"
433
- else
434
- message << "days of data from #{issue.changes.first.time.to_date} to #{cutoff_time.to_date}"
507
+ def file_system
508
+ @exporter.file_system
509
+ end
510
+
511
+ def discard_changes_before status_becomes: nil, &block
512
+ if status_becomes
513
+ status_becomes = [status_becomes] unless status_becomes.is_a? Array
514
+
515
+ block = lambda do |issue|
516
+ trigger_statuses = status_becomes.collect do |status_name|
517
+ if status_name == :backlog
518
+ issue.board.backlog_statuses
519
+ else
520
+ possible_statuses.find_all_by_name status_name
521
+ end
522
+ end.flatten
523
+
524
+ next if trigger_statuses.empty?
525
+
526
+ trigger_status_ids = trigger_statuses.collect(&:id)
527
+
528
+ time = nil
529
+ issue.status_changes.each do |change|
530
+ time = change.time if trigger_status_ids.include?(change.value_id) # && change.artificial? == false
531
+ end
532
+ time
435
533
  end
436
- exporter.file_system.log message
437
534
  end
438
- exporter.file_system.log "Discarded data from #{issues_cutoff_times.count} issues out of a total #{issues.size}"
439
- end
440
535
 
441
- def file_system
442
- @exporter.file_system
536
+ issues.each do |issue|
537
+ cutoff_time = block.call(issue)
538
+ next if cutoff_time.nil?
539
+
540
+ original_start_time = issue.board.cycletime.started_stopped_times(issue).first
541
+ next if original_start_time.nil?
542
+
543
+ issue.discard_changes_before cutoff_time
544
+
545
+ next unless cutoff_time
546
+ next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
547
+
548
+ (@discarded_changes_data ||= []) << {
549
+ cutoff_time: cutoff_time,
550
+ original_start_time: original_start_time,
551
+ issue: issue
552
+ }
553
+ end
443
554
  end
444
555
  end
@@ -1,8 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rules
4
- def ignore
5
- @ignore = true
4
+ def ignore value = true # rubocop:disable Style/OptionalBooleanParameter
5
+ @ignore = value
6
6
  end
7
7
 
8
8
  def ignored?
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SelfOrIssueDispatcher
4
+ # rubocop:disable Style/ArgumentsForwarding
4
5
  def method_missing method_name, *args, &block
5
6
  raise "#{method_name} isn't a method on Issue or #{self.class}" unless ::Issue.method_defined? method_name.to_sym
6
7
 
@@ -8,6 +9,7 @@ module SelfOrIssueDispatcher
8
9
  issue.__send__ method_name, *args, &block
9
10
  end
10
11
  end
12
+ # rubocop:enable Style/ArgumentsForwarding
11
13
 
12
14
  def respond_to_missing?(method_name, include_all = false)
13
15
  ::Issue.method_defined?(method_name.to_sym) || super
@@ -2,6 +2,9 @@
2
2
  "stalled_threshold_days": 5,
3
3
  "stalled_statuses": [],
4
4
 
5
- "blocked_link_text": [],
6
- "blocked_statuses": []
5
+ "blocked_link_text": ["is blocked by"],
6
+ "blocked_statuses": [],
7
+ "flagged_means_blocked": true,
8
+
9
+ "expedited_priority_names": ["Critical", "Highest"]
7
10
  }
@@ -18,7 +18,7 @@ class SprintBurndown < ChartBase
18
18
  attr_accessor :board_id
19
19
 
20
20
  def initialize
21
- super()
21
+ super
22
22
 
23
23
  @summary_stats = {}
24
24
  header_text 'Sprint burndown'
@@ -126,7 +126,7 @@ class SprintBurndown < ChartBase
126
126
  currently_in_sprint = false
127
127
  change_data = []
128
128
 
129
- issue_completed_time = issue.board.cycletime.stopped_time(issue)
129
+ issue_completed_time = issue.board.cycletime.started_stopped_times(issue).last
130
130
  completed_has_been_tracked = false
131
131
 
132
132
  issue.changes.each do |change|
@@ -172,7 +172,7 @@ class SprintBurndown < ChartBase
172
172
  change_item.raw['to'].split(/\s*,\s*/).any? { |id| id.to_i == sprint.id }
173
173
  end
174
174
 
175
- def data_set_by_story_points sprint:, change_data_for_sprint:
175
+ def data_set_by_story_points sprint:, change_data_for_sprint: # rubocop:disable Metrics/CyclomaticComplexity
176
176
  summary_stats = SprintSummaryStats.new
177
177
  summary_stats.completed = 0.0
178
178