jirametrics 2.31 → 3.1

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 (66) 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 +51 -65
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +60 -47
  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 +70 -66
  25. data/lib/jirametrics/dependency_chart.rb +40 -31
  26. data/lib/jirametrics/download_config.rb +0 -3
  27. data/lib/jirametrics/downloader.rb +39 -14
  28. data/lib/jirametrics/downloader_for_cloud.rb +110 -74
  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 +203 -12
  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 +52 -12
  38. data/lib/jirametrics/groupable_issue_chart.rb +3 -0
  39. data/lib/jirametrics/html/cumulative_flow_diagram.erb +3 -3
  40. data/lib/jirametrics/html/index.css +10 -4
  41. data/lib/jirametrics/html_report_config.rb +9 -29
  42. data/lib/jirametrics/issue.rb +293 -411
  43. data/lib/jirametrics/issue_collection.rb +1 -0
  44. data/lib/jirametrics/issue_printer.rb +60 -23
  45. data/lib/jirametrics/jira_gateway.rb +56 -18
  46. data/lib/jirametrics/mcp_server.rb +226 -210
  47. data/lib/jirametrics/project_config.rb +206 -133
  48. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
  49. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
  50. data/lib/jirametrics/rules.rb +1 -0
  51. data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
  52. data/lib/jirametrics/sprint_burndown.rb +142 -242
  53. data/lib/jirametrics/sprint_count_measure.rb +42 -0
  54. data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
  55. data/lib/jirametrics/sprint_points_measure.rb +62 -0
  56. data/lib/jirametrics/sprint_summary_stats.rb +16 -0
  57. data/lib/jirametrics/status_collection.rb +29 -5
  58. data/lib/jirametrics/stitcher.rb +21 -16
  59. data/lib/jirametrics/throughput_chart.rb +38 -24
  60. data/lib/jirametrics/time_based_chart.rb +65 -0
  61. data/lib/jirametrics/time_based_histogram.rb +30 -23
  62. data/lib/jirametrics/time_based_scatterplot.rb +11 -2
  63. data/lib/jirametrics/trend_line_calculator.rb +2 -2
  64. data/lib/jirametrics/wip_by_column_chart.rb +75 -34
  65. data/lib/jirametrics.rb +35 -4
  66. metadata +13 -20
@@ -26,7 +26,7 @@ class Issue
26
26
  return unless @raw['fields']
27
27
 
28
28
  # If this is an older pull of data then comments may not be there.
29
- load_comments_into_changes if @raw['fields']['comment']
29
+ load_comments_into_changes if raw_fields['comment']
30
30
 
31
31
  # It might appear that Jira already returns these in order but we've found different
32
32
  # versions of Server/Cloud return the changelog in different orders so we sort them.
@@ -36,40 +36,46 @@ class Issue
36
36
  # not showing up in the change log. Create some artificial entries to capture those.
37
37
  @changes = [
38
38
  fabricate_change(field_name: 'status'),
39
- fabricate_change(field_name: 'priority')
39
+ fabricate_change(field_name: 'priority'),
40
+ fabricate_sprint_change
40
41
  ].compact + @changes
41
- rescue # rubocop:disable Style/RescueStandardError
42
+ rescue # rubocop:disable Style/RescueStandardError -- deliberately broad: any failure is re-raised with context
42
43
  # All we're doing is adding information to the existing exception and letting it propogate up
43
44
  raise "Unable to initialize #{raw['key']}"
44
45
  end
45
46
 
46
47
  def key = @raw['key']
47
48
 
48
- def type = @raw['fields']['issuetype']['name']
49
- def type_icon_url = @raw['fields']['issuetype']['iconUrl']
49
+ # 'fields' is the one part of the issue JSON that must always be present -- its absence means the
50
+ # payload is malformed. (Linked-issue fragments legitimately have no fields; that case is guarded in
51
+ # initialize, which returns before any of the fields-based accessors below can run.)
52
+ def raw_fields
53
+ @raw['fields'] || raise("Issue(#{@raw['key']}) has no 'fields'; is this an Issue JSON?")
54
+ end
55
+
56
+ def type = raw_fields['issuetype']['name']
57
+ def type_icon_url = raw_fields['issuetype']['iconUrl']
50
58
 
51
59
  def priority_name = @raw.dig('fields', 'priority', 'name')
52
60
  def priority_url = @raw.dig('fields', 'priority', 'iconUrl')
53
61
 
54
- def summary = @raw['fields']['summary']
62
+ def summary = raw_fields['summary']
55
63
 
56
- def labels = @raw['fields']['labels'] || []
64
+ def labels = raw_fields['labels'] || []
57
65
 
58
- def author = @raw['fields']['creator']&.[]('displayName') || ''
66
+ def author = raw_fields['creator']&.[]('displayName') || ''
59
67
 
60
- def resolution = @raw['fields']['resolution']&.[]('name')
68
+ def resolution = raw_fields['resolution']&.[]('name')
61
69
 
62
70
  def status
63
- @status = Status.from_raw(@raw['fields']['status']) unless @status
71
+ @status ||= Status.from_raw(raw_fields['status'])
64
72
  @status
65
73
  end
66
74
 
67
- def status= status
68
- @status = status
69
- end
75
+ attr_writer :status
70
76
 
71
77
  def due_date
72
- text = @raw['fields']['duedate']
78
+ text = raw_fields['duedate']
73
79
  text.nil? ? nil : Date.parse(text)
74
80
  end
75
81
 
@@ -79,11 +85,11 @@ class Issue
79
85
  end
80
86
 
81
87
  def key_as_i
82
- key =~ /-(\d+)$/ ? $1.to_i : 0
88
+ /-(?<number>\d+)$/ =~ key ? number.to_i : 0
83
89
  end
84
90
 
85
91
  def component_names
