jirametrics 2.6 → 2.7.1

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 (40) hide show
  1. checksums.yaml +4 -4
  2. data/lib/jirametrics/aggregate_config.rb +6 -1
  3. data/lib/jirametrics/aging_work_bar_chart.rb +6 -6
  4. data/lib/jirametrics/aging_work_in_progress_chart.rb +1 -1
  5. data/lib/jirametrics/aging_work_table.rb +4 -5
  6. data/lib/jirametrics/blocked_stalled_change.rb +1 -1
  7. data/lib/jirametrics/board.rb +14 -12
  8. data/lib/jirametrics/chart_base.rb +16 -10
  9. data/lib/jirametrics/cycletime_config.rb +26 -7
  10. data/lib/jirametrics/cycletime_histogram.rb +1 -1
  11. data/lib/jirametrics/cycletime_scatterplot.rb +3 -6
  12. data/lib/jirametrics/daily_wip_by_age_chart.rb +2 -4
  13. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +2 -2
  14. data/lib/jirametrics/daily_wip_by_parent_chart.rb +0 -4
  15. data/lib/jirametrics/daily_wip_chart.rb +7 -9
  16. data/lib/jirametrics/data_quality_report.rb +166 -7
  17. data/lib/jirametrics/dependency_chart.rb +3 -4
  18. data/lib/jirametrics/discard_changes_before.rb +1 -1
  19. data/lib/jirametrics/downloader.rb +14 -13
  20. data/lib/jirametrics/estimate_accuracy_chart.rb +1 -2
  21. data/lib/jirametrics/examples/aggregated_project.rb +1 -3
  22. data/lib/jirametrics/examples/standard_project.rb +10 -9
  23. data/lib/jirametrics/expedited_chart.rb +1 -2
  24. data/lib/jirametrics/exporter.rb +25 -0
  25. data/lib/jirametrics/file_system.rb +1 -1
  26. data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
  27. data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
  28. data/lib/jirametrics/html/index.css +10 -3
  29. data/lib/jirametrics/html_report_config.rb +2 -1
  30. data/lib/jirametrics/issue.rb +63 -11
  31. data/lib/jirametrics/jira_gateway.rb +1 -1
  32. data/lib/jirametrics/project_config.rb +27 -19
  33. data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
  34. data/lib/jirametrics/sprint_burndown.rb +2 -2
  35. data/lib/jirametrics/status.rb +1 -1
  36. data/lib/jirametrics/status_collection.rb +4 -0
  37. data/lib/jirametrics/throughput_chart.rb +1 -1
  38. data/lib/jirametrics.rb +15 -5
  39. metadata +8 -7
  40. data/lib/jirametrics/html/data_quality_report.erb +0 -126
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 749f92d4329a18999bf8954aee5b41955047ef263d7a37fe766b2233d697a8c9
4
- data.tar.gz: f174b2241d23a3d7a9ff34b20093ff0716406197563df4b7f4f6447773fc7a51
3
+ metadata.gz: d5bb71d8f3c6ab4cf1fa17ad93ad1c00ba1b8381c196bc15a054095a5a91917a
4
+ data.tar.gz: 790abb0404719d6b55d833ad76d34121875997f3ec3967f69f5d467fae203752
5
5
  SHA512:
6
- metadata.gz: f89eb6c1a9655163d91b19d9f5b92f79a7d714e6b45fe53e972248f20c5d788a356e6d5be0cbfe24a70665381bf0a7a28ce898b0e31cef9c4c3349d8ce335558
7
- data.tar.gz: b95ee06e9b3ef78aba0635aa634fa56082da2aa8bfa759f7e38be2eb2b6889a04a23e99dc8aa9a021391d8db4fe797247fb2b9379c5801fc2c5ec7a3bc3ff103
6
+ metadata.gz: f71c2ae123a13435a1147ceaadf7f10364a6cdf1421b4f8491633936189e3744e58da4d99be6c43c6e8034d5f27afc470498532157ac15a7d4459a0399f6fc12
7
+ data.tar.gz: f33eb0ea450d6002b09d84311823fc20847327bb78dca5fa218c3f724a23f02d49f4ef0183e9dc02499e0eea1f5916147396538751192974926e1c197f4f0bb5
@@ -62,7 +62,12 @@ class AggregateConfig
62
62
  'the first file section'
63
63
  end
64
64
  end
