jirametrics 2.13 → 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 (87) hide show
  1. checksums.yaml +4 -4
  2. data/bin/jirametrics-mcp +5 -0
  3. data/lib/jirametrics/aggregate_config.rb +10 -2
  4. data/lib/jirametrics/aging_work_bar_chart.rb +191 -133
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +43 -11
  6. data/lib/jirametrics/aging_work_table.rb +9 -7
  7. data/lib/jirametrics/anonymizer.rb +81 -6
  8. data/lib/jirametrics/atlassian_document_format.rb +101 -97
  9. data/lib/jirametrics/bar_chart_range.rb +17 -0
  10. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  11. data/lib/jirametrics/board.rb +32 -8
  12. data/lib/jirametrics/board_config.rb +4 -1
  13. data/lib/jirametrics/board_feature.rb +14 -0
  14. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  15. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  16. data/lib/jirametrics/change_item.rb +14 -6
  17. data/lib/jirametrics/chart_base.rb +141 -3
  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} +21 -4
  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 +85 -53
  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 +117 -100
  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 +42 -4
  35. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  36. data/lib/jirametrics/examples/standard_project.rb +41 -28
  37. data/lib/jirametrics/expedited_chart.rb +3 -1
  38. data/lib/jirametrics/exporter.rb +26 -6
  39. data/lib/jirametrics/file_config.rb +9 -11
  40. data/lib/jirametrics/file_system.rb +59 -3
  41. data/lib/jirametrics/fix_version.rb +13 -0
  42. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  43. data/lib/jirametrics/github_gateway.rb +115 -0
  44. data/lib/jirametrics/groupable_issue_chart.rb +11 -1
  45. data/lib/jirametrics/grouping_rules.rb +26 -4
  46. data/lib/jirametrics/html/aging_work_bar_chart.erb +5 -5
  47. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +3 -1
  48. data/lib/jirametrics/html/aging_work_table.erb +5 -0
  49. data/lib/jirametrics/html/collapsible_issues_panel.erb +2 -2
  50. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  51. data/lib/jirametrics/html/daily_wip_chart.erb +40 -5
  52. data/lib/jirametrics/html/estimate_accuracy_chart.erb +4 -12
  53. data/lib/jirametrics/html/expedited_chart.erb +6 -14
  54. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +4 -8
  55. data/lib/jirametrics/html/index.css +249 -69
  56. data/lib/jirametrics/html/index.erb +9 -35
  57. data/lib/jirametrics/html/index.js +164 -0
  58. data/lib/jirametrics/html/legacy_colors.css +174 -0
  59. data/lib/jirametrics/html/sprint_burndown.erb +17 -15
  60. data/lib/jirametrics/html/throughput_chart.erb +42 -11
  61. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +61 -59
  62. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +15 -11
  63. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  64. data/lib/jirametrics/html_generator.rb +32 -0
  65. data/lib/jirametrics/html_report_config.rb +52 -57
  66. data/lib/jirametrics/issue.rb +304 -101
  67. data/lib/jirametrics/issue_printer.rb +97 -0
  68. data/lib/jirametrics/jira_gateway.rb +77 -17
  69. data/lib/jirametrics/mcp_server.rb +531 -0
  70. data/lib/jirametrics/project_config.rb +128 -12
  71. data/lib/jirametrics/pull_request.rb +30 -0
  72. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  73. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  74. data/lib/jirametrics/pull_request_review.rb +13 -0
  75. data/lib/jirametrics/raw_javascript.rb +17 -0
  76. data/lib/jirametrics/settings.json +5 -1
  77. data/lib/jirametrics/sprint.rb +12 -0
  78. data/lib/jirametrics/sprint_burndown.rb +10 -4
  79. data/lib/jirametrics/status.rb +1 -1
  80. data/lib/jirametrics/stitcher.rb +81 -0
  81. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  82. data/lib/jirametrics/throughput_chart.rb +73 -23
  83. data/lib/jirametrics/time_based_histogram.rb +139 -0
  84. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  85. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  86. data/lib/jirametrics.rb +83 -69
  87. metadata +60 -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']
@@ -46,8 +48,8 @@ class Issue
46
48
  def type = @raw['fields']['issuetype']['name']
47
49
  def type_icon_url = @raw['fields']['issuetype']['iconUrl']
48
50
 
