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.
Files changed (61) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +9 -4
  3. data/lib/jirametrics/aging_work_bar_chart.rb +13 -11
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
  5. data/lib/jirametrics/aging_work_table.rb +54 -7
  6. data/lib/jirametrics/blocked_stalled_change.rb +1 -1
  7. data/lib/jirametrics/board.rb +44 -15
  8. data/lib/jirametrics/board_config.rb +7 -3
  9. data/lib/jirametrics/board_movement_calculator.rb +147 -0
  10. data/lib/jirametrics/change_item.rb +19 -6
  11. data/lib/jirametrics/chart_base.rb +63 -27
  12. data/lib/jirametrics/css_variable.rb +1 -1
  13. data/lib/jirametrics/cycletime_config.rb +59 -8
  14. data/lib/jirametrics/cycletime_histogram.rb +68 -3
  15. data/lib/jirametrics/cycletime_scatterplot.rb +3 -6
  16. data/lib/jirametrics/daily_wip_by_age_chart.rb +2 -4
  17. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +2 -2
  18. data/lib/jirametrics/daily_wip_by_parent_chart.rb +0 -4
  19. data/lib/jirametrics/daily_wip_chart.rb +7 -9
  20. data/lib/jirametrics/data_quality_report.rb +219 -41
  21. data/lib/jirametrics/dependency_chart.rb +37 -10
  22. data/lib/jirametrics/download_config.rb +12 -0
  23. data/lib/jirametrics/downloader.rb +68 -50
  24. data/lib/jirametrics/estimate_accuracy_chart.rb +1 -2
  25. data/lib/jirametrics/examples/aggregated_project.rb +7 -21
  26. data/lib/jirametrics/examples/standard_project.rb +18 -34
  27. data/lib/jirametrics/expedited_chart.rb +8 -9
  28. data/lib/jirametrics/exporter.rb +28 -11
  29. data/lib/jirametrics/file_config.rb +23 -6
  30. data/lib/jirametrics/file_system.rb +39 -3
  31. data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
  32. data/lib/jirametrics/groupable_issue_chart.rb +1 -3
  33. data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
  34. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
  35. data/lib/jirametrics/html/aging_work_table.erb +6 -4
  36. data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
  37. data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
  38. data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
  39. data/lib/jirametrics/html/expedited_chart.erb +1 -10
  40. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
  41. data/lib/jirametrics/html/hierarchy_table.erb +1 -1
  42. data/lib/jirametrics/html/index.css +28 -5
  43. data/lib/jirametrics/html/index.erb +8 -4
  44. data/lib/jirametrics/html/sprint_burndown.erb +1 -10
  45. data/lib/jirametrics/html/throughput_chart.erb +1 -10
  46. data/lib/jirametrics/html_report_config.rb +33 -23
  47. data/lib/jirametrics/issue.rb +232 -47
  48. data/lib/jirametrics/jira_gateway.rb +16 -3
  49. data/lib/jirametrics/project_config.rb +245 -134
  50. data/lib/jirametrics/rules.rb +2 -2
  51. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  52. data/lib/jirametrics/settings.json +5 -2
  53. data/lib/jirametrics/sprint_burndown.rb +3 -3
  54. data/lib/jirametrics/status.rb +84 -19
  55. data/lib/jirametrics/status_collection.rb +77 -39
  56. data/lib/jirametrics/throughput_chart.rb +1 -1
  57. data/lib/jirametrics/value_equality.rb +2 -2
  58. data/lib/jirametrics.rb +22 -6
  59. metadata +10 -13
  60. data/lib/jirametrics/discard_changes_before.rb +0 -37
  61. 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
- unless @group_by_block
26
- grouping_rules do |issue, rules|
27
- default_grouping_rules issue: issue, rules: rules
28
- end
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
- cycletime = issue.board.cycletime
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: "Trendline",
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 :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,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
- format = '%Y-%m-%d %H:%M:%S %z'
107
+ formatter = ->(time) { time&.strftime('%Y-%m-%d %H:%M:%S %z') || '' }
75
108
  @entries.collect do |entry|
76
- [entry.started&.strftime(format) || '', entry.stopped&.strftime(format) || '', entry.issue]
109
+ [
110
+ formatter.call(entry.started),
111
+ formatter.call(entry.stopped),
112
+ entry.issue
113
+ ]
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
- cycletime = issue.board.cycletime
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.changes.filter_map do |change|
114
- next unless change.status?
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.value
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.value, board: board} on #{change.time.to_date}."
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? change.value_id
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.value, board: board} is not on the board"
161
- if issue.board.possible_statuses.expand_statuses(change.value).empty?
162
- 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?"
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 = category_name_for(status_name: change.value, board: board)
173
- 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
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.old_value, board: board}" \
179
- " 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}" \
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.old_value, board: board}" \
186
- " to #{format_status change.value, board: board}" \
187
- " on #{change.time.to_date}, " \
188
- " crossing from category #{format_status old_category, board: board, is_category: true}" \
189
- " 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}."
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.name, board: entry.issue.board }.join(', ')
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.value, board: entry.issue.board}, " \
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.started_time(subtask)
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
- "Started subtask: #{link_to_issue(subtask)} (#{format_status subtask.status.name, board: entry.issue.board}) " \
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 = @original_issue_times[entry.issue]
329
+ hash = @discarded_changes_data&.find { |a| a[:issue] == entry.issue }
249
330
  return if hash.nil?
250
331
 
251
- old_start_time = hash[:started_time]
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 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
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 = "Unable to execute the command 'dot' which is part of graphviz. " \
191
- 'Ensure that graphviz is installed and that dot is in your path.'
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