jirametrics 2.31 → 3.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 (66) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +42 -29
  3. data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
  5. data/lib/jirametrics/aging_work_table.rb +36 -33
  6. data/lib/jirametrics/anonymizer.rb +31 -86
  7. data/lib/jirametrics/atlassian_document_format.rb +11 -4
  8. data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -1
  10. data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
  11. data/lib/jirametrics/board.rb +23 -9
  12. data/lib/jirametrics/board_config.rb +0 -7
  13. data/lib/jirametrics/board_movement_calculator.rb +24 -21
  14. data/lib/jirametrics/cfd_data_builder.rb +46 -44
  15. data/lib/jirametrics/change_item.rb +19 -7
  16. data/lib/jirametrics/chart_base.rb +51 -65
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +60 -47
  18. data/lib/jirametrics/cycle_time_config.rb +35 -46
  19. data/lib/jirametrics/cycletime_histogram.rb +7 -0
  20. data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
  21. data/lib/jirametrics/daily_view.rb +83 -61
  22. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
  23. data/lib/jirametrics/daily_wip_chart.rb +72 -45
  24. data/lib/jirametrics/data_quality_report.rb +70 -66
  25. data/lib/jirametrics/dependency_chart.rb +40 -31
  26. data/lib/jirametrics/download_config.rb +0 -3
  27. data/lib/jirametrics/downloader.rb +39 -14
  28. data/lib/jirametrics/downloader_for_cloud.rb +110 -74
  29. data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
  30. data/lib/jirametrics/examples/standard_project.rb +2 -2
  31. data/lib/jirametrics/expedited_chart.rb +47 -37
  32. data/lib/jirametrics/exporter.rb +203 -12
  33. data/lib/jirametrics/file_config.rb +19 -12
  34. data/lib/jirametrics/file_system.rb +16 -10
  35. data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
  36. data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
  37. data/lib/jirametrics/github_gateway.rb +52 -12
  38. data/lib/jirametrics/groupable_issue_chart.rb +3 -0
  39. data/lib/jirametrics/html/cumulative_flow_diagram.erb +3 -3
  40. data/lib/jirametrics/html/index.css +10 -4
  41. data/lib/jirametrics/html_report_config.rb +9 -29
  42. data/lib/jirametrics/issue.rb +293 -411
  43. data/lib/jirametrics/issue_collection.rb +1 -0
  44. data/lib/jirametrics/issue_printer.rb +60 -23
  45. data/lib/jirametrics/jira_gateway.rb +56 -18
  46. data/lib/jirametrics/mcp_server.rb +226 -210
  47. data/lib/jirametrics/project_config.rb +206 -133
  48. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
  49. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
  50. data/lib/jirametrics/rules.rb +1 -0
  51. data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
  52. data/lib/jirametrics/sprint_burndown.rb +142 -242
  53. data/lib/jirametrics/sprint_count_measure.rb +42 -0
  54. data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
  55. data/lib/jirametrics/sprint_points_measure.rb +62 -0
  56. data/lib/jirametrics/sprint_summary_stats.rb +16 -0
  57. data/lib/jirametrics/status_collection.rb +29 -5
  58. data/lib/jirametrics/stitcher.rb +21 -16
  59. data/lib/jirametrics/throughput_chart.rb +38 -24
  60. data/lib/jirametrics/time_based_chart.rb +65 -0
  61. data/lib/jirametrics/time_based_histogram.rb +30 -23
  62. data/lib/jirametrics/time_based_scatterplot.rb +11 -2
  63. data/lib/jirametrics/trend_line_calculator.rb +2 -2
  64. data/lib/jirametrics/wip_by_column_chart.rb +75 -34
  65. data/lib/jirametrics.rb +35 -4
  66. metadata +13 -20
@@ -55,22 +55,62 @@ class ProjectConfig
55
55
 
56
56
  anonymize_data if @anonymizer_needed
57
57
 
