jirametrics 3.3.1 → 3.4
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/aging_work_bar_chart.rb +2 -4
- data/lib/jirametrics/aging_work_in_progress_chart.rb +26 -26
- data/lib/jirametrics/atlassian_document_format.rb +22 -14
- data/lib/jirametrics/board_movement_calculator.rb +0 -23
- data/lib/jirametrics/change_item.rb +4 -8
- data/lib/jirametrics/chart_base.rb +44 -15
- data/lib/jirametrics/chart_format.rb +15 -0
- data/lib/jirametrics/daily_view.rb +6 -7
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +1 -1
- data/lib/jirametrics/data_quality_report.rb +3 -3
- data/lib/jirametrics/dependency_chart.rb +3 -5
- data/lib/jirametrics/estimate_accuracy_chart.rb +37 -8
- data/lib/jirametrics/expedited_chart.rb +5 -6
- data/lib/jirametrics/exporter.rb +24 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +3 -5
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +10 -7
- data/lib/jirametrics/html/estimate_accuracy_chart.erb +34 -1
- data/lib/jirametrics/html/index.css +10 -9
- data/lib/jirametrics/html/index.js +13 -0
- data/lib/jirametrics/html/time_based_scatterplot.erb +2 -1
- data/lib/jirametrics/issue.rb +5 -7
- data/lib/jirametrics/issue_printer.rb +5 -3
- data/lib/jirametrics/jira_gateway.rb +5 -7
- data/lib/jirametrics/sprint_burndown.rb +2 -2
- data/lib/jirametrics/testing/mock_board.rb +34 -0
- data/lib/jirametrics/testing/mock_change_item.rb +112 -0
- data/lib/jirametrics/testing/mock_cycle_time_config.rb +73 -0
- data/lib/jirametrics/testing/mock_issue.rb +108 -0
- data/lib/jirametrics/testing.rb +88 -0
- data/lib/jirametrics/time_based_histogram.rb +2 -4
- data/lib/jirametrics/time_based_scatterplot.rb +2 -4
- data/lib/jirametrics/user.rb +16 -2
- data/lib/jirametrics/wip_by_column_chart.rb +1 -2
- data/lib/jirametrics.rb +6 -1
- metadata +7 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 1f7292394330330ca10f89a28f02fab3e95cff385736c423ca3602c23551e4ca
|
|
4
|
+
data.tar.gz: 4690b5ce8cc47d3e980ee026e68f6f245e564ec51aa914914dd7a0db32f3c0b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 6f32fbe86ecfb9ecc5ce7ace7d0a2971b6777c7a61e4513d41d2444a976699487ba4da415b6e84484cad72a45ee6af4a822f14da430018e6b40b39bb48046a70
|
|
7
|
+
data.tar.gz: 2b4af2fe6c95834710901e3aacccc1d260ceb7c28020a853f1a643be1faf3c48a0663488839c522249e2a14a2d3c6e4665425a3c7bf5175354a0311493679ee6
|
|
@@ -14,6 +14,7 @@ class AgingWorkBarChart < ChartBase
|
|
|
14
14
|
@percentage_lines = [] # Populated by run; the description reads it, so it must never be nil.
|
|
15
15
|
percentiles [85]
|
|
16
16
|
header_text 'Aging Work Bar Chart'
|
|
17
|
+
no_data_text '<%= render_header %><p>There is no aging work</p>'
|
|
17
18
|
# div class="p" throughout rather than <p>: color_block emits a div and the list below is an
|
|
18
19
|
# ol, neither of which is legal inside a paragraph. A browser closes the p at the first one,
|
|
19
20
|
# which strands the rest of the text outside it. This is what the other charts use.
|
|
@@ -69,10 +70,7 @@ class AgingWorkBarChart < ChartBase
|
|
|
69
70
|
{ percentile: percentile, days: days, x: date_range.end - days, id: "percentile_#{percentile}" }
|
|
70
71
|
end
|
|
71
72
|
|
|
72
|
-
if aging_issues.empty?
|
|
73
|
-
@description_text = '<p>There is no aging work</p>'
|
|
74
|
-
return render_top_text(binding)
|
|
75
|
-
end
|
|
73
|
+
return render_no_data if aging_issues.empty?
|
|
76
74
|
|
|
77
75
|
wrap_and_render(binding, __FILE__)
|
|
78
76
|
end
|
|
@@ -12,7 +12,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
12
12
|
|
|
13
13
|
def initialize block
|
|
14
14
|
super()
|
|
15
|
-
header_text 'Aging Work in Progress'
|
|
15
|
+
header_text 'Aging Work in Progress on board: <%= current_board.name %>'
|
|
16
16
|
# div class="p" rather than <p>: the notes below are a ul, which is block level and not legal
|
|
17
17
|
# inside a paragraph, so a browser closes the p early. Harmless while the list is the last
|
|
18
18
|
# thing in the block, but it breaks the moment any text follows it.
|
|
@@ -59,7 +59,6 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
59
59
|
def run
|
|
60
60
|
determine_board_columns
|
|
61
61
|
|
|
62
|
-
@header_text += " on board: #{current_board.name}"
|
|
63
62
|
data_sets = make_data_sets
|
|
64
63
|
|
|
65
64
|
adjust_visibility_of_unmapped_status_column data_sets: data_sets
|
|
@@ -91,17 +90,27 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
91
90
|
|
|
92
91
|
rules_to_issues = group_issues aging_issues
|
|
93
92
|
data_sets = rules_to_issues.keys.collect { |rules| line_data_set(rules, rules_to_issues[rules]) }
|
|
94
|
-
# The y-axis is scaled to the oldest item, but never shorter than 20 days.
|
|
95
|
-
@max_age = data_sets.flat_map { |set| set['data'].collect { |point| point['y'] } }.push(20).max
|
|
96
93
|
|
|
97
94
|
calculator = BoardMovementCalculator.new board: @all_boards[@board_id], issues: issues, today: date_range.end
|
|
98
95
|
column_indexes_to_remove = trim_board_columns data_sets: data_sets, calculator: calculator
|
|
99
|
-
@row_index_offset = data_sets.size
|
|
100
96
|
|
|
101
97
|
append_bar_data_sets data_sets, calculator, column_indexes_to_remove
|
|
98
|
+
@max_age = highest_plotted_age data_sets
|
|
102
99
|
data_sets
|
|
103
100
|
end
|
|
104
101
|
|
|
102
|
+
# The y-axis ceiling is set a little above this so you can see that the tallest thing on the chart
|
|
103
|
+
# really does stop where it stops, rather than being clipped by the top of the plot. Bands count as
|
|
104
|
+
# much as dots do. Never shorter than 20 days.
|
|
105
|
+
# Called after the columns have been trimmed, so a column nobody can see can't push the ceiling up
|
|
106
|
+
# and leave unexplained space at the top.
|
|
107
|
+
def highest_plotted_age data_sets
|
|
108
|
+
data_sets.flat_map do |set|
|
|
109
|
+
# A dot is {x:, y:, title:}. A band is a floating bar, [bottom, top].
|
|
110
|
+
set['data'].collect { |point| point.is_a?(Hash) ? point['y'] : point.last }
|
|
111
|
+
end.push(20).max
|
|
112
|
+
end
|
|
113
|
+
|
|
105
114
|
def aging_issue_on_board? issue
|
|
106
115
|
issue.board.id == @board_id && issue.board.cycletime.in_progress?(issue)
|
|
107
116
|
end
|
|
@@ -128,32 +137,23 @@ class AgingWorkInProgressChart < ChartBase
|
|
|
128
137
|
end
|
|
129
138
|
end
|
|
130
139
|
|
|
140
|
+
# Each band is a floating bar running from the previous percentile's age up to its own, so it
|
|
141
|
+
# carries its own absolute position. Asking Chart.js to stack the bands instead hands it the
|
|
142
|
+
# positioning of every dataset on the chart, and it will stack the aging dots right along with them.
|
|
131
143
|
def append_bar_data_sets data_sets, calculator, column_indexes_to_remove
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
144
|
+
previous_ages = []
|
|
145
|
+
|
|
146
|
+
@percentiles.keys.sort.each do |percentage|
|
|
147
|
+
ages = calculator.age_data_for percentage: percentage
|
|
148
|
+
column_indexes_to_remove.reverse_each { |index| ages.delete_at index }
|
|
149
|
+
|
|
135
150
|
data_sets << {
|
|
136
151
|
'type' => 'bar', 'label' => "#{percentage}%", 'barPercentage' => 1.0, 'categoryPercentage' => 1.0,
|
|
137
|
-
'backgroundColor' => @percentiles[percentage],
|
|
152
|
+
'backgroundColor' => @percentiles[percentage],
|
|
153
|
+
'data' => ages.each_with_index.collect { |age, index| [previous_ages[index] || 0, age] }
|
|
138
154
|
}
|
|
139
|
-
|
|
155
|
+
previous_ages = ages
|
|
140
156
|
end
|
|
141
|
-
@bar_data = adjust_bar_data bar_data
|
|
142
|
-
end
|
|
143
|
-
|
|
144
|
-
def adjust_bar_data input
|
|
145
|
-
return [] if input.empty?
|
|
146
|
-
|
|
147
|
-
row_size = input.first.size
|
|
148
|
-
|
|
149
|
-
output = []
|
|
150
|
-
output << input.first
|
|
151
|
-
input.drop(1).each do |row|
|
|
152
|
-
previous_row = output.last
|
|
153
|
-
output << 0.upto(row_size - 1).collect { |i| row[i] + previous_row[i] }
|
|
154
|
-
end
|
|
155
|
-
|
|
156
|
-
output
|
|
157
157
|
end
|
|
158
158
|
|
|
159
159
|
def indexes_of_leading_and_trailing_zeros list
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require 'cgi'
|
|
4
|
+
|
|
3
5
|
class AtlassianDocumentFormat
|
|
4
6
|
attr_reader :users
|
|
5
7
|
|
|
@@ -10,7 +12,7 @@ class AtlassianDocumentFormat
|
|
|
10
12
|
|
|
11
13
|
def to_html input
|
|
12
14
|
if input.is_a? String
|
|
13
|
-
input
|
|
15
|
+
escape(input)
|
|
14
16
|
.gsub(/{color:(#\w{6})}([^{]+){color}/, '<span style="color: \1">\2</span>') # Colours
|
|
15
17
|
# rubocop:disable Style/PerlBackrefs -- in a gsub block $1 reads cleaner than Regexp.last_match[:...]
|
|
16
18
|
.gsub(/\[~accountid:([^\]]+)\]/) { expand_account_id $1 } # Tagged people
|
|
@@ -52,22 +54,22 @@ class AtlassianDocumentFormat
|
|
|
52
54
|
[Time.at(node_attrs['timestamp'].to_i / 1000, in: @timezone_offset).to_date.to_s, nil]
|
|
53
55
|
when 'decisionItem', 'listItem' then ['<li>', '</li>']
|
|
54
56
|
when 'decisionList' then ['<div>Decisions<ul>', '</ul></div>']
|
|
55
|
-
when 'emoji', 'status' then [node_attrs['text'], nil]
|
|
56
|
-
when 'expand' then ["<div>#{node_attrs['title']}</div>", nil]
|
|
57
|
+
when 'emoji', 'status' then [escape(node_attrs['text']), nil]
|
|
58
|
+
when 'expand' then ["<div>#{escape node_attrs['title']}</div>", nil]
|
|
57
59
|
when 'hardBreak' then ['<br />', nil]
|
|
58
60
|
when 'heading'
|
|
59
61
|
level = node_attrs['level']
|
|
60
62
|
["<h#{level}>", "</h#{level}>"]
|
|
61
63
|
when 'inlineCard'
|
|
62
|
-
url = node_attrs['url']
|
|
64
|
+
url = escape node_attrs['url']
|
|
63
65
|
["[Inline card]: <a href='#{url}'>#{url}</a>", nil]
|
|
64
66
|
when 'media'
|
|
65
|
-
text = node_attrs['alt'] || node_attrs['id']
|
|
67
|
+
text = escape(node_attrs['alt'] || node_attrs['id'])
|
|
66
68
|
["Media: #{text}", nil]
|
|
67
69
|
when 'mediaSingle', 'mediaGroup' then ['<div>', '</div>']
|
|
68
|
-
when 'mention' then ["<b>#{node_attrs['text']}</b>", nil]
|
|
70
|
+
when 'mention' then ["<b>#{escape node_attrs['text']}</b>", nil]
|
|
69
71
|
when 'orderedList' then ['<ol>', '</ol>']
|
|
70
|
-
when 'panel' then ["<div>#{node_attrs['panelType'].upcase}</div>", nil]
|
|
72
|
+
when 'panel' then ["<div>#{escape node_attrs['panelType'].upcase}</div>", nil]
|
|
71
73
|
when 'paragraph' then ['<p>', '</p>']
|
|
72
74
|
when 'rule' then ['<hr />', nil]
|
|
73
75
|
when 'table' then ['<table>', '</table>']
|
|
@@ -76,13 +78,13 @@ class AtlassianDocumentFormat
|
|
|
76
78
|
when 'tableRow' then ['<tr>', '</tr>']
|
|
77
79
|
when 'text'
|
|
78
80
|
marks = adf_marks_to_html(n['marks'])
|
|
79
|
-
[marks.collect(&:first).join + n['text'], marks.collect(&:last).join]
|
|
81
|
+
[marks.collect(&:first).join + escape(n['text']), marks.collect(&:last).join]
|
|
80
82
|
when 'taskItem'
|
|
81
83
|
state = node_attrs['state'] == 'TODO' ? '☐' : '☑'
|
|
82
84
|
["<li>#{state} ", '</li>']
|
|
83
85
|
when 'taskList' then ["<ul class='taskList'>", '</ul>']
|
|
84
86
|
else
|
|
85
|
-
["<p>Unparseable section: #{n['type']}</p>", nil]
|
|
87
|
+
["<p>Unparseable section: #{escape n['type']}</p>", nil]
|
|
86
88
|
end
|
|
87
89
|
end
|
|
88
90
|
end
|
|
@@ -135,11 +137,11 @@ class AtlassianDocumentFormat
|
|
|
135
137
|
list.filter_map do |mark|
|
|
136
138
|
type = mark['type']
|
|
137
139
|
if type == 'textColor'
|
|
138
|
-
color = mark['attrs']['color']
|
|
140
|
+
color = escape mark['attrs']['color']
|
|
139
141
|
["<span style='color: #{color}'>", '</span>']
|
|
140
142
|
elsif type == 'link'
|
|
141
|
-
href = mark['attrs']['href']
|
|
142
|
-
title = mark['attrs']['title']
|
|
143
|
+
href = escape mark['attrs']['href']
|
|
144
|
+
title = escape mark['attrs']['title']
|
|
143
145
|
["<a href='#{href}' title='#{title}'>", '</a>']
|
|
144
146
|
else
|
|
145
147
|
line = mappings.find { |key, _open, _close| key == type }
|
|
@@ -150,13 +152,19 @@ class AtlassianDocumentFormat
|
|
|
150
152
|
|
|
151
153
|
def expand_account_id account_id
|
|
152
154
|
user = @users.find { |u| u.account_id == account_id }
|
|
153
|
-
text = account_id
|
|
154
|
-
text = "@#{user.display_name}" if user
|
|
155
|
+
text = escape account_id
|
|
156
|
+
text = "@#{escape user.display_name}" if user
|
|
155
157
|
"<span class='account_id'>#{text}</span>"
|
|
156
158
|
end
|
|
157
159
|
|
|
158
160
|
private
|
|
159
161
|
|
|
162
|
+
# The only markup in the output should be markup this class generated. Everything that comes out
|
|
163
|
+
# of the document is data written by whoever raised the issue, so it gets escaped on the way past.
|
|
164
|
+
def escape value
|
|
165
|
+
CGI.escapeHTML value.to_s
|
|
166
|
+
end
|
|
167
|
+
|
|
160
168
|
def adf_node_render node, &render_node
|
|
161
169
|
prefix, suffix = yield(node)
|
|
162
170
|
result = +(prefix || '')
|
|
@@ -22,29 +22,6 @@ class BoardMovementCalculator
|
|
|
22
22
|
columns.each_cons(2).any? { |previous, current| current < previous }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
|
-
def stacked_age_data_for percentages:
|
|
26
|
-
data_list = percentages.sort.collect do |percentage|
|
|
27
|
-
[percentage, age_data_for(percentage: percentage)]
|
|
28
|
-
end
|
|
29
|
-
|
|
30
|
-
stack_data data_list
|
|
31
|
-
end
|
|
32
|
-
|
|
33
|
-
def stack_data data_list
|
|
34
|
-
remainder = nil
|
|
35
|
-
data_list.collect do |percentage, data|
|
|
36
|
-
unless remainder.nil?
|
|
37
|
-
data = (0...data.length).collect do |i|
|
|
38
|
-
data[i] - remainder[i]
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
end
|
|
42
|
-
remainder = data
|
|
43
|
-
|
|
44
|
-
[percentage, data]
|
|
45
|
-
end
|
|
46
|
-
end
|
|
47
|
-
|
|
48
25
|
def age_data_for percentage:
|
|
49
26
|
data = []
|
|
50
27
|
board.visible_columns.each_with_index do |_column, column_index|
|
|
@@ -37,13 +37,10 @@ class ChangeItem
|
|
|
37
37
|
raw_value.to_s.split(', ').collect(&:to_i)
|
|
38
38
|
end
|
|
39
39
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
end
|
|
40
|
+
# It should be impossible to have no author at all, but we've seen it in production.
|
|
41
|
+
def author = User.from_raw(@author_raw)&.display_name || 'Unknown author'
|
|
43
42
|
|
|
44
|
-
def author_icon_url
|
|
45
|
-
@author_raw&.[]('avatarUrls')&.[]('16x16')
|
|
46
|
-
end
|
|
43
|
+
def author_icon_url = User.from_raw(@author_raw)&.avatar_url
|
|
47
44
|
|
|
48
45
|
def artificial? = @artificial
|
|
49
46
|
def assignee? = (field == 'assignee')
|
|
@@ -129,8 +126,7 @@ class ChangeItem
|
|
|
129
126
|
private
|
|
130
127
|
|
|
131
128
|
def time_to_s time
|
|
132
|
-
#
|
|
133
|
-
# format so that tests work under both environments.
|
|
129
|
+
# Not all ruby versions return the same string for to_s so we force to a known format.
|
|
134
130
|
time.strftime '%Y-%m-%d %H:%M:%S %z'
|
|
135
131
|
end
|
|
136
132
|
end
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
# require_all cannot resolve an include the way it resolves a superclass, and chart_format.rb
|
|
4
|
+
# sorts after this file, so name it explicitly.
|
|
5
|
+
require_relative 'chart_format'
|
|
6
|
+
|
|
3
7
|
class ChartBase
|
|
8
|
+
include ChartFormat
|
|
9
|
+
|
|
4
10
|
# Okabe-Ito palette - perceptually distinct under the most common forms of colour blindness.
|
|
5
11
|
# Ordered from most- to least-commonly useful for chart series.
|
|
6
12
|
OKABE_ITO_PALETTE = %w[
|
|
@@ -63,17 +69,44 @@ class ChartBase
|
|
|
63
69
|
erb.result(caller_binding)
|
|
64
70
|
end
|
|
65
71
|
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
72
|
+
# A fresh scope for every template expansion. self is still the chart, so instance variables and
|
|
73
|
+
# methods are reachable from a template, but the caller's local variables are not. That is the
|
|
74
|
+
# point: ERB#result assigns its output buffer into whatever binding you hand it, so two templates
|
|
75
|
+
# sharing one scope silently discard each other's output when one renders the other.
|
|
76
|
+
def render_binding = binding
|
|
77
|
+
|
|
78
|
+
def expand_template text
|
|
79
|
+
ERB.new(text).result(render_binding)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
# The header as the reader sees it. Stitch configs match on this through grab_by_title, so it has
|
|
83
|
+
# to be the expanded text rather than the template.
|
|
84
|
+
def expanded_header_text = @header_text.nil? ? '' : expand_template(@header_text)
|
|
85
|
+
|
|
86
|
+
def render_header
|
|
87
|
+
return '' unless @header_text
|
|
88
|
+
|
|
89
|
+
"<h1 class='foldable'>#{expanded_header_text}</h1>"
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def render_top_text
|
|
93
|
+
result = +render_header
|
|
94
|
+
result << expand_template(@description_text) if @description_text
|
|
70
95
|
result
|
|
71
96
|
end
|
|
72
97
|
|
|
98
|
+
# What a chart shows when it has nothing to show. Empty means show nothing at all, not even the
|
|
99
|
+
# header, which is what a chart that simply does not apply to this board wants.
|
|
100
|
+
def render_no_data
|
|
101
|
+
return '' if @no_data_text.nil? || @no_data_text.empty?
|
|
102
|
+
|
|
103
|
+
expand_template @no_data_text
|
|
104
|
+
end
|
|
105
|
+
|
|
73
106
|
# Render the file and then wrap it with standard headers and quality checks.
|
|
74
107
|
def wrap_and_render caller_binding, file
|
|
75
108
|
result = +''
|
|
76
|
-
result << render_top_text
|
|
109
|
+
result << render_top_text
|
|
77
110
|
result << render(caller_binding, file)
|
|
78
111
|
result
|
|
79
112
|
end
|
|
@@ -240,20 +273,16 @@ class ChartBase
|
|
|
240
273
|
end
|
|
241
274
|
end
|
|
242
275
|
|
|
243
|
-
def chart_format object
|
|
244
|
-
if object.is_a? Time
|
|
245
|
-
# "2022-04-09T11:38:30-07:00"
|
|
246
|
-
object.strftime '%Y-%m-%dT%H:%M:%S%z'
|
|
247
|
-
else
|
|
248
|
-
object.to_s
|
|
249
|
-
end
|
|
250
|
-
end
|
|
251
|
-
|
|
252
276
|
def header_text text = :none
|
|
253
277
|
@header_text = text unless text == :none
|
|
254
278
|
@header_text
|
|
255
279
|
end
|
|
256
280
|
|
|
281
|
+
def no_data_text text = :none
|
|
282
|
+
@no_data_text = text unless text == :none
|
|
283
|
+
@no_data_text
|
|
284
|
+
end
|
|
285
|
+
|
|
257
286
|
def description_text text = :none
|
|
258
287
|
@description_text = text unless text == :none
|
|
259
288
|
@description_text
|
|
@@ -412,7 +441,7 @@ class ChartBase
|
|
|
412
441
|
# reassemble the report. Generated because only the start/end word differs.
|
|
413
442
|
%w[start end].each do |position|
|
|
414
443
|
define_method "seam_#{position}" do |type = 'chart'|
|
|
415
|
-
"\n<!-- seam-#{position} | chart#{@@chart_counter} | #{self.class} | #{
|
|
444
|
+
"\n<!-- seam-#{position} | chart#{@@chart_counter} | #{self.class} | #{expanded_header_text} | #{type} -->\n"
|
|
416
445
|
end
|
|
417
446
|
end
|
|
418
447
|
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Formats values for a Chart.js axis. Included by ChartBase and by the specs, which build the same
|
|
4
|
+
# strings to assert against.
|
|
5
|
+
module ChartFormat
|
|
6
|
+
def chart_format object
|
|
7
|
+
# Not all ruby versions return the same string for to_s so we force to a known format.
|
|
8
|
+
|
|
9
|
+
if object.is_a? Time
|
|
10
|
+
object.strftime '%Y-%m-%dT%H:%M:%S%z' # => 2022-04-09T11:38:30-0700
|
|
11
|
+
else
|
|
12
|
+
object.to_s
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -7,9 +7,10 @@ class DailyView < ChartBase
|
|
|
7
7
|
super()
|
|
8
8
|
|
|
9
9
|
header_text 'Daily View'
|
|
10
|
+
no_data_text '<%= render_header %><div>There are no items currently in progress</div>'
|
|
10
11
|
description_text <<-HTML
|
|
11
12
|
<div class="p">
|
|
12
|
-
This view shows all the items (<%= aging_issues.count %>) you'll want to discuss during your daily
|
|
13
|
+
This view shows all the items (<%= @aging_issues.count %>) you'll want to discuss during your daily
|
|
13
14
|
coordination meeting
|
|
14
15
|
(aka daily scrum, standup), in the order that you should be discussing them. The most important
|
|
15
16
|
items are at the top, and the least at the bottom.
|
|
@@ -22,15 +23,13 @@ class DailyView < ChartBase
|
|
|
22
23
|
end
|
|
23
24
|
|
|
24
25
|
def run
|
|
25
|
-
aging_issues = select_aging_issues
|
|
26
|
+
@aging_issues = select_aging_issues
|
|
26
27
|
|
|
27
|
-
if aging_issues.empty?
|
|
28
|
-
return "<h1 class='foldable'>#{@header_text}</h1><div>There are no items currently in progress</div>"
|
|
29
|
-
end
|
|
28
|
+
return render_no_data if @aging_issues.empty?
|
|
30
29
|
|
|
31
30
|
result = +''
|
|
32
|
-
result << render_top_text
|
|
33
|
-
aging_issues.each do |issue|
|
|
31
|
+
result << render_top_text
|
|
32
|
+
@aging_issues.each do |issue|
|
|
34
33
|
result << render_issue(issue, child: false)
|
|
35
34
|
end
|
|
36
35
|
result
|
|
@@ -14,7 +14,7 @@ class DailyWipByParentChart < DailyWipChart
|
|
|
14
14
|
indication of how focused we are on higher level objectives. If there are many parent
|
|
15
15
|
tickets in progress at the same time, either this team has their focus scattered or we
|
|
16
16
|
aren't doing a good job of
|
|
17
|
-
<a href="https://
|
|
17
|
+
<a href="https://blog.mikebowler.ca/2024/02/21/slicing-epics/">splitting those parent
|
|
18
18
|
tickets</a>. Neither of those is desirable.
|
|
19
19
|
</div>
|
|
20
20
|
<div class="p">
|
|
@@ -63,11 +63,10 @@ class DataQualityReport < ChartBase
|
|
|
63
63
|
scan_for_issues_on_multiple_boards entries: @entries
|
|
64
64
|
|
|
65
65
|
entries_with_problems = entries_with_problems()
|
|
66
|
-
return
|
|
66
|
+
return render_no_data if entries_with_problems.empty?
|
|
67
67
|
|
|
68
|
-
caller_binding = binding
|
|
69
68
|
result = +''
|
|
70
|
-
result << render_top_text
|
|
69
|
+
result << render_top_text
|
|
71
70
|
|
|
72
71
|
result << '<ul class="quality_report">'
|
|
73
72
|
result << render_problem_type(:discarded_changes)
|
|
@@ -111,6 +110,7 @@ class DataQualityReport < ChartBase
|
|
|
111
110
|
|
|
112
111
|
# Return a format that's easier to assert against
|
|
113
112
|
def testable_entries
|
|
113
|
+
# Not all ruby versions return the same string for to_s so we force to a known format.
|
|
114
114
|
formatter = ->(time) { time&.strftime('%Y-%m-%d %H:%M:%S %z') || '' }
|
|
115
115
|
@entries.collect do |entry|
|
|
116
116
|
[
|
|
@@ -67,6 +67,7 @@ class DependencyChart < ChartBase
|
|
|
67
67
|
}
|
|
68
68
|
|
|
69
69
|
header_text 'Dependencies'
|
|
70
|
+
no_data_text '<%= render_header %><div>No data matched the selected criteria. Nothing to show.</div>'
|
|
70
71
|
description_text <<-HTML
|
|
71
72
|
<p>
|
|
72
73
|
These are all the "linked issues" as defined in Jira
|
|
@@ -83,13 +84,10 @@ class DependencyChart < ChartBase
|
|
|
83
84
|
instance_eval(&@rules_block) if @rules_block
|
|
84
85
|
|
|
85
86
|
dot_graph = build_dot_graph
|
|
86
|
-
if dot_graph.nil?
|
|
87
|
-
return "<h1 class='foldable'>#{@header_text}</h1>" \
|
|
88
|
-
'<div>No data matched the selected criteria. Nothing to show.</div>'
|
|
89
|
-
end
|
|
87
|
+
return render_no_data if dot_graph.nil?
|
|
90
88
|
|
|
91
89
|
svg = restore_css_variables execute_graphviz(dot_graph.join("\n"))
|
|
92
|
-
"
|
|
90
|
+
"#{render_top_text}<div>#{shrink_svg svg}</div>"
|
|
93
91
|
end
|
|
94
92
|
|
|
95
93
|
def link_rules &block
|
|
@@ -14,17 +14,24 @@ class EstimateAccuracyChart < ChartBase
|
|
|
14
14
|
The #{color_block '--estimate-accuracy-chart-completed-fill-color'} completed dots indicate
|
|
15
15
|
cycletimes.
|
|
16
16
|
<% if @has_aging_data %>
|
|
17
|
-
The #{color_block '--estimate-accuracy-chart-active-fill-color'} aging
|
|
17
|
+
The #{color_block '--estimate-accuracy-chart-active-fill-color'} aging arrows
|
|
18
18
|
(click on the legend to turn them on) show the current
|
|
19
|
-
age of items, which will give you a hint as to where they might end up.
|
|
20
|
-
|
|
19
|
+
age of items, which will give you a hint as to where they might end up. They point right
|
|
20
|
+
because those items haven't finished yet, so every one of them will keep moving that way
|
|
21
|
+
until it does. If they're already far to the right then you know you have a problem.
|
|
21
22
|
<% end %>
|
|
22
23
|
</div>
|
|
23
24
|
<% if @correlation_coefficient %>
|
|
24
25
|
<div class="p">
|
|
25
26
|
The completed items here have a correlation coefficient of <b><%= @correlation_coefficient.round(3) %></b>.
|
|
26
27
|
The closer it is to +1, the stronger the positive correlation. The closer it is to -1,
|
|
27
|
-
the stronger the negative
|
|
28
|
+
the stronger the negative correlation. Zero would mean no correlation at all.
|
|
29
|
+
</div>
|
|
30
|
+
<% elsif @estimates_are_numeric == false %>
|
|
31
|
+
<div class="p">
|
|
32
|
+
There's no correlation coefficient here because these estimates are categories rather than
|
|
33
|
+
numbers. Working one out means measuring the gaps between values, and there's no defined
|
|
34
|
+
gap between one category and the next, so any number we showed you would be invented.
|
|
28
35
|
</div>
|
|
29
36
|
<% end %>
|
|
30
37
|
HTML
|
|
@@ -46,14 +53,15 @@ class EstimateAccuracyChart < ChartBase
|
|
|
46
53
|
end
|
|
47
54
|
data_sets = scan_issues
|
|
48
55
|
|
|
49
|
-
return
|
|
56
|
+
return render_no_data if data_sets.empty?
|
|
50
57
|
|
|
51
58
|
wrap_and_render(binding, __FILE__)
|
|
52
59
|
end
|
|
53
60
|
|
|
54
61
|
def scan_issues
|
|
55
62
|
completed_hash, aging_hash = split_into_completed_and_aging issues: issues
|
|
56
|
-
@
|
|
63
|
+
@estimates_are_numeric = numeric_estimates? completed_hash
|
|
64
|
+
@correlation_coefficient = correlation_coefficient(completed_hash) if @estimates_are_numeric
|
|
57
65
|
estimation_units = current_board.estimation_configuration.units
|
|
58
66
|
@has_aging_data = !aging_hash.empty?
|
|
59
67
|
|
|
@@ -77,7 +85,7 @@ class EstimateAccuracyChart < ChartBase
|
|
|
77
85
|
{
|
|
78
86
|
'x' => cycle_time,
|
|
79
87
|
'y' => estimate,
|
|
80
|
-
'r' => values.size
|
|
88
|
+
'r' => bubble_radius(values.size),
|
|
81
89
|
'title' => title
|
|
82
90
|
}
|
|
83
91
|
end
|
|
@@ -90,11 +98,23 @@ class EstimateAccuracyChart < ChartBase
|
|
|
90
98
|
'showLine' => false,
|
|
91
99
|
'backgroundColor' => fill_color,
|
|
92
100
|
'borderColor' => border_color,
|
|
93
|
-
'hidden' => starts_hidden
|
|
101
|
+
'hidden' => starts_hidden,
|
|
102
|
+
# The active series is drawn as right pointing arrows rather than discs, because its
|
|
103
|
+
# cycletime is a lower bound that keeps growing. The erb needs to know which one that is.
|
|
104
|
+
'still_in_progress' => completed_or_active == 'active'
|
|
94
105
|
}
|
|
95
106
|
end
|
|
96
107
|
end
|
|
97
108
|
|
|
109
|
+
# Chart.js sizes a bubble by its radius but the eye reads it by area, so the radius has
|
|
110
|
+
# to go as the square root of the count. This multiplier is the radius of a single issue
|
|
111
|
+
# and everything else scales up from there.
|
|
112
|
+
SINGLE_ISSUE_RADIUS = 4
|
|
113
|
+
|
|
114
|
+
def bubble_radius issue_count
|
|
115
|
+
(SINGLE_ISSUE_RADIUS * Math.sqrt(issue_count)).round 1
|
|
116
|
+
end
|
|
117
|
+
|
|
98
118
|
def estimate_label estimate:, estimation_units:
|
|
99
119
|
if @y_axis_type == 'linear'
|
|
100
120
|
if estimation_units == :story_points
|
|
@@ -183,6 +203,15 @@ class EstimateAccuracyChart < ChartBase
|
|
|
183
203
|
|
|
184
204
|
# Correlation coefficient is calculated using the Pearson Correlation Coefficient
|
|
185
205
|
# r = Σ((xi - x̄)(yi - ȳ)) / sqrt(Σ(xi - x̄)² · Σ(yi - ȳ)²)
|
|
206
|
+
# A Pearson coefficient is arithmetic on the distances between values, so it needs a scale where
|
|
207
|
+
# those distances mean something. T-shirt sizes are ordered but not spaced: nothing says the gap
|
|
208
|
+
# from M to L matches the gap from S to M. Summing them used to raise and take the export with it.
|
|
209
|
+
def numeric_estimates? completed_hash
|
|
210
|
+
return false if completed_hash.empty?
|
|
211
|
+
|
|
212
|
+
completed_hash.keys.all? { |estimate, _cycle_time| estimate.is_a? Numeric }
|
|
213
|
+
end
|
|
214
|
+
|
|
186
215
|
def correlation_coefficient completed_hash
|
|
187
216
|
list1 = []
|
|
188
217
|
list2 = []
|
|
@@ -24,12 +24,13 @@ class ExpeditedChart < ChartBase
|
|
|
24
24
|
super()
|
|
25
25
|
|
|
26
26
|
header_text 'Expedited work'
|
|
27
|
+
no_data_text '<%= render_header %><div>There is no expedited work in this time period.</div>'
|
|
27
28
|
description_text <<-HTML
|
|
28
29
|
<div class="p">
|
|
29
30
|
This chart only shows issues that have been expedited at some point. We care about these as
|
|
30
31
|
any form of expedited work will affect the entire system and will slow down non-expedited work.
|
|
31
32
|
Refer to this article on
|
|
32
|
-
<a href="https://
|
|
33
|
+
<a href="https://blog.mikebowler.ca/2021/06/16/classes-of-service/">classes of service</a>
|
|
33
34
|
for a longer explanation on why we want to avoid expedited work.
|
|
34
35
|
</div>
|
|
35
36
|
<div class="p">
|
|
@@ -49,11 +50,9 @@ class ExpeditedChart < ChartBase
|
|
|
49
50
|
make_expedite_lines_data_set(issue: issue, expedite_data: prepare_expedite_data(issue))
|
|
50
51
|
end
|
|
51
52
|
|
|
52
|
-
if data_sets.empty?
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
wrap_and_render(binding, __FILE__)
|
|
56
|
-
end
|
|
53
|
+
return render_no_data if data_sets.empty?
|
|
54
|
+
|
|
55
|
+
wrap_and_render(binding, __FILE__)
|
|
57
56
|
end
|
|
58
57
|
|
|
59
58
|
def prepare_expedite_data issue
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -6,6 +6,30 @@ class Exporter
|
|
|
6
6
|
attr_reader :project_configs
|
|
7
7
|
attr_accessor :file_system
|
|
8
8
|
|
|
9
|
+
# Nothing in examples/ is loaded with the rest of the library, because an example is something you
|
|
10
|
+
# opt into rather than something the product provides. Each file there defines the method named
|
|
11
|
+
# after it. Derived from the directory rather than listed, so a new example is covered by existing.
|
|
12
|
+
EXAMPLE_NAMES = Dir[File.join(__dir__, 'examples', '*.rb')]
|
|
13
|
+
.collect { |file| File.basename(file, '.rb').to_sym }
|
|
14
|
+
.freeze
|
|
15
|
+
|
|
16
|
+
# Deliberately method_missing rather than stub methods that the real ones overwrite. Stubs depend
|
|
17
|
+
# on this file loading before the example, which require_all does not guarantee, and when it lost
|
|
18
|
+
# that race the stub replaced the working method.
|
|
19
|
+
def method_missing name, *args, **keywords, &block
|
|
20
|
+
return super unless EXAMPLE_NAMES.include? name
|
|
21
|
+
|
|
22
|
+
raise "#{name} is an example rather than part of jirametrics, so your config has to ask for " \
|
|
23
|
+
"it. Add this line to the top of your config file:\n\n" \
|
|
24
|
+
" require 'jirametrics/examples/#{name}'\n"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# These are not things we respond to. Calling one raises, so respond_to? should say false and the
|
|
28
|
+
# message above should be the only way anyone learns they exist.
|
|
29
|
+
def respond_to_missing? name, include_private = false
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
9
33
|
def self.logfile_name
|
|
10
34
|
@logfile_name ||= 'jirametrics.log'
|
|
11
35
|
end
|