jirametrics 2.4 → 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 +9 -4
- data/lib/jirametrics/aging_work_bar_chart.rb +13 -11
- data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
- data/lib/jirametrics/aging_work_table.rb +54 -7
- data/lib/jirametrics/blocked_stalled_change.rb +1 -1
- data/lib/jirametrics/board.rb +44 -15
- data/lib/jirametrics/board_config.rb +7 -3
- data/lib/jirametrics/board_movement_calculator.rb +147 -0
- data/lib/jirametrics/change_item.rb +19 -6
- data/lib/jirametrics/chart_base.rb +63 -27
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cycletime_config.rb +59 -8
- data/lib/jirametrics/cycletime_histogram.rb +68 -3
- data/lib/jirametrics/cycletime_scatterplot.rb +3 -6
- data/lib/jirametrics/daily_wip_by_age_chart.rb +2 -4
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +2 -2
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +0 -4
- data/lib/jirametrics/daily_wip_chart.rb +7 -9
- data/lib/jirametrics/data_quality_report.rb +219 -41
- data/lib/jirametrics/dependency_chart.rb +37 -10
- data/lib/jirametrics/download_config.rb +12 -0
- data/lib/jirametrics/downloader.rb +68 -50
- data/lib/jirametrics/estimate_accuracy_chart.rb +1 -2
- data/lib/jirametrics/examples/aggregated_project.rb +7 -21
- data/lib/jirametrics/examples/standard_project.rb +18 -34
- data/lib/jirametrics/expedited_chart.rb +8 -9
- data/lib/jirametrics/exporter.rb +28 -11
- data/lib/jirametrics/file_config.rb +23 -6
- data/lib/jirametrics/file_system.rb +39 -3
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
- 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/flow_efficiency_scatterplot.erb +85 -0
- 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 +33 -23
- data/lib/jirametrics/issue.rb +232 -47
- data/lib/jirametrics/jira_gateway.rb +16 -3
- data/lib/jirametrics/project_config.rb +245 -134
- data/lib/jirametrics/rules.rb +2 -2
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +5 -2
- data/lib/jirametrics/sprint_burndown.rb +3 -3
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +77 -39
- data/lib/jirametrics/throughput_chart.rb +1 -1
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics.rb +22 -6
- metadata +10 -13
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
|
@@ -6,7 +6,7 @@ class DailyGroupingRules < GroupingRules
|
|
|
6
6
|
attr_accessor :current_date, :group_priority, :issue_hint
|
|
7
7
|
|
|
8
8
|
def initialize
|
|
9
|
-
super
|
|
9
|
+
super
|
|
10
10
|
@group_priority = 0
|
|
11
11
|
end
|
|
12
12
|
end
|
|
@@ -22,10 +22,10 @@ class DailyWipChart < ChartBase
|
|
|
22
22
|
|
|
23
23
|
instance_eval(&block) if block
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
25
|
+
return if @group_by_block
|
|
26
|
+
|
|
27
|
+
grouping_rules do |issue, rules|
|
|
28
|
+
default_grouping_rules issue: issue, rules: rules
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
|
|
@@ -66,9 +66,7 @@ class DailyWipChart < ChartBase
|
|
|
66
66
|
hash = {}
|
|
67
67
|
|
|
68
68
|
@issues.each do |issue|
|
|
69
|
-
|
|
70
|
-
start = cycletime.started_time(issue)&.to_date
|
|
71
|
-
stop = cycletime.stopped_time(issue)&.to_date
|
|
69
|
+
start, stop = issue.board.cycletime.started_stopped_dates(issue)
|
|
72
70
|
next if start.nil? && stop.nil?
|
|
73
71
|
|
|
74
72
|
# If it stopped but never started then assume it started at creation so the data points
|
|
@@ -158,7 +156,7 @@ class DailyWipChart < ChartBase
|
|
|
158
156
|
|
|
159
157
|
{
|
|
160
158
|
type: 'line',
|
|
161
|
-
label:
|
|
159
|
+
label: 'Trendline',
|
|
162
160
|
data: data_points,
|
|
163
161
|
fill: false,
|
|
164
162
|
borderWidth: 1,
|
|
@@ -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
|
|
@@ -48,7 +48,9 @@ class DataQualityReport < ChartBase
|
|
|
48
48
|
scan_for_issues_not_created_in_a_backlog_status entry: entry, backlog_statuses: backlog_statuses
|
|
49
49
|
scan_for_stopped_before_started entry: entry
|
|
50
50
|
scan_for_issues_not_started_with_subtasks_that_have entry: entry
|
|
51
|
+
scan_for_incomplete_subtasks_when_issue_done entry: entry
|
|
51
52
|
scan_for_discarded_data entry: entry
|
|
53
|
+
scan_for_items_blocked_on_closed_tickets entry: entry
|
|
52
54
|
end
|
|
53
55
|
|
|
54
56
|
scan_for_issues_on_multiple_boards entries: @entries
|
|
@@ -56,7 +58,26 @@ class DataQualityReport < ChartBase
|
|
|
56
58
|
entries_with_problems = entries_with_problems()
|
|
57
59
|
return '' if entries_with_problems.empty?
|
|
58
60
|
|
|
59
|
-
|
|
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
|
|
60
81
|
end
|
|
61
82
|
|
|
62
83
|
def problems_for key
|
|
@@ -69,11 +90,27 @@ class DataQualityReport < ChartBase
|
|
|
69
90
|
result
|
|
70
91
|
end
|
|
71
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
|
+
|
|
72
105
|
# Return a format that's easier to assert against
|
|
73
106
|
def testable_entries
|
|
74
|
-
|
|
107
|
+
formatter = ->(time) { time&.strftime('%Y-%m-%d %H:%M:%S %z') || '' }
|
|
75
108
|
@entries.collect do |entry|
|
|
76
|
-
[
|
|
109
|
+
[
|
|
110
|
+
formatter.call(entry.started),
|
|
111
|
+
formatter.call(entry.stopped),
|
|
112
|
+
entry.issue
|
|
113
|
+
]
|
|
77
114
|
end
|
|
78
115
|
end
|
|
79
116
|
|
|
@@ -81,15 +118,9 @@ class DataQualityReport < ChartBase
|
|
|
81
118
|
@entries.reject { |entry| entry.problems.empty? }
|
|
82
119
|
end
|
|
83
120
|
|
|
84
|
-
def category_name_for status_name:, board:
|
|
85
|
-
board.possible_statuses.find { |status| status.name == status_name }&.category_name
|
|
86
|
-
end
|
|
87
|
-
|
|
88
121
|
def initialize_entries
|
|
89
122
|
@entries = @issues.filter_map do |issue|
|
|
90
|
-
|
|
91
|
-
started = cycletime.started_time(issue)
|
|
92
|
-
stopped = cycletime.stopped_time(issue)
|
|
123
|
+
started, stopped = issue.board.cycletime.started_stopped_times(issue)
|
|
93
124
|
next if stopped && stopped < time_range.begin
|
|
94
125
|
next if started && started > time_range.end
|
|
95
126
|
|
|
@@ -110,10 +141,8 @@ class DataQualityReport < ChartBase
|
|
|
110
141
|
def scan_for_completed_issues_without_a_start_time entry:
|
|
111
142
|
return unless entry.stopped && entry.started.nil?
|
|
112
143
|
|
|
113
|
-
status_names = entry.issue.
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
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
|
|
117
146
|
end
|
|
118
147
|
|
|
119
148
|
entry.report(
|
|
@@ -128,14 +157,14 @@ class DataQualityReport < ChartBase
|
|
|
128
157
|
changes_after_done = entry.issue.changes.select do |change|
|
|
129
158
|
change.status? && change.time >= entry.stopped
|
|
130
159
|
end
|
|
131
|
-
done_status = changes_after_done.shift
|
|
160
|
+
done_status = changes_after_done.shift
|
|
132
161
|
|
|
133
162
|
return if changes_after_done.empty?
|
|
134
163
|
|
|
135
164
|
board = entry.issue.board
|
|
136
165
|
problem = "Completed on #{entry.stopped.to_date} with status #{format_status done_status, board: board}."
|
|
137
166
|
changes_after_done.each do |change|
|
|
138
|
-
problem << " Changed to #{format_status change
|
|
167
|
+
problem << " Changed to #{format_status change, board: board} on #{change.time.to_date}."
|
|
139
168
|
end
|
|
140
169
|
entry.report(
|
|
141
170
|
problem_key: :status_changes_after_done,
|
|
@@ -155,11 +184,11 @@ class DataQualityReport < ChartBase
|
|
|
155
184
|
index = entry.issue.board.visible_columns.find_index { |column| column.status_ids.include? change.value_id }
|
|
156
185
|
if index.nil?
|
|
157
186
|
# If it's a backlog status then ignore it. Not supposed to be visible.
|
|
158
|
-
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))
|
|
159
188
|
|
|
160
|
-
detail = "Status #{format_status change
|
|
161
|
-
if issue.board.possible_statuses.
|
|
162
|
-
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?"
|
|
163
192
|
end
|
|
164
193
|
|
|
165
194
|
# If it's been moved back to backlog then it's on a different report. Ignore it here.
|
|
@@ -169,24 +198,24 @@ class DataQualityReport < ChartBase
|
|
|
169
198
|
elsif change.old_value.nil?
|
|
170
199
|
# Do nothing
|
|
171
200
|
elsif index < last_index
|
|
172
|
-
new_category =
|
|
173
|
-
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
|
|
174
203
|
|
|
175
204
|
if new_category == old_category
|
|
176
205
|
entry.report(
|
|
177
206
|
problem_key: :backwords_through_statuses,
|
|
178
|
-
detail: "Moved from #{format_status change
|
|
179
|
-
" to #{format_status change
|
|
207
|
+
detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
|
|
208
|
+
" to #{format_status change, board: board}" \
|
|
180
209
|
" on #{change.time.to_date}"
|
|
181
210
|
)
|
|
182
211
|
else
|
|
183
212
|
entry.report(
|
|
184
213
|
problem_key: :backwards_through_status_categories,
|
|
185
|
-
detail: "Moved from #{format_status change
|
|
186
|
-
" to #{format_status change
|
|
187
|
-
" on #{change.time.to_date},
|
|
188
|
-
" crossing from category #{format_status
|
|
189
|
-
" 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}."
|
|
190
219
|
)
|
|
191
220
|
end
|
|
192
221
|
end
|
|
@@ -195,16 +224,14 @@ class DataQualityReport < ChartBase
|
|
|
195
224
|
end
|
|
196
225
|
|
|
197
226
|
def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
|
|
198
|
-
return if backlog_statuses.empty?
|
|
199
|
-
|
|
200
227
|
creation_change = entry.issue.changes.find { |issue| issue.status? }
|
|
201
228
|
|
|
202
229
|
return if backlog_statuses.any? { |status| status.id == creation_change.value_id }
|
|
203
230
|
|
|
204
|
-
status_string = backlog_statuses.collect { |s| format_status s
|
|
231
|
+
status_string = backlog_statuses.collect { |s| format_status s, board: entry.issue.board }.join(', ')
|
|
205
232
|
entry.report(
|
|
206
233
|
problem_key: :created_in_wrong_status,
|
|
207
|
-
detail: "Created in #{format_status creation_change
|
|
234
|
+
detail: "Created in #{format_status creation_change, board: entry.issue.board}, " \
|
|
208
235
|
"which is not one of the backlog statuses for this board: #{status_string}"
|
|
209
236
|
)
|
|
210
237
|
end
|
|
@@ -223,14 +250,13 @@ class DataQualityReport < ChartBase
|
|
|
223
250
|
|
|
224
251
|
started_subtasks = []
|
|
225
252
|
entry.issue.subtasks.each do |subtask|
|
|
226
|
-
started_subtasks << subtask if subtask.board.cycletime.
|
|
253
|
+
started_subtasks << subtask if subtask.board.cycletime.started_stopped_times(subtask).first
|
|
227
254
|
end
|
|
228
255
|
|
|
229
256
|
return if started_subtasks.empty?
|
|
230
257
|
|
|
231
258
|
subtask_labels = started_subtasks.collect do |subtask|
|
|
232
|
-
|
|
233
|
-
"#{subtask.summary[..50].inspect}"
|
|
259
|
+
subtask_label(subtask)
|
|
234
260
|
end
|
|
235
261
|
entry.report(
|
|
236
262
|
problem_key: :issue_not_started_but_subtasks_have,
|
|
@@ -238,6 +264,61 @@ class DataQualityReport < ChartBase
|
|
|
238
264
|
)
|
|
239
265
|
end
|
|
240
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
|
+
|
|
281
|
+
def subtask_label subtask
|
|
282
|
+
"<img src='#{subtask.type_icon_url}' /> #{link_to_issue(subtask)} #{subtask.summary[..50].inspect}"
|
|
283
|
+
end
|
|
284
|
+
|
|
285
|
+
def time_as_english(from_time, to_time)
|
|
286
|
+
delta = (to_time - from_time).to_i
|
|
287
|
+
return "#{delta} seconds" if delta < 60
|
|
288
|
+
|
|
289
|
+
delta /= 60
|
|
290
|
+
return "#{delta} minutes" if delta < 60
|
|
291
|
+
|
|
292
|
+
delta /= 60
|
|
293
|
+
return "#{delta} hours" if delta < 24
|
|
294
|
+
|
|
295
|
+
delta /= 24
|
|
296
|
+
"#{delta} days"
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def scan_for_incomplete_subtasks_when_issue_done entry:
|
|
300
|
+
return unless entry.stopped
|
|
301
|
+
|
|
302
|
+
subtask_labels = entry.issue.subtasks.filter_map do |subtask|
|
|
303
|
+
subtask_started, subtask_stopped = subtask.board.cycletime.started_stopped_times(subtask)
|
|
304
|
+
|
|
305
|
+
if !subtask_started && !subtask_stopped
|
|
306
|
+
"#{subtask_label subtask} (Not even started)"
|
|
307
|
+
elsif !subtask_stopped
|
|
308
|
+
"#{subtask_label subtask} (Still not done)"
|
|
309
|
+
elsif subtask_stopped > entry.stopped
|
|
310
|
+
"#{subtask_label subtask} (Closed #{time_as_english entry.stopped, subtask_stopped} later)"
|
|
311
|
+
end
|
|
312
|
+
end
|
|
313
|
+
|
|
314
|
+
return if subtask_labels.empty?
|
|
315
|
+
|
|
316
|
+
entry.report(
|
|
317
|
+
problem_key: :incomplete_subtasks_when_issue_done,
|
|
318
|
+
detail: subtask_labels.join('<br />')
|
|
319
|
+
)
|
|
320
|
+
end
|
|
321
|
+
|
|
241
322
|
def label_issues number
|
|
242
323
|
return '1 item' if number == 1
|
|
243
324
|
|
|
@@ -245,10 +326,10 @@ class DataQualityReport < ChartBase
|
|
|
245
326
|
end
|
|
246
327
|
|
|
247
328
|
def scan_for_discarded_data entry:
|
|
248
|
-
hash = @
|
|
329
|
+
hash = @discarded_changes_data&.find { |a| a[:issue] == entry.issue }
|
|
249
330
|
return if hash.nil?
|
|
250
331
|
|
|
251
|
-
old_start_time = hash[:
|
|
332
|
+
old_start_time = hash[:original_start_time]
|
|
252
333
|
cutoff_time = hash[:cutoff_time]
|
|
253
334
|
|
|
254
335
|
old_start_date = old_start_time.to_date
|
|
@@ -278,4 +359,101 @@ class DataQualityReport < ChartBase
|
|
|
278
359
|
)
|
|
279
360
|
end
|
|
280
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
|
|
281
459
|
end
|
|
@@ -42,17 +42,13 @@ class DependencyChart < ChartBase
|
|
|
42
42
|
HTML
|
|
43
43
|
|
|
44
44
|
@rules_block = rules_block
|
|
45
|
-
@link_rules_block = ->(link_name, link_rules) {}
|
|
46
45
|
|
|
47
|
-
issue_rules
|
|
48
|
-
|
|
49
|
-
key = "<S>#{key} </S> " if issue.status.category_name == 'Done'
|
|
50
|
-
rules.label = "<#{key} [#{issue.type}]<BR/>#{word_wrap issue.summary}>"
|
|
51
|
-
end
|
|
46
|
+
issue_rules(&default_issue_rules)
|
|
47
|
+
link_rules(&default_link_rules)
|
|
52
48
|
end
|
|
53
49
|
|
|
54
50
|
def run
|
|
55
|
-
instance_eval(&@rules_block)
|
|
51
|
+
instance_eval(&@rules_block) if @rules_block
|
|
56
52
|
|
|
57
53
|
dot_graph = build_dot_graph
|
|
58
54
|
return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if dot_graph.nil?
|
|
@@ -187,9 +183,8 @@ class DependencyChart < ChartBase
|
|
|
187
183
|
return stdout.read
|
|
188
184
|
end
|
|
189
185
|
rescue # rubocop:disable Style/RescueStandardError
|
|
190
|
-
message =
|
|
191
|
-
|
|
192
|
-
puts message
|
|
186
|
+
message = 'Unable to generate the dependency chart because graphviz could not be found in the path.'
|
|
187
|
+
file_system.log message, also_write_to_stderr: true
|
|
193
188
|
message
|
|
194
189
|
end
|
|
195
190
|
|
|
@@ -219,4 +214,36 @@ class DependencyChart < ChartBase
|
|
|
219
214
|
end
|
|
220
215
|
end.join(separator)
|
|
221
216
|
end
|
|
217
|
+
|
|
218
|
+
def default_issue_rules
|
|
219
|
+
chart = self
|
|
220
|
+
lambda do |issue, rules|
|
|
221
|
+
is_done = issue.done?
|
|
222
|
+
|
|
223
|
+
key = issue.key
|
|
224
|
+
key = "<S>#{key} </S> " if is_done
|
|
225
|
+
line2 = +'<BR/>'
|
|
226
|
+
if issue.artificial?
|
|
227
|
+
line2 << '(unknown state)' # Shouldn't happen if we've done a full download but is still possible.
|
|
228
|
+
elsif is_done
|
|
229
|
+
line2 << 'Done'
|
|
230
|
+
else
|
|
231
|
+
started_at = issue.board.cycletime.started_stopped_times(issue).first
|
|
232
|
+
if started_at.nil?
|
|
233
|
+
line2 << 'Not started'
|
|
234
|
+
else
|
|
235
|
+
line2 << "Age: #{issue.board.cycletime.age(issue, today: chart.date_range.end)} days"
|
|
236
|
+
end
|
|
237
|
+
end
|
|
238
|
+
rules.label = "<#{key} [#{issue.type}]#{line2}<BR/>#{word_wrap issue.summary}>"
|
|
239
|
+
end
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
def default_link_rules
|
|
243
|
+
lambda do |link, rules|
|
|
244
|
+
rules.ignore if link.origin.done? && link.other_issue.done?
|
|
245
|
+
rules.ignore if link.name == 'Cloners'
|
|
246
|
+
rules.merge_bidirectional keep: 'outward'
|
|
247
|
+
end
|
|
248
|
+
end
|
|
222
249
|
end
|
|
@@ -19,4 +19,16 @@ class DownloadConfig
|
|
|
19
19
|
@rolling_date_count = count unless count.nil?
|
|
20
20
|
@rolling_date_count
|
|
21
21
|
end
|
|
22
|
+
|
|
23
|
+
def no_earlier_than date = :not_set
|
|
24
|
+
@no_earlier_than = Date.parse(date) unless date == :not_set
|
|
25
|
+
@no_earlier_than
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def start_date today:
|
|
29
|
+
date = today.to_date - @rolling_date_count if @rolling_date_count
|
|
30
|
+
date = [date, @no_earlier_than].max if date && @no_earlier_than
|
|
31
|
+
date = @no_earlier_than if date.nil? && @no_earlier_than
|
|
32
|
+
date
|
|
33
|
+
end
|
|
22
34
|
end
|