jirametrics 2.7 → 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 (49) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +4 -4
  3. data/lib/jirametrics/aging_work_bar_chart.rb +7 -5
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
  5. data/lib/jirametrics/aging_work_table.rb +50 -2
  6. data/lib/jirametrics/board.rb +33 -5
  7. data/lib/jirametrics/board_config.rb +6 -2
  8. data/lib/jirametrics/board_movement_calculator.rb +147 -0
  9. data/lib/jirametrics/change_item.rb +19 -6
  10. data/lib/jirametrics/chart_base.rb +59 -21
  11. data/lib/jirametrics/css_variable.rb +1 -1
  12. data/lib/jirametrics/cycletime_config.rb +37 -5
  13. data/lib/jirametrics/cycletime_histogram.rb +67 -2
  14. data/lib/jirametrics/data_quality_report.rb +174 -35
  15. data/lib/jirametrics/download_config.rb +2 -2
  16. data/lib/jirametrics/downloader.rb +44 -25
  17. data/lib/jirametrics/examples/aggregated_project.rb +2 -5
  18. data/lib/jirametrics/examples/standard_project.rb +4 -6
  19. data/lib/jirametrics/expedited_chart.rb +7 -7
  20. data/lib/jirametrics/exporter.rb +10 -20
  21. data/lib/jirametrics/file_config.rb +23 -6
  22. data/lib/jirametrics/file_system.rb +39 -4
  23. data/lib/jirametrics/flow_efficiency_scatterplot.rb +2 -4
  24. data/lib/jirametrics/groupable_issue_chart.rb +1 -3
  25. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
  26. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
  27. data/lib/jirametrics/html/aging_work_table.erb +6 -4
  28. data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
  29. data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
  30. data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
  31. data/lib/jirametrics/html/expedited_chart.erb +1 -10
  32. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  33. data/lib/jirametrics/html/index.css +28 -5
  34. data/lib/jirametrics/html/index.erb +8 -4
  35. data/lib/jirametrics/html/sprint_burndown.erb +1 -10
  36. data/lib/jirametrics/html/throughput_chart.erb +1 -10
  37. data/lib/jirametrics/html_report_config.rb +32 -23
  38. data/lib/jirametrics/issue.rb +104 -44
  39. data/lib/jirametrics/jira_gateway.rb +16 -3
  40. data/lib/jirametrics/project_config.rb +223 -120
  41. data/lib/jirametrics/sprint_burndown.rb +1 -1
  42. data/lib/jirametrics/status.rb +81 -26
  43. data/lib/jirametrics/status_collection.rb +74 -40
  44. data/lib/jirametrics/throughput_chart.rb +1 -1
  45. data/lib/jirametrics/value_equality.rb +2 -2
  46. data/lib/jirametrics.rb +7 -1
  47. metadata +8 -13
  48. data/lib/jirametrics/discard_changes_before.rb +0 -37
  49. data/lib/jirametrics/html/data_quality_report.erb +0 -138
@@ -25,6 +25,11 @@ class ChartBase
25
25
  @aggregated_project
26
26
  end
27
27
 
28
+ def html_directory
29
+ pathname = Pathname.new(File.realpath(__FILE__))
30
+ "#{pathname.dirname}/html"
31
+ end
32
+
28
33
  def render caller_binding, file
29
34
  pathname = Pathname.new(File.realpath(file))
30
35
  basename = pathname.basename.to_s
@@ -33,8 +38,8 @@ class ChartBase
33
38
  # Insert a incrementing chart_id so that all the chart names on the page are unique
34
39
  caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3
35
40
 
36
- @html_directory = "#{pathname.dirname}/html"
37
- erb = ERB.new file_system.load "#{@html_directory}/#{$1}.erb"
41
+ # @html_directory = "#{pathname.dirname}/html"
42
+ erb = ERB.new file_system.load "#{html_directory}/#{$1}.erb"
38
43
  erb.result(caller_binding)
39
44
  end
