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
@@ -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
@@ -213,22 +219,24 @@ class Issue
213
219
  visible_status_ids = board.visible_columns.collect(&:status_ids).flatten
214
220
  return first_time_in_status(*visible_status_ids) unless board.scrum?
215
221
 
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 = []
222
+ # On scrum boards an issue is only visible when its status is in a visible column AND it is in an
223
+ # active sprint. Each source below is a moment when the second condition became true while the first
224
+ # already held; the earliest of them is when it first became visible.
225
+ candidates = visible_status_changes_in_active_sprint(visible_status_ids) +
226
+ sprint_entries_while_in_visible_status(visible_status_ids)
227
+ candidates.min_by(&:time)
228
+ end
221
229
 
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)
230
+ def visible_status_changes_in_active_sprint visible_status_ids
231
+ status_changes.select do |change|
232
+ visible_status_ids.include?(change.value_id) && in_active_sprint_at?(change.time)
225
233
  end
234
+ end
226
235
 
227
- sprint_entry_events.each do |effective_time, representative_change|
228
- candidates << representative_change if in_visible_status_at?(effective_time, visible_status_ids)
236
+ def sprint_entries_while_in_visible_status visible_status_ids
237
+ sprint_entry_events.filter_map do |effective_time, representative_change|
238
+ representative_change if in_visible_status_at?(effective_time, visible_status_ids)
229
239
  end
230
-
231
- candidates.min_by(&:time)
232
240
  end
233
241
 
234
242
  def reasons_not_visible_on_board
@@ -244,87 +252,113 @@ class Issue
244
252
  reasons_not_visible_on_board.empty?
245
253
  end
246
254
 
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.
255
+ # A sprint the issue was added to: its start (all we care about is whether it started) and the change
256
+ # that added it.
257
+ SprintMembership = Data.define(:sprint_id, :sprint_start, :change)
258
+
259
+ # Like SprintMembership but also tracks when this issue was added, used while pairing sprint entries
260
+ # and exits in #sprint_entry_events.
261
+ TrackedSprint = Data.define(:sprint_id, :sprint_start, :add_time, :change)
262
+
263
+ # If this issue is ever in an active sprint, returns the change where it was first added to that
264
+ # sprint (whether or not the sprint was active at that moment). It's a reasonable proxy for 'ready'
265
+ # when a team has no explicit 'ready' status -- you'd be better off with one, but sometimes that's
266
+ # not an option. Only valid for Scrum boards.
267
+ # rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
252
268
  def first_time_added_to_active_sprint
269
+ # Why are complexity warnings disabled? Bottom line is that we felt the code would be less readable
270
+ # if we split it, so it's remaining as one longer method.
271
+
253
272
  unless board.scrum?
254
273
  raise 'first_time_added_to_active_sprint() can only be used with Scrum boards: ' \
255
274
  "issue=#{key}, board=#{board.inspect}"
256
275
  end
257
- data_clazz = Struct.new(:sprint_id, :sprint_start, :sprint_stop, :change)
258
-
259
276
  matching_changes = []
260
- all_datas = []
277
+ memberships = []
261
278
 
262
279
  @changes.each do |change|
263
280
  next unless change.sprint?
264
281
 
265
282
  added_sprint_ids = change.value_id - change.old_value_id
266
283
  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
284
+ sprint_start = find_sprint_start_end(sprint_id: id, change: change).first
285
+ memberships << SprintMembership.new(sprint_id: id, sprint_start:, change:)
272
286
  end
273
287
 
274
288
  removed_sprint_ids = change.old_value_id - change.value_id
275
289
  removed_sprint_ids.each do |id|
276
- data = all_datas.find { |d| d.sprint_id == id }
290
+ membership = memberships.find { |m| m.sprint_id == id }
277
291
  # It's possible for an issue to be created inside a sprint and therefore for
278
292
  # that add-to-sprint not show in the history.
279
- next unless data
293
+ next unless membership
280
294
 
281
- all_datas.delete(data)
282
- next if data.sprint_start.nil? || data.sprint_start >= change.time
295
+ memberships.delete(membership)
296
+ next unless counts_as_sprint_start? membership
297
+ next if membership.sprint_start >= change.time
283
298
 