86
- @raw['fields']['components']&.collect { |component| component['name'] } || []
92
+ raw_fields['components']&.collect { |component| component['name'] } || []
87
93
  end
88
94
 
89
95
  def first_time_in_status *status_names
@@ -103,7 +109,7 @@ class Issue
103
109
  next unless change.labels?
104
110
 
105
111
  change_labels = change.value.split
106
- return change if change_labels.any? { |l| labels.include?(l) }
112
+ return change if change_labels.intersect?(labels)
107
113
  end
108
114
  nil
109
115
  end
@@ -171,27 +177,8 @@ class Issue
171
177
  end
172
178
 
173
179
  def find_or_create_status id:, name:
174
- status = board.possible_statuses.find_by_id(id)
175
-
176
- unless status
177
- # Have to pull this list before the call to fabricate or else the warning will incorrectly
178
- # list this status as one it actually found
179
- found_statuses = board.possible_statuses.to_s
180
-
181
- status = board.possible_statuses.fabricate_status_for id: id, name: name
182
-
183
- message = +'The history for issue '
184
- message << key
185
- message << ' references the status ('
186
- message << "#{name.inspect}:#{id.inspect}"
187
- message << ') that can\'t be found. We are guessing that this belongs to the '
188
- message << status.category.to_s
189
- message << ' status category but that may be wrong. See https://jirametrics.org/faq/#q1 for more '
190
- message << 'details on defining statuses.'
191
- board.project_config.file_system.warning message, more: "The statuses we did find are: #{found_statuses}"
192
- end
193
-
194
- status
180
+ board.possible_statuses.find_by_id(id) ||
181
+ board.possible_statuses.fabricate_status_for(id: id, name: name, example_issue_key: key)
195
182
  end
196
183
 
197
184
  def first_status_change_after_created
@@ -213,22 +200,24 @@ class Issue
213
200
  visible_status_ids = board.visible_columns.collect(&:status_ids).flatten
214
201
  return first_time_in_status(*visible_status_ids) unless board.scrum?
215
202
 
216
- # For scrum boards, an issue is only visible when BOTH conditions are true simultaneously:
217
- # 1. Its status is in a visible column
218
- # 2. It is in an active sprint
219
- # At each moment one condition becomes true, check if the other is already true.
220
- candidates = []
203
+ # On scrum boards an issue is only visible when its status is in a visible column AND it is in an
204
+ # active sprint. Each source below is a moment when the second condition became true while the first
205
+ # already held; the earliest of them is when it first became visible.
206
+ candidates = visible_status_changes_in_active_sprint(visible_status_ids) +
207
+ sprint_entries_while_in_visible_status(visible_status_ids)
208
+ candidates.min_by(&:time)
209
+ end
221
210
 
222
- status_changes.each do |change|
223
- next unless visible_status_ids.include?(change.value_id)
224
- candidates << change if in_active_sprint_at?(change.time)
211
+ def visible_status_changes_in_active_sprint visible_status_ids
212
+ status_changes.select do |change|
213
+ visible_status_ids.include?(change.value_id) && in_active_sprint_at?(change.time)
225
214
  end
215
+ end
226
216
 
227
- sprint_entry_events.each do |effective_time, representative_change|
228
- candidates << representative_change if in_visible_status_at?(effective_time, visible_status_ids)
217
+ def sprint_entries_while_in_visible_status visible_status_ids
218
+ sprint_entry_events.filter_map do |effective_time, representative_change|
219
+ representative_change if in_visible_status_at?(effective_time, visible_status_ids)
229
220
  end
230
-
231
- candidates.min_by(&:time)
232
221
  end
233
222
 
234
223
  def reasons_not_visible_on_board
@@ -244,87 +233,113 @@ class Issue
244
233
  reasons_not_visible_on_board.empty?
245
234
  end
246
235
 
247
- # If this issue will ever be in an active sprint then return the time that it
248
- # was first added to that sprint, whether or not the sprint was active at that
249
- # time. Although it seems like an odd thing to calculate, it's a reasonable proxy
250
- # for 'ready' in cases where the team doesn't have an explicit 'ready' status.
251
- # You'd be better off with an explicit 'ready' but sometimes that's not an option.
236
+ # A sprint the issue was added to: its start (all we care about is whether it started) and the change
237
+ # that added it.
238
+ SprintMembership = Data.define(:sprint_id, :sprint_start, :change)
239
+
240
+ # Like SprintMembership but also tracks when this issue was added, used while pairing sprint entries
241
+ # and exits in #sprint_entry_events.
242
+ TrackedSprint = Data.define(:sprint_id, :sprint_start, :add_time, :change)
243
+
244
+ # If this issue is ever in an active sprint, returns the change where it was first added to that
245
+ # sprint (whether or not the sprint was active at that moment). It's a reasonable proxy for 'ready'
246
+ # when a team has no explicit 'ready' status -- you'd be better off with one, but sometimes that's
247
+ # not an option. Only valid for Scrum boards.
248
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
252
249
  def first_time_added_to_active_sprint
250
+ # Why are complexity warnings disabled? Bottom line is that we felt the code would be less readable
251
+ # if we split it, so it's remaining as one longer method.
252
+
253
253
  unless board.scrum?
254
254
  raise 'first_time_added_to_active_sprint() can only be used with Scrum boards: ' \
255
255
  "issue=#{key}, board=#{board.inspect}"
256
256
  end
257
- data_clazz = Struct.new(:sprint_id, :sprint_start, :sprint_stop, :change)
258
-
259
257
  matching_changes = []
260
- all_datas = []
258
+ memberships = []
261
259
 
262
260
  @changes.each do |change|
263
261
  next unless change.sprint?
264
262
 
265
263
  added_sprint_ids = change.value_id - change.old_value_id
266
264
  added_sprint_ids.each do |id|
