jirametrics 2.4 → 2.30

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 (100) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +16 -3
  4. data/lib/jirametrics/aging_work_bar_chart.rb +193 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +138 -42
  6. data/lib/jirametrics/aging_work_table.rb +63 -19
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +160 -0
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +6 -4
  11. data/lib/jirametrics/board.rb +74 -22
  12. data/lib/jirametrics/board_config.rb +11 -3
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +155 -0
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +54 -18
  17. data/lib/jirametrics/chart_base.rb +203 -30
  18. data/lib/jirametrics/css_variable.rb +2 -2
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/cycle_time_config.rb +137 -0
  21. data/lib/jirametrics/cycletime_histogram.rb +17 -38
  22. data/lib/jirametrics/cycletime_scatterplot.rb +18 -87
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +5 -8
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +15 -5
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -6
  27. data/lib/jirametrics/daily_wip_chart.rb +36 -16
  28. data/lib/jirametrics/data_quality_report.rb +251 -42
  29. data/lib/jirametrics/dependency_chart.rb +42 -12
  30. data/lib/jirametrics/download_config.rb +27 -0
  31. data/lib/jirametrics/downloader.rb +185 -110
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +75 -14
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +9 -23
  37. data/lib/jirametrics/examples/standard_project.rb +57 -58
  38. data/lib/jirametrics/expedited_chart.rb +11 -10
  39. data/lib/jirametrics/exporter.rb +51 -14
  40. data/lib/jirametrics/file_config.rb +21 -6
  41. data/lib/jirametrics/file_system.rb +96 -4
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +115 -0
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +12 -4
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +8 -17
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +24 -5
  49. data/lib/jirametrics/html/aging_work_table.erb +13 -4
  50. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  51. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  52. data/lib/jirametrics/html/daily_wip_chart.erb +41 -15
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +7 -24
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +81 -0
  56. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  57. data/lib/jirametrics/html/index.css +336 -62
  58. data/lib/jirametrics/html/index.erb +16 -21
  59. data/lib/jirametrics/html/index.js +164 -0
  60. data/lib/jirametrics/html/legacy_colors.css +174 -0
  61. data/lib/jirametrics/html/sprint_burndown.erb +18 -25
  62. data/lib/jirametrics/html/throughput_chart.erb +43 -21
  63. data/lib/jirametrics/html/time_based_histogram.erb +123 -0
  64. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +16 -21
  65. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  66. data/lib/jirametrics/html_generator.rb +32 -0
  67. data/lib/jirametrics/html_report_config.rb +83 -76
  68. data/lib/jirametrics/issue.rb +499 -91
  69. data/lib/jirametrics/issue_collection.rb +33 -0
  70. data/lib/jirametrics/issue_printer.rb +97 -0
  71. data/lib/jirametrics/jira_gateway.rb +96 -16
  72. data/lib/jirametrics/mcp_server.rb +531 -0
  73. data/lib/jirametrics/project_config.rb +374 -130
  74. data/lib/jirametrics/pull_request.rb +30 -0
  75. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  76. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  77. data/lib/jirametrics/pull_request_review.rb +13 -0
  78. data/lib/jirametrics/raw_javascript.rb +17 -0
  79. data/lib/jirametrics/rules.rb +2 -2
  80. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  81. data/lib/jirametrics/settings.json +10 -2
  82. data/lib/jirametrics/sprint.rb +13 -0
  83. data/lib/jirametrics/sprint_burndown.rb +47 -39
  84. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  85. data/lib/jirametrics/status.rb +84 -19
  86. data/lib/jirametrics/status_collection.rb +83 -38
  87. data/lib/jirametrics/stitcher.rb +81 -0
  88. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  89. data/lib/jirametrics/throughput_chart.rb +73 -23
  90. data/lib/jirametrics/time_based_histogram.rb +139 -0
  91. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  92. data/lib/jirametrics/user.rb +12 -0
  93. data/lib/jirametrics/value_equality.rb +2 -2
  94. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  95. data/lib/jirametrics.rb +101 -66
  96. metadata +72 -16
  97. data/lib/jirametrics/cycletime_config.rb +0 -69
  98. data/lib/jirametrics/discard_changes_before.rb +0 -37
  99. data/lib/jirametrics/html/cycletime_histogram.erb +0 -47
  100. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -4,68 +4,113 @@ class StatusNotFoundError < StandardError