284
- matching_changes << data.change
299
+ matching_changes << membership.change
285
300
  end
286
301
  end
287
302
 
288
303
  # There can't be any more removes so whatever is left is a valid option
289
304
  # 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
305
+ memberships.each do |membership|
306
+ matching_changes << membership.change if counts_as_sprint_start? membership
292
307
  end
293
308
 
294
309
  matching_changes.min_by(&:time)
295
310
  end
311
+ # rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
312
+
313
+ # Being added to a sprint only counts as a "start" if that sprint had actually started and the issue
314
+ # wasn't already done when it did. Joining a sprint you've already finished (e.g. a done issue swept
315
+ # into a later sprint) is bookkeeping on completed work, not the moment work began.
316
+ def counts_as_sprint_start? membership
317
+ return false if membership.sprint_start.nil?
318
+
319
+ !in_done_status_at?(membership.sprint_start)
320
+ end
321
+
322
+ # Was the issue sitting in a done-category status at the given moment? Keys off the status category
323
+ # (not the status name), so a status merely called "Done" that Jira categorises otherwise won't count.
324
+ def in_done_status_at? time
325
+ last = status_changes.reverse.find { |change| change.time <= time }
326
+ return false unless last
327
+
328
+ find_or_create_status(id: last.value_id, name: last.value).category.done?
329
+ end
296
330
 
297
331
  def find_sprint_start_end sprint_id:, change:
298
332
  # There are two different places that sprint data could be found. In theory all
299
333
  # sprints would be found in both places. In practice, sometimes what we need is
300
334
  # in one or the other but not both.
335
+ times = sprint_times_from_board(sprint_id) || sprint_times_from_issue(sprint_id, change)
301
336
 
302
- # First look in the actual sprints json. If any issues are in this sprint then it should
303
- # be here.
337
+ # If both came up empty then the sprint can't be found anywhere, so we pretend that it never
338
+ # started. Is this guaranteed to be true? No. In theory if all issues were removed from
339
+ # an active sprint then it would also disappear, even though it had started. Nothing we
340
+ # can do to detect that edge-case though.
341
+ times || [nil, nil]
342
+ end
343
+
344
+ # First look in the actual sprints json. If any issues are in this sprint then it should be here.
345
+ def sprint_times_from_board sprint_id
304
346
  sprint = board.sprints.find { |s| s.id == sprint_id }
305
- if sprint
306
- return [nil, nil] if sprint.future?
347
+ return nil unless sprint
348
+ return [nil, nil] if sprint.future?
307
349
 
308
- return [sprint.start_time, sprint.completed_time]
309
- end
350
+ [sprint.start_time, sprint.completed_time]
351
+ end
310
352
 
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.
353
+ # Then look at the sprints inside the issue. Even though the field id may be specified, that custom
354
+ # field may not be present. This happens if it was in that sprint but was then removed, whether or
355
+ # not that sprint had ever started.
356
+ def sprint_times_from_issue sprint_id, change
314
357
  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'
358
+ return nil unless sprint_data
359
+ return [nil, nil] if sprint_data['state'] == 'future'
317
360
 
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]
361
+ [parse_time(sprint_data['startDate']), parse_time(sprint_data['completeDate'])]
328
362
  end
329
363
 
330
364
  def parse_time text
@@ -339,7 +373,8 @@ class Issue
339
373
 
340
374
  def created
341
375
  # 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']
376
+ created_text = raw_fields['created']
377
+ parse_time created_text if created_text
343
378
  end
344
379
 
345
380
  def time_created
@@ -347,23 +382,23 @@ class Issue
347
382
  end
348
383
 
349
384
  def updated
350
- parse_time @raw['fields']['updated']
385
+ parse_time raw_fields['updated']
351
386
  end
352
387
 
353
388
  def first_resolution
354
- @changes.find { |change| change.resolution? }
389
+ @changes.find(&:resolution?)
355
390
  end
356
391
 
357
392
  def last_resolution
358
- @changes.reverse.find { |change| change.resolution? }
393
+ @changes.reverse.find(&:resolution?)
359
394
  end
360
395
 
361
396
  def assigned_to
362
- @raw['fields']['assignee']&.[]('displayName')
397
+ raw_fields['assignee']&.[]('displayName')
363
398
  end