40
45
 
@@ -100,7 +105,7 @@ class ChartBase
100
105
  issues_id = next_id
101
106
 
102
107
  issue_descriptions.sort! { |a, b| a[0].key_as_i <=> b[0].key_as_i }
103
- erb = ERB.new file_system.load "#{@html_directory}/collapsible_issues_panel.erb"
108
+ erb = ERB.new file_system.load File.join(html_directory, 'collapsible_issues_panel.erb')
104
109
  erb.result(binding)
105
110
  end
106
111
 
@@ -125,6 +130,21 @@ class ChartBase
125
130
  result
126
131
  end
127
132
 
133
+ def working_days_annotation
134
+ holidays.each_with_index.collect do |range, index|
135
+ <<~TEXT
136
+ holiday#{index}: {
137
+ drawTime: 'beforeDraw',
138
+ type: 'box',
139
+ xMin: '#{range.begin}T00:00:00',
140
+ xMax: '#{range.end}T23:59:59',
141
+ backgroundColor: #{CssVariable.new('--non-working-days-color').to_json},
142
+ borderColor: #{CssVariable.new('--non-working-days-color').to_json}
143
+ },
144
+ TEXT
145
+ end.join
146
+ end
147
+
128
148
  # Return only the board columns for the current board.
129
149
  def current_board
130
150
  if @board_id.nil?
@@ -161,28 +181,43 @@ class ChartBase
161
181
  end
162
182
  end
163
183
 
164
- def header_text text = nil
165
- @header_text = text if text
184
+ def header_text text = :none
185
+ @header_text = text unless text == :none
166
186
  @header_text
167
187
  end
168
188
 
169
- def description_text text = nil
170
- @description_text = text if text
189
+ def description_text text = :none
190
+ @description_text = text unless text == :none
171
191
  @description_text
172
192
  end
173
193
 
194
+ # Convert a number like 1234567 into the string "1,234,567"
174
195
  def format_integer number
175
196
  number.to_s.reverse.scan(/.{1,3}/).join(',').reverse
176
197
  end
177
198
 
178
- def format_status name_or_id, board:, is_category: false
179
- begin
180
- statuses = board.possible_statuses.expand_statuses([name_or_id])
181
- rescue StatusNotFoundError
182
- return "<span style='color: red'>#{name_or_id}</span>"
199
+ # object will be either a Status or a ChangeItem
200
+ # if it's a ChangeItem then use_old_status will specify whether we're using the new or old
201
+ # Either way, is_category will format the category rather than the status
202
+ def format_status object, board:, is_category: false, use_old_status: false
203
+ status = nil
204
+ error_message = nil
205
+
206
+ case object
207
+ when ChangeItem
208
+ id = use_old_status ? object.old_value_id : object.value_id
209
+ status = board.possible_statuses.find_by_id(id)
210
+ if status.nil?
211
+ error_message = use_old_status ? object.old_value : object.value
212
+ end
213
+ when Status
214
+ status = object
215
+ else
216
+ raise "Unexpected type: #{object.class}"
183
217
  end
184
218
 
185
- status = statuses.first
219
+ return "<span style='color: red'>#{error_message}</span>" if error_message
220
+
186
221
  color = status_category_color status
187
222
 
188
223
  visibility = ''
@@ -192,8 +227,8 @@ class ChartBase
192
227
  icon: ' 👀'
193
228
  )
194
229
  end
195
- text = is_category ? status.category_name : status.name
196
- "<span title='Category: #{status.category_name}'>#{color_block color.name} #{text}</span>#{visibility}"
230
+ text = is_category ? status.category.name : status.name
231
+ "<span title='Category: #{status.category.name}'>#{color_block color.name} #{text}</span>#{visibility}"
197
232
  end
198
233
 
199
234
  def icon_span title:, icon:
@@ -201,11 +236,11 @@ class ChartBase
201
236
  end
202
237
 
203
238
  def status_category_color status
