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
@@ -1,17 +1,15 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'jirametrics/groupable_issue_chart'
3
+ require 'jirametrics/time_based_histogram'
4
4
 
5
- class CycletimeHistogram < ChartBase
6
- include GroupableIssueChart
5
+ class CycletimeHistogram < TimeBasedHistogram
7
6
  attr_accessor :possible_statuses
8
- attr_reader :show_stats
9
7
 
10
8
  def initialize block
11
9
  super()
12
10
 
13
- percentiles [50, 85, 98]
14
- @show_stats = true
11
+ @x_axis_title = 'Cycletime in days'
12
+ @y_axis_title = 'Count'
15
13
 
16
14
  header_text 'Cycletime Histogram'
17
15
  description_text <<-HTML
@@ -30,112 +28,26 @@ class CycletimeHistogram < ChartBase
30
28
  end
31
29
  end
32
30
 
33
- def percentiles percs = nil
34
- @percentiles = percs unless percs.nil?
35
- @percentiles
36
- end
37
-
38
- def disable_stats
39
- @show_stats = false
40
- end
41
-
42
- def run
31
+ def all_items
43
32
  stopped_issues = completed_issues_in_range include_unstarted: true
44
33
 
45
34
  # For the histogram, we only want to consider items that have both a start and a stop time.
46
- histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
47
- rules_to_issues = group_issues histogram_issues
48
-
49
- the_stats = {}
50
-
51
- overall_stats = stats_for histogram_data: histogram_data_for(issues: histogram_issues), percentiles: @percentiles
52
- the_stats[:all] = overall_stats
53
- data_sets = rules_to_issues.keys.collect do |rules|
54
- the_issue_type = rules.label
55
- the_histogram = histogram_data_for(issues: rules_to_issues[rules])
56
- the_stats[the_issue_type] = stats_for histogram_data: the_histogram, percentiles: @percentiles if @show_stats
57
-
58
- data_set_for(
59
- histogram_data: the_histogram,
60
- label: the_issue_type,
61
- color: rules.color
62
- )
63
- end
64
-
65
- if data_sets.empty?
66
- return "<h1 class='foldable'>#{@header_text}</h1><div>No data matched the selected criteria. Nothing to show.</div>"
67
- end
68
-
69
- wrap_and_render(binding, __FILE__)
35
+ stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
70
36
  end
71
37
 
72
- def histogram_data_for issues:
73
- count_hash = {}
74
- issues.each do |issue|
75
- days = issue.board.cycletime.cycletime(issue)
76
- count_hash[days] = (count_hash[days] || 0) + 1 if days.positive?
77
- end
78
- count_hash
38
+ def value_for_item issue
39
+ issue.board.cycletime.cycletime(issue)
79
40
  end
80
41
 
81
- def stats_for histogram_data:, percentiles:
82
- return {} if histogram_data.empty?
83
-
84
- total_values = histogram_data.values.sum
85
-
86
- # Calculate the average
87
- weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
88
- average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
89
-
90
- # Find the mode (or modes!) and the spread of the distribution
91
- sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
92
- max_freq = sorted_histogram[-1][1]
93
- mode = sorted_histogram.select { |_v, f| f == max_freq }
94
-
95
- minmax = histogram_data.keys.minmax
96
-
97
- # Calculate percentiles
98
- sorted_values = histogram_data.keys.sort
99
- cumulative_counts = {}
100
- cumulative_sum = 0
101
-
102
- sorted_values.each do |value|
103
- cumulative_sum += histogram_data[value]
104
- cumulative_counts[value] = cumulative_sum
105
- end
106
-
107
- percentile_results = {}
108
- percentiles.each do |percentile|
109
- rank = (percentile / 100.0) * total_values
110
- percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
111
- percentile_results[percentile] = percentile_value
112
- end
113
-
114
- {
115
- average: average,
116
- mode: mode.collect(&:first).sort,
117
- min: minmax[0],
118
- max: minmax[1],
119
- percentiles: percentile_results
120
- }
42
+ def title_for_item count:, value:
43
+ "#{count} items completed in #{label_days value}"
121
44
  end
122
45
 