267
- data = data_clazz.new
268
- data.sprint_id = id
269
- data.change = change
270
- data.sprint_start, data.sprint_stop = find_sprint_start_end(sprint_id: id, change: change)
271
- all_datas << data
265
+ sprint_start = find_sprint_start_end(sprint_id: id, change: change).first
266
+ memberships << SprintMembership.new(sprint_id: id, sprint_start:, change:)
272
267
  end
273
268
 
274
269
  removed_sprint_ids = change.old_value_id - change.value_id
275
270
  removed_sprint_ids.each do |id|
276
- data = all_datas.find { |d| d.sprint_id == id }
271
+ membership = memberships.find { |m| m.sprint_id == id }
277
272
  # It's possible for an issue to be created inside a sprint and therefore for
278
273
  # that add-to-sprint not show in the history.
279
- next unless data
274
+ next unless membership
280
275
 
281
- all_datas.delete(data)
282
- next if data.sprint_start.nil? || data.sprint_start >= change.time
276
+ memberships.delete(membership)
277
+ next unless counts_as_sprint_start? membership
278
+ next if membership.sprint_start >= change.time
283
279
 
284
- matching_changes << data.change
280
+ matching_changes << membership.change
285
281
  end
286
282
  end
287
283
 
288
284
  # There can't be any more removes so whatever is left is a valid option
289
285
  # Now all we care about is if the sprint has started.
290
- all_datas.each do |data|
291
- matching_changes << data.change if data.sprint_start
286
+ memberships.each do |membership|
287
+ matching_changes << membership.change if counts_as_sprint_start? membership
292
288
  end
293
289
 
294
290
  matching_changes.min_by(&:time)
295
291
  end
292
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
293
+
294
+ # Being added to a sprint only counts as a "start" if that sprint had actually started and the issue
295
+ # wasn't already done when it did. Joining a sprint you've already finished (e.g. a done issue swept
296
+ # into a later sprint) is bookkeeping on completed work, not the moment work began.
297
+ def counts_as_sprint_start? membership
298
+ return false if membership.sprint_start.nil?
299
+
300
+ !in_done_status_at?(membership.sprint_start)
301
+ end
302
+
303
+ # Was the issue sitting in a done-category status at the given moment? Keys off the status category
304
+ # (not the status name), so a status merely called "Done" that Jira categorises otherwise won't count.
305
+ def in_done_status_at? time
306
+ last = status_changes.reverse.find { |change| change.time <= time }
307
+ return false unless last
308
+
309
+ find_or_create_status(id: last.value_id, name: last.value).category.done?
310
+ end
296
311
 
297
312
  def find_sprint_start_end sprint_id:, change:
298
313
  # There are two different places that sprint data could be found. In theory all
299
314
  # sprints would be found in both places. In practice, sometimes what we need is
300
315
  # in one or the other but not both.
316
+ times = sprint_times_from_board(sprint_id) || sprint_times_from_issue(sprint_id, change)
317
+
318
+ # If both came up empty then the sprint can't be found anywhere, so we pretend that it never
319
+ # started. Is this guaranteed to be true? No. In theory if all issues were removed from
320
+ # an active sprint then it would also disappear, even though it had started. Nothing we
321
+ # can do to detect that edge-case though.
322
+ times || [nil, nil]
323
+ end
301
324
 
302
- # First look in the actual sprints json. If any issues are in this sprint then it should
303
- # be here.
325
+ # First look in the actual sprints json. If any issues are in this sprint then it should be here.
326
+ def sprint_times_from_board sprint_id
304
327
  sprint = board.sprints.find { |s| s.id == sprint_id }
305
- if sprint
306
- return [nil, nil] if sprint.future?
328
+ return nil unless sprint
329
+ return [nil, nil] if sprint.future?
307
330
 
308
- return [sprint.start_time, sprint.completed_time]
309
- end
331
+ [sprint.start_time, sprint.completed_time]
332
+ end
310
333
 
311
- # Then look at the sprints inside the issue. Even though the field id may be specified,
312
- # that custom field may not be present. This happens if it was in that sprint but was
313
- # then removed, whether or not that sprint had ever started.
334
+ # Then look at the sprints inside the issue. Even though the field id may be specified, that custom
335
+ # field may not be present. This happens if it was in that sprint but was then removed, whether or
336
+ # not that sprint had ever started.
337
+ def sprint_times_from_issue sprint_id, change
314
338
  sprint_data = raw['fields'][change.field_id]&.find { |sd| sd['id'].to_i == sprint_id }
315
- if sprint_data
316
- return [nil, nil] if sprint_data['state'] == 'future'
339
+ return nil unless sprint_data
340
+ return [nil, nil] if sprint_data['state'] == 'future'
317
341
 
318
- start = parse_time(sprint_data['startDate'])
319
- stop = parse_time(sprint_data['completeDate'])
320
- return [start, stop]
321
- end
322
-
323
- # If we got this far then the sprint can't be found anywhere, so we pretend that it never
324
- # started. Is this guaranteed to be true? No. In theory if all issues were removed from
325
- # an active sprint then it would also disappear, even though it had started. Nothing we
326
- # can do to detect that edge-case though.
327
- [nil, nil]
342
+ [parse_time(sprint_data['startDate']), parse_time(sprint_data['completeDate'])]
328
343
  end
329
344
 
330
345
  def parse_time text
@@ -339,7 +354,8 @@ class Issue
339
354
 
340
355
  def created
341
356
  # This nil check shouldn't be necessary and yet we've seen one case where it was.
342
- parse_time @raw['fields']['created'] if @raw['fields']['created']
357
+ created_text = raw_fields['created']
358
+ parse_time created_text if created_text
343
359
  end
344
360
 
345
361
  def time_created
@@ -347,23 +363,23 @@ class Issue
347
363
  end
348
364
 
349
365
  def updated
350
- parse_time @raw['fields']['updated']
366
+ parse_time raw_fields['updated']
351
367
  end
352
368
 
