jirametrics 2.31 → 3.0

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 (65) 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 +50 -64
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +59 -46
  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 +61 -63
  25. data/lib/jirametrics/dependency_chart.rb +40 -31
  26. data/lib/jirametrics/download_config.rb +0 -3
  27. data/lib/jirametrics/downloader.rb +8 -7
  28. data/lib/jirametrics/downloader_for_cloud.rb +108 -72
  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 +19 -10
  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 +23 -9
  38. data/lib/jirametrics/groupable_issue_chart.rb +3 -0
  39. data/lib/jirametrics/html/index.css +6 -0
  40. data/lib/jirametrics/html_report_config.rb +9 -29
  41. data/lib/jirametrics/issue.rb +290 -389
  42. data/lib/jirametrics/issue_collection.rb +1 -0
  43. data/lib/jirametrics/issue_printer.rb +60 -23
  44. data/lib/jirametrics/jira_gateway.rb +29 -18
  45. data/lib/jirametrics/mcp_server.rb +225 -209
  46. data/lib/jirametrics/project_config.rb +158 -134
  47. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
  48. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
  49. data/lib/jirametrics/rules.rb +1 -0
  50. data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
  51. data/lib/jirametrics/sprint_burndown.rb +142 -242
  52. data/lib/jirametrics/sprint_count_measure.rb +42 -0
  53. data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
  54. data/lib/jirametrics/sprint_points_measure.rb +62 -0
  55. data/lib/jirametrics/sprint_summary_stats.rb +16 -0
  56. data/lib/jirametrics/status_collection.rb +2 -2
  57. data/lib/jirametrics/stitcher.rb +21 -16
  58. data/lib/jirametrics/throughput_chart.rb +38 -24
  59. data/lib/jirametrics/time_based_chart.rb +65 -0
  60. data/lib/jirametrics/time_based_histogram.rb +30 -23
  61. data/lib/jirametrics/time_based_scatterplot.rb +11 -2
  62. data/lib/jirametrics/trend_line_calculator.rb +2 -2
  63. data/lib/jirametrics/wip_by_column_chart.rb +73 -32
  64. data/lib/jirametrics.rb +6 -2
  65. metadata +13 -20
@@ -55,22 +55,13 @@ class ProjectConfig
55
55
 
56
56
  anonymize_data if @anonymizer_needed
57
57
 
58
- @file_configs.each do |file_config|
59
- file_config.run
60
- end
58
+ @file_configs.each(&:run)
61
59
  end
62
60
 
63
61
  def load_settings
64
62
  # This is the weird exception that we don't ever want mocked out so we skip FileSystem entirely.
65
63
  settings = JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
66
64
 
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
65
  settings['blocked_statuses'] = StatusCollection.new
75
66
  settings['stalled_statuses'] = StatusCollection.new
76
67
 
@@ -208,25 +199,45 @@ class ProjectConfig
208
199
  category, category_id = possible_statuses.parse_name_id category
209
200
 
210
201
  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
202
+ status_id = guess_status_id_for status, category
203
+ return if status_id.nil? # no id could be guessed; the mapping is ignored (already warned)
204
+ end
219
205
 
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
206
+ found_category = resolve_category category, category_id
207
+ add_possible_status(
208
+ Status.new(
209
+ name: status, id: status_id,
210
+ category_name: category, category_id: found_category.id, category_key: found_category.key
211
+ )
212
+ )
213
+ end
224
214
 
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."
215
+ # When a status is named but not given an id, guess it from the ids seen under that name across all
216
+ # issue histories. Returns nil (after warning) when none is found, or raises when it's ambiguous.
217
+ def guess_status_id_for status, category
218
+ guesses = find_ids_by_status_name_across_all_issues status
219
+ if guesses.empty?
220
+ file_system.warning "For status_category_mapping status: #{status.inspect}, category: #{category.inspect}\n" \
221
+ "Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
222
+ "histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
223
+ "going to ignore it. If you really want it, then you'll need to specify a status id."
224
+ return nil
228
225
  end