364
399
 
365
400
  def assigned_to_icon_url
366
- @raw['fields']['assignee']&.[]('avatarUrls')&.[]('16x16')
401
+ raw_fields['assignee']&.[]('avatarUrls')&.[]('16x16')
367
402
  end
368
403
 
369
404
  # Many test failures are simply unreadable because the default inspect on this class goes
@@ -382,197 +417,22 @@ class Issue
382
417
  # If the day was stalled for the entire day then it's stalled
383
418
  # If there was no activity at all on this day then the last change from the previous day carries over
384
419
  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
420
+ BlockedStalledByDateBuilder.new(
421
+ blocked_stalled_changes: blocked_stalled_changes(end_time: chart_end_time, settings: settings),
422
+ date_range: date_range
423
+ ).build
427
424
  end
428
425
 
429
426
  def blocked_stalled_changes end_time:, settings: nil
430
427
  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
428
+ BlockedStalledChangeStreamBuilder.new(
429
+ changes: changes,
430
+ settings: settings,
431
+ created: created,
432
+ key: key,
433
+ subtask_activity_times: all_subtask_activity_times,
434
+ atlassian_document_format: @board.project_config.atlassian_document_format
435
+ ).build(end_time: end_time)
576
436
  end
577
437
 
578
438
  # return [number of active seconds, total seconds] that this issue had up to the end_time.
@@ -581,37 +441,13 @@ class Issue
581
441
  issue_start, issue_stop = started_stopped_times
582
442
  return [0.0, 0.0] if !issue_start || issue_start > end_time
583
443
 
584
- value_add_time = 0.0
444
+ # Nothing after the issue finishes counts, so cap the window before we build the stream.
585
445
  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]
446
+ FlowEfficiencyCalculator.new(
447
+ blocked_stalled_changes: blocked_stalled_changes(end_time: end_time, settings: settings),
448
+ issue_start: issue_start,
449
+ end_time: end_time
450
+ ).calculate
615
451
  end
616
452
 
617
453
  def all_subtask_activity_times
@@ -631,26 +467,30 @@ class Issue
631
467
  end
632
468
 
633
469
  def expedited_on_date? date
634
- expedited_start = nil
635
470
  return false unless @board&.project_config
636
471
 
472
+ expedited_ranges.any? { |range| range.cover?(date) }
473
+ end
474
+
475
+ # The date ranges during which this issue sat at an expedited priority. A range that never closes
476
+ # (the issue was never de-prioritised) is endless.
477
+ def expedited_ranges
637
478
  expedited_names = @board.project_config.settings['expedited_priority_names']
479
+ ranges = []
480
+ started_on = nil
638
481
 
639
482
  changes.each do |change|
640
483
  next unless change.priority?
641
484
 
642
485
  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
486
+ started_on ||= change.time.to_date
487
+ elsif started_on
488
+ ranges << (started_on..change.time.to_date)
489
+ started_on = nil
648
490
  end
649
491
  end
650
-
651
- return false if expedited_start.nil?
652
-
653
- expedited_start <= date
492
+ ranges << (started_on..) if started_on
493
+ ranges
654
494
  end
655
495
 
656
496
  # Return the last time there was any activity on this ticket. Starting from "now" and going backwards
@@ -671,7 +511,7 @@ class Issue
671
511
 
672
512
  def issue_links
673
513
  if @issue_links.nil?
674
- @issue_links = @raw['fields']['issuelinks']&.collect do |issue_link|
514
+ @issue_links = raw_fields['issuelinks']&.collect do |issue_link|
675
515
  IssueLink.new origin: self, raw: issue_link
676
516
  end || []
677
517
  end
@@ -680,7 +520,7 @@ class Issue
680
520
 
681
521
  def fix_versions
682
522
  if @fix_versions.nil?
683
- @fix_versions = @raw['fields']['fixVersions']&.collect do |fix_version|
523
+ @fix_versions = raw_fields['fixVersions']&.collect do |fix_version|
684
524
  FixVersion.new fix_version
685
525
  end || []
686
526
  end
@@ -695,34 +535,31 @@ class Issue
695
535
  # Although Atlassian is trying to standardize on one way to determine the parent, today it's a mess.