58
- @file_configs.each do |file_config|
59
- file_config.run
58
+ @file_configs.each(&:run)
59
+
60
+ # By this point the charts have walked the issue histories, so every deleted status has already
61
+ # been fabricated lazily. Just report them. (The mcp path has no chart phase, so it resolves them
62
+ # explicitly before reporting.)
63
+ report_fabricated_statuses
64
+ end
65
+
66
+ # Walk every issue's status history so that any status that no longer exists in Jira gets fabricated
67
+ # now. Fabrication is otherwise lazy (it happens the first time a status is touched, which for some
68
+ # code paths is mid-render), so this gives us a single deterministic point where we know they all
69
+ # exist before we report them.
70
+ def resolve_all_status_changes
71
+ issues.each do |issue|
72
+ issue.status_changes.each do |change|
73
+ issue.find_or_create_status id: change.value_id, name: change.value
74
+ end
60
75
  end
61
76
  end
62
77
 
78
+ # Print one consolidated warning covering every status we had to fabricate (a status that an issue
79
+ # passed through but that no longer exists in Jira). It fires often -- any workflow rework leaves
80
+ # these behind -- so we say the explanation once and hand over a single ready-to-paste block of
81
+ # mappings rather than one repetitive paragraph per status.
82
+ def report_fabricated_statuses
83
+ statuses = possible_statuses.fabricated_statuses
84
+ return if statuses.empty? || @fabricated_statuses_reported
85
+
86
+ @fabricated_statuses_reported = true
87
+
88
+ width = statuses.map { |status| "'#{status.name}:#{status.id}'".length }.max
89
+ mappings = statuses.map do |status|
90
+ " #{"'#{status.name}:#{status.id}'".ljust(width)} => '#{status.category.name}',"
91
+ end.join("\n")
92
+
93
+ example_key = possible_statuses.example_issue_key
94
+ info_line =
95
+ if example_key
96
+ "\nRun `jirametrics info #{example_key}` on an affected issue to see how it moved through its workflow."
97
+ else
98
+ ''
99
+ end
100
+
101
+ file_system.warning(
102
+ 'Some statuses in your issue history no longer exist in Jira (they were probably deleted). ' \
103
+ "We've guessed each one's category from its name. If a guess is right the mapping just confirms " \
104
+ 'it and silences this warning; if it is wrong, change the category. Add these to your ' \
105
+ "config's status category mappings; with standard_project that's:\n" \
106
+ " status_category_mappings: {\n#{mappings}\n }\n#{info_line}"
107
+ )
108
+ end
109
+
63
110
  def load_settings
64
111
  # This is the weird exception that we don't ever want mocked out so we skip FileSystem entirely.
65
112
  settings = JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
66
113
 
67
- if settings['blocked_color']
68
- file_system.deprecated message: 'blocked color should be set via css now', date: '2024-05-03'
69
- end
70
- if settings['stalled_color']
71
- file_system.deprecated message: 'stalled color should be set via css now', date: '2024-05-03'
72
- end
73
-
74
114
  settings['blocked_statuses'] = StatusCollection.new
75
115
  settings['stalled_statuses'] = StatusCollection.new
76
116
 
@@ -208,25 +248,45 @@ class ProjectConfig
208
248
  category, category_id = possible_statuses.parse_name_id category
209
249
 
210
250
  if status_id.nil?
211
- guesses = find_ids_by_status_name_across_all_issues status
212
- if guesses.empty?
213
- file_system.warning "For status_category_mapping status: #{status.inspect}, category: #{category.inspect}\n" \
214
- "Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
215
- "histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
216
- "going to ignore it. If you really want it, then you'll need to specify a status id."
217
- return
218
- end
251
+ status_id = guess_status_id_for status, category
252
+ return if status_id.nil? # no id could be guessed; the mapping is ignored (already warned)
253
+ end
219
254
 
220
- if guesses.size > 1
221
- raise "Cannot guess status id as there are multiple ids for the name #{status.inspect}. Perhaps it's one " \
222
- "of #{guesses.to_a.sort.inspect}. If you need this mapping then you must specify the status_id."
223
- end
255
+ found_category = resolve_category category, category_id
256
+ add_possible_status(
257
+ Status.new(
258
+ name: status, id: status_id,
259
+ category_name: category, category_id: found_category.id, category_key: found_category.key
260
+ )
261
+ )
262
+ end
263
+
264
+ # When a status is named but not given an id, guess it from the ids seen under that name across all
265
+ # issue histories. Returns nil (after warning) when none is found, or raises when it's ambiguous.
266
+ def guess_status_id_for status, category
267
+ guesses = find_ids_by_status_name_across_all_issues status
268
+ if guesses.empty?
269
+ file_system.warning "For status_category_mapping status: #{status.inspect}, category: #{category.inspect}\n" \
270
+ "Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
271
+ "histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
272
+ "going to ignore it. If you really want it, then you'll need to specify a status id."
273
+ return nil
274
+ end
224
275
 
