jirametrics 2.22 → 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 (81) 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 +26 -10
  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 +74 -1
  8. data/lib/jirametrics/atlassian_document_format.rb +93 -93
  9. data/lib/jirametrics/blocked_stalled_change.rb +5 -3
  10. data/lib/jirametrics/board.rb +28 -8
  11. data/lib/jirametrics/board_feature.rb +14 -0
  12. data/lib/jirametrics/board_movement_calculator.rb +2 -2
  13. data/lib/jirametrics/cfd_data_builder.rb +108 -0
  14. data/lib/jirametrics/change_item.rb +4 -3
  15. data/lib/jirametrics/chart_base.rb +107 -3
  16. data/lib/jirametrics/css_variable.rb +1 -1
  17. data/lib/jirametrics/cumulative_flow_diagram.rb +208 -0
  18. data/lib/jirametrics/{cycletime_config.rb → cycle_time_config.rb} +1 -2
  19. data/lib/jirametrics/cycletime_histogram.rb +15 -103
  20. data/lib/jirametrics/cycletime_scatterplot.rb +13 -98
  21. data/lib/jirametrics/daily_view.rb +38 -13
  22. data/lib/jirametrics/daily_wip_by_age_chart.rb +1 -1
  23. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +1 -1
  24. data/lib/jirametrics/daily_wip_by_parent_chart.rb +4 -2
  25. data/lib/jirametrics/daily_wip_chart.rb +29 -7
  26. data/lib/jirametrics/data_quality_report.rb +38 -12
  27. data/lib/jirametrics/dependency_chart.rb +2 -2
  28. data/lib/jirametrics/download_config.rb +15 -0
  29. data/lib/jirametrics/downloader.rb +87 -5
  30. data/lib/jirametrics/downloader_for_cloud.rb +107 -22
  31. data/lib/jirametrics/downloader_for_data_center.rb +3 -2
  32. data/lib/jirametrics/estimate_accuracy_chart.rb +42 -4
  33. data/lib/jirametrics/examples/aggregated_project.rb +2 -2
  34. data/lib/jirametrics/examples/standard_project.rb +32 -19
  35. data/lib/jirametrics/expedited_chart.rb +3 -1
  36. data/lib/jirametrics/exporter.rb +15 -2
  37. data/lib/jirametrics/file_config.rb +9 -11
  38. data/lib/jirametrics/file_system.rb +35 -2
  39. data/lib/jirametrics/flow_efficiency_scatterplot.rb +5 -1
  40. data/lib/jirametrics/github_gateway.rb +115 -0
  41. data/lib/jirametrics/groupable_issue_chart.rb +4 -0
  42. data/lib/jirametrics/grouping_rules.rb +26 -4
  43. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -4
  44. data/lib/jirametrics/html/aging_work_table.erb +3 -0
  45. data/lib/jirametrics/html/cumulative_flow_diagram.erb +503 -0
  46. data/lib/jirametrics/html/daily_wip_chart.erb +38 -5
  47. data/lib/jirametrics/html/estimate_accuracy_chart.erb +2 -12
  48. data/lib/jirametrics/html/expedited_chart.erb +3 -13
  49. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +2 -8
  50. data/lib/jirametrics/html/index.css +228 -60
  51. data/lib/jirametrics/html/index.erb +6 -0
  52. data/lib/jirametrics/html/index.js +53 -3
  53. data/lib/jirametrics/html/legacy_colors.css +174 -0
  54. data/lib/jirametrics/html/sprint_burndown.erb +7 -13
  55. data/lib/jirametrics/html/throughput_chart.erb +40 -9
  56. data/lib/jirametrics/html/{cycletime_histogram.erb → time_based_histogram.erb} +59 -59
  57. data/lib/jirametrics/html/{cycletime_scatterplot.erb → time_based_scatterplot.erb} +11 -7
  58. data/lib/jirametrics/html/wip_by_column_chart.erb +250 -0
  59. data/lib/jirametrics/html_generator.rb +2 -1
  60. data/lib/jirametrics/html_report_config.rb +45 -33
  61. data/lib/jirametrics/issue.rb +197 -99
  62. data/lib/jirametrics/issue_printer.rb +97 -0
  63. data/lib/jirametrics/jira_gateway.rb +32 -10
  64. data/lib/jirametrics/mcp_server.rb +531 -0
  65. data/lib/jirametrics/project_config.rb +87 -8
  66. data/lib/jirametrics/pull_request.rb +30 -0
  67. data/lib/jirametrics/pull_request_cycle_time_histogram.rb +77 -0
  68. data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +88 -0
  69. data/lib/jirametrics/pull_request_review.rb +13 -0
  70. data/lib/jirametrics/raw_javascript.rb +4 -0
  71. data/lib/jirametrics/settings.json +3 -1
  72. data/lib/jirametrics/sprint_burndown.rb +4 -2
  73. data/lib/jirametrics/status.rb +1 -1
  74. data/lib/jirametrics/stitcher.rb +7 -1
  75. data/lib/jirametrics/throughput_by_completed_resolution_chart.rb +22 -0
  76. data/lib/jirametrics/throughput_chart.rb +73 -23
  77. data/lib/jirametrics/time_based_histogram.rb +139 -0
  78. data/lib/jirametrics/time_based_scatterplot.rb +107 -0
  79. data/lib/jirametrics/wip_by_column_chart.rb +236 -0
  80. data/lib/jirametrics.rb +58 -0
  81. metadata +52 -5