4
4
  end
5
5
 
6
6
  class StatusCollection
7
+ attr_reader :historical_status_mappings
8
+
7
9
  def initialize
8
10
  @list = []
11
+ @historical_status_mappings = {} # 'name:id' => category
9
12
  end
10
13
 
11
- def filter_status_names category_name:, including: nil, excluding: nil
12
- including = expand_statuses including
13
- excluding = expand_statuses excluding
14
+ # Return the status matching this id or nil if it can't be found.
15
+ def find_by_id id
16
+ @list.find { |status| status.id == id }
17
+ end
14
18
 
15
- @list.filter_map do |status|
16
- keep = status.category_name == category_name ||
17
- including.any? { |s| s.name == status.name }
18
- keep = false if excluding.any? { |s| s.name == status.name }
19
+ def find_by_id! id
20
+ status = @list.find { |status| status.id == id }
21
+ raise "Can't find any status for id #{id} in #{self}" unless status
19
22
 
20
- status.name if keep
21
- end
23
+ status
22
24
  end
23
25
 
24
- def expand_statuses names_or_ids
25
- result = []
26
- return result if names_or_ids.nil?
27
-
28
- names_or_ids = [names_or_ids] unless names_or_ids.is_a? Array
29
-
30
- names_or_ids.each do |name_or_id|
31
- status = @list.find { |s| s.name == name_or_id || s.id == name_or_id }
32
- if status.nil?
33
- if block_given?
34
- yield name_or_id
35
- next
36
- else
37
- all_status_names = @list.collect { |s| "#{s.name.inspect}:#{s.id.inspect}" }.uniq.sort.join(', ')
38
- raise StatusNotFoundError, "Status not found: \"#{name_or_id}\". Possible statuses are: #{all_status_names}"
39
- end
40
- end
26
+ def find_all_by_name identifier
27
+ name, id = parse_name_id identifier
28
+
29
+ if id
30
+ status = find_by_id id
31
+ return [] if status.nil?
41
32
 
42
- result << status
33
+ if name && status.name != name
34
+ raise "Specified status ID of #{id} does not match specified name #{name.inspect}. " \
35
+ "You might have meant one of these: #{self}."
36
+ end
37
+ [status]
38
+ else
39
+ @list.select { |status| status.name == name }
43
40
  end
44
- result
45
41
  end
46
42
 
47
- def todo including: nil, excluding: nil
48
- filter_status_names category_name: 'To Do', including: including, excluding: excluding
43
+ def find_all_categories
44
+ @list
45
+ .collect(&:category)
46
+ .uniq
47
+ .sort_by(&:id)
49
48
  end
50
49
 
51
- def in_progress including: nil, excluding: nil
52
- filter_status_names category_name: 'In Progress', including: including, excluding: excluding
50
+ def parse_name_id name
51
+ # Names could arrive in one of the following formats: "Done:3", "3", "Done"
52
+ if name =~ /^(.*):(\d+)$/
53
+ [$1, $2.to_i]
54
+ elsif name.match?(/^\d+$/)
55
+ [nil, name.to_i]
56
+ else
57
+ [name, nil]
58
+ end
53
59
  end
54
60
 
55
- def done including: nil, excluding: nil
56
- filter_status_names category_name: 'Done', including: including, excluding: excluding
57
- end
61
+ def find_all_categories_by_name identifier
62
+ key = nil
63
+ id = nil
58
64
 
59
- def find_by_name name
60
- find { |status| status.name == name }
65
+ if identifier.is_a? Symbol
66
+ key = identifier.to_s
67
+ else
68
+ name, id = parse_name_id identifier
69
+ end
70
+
71
+ find_all_categories.select { |c| c.id == id || c.name == name || c.key == key }
61
72
  end
