jirametrics 2.0 → 2.11

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 (68) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +19 -26
  3. data/lib/jirametrics/aging_work_bar_chart.rb +79 -54
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +106 -40
  5. data/lib/jirametrics/aging_work_table.rb +78 -43
  6. data/lib/jirametrics/anonymizer.rb +6 -5
  7. data/lib/jirametrics/blocked_stalled_change.rb +24 -4
  8. data/lib/jirametrics/board.rb +44 -15
  9. data/lib/jirametrics/board_config.rb +8 -4
  10. data/lib/jirametrics/board_movement_calculator.rb +147 -0
  11. data/lib/jirametrics/change_item.rb +31 -10
  12. data/lib/jirametrics/chart_base.rb +102 -61
  13. data/lib/jirametrics/columns_config.rb +4 -0
  14. data/lib/jirametrics/css_variable.rb +33 -0
  15. data/lib/jirametrics/cycletime_config.rb +59 -8
  16. data/lib/jirametrics/cycletime_histogram.rb +69 -4
  17. data/lib/jirametrics/cycletime_scatterplot.rb +11 -15
  18. data/lib/jirametrics/daily_wip_by_age_chart.rb +44 -20
  19. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +37 -35
  20. data/lib/jirametrics/daily_wip_by_parent_chart.rb +38 -0
  21. data/lib/jirametrics/daily_wip_chart.rb +61 -14
  22. data/lib/jirametrics/data_quality_report.rb +222 -41
  23. data/lib/jirametrics/dependency_chart.rb +54 -23
  24. data/lib/jirametrics/download_config.rb +12 -0
  25. data/lib/jirametrics/downloader.rb +76 -57
  26. data/lib/jirametrics/{story_point_accuracy_chart.rb → estimate_accuracy_chart.rb} +48 -33
  27. data/lib/jirametrics/examples/aggregated_project.rb +22 -39
  28. data/lib/jirametrics/examples/standard_project.rb +25 -49
  29. data/lib/jirametrics/expedited_chart.rb +28 -25
  30. data/lib/jirametrics/exporter.rb +59 -32
  31. data/lib/jirametrics/file_config.rb +34 -13
  32. data/lib/jirametrics/file_system.rb +48 -3
  33. data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
  34. data/lib/jirametrics/groupable_issue_chart.rb +2 -6
  35. data/lib/jirametrics/grouping_rules.rb +7 -1
  36. data/lib/jirametrics/hierarchy_table.rb +4 -4
  37. data/lib/jirametrics/html/aging_work_bar_chart.erb +13 -16
  38. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +28 -5
  39. data/lib/jirametrics/html/aging_work_table.erb +19 -25
  40. data/lib/jirametrics/html/cycletime_histogram.erb +83 -3
  41. data/lib/jirametrics/html/cycletime_scatterplot.erb +9 -12
  42. data/lib/jirametrics/html/daily_wip_chart.erb +17 -13
  43. data/lib/jirametrics/html/{story_point_accuracy_chart.erb → estimate_accuracy_chart.erb} +9 -4
  44. data/lib/jirametrics/html/expedited_chart.erb +10 -13
  45. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
  46. data/lib/jirametrics/html/hierarchy_table.erb +2 -2
  47. data/lib/jirametrics/html/index.css +209 -0
  48. data/lib/jirametrics/html/index.erb +16 -39
  49. data/lib/jirametrics/html/sprint_burndown.erb +10 -14
  50. data/lib/jirametrics/html/throughput_chart.erb +10 -13
  51. data/lib/jirametrics/html_report_config.rb +108 -86
  52. data/lib/jirametrics/issue.rb +357 -96
  53. data/lib/jirametrics/jira_gateway.rb +29 -11
  54. data/lib/jirametrics/project_config.rb +256 -144
  55. data/lib/jirametrics/rules.rb +2 -2
  56. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  57. data/lib/jirametrics/settings.json +10 -0
  58. data/lib/jirametrics/sprint_burndown.rb +24 -7
  59. data/lib/jirametrics/status.rb +84 -19
  60. data/lib/jirametrics/status_collection.rb +80 -39
  61. data/lib/jirametrics/throughput_chart.rb +12 -4
  62. data/lib/jirametrics/value_equality.rb +2 -2
  63. data/lib/jirametrics.rb +25 -7
  64. metadata +16 -17
  65. data/lib/jirametrics/discard_changes_before.rb +0 -37
  66. data/lib/jirametrics/experimental/generator.rb +0 -210
  67. data/lib/jirametrics/experimental/info.rb +0 -77
  68. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -3,39 +3,62 @@
