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
|
@@ -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
|
|
@@ -106,10 +106,16 @@
|
|
|
106
106
|
--wip-by-column-chart-limit-line-color: #D55E00; /* Okabe-Ito vermilion */
|
|
107
107
|
--wip-by-column-chart-recommendation-color: #009E73; /* Okabe-Ito bluish green */
|
|
108
108
|
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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);
|
|
113
119
|
|
|
114
120
|
--expedited-chart-no-longer-expedited: gray;
|
|
115
121
|
--expedited-chart-dot-issue-started-color: #E69F00; /* Okabe-Ito orange */
|
|
@@ -315,8 +321,6 @@ html[data-theme="dark"] {
|
|
|
315
321
|
band. Those are the two ends of the duration ramp, so confusing them is the worst case. This
|
|
316
322
|
stays in the same warm family so the ramp still reads cool to warm as work ages. */
|
|
317
323
|
--wip-chart-duration-more-than-four-weeks-color: #B16BAE;
|
|
318
|
-
--estimate-accuracy-chart-completed-border-color: #2DCB9A;
|
|
319
|
-
--estimate-accuracy-chart-active-border-color: #E69F00;
|
|
320
324
|
--expedited-chart-dot-issue-stopped-color: #2DCB9A;
|
|
321
325
|
--expedited-chart-dot-expedite-started-color: #E69F00;
|
|
322
326
|
--expedited-chart-dot-expedite-stopped-color: #2DCB9A;
|
|
@@ -481,9 +485,6 @@ html[data-theme="light"] {
|
|
|
481
485
|
--wip-chart-duration-four-weeks-or-less-color: #D55E00;
|
|
482
486
|
--wip-chart-duration-more-than-four-weeks-color: #B16BAE; /* see jirametrics-3fg */
|
|
483
487
|
|
|
484
|
-
--estimate-accuracy-chart-completed-border-color: #2DCB9A;
|
|
485
|
-
--estimate-accuracy-chart-active-border-color: #E69F00;
|
|
486
|
-
|
|
487
488
|
--expedited-chart-dot-issue-stopped-color: #2DCB9A;
|
|
488
489
|
--expedited-chart-dot-expedite-started-color: #E69F00;
|
|
489
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')
|
|
@@ -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
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class JiraMetrics
|
|
4
|
+
module Testing
|
|
5
|
+
# Builds a ChangeItem for tests without going through Jira's changelog format.
|
|
6
|
+
#
|
|
7
|
+
# Normalises the arguments a caller is likely to get slightly wrong (a status given as a name when
|
|
8
|
+
# an id is wanted, a time given as a string) and, for status changes with an issue, validates the
|
|
9
|
+
# status against that issue's board so a typo surfaces loudly rather than silently passing.
|
|
10
|
+
class MockChangeItem
|
|
11
|
+
def initialize(
|
|
12
|
+
field:, value:, time:, value_id: nil, old_value: nil, old_value_id: nil,
|
|
13
|
+
artificial: false, issue: nil, field_id: nil
|
|
14
|
+
)
|
|
15
|
+
@field = field
|
|
16
|
+
@value = value
|
|
17
|
+
# Callers write dates as strings far more often than as Times.
|
|
18
|
+
@time = JiraMetrics::Testing.to_time time
|
|
19
|
+
@value_id = value_id
|
|
20
|
+
@old_value = old_value
|
|
21
|
+
@old_value_id = old_value_id
|
|
22
|
+
@artificial = artificial
|
|
23
|
+
@issue = issue
|
|
24
|
+
@field_id = field_id
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def to_change_item
|
|
28
|
+
normalize_status_arguments
|
|
29
|
+
validate_status_change if @field == 'status' && @issue
|
|
30
|
+
|
|
31
|
+
ChangeItem.new time: @time, artificial: @artificial, author_raw: nil, raw: {
|
|
32
|
+
'field' => @field,
|
|
33
|
+
'to' => @value_id,
|
|
34
|
+
'toString' => @value,
|
|
35
|
+
'from' => @old_value_id,
|
|
36
|
+
'fromString' => @old_value,
|
|
37
|
+
'fieldId' => @field_id
|
|
38
|
+
}
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# If either value or old_value is a Status object then pull the name and id off it.
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def normalize_status_arguments
|
|
45
|
+
if @value.is_a? Status
|
|
46
|
+
@value_id = @value.id
|
|
47
|
+
@value = @value.name
|
|
48
|
+
end
|
|
49
|
+
return unless @old_value.is_a? Status
|
|
50
|
+
|
|
51
|
+
@old_value_id = @old_value.id
|
|
52
|
+
@old_value = @old_value.name
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# Status names aren't unique, so a status name always has to be paired with an explicit id.
|
|
56
|
+
def validate_status_change
|
|
57
|
+
require_value_id!
|
|
58
|
+
require_old_value_id!
|
|
59
|
+
verify_value_id!
|
|
60
|
+
verify_old_value_id!
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def require_value_id!
|
|
64
|
+
return unless @value && !@value_id
|
|
65
|
+
|
|
66
|
+
guesses = possible_statuses.find_all_by_name(@value).collect(&:id)
|
|
67
|
+
message = "ID was not specified for new status #{@value.inspect}. "
|
|
68
|
+
if guesses.empty?
|
|
69
|
+
message << "No statuses with name #{@value.inspect} but did find these: #{possible_statuses.inspect}"
|
|
70
|
+
else
|
|
71
|
+
message << "Perhaps you meant one of #{guesses.inspect}"
|
|
72
|
+
end
|
|
73
|
+
raise message
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def require_old_value_id!
|
|
77
|
+
return unless @old_value && !@old_value_id
|
|
78
|
+
|
|
79
|
+
guesses = possible_statuses.find_all_by_name(@old_value).collect(&:id)
|
|
80
|
+
raise "ID was not specified for old status #{@old_value.inspect}. Perhaps you meant one of #{guesses.inspect}"
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def verify_value_id!
|
|
84
|
+
return unless @value_id
|
|
85
|
+
|
|
86
|
+
status = possible_statuses.find_by_id(@value_id)
|
|
87
|
+
raise "No status found for id: #{@value_id} (#{@value.inspect}) in #{possible_statuses.inspect}" unless status
|
|
88
|
+
return if status.name == @value
|
|
89
|
+
|
|
90
|
+
raise "Value passed to mock_change (#{@value.inspect}:#{@value_id.inspect}) " \
|
|
91
|
+
"doesn't match the status found in the board (#{status})"
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def verify_old_value_id!
|
|
95
|
+
return unless @old_value_id
|
|
96
|
+
|
|
97
|
+
status = possible_statuses.find_by_id(@old_value_id)
|
|
98
|
+
unless status
|
|
99
|
+
raise "No status found for id: #{@old_value_id} (#{@old_value.inspect}) in #{possible_statuses.inspect}"
|
|
100
|
+
end
|
|
101
|
+
return if status.name == @old_value
|
|
102
|
+
|
|
103
|
+
raise "Old value passed to mock_change (#{@old_value.inspect}:#{@old_value_id.inspect}) " \
|
|
104
|
+
"doesn't match the status found in the board (#{status})"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def possible_statuses
|
|
108
|
+
@issue.board.possible_statuses
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class JiraMetrics
|
|
4
|
+
module Testing
|
|
5
|
+
# Stubs are matched by the issue's KEY, not by object identity, so every issue in a test needs its
|
|
6
|
+
# own key. Building two issues with the same key and stubbing both raises.
|
|
7
|
+
#
|
|
8
|
+
# board.cycletime = MockCycleTimeConfig.new
|
|
9
|
+
# .stub(issue1, started: '2021-01-02')
|
|
10
|
+
# .stub(issue2, started: '2021-01-02', stopped: '2021-10-04')
|
|
11
|
+
#
|
|
12
|
+
# An issue with no stub at all reads as never started, which is a legitimate state and so cannot be
|
|
13
|
+
# distinguished from one you forgot.
|
|
14
|
+
class MockCycleTimeConfig < CycleTimeConfig
|
|
15
|
+
def initialize
|
|
16
|
+
super(possible_statuses: nil, label: nil, block: nil, settings: self.class.default_settings)
|
|
17
|
+
@stubs = []
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# The same file ProjectConfig reads, resolved from this file's own location so that it works from
|
|
21
|
+
# wherever the caller happens to be rather than only from a checkout of this repo.
|
|
22
|
+
def self.default_settings
|
|
23
|
+
settings_file = File.expand_path '../settings.json', __dir__
|
|
24
|
+
JSON.parse(File.read(settings_file, encoding: 'UTF-8')).tap do |settings|
|
|
25
|
+
# A cached cycle time would outlive the stub that produced it, so a second stub for the same
|
|
26
|
+
# issue would appear to have no effect.
|
|
27
|
+
settings['cache_cycletime_calculations'] = false
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Returns self so that calls cascade.
|
|
32
|
+
def stub issue, started: nil, stopped: nil
|
|
33
|
+
key = issue.is_a?(Issue) ? issue.key : issue
|
|
34
|
+
assert_key_unused key
|
|
35
|
+
@stubs << [key, normalize_time(started), normalize_time(stopped)]
|
|
36
|
+
self
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def started_stopped_changes(issue)
|
|
40
|
+
value = @stubs.find { |issue_key, _start, _stop| issue_key == issue.key }
|
|
41
|
+
return [nil, nil] unless value
|
|
42
|
+
|
|
43
|
+
[to_change(value[1]), to_change(value[2])]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def normalize_time value
|
|
49
|
+
value.is_a?(String) ? JiraMetrics::Testing.to_time(value) : value
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Only the first stub for a key is reachable, so a second one always means the test is asserting
|
|
53
|
+
# against something other than what it looks like.
|
|
54
|
+
def assert_key_unused key
|
|
55
|
+
return unless @stubs.any? { |existing, _start, _stop| existing == key }
|
|
56
|
+
|
|
57
|
+
raise "More than one stub for #{key}. Stubs are matched by issue key, so only the first would " \
|
|
58
|
+
'ever be used. Give each issue its own key.'
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def to_change change
|
|
62
|
+
case change
|
|
63
|
+
when nil
|
|
64
|
+
nil
|
|
65
|
+
when ChangeItem
|
|
66
|
+
change
|
|
67
|
+
else
|
|
68
|
+
MockChangeItem.new(field: 'status', value: 'fake', value_id: 1_000_001, time: change&.to_time).to_change_item
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class JiraMetrics
|
|
4
|
+
module Testing
|
|
5
|
+
# An Issue you can build without a fixture file and then add history to.
|
|
6
|
+
#
|
|
7
|
+
# MockIssue.new keeps Issue's own signature (raw:, board:) so that a MockIssue is substitutable for
|
|
8
|
+
# an Issue everywhere, including MockCycleTimeConfig's is_a?(Issue) check. The friendly constructor
|
|
9
|
+
# is MockIssue.empty, which builds the minimal raw hash for you.
|
|
10
|
+
class MockIssue < Issue
|
|
11
|
+
# Generated keys start well clear of the hand-written SP-1 and SP-2 that fixtures use.
|
|
12
|
+
FIRST_GENERATED_KEY_NUMBER = 1000
|
|
13
|
+
|
|
14
|
+
# A leap day on purpose. Anything that does its own date arithmetic, assumes 365-day years, or
|
|
15
|
+
# round-trips through a format that cannot represent Feb 29 will trip over this default rather
|
|
16
|
+
# than sailing past on a date that hides the bug.
|
|
17
|
+
DEFAULT_CREATED = '2024-02-29'
|
|
18
|
+
|
|
19
|
+
# The change is built and appended in one step, and returned for tests that need to hold on to it.
|
|
20
|
+
def add_change field:, value:, time:, value_id: nil, old_value: nil, old_value_id: nil,
|
|
21
|
+
artificial: false, field_id: nil
|
|
22
|
+
change = MockChangeItem.new(
|
|
23
|
+
issue: self, field: field, time: time, value: value, value_id: value_id,
|
|
24
|
+
old_value: old_value, old_value_id: old_value_id, artificial: artificial, field_id: field_id
|
|
25
|
+
).to_change_item
|
|
26
|
+
changes << change
|
|
27
|
+
change
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
class << self
|
|
31
|
+
# board has no default. Supplying one would mean either packaging a sample board or reading
|
|
32
|
+
# from this repo's spec directory, and neither belongs in a shipped gem. MockBoard.load builds
|
|
33
|
+
# one from the files jirametrics already downloads.
|
|
34
|
+
def empty board:, created: DEFAULT_CREATED, key: nil, creation_status: nil,
|
|
35
|
+
current_sprint_ids: nil
|
|
36
|
+
new(
|
|
37
|
+
raw: raw_for(
|
|
38
|
+
created: created,
|
|
39
|
+
key: key || next_generated_key,
|
|
40
|
+
creation_status: resolve_creation_status(creation_status, board),
|
|
41
|
+
current_sprint_ids: current_sprint_ids,
|
|
42
|
+
board_id: board.id
|
|
43
|
+
),
|
|
44
|
+
board: board
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def next_generated_key
|
|
51
|
+
@next_key_number ||= FIRST_GENERATED_KEY_NUMBER
|
|
52
|
+
"SP-#{@next_key_number}".tap { @next_key_number += 1 }
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A Status carries its category, so the issue cannot end up claiming to be Done while sitting in
|
|
56
|
+
# To Do. Specs that need a status the board does not have can build one with Status.new, which
|
|
57
|
+
# is explicit about being artificial rather than being smuggled in as a name and id.
|
|
58
|
+
def resolve_creation_status creation_status, board
|
|
59
|
+
return creation_status if creation_status.is_a? Status
|
|
60
|
+
|
|
61
|
+
raise "creation_status must be a Status, got #{creation_status.class}" unless creation_status.nil?
|
|
62
|
+
|
|
63
|
+
backlog_statuses = board.possible_statuses.find_all_by_name('Backlog')
|
|
64
|
+
raise 'No Backlog status found' if backlog_statuses.empty?
|
|
65
|
+
|
|
66
|
+
backlog_statuses.first
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Mimics an issue created directly inside a sprint: that membership lives only in the current
|
|
70
|
+
# Sprint custom field and never appears as a changelog transition.
|
|
71
|
+
#
|
|
72
|
+
# Only two keys are ever read back. Issue#current_sprint_ids takes the ids, and
|
|
73
|
+
# Issue#sprint_field_id finds the sprint field by looking for an array of hashes carrying a
|
|
74
|
+
# boardId. Anything else written here would be decoration that could contradict the board.
|
|
75
|
+
def raw_for created:, key:, creation_status:, current_sprint_ids:, board_id:
|
|
76
|
+
sprint_field =
|
|
77
|
+
if current_sprint_ids
|
|
78
|
+
{ 'customfield_10020' => current_sprint_ids.collect { |id| { 'id' => id, 'boardId' => board_id } } }
|
|
79
|
+
else
|
|
80
|
+
{}
|
|
81
|
+
end
|
|
82
|
+
created_time = JiraMetrics::Testing.to_time(created).to_s
|
|
83
|
+
{
|
|
84
|
+
'key' => key,
|
|
85
|
+
'changelog' => { 'histories' => [] },
|
|
86
|
+
'fields' => sprint_field.merge(
|
|
87
|
+
'created' => created_time,
|
|
88
|
+
'updated' => created_time,
|
|
89
|
+
'status' => {
|
|
90
|
+
'name' => creation_status.name,
|
|
91
|
+
'id' => creation_status.id.to_s,
|
|
92
|
+
'statusCategory' => {
|
|
93
|
+
'name' => creation_status.category.name,
|
|
94
|
+
'id' => creation_status.category.id,
|
|
95
|
+
'key' => creation_status.category.key
|
|
96
|
+
}
|
|
97
|
+
},
|
|
98
|
+
'priority' => { 'name' => 'Medium', 'id' => '3' },
|
|
99
|
+
'issuetype' => { 'name' => 'Bug' },
|
|
100
|
+
'creator' => { 'displayName' => 'Tolkien' },
|
|
101
|
+
'summary' => 'Do the thing'
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
end
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|