62
73
 
63
- def find(&block)= @list.find(&block)
64
74
  def collect(&block) = @list.collect(&block)
75
+ def find(&block) = @list.find(&block)
65
76
  def each(&block) = @list.each(&block)
66
77
  def select(&block) = @list.select(&block)
67
78
  def <<(arg) = @list << arg
68
79
  def empty? = @list.empty?
69
80
  def clear = @list.clear
70
81
  def delete(object) = @list.delete(object)
82
+
83
+ def to_s
84
+ "[#{@list.sort.join(', ')}]"
85
+ end
86
+
87
+ def inspect
88
+ "StatusCollection#{self}"
89
+ end
90
+
91
+ def fabricate_status_for id:, name:
92
+ category = @historical_status_mappings["#{name.inspect}:#{id.inspect}"]
93
+ category = in_progress_category if category.nil?
94
+
95
+ status = Status.new(
96
+ name: name,
97
+ id: id,
98
+ category_name: category.name,
99
+ category_id: category.id,
100
+ category_key: category.key,
101
+ artificial: true
102
+ )
103
+ @list << status
104
+ status
105
+ end
106
+
107
+ private
108
+
109
+ # Return the in-progress category or raise an error if we can't find one.
110
+ def in_progress_category
111
+ first_in_progress_status = find { |s| s.category.indeterminate? }
112
+ raise "Can't find even one in-progress status in #{self}" unless first_in_progress_status
113
+
114
+ first_in_progress_status.category
115
+ end
71
116
  end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Stitcher < HtmlGenerator
4
+ class StitchContent
5
+ include ValueEquality
6
+
7
+ attr_reader :file, :title, :content, :type
8
+
9
+ def initialize file:, title:, type:, content:
10
+ @file = file
11
+ @title = title
12
+ @content = content
13
+ @type = type
14
+ end
15
+ end
16
+
17
+ attr_reader :loaded_files, :all_stitches
18
+
19
+ def initialize file_system:
20
+ super()
21
+ self.file_system = file_system
22
+ @all_stitches = []
23
+ @loaded_files = []
24
+ end
25
+
26
+ def run stitch_file:
27
+ output_filename = make_output_filename stitch_file
28
+ file_system.log "Creating file #{output_filename.inspect}", also_write_to_stderr: true
29
+ erb = ERB.new file_system.load(stitch_file)
30
+ @sections = [[erb.result(binding), :body]]
31
+ create_html output_filename: output_filename, settings: {}
32
+ end
33
+
34
+ def make_output_filename input_filename
35
+ if /^(.+)\.erb$/ =~ input_filename
36
+ "#{$1}.html"
37
+ else
38
+ "#{input_filename}.html"
39
+ end
40
+ end
41
+
42
+ def grab_by_title title, from_file:, type: 'chart'
43
+ parse_file from_file
44
+ stitch_content = @all_stitches.find { |s| s.file == from_file && s.title == title && s.type == type }
45
+ return stitch_content.content if stitch_content
46
+
47
+ file_system.error "Unable to find content in file #{from_file.inspect} matching title: #{title.inspect}"
48
+ ''
49
+ end
50
+
51
+ def parse_file filename
52
+ return false if @loaded_files.include? filename
53
+
54
+ # To match: <!-- seam-start | chart78 | GithubPrScatterplot | PR Scatterplot | chart -->
55
+ regex = /^<!-- seam-(?<seam>start|end) \| (?<id>[^|]+) \| (?<clazz>[^|]+) \| (?<title>[^|]+) \| (?<type>[^|]+) -->$/
56
+ content = nil
57
+ file_system.load(filename).lines do |line|
58
+ matches = line.match(regex)
59
+ if matches
60
+ if matches[:seam] == 'start'
61
+ content = +''
62
+ else
63
+ if content.nil? || content.strip.empty?
64
+ file_system.warning "Seam found with no content in #{filename.inspect}: " \
65
+ "id=#{matches[:id].strip.inspect}, class=#{matches[:clazz].strip.inspect}, " \
66
+ "title=#{matches[:title].strip.inspect}"
67
+ end
68
+ @all_stitches << Stitcher::StitchContent.new(
69
+ file: filename, title: matches[:title], type: matches[:type], content: content
70
+ )
71
+ content = nil
72
+ end
73
+ elsif content
74
+ content << line
75
+ end
76
+ end
77
+
78
+ @loaded_files << filename
79
+ true
80
+ end
81
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/throughput_chart'
4
+
5
+ class ThroughputByCompletedResolutionChart < ThroughputChart
6
+ def initialize block
7
+ super
8
+ header_text 'Throughput, grouped by completion status and resolution'
9
+ description_text nil
10
+ end
11
+
12
+ def default_grouping_rules issue, rules
13
+ status, resolution = issue.status_resolution_at_done
14
+ if resolution
15
+ rules.label = "#{status.name}:#{resolution}"
16
+ rules.label_hint = "Status: #{status.name.inspect}:#{status.id}, resolution: #{resolution.inspect}"
17
+ else
18
+ rules.label = status.name
19
+ rules.label_hint = "Status: #{status.name.inspect}:#{status.id}"
20
+ end
21
+ end
22
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'cgi'
4
+
3
5
  class ThroughputChart < ChartBase