@@ -20,33 +20,58 @@ class HtmlReportConfig < HtmlGenerator
20
20
  module_eval lines.join("\n"), __FILE__, __LINE__
21
21
  end
22
22
 
23
- define_chart name: 'aging_work_bar_chart', classname: 'AgingWorkBarChart'
24
- define_chart name: 'aging_work_table', classname: 'AgingWorkTable'
25
- define_chart name: 'cycletime_scatterplot', classname: 'CycletimeScatterplot'
26
- define_chart name: 'daily_wip_chart', classname: 'DailyWipChart'
27
- define_chart name: 'daily_wip_by_age_chart', classname: 'DailyWipByAgeChart'
28
- define_chart name: 'daily_wip_by_blocked_stalled_chart', classname: 'DailyWipByBlockedStalledChart'
29
- define_chart name: 'daily_wip_by_parent_chart', classname: 'DailyWipByParentChart'
30
- define_chart name: 'throughput_chart', classname: 'ThroughputChart'
31
- define_chart name: 'expedited_chart', classname: 'ExpeditedChart'
32
- define_chart name: 'cycletime_histogram', classname: 'CycletimeHistogram'
33
- define_chart name: 'estimate_accuracy_chart', classname: 'EstimateAccuracyChart'
34
- define_chart name: 'hierarchy_table', classname: 'HierarchyTable'
35
- define_chart name: 'flow_efficiency_scatterplot', classname: 'FlowEfficiencyScatterplot'
36
- define_chart name: 'daily_view', classname: 'DailyView'
37
-
38
23
  define_chart name: 'daily_wip_by_type', classname: 'DailyWipChart',
39
24
  deprecated_warning: 'This is the same as daily_wip_chart. Please use that one', deprecated_date: '2024-05-23'
40
25
  define_chart name: 'story_point_accuracy_chart', classname: 'EstimateAccuracyChart',
41
26
  deprecated_warning: 'Renamed to estimate_accuracy_chart. Please use that one', deprecated_date: '2024-05-23'
42
27
 
43
28
  def initialize file_config:, block:
29
+ super()
44
30
  @file_config = file_config
45
31
  @block = block
46
32
  @sections = [] # Where we store the chunks of text that will be assembled into the HTML
47
33
  @charts = [] # Where we store all the charts we executed so we can assert against them.
48
34
  end
49
35
 