123
- def data_set_for histogram_data:, label:, color:
124
- keys = histogram_data.keys.sort
125
- {
126
- type: 'bar',
127
- label: label,
128
- data: keys.sort.filter_map do |key|
129
- next if histogram_data[key].zero?
46
+ def sort_items items
47
+ items.sort_by(&:key_as_i)
48
+ end
130
49
 
131
- {
132
- x: key,
133
- y: histogram_data[key],
134
- title: "#{histogram_data[key]} items completed in #{label_days key}"
135
- }
136
- end,
137
- backgroundColor: color,
138
- borderRadius: 0
139
- }
50
+ def label_for_item issue, hint:
51
+ "#{issue.key} : #{issue.summary}#{" #{hint}" if hint}"
140
52
  end
141
53
  end
@@ -1,10 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require 'jirametrics/groupable_issue_chart'
4
-
5
- class CycletimeScatterplot < ChartBase
6
- include GroupableIssueChart
3
+ require 'jirametrics/time_based_scatterplot'
7
4
 
5
+ class CycletimeScatterplot < TimeBasedScatterplot
8
6
  attr_accessor :possible_statuses
9
7
 
10
8
  def initialize block
@@ -26,6 +24,8 @@ class CycletimeScatterplot < ChartBase
26
24
  </div>
27
25
  #{describe_non_working_days}
28
26
  HTML
27
+ @x_axis_title = 'Date completed'
28
+ @y_axis_title = 'Cycletime in days'
29
29
 
30
30
  init_configuration_block block do
31
31
  grouping_rules do |issue, rule|
@@ -33,9 +33,6 @@ class CycletimeScatterplot < ChartBase
33
33
  rule.color = color_for type: issue.type
34
34
  end
35
35
  end
36
-
37
- @percentage_lines = []
38
- @highest_y_value = 0
39
36
  end
40
37
 
41
38
  def all_items
@@ -51,96 +48,10 @@ class CycletimeScatterplot < ChartBase
51
48
  end
52
49
 
53
50
  def title_value item
54
- "#{item.key} : #{item.summary} (#{label_days(y_value(item))})"
55
- end
56
-
57
- def y_axis_heading
58
- 'Cycle time in days'
59
- end
60
-
61
- def run
62
- items = all_items
63
- data_sets = create_datasets items
64
- overall_percent_line = calculate_percent_line(items)
65
- @percentage_lines << [overall_percent_line, CssVariable['--cycletime-scatterplot-overall-trendline-color']]
66
-
67
- return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
68
-
69
- wrap_and_render(binding, __FILE__)
70
- end
71
-
72
- def create_datasets items
73
- data_sets = []
74
-
75
- group_issues(items).each do |rules, completed_items_by_type|
76
- label = rules.label
77
- color = rules.color
78
- percent_line = calculate_percent_line completed_items_by_type
79
- data = completed_items_by_type.filter_map { |issue| data_for_issue(issue) }
80
- data_sets << {
81
- label: "#{label} (85% at #{label_days(percent_line)})",
82
- data: data,
83
- fill: false,
84
- showLine: false,
85
- backgroundColor: color
86
- }
87
-
88
- data_sets << trend_line_data_set(label: label, data: data, color: color)
89
-
90
- @percentage_lines << [percent_line, color]
91
- end
92
- data_sets
51
+ hint = @issue_hints&.fetch(item, nil)
52
+ "#{item.key} : #{item.summary} (#{label_days(y_value(item))})#{" #{hint}" if hint}"
93
53
  end
94
54
 
95
- def show_trend_lines
96
- @show_trend_lines = true
97
- end
98
-
99
- def trend_line_data_set label:, data:, color:
100
- points = data.collect do |hash|
101
- [Time.parse(hash[:x]).to_i, hash[:y]]
102
- end
103
-
104
- # The trend calculation works with numbers only so convert Time to an int and back
105
- calculator = TrendLineCalculator.new(points)
106
- data_points = calculator.chart_datapoints(
107
- range: time_range.begin.to_i..time_range.end.to_i,
108
- max_y: @highest_y_value
109
- )
110
- data_points.each do |point_hash|
111
- point_hash[:x] = chart_format Time.at(point_hash[:x])
112
- end
113
-
114
- {
115
- type: 'line',
116
- label: "#{label} Trendline",
117
- data: data_points,
118
- fill: false,
119
- borderWidth: 1,
120
- markerType: 'none',
121
- borderColor: color,
122
- borderDash: [6, 3],
123
- pointStyle: 'dash',
124
- hidden: !@show_trend_lines
125
- }
126
- end
127
-
128
- def data_for_issue item
129
- cycle_time = y_value(item)
130
- return nil if cycle_time < 1 # These will get called out on the quality report
131
-
132
- @highest_y_value = cycle_time if @highest_y_value < cycle_time
133
-
134
- {
135
- y: cycle_time,
136
- x: chart_format(x_value(item)),
137
- title: [title_value(item)]
138
- }
139
- end
140
-
141
- def calculate_percent_line items
142
- times = items.collect { |item| y_value(item) }
143
- index = times.size * 85 / 100
144
- times.sort[index]
145
- end
55
+ # Kept for backwards compatibility with existing callers and specs
56
+ alias data_for_issue data_for_item
146
57
  end
