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
@@ -0,0 +1,287 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DownloaderForCloud < Downloader
4
+ def jira_instance_type
5
+ 'Jira Cloud'
6
+ end
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
+
47
+ def search_for_issues jql:, board_id:, path:
48
+ log " JQL: #{jql}"
49
+ escaped_jql = CGI.escape jql
50
+
51
+ hash = {}
52
+ max_results = 5_000 # The maximum allowed by Jira
53
+ next_page_token = nil
54
+ issue_count = 0
55
+
56
+ start_progress
57
+ loop do
58
+ relative_url = +''
59
+ relative_url << '/rest/api/3/search/jql'
60
+ relative_url << "?jql=#{escaped_jql}&maxResults=#{max_results}"
61
+ relative_url << "&nextPageToken=#{next_page_token}" if next_page_token
62
+ relative_url << '&fields=updated'
63
+
64
+ json = @jira_gateway.call_url relative_url: relative_url
65
+ next_page_token = json['nextPageToken']
66
+
67
+ json['issues'].each do |i|
68
+ key = i['key']
69
+ data = DownloadIssueData.new key: key
70
+ data.key = key
71
+ data.last_modified = Time.parse i['fields']['updated']
72
+ data.found_in_primary_query = true
73
+ data.cache_path = File.join(path, "#{key}-#{board_id}.json")
74
+ data.up_to_date = last_modified(filename: data.cache_path) == data.last_modified
75
+ hash[key] = data
76
+ issue_count += 1
77
+ end
78
+
79
+ progress_dot " Found #{issue_count} issues"
80
+
81
+ break unless next_page_token
82
+ end
83
+ end_progress
84
+
85
+ hash
86
+ end
87
+
88
+ def bulk_fetch_issues issue_datas:, board:, in_initial_query:
89
+ # We used to use the expand option to pull in the changelog directly. Unfortunately
90
+ # that only returns the "recent" changes, not all of them. So now we get the issue
91
+ # without changes and then make a second call for that changes. Then we insert it
92
+ # into the raw issue as if it had been there all along.
93
+ log " Downloading #{issue_datas.size} issues"
94
+ payload = {
95
+ 'fields' => ['*all'],
96
+ 'issueIdsOrKeys' => issue_datas.collect(&:key)
97
+ }
98
+ response = @jira_gateway.post_request(
99
+ relative_url: '/rest/api/3/issue/bulkfetch',
100
+ payload: JSON.generate(payload)
101
+ )
102
+
103
+ attach_changelog_to_issues issue_datas: issue_datas, issue_jsons: response['issues']
104
+
105
+ response['issues'].each do |issue_json|
106
+ issue_json['exporter'] = {
107
+ 'in_initial_query' => in_initial_query
108
+ }
109
+ issue = Issue.new(raw: issue_json, board: board)
110
+ data = issue_datas.find { |d| d.key == issue.key }
111
+ unless data
112
+ log " Skipping #{issue.key}: returned by Jira but key not in request (issue may have been moved)"
113
+ next
114
+ end
115
+ data.up_to_date = true
116
+ data.last_modified = issue.updated
117
+ data.issue = issue
118
+ end
119
+
120
+ # Mark any unmatched requests as up_to_date to prevent infinite re-fetching.
121
+ # This happens when Jira returns a different key (moved issue) leaving the original unmatched.
122
+ issue_datas.each do |data|
123
+ next if data.up_to_date
124
+
125
+ log " Skipping #{data.key}: not returned by Jira (issue may have been deleted or moved)"
126
+ data.up_to_date = true
127
+ end
128
+
129
+ issue_datas
130
+ end
131
+
132
+ def attach_changelog_to_issues issue_datas:, issue_jsons:
133
+ max_results = 10_000 # The max jira accepts is 10K
134
+ payload = {
135
+ 'issueIdsOrKeys' => issue_datas.collect(&:key),
136
+ 'maxResults' => max_results
137
+ }
138
+ loop do
139
+ response = @jira_gateway.post_request(
140
+ relative_url: '/rest/api/3/changelog/bulkfetch',
141
+ payload: JSON.generate(payload)
142
+ )
143
+
144
+ response['issueChangeLogs'].each do |issue_change_log|
145
+ issue_id = issue_change_log['issueId']
146
+ json = issue_jsons.find { |json| json['id'] == issue_id }
147
+
148
+ unless json['changelog']
149
+ # If this is our first time in, there won't be a changelog section
150
+ json['changelog'] = {
151
+ 'startAt' => 0,
152
+ 'maxResults' => max_results,
153
+ 'total' => 0,
154
+ 'histories' => []
155
+ }
156
+ end
157
+
158
+ new_changes = issue_change_log['changeHistories']
159
+ json['changelog']['total'] += new_changes.size
160
+ json['changelog']['histories'] += new_changes
161
+ end
162
+
163
+ next_page_token = response['nextPageToken']
164
+ payload['nextPageToken'] = next_page_token
165
+ break if next_page_token.nil?
166
+ end
167
+ end
168
+
169
+ def download_issues board:
170
+ log " Downloading primary issues for board #{board.id} from #{jira_instance_type}", both: true
171
+ path = File.join(@target_path, "#{file_prefix}_issues/")
172
+ unless @file_system.dir_exist?(path)
173
+ log " Creating path #{path}"
174
+ @file_system.mkdir(path)
175
+ end
176
+
177
+ filter_id = @board_id_to_filter_id[board.id]
178
+ jql = make_jql(filter_id: filter_id)
179
+ intercept_jql = @download_config.project_config.settings['intercept_jql']
180
+ jql = intercept_jql.call jql if intercept_jql
181
+
182
+ issue_data_hash = search_for_issues jql: jql, board_id: board.id, path: path
183
+
184
+ checked_for_related = Set.new
185
+ in_related_phase = false
186
+
187
+ loop do
188
+ related_issue_keys = Set.new
189
+ stale = issue_data_hash.values.reject { |data| data.up_to_date }
190
+ unless stale.empty?
191
+ log_start ' Downloading more issues ' unless in_related_phase
192
+ stale.each_slice(100) do |slice|
193
+ slice = bulk_fetch_issues(issue_datas: slice, board: board, in_initial_query: true)
194
+ progress_dot
195
+ slice.each do |data|
196
+ next unless data.issue
197
+
198
+ @file_system.save_json(
199
+ json: data.issue.raw, filename: data.cache_path
200
+ )
201
+ # Set the timestamp on the file to match the updated one so that we don't have
202
+ # to parse the file just to find the timestamp
203
+ @file_system.utime time: data.issue.updated, file: data.cache_path
204
+
205
+ collect_related_issue_keys issue: data.issue, related_issue_keys: related_issue_keys
206
+ checked_for_related << data.key
207
+ end
208
+ end
209
+ end_progress unless in_related_phase
210
+ end
211
+
212
+ # Also scan up-to-date cached issues we haven't checked yet — they may reference
213
+ # related issues that are not in the primary query result.
214
+ issue_data_hash.each_value do |data|
215
+ next if checked_for_related.include?(data.key)
216
+ next unless @file_system.file_exist?(data.cache_path)
217
+
218
+ checked_for_related << data.key
219
+ raw = @file_system.load_json(data.cache_path)
220
+ collect_related_issue_keys issue: Issue.new(raw: raw, board: board), related_issue_keys: related_issue_keys
221
+ end
222
+
223
+ # Remove all the ones we already have
224
+ related_issue_keys.reject! { |key| issue_data_hash[key] }
225
+
226
+ related_issue_keys.each do |key|
227
+ data = DownloadIssueData.new key: key
228
+ data.found_in_primary_query = false
229
+ data.up_to_date = false
230
+ data.cache_path = File.join(path, "#{key}-#{board.id}.json")
231
+ issue_data_hash[key] = data
232
+ end
233
+ break if related_issue_keys.empty?
234
+
235
+ unless in_related_phase
236
+ in_related_phase = true
237
+ log " Identifying related issues (parents, subtasks, links) for board #{board.id}", both: true
238
+ log_start ' Downloading more issues '
239
+ end
240
+ end
241
+
242
+ end_progress if in_related_phase
243
+
244
+ delete_issues_from_cache_that_are_not_in_server(
245
+ issue_data_hash: issue_data_hash, path: path
246
+ )
247
+ end
248
+
249
+ def delete_issues_from_cache_that_are_not_in_server issue_data_hash:, path:
250
+ # The gotcha with deleted issues is that they just stop being returned in queries
251
+ # and we have no way to know that they should be removed from our local cache.
252
+ # With the new approach, we ask for every issue that Jira knows about (within
253
+ # the parameters of the query) and then delete anything that's in our local cache
254
+ # but wasn't returned.
255
+ @file_system.foreach path do |file|
256
+ next if file.start_with? '.'
257
+ unless /^(?<key>\w+-\d+)-\d+\.json$/ =~ file
258
+ raise "Unexpected filename in #{path}: #{file}"
259
+ end
260
+ next if issue_data_hash[key] # Still in Jira
261
+
262
+ file_to_delete = File.join(path, file)
263
+ log " Removing #{file_to_delete} from local cache"
264
+ file_system.unlink file_to_delete
265
+ end
266
+ end
267
+
268
+ def collect_related_issue_keys issue:, related_issue_keys:
269
+ parent_key = issue.parent_key(project_config: @download_config.project_config)
270
+ related_issue_keys << parent_key if parent_key
271
+
272
+ issue.raw['fields']['subtasks']&.each do |raw_subtask|
273
+ related_issue_keys << raw_subtask['key']
274
+ end
275
+
276
+ issue.raw['fields']['issuelinks']&.each do |link|
277
+ next if link['type']['name'] == 'Cloners'
278
+
279
+ linked = link['inwardIssue'] || link['outwardIssue']
280
+ related_issue_keys << linked['key'] if linked
281
+ end
282
+ end
283
+
284
+ def last_modified filename:
285
+ File.mtime(filename) if File.exist?(filename)
286
+ end
287
+ end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ class DownloaderForDataCenter < Downloader
4
+ def jira_instance_type
5
+ 'Jira DataCenter'
6
+ end
7
+
8
+ def download_issues board:
9
+ log " Downloading primary issues for board #{board.id}", both: true
10
+ path = File.join(@target_path, "#{file_prefix}_issues/")
11
+ unless Dir.exist?(path)
12
+ log " Creating path #{path}"
13
+ Dir.mkdir(path)
14
+ end
15
+
16
+ filter_id = board_id_to_filter_id[board.id]
17
+ jql = make_jql(filter_id: filter_id)
18
+ jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
19
+
20
+ log " Downloading linked issues for board #{board.id}", both: true
21
+ loop do
22
+ @issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
23
+ break if @issue_keys_pending_download.empty?
24
+
25
+ keys_to_request = @issue_keys_pending_download[0..99]
26
+ @issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
27
+ jql = "key in (#{keys_to_request.join(', ')})"
28
+ jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
29
+ end
30
+ end
31
+
32
+ def jira_search_by_jql jql:, initial_query:, board:, path:
33
+ intercept_jql = @download_config.project_config.settings['intercept_jql']
34
+ jql = intercept_jql.call jql if intercept_jql
35
+
36
+ log " JQL: #{jql}"
37
+ escaped_jql = CGI.escape jql
38
+
39
+ max_results = 100
40
+ start_at = 0
41
+ total = 1
42
+ while start_at < total
43
+ json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
44
+ "?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
45
+
46
+ json['issues'].each do |issue_json|
47
+ issue_json['exporter'] = {
48
+ 'in_initial_query' => initial_query
49
+ }
50
+ identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
51
+ file = "#{issue_json['key']}-#{board.id}.json"
52
+
53
+ @file_system.save_json(json: issue_json, filename: File.join(path, file))
54
+ end
55
+
56
+ total = json['total'].to_i
57
+ max_results = json['maxResults']
58
+
59
+ message = " Downloaded #{start_at + 1}-#{[start_at + max_results, total].min} of #{total} issues to #{path} "
60
+ log message, both: true
61
+
62
+ start_at += json['issues'].size
63
+ end
64
+ end
65
+
66
+ def make_jql filter_id:, today: nil
67
+ today ||= today_in_project_timezone
68
+ segments = []
69
+ segments << "filter=#{filter_id}"
70
+
71
+ start_date = @download_config.start_date today: today
72
+
73
+ if start_date
74
+ @download_date_range = start_date..today.to_date
75
+
76
+ # For an incremental download, we want to query from the end of the previous one, not from the
77
+ # beginning of the full range.
78
+ @start_date_in_query = metadata['date_end'] || @download_date_range.begin
79
+ log " Incremental download only. Pulling from #{@start_date_in_query}", both: true if metadata['date_end']
80
+
81
+ # Catch-all to pick up anything that's been around since before the range started but hasn't
82
+ # had an update during the range.
83
+ catch_all = '((status changed OR Sprint is not EMPTY) AND statusCategory != Done)'
84
+
85
+ # Pick up any issues that had a status change in the range
86
+ start_date_text = @start_date_in_query.strftime '%Y-%m-%d'
87
+ # find_in_range = %((status changed DURING ("#{start_date_text} 00:00","#{end_date_text} 23:59")))
88
+ find_in_range = %(updated >= "#{start_date_text} 00:00")
89
+
90
+ segments << "(#{find_in_range} OR #{catch_all})"
91
+ end
92
+
93
+ segments.join ' AND '
94
+ end
95
+ end
@@ -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,17 +20,30 @@ 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
 