36
+ def method_missing name, *_args, board_id: nil, **_kwargs, &block
37
+ class_name = name.to_s.split('_').map(&:capitalize).join
38
+ klass = resolve_chart_class(class_name)
39
+ return super if klass.nil?
40
+
41
+ block ||= ->(_) {}
42
+
43
+ if klass.instance_method(:board_id=).owner == klass
44
+ execute_chart_per_board klass: klass, block: block, board_id: board_id
45
+ else
46
+ execute_chart klass.new(block)
47
+ end
48
+ end
49
+
50
+ def resolve_chart_class class_name
51
+ klass = Object.const_get(class_name)
52
+ klass < ChartBase ? klass : nil
53
+ rescue NameError
54
+ nil
55
+ end
56
+
57
+ def execute_chart_per_board klass:, block:, board_id:
58
+ all_boards = @file_config.project_config.all_boards
59
+ ids = board_id ? [board_id] : issues.collect { |i| i.board.id }.uniq
60
+ ids = ids.sort_by { |id| all_boards[id]&.name || '' }
61
+ ids.each_with_index do |id, index|
62
+ execute_chart(klass.new(block)) do |chart|
63
+ chart.board_id = id
64
+ # We're showing the description only on the first one in order to reduce noise on the report
65
+ chart.description_text nil unless index.zero?
66
+ end
67
+ end
68
+ end
69
+
70
+ def respond_to_missing? name, include_private = false
71
+ class_name = name.to_s.split('_').map(&:capitalize).join
72
+ !resolve_chart_class(class_name).nil? || super
73
+ end
74
+
50
75
  def cycletime label = nil, &block
51
76
  @file_config.project_config.all_boards.each_value do |board|
52
77
  raise 'Multiple cycletimes not supported' if board.cycletime
@@ -73,7 +98,8 @@ class HtmlReportConfig < HtmlGenerator
73
98
 
74
99
  html create_footer
75
100
 
76
- create_html output_filename: @file_config.output_filename, settings: settings
101
+ create_html output_filename: @file_config.output_filename, settings: settings,
102
+ project_name: @file_config.project_config.name
77
103
  end
78
104
 
79
105
  def file_system
@@ -92,24 +118,9 @@ class HtmlReportConfig < HtmlGenerator
92
118
  @file_config.project_config.exporter.timezone_offset
93
119
  end
94
120
 
95
- def aging_work_in_progress_chart board_id: nil, &block
96
- block ||= ->(_) {}
97
-
98
- if board_id.nil?
99
- ids = issues.collect { |i| i.board.id }.uniq.sort
100
- else
101
- ids = [board_id]
102
- end
103
-
104
- ids.each do |id|
105
- execute_chart(AgingWorkInProgressChart.new(block)) do |chart|
106
- chart.board_id = id
107
- end
108
- end
109
- end
110
-
111
121
  def random_color
112
- "##{Random.bytes(3).unpack1('H*')}"
122
+ @palette_index = (@palette_index || -1) + 1
123
+ ChartBase::OKABE_ITO_PALETTE[@palette_index % ChartBase::OKABE_ITO_PALETTE.size]
113
124
  end
114
125
 
115
126
  def html string, type: :body
@@ -147,6 +158,7 @@ class HtmlReportConfig < HtmlGenerator
147
158
  chart.all_boards = project_config.all_boards
148
159
  chart.board_id = find_board_id
149
160
  chart.holiday_dates = project_config.exporter.holiday_dates
161
+ chart.fix_versions = project_config.fix_versions
150
162
 
151
163
  time_range = @file_config.project_config.time_range
152
164
  chart.date_range = time_range.begin.to_date..time_range.end.to_date
@@ -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
@@ -47,8 +48,8 @@ class Issue
47
48
  def type = @raw['fields']['issuetype']['name']
48
49
  def type_icon_url = @raw['fields']['issuetype']['iconUrl']
49
50
 
50
- def priority_name = @raw['fields']['priority']['name']
51
- 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')
52
53
 
53
54
  def summary = @raw['fields']['summary']
54
55
 
@@ -209,7 +210,38 @@ class Issue
209
210
  end
210
211
 
211
212
  def first_time_visible_on_board
212
- 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?
213
245
  end
214
246
 
215
247
  # If this issue will ever be in an active sprint then return the time that it
@@ -270,13 +302,19 @@ class Issue
270
302
  # First look in the actual sprints json. If any issues are in this sprint then it should