65
- @project_config.add_issues issues
65
+
66
+ if issues.nil?
67
+ log "No issues found for #{project_name}"
68
+ else
69
+ @project_config.add_issues issues
70
+ end
66
71
  end
67
72
 
68
73
  def find_time_range projects:
@@ -53,8 +53,8 @@ class AgingWorkBarChart < ChartBase
53
53
  percentage_line_x = date_range.end - calculate_percent_line if percentage
54
54
 
55
55
  if aging_issues.empty?
56
- @description_text = "<p>There is no aging work</p>"
57
- return render_top_text(binding) #if aging_issues.empty?
56
+ @description_text = '<p>There is no aging work</p>'
57
+ return render_top_text(binding)
58
58
  end
59
59
 
60
60
  wrap_and_render(binding, __FILE__)
@@ -62,7 +62,7 @@ class AgingWorkBarChart < ChartBase
62
62
 
63
63
  def data_sets_for_one_issue issue:, today:
64
64
  cycletime = issue.board.cycletime
65
- issue_start_time = cycletime.started_time(issue)
65
+ issue_start_time, _stopped_time = cycletime.started_stopped_times(issue)
66
66
  issue_start_date = issue_start_time.to_date
67
67
  issue_label = "[#{label_days cycletime.age(issue, today: today)}] #{issue.key}: #{issue.summary}"[0..60]