25
- @y_axis_label = 'Story Point Estimates'
32
+ @x_axis_title = 'Cycletime (days)'
33
+ @y_axis_title = 'Estimate'
34
+
26
35
  @y_axis_type = 'linear'
27
- @y_axis_block = ->(issue, start_time) { story_points_at(issue: issue, start_time: start_time)&.to_f }
36
+ @y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
28
37
  @y_axis_sort_order = nil
29
38
 
30
39
  instance_eval(&configuration_block)
31
40
  end
32
41
 
33
42
  def run
43
+ if @y_axis_title.nil?
44
+ text = current_board.estimation_configuration.units == :story_points ? 'Story Points' : 'Days'
45
+ @y_axis_title = "Estimated #{text}"
46
+ end
34
47
  data_sets = scan_issues
35
48
 
36
49
  return '' if data_sets.empty?
@@ -40,7 +53,8 @@ class EstimateAccuracyChart < ChartBase
40
53
 
41
54
  def scan_issues
42
55
  completed_hash, aging_hash = split_into_completed_and_aging issues: issues
43
-
56
+ @correlation_coefficient = correlation_coefficient(completed_hash) unless completed_hash.empty?
57
+ estimation_units = current_board.estimation_configuration.units
44
58
  @has_aging_data = !aging_hash.empty?
