jirametrics 2.11 → 2.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +10 -2
  4. data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
  6. data/lib/jirametrics/aging_work_table.rb +14 -17
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +160 -0
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  11. data/lib/jirametrics/board.rb +34 -11
  12. data/lib/jirametrics/board_config.rb +5 -1
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +10 -2
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +43 -20
  17. data/lib/jirametrics/chart_base.rb +143 -6
  18. data/lib/jirametrics/css_variable.rb +1 -1
  19. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  20. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +22 -5
  21. data/lib/jirametrics/cycletime_histogram.rb +15 -101
  22. data/lib/jirametrics/cycletime_scatterplot.rb +17 -83
  23. data/lib/jirametrics/daily_view.rb +306 -0
  24. data/lib/jirametrics/daily_wip_by_age_chart.rb +4 -5
  25. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +14 -4
  26. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  27. data/lib/jirametrics/daily_wip_chart.rb +30 -8
  28. data/lib/jirametrics/data_quality_report.rb +43 -12
  29. data/lib/jirametrics/dependency_chart.rb +6 -3
  30. data/lib/jirametrics/download_config.rb +15 -0
  31. data/lib/jirametrics/downloader.rb +128 -71
  32. data/lib/jirametrics/downloader_for_cloud.rb +287 -0
  33. data/lib/jirametrics/downloader_for_data_center.rb +95 -0
  34. data/lib/jirametrics/estimate_accuracy_chart.rb +74 -12
  35. data/lib/jirametrics/estimation_configuration.rb +25 -0
  36. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  37. data/lib/jirametrics/examples/standard_project.rb +42 -27
  38. data/lib/jirametrics/expedited_chart.rb +3 -1
  39. data/lib/jirametrics/exporter.rb +26 -6
  40. data/lib/jirametrics/file_config.rb +10 -12
  41. data/lib/jirametrics/file_system.rb +59 -3
  42. data/lib/jirametrics/fix_version.rb +13 -0
  43. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  44. data/lib/jirametrics/github_gateway.rb +115 -0
  45. data/lib/jirametrics/groupable_issue_chart.rb +11 -1
  46. data/lib/jirametrics/grouping_rules.rb +26 -4
  47. data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
  48. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
  49. data/lib/jirametrics/html/aging_work_table.erb +7 -0
  50. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  51. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  52. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  53. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  54. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  55. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  56. data/lib/jirametrics/html/index.css +320 -69
  57. data/lib/jirametrics/html/index.erb +11 -20
  58. data/lib/jirametrics/html/index.js +164 -0
  59. data/lib/jirametrics/html/legacy_colors.css +174 -0
  60. data/lib/jirametrics/html/sprint_burndown.erb +17 -15
  61. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  62. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  63. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
  64. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  65. data/lib/jirametrics/html_generator.rb +32 -0
  66. data/lib/jirametrics/html_report_config.rb +52 -55
  67. data/lib/jirametrics/issue.rb +329 -106
  68. data/lib/jirametrics/issue_collection.rb +33 -0
  69. data/lib/jirametrics/issue_printer.rb +97 -0
  70. data/lib/jirametrics/jira_gateway.rb +81 -14
  71. data/lib/jirametrics/mcp_server.rb +531 -0
  72. data/lib/jirametrics/project_config.rb +151 -18
  73. data/lib/jirametrics/pull_request.rb +30 -0
  74. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  75. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  76. data/lib/jirametrics/pull_request_review.rb +13 -0
  77. data/lib/jirametrics/raw_javascript.rb +17 -0
  78. data/lib/jirametrics/settings.json +6 -1
  79. data/lib/jirametrics/sprint.rb +13 -0
  80. data/lib/jirametrics/sprint_burndown.rb +45 -37
  81. data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
  82. data/lib/jirametrics/status.rb +1 -1
  83. data/lib/jirametrics/status_collection.rb +7 -0
  84. data/lib/jirametrics/stitcher.rb +81 -0
  85. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  86. data/lib/jirametrics/throughput_chart.rb +73 -23
  87. data/lib/jirametrics/time_based_histogram.rb +139 -0
  88. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  89. data/lib/jirametrics/user.rb +12 -0
  90. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  91. data/lib/jirametrics.rb +83 -64
  92. metadata +65 -6