68
68
  [
@@ -92,8 +92,8 @@ class AgingWorkBarChart < ChartBase
92
92
 
93
93
  def select_aging_issues issues:
94
94
  issues.select do |issue|
95
- cycletime = issue.board.cycletime
96
- cycletime.started_time(issue) && cycletime.stopped_time(issue).nil?
95
+ started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
96
+ started_time && stopped_time.nil?
97
97
  end
98
98
  end
99
99
 
@@ -107,7 +107,7 @@ class AgingWorkBarChart < ChartBase
107
107
  def status_data_sets issue:, label:, today:
108
108
  cycletime = issue.board.cycletime
109
109
 
110
- issue_started_time = cycletime.started_time(issue)
110
+ issue_started_time, _issue_stopped_time = cycletime.started_stopped_times(issue)
111
111
 
112
112
  previous_start = nil
113
113
  previous_status = nil
@@ -114,7 +114,7 @@ class AgingWorkInProgressChart < ChartBase
114
114
  def ages_of_issues_that_crossed_column_boundary issues:, status_ids:
115
115
  issues.filter_map do |issue|
116
116
  stop = issue.first_time_in_status(*status_ids)
117
- start = issue.board.cycletime.started_time(issue)
117
+ start, = issue.board.cycletime.started_stopped_times(issue)
118
118
 
119
119
  # Skip if either it hasn't crossed the boundary or we can't tell when it started.
120
120
  next if stop.nil? || start.nil?
@@ -36,16 +36,15 @@ class AgingWorkTable < ChartBase
36
36
 
37
37
  def expedited_but_not_started
38
38
  @issues.select do |issue|
39
- cycletime = issue.board.cycletime
40
- cycletime.started_time(issue).nil? && cycletime.stopped_time(issue).nil? && issue.expedited?
39
+ started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
40
+ started_time.nil? && stopped_time.nil? && issue.expedited?
41
41
  end.sort_by(&:created)
42
42
  end
43
43
 
44
44
  def select_aging_issues
45
45
  aging_issues = @issues.select do |issue|
46
46
  cycletime = issue.board.cycletime
47
- started = cycletime.started_time(issue)
48
- stopped = cycletime.stopped_time(issue)
47
+ started, stopped = cycletime.started_stopped_times(issue)
49
48
  next false if started.nil? || stopped
50
49
  next true if issue.blocked_on_date?(@today, end_time: time_range.end) || issue.expedited?
51
50
 
@@ -64,7 +63,7 @@ class AgingWorkTable < ChartBase
64
63
  end
65
64
 
66
65
  def blocked_text issue
67
- started_time = issue.board.cycletime.started_time(issue)
66
+ started_time, _stopped_time = issue.board.cycletime.started_stopped_times(issue)
68
67
  return nil if started_time.nil?
69
68
 
70
69
  current = issue.blocked_stalled_changes(end_time: time_range.end)[-1]
@@ -47,7 +47,7 @@ class BlockedStalledChange
47
47
  end
48
48
 
49
49
  def inspect
50
- text = +"BlockedStalledChange(time: '#{@time}', "
50
+ text = "BlockedStalledChange(time: '#{@time}', "
51
51
  if active?
52
52
  text << 'Active'
53
53
  else
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Board
4
- attr_reader :visible_columns, :raw, :possible_statuses, :sprints, :backlog_statuses
4
+ attr_reader :visible_columns, :raw, :possible_statuses, :sprints, :board_type
5
5
  attr_accessor :cycletime, :project_config
6
6
 
7
7
  def initialize raw:, possible_statuses: StatusCollection.new
@@ -15,24 +15,26 @@ class Board
15
15
  # For a Kanban board, the first column here will always be called 'Backlog' and will NOT be
16
16
  # visible on the board. If the board is configured to have a kanban backlog then it will have
17
17
  # statuses matched to it and otherwise, there will be no statuses.
18
- if kanban?
19
- @backlog_statuses = @possible_statuses.expand_statuses(status_ids_from_column columns[0]) do |unknown_status|
20
- # There is a status defined as being 'backlog' that is no longer being returned in statuses.
21
- # We used to display a warning for this but honestly, there is nothing that anyone can do about it
22
- # so now we just quietly ignore it.
23
- end
24
- columns = columns[1..]
25
- else
26
- # We currently don't know how to get the backlog status for a Scrum board
27
- @backlog_statuses = []
28
- end
18
+ columns = columns[1..] if kanban?
29
19
 
20
+ @backlog_statuses = []
30
21
  @visible_columns = columns.filter_map do |column|
31
22
  # It's possible for a column to be defined without any statuses and in this case, it won't be visible.
32
23
  BoardColumn.new column unless status_ids_from_column(column).empty?
33
24
  end
34
25
  end
35
26
 
27
+ def backlog_statuses
28
+ if @backlog_statuses.empty? && kanban?
29
+ status_ids = status_ids_from_column raw['columnConfig']['columns'].first
30
+ @backlog_statuses = @possible_statuses.expand_statuses(status_ids) do |unknown_status|
31
+ # If a status is returned here that is no longer in the system then there's nothing useful
32
+ # we can do about it. Ignore it.
33
+ end
34
+ end
35
+ @backlog_statuses
36
+ end
37
+
36
38
  def server_url_prefix
37
39
  raise "Cannot parse self: #{@raw['self'].inspect}" unless @raw['self'] =~ /^(https?:\/\/.+)\/rest\//
38
40
 
@@ -25,6 +25,14 @@ class ChartBase
25
25
  @aggregated_project
26
26
  end
27
27
 
28
+ def html_directory
29
+ pathname = Pathname.new(File.realpath(__FILE__))
30
+ # basename = pathname.basename.to_s
31
+ # raise "Unexpected filename #{basename.inspect}" unless basename.match?(/^(.+)\.rb$/)
32
+
33
+ "#{pathname.dirname}/html"
34
+ end
35
+
28
36
  def render caller_binding, file
29
37
  pathname = Pathname.new(File.realpath(file))
30
38
  basename = pathname.basename.to_s
@@ -33,8 +41,8 @@ class ChartBase
33
41
  # Insert a incrementing chart_id so that all the chart names on the page are unique
34
42
  caller_binding.eval "chart_id='chart#{next_id}'" # chart_id=chart3
35
43
 
36
- @html_directory = "#{pathname.dirname}/html"
37
- erb = ERB.new file_system.load "#{@html_directory}/#{$1}.erb"
44
+ # @html_directory = "#{pathname.dirname}/html"
45
+ erb = ERB.new file_system.load "#{html_directory}/#{$1}.erb"
38
46
  erb.result(caller_binding)
39
47
  end
40
48
 
@@ -100,7 +108,7 @@ class ChartBase
100
108
  issues_id = next_id
101
109
 
102
110
  issue_descriptions.sort! { |a, b| a[0].key_as_i <=> b[0].key_as_i }
103
- erb = ERB.new file_system.load "#{@html_directory}/collapsible_issues_panel.erb"
111
+ erb = ERB.new file_system.load File.join(html_directory, 'collapsible_issues_panel.erb')
104
112
  erb.result(binding)
105
113
  end
106
114
 
@@ -144,8 +152,7 @@ class ChartBase
144
152
  def completed_issues_in_range include_unstarted: false
145
153
  issues.select do |issue|
146
154
  cycletime = issue.board.cycletime
147
- stopped_time = cycletime.stopped_time(issue)
148
- started_time = cycletime.started_time(issue)
155
+ started_time, stopped_time = cycletime.started_stopped_times(issue)
149
156
 
150
157
  stopped_time &&
151
158
  date_range.include?(stopped_time.to_date) && # Remove outside range
@@ -179,7 +186,7 @@ class ChartBase
179
186
  def format_status name_or_id, board:, is_category: false
180
187
  begin
181
188
  statuses = board.possible_statuses.expand_statuses([name_or_id])
182
- rescue StatusNotFoundError => e
189
+ rescue StatusNotFoundError
183
190
  return "<span style='color: red'>#{name_or_id}</span>"
184
191
  end
185
192
 
@@ -189,10 +196,9 @@ class ChartBase
189
196
  visibility = ''
190
197
  if is_category == false && board.visible_columns.none? { |column| column.status_ids.include? status.id }
191
198
  visibility = icon_span(
192
- title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
193
- icon: ' 👀'
194
- )
195
-
199
+ title: "Not visible: The status #{status.name.inspect} is not mapped to any column and will not be visible",
200
+ icon: ' 👀'
201
+ )
196
202
  end
