jirametrics 2.22 → 2.23

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +10 -2
  3. data/lib/jirametrics/aging_work_bar_chart.rb +11 -0
  4. data/lib/jirametrics/aging_work_table.rb +1 -1
  5. data/lib/jirametrics/anonymizer.rb +74 -1
  6. data/lib/jirametrics/atlassian_document_format.rb +104 -93
  7. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  8. data/lib/jirametrics/board.rb +17 -3
  9. data/lib/jirametrics/change_item.rb +4 -3
  10. data/lib/jirametrics/chart_base.rb +80 -1
  11. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
  12. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  13. data/lib/jirametrics/cycletime_scatterplot.rb +8 -97
  14. data/lib/jirametrics/daily_wip_chart.rb +27 -7
  15. data/lib/jirametrics/download_config.rb +15 -0
  16. data/lib/jirametrics/downloader.rb +76 -5
  17. data/lib/jirametrics/downloader_for_cloud.rb +39 -0
  18. data/lib/jirametrics/downloader_for_data_center.rb +2 -1
  19. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  20. data/lib/jirametrics/examples/standard_project.rb +15 -5
  21. data/lib/jirametrics/expedited_chart.rb +2 -0
  22. data/lib/jirametrics/exporter.rb +3 -1
  23. data/lib/jirametrics/file_system.rb +4 -0
  24. data/lib/jirametrics/flow_efficiency_scatterplot.rb +2 -0
  25. data/lib/jirametrics/github_gateway.rb +99 -0
  26. data/lib/jirametrics/groupable_issue_chart.rb +2 -0
  27. data/lib/jirametrics/grouping_rules.rb +1 -1
  28. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
  29. data/lib/jirametrics/html/daily_wip_chart.erb +5 -4
  30. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
  31. data/lib/jirametrics/html/expedited_chart.erb +3 -13
  32. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
  33. data/lib/jirametrics/html/sprint_burndown.erb +7 -13
  34. data/lib/jirametrics/html/throughput_chart.erb +5 -8
  35. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +57 -59
  36. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +3 -4
  37. data/lib/jirametrics/html_report_config.rb +1 -0
  38. data/lib/jirametrics/issue.rb +37 -74
  39. data/lib/jirametrics/issue_printer.rb +97 -0
  40. data/lib/jirametrics/project_config.rb +32 -5
  41. data/lib/jirametrics/pull_request.rb +30 -0
  42. data/lib/jirametrics/pull_request_review.rb +13 -0
  43. data/lib/jirametrics/raw_javascript.rb +4 -0
  44. data/lib/jirametrics/settings.json +3 -1
  45. data/lib/jirametrics/sprint_burndown.rb +2 -0
  46. data/lib/jirametrics/stitcher.rb +2 -1
  47. data/lib/jirametrics/throughput_chart.rb +7 -1
  48. data/lib/jirametrics/time_based_histogram.rb +139 -0
  49. data/lib/jirametrics/time_based_scatterplot.rb +100 -0
  50. metadata +11 -5
@@ -3,14 +3,15 @@
3
3
  require 'time'
4
4
 
5
5
  class Issue
6
- attr_reader :changes, :raw, :subtasks, :board
7
- attr_accessor :parent
6
+ attr_reader :changes, :raw, :subtasks, :board, :discarded_changes, :discarded_change_times
7
+ attr_accessor :parent, :github_prs
8
8
 
9
9
  def initialize raw:, board:, timezone_offset: '+00:00'
10
10
  @raw = raw
11
11
  @timezone_offset = timezone_offset
12
12
  @subtasks = []
13
13
  @changes = []
14
+ @github_prs = []
14
15
  @board = board
15
16
 
16
17
  # We only check for this here because if a board isn't passed in then things will fail much
@@ -270,13 +271,19 @@ class Issue
270
271
  # First look in the actual sprints json. If any issues are in this sprint then it should
271
272
  # be here.
272
273
  sprint = board.sprints.find { |s| s.id == sprint_id }
273
- return [sprint.start_time, sprint.completed_time] if sprint
274
+ if sprint
275
+ return [nil, nil] if sprint.future?
276
+
277
+ return [sprint.start_time, sprint.completed_time]
278
+ end
274
279
 
275
280
  # Then look at the sprints inside the issue. Even though the field id may be specified,