45
59
 
46
60
  [
@@ -53,9 +67,13 @@ class EstimateAccuracyChart < ChartBase
53
67
  # We sort so that the smaller circles are in front of the bigger circles.
54
68
  data = hash.sort(&hash_sorter).collect do |key, values|
55
69
  estimate, cycle_time = *key
56
- estimate_label = "#{estimate}#{'pts' if @y_axis_type == 'linear'}"
57
- title = ["Estimate: #{estimate_label}, Cycletime: #{label_days(cycle_time)}, #{values.size} issues"] +
58
- values.collect { |issue| "#{issue.key}: #{issue.summary}" }
70
+
71
+ title = [
72
+ "Estimate: #{estimate_label(estimate: estimate, estimation_units: estimation_units)}, " \
73
+ "Cycletime: #{label_days(cycle_time)}, " \
74
+ "#{values.size} issues"
75
+ ] + values.collect { |issue| "#{issue.key}: #{issue.summary}" }
76
+
59
77
  {
60
78
  'x' => cycle_time,
61
79
  'y' => estimate,
@@ -77,14 +95,25 @@ class EstimateAccuracyChart < ChartBase
77
95
  end
78
96
  end
79
97
 
98
+ def estimate_label estimate:, estimation_units:
99
+ if @y_axis_type == 'linear'
100
+ if estimation_units == :story_points
101
+ estimate_label = "#{estimate}pts"
102
+ elsif estimation_units == :seconds
103
+ estimate_label = label_days estimate
104
+ end
105
+ end
106
+ estimate_label = estimate.to_s if estimate_label.nil?
107
+ estimate_label
108
+ end
109
+
80
110
  def split_into_completed_and_aging issues:
81
111
  aging_hash = {}
82
112
  completed_hash = {}
83
113
 
84
114
  issues.each do |issue|
85
115
  cycletime = issue.board.cycletime
86
- start_time = cycletime.started_time(issue)
87
- stop_time = cycletime.stopped_time(issue)
116
+ start_time, stop_time = cycletime.started_stopped_times(issue)
88
117
 
89
118
  next unless start_time
90
119
 
@@ -127,14 +156,18 @@ class EstimateAccuracyChart < ChartBase
127
156
  end
128
157
  end
129
158
 
130
- def story_points_at issue:, start_time:
131
- story_points = nil
159
+ def estimate_at issue:, start_time:, estimation_configuration: current_board.estimation_configuration
160
+ estimate = nil
161
+
132
162
  issue.changes.each do |change|
133
- return story_points if change.time >= start_time
163
+ return estimate if change.time >= start_time
134
164
 
135
- story_points = change.value if change.story_points?
165
+ if change.field == estimation_configuration.display_name || change.field == estimation_configuration.field_id
166
+ estimate = change.value
167
+ estimate = estimate.to_f / (24 * 60 * 60) if estimation_configuration.units == :seconds
168
+ end
136
169
  end
137
- story_points
170
+ estimate
138
171
  end
139
172
 
140
173
  def y_axis label:, sort_order: nil, &block
@@ -147,4 +180,32 @@ class EstimateAccuracyChart < ChartBase
147
180
  end
148
181
  @y_axis_block = block
149
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
150
211
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ class EstimationConfiguration
4
+ attr_reader :units, :display_name, :field_id
5
+
6
+ def initialize raw:
7
+ @units = :story_points
8
+ @display_name = 'Story Points'
9
+
10
+ # If there wasn't an estimation section they rely on all defaults
11
+ return if raw.nil?
12
+
13
+ if raw['type'] == 'field'
14
+ @field_id = raw['field']['fieldId']
15
+ @display_name = raw['field']['displayName']
16
+ if @field_id == 'timeoriginalestimate'
17
+ @units = :seconds
18
+ @display_name = 'Original estimate'
19
+ end
20
+ elsif raw['type'] == 'issueCount'
21
+ @display_name = 'Issue Count'
22
+ @units = :issue_count
23
+ end
24
+ end
25
+ end
@@ -3,8 +3,6 @@
3
3
  # This file is really intended to give you ideas about how you might configure your own reports, not
4
4
  # as a complete setup that will work in every case.
5
5
  #
6
- # See https://github.com/mikebowler/jirametrics/wiki/Examples-folder for more details
7
- #
8
6
  # The point of an AGGREGATED report is that we're now looking at a higher level. We might use this in a
9
7
  # S2 meeting (Scrum of Scrums) to talk about the things that are happening across teams, not within a
10
8
  # single team. For that reason, we look at slightly different things that we would on a single team board.
@@ -12,8 +10,9 @@
12
10
  class Exporter
13
11
  def aggregated_project name:, project_names:, settings: {}
14
12
  project name: name do
15
- puts name
16
- self.settings.merge! settings
13
+ file_system.log name
14
+ file_prefix name
15
+ self.settings.merge! stringify_keys(settings)
17
16
 
18
17
  aggregate do
19
18
  project_names.each do |project_name|
@@ -21,8 +20,6 @@ class Exporter
21
20
  end
22
21
  end
23
22
 
24
- file_prefix name
25
-
26
23
  file do
27
24
  file_suffix '.html'
28
25
  issues.reject! do |issue|
@@ -33,8 +30,8 @@ class Exporter
33
30
  html '<h1>Boards included in this report</h1><ul>', type: :header
34
31
  board_lines = []
35
32
  included_projects.each do |project|
36
- project.all_boards.values.each do |board|
37
- board_lines << "<a href='#{project.file_prefix}.html'>#{board.name}</a> from project #{project.name}"
33
+ project.all_boards.each_value do |board|
34
+ board_lines << "<a href='#{project.get_file_prefix}.html'>#{board.name}</a> from project #{project.name}"
38
35
  end
39
36
  end
40
37
  board_lines.sort.each { |line| html "<li>#{line}</li>", type: :header }
@@ -64,25 +61,14 @@ class Exporter
64
61
 
65
62
  # By default, the issue doesn't show what board it's on and this is important for an
66
63
  # aggregated view
64
+ chart = self
67
65
  issue_rules do |issue, rules|
68
- key = issue.key
69
- key = "<S>#{key} </S> " if issue.status.category_name == 'Done'
70
- rules.label = "<#{key} [#{issue.type}]<BR/>#{issue.board.name}<BR/>#{word_wrap issue.summary}>"
66
+ chart.default_issue_rules.call(issue, rules)
67
+ rules.label = rules.label.split('<BR/>').insert(1, "Board: #{issue.board.name}").join('<BR/>')
71
68
  end
72
69
 
73
70
  link_rules do |link, rules|
74
- # By default, the dependency chart shows everything. Clean it up a bit.
75
- case link.name
76
- when 'Cloners'
77
- # We don't want to see any clone links at all.
78
- rules.ignore
79
- when 'Blocks'
80
- # For blocks, by default Jira will have links going both
81
- # ways and we want them only going one way. Also make the
82
- # link red.
83
- rules.merge_bidirectional keep: 'outward'
84
- rules.line_color = 'red'
85
- end
71
+ chart.default_link_rules.call(link, rules)
86
72
 
87
73
  # Because this is the aggregated view, let's hide any link that doesn't cross boards.
88
74
  rules.ignore if link.origin.board == link.other_issue.board