jirametrics 2.20.1 → 2.25

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 (79) 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 +189 -133
  4. data/lib/jirametrics/aging_work_table.rb +4 -5
  5. data/lib/jirametrics/anonymizer.rb +74 -1
  6. data/lib/jirametrics/atlassian_document_format.rb +93 -93
  7. data/lib/jirametrics/bar_chart_range.rb +17 -0
  8. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  9. data/lib/jirametrics/board.rb +24 -8
  10. data/lib/jirametrics/board_config.rb +2 -1
  11. data/lib/jirametrics/board_feature.rb +14 -0
  12. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  13. data/lib/jirametrics/cfd_data_builder.rb +103 -0
  14. data/lib/jirametrics/change_item.rb +13 -5
  15. data/lib/jirametrics/chart_base.rb +124 -1
  16. data/lib/jirametrics/css_variable.rb +1 -1
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +200 -0
  18. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +4 -6
  19. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  20. data/lib/jirametrics/cycletime_scatterplot.rb +15 -85
  21. data/lib/jirametrics/daily_view.rb +35 -11
  22. data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
  23. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
  24. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  25. data/lib/jirametrics/daily_wip_chart.rb +30 -8
  26. data/lib/jirametrics/data_quality_report.rb +37 -11
  27. data/lib/jirametrics/dependency_chart.rb +1 -1
  28. data/lib/jirametrics/download_config.rb +15 -0
  29. data/lib/jirametrics/downloader.rb +76 -5
  30. data/lib/jirametrics/downloader_for_cloud.rb +39 -0
  31. data/lib/jirametrics/downloader_for_data_center.rb +2 -1
  32. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  33. data/lib/jirametrics/examples/aggregated_project.rb +1 -1
  34. data/lib/jirametrics/examples/standard_project.rb +28 -18
  35. data/lib/jirametrics/expedited_chart.rb +3 -1
  36. data/lib/jirametrics/exporter.rb +7 -3
  37. data/lib/jirametrics/file_system.rb +4 -0
  38. data/lib/jirametrics/fix_version.rb +13 -0
  39. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  40. data/lib/jirametrics/github_gateway.rb +106 -0
  41. data/lib/jirametrics/groupable_issue_chart.rb +9 -1
  42. data/lib/jirametrics/grouping_rules.rb +21 -3
  43. data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
  44. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +2 -0
  45. data/lib/jirametrics/html/aging_work_table.erb +5 -0
  46. data/lib/jirametrics/html/cumulative_flow_diagram.erb +504 -0
  47. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  48. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  49. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  50. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  51. data/lib/jirametrics/html/index.css +134 -0
  52. data/lib/jirametrics/html/index.erb +6 -1
  53. data/lib/jirametrics/html/index.js +76 -2
  54. data/lib/jirametrics/html/sprint_burndown.erb +12 -12
  55. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  56. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  57. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +8 -9
  58. data/lib/jirametrics/html_generator.rb +31 -0
  59. data/lib/jirametrics/html_report_config.rb +26 -39
  60. data/lib/jirametrics/issue.rb +186 -88
  61. data/lib/jirametrics/issue_printer.rb +97 -0
  62. data/lib/jirametrics/jira_gateway.rb +6 -3
  63. data/lib/jirametrics/project_config.rb +78 -8
  64. data/lib/jirametrics/pull_request.rb +30 -0
  65. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  66. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +81 -0
  67. data/lib/jirametrics/pull_request_review.rb +13 -0
  68. data/lib/jirametrics/raw_javascript.rb +17 -0
  69. data/lib/jirametrics/settings.json +3 -1
  70. data/lib/jirametrics/sprint.rb +12 -0
  71. data/lib/jirametrics/sprint_burndown.rb +9 -3
  72. data/lib/jirametrics/status.rb +1 -1
  73. data/lib/jirametrics/stitcher.rb +76 -0
  74. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  75. data/lib/jirametrics/throughput_chart.rb +56 -22
  76. data/lib/jirametrics/time_based_histogram.rb +139 -0
  77. data/lib/jirametrics/time_based_scatterplot.rb +100 -0
  78. data/lib/jirametrics.rb +8 -1
  79. metadata +22 -5