49
- def priority_name = @raw['fields']['priority']['name']
50
- def priority_url = @raw['fields']['priority']['iconUrl']
51
+ def priority_name = @raw.dig('fields', 'priority', 'name')
52
+ def priority_url = @raw.dig('fields', 'priority', 'iconUrl')
51
53
 
52
54
  def summary = @raw['fields']['summary']
53
55
 
@@ -152,7 +154,7 @@ class Issue
152
154
  # Are we currently in this status? If yes, then return the most recent status change.
153
155
  def currently_in_status *status_names
154
156
  change = most_recent_status_change
155
- return false if change.nil?
157
+ return nil if change.nil?
156
158
 
157
159
  change if change.current_status_matches(*status_names)
158
160
  end
@@ -162,7 +164,7 @@ class Issue
162
164
  category_ids = find_status_category_ids_by_names category_names
163
165
 
164
166
  change = most_recent_status_change
165
- return false if change.nil?
167
+ return nil if change.nil?
166
168
 
167
169
  status = find_or_create_status id: change.value_id, name: change.value
168
170
  change if status && category_ids.include?(status.category.id)
@@ -208,11 +210,131 @@ class Issue
208
210
  end
209
211
 
210
212
  def first_time_visible_on_board
211
- first_time_in_status(*board.visible_columns.collect(&:status_ids).flatten)
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]
212
328
  end
213
329
 
214
330
  def parse_time text
215
- 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
216
338
  end
217
339
 
218
340
  def created
@@ -220,6 +342,10 @@ class Issue
220
342
  parse_time @raw['fields']['created'] if @raw['fields']['created']
221
343
  end
222
344
 
345
+ def time_created
346
+ @changes.first
347
+ end
348
+
223
349
  def updated
224
350
  parse_time @raw['fields']['updated']
225
351
  end
@@ -233,11 +359,11 @@ class Issue
233
359
  end
234
360
 
235
361
  def assigned_to
236
- @raw['fields']&.[]('assignee')&.[]('displayName')
362
+ @raw['fields']['assignee']&.[]('displayName')
237
363
  end
238
364
 
239
365
  def assigned_to_icon_url
240
- @raw['fields']&.[]('assignee')&.[]('avatarUrls')&.[]('16x16')
366
+ @raw['fields']['assignee']&.[]('avatarUrls')&.[]('16x16')
241
367
  end
242
368
 
243
369
  # Many test failures are simply unreadable because the default inspect on this class goes
@@ -305,10 +431,6 @@ class Issue
305
431
 
306
432
  blocked_statuses = settings['blocked_statuses']
307
433
  stalled_statuses = settings['stalled_statuses']
308
- unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
309
- raise "blocked_statuses(#{blocked_statuses.inspect}) and " \
310
- "stalled_statuses(#{stalled_statuses.inspect}) must both be arrays"
311
- end
312
434
 
313
435
  blocked_link_texts = settings['blocked_link_text']
314
436
  stalled_threshold = settings['stalled_threshold_days']
@@ -321,7 +443,9 @@ class Issue
321
443
  previous_change_time = created
322
444
 
323
445
  blocking_status = nil
446
+ blocking_is_blocked = false
324
447
  flag = nil
448
+ flag_reason = nil
325
449
 
326
450
  # This mock change is to force the writing of one last entry at the end of the time range.
327
451
  # By doing this, we're able to eliminate a lot of duplicated code in charts.
@@ -336,11 +460,14 @@ class Issue
336
460
  )
337
461
 
338
462
  if change.flagged? && flagged_means_blocked
339
- flag = change.value
340
- flag = nil if change.value == ''
463
+ flag, flag_reason = blocked_stalled_changes_flag_logic change
341
464
  elsif change.status?
342
465
  blocking_status = nil
343
- 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)
344
471
  blocking_status = change.value
345
472
  end
346
473
  elsif change.link?
@@ -361,8 +488,9 @@ class Issue
361
488
 
362
489
  new_change = BlockedStalledChange.new(
363
490
  flagged: flag,
491
+ flag_reason: flag_reason,
364
492
  status: blocking_status,
365
- status_is_blocking: blocking_status.nil? || blocked_statuses.include?(blocking_status),
493
+ status_is_blocking: blocking_status.nil? || blocking_is_blocked,
366
494
  blocking_issue_keys: (blocking_issue_keys.empty? ? nil : blocking_issue_keys.dup),
367
495
  time: change.time
368
496
  )
@@ -381,6 +509,7 @@ class Issue
381
509
  hack = result.pop