276
281
  # that custom field may not be present. This happens if it was in that sprint but was
277
282
  # then removed, whether or not that sprint had ever started.
278
283
  sprint_data = raw['fields'][change.field_id]&.find { |sd| sd['id'].to_i == sprint_id }
279
284
  if sprint_data
285
+ return [nil, nil] if sprint_data['state'] == 'future'
286
+
280
287
  start = parse_time(sprint_data['startDate'])
281
288
  stop = parse_time(sprint_data['completeDate'])
282
289
  return [start, stop]
@@ -416,6 +423,7 @@ class Issue
416
423
 
417
424
  blocking_status = nil
418
425
  flag = nil
426
+ flag_reason = nil
419
427
 
420
428
  # This mock change is to force the writing of one last entry at the end of the time range.
421
429
  # By doing this, we're able to eliminate a lot of duplicated code in charts.
@@ -430,8 +438,7 @@ class Issue
430
438
  )
431
439
 
432
440
  if change.flagged? && flagged_means_blocked
433
- flag = change.value
434
- flag = nil if change.value == ''
441
+ flag, flag_reason = blocked_stalled_changes_flag_logic change
435
442
  elsif change.status?
436
443
  blocking_status = nil
437
444
  if blocked_statuses.include?(change.value) || stalled_statuses.include?(change.value)
@@ -455,6 +462,7 @@ class Issue
455
462
 