@@ -39,23 +39,32 @@ class DailyWipByBlockedStalledChart < DailyWipChart
39
39
  end
40
40
 
41
41
  def default_grouping_rules issue:, rules:
42
- started, stopped = issue.board.cycletime.started_stopped_times(issue)
42
+ started, stopped = issue.started_stopped_times
43
43
  stopped_date = stopped&.to_date
44
+ started_date = started&.to_date
44
45
 
45
46
  date = rules.current_date
46
47
  change = issue.blocked_stalled_by_date(date_range: date..date, chart_end_time: time_range.end)[date]
47
-
48
48
  stopped_today = stopped_date == rules.current_date
49
49
 
50
+ days = nil
51
+ if started_date && stopped_date
52
+ days = (stopped_date - started_date).to_i + 1 # cycletime
53
+ elsif started_date
54
+ days = (time_range.end.to_date - started_date).to_i + 1 # age
55
+ end
56
+
50
57
  if stopped_today && started.nil?
51
58
  @has_completed_but_not_started = true
52
59
  rules.label = 'Completed but not started'
53
60
  rules.color = '--wip-chart-completed-but-not-started-color'
54
61
  rules.group_priority = -1
62
+ rules.issue_hint = '(Cycle time: Unknown)'
55
63
  elsif stopped_today
56
64
  rules.label = 'Completed'
57
65
  rules.color = '--wip-chart-completed-color'
58
66
  rules.group_priority = -2
67
+ rules.issue_hint = "(Cycle time: #{label_days days})"
59
68
  elsif started.nil?
60
69
  rules.label = 'Start date unknown'
61
70
  rules.color = '--body-background'
@@ -64,16 +73,17 @@ class DailyWipByBlockedStalledChart < DailyWipChart
64
73
  rules.label = 'Blocked'
65
74
  rules.color = '--blocked-color'
66
75
  rules.group_priority = 1
67
- rules.issue_hint = "(#{change.reasons})"
76
+ rules.issue_hint = "(Age: #{label_days days}, #{change.reasons})"
68
77
  elsif change&.stalled?
69
78
  rules.label = 'Stalled'
70
79
  rules.color = '--stalled-color'
71
80
  rules.group_priority = 2
72
- rules.issue_hint = "(#{change.reasons})"
81
+ rules.issue_hint = "(Age: #{label_days days}, #{change.reasons})"
73
82
  else
74
83
  rules.label = 'Active'
75
84
  rules.color = '--wip-chart-active-color'
76
85
  rules.group_priority = 3
86
+ rules.issue_hint = "(Age: #{label_days days})"
77
87
  end
78
88
  end
79
89
  end
@@ -26,11 +26,13 @@ class DailyWipByParentChart < DailyWipChart
26
26
  end
27
27
 
28
28
  def default_grouping_rules issue:, rules:
29
- parent = issue.parent&.key
29
+ parent = issue.parent
30
30
  if parent
31
- rules.label = parent
31
+ rules.label = parent.key
32
+ rules.label_hint = "#{parent.key} : #{parent.summary}"
32
33
  else
33
34
  rules.label = 'No parent'
35
+ rules.label_hint = 'No parent'
34
36
  rules.group_priority = 1000
35
37
  rules.color = '--body-background'
36
38
  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|
@@ -66,7 +79,7 @@ class DailyWipChart < ChartBase
66
79
  hash = {}
67
80
 
68
81
  @issues.each do |issue|
69
- start, stop = issue.board.cycletime.started_stopped_dates(issue)
82
+ start, stop = cycletime_for_issue(issue).started_stopped_dates(issue)
70
83
  next if start.nil? && stop.nil?
71
84
 
72
85
  # If it stopped but never started then assume it started at creation so the data points