@@ -3,12 +3,16 @@
3
3
  require 'jirametrics/chart_base'
4
4
 
5
5
  class DailyGroupingRules < GroupingRules
6
- attr_accessor :current_date, :group_priority, :issue_hint
6
+ attr_accessor :current_date, :group_priority, :issue_hint, :highlight
7
7
 
8
8
  def initialize
9
9
  super
10
10
  @group_priority = 0
11
11
  end
12
+
13
+ def group
14
+ [@label, @color, @highlight ? true : false]
15
+ end
12
16
  end
13
17
 
14
18
  class DailyWipChart < ChartBase
@@ -19,6 +23,8 @@ class DailyWipChart < ChartBase
19
23
 
20
24
  header_text default_header_text
21
25
  description_text default_description_text
26
+ @x_axis_title = nil
27
+ @y_axis_title = 'Count of items'
22
28
 
23
29
  instance_eval(&block) if block
24
30
 
@@ -33,8 +39,15 @@ class DailyWipChart < ChartBase
33
39
  issue_rules_by_active_date = group_issues_by_active_dates
34
40
  possible_rules = select_possible_rules issue_rules_by_active_date
35
41
 
42
+ conflicting_labels = possible_rules
43
+ .group_by(&:label)
44
+ .select { |_label, rules| rules.any?(&:highlight) && rules.any? { |r| !r.highlight } }
45
+ .keys
46
+
36
47
  data_sets = possible_rules.collect do |grouping_rule|
37
- make_data_set grouping_rule: grouping_rule, issue_rules_by_active_date: issue_rules_by_active_date
48
+ suffix = conflicting_labels.include?(grouping_rule.label) && grouping_rule.highlight ? '*' : ''
49
+ make_data_set grouping_rule: grouping_rule, issue_rules_by_active_date: issue_rules_by_active_date,
50
+ label_suffix: suffix
38
51
  end
39
52
  if @trend_lines
40
53
  data_sets = @trend_lines.filter_map do |group_labels, line_color|
@@ -82,16 +95,16 @@ class DailyWipChart < ChartBase
82
95
  hash
83
96
  end
84
97
 
85
- def make_data_set grouping_rule:, issue_rules_by_active_date:
98
+ def make_data_set grouping_rule:, issue_rules_by_active_date:, label_suffix: ''
86
99
  positive = grouping_rule.group_priority >= 0
100
+ display_label = "#{grouping_rule.label}#{label_suffix}"
87
101
 
88
102
  data = issue_rules_by_active_date.collect do |date, issue_rules|
89
- # issues = []
90
103
  issue_strings = issue_rules
91
104
  .select { |_issue, rules| rules.group == grouping_rule.group }
92
105
  .sort_by { |issue, _rules| issue.key_as_i }
93
106
  .collect { |issue, rules| "#{issue.key} : #{issue.summary.strip} #{rules.issue_hint}" }
94
- title = ["#{grouping_rule.label} (#{label_issues issue_strings.size})"] + issue_strings
107
+ title = ["#{display_label} (#{label_issues issue_strings.size})"] + issue_strings
95
108
 
96
109
  {
97
110
  x: date,
@@ -100,11 +113,18 @@ class DailyWipChart < ChartBase
100
113
  }
101
114
  end
102
115
 