4
6
  include GroupableIssueChart
5
7
 
@@ -10,42 +12,54 @@ class ThroughputChart < ChartBase
10
12
 
11
13
  header_text 'Throughput Chart'
12
14
  description_text <<-TEXT
13
- <div class="p">
14
- This chart shows how many items we completed per week
15
+ <div>Throughput data is very useful for#{' '}
16
+ <a href="https://blog.mikebowler.ca/2024/06/02/probabilistic-forecasting/">probabilistic forecasting</a>,
17
+ to determine when we'll be done. Try it now with the
18
+ <a href="<%= throughput_forecaster_url %>" target="_blank" rel="noopener noreferrer">
19
+ Focused Objective throughput forecaster,</a> to see how long it would take to complete all of the
20
+ <%= @not_started_count %> items you currently have in your backlog.
15
21
  </div>
16
22
  #{describe_non_working_days}
17
23
  TEXT
24
+ @x_axis_title = nil
25
+ @y_axis_title = 'Count of items'
18
26
 
19
27
  init_configuration_block(block) do
20
- grouping_rules do |issue, rule|
21
- rule.label = issue.type
22
- rule.color = color_for type: issue.type
23
- end
28
+ grouping_rules { |issue, rule| default_grouping_rules(issue, rule) }
24
29
  end
25
30
  end
26
31
 
27
32
  def run
33
+ # This is saved as an instance variable so that it's accessible later when rendering the description text
34
+ @not_started_count = issues.count { |issue| issue.started_stopped_times.first.nil? }
35
+
28
36
  completed_issues = completed_issues_in_range include_unstarted: true
29
37
  rules_to_issues = group_issues completed_issues
30
38
  data_sets = []
31
- if rules_to_issues.size > 1
32
- data_sets << weekly_throughput_dataset(
33
- completed_issues: completed_issues,
34
- label: 'Totals',
35
- color: CssVariable['--throughput_chart_total_line_color'],
36
- dashed: true
37
- )
38
- end
39
+ total_data_set = weekly_throughput_dataset(
40
+ completed_issues: completed_issues,
41
+ label: 'Totals',
42
+ color: CssVariable['--throughput_chart_total_line_color'],
43
+ dashed: true
44
+ )
45
+ @throughput_samples = total_data_set[:data].collect { |d| d[:y] }
46
+ data_sets << total_data_set if rules_to_issues.size > 1
39
47
 
40
48
  rules_to_issues.each_key do |rules|
41
49
  data_sets << weekly_throughput_dataset(
42
- completed_issues: rules_to_issues[rules], label: rules.label, color: rules.color
50
+ completed_issues: rules_to_issues[rules], label: rules.label, color: rules.color,
51
+ label_hint: rules.label_hint
43
52
  )