197
203
  text = is_category ? status.category_name : status.name
198
204
  "<span title='Category: #{status.category_name}'>#{color_block color.name} #{text}</span>#{visibility}"
@@ -26,31 +26,50 @@ class CycleTimeConfig
26
26
  end
27
27
 
28
28
  def in_progress? issue
29
- started_time(issue) && stopped_time(issue).nil?
29
+ started_time, stopped_time = started_stopped_times(issue)
30
+ started_time && stopped_time.nil?
30
31
  end
31
32
 
32
33
  def done? issue
33
- stopped_time(issue)
34
+ started_stopped_times(issue).last
34
35
  end
35
36
 
36
37
  def started_time issue
37
- @start_at.call(issue)
38
+ deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
39
+ started_stopped_times(issue).first
38
40
  end
39
41
 
40
42
  def stopped_time issue
41
- @stop_at.call(issue)
43
+ deprecated date: '2024-10-16', message: 'Use started_stopped_times() instead'
44
+ started_stopped_times(issue).last
45
+ end
46
+
47
+ def started_stopped_times issue
48
+ started = @start_at.call(issue)
49
+ stopped = @stop_at.call(issue)
50
+
51
+ # In the case where started and stopped are exactly the same time, we pretend that
52
+ # it just stopped and never started. This allows us to have logic like 'in or right of'
53
+ # for the start and not have it conflict.
54
+ started = nil if started == stopped
55
+
56
+ [started, stopped]
57
+ end
58
+
59
+ def started_stopped_dates issue
60
+ started_time, stopped_time = started_stopped_times(issue)
61
+ [started_time&.to_date, stopped_time&.to_date]
42
62
  end
43
63
 
44
64
  def cycletime issue
45
- start = started_time(issue)
46
- stop = stopped_time(issue)
65
+ start, stop = started_stopped_times(issue)
47
66
  return nil if start.nil? || stop.nil?
48
67
 
49
68
  (stop.to_date - start.to_date).to_i + 1
50
69
  end
51
70
 
52
71
  def age issue, today: nil
53
- start = started_time(issue)
72
+ start = started_stopped_times(issue).first
54
73
  stop = today || @today || Date.today
55
74
  return nil if start.nil? || stop.nil?
56
75
 
@@ -30,7 +30,7 @@ class CycletimeHistogram < ChartBase
30
30
  stopped_issues = completed_issues_in_range include_unstarted: true
31
31
 
32
32
  # For the histogram, we only want to consider items that have both a start and a stop time.
33
- histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_time(issue) }
33
+ histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
34
34
  rules_to_issues = group_issues histogram_issues
35
35
 
36
36
  data_sets = rules_to_issues.keys.collect do |rules|
@@ -24,7 +24,7 @@ class CycletimeScatterplot < ChartBase
24
24
  predict that most work of this type will complete in <%= overall_percent_line %> days or
25
25
  less. The other lines reflect the 85% line for that respective type of work.
26
26
  </div>
27
- #{ describe_non_working_days }
27
+ #{describe_non_working_days}
28
28
  HTML
29
29
 
30
30
  init_configuration_block block do