696
536
  # We try a variety of ways to get the parent and hopefully one of them will work. See this link:
697
537
  # https://community.developer.atlassian.com/t/deprecation-of-the-epic-link-parent-link-and-other-related-fields-in-rest-apis-and-webhooks/54048
538
+ fields = raw_fields
698
539
 
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?
540
+ # The 'parent' field will eventually be the only way; the 'epic' field is the older form. Failing
541
+ # both, the parent link may be stored in a custom field.
542
+ parent = fields['parent']&.[]('key') || fields['epic']&.[]('key')
543
+ parent ||= parent_from_custom_fields(fields, project_config) if project_config
544
+ parent
545
+ end
706
546
 
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
547
+ def parent_from_custom_fields fields, project_config
548
+ # We've seen different custom fields used for parent_link vs epic_link, so we try each configured
549
+ # one until we find a value that looks like an issue key.
550
+ custom_field_names = project_config.settings['customfield_parent_links']
551
+ custom_field_names = [custom_field_names] if custom_field_names.is_a? String
712
552
 
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
553
+ custom_field_names&.each do |field_name|
554
+ parent = fields[field_name]
555
+ next if parent.nil?
556
+ return parent if looks_like_issue_key? parent
717
557
 
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
558
+ project_config.file_system.log(
559
+ "Custom field #{field_name.inspect} should point to a parent id but found #{parent.inspect}"
560
+ )
723
561
  end
724
-
725
- parent
562
+ nil
726
563
  end
727
564
 
728
565
  def in_initial_query?
@@ -746,17 +583,19 @@ class Issue
746
583
  def discard_changes_before cutoff_time
747
584
  rejected_any = false
748
585
  @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
586
+ next false unless discardable_before? change, cutoff_time
587
+
588
+ (@discarded_changes ||= []) << change
589
+ rejected_any = true
755
590
  end
756
591
 
757
592
  (@discarded_change_times ||= []) << cutoff_time if rejected_any
758
593
  end
759
594
 
595
+ def discardable_before? change, cutoff_time
596
+ change.status? && change.time <= cutoff_time && change.artificial? == false
597
+ end
598
+
760
599
  def dump
761
600
  IssuePrinter.new(self).to_s
762
601
  end
@@ -780,7 +619,7 @@ class Issue
780
619
  end
781
620
 
782
621
  def status_changes
783
- @changes.select { |change| change.status? }
622
+ @changes.select(&:status?)
784
623
  end
785
624
 
786
625
  def status_resolution_at_done
@@ -807,7 +646,7 @@ class Issue
807
646
  changes.each do |change|
808
647
  next unless change.sprint?
809
648
 
810
- sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
649
+ sprint_ids << change.raw['to'].split(/\s*,\s*/).collect(&:to_i)
811
650
  end
812
651
  sprint_ids.flatten!
813
652
 
@@ -815,17 +654,13 @@ class Issue
815
654
  end
816
655
 
817
656
  def started_sprints
818
- sprints.reject { |sprint| sprint.future? }
657
+ sprints.reject(&:future?)
819
658
  end
820
659
 
821
660
  def compact_text text, max: 60
822
661
  return '' if text.nil?
823
662
 
824
- text = if text.is_a? Hash
825
- @board.project_config.atlassian_document_format.to_text(text)
826
- else
827
- text
828
- end
663
+ text = @board.project_config.atlassian_document_format.to_text(text) if text.is_a? Hash
829
664
  text = text.gsub(/\s+/, ' ').strip
830
665
  text = "#{text[0...max]}..." if text.length > max
831
666
  text
@@ -836,36 +671,47 @@ class Issue
836
671
  # Returns [[effective_time, change_item]] for each moment the issue entered an active sprint.
837
672
  # Skips sprints that were removed before they activated.
838
673
  def sprint_entry_events
839
- data_clazz = Struct.new(:sprint_id, :sprint_start, :add_time, :change)
840
674
  events = []
841
675
  in_sprint = []
842
676
 
843
677
  @changes.each do |change|
844
678
  next unless change.sprint?
845
679
 
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
680
+ add_tracked_sprints(in_sprint, change)
681
+ close_tracked_sprints(in_sprint, change, events)
682
+ end
854
683
 