229
226
 
227
+ if guesses.size > 1
228
+ raise "Cannot guess status id as there are multiple ids for the name #{status.inspect}. Perhaps it's one " \
229
+ "of #{guesses.to_a.sort.inspect}. If you need this mapping then you must specify the status_id."
230
+ end
231
+
232
+ status_id = guesses.first
233
+ file_system.log "status_category_mapping for #{status.inspect} has been mapped to id #{status_id}. " \
234
+ "If that's incorrect then specify the status_id."
235
+ status_id
236
+ end
237
+
238
+ # Find the single status category with the given name, raising if there are none, several, or a
239
+ # supplied id that disagrees with the one we found.
240
+ def resolve_category category, category_id
230
241
  possible_categories = possible_statuses.find_all_categories_by_name category
231
242
  if possible_categories.empty?
232
243
  all = possible_statuses.find_all_categories.join(', ')
@@ -239,17 +250,11 @@ class ProjectConfig
239
250
  end
240
251
 
241
252
  found_category = possible_categories.first
242
-
243
253
  if category_id && category_id != found_category.id
244
254
  raise "ID is incorrect for status category #{category.inspect}. Did you mean #{found_category.id}?"
245
255
  end
246
256
 
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
- )
257
+ found_category
253
258
  end
254
259
 
255
260
  def add_possible_status status
@@ -283,9 +288,10 @@ class ProjectConfig
283
288
 
284
289
  def load_all_boards
285
290
  Dir.foreach(@target_path) do |file|