225
- status_id = guesses.first
226
- file_system.log "status_category_mapping for #{status.inspect} has been mapped to id #{status_id}. " \
227
- "If that's incorrect then specify the status_id."
276
+ if guesses.size > 1
277
+ raise "Cannot guess status id as there are multiple ids for the name #{status.inspect}. Perhaps it's one " \
278
+ "of #{guesses.to_a.sort.inspect}. If you need this mapping then you must specify the status_id."
228
279
  end
229
280
 
281
+ status_id = guesses.first
282
+ file_system.log "status_category_mapping for #{status.inspect} has been mapped to id #{status_id}. " \
283
+ "If that's incorrect then specify the status_id."
284
+ status_id
285
+ end
286
+
287
+ # Find the single status category with the given name, raising if there are none, several, or a
288
+ # supplied id that disagrees with the one we found.
289
+ def resolve_category category, category_id
230
290
  possible_categories = possible_statuses.find_all_categories_by_name category
231
291
  if possible_categories.empty?
232
292
  all = possible_statuses.find_all_categories.join(', ')
@@ -239,17 +299,11 @@ class ProjectConfig
239
299
  end
240
300
 
241
301
  found_category = possible_categories.first
242
-
243
302
  if category_id && category_id != found_category.id
244
303
  raise "ID is incorrect for status category #{category.inspect}. Did you mean #{found_category.id}?"
245
304
  end
246
305
 
247
- add_possible_status(
248
- Status.new(
249
- name: status, id: status_id,
250
- category_name: category, category_id: found_category.id, category_key: found_category.key
251
- )
252
- )
306
+ found_category
253
307
  end
254
308
 
255
309
  def add_possible_status status
@@ -283,9 +337,10 @@ class ProjectConfig
283
337
 
284
338
  def load_all_boards
285
339
  Dir.foreach(@target_path) do |file|