855
- in_sprint.delete(data)
856
- next if data.sprint_start >= change.time # sprint hadn't activated before removal
684
+ # Anything still tracked at the end never left, so its entry is the moment it started (or was added).
685
+ in_sprint.each { |tracked| events << sprint_entry_event_for(tracked) }
686
+ events
687
+ end
857
688
 
858
- effective_time = [data.add_time, data.sprint_start].max
859
- events << [effective_time, sprint_change_at(effective_time, data.change)]
860
- end
689
+ # Records each sprint this change newly joined, but only those we know eventually started.
690
+ def add_tracked_sprints in_sprint, change
691
+ (change.value_id - change.old_value_id).each do |sprint_id|
692
+ sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
693
+ in_sprint << TrackedSprint.new(sprint_id:, sprint_start:, add_time: change.time, change:) if sprint_start
861
694
  end
695
+ end
696
+
697
+ # Emits an entry for each sprint this change left, unless it was removed before ever activating.
698
+ def close_tracked_sprints in_sprint, change, events
699
+ (change.old_value_id - change.value_id).each do |sprint_id|
700
+ tracked = in_sprint.find { |candidate| candidate.sprint_id == sprint_id }
701
+ next unless tracked
862
702
 
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)]
703
+ in_sprint.delete(tracked)
704
+ next if tracked.sprint_start >= change.time # sprint hadn't activated before removal
705
+
706
+ events << sprint_entry_event_for(tracked)
866
707
  end
708
+ end
867
709
 
868
- events
710
+ # The moment the issue was effectively in an active sprint - the later of when it was added and when
711
+ # the sprint started - paired with the change that best represents that moment.
712
+ def sprint_entry_event_for tracked
713
+ effective_time = [tracked.add_time, tracked.sprint_start].max
714
+ [effective_time, sprint_change_at(effective_time, tracked.change)]
869
715
  end
870
716
 
871
717
  def sprint_change_at effective_time, change
@@ -885,15 +731,21 @@ class Issue
885
731
  break if change.time > time
886
732
  next unless change.sprint?
887
733
 
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) }
734
+ apply_sprint_membership_change(active_ids, change, time)
893
735
  end
894
736
  active_ids.any?
895
737
  end
896
738
 
739
+ # Adds the sprints newly joined by this change (only if they had already started by `time`) and
740
+ # removes the ones it left, mutating active_ids in place.
741
+ def apply_sprint_membership_change active_ids, change, time
742
+ (change.value_id - change.old_value_id).each do |sprint_id|
743
+ sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
744
+ active_ids << sprint_id if sprint_start && sprint_start <= time
745
+ end
746
+ (change.old_value_id - change.value_id).each { |id| active_ids.delete(id) }
747
+ end
748
+
897
749
  def in_visible_status_at? time, visible_status_ids
898
750
  last = status_changes.reverse.find { |c| c.time <= time }
899
751
  last && visible_status_ids.include?(last.value_id)
@@ -904,30 +756,40 @@ class Issue
904
756
  created = parse_time(history['created'])
905
757
 
906
758
  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
-
759
+ item = backfill_missing_status_id(item) if item['field'] == 'status' && item['to'].nil?
924
760
  @changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
925
761
  end
926
762
  end
927
763
  end
928
764
 
765
+ # Jira sometimes reports a status change with a name but no id. Guess the id from the name (when we
766
+ # can) and return a copy of the item with a 'to' filled in, logging what we did.
767
+ def backfill_missing_status_id item
768
+ to_name = item['toString']
769
+ matches = board.possible_statuses.find_all_by_name(to_name)
770
+ guessed_id, id_note = guess_status_id(to_name, matches)
771
+ board.project_config.file_system.warning(
772
+ "Issue #{key} has a status change without a 'to' id " \
773
+ "(from #{item['fromString'].inspect} to #{to_name.inspect}). #{id_note}"
774
+ )
775
+ item.merge('to' => guessed_id)
776
+ end
777
+
778
+ # Returns [id_as_string, explanation]. A single name match gives that id; anything else falls back to
779
+ # id 0 because we can't safely disambiguate.
780
+ def guess_status_id to_name, matches
781
+ if matches.length == 1
782
+ [matches.first.id.to_s, "Guessed id #{matches.first.id} from status name."]
783
+ elsif matches.length > 1
784
+ ['0', "Multiple statuses named #{to_name.inspect} exist " \
785
+ "(ids: #{matches.map(&:id).join(', ')}); cannot disambiguate. Using id 0."]
786
+ else
787
+ ['0', "No known status named #{to_name.inspect}. Using id 0."]
788
+ end
789
+ end
790
+
929
791
  def load_comments_into_changes
