jirametrics 2.20.1 → 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 (86) 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 +74 -1
  8. data/lib/jirametrics/atlassian_document_format.rb +93 -93
  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 +2 -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 +13 -5
  17. data/lib/jirametrics/chart_base.rb +137 -2
  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} +4 -6
  21. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  22. data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
  23. data/lib/jirametrics/daily_view.rb +38 -13
  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 +40 -12
  29. data/lib/jirametrics/dependency_chart.rb +2 -2
  30. data/lib/jirametrics/download_config.rb +15 -0
  31. data/lib/jirametrics/downloader.rb +87 -5
  32. data/lib/jirametrics/downloader_for_cloud.rb +107 -22
  33. data/lib/jirametrics/downloader_for_data_center.rb +3 -2
  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 +32 -19
  37. data/lib/jirametrics/expedited_chart.rb +3 -1
  38. data/lib/jirametrics/exporter.rb +19 -4
  39. data/lib/jirametrics/file_config.rb +9 -11
  40. data/lib/jirametrics/file_system.rb +35 -2
  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 +2 -0
  48. data/lib/jirametrics/html/aging_work_table.erb +5 -0
  49. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  50. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  51. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  52. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  53. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  54. data/lib/jirametrics/html/index.css +244 -59
  55. data/lib/jirametrics/html/index.erb +7 -1
  56. data/lib/jirametrics/html/index.js +77 -3
  57. data/lib/jirametrics/html/legacy_colors.css +174 -0
  58. data/lib/jirametrics/html/sprint_burndown.erb +12 -12
  59. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  60. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  61. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
  62. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  63. data/lib/jirametrics/html_generator.rb +32 -0
  64. data/lib/jirametrics/html_report_config.rb +49 -56
  65. data/lib/jirametrics/issue.rb +282 -91
  66. data/lib/jirametrics/issue_printer.rb +97 -0
  67. data/lib/jirametrics/jira_gateway.rb +32 -10
  68. data/lib/jirametrics/mcp_server.rb +531 -0
  69. data/lib/jirametrics/project_config.rb +98 -9
  70. data/lib/jirametrics/pull_request.rb +30 -0
  71. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  72. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  73. data/lib/jirametrics/pull_request_review.rb +13 -0
  74. data/lib/jirametrics/raw_javascript.rb +17 -0
  75. data/lib/jirametrics/settings.json +3 -1
  76. data/lib/jirametrics/sprint.rb +12 -0
  77. data/lib/jirametrics/sprint_burndown.rb +10 -4
  78. data/lib/jirametrics/status.rb +1 -1
  79. data/lib/jirametrics/stitcher.rb +81 -0
  80. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  81. data/lib/jirametrics/throughput_chart.rb +73 -23
  82. data/lib/jirametrics/time_based_histogram.rb +139 -0
  83. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  84. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  85. data/lib/jirametrics.rb +66 -1
  86. metadata +56 -5
@@ -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 = []
@@ -72,17 +74,36 @@ class Downloader
72
74
  download_statuses
73
75
  find_board_ids.each do |id|
74
76
  board = download_board_configuration board_id: id
77
+ board.project_config = @download_config.project_config
75
78
  download_issues board: board
76
79
  end
77
80
  download_users
78
81
 
79
82
  save_metadata
83
+ download_github_prs if @download_config.github_repos.any?
80
84
  end
81
85
 
82
86
  def log text, both: false
83
87
  @file_system.log text, also_write_to_stderr: both
84
88
  end
85
89
 
90
+ def log_start text
91
+ @file_system.log_start text
92
+ end
93
+
94
+ def start_progress
95
+ @file_system.start_progress
96
+ end
97
+
98
+ def progress_dot message = nil
99
+ @file_system.log message if message
100
+ @file_system.progress_dot
101
+ end
102
+
103
+ def end_progress
104
+ @file_system.end_progress
105
+ end
106
+
86
107
  def find_board_ids
87
108
  ids = @download_config.project_config.board_configs.collect(&:id)
88
109
  raise 'Board ids must be specified' if ids.empty?
@@ -165,11 +186,28 @@ class Downloader
165
186
  # actually look at the returned json.