204
- case status.category_name
205
- when 'To Do' then CssVariable['--status-category-todo-color']
206
- when 'In Progress' then CssVariable['--status-category-inprogress-color']
207
- when 'Done' then CssVariable['--status-category-done-color']
208
- else 'black' # Theoretically impossible but seen in prod.
239
+ case status.category.key
240
+ when 'new' then CssVariable['--status-category-todo-color']
241
+ when 'indeterminate' then CssVariable['--status-category-inprogress-color']
242
+ when 'done' then CssVariable['--status-category-done-color']
243
+ else CssVariable['--status-category-unknown-color'] # Theoretically impossible but seen in prod.
209
244
  end
210
245
  end
211
246
 
@@ -225,7 +260,10 @@ class ChartBase
225
260
 
226
261
  def color_block color, title: nil
227
262
  result = +''
228
- result << "<div class='color_block' style='background: var(#{color});'"
263
+ result << "<div class='color_block' style='"
264
+ result << "background: #{CssVariable[color]};" if color
265
+ result << 'visibility: hidden;' unless color
266
+ result << "'"
229
267
  result << " title=#{title.inspect}" if title
230
268
  result << '></div>'
231
269
  result
@@ -4,7 +4,7 @@ class CssVariable
4
4
  attr_reader :name
5
5
 
6
6
  def self.[](name)
7
- if name.start_with? '--'
7
+ if name.is_a?(String) && name.start_with?('--')
8
8
  CssVariable.new name
9
9
  else
10
10
  name
@@ -8,10 +8,14 @@ class CycleTimeConfig
8
8
 
9
9
  attr_reader :label, :parent_config
10
10
 
11
- def initialize parent_config:, label:, block:, today: Date.today
11
+ def initialize parent_config:, label:, block:, file_system: nil, today: Date.today
12
12
  @parent_config = parent_config
13
13
  @label = label
14
14
  @today = today
15
+
16
+ # If we hit something deprecated and this is nil then we'll blow up. Although it's ugly, this
17
+ # may make it easier to find problems in the test code ;-)
18
+ @file_system = file_system
15
19
  instance_eval(&block) unless block.nil?
16
20
  end
17
21
 
@@ -35,27 +39,55 @@ class CycleTimeConfig
35
39
  end
36
40
 
37
41
  def started_time issue
38
- deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
42
+ @file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
39
43
  started_stopped_times(issue).first
40
44
  end
41
45
 
42
46
  def stopped_time issue
43
- deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
47
+ @file_system.deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
44
48
  started_stopped_times(issue).last
45
49
  end
46
50
 
47
- def started_stopped_times issue
51
+ def fabricate_change_item time
52
+ @file_system.deprecated(
53
+ date: '2024-12-16', message: "This method should now return a ChangeItem not a #{time.class}", depth: 4
54
+ )
55
+ raw = {
56
+ 'field' => 'Fabricated change',
57
+ 'to' => '0',
58
+ 'toString' => '',
59
+ 'from' => '0',
60
+ 'fromString' => ''
61
+ }
62
+ ChangeItem.new raw: raw, time: time, author: 'unknown', artificial: true
63
+ end
64
+
65
+ def started_stopped_changes issue
48
66
  started = @start_at.call(issue)
49
67
  stopped = @stop_at.call(issue)
50
68
 
69
+ # Obscure edge case where some of the start_at and stop_at blocks might return false in place of nil.
70
+ # If they are false then explicitly make them nil.
71
+ started ||= nil
72
+ stopped ||= nil
73
+
74
+ # These are only here for backwards compatibility. Hopefully nobody will ever need them.
75
+ started = fabricate_change_item(started) if !started.nil? && !started.is_a?(ChangeItem)
76
+ stopped = fabricate_change_item(stopped) if !stopped.nil? && !stopped.is_a?(ChangeItem)
77
+
51
78
  # In the case where started and stopped are exactly the same time, we pretend that
52
79
  # it just stopped and never started. This allows us to have logic like 'in or right of'