286
- next unless file =~ /^#{get_file_prefix}_board_(\d+)_configuration\.json$/
291
+ match = file.match(/^#{get_file_prefix}_board_(?<board_id>\d+)_configuration\.json$/)
292
+ next unless match
287
293
 
288
- board_id = $1.to_i
294
+ board_id = match[:board_id].to_i
289
295
  load_board board_id: board_id, filename: "#{@target_path}#{file}"
290
296
  end
291
297
  end
@@ -326,16 +332,24 @@ class ProjectConfig
326
332
  .each { |status| possible_statuses.historical_status_mappings[status.to_s] = status.category }
327
333
 
328
334
  possible_statuses
335
+ # This is an optional enrichment file. A malformed one surfaces as anything from a JSON::ParserError to
336
+ # a TypeError/NoMethodError out of Status.from_raw, so we deliberately catch broadly, warn, and carry on
337
+ # without it rather than fail the whole export. The exception itself goes to the log file only (via
338
+ # `more`) so we don't lose it, while the console stays uncluttered.
329
339
  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.'
340
+ file_system.warning(
341
+ 'Unable to load status history. If this is because of a malformed file then it should be ' \
342
+ 'fixed on the next download.',
343
+ more: [e.message, *e.backtrace].join("\n")
344
+ )
332
345
  end
333
346
 
334
347
  def load_sprints
335
348
  file_system.foreach(@target_path) do |file|
336
- next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
349
+ match = file.match(/^#{get_file_prefix}_board_(?<board_id>\d+)_sprints_\d+.json$/)
350
+ next unless match
337
351
 
338
- board_id = $1.to_i
352
+ board_id = match[:board_id].to_i
339
353
  file_path = File.join(@target_path, file)
340
354
  board = @all_boards[board_id]
341
355
  unless board
@@ -373,24 +387,25 @@ class ProjectConfig
373
387
  start = to_time(json['date_start'] || json['time_start']) # date_start is the current format. Time is the old.
374
388
  stop = to_time(json['date_end'] || json['time_end'], end_of_day: true)
375
389
 
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
-
390
+ @time_range = clamp_to_no_earlier_than(start)..stop
388
391
  @jira_url = json['jira_url']
389
392
  rescue Errno::ENOENT
390
393
  file_system.log "Can't load #{filename}. Have you done a download?", also_write_to_stderr: true
391
394
  raise
392
395
  end
393
396
 
397
+ # If the download was configured with a no_earlier_than, the data can't start before it.
398
+ def clamp_to_no_earlier_than start
399
+ return start unless download_config
400
+
401
+ download_config.run
402
+ no_earlier = download_config.no_earlier_than
403
+ return start unless no_earlier
404
+
405
+ no_earlier = to_time(no_earlier.to_s)
406
+ [start, no_earlier].max
407
+ end
408
+
394
409
  def load_users
395
410
  @users = []
396
411
  filename = File.join @target_path, "#{get_file_prefix}_users.json"
@@ -474,39 +489,46 @@ class ProjectConfig
474
489
 
475
490
  load_data if all_boards.empty?
476
491
 
477
- timezone_offset = exporter.timezone_offset
478
-
479
492
  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
493
+ # File.directory? is already false for a path that doesn't exist, so no need to also check File.exist?.
494
+ unless File.directory?(issues_path)
485
495
  file_system.log "Can't find directory #{issues_path}. Has a download been done?", also_write_to_stderr: true
486
496
  return IssueCollection.new
487
497
  end
488
498
 
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
499
+ @issues = build_issues_from_directory(issues_path)
504
500
  attach_github_prs
505
501
  end
506
502
 
507
503
  @issues
508
504
  end
509
505
 
506
+ def build_issues_from_directory issues_path
507
+ file_system.diagnostic "Loading issues from #{issues_path}"
508
+ issues = load_issues_from_issues_directory path: issues_path, timezone_offset: exporter.timezone_offset
509
+ file_system.diagnostic "Loaded #{issues.size} issues from disk"
510
+
511
+ attach_related_issues issues
512
+
513
+ # We'll have some issues that are in the list that weren't part of the initial query. Once we've
514
+ # attached them in the appropriate places, remove any that aren't part of that initial set.
515
+ issues.select!(&:in_initial_query?)
516
+ file_system.diagnostic "Retained #{issues.size} primary issues"
517
+ issues
518
+ end
519
+
520
+ # Wire up subtasks, parents and linked issues now that we have the whole set loaded.
521
+ def attach_related_issues issues
522
+ file_system.diagnostic 'Starting attach phase'
523
+ issues_by_key = issues.to_h { |issue| [issue.key, issue] }
524
+ issues.each do |issue|
525
+ attach_subtasks issue: issue, issues_by_key: issues_by_key
526
+ attach_parent issue: issue, issues_by_key: issues_by_key
527
+ attach_linked_issues issue: issue, issues_by_key: issues_by_key
528
+ end
529
+ file_system.diagnostic 'Attach phase complete'
530
+ end
531
+
510
532
  def attach_subtasks issue:, issues_by_key:
511
533
  issue.raw['fields']['subtasks']&.each do |subtask_element|
512
534
  subtask = issues_by_key[subtask_element['key']]
@@ -567,6 +589,10 @@ class ProjectConfig
567
589
  # It's ok if there are multiple files for the same issue. We load the newest one and map all the other
568
590
  # board ids appropriately.
569
591
  def group_filenames_and_board_ids path:
592
+ group_files_by_issue_key(path).values.to_h { |list| resolve_key_files(list, path) }
593
+ end
594
+
595
+ def group_files_by_issue_key path
570
596
  hash = {}
571
597
  file_system.foreach(path) do |filename|
572
598
  # Matches either FAKE-123.json or FAKE-123-456.json
@@ -574,32 +600,21 @@ class ProjectConfig
574
600
  (hash[key] ||= []) << [filename, board_id&.to_i || :unknown]
575
601
  end
576
602
  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
603
+ hash
604
+ end
605
+
606
+ # Given all the files that share an issue key, returns the [filename, board_ids] pair for the result.
607
+ # A lone file keeps its own board id; when the same issue was exported for several boards we keep the
608
+ # newest file and associate every known board id with it.
609
+ def resolve_key_files list, path
610
+ if list.size == 1
611
+ filename, board_id = *list.first
612
+ [filename, board_id == :unknown ? board_id : [board_id]]
613
+ else
614
+ newest_filename, = list.max_by { |filename, _| File.mtime(File.join(path, filename)) }
615
+ board_ids = list.filter_map { |_, board_id| board_id unless board_id == :unknown }
616
+ [newest_filename, board_ids]
601
617
  end
602
- result
603
618
  end
604
619
 
605
620
  def anonymize
@@ -615,55 +630,64 @@ class ProjectConfig
615
630
  end
616
631
 
617
632
  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 }
633
+ block = discard_block_for(status_becomes) if status_becomes
634
+ apply_discard_block block
635
+ end
623
636
 
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
637
+ # Build the block that, for an issue, returns the time it most recently entered one of the
638
+ # status_becomes statuses (or nil).
639
+ def discard_block_for status_becomes
640
+ status_becomes = [status_becomes] unless status_becomes.is_a? Array
641
+ status_becomes.each { |status_name| validate_discard_status status_name }
642
+ ->(issue) { last_matching_status_time issue, status_becomes }
643
+ end
632
644
 
633
- next if trigger_statuses.empty?
645
+ def last_matching_status_time issue, status_becomes
646
+ trigger_status_ids = trigger_status_ids_for issue, status_becomes
647
+ return if trigger_status_ids.empty?
634
648
 
635
- trigger_status_ids = trigger_statuses.collect(&:id)
649
+ time = nil
650
+ issue.status_changes.each do |change|
651
+ time = change.time if trigger_status_ids.include?(change.value_id) # && change.artificial? == false
652
+ end
653
+ time
654
+ end
636
655
 
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
656
+ def trigger_status_ids_for issue, status_becomes
657
+ status_becomes.collect do |status_name|
658
+ if status_name == :backlog
659
+ issue.board.backlog_statuses
660
+ else
661
+ possible_statuses.find_all_by_name status_name
642
662
  end
643
- end
663
+ end.flatten.collect(&:id)
664
+ end
644
665
 
666
+ def apply_discard_block block
667
+ cycletimes_touched = Set.new
645
668
  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?
669
+ issues.each { |issue| discard_changes_for_issue issue, block, cycletimes_touched }
670
+ cycletimes_touched.each(&:flush_cache)
671
+ end
649
672
 
650
- original_start_time = issue.started_stopped_times.first
651
- next if original_start_time.nil?
673
+ def discard_changes_for_issue issue, block, cycletimes_touched
674
+ cutoff_time = block.call(issue)
675
+ return if cutoff_time.nil?
652
676
 
653
- issue.discard_changes_before cutoff_time
654
- cycletimes_touched << issue.board.cycletime
677
+ original_start_time = issue.started_stopped_times.first
678
+ return if original_start_time.nil?
655
679
 
656
- next unless cutoff_time
657
- next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
680
+ issue.discard_changes_before cutoff_time
681
+ cycletimes_touched << issue.board.cycletime
658
682
 
659
- (@discarded_changes_data ||= []) << {
660
- cutoff_time: cutoff_time,
661
- original_start_time: original_start_time,
662
- issue: issue
663
- }
664
- end
683
+ return unless cutoff_time # a user-supplied block may return false rather than nil
684
+ return if original_start_time > cutoff_time # ie the cutoff would have made no difference.
665
685
 
666
- cycletimes_touched.each { |c| c.flush_cache }
686
+ (@discarded_changes_data ||= []) << {
687
+ cutoff_time: cutoff_time,
688
+ original_start_time: original_start_time,
689
+ issue: issue
690
+ }
667
691
  end
668
692
 
669
693
  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