@@ -3,14 +3,15 @@
3
3
  require 'time'
4
4
 
5
5
  class Issue
6
- attr_reader :changes, :raw, :subtasks, :board
7
- attr_accessor :parent
6
+ attr_reader :changes, :raw, :subtasks, :board, :discarded_changes, :discarded_change_times
7
+ attr_accessor :parent, :github_prs
8
8
 
9
9
  def initialize raw:, board:, timezone_offset: '+00:00'
10
10
  @raw = raw
11
11
  @timezone_offset = timezone_offset
12
12
  @subtasks = []
13
13
  @changes = []
14
+ @github_prs = []
14
15
  @board = board
15
16
 
16
17
  # We only check for this here because if a board isn't passed in then things will fail much
@@ -19,9 +20,10 @@ class Issue
19
20
 
20
21
  # There are cases where we create an Issue of fragments like linked issues and those won't have
21
22
  # changelogs.
22
- return unless @raw['changelog']
23
+ load_history_into_changes if @raw['changelog']
23
24
 
24
- load_history_into_changes
25
+ # As above with fragments, there may not be a fields section
26
+ return unless @raw['fields']
25
27
 
26
28
  # If this is an older pull of data then comments may not be there.
27
29
  load_comments_into_changes if @raw['fields']['comment']
@@ -44,9 +46,11 @@ class Issue
44
46
  def key = @raw['key']
45
47
 
46
48
  def type = @raw['fields']['issuetype']['name']
47
-
48
49
  def type_icon_url = @raw['fields']['issuetype']['iconUrl']
49
50
 
51
+ def priority_name = @raw.dig('fields', 'priority', 'name')
52
+ def priority_url = @raw.dig('fields', 'priority', 'iconUrl')
53
+
50
54
  def summary = @raw['fields']['summary']
51
55
 
52
56
  def labels = @raw['fields']['labels'] || []
@@ -150,7 +154,7 @@ class Issue
150
154
  # Are we currently in this status? If yes, then return the most recent status change.
151
155
  def currently_in_status *status_names
152
156
  change = most_recent_status_change
153
- return false if change.nil?
157
+ return nil if change.nil?
154
158
 
155
159
  change if change.current_status_matches(*status_names)
156
160
  end
@@ -160,7 +164,7 @@ class Issue
160
164
  category_ids = find_status_category_ids_by_names category_names
161
165
 
162
166
  change = most_recent_status_change
163
- return false if change.nil?
167
+ return nil if change.nil?
164
168
 
165
169
  status = find_or_create_status id: change.value_id, name: change.value
166
170
  change if status && category_ids.include?(status.category.id)
@@ -205,8 +209,132 @@ class Issue
205
209
  nil
206
210
  end
207
211
 