@@ -53,10 +53,7 @@ class CycletimeScatterplot < ChartBase
53
53
  def create_datasets completed_issues
54
54
  data_sets = []
55
55
 
56
- groups = group_issues completed_issues
57
-
58
- groups.each_key do |rules|
59
- completed_issues_by_type = groups[rules]
56
+ group_issues(completed_issues).each do |rules, completed_issues_by_type|
60
57
  label = rules.label
61
58
  color = rules.color
62
59
  percent_line = calculate_percent_line completed_issues_by_type
@@ -117,7 +114,7 @@ class CycletimeScatterplot < ChartBase
117
114
 
118
115
  {
119
116
  y: cycle_time,
120
- x: chart_format(issue.board.cycletime.stopped_time(issue)),
117
+ x: chart_format(issue.board.cycletime.started_stopped_times(issue).last),
121
118
  title: ["#{issue.key} : #{issue.summary} (#{label_days(cycle_time)})"]
122
119
  }
123
120
  end
@@ -4,7 +4,7 @@ require 'jirametrics/daily_wip_chart'
4
4
 
5
5
  class DailyWipByAgeChart < DailyWipChart
6
6
  def initialize block
7
- super(block)
7
+ super
8
8
 
9
9
  add_trend_line line_color: '--aging-work-in-progress-by-age-trend-line-color', group_labels: [
10
10
  'Less than a day',
@@ -49,9 +49,7 @@ class DailyWipByAgeChart < DailyWipChart
49
49
  end
50
50
 
51
51
  def default_grouping_rules issue:, rules:
52
- cycletime = issue.board.cycletime
53
- started = cycletime.started_time(issue)&.to_date
54
- stopped = cycletime.stopped_time(issue)&.to_date
52
+ started, stopped = issue.board.cycletime.started_stopped_dates(issue)
55
53
 
56
54
  rules.issue_hint = "(age: #{label_days (rules.current_date - started + 1).to_i})" if started
57
55
 
@@ -39,8 +39,8 @@ class DailyWipByBlockedStalledChart < DailyWipChart
39
39
  end
40
40
 
41
41
  def default_grouping_rules issue:, rules:
42
- started = issue.board.cycletime.started_time(issue)
43
- stopped_date = issue.board.cycletime.stopped_time(issue)&.to_date
42
+ started, stopped = issue.board.cycletime.started_stopped_times(issue)
43
+ stopped_date = stopped&.to_date
44
44
 
45
45
  date = rules.current_date
46
46
  change = issue.blocked_stalled_by_date(date_range: date..date, chart_end_time: time_range.end)[date]
@@ -3,10 +3,6 @@
3
3
  require 'jirametrics/daily_wip_chart'
4
4
 
5
5
  class DailyWipByParentChart < DailyWipChart
6
- def initialize block
7
- super(block)
8
- end
9
-
10
6
  def default_header_text
11
7
  'Daily WIP, grouped by the parent ticket (Epic, Feature, etc)'
12
8
  end
@@ -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,
@@ -48,6 +48,7 @@ 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
52
53
  end
53
54
 
@@ -56,7 +57,25 @@ class DataQualityReport < ChartBase
56
57
  entries_with_problems = entries_with_problems()
57
58
  return '' if entries_with_problems.empty?
58
59
 
59
- wrap_and_render(binding, __FILE__)
60
+ caller_binding = binding
61
+ result = +''
62
+ result << render_top_text(caller_binding)
63
+
64
+ result << '<ul class="quality_report">'
65
+ result << render_problem_type(:discarded_changes)
66
+ result << render_problem_type(:completed_but_not_started)
67
+ result << render_problem_type(:status_changes_after_done)
68
+ result << render_problem_type(:backwards_through_status_categories)
69
+ result << render_problem_type(:backwords_through_statuses)
70
+ result << render_problem_type(:status_not_on_board)
71
+ result << render_problem_type(:created_in_wrong_status)
72
+ result << render_problem_type(:stopped_before_started)
73
+ result << render_problem_type(:issue_not_started_but_subtasks_have)
74
+ result << render_problem_type(:incomplete_subtasks_when_issue_done)
75
+ result << render_problem_type(:issue_on_multiple_boards)
76
+ result << '</ul>'
77
+
78
+ result
60
79
  end
61
80
 
62
81
  def problems_for key
@@ -69,6 +88,18 @@ class DataQualityReport < ChartBase
69
88
  result
70
89
  end
71
90
 
91
+ def render_problem_type problem_key
92
+ problems = problems_for problem_key
93
+ return '' if problems.empty?
94
+
95
+ <<-HTML
96
+ <li>
97
+ #{__send__ :"render_#{problem_key}", problems}
98
+ #{collapsible_issues_panel problems}
99
+ </li>
100
+ HTML
101
+ end
102
+
72
103
  # Return a format that's easier to assert against
73
104
  def testable_entries
74
105
  format = '%Y-%m-%d %H:%M:%S %z'
@@ -87,9 +118,7 @@ class DataQualityReport < ChartBase
87
118
 
88
119
  def initialize_entries
89
120
  @entries = @issues.filter_map do |issue|
90
- cycletime = issue.board.cycletime
91
- started = cycletime.started_time(issue)
92
- stopped = cycletime.stopped_time(issue)
121
+ started, stopped = issue.board.cycletime.started_stopped_times(issue)
93
122
  next if stopped && stopped < time_range.begin
94
123
  next if started && started > time_range.end
95
124
 
@@ -223,14 +252,13 @@ class DataQualityReport < ChartBase
223
252
 
224
253
  started_subtasks = []
225
254
  entry.issue.subtasks.each do |subtask|
226
- started_subtasks << subtask if subtask.board.cycletime.started_time(subtask)
255
+ started_subtasks << subtask if subtask.board.cycletime.started_stopped_times(subtask).first
227
256
  end
228
257
 
229
258
  return if started_subtasks.empty?
230
259
 
231
260
  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}"
