jirametrics 2.0 → 2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/jirametrics/aggregate_config.rb +19 -26
- data/lib/jirametrics/aging_work_bar_chart.rb +79 -54
- data/lib/jirametrics/aging_work_in_progress_chart.rb +106 -40
- data/lib/jirametrics/aging_work_table.rb +78 -43
- data/lib/jirametrics/anonymizer.rb +6 -5
- data/lib/jirametrics/blocked_stalled_change.rb +24 -4
- data/lib/jirametrics/board.rb +44 -15
- data/lib/jirametrics/board_config.rb +8 -4
- data/lib/jirametrics/board_movement_calculator.rb +147 -0
- data/lib/jirametrics/change_item.rb +31 -10
- data/lib/jirametrics/chart_base.rb +102 -61
- data/lib/jirametrics/columns_config.rb +4 -0
- data/lib/jirametrics/css_variable.rb +33 -0
- data/lib/jirametrics/cycletime_config.rb +59 -8
- data/lib/jirametrics/cycletime_histogram.rb +69 -4
- data/lib/jirametrics/cycletime_scatterplot.rb +11 -15
- data/lib/jirametrics/daily_wip_by_age_chart.rb +44 -20
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +37 -35
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +38 -0
- data/lib/jirametrics/daily_wip_chart.rb +61 -14
- data/lib/jirametrics/data_quality_report.rb +222 -41
- data/lib/jirametrics/dependency_chart.rb +54 -23
- data/lib/jirametrics/download_config.rb +12 -0
- data/lib/jirametrics/downloader.rb +76 -57
- data/lib/jirametrics/{story_point_accuracy_chart.rb → estimate_accuracy_chart.rb} +48 -33
- data/lib/jirametrics/examples/aggregated_project.rb +22 -39
- data/lib/jirametrics/examples/standard_project.rb +25 -49
- data/lib/jirametrics/expedited_chart.rb +28 -25
- data/lib/jirametrics/exporter.rb +59 -32
- data/lib/jirametrics/file_config.rb +34 -13
- data/lib/jirametrics/file_system.rb +48 -3
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
- data/lib/jirametrics/groupable_issue_chart.rb +2 -6
- data/lib/jirametrics/grouping_rules.rb +7 -1
- data/lib/jirametrics/hierarchy_table.rb +4 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +13 -16
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +28 -5
- data/lib/jirametrics/html/aging_work_table.erb +19 -25
- data/lib/jirametrics/html/cycletime_histogram.erb +83 -3
- data/lib/jirametrics/html/cycletime_scatterplot.erb +9 -12
- data/lib/jirametrics/html/daily_wip_chart.erb +17 -13
- data/lib/jirametrics/html/{story_point_accuracy_chart.erb → estimate_accuracy_chart.erb} +9 -4
- data/lib/jirametrics/html/expedited_chart.erb +10 -13
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
- data/lib/jirametrics/html/hierarchy_table.erb +2 -2
- data/lib/jirametrics/html/index.css +209 -0
- data/lib/jirametrics/html/index.erb +16 -39
- data/lib/jirametrics/html/sprint_burndown.erb +10 -14
- data/lib/jirametrics/html/throughput_chart.erb +10 -13
- data/lib/jirametrics/html_report_config.rb +108 -86
- data/lib/jirametrics/issue.rb +357 -96
- data/lib/jirametrics/jira_gateway.rb +29 -11
- data/lib/jirametrics/project_config.rb +256 -144
- data/lib/jirametrics/rules.rb +2 -2
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +10 -0
- data/lib/jirametrics/sprint_burndown.rb +24 -7
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +80 -39
- data/lib/jirametrics/throughput_chart.rb +12 -4
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics.rb +25 -7
- metadata +16 -17
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/experimental/generator.rb +0 -210
- data/lib/jirametrics/experimental/info.rb +0 -77
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
|
@@ -3,11 +3,14 @@
|
|
|
3
3
|
require 'jirametrics/chart_base'
|
|
4
4
|
|
|
5
5
|
class ExpeditedChart < ChartBase
|
|
6
|
-
EXPEDITED_SEGMENT =
|
|
6
|
+
EXPEDITED_SEGMENT = ChartBase.new.tap do |segment|
|
|
7
7
|
def segment.to_json *_args
|
|
8
|
+
expedited = CssVariable.new('--expedited-color').to_json
|
|
9
|
+
not_expedited = CssVariable.new('--expedited-chart-no-longer-expedited').to_json
|
|
10
|
+
|
|
8
11
|
<<~SNIPPET
|
|
9
12
|
{
|
|
10
|
-
borderColor: ctx => expedited(ctx,
|
|
13
|
+
borderColor: ctx => expedited(ctx, #{expedited}) || notExpedited(ctx, #{not_expedited}),
|
|
11
14
|
borderDash: ctx => notExpedited(ctx, [6, 6])
|
|
12
15
|
}
|
|
13
16
|
SNIPPET
|
|
@@ -17,25 +20,26 @@ class ExpeditedChart < ChartBase
|
|
|
17
20
|
attr_accessor :issues, :cycletime, :possible_statuses, :date_range
|
|
18
21
|
attr_reader :expedited_label
|
|
19
22
|
|
|
20
|
-
def initialize
|
|
23
|
+
def initialize block
|
|
21
24
|
super()
|
|
22
25
|
|
|
23
26
|
header_text 'Expedited work'
|
|
24
27
|
description_text <<-HTML
|
|
25
|
-
<p>
|
|
28
|
+
<div class="p">
|
|
26
29
|
This chart only shows issues that have been expedited at some point. We care about these as
|
|
27
30
|
any form of expedited work will affect the entire system and will slow down non-expedited work.
|
|
28
31
|
Refer to this article on
|
|
29
32
|
<a href="https://improvingflow.com/2021/06/16/classes-of-service.html">classes of service</a>
|
|
30
33
|
for a longer explanation on why we want to avoid expedited work.
|
|
31
|
-
</
|
|
32
|
-
<p>
|
|
33
|
-
The
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
</p>
|
|
34
|
+
</div>
|
|
35
|
+
<div class="p">
|
|
36
|
+
The colour of the line indicates time that this issue was #{color_block '--expedited-color'} expedited
|
|
37
|
+
or #{color_block '--expedited-chart-no-longer-expedited'} not expedited.
|
|
38
|
+
</div>
|
|
39
|
+
#{describe_non_working_days}
|
|
38
40
|
HTML
|
|
41
|
+
|
|
42
|
+
instance_eval(&block)
|
|
39
43
|
end
|
|
40
44
|
|
|
41
45
|
def run
|
|
@@ -53,13 +57,13 @@ class ExpeditedChart < ChartBase
|
|
|
53
57
|
def prepare_expedite_data issue
|
|
54
58
|
expedite_start = nil
|
|
55
59
|
result = []
|
|
56
|
-
expedited_priority_names = issue.board.expedited_priority_names
|
|
60
|
+
expedited_priority_names = issue.board.project_config.settings['expedited_priority_names']
|
|
57
61
|
|
|
58
62
|
issue.changes.each do |change|
|
|
59
63
|
next unless change.priority?
|
|
60
64
|
|
|
61
65
|
if expedited_priority_names.include? change.value
|
|
62
|
-
expedite_start = change.time
|
|
66
|
+
expedite_start = change.time.to_date
|
|
63
67
|
elsif expedite_start
|
|
64
68
|
start_date = expedite_start.to_date
|
|
65
69
|
stop_date = change.time.to_date
|
|
@@ -68,7 +72,7 @@ class ExpeditedChart < ChartBase
|
|
|
68
72
|
(start_date < date_range.begin && stop_date > date_range.end)
|
|
69
73
|
|
|
70
74
|
result << [expedite_start, :expedite_start]
|
|
71
|
-
result << [change.time, :expedite_stop]
|
|
75
|
+
result << [change.time.to_date, :expedite_stop]
|
|
72
76
|
end
|
|
73
77
|
expedite_start = nil
|
|
74
78
|
end
|
|
@@ -105,12 +109,11 @@ class ExpeditedChart < ChartBase
|
|
|
105
109
|
|
|
106
110
|
def make_expedite_lines_data_set issue:, expedite_data:
|
|
107
111
|
cycletime = issue.board.cycletime
|
|
108
|
-
|
|
109
|
-
stopped_time = cycletime.stopped_time(issue)
|
|
112
|
+
started_date, stopped_date = cycletime.started_stopped_dates(issue)
|
|
110
113
|
|
|
111
|
-
expedite_data << [
|
|
112
|
-
expedite_data << [
|
|
113
|
-
expedite_data.sort_by!
|
|
114
|
+
expedite_data << [started_date, :issue_started] if started_date
|
|
115
|
+
expedite_data << [stopped_date, :issue_stopped] if stopped_date
|
|
116
|
+
expedite_data.sort_by!(&:first)
|
|
114
117
|
|
|
115
118
|
# If none of the data would be visible on the chart then skip it.
|
|
116
119
|
return nil unless expedite_data.any? { |time, _action| time.to_date >= date_range.begin }
|
|
@@ -124,20 +127,20 @@ class ExpeditedChart < ChartBase
|
|
|
124
127
|
case action
|
|
125
128
|
when :issue_started
|
|
126
129
|
data << make_point(issue: issue, time: time, label: 'Started', expedited: expedited)
|
|
127
|
-
dot_colors << '
|
|
130
|
+
dot_colors << CssVariable['--expedited-chart-dot-issue-started-color']
|
|
128
131
|
point_styles << 'rect'
|
|
129
132
|
when :issue_stopped
|
|
130
133
|
data << make_point(issue: issue, time: time, label: 'Completed', expedited: expedited)
|
|
131
|
-
dot_colors << '
|
|
134
|
+
dot_colors << CssVariable['--expedited-chart-dot-issue-stopped-color']
|
|
132
135
|
point_styles << 'rect'
|
|
133
136
|
when :expedite_start
|
|
134
137
|
data << make_point(issue: issue, time: time, label: 'Expedited', expedited: true)
|
|
135
|
-
dot_colors << '
|
|
138
|
+
dot_colors << CssVariable['--expedited-chart-dot-expedite-started-color']
|
|
136
139
|
point_styles << 'circle'
|
|
137
140
|
expedited = true
|
|
138
141
|
when :expedite_stop
|
|
139
142
|
data << make_point(issue: issue, time: time, label: 'Not expedited', expedited: false)
|
|
140
|
-
dot_colors << '
|
|
143
|
+
dot_colors << CssVariable['--expedited-chart-dot-expedite-stopped-color']
|
|
141
144
|
point_styles << 'circle'
|
|
142
145
|
expedited = false
|
|
143
146
|
else
|
|
@@ -147,9 +150,9 @@ class ExpeditedChart < ChartBase
|
|
|
147
150
|
|
|
148
151
|
unless expedite_data.empty?
|
|
149
152
|
last_change_time = expedite_data[-1][0].to_date
|
|
150
|
-
if last_change_time && last_change_time <= date_range.end &&
|
|
153
|
+
if last_change_time && last_change_time <= date_range.end && stopped_date.nil?
|
|
151
154
|
data << make_point(issue: issue, time: date_range.end, label: 'Still ongoing', expedited: expedited)
|
|
152
|
-
dot_colors << '
|
|
155
|
+
dot_colors << '' # It won't be visible so it doesn't matter
|
|
153
156
|
point_styles << 'dash'
|
|
154
157
|
end
|
|
155
158
|
end
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -2,21 +2,19 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
|
|
5
|
-
class Object
|
|
6
|
-
def deprecated message:
|
|
7
|
-
text = +''
|
|
8
|
-
text << 'Deprecated:'
|
|
9
|
-
text << message
|
|
10
|
-
text << "\n-> Called from #{caller(1..1).first}"
|
|
11
|
-
warn text
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
|
|
15
5
|
class Exporter
|
|
16
|
-
attr_reader :project_configs
|
|
6
|
+
attr_reader :project_configs
|
|
7
|
+
attr_accessor :file_system
|
|
17
8
|
|
|
18
9
|
def self.configure &block
|
|
19
|
-
|
|
10
|
+
logfile_name = 'jirametrics.log'
|
|
11
|
+
logfile = File.open logfile_name, 'w'
|
|
12
|
+
file_system = FileSystem.new
|
|
13
|
+
file_system.logfile = logfile
|
|
14
|
+
file_system.logfile_name = logfile_name
|
|
15
|
+
|
|
16
|
+
exporter = Exporter.new file_system: file_system
|
|
17
|
+
|
|
20
18
|
exporter.instance_eval(&block)
|
|
21
19
|
@@instance = exporter
|
|
22
20
|
end
|
|
@@ -25,11 +23,12 @@ class Exporter
|
|
|
25
23
|
|
|
26
24
|
def initialize file_system: FileSystem.new
|
|
27
25
|
@project_configs = []
|
|
28
|
-
@timezone_offset = '+00:00'
|
|
29
26
|
@target_path = '.'
|
|
30
27
|
@holiday_dates = []
|
|
31
28
|
@downloading = false
|
|
32
29
|
@file_system = file_system
|
|
30
|
+
|
|
31
|
+
timezone_offset '+00:00'
|
|
33
32
|
end
|
|
34
33
|
|
|
35
34
|
def export name_filter:
|
|
@@ -41,25 +40,49 @@ class Exporter
|
|
|
41
40
|
|
|
42
41
|
def download name_filter:
|
|
43
42
|
@downloading = true
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
43
|
+
each_project_config(name_filter: name_filter) do |project|
|
|
44
|
+
project.evaluate_next_level
|
|
45
|
+
next if project.aggregated_project?
|
|
46
|
+
|
|
47
|
+
unless project.download_config
|
|
48
|
+
raise "Project #{project.name.inspect} is missing a download section in the config. " \
|
|
49
|
+
'That is required in order to download'
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
project.download_config.run
|
|
53
|
+
downloader = Downloader.new(
|
|
54
|
+
download_config: project.download_config,
|
|
55
|
+
file_system: file_system,
|
|
56
|
+
jira_gateway: JiraGateway.new(file_system: file_system)
|
|
57
|
+
)
|
|
58
|
+
downloader.run
|
|
59
|
+
end
|
|
60
|
+
puts "Full output from downloader in #{file_system.logfile_name}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def info keys, name_filter:
|
|
64
|
+
selected = []
|
|
65
|
+
each_project_config(name_filter: name_filter) do |project|
|
|
66
|
+
project.evaluate_next_level
|
|
67
|
+
|
|
68
|
+
project.run load_only: true
|
|
69
|
+
project.issues.each do |issue|
|
|
70
|
+
selected << [project, issue] if keys.include? issue.key
|
|
71
|
+
end
|
|
72
|
+
rescue => e # rubocop:disable Style/RescueStandardError
|
|
73
|
+
# This happens when we're attempting to load an aggregated project because it hasn't been
|
|
74
|
+
# properly initialized. Since we don't care about aggregated projects, we just ignore it.
|
|
75
|
+
raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if selected.empty?
|
|
79
|
+
file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
|
|
80
|
+
else
|
|
81
|
+
selected.each do |project, issue|
|
|
82
|
+
file_system.log "\nProject #{project.name}", also_write_to_stderr: true
|
|
83
|
+
file_system.log issue.dump, also_write_to_stderr: true
|
|
60
84
|
end
|
|
61
85
|
end
|
|
62
|
-
puts "Full output from downloader in #{logfile_name}"
|
|
63
86
|
end
|
|
64
87
|
|
|
65
88
|
def each_project_config name_filter:
|
|
@@ -73,7 +96,6 @@ class Exporter
|
|
|
73
96
|
end
|
|
74
97
|
|
|
75
98
|
def project name: nil, &block
|
|
76
|
-
raise 'target_path was never set!' if @target_path.nil?
|
|
77
99
|
raise 'jira_config not set' if @jira_config.nil?
|
|
78
100
|
|
|
79
101
|
@project_configs << ProjectConfig.new(
|
|
@@ -93,7 +115,12 @@ class Exporter
|
|
|
93
115
|
end
|
|
94
116
|
|
|
95
117
|
def jira_config filename = nil
|
|
96
|
-
|
|
118
|
+
if filename
|
|
119
|
+
@jira_config = file_system.load_json(filename, fail_on_error: false)
|
|
120
|
+
raise "Unable to load Jira configuration file and cannot continue: #{filename.inspect}" if @jira_config.nil?
|
|
121
|
+
|
|
122
|
+
@jira_config['url'] = $1 if @jira_config['url'] =~ /^(.+)\/+$/
|
|
123
|
+
end
|
|
97
124
|
@jira_config
|
|
98
125
|
end
|
|
99
126
|
|
|
@@ -5,10 +5,11 @@ require 'csv'
|
|
|
5
5
|
class FileConfig
|
|
6
6
|
attr_reader :project_config, :issues
|
|
7
7
|
|
|
8
|
-
def initialize project_config:, block:
|
|
8
|
+
def initialize project_config:, block:, today: Date.today
|
|
9
9
|
@project_config = project_config
|
|
10
10
|
@block = block
|
|
11
11
|
@columns = nil
|
|
12
|
+
@today = today
|
|
12
13
|
end
|
|
13
14
|
|
|
14
15
|
def run
|
|
@@ -18,11 +19,8 @@ class FileConfig
|
|
|
18
19
|
if @columns
|
|
19
20
|
all_lines = prepare_grid
|
|
20
21
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
file.puts CSV.generate_line(output_line)
|
|
24
|
-
end
|
|
25
|
-
end
|
|
22
|
+
content = all_lines.collect { |line| CSV.generate_line line }.join
|
|
23
|
+
project_config.exporter.file_system.save_file content: content, filename: output_filename
|
|
26
24
|
elsif @html_report
|
|
27
25
|
@html_report.run
|
|
28
26
|
else
|
|
@@ -58,8 +56,8 @@ class FileConfig
|
|
|
58
56
|
def output_filename
|
|
59
57
|
segments = []
|
|
60
58
|
segments << project_config.target_path
|
|
61
|
-
segments << project_config.
|
|
62
|
-
segments << (@file_suffix || "-#{
|
|
59
|
+
segments << project_config.get_file_prefix
|
|
60
|
+
segments << (@file_suffix || "-#{@today}.csv")
|
|
63
61
|
segments.join
|
|
64
62
|
end
|
|
65
63
|
|
|
@@ -68,13 +66,20 @@ class FileConfig
|
|
|
68
66
|
# is that all empty values in the first column should be at the bottom.
|
|
69
67
|
def sort_output all_lines
|
|
70
68
|
all_lines.sort do |a, b|
|
|
71
|
-
|
|
72
|
-
|
|
69
|
+
result = nil
|
|
70
|
+
if a[0] == b[0]
|
|
71
|
+
result = a[1..] <=> b[1..]
|
|
72
|
+
elsif a[0].nil?
|
|
73
|
+
result = 1
|
|
73
74
|
elsif b[0].nil?
|
|
74
|
-
-1
|
|
75
|
+
result = -1
|
|
75
76
|
else
|
|
76
|
-
a[0] <=> b[0]
|
|
77
|
+
result = a[0] <=> b[0]
|
|
77
78
|
end
|
|
79
|
+
|
|
80
|
+
# This will only happen if one of the objects isn't comparable. Seen in production.
|
|
81
|
+
result = -1 if result.nil?
|
|
82
|
+
result
|
|
78
83
|
end
|
|
79
84
|
end
|
|
80
85
|
|
|
@@ -85,6 +90,11 @@ class FileConfig
|
|
|
85
90
|
|
|
86
91
|
def html_report &block
|
|
87
92
|
assert_only_one_filetype_config_set
|
|
93
|
+
if block.nil?
|
|
94
|
+
project_config.file_system.warning 'No charts were specified for the report. This is almost certainly a mistake.'
|
|
95
|
+
block = ->(_) {}
|
|
96
|
+
end
|
|
97
|
+
|
|
88
98
|
@html_report = HtmlReportConfig.new file_config: self, block: block
|
|
89
99
|
end
|
|
90
100
|
|
|
@@ -103,7 +113,7 @@ class FileConfig
|
|
|
103
113
|
def to_datetime object
|
|
104
114
|
return nil if object.nil?
|
|
105
115
|
|
|
106
|
-
object = object.to_datetime
|
|
116
|
+
object = object.to_time.to_datetime
|
|
107
117
|
object = object.new_offset(@timezone_offset) if @timezone_offset
|
|
108
118
|
object
|
|
109
119
|
end
|
|
@@ -112,8 +122,19 @@ class FileConfig
|
|
|
112
122
|
object.to_s
|
|
113
123
|
end
|
|
114
124
|
|
|
125
|
+
def to_integer object
|
|
126
|
+
object.to_i
|
|
127
|
+
end
|
|
128
|
+
|
|
115
129
|
def file_suffix suffix = nil
|
|
116
130
|
@file_suffix = suffix unless suffix.nil?
|
|
117
131
|
@file_suffix
|
|
118
132
|
end
|
|
133
|
+
|
|
134
|
+
def children
|
|
135
|
+
result = []
|
|
136
|
+
result << @columns if @columns
|
|
137
|
+
result << @html_report if @html_report
|
|
138
|
+
result
|
|
139
|
+
end
|
|
119
140
|
end
|
|
@@ -5,21 +5,48 @@ require 'json'
|
|
|
5
5
|
class FileSystem
|
|
6
6
|
attr_accessor :logfile, :logfile_name
|
|
7
7
|
|
|
8
|
+
# Effectively the same as File.read except it forces the encoding to UTF-8
|
|
9
|
+
def load filename, supress_deprecation: false
|
|
10
|
+
if filename.end_with?('.json') && !supress_deprecation
|
|
11
|
+
deprecated(message: 'call load_json instead', date: '2024-11-13')
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
File.read filename, encoding: 'UTF-8'
|
|
15
|
+
end
|
|
16
|
+
|
|
8
17
|
def load_json filename, fail_on_error: true
|
|
9
18
|
return nil if fail_on_error == false && File.exist?(filename) == false
|
|
10
19
|
|
|
11
|
-
JSON.parse
|
|
20
|
+
JSON.parse load(filename, supress_deprecation: true)
|
|
12
21
|
end
|
|
13
22
|
|
|
14
23
|
def save_json json:, filename:
|
|
24
|
+
save_file content: JSON.pretty_generate(compress json), filename: filename
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def save_file content:, filename:
|
|
15
28
|
file_path = File.dirname(filename)
|
|
16
29
|
FileUtils.mkdir_p file_path unless File.exist?(file_path)
|
|
17
30
|
|
|
18
|
-
File.write(filename,
|
|
31
|
+
File.write(filename, content)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def warning message, more: nil
|
|
35
|
+
log "Warning: #{message}", more: more, also_write_to_stderr: true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def error message, more: nil
|
|
39
|
+
log "Error: #{message}", more: more, also_write_to_stderr: true
|
|
19
40
|
end
|
|
20
41
|
|
|
21
|
-
def log message
|
|
42
|
+
def log message, more: nil, also_write_to_stderr: false
|
|
43
|
+
message += " See #{logfile_name} for more details about this message." if more
|
|
44
|
+
|
|
22
45
|
logfile.puts message
|
|
46
|
+
logfile.puts more if more
|
|
47
|
+
return unless also_write_to_stderr
|
|
48
|
+
|
|
49
|
+
$stderr.puts message # rubocop:disable Style/StderrPuts
|
|
23
50
|
end
|
|
24
51
|
|
|
25
52
|
# In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
|
|
@@ -33,4 +60,22 @@ class FileSystem
|
|
|
33
60
|
end
|
|
34
61
|
node
|
|
35
62
|
end
|
|
63
|
+
|
|
64
|
+
def foreach root, &block
|
|
65
|
+
Dir.foreach root, &block
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def file_exist? filename
|
|
69
|
+
File.exist? filename
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def deprecated message:, date:, depth: 2
|
|
73
|
+
text = +''
|
|
74
|
+
text << "Deprecated(#{date}): "
|
|
75
|
+
text << message
|
|
76
|
+
caller(1..depth).each do |line|
|
|
77
|
+
text << "\n-> Called from #{line}"
|
|
78
|
+
end
|
|
79
|
+
log text, also_write_to_stderr: true
|
|
80
|
+
end
|
|
36
81
|
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'jirametrics/groupable_issue_chart'
|
|
4
|
+
|
|
5
|
+
class FlowEfficiencyScatterplot < ChartBase
|
|
6
|
+
include GroupableIssueChart
|
|
7
|
+
|
|
8
|
+
attr_accessor :possible_statuses
|
|
9
|
+
|
|
10
|
+
def initialize block
|
|
11
|
+
super()
|
|
12
|
+
|
|
13
|
+
header_text 'Flow Efficiency'
|
|
14
|
+
description_text <<-HTML
|
|
15
|
+
<div class="p">
|
|
16
|
+
This chart shows the active time against the the total time spent on a ticket.
|
|
17
|
+
<a href="https://improvingflow.com/2024/07/06/flow-efficiency.html">Flow efficiency</a> is the ratio
|
|
18
|
+
between these two numbers.
|
|
19
|
+
</div>
|
|
20
|
+
<div class="p">
|
|
21
|
+
<math>
|
|
22
|
+
<mn>Flow efficiency (%)</mn>
|
|
23
|
+
<mo>=</mo>
|
|
24
|
+
<mfrac>
|
|
25
|
+
<mrow><mn>Time adding value</mn></mrow>
|
|
26
|
+
<mrow><mn>Total time</mn></mrow>
|
|
27
|
+
</mfrac>
|
|
28
|
+
</math>
|
|
29
|
+
</div>
|
|
30
|
+
<div style="background: var(--warning-banner)">Note that for this calculation to be accurate, we must be moving items into a
|
|
31
|
+
blocked or stalled state the moment we stop working on it, and most teams don't do that.
|
|
32
|
+
So be aware that your team may have to change their behaviours if you want this chart to be useful.
|
|
33
|
+
</div>
|
|
34
|
+
HTML
|
|
35
|
+
|
|
36
|
+
init_configuration_block block do
|
|
37
|
+
grouping_rules do |issue, rule|
|
|
38
|
+
active_time, total_time = issue.flow_efficiency_numbers end_time: time_range.end
|
|
39
|
+
flow_efficiency = active_time * 100.0 / total_time
|
|
40
|
+
|
|
41
|
+
if flow_efficiency > 99.0
|
|
42
|
+
rule.label = '~100%'
|
|
43
|
+
rule.color = 'green'
|
|
44
|
+
elsif flow_efficiency < 30.0
|
|
45
|
+
rule.label = '< 30%'
|
|
46
|
+
rule.color = 'orange'
|
|
47
|
+
else
|
|
48
|
+
rule.label = 'The rest'
|
|
49
|
+
rule.color = 'black'
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
@percentage_lines = []
|
|
55
|
+
@highest_cycletime = 0
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def run
|
|
59
|
+
data_sets = group_issues(completed_issues_in_range include_unstarted: false).filter_map do |rules, issues|
|
|
60
|
+
create_dataset(issues: issues, label: rules.label, color: rules.color)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
return "<h1>#{@header_text}</h1>No data matched the selected criteria. Nothing to show." if data_sets.empty?
|
|
64
|
+
|
|
65
|
+
wrap_and_render(binding, __FILE__)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def to_days seconds
|
|
69
|
+
seconds / 60 / 60 / 24
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def create_dataset issues:, label:, color:
|
|
73
|
+
return nil if issues.empty?
|
|
74
|
+
|
|
75
|
+
data = issues.filter_map do |issue|
|
|
76
|
+
active_time, total_time = issue.flow_efficiency_numbers(
|
|
77
|
+
end_time: time_range.end, settings: settings
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
active_days = to_days(active_time)
|
|
81
|
+
total_days = to_days(total_time)
|
|
82
|
+
flow_efficiency = active_time * 100.0 / total_time
|
|
83
|
+
|
|
84
|
+
if flow_efficiency.nan?
|
|
85
|
+
# If this happens then something is probably misconfigured. We've seen it in production though
|
|
86
|
+
# so we have to handle it.
|
|
87
|
+
file_system.log(
|
|
88
|
+
"Issue(#{issue.key}) flow_efficiency: NaN, active_time: #{active_time}, total_time: #{total_time}"
|
|
89
|
+
)
|
|
90
|
+
flow_efficiency = 0.0
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
{
|
|
94
|
+
y: active_days,
|
|
95
|
+
x: total_days,
|
|
96
|
+
title: [
|
|
97
|
+
"#{issue.key} : #{issue.summary}, flow efficiency: #{flow_efficiency.to_i}%," \
|
|
98
|
+
" total: #{total_days.round(1)} days," \
|
|
99
|
+
" active: #{active_days.round(1)} days"
|
|
100
|
+
]
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
{
|
|
104
|
+
label: label,
|
|
105
|
+
data: data,
|
|
106
|
+
fill: false,
|
|
107
|
+
showLine: false,
|
|
108
|
+
backgroundColor: color
|
|
109
|
+
}
|
|
110
|
+
end
|
|
111
|
+
end
|
|
@@ -5,12 +5,8 @@ require 'jirametrics/grouping_rules'
|
|
|
5
5
|
|
|
6
6
|
module GroupableIssueChart
|
|
7
7
|
def init_configuration_block user_provided_block, &default_block
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
return if @group_by_block
|
|
11
|
-
end
|
|
12
|
-
|
|
13
|
-
instance_eval(&default_block)
|
|
8
|
+
instance_eval(&user_provided_block)
|
|
9
|
+
instance_eval(&default_block) unless @group_by_block
|
|
14
10
|
end
|
|
15
11
|
|
|
16
12
|
def grouping_rules &block
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class GroupingRules < Rules
|
|
4
|
-
attr_accessor :label
|
|
4
|
+
attr_accessor :label
|
|
5
|
+
attr_reader :color
|
|
5
6
|
|
|
6
7
|
def eql? other
|
|
7
8
|
other.label == @label && other.color == @color
|
|
@@ -10,4 +11,9 @@ class GroupingRules < Rules
|
|
|
10
11
|
def group
|
|
11
12
|
[@label, @color]
|
|
12
13
|
end
|
|
14
|
+
|
|
15
|
+
def color= color
|
|
16
|
+
color = CssVariable[color] unless color.is_a?(CssVariable)
|
|
17
|
+
@color = color
|
|
18
|
+
end
|
|
13
19
|
end
|
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
require 'jirametrics/chart_base'
|
|
4
4
|
|
|
5
5
|
class HierarchyTable < ChartBase
|
|
6
|
-
def initialize block
|
|
6
|
+
def initialize block
|
|
7
7
|
super()
|
|
8
8
|
|
|
9
9
|
header_text 'Hierarchy Table'
|
|
10
|
-
description_text
|
|
11
|
-
<p>
|
|
10
|
+
description_text <<~HTML
|
|
11
|
+
<p>Shows all issues through this time period and the full hierarchy of their parents.</p>
|
|
12
12
|
HTML
|
|
13
13
|
|
|
14
|
-
instance_eval(&block)
|
|
14
|
+
instance_eval(&block)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
def run
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
<div>
|
|
1
|
+
<div class="chart">
|
|
2
2
|
<canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
|
|
3
3
|
</div>
|
|
4
4
|
<script>
|
|
@@ -19,36 +19,33 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
19
19
|
stacked: false,
|
|
20
20
|
title: {
|
|
21
21
|
display: false
|
|
22
|
-
}
|
|
22
|
+
},
|
|
23
|
+
grid: {
|
|
24
|
+
color: <%= CssVariable['--grid-line-color'].to_json %>
|
|
25
|
+
},
|
|
23
26
|
},
|
|
24
27
|
y: {
|
|
25
28
|
stacked: true,
|
|
26
29
|
position: 'right',
|
|
27
30
|
ticks: {
|
|
28
31
|
display: true
|
|
29
|
-
}
|
|
32
|
+
},
|
|
33
|
+
grid: {
|
|
34
|
+
color: <%= CssVariable['--grid-line-color'].to_json %>
|
|
35
|
+
},
|
|
30
36
|
}
|
|
31
37
|
},
|
|
32
38
|
plugins: {
|
|
33
39
|
annotation: {
|
|
34
40
|
annotations: {
|
|
35
|
-
|
|
36
|
-
holiday<%= index %>: {
|
|
37
|
-
drawTime: 'beforeDraw',
|
|
38
|
-
type: 'box',
|
|
39
|
-
xMin: '<%= range.begin %>T00:00:00',
|
|
40
|
-
xMax: '<%= range.end %>T23:59:59',
|
|
41
|
-
backgroundColor: '#F0F0F0',
|
|
42
|
-
borderColor: '#F0F0F0'
|
|
43
|
-
},
|
|
44
|
-
<% end %>
|
|
41
|
+
<%= working_days_annotation %>
|
|
45
42
|
|
|
46
43
|
<% if percentage_line_x %>
|
|
47
44
|
line: {
|
|
48
45
|
type: 'line',
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
borderColor: '
|
|
46
|
+
scaleID: 'x',
|
|
47
|
+
value: '<%= percentage_line_x %>',
|
|
48
|
+
borderColor: <%= CssVariable.new('--aging-work-bar-chart-percentage-line-color').to_json %>,
|
|
52
49
|
borderWidth: 1,
|
|
53
50
|
drawTime: 'afterDraw'
|
|
54
51
|
}
|