212
+ def first_time_visible_on_board
213
+ visible_status_ids = board.visible_columns.collect(&:status_ids).flatten
214
+ return first_time_in_status(*visible_status_ids) unless board.scrum?
215
+
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 = []
221
+
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)
225
+ end
226
+
227
+ sprint_entry_events.each do |effective_time, representative_change|
228
+ candidates << representative_change if in_visible_status_at?(effective_time, visible_status_ids)
229
+ end
230
+
231
+ candidates.min_by(&:time)
232
+ end
233
+
234
+ def reasons_not_visible_on_board
235
+ reasons = []
236
+ reasons << 'Not in an active sprint' if board.scrum? && sprints.none?(&:active?)
237
+ unless board.visible_columns.any? { |c| c.status_ids.include?(status.id) }
238
+ reasons << 'Status is not configured for any visible column on the board'
239
+ end
240
+ reasons
241
+ end
242
+
243
+ def visible_on_board?
244
+ reasons_not_visible_on_board.empty?
245
+ end
246
+
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.
252
+ def first_time_added_to_active_sprint
253
+ unless board.scrum?
254
+ raise 'first_time_added_to_active_sprint() can only be used with Scrum boards: ' \
255
+ "issue=#{key}, board=#{board.inspect}"
256
+ end
257
+ data_clazz = Struct.new(:sprint_id, :sprint_start, :sprint_stop, :change)
258
+
259
+ matching_changes = []
260
+ all_datas = []
261
+
262
+ @changes.each do |change|
263
+ next unless change.sprint?
264
+
265
+ added_sprint_ids = change.value_id - change.old_value_id
266
+ 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
272
+ end
273
+
274
+ removed_sprint_ids = change.old_value_id - change.value_id
275
+ removed_sprint_ids.each do |id|
276
+ data = all_datas.find { |d| d.sprint_id == id }
277
+ # It's possible for an issue to be created inside a sprint and therefore for
278
+ # that add-to-sprint not show in the history.
279
+ next unless data
280
+
281
+ all_datas.delete(data)
282
+ next if data.sprint_start.nil? || data.sprint_start >= change.time
283
+
284
+ matching_changes << data.change
285
+ end
286
+ end
287
+
288
+ # There can't be any more removes so whatever is left is a valid option
289
+ # 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
292
+ end
293
+
294
+ matching_changes.min_by(&:time)
295
+ end
296
+
297
+ def find_sprint_start_end sprint_id:, change:
298
+ # There are two different places that sprint data could be found. In theory all
299
+ # sprints would be found in both places. In practice, sometimes what we need is
300
+ # in one or the other but not both.
301
+
302
+ # First look in the actual sprints json. If any issues are in this sprint then it should
303
+ # be here.
304
+ sprint = board.sprints.find { |s| s.id == sprint_id }
305
+ if sprint
306
+ return [nil, nil] if sprint.future?
307
+
308
+ return [sprint.start_time, sprint.completed_time]
309
+ end
310
+
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.
314
+ 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'
317
+
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]
328
+ end
329
+
208
330
  def parse_time text
209
- Time.parse(text).getlocal(@timezone_offset)
331
+ if text.nil?
332
+ nil
333
+ elsif text.is_a? String
334
+ Time.parse(text).getlocal(@timezone_offset)
335
+ else
336
+ Time.at(text / 1000).getlocal(@timezone_offset)
337
+ end
210
338
  end
211
339
 
212
340
  def created
@@ -214,6 +342,10 @@ class Issue
214
342
  parse_time @raw['fields']['created'] if @raw['fields']['created']
215
343
  end
216
344
 
345
+ def time_created
346
+ @changes.first
347
+ end
348
+
217
349
  def updated
218
350
  parse_time @raw['fields']['updated']
219
351
  end
@@ -227,7 +359,11 @@ class Issue
227
359
  end
228
360
 
229
361
  def assigned_to
230
- @raw['fields']&.[]('assignee')&.[]('displayName')
362
+ @raw['fields']['assignee']&.[]('displayName')
363
+ end
364
+
365
+ def assigned_to_icon_url
366
+ @raw['fields']['assignee']&.[]('avatarUrls')&.[]('16x16')
231
367
  end
232
368
 
233
369
  # Many test failures are simply unreadable because the default inspect on this class goes
@@ -295,10 +431,6 @@ class Issue
295
431
 
296
432
  blocked_statuses = settings['blocked_statuses']
297
433
  stalled_statuses = settings['stalled_statuses']
298
- unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
299
- raise "blocked_statuses(#{blocked_statuses.inspect}) and " \
300
- "stalled_statuses(#{stalled_statuses.inspect}) must both be arrays"
301
- end
302
434
 
303
435
  blocked_link_texts = settings['blocked_link_text']
304
436
  stalled_threshold = settings['stalled_threshold_days']
@@ -311,11 +443,13 @@ class Issue
311
443
  previous_change_time = created
312
444
 
313
445
  blocking_status = nil
446
+ blocking_is_blocked = false
314
447
  flag = nil
448
+ flag_reason = nil
315
449
 
316
450
  # This mock change is to force the writing of one last entry at the end of the time range.
317
451
  # By doing this, we're able to eliminate a lot of duplicated code in charts.
318
- mock_change = ChangeItem.new time: end_time, author: '', artificial: true, raw: { 'field' => '' }
452
+ mock_change = ChangeItem.new time: end_time, artificial: true, raw: { 'field' => '' }, author_raw: nil
319
453
 
320
454
  (changes + [mock_change]).each do |change|
321
455
  previous_was_active = false if check_for_stalled(
@@ -326,16 +460,19 @@ class Issue
326
460
  )
