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.
- checksums.yaml +4 -4
- data/lib/jirametrics/aggregate_config.rb +19 -26
- data/lib/jirametrics/aging_work_bar_chart.rb +79 -54
- data/lib/jirametrics/aging_work_in_progress_chart.rb +106 -40
- data/lib/jirametrics/aging_work_table.rb +78 -43
- data/lib/jirametrics/anonymizer.rb +6 -5
- data/lib/jirametrics/blocked_stalled_change.rb +24 -4
- data/lib/jirametrics/board.rb +44 -15
- data/lib/jirametrics/board_config.rb +8 -4
- data/lib/jirametrics/board_movement_calculator.rb +147 -0
- data/lib/jirametrics/change_item.rb +31 -10
- data/lib/jirametrics/chart_base.rb +102 -61
- data/lib/jirametrics/columns_config.rb +4 -0
- data/lib/jirametrics/css_variable.rb +33 -0
- data/lib/jirametrics/cycletime_config.rb +59 -8
- data/lib/jirametrics/cycletime_histogram.rb +69 -4
- data/lib/jirametrics/cycletime_scatterplot.rb +11 -15
- data/lib/jirametrics/daily_wip_by_age_chart.rb +44 -20
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +37 -35
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +38 -0
- data/lib/jirametrics/daily_wip_chart.rb +61 -14
- data/lib/jirametrics/data_quality_report.rb +222 -41
- data/lib/jirametrics/dependency_chart.rb +54 -23
- data/lib/jirametrics/download_config.rb +12 -0
- data/lib/jirametrics/downloader.rb +76 -57
- data/lib/jirametrics/{story_point_accuracy_chart.rb → estimate_accuracy_chart.rb} +48 -33
- data/lib/jirametrics/examples/aggregated_project.rb +22 -39
- data/lib/jirametrics/examples/standard_project.rb +25 -49
- data/lib/jirametrics/expedited_chart.rb +28 -25
- data/lib/jirametrics/exporter.rb +59 -32
- data/lib/jirametrics/file_config.rb +34 -13
- data/lib/jirametrics/file_system.rb +48 -3
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
- data/lib/jirametrics/groupable_issue_chart.rb +2 -6
- data/lib/jirametrics/grouping_rules.rb +7 -1
- data/lib/jirametrics/hierarchy_table.rb +4 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +13 -16
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +28 -5
- data/lib/jirametrics/html/aging_work_table.erb +19 -25
- data/lib/jirametrics/html/cycletime_histogram.erb +83 -3
- data/lib/jirametrics/html/cycletime_scatterplot.erb +9 -12
- data/lib/jirametrics/html/daily_wip_chart.erb +17 -13
- data/lib/jirametrics/html/{story_point_accuracy_chart.erb → estimate_accuracy_chart.erb} +9 -4
- data/lib/jirametrics/html/expedited_chart.erb +10 -13
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
- data/lib/jirametrics/html/hierarchy_table.erb +2 -2
- data/lib/jirametrics/html/index.css +209 -0
- data/lib/jirametrics/html/index.erb +16 -39
- data/lib/jirametrics/html/sprint_burndown.erb +10 -14
- data/lib/jirametrics/html/throughput_chart.erb +10 -13
- data/lib/jirametrics/html_report_config.rb +108 -86
- data/lib/jirametrics/issue.rb +357 -96
- data/lib/jirametrics/jira_gateway.rb +29 -11
- data/lib/jirametrics/project_config.rb +256 -144
- data/lib/jirametrics/rules.rb +2 -2
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +10 -0
- data/lib/jirametrics/sprint_burndown.rb +24 -7
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +80 -39
- data/lib/jirametrics/throughput_chart.rb +12 -4
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics.rb +25 -7
- metadata +16 -17
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/experimental/generator.rb +0 -210
- data/lib/jirametrics/experimental/info.rb +0 -77
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
|
@@ -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,24 +90,37 @@ 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') || '' }
|
|
108
|
+
@entries.collect do |entry|
|
|
109
|
+
[
|
|
110
|
+
formatter.call(entry.started),
|
|
111
|
+
formatter.call(entry.stopped),
|
|
112
|
+
entry.issue
|
|
113
|
+
]
|
|
114
|
+
end
|
|
75
115
|
end
|
|
76
116
|
|
|
77
117
|
def entries_with_problems
|
|
78
118
|
@entries.reject { |entry| entry.problems.empty? }
|
|
79
119
|
end
|
|
80
120
|
|
|
81
|
-
def category_name_for status_name:, board:
|
|
82
|
-
board.possible_statuses.find { |status| status.name == status_name }&.category_name
|
|
83
|
-
end
|
|
84
|
-
|
|
85
121
|
def initialize_entries
|
|
86
122
|
@entries = @issues.filter_map do |issue|
|
|
87
|
-
|
|
88
|
-
started = cycletime.started_time(issue)
|
|
89
|
-
stopped = cycletime.stopped_time(issue)
|
|
123
|
+
started, stopped = issue.board.cycletime.started_stopped_times(issue)
|
|
90
124
|
next if stopped && stopped < time_range.begin
|
|
91
125
|
next if started && started > time_range.end
|
|
92
126
|
|
|
@@ -107,10 +141,8 @@ class DataQualityReport < ChartBase
|
|
|
107
141
|
def scan_for_completed_issues_without_a_start_time entry:
|
|
108
142
|
return unless entry.stopped && entry.started.nil?
|
|
109
143
|
|
|
110
|
-
status_names = entry.issue.
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
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
|
|
114
146
|
end
|
|
115
147
|
|
|
116
148
|
entry.report(
|
|
@@ -125,14 +157,14 @@ class DataQualityReport < ChartBase
|
|
|
125
157
|
changes_after_done = entry.issue.changes.select do |change|
|
|
126
158
|
change.status? && change.time >= entry.stopped
|
|
127
159
|
end
|
|
128
|
-
done_status = changes_after_done.shift
|
|
160
|
+
done_status = changes_after_done.shift
|
|
129
161
|
|
|
130
162
|
return if changes_after_done.empty?
|
|
131
163
|
|
|
132
164
|
board = entry.issue.board
|
|
133
165
|
problem = "Completed on #{entry.stopped.to_date} with status #{format_status done_status, board: board}."
|
|
134
166
|
changes_after_done.each do |change|
|
|
135
|
-
problem << " Changed to #{format_status change
|
|
167
|
+
problem << " Changed to #{format_status change, board: board} on #{change.time.to_date}."
|
|
136
168
|
end
|
|
137
169
|
entry.report(
|
|
138
170
|
problem_key: :status_changes_after_done,
|
|
@@ -152,11 +184,11 @@ class DataQualityReport < ChartBase
|
|
|
152
184
|
index = entry.issue.board.visible_columns.find_index { |column| column.status_ids.include? change.value_id }
|
|
153
185
|
if index.nil?
|
|
154
186
|
# If it's a backlog status then ignore it. Not supposed to be visible.
|
|
155
|
-
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))
|
|
156
188
|
|
|
157
|
-
detail = "Status #{format_status change
|
|
158
|
-
if issue.board.possible_statuses.
|
|
159
|
-
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?"
|
|
160
192
|
end
|
|
161
193
|
|
|
162
194
|
# If it's been moved back to backlog then it's on a different report. Ignore it here.
|
|
@@ -166,24 +198,24 @@ class DataQualityReport < ChartBase
|
|
|
166
198
|
elsif change.old_value.nil?
|
|
167
199
|
# Do nothing
|
|
168
200
|
elsif index < last_index
|
|
169
|
-
new_category =
|
|
170
|
-
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
|
|
171
203
|
|
|
172
204
|
if new_category == old_category
|
|
173
205
|
entry.report(
|
|
174
206
|
problem_key: :backwords_through_statuses,
|
|
175
|
-
detail: "Moved from #{format_status change
|
|
176
|
-
" to #{format_status change
|
|
207
|
+
detail: "Moved from #{format_status change, use_old_status: true, board: board}" \
|
|
208
|
+
" to #{format_status change, board: board}" \
|
|
177
209
|
" on #{change.time.to_date}"
|
|
178
210
|
)
|
|
179
211
|
else
|
|
180
212
|
entry.report(
|
|
181
213
|
problem_key: :backwards_through_status_categories,
|
|
182
|
-
detail: "Moved from #{format_status change
|
|
183
|
-
" to #{format_status change
|
|
184
|
-
" on #{change.time.to_date},
|
|
185
|
-
" crossing from category #{format_status
|
|
186
|
-
" 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}."
|
|
187
219
|
)
|
|
188
220
|
end
|
|
189
221
|
end
|
|
@@ -192,16 +224,14 @@ class DataQualityReport < ChartBase
|
|
|
192
224
|
end
|
|
193
225
|
|
|
194
226
|
def scan_for_issues_not_created_in_a_backlog_status entry:, backlog_statuses:
|
|
195
|
-
return if backlog_statuses.empty?
|
|
196
|
-
|
|
197
227
|
creation_change = entry.issue.changes.find { |issue| issue.status? }
|
|
198
228
|
|
|
199
229
|
return if backlog_statuses.any? { |status| status.id == creation_change.value_id }
|
|
200
230
|
|
|
201
|
-
status_string = backlog_statuses.collect { |s| format_status s
|
|
231
|
+
status_string = backlog_statuses.collect { |s| format_status s, board: entry.issue.board }.join(', ')
|
|
202
232
|
entry.report(
|
|
203
233
|
problem_key: :created_in_wrong_status,
|
|
204
|
-
detail: "Created in #{format_status creation_change
|
|
234
|
+
detail: "Created in #{format_status creation_change, board: entry.issue.board}, " \
|
|
205
235
|
"which is not one of the backlog statuses for this board: #{status_string}"
|
|
206
236
|
)
|
|
207
237
|
end
|
|
@@ -220,14 +250,13 @@ class DataQualityReport < ChartBase
|
|
|
220
250
|
|
|
221
251
|
started_subtasks = []
|
|
222
252
|
entry.issue.subtasks.each do |subtask|
|
|
223
|
-
started_subtasks << subtask if subtask.board.cycletime.
|
|
253
|
+
started_subtasks << subtask if subtask.board.cycletime.started_stopped_times(subtask).first
|
|
224
254
|
end
|
|
225
255
|
|
|
226
256
|
return if started_subtasks.empty?
|
|
227
257
|
|
|
228
258
|
subtask_labels = started_subtasks.collect do |subtask|
|
|
229
|
-
|
|
230
|
-
"#{subtask.summary[..50].inspect}"
|
|
259
|
+
subtask_label(subtask)
|
|
231
260
|
end
|
|
232
261
|
entry.report(
|
|
233
262
|
problem_key: :issue_not_started_but_subtasks_have,
|
|
@@ -235,6 +264,61 @@ class DataQualityReport < ChartBase
|
|
|
235
264
|
)
|
|
236
265
|
end
|
|
237
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
|
+
|
|
238
322
|
def label_issues number
|
|
239
323
|
return '1 item' if number == 1
|
|
240
324
|
|
|
@@ -242,10 +326,10 @@ class DataQualityReport < ChartBase
|
|
|
242
326
|
end
|
|
243
327
|
|
|
244
328
|
def scan_for_discarded_data entry:
|
|
245
|
-
hash = @
|
|
329
|
+
hash = @discarded_changes_data&.find { |a| a[:issue] == entry.issue }
|
|
246
330
|
return if hash.nil?
|
|
247
331
|
|
|
248
|
-
old_start_time = hash[:
|
|
332
|
+
old_start_time = hash[:original_start_time]
|
|
249
333
|
cutoff_time = hash[:cutoff_time]
|
|
250
334
|
|
|
251
335
|
old_start_date = old_start_time.to_date
|
|
@@ -271,8 +355,105 @@ class DataQualityReport < ChartBase
|
|
|
271
355
|
board_names = entry_list.collect { |entry| entry.issue.board.name.inspect }
|
|
272
356
|
entry_list.first.report(
|
|
273
357
|
problem_key: :issue_on_multiple_boards,
|
|
274
|
-
detail: "Found on boards: #{board_names.join(', ')}"
|
|
358
|
+
detail: "Found on boards: #{board_names.sort.join(', ')}"
|
|
275
359
|
)
|
|
276
360
|
end
|
|
277
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
|
|
278
459
|
end
|
|
@@ -42,13 +42,9 @@ 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
|
|
@@ -84,7 +80,8 @@ class DependencyChart < ChartBase
|
|
|
84
80
|
result << issue_link.other_issue.key.inspect
|
|
85
81
|
result << '['
|
|
86
82
|
result << 'label=' << (link_rules.label || issue_link.label).inspect
|
|
87
|
-
result << ',color=' << (link_rules.line_color || '
|
|
83
|
+
result << ',color=' << (link_rules.line_color || 'gray').inspect
|
|
84
|
+
result << ',fontcolor=' << (link_rules.line_color || 'gray').inspect
|
|
88
85
|
result << ',dir=both' if link_rules.bidirectional_arrows?
|
|
89
86
|
result << '];'
|
|
90
87
|
result
|
|
@@ -101,12 +98,26 @@ class DependencyChart < ChartBase
|
|
|
101
98
|
tooltip = "#{issue.key}: #{issue.summary}"
|
|
102
99
|
result << ",tooltip=#{tooltip[0..80].inspect}"
|
|
103
100
|
unless issue_rules.color == :none
|
|
104
|
-
result << %(,style=filled,fillcolor="#{issue_rules.color || color_for(type: issue.type
|
|
101
|
+
result << %(,style=filled,fillcolor="#{issue_rules.color || color_for(type: issue.type)}")
|
|
105
102
|
end
|
|
106
103
|
result << ']'
|
|
107
104
|
result
|
|
108
105
|
end
|
|
109
106
|
|
|
107
|
+
# This used to pull colours from chart_base but the migration to CSS colours kept breaking
|
|
108
|
+
# this chart so we moved it here, until we're finished with the rest. TODO: Revisit whether
|
|
109
|
+
# this can also use customizable CSS colours
|
|
110
|
+
def color_for type:
|
|
111
|
+
@chart_colors = {
|
|
112
|
+
'Story' => '#90EE90',
|
|
113
|
+
'Task' => '#87CEFA',
|
|
114
|
+
'Bug' => '#ffdab9',
|
|
115
|
+
'Defect' => '#ffdab9',
|
|
116
|
+
'Epic' => '#fafad2',
|
|
117
|
+
'Spike' => '#DDA0DD' # light purple
|
|
118
|
+
}[type] ||= random_color
|
|
119
|
+
end
|
|
120
|
+
|
|
110
121
|
def build_dot_graph
|
|
111
122
|
issue_links = find_links
|
|
112
123
|
|
|
@@ -148,6 +159,7 @@ class DependencyChart < ChartBase
|
|
|
148
159
|
dot_graph = []
|
|
149
160
|
dot_graph << 'digraph mygraph {'
|
|
150
161
|
dot_graph << 'rankdir=LR'
|
|
162
|
+
dot_graph << 'bgcolor="transparent"'
|
|
151
163
|
|
|
152
164
|
# Sort the keys so they are proccessed in a deterministic order.
|
|
153
165
|
visible_issues.values.sort_by(&:key_as_i).each do |issue|
|
|
@@ -171,24 +183,11 @@ class DependencyChart < ChartBase
|
|
|
171
183
|
return stdout.read
|
|
172
184
|
end
|
|
173
185
|
rescue # rubocop:disable Style/RescueStandardError
|
|
174
|
-
message =
|
|
175
|
-
|
|
176
|
-
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
|
|
177
188
|
message
|
|
178
189
|
end
|
|
179
190
|
|
|
180
|
-
def default_color_for_issue issue
|
|
181
|
-
{
|
|
182
|
-
'Story' => '#90EE90',
|
|
183
|
-
'Task' => '#87CEFA',
|
|
184
|
-
'Bug' => '#f08080',
|
|
185
|
-
'Defect' => '#f08080',
|
|
186
|
-
'Epic' => '#fafad2',
|
|
187
|
-
'Spike' => '#7fffd4',
|
|
188
|
-
'Sub-task' => '#dcdcdc'
|
|
189
|
-
}[issue.type]
|
|
190
|
-
end
|
|
191
|
-
|
|
192
191
|
def shrink_svg svg
|
|
193
192
|
scale = 0.8
|
|
194
193
|
svg.sub(/width="([\d.]+)pt" height="([\d.]+)pt"/) do
|
|
@@ -215,4 +214,36 @@ class DependencyChart < ChartBase
|
|
|
215
214
|
end
|
|
216
215
|
end.join(separator)
|
|
217
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
|
|
218
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
|