382
510
  result << BlockedStalledChange.new(
383
511
  flagged: hack.flag,
512
+ flag_reason: hack.flag_reason,
384
513
  status: hack.status,
385
514
  status_is_blocking: hack.status_is_blocking,
386
515
  blocking_issue_keys: hack.blocking_issue_keys,
@@ -392,6 +521,28 @@ class Issue
392
521
  result
393
522
  end
394
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
+
395
546
  def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
396
547
  stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
397
548
 
@@ -427,7 +578,7 @@ class Issue
427
578
  # return [number of active seconds, total seconds] that this issue had up to the end_time.
428
579
  # It does not include data before issue start or after issue end
429
580
  def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
430
- issue_start, issue_stop = @board.cycletime.started_stopped_times(self)
581
+ issue_start, issue_stop = started_stopped_times
431
582
  return [0.0, 0.0] if !issue_start || issue_start > end_time
432
583
 
433
584
  value_add_time = 0.0
@@ -607,92 +758,49 @@ class Issue
607
758
  end
608
759
 
609
760
  def dump
610
- result = +''
611
- result << "#{key} (#{type}): #{compact_text summary, 200}\n"
612
-
613
- assignee = raw['fields']['assignee']
614
- result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
615
-
616
- raw['fields']['issuelinks']&.each do |link|
617
- result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
618
- result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
619
- end
620
- history = [] # time, type, detail
621
-
622
- if board.cycletime
623
- started_at, stopped_at = board.cycletime.started_stopped_times(self)
624
- history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
625
- history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
626
- else
627
- result << " Unable to determine start/end times as board #{board.id} has no cycletime specified\n"
628
- end
629
-
630
- @discarded_change_times&.each do |time|
631
- history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
632
- end
633
-
634
- (changes + (@discarded_changes || [])).each do |change|
635
- if change.status?
636
- value = "#{change.value.inspect}:#{change.value_id.inspect}"
637
- old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
638
- else
639
- value = compact_text(change.value).inspect
640
- old_value = change.old_value ? compact_text(change.old_value).inspect : nil
641
- end
642
-
643
- message = +''
644
- message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
645
- message << value
646
- if change.artificial?
647
- message << ' (Artificial entry)' if change.artificial?
648
- else
649
- message << " (Author: #{change.author})"
650
- end
651
- history << [change.time, change.field, message, change.artificial?]
652
- end
653
-
654
- result << " History:\n"
655
- type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
656
- history.sort! do |a, b|
657
- if a[0] == b[0]
658
- if a[1].nil?
659
- 1
660
- elsif b[1].nil?
661
- -1
662
- else
663
- a[1] <=> b[1]
664
- end
665
- else
666
- a[0] <=> b[0]
667
- end
668
- end
669
- history.each do |time, type, detail, _artificial|
670
- if type.nil?
671
- type = '-' * type_width
672
- else
673
- type = (' ' * (type_width - type.length)) << type
674
- end
675
- result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
676
- end
677
-
678
- result
761
+ IssuePrinter.new(self).to_s
679
762
  end
680
763
 
681
764
  def done?
682
765
  if artificial? || board.cycletime.nil?
683
766
  # This was probably loaded as a linked issue, which means we don't know what board it really
684
- # belonged to. The best we can do is look at the status category. This case should be rare but
685
- # it can happen.
686
- status.category.name == 'Done'
767
+ # belonged to. The best we can do is look at the status key
768
+ status.category.done?
687
769
  else
688
770
  board.cycletime.done? self
689
771
  end
690
772
  end
691
773
 
774
+ def started_stopped_times
775
+ board.cycletime.started_stopped_times(self)
776
+ end
777
+
778
+ def started_stopped_dates
779
+ board.cycletime.started_stopped_dates(self)
780
+ end
781
+
692
782
  def status_changes
693
783
  @changes.select { |change| change.status? }
694
784
  end
695
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?
798
+ end
799
+
800
+ status = status_change ? find_or_create_status(id: status_change.value_id, name: status_change.value) : nil
801
+ [status, resolution]
802
+ end
803
+
696
804
  def sprints
697
805
  sprint_ids = []
698
806
 
@@ -706,13 +814,113 @@ class Issue
706
814
  board.sprints.select { |s| sprint_ids.include? s.id }
707
815
  end
708
816
 
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
+
709
834
  private
710
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
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
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)]
860
+ end
861
+ end
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)]
866
+ end
867
+
868
+ events
869
+ end
870
+
871
+ def sprint_change_at effective_time, change
872
+ return change if effective_time == change.time
873
+
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
+ )
880
+ end
881
+
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
896
+
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)
900
+ end
901
+
711
902
  def load_history_into_changes