327
461
 
328
462
  if change.flagged? && flagged_means_blocked
329
- flag = change.value
330
- flag = nil if change.value == ''
463
+ flag, flag_reason = blocked_stalled_changes_flag_logic change
331
464
  elsif change.status?
332
465
  blocking_status = nil
333
- if blocked_statuses.include?(change.value) || stalled_statuses.include?(change.value)
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)
334
471
  blocking_status = change.value
335
472
  end
336
473
  elsif change.link?
337
474
  # Example: "This issue is satisfied by ANON-30465"
338
- unless /^This issue (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
475
+ unless /^This (?<_>issue|work item) (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
339
476
  puts "Issue(#{key}) Can't parse link text: #{change.value || change.old_value}"
340
477
  next
341
478
  end
@@ -351,8 +488,9 @@ class Issue
351
488
 
352
489
  new_change = BlockedStalledChange.new(
353
490
  flagged: flag,
491
+ flag_reason: flag_reason,
354
492
  status: blocking_status,
355
- status_is_blocking: blocking_status.nil? || blocked_statuses.include?(blocking_status),
493
+ status_is_blocking: blocking_status.nil? || blocking_is_blocked,
356
494
  blocking_issue_keys: (blocking_issue_keys.empty? ? nil : blocking_issue_keys.dup),
357
495
  time: change.time
358
496
  )
@@ -371,6 +509,7 @@ class Issue
371
509
  hack = result.pop
372
510
  result << BlockedStalledChange.new(
373
511
  flagged: hack.flag,
512
+ flag_reason: hack.flag_reason,
374
513
  status: hack.status,
375
514
  status_is_blocking: hack.status_is_blocking,
376
515
  blocking_issue_keys: hack.blocking_issue_keys,
@@ -382,6 +521,28 @@ class Issue
382
521
  result
383
522
  end
384
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
+
385
546
  def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
386
547
  stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
387
548
 
@@ -417,7 +578,7 @@ class Issue
417
578
  # return [number of active seconds, total seconds] that this issue had up to the end_time.
418
579
  # It does not include data before issue start or after issue end
419
580
  def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
420
- issue_start, issue_stop = @board.cycletime.started_stopped_times(self)
581
+ issue_start, issue_stop = started_stopped_times
421
582
  return [0.0, 0.0] if !issue_start || issue_start > end_time
422
583
 
423
584
  value_add_time = 0.0
@@ -462,8 +623,6 @@ class Issue
462
623
  end
463
624
 
464
625
  def expedited?
465
- return false unless @board&.project_config
466
-
467
626
  names = @board.project_config.settings['expedited_priority_names']
468
627
  return false unless names
469
628
 
@@ -580,7 +739,7 @@ class Issue
580
739
  /(?<project_code1>[^-]+)-(?<id1>.+)/ =~ key
581
740
  /(?<project_code2>[^-]+)-(?<id2>.+)/ =~ other.key
582
741
  comparison = project_code1 <=> project_code2
583
- comparison = id1 <=> id2 if comparison.zero?
742
+ comparison = id1.to_i <=> id2.to_i if comparison.zero?
584
743
  comparison
585
744
  end
586
745
 
@@ -599,127 +758,186 @@ class Issue
599
758
  end
600
759
 
601
760
  def dump
602
- result = +''
603
- result << "#{key} (#{type}): #{compact_text summary, 200}\n"
761
+ IssuePrinter.new(self).to_s
762
+ end
763
+
764
+ def done?
765
+ if artificial? || board.cycletime.nil?
766
+ # This was probably loaded as a linked issue, which means we don't know what board it really
767
+ # belonged to. The best we can do is look at the status key
768
+ status.category.done?
769
+ else
770
+ board.cycletime.done? self
771
+ end
772
+ end
773
+
774
+ def started_stopped_times
775
+ board.cycletime.started_stopped_times(self)
776
+ end
604
777
 
605
- assignee = raw['fields']['assignee']
606
- result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
778
+ def started_stopped_dates
779
+ board.cycletime.started_stopped_dates(self)
780
+ end
607
781
 
608
- raw['fields']['issuelinks']&.each do |link|
609
- result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
610
- result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
782
+ def status_changes
783
+ @changes.select { |change| change.status? }
784
+ end
785
+
786
+ def status_resolution_at_done
787
+ done_time = started_stopped_times.last
788
+ return [nil, nil] if done_time.nil?
789
+
790
+ status_change = nil
791
+ resolution = nil
792
+
793
+ @changes.each do |change|
794
+ break if change.time > done_time
795
+
796
+ status_change = change if change.status?
797
+ resolution = change.value if change.resolution?
611
798
  end
612
- history = [] # time, type, detail
613
799
 
614
- started_at, stopped_at = board.cycletime.started_stopped_times(self)
615
- history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
616
- history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
800
+ status = status_change ? find_or_create_status(id: status_change.value_id, name: status_change.value) : nil
801
+ [status, resolution]
802
+ end
803
+
804
+ def sprints
805
+ sprint_ids = []
806
+
807
+ changes.each do |change|
808
+ next unless change.sprint?
617
809
 
618
- @discarded_change_times&.each do |time|
619
- history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
810
+ sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
620
811
  end
812
+ sprint_ids.flatten!
621
813
 
622
- (changes + (@discarded_changes || [])).each do |change|
623
- if change.status?
624
- value = "#{change.value.inspect}:#{change.value_id.inspect}"
625
- old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
626
- else
627
- value = compact_text(change.value).inspect
628
- old_value = change.old_value ? compact_text(change.old_value).inspect : nil
629
- end
814
+ board.sprints.select { |s| sprint_ids.include? s.id }
815
+ end
630
816
 
631
- message = +''
632
- message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
633
- message << value
634
- if change.artificial?
635
- message << ' (Artificial entry)' if change.artificial?
636
- else
637
- message << " (Author: #{change.author})"
817
+ def started_sprints
818
+ sprints.reject { |sprint| sprint.future? }
819
+ end
820
+
821
+ def compact_text text, max: 60
822
+ return '' if text.nil?
823
+
824
+ text = if text.is_a? Hash
825
+ @board.project_config.atlassian_document_format.to_text(text)
826
+ else
827
+ text
828
+ end
829
+ text = text.gsub(/\s+/, ' ').strip
830
+ text = "#{text[0...max]}..." if text.length > max
831
+ text
832
+ end
833
+
834
+ private
835
+
836
+ # Returns [[effective_time, change_item]] for each moment the issue entered an active sprint.
837
+ # Skips sprints that were removed before they activated.
838
+ def sprint_entry_events
839
+ data_clazz = Struct.new(:sprint_id, :sprint_start, :add_time, :change)
840
+ events = []
841
+ in_sprint = []
842
+
843
+ @changes.each do |change|
844
+ next unless change.sprint?
845
+
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
638
849
  end
639
- history << [change.time, change.field, message, change.artificial?]
640
- end
641
-
642
- result << " History:\n"
643
- type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
644
- history.sort! do |a, b|
645
- if a[0] == b[0]
646
- if a[1].nil?
647
- 1
648
- elsif b[1].nil?
649
- -1
650
- else
651
- a[1] <=> b[1]
652
- end
653
- else
654
- a[0] <=> b[0]
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
854
+
855
+ in_sprint.delete(data)
856
+ next if data.sprint_start >= change.time # sprint hadn't activated before removal
857
+
858
+ effective_time = [data.add_time, data.sprint_start].max
859
+ events << [effective_time, sprint_change_at(effective_time, data.change)]
655
860
  end
656
861
  end
657
- history.each do |time, type, detail, _artificial|
658
- if type.nil?
659
- type = '-' * type_width
660
- else
661
- type = (' ' * (type_width - type.length)) << type
662
- end
663
- result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
862
+
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)]
664
866
  end
665
867
 
666
- result
868
+ events
667
869
  end
668
870
 
669
- def done?
670
- if artificial? || board.cycletime.nil?
671
- # This was probably loaded as a linked issue, which means we don't know what board it really
672
- # belonged to. The best we can do is look at the status category. This case should be rare but
673
- # it can happen.
674
- status.category.name == 'Done'
675
- else
676
- board.cycletime.done? self
677
- end
678
- end
871
+ def sprint_change_at effective_time, change
872
+ return change if effective_time == change.time
679
873
 
680
- def status_changes
681
- @changes.select { |change| change.status? }
874
+ ChangeItem.new(
875
+ raw: { 'field' => 'Sprint', 'toString' => 'Sprint activated', 'to' => '0', 'from' => nil, 'fromString' => nil },
876
+ author_raw: nil,
877
+ time: effective_time,
878
+ artificial: true
879
+ )
682
880
  end
683
881
 
684
- private
882
+ def in_active_sprint_at? time
883
+ active_ids = []
884
+ @changes.each do |change|
885
+ break if change.time > time
886
+ next unless change.sprint?
887
+
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) }
893
+ end
894
+ active_ids.any?
895
+ end
685
896
 