166
187
  @board_id_to_filter_id[board_id] = json['filter']['id'].to_i
167
188
 
189
+ if json['type'] == 'simple'
190
+ features_json = download_features board_id: board_id
191
+ if BoardFeature.from_raw(features_json).any? { |f| f.name == 'jsw.agility.sprints' && f.enabled? }
192
+ download_sprints board_id: board_id
193
+ end
194
+ end
168
195
  download_sprints board_id: board_id if json['type'] == 'scrum'
169
196
  # TODO: Should be passing actual statuses, not empty list
170
197
  Board.new raw: json, possible_statuses: StatusCollection.new
171
198
  end
172
199
 
200
+ def download_features board_id:
201
+ log " Downloading features for board #{board_id}", both: true
202
+ json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/features"
203
+
204
+ @file_system.save_json(
205
+ json: json,
206
+ filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_features.json")
207
+ )
208
+ json
209
+ end
210
+
173
211
  def download_sprints board_id:
174
212
  log " Downloading sprints for board #{board_id}", both: true
175
213
  max_results = 100
@@ -211,19 +249,29 @@ class Downloader
211
249
  value = Date.parse(value) if value.is_a?(String) && value =~ /^\d{4}-\d{2}-\d{2}$/
212
250
  @metadata[key] = value
213
251
  end
252
+
214
253
  end
215
254
 
216
255
  # Even if this is the old format, we want to obey this one tag
217
256
  @metadata['no-download'] = hash['no-download'] if hash['no-download']
218
257
  end
219
258
 
259
+ def timezone_offset
260
+ @download_config.project_config.exporter.timezone_offset
261
+ end
262
+
263
+ def today_in_project_timezone
264
+ Time.now.getlocal(timezone_offset).to_date
265
+ end
266
+
220
267
  def save_metadata
221
268
  @metadata['version'] = CURRENT_METADATA_VERSION
269
+ @metadata['rolling_date_count'] = @download_config.rolling_date_count
222
270
  @metadata['date_start_from_last_query'] = @start_date_in_query if @start_date_in_query
223
271
 
224
272
  if @download_date_range.nil?
225
273
  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
274
+ today = today_in_project_timezone
227
275
  @download_date_range = (today - 7)..today
228
276
  end
229
277
 
@@ -258,7 +306,8 @@ class Downloader
258
306
  end
259
307
  end
260
308
 
261
- def make_jql filter_id:, today: Date.today
309
+ def make_jql filter_id:, today: nil
310
+ today ||= today_in_project_timezone
262
311
  segments = []
263
312
  segments << "filter=#{filter_id}"
264
313
 
@@ -283,6 +332,39 @@ class Downloader
283
332
  segments.join ' AND '
284
333
  end
285
334
 
335
+ def download_github_prs
336
+ project_keys = extract_project_keys_from_downloaded_issues
337
+ if project_keys.empty?
338
+ log ' No project keys found in downloaded issues, skipping GitHub PR download', both: true
339
+ return
340
+ end
341
+
342
+ prs = @download_config.github_repos.flat_map do |repo|
343
+ GithubGateway.new(
344
+ repo: repo,
345
+ project_keys: project_keys,
346
+ file_system: @file_system,
347
+ raw_pr_cache: @github_pr_cache
348
+ ).fetch_pull_requests(since: @download_date_range&.begin)
349
+ end
350
+
351
+ @file_system.save_json(
352
+ json: prs.map(&:raw),
353
+ filename: File.join(@target_path, "#{file_prefix}_github_prs.json")
354
+ )
355
+ end
356
+
357
+ def extract_project_keys_from_downloaded_issues
358
+ path = File.join(@target_path, "#{file_prefix}_issues")
359
+ return [] unless @file_system.dir_exist?(path)
360
+
361
+ keys = []
362
+ @file_system.foreach(path) do |filename|
363
+ keys << filename.split('-').first if filename.match?(/^[A-Z][A-Z_0-9]+-\d+-\d+\.json$/)
364
+ end
365
+ keys.uniq
366
+ end
367
+
286
368
  def file_prefix
287
369
  @download_config.project_config.get_file_prefix
288
370
  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