271
303
  # be here.
272
304
  sprint = board.sprints.find { |s| s.id == sprint_id }
273
- return [sprint.start_time, sprint.completed_time] if sprint
305
+ if sprint
306
+ return [nil, nil] if sprint.future?
307
+
308
+ return [sprint.start_time, sprint.completed_time]
309
+ end
274
310
 
275
311
  # Then look at the sprints inside the issue. Even though the field id may be specified,
276
312
  # that custom field may not be present. This happens if it was in that sprint but was
277
313
  # then removed, whether or not that sprint had ever started.
278
314
  sprint_data = raw['fields'][change.field_id]&.find { |sd| sd['id'].to_i == sprint_id }
279
315
  if sprint_data
316
+ return [nil, nil] if sprint_data['state'] == 'future'
317
+
280
318
  start = parse_time(sprint_data['startDate'])
281
319
  stop = parse_time(sprint_data['completeDate'])
282
320
  return [start, stop]
@@ -388,21 +426,11 @@ class Issue
388
426
  results
389
427
  end
390
428
 
391
- def blocked_stalled_statuses settings
392
- blocked_statuses = settings['blocked_statuses']
393
- stalled_statuses = settings['stalled_statuses']
394
- unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
395
- raise "blocked_statuses(#{blocked_statuses.inspect}) and " \
396
- "stalled_statuses(#{stalled_statuses.inspect}) must both be arrays"
397
- end
398
-
399
- [blocked_statuses, stalled_statuses]
400
- end
401
-
402
429
  def blocked_stalled_changes end_time:, settings: nil
403
430
  settings ||= @board.project_config.settings
404
431
 
405
- blocked_statuses, stalled_statuses = blocked_stalled_statuses(settings)
432
+ blocked_statuses = settings['blocked_statuses']
433
+ stalled_statuses = settings['stalled_statuses']
406
434
 
407
435
  blocked_link_texts = settings['blocked_link_text']
408
436
  stalled_threshold = settings['stalled_threshold_days']
@@ -415,7 +443,9 @@ class Issue
415
443
  previous_change_time = created
416
444
 
417
445
  blocking_status = nil
446
+ blocking_is_blocked = false
418
447
  flag = nil
448
+ flag_reason = nil
419
449
 
420
450
  # This mock change is to force the writing of one last entry at the end of the time range.
421
451
  # By doing this, we're able to eliminate a lot of duplicated code in charts.
@@ -430,11 +460,14 @@ class Issue
430
460
  )
431
461
 
432
462
  if change.flagged? && flagged_means_blocked
433
- flag = change.value
434
- flag = nil if change.value == ''
463
+ flag, flag_reason = blocked_stalled_changes_flag_logic change
435
464
  elsif change.status?
436
465
  blocking_status = nil
437
- 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)
438
471
  blocking_status = change.value
439
472
  end
440
473
  elsif change.link?
@@ -455,8 +488,9 @@ class Issue
455
488
 
456
489
  new_change = BlockedStalledChange.new(
457
490
  flagged: flag,
491
+ flag_reason: flag_reason,
458
492
  status: blocking_status,
459
- status_is_blocking: blocking_status.nil? || blocked_statuses.include?(blocking_status),
493
+ status_is_blocking: blocking_status.nil? || blocking_is_blocked,
460
494
  blocking_issue_keys: (blocking_issue_keys.empty? ? nil : blocking_issue_keys.dup),
461
495
  time: change.time
462
496
  )
@@ -475,6 +509,7 @@ class Issue
475
509
  hack = result.pop
