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.
- checksums.yaml +4 -4
- data/lib/jirametrics/aggregate_config.rb +4 -4
- data/lib/jirametrics/aging_work_bar_chart.rb +7 -5
- data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
- data/lib/jirametrics/aging_work_table.rb +50 -2
- data/lib/jirametrics/board.rb +33 -5
- data/lib/jirametrics/board_config.rb +6 -2
- data/lib/jirametrics/board_movement_calculator.rb +147 -0
- data/lib/jirametrics/change_item.rb +19 -6
- data/lib/jirametrics/chart_base.rb +59 -21
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cycletime_config.rb +37 -5
- data/lib/jirametrics/cycletime_histogram.rb +67 -2
- data/lib/jirametrics/data_quality_report.rb +174 -35
- data/lib/jirametrics/download_config.rb +2 -2
- data/lib/jirametrics/downloader.rb +44 -25
- data/lib/jirametrics/examples/aggregated_project.rb +2 -5
- data/lib/jirametrics/examples/standard_project.rb +4 -6
- data/lib/jirametrics/expedited_chart.rb +7 -7
- data/lib/jirametrics/exporter.rb +10 -20
- data/lib/jirametrics/file_config.rb +23 -6
- data/lib/jirametrics/file_system.rb +39 -4
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +2 -4
- data/lib/jirametrics/groupable_issue_chart.rb +1 -3
- data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
- data/lib/jirametrics/html/aging_work_table.erb +6 -4
- data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
- data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
- data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
- data/lib/jirametrics/html/expedited_chart.erb +1 -10
- data/lib/jirametrics/html/hierarchy_table.erb +1 -1
- data/lib/jirametrics/html/index.css +28 -5
- data/lib/jirametrics/html/index.erb +8 -4
- data/lib/jirametrics/html/sprint_burndown.erb +1 -10
- data/lib/jirametrics/html/throughput_chart.erb +1 -10
- data/lib/jirametrics/html_report_config.rb +32 -23
- data/lib/jirametrics/issue.rb +104 -44
- data/lib/jirametrics/jira_gateway.rb +16 -3
- data/lib/jirametrics/project_config.rb +223 -120
- data/lib/jirametrics/sprint_burndown.rb +1 -1
- data/lib/jirametrics/status.rb +81 -26
- data/lib/jirametrics/status_collection.rb +74 -40
- data/lib/jirametrics/throughput_chart.rb +1 -1
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics.rb +7 -1
- metadata +8 -13
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- 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 "#{
|
|
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
|
|
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 =
|
|
165
|
-
@header_text = 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 =
|
|
170
|
-
@description_text = 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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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.
|
|
196
|
-
"<span title='Category: #{status.
|
|
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.
|
|
205
|
-
when '
|
|
206
|
-
when '
|
|
207
|
-
when '
|
|
208
|
-
else '
|
|
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='
|
|
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
|
|
@@ -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
|
|
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:
|
|
39
|
-
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 :
|
|
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
|
|
22
|
+
def initialize discarded_changes_data
|
|
23
23
|
super()
|
|
24
24
|
|
|
25
|
-
@
|
|
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
|
-
|
|
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
|
-
|
|
107
|
+
formatter = ->(time) { time&.strftime('%Y-%m-%d %H:%M:%S %z') || '' }
|
|
76
108
|
@entries.collect do |entry|
|
|
77
|
-
[
|
|
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.
|
|
113
|
-
|
|
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
|
|
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
|
|
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?
|
|
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
|
|
160
|
-
if issue.board.possible_statuses.
|
|
161
|
-
detail = "Status #{format_status change
|
|
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 =
|
|
172
|
-
old_category =
|
|
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
|
|
178
|
-
" to #{format_status change
|
|
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
|
|
185
|
-
" to #{format_status change
|
|
186
|
-
" on #{change.time.to_date},
|
|
187
|
-
" crossing from category #{format_status
|
|
188
|
-
" to #{format_status
|
|
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
|
|
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
|
|
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 = @
|
|
329
|
+
hash = @discarded_changes_data&.find { |a| a[:issue] == entry.issue }
|
|
288
330
|
return if hash.nil?
|
|
289
331
|
|
|
290
|
-
old_start_time = hash[:
|
|
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 =
|
|
24
|
-
@no_earlier_than = Date.parse(date) unless date
|
|
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
|
|