jirametrics 2.13 → 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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +10 -2
  4. data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
  6. data/lib/jirametrics/aging_work_table.rb +9 -7
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +101 -97
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  11. data/lib/jirametrics/board.rb +32 -8
  12. data/lib/jirametrics/board_config.rb +4 -1
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +14 -6
  17. data/lib/jirametrics/chart_base.rb +141 -3
  18. data/lib/jirametrics/css_variable.rb +1 -1
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +21 -4
  21. data/lib/jirametrics/cycletime_histogram.rb +15 -101
  22. data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
  23. data/lib/jirametrics/daily_view.rb +85 -53
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  27. data/lib/jirametrics/daily_wip_chart.rb +30 -8
  28. data/lib/jirametrics/data_quality_report.rb +43 -12
  29. data/lib/jirametrics/dependency_chart.rb +6 -3
  30. data/lib/jirametrics/download_config.rb +15 -0
  31. data/lib/jirametrics/downloader.rb +117 -100
  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 +42 -4
  35. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  36. data/lib/jirametrics/examples/standard_project.rb +41 -28
  37. data/lib/jirametrics/expedited_chart.rb +3 -1
  38. data/lib/jirametrics/exporter.rb +26 -6
  39. data/lib/jirametrics/file_config.rb +9 -11
  40. data/lib/jirametrics/file_system.rb +59 -3
  41. data/lib/jirametrics/fix_version.rb +13 -0
  42. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  43. data/lib/jirametrics/github_gateway.rb +115 -0
  44. data/lib/jirametrics/groupable_issue_chart.rb +11 -1
  45. data/lib/jirametrics/grouping_rules.rb +26 -4
  46. data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
  47. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
  48. data/lib/jirametrics/html/aging_work_table.erb +5 -0
  49. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  50. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  51. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  52. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  53. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  54. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  55. data/lib/jirametrics/html/index.css +249 -69
  56. data/lib/jirametrics/html/index.erb +9 -35
  57. data/lib/jirametrics/html/index.js +164 -0
  58. data/lib/jirametrics/html/legacy_colors.css +174 -0
  59. data/lib/jirametrics/html/sprint_burndown.erb +17 -15
  60. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  61. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  62. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
  63. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  64. data/lib/jirametrics/html_generator.rb +32 -0
  65. data/lib/jirametrics/html_report_config.rb +52 -57
  66. data/lib/jirametrics/issue.rb +304 -101
  67. data/lib/jirametrics/issue_printer.rb +97 -0
  68. data/lib/jirametrics/jira_gateway.rb +77 -17
  69. data/lib/jirametrics/mcp_server.rb +531 -0
  70. data/lib/jirametrics/project_config.rb +128 -12
  71. data/lib/jirametrics/pull_request.rb +30 -0
  72. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  73. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  74. data/lib/jirametrics/pull_request_review.rb +13 -0
  75. data/lib/jirametrics/raw_javascript.rb +17 -0
  76. data/lib/jirametrics/settings.json +5 -1
  77. data/lib/jirametrics/sprint.rb +12 -0
  78. data/lib/jirametrics/sprint_burndown.rb +10 -4
  79. data/lib/jirametrics/status.rb +1 -1
  80. data/lib/jirametrics/stitcher.rb +81 -0
  81. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  82. data/lib/jirametrics/throughput_chart.rb +73 -23
  83. data/lib/jirametrics/time_based_histogram.rb +139 -0
  84. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  85. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  86. data/lib/jirametrics.rb +83 -69
  87. metadata +60 -6
@@ -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,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
@@ -10,9 +10,9 @@
10
10
  class Exporter
11
11
  def aggregated_project name:, project_names:, settings: {}
12
12
  project name: name do
13
- puts name
13
+ file_system.log 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|
@@ -6,23 +6,14 @@ class Exporter
6
6
  def standard_project name:, file_prefix:, ignore_issues: nil, starting_status: nil, boards: {},
7
7
  default_board: nil, anonymize: false, settings: {}, status_category_mappings: {},
8
8
  rolling_date_count: 90, no_earlier_than: nil, ignore_types: %w[Sub-task Subtask Epic],
9
- show_experimental_charts: false
10
-
9
+ show_experimental_charts: false, github_repos: nil
10
+ exporter = self
11
11
  project name: name do
12
- puts name
12
+ file_system.log name, also_write_to_stderr: true
13
13
  file_prefix file_prefix
14
14
 
15
15
  self.anonymize if anonymize
16
- self.settings.merge! settings
17
-
18
- status_category_mappings.each do |status, category|
19
- status_category_mapping status: status, category: category
20
- end
21
-
22
- download do
23
- self.rolling_date_count(rolling_date_count) if rolling_date_count
24
- self.no_earlier_than(no_earlier_than) if no_earlier_than
25
- end
16
+ self.settings.merge! stringify_keys(settings)
26
17
 