53
80
  # for the start and not have it conflict.
54
- started = nil if started == stopped
81
+ started = nil if started&.time == stopped&.time
55
82
 
56
83
  [started, stopped]
57
84
  end
58
85
 
86
+ def started_stopped_times issue
87
+ started, stopped = started_stopped_changes(issue)
88
+ [started&.time, stopped&.time]
89
+ end
90
+
59
91
  def started_stopped_dates issue
60
92
  started_time, stopped_time = started_stopped_times(issue)
61
93
  [started_time&.to_date, stopped_time&.to_date]
@@ -5,10 +5,14 @@ require 'jirametrics/groupable_issue_chart'
5
5
  class CycletimeHistogram < ChartBase
6
6
  include GroupableIssueChart
7
7
  attr_accessor :possible_statuses
8
+ attr_reader :show_stats
8
9
 
9
10
  def initialize block
10
11
  super()
11
12
 
13
+ percentiles [50, 85, 98]
14
+ @show_stats = true
15
+
12
16
  header_text 'Cycletime Histogram'
13
17
  description_text <<-HTML
14
18
  <p>
@@ -26,6 +30,15 @@ class CycletimeHistogram < ChartBase
26
30
  end
27
31
  end
28
32
 
33
+ def percentiles percs = nil
34
+ @percentiles = percs unless percs.nil?
35
+ @percentiles
36
+ end
37
+
38
+ def disable_stats
39
+ @show_stats = false
40
+ end
41
+
29
42
  def run
30
43
  stopped_issues = completed_issues_in_range include_unstarted: true
31
44
 
@@ -33,14 +46,24 @@ class CycletimeHistogram < ChartBase
33
46
  histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
34
47
  rules_to_issues = group_issues histogram_issues
35
48
 
49
+ the_stats = {}
50
+
51
+ overall_stats = stats_for histogram_data: histogram_data_for(issues: histogram_issues), percentiles: @percentiles
52
+ the_stats[:all] = overall_stats
36
53
  data_sets = rules_to_issues.keys.collect do |rules|
54
+ the_issue_type = rules.label
55
+ the_histogram = histogram_data_for(issues: rules_to_issues[rules])
56
+ the_stats[the_issue_type] = stats_for histogram_data: the_histogram, percentiles: @percentiles if @show_stats
57
+
37
58
  data_set_for(
38
- histogram_data: histogram_data_for(issues: rules_to_issues[rules]),
39
- label: rules.label,
59
+ histogram_data: the_histogram,
60
+ label: the_issue_type,
40
61
  color: rules.color
41
62
  )
42
63
  end
43
64
 
65
+ return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
66
+
44
67
  wrap_and_render(binding, __FILE__)
45
68
  end
46
69
 
@@ -53,6 +76,48 @@ class CycletimeHistogram < ChartBase
53
76
  count_hash
54
77
  end
55
78
 
79
+ def stats_for histogram_data:, percentiles:
80
+ return {} if histogram_data.empty?
81
+
82
+ total_values = histogram_data.values.sum
83
+
84
+ # Calculate the average
85
+ weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
86
+ average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
87
+
88
+ # Find the mode (or modes!) and the spread of the distribution
89
+ sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
90
+ max_freq = sorted_histogram[-1][1]
91
+ mode = sorted_histogram.select { |_v, f| f == max_freq }
92
+
93
+ minmax = histogram_data.keys.minmax
94
+
95
+ # Calculate percentiles
96
+ sorted_values = histogram_data.keys.sort
97
+ cumulative_counts = {}
98
+ cumulative_sum = 0
99
+
100
+ sorted_values.each do |value|
101
+ cumulative_sum += histogram_data[value]
102
+ cumulative_counts[value] = cumulative_sum
103
+ end
104
+
105
+ percentile_results = {}
106
+ percentiles.each do |percentile|
107
+ rank = (percentile / 100.0) * total_values
108
+ percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
109
+ percentile_results[percentile] = percentile_value
110
+ end
111
+
112
+ {
113
+ average: average,
114
+ mode: mode.collect(&:first).sort,
115
+ min: minmax[0],
116
+ max: minmax[1],
117
+ percentiles: percentile_results
118
+ }
119
+ end
120
+
56
121
  def data_set_for histogram_data:, label:, color:
