jirametrics 3.3 → 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 +109 -20
- 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 +55 -9
- data/lib/jirametrics/html/index.js +13 -0
- data/lib/jirametrics/html/legacy_colors.css +18 -0
- data/lib/jirametrics/html/time_based_histogram.erb +9 -5
- 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 +16 -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
|
@@ -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
|
|
@@ -12,10 +12,11 @@ class FlowEfficiencyScatterplot < ChartBase
|
|
|
12
12
|
super()
|
|
13
13
|
|
|
14
14
|
header_text 'Flow Efficiency'
|
|
15
|
+
no_data_text '<%= render_header %><div>No data matched the selected criteria. Nothing to show.</div>'
|
|
15
16
|
description_text <<-HTML
|
|
16
17
|
<div class="p">
|
|
17
18
|
This chart shows the active time against the the total time spent on a ticket.
|
|
18
|
-
<a href="https://
|
|
19
|
+
<a href="https://blog.mikebowler.ca/2024/07/06/flow-efficiency/">Flow efficiency</a> is the ratio
|
|
19
20
|
between these two numbers.
|
|
20
21
|
</div>
|
|
21
22
|
<div class="p">
|
|
@@ -63,10 +64,7 @@ class FlowEfficiencyScatterplot < ChartBase
|
|
|
63
64
|
create_dataset(issues: issues, label: rules.label, color: rules.color)
|
|
64
65
|
end
|
|
65
66
|
|
|
66
|
-
if data_sets.empty?
|
|
67
|
-
return "<h1 class='foldable'>#{@header_text}</h1>" \
|
|
68
|
-
'<div>No data matched the selected criteria. Nothing to show.</div>'
|
|
69
|
-
end
|
|
67
|
+
return render_no_data if data_sets.empty?
|
|
70
68
|
|
|
71
69
|
wrap_and_render(binding, __FILE__)
|
|
72
70
|
end
|
|
@@ -42,21 +42,24 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
|
|
|
42
42
|
z: 1 // draw the grid lines on top of the bars
|
|
43
43
|
},
|
|
44
44
|
stacked: false,
|
|
45
|
-
max: <%= (@max_age * 1.1).to_i
|
|
45
|
+
max: <%= (@max_age * 1.1).to_i %>,
|
|
46
|
+
ticks: {
|
|
47
|
+
// Uses labelExceptCrowdedCeiling() defined in index.js.
|
|
48
|
+
callback: labelExceptCrowdedCeiling
|
|
49
|
+
}
|
|
46
50
|
}
|
|
47
51
|
},
|
|
48
52
|
plugins: {
|
|
49
53
|
tooltip: {
|
|
50
54
|
callbacks: {
|
|
51
55
|
label: function(context) {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
return context.dataset.label + " of completed work items left this column in " +full_data[rowIndex][columnIndex] + " days or less";
|
|
56
|
+
let point = context.dataset.data[context.dataIndex];
|
|
57
|
+
// A band is a floating bar, [bottom, top]. A dot is an object carrying its own title.
|
|
58
|
+
if( Array.isArray(point) ) {
|
|
59
|
+
return context.dataset.label + " of completed work items left this column in " + point[1] + " days or less";
|
|
57
60
|
}
|
|
58
61
|
else {
|
|
59
|
-
return
|
|
62
|
+
return point.title;
|
|
60
63
|
}
|
|
61
64
|
}
|
|
62
65
|
}
|
|
@@ -6,7 +6,40 @@
|
|
|
6
6
|
new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
7
7
|
type: 'bubble',
|
|
8
8
|
data: {
|
|
9
|
-
datasets:
|
|
9
|
+
datasets: (function() {
|
|
10
|
+
const dataSets = <%= JSON.generate(data_sets) %>;
|
|
11
|
+
// The still in progress series is drawn as right pointing arrows rather than discs. Those
|
|
12
|
+
// items have not finished, so their cycletime is a lower bound that keeps growing and the
|
|
13
|
+
// arrow says "this one is still moving that way". Same idea as the capped-value arrows on
|
|
14
|
+
// the scatterplot, turned to point along the axis that is actually still moving.
|
|
15
|
+
//
|
|
16
|
+
// Sized per point rather than at a fixed size, because on this chart the radius carries the
|
|
17
|
+
// number of issues stacked at that spot and we would be throwing that away.
|
|
18
|
+
function movementArrow(color, radius) {
|
|
19
|
+
const size = Math.max(12, Math.round(radius * 3));
|
|
20
|
+
const glyph = document.createElement('canvas');
|
|
21
|
+
glyph.width = size; glyph.height = size;
|
|
22
|
+
const pen = glyph.getContext('2d');
|
|
23
|
+
const middle = size / 2, head = size * 0.3, tip = size * 0.88;
|
|
24
|
+
pen.strokeStyle = color;
|
|
25
|
+
pen.lineWidth = Math.max(2, radius * 0.42);
|
|
26
|
+
pen.lineCap = 'round'; pen.lineJoin = 'round';
|
|
27
|
+
pen.beginPath();
|
|
28
|
+
pen.moveTo(size * 0.12, middle); pen.lineTo(tip, middle);
|
|
29
|
+
pen.moveTo(tip - head, middle - head);
|
|
30
|
+
pen.lineTo(tip, middle);
|
|
31
|
+
pen.lineTo(tip - head, middle + head);
|
|
32
|
+
pen.stroke();
|
|
33
|
+
return glyph;
|
|
34
|
+
}
|
|
35
|
+
dataSets.forEach(function(dataSet) {
|
|
36
|
+
if (!dataSet.still_in_progress) return;
|
|
37
|
+
dataSet.pointStyle = dataSet.data.map(function(point) {
|
|
38
|
+
return movementArrow(dataSet.backgroundColor, point.r);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
return dataSets;
|
|
42
|
+
})()
|
|
10
43
|
},
|
|
11
44
|
options: {
|
|
12
45
|
responsive: <%= canvas_responsive? %>, // If responsive is true then it fills the screen
|
|
@@ -33,6 +33,41 @@
|
|
|
33
33
|
--type-bug-color: #D55E00; /* Okabe-Ito vermilion */
|
|
34
34
|
--type-spike-color: #CC79A7; /* Okabe-Ito reddish purple */
|
|
35
35
|
|
|
36
|
+
/* The dependency chart is drawn by graphviz, which sets the label text inside the node rather
|
|
37
|
+
than beside it, so unlike --type-*-color above these are judged against the text and not
|
|
38
|
+
against the page. They are opaque, so the fill is the background the text sits on and there
|
|
39
|
+
is nothing for a dark theme to override.
|
|
40
|
+
|
|
41
|
+
These are Okabe-Ito hues rather than Okabe-Ito itself. At full strength they sit in a middle
|
|
42
|
+
band of lightness where NEITHER black nor white text is comfortable on them, which is the
|
|
43
|
+
thing to understand before changing any of this. Contrast ratio alone will mislead you here:
|
|
44
|
+
two earlier attempts cleared 7:1 and then 9:1, both of which WCAG calls comfortable, and both
|
|
45
|
+
still read as muddy in a real report. A mid-toned fill with dark text on it looks wrong at
|
|
46
|
+
almost any ratio. Going DARK and putting white text on it is what actually reads, so most of
|
|
47
|
+
these fills are dark, and each carries its own label colour: they are a pair, and changing a
|
|
48
|
+
fill without rechecking its label is how you get unreadable nodes.
|
|
49
|
+
|
|
50
|
+
They are not all dark, because the lightness spread is what does most of the work of keeping
|
|
51
|
+
them apart. Levelling them all to the same darkness measures about 3, which is unusable;
|
|
52
|
+
pastelling them all measures about 4.7, which is what the original colours here did. Run
|
|
53
|
+
`rake check_colors` for what they currently measure, against the full Okabe-Ito set as a
|
|
54
|
+
reference ceiling. */
|
|
55
|
+
--dependency-chart-story-color: #015C41; /* Okabe-Ito bluish green, darkened */
|
|
56
|
+
--dependency-chart-story-label-color: white;
|
|
57
|
+
--dependency-chart-task-color: #56B4E9; /* Okabe-Ito sky blue, unchanged */
|
|
58
|
+
--dependency-chart-task-label-color: black;
|
|
59
|
+
--dependency-chart-bug-color: #783200; /* Okabe-Ito vermilion, darkened. Also Defect */
|
|
60
|
+
--dependency-chart-bug-label-color: white;
|
|
61
|
+
--dependency-chart-epic-color: #F0E442; /* Okabe-Ito yellow, unchanged */
|
|
62
|
+
--dependency-chart-epic-label-color: black;
|
|
63
|
+
--dependency-chart-spike-color: #762A58; /* Okabe-Ito reddish purple, darkened */
|
|
64
|
+
--dependency-chart-spike-label-color: white;
|
|
65
|
+
|
|
66
|
+
/* For a node whose fill was set in the config rather than coming from the palette above, where
|
|
67
|
+
we cannot know what would read on it. */
|
|
68
|
+
--dependency-chart-label-color: black;
|
|
69
|
+
--dependency-chart-link-color: gray;
|
|
70
|
+
|
|
36
71
|
--status-category-todo-color: gray;
|
|
37
72
|
--status-category-inprogress-color: #0072B2; /* Okabe-Ito blue */
|
|
38
73
|
--status-category-done-color: #009E73; /* Okabe-Ito bluish green */
|
|
@@ -71,10 +106,16 @@
|
|
|
71
106
|
--wip-by-column-chart-limit-line-color: #D55E00; /* Okabe-Ito vermilion */
|
|
72
107
|
--wip-by-column-chart-recommendation-color: #009E73; /* Okabe-Ito bluish green */
|
|
73
108
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
109
|
+
/* Full strength, not lightened. The pastel versions of these two collapsed into the same beige
|
|
110
|
+
under protanopia (4.45 apart, where the palette's own floor is 11.08) and measured 1.6 against
|
|
111
|
+
white, half the contrast WCAG asks of a shape you need to see. At full strength they are 18.45
|
|
112
|
+
apart and clear the contrast floor. The border is the page colour rather than a second hue, so
|
|
113
|
+
it separates overlapping bubbles instead of competing with the fill, and needs no dark mode
|
|
114
|
+
override. See jirametrics-7ss. */
|
|
115
|
+
--estimate-accuracy-chart-completed-fill-color: #009E73; /* Okabe-Ito bluish green */
|
|
116
|
+
--estimate-accuracy-chart-completed-border-color: var(--body-background);
|
|
117
|
+
--estimate-accuracy-chart-active-fill-color: #D55E00; /* Okabe-Ito vermilion */
|
|
118
|
+
--estimate-accuracy-chart-active-border-color: var(--body-background);
|
|
78
119
|
|
|
79
120
|
--expedited-chart-no-longer-expedited: gray;
|
|
80
121
|
--expedited-chart-dot-issue-started-color: #E69F00; /* Okabe-Ito orange */
|
|
@@ -248,6 +289,10 @@ div.child_issue {
|
|
|
248
289
|
html[data-theme="dark"] {
|
|
249
290
|
--warning-banner: #9F2B00;
|
|
250
291
|
--non-working-days-color: #2f2f2f;
|
|
292
|
+
|
|
293
|
+
/* See the matching comment in the prefers-color-scheme block below. */
|
|
294
|
+
--dependency-chart-link-color: #999999;
|
|
295
|
+
|
|
251
296
|
--type-story-color: #2DCB9A; /* lighter bluish green for dark bg */
|
|
252
297
|
--type-task-color: #56B4E9; /* sky blue for dark bg */
|
|
253
298
|
--type-bug-color: #E69F00; /* orange instead of vermilion for dark bg */
|
|
@@ -276,8 +321,6 @@ html[data-theme="dark"] {
|
|
|
276
321
|
band. Those are the two ends of the duration ramp, so confusing them is the worst case. This
|
|
277
322
|
stays in the same warm family so the ramp still reads cool to warm as work ages. */
|
|
278
323
|
--wip-chart-duration-more-than-four-weeks-color: #B16BAE;
|
|
279
|
-
--estimate-accuracy-chart-completed-border-color: #2DCB9A;
|
|
280
|
-
--estimate-accuracy-chart-active-border-color: #E69F00;
|
|
281
324
|
--expedited-chart-dot-issue-stopped-color: #2DCB9A;
|
|
282
325
|
--expedited-chart-dot-expedite-started-color: #E69F00;
|
|
283
326
|
--expedited-chart-dot-expedite-stopped-color: #2DCB9A;
|
|
@@ -400,6 +443,12 @@ html[data-theme="light"] {
|
|
|
400
443
|
--warning-banner: #9F2B00;
|
|
401
444
|
|
|
402
445
|
--non-working-days-color: #2f2f2f;
|
|
446
|
+
|
|
447
|
+
/* The only dependency chart colour needing a dark variant. The fills do not, because they
|
|
448
|
+
are opaque and are their own background, but link lines and their labels sit on the page.
|
|
449
|
+
Plain gray manages only 2.4:1 here against 3.94:1 in light mode, so it is lifted to match. */
|
|
450
|
+
--dependency-chart-link-color: #999999;
|
|
451
|
+
|
|
403
452
|
--type-story-color: #2DCB9A; /* lighter bluish green for dark bg */
|
|
404
453
|
--type-task-color: #56B4E9; /* sky blue for dark bg */
|
|
405
454
|
--type-bug-color: #E69F00; /* orange instead of vermilion for dark bg */
|
|
@@ -436,9 +485,6 @@ html[data-theme="light"] {
|
|
|
436
485
|
--wip-chart-duration-four-weeks-or-less-color: #D55E00;
|
|
437
486
|
--wip-chart-duration-more-than-four-weeks-color: #B16BAE; /* see jirametrics-3fg */
|
|
438
487
|
|
|
439
|
-
--estimate-accuracy-chart-completed-border-color: #2DCB9A;
|
|
440
|
-
--estimate-accuracy-chart-active-border-color: #E69F00;
|
|
441
|
-
|
|
442
488
|
--expedited-chart-dot-issue-stopped-color: #2DCB9A;
|
|
443
489
|
--expedited-chart-dot-expedite-started-color: #E69F00;
|
|
444
490
|
--expedited-chart-dot-expedite-stopped-color: #2DCB9A;
|
|
@@ -157,6 +157,19 @@ function withAlpha(color, alpha) {
|
|
|
157
157
|
return resolved.replace(/^rgb\(/, 'rgba(').replace(/\)$/, `, ${alpha})`)
|
|
158
158
|
}
|
|
159
159
|
|
|
160
|
+
// A y-axis tick callback for charts whose ceiling is deliberately set above their tallest value, so
|
|
161
|
+
// you can see that nothing is clipped. Chart.js labels that ceiling too, which gives you an arbitrary
|
|
162
|
+
// number like 169 sitting just above 160, or 62 above 50. Drop that label, unless the ceiling happens
|
|
163
|
+
// to land exactly on the regular grid, in which case it is a perfectly good label and worth keeping.
|
|
164
|
+
// Pass it straight to ticks.callback.
|
|
165
|
+
// ticks 0 20 .. 160 169 -> 169 dropped, it breaks the spacing
|
|
166
|
+
// ticks 0 20 .. 100 120 -> 120 kept, it continues the spacing
|
|
167
|
+
function labelExceptCrowdedCeiling(value, index, ticks) {
|
|
168
|
+
if (index !== ticks.length - 1 || index < 2) return value
|
|
169
|
+
let regularGap = ticks[index - 1].value - ticks[index - 2].value
|
|
170
|
+
return value - ticks[index - 1].value === regularGap ? value : null
|
|
171
|
+
}
|
|
172
|
+
|
|
160
173
|
function createDiagonalPattern(color = 'black') {
|
|
161
174
|
// create a 5x5 px canvas for the pattern's base shape
|
|
162
175
|
let shape = document.createElement('canvas')
|
|
@@ -30,6 +30,22 @@ html[data-theme="light"] {
|
|
|
30
30
|
--type-bug-color: orange;
|
|
31
31
|
--type-spike-color: #9400D3;
|
|
32
32
|
|
|
33
|
+
/* The dependency chart's original pastels. They are pleasant but they are not safe: pale
|
|
34
|
+
colours sit close together, and green, peach and pale yellow are hard to separate for anyone
|
|
35
|
+
with red-green colour vision deficiency. Every one of them is light, so the label colours all
|
|
36
|
+
go back to black; the shipped set has dark fills that need white. */
|
|
37
|
+
--dependency-chart-story-color: #90EE90;
|
|
38
|
+
--dependency-chart-story-label-color: black;
|
|
39
|
+
--dependency-chart-task-color: #87CEFA;
|
|
40
|
+
--dependency-chart-task-label-color: black;
|
|
41
|
+
--dependency-chart-bug-color: #ffdab9;
|
|
42
|
+
--dependency-chart-bug-label-color: black;
|
|
43
|
+
--dependency-chart-epic-color: #fafad2;
|
|
44
|
+
--dependency-chart-epic-label-color: black;
|
|
45
|
+
--dependency-chart-spike-color: #DDA0DD;
|
|
46
|
+
--dependency-chart-spike-label-color: black;
|
|
47
|
+
--dependency-chart-link-color: gray;
|
|
48
|
+
|
|
33
49
|
--status-category-todo-color: gray;
|
|
34
50
|
--status-category-inprogress-color: #2663ff;
|
|
35
51
|
--status-category-done-color: #00ff00;
|
|
@@ -106,6 +122,7 @@ html[data-theme="light"] {
|
|
|
106
122
|
html[data-theme="dark"] {
|
|
107
123
|
--warning-banner: #9F2B00;
|
|
108
124
|
--non-working-days-color: #2f2f2f;
|
|
125
|
+
--dependency-chart-link-color: gray; /* the chart had no dark variant originally */
|
|
109
126
|
--type-story-color: #6fb86f;
|
|
110
127
|
--type-task-color: #0021b3;
|
|
111
128
|
--type-bug-color: #bb5603;
|
|
@@ -139,6 +156,7 @@ html[data-theme="dark"] {
|
|
|
139
156
|
:root {
|
|
140
157
|
--warning-banner: #9F2B00;
|
|
141
158
|
--non-working-days-color: #2f2f2f;
|
|
159
|
+
--dependency-chart-link-color: gray; /* the chart had no dark variant originally */
|
|
142
160
|
--type-story-color: #6fb86f;
|
|
143
161
|
--type-task-color: #0021b3;
|
|
144
162
|
--type-bug-color: #bb5603;
|
|
@@ -100,16 +100,20 @@ if show_stats
|
|
|
100
100
|
<% the_stats.each do |k, v| %>
|
|
101
101
|
<tr>
|
|
102
102
|
<td><%= k %></td>
|
|
103
|
-
<td style="text-align: right;"><%= v[:min] %></td>
|
|
104
|
-
<td style="text-align: right;"><%= v[:max] %></td>
|
|
105
|
-
<td style="text-align: right;"><%=
|
|
106
|
-
<td><%= v[:mode].join
|
|
103
|
+
<td style="text-align: right;"><%= stats_cell v[:min] %></td>
|
|
104
|
+
<td style="text-align: right;"><%= stats_cell v[:max] %></td>
|
|
105
|
+
<td style="text-align: right;"><%= stats_cell(v[:average]) { |average| format '%.2f', average } %></td>
|
|
106
|
+
<td><%= stats_cell(v[:mode]) { |modes| modes.join ', ' } %></td>
|
|
107
107
|
<% percentiles.each do |percentile| %>
|
|
108
|
-
<td style="text-align: right;"><%= v
|
|
108
|
+
<td style="text-align: right;"><%= stats_cell v.dig(:percentiles, percentile) %></td>
|
|
109
109
|
<% end %>
|
|
110
110
|
</tr>
|
|
111
111
|
<% end %>
|
|
112
112
|
</table>
|
|
113
|
+
<% if any_empty_stats? the_stats %>
|
|
114
|
+
<p><b>–</b> means no usable data for that group. Every item in it was excluded from the histogram
|
|
115
|
+
because it had no measurable cycle time. The Data Quality report has the details.</p>
|
|
116
|
+
<% end %>
|
|
113
117
|
</div>
|
|
114
118
|
<div>
|
|
115
119
|
<p>These statistics help understand the <i>"shape"</i> of the histogram distribution, to help us with predictions.</p>
|
|
@@ -82,9 +82,10 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
82
82
|
color: <%= CssVariable['--grid-line-color'].to_json %>
|
|
83
83
|
},
|
|
84
84
|
ticks: {
|
|
85
|
+
// Uses labelExceptCrowdedCeiling() defined in index.js.
|
|
85
86
|
callback: function(value, index, ticks) {
|
|
86
87
|
<% if @cap %>if (value > <%= @cap[:cutoff] %>) return null;<% end %>
|
|
87
|
-
return index
|
|
88
|
+
return labelExceptCrowdedCeiling(value, index, ticks);
|
|
88
89
|
}
|
|
89
90
|
}
|
|
90
91
|
}
|
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -63,7 +63,7 @@ class Issue
|
|
|
63
63
|
|
|
64
64
|
def labels = raw_fields['labels'] || []
|
|
65
65
|
|
|
66
|
-
def author = raw_fields['creator']&.
|
|
66
|
+
def author = User.from_raw(raw_fields['creator'])&.display_name || ''
|
|
67
67
|
|
|
68
68
|
def resolution = raw_fields['resolution']&.[]('name')
|
|
69
69
|
|
|
@@ -374,13 +374,11 @@ class Issue
|
|
|
374
374
|
@changes.reverse.find(&:resolution?)
|
|
375
375
|
end
|
|
376
376
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
377
|
+
# nil rather than '' when there's no assignee, and daily_view relies on it to decide whether to
|
|
378
|
+
# render an assignee line at all.
|
|
379
|
+
def assigned_to = User.from_raw(raw_fields['assignee'])&.display_name
|
|
380
380
|
|
|
381
|
-
def assigned_to_icon_url
|
|
382
|
-
raw_fields['assignee']&.[]('avatarUrls')&.[]('16x16')
|
|
383
|
-
end
|
|
381
|
+
def assigned_to_icon_url = User.from_raw(raw_fields['assignee'])&.avatar_url
|
|
384
382
|
|
|
385
383
|
# Many test failures are simply unreadable because the default inspect on this class goes
|
|
386
384
|
# on for pages. Shorten it up.
|
|
@@ -19,10 +19,11 @@ class IssuePrinter
|
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
def assignee_line
|
|
22
|
-
|
|
23
|
-
return '' if
|
|
22
|
+
user = User.from_raw @issue.raw['fields']['assignee']
|
|
23
|
+
return '' if user.nil?
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
email = user.email_address
|
|
26
|
+
" [assignee] #{user.display_name.inspect}#{" <#{email}>" if email}\n"
|
|
26
27
|
end
|
|
27
28
|
|
|
28
29
|
def links_section
|
|
@@ -78,6 +79,7 @@ class IssuePrinter
|
|
|
78
79
|
def render_history history
|
|
79
80
|
type_width = history.collect { |_time, type, _detail, _artificial| type&.length || 0 }.max
|
|
80
81
|
sort_history!(history)
|
|
82
|
+
# Not all ruby versions return the same string for to_s so we force to a known format.
|
|
81
83
|
history.map do |time, type, detail, _artificial|
|
|
82
84
|
type = type.nil? ? '-' * type_width : type.rjust(type_width)
|
|
83
85
|
" #{time.strftime '%Y-%m-%d %H:%M:%S %z'} [#{type}] #{detail}\n"
|
|
@@ -109,15 +109,13 @@ class JiraGateway
|
|
|
109
109
|
|
|
110
110
|
VerifyResult = Data.define(:ok, :url, :message)
|
|
111
111
|
|
|
112
|
-
# Confirm that our credentials actually authenticate against Jira. We deliberately hit /myself,
|
|
113
|
-
# which requires authentication on every Jira edition (anonymous callers get a 401). An endpoint
|
|
114
|
-
# that also answers anonymously would happily "succeed" with bad credentials, which defeats the
|
|
115
|
-
# whole point. Cloud speaks v3, Server/Data Center speaks v2. Returns a VerifyResult rather than
|
|
116
|
-
# raising so the caller can report on every configured connection and set its own exit status.
|
|
117
112
|
def verify_connection
|
|
113
|
+
# We use /myself because the only reason it can fail is if we aren't authenticated.
|
|
118
114
|
json = call_url relative_url: "/rest/api/#{cloud? ? 3 : 2}/myself"
|
|
119
|
-
|
|
120
|
-
|
|
115
|
+
|
|
116
|
+
user = User.new raw: json
|
|
117
|
+
who = user.display_name || 'unknown user'
|
|
118
|
+
VerifyResult.new(ok: true, url: @jira_url, message: "Verified #{@jira_url} (authenticated as #{who})")
|
|
121
119
|
rescue StandardError => e
|
|
122
120
|
VerifyResult.new(ok: false, url: @jira_url, message: "Could not authenticate to #{@jira_url}: #{e.message}")
|
|
123
121
|
end
|
|
@@ -37,13 +37,13 @@ class SprintBurndown < ChartBase
|
|
|
37
37
|
end
|
|
38
38
|
|
|
39
39
|
def run
|
|
40
|
-
return
|
|
40
|
+
return render_no_data unless current_board.scrum?
|
|
41
41
|
|
|
42
42
|
sprints = sprints_in_time_range current_board
|
|
43
43
|
change_data_by_sprint = gather_change_data_by_sprint sprints
|
|
44
44
|
|
|
45
45
|
result = +''
|
|
46
|
-
result << render_top_text
|
|
46
|
+
result << render_top_text
|
|
47
47
|
|
|
48
48
|
# HashEachMethods misreads this array of [method, title] pairs as a hash and thinks y_axis_title
|
|
49
49
|
# is unused; in fact the ERB template reads it (and data_method) from the binding we pass to render.
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class JiraMetrics
|
|
4
|
+
module Testing
|
|
5
|
+
# Builds a Board from the two files jirametrics already downloads for you, so a test can use your
|
|
6
|
+
# own board rather than a fabricated one.
|
|
7
|
+
#
|
|
8
|
+
# board = MockBoard.load statuses: 'target/mine_statuses.json',
|
|
9
|
+
# configuration: 'target/mine_board_1_configuration.json'
|
|
10
|
+
#
|
|
11
|
+
# Takes filenames rather than packaging a sample board, because a board that resembles yours is
|
|
12
|
+
# worth more than one of ours, and because sample data would have to be carried in the gem forever.
|
|
13
|
+
class MockBoard
|
|
14
|
+
class << self
|
|
15
|
+
def load statuses:, configuration:
|
|
16
|
+
board = Board.new(
|
|
17
|
+
raw: JSON.parse(File.read(configuration, encoding: 'UTF-8')),
|
|
18
|
+
possible_statuses: load_statuses(statuses)
|
|
19
|
+
)
|
|
20
|
+
board.project_config = ProjectConfig.new(
|
|
21
|
+
exporter: Exporter.new, target_path: File.dirname(configuration), jira_config: nil, block: nil
|
|
22
|
+
)
|
|
23
|
+
board
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def load_statuses filename
|
|
27
|
+
JSON.parse(File.read(filename, encoding: 'UTF-8')).each_with_object(StatusCollection.new) do |raw, collection|
|
|
28
|
+
collection << Status.from_raw(raw)
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|