27
18
  boards.each_key do |board_id|
28
19
  block = boards[board_id]
@@ -37,17 +28,27 @@ class Exporter
37
28
  end
38
29
  end
39
30
 
31
+ status_category_mappings.each do |status, category|
32
+ status_category_mapping status: status, category: category
33
+ end
34
+
35
+ download do
36
+ self.rolling_date_count(rolling_date_count) if rolling_date_count
37
+ self.no_earlier_than(no_earlier_than) if no_earlier_than
38
+ github_repo *github_repos if github_repos
39
+ end
40
+
40
41
  issues.reject! do |issue|
41
42
  ignore_types.include? issue.type
42
43
  end
43
44
 
45
+ exporter.filter_issues issues, ignore_issues
46
+
44
47
  discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
45
48
 
46
49
  file do
47
50
  file_suffix '.html'
48
51
 
49
- issues.reject! { |issue| ignore_issues.include? issue.key } if ignore_issues
50
-
51
52
  html_report do
52
53
  board_id default_board if default_board
53
54
 
@@ -57,37 +58,39 @@ class Exporter
57
58
  html "<div><a href='#{board.url}'>#{id} #{board.name}</a> (#{board.board_type})</div>",
58
59
  type: :header
59
60
  end
60
-
61
61
  daily_view
62
-
62
+ cumulative_flow_diagram
63
63
  cycletime_scatterplot do
64
64
  show_trend_lines
65
65
  end
66
66
  cycletime_histogram
67
67
 
68
68
  throughput_chart do
69
- description_text '<h2>Number of items completed, grouped by issue type</h2>'
69
+ description_text <<~TEXT
70
+ <div>Throughput data is very useful for#{' '}
71
+ <a href="https://blog.mikebowler.ca/2024/06/02/probabilistic-forecasting/">probabilistic forecasting</a>,
72
+ to determine when we'll be done. Try it now with the
73
+ <a href="<%= throughput_forecaster_url %>" target="_blank" rel="noopener noreferrer">
74
+ Focused Objective throughput forecaster,</a> to see how long it would take to complete all of the
75
+ <%= @not_started_count %> items you currently have in your backlog.
76
+ </div>
77
+ <h2>Number of items completed, grouped by issue type</h2>'
78
+ TEXT
70
79
  end
71
- throughput_chart do
72
- header_text nil
80
+ throughput_by_completed_resolution_chart do
73
81
  description_text '<h2>Number of items completed, grouped by completion status and resolution</h2>'
74
- grouping_rules do |issue, rules|
75
- if issue.resolution
76
- rules.label = "#{issue.status.name}:#{issue.resolution}"
77
- else
78
- rules.label = issue.status.name
79
- end
80
- end
81
82
  end
82
83
 
83
84
  aging_work_in_progress_chart
85
+ wip_by_column_chart do
86
+ show_recommendations
87
+ end
84
88
  aging_work_bar_chart
85
89
  aging_work_table
86
90
  daily_wip_by_age_chart
87
91
  daily_wip_by_blocked_stalled_chart
88
92
  daily_wip_by_parent_chart
89
93
  flow_efficiency_scatterplot if show_experimental_charts
90
- expedited_chart
91
94
  sprint_burndown
92
95
  estimate_accuracy_chart
93
96
  dependency_chart
@@ -95,4 +98,14 @@ class Exporter
95
98
  end
96
99
  end
97
100
  end
101
+
102
+ # Extracted as a separate method so it can be tested independently, without needing to invoke
103
+ # the full standard_project DSL setup.
104
+ def filter_issues issues, ignore_issues
105
+ return unless ignore_issues
106
+
107
+ issues.reject! do |issue|
108
+ ignore_issues.is_a?(Proc) ? ignore_issues.call(issue) : ignore_issues.include?(issue.key)
109
+ end
110
+ end
98
111
  end
@@ -38,6 +38,8 @@ class ExpeditedChart < ChartBase
38
38
  </div>
39
39
  #{describe_non_working_days}
40
40
  HTML
41
+ @x_axis_title = 'Date'
42
+ @y_axis_title = 'Age in days'
41
43
 
42
44
  instance_eval(&block)
43
45
  end
@@ -48,7 +50,7 @@ class ExpeditedChart < ChartBase
48
50
  end
49
51
 
50
52
  if data_sets.empty?
51
- '<h1>Expedited work</h1>There is no expedited work in this time period.'
53
+ '<h1 class="foldable">Expedited work</h1><div>There is no expedited work in this time period.</div>'
52
54
  else
53
55
  wrap_and_render(binding, __FILE__)
54
56
  end