3
3
  require 'jirametrics/chart_base'
4
4
 
5
5
  class AgingWorkTable < ChartBase
6
- attr_accessor :today, :board_id
6
+ attr_accessor :today
7
+ attr_reader :any_scrum_boards
7
8
 
8
9
  def initialize block
9
10
  super()
10
- @blocked_icon = '🛑'
11
- @expedited_icon = '🔥'
12
- @stalled_icon = '🟧'
13
11
  @stalled_threshold = 5
14
- @dead_icon = '⬛'
15
12
  @dead_threshold = 45
16
13
  @age_cutoff = 0
17
14
 
18
- instance_eval(&block) if block
15
+ header_text 'Aging Work Table'
16
+ description_text <<-TEXT
17
+ <p>
18
+ This chart shows all active (started but not completed) work, ordered from oldest at the top to
19
+ newest at the bottom.
20
+ </p>
21
+ <p>
22
+ If there are expedited items that haven't yet started then they're at the bottom of the table.
23
+ By the very definition of expedited, if we haven't started them already, we'd better get on that.
24
+ </p>
25
+ <p>
26
+ Legend:
27
+ <ul>
28
+ <li><b>E:</b> Whether this item is <b>E</b>xpedited.</li>
29
+ <li><b>B/S:</b> Whether this item is either <b>B</b>locked or <b>S</b>talled.</li>
30
+ <li><b>Forecast:</b> A forecast of how long it is likely to take to finish this work item.</li>
31
+ </ul>
32
+ </p>
33
+ TEXT
34
+
35
+ instance_eval(&block)
19
36
  end
20
37
 
21
38
  def run
22
- @today = date_range.end
23
- aging_issues = select_aging_issues
39
+ initialize_calculator
40
+ aging_issues = select_aging_issues + expedited_but_not_started
24
41
 
25
- expedited_but_not_started = @issues.select do |issue|
26
- cycletime = issue.board.cycletime
27
- cycletime.started_time(issue).nil? && cycletime.stopped_time(issue).nil? && issue.expedited?
28
- end
29
- aging_issues += expedited_but_not_started.sort_by(&:created)
42
+ wrap_and_render(binding, __FILE__)
43
+ end
44
+
45
+ # This is its own method simply so the tests can initialize the calculator without doing a full run.
46
+ def initialize_calculator
47
+ @today = date_range.end
48
+ @calculator = BoardMovementCalculator.new board: current_board, issues: issues, today: @today
49
+ end
30
50
 
31
- render(binding, __FILE__)
51
+ def expedited_but_not_started
52
+ @issues.select do |issue|
53
+ started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
54
+ started_time.nil? && stopped_time.nil? && issue.expedited?
55
+ end.sort_by(&:created)
32
56
  end
33
57
 
34
58
  def select_aging_issues
35
59
  aging_issues = @issues.select do |issue|
36
60
  cycletime = issue.board.cycletime
37
- started = cycletime.started_time(issue)
38
- stopped = cycletime.stopped_time(issue)
61
+ started, stopped = cycletime.started_stopped_times(issue)
39
62
  next false if started.nil? || stopped
40
63
  next true if issue.blocked_on_date?(@today, end_time: time_range.end) || issue.expedited?
41
64
 
@@ -46,47 +69,33 @@ class AgingWorkTable < ChartBase
46
69
  aging_issues.sort { |a, b| b.board.cycletime.age(b, today: @today) <=> a.board.cycletime.age(a, today: @today) }
47
70
  end
48
71
 
49
- def icon_span title:, icon:
50
- "<span title='#{title}' style='font-size: 0.8em;'>#{icon}</span>"
51
- end
52
-
53
72
  def expedited_text issue
54
73
  return unless issue.expedited?
55
74
 
56
75
  name = issue.raw['fields']['priority']['name']