930
- @raw['fields']['comment']['comments']&.each do |comment|
792
+ raw_fields['comment']['comments']&.each do |comment|
931
793
  raw = comment.merge({
932
794
  'field' => 'comment',
933
795
  'to' => comment['id'],
@@ -956,16 +818,16 @@ class Issue
956
818
  first_status_id = nil
957
819
 
958
820
  # There won't be a created timestamp in cases where this was a linked issue
959
- return unless @raw['fields']['created']
821
+ return unless raw_fields['created']
960
822
 
961
- created_time = parse_time @raw['fields']['created']
823
+ created_time = parse_time raw_fields['created']
962
824
  first_change = @changes.find { |change| change.field == field_name }
963
825
  if first_change.nil?
964
826
  # 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]
827
+ return nil unless raw_fields[field_name]
966
828
 
967
- first_status = @raw['fields'][field_name]['name']
968
- first_status_id = @raw['fields'][field_name]['id'].to_i
829
+ first_status = raw_fields[field_name]['name']
830
+ first_status_id = raw_fields[field_name]['id'].to_i
969
831
  else
970
832
  # Otherwise, we look at what the first one had changed away from.
971
833
  first_status = first_change.old_value
@@ -975,7 +837,7 @@ class Issue
975
837
  first_status_id = first_change.old_value_id || 0
976
838
  end
977
839
 
978
- creator = raw['fields']['creator']
840
+ creator = raw_fields['creator']
979
841
  ChangeItem.new time: created_time, artificial: true, author_raw: creator, raw: {
980
842
  'field' => field_name,
981
843
  'to' => first_status_id,
@@ -983,6 +845,45 @@ class Issue
983
845
  }
984
846
  end
985
847
 
848
+ # Jira never records an issue's *initial* sprint membership as a changelog transition, so an issue
849
+ # created directly inside a sprint (and never moved out) has no Sprint change at all, and one whose
850
+ # first recorded change already lists the sprint in its 'from' looks like it entered at that later
851
+ # moment. Reconstruct that initial membership as an artificial change at creation time, mirroring how
852
+ # we fabricate the initial status and priority. Returns nil when the issue started in no sprints.
853
+ def fabricate_sprint_change
854
+ return unless raw_fields['created']
855
+
856
+ first_sprint_change = @changes.find(&:sprint?)
857
+ initial_sprint_ids = first_sprint_change ? first_sprint_change.old_value_id : current_sprint_ids
858
+ return if initial_sprint_ids.empty?
859
+
860
+ ChangeItem.new(
861
+ time: parse_time(raw_fields['created']), artificial: true, author_raw: raw_fields['creator'],
862
+ raw: {
863
+ 'field' => 'Sprint',
864
+ 'fieldId' => first_sprint_change&.field_id || sprint_field_id,
865
+ 'to' => initial_sprint_ids.join(', '),
866
+ 'toString' => 'Sprint'
867
+ }
868
+ )
869
+ end
870
+
871
+ # The Sprint custom field id varies by Jira instance, so we find it by shape: its value is a list of
872
+ # sprint objects, each of which carries a boardId. Returns nil when the issue is in no sprints.
873
+ def sprint_field_id
874
+ field = raw_fields.find do |_field_id, value|
875
+ value.is_a?(Array) && value.first.is_a?(Hash) && value.first.key?('boardId')
876
+ end
877
+ field&.first
878
+ end
879
+
880
+ def current_sprint_ids
881
+ field_id = sprint_field_id
882
+ return [] unless field_id
883
+
884
+ raw_fields[field_id].filter_map { |sprint| sprint['id']&.to_i }
885
+ end
886
+
986
887
  def find_status_category_ids_by_names category_names
987
888
  category_names.filter_map do |name|
988
889
  list = board.possible_statuses.find_all_categories_by_name name