286
- next unless file =~ /^#{get_file_prefix}_board_(\d+)_configuration\.json$/
340
+ match = file.match(/^#{get_file_prefix}_board_(?<board_id>\d+)_configuration\.json$/)
341
+ next unless match
287
342
 
288
- board_id = $1.to_i
343
+ board_id = match[:board_id].to_i
289
344
  load_board board_id: board_id, filename: "#{@target_path}#{file}"
290
345
  end
291
346
  end
@@ -326,16 +381,24 @@ class ProjectConfig
326
381
  .each { |status| possible_statuses.historical_status_mappings[status.to_s] = status.category }
327
382
 
328
383
  possible_statuses
384
+ # This is an optional enrichment file. A malformed one surfaces as anything from a JSON::ParserError to
385
+ # a TypeError/NoMethodError out of Status.from_raw, so we deliberately catch broadly, warn, and carry on
386
+ # without it rather than fail the whole export. The exception itself goes to the log file only (via
387
+ # `more`) so we don't lose it, while the console stays uncluttered.
329
388
  rescue => e # rubocop:disable Style/RescueStandardError
330
- file_system.warning "Unable to load status history due to #{e.message.inspect}. If this is because of a " \
331
- 'malformed file then it should be fixed on the next download.'
389
+ file_system.warning(
390
+ 'Unable to load status history. If this is because of a malformed file then it should be ' \
391
+ 'fixed on the next download.',
392
+ more: [e.message, *e.backtrace].join("\n")
393
+ )
332
394
  end
333
395
 
334
396
  def load_sprints
335
397
  file_system.foreach(@target_path) do |file|
336
- next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
398
+ match = file.match(/^#{get_file_prefix}_board_(?<board_id>\d+)_sprints_\d+.json$/)
399
+ next unless match
337
400
 
338
- board_id = $1.to_i
401
+ board_id = match[:board_id].to_i
339
402
  file_path = File.join(@target_path, file)
340
403
  board = @all_boards[board_id]
341
404
  unless board
@@ -373,24 +436,25 @@ class ProjectConfig
373
436
  start = to_time(json['date_start'] || json['time_start']) # date_start is the current format. Time is the old.
374
437
  stop = to_time(json['date_end'] || json['time_end'], end_of_day: true)
375
438
 
376
- # If no_earlier_than was set then make sure it's applied here.
377
- if download_config
378
- download_config.run
379
- no_earlier = download_config.no_earlier_than
380
- if no_earlier
381
- no_earlier = to_time(no_earlier.to_s)
382
- start = no_earlier if start < no_earlier
383
- end
384
- end
385
-
386
- @time_range = start..stop
387
-
439
+ @time_range = clamp_to_no_earlier_than(start)..stop
388
440
  @jira_url = json['jira_url']
389
441
  rescue Errno::ENOENT
390
442
  file_system.log "Can't load #{filename}. Have you done a download?", also_write_to_stderr: true
391
443
  raise
392
444
  end
393
445
 
446
+ # If the download was configured with a no_earlier_than, the data can't start before it.
447
+ def clamp_to_no_earlier_than start
448
+ return start unless download_config
449
+
450
+ download_config.run
451
+ no_earlier = download_config.no_earlier_than
452
+ return start unless no_earlier
453
+
454
+ no_earlier = to_time(no_earlier.to_s)
455
+ [start, no_earlier].max
456
+ end
457
+
394
458
  def load_users
395
459
  @users = []
396
460
  filename = File.join @target_path, "#{get_file_prefix}_users.json"
@@ -474,39 +538,46 @@ class ProjectConfig
474
538
 
475
539
  load_data if all_boards.empty?
476
540
 
477
- timezone_offset = exporter.timezone_offset
478
-
479
541
  issues_path = File.join @target_path, "#{get_file_prefix}_issues"
480
- if File.exist?(issues_path) && File.directory?(issues_path)
481
- file_system.diagnostic "Loading issues from #{issues_path}"
482
- issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
483
- file_system.diagnostic "Loaded #{issues.size} issues from disk"
484
- else
542
+ # File.directory? is already false for a path that doesn't exist, so no need to also check File.exist?.
543
+ unless File.directory?(issues_path)
485
544
  file_system.log "Can't find directory #{issues_path}. Has a download been done?", also_write_to_stderr: true
486
545
  return IssueCollection.new
487
546
  end
488
547
 
489
- # Attach related issues
490
- file_system.diagnostic 'Starting attach phase'
491
- issues_by_key = issues.to_h { |i| [i.key, i] }
492
- issues.each do |i|
493
- attach_subtasks issue: i, issues_by_key: issues_by_key
494
- attach_parent issue: i, issues_by_key: issues_by_key
495
- attach_linked_issues issue: i, issues_by_key: issues_by_key
496
- end
497
- file_system.diagnostic 'Attach phase complete'
498
-
499
- # We'll have some issues that are in the list that weren't part of the initial query. Once we've
500
- # attached them in the appropriate places, remove any that aren't part of that initial set.
501
- issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
502
- file_system.diagnostic "Retained #{issues.size} primary issues"
503
- @issues = issues
548
+ @issues = build_issues_from_directory(issues_path)
504
549
  attach_github_prs
505
550
  end
506
551
 
507
552
  @issues
508
553
  end
509
554
 
555
+ def build_issues_from_directory issues_path
556
+ file_system.diagnostic "Loading issues from #{issues_path}"
557
+ issues = load_issues_from_issues_directory path: issues_path, timezone_offset: exporter.timezone_offset
558
+ file_system.diagnostic "Loaded #{issues.size} issues from disk"
559
+
560
+ attach_related_issues issues
561
+
562
+ # We'll have some issues that are in the list that weren't part of the initial query. Once we've
563
+ # attached them in the appropriate places, remove any that aren't part of that initial set.
564
+ issues.select!(&:in_initial_query?)
565
+ file_system.diagnostic "Retained #{issues.size} primary issues"
566
+ issues
567
+ end
568
+
569
+ # Wire up subtasks, parents and linked issues now that we have the whole set loaded.
570
+ def attach_related_issues issues
571
+ file_system.diagnostic 'Starting attach phase'
572
+ issues_by_key = issues.to_h { |issue| [issue.key, issue] }
573
+ issues.each do |issue|
574
+ attach_subtasks issue: issue, issues_by_key: issues_by_key
575
+ attach_parent issue: issue, issues_by_key: issues_by_key
576
+ attach_linked_issues issue: issue, issues_by_key: issues_by_key
577
+ end
578
+ file_system.diagnostic 'Attach phase complete'
579
+ end
580
+
510
581
  def attach_subtasks issue:, issues_by_key:
511
582
  issue.raw['fields']['subtasks']&.each do |subtask_element|
512
583
  subtask = issues_by_key[subtask_element['key']]
@@ -567,6 +638,10 @@ class ProjectConfig
567
638
  # It's ok if there are multiple files for the same issue. We load the newest one and map all the other
568
639
  # board ids appropriately.
569
640
  def group_filenames_and_board_ids path:
641
+ group_files_by_issue_key(path).values.to_h { |list| resolve_key_files(list, path) }
642
+ end
643
+
644
+ def group_files_by_issue_key path
570
645
  hash = {}
571
646
  file_system.foreach(path) do |filename|
572
647
  # Matches either FAKE-123.json or FAKE-123-456.json
@@ -574,32 +649,21 @@ class ProjectConfig
574
649
  (hash[key] ||= []) << [filename, board_id&.to_i || :unknown]
575
650
  end
576
651
  end
577
-
578
- result = {}
579
- hash.values.collect do |list|
580
- if list.size == 1
581
- filename, board_id = *list.first
582
- result[filename] = board_id == :unknown ? board_id : [board_id]
583
- else
584
- max_time = nil
585
- max_board_id = nil
586
- max_filename = nil
587
- all_board_ids = []
588
-
589
- list.each do |filename, board_id|
590
- mtime = File.mtime(File.join(path, filename))
591
- if max_time.nil? || mtime > max_time
592
- max_time = mtime
593
- max_board_id = board_id
594
- max_filename = filename
595
- end
596
- all_board_ids << board_id unless board_id == :unknown
597
- end
598
-
599
- result[max_filename] = all_board_ids
600
- end
652
+ hash
653
+ end
654
+
655
+ # Given all the files that share an issue key, returns the [filename, board_ids] pair for the result.
656
+ # A lone file keeps its own board id; when the same issue was exported for several boards we keep the
657
+ # newest file and associate every known board id with it.
658
+ def resolve_key_files list, path
659
+ if list.size == 1
660
+ filename, board_id = *list.first
661
+ [filename, board_id == :unknown ? board_id : [board_id]]
662
+ else
663
+ newest_filename, = list.max_by { |filename, _| File.mtime(File.join(path, filename)) }
664
+ board_ids = list.filter_map { |_, board_id| board_id unless board_id == :unknown }
665
+ [newest_filename, board_ids]
601
666
  end
602
- result
603
667
  end
604
668
 
605
669
  def anonymize
@@ -615,55 +679,64 @@ class ProjectConfig
615
679
  end
616
680
 
617
681
  def discard_changes_before status_becomes: nil, &block
618
- cycletimes_touched = Set.new
619
- if status_becomes
620
- status_becomes = [status_becomes] unless status_becomes.is_a? Array
621
-
622
- status_becomes.each { |status_name| validate_discard_status status_name }
682
+ block = discard_block_for(status_becomes) if status_becomes
683
+ apply_discard_block block
684
+ end
623
685
 
624
- block = lambda do |issue|
625
- trigger_statuses = status_becomes.collect do |status_name|
626
- if status_name == :backlog
627
- issue.board.backlog_statuses
628
- else
629
- possible_statuses.find_all_by_name status_name
630
- end
631
- end.flatten
686
+ # Build the block that, for an issue, returns the time it most recently entered one of the
687
+ # status_becomes statuses (or nil).
688
+ def discard_block_for status_becomes
689
+ status_becomes = [status_becomes] unless status_becomes.is_a? Array
690
+ status_becomes.each { |status_name| validate_discard_status status_name }
691
+ ->(issue) { last_matching_status_time issue, status_becomes }
692
+ end
632
693
 
633
- next if trigger_statuses.empty?
694
+ def last_matching_status_time issue, status_becomes
695
+ trigger_status_ids = trigger_status_ids_for issue, status_becomes
696
+ return if trigger_status_ids.empty?
634
697
 
635
- trigger_status_ids = trigger_statuses.collect(&:id)
698
+ time = nil
699
+ issue.status_changes.each do |change|
700
+ time = change.time if trigger_status_ids.include?(change.value_id) # && change.artificial? == false
701
+ end
702
+ time
703
+ end
636
704
 
637
- time = nil
638
- issue.status_changes.each do |change|
639
- time = change.time if trigger_status_ids.include?(change.value_id) # && change.artificial? == false
640
- end
641
- time
705
+ def trigger_status_ids_for issue, status_becomes
706
+ status_becomes.collect do |status_name|
707
+ if status_name == :backlog
708
+ issue.board.backlog_statuses
709
+ else
710
+ possible_statuses.find_all_by_name status_name
642
711
  end
643
- end
712
+ end.flatten.collect(&:id)
713
+ end
644
714
 
715
+ def apply_discard_block block
716
+ cycletimes_touched = Set.new
645
717
  file_system.diagnostic "discard_changes_before: processing #{issues.size} issues"
646
- issues.each do |issue|
647
- cutoff_time = block.call(issue)
648
- next if cutoff_time.nil?
718
+ issues.each { |issue| discard_changes_for_issue issue, block, cycletimes_touched }
719
+ cycletimes_touched.each(&:flush_cache)
720
+ end
649
721
 
650
- original_start_time = issue.started_stopped_times.first
651
- next if original_start_time.nil?
722
+ def discard_changes_for_issue issue, block, cycletimes_touched
723
+ cutoff_time = block.call(issue)
724
+ return if cutoff_time.nil?
652
725
 
653
- issue.discard_changes_before cutoff_time
654
- cycletimes_touched << issue.board.cycletime
726
+ original_start_time = issue.started_stopped_times.first
727
+ return if original_start_time.nil?
655
728
 
656
- next unless cutoff_time
657
- next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
729
+ issue.discard_changes_before cutoff_time
730
+ cycletimes_touched << issue.board.cycletime
658
731
 
659
- (@discarded_changes_data ||= []) << {
660
- cutoff_time: cutoff_time,
661
- original_start_time: original_start_time,
662
- issue: issue
663
- }
664
- end
732
+ return unless cutoff_time # a user-supplied block may return false rather than nil
733
+ return if original_start_time > cutoff_time # ie the cutoff would have made no difference.
665
734
 
666
- cycletimes_touched.each { |c| c.flush_cache }
735
+ (@discarded_changes_data ||= []) << {
736
+ cutoff_time: cutoff_time,
737
+ original_start_time: original_start_time,
738
+ issue: issue
739
+ }
667
740
  end
668
741
 
669
742
  def stringify_keys value
@@ -6,7 +6,6 @@ class PullRequestCycleTimeHistogram < TimeBasedHistogram
6
6
  def initialize block
7
7
  super()
8
8
 
9
- @cycletime_unit = :days
10
9
  @x_axis_title = 'Cycle time in days'
11
10
 
12
11
  header_text 'PR Histogram'
@@ -25,15 +24,6 @@ class PullRequestCycleTimeHistogram < TimeBasedHistogram
25
24
  end
26
25
  end
27
26
 
28
- def cycletime_unit unit
29
- unless %i[minutes hours days].include?(unit)
30
- raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
31
- end
32
-
33
- @cycletime_unit = unit
34
- @x_axis_title = "Cycle time in #{unit}"
35
- end
36
-
37
27
  def all_items
38
28
  result = []
39
29
  issues.each do |issue|
@@ -49,16 +39,7 @@ class PullRequestCycleTimeHistogram < TimeBasedHistogram
49
39
  end
50
40
 
51
41
  def value_for_item item
52
- divisor = { minutes: 60.0, hours: 3600.0, days: 86_400.0 }[@cycletime_unit]
53
- ((item.closed_at - item.opened_at) / divisor).ceil
54
- end
55
-
56
- def label_cycletime value
57
- case @cycletime_unit
58
- when :minutes then label_minutes(value)
59
- when :hours then label_hours(value)
60
- when :days then label_days(value)
61
- end
42
+ duration_in_unit item.opened_at, item.closed_at
62
43
  end
63
44
 
64
45
  def title_for_item count:, value:
@@ -6,7 +6,6 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
6
6
  def initialize block
7
7
  super()
8
8
 
9
- @cycletime_unit = :days
10
9
  @y_axis_title = 'Cycle time in days'
11
10
 
12
11
  header_text 'Pull Request (PR) Scatterplot'
@@ -24,15 +23,6 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
24
23
  end
25
24
  end
26
25
 
27
- def cycletime_unit unit
28
- unless %i[minutes hours days].include?(unit)
29
- raise ArgumentError, "cycletime_unit must be :minutes, :hours, or :days, got #{unit.inspect}"
30
- end
31
-
32
- @cycletime_unit = unit
33
- @y_axis_title = "Cycle time in #{unit}"
34
- end
35
-
36
26
  def all_items
37
27
  result = []
38
28
  issues.each do |issue|
@@ -48,23 +38,7 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
48
38
  end
49
39
 
50
40
  def y_value pull_request
51
- if @cycletime_unit == :days
52
- tz = timezone_offset || '+00:00'
53
- opened = pull_request.opened_at.getlocal(tz).to_date
54
- closed = pull_request.closed_at.getlocal(tz).to_date
55
- (closed - opened).to_i + 1
56
- else
57
- divisor = { minutes: 60, hours: 3600 }[@cycletime_unit]
58
- ((pull_request.closed_at - pull_request.opened_at) / divisor).round
59
- end
60
- end
61
-
62
- def label_cycletime value
63
- case @cycletime_unit
64
- when :minutes then label_minutes(value)
65
- when :hours then label_hours(value)
66
- when :days then label_days(value)
67
- end
41
+ duration_in_unit pull_request.opened_at, pull_request.closed_at
68
42
  end
69
43
 
70
44
  def title_value pull_request, rules: nil
@@ -76,13 +50,17 @@ class PullRequestCycleTimeScatterplot < TimeBasedScatterplot
76
50
  def lines_changed_text pull_request
77
51
  return '' unless pull_request.changed_files
78
52
 
53
+ summary = additions_deletions_summary pull_request
54
+ " | Lines changed: [#{summary}], Files changed: #{to_human_readable pull_request.changed_files}"
55
+ end
56
+
57
+ # The "+10 -4" bit: each side only appears when non-zero, joined by a space only when both do.
58
+ def additions_deletions_summary pull_request
79
59
  additions = pull_request.additions || 0
80
60
  deletions = pull_request.deletions || 0
81
- text = +' | Lines changed: ['
82
- text << "+#{to_human_readable additions}" unless additions.zero?
83
- text << ' ' if additions != 0 && deletions != 0
84
- text << "-#{to_human_readable deletions}" unless deletions.zero?
85
- text << "], Files changed: #{to_human_readable pull_request.changed_files}"
86
- text
61
+ parts = []
62
+ parts << "+#{to_human_readable additions}" unless additions.zero?
63
+ parts << "-#{to_human_readable deletions}" unless deletions.zero?
64
+ parts.join(' ')
87
65
  end
88
66
  end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Rules
4
+ # Boolean positional is the config-block DSL's public API (ignore false).
4
5
  def ignore value = true # rubocop:disable Style/OptionalBooleanParameter
5
6
  @ignore = value
6
7
  end
@@ -1,15 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module SelfOrIssueDispatcher
4
- # rubocop:disable Style/ArgumentsForwarding
5
4
  def method_missing method_name, *args, &block
6
5
  raise "#{method_name} isn't a method on Issue or #{self.class}" unless ::Issue.method_defined? method_name.to_sym
7
6
 
8
- ->(issue) do # rubocop:disable Style/Lambda
7
+ lambda do |issue|
9
8
  issue.__send__ method_name, *args, &block
10
9
  end
11
10
  end
12
- # rubocop:enable Style/ArgumentsForwarding
13
11
 
14
12
  def respond_to_missing?(method_name, include_all = false)
15
13
  ::Issue.method_defined?(method_name.to_sym) || super