jirametrics 2.0 → 2.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +19 -26
  3. data/lib/jirametrics/aging_work_bar_chart.rb +79 -54
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +106 -40
  5. data/lib/jirametrics/aging_work_table.rb +78 -43
  6. data/lib/jirametrics/anonymizer.rb +6 -5
  7. data/lib/jirametrics/blocked_stalled_change.rb +24 -4
  8. data/lib/jirametrics/board.rb +44 -15
  9. data/lib/jirametrics/board_config.rb +8 -4
  10. data/lib/jirametrics/board_movement_calculator.rb +147 -0
  11. data/lib/jirametrics/change_item.rb +31 -10
  12. data/lib/jirametrics/chart_base.rb +102 -61
  13. data/lib/jirametrics/columns_config.rb +4 -0
  14. data/lib/jirametrics/css_variable.rb +33 -0
  15. data/lib/jirametrics/cycletime_config.rb +59 -8
  16. data/lib/jirametrics/cycletime_histogram.rb +69 -4
  17. data/lib/jirametrics/cycletime_scatterplot.rb +11 -15
  18. data/lib/jirametrics/daily_wip_by_age_chart.rb +44 -20
  19. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +37 -35
  20. data/lib/jirametrics/daily_wip_by_parent_chart.rb +38 -0
  21. data/lib/jirametrics/daily_wip_chart.rb +61 -14
  22. data/lib/jirametrics/data_quality_report.rb +222 -41
  23. data/lib/jirametrics/dependency_chart.rb +54 -23
  24. data/lib/jirametrics/download_config.rb +12 -0
  25. data/lib/jirametrics/downloader.rb +76 -57
  26. data/lib/jirametrics/{story_point_accuracy_chart.rb → estimate_accuracy_chart.rb} +48 -33
  27. data/lib/jirametrics/examples/aggregated_project.rb +22 -39
  28. data/lib/jirametrics/examples/standard_project.rb +25 -49
  29. data/lib/jirametrics/expedited_chart.rb +28 -25
  30. data/lib/jirametrics/exporter.rb +59 -32
  31. data/lib/jirametrics/file_config.rb +34 -13
  32. data/lib/jirametrics/file_system.rb +48 -3
  33. data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
  34. data/lib/jirametrics/groupable_issue_chart.rb +2 -6
  35. data/lib/jirametrics/grouping_rules.rb +7 -1
  36. data/lib/jirametrics/hierarchy_table.rb +4 -4
  37. data/lib/jirametrics/html/aging_work_bar_chart.erb +13 -16
  38. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +28 -5
  39. data/lib/jirametrics/html/aging_work_table.erb +19 -25
  40. data/lib/jirametrics/html/cycletime_histogram.erb +83 -3
  41. data/lib/jirametrics/html/cycletime_scatterplot.erb +9 -12
  42. data/lib/jirametrics/html/daily_wip_chart.erb +17 -13
  43. data/lib/jirametrics/html/{story_point_accuracy_chart.erb → estimate_accuracy_chart.erb} +9 -4
  44. data/lib/jirametrics/html/expedited_chart.erb +10 -13
  45. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
  46. data/lib/jirametrics/html/hierarchy_table.erb +2 -2
  47. data/lib/jirametrics/html/index.css +209 -0
  48. data/lib/jirametrics/html/index.erb +16 -39
  49. data/lib/jirametrics/html/sprint_burndown.erb +10 -14
  50. data/lib/jirametrics/html/throughput_chart.erb +10 -13
  51. data/lib/jirametrics/html_report_config.rb +108 -86
  52. data/lib/jirametrics/issue.rb +357 -96
  53. data/lib/jirametrics/jira_gateway.rb +29 -11
  54. data/lib/jirametrics/project_config.rb +256 -144
  55. data/lib/jirametrics/rules.rb +2 -2
  56. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  57. data/lib/jirametrics/settings.json +10 -0
  58. data/lib/jirametrics/sprint_burndown.rb +24 -7
  59. data/lib/jirametrics/status.rb +84 -19
  60. data/lib/jirametrics/status_collection.rb +80 -39
  61. data/lib/jirametrics/throughput_chart.rb +12 -4
  62. data/lib/jirametrics/value_equality.rb +2 -2
  63. data/lib/jirametrics.rb +25 -7
  64. metadata +16 -17
  65. data/lib/jirametrics/discard_changes_before.rb +0 -37
  66. data/lib/jirametrics/experimental/generator.rb +0 -210
  67. data/lib/jirametrics/experimental/info.rb +0 -77
  68. data/lib/jirametrics/html/data_quality_report.erb +0 -126
@@ -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
@@ -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
- 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
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
- @entries.collect { |entry| [entry.started.to_s, entry.stopped.to_s, entry.issue] }
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
- cycletime = issue.board.cycletime
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.changes.filter_map do |change|
111
- next unless change.status?
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.value
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.value, board: board} on #{change.time.to_date}."
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? change.value_id
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.value, board: board} is not on the board"
158
- if issue.board.possible_statuses.expand_statuses(change.value).empty?
159
- 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?"
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 = category_name_for(status_name: change.value, board: board)
170
- 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
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.old_value, board: board}" \
176
- " 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}" \
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.old_value, board: board}" \
183
- " to #{format_status change.value, board: board}" \
184
- " on #{change.time.to_date}, " \
185
- " crossing from category #{format_status old_category, board: board, is_category: true}" \
186
- " 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}."
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.name, board: entry.issue.board }.join(', ')
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.value, board: entry.issue.board}, " \
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.started_time(subtask)
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
- "Started subtask: #{link_to_issue(subtask)} (#{format_status subtask.status.name, board: entry.issue.board}) " \
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 = @original_issue_times[entry.issue]
329
+ hash = @discarded_changes_data&.find { |a| a[:issue] == entry.issue }
246
330
  return if hash.nil?
247
331
 
248
- old_start_time = hash[:started_time]
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 do |issue, rules|
48
- key = issue.key
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 || 'black').inspect
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, shade: :light)}")
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 = "Unable to execute the command 'dot' which is part of graphviz. " \
175
- 'Ensure that graphviz is installed and that dot is in your path.'
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