@@ -14,6 +53,7 @@ class DownloaderForCloud < Downloader
14
53
  next_page_token = nil
15
54
  issue_count = 0
16
55
 
56
+ start_progress
17
57
  loop do
18
58
  relative_url = +''
19
59
  relative_url << '/rest/api/3/search/jql'
@@ -36,11 +76,12 @@ class DownloaderForCloud < Downloader
36
76
  issue_count += 1
37
77
  end
38
78
 
39
- message = " Found #{issue_count} issues"
40
- log message, both: true
79
+ progress_dot " Found #{issue_count} issues"
41
80
 
42
81
  break unless next_page_token
43
82
  end
83
+ end_progress
84
+
44
85
  hash
45
86
  end
46
87
 
@@ -49,7 +90,7 @@ class DownloaderForCloud < Downloader
49
90
  # that only returns the "recent" changes, not all of them. So now we get the issue
50
91
  # without changes and then make a second call for that changes. Then we insert it
51
92
  # into the raw issue as if it had been there all along.
52
- log " Downloading #{issue_datas.size} issues", both: true
93
+ log " Downloading #{issue_datas.size} issues"
53
94
  payload = {
54
95
  'fields' => ['*all'],
55
96
  'issueIdsOrKeys' => issue_datas.collect(&:key)
@@ -67,11 +108,24 @@ class DownloaderForCloud < Downloader
67
108
  }
68
109
  issue = Issue.new(raw: issue_json, board: board)
69
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
70
115
  data.up_to_date = true
71
116
  data.last_modified = issue.updated
72
117
  data.issue = issue
73
118
  end
74
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
+
75
129
  issue_datas
76
130
  end
77
131
 
@@ -127,16 +181,20 @@ class DownloaderForCloud < Downloader
127
181
 
128
182
  issue_data_hash = search_for_issues jql: jql, board_id: board.id, path: path
129
183
 
184
+ checked_for_related = Set.new
185
+ in_related_phase = false
186
+
130
187
  loop do
131
188
  related_issue_keys = Set.new
132
- issue_data_hash
133
- .values
134
- .reject { |data| data.up_to_date }
135
- .each_slice(100) do |slice|
136
- slice = bulk_fetch_issues(
137
- issue_datas: slice, board: board, in_initial_query: true
138
- )
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
139
195
  slice.each do |data|
196
+ next unless data.issue
197
+
140
198
  @file_system.save_json(
141
199
  json: data.issue.raw, filename: data.cache_path
142
200
  )
@@ -144,20 +202,25 @@ class DownloaderForCloud < Downloader
144
202
  # to parse the file just to find the timestamp
145
203
  @file_system.utime time: data.issue.updated, file: data.cache_path
146
204
 
147
- issue = data.issue
148
- next unless issue
149
-
150
- parent_key = issue.parent_key(project_config: @download_config.project_config)
151
- related_issue_keys << parent_key if parent_key
152
-
153
- # Sub-tasks
154
- issue.raw['fields']['subtasks']&.each do |raw_subtask|
155
- related_issue_keys << raw_subtask['key']
156
- end
205
+ collect_related_issue_keys issue: data.issue, related_issue_keys: related_issue_keys
206
+ checked_for_related << data.key
157
207
  end
158
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
159
222
 
160
- # Remove all the ones we already downloaded
223
+ # Remove all the ones we already have
161
224
  related_issue_keys.reject! { |key| issue_data_hash[key] }
162
225
 
163
226
  related_issue_keys.each do |key|
@@ -169,9 +232,15 @@ class DownloaderForCloud < Downloader
169
232
  end
170
233
  break if related_issue_keys.empty?
171
234
 
172
- log " Downloading linked issues for board #{board.id}", both: true
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
173
240
  end
174
241
 
242
+ end_progress if in_related_phase
243
+
175
244
  delete_issues_from_cache_that_are_not_in_server(
176
245
  issue_data_hash: issue_data_hash, path: path
177
246
  )
@@ -196,6 +265,22 @@ class DownloaderForCloud < Downloader
196
265
  end
197
266
  end
198
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
+
199
284
  def last_modified filename:
200
285
  File.mtime(filename) if File.exist?(filename)