@@ -82,16 +95,17 @@ 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_label = grouping_rule.label_hint || display_label
108
+ title = ["#{title_label} (#{label_issues issue_strings.size})"] + issue_strings
95
109
 
96
110
  {
97
111
  x: date,
@@ -100,11 +114,19 @@ class DailyWipChart < ChartBase
100
114
  }
101
115
  end
102
116
 
117
+ color = grouping_rule.color || random_color
118
+ background_color = if grouping_rule.highlight
119
+ RawJavascript.new("createDiagonalPattern(#{color.to_json})")
120
+ else
121
+ color
122
+ end
123
+
103
124
  {
104
125
  type: 'bar',
105
- label: grouping_rule.label,
126
+ label: display_label,
127
+ label_hint: grouping_rule.label_hint,
106
128
  data: data,
107
- backgroundColor: grouping_rule.color || random_color,
129
+ backgroundColor: background_color,
108
130
  borderColor: CssVariable['--wip-chart-border-color'],
109
131
  borderWidth: grouping_rule.color.to_s == 'var(--body-background)' ? 1 : 0,
110
132
  borderRadius: positive ? 0 : 5
@@ -45,6 +45,7 @@ class DataQualityReport < ChartBase
45
45
  scan_for_completed_issues_without_a_start_time entry: entry
46
46
  scan_for_status_change_after_done entry: entry
47
47
  scan_for_backwards_movement entry: entry, backlog_statuses: backlog_statuses
48
+ scan_for_issue_not_in_active_sprint entry: entry
48
49
  scan_for_issues_not_created_in_a_backlog_status entry: entry, backlog_statuses: backlog_statuses
49
50
  scan_for_stopped_before_started entry: entry
50
51
  scan_for_issues_not_started_with_subtasks_that_have entry: entry
@@ -68,7 +69,7 @@ class DataQualityReport < ChartBase
68
69
  result << render_problem_type(:status_changes_after_done)
69
70
  result << render_problem_type(:backwards_through_status_categories)
70
71
  result << render_problem_type(:backwords_through_statuses)
71
- result << render_problem_type(:status_not_on_board)
72
+ result << render_problem_type(:issue_not_visible_on_board)
72
73
  result << render_problem_type(:created_in_wrong_status)
73
74
  result << render_problem_type(:stopped_before_started)
74
75
  result << render_problem_type(:issue_not_started_but_subtasks_have)
@@ -120,7 +121,7 @@ class DataQualityReport < ChartBase
120
121
 
121
122
  def initialize_entries
122
123
  @entries = @issues.filter_map do |issue|
123
- started, stopped = issue.board.cycletime.started_stopped_times(issue)
124
+ started, stopped = issue.started_stopped_times
124
125
  next if stopped && stopped < time_range.begin
125
126
  next if started && started > time_range.end
126
127
 
@@ -194,7 +195,7 @@ class DataQualityReport < ChartBase
194
195
  # If it's been moved back to backlog then it's on a different report. Ignore it here.
195
196
  detail = nil if backlog_statuses.any? { |s| s.name == change.value }
196
197
 
197
- entry.report(problem_key: :status_not_on_board, detail: detail) unless detail.nil?
198
+ entry.report(problem_key: :issue_not_visible_on_board, detail: detail) unless detail.nil?
198
199
  elsif change.old_value.nil?
199
200
  # Do nothing
200
201
  elsif index < last_index
@@ -223,6 +224,29 @@ class DataQualityReport < ChartBase
223
224
  end
224
225
  end
225
226
 
227
+ def scan_for_issue_not_in_active_sprint entry:
228
+ issue = entry.issue
229
+ return unless issue.board.scrum?
230
+ return if issue.sprints.any?(&:active?)
231
+
232
+ entry.report(problem_key: :issue_not_visible_on_board, detail: 'Issue is not in an active sprint')
233
+ end
234
+
235
+ def scan_for_issue_never_visible_on_board entry:
236
+ issue = entry.issue
237
+ ever_visible = issue.changes.any? do |change|
238
+ next unless change.status?
239
+
240
+ issue.board.visible_columns.any? { |col| col.status_ids.include?(change.value_id) }
241
+ end
242
+ return if ever_visible
243
+
244
+ entry.report(
245
+ problem_key: :issue_not_visible_on_board,
246
+ detail: 'Issue has never been in a status mapped to a visible column on the board'
247
+ )
248
+ end
249
+
226
250
  def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
227
251
  creation_change = entry.issue.changes.find { |issue| issue.status? }
228
252
 
@@ -250,7 +274,7 @@ class DataQualityReport < ChartBase
250
274
 
251
275
  started_subtasks = []
252
276
  entry.issue.subtasks.each do |subtask|
253
- started_subtasks << subtask if subtask.board.cycletime.started_stopped_times(subtask).first
277
+ started_subtasks << subtask if subtask.started_stopped_times.first
254
278
  end
255
279
 
256
280
  return if started_subtasks.empty?
@@ -266,8 +290,10 @@ class DataQualityReport < ChartBase
266
290
 
267
291
  def scan_for_items_blocked_on_closed_tickets entry:
268
292
  entry.issue.issue_links.each do |link|
293
+ next unless settings['blocked_link_text'].include?(link.label)
294
+
269
295
  this_active = !entry.stopped
270
- other_active = !link.other_issue.board.cycletime.started_stopped_times(link.other_issue).last
296
+ other_active = !link.other_issue.started_stopped_times.last
271
297
  next unless this_active && !other_active
272
298
 
273
299
  entry.report(
@@ -293,14 +319,14 @@ class DataQualityReport < ChartBase
293
319
  return "#{delta} hours" if delta < 24
294
320
 
295
321
  delta /= 24
296
- "#{delta} days"
322
+ label_days delta
297
323
  end
298
324
 
299
325
  def scan_for_incomplete_subtasks_when_issue_done entry:
300
326
  return unless entry.stopped
301
327
 
302
328
  subtask_labels = entry.issue.subtasks.filter_map do |subtask|
303
- subtask_started, subtask_stopped = subtask.board.cycletime.started_stopped_times(subtask)
329
+ subtask_started, subtask_stopped = subtask.started_stopped_times
304
330
 
305
331
  if !subtask_started && !subtask_stopped
306
332
  "#{subtask_label subtask} (Not even started)"
@@ -407,12 +433,12 @@ class DataQualityReport < ChartBase
407
433
  HTML
408
434
  end
409
435
 
410
- def render_status_not_on_board problems
436
+ def render_issue_not_visible_on_board problems
411
437
  <<-HTML
412
438
  #{label_issues problems.size} were not visible on the board for some period of time. This may impact
413
- timings as the work was likely to have been forgotten if it wasn't visible. What does "not visible"
414
- mean in this context? The issue was in a status that is not mapped to any visible column on the board.
415
- Look in "unmapped statuses" on your board.
439
+ timings as the work was likely to have been forgotten if it wasn't visible. An issue can be not visible
440
+ for two reasons: the issue was in a status that is not mapped to any visible column on the board
441
+ (look in "unmapped statuses" on your board), or for scrum boards, the issue was not in an active sprint.
416
442
  HTML
417
443
  end
418
444
 
@@ -231,7 +231,7 @@ class DependencyChart < ChartBase
231
231
  elsif is_done
232
232
  line2 << 'Done'
233
233
  else
234
- started_at = issue.board.cycletime.started_stopped_times(issue).first
234
+ started_at = issue.started_stopped_times.first
235
235
  if started_at.nil?
236
236
  line2 << 'Not started'
237
237
  else
@@ -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 BoardFeature.from_raw(features_json).any? { |f| f.name == 'jsw.agility.sprints' && f.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 = 'Estimate'
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
@@ -12,7 +12,7 @@ class Exporter
12
12
  project name: name do
13
13
  puts name
14
14
  file_prefix name
15
- self.settings.merge! settings
15
+ self.settings.merge! stringify_keys(settings)
16
16
 
17
17
  aggregate do
18
18
  project_names.each do |project_name|