261
+ subtask_label(subtask)
234
262
  end
235
263
  entry.report(
236
264
  problem_key: :issue_not_started_but_subtasks_have,
@@ -238,6 +266,47 @@ class DataQualityReport < ChartBase
238
266
  )
239
267
  end
240
268
 
269
+ def subtask_label subtask
270
+ "<img src='#{subtask.type_icon_url}' /> #{link_to_issue(subtask)} #{subtask.summary[..50].inspect}"
271
+ end
272
+
273
+ def time_as_english(from_time, to_time)
274
+ delta = (to_time - from_time).to_i
275
+ return "#{delta} seconds" if delta < 60
276
+
277
+ delta /= 60
278
+ return "#{delta} minutes" if delta < 60
279
+
280
+ delta /= 60
281
+ return "#{delta} hours" if delta < 24
282
+
283
+ delta /= 24
284
+ "#{delta} days"
285
+ end
286
+
287
+ def scan_for_incomplete_subtasks_when_issue_done entry:
288
+ return unless entry.stopped
289
+
290
+ subtask_labels = entry.issue.subtasks.filter_map do |subtask|
291
+ subtask_started, subtask_stopped = subtask.board.cycletime.started_stopped_times(subtask)
292
+
293
+ if !subtask_started && !subtask_stopped
294
+ "#{subtask_label subtask} (Not even started)"
295
+ elsif !subtask_stopped
296
+ "#{subtask_label subtask} (Still not done)"
297
+ elsif subtask_stopped > entry.stopped
298
+ "#{subtask_label subtask} (Closed #{time_as_english entry.stopped, subtask_stopped} later)"
299
+ end
300
+ end
301
+
302
+ return if subtask_labels.empty?
303
+
304
+ entry.report(
305
+ problem_key: :incomplete_subtasks_when_issue_done,
306
+ detail: subtask_labels.join('<br />')
307
+ )
308
+ end
309
+
241
310
  def label_issues number
242
311
  return '1 item' if number == 1
243
312
 
@@ -278,4 +347,94 @@ class DataQualityReport < ChartBase
278
347
  )
279
348
  end
280
349
  end