201
286
  end
@@ -25,7 +25,7 @@ class DownloaderForDataCenter < Downloader
25
25
  keys_to_request = @issue_keys_pending_download[0..99]
26
26
  @issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
27
27
  jql = "key in (#{keys_to_request.join(', ')})"
28
- jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
28
+ jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
29
29
  end
30
30
  end
31
31
 
@@ -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
@@ -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,14 +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
16
+ self.settings.merge! stringify_keys(settings)
17
17
 
18
18
  boards.each_key do |board_id|
19
19
  block = boards[board_id]
@@ -35,19 +35,20 @@ class Exporter
35
35
  download do
36
36
  self.rolling_date_count(rolling_date_count) if rolling_date_count
37
37
  self.no_earlier_than(no_earlier_than) if no_earlier_than
38
+ github_repo *github_repos if github_repos
38
39
  end
39
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 class="foldable">Expedited work</h1><p>There is no expedited work in this time period.</p>'
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
@@ -8,7 +8,13 @@ class Exporter
8
8
 
9
9
  def self.configure &block
10
10
  logfile_name = 'jirametrics.log'
11
- logfile = File.open logfile_name, 'w'
11
+ logfile = File.open(logfile_name, 'w')
12
+ rescue Errno::EACCES
13
+ # FileSystem can't be used here — it hasn't been created yet (it depends on this logfile).
14
+ warn "Error: Cannot write to #{File.expand_path(logfile_name)}. " \
15
+ 'Please ensure the current directory is writable.'
16
+ exit 1
17
+ else
12
18
  file_system = FileSystem.new
13
19
  file_system.logfile = logfile
14
20
  file_system.logfile_name = logfile_name
@@ -40,6 +46,7 @@ class Exporter
40
46
 
41
47
  def download name_filter:
42
48
  @downloading = true
49
+ github_pr_cache = {}
43
50
  each_project_config(name_filter: name_filter) do |project|
44
51
  project.evaluate_next_level
45
52
  next if project.aggregated_project?
@@ -50,15 +57,14 @@ class Exporter
50
57
  end
51
58
 
52
59
  project.download_config.run
53
- # load_jira_config(download_config.project_config.jira_config)
54
- # @ignore_ssl_errors = download_config.project_config.settings['ignore_ssl_errors']
55
60
  gateway = JiraGateway.new(
56
61
  file_system: file_system, jira_config: project.jira_config, settings: project.settings
57
62
  )
58
63
  downloader = Downloader.create(
59
64
  download_config: project.download_config,
60
65
  file_system: file_system,
61
- jira_gateway: gateway
66
+ jira_gateway: gateway,
67
+ github_pr_cache: github_pr_cache
62
68
  )
63
69
  downloader.run
64
70
  end
@@ -67,18 +73,23 @@ class Exporter
67
73
 
68
74
  def info key, name_filter:
69
75
  selected = []
76
+ file_system.log_only = true
70
77
  each_project_config(name_filter: name_filter) do |project|
71
78
  project.evaluate_next_level
72
79
 
73
80
  project.run load_only: true
74
81
  project.issues.each do |issue|
75
82
  selected << [project, issue] if key == issue.key
83
+ issue.subtasks.each do |subtask|
84
+ selected << [project, subtask] if key == subtask.key
85
+ end
76
86
  end
77
87
  rescue => e # rubocop:disable Style/RescueStandardError
78
88
  # This happens when we're attempting to load an aggregated project because it hasn't been
79
89
  # properly initialized. Since we don't care about aggregated projects, we just ignore it.
80
90
  raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
81
91
  end
92
+ file_system.log_only = false
82
93
 
83
94
  if selected.empty?
84
95
  file_system.log "No issues found to match #{key.inspect}"
@@ -90,6 +101,10 @@ class Exporter
90
101
  end
91
102
  end
92
103
 
104
+ def stitch stitch_file
105
+ Stitcher.new(file_system: file_system).run(stitch_file: stitch_file)
106
+ end
107
+
93
108
  def each_project_config name_filter:
94
109
  @project_configs.each do |project|
95
110
  yield project if project.name.nil? || File.fnmatch(name_filter, project.name)