456
463
  new_change = BlockedStalledChange.new(
457
464
  flagged: flag,
465
+ flag_reason: flag_reason,
458
466
  status: blocking_status,
459
467
  status_is_blocking: blocking_status.nil? || blocked_statuses.include?(blocking_status),
460
468
  blocking_issue_keys: (blocking_issue_keys.empty? ? nil : blocking_issue_keys.dup),
@@ -475,6 +483,7 @@ class Issue
475
483
  hack = result.pop
476
484
  result << BlockedStalledChange.new(
477
485
  flagged: hack.flag,
486
+ flag_reason: hack.flag_reason,
478
487
  status: hack.status,
479
488
  status_is_blocking: hack.status_is_blocking,
480
489
  blocking_issue_keys: hack.blocking_issue_keys,
@@ -486,6 +495,28 @@ class Issue
486
495
  result
487
496
  end
488
497
 
498
+ def blocked_stalled_changes_flag_logic change
499
+ flag = change.value
500
+ flag = nil if change.value == ''
501
+ if flag
502
+ # When the user is adding a comment to explain why a flag was set, the flag is set immediately
503
+ # and the comment is inserted after the user hits enter, which means that there is some time
504
+ # gap. If a comment happened shortly after the flag was set, we assume they're linked. This
505
+ # won't always be true and so there will be false positives, but it's a reasonable assumption.
506
+ max_seconds_between_flag_and_comment = 30
507
+ comment_change = changes.find do |c|
508
+ c.comment? && c.time >= change.time && (c.time - change.time) <= max_seconds_between_flag_and_comment
509
+ end
510
+ flag_reason = comment_change && @board.project_config.atlassian_document_format.to_text(comment_change.value)
511
+ # Newer Jira instances may add this extra text but older instances did not. Strip it out if found.
512
+ flag_reason = flag_reason&.sub(/\A:flag_on: Flag added\s*/m, '')&.strip
513
+ flag_reason = nil if flag_reason&.empty?
514
+ else
515
+ flag_reason = nil
516
+ end
517
+ [flag, flag_reason]
518
+ end
519
+
489
520
  def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
490
521
  stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
491
522
 
@@ -701,75 +732,7 @@ class Issue
701
732
  end
702
733
 
703
734
  def dump
704
- result = +''
705
- result << "#{key} (#{type}): #{compact_text summary, max: 200}\n"
706
-
707
- assignee = raw['fields']['assignee']
708
- result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
709
-
710
- raw['fields']['issuelinks']&.each do |link|
711
- result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
712
- result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
713
- end
714
- history = [] # time, type, detail
715
-
716
- if board.cycletime
717
- started_at, stopped_at = board.cycletime.started_stopped_times(self)
718
- history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
719
- history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
720
- else
721
- result << " Unable to determine start/end times as board #{board.id} has no cycletime specified\n"
722
- end
723
-
724
- @discarded_change_times&.each do |time|
725
- history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
726
- end
727
-
728
- (changes + (@discarded_changes || [])).each do |change|
729
- if change.status?
730
- value = "#{change.value.inspect}:#{change.value_id.inspect}"
731
- old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
732
- else
733
- value = compact_text(change.value).inspect
734
- old_value = change.old_value ? compact_text(change.old_value).inspect : nil
735
- end
736
-
737
- message = +''
738
- message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
739
- message << value
740
- if change.artificial?
741
- message << ' (Artificial entry)' if change.artificial?
742
- else
743
- message << " (Author: #{change.author})"
744
- end
745
- history << [change.time, change.field, message, change.artificial?]
746
- end
747
-
748
- result << " History:\n"
749
- type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
750
- history.sort! do |a, b|
751
- if a[0] == b[0]
752
- if a[1].nil?
753
- 1
754
- elsif b[1].nil?
755
- -1
756
- else
757
- a[1] <=> b[1]
758
- end
759
- else
760
- a[0] <=> b[0]
761
- end
762
- end
763
- history.each do |time, type, detail, _artificial|
764
- if type.nil?
765
- type = '-' * type_width
766
- else
767
- type = (' ' * (type_width - type.length)) << type
768
- end
769
- result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
770
- end
771
-
772
- result
735
+ IssuePrinter.new(self).to_s
773
736
  end
774
737
 
775
738
  def done?
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ class IssuePrinter
4
+ def initialize issue
5
+ @issue = issue
6
+ end
7
+
8
+ def to_s
9
+ issue = @issue
10
+ result = +''
11
+ result << "#{issue.key} (#{issue.type}): #{issue.compact_text issue.summary, max: 200}\n"
12
+
13
+ assignee = issue.raw['fields']['assignee']
14
+ result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
15
+
16
+ issue.raw['fields']['issuelinks']&.each do |link|
17
+ result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
18
+ result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
19
+ end
20
+
21
+ history = [] # time, type, detail
22
+
23
+ if issue.board.cycletime
24
+ started_at, stopped_at = issue.board.cycletime.started_stopped_times(issue)
25
+ history << [started_at, nil, 'vvvv Started here vvvv', true] if started_at
26
+ history << [stopped_at, nil, '^^^^ Finished here ^^^^', true] if stopped_at
27
+ else
28
+ result << " Unable to determine start/end times as board #{issue.board.id} has no cycletime specified\n"
29
+ end
30
+
31
+ issue.discarded_change_times&.each do |time|
32
+ history << [time, nil, '^^^^ Changes discarded ^^^^', true]
33
+ end
34
+
35
+ (issue.changes + (issue.discarded_changes || [])).each do |change|
36
+ history << [change.time, change.field, create_change_message(change: change, issue: issue), change.artificial?]
37
+ end
38
+
39
+ result << " History:\n"
40
+ type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
41
+ sort_history!(history)
42
+ history.each do |time, type, detail, _artificial|
43
+ type = type.nil? ? '-' * type_width : type.rjust(type_width)
44
+ result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
45
+ end
46
+
47
+ result
48
+ end
49
+
50
+ def create_change_message change:, issue:
51
+ value, old_value = format_change_values(change: change, issue: issue)
52
+
53
+ message = +''
54
+ message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
55
+ message << value
56
+ if change.artificial?
57
+ message << ' (Artificial entry)'
58
+ else
59
+ message << " (Author: #{change.author})"
60
+ end
61
+ message
62
+ end
63
+
64
+ def format_change_values change:, issue:
65
+ if change.status?
66
+ value = "#{change.value.inspect}:#{change.value_id.inspect}"
67
+ old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
68
+ elsif change.sprint?
69
+ added = change.value_id - change.old_value_id
70
+ removed = change.old_value_id - change.value_id
71
+ value = "#{change.value.inspect} #{change.value_id}"
72
+ value << " (added: #{added})" unless added.empty?
73
+ value << " (removed: #{removed})" unless removed.empty?
74
+ old_value = nil
75
+ else
76
+ value = issue.compact_text(change.value).inspect
77
+ old_value = change.old_value ? issue.compact_text(change.old_value).inspect : nil
78
+ end
79
+ [value, old_value]
80
+ end
81
+
82
+ def sort_history! history
83
+ history.sort! do |a, b|
84
+ if a[0] == b[0]
85
+ if a[1].nil?
86
+ 1
87
+ elsif b[1].nil?
88
+ -1
89
+ else
90
+ a[1] <=> b[1]
91
+ end
92
+ else
93
+ a[0] <=> b[0]
94
+ end
95
+ end
96
+ end
97
+ end
@@ -6,7 +6,7 @@ require 'jirametrics/status_collection'
6
6
  class ProjectConfig
7
7
  attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
8
8
  :download_config, :file_configs, :exporter, :data_version, :name, :board_configs,
9
- :settings, :aggregate_config, :discarded_changes_data, :users
9
+ :settings, :aggregate_config, :discarded_changes_data, :users, :fix_versions
10
10
  attr_accessor :time_range, :jira_url, :id
11
11
 
12
12
  def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
@@ -23,6 +23,7 @@ class ProjectConfig
23
23
  @settings = load_settings
24
24
  @id = id
25
25
  @has_loaded_data = false
26
+ @fix_versions = []
26
27
  end
27
28
 
28
29
  def evaluate_next_level
@@ -40,6 +41,7 @@ class ProjectConfig
40
41
  @id = guess_project_id
41
42
  load_project_metadata
42
43
  load_sprints
44
+ load_fix_versions
43
45
  load_users
44
46
  end
45
47
 
@@ -47,10 +49,11 @@ class ProjectConfig
47
49
  return if @exporter.downloading?
48
50
 
49
51
  load_data unless aggregated_project?
50
- anonymize_data if @anonymizer_needed
51
52
 
52
53
  return if load_only
53
54
 
55
+ anonymize_data if @anonymizer_needed
56
+
54
57
  @file_configs.each do |file_config|
55
58
  file_config.run
56
59
  end
@@ -267,9 +270,12 @@ class ProjectConfig
267
270
  end
268
271
 
269
272
  def load_board board_id:, filename:
270
- board = Board.new(
271
- raw: file_system.load_json(filename), possible_statuses: @possible_statuses
272
- )
273
+ raw = file_system.load_json(filename)
274
+
275
+ features_filename = File.join(@target_path, "#{get_file_prefix}_board_#{board_id}_features.json")
276
+ features_raw = file_system.load_json(features_filename) if file_system.file_exist?(features_filename)
277
+
278
+ board = Board.new(raw: raw, possible_statuses: @possible_statuses, features_raw: features_raw)
273
279
  board.project_config = self
274
280
  @all_boards[board_id] = board
275
281
  end
@@ -326,6 +332,13 @@ class ProjectConfig
326
332
  end
327
333
  end
328
334
 
335
+ def load_fix_versions
336
+ filename = File.join(@target_path, "#{get_file_prefix}_fix_versions.json")
337
+ return unless file_system.file_exist?(filename)
338
+
339
+ @fix_versions = file_system.load_json(filename).map { |raw| FixVersion.new(raw) }
340
+ end
341
+
329
342
  def load_project_metadata
330
343
  filename = File.join @target_path, "#{get_file_prefix}_meta.json"
331
344
  json = file_system.load_json(filename)
@@ -362,6 +375,19 @@ class ProjectConfig
362
375
  json.each { |user_data| @users << User.new(raw: user_data) }
363
376
  end
364
377
 
378
+ def attach_github_prs
379
+ filename = File.join(@target_path, "#{get_file_prefix}_github_prs.json")
380
+ return unless File.exist?(filename)
381
+
382
+ prs_by_issue_key = Hash.new { |h, k| h[k] = [] }
383
+ file_system.load_json(filename).each do |raw|
384
+ pr = PullRequest.new(raw: raw)
385
+ pr.issue_keys.each { |key| prs_by_issue_key[key] << pr }
386
+ end
387
+
388
+ @issues.each { |issue| issue.github_prs = prs_by_issue_key[issue.key] }
389
+ end
390
+
365
391
  def atlassian_document_format
366
392
  @atlassian_document_format ||= AtlassianDocumentFormat.new(
367
393
  users: @users, timezone_offset: exporter.timezone_offset
@@ -444,6 +470,7 @@ class ProjectConfig
444
470
  # attached them in the appropriate places, remove any that aren't part of that initial set.
445
471
  issues.reject! { |i| !i.in_initial_query? } # rubocop:disable Style/InverseMethods
446
472
  @issues = issues
473
+ attach_github_prs
447
474
  end
448
475
 
449
476
  @issues
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+ require 'jirametrics/pull_request_review'
5
+
6
+ class PullRequest
7
+ attr_reader :raw
8
+
9
+ def initialize raw:
10
+ @raw = raw
11
+ end
12
+
13
+ def number = @raw['number']
14
+ def repo = @raw['repo']
15
+ def url = @raw['url']
16
+ def title = @raw['title']
17
+ def branch = @raw['branch']
18
+ def state = @raw['state']
19
+ def issue_keys = @raw['issue_keys']
20
+
21
+ def opened_at = Time.parse(@raw['opened_at'])
22
+ def closed_at = @raw['closed_at'] ? Time.parse(@raw['closed_at']) : nil
23
+ def merged_at = @raw['merged_at'] ? Time.parse(@raw['merged_at']) : nil
24
+
25
+ def reviews = (@raw['reviews'] || []).map { |r| PullRequestReview.new(raw: r) }
26
+ def additions = @raw['additions']
27
+ def deletions = @raw['deletions']
28
+ def changed_files = @raw['changed_files']
29
+ def lines_changed = (additions || 0) + (deletions || 0)
30
+ end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'time'
4
+
5
+ class PullRequestReview
6
+ def initialize raw:
7
+ @raw = raw
8
+ end
9
+
10
+ def author = @raw['author']
11
+ def state = @raw['state']
12
+ def submitted_at = Time.parse(@raw['submitted_at'])
13
+ end
@@ -10,4 +10,8 @@ class RawJavascript
10
10
  def to_json(*_args)
11
11
  @content
12
12
  end
13
+
14
+ def == other
15
+ other.is_a?(RawJavascript) && to_json == other.to_json
16
+ end
13
17
  end
@@ -9,5 +9,7 @@
9
9
  "expedited_priority_names": ["Critical", "Highest"],
10
10
  "priority_order": ["Lowest", "Low", "Medium", "High", "Highest"],
11
11
 
12
- "cache_cycletime_calculations": true
12
+ "cache_cycletime_calculations": true,
13
+
14
+ "date_annotations": []
13
15
  }
@@ -29,6 +29,8 @@ class SprintBurndown < ChartBase
29
29
  </div>
30
30
  #{describe_non_working_days}
31
31
  TEXT
32
+ @x_axis_title = 'Date'
33
+ @y_axis_title = 'Items remaining'
32
34
  end
33
35
 
34
36
  def options= arg
@@ -44,7 +44,8 @@ class Stitcher < HtmlGenerator
44
44
  stitch_content = @all_stitches.find { |s| s.file == from_file && s.title == title && s.type == type }
45
45
  return stitch_content.content if stitch_content
46
46
 
47
- raise "Unable to find content in file #{from_file.inspect} matching title: #{title.inspect}"
47
+ file_system.error "Unable to find content in file #{from_file.inspect} matching title: #{title.inspect}"
48
+ ''
48
49
  end
49
50
 
50
51
  def parse_file filename
@@ -15,6 +15,9 @@ class ThroughputChart < ChartBase
15
15
  </div>
16
16
  #{describe_non_working_days}
17
17
  TEXT
18
+ @x_axis_title = nil
19
+ @y_axis_title = 'Count of items'
20
+
18
21
 
19
22
  init_configuration_block(block) do
20
23
  grouping_rules do |issue, rule|
@@ -92,7 +95,10 @@ class ThroughputChart < ChartBase
92
95
  { y: closed_issues.size,
93
96
  x: "#{period.end}T23:59:59",
94
97
  title: ["#{closed_issues.size} items completed #{date_label}"] +
95
- closed_issues.collect { |_stop_date, issue| "#{issue.key} : #{issue.summary}" }
98
+ closed_issues.collect do |_stop_date, issue|
99
+ hint = @issue_hints&.fetch(issue, nil)
100
+ "#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
101
+ end
96
102
  }
97
103
  end
98
104
  end
@@ -0,0 +1,139 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/groupable_issue_chart'
4
+
5
+ class TimeBasedHistogram < ChartBase
6
+ include GroupableIssueChart
7
+
8
+ attr_reader :show_stats
9
+
10
+ def initialize
11
+ super
12
+
13
+ percentiles [50, 85, 98]
14
+ @show_stats = true
15
+ end
16
+
17
+ def percentiles percs = nil
18
+ @percentiles = percs unless percs.nil?
19
+ @percentiles
20
+ end
21
+
22
+ def disable_stats
23
+ @show_stats = false
24
+ end
25
+
26
+ def run
27
+ histogram_items = all_items
28
+ rules_to_items = group_issues histogram_items
29
+
30
+ the_stats = {}
31
+
32
+ overall_histogram = histogram_data_for(items: histogram_items).transform_values(&:size)
33
+ the_stats[:all] = stats_for histogram_data: overall_histogram, percentiles: @percentiles
34
+ data_sets = rules_to_items.keys.collect do |rules|
35
+ the_label = rules.label
36
+ the_histogram = histogram_data_for(items: rules_to_items[rules])
37
+ if @show_stats
38
+ the_stats[the_label] = stats_for(
39
+ histogram_data: the_histogram.transform_values(&:size), percentiles: @percentiles
40
+ )
41
+ end
42
+
43
+ data_set_for(
44
+ histogram_data: the_histogram,
45
+ label: the_label,
46
+ color: rules.color
47
+ )
48
+ end
49
+
50
+ if data_sets.empty?
51
+ return "<h1 class='foldable'>#{@header_text}</h1>" \
52
+ '<div>No data matched the selected criteria. Nothing to show.</div>'
53
+ end
54
+
55
+ wrap_and_render(binding, __FILE__)
56
+ end
57
+
58
+ def histogram_data_for items:
59
+ items_hash = {}
60
+ items.each do |item|
61
+ days = value_for_item item
62
+ (items_hash[days] ||= []) << item if days.positive?
63
+ end
64
+ items_hash
65
+ end
66
+
67
+ def stats_for histogram_data:, percentiles:
68
+ return {} if histogram_data.empty?
69
+
70
+ total_values = histogram_data.values.sum
71
+
72
+ # Calculate the average
73
+ weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
74
+ average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
75
+
76
+ # Find the mode (or modes!) and the spread of the distribution
77
+ sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
78
+ max_freq = sorted_histogram[-1][1]
79
+ mode = sorted_histogram.select { |_v, f| f == max_freq }
80
+
81
+ minmax = histogram_data.keys.minmax
82
+
83
+ # Calculate percentiles
84
+ sorted_values = histogram_data.keys.sort
85
+ cumulative_counts = {}
86
+ cumulative_sum = 0
87
+
88
+ sorted_values.each do |value|
89
+ cumulative_sum += histogram_data[value]
90
+ cumulative_counts[value] = cumulative_sum
91
+ end
92
+
93
+ percentile_results = {}
94
+ percentiles.each do |percentile|
95
+ rank = (percentile / 100.0) * total_values
96
+ percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
97
+ percentile_results[percentile] = percentile_value
98
+ end
99
+
100
+ {
101
+ average: average,
102
+ mode: mode.collect(&:first).sort,
103
+ min: minmax[0],
104
+ max: minmax[1],
105
+ percentiles: percentile_results
106
+ }
107
+ end
108
+
109
+ def sort_items items
110
+ items
111
+ end
112
+
113
+ def label_for_item item, hint:
114
+ raise NotImplementedError, "#{self.class} must implement label_for_item"
115
+ end
116
+
117
+ def data_set_for histogram_data:, label:, color:
118
+ {
119
+ type: 'bar',
120
+ label: label,
121
+ data: histogram_data.keys.sort.filter_map do |days|
122
+ items = histogram_data[days]
123
+ next if items.empty?
124
+
125
+ {
126
+ x: days,
127
+ y: items.size,
128
+ title: [title_for_item(count: items.size, value: days)] +
129
+ sort_items(items).collect do |item|
130
+ hint = @issue_hints&.fetch(item, nil)
131
+ label_for_item(item, hint: hint)
132
+ end
133
+ }
134
+ end,
135
+ backgroundColor: color,
136
+ borderRadius: 0
137
+ }
138
+ end
139
+ end