350
+
351
+ def render_discarded_changes problems
352
+ <<-HTML
353
+ #{label_issues problems.size} have had information discarded. This configuration is set
354
+ to "reset the clock" if an item is moved back to the backlog after it's been started. This hides important
355
+ information and makes the data less accurate. <b>Moving items back to the backlog is strongly discouraged.</b> HTML
356
+ HTML
357
+ end
358
+
359
+ def render_completed_but_not_started problems
360
+ percentage_work_included = ((issues.size - problems.size).to_f / issues.size * 100).to_i
361
+ html = <<-HTML
362
+ #{label_issues problems.size} were discarded from all charts using cycletime (scatterplot, histogram, etc)
363
+ as we couldn't determine when they started.
364
+ HTML
365
+ if percentage_work_included < 85
366
+ html << <<-HTML
367
+ Consider whether looking at only #{percentage_work_included}% of the total data points is enough
368
+ to come to any reasonable conclusions. See <a href="https://en.wikipedia.org/wiki/Survivorship_bias">
369
+ Survivorship Bias</a>.
370
+ HTML
371
+ end
372
+ html
373
+ end
374
+
375
+ def render_status_changes_after_done problems
376
+ <<-HTML
377
+ #{label_issues problems.size} had a status change after being identified as done. We should question
378
+ whether they were really done at that point or if we stopped the clock too early.
379
+ HTML
380
+ end
381
+
382
+ def render_backwards_through_status_categories problems
383
+ <<-HTML
384
+ #{label_issues problems.size} moved backwards across the board, <b>crossing status categories</b>.
385
+ This will almost certainly have impacted timings as the end times are often taken at status category
386
+ boundaries. You should assume that any timing measurements for this item are wrong.
387
+ HTML
388
+ end
389
+
390
+ def render_backwords_through_statuses problems
391
+ <<-HTML
392
+ #{label_issues problems.size} moved backwards across the board. Depending where we have set the
393
+ start and end points, this may give us incorrect timing data. Note that these items did not cross
394
+ a status category and may not have affected metrics.
395
+ HTML
396
+ end
397
+
398
+ def render_status_not_on_board problems
399
+ <<-HTML
400
+ #{label_issues problems.size} were not visible on the board for some period of time. This may impact
401
+ timings as the work was likely to have been forgotten if it wasn't visible.
402
+ HTML
403
+ end
404
+
405
+ def render_created_in_wrong_status problems
406
+ <<-HTML
407
+ #{label_issues problems.size} were created in a status not designated as Backlog. This will impact
408
+ the measurement of start times and will therefore impact whether it's shown as in progress or not.
409
+ HTML
410
+ end
411
+
412
+ def render_stopped_before_started problems
413
+ <<-HTML
414
+ #{label_issues problems.size} were stopped before they were started and this will play havoc with
415
+ any cycletime or WIP calculations. The most common case for this is when an item gets closed and
416
+ then moved back into an in-progress status.
417
+ HTML
418
+ end
419
+
420
+ def render_issue_not_started_but_subtasks_have problems
421
+ <<-HTML
422
+ #{label_issues problems.size} still showing 'not started' while sub-tasks underneath them have
423
+ started. This is almost always a mistake; if we're working on subtasks, the top level item should
424
+ also have started.
425
+ HTML
426
+ end
427
+
428
+ def render_incomplete_subtasks_when_issue_done problems
429
+ <<-HTML
430
+ #{label_issues problems.size} issues were marked as done while subtasks were still not done.
431
+ HTML
432
+ end
433
+
434
+ def render_issue_on_multiple_boards problems
435
+ <<-HTML
436
+ For #{label_issues problems.size}, we have an issue that shows up on more than one board. This
437
+ could result in more data points showing up on a chart then there really should be.
438
+ HTML
439
+ end
281
440
  end
@@ -183,9 +183,8 @@ class DependencyChart < ChartBase
183
183
  return stdout.read
184
184
  end
185
185
  rescue # rubocop:disable Style/RescueStandardError
186
- message = "Unable to execute the command 'dot' which is part of graphviz. " \
187
- 'Ensure that graphviz is installed and that dot is in your path.'
188
- 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
189
188
  message
190
189
  end
191
190
 
@@ -229,7 +228,7 @@ class DependencyChart < ChartBase
229
228
  elsif is_done
230
229
  line2 << 'Done'
231
230
  else
232
- started_at = issue.board.cycletime.started_time(issue)
231
+ started_at = issue.board.cycletime.started_stopped_times(issue).first
233
232
  if started_at.nil?
234
233
  line2 << 'Not started'
235
234
  else
@@ -31,7 +31,7 @@ module DiscardChangesBefore
31
31
  discard_changes_before_hook issues_cutoff_times
32
32
 
33
33
  issues_cutoff_times.each do |issue, cutoff_time|
34
- issue.changes.reject! { |change| change.status? && change.time <= cutoff_time && change.artificial? == false }
34
+ issue.discard_changes_before cutoff_time
35
35
  end
36
36
  end
37
37
  end