353
369
  def first_resolution
354
- @changes.find { |change| change.resolution? }
370
+ @changes.find(&:resolution?)
355
371
  end
356
372
 
357
373
  def last_resolution
358
- @changes.reverse.find { |change| change.resolution? }
374
+ @changes.reverse.find(&:resolution?)
359
375
  end
360
376
 
361
377
  def assigned_to
362
- @raw['fields']['assignee']&.[]('displayName')
378
+ raw_fields['assignee']&.[]('displayName')
363
379
  end
364
380
 
365
381
  def assigned_to_icon_url
366
- @raw['fields']['assignee']&.[]('avatarUrls')&.[]('16x16')
382
+ raw_fields['assignee']&.[]('avatarUrls')&.[]('16x16')
367
383
  end
368
384
 
369
385
  # Many test failures are simply unreadable because the default inspect on this class goes
@@ -382,197 +398,22 @@ class Issue
382
398
  # If the day was stalled for the entire day then it's stalled
383
399
  # If there was no activity at all on this day then the last change from the previous day carries over
384
400
  def blocked_stalled_by_date date_range:, chart_end_time:, settings: nil
385
- results = {}
386
- current_date = nil
387
- blocked_stalled_changes = blocked_stalled_changes(end_time: chart_end_time, settings: settings)
388
- blocked_stalled_changes.each do |change|
389
- current_date = change.time.to_date
390
-
391
- winning_change, _last_change = results[current_date]
392
- if winning_change.nil? ||
393
- change.blocked? ||
394
- (change.active? && (winning_change.active? || winning_change.stalled?)) ||
395
- (change.stalled? && winning_change.stalled?)
396
-
397
- winning_change = change
398
- end
399
-
400
- results[current_date] = [winning_change, change]
401
- end
402
-
403
- last_populated_date = nil
404
- (results.keys.min..results.keys.max).each do |date|
405
- if results.key? date
406
- last_populated_date = date
407
- else
408
- _winner, last = results[last_populated_date]
409
- results[date] = [last, last]
410
- end
411
- end
412
- results = results.transform_values(&:first)
413
-
414
- # The requested date range may span outside the actual changes we find in the changelog
415
- date_of_first_change = blocked_stalled_changes[0].time.to_date
416
- date_of_last_change = blocked_stalled_changes[-1].time.to_date
417
- date_range.each do |date|
418
- results[date] = blocked_stalled_changes[0] if date < date_of_first_change
419
- results[date] = blocked_stalled_changes[-1] if date > date_of_last_change
420
- end
421
-
422
- # To make the code simpler, we've been accumulating data for every date. Now remove anything
423
- # that isn't in the requested date_range
424
- results.select! { |date, _value| date_range.include? date }
425
-
426
- results
401
+ BlockedStalledByDateBuilder.new(
402
+ blocked_stalled_changes: blocked_stalled_changes(end_time: chart_end_time, settings: settings),
403
+ date_range: date_range
404
+ ).build
427
405
  end
428
406
 
429
407
  def blocked_stalled_changes end_time:, settings: nil
430
408
  settings ||= @board.project_config.settings