57
- icon_span(title: "Expedited: Has a priority of &quot;#{name}&quot;", icon: @expedited_icon)
76
+ color_block '--expedited-color', title: "Expedited: Has a priority of &quot;#{name}&quot;"
58
77
  end
59
78
 
60
79
  def blocked_text issue
61
- started_time = issue.board.cycletime.started_time(issue)
80
+ started_time, _stopped_time = issue.board.cycletime.started_stopped_times(issue)
62
81
  return nil if started_time.nil?
63
82
 
64
83
  current = issue.blocked_stalled_changes(end_time: time_range.end)[-1]
65
84
  if current.blocked?
66
- icon_span title: current.reasons, icon: @blocked_icon
85
+ color_block '--blocked-color', title: current.reasons
67
86
  elsif current.stalled?
68
87
  if current.stalled_days && current.stalled_days > @dead_threshold
69
- icon_span(
88
+ color_block(
89
+ '--dead-color',
70
90
  title: "Dead? Hasn&apos;t had any activity in #{label_days current.stalled_days}. " \
71
- 'Does anyone still care about this?',
72
- icon: @dead_icon
91
+ 'Does anyone still care about this?'
73
92
  )
74
93
  else
75
- icon_span(
76
- title: current.reasons,
77
- icon: @stalled_icon
78
- )
94
+ color_block '--stalled-color', title: current.reasons
79
95
  end
80
96
  end
81
97
  end
82
98
 
83
- def unmapped_status_text issue
84
- icon_span(
85
- title: "The status #{issue.status.name.inspect} is not mapped to any column and will not be visible",
86
- icon: ' ⁉️'
87
- )
88
- end
89
-
90
99
  def fix_versions_text issue
91
100
  issue.fix_versions.collect do |fix|
92
101
  if fix.released?
@@ -119,8 +128,38 @@ class AgingWorkTable < ChartBase
119
128
  end.join('<br />')
120
129
  end
121
130
 
122
- def current_status_visible? issue
123
- issue.board.visible_columns.any? { |column| column.status_ids.include? issue.status.id }
131
+ def dates_text issue
132
+ date = date_range.end
133
+ due = issue.due_date
134
+ message = nil
135
+
136
+ days_remaining, error = @calculator.forecasted_days_remaining_and_message issue: issue, today: @today
137
+
138
+ unless error
139
+ if due
140
+ if due < date
141
+ message = "Due: <b>#{due}</b> (#{label_days (@today - due).to_i} ago)"
142
+ error = 'Overdue'
143
+ elsif due == date
144
+ message = 'Due: <b>today</b>'
145
+ else
146
+ error = 'Due date at risk' if date_range.end + days_remaining > due
147
+ message = "Due: <b>#{due}</b> (#{label_days (due - @today).to_i})"
148
+ end
149
+ else
150
+ "#{label_days days_remaining} left."
151
+ end
152
+ end
153
+
154
+ text = +''
155
+ text << "<span title='#{error}' style='color: red'>ⓘ </span>" if error
156
+ if days_remaining
157
+ text << "#{label_days days_remaining} left"
158
+ else
159
+ text << 'Unable to forecast'
160
+ end
161
+ text << ' | ' << message if message
162
+ text
124
163
  end
125
164
 
126
165
  def age_cutoff age = nil
@@ -128,10 +167,6 @@ class AgingWorkTable < ChartBase
128
167
  @age_cutoff
129
168
  end
130
169
 
131
- def any_scrum_boards?
132
- @any_scrum_boards
133
- end
134
-
135
170
  def parent_hierarchy issue
136
171
  result = []
137
172
 
@@ -12,6 +12,7 @@ class Anonymizer
12
12
  @all_boards = @project_config.all_boards
13
13
  @possible_statuses = @project_config.possible_statuses
14
14
  @date_adjustment = date_adjustment
15
+ @file_system = project_config.exporter.file_system
15
16
  end
16
17
 
17
18
  def run
@@ -20,7 +21,7 @@ class Anonymizer
20
21
  # anonymize_issue_statuses
21
22
  anonymize_board_names
22
23
  shift_all_dates unless @date_adjustment.zero?
23
- puts 'Anonymize done'
24
+ @file_system.log 'Anonymize done'
24
25
  end
25
26
 
26
27
  def random_phrase
@@ -30,7 +31,7 @@ class Anonymizer
30
31
  5.times do |i|
31
32
  return RandomWord.phrases.next.tr('_', ' ')
32
33
  rescue # rubocop:disable Style/RescueStandardError We don't care what exception was thrown.
33
- puts "Random word blew up on attempt #{i + 1}"
34
+ @file_system.log "Random word blew up on attempt #{i + 1}"
34
35
  end
35
36
  end
36
37
 
@@ -55,7 +56,7 @@ class Anonymizer
55
56
 
56
57
  def anonymize_column_names
57
58
  @all_boards.each_key do |board_id|
58
- puts "Anonymizing column names for board #{board_id}"
59
+ @file_system.log "Anonymizing column names for board #{board_id}"
59
60
 
60
61
  column_name = 'Column-A'
61
62
  @all_boards[board_id].visible_columns.each do |column|
@@ -93,7 +94,7 @@ class Anonymizer
93
94
  end
94
95
 
95
96
  def anonymize_issue_statuses
96
- puts 'Anonymizing issue statuses and status categories'
97
+ @file_system.log 'Anonymizing issue statuses and status categories'
97
98
  status_name_hash = build_status_name_hash
98
99
 
99
100
  @issues.each do |issue|
@@ -130,7 +131,7 @@ class Anonymizer
130
131
  end
131
132
 
132
133
  def shift_all_dates
133
- puts "Shifting all dates by #{@date_adjustment} days"
134
+ @file_system.log "Shifting all dates by #{@date_adjustment} days"
134
135
  @issues.each do |issue|
135
136
  issue.changes.each do |change|
136
137
  change.time = change.time + @date_adjustment
@@ -15,12 +15,12 @@ class BlockedStalledChange
15
15
  @time = time
16
16
  end
17
17
 
18
- def blocked? = @flag || blocked_by_status? || @blocking_issue_keys
19
- def stalled? = @stalled_days || stalled_by_status?
18
+ def blocked? = !!(@flag || blocked_by_status? || @blocking_issue_keys)
19
+ def stalled? = !!(@stalled_days || stalled_by_status?)
20
20
  def active? = !blocked? && !stalled?
21
21
 
22
- def blocked_by_status? = @status && @status_is_blocking
23
- def stalled_by_status? = @status && !@status_is_blocking
22
+ def blocked_by_status? = !!(@status && @status_is_blocking)
23
+ def stalled_by_status? = !!(@status && !@status_is_blocking)
24
24
 
25
25
  def reasons
26
26
  result = []
@@ -35,4 +35,24 @@ class BlockedStalledChange
35
35
  end
36
36
  result.join(', ')
37
37
  end
38
+
39
+ def as_symbol
40
+ if blocked?
41
+ :blocked
42
+ elsif stalled?
43
+ :stalled
44
+ else
45
+ :active
46
+ end
47
+ end
48
+
49
+ def inspect
50
+ text = "BlockedStalledChange(time: '#{@time}', "
51
+ if active?
52
+ text << 'Active'
53
+ else
54
+ text << reasons
55
+ end
56
+ text << ')'
57
+ end
38
58
  end
@@ -1,39 +1,40 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Board
4
- attr_reader :visible_columns, :raw, :possible_statuses, :sprints, :backlog_statuses
5
- attr_accessor :cycletime, :project_config, :expedited_priority_names
4
+ attr_reader :visible_columns, :raw, :possible_statuses, :sprints, :board_type
5
+ attr_accessor :cycletime, :project_config
6
6
 
7
- def initialize raw:, possible_statuses: StatusCollection.new
7
+ def initialize raw:, possible_statuses:
8
8
  @raw = raw
9
9
  @board_type = raw['type']
10
10
  @possible_statuses = possible_statuses
11
11
  @sprints = []
12
- @expedited_priority_names = []
13
12
 
14
13
  columns = raw['columnConfig']['columns']
14
+ ensure_uniqueness_of_column_names! columns
15
15
 
16
16
  # For a Kanban board, the first column here will always be called 'Backlog' and will NOT be
17
17
  # visible on the board. If the board is configured to have a kanban backlog then it will have
18
18
  # statuses matched to it and otherwise, there will be no statuses.
19
- if kanban?
20
- @backlog_statuses = @possible_statuses.expand_statuses(status_ids_from_column columns[0]) do |unknown_status|
21
- # There is a status defined as being 'backlog' that is no longer being returned in statuses.
22
- # We used to display a warning for this but honestly, there is nothing that anyone can do about it
23
- # so now we just quietly ignore it.
24
- end
25
- columns = columns[1..]
26
- else
27
- # We currently don't know how to get the backlog status for a Scrum board
28
- @backlog_statuses = []
29
- end
19
+ columns = columns.drop(1) if kanban?
30
20
 
21
+ @backlog_statuses = []
31
22
  @visible_columns = columns.filter_map do |column|
32
23
  # It's possible for a column to be defined without any statuses and in this case, it won't be visible.
33
24
  BoardColumn.new column unless status_ids_from_column(column).empty?
34
25
  end
35
26
  end
36
27
 
28
+ def backlog_statuses
29
+ if @backlog_statuses.empty? && kanban?
30
+ status_ids = status_ids_from_column raw['columnConfig']['columns'].first
31
+ @backlog_statuses = status_ids.filter_map do |id|
32
+ @possible_statuses.find_by_id id
33
+ end
34
+ end
35
+ @backlog_statuses
36
+ end
37
+
37
38
  def server_url_prefix
38
39
  raise "Cannot parse self: #{@raw['self'].inspect}" unless @raw['self'] =~ /^(https?:\/\/.+)\/rest\//
39
40
 
@@ -88,4 +89,32 @@ class Board
88
89
  def name
89
90
  @raw['name']
90
91
  end
92
+
93
+ def accumulated_status_ids_per_column
94
+ accumulated_status_ids = []
95
+ visible_columns.reverse.filter_map do |column|
96
+ next if column == @fake_column
97
+
98
+ accumulated_status_ids += column.status_ids
99
+ [column.name, accumulated_status_ids.dup]
100
+ end.reverse
101
+ end
102
+
103
+ def ensure_uniqueness_of_column_names! json
104
+ all_names = []
105
+ json.each do |column_json|
106
+ name = column_json['name']
107
+ if all_names.include? name
108
+ (2..).each do |i|
109
+ new_name = "#{name}-#{i}"
110
+ next if all_names.include?(new_name)
111
+
112
+ name = new_name
113
+ column_json['name'] = new_name
114
+ break
115
+ end
116
+ end
117
+ all_names << name
118
+ end
119
+ end
91
120
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class BoardConfig
4
- attr_reader :id, :project_config
4
+ attr_reader :id, :project_config, :board
5
5
 
6
6
  def initialize id:, block:, project_config:
7
7
  @id = id
@@ -11,7 +11,6 @@ class BoardConfig
11
11
 
12
12
  def run
13
13
  @board = @project_config.all_boards[id]
14
- @board.expedited_priority_names = []
15
14
 
16
15
  instance_eval(&@block)
17
16
  end
@@ -22,10 +21,15 @@ class BoardConfig
22
21
  'If so, remove it from there.'
23
22
  end
24
23
 
25
- @board.cycletime = CycleTimeConfig.new(parent_config: self, label: label, block: block)
24
+ @board.cycletime = CycleTimeConfig.new(
25
+ parent_config: self, label: label, block: block, file_system: project_config.file_system
26
+ )
26
27
  end
27
28
 
28
29
  def expedited_priority_names *priority_names
29
- @board.expedited_priority_names = priority_names unless priority_names.empty?
30
+ project_config.exporter.file_system.deprecated(
31
+ date: '2024-09-15', message: 'Expedited priority names are now specified in settings'
32
+ )
33
+ @project_config.settings['expedited_priority_names'] = priority_names
30
34
  end
31
35
  end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ class BoardMovementCalculator
4
+ attr_reader :board, :issues, :today
5
+
6
+ def initialize board:, issues:, today:
7
+ @board = board
8
+ @issues = issues.select { |issue| issue.board == board && issue.done? && !moves_backwards?(issue) }
9
+ @today = today
10
+ end
11
+
12
+ def moves_backwards? issue
13
+ started, stopped = issue.board.cycletime.started_stopped_times(issue)
14
+ return false unless started
15
+
16
+ previous_column = nil
17
+ issue.status_changes.each do |change|
18
+ column = board.visible_columns.index { |c| c.status_ids.include?(change.value_id) }
19
+ next if change.time < started
20
+ next if column.nil? # It disappeared from the board for a bit
21
+ return true if previous_column && column && column < previous_column
22
+
23
+ previous_column = column
24
+ end
25
+ false
26
+ end
27
+
28
+ def stacked_age_data_for percentages:
29
+ data_list = percentages.sort.collect do |percentage|
30
+ [percentage, age_data_for(percentage: percentage)]
31
+ end
32
+
33
+ stack_data data_list
34
+ end
35
+
36
+ def stack_data data_list
37
+ remainder = nil
38
+ data_list.collect do |percentage, data|
39
+ unless remainder.nil?
40
+ data = (0...data.length).collect do |i|
41
+ data[i] - remainder[i]
42
+ end
43
+
44
+ end
45
+ remainder = data
46
+
47
+ [percentage, data]
48
+ end
49
+ end
50
+
51
+ def age_data_for percentage:
52
+ data = []
53
+ board.visible_columns.each_with_index do |_column, column_index|
54
+ ages = ages_of_issues_when_leaving_column column_index: column_index, today: today
55
+
56
+ if ages.empty?
57
+ data << 0
58
+ else
59
+ index = ((ages.size - 1) * percentage / 100).to_i
60
+ data << ages[index]
61
+ end
62
+ end
63
+ data
64
+ end
65
+
66
+ def ages_of_issues_when_leaving_column column_index:, today:
67
+ this_column = board.visible_columns[column_index]
68
+ next_column = board.visible_columns[column_index + 1]
69
+
70
+ @issues.filter_map do |issue|
71
+ this_column_start = issue.first_time_in_or_right_of_column(this_column.name)&.time
72
+ next_column_start = next_column.nil? ? nil : issue.first_time_in_or_right_of_column(next_column.name)&.time
73
+ issue_start, issue_done = issue.board.cycletime.started_stopped_times(issue)
74
+
75
+ # Skip if we can't tell when it started.
76
+ next if issue_start.nil?
77
+
78
+ # Skip if it never entered this column
79
+ next if this_column_start.nil?
80
+
81
+ # Skip if it left this column before the item is considered started.
82
+ next 0 if next_column_start && next_column_start <= issue_start
83
+
84
+ # Skip if it was already done by the time it got to this column or it became done when it got to this column
85
+ next if issue_done && issue_done <= this_column_start
86
+
87
+ end_date = case # rubocop:disable Style/EmptyCaseCondition
88
+ when next_column_start.nil?
89
+ # If this is the last column then base age against today
90
+ today
91
+ when issue_done && issue_done < next_column_start
92
+ # it completed while in this column
93
+ issue_done.to_date
94
+ else
95
+ # It passed through this whole column
96
+ next_column_start.to_date
97
+ end
98
+ (end_date - issue_start.to_date).to_i + 1
99
+ end.sort
100
+ end
101
+
102
+ # Figure out what column this is issue is currently in and what time it entered that column. We need this for
103
+ # aging and forecasting purposes
104
+ def find_current_column_and_entry_time_in_column issue
105
+ column = board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
106
+ return [] if column.nil? # This issue isn't visible on the board
107
+
108
+ status_ids = column.status_ids
109
+
110
+ entry_at = issue.changes.reverse.find { |change| change.status? && status_ids.include?(change.value_id) }&.time
111
+
112
+ [column.name, entry_at]
113
+ end
114
+
115
+ def label_days days
116
+ "#{days} day#{'s' unless days == 1}"
117
+ end
118
+
119
+ def forecasted_days_remaining_and_message issue:, today:
120
+ return [nil, 'Already done'] if issue.done?
121
+
122
+ likely_age_data = age_data_for percentage: 85
123
+
124
+ column_name, entry_time = find_current_column_and_entry_time_in_column issue
125
+ return [nil, 'This issue is not visible on the board. No way to predict when it will be done.'] if column_name.nil?
126
+
127
+ age_in_column = (today - entry_time.to_date).to_i + 1
128
+
129
+ message = nil
130
+ column_index = board.visible_columns.index { |c| c.name == column_name }
131
+
132
+ last_non_zero_datapoint = likely_age_data.reverse.find { |d| !d.zero? }
133
+ return [nil, 'There is no historical data for this board. No forecast can be made.'] if last_non_zero_datapoint.nil?
134
+
135
+ remaining_in_current_column = likely_age_data[column_index] - age_in_column
136
+ if remaining_in_current_column.negative?
137
+ message = "This item is an outlier at #{label_days issue.board.cycletime.age(issue, today: today)} " \
138
+ "in the #{column_name.inspect} column. Most items on this board have left this column in " \
139
+ "#{label_days likely_age_data[column_index]} or less, so we cannot forecast when it will be done."
140
+ remaining_in_current_column = 0
141
+ return [nil, message]
142
+ end
143
+
144
+ forecasted_days = last_non_zero_datapoint - likely_age_data[column_index] + remaining_in_current_column
145
+ [forecasted_days, message]
146
+ end
147
+ end
@@ -1,17 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class ChangeItem
4
- attr_reader :field, :value_id, :old_value_id, :raw, :author
5
- attr_accessor :value, :old_value, :time
4
+ attr_reader :field, :value_id, :old_value_id, :raw, :author, :time
5
+ attr_accessor :value, :old_value
6
6
 
7
7
  def initialize raw:, time:, author:, artificial: false
8
- # raw will only ever be nil in a test and in that case field and value should be passed in
9
8
  @raw = raw
10
9
  @time = time
11
- raise "Time must be an object of type Time in the correct timezone: #{@time}" if @time.is_a? String
10
+ raise 'ChangeItem.new() time cannot be nil' if time.nil?
11
+ raise "Time must be an object of type Time in the correct timezone: #{@time.inspect}" unless @time.is_a? Time
12
12
 
13
- @field = field || @raw['field']
14
- @value = value || @raw['toString']
13
+ @field = @raw['field']
14
+ @value = @raw['toString']
15
15
  @value_id = @raw['to'].to_i
16
16
  @old_value = @raw['fromString']
17
17
  @old_value_id = @raw['from']&.to_i
@@ -35,17 +35,30 @@ class ChangeItem
35
35
 
36
36
  def link? = (field == 'Link')
37
37
 
38
+ def labels? = (field == 'labels')
39
+
40
+ # An alias for time so that logic accepting a Time, Date, or ChangeItem can all respond to :to_time
41
+ def to_time = @time
42
+
38
43
  def to_s
39
- message = "ChangeItem(field: #{field.inspect}, value: #{value.inspect}, time: \"#{@time}\""
40
- message += ', artificial' if artificial?
41
- message += ')'
44
+ message = +''
45
+ message << "ChangeItem(field: #{field.inspect}"
46
+ message << ", value: #{value.inspect}"
47
+ message << ':' << value_id.inspect if status?
48
+ if old_value
49
+ message << ", old_value: #{old_value.inspect}"
50
+ message << ':' << old_value_id.inspect if status?
51
+ end
52
+ message << ", time: #{time_to_s(@time).inspect}"
53
+ message << ', artificial' if artificial?
54
+ message << ')'
42
55
  message
43
56
  end
44
57
 
45
58
  def inspect = to_s
46
59
 
47
60
  def == other
48
- field.eql?(other.field) && value.eql?(other.value) && time.to_s.eql?(other.time.to_s)
61
+ field.eql?(other.field) && value.eql?(other.value) && time_to_s(time).eql?(time_to_s(other.time))
49
62
  end
50
63
 
51
64
  def current_status_matches *status_names_or_ids
@@ -77,4 +90,12 @@ class ChangeItem
77
90
  end
78
91
  end
79
92
  end
93
+
94
+ private
95
+
96
+ def time_to_s time
97
+ # MRI and JRuby return different strings for to_s() so we have to explicitly provide a full
98
+ # format so that tests work under both environments.
99
+ time.strftime '%Y-%m-%d %H:%M:%S %z'
100
+ end
80
101
  end