jirametrics 2.31 → 3.0

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 (65) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +42 -29
  3. data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
  5. data/lib/jirametrics/aging_work_table.rb +36 -33
  6. data/lib/jirametrics/anonymizer.rb +31 -86
  7. data/lib/jirametrics/atlassian_document_format.rb +11 -4
  8. data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -1
  10. data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
  11. data/lib/jirametrics/board.rb +23 -9
  12. data/lib/jirametrics/board_config.rb +0 -7
  13. data/lib/jirametrics/board_movement_calculator.rb +24 -21
  14. data/lib/jirametrics/cfd_data_builder.rb +46 -44
  15. data/lib/jirametrics/change_item.rb +19 -7
  16. data/lib/jirametrics/chart_base.rb +50 -64
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +59 -46
  18. data/lib/jirametrics/cycle_time_config.rb +35 -46
  19. data/lib/jirametrics/cycletime_histogram.rb +7 -0
  20. data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
  21. data/lib/jirametrics/daily_view.rb +83 -61
  22. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
  23. data/lib/jirametrics/daily_wip_chart.rb +72 -45
  24. data/lib/jirametrics/data_quality_report.rb +61 -63
  25. data/lib/jirametrics/dependency_chart.rb +40 -31
  26. data/lib/jirametrics/download_config.rb +0 -3
  27. data/lib/jirametrics/downloader.rb +8 -7
  28. data/lib/jirametrics/downloader_for_cloud.rb +108 -72
  29. data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
  30. data/lib/jirametrics/examples/standard_project.rb +2 -2
  31. data/lib/jirametrics/expedited_chart.rb +47 -37
  32. data/lib/jirametrics/exporter.rb +19 -10
  33. data/lib/jirametrics/file_config.rb +19 -12
  34. data/lib/jirametrics/file_system.rb +16 -10
  35. data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
  36. data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
  37. data/lib/jirametrics/github_gateway.rb +23 -9
  38. data/lib/jirametrics/groupable_issue_chart.rb +3 -0
  39. data/lib/jirametrics/html/index.css +6 -0
  40. data/lib/jirametrics/html_report_config.rb +9 -29
  41. data/lib/jirametrics/issue.rb +290 -389
  42. data/lib/jirametrics/issue_collection.rb +1 -0
  43. data/lib/jirametrics/issue_printer.rb +60 -23
  44. data/lib/jirametrics/jira_gateway.rb +29 -18
  45. data/lib/jirametrics/mcp_server.rb +225 -209
  46. data/lib/jirametrics/project_config.rb +158 -134
  47. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
  48. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
  49. data/lib/jirametrics/rules.rb +1 -0
  50. data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
  51. data/lib/jirametrics/sprint_burndown.rb +142 -242
  52. data/lib/jirametrics/sprint_count_measure.rb +42 -0
  53. data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
  54. data/lib/jirametrics/sprint_points_measure.rb +62 -0
  55. data/lib/jirametrics/sprint_summary_stats.rb +16 -0
  56. data/lib/jirametrics/status_collection.rb +2 -2
  57. data/lib/jirametrics/stitcher.rb +21 -16
  58. data/lib/jirametrics/throughput_chart.rb +38 -24
  59. data/lib/jirametrics/time_based_chart.rb +65 -0
  60. data/lib/jirametrics/time_based_histogram.rb +30 -23
  61. data/lib/jirametrics/time_based_scatterplot.rb +11 -2
  62. data/lib/jirametrics/trend_line_calculator.rb +2 -2
  63. data/lib/jirametrics/wip_by_column_chart.rb +73 -32
  64. data/lib/jirametrics.rb +6 -2
  65. metadata +13 -20
@@ -130,7 +130,7 @@ class DownloaderForCloud < Downloader
130
130
  issue_datas
131
131
  end
132
132
 
133
- def attach_worklogs_to_issues issue_datas:, issue_jsons:, max_results: 100 # rubocop:disable Lint/UnusedMethodArgument
133
+ def attach_worklogs_to_issues issue_datas:, issue_jsons:, max_results: 100
134
134
  issue_jsons.each do |issue_json|
135
135
  worklog = issue_json['fields']['worklog']
136
136
  next unless worklog