116
+ color = grouping_rule.color || random_color
117
+ background_color = if grouping_rule.highlight
118
+ RawJavascript.new("createDiagonalPattern(#{color.to_json})")
119
+ else
120
+ color
121
+ end
122
+
103
123
  {
104
124
  type: 'bar',
105
- label: grouping_rule.label,
125
+ label: display_label,
106
126
  data: data,
107
- backgroundColor: grouping_rule.color || random_color,
127
+ backgroundColor: background_color,
108
128
  borderColor: CssVariable['--wip-chart-border-color'],
109
129
  borderWidth: grouping_rule.color.to_s == 'var(--body-background)' ? 1 : 0,
110
130
  borderRadius: positive ? 0 : 5
@@ -25,10 +25,25 @@ class DownloadConfig
25
25
  @no_earlier_than
26
26
  end
27
27
 
28
+ def github_repos
29
+ @github_repos ||= []
30
+ end
31
+
32
+ def github_repo *repos
33
+ github_repos.concat(repos.map { |r| normalize_github_repo(r) })
34
+ end
35
+
28
36
  def start_date today:
29
37
  date = today.to_date - @rolling_date_count if @rolling_date_count
30
38
  date = [date, @no_earlier_than].max if date && @no_earlier_than
31
39
  date = @no_earlier_than if date.nil? && @no_earlier_than
32
40
  date
33
41
  end
42
+
43
+ private
44
+
45
+ def normalize_github_repo repo
46
+ match = repo.match(%r{github\.com/([^/]+/[^/]+?)/?$})
47
+ match ? match[1] : repo
48
+ end
34
49
  end
@@ -33,21 +33,23 @@ class Downloader
33
33
  # For testing only
34
34
  attr_reader :start_date_in_query, :board_id_to_filter_id
35
35
 
36
- def self.create download_config:, file_system:, jira_gateway:
36
+ def self.create download_config:, file_system:, jira_gateway:, github_pr_cache: {}
37
37
  is_cloud = jira_gateway.settings['jira_cloud'] || jira_gateway.cloud?
38
38
  (is_cloud ? DownloaderForCloud : DownloaderForDataCenter).new(
39
39
  download_config: download_config,
40
40
  file_system: file_system,
41
- jira_gateway: jira_gateway
41
+ jira_gateway: jira_gateway,
42
+ github_pr_cache: github_pr_cache
42
43
  )
43
44
  end
44
45
 
45
- def initialize download_config:, file_system:, jira_gateway:
46
+ def initialize download_config:, file_system:, jira_gateway:, github_pr_cache: {}
46
47
  @metadata = {}
47
48
  @download_config = download_config
48
49
  @target_path = @download_config.project_config.target_path
49
50
  @file_system = file_system
50
51
  @jira_gateway = jira_gateway
52
+ @github_pr_cache = github_pr_cache
51
53
  @board_id_to_filter_id = {}
52
54
 
53
55
  @issue_keys_downloaded_in_current_run = []
@@ -77,6 +79,7 @@ class Downloader
77
79
  download_users
78
80
 
79
81
  save_metadata
82
+ download_github_prs if @download_config.github_repos.any?
80
83
  end
81
84
 
82
85
  def log text, both: false
@@ -165,11 +168,28 @@ class Downloader
165
168
  # actually look at the returned json.
166
169
  @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
167
170
 
171
+ if json['type'] == 'simple'
172
+ features_json = download_features board_id: board_id
173
+ if features_json['features']&.any? { |f| f['feature'] == 'jsw.agility.sprints' && f['state'] == 'ENABLED' }
174
+ download_sprints board_id: board_id
175
+ end
176
+ end
168
177
  download_sprints board_id: board_id if json['type'] == 'scrum'
169
178
  # TODO: Should be passing actual statuses, not empty list
170
179
  Board.new raw: json, possible_statuses: StatusCollection.new
171
180
  end
172
181
 
182
+ def download_features board_id:
183
+ log " Downloading features for board #{board_id}", both: true
184
+ json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/features"
185
+
186
+ @file_system.save_json(
187
+ json: json,
188
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_features.json")
189
+ )
190
+ json
191
+ end
192
+
173
193
  def download_sprints board_id:
174
194
  log " Downloading sprints for board #{board_id}", both: true
175
195
  max_results = 100
@@ -211,19 +231,36 @@ class Downloader
211
231
  value = Date.parse(value) if value.is_a?(String) && value =~ /^\d{4}-\d{2}-\d{2}$/
212
232
  @metadata[key] = value
213
233
  end
234
+
235
+ # If rolling_date_count has changed, we may be missing data outside the previous range,
236
+ # so force a full re-download.
237
+ if @metadata['rolling_date_count'] != @download_config.rolling_date_count
238
+ log ' rolling_date_count has changed. Forcing a full download.', both: true
239
+ @cached_data_format_is_current = false
240
+ @metadata = {}
241
+ end
214
242
  end
215
243
 
216
244
  # Even if this is the old format, we want to obey this one tag
217
245
  @metadata['no-download'] = hash['no-download'] if hash['no-download']
218
246
  end
219
247
 
248
+ def timezone_offset
249
+ @download_config.project_config.exporter.timezone_offset
250
+ end
251
+
252
+ def today_in_project_timezone
253
+ Time.now.getlocal(timezone_offset).to_date
254
+ end
255
+
220
256
  def save_metadata
221
257
  @metadata['version'] = CURRENT_METADATA_VERSION
258
+ @metadata['rolling_date_count'] = @download_config.rolling_date_count
222
259
  @metadata['date_start_from_last_query'] = @start_date_in_query if @start_date_in_query
223
260
 
224
261
  if @download_date_range.nil?
225
262
  log "Making up a date range in meta since one wasn't specified. You'll want to change that.", both: true
226
- today = Date.today
263
+ today = today_in_project_timezone
227
264
  @download_date_range = (today - 7)..today
228
265
  end
229
266
 
@@ -258,7 +295,8 @@ class Downloader
258
295
  end
259
296
  end
260
297
 
261
- def make_jql filter_id:, today: Date.today
298
+ def make_jql filter_id:, today: nil
299
+ today ||= today_in_project_timezone
262
300
  segments = []
263
301
  segments << "filter=#{filter_id}"
264
302
 
@@ -283,6 +321,39 @@ class Downloader
283
321
  segments.join ' AND '
284
322
  end
285
323
 
324
+ def download_github_prs
325
+ project_keys = extract_project_keys_from_downloaded_issues
326
+ if project_keys.empty?
327
+ log ' No project keys found in downloaded issues, skipping GitHub PR download', both: true
328
+ return
329
+ end
330
+
331
+ prs = @download_config.github_repos.flat_map do |repo|
332
+ GithubGateway.new(
333
+ repo: repo,
334
+ project_keys: project_keys,
335
+ file_system: @file_system,
336
+ raw_pr_cache: @github_pr_cache
337
+ ).fetch_pull_requests(since: @download_date_range&.begin)
338
+ end
339
+
340
+ @file_system.save_json(
341
+ json: prs.map(&:raw),
342
+ filename: File.join(@target_path, "#{file_prefix}_github_prs.json")
343
+ )
344
+ end
345
+
346
+ def extract_project_keys_from_downloaded_issues
347
+ path = File.join(@target_path, "#{file_prefix}_issues")
348
+ return [] unless @file_system.dir_exist?(path)
349
+
350
+ keys = []
351
+ @file_system.foreach(path) do |filename|
352
+ keys << filename.split('-').first if filename.match?(/^[A-Z][A-Z_0-9]+-\d+-\d+\.json$/)
353
+ end
354
+ keys.uniq
355
+ end
356
+
286
357
  def file_prefix
287
358
  @download_config.project_config.get_file_prefix
288
359
  end
@@ -5,6 +5,45 @@ class DownloaderForCloud < Downloader
5
5
  'Jira Cloud'
6
6
  end
7
7
 
8
+ def run
9
+ super
10
+ download_fix_versions
11
+ end
12
+
13
+ def download_board_configuration board_id:
14
+ board = super
15
+ location = board.raw['location']
16
+ @project_key ||= location['key'] if location&.[]('type') == 'project'
17
+ board
18
+ end
19
+
20
+ def download_fix_versions
21
+ return unless @project_key
22
+
23
+ log " Downloading fix versions for project #{@project_key}", both: true
24
+ max_results = 50
25
+ start_at = 0
26
+ all_versions = []
27
+
28
+ loop do
29
+ json = @jira_gateway.call_url(
30
+ relative_url: "/rest/api/3/project/#{@project_key}/version?" \
31
+ "startAt=#{start_at}&maxResults=#{max_results}"
32
+ )
33
+
34
+ values = json['values'] || []
35
+ all_versions.concat(values)
36
+ break if json['isLast'] || values.empty?
37
+
38
+ start_at += values.size
39
+ end
40
+
41
+ @file_system.save_json(
42
+ json: all_versions,
43
+ filename: File.join(@target_path, "#{file_prefix}_fix_versions.json")
44
+ )
45
+ end
46
+
8
47
  def search_for_issues jql:, board_id:, path:
9
48
  log " JQL: #{jql}"
10
49
  escaped_jql = CGI.escape jql
@@ -63,7 +63,8 @@ class DownloaderForDataCenter < Downloader
63
63
  end
64
64
  end
65
65
 
66
- def make_jql filter_id:, today: Date.today
66
+ def make_jql filter_id:, today: nil
67
+ today ||= today_in_project_timezone
67
68
  segments = []
68
69
  segments << "filter=#{filter_id}"
69
70
 
@@ -5,7 +5,7 @@ class EstimateAccuracyChart < ChartBase
5
5
  super()
6
6
 
7
7
  header_text 'Estimate Accuracy'
8
- description_text <<-HTML
8
+ description_text <<~HTML
9
9
  <div class="p">
10
10
  This chart graphs estimates against actual recorded cycle times. Since
11
11
  estimates can change over time, we're graphing the estimate at the time that the story started.
@@ -20,8 +20,18 @@ class EstimateAccuracyChart < ChartBase
20
20
  far to the right then you know you have a problem.
21
21
  <% end %>
22
22
  </div>
23
+ <% if @correlation_coefficient %>
24
+ <div class="p">
25
+ The completed items here have a correlation coefficient of <b><%= @correlation_coefficient.round(3) %></b>.
26
+ The closer it is to +1, the stronger the positive correlation. The closer it is to -1,
27
+ the stronger the negative collalation. Zero would mean no correlation at all.
28
+ </div>
29
+ <% end %>
23
30
  HTML
24
31
 
32
+ @x_axis_title = 'Cycletime (days)'
33
+ @y_axis_title = 'Count of items'
34
+
25
35
  @y_axis_type = 'linear'
26
36
  @y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
27
37
  @y_axis_sort_order = nil
@@ -30,9 +40,9 @@ class EstimateAccuracyChart < ChartBase
30
40
  end
31
41
 
32
42
  def run
33
- if @y_axis_label.nil?
43
+ if @y_axis_title.nil?
34
44
  text = current_board.estimation_configuration.units == :story_points ? 'Story Points' : 'Days'
35
- @y_axis_label = "Estimated #{text}"
45
+ @y_axis_title = "Estimated #{text}"
36
46
  end
37
47
  data_sets = scan_issues
38
48
 
@@ -43,7 +53,7 @@ class EstimateAccuracyChart < ChartBase
43
53
 
44
54
  def scan_issues
45
55
  completed_hash, aging_hash = split_into_completed_and_aging issues: issues
46
-
56
+ @correlation_coefficient = correlation_coefficient(completed_hash) unless completed_hash.empty?
47
57
  estimation_units = current_board.estimation_configuration.units
48
58
  @has_aging_data = !aging_hash.empty?
49
59
 
@@ -170,4 +180,32 @@ class EstimateAccuracyChart < ChartBase
170
180
  end
171
181
  @y_axis_block = block
172
182
  end
183
+
184
+ # Correlation coefficient is calculated using the Pearson Correlation Coefficient
185
+ # r = Σ((xi - x̄)(yi - ȳ)) / sqrt(Σ(xi - x̄)² · Σ(yi - ȳ)²)
186
+ def correlation_coefficient completed_hash
187
+ list1 = []
188
+ list2 = []
189
+ completed_hash.each do |(estimate, cycle_time), issues|
190
+ issues.size.times do
191
+ list1 << estimate
192
+ list2 << cycle_time
193
+ end
194
+ end
195
+
196
+ n = list1.size
197
+ return nil if n < 2
198
+
199
+ mean1 = list1.sum.to_f / n
200
+ mean2 = list2.sum.to_f / n
201
+
202
+ numerator = list1.zip(list2).sum { |x, y| (x - mean1) * (y - mean2) }
203
+ sum_sq1 = list1.sum { |x| (x - mean1)**2 }
204
+ sum_sq2 = list2.sum { |y| (y - mean2)**2 }
205
+
206
+ denominator = Math.sqrt(sum_sq1 * sum_sq2)
207
+ return nil if denominator.zero?
208
+
209
+ numerator / denominator
210
+ end
173
211
  end