44
53
  end
45
54
 
46
55
  wrap_and_render(binding, __FILE__)
47
56
  end
48
57
 
58
+ def default_grouping_rules issue, rule
59
+ rule.label = issue.type
60
+ rule.color = color_for type: issue.type
61
+ end
62
+
49
63
  def calculate_time_periods
50
64
  first_day = @date_range.begin
51
65
  first_day = case first_day.wday
@@ -65,10 +79,22 @@ class ThroughputChart < ChartBase
65
79
  end
66
80
  end
67
81
 
68
- def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false
82
+ def calculate_custom_periods
83
+ last_days = @issue_periods.values.compact.uniq.sort
84
+ last_days.each_with_index.map do |last_day, i|
85
+ first_day = i.zero? ? @date_range.begin : last_days[i - 1] + 1
86
+ first_day..last_day
87
+ end
88
+ end
89
+
90
+ def weekly_throughput_dataset completed_issues:, label:, color:, dashed: false, label_hint: nil
91
+ periods = @issue_periods&.values&.any? ? calculate_custom_periods : calculate_time_periods
69
92
  result = {
70
93
  label: label,
71
- data: throughput_dataset(periods: calculate_time_periods, completed_issues: completed_issues),
94
+ label_hint: label_hint,
95
+ data: throughput_dataset(
96
+ periods: periods, completed_issues: completed_issues, label_hint: label_hint
97
+ ),
72
98
  fill: false,
73
99
  showLine: true,
74
100
  borderColor: color,
@@ -79,20 +105,44 @@ class ThroughputChart < ChartBase
79
105
  result
80
106
  end
81
107
 
82
- def throughput_dataset periods:, completed_issues:
108
+ def throughput_forecaster_url
109
+ params = {
110
+ throughputMode: 'data',
111
+ samplesText: @throughput_samples.join(','),
112
+ storyLow: @not_started_count,
113
+ storyHigh: @not_started_count
114
+ }
115
+
116
+ query = params.map { |k, v| "#{k}=#{CGI.escape(v.to_s)}" }.join('&')
117
+ "https://focusedobjective.com/throughput?#{query}"
118
+ end
119
+
120
+ def throughput_dataset periods:, completed_issues:, label_hint: nil
121
+ custom_mode = @issue_periods&.values&.any?
83
122
  periods.collect do |period|
84
123
  closed_issues = completed_issues.filter_map do |issue|
85
- stop_date = issue.board.cycletime.stopped_time(issue)&.to_date
86
- [stop_date, issue] if stop_date && period.include?(stop_date)
124
+ stop_date = issue.started_stopped_dates.last
125
+ next unless stop_date
126
+
127
+ if custom_mode
128
+ [stop_date, issue] if @issue_periods[issue] == period.end
129
+ elsif period.include?(stop_date)
130
+ [stop_date, issue]
131
+ end
87
132
  end
88
133
 
89
134
  date_label = "on #{period.end}"
90
135
  date_label = "between #{period.begin} and #{period.end}" unless period.begin == period.end
91
136
 
92
- { y: closed_issues.size,
137
+ with_label_hint = label_hint ? " with #{label_hint}" : ''
138
+ {
139
+ y: closed_issues.size,
93
140
  x: "#{period.end}T23:59:59",
94
- title: ["#{closed_issues.size} items completed #{date_label}"] +
95
- closed_issues.collect { |_stop_date, issue| "#{issue.key} : #{issue.summary}" }
141
+ title: ["#{closed_issues.size} items closed#{with_label_hint} #{date_label}"] +
142
+ closed_issues.collect do |_stop_date, issue|
143
+ hint = @issue_hints&.fetch(issue, nil)
144
+ "#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
145
+ end
96
146
  }
97
147
  end
98
148
  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
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'jirametrics/groupable_issue_chart'
4
+
5
+ class TimeBasedScatterplot < ChartBase
6
+ include GroupableIssueChart
7
+
8
+ def initialize
9
+ super
10
+
11
+ @percentage_lines = []
12
+ @highest_y_value = 0
13
+ end
14
+
15
+ def run
16
+ items = all_items
17
+ data_sets = create_datasets items
18
+ overall_percent_line = calculate_percent_line(items)
19
+ @percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
20
+
21
+ return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>" if data_sets.empty?
22
+
23
+ wrap_and_render(binding, __FILE__)
24
+ end
25
+
26
+ def create_datasets items
27
+ data_sets = []
28
+
29
+ group_issues(items).each do |rules, items_by_type|
30
+ label = rules.label
31
+ color = rules.color
32
+ percent_line = calculate_percent_line items_by_type
33
+ data = items_by_type.filter_map { |item| data_for_item(item, rules: rules) }
34
+ data_sets << {
35
+ label: "#{label} (85% at #{label_days(percent_line)})",
36
+ data: data,
37
+ fill: false,
38
+ showLine: false,
39
+ backgroundColor: color
40
+ }
41
+
42
+ data_sets << trend_line_data_set(label: label, data: data, color: color)
43
+
44
+ @percentage_lines << [percent_line, color]
45
+ end
46
+ data_sets
47
+ end
48
+
49
+ def show_trend_lines
50
+ @show_trend_lines = true
51
+ end
52
+
53
+ def trend_line_data_set label:, data:, color:
54
+ points = data.collect do |hash|
55
+ [Time.parse(hash[:x]).to_i, hash[:y]]
56
+ end
57
+
58
+ # The trend calculation works with numbers only so convert Time to an int and back
59
+ calculator = TrendLineCalculator.new(points)
60
+ data_points = calculator.chart_datapoints(
61
+ range: time_range.begin.to_i..time_range.end.to_i,
62
+ max_y: @highest_y_value
63
+ )
64
+ data_points.each do |point_hash|
65
+ point_hash[:x] = chart_format Time.at(point_hash[:x])
66
+ end
67
+
68
+ {
69
+ type: 'line',
70
+ label: "#{label} Trendline",
71
+ data: data_points,
72
+ fill: false,
73
+ borderWidth: 1,
74
+ markerType: 'none',
75
+ borderColor: color,
76
+ borderDash: [6, 3],
77
+ pointStyle: 'dash',
78
+ hidden: !@show_trend_lines
79
+ }
80
+ end
81
+
82
+ def minimum_y_value
83
+ nil
84
+ end
85
+
86
+ def data_for_item item, rules: nil
87
+ y = y_value(item)
88
+ min = minimum_y_value
89
+ return nil if min && y < min
90
+
91
+ @highest_y_value = y if @highest_y_value < y
92
+
93
+ {
94
+ y: y,
95
+ x: chart_format(x_value(item)),
96
+ title: [title_value(item, rules: rules)]
97
+ }
98
+ end
99
+
100
+ def calculate_percent_line items
101
+ min = minimum_y_value
102
+ times = items.collect { |item| y_value(item) }
103
+ times.reject! { |y| min && y < min }
104
+ index = times.size * 85 / 100
105
+ times.sort[index]
106
+ end
107
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ class User
4
+ def initialize raw:
5
+ @raw = raw
6
+ end
7
+
8
+ def account_id = @raw['accountId']
9
+ def avatar_url = @raw['avatarUrls']['16x16']
10
+ def active? = @raw['active']
11
+ def display_name = @raw['displayName']
12
+ end
@@ -9,9 +9,9 @@ module ValueEquality
9
9
  names = object.instance_variables
10
10
  if object.respond_to? :value_equality_ignored_variables
11
11
  ignored_variables = object.value_equality_ignored_variables
12
- names.reject! { |n| ignored_variables.include? n }
12
+ names.reject! { |n| ignored_variables.include? n.to_sym }
13
13
  end
14
- names.map { |variable| instance_variable_get variable }
14
+ names.map { |variable| object.instance_variable_get variable }
15
15
  end
16
16
 
17
17
  code.call(self) == code.call(other)