686
- def assemble_author raw
687
- raw['author']&.[]('displayName') || raw['author']&.[]('name') || 'Unknown author'
897
+ def in_visible_status_at? time, visible_status_ids
898
+ last = status_changes.reverse.find { |c| c.time <= time }
899
+ last && visible_status_ids.include?(last.value_id)
688
900
  end
689
901
 
690
902
  def load_history_into_changes
691
903
  @raw['changelog']['histories']&.each do |history|
692
904
  created = parse_time(history['created'])
693
905
 
694
- # It should be impossible to not have an author but we've seen it in production
695
- author = assemble_author history
696
906
  history['items']&.each do |item|
697
- @changes << ChangeItem.new(raw: item, time: created, author: author)
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
+
924
+ @changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
698
925
  end
699
926
  end
700
927
  end
701
928
 
702
929
  def load_comments_into_changes
703
930
  @raw['fields']['comment']['comments']&.each do |comment|
704
- raw = {
931
+ raw = comment.merge({
705
932
  'field' => 'comment',
706
933
  'to' => comment['id'],
707
934
  'toString' => comment['body']
708
- }
709
- author = assemble_author comment
935
+ })
710
936
  created = parse_time(comment['created'])
711
- @changes << ChangeItem.new(raw: raw, time: created, author: author, artificial: true)
937
+ @changes << ChangeItem.new(raw: raw, time: created, artificial: true, author_raw: comment['author'])
712
938
  end