431
-
432
- blocked_statuses = settings['blocked_statuses']
433
- stalled_statuses = settings['stalled_statuses']
434
-
435
- blocked_link_texts = settings['blocked_link_text']
436
- stalled_threshold = settings['stalled_threshold_days']
437
- flagged_means_blocked = !!settings['flagged_means_blocked'] # rubocop:disable Style/DoubleNegation
438
-
439
- blocking_issue_keys = []
440
-
441
- result = []
442
- previous_was_active = false # Must start as false so that the creation will insert an :active
443
- previous_change_time = created
444
-
445
- blocking_status = nil
446
- blocking_is_blocked = false
447
- flag = nil
448
- flag_reason = nil
449
-
450
- # This mock change is to force the writing of one last entry at the end of the time range.
451
- # By doing this, we're able to eliminate a lot of duplicated code in charts.
452
- mock_change = ChangeItem.new time: end_time, artificial: true, raw: { 'field' => '' }, author_raw: nil
453
-
454
- (changes + [mock_change]).each do |change|
455
- previous_was_active = false if check_for_stalled(
456
- change_time: change.time,
457
- previous_change_time: previous_change_time,
458
- stalled_threshold: stalled_threshold,
459
- blocking_stalled_changes: result
460
- )
461
-
462
- if change.flagged? && flagged_means_blocked
463
- flag, flag_reason = blocked_stalled_changes_flag_logic change
464
- elsif change.status?
465
- blocking_status = nil
466
- blocking_is_blocked = false
467
- if blocked_statuses.find_by_id(change.value_id)
468
- blocking_status = change.value
469
- blocking_is_blocked = true
470
- elsif stalled_statuses.find_by_id(change.value_id)
471
- blocking_status = change.value
472
- end
473
- elsif change.link?
474
- # Example: "This issue is satisfied by ANON-30465"
475
- unless /^This (?<_>issue|work item) (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
476
- puts "Issue(#{key}) Can't parse link text: #{change.value || change.old_value}"
477
- next
478
- end
479
-
480
- if blocked_link_texts.include? link_text
481
- if change.value
482
- blocking_issue_keys << issue_key
483
- else
484
- blocking_issue_keys.delete issue_key
485
- end
486
- end
487
- end
488
-
489
- new_change = BlockedStalledChange.new(
490
- flagged: flag,
491
- flag_reason: flag_reason,
492
- status: blocking_status,
493
- status_is_blocking: blocking_status.nil? || blocking_is_blocked,
494
- blocking_issue_keys: (blocking_issue_keys.empty? ? nil : blocking_issue_keys.dup),
495
- time: change.time
496
- )
497
-
498
- # We don't want to dump two actives in a row as that would just be noise. Unless this is
499
- # the mock change, which we always want to dump
500
- result << new_change if !new_change.active? || !previous_was_active || change == mock_change
501
-
502
- previous_was_active = new_change.active?
503
- previous_change_time = change.time
504
- end
505
-
506
- if result.size >= 2
507
- # The existence of the mock entry will mess with the stalled count as it will wake everything
508
- # back up. This hack will clean up appropriately.
509
- hack = result.pop
510
- result << BlockedStalledChange.new(
511
- flagged: hack.flag,
512
- flag_reason: hack.flag_reason,
513
- status: hack.status,
514
- status_is_blocking: hack.status_is_blocking,
515
- blocking_issue_keys: hack.blocking_issue_keys,
516
- time: hack.time,
517
- stalled_days: result[-1].stalled_days
518
- )
519
- end
520
-
521
- result
522
- end
523
-
524
- def blocked_stalled_changes_flag_logic change
525
- flag = change.value
526
- flag = nil if change.value == ''
527
- if flag
528
- # When the user is adding a comment to explain why a flag was set, the flag is set immediately
529
- # and the comment is inserted after the user hits enter, which means that there is some time
530
- # gap. If a comment happened shortly after the flag was set, we assume they're linked. This
531
- # won't always be true and so there will be false positives, but it's a reasonable assumption.
532
- max_seconds_between_flag_and_comment = 30
533
- comment_change = changes.find do |c|
534
- c.comment? && c.time >= change.time && (c.time - change.time) <= max_seconds_between_flag_and_comment
535
- end
536
- flag_reason = comment_change && @board.project_config.atlassian_document_format.to_text(comment_change.value)
537
- # Newer Jira instances may add this extra text but older instances did not. Strip it out if found.
538
- flag_reason = flag_reason&.sub(/\A:flag_on: Flag added\s*/m, '')&.strip
539
- flag_reason = nil if flag_reason&.empty?
540
- else
541
- flag_reason = nil
542
- end
543
- [flag, flag_reason]
544
- end
545
-
546
- def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
547
- stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
548
-
549
- # The most common case will be nothing to split so quick escape.
550
- return false if (change_time - previous_change_time).to_i < stalled_threshold_seconds
551
-
552
- # If the last identified change was blocked then it doesn't matter now long we've waited, we're still blocked.
553
- return false if blocking_stalled_changes[-1]&.blocked?
554
-
555
- list = [previous_change_time..change_time]
556
- all_subtask_activity_times.each do |time|
557
- matching_range = list.find { |range| time >= range.begin && time <= range.end }
558
- next unless matching_range
559
-
560
- list.delete matching_range
561
- list << ((matching_range.begin)..time)
562
- list << (time..(matching_range.end))
563
- end
564
-
565
- inserted_stalled = false
566
-
567
- list.sort_by(&:begin).each do |range|
568
- seconds = (range.end - range.begin).to_i
569
- next if seconds < stalled_threshold_seconds
570
-
571
- an_hour_later = range.begin + (60 * 60)
572
- blocking_stalled_changes << BlockedStalledChange.new(stalled_days: seconds / (24 * 60 * 60), time: an_hour_later)
573
- inserted_stalled = true
574
- end
575
- inserted_stalled
409
+ BlockedStalledChangeStreamBuilder.new(
410
+ changes: changes,
411
+ settings: settings,
412
+ created: created,
413
+ key: key,
414
+ subtask_activity_times: all_subtask_activity_times,
415
+ atlassian_document_format: @board.project_config.atlassian_document_format
416
+ ).build(end_time: end_time)
576
417
  end
577
418
 
578
419
  # return [number of active seconds, total seconds] that this issue had up to the end_time.
@@ -581,37 +422,13 @@ class Issue
581
422
  issue_start, issue_stop = started_stopped_times
582
423
  return [0.0, 0.0] if !issue_start || issue_start > end_time
583
424
 
584
- value_add_time = 0.0
425
+ # Nothing after the issue finishes counts, so cap the window before we build the stream.
585
426
  end_time = issue_stop if issue_stop && issue_stop < end_time
586
-
587
- active_start = nil
588
- blocked_stalled_changes(end_time: end_time, settings: settings).each_with_index do |change, index|
589
- break if change.time > end_time
590
-
591
- if index.zero?
592
- active_start = change.time if change.active?
593
- next
594
- end
595
-
596
- # Already active and we just got another active.
597
- next if active_start && change.active?
598
-
599
- if change.active?
600
- active_start = change.time
601
- elsif active_start && change.time >= issue_start
602
- # Not active now but we have been. Record the active time.
603
- change_delta = change.time - [issue_start, active_start].max
604
- value_add_time += change_delta
605
- active_start = nil
606
- end
607
- end
608
-
609
- if active_start
610
- change_delta = end_time - [issue_start, active_start].max
611
- value_add_time += change_delta if change_delta.positive?
612
- end
613
-
614
- [value_add_time, end_time - issue_start]
427
+ FlowEfficiencyCalculator.new(
428
+ blocked_stalled_changes: blocked_stalled_changes(end_time: end_time, settings: settings),
429
+ issue_start: issue_start,
430
+ end_time: end_time
431
+ ).calculate
615
432
  end
616
433
 
617
434
  def all_subtask_activity_times
@@ -631,26 +448,30 @@ class Issue
631
448
  end
632
449
 
633
450
  def expedited_on_date? date
634
- expedited_start = nil
635
451
  return false unless @board&.project_config
636
452
 
453
+ expedited_ranges.any? { |range| range.cover?(date) }
454
+ end
455
+
456
+ # The date ranges during which this issue sat at an expedited priority. A range that never closes
457
+ # (the issue was never de-prioritised) is endless.
458
+ def expedited_ranges
637
459
  expedited_names = @board.project_config.settings['expedited_priority_names']
460
+ ranges = []
461
+ started_on = nil
638
462
 
639
463
  changes.each do |change|
640
464
  next unless change.priority?
641
465
 
642
466
  if expedited_names.include? change.value
643
- expedited_start = change.time.to_date if expedited_start.nil?
644
- else
645
- return true if expedited_start && (expedited_start..change.time.to_date).cover?(date)
646
-
647
- expedited_start = nil
467
+ started_on ||= change.time.to_date
468
+ elsif started_on
469
+ ranges << (started_on..change.time.to_date)
470
+ started_on = nil
648
471
  end
649
472
  end
650
-
651
- return false if expedited_start.nil?
652
-
653
- expedited_start <= date
473
+ ranges << (started_on..) if started_on
474
+ ranges
654
475
  end
655
476
 
656
477
  # Return the last time there was any activity on this ticket. Starting from "now" and going backwards
@@ -671,7 +492,7 @@ class Issue
671
492
 
672
493
  def issue_links
673
494
  if @issue_links.nil?
674
- @issue_links = @raw['fields']['issuelinks']&.collect do |issue_link|
495
+ @issue_links = raw_fields['issuelinks']&.collect do |issue_link|
675
496
  IssueLink.new origin: self, raw: issue_link
676
497
  end || []
677
498
  end
@@ -680,7 +501,7 @@ class Issue
680
501
 
681
502
  def fix_versions
682
503
  if @fix_versions.nil?
683
- @fix_versions = @raw['fields']['fixVersions']&.collect do |fix_version|
504
+ @fix_versions = raw_fields['fixVersions']&.collect do |fix_version|
684
505
  FixVersion.new fix_version
685
506
  end || []
686
507
  end
@@ -695,34 +516,31 @@ class Issue
695
516
  # Although Atlassian is trying to standardize on one way to determine the parent, today it's a mess.
696
517
  # We try a variety of ways to get the parent and hopefully one of them will work. See this link:
697
518
  # https://community.developer.atlassian.com/t/deprecation-of-the-epic-link-parent-link-and-other-related-fields-in-rest-apis-and-webhooks/54048
519
+ fields = raw_fields
698
520
 
699
- fields = @raw['fields']
700
-
701
- # At some point in the future, this will be the only way to retrieve the parent so we try this first.
702
- parent = fields['parent']&.[]('key')
703
-
704
- # The epic field
705
- parent = fields['epic']&.[]('key') if parent.nil?
521
+ # The 'parent' field will eventually be the only way; the 'epic' field is the older form. Failing
522
+ # both, the parent link may be stored in a custom field.
523
+ parent = fields['parent']&.[]('key') || fields['epic']&.[]('key')
524
+ parent ||= parent_from_custom_fields(fields, project_config) if project_config
525
+ parent
526
+ end
706
527
 
707
- # Otherwise the parent link will be stored in one of the custom fields. We've seen different custom fields
708
- # used for parent_link vs epic_link so we have to support more than one.
709
- if parent.nil? && project_config
710
- custom_field_names = project_config.settings['customfield_parent_links']
711
- custom_field_names = [custom_field_names] if custom_field_names.is_a? String
528
+ def parent_from_custom_fields fields, project_config
529
+ # We've seen different custom fields used for parent_link vs epic_link, so we try each configured
530
+ # one until we find a value that looks like an issue key.
531
+ custom_field_names = project_config.settings['customfield_parent_links']
532
+ custom_field_names = [custom_field_names] if custom_field_names.is_a? String
712
533
 
713
- custom_field_names&.each do |field_name|
714
- parent = fields[field_name]
715
- next if parent.nil?
716
- break if looks_like_issue_key? parent
534
+ custom_field_names&.each do |field_name|
535
+ parent = fields[field_name]
536
+ next if parent.nil?
537
+ return parent if looks_like_issue_key? parent
717
538
 
718
- project_config.file_system.log(
719
- "Custom field #{field_name.inspect} should point to a parent id but found #{parent.inspect}"
720
- )
721
- parent = nil
722
- end
539
+ project_config.file_system.log(
540
+ "Custom field #{field_name.inspect} should point to a parent id but found #{parent.inspect}"
541
+ )
723
542
  end
724
-
725
- parent
543
+ nil
726
544
  end
727
545
 
728
546
  def in_initial_query?
@@ -746,17 +564,19 @@ class Issue
746
564
  def discard_changes_before cutoff_time
747
565
  rejected_any = false
748
566
  @changes.reject! do |change|
749
- reject = change.status? && change.time <= cutoff_time && change.artificial? == false
750
- if reject
751
- (@discarded_changes ||= []) << change
752
- rejected_any = true
753
- end
754
- reject
567
+ next false unless discardable_before? change, cutoff_time
568
+
569
+ (@discarded_changes ||= []) << change
570
+ rejected_any = true
755
571
  end
756
572
 
757
573
  (@discarded_change_times ||= []) << cutoff_time if rejected_any
758
574
  end
759
575
 
576
+ def discardable_before? change, cutoff_time
577
+ change.status? && change.time <= cutoff_time && change.artificial? == false
578
+ end
579
+
760
580
  def dump
761
581
  IssuePrinter.new(self).to_s
762
582
  end
@@ -780,7 +600,7 @@ class Issue
780
600
  end
781
601
 
782
602
  def status_changes
783
- @changes.select { |change| change.status? }
603
+ @changes.select(&:status?)
784
604
  end
785
605
 
786
606
  def status_resolution_at_done
@@ -807,7 +627,7 @@ class Issue
807
627
  changes.each do |change|
808
628
  next unless change.sprint?
809
629
 
810
- sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
630
+ sprint_ids << change.raw['to'].split(/\s*,\s*/).collect(&:to_i)
811
631
  end
812
632
  sprint_ids.flatten!
813
633
 
@@ -815,17 +635,13 @@ class Issue
815
635
  end
816
636
 
817
637
  def started_sprints
818
- sprints.reject { |sprint| sprint.future? }
638
+ sprints.reject(&:future?)
819
639
  end
820
640
 
821
641
  def compact_text text, max: 60
822
642
  return '' if text.nil?
823
643
 
824
- text = if text.is_a? Hash
825
- @board.project_config.atlassian_document_format.to_text(text)
826
- else
827
- text
828
- end
644
+ text = @board.project_config.atlassian_document_format.to_text(text) if text.is_a? Hash
829
645
  text = text.gsub(/\s+/, ' ').strip
830
646
  text = "#{text[0...max]}..." if text.length > max
831
647
  text
@@ -836,36 +652,47 @@ class Issue
836
652
  # Returns [[effective_time, change_item]] for each moment the issue entered an active sprint.
837
653
  # Skips sprints that were removed before they activated.
838
654
  def sprint_entry_events
839
- data_clazz = Struct.new(:sprint_id, :sprint_start, :add_time, :change)
840
655
  events = []
841
656
  in_sprint = []
842
657
 
843
658
  @changes.each do |change|
844
659
  next unless change.sprint?
845
660
 
846
- (change.value_id - change.old_value_id).each do |sprint_id|
847
- sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
848
- in_sprint << data_clazz.new(sprint_id, sprint_start, change.time, change) if sprint_start
849
- end
850
-
851
- (change.old_value_id - change.value_id).each do |sprint_id|
852
- data = in_sprint.find { |d| d.sprint_id == sprint_id }
853
- next unless data
661
+ add_tracked_sprints(in_sprint, change)
662
+ close_tracked_sprints(in_sprint, change, events)
663
+ end
854
664
 
855
- in_sprint.delete(data)
856
- next if data.sprint_start >= change.time # sprint hadn't activated before removal
665
+ # Anything still tracked at the end never left, so its entry is the moment it started (or was added).
666
+ in_sprint.each { |tracked| events << sprint_entry_event_for(tracked) }
667
+ events
668
+ end
857
669
 
858
- effective_time = [data.add_time, data.sprint_start].max
859
- events << [effective_time, sprint_change_at(effective_time, data.change)]
860
- end
670
+ # Records each sprint this change newly joined, but only those we know eventually started.
671
+ def add_tracked_sprints in_sprint, change
672
+ (change.value_id - change.old_value_id).each do |sprint_id|
673
+ sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
674
+ in_sprint << TrackedSprint.new(sprint_id:, sprint_start:, add_time: change.time, change:) if sprint_start
861
675
  end
676
+ end
677
+
678
+ # Emits an entry for each sprint this change left, unless it was removed before ever activating.
679
+ def close_tracked_sprints in_sprint, change, events
680
+ (change.old_value_id - change.value_id).each do |sprint_id|
681
+ tracked = in_sprint.find { |candidate| candidate.sprint_id == sprint_id }
682
+ next unless tracked
683
+
684
+ in_sprint.delete(tracked)
685
+ next if tracked.sprint_start >= change.time # sprint hadn't activated before removal
862
686
 
863
- in_sprint.each do |data|
864
- effective_time = [data.add_time, data.sprint_start].max
865
- events << [effective_time, sprint_change_at(effective_time, data.change)]
687
+ events << sprint_entry_event_for(tracked)
866
688
  end
689
+ end
867
690
 
868
- events
691
+ # The moment the issue was effectively in an active sprint - the later of when it was added and when
692
+ # the sprint started - paired with the change that best represents that moment.
693
+ def sprint_entry_event_for tracked
694
+ effective_time = [tracked.add_time, tracked.sprint_start].max
695
+ [effective_time, sprint_change_at(effective_time, tracked.change)]
869
696
  end
870
697
 
871
698
  def sprint_change_at effective_time, change
@@ -885,15 +712,21 @@ class Issue
885
712
  break if change.time > time
886
713
  next unless change.sprint?
887
714
 
888
- (change.value_id - change.old_value_id).each do |sprint_id|
889
- sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
890
- active_ids << sprint_id if sprint_start && sprint_start <= time
891
- end
892
- (change.old_value_id - change.value_id).each { |id| active_ids.delete(id) }
715
+ apply_sprint_membership_change(active_ids, change, time)
893
716
  end
894
717
  active_ids.any?
895
718
  end
896
719
 
720
+ # Adds the sprints newly joined by this change (only if they had already started by `time`) and
721
+ # removes the ones it left, mutating active_ids in place.
722
+ def apply_sprint_membership_change active_ids, change, time
723
+ (change.value_id - change.old_value_id).each do |sprint_id|
724
+ sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
725
+ active_ids << sprint_id if sprint_start && sprint_start <= time
726
+ end
727
+ (change.old_value_id - change.value_id).each { |id| active_ids.delete(id) }
728
+ end
729
+
897
730
  def in_visible_status_at? time, visible_status_ids
898
731
  last = status_changes.reverse.find { |c| c.time <= time }
899
732
  last && visible_status_ids.include?(last.value_id)
@@ -904,30 +737,40 @@ class Issue
904
737
  created = parse_time(history['created'])
905
738
 
906
739
  history['items']&.each do |item|
907
- if item['field'] == 'status' && item['to'].nil?
908
- to_name = item['toString']
909
- matches = board.possible_statuses.find_all_by_name(to_name)
910
- guessed_id, id_note = if matches.length == 1
911
- [matches.first.id.to_s, "Guessed id #{matches.first.id} from status name."]
912
- elsif matches.length > 1
913
- ['0', "Multiple statuses named #{to_name.inspect} exist (ids: #{matches.map(&:id).join(', ')}); cannot disambiguate. Using id 0."]
914
- else
915
- ['0', "No known status named #{to_name.inspect}. Using id 0."]
916
- end
917
- board.project_config.file_system.warning(
918
- "Issue #{key} has a status change without a 'to' id " \
919
- "(from #{item['fromString'].inspect} to #{to_name.inspect}). #{id_note}"
920
- )
921
- item = item.merge('to' => guessed_id)
922
- end
923
-
740
+ item = backfill_missing_status_id(item) if item['field'] == 'status' && item['to'].nil?
924
741
  @changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
925
742
  end
926
743
  end
927
744
  end
928
745
 
746
+ # Jira sometimes reports a status change with a name but no id. Guess the id from the name (when we
747
+ # can) and return a copy of the item with a 'to' filled in, logging what we did.
748
+ def backfill_missing_status_id item
749
+ to_name = item['toString']
750
+ matches = board.possible_statuses.find_all_by_name(to_name)
751
+ guessed_id, id_note = guess_status_id(to_name, matches)
752
+ board.project_config.file_system.warning(
753
+ "Issue #{key} has a status change without a 'to' id " \
754
+ "(from #{item['fromString'].inspect} to #{to_name.inspect}). #{id_note}"
755
+ )
756
+ item.merge('to' => guessed_id)
757
+ end
758
+
759
+ # Returns [id_as_string, explanation]. A single name match gives that id; anything else falls back to
760
+ # id 0 because we can't safely disambiguate.
761
+ def guess_status_id to_name, matches
762
+ if matches.length == 1
763
+ [matches.first.id.to_s, "Guessed id #{matches.first.id} from status name."]
764
+ elsif matches.length > 1
765
+ ['0', "Multiple statuses named #{to_name.inspect} exist " \
766
+ "(ids: #{matches.map(&:id).join(', ')}); cannot disambiguate. Using id 0."]
767
+ else
768
+ ['0', "No known status named #{to_name.inspect}. Using id 0."]
769
+ end
770
+ end
771
+
929
772
  def load_comments_into_changes
930
- @raw['fields']['comment']['comments']&.each do |comment|
773
+ raw_fields['comment']['comments']&.each do |comment|
931
774
  raw = comment.merge({
932
775
  'field' => 'comment',
933
776
  'to' => comment['id'],
@@ -956,26 +799,26 @@ class Issue
956
799
  first_status_id = nil
957
800
 
958
801
  # There won't be a created timestamp in cases where this was a linked issue
959
- return unless @raw['fields']['created']
802
+ return unless raw_fields['created']
960
803
 
961
- created_time = parse_time @raw['fields']['created']
804
+ created_time = parse_time raw_fields['created']
962
805
  first_change = @changes.find { |change| change.field == field_name }
963
806
  if first_change.nil?
964
807
  # There have been no changes of this type yet so we have to look at the current one
965
- return nil unless @raw['fields'][field_name]
808
+ return nil unless raw_fields[field_name]
966
809
 
967
- first_status = @raw['fields'][field_name]['name']
968
- first_status_id = @raw['fields'][field_name]['id'].to_i
810
+ first_status = raw_fields[field_name]['name']
811
+ first_status_id = raw_fields[field_name]['id'].to_i
969
812
  else
970
813
  # Otherwise, we look at what the first one had changed away from.
971
814
  first_status = first_change.old_value
972
- # old_value_id should never be nil — a status change must have a 'from' id — but Jira has
815
+ # old_value_id should never be nil - a status change must have a 'from' id - but Jira has
973
816
  # been seen in production omitting the 'from' field entirely. Fall back to 0 so the
974
817
  # downstream fabricate/warn path handles it rather than crashing.
975
818
  first_status_id = first_change.old_value_id || 0
976
819
  end
977
820
 
978
- creator = raw['fields']['creator']
821
+ creator = raw_fields['creator']
979
822
  ChangeItem.new time: created_time, artificial: true, author_raw: creator, raw: {
980
823
  'field' => field_name,
981
824
  'to' => first_status_id,
@@ -983,6 +826,45 @@ class Issue
983
826
  }
984
827
  end
985
828
 
829
+ # Jira never records an issue's *initial* sprint membership as a changelog transition, so an issue
830
+ # created directly inside a sprint (and never moved out) has no Sprint change at all, and one whose
831
+ # first recorded change already lists the sprint in its 'from' looks like it entered at that later
832
+ # moment. Reconstruct that initial membership as an artificial change at creation time, mirroring how
833
+ # we fabricate the initial status and priority. Returns nil when the issue started in no sprints.
834
+ def fabricate_sprint_change
835
+ return unless raw_fields['created']
836
+
837
+ first_sprint_change = @changes.find(&:sprint?)
838
+ initial_sprint_ids = first_sprint_change ? first_sprint_change.old_value_id : current_sprint_ids
839
+ return if initial_sprint_ids.empty?
840
+
841
+ ChangeItem.new(
842
+ time: parse_time(raw_fields['created']), artificial: true, author_raw: raw_fields['creator'],
843
+ raw: {
844
+ 'field' => 'Sprint',
845
+ 'fieldId' => first_sprint_change&.field_id || sprint_field_id,
846
+ 'to' => initial_sprint_ids.join(', '),
847
+ 'toString' => 'Sprint'
848
+ }
849
+ )
850
+ end
851
+
852
+ # The Sprint custom field id varies by Jira instance, so we find it by shape: its value is a list of
853
+ # sprint objects, each of which carries a boardId. Returns nil when the issue is in no sprints.
854
+ def sprint_field_id
855
+ field = raw_fields.find do |_field_id, value|
856
+ value.is_a?(Array) && value.first.is_a?(Hash) && value.first.key?('boardId')
857
+ end
858
+ field&.first
859
+ end
860
+
861
+ def current_sprint_ids
862
+ field_id = sprint_field_id
863
+ return [] unless field_id
864
+
865
+ raw_fields[field_id].filter_map { |sprint| sprint['id']&.to_i }
866
+ end
867
+
986
868
  def find_status_category_ids_by_names category_names
987
869
  category_names.filter_map do |name|
988
870
  list = board.possible_statuses.find_all_categories_by_name name