476
510
  result << BlockedStalledChange.new(
477
511
  flagged: hack.flag,
512
+ flag_reason: hack.flag_reason,
478
513
  status: hack.status,
479
514
  status_is_blocking: hack.status_is_blocking,
480
515
  blocking_issue_keys: hack.blocking_issue_keys,
@@ -486,6 +521,28 @@ class Issue
486
521
  result
487
522
  end
488
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
+
489
546
  def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
490
547
  stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
491
548
 
@@ -521,7 +578,7 @@ class Issue
521
578
  # return [number of active seconds, total seconds] that this issue had up to the end_time.
522
579
  # It does not include data before issue start or after issue end
523
580
  def flow_efficiency_numbers end_time:, settings: @board.project_config.settings
524
- issue_start, issue_stop = @board.cycletime.started_stopped_times(self)
581
+ issue_start, issue_stop = started_stopped_times
525
582
  return [0.0, 0.0] if !issue_start || issue_start > end_time
526
583
 
527
584
  value_add_time = 0.0
@@ -701,75 +758,7 @@ class Issue
701
758
  end
702
759
 
703
760
  def dump
704
- result = +''
705
- result << "#{key} (#{type}): #{compact_text summary, max: 200}\n"
706
-
707
- assignee = raw['fields']['assignee']
708
- result << " [assignee] #{assignee['name'].inspect} <#{assignee['emailAddress']}>\n" unless assignee.nil?
709
-
710
- raw['fields']['issuelinks']&.each do |link|
711
- result << " [link] #{link['type']['outward']} #{link['outwardIssue']['key']}\n" if link['outwardIssue']
712
- result << " [link] #{link['type']['inward']} #{link['inwardIssue']['key']}\n" if link['inwardIssue']
713
- end
714
- history = [] # time, type, detail
715
-
716
- if board.cycletime
717
- started_at, stopped_at = board.cycletime.started_stopped_times(self)
718
- history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
719
- history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
720
- else
721
- result << " Unable to determine start/end times as board #{board.id} has no cycletime specified\n"
722
- end
723
-
724
- @discarded_change_times&.each do |time|
725
- history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
726
- end
727
-
728
- (changes + (@discarded_changes || [])).each do |change|
729
- if change.status?
730
- value = "#{change.value.inspect}:#{change.value_id.inspect}"
731
- old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
732
- else
733
- value = compact_text(change.value).inspect
734
- old_value = change.old_value ? compact_text(change.old_value).inspect : nil
735
- end
736
-
737
- message = +''
738
- message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
739
- message << value
740
- if change.artificial?
741
- message << ' (Artificial entry)' if change.artificial?
742
- else
743
- message << " (Author: #{change.author})"
744
- end
745
- history << [change.time, change.field, message, change.artificial?]
746
- end
747
-
748
- result << " History:\n"
749
- type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
750
- history.sort! do |a, b|
751
- if a[0] == b[0]
752
- if a[1].nil?
753
- 1
754
- elsif b[1].nil?
755
- -1
756
- else
757
- a[1] <=> b[1]
758
- end
759
- else
760
- a[0] <=> b[0]
761
- end
762
- end
763
- history.each do |time, type, detail, _artificial|
764
- if type.nil?
765
- type = '-' * type_width
766
- else
767
- type = (' ' * (type_width - type.length)) << type
768
- end
769
- result << " #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
770
- end
771
-
772
- result
761
+ IssuePrinter.new(self).to_s
773
762
  end
774
763
 
775
764
  def done?
@@ -782,10 +771,36 @@ class Issue
782
771
  end
783
772
  end
784
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
+
785
782
  def status_changes
786
783
  @changes.select { |change| change.status? }
787
784
  end
788
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
+
789
804
  def sprints
790
805
  sprint_ids = []
791
806
 
@@ -806,23 +821,106 @@ class Issue
806
821
  def compact_text text, max: 60
807
822
  return '' if text.nil?
808
823
 
809
- if text.is_a? Hash
810
- # We can't effectively compact it but we can convert it into a string.
811
- text = @board.project_config.atlassian_document_format.to_html(text)
812
- else
813
- text = text.gsub(/\s+/, ' ').strip
814
- text = "#{text[0...max]}..." if text.length > max
815
- end
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
816
831
  text
817
832
  end
818
833
 
819
834
  private
820
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
+
821
902
  def load_history_into_changes
822
903
  @raw['changelog']['histories']&.each do |history|
823
904
  created = parse_time(history['created'])
824
905
 
825
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
+
826
924
  @changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
827
925
  end
828
926
  end
@@ -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