713
939
  end
714
940
 
715
- def compact_text text, max = 60
716
- return nil if text.nil?
717
-
718
- text = text.gsub(/\s+/, ' ').strip
719
- text = "#{text[0..max]}..." if text.length > max
720
- text
721
- end
722
-
723
941
  def sort_changes!
724
942
  @changes.sort! do |a, b|
725
943
  # It's common that a resolved will happen at the same time as a status change.
@@ -737,6 +955,9 @@ class Issue
737
955
  first_status = nil
738
956
  first_status_id = nil
739
957
 
958
+ # There won't be a created timestamp in cases where this was a linked issue
959
+ return unless @raw['fields']['created']
960
+
740
961
  created_time = parse_time @raw['fields']['created']
741
962
  first_change = @changes.find { |change| change.field == field_name }
742
963
  if first_change.nil?
@@ -750,7 +971,9 @@ class Issue
750
971
  first_status = first_change.old_value
751
972
  first_status_id = first_change.old_value_id
752
973
  end
753
- ChangeItem.new time: created_time, artificial: true, author: author, raw: {
974
+
975
+ creator = raw['fields']['creator']
976
+ ChangeItem.new time: created_time, artificial: true, author_raw: creator, raw: {
754
977
  'field' => field_name,
755
978
  'to' => first_status_id,
756
979
  'toString' => first_status
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class IssueCollection < Array
4
+ attr_reader :hidden
5
+
6
+ def self.[] *issues
7
+ collection = new
8
+ issues.each { |i| collection << i }
9
+ collection
10
+ end
11
+
12
+ def initialize
13
+ super
14
+ @hidden = []
15
+ end
16
+
17
+ def reject! &block
18
+ select(&block).each do |issue|
19
+ @hidden << issue
20
+ end
21
+ super
22
+ end
23
+
24
+ def find_by_key key:, include_hidden: false
25
+ block = ->(issue) { issue.key == key }
26
+ issue = find(&block)
27
+ issue = hidden.find(&block) if issue.nil? && include_hidden
28
+ issue
29
+ end
30
+ def clone
31
+ raise 'baboom'
32
+ end
33
+ end