57
122
  keys = histogram_data.keys.sort
58
123
  {
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class DataQualityReport < ChartBase
4
- attr_reader :original_issue_times # For testing purposes only
4
+ attr_reader :discarded_changes_data, :entries # Both for testing purposes only
5
5
  attr_accessor :board_id
6
6
 
7
7
  class Entry
@@ -19,10 +19,10 @@ class DataQualityReport < ChartBase
19
19
  end
20
20
  end
21
21
 
22
- def initialize original_issue_times
22
+ def initialize discarded_changes_data
23
23
  super()
24
24
 
25
- @original_issue_times = original_issue_times
25
+ @discarded_changes_data = discarded_changes_data
26
26
 
27
27
  header_text 'Data Quality Report'
28
28
  description_text <<-HTML
@@ -50,6 +50,7 @@ class DataQualityReport < ChartBase
50
50
  scan_for_issues_not_started_with_subtasks_that_have entry: entry
51
51
  scan_for_incomplete_subtasks_when_issue_done entry: entry
52
52
  scan_for_discarded_data entry: entry
53
+ scan_for_items_blocked_on_closed_tickets entry: entry
53
54
  end
54
55
 
55
56
  scan_for_issues_on_multiple_boards entries: @entries
@@ -57,7 +58,26 @@ class DataQualityReport < ChartBase
57
58
  entries_with_problems = entries_with_problems()
58
59
  return '' if entries_with_problems.empty?
59
60
 
60
- wrap_and_render(binding, __FILE__)
61
+ caller_binding = binding
62
+ result = +''
63
+ result << render_top_text(caller_binding)
64
+
65
+ result << '<ul class="quality_report">'
66
+ result << render_problem_type(:discarded_changes)
67
+ result << render_problem_type(:completed_but_not_started)
68
+ result << render_problem_type(:status_changes_after_done)
69
+ result << render_problem_type(:backwards_through_status_categories)
70
+ result << render_problem_type(:backwords_through_statuses)
71
+ result << render_problem_type(:status_not_on_board)
72
+ result << render_problem_type(:created_in_wrong_status)
73
+ result << render_problem_type(:stopped_before_started)
74
+ result << render_problem_type(:issue_not_started_but_subtasks_have)
75
+ result << render_problem_type(:incomplete_subtasks_when_issue_done)
76
+ result << render_problem_type(:issue_on_multiple_boards)
77
+ result << render_problem_type(:items_blocked_on_closed_tickets)
78
+ result << '</ul>'
79
+
80
+ result
61
81
  end
62
82
 
63
83
  def problems_for key
@@ -70,11 +90,27 @@ class DataQualityReport < ChartBase
70
90
  result
71
91
  end
72
92
 
93
+ def render_problem_type problem_key
94
+ problems = problems_for problem_key
95
+ return '' if problems.empty?
96
+
97
+ <<-HTML
98
+ <li>
99
+ #{__send__ :"render_#{problem_key}", problems}
100
+ #{collapsible_issues_panel problems}
101
+ </li>
102
+ HTML
103
+ end
104
+
73
105
  # Return a format that's easier to assert against
74
106
  def testable_entries
75
- format = '%Y-%m-%d %H:%M:%S %z'
107
+ formatter = ->(time) { time&.strftime('%Y-%m-%d %H:%M:%S %z') || '' }
76
108
  @entries.collect do |entry|
77
- [entry.started&.strftime(format) || '', entry.stopped&.strftime(format) || '', entry.issue]
109
+ [
110
+ formatter.call(entry.started),
111
+ formatter.call(entry.stopped),
112
+ entry.issue
113
+ ]
78
114
  end
79
115
  end
80
116
 
@@ -82,10 +118,6 @@ class DataQualityReport < ChartBase
82
118
  @entries.reject { |entry| entry.problems.empty? }
83
119
  end
84
120
 
85
- def category_name_for status_name:, board:
86
- board.possible_statuses.find { |status| status.name == status_name }&.category_name
87
- end
88
-
89
121
  def initialize_entries
90
122
  @entries = @issues.filter_map do |issue|
91
123
  started, stopped = issue.board.cycletime.started_stopped_times(issue)
@@ -109,10 +141,8 @@ class DataQualityReport < ChartBase
109
141
  def scan_for_completed_issues_without_a_start_time entry:
110
142
  return unless entry.stopped && entry.started.nil?
111
143
 
112
- status_names = entry.issue.changes.filter_map do |change|
113
- next unless change.status?
114
-
115
- format_status change.value, board: entry.issue.board
144
+ status_names = entry.issue.status_changes.filter_map do |change|
145
+ format_status change, board: entry.issue.board
116
146
  end
117
147
 
118
148
  entry.report(
@@ -127,14 +157,14 @@ class DataQualityReport < ChartBase
127
157
  changes_after_done = entry.issue.changes.select do |change|
128
158
  change.status? && change.time >= entry.stopped
129
159
  end
130
- done_status = changes_after_done.shift.value
160
+ done_status = changes_after_done.shift
131
161
 
132
162
  return if changes_after_done.empty?
133
163
 
134
164
  board = entry.issue.board
135
165
  problem = "Completed on #{entry.stopped.to_date} with status #{format_status done_status, board: board}."
136
166
  changes_after_done.each do |change|
137
- problem << " Changed to #{format_status change.value, board: board} on #{change.time.to_date}."
167
+ problem << " Changed to #{format_status change, board: board} on #{change.time.to_date}."
138
168
  end
139
169
  entry.report(
140
170
  problem_key: :status_changes_after_done,
@@ -154,11 +184,11 @@ class DataQualityReport < ChartBase
154
184
  index = entry.issue.board.visible_columns.find_index { |column| column.status_ids.include? change.value_id }
155
185
  if index.nil?
156
186
  # If it's a backlog status then ignore it. Not supposed to be visible.
157
- next if entry.issue.board.backlog_statuses.include? change.value_id
187
+ next if entry.issue.board.backlog_statuses.include?(board.possible_statuses.find_by_id(change.value_id))
158
188
 
159
- detail = "Status #{format_status change.value, board: board} is not on the board"
160
- if issue.board.possible_statuses.expand_statuses(change.value).empty?
161
- detail = "Status #{format_status change.value, board: board} cannot be found at all. Was it deleted?"
189
+ detail = "Status #{format_status change, board: board} is not on the board"
190
+ if issue.board.possible_statuses.find_by_id(change.value_id).nil?
191
+ detail = "Status #{format_status change, board: board} cannot be found at all. Was it deleted?"
162
192
  end
163
193
 
164
194
  # If it's been moved back to backlog then it's on a different report. Ignore it here.
@@ -168,24 +198,24 @@ class DataQualityReport < ChartBase
168
198
  elsif change.old_value.nil?
169
199
  # Do nothing
170
200
  elsif index < last_index
171
- new_category = category_name_for(status_name: change.value, board: board)
172
- old_category = category_name_for(status_name: change.old_value, board: board)
201
+ new_category = board.possible_statuses.find_by_id(change.value_id).category.name
202
+ old_category = board.possible_statuses.find_by_id(change.old_value_id).category.name
173
203
 
174
204
  if new_category == old_category
175
205
  entry.report(
176
206
  problem_key: :backwords_through_statuses,
177
- detail: "Moved from #{format_status change.old_value, board: board}" \
178
- " to #{format_status change.value, board: board}" \
207
+ detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
208
+ " to #{format_status change, board: board}" \
179
209
  " on #{change.time.to_date}"
180
210
  )
181
211
  else
182
212
  entry.report(
183
213
  problem_key: :backwards_through_status_categories,
184
- detail: "Moved from #{format_status change.old_value, board: board}" \
185
- " to #{format_status change.value, board: board}" \
186
- " on #{change.time.to_date}, " \
187
- " crossing from category #{format_status old_category, board: board, is_category: true}" \
188
- " to #{format_status new_category, board: board, is_category: true}."
214
+ detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
215
+ " to #{format_status change, board: board}" \
216
+ " on #{change.time.to_date}," \
217
+ " crossing from category #{format_status change, use_old_status: true, board: board, is_category: true}" \
218
+ " to #{format_status change, board: board, is_category: true}."
189
219
  )
190
220
  end
191
221
  end
@@ -194,16 +224,14 @@ class DataQualityReport < ChartBase
194
224
  end
195
225
 
196
226
  def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
197
- return if backlog_statuses.empty?
198
-
199
227
  creation_change = entry.issue.changes.find { |issue| issue.status? }
200
228
 
201
229
  return if backlog_statuses.any? { |status| status.id == creation_change.value_id }
202
230
 
203
- status_string = backlog_statuses.collect { |s| format_status s.name, board: entry.issue.board }.join(', ')
231
+ status_string = backlog_statuses.collect { |s| format_status s, board: entry.issue.board }.join(', ')
204
232
  entry.report(
205
233
  problem_key: :created_in_wrong_status,
206
- detail: "Created in #{format_status creation_change.value, board: entry.issue.board}, " \
234
+ detail: "Created in #{format_status creation_change, board: entry.issue.board}, " \
207
235
  "which is not one of the backlog statuses for this board: #{status_string}"
208
236
  )
209
237
  end
@@ -236,6 +264,20 @@ class DataQualityReport < ChartBase
236
264
  )
237
265
  end
238
266
 
267
+ def scan_for_items_blocked_on_closed_tickets entry:
268
+ entry.issue.issue_links.each do |link|
269
+ this_active = !entry.stopped
270
+ other_active = !link.other_issue.board.cycletime.started_stopped_times(link.other_issue).last
271
+ next unless this_active && !other_active
272
+
273
+ entry.report(
274
+ problem_key: :items_blocked_on_closed_tickets,
275
+ detail: "#{entry.issue.key} thinks it's blocked by #{link.other_issue.key}, " \
276
+ "except #{link.other_issue.key} is closed."
277
+ )
278
+ end
279
+ end
280
+
239
281
  def subtask_label subtask
240
282
  "<img src='#{subtask.type_icon_url}' /> #{link_to_issue(subtask)} #{subtask.summary[..50].inspect}"
241
283
  end
@@ -284,10 +326,10 @@ class DataQualityReport < ChartBase
284
326
  end
285
327
 
286
328
  def scan_for_discarded_data entry:
287
- hash = @original_issue_times[entry.issue]
329
+ hash = @discarded_changes_data&.find { |a| a[:issue] == entry.issue }
288
330
  return if hash.nil?
289
331
 
290
- old_start_time = hash[:started_time]
332
+ old_start_time = hash[:original_start_time]
291
333
  cutoff_time = hash[:cutoff_time]
292
334
 
293
335
  old_start_date = old_start_time.to_date
@@ -317,4 +359,101 @@ class DataQualityReport < ChartBase
317
359
  )
318
360
  end
319
361
  end
362
+
363
+ def render_discarded_changes problems
364
+ <<-HTML
365
+ #{label_issues problems.size} have had information discarded. This configuration is set
366
+ to "reset the clock" if an item is moved back to the backlog after it's been started. This hides important
367
+ information and makes the data less accurate. <b>Moving items back to the backlog is strongly discouraged.</b>
368
+ HTML
369
+ end
370
+
371
+ def render_completed_but_not_started problems
372
+ percentage_work_included = ((issues.size - problems.size).to_f / issues.size * 100).to_i
373
+ html = <<-HTML
374
+ #{label_issues problems.size} were discarded from all charts using cycletime (scatterplot, histogram, etc)
375
+ as we couldn't determine when they started.
376
+ HTML
377
+ if percentage_work_included < 85
378
+ html << <<-HTML
379
+ Consider whether looking at only #{percentage_work_included}% of the total data points is enough
380
+ to come to any reasonable conclusions. See <a href="https://unconsciousagile.com/2024/11/19/survivor-bias.html">
381
+ Survivor Bias</a>.
382
+ HTML
383
+ end
384
+ html
385
+ end
386
+
387
+ def render_status_changes_after_done problems
388
+ <<-HTML
389
+ #{label_issues problems.size} had a status change after being identified as done. We should question
390
+ whether they were really done at that point or if we stopped the clock too early.
391
+ HTML
392
+ end
393
+
394
+ def render_backwards_through_status_categories problems
395
+ <<-HTML
396
+ #{label_issues problems.size} moved backwards across the board, <b>crossing status categories</b>.
397
+ This will almost certainly have impacted timings as the end times are often taken at status category
398
+ boundaries. You should assume that any timing measurements for this item are wrong.
399
+ HTML
400
+ end
401
+
402
+ def render_backwords_through_statuses problems
403
+ <<-HTML
404
+ #{label_issues problems.size} moved backwards across the board. Depending where we have set the
405
+ start and end points, this may give us incorrect timing data. Note that these items did not cross
406
+ a status category and may not have affected metrics.
407
+ HTML
408
+ end
409
+
410
+ def render_status_not_on_board problems
411
+ <<-HTML
412
+ #{label_issues problems.size} were not visible on the board for some period of time. This may impact
413
+ timings as the work was likely to have been forgotten if it wasn't visible.
414
+ HTML
415
+ end
416
+
417
+ def render_created_in_wrong_status problems
418
+ <<-HTML
419
+ #{label_issues problems.size} were created in a status not designated as Backlog. This will impact
420
+ the measurement of start times and will therefore impact whether it's shown as in progress or not.
421
+ HTML
422
+ end
423
+
424
+ def render_stopped_before_started problems
425
+ <<-HTML
426
+ #{label_issues problems.size} were stopped before they were started and this will play havoc with
427
+ any cycletime or WIP calculations. The most common case for this is when an item gets closed and
428
+ then moved back into an in-progress status.
429
+ HTML
430
+ end
431
+
432
+ def render_issue_not_started_but_subtasks_have problems
433
+ <<-HTML
434
+ #{label_issues problems.size} still showing 'not started' while sub-tasks underneath them have
435
+ started. This is almost always a mistake; if we're working on subtasks, the top level item should
436
+ also have started.
437
+ HTML
438
+ end
439
+
440
+ def render_incomplete_subtasks_when_issue_done problems
441
+ <<-HTML
442
+ #{label_issues problems.size} issues were marked as done while subtasks were still not done.
443
+ HTML
444
+ end
445
+
446
+ def render_issue_on_multiple_boards problems
447
+ <<-HTML
448
+ For #{label_issues problems.size}, we have an issue that shows up on more than one board. This
449
+ could result in more data points showing up on a chart then there really should be.
450
+ HTML
451
+ end
452
+
453
+ def render_items_blocked_on_closed_tickets problems
454
+ <<-HTML
455
+ For #{label_issues problems.size}, the issue is identified as being blocked by another issue. Yet,
456
+ that other issue is already completed so, by definition, it can't still be blocking.
457
+ HTML
458
+ end
320
459
  end
@@ -20,8 +20,8 @@ class DownloadConfig
20
20
  @rolling_date_count
21
21
  end
22
22
 
23
- def no_earlier_than date = nil
24
- @no_earlier_than = Date.parse(date) unless date.nil?
23
+ def no_earlier_than date = :not_set
24
+ @no_earlier_than = Date.parse(date) unless date == :not_set
25
25
  @no_earlier_than
26
26
  end
27
27