jirametrics 2.9.1pre1 → 2.10pre1
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/cycletime_histogram.rb +63 -2
- data/lib/jirametrics/downloader.rb +0 -14
- data/lib/jirametrics/exporter.rb +3 -1
- data/lib/jirametrics/file_config.rb +9 -4
- data/lib/jirametrics/file_system.rb +4 -0
- data/lib/jirametrics/html/cycletime_histogram.erb +51 -0
- data/lib/jirametrics/jira_gateway.rb +16 -3
- metadata +2 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: 61592efc6953979c52dcf6798aec717b51e3b1c1a78d2d79db9f4715a554c926
|
4
|
+
data.tar.gz: 2eca3045f51a62ee94c83d76e46c808db86ba34c0eb7fc6691284d8de0d5544d
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: c34d8f657119ef2118a2945822724a0f23534e914a41cedb2e95f05b4a921d3039ff937dc4bdc7fe7408ec503f9f057d04ea78956e221a59ba324b7ea24fcb00
|
7
|
+
data.tar.gz: d474eaf1afee67bc9181e446d23ba978bda2a2abeea1968dfe2623e255505f82b923bf7da9574b52f06c35ae526cab3e8b8d269cd3edd4521e5a5cbc9e33ab73
|
@@ -5,10 +5,14 @@ require 'jirametrics/groupable_issue_chart'
|
|
5
5
|
class CycletimeHistogram < ChartBase
|
6
6
|
include GroupableIssueChart
|
7
7
|
attr_accessor :possible_statuses
|
8
|
+
attr_reader :show_stats
|
8
9
|
|
9
10
|
def initialize block
|
10
11
|
super()
|
11
12
|
|
13
|
+
percentiles [50, 85, 98]
|
14
|
+
@show_stats = true
|
15
|
+
|
12
16
|
header_text 'Cycletime Histogram'
|
13
17
|
description_text <<-HTML
|
14
18
|
<p>
|
@@ -26,6 +30,15 @@ class CycletimeHistogram < ChartBase
|
|
26
30
|
end
|
27
31
|
end
|
28
32
|
|
33
|
+
def percentiles percs = nil
|
34
|
+
@percentiles = percs unless percs.nil?
|
35
|
+
@percentiles
|
36
|
+
end
|
37
|
+
|
38
|
+
def disable_stats
|
39
|
+
@show_stats = false
|
40
|
+
end
|
41
|
+
|
29
42
|
def run
|
30
43
|
stopped_issues = completed_issues_in_range include_unstarted: true
|
31
44
|
|
@@ -33,10 +46,16 @@ class CycletimeHistogram < ChartBase
|
|
33
46
|
histogram_issues = stopped_issues.select { |issue| issue.board.cycletime.started_stopped_times(issue).first }
|
34
47
|
rules_to_issues = group_issues histogram_issues
|
35
48
|
|
49
|
+
the_stats = {}
|
50
|
+
|
36
51
|
data_sets = rules_to_issues.keys.collect do |rules|
|
52
|
+
the_issue_type = rules.label
|
53
|
+
the_histogram = histogram_data_for(issues: rules_to_issues[rules])
|
54
|
+
the_stats[the_issue_type] = stats_for histogram_data: the_histogram, percentiles: @percentiles if @show_stats
|
55
|
+
|
37
56
|
data_set_for(
|
38
|
-
histogram_data:
|
39
|
-
label:
|
57
|
+
histogram_data: the_histogram,
|
58
|
+
label: the_issue_type,
|
40
59
|
color: rules.color
|
41
60
|
)
|
42
61
|
end
|
@@ -55,6 +74,48 @@ class CycletimeHistogram < ChartBase
|
|
55
74
|
count_hash
|
56
75
|
end
|
57
76
|
|
77
|
+
def stats_for histogram_data:, percentiles: []
|
78
|
+
return {} if histogram_data.empty?
|
79
|
+
|
80
|
+
total_values = histogram_data.values.sum
|
81
|
+
|
82
|
+
# Calculate the average
|
83
|
+
weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
|
84
|
+
average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
|
85
|
+
|
86
|
+
# Find the mode (or modes!) and the spread of the distribution
|
87
|
+
sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
|
88
|
+
max_freq = sorted_histogram[-1][1]
|
89
|
+
mode = sorted_histogram.select { |_v, f| f == max_freq }
|
90
|
+
|
91
|
+
minmax = histogram_data.keys.minmax
|
92
|
+
|
93
|
+
# Calculate percentiles
|
94
|
+
sorted_values = histogram_data.keys.sort
|
95
|
+
cumulative_counts = {}
|
96
|
+
cumulative_sum = 0
|
97
|
+
|
98
|
+
sorted_values.each do |value|
|
99
|
+
cumulative_sum += histogram_data[value]
|
100
|
+
cumulative_counts[value] = cumulative_sum
|
101
|
+
end
|
102
|
+
|
103
|
+
percentile_results = {}
|
104
|
+
percentiles.each do |percentile|
|
105
|
+
rank = (percentile / 100.0) * total_values
|
106
|
+
percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
|
107
|
+
percentile_results[percentile] = percentile_value
|
108
|
+
end
|
109
|
+
|
110
|
+
{
|
111
|
+
average: average,
|
112
|
+
mode: mode.collect(&:first).sort,
|
113
|
+
min: minmax[0],
|
114
|
+
max: minmax[1],
|
115
|
+
percentiles: percentile_results
|
116
|
+
}
|
117
|
+
end
|
118
|
+
|
58
119
|
def data_set_for histogram_data:, label:, color:
|
59
120
|
keys = histogram_data.keys.sort
|
60
121
|
{
|
@@ -103,8 +103,6 @@ class Downloader
|
|
103
103
|
json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
|
104
104
|
"?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
|
105
105
|
|
106
|
-
exit_if_call_failed json
|
107
|
-
|
108
106
|
json['issues'].each do |issue_json|
|
109
107
|
issue_json['exporter'] = {
|
110
108
|
'in_initial_query' => initial_query
|
@@ -139,15 +137,6 @@ class Downloader
|
|
139
137
|
end
|
140
138
|
end
|
141
139
|
|
142
|
-
def exit_if_call_failed json
|
143
|
-
# Sometimes Jira returns the singular form of errorMessage and sometimes the plural. Consistency FTW.
|
144
|
-
return unless json['error'] || json['errorMessages'] || json['errorMessage']
|
145
|
-
|
146
|
-
log "Download failed. See #{@file_system.logfile_name} for details.", both: true
|
147
|
-
log " #{JSON.pretty_generate(json)}"
|
148
|
-
exit 1
|
149
|
-
end
|
150
|
-
|
151
140
|
def download_statuses
|
152
141
|
log ' Downloading all statuses', both: true
|
153
142
|
json = @jira_gateway.call_url relative_url: '/rest/api/2/status'
|
@@ -188,8 +177,6 @@ class Downloader
|
|
188
177
|
log " Downloading board configuration for board #{board_id}", both: true
|
189
178
|
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
|
190
179
|
|
191
|
-
exit_if_call_failed json
|
192
|
-
|
193
180
|
@file_system.save_json(
|
194
181
|
json: json,
|
195
182
|
filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
|
@@ -213,7 +200,6 @@ class Downloader
|
|
213
200
|
while is_last == false
|
214
201
|
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/sprint?" \
|
215
202
|
"maxResults=#{max_results}&startAt=#{start_at}"
|
216
|
-
exit_if_call_failed json
|
217
203
|
|
218
204
|
@file_system.save_json(
|
219
205
|
json: json,
|
data/lib/jirametrics/exporter.rb
CHANGED
@@ -116,7 +116,9 @@ class Exporter
|
|
116
116
|
|
117
117
|
def jira_config filename = nil
|
118
118
|
if filename
|
119
|
-
@jira_config = file_system.load_json(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
|
+
|
120
122
|
@jira_config['url'] = $1 if @jira_config['url'] =~ /^(.+)\/+$/
|
121
123
|
end
|
122
124
|
@jira_config
|
@@ -66,15 +66,20 @@ class FileConfig
|
|
66
66
|
# is that all empty values in the first column should be at the bottom.
|
67
67
|
def sort_output all_lines
|
68
68
|
all_lines.sort do |a, b|
|
69
|
+
result = nil
|
69
70
|
if a[0] == b[0]
|
70
|
-
a[1..] <=> b[1..]
|
71
|
+
result = a[1..] <=> b[1..]
|
71
72
|
elsif a[0].nil?
|
72
|
-
1
|
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
|
|
@@ -35,6 +35,10 @@ class FileSystem
|
|
35
35
|
log "Warning: #{message}", more: more, also_write_to_stderr: true
|
36
36
|
end
|
37
37
|
|
38
|
+
def error message, more: nil
|
39
|
+
log "Error: #{message}", more: more, also_write_to_stderr: true
|
40
|
+
end
|
41
|
+
|
38
42
|
def log message, more: nil, also_write_to_stderr: false
|
39
43
|
message += " See #{logfile_name} for more details about this message." if more
|
40
44
|
|
@@ -1,6 +1,57 @@
|
|
1
1
|
<div class="chart">
|
2
2
|
<canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
|
3
3
|
</div>
|
4
|
+
<%
|
5
|
+
if show_stats
|
6
|
+
link_id = next_id
|
7
|
+
issues_id = next_id
|
8
|
+
%>
|
9
|
+
[<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
|
10
|
+
<div id="<%= issues_id %>" style="display: none;">
|
11
|
+
<div>
|
12
|
+
<table class="standard">
|
13
|
+
<tr>
|
14
|
+
<th>Issue Type</th>
|
15
|
+
<th>Min</th>
|
16
|
+
<th>Max</th>
|
17
|
+
<th>Avg</th>
|
18
|
+
<th>Mode</th>
|
19
|
+
<% percentiles.each do |p| %>
|
20
|
+
<th><%= p %>th</th>
|
21
|
+
<% end %>
|
22
|
+
</tr>
|
23
|
+
<% the_stats.each do |k, v| %>
|
24
|
+
<tr>
|
25
|
+
<td><%= k %></td>
|
26
|
+
<td><%= v[:min] %></td>
|
27
|
+
<td><%= v[:max] %></td>
|
28
|
+
<td><%= sprintf('%.2f', v[:average]) %></td>
|
29
|
+
<td><%= v[:mode].join(', ') %></td>
|
30
|
+
<% percentiles.each do |p| %>
|
31
|
+
<td><%= v[:percentiles][p] %></td>
|
32
|
+
<% end %>
|
33
|
+
</tr>
|
34
|
+
<% end %>
|
35
|
+
</table>
|
36
|
+
</div>
|
37
|
+
<div>
|
38
|
+
<p>These statistics help understand the <i>"shape"</i> of the cycletime histogram distribution, to help us with predictions.</p>
|
39
|
+
<ul>
|
40
|
+
<li><b>Min & Max:</b> the observed spread for the data set. Useful to judge how wide the variation is. </li>
|
41
|
+
<li><b>Average:</b> the arithmetic mean of the data set. Useful as a <i>"typical representative"</i> of the complete set.</li>
|
42
|
+
<li><b>Mode:</b> the most repeated value(s) in the data set. This is the value we're most likely to remember. </li>
|
43
|
+
<li><b>Percentiles:</b> they partition the data set. If X is the Nth percentile, it means that N% of cycletime values are X or less. Typical percentiles of interest are:</li>
|
44
|
+
<ul>
|
45
|
+
<li><b>50%</b>: also known as the <b>Median</b>. Useful to establish short feedback loops, to monitor that it's not drifting to the right.</li>
|
46
|
+
<li><b>85%</b>: useful to establish service level expectations, accounting for rare events..</li>
|
47
|
+
<li><b>98% (or higher)</b>: useful to gauge worst case expectations..</li>
|
48
|
+
</ul>
|
49
|
+
</ul>
|
50
|
+
</div>
|
51
|
+
</div>
|
52
|
+
<%
|
53
|
+
end
|
54
|
+
%>
|
4
55
|
<script>
|
5
56
|
new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
6
57
|
{
|
@@ -14,9 +14,15 @@ class JiraGateway
|
|
14
14
|
def call_url relative_url:
|
15
15
|
command = make_curl_command url: "#{@jira_url}#{relative_url}"
|
16
16
|
result = call_command command
|
17
|
-
|
18
|
-
|
19
|
-
|
17
|
+
begin
|
18
|
+
json = JSON.parse(result)
|
19
|
+
rescue # rubocop:disable Style/RescueStandardError
|
20
|
+
raise "Error when parsing result: #{result.inspect}"
|
21
|
+
end
|
22
|
+
|
23
|
+
raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)
|
24
|
+
|
25
|
+
json
|
20
26
|
end
|
21
27
|
|
22
28
|
def call_command command
|
@@ -61,4 +67,11 @@ class JiraGateway
|
|
61
67
|
command << " --url \"#{url}\""
|
62
68
|
command
|
63
69
|
end
|
70
|
+
|
71
|
+
def json_successful? json
|
72
|
+
return false if json.is_a?(Hash) && (json['error'] || json['errorMessages'] || json['errorMessage'])
|
73
|
+
return false if json.is_a?(Array) && json.first == 'errorMessage'
|
74
|
+
|
75
|
+
true
|
76
|
+
end
|
64
77
|
end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: jirametrics
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version: 2.
|
4
|
+
version: 2.10pre1
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Mike Bowler
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-01-
|
10
|
+
date: 2025-01-31 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: random-word
|