712
903
  @raw['changelog']['histories']&.each do |history|
713
904
  created = parse_time(history['created'])
714
905
 
715
906
  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
+
716
924
  @changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
717
925
  end
718
926
  end
@@ -730,14 +938,6 @@ class Issue
730
938
  end
731
939
  end
732
940
 
733
- def compact_text text, max = 60
734
- return nil if text.nil?
735
-
736
- text = text.gsub(/\s+/, ' ').strip
737
- text = "#{text[0..max]}..." if text.length > max
738
- text
739
- end
740
-
741
941
  def sort_changes!
742
942
  @changes.sort! do |a, b|
743
943
  # It's common that a resolved will happen at the same time as a status change.
@@ -755,6 +955,9 @@ class Issue
755
955
  first_status = nil
756
956
  first_status_id = nil
757
957
 
958
+ # There won't be a created timestamp in cases where this was a linked issue
959
+ return unless @raw['fields']['created']
960
+
758
961
  created_time = parse_time @raw['fields']['created']
759
962
  first_change = @changes.find { |change| change.field == field_name }
760
963
  if first_change.nil?
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ class IssuePrinter
4
+ def initialize issue
5
+ @issue = issue
6
+ end
7
+
8
+ def to_s
9
+ issue = @issue
10
+ result = +''
11
+ result << "#{issue.key} (#{issue.type}): #{issue.compact_text issue.summary, max: 200}\n"
12
+
13
+ assignee = issue.raw['fields']['assignee']
14
+ result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
15
+
16
+ issue.raw['fields']['issuelinks']&.each do |link|
17
+ result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
18
+ result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
19
+ end
20
+
21
+ history = [] # time, type, detail
22
+
23
+ if issue.board.cycletime
24
+ started_at, stopped_at = issue.started_stopped_times
25
+ history << [started_at, nil, 'vvvv Started here vvvv', true] if started_at
26
+ history << [stopped_at, nil, '^^^^ Finished here ^^^^', true] if stopped_at
27
+ else
28
+ result << " Unable to determine start/end times as board #{issue.board.id} has no cycletime specified\n"
29
+ end
30
+
31
+ issue.discarded_change_times&.each do |time|
32
+ history << [time, nil, '^^^^ Changes discarded ^^^^', true]
33
+ end
34
+
35
+ (issue.changes + (issue.discarded_changes || [])).each do |change|
36
+ history << [change.time, change.field, create_change_message(change: change, issue: issue), change.artificial?]
37
+ end
38
+
39
+ result << " History:\n"
40
+ type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
41
+ sort_history!(history)
42
+ history.each do |time, type, detail, _artificial|
43
+ type = type.nil? ? '-' * type_width : type.rjust(type_width)
44
+ result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
45
+ end
46
+
47
+ result
48
+ end
49
+
50
+ def create_change_message change:, issue:
51
+ value, old_value = format_change_values(change: change, issue: issue)
52
+
53
+ message = +''
54
+ message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
55
+ message << value
56
+ if change.artificial?
57
+ message << ' (Artificial entry)'
58
+ else
59
+ message << " (Author: #{change.author})"
60
+ end
61
+ message
62
+ end
63
+
64
+ def format_change_values change:, issue:
65
+ if change.status?
66
+ value = "#{change.value.inspect}:#{change.value_id.inspect}"
67
+ old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
68
+ elsif change.sprint?
69
+ added = change.value_id - change.old_value_id
70
+ removed = change.old_value_id - change.value_id
71
+ value = "#{change.value.inspect} #{change.value_id}"
72
+ value << " (added: #{added})" unless added.empty?
73
+ value << " (removed: #{removed})" unless removed.empty?
74
+ old_value = nil
75
+ else
76
+ value = issue.compact_text(change.value).inspect
77
+ old_value = change.old_value ? issue.compact_text(change.old_value).inspect : nil
78
+ end
79
+ [value, old_value]
80
+ end
81
+
82
+ def sort_history! history
83
+ history.sort! do |a, b|
84
+ if a[0] == b[0]
85
+ if a[1].nil?
86
+ 1
87
+ elsif b[1].nil?
88
+ -1
89
+ else
90
+ a[1] <=> b[1]
91
+ end
92
+ else
93
+ a[0] <=> b[0]
94
+ end
95
+ end
96
+ end
97
+ end