jirametrics 2.9.1 → 2.10

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 611df8210f566c143bfe8a90dcb15d17c5879e589d1675e58cc37a2f0fcd3d56
4
- data.tar.gz: 4ed55e082c7380278b1aebaaf6e38810470cd88b4a1b197694145881e8045eb7
3
+ metadata.gz: f6d6dd1e6c2811b1395ef0ce25d1458db4c3b88c2e4ab774e6c2db045549e51e
4
+ data.tar.gz: 53d614e48e61a7fad285d72de82f6f59a0c9024c33f39b36478c7c01866150c2
5
5
  SHA512:
6
- metadata.gz: f77bff075dcbfffcb1808c0460751087ba4c48d1fdbbf83213919ebbc54a7bbd1d630f5e082dabca2724173ca141ed1ca4f8291370935b1a06d16d66332d5c1a
7
- data.tar.gz: ef48c973389dfd456daac50686a3d3d62f9dbcebfe59175a66dfc1a7c390f214290fffa6e9e206ec1109e037a122aa3a3775e270c0965a551b96a2b9ca6a7d73
6
+ metadata.gz: '079f3d1f72a7d4cfd3d3da08d508cacfe4f04d6926e3674d5c6654cb290b8f1a1d7bbdb585bc587b743a888b85a19714294cdd15a9f891e2a7b52f5303b70d6f'
7
+ data.tar.gz: 9a6bdfbe92dc04d8264efad3d6be1569383f604968fa3c83fbda33fb20b25210b545cb86102a1e51926348f58aad97328c119be772e15f72090932fc40fff5d4
@@ -64,7 +64,7 @@ class AggregateConfig
64
64
  end
65
65
 
66
66
  if issues.nil?
67
- log "No issues found for #{project_name}"
67
+ file_system.warning "No issues found for #{project_name}"
68
68
  else
69
69
  @project_config.add_issues issues
70
70
  end
@@ -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,18 @@ 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
+
51
+ overall_stats = stats_for histogram_data: histogram_data_for(issues: histogram_issues), percentiles: @percentiles
52
+ the_stats[:all] = overall_stats
36
53
  data_sets = rules_to_issues.keys.collect do |rules|
54
+ the_issue_type = rules.label
55
+ the_histogram = histogram_data_for(issues: rules_to_issues[rules])
56
+ the_stats[the_issue_type] = stats_for histogram_data: the_histogram, percentiles: @percentiles if @show_stats
57
+
37
58
  data_set_for(
38
- histogram_data: histogram_data_for(issues: rules_to_issues[rules]),
39
- label: rules.label,
59
+ histogram_data: the_histogram,
60
+ label: the_issue_type,
40
61
  color: rules.color
41
62
  )
42
63
  end
@@ -55,6 +76,48 @@ class CycletimeHistogram < ChartBase
55
76
  count_hash
56
77
  end
57
78
 
79
+ def stats_for histogram_data:, percentiles:
80
+ return {} if histogram_data.empty?
81
+
82
+ total_values = histogram_data.values.sum
83
+
84
+ # Calculate the average
85
+ weighted_sum = histogram_data.reduce(0) { |sum, (value, frequency)| sum + (value * frequency) }
86
+ average = total_values.zero? ? 0 : weighted_sum.to_f / total_values
87
+
88
+ # Find the mode (or modes!) and the spread of the distribution
89
+ sorted_histogram = histogram_data.sort_by { |_value, frequency| frequency }
90
+ max_freq = sorted_histogram[-1][1]
91
+ mode = sorted_histogram.select { |_v, f| f == max_freq }
92
+
93
+ minmax = histogram_data.keys.minmax
94
+
95
+ # Calculate percentiles
96
+ sorted_values = histogram_data.keys.sort
97
+ cumulative_counts = {}
98
+ cumulative_sum = 0
99
+
100
+ sorted_values.each do |value|
101
+ cumulative_sum += histogram_data[value]
102
+ cumulative_counts[value] = cumulative_sum
103
+ end
104
+
105
+ percentile_results = {}
106
+ percentiles.each do |percentile|
107
+ rank = (percentile / 100.0) * total_values
108
+ percentile_value = sorted_values.find { |value| cumulative_counts[value] >= rank }
109
+ percentile_results[percentile] = percentile_value
110
+ end
111
+
112
+ {
113
+ average: average,
114
+ mode: mode.collect(&:first).sort,
115
+ min: minmax[0],
116
+ max: minmax[1],
117
+ percentiles: percentile_results
118
+ }
119
+ end
120
+
58
121
  def data_set_for histogram_data:, label:, color:
59
122
  keys = histogram_data.keys.sort
60
123
  {
@@ -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,
@@ -64,12 +64,8 @@ class Exporter
64
64
  selected = []
65
65
  each_project_config(name_filter: name_filter) do |project|
66
66
  project.evaluate_next_level
67
- # next if project.aggregated_project?
68
67
 
69
68
  project.run load_only: true
70
- project.board_configs.each do |board_config|
71
- board_config.run
72
- end
73
69
  project.issues.each do |issue|
74
70
  selected << [project, issue] if keys.include? issue.key
75
71
  end
@@ -79,9 +75,13 @@ class Exporter
79
75
  raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
80
76
  end
81
77
 
82
- selected.each do |project, issue|
83
- puts "\nProject #{project.name}"
84
- puts issue.dump
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}"
83
+ file_system.log issue.dump
84
+ end
85
85
  end
86
86
  end
87
87
 
@@ -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
 
@@ -43,8 +43,8 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
43
43
  <% if percentage_line_x %>
44
44
  line: {
45
45
  type: 'line',
46
- xMin: '<%= percentage_line_x %>',
47
- xMax: '<%= percentage_line_x %>',
46
+ scaleID: 'x',
47
+ value: '<%= percentage_line_x %>',
48
48
  borderColor: <%= CssVariable.new('--aging-work-bar-chart-percentage-line-color').to_json %>,
49
49
  borderWidth: 1,
50
50
  drawTime: 'afterDraw'
@@ -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 style="text-align: right;"><%= v[:min] %></td>
27
+ <td style="text-align: right;"><%= v[:max] %></td>
28
+ <td style="text-align: right;"><%= sprintf('%.2f', v[:average]) %></td>
29
+ <td><%= v[:mode].join(', ') %></td>
30
+ <% percentiles.each do |p| %>
31
+ <td style="text-align: right;"><%= 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
  {
@@ -21,6 +72,8 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
21
72
  grid: {
22
73
  color: <%= CssVariable['--grid-line-color'].to_json %>
23
74
  },
75
+ min: 0,
76
+ offset: false, // Gets rid of the ugly padding on left.
24
77
  },
25
78
  y: {
26
79
  stacked: true,
@@ -34,6 +87,27 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
34
87
  }
35
88
  },
36
89
  plugins: {
90
+ annotation: {
91
+ annotations: {
92
+ <%
93
+ results = the_stats[:all][:percentiles]
94
+ results.each do |percentile, value|
95
+ %>
96
+ percentile<%= percentile.to_s %>: {
97
+ type: 'line',
98
+ scaleID: 'x',
99
+ value: <%= value %>,
100
+ borderWidth: 1,
101
+ drawTime: 'beforeDatasetsDraw',
102
+ label: {
103
+ enabled: true,
104
+ content: '<%= "#{percentile}%" %>',
105
+ position: 'start',
106
+ }
107
+ },
108
+ <% end %>
109
+ },
110
+ },
37
111
  tooltip: {
38
112
  callbacks: {
39
113
  label: function(context) {
@@ -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
- JSON.parse result
18
- rescue => e # rubocop:disable Style/RescueStandardError
19
- raise "Error #{e.message.inspect} when parsing result: #{result.inspect}"
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
@@ -38,12 +38,6 @@ class Status
38
38
  def self.from_raw raw
39
39
  category_config = raw['statusCategory']
40
40
 
41
- legal_keys = %w[new indeterminate done]
42
- unless legal_keys.include? category_config['key']
43
- puts "Category key #{category_config['key'].inspect} should be one of #{legal_keys.inspect}. Found:\n" \
44
- "#{category_config}"
45
- end
46
-
47
41
  Status.new(
48
42
  name: raw['name'],
49
43
  id: raw['id'].to_i,
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.9.1
4
+ version: '2.10'
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mike Bowler
8
8
  bindir: bin
9
9
  cert_chain: []
10
- date: 2025-01-21 00:00:00.000000000 Z
10
+ date: 2025-02-06 00:00:00.000000000 Z
11
11
  dependencies:
12
12
  - !ruby/object:Gem::Dependency
13
13
  name: random-word