@@ -140,27 +140,7 @@ class DownloaderForCloud < Downloader
140
140
  next if all_worklogs.size >= total
141
141
 
142
142
  key = issue_json['key']
143
- start_at = all_worklogs.size
144
-
145
- loop do
146
- response = @jira_gateway.call_url(
147
- relative_url: "/rest/api/3/issue/#{CGI.escape(key)}/worklog?startAt=#{start_at}&maxResults=#{max_results}"
148
- )
149
-
150
- worklogs = response['worklogs'] || []
151
- all_worklogs.concat(worklogs)
152
-
153
- total = response['total'].to_i
154
- log " #{key} worklogs: page startAt=#{start_at}, " \
155
- "received=#{worklogs.size}, fetched=#{all_worklogs.size}/#{total}"
156
- break if all_worklogs.size >= total
157
- # Guard against Jira reporting a higher total than it will actually return — seen when
158
- # worklogs are deleted or access-restricted after the initial fetch. Without this,
159
- # start_at never advances and we loop forever requesting the same empty page.
160
- break if worklogs.empty?
161
-
162
- start_at += worklogs.size
163
- end
143
+ fetch_remaining_worklogs key: key, all_worklogs: all_worklogs, max_results: max_results
164
144
 
165
145
  issue_json['fields']['worklog'] = {
166
146
  'startAt' => 0,
@@ -173,6 +153,30 @@ class DownloaderForCloud < Downloader
173
153
  end
174
154
  end
175
155
 
156
+ # Page through the worklogs Jira didn't include inline, appending each page to all_worklogs.
157
+ def fetch_remaining_worklogs key:, all_worklogs:, max_results:
158
+ start_at = all_worklogs.size
159
+ loop do
160
+ response = @jira_gateway.call_url(
161
+ relative_url: "/rest/api/3/issue/#{CGI.escape(key)}/worklog?startAt=#{start_at}&maxResults=#{max_results}"
162
+ )
163
+
164
+ worklogs = response['worklogs'] || []
165
+ all_worklogs.concat(worklogs)
166
+
167
+ total = response['total'].to_i
168
+ log " #{key} worklogs: page startAt=#{start_at}, " \
169
+ "received=#{worklogs.size}, fetched=#{all_worklogs.size}/#{total}"
170
+ break if all_worklogs.size >= total
171
+ # Guard against Jira reporting a higher total than it will actually return — seen when
172
+ # worklogs are deleted or access-restricted after the initial fetch. Without this,
173
+ # start_at never advances and we loop forever requesting the same empty page.
174
+ break if worklogs.empty?
175
+
176
+ start_at += worklogs.size
177
+ end
178
+ end
179
+
176
180
  def attach_changelog_to_issues issue_datas:, issue_jsons:
177
181
  max_results = 10_000 # The max jira accepts is 10K
178
182
  payload = {
@@ -212,71 +216,59 @@ class DownloaderForCloud < Downloader
212
216
 
213
217
  def download_issues board:
214
218
  log " Downloading primary issues for board #{board.id} from #{jira_instance_type}", both: true
219
+ path = ensure_issues_directory
220
+ issue_data_hash = search_for_issues jql: build_jql(board), board_id: board.id, path: path
221
+
222
+ download_all_issues board: board, path: path, issue_data_hash: issue_data_hash
223
+
224
+ delete_issues_from_cache_that_are_not_in_server(
225
+ issue_data_hash: issue_data_hash, path: path
226
+ )
227
+ end
228
+
229
+ def ensure_issues_directory
215
230
  path = File.join(@target_path, "#{file_prefix}_issues/")
216
231
  unless @file_system.dir_exist?(path)
217
232
  log " Creating path #{path}"
218
233
  @file_system.mkdir(path)
219
234
  end
235
+ path
236
+ end
220
237
 
221
- filter_id = @board_id_to_filter_id[board.id]
222
- jql = make_jql(filter_id: filter_id)
238
+ def build_jql board
239
+ jql = make_jql(filter_id: @board_id_to_filter_id[board.id])
223
240
  intercept_jql = @download_config.project_config.settings['intercept_jql']
224
- jql = intercept_jql.call jql if intercept_jql
225
-
226
- issue_data_hash = search_for_issues jql: jql, board_id: board.id, path: path
241
+ intercept_jql ? intercept_jql.call(jql) : jql
242
+ end
227
243
 
244
+ # Downloads the primary issues, then repeatedly follows related issues (parents, subtasks, links)
245
+ # until no new ones turn up. `in_related_phase` is false for the first pass and true thereafter,
246
+ # which controls the once-only "Identifying related issues" announcement and the loop diagnostics.
247
+ def download_all_issues board:, path:, issue_data_hash:
228
248
  checked_for_related = Set.new
229
249
  in_related_phase = false
230
250
 
231
251
  loop do
232
252
  related_issue_keys = Set.new
233
- stale = issue_data_hash.values.reject { |data| data.up_to_date }
253
+ stale = issue_data_hash.values.reject(&:up_to_date)
234
254
  if in_related_phase
235
255
  @file_system.diagnostic "Download loop: #{issue_data_hash.size} total known, " \
236
256
  "#{stale.size} stale, #{checked_for_related.size} link-scanned"
237
257
  end
238
- unless stale.empty?
239
- log_start ' Downloading more issues ' unless in_related_phase
240
- stale.each_slice(100) do |slice|
241
- slice = bulk_fetch_issues(issue_datas: slice, board: board, in_initial_query: !in_related_phase)
242
- progress_dot
243
- slice.each do |data|
244
- next unless data.issue
245
-
246
- @file_system.save_json(
247
- json: data.issue.raw, filename: data.cache_path
248
- )
249
- # Set the timestamp on the file to match the updated one so that we don't have
250
- # to parse the file just to find the timestamp
251
- @file_system.utime time: data.issue.updated, file: data.cache_path
252
-
253
- collect_or_log_related(
254
- issue: data.issue, found_in_primary_query: data.found_in_primary_query,
255
- related_issue_keys: related_issue_keys, issue_data_hash: issue_data_hash
256
- )
257
- checked_for_related << data.key
258
- end
259
- end
260
- end_progress unless in_related_phase
261
- end
258
+ download_stale_issues(
259
+ stale: stale, board: board, in_related_phase: in_related_phase,
260
+ checked_for_related: checked_for_related, related_issue_keys: related_issue_keys,
261
+ issue_data_hash: issue_data_hash
262
+ )
262
263
 
263
264
  scan_cached_issues_for_related(
264
265
  issue_data_hash: issue_data_hash, board: board,
265
266
  checked_for_related: checked_for_related, related_issue_keys: related_issue_keys
266
267
  )
267
-
268
- # Remove all the ones we already have
269
- related_issue_keys.reject! { |key| issue_data_hash[key] }
270
-
271
- related_issue_keys.each do |key|
272
- data = DownloadIssueData.new key: key
273
- data.found_in_primary_query = false
274
- data.up_to_date = false
275
- data.cache_path = File.join(path, "#{key}-#{board.id}.json")
276
- issue_data_hash[key] = data
277
- end
268
+ register_related_issues(
269
+ related_issue_keys: related_issue_keys, issue_data_hash: issue_data_hash, path: path, board: board
270
+ )
278
271
  break if related_issue_keys.empty?
279
-
280
272
  next if in_related_phase
281
273
 
282
274
  in_related_phase = true
@@ -285,10 +277,52 @@ class DownloaderForCloud < Downloader
285
277
  end
286
278
 
287
279
  end_progress if in_related_phase
280
+ end
288
281
 
289
- delete_issues_from_cache_that_are_not_in_server(
290
- issue_data_hash: issue_data_hash, path: path
291
- )
282
+ def download_stale_issues stale:, board:, in_related_phase:, checked_for_related:, related_issue_keys:,
283
+ issue_data_hash:
284
+ return if stale.empty?
285
+
286
+ log_start ' Downloading more issues ' unless in_related_phase
287
+ stale.each_slice(100) do |slice|
288
+ slice = bulk_fetch_issues(issue_datas: slice, board: board, in_initial_query: !in_related_phase)
289
+ progress_dot
290
+ save_fetched_issues(
291
+ slice: slice, checked_for_related: checked_for_related,
292
+ related_issue_keys: related_issue_keys, issue_data_hash: issue_data_hash
293
+ )
294
+ end
295
+ end_progress unless in_related_phase
296
+ end
297
+
298
+ def save_fetched_issues slice:, checked_for_related:, related_issue_keys:, issue_data_hash:
299
+ slice.each do |data|
300
+ next unless data.issue
301
+
302
+ @file_system.save_json(json: data.issue.raw, filename: data.cache_path)
303
+ # Set the timestamp on the file to match the updated one so that we don't have
304
+ # to parse the file just to find the timestamp
305
+ @file_system.utime time: data.issue.updated, file: data.cache_path
306
+
307
+ collect_or_log_related(
308
+ issue: data.issue, found_in_primary_query: data.found_in_primary_query,
309
+ related_issue_keys: related_issue_keys, issue_data_hash: issue_data_hash
310
+ )
311
+ checked_for_related << data.key
312
+ end
313
+ end
314
+
315
+ def register_related_issues related_issue_keys:, issue_data_hash:, path:, board:
316
+ # Remove all the ones we already have
317
+ related_issue_keys.reject! { |key| issue_data_hash[key] }
318
+
319
+ related_issue_keys.each do |key|
320
+ data = DownloadIssueData.new key: key
321
+ data.found_in_primary_query = false
322
+ data.up_to_date = false
323
+ data.cache_path = File.join(path, "#{key}-#{board.id}.json")
324
+ issue_data_hash[key] = data
325
+ end
292
326
  end
293
327
 
294
328
  def delete_issues_from_cache_that_are_not_in_server issue_data_hash:, path:
@@ -348,18 +382,20 @@ class DownloaderForCloud < Downloader
348
382
  parent_key = issue.parent_key(project_config: @download_config.project_config)
349
383
  keys << parent_key if parent_key
350
384
 
351
- issue.raw['fields']['subtasks']&.each do |raw_subtask|
352
- keys << raw_subtask['key']
353
- end
385
+ issue.raw['fields']['subtasks']&.each { |raw_subtask| keys << raw_subtask['key'] }
386
+ add_linked_issue_keys issue, keys
387
+
388
+ keys
389
+ end
354
390
 
391
+ # Add the keys of issues linked to this one, skipping clone links (a clone isn't a dependency).
392
+ def add_linked_issue_keys issue, keys
355
393
  issue.raw['fields']['issuelinks']&.each do |link|
356
394
  next if link['type']['name'] == 'Cloners'
357
395
 
358
396
  linked = link['inwardIssue'] || link['outwardIssue']
359
397
  keys << linked['key'] if linked
360
398
  end
361
-
362
- keys
363
399
  end
364
400
 
365
401
  # We only follow links one hop out from the primary (board) issues. If a related issue
@@ -110,25 +110,25 @@ class EstimateAccuracyChart < ChartBase
110
110
  def split_into_completed_and_aging issues:
111
111
  aging_hash = {}
112
112
  completed_hash = {}
113
+ issues.each { |issue| bucket_issue_by_estimate issue, completed_hash: completed_hash, aging_hash: aging_hash }
114
+ [completed_hash, aging_hash]
115
+ end
113
116
 
114
- issues.each do |issue|
115
- cycletime = issue.board.cycletime
116
- start_time, stop_time = cycletime.started_stopped_times(issue)
117
+ def bucket_issue_by_estimate issue, completed_hash:, aging_hash:
118
+ cycletime = issue.board.cycletime
119
+ start_time, stop_time = cycletime.started_stopped_times(issue)
117
120
 
118
- next unless start_time
121
+ return unless start_time
119
122
 
120
- hash = stop_time ? completed_hash : aging_hash
123
+ hash = stop_time ? completed_hash : aging_hash
121
124
 
122
- estimate = @y_axis_block.call issue, start_time
123
- cycle_time = ((stop_time&.to_date || date_range.end) - start_time.to_date).to_i + 1
125
+ estimate = @y_axis_block.call issue, start_time
126
+ cycle_time = ((stop_time&.to_date || date_range.end) - start_time.to_date).to_i + 1
124
127
 
125
- next if estimate.nil?
128
+ return if estimate.nil?
126
129
 
127
- key = [estimate, cycle_time]
128
- (hash[key] ||= []) << issue
129
- end
130
-
131
- [completed_hash, aging_hash]
130
+ key = [estimate, cycle_time]
131
+ (hash[key] ||= []) << issue
132
132
  end
133
133
 
134
134
  def hash_sorter
@@ -35,7 +35,7 @@ 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
+ github_repo(*github_repos) if github_repos
39
39
  end
40
40
 
41
41
  issues.reject! do |issue|
@@ -44,7 +44,7 @@ class Exporter
44
44
 
45
45
  exporter.filter_issues issues, ignore_issues
46
46
 
47
- discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
47
+ discard_changes_before status_becomes: (starting_status || :backlog)
48
48
 
49
49
  file do
50
50
  file_suffix '.html'
@@ -67,12 +67,7 @@ class ExpeditedChart < ChartBase
67
67
  if expedited_priority_names.include? change.value
68
68
  expedite_start = change.time.to_date
69
69
  elsif expedite_start
70
- start_date = expedite_start.to_date
71
- stop_date = change.time.to_date
72
-
73
- if date_range.include?(start_date) || date_range.include?(stop_date) ||
74
- (start_date < date_range.begin && stop_date > date_range.end)
75
-
70
+ if expedite_visible?(expedite_start, change.time.to_date)
76
71
  result << [expedite_start, :expedite_start]
77
72
  result << [change.time.to_date, :expedite_stop]
78
73
  end
@@ -85,6 +80,13 @@ class ExpeditedChart < ChartBase
85
80
  result
86
81
  end
87
82
 
83
+ # True when an expedite span overlaps the chart's date range: either endpoint falls inside it, or it
84
+ # starts before and ends after (spanning the whole range).
85
+ def expedite_visible? start_date, stop_date
86
+ date_range.include?(start_date) || date_range.include?(stop_date) ||
87
+ (start_date < date_range.begin && stop_date > date_range.end)
88
+ end
89
+
88
90
  def find_expedited_issues
89
91
  expedited_issues = @issues.reject do |issue|
90
92
  prepare_expedite_data(issue).empty?
@@ -110,8 +112,7 @@ class ExpeditedChart < ChartBase
110
112
  end
111
113
 
112
114
  def make_expedite_lines_data_set issue:, expedite_data:
113
- cycletime = issue.board.cycletime
114
- started_date, stopped_date = cycletime.started_stopped_dates(issue)
115
+ started_date, stopped_date = issue.board.cycletime.started_stopped_dates(issue)
115
116
 
116
117
  expedite_data << [started_date, :issue_started] if started_date
117
118
  expedite_data << [stopped_date, :issue_stopped] if stopped_date
@@ -126,37 +127,16 @@ class ExpeditedChart < ChartBase
126
127
  expedited = false
127
128
 
128
129
  expedite_data.each do |time, action|
129
- case action
130
- when :issue_started
131
- data << make_point(issue: issue, time: time, label: 'Started', expedited: expedited)
132
- dot_colors << CssVariable['--expedited-chart-dot-issue-started-color']
133
- point_styles << 'rect'
134
- when :issue_stopped
135
- data << make_point(issue: issue, time: time, label: 'Completed', expedited: expedited)
136
- dot_colors << CssVariable['--expedited-chart-dot-issue-stopped-color']
137
- point_styles << 'rect'
138
- when :expedite_start
139
- data << make_point(issue: issue, time: time, label: 'Expedited', expedited: true)
140
- dot_colors << CssVariable['--expedited-chart-dot-expedite-started-color']
141
- point_styles << 'circle'
142
- expedited = true
143
- when :expedite_stop
144
- data << make_point(issue: issue, time: time, label: 'Not expedited', expedited: false)
145
- dot_colors << CssVariable['--expedited-chart-dot-expedite-stopped-color']
146
- point_styles << 'circle'
147
- expedited = false
148
- else
149
- raise "Unexpected action: #{action}"
150
- end
130
+ point, color, style, expedited = expedite_point(issue, time, action, expedited)
131
+ data << point
132
+ dot_colors << color
133
+ point_styles << style
151
134
  end
152
135
 
153
- unless expedite_data.empty?
154
- last_change_time = expedite_data[-1][0].to_date
155
- if last_change_time && last_change_time <= date_range.end && stopped_date.nil?
156
- data << make_point(issue: issue, time: date_range.end, label: 'Still ongoing', expedited: expedited)
157
- dot_colors << '' # It won't be visible so it doesn't matter
158
- point_styles << 'dash'
159
- end
136
+ if still_ongoing?(expedite_data, stopped_date)
137
+ data << make_point(issue: issue, time: date_range.end, label: 'Still ongoing', expedited: expedited)
138
+ dot_colors << '' # It won't be visible so it doesn't matter
139
+ point_styles << 'dash'
160
140
  end
161
141
 
162
142
  {
@@ -171,4 +151,34 @@ class ExpeditedChart < ChartBase
171
151
  segment: EXPEDITED_SEGMENT
172
152
  }
173
153
  end
154
+
155
+ # Returns [point, dot_color, point_style, expedited] for one timeline event. The returned expedited
156
+ # flag carries the (possibly changed) state forward to the next event.
157
+ def expedite_point issue, time, action, expedited
158
+ case action
159
+ when :issue_started
160
+ [make_point(issue: issue, time: time, label: 'Started', expedited: expedited),
161
+ CssVariable['--expedited-chart-dot-issue-started-color'], 'rect', expedited]
162
+ when :issue_stopped
163
+ [make_point(issue: issue, time: time, label: 'Completed', expedited: expedited),
164
+ CssVariable['--expedited-chart-dot-issue-stopped-color'], 'rect', expedited]
165
+ when :expedite_start
166
+ [make_point(issue: issue, time: time, label: 'Expedited', expedited: true),
167
+ CssVariable['--expedited-chart-dot-expedite-started-color'], 'circle', true]
168
+ when :expedite_stop
169
+ [make_point(issue: issue, time: time, label: 'Not expedited', expedited: false),
170
+ CssVariable['--expedited-chart-dot-expedite-stopped-color'], 'circle', false]
171
+ else
172
+ raise "Unexpected action: #{action}"
173
+ end
174
+ end
175
+
176
+ # The issue is still expedited/open at the end of the chart: it hasn't stopped and its last recorded
177
+ # change is on or before the end of the range.
178
+ def still_ongoing? expedite_data, stopped_date
179
+ return false if expedite_data.empty?
180
+
181
+ last_change_time = expedite_data[-1][0].to_date
182
+ last_change_time && last_change_time <= date_range.end && stopped_date.nil?
183
+ end
174
184
  end
@@ -8,7 +8,9 @@ class Exporter
8
8
 
9
9
  def self.configure &block
10
10
  logfile_name = 'jirametrics.log'
11
- logfile = File.open(logfile_name, 'w')
11
+ # No block form: FileSystem holds this descriptor open for the whole run and writes to it as we
12
+ # go, so it must outlive this method.
13
+ logfile = File.open(logfile_name, 'w') # rubocop:disable Style/FileOpen
12
14
  rescue Errno::EACCES
13
15
  # FileSystem can't be used here — it hasn't been created yet (it depends on this logfile).
14
16
  warn "Error: Cannot write to #{File.expand_path(logfile_name)}. " \
@@ -78,12 +80,7 @@ class Exporter
78
80
  project.evaluate_next_level
79
81
 
80
82
  project.run load_only: true
81
- project.issues.each do |issue|
82
- selected << [project, issue] if key == issue.key
83
- issue.subtasks.each do |subtask|
84
- selected << [project, subtask] if key == subtask.key
85
- end
86
- end
83
+ selected.concat matching_issues_in(project, key)
87
84
  rescue => e # rubocop:disable Style/RescueStandardError
88
85
  # This happens when we're attempting to load an aggregated project because it hasn't been
89
86
  # properly initialized. Since we don't care about aggregated projects, we just ignore it.
@@ -101,6 +98,17 @@ class Exporter
101
98
  end
102
99
  end
103
100
 
101
+ def matching_issues_in project, key
102
+ matches = []
103
+ project.issues.each do |issue|
104
+ matches << [project, issue] if key == issue.key
105
+ issue.subtasks.each do |subtask|
106
+ matches << [project, subtask] if key == subtask.key
107
+ end
108
+ end
109
+ matches
110
+ end
111
+
104
112
  def stitch stitch_file
105
113
  Stitcher.new(file_system: file_system).run(stitch_file: stitch_file)
106
114
  end
@@ -139,7 +147,8 @@ class Exporter
139
147
  @jira_config = file_system.load_json(filename, fail_on_error: false)
140
148
  raise "Unable to load Jira configuration file and cannot continue: #{filename.inspect}" if @jira_config.nil?
141
149
 
142
- @jira_config['url'] = $1 if @jira_config['url'] =~ /^(.+)\/+$/
150
+ match = %r{^(?<base_url>.+)/+$}.match(@jira_config['url'])
151
+ @jira_config['url'] = match[:base_url] if match
143
152
  end
144
153
  @jira_config
145
154
  end
@@ -153,8 +162,8 @@ class Exporter
153
162
  unless args.empty?
154
163
  dates = []
155
164
  args.each do |arg|
156
- if arg =~ /^(\d{4}-\d{2}-\d{2})\.\.(\d{4}-\d{2}-\d{2})$/
157
- Date.parse($1).upto(Date.parse($2)).each { |date| dates << date }
165
+ if /^(?<from>\d{4}-\d{2}-\d{2})\.\.(?<to>\d{4}-\d{2}-\d{2})$/ =~ arg
166
+ Date.parse(from).upto(Date.parse(to)).each { |date| dates << date }
158
167
  else
159
168
  dates << Date.parse(arg)
160
169
  end
@@ -5,11 +5,12 @@ require 'csv'
5
5
  class FileConfig
6
6
  attr_reader :project_config, :issues
7
7
 
8
- def initialize project_config:, block:, today: Date.today
8
+ def initialize project_config:, block:, today: Date.today, issues: nil
9
9
  @project_config = project_config
10
10
  @block = block
11
11
  @columns = nil
12
12
  @today = today
13
+ @issues = issues
13
14
  end
14
15
 
15
16
  def run
@@ -65,22 +66,28 @@ class FileConfig
65
66
  # most common usecase - the Team Dashboard from FocusedObjective.com. The rule for that one
66
67
  # is that all empty values in the first column should be at the bottom.
67
68
  def sort_output all_lines
68
- all_lines.each_with_index.sort do |(a, a_idx), (b, b_idx)|
69
- result = if a[0] == b[0]
70
- a[1..] <=> b[1..]
71
- elsif a[0].nil?
72
- 1
73
- elsif b[0].nil?
74
- -1
75
- else
76
- a[0] <=> b[0]
77
- end
69
+ all_lines.each_with_index.sort do |(left, left_idx), (right, right_idx)|
70
+ result = compare_rows left, right
78
71
 
79
72
  # When objects aren't comparable, preserve original order for a stable sort.
80
- result.nil? || result.zero? ? a_idx <=> b_idx : result
73
+ result.nil? || result.zero? ? left_idx <=> right_idx : result
81
74
  end.map(&:first)
82
75
  end
83
76
 
77
+ # Compare two rows by their first column, falling back to the remaining columns on a tie, and
78
+ # sorting nil first columns to the bottom.
79
+ def compare_rows left, right
80
+ if left[0] == right[0]
81
+ left[1..] <=> right[1..]
82
+ elsif left[0].nil?
83
+ 1
84
+ elsif right[0].nil?
85
+ -1
86
+ else
87
+ left[0] <=> right[0]
88
+ end
89
+ end
90
+
84
91
  def columns &block
85
92
  assert_only_one_filetype_config_set
86
93
  @columns = ColumnsConfig.new file_config: self, block: block
@@ -2,6 +2,10 @@
2
2
 
3
3
  require 'json'
4
4
 
5
+ # This is the stderr display/logging layer: it writes progress and log output straight to $stderr
6
+ # (see the $stderr.print/.flush calls). That output isn't warnings, so $stderr.puts is intentional
7
+ # throughout rather than warn.
8
+ # rubocop:disable Style/StderrPuts
5
9
  class FileSystem
6
10
  attr_accessor :logfile, :logfile_name, :log_only
7
11
 
@@ -14,18 +18,14 @@ class FileSystem
14
18
  end
15
19
 
16
20
  # Effectively the same as File.read except it forces the encoding to UTF-8
17
- def load filename, supress_deprecation: false
18
- if filename.end_with?('.json') && !supress_deprecation
19
- deprecated(message: 'call load_json instead', date: '2024-11-13')
20
- end
21
-
21
+ def load filename
22
22
  File.read filename, encoding: 'UTF-8'
23
23
  end
24
24
 
25
25
  def load_json filename, fail_on_error: true
26
26
  return nil if fail_on_error == false && File.exist?(filename) == false
27
27
 
28
- JSON.parse load(filename, supress_deprecation: true)
28
+ JSON.parse load(filename)
29
29
  end
30
30
 
31
31
  def save_json json:, filename:
@@ -70,7 +70,7 @@ class FileSystem
70
70
  # set up. Quick escape here so that we don't dump the error twice.
71
71
  return if logfile == $stdout
72
72
 
73
- $stderr.puts message # rubocop:disable Style/StderrPuts
73
+ $stderr.puts message
74
74
  end
75
75
 
76
76
  def log_start message
@@ -98,21 +98,26 @@ class FileSystem
98
98
  def end_progress
99
99
  return if log_only
100
100
 
101
- $stderr.puts '' # rubocop:disable Style/StderrPuts
101
+ $stderr.puts ''
102
102
  end
103
103
 
104
104
  # In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
105
105
  # cases where this simple compression will drop the filesize by half.
106
106
  def compress node
107
107
  if node.is_a? Hash
108
- node.reject! { |_key, value| value.nil? || (value.is_a?(Array) && value.empty?) }
108
+ node.reject! { |_key, value| prunable? value }
109
109
  node.each_value { |value| compress value }
110
110
  elsif node.is_a? Array
111
- node.each { |a| compress a }
111
+ node.each { |element| compress element }
112
112
  end
113
113
  node
114
114
  end
115
115
 
116
+ # A value worth dropping from the compressed output: nothing there, or an empty array.
117
+ def prunable? value
118
+ value.nil? || (value.is_a?(Array) && value.empty?)
119
+ end
120
+
116
121
  def foreach root, &block
117
122
  Dir.foreach root, &block
118
123
  end
@@ -139,3 +144,4 @@ class FileSystem
139
144
  log text, also_write_to_stderr: true
140
145
  end
141
146
  end
147
+ # rubocop:enable Style/StderrPuts
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Walks a stream of BlockedStalledChange entries and totals up the value-add (active) time between
4
+ # issue_start and end_time. It's handed the already-computed stream and the resolved start/end so it
5
+ # never reaches back through the issue's collaborators.
6
+ #
7
+ # Returns [value_add_seconds, total_seconds].
8
+ class FlowEfficiencyCalculator
9
+ def initialize blocked_stalled_changes:, issue_start:, end_time:
10
+ @blocked_stalled_changes = blocked_stalled_changes
11
+ @issue_start = issue_start
12
+ @end_time = end_time
13
+ end
14
+
15
+ def calculate
16
+ @active_start = nil
17
+ @value_add_time = 0.0
18
+
19
+ @blocked_stalled_changes.each_with_index do |change, index|
20
+ break if change.time > @end_time
21
+
22
+ process change, index
23
+ end
24
+
25
+ close_final_active_period
26
+
27
+ [@value_add_time, @end_time - @issue_start]
28
+ end
29
+
30
+ def process change, index
31
+ if index.zero?
32
+ @active_start = change.time if change.active?
33
+ return
34
+ end
35
+
36
+ process_transition change
37
+ end
38
+
39
+ def process_transition change
40
+ # Already active and we just got another active.
41
+ return if @active_start && change.active?
42
+
43
+ if change.active?
44
+ @active_start = change.time
45
+ elsif @active_start && change.time >= @issue_start
46
+ # Not active now but we have been. Record the active time.
47
+ record_active_period ending_at: change.time
48
+ @active_start = nil
49
+ end
50
+ end
51
+
52
+ def record_active_period ending_at:
53
+ @value_add_time += ending_at - [@issue_start, @active_start].max
54
+ end
55
+
56
+ def close_final_active_period
57
+ return unless @active_start
58
+
59
+ change_delta = @end_time - [@issue_start, @active_start].max
60
+ @value_add_time += change_delta if change_delta.positive?
61
+ end
62
+ end