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.
- checksums.yaml +4 -4
- data/lib/jirametrics/aggregate_config.rb +6 -1
- data/lib/jirametrics/aging_work_bar_chart.rb +6 -6
- data/lib/jirametrics/aging_work_in_progress_chart.rb +1 -1
- data/lib/jirametrics/aging_work_table.rb +4 -5
- data/lib/jirametrics/blocked_stalled_change.rb +1 -1
- data/lib/jirametrics/board.rb +14 -12
- data/lib/jirametrics/chart_base.rb +16 -10
- data/lib/jirametrics/cycletime_config.rb +26 -7
- data/lib/jirametrics/cycletime_histogram.rb +1 -1
- data/lib/jirametrics/cycletime_scatterplot.rb +3 -6
- data/lib/jirametrics/daily_wip_by_age_chart.rb +2 -4
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +2 -2
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +0 -4
- data/lib/jirametrics/daily_wip_chart.rb +7 -9
- data/lib/jirametrics/data_quality_report.rb +166 -7
- data/lib/jirametrics/dependency_chart.rb +3 -4
- data/lib/jirametrics/discard_changes_before.rb +1 -1
- data/lib/jirametrics/downloader.rb +14 -13
- data/lib/jirametrics/estimate_accuracy_chart.rb +1 -2
- data/lib/jirametrics/examples/aggregated_project.rb +1 -3
- data/lib/jirametrics/examples/standard_project.rb +10 -9
- data/lib/jirametrics/expedited_chart.rb +1 -2
- data/lib/jirametrics/exporter.rb +25 -0
- data/lib/jirametrics/file_system.rb +1 -1
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
- data/lib/jirametrics/html/index.css +10 -3
- data/lib/jirametrics/html_report_config.rb +2 -1
- data/lib/jirametrics/issue.rb +63 -11
- data/lib/jirametrics/jira_gateway.rb +1 -1
- data/lib/jirametrics/project_config.rb +27 -19
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/sprint_burndown.rb +2 -2
- data/lib/jirametrics/status.rb +1 -1
- data/lib/jirametrics/status_collection.rb +4 -0
- data/lib/jirametrics/throughput_chart.rb +1 -1
- data/lib/jirametrics.rb +15 -5
- metadata +8 -7
- 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:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: d5bb71d8f3c6ab4cf1fa17ad93ad1c00ba1b8381c196bc15a054095a5a91917a
|
4
|
+
data.tar.gz: 790abb0404719d6b55d833ad76d34121875997f3ec3967f69f5d467fae203752
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
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
|
-
|
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 =
|
57
|
-
return render_top_text(binding)
|
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.
|
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
|
-
|
96
|
-
|
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.
|
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.
|
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
|
-
|
40
|
-
|
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.
|
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.
|
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]
|
data/lib/jirametrics/board.rb
CHANGED
@@ -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, :
|
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 "#{
|
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
|
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.
|
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
|
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
|
-
|
193
|
-
|
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
|
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
|
-
|
34
|
+
started_stopped_times(issue).last
|
34
35
|
end
|
35
36
|
|
36
37
|
def started_time issue
|
37
|
-
|
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
|
-
|
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 =
|
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 =
|
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.
|
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
|
-
#{
|
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
|
-
|
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.
|
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
|
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
|
-
|
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.
|
43
|
-
stopped_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]
|
@@ -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
|
-
|
26
|
-
|
27
|
-
|
28
|
-
|
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
|
-
|
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:
|
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
|
-
|
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
|
-
|
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.
|
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
|
-
|
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 =
|
187
|
-
|
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.
|
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.
|
34
|
+
issue.discard_changes_before cutoff_time
|
35
35
|
end
|
36
36
|
end
|
37
37
|
end
|