jirametrics 2.6 → 2.7.1

Sign up to get free protection for your applications and to get access to all the features.
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