jirametrics 2.9 → 2.11
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/aggregate_config.rb +1 -1
- data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
- data/lib/jirametrics/aging_work_table.rb +50 -2
- data/lib/jirametrics/board.rb +30 -1
- data/lib/jirametrics/board_movement_calculator.rb +147 -0
- data/lib/jirametrics/change_item.rb +8 -1
- data/lib/jirametrics/chart_base.rb +19 -1
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cycletime_histogram.rb +65 -2
- data/lib/jirametrics/data_quality_report.rb +1 -1
- data/lib/jirametrics/downloader.rb +0 -14
- data/lib/jirametrics/exporter.rb +10 -8
- data/lib/jirametrics/file_config.rb +9 -4
- data/lib/jirametrics/file_system.rb +4 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
- data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
- data/lib/jirametrics/html/aging_work_table.erb +5 -3
- data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
- data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
- data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
- data/lib/jirametrics/html/expedited_chart.erb +1 -10
- data/lib/jirametrics/html/index.css +11 -2
- data/lib/jirametrics/html/index.erb +8 -1
- data/lib/jirametrics/html/sprint_burndown.erb +1 -10
- data/lib/jirametrics/html/throughput_chart.erb +1 -10
- data/lib/jirametrics/issue.rb +29 -8
- data/lib/jirametrics/jira_gateway.rb +16 -3
- data/lib/jirametrics/project_config.rb +1 -1
- data/lib/jirametrics/status.rb +3 -6
- metadata +3 -2
|
@@ -272,7 +272,7 @@ class DataQualityReport < ChartBase
|
|
|
272
272
|
|
|
273
273
|
entry.report(
|
|
274
274
|
problem_key: :items_blocked_on_closed_tickets,
|
|
275
|
-
detail: "#{entry.issue.key} thinks it's blocked
|
|
275
|
+
detail: "#{entry.issue.key} thinks it's blocked by #{link.other_issue.key}, " \
|
|
276
276
|
"except #{link.other_issue.key} is closed."
|
|
277
277
|
)
|
|
278
278
|
end
|
|
@@ -103,8 +103,6 @@ class Downloader
|
|
|
103
103
|
json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
|
|
104
104
|
"?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
|
|
105
105
|
|
|
106
|
-
exit_if_call_failed json
|
|
107
|
-
|
|
108
106
|
json['issues'].each do |issue_json|
|
|
109
107
|
issue_json['exporter'] = {
|
|
110
108
|
'in_initial_query' => initial_query
|
|
@@ -139,15 +137,6 @@ class Downloader
|
|
|
139
137
|
end
|
|
140
138
|
end
|
|
141
139
|
|
|
142
|
-
def exit_if_call_failed json
|
|
143
|
-
# Sometimes Jira returns the singular form of errorMessage and sometimes the plural. Consistency FTW.
|
|
144
|
-
return unless json['error'] || json['errorMessages'] || json['errorMessage']
|
|
145
|
-
|
|
146
|
-
log "Download failed. See #{@file_system.logfile_name} for details.", both: true
|
|
147
|
-
log " #{JSON.pretty_generate(json)}"
|
|
148
|
-
exit 1
|
|
149
|
-
end
|
|
150
|
-
|
|
151
140
|
def download_statuses
|
|
152
141
|
log ' Downloading all statuses', both: true
|
|
153
142
|
json = @jira_gateway.call_url relative_url: '/rest/api/2/status'
|
|
@@ -188,8 +177,6 @@ class Downloader
|
|
|
188
177
|
log " Downloading board configuration for board #{board_id}", both: true
|
|
189
178
|
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
|
|
190
179
|
|
|
191
|
-
exit_if_call_failed json
|
|
192
|
-
|
|
193
180
|
@file_system.save_json(
|
|
194
181
|
json: json,
|
|
195
182
|
filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
|
|
@@ -213,7 +200,6 @@ class Downloader
|
|
|
213
200
|
while is_last == false
|
|
214
201
|
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/sprint?" \
|
|
215
202
|
"maxResults=#{max_results}&startAt=#{start_at}"
|
|
216
|
-
exit_if_call_failed json
|
|
217
203
|
|
|
218
204
|
@file_system.save_json(
|
|
219
205
|
json: json,
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -64,12 +64,8 @@ class Exporter
|
|
|
64
64
|
selected = []
|
|
65
65
|
each_project_config(name_filter: name_filter) do |project|
|
|
66
66
|
project.evaluate_next_level
|
|
67
|
-
# next if project.aggregated_project?
|
|
68
67
|
|
|
69
68
|
project.run load_only: true
|
|
70
|
-
project.board_configs.each do |board_config|
|
|
71
|
-
board_config.run
|
|
72
|
-
end
|
|
73
69
|
project.issues.each do |issue|
|
|
74
70
|
selected << [project, issue] if keys.include? issue.key
|
|
75
71
|
end
|
|
@@ -79,9 +75,13 @@ class Exporter
|
|
|
79
75
|
raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
|
|
80
76
|
end
|
|
81
77
|
|
|
82
|
-
selected.
|
|
83
|
-
|
|
84
|
-
|
|
78
|
+
if selected.empty?
|
|
79
|
+
file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
|
|
80
|
+
else
|
|
81
|
+
selected.each do |project, issue|
|
|
82
|
+
file_system.log "\nProject #{project.name}", also_write_to_stderr: true
|
|
83
|
+
file_system.log issue.dump, also_write_to_stderr: true
|
|
84
|
+
end
|
|
85
85
|
end
|
|
86
86
|
end
|
|
87
87
|
|
|
@@ -116,7 +116,9 @@ class Exporter
|
|
|
116
116
|
|
|
117
117
|
def jira_config filename = nil
|
|
118
118
|
if filename
|
|
119
|
-
@jira_config = file_system.load_json(filename)
|
|
119
|
+
@jira_config = file_system.load_json(filename, fail_on_error: false)
|
|
120
|
+
raise "Unable to load Jira configuration file and cannot continue: #{filename.inspect}" if @jira_config.nil?
|
|
121
|
+
|
|
120
122
|
@jira_config['url'] = $1 if @jira_config['url'] =~ /^(.+)\/+$/
|
|
121
123
|
end
|
|
122
124
|
@jira_config
|
|
@@ -66,15 +66,20 @@ class FileConfig
|
|
|
66
66
|
# is that all empty values in the first column should be at the bottom.
|
|
67
67
|
def sort_output all_lines
|
|
68
68
|
all_lines.sort do |a, b|
|
|
69
|
+
result = nil
|
|
69
70
|
if a[0] == b[0]
|
|
70
|
-
a[1..] <=> b[1..]
|
|
71
|
+
result = a[1..] <=> b[1..]
|
|
71
72
|
elsif a[0].nil?
|
|
72
|
-
1
|
|
73
|
+
result = 1
|
|
73
74
|
elsif b[0].nil?
|
|
74
|
-
-1
|
|
75
|
+
result = -1
|
|
75
76
|
else
|
|
76
|
-
a[0] <=> b[0]
|
|
77
|
+
result = a[0] <=> b[0]
|
|
77
78
|
end
|
|
79
|
+
|
|
80
|
+
# This will only happen if one of the objects isn't comparable. Seen in production.
|
|
81
|
+
result = -1 if result.nil?
|
|
82
|
+
result
|
|
78
83
|
end
|
|
79
84
|
end
|
|
80
85
|
|
|
@@ -35,6 +35,10 @@ class FileSystem
|
|
|
35
35
|
log "Warning: #{message}", more: more, also_write_to_stderr: true
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
+
def error message, more: nil
|
|
39
|
+
log "Error: #{message}", more: more, also_write_to_stderr: true
|
|
40
|
+
end
|
|
41
|
+
|
|
38
42
|
def log message, more: nil, also_write_to_stderr: false
|
|
39
43
|
message += " See #{logfile_name} for more details about this message." if more
|
|
40
44
|
|
|
@@ -27,7 +27,7 @@ class FlowEfficiencyScatterplot < ChartBase
|
|
|
27
27
|
</mfrac>
|
|
28
28
|
</math>
|
|
29
29
|
</div>
|
|
30
|
-
<div style="background:
|
|
30
|
+
<div style="background: var(--warning-banner)">Note that for this calculation to be accurate, we must be moving items into a
|
|
31
31
|
blocked or stalled state the moment we stop working on it, and most teams don't do that.
|
|
32
32
|
So be aware that your team may have to change their behaviours if you want this chart to be useful.
|
|
33
33
|
</div>
|
|
@@ -38,22 +38,13 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
38
38
|
plugins: {
|
|
39
39
|
annotation: {
|
|
40
40
|
annotations: {
|
|
41
|
-
|
|
42
|
-
holiday<%= index %>: {
|
|
43
|
-
drawTime: 'beforeDraw',
|
|
44
|
-
type: 'box',
|
|
45
|
-
xMin: '<%= range.begin %>T00:00:00',
|
|
46
|
-
xMax: '<%= range.end %>T23:59:59',
|
|
47
|
-
backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
|
|
48
|
-
borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
|
|
49
|
-
},
|
|
50
|
-
<% end %>
|
|
41
|
+
<%= working_days_annotation %>
|
|
51
42
|
|
|
52
43
|
<% if percentage_line_x %>
|
|
53
44
|
line: {
|
|
54
45
|
type: 'line',
|
|
55
|
-
|
|
56
|
-
|
|
46
|
+
scaleID: 'x',
|
|
47
|
+
value: '<%= percentage_line_x %>',
|
|
57
48
|
borderColor: <%= CssVariable.new('--aging-work-bar-chart-percentage-line-color').to_json %>,
|
|
58
49
|
borderWidth: 1,
|
|
59
50
|
drawTime: 'afterDraw'
|
|
@@ -6,7 +6,7 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
|
|
|
6
6
|
{
|
|
7
7
|
type: 'bar',
|
|
8
8
|
data: {
|
|
9
|
-
labels: [<%=
|
|
9
|
+
labels: [<%= @board_columns.collect { |c| c.name.inspect }.join(',') %>],
|
|
10
10
|
datasets: <%= JSON.generate(data_sets) %>
|
|
11
11
|
},
|
|
12
12
|
options: {
|
|
@@ -22,8 +22,10 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
|
|
|
22
22
|
labelString: 'Date Completed'
|
|
23
23
|
},
|
|
24
24
|
grid: {
|
|
25
|
-
color: <%= CssVariable['--grid-line-color'].to_json
|
|
25
|
+
color: <%= CssVariable['--grid-line-color'].to_json %>,
|
|
26
|
+
z: 1 // draw the grid lines on top of the bars
|
|
26
27
|
},
|
|
28
|
+
stacked: true
|
|
27
29
|
},
|
|
28
30
|
y: {
|
|
29
31
|
scaleLabel: {
|
|
@@ -35,8 +37,11 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
|
|
|
35
37
|
text: 'Age in days'
|
|
36
38
|
},
|
|
37
39
|
grid: {
|
|
38
|
-
color: <%= CssVariable['--grid-line-color'].to_json
|
|
40
|
+
color: <%= CssVariable['--grid-line-color'].to_json %>,
|
|
41
|
+
z: 1 // draw the grid lines on top of the bars
|
|
39
42
|
},
|
|
43
|
+
stacked: true,
|
|
44
|
+
max: <%= (@max_age * 1.1).to_i %>
|
|
40
45
|
}
|
|
41
46
|
},
|
|
42
47
|
plugins: {
|
|
@@ -44,14 +49,26 @@ new Chart(document.getElementById(<%= chart_id.inspect %>).getContext('2d'),
|
|
|
44
49
|
callbacks: {
|
|
45
50
|
label: function(context) {
|
|
46
51
|
if( typeof(context.dataset.data[context.dataIndex]) == "number" ) {
|
|
47
|
-
|
|
52
|
+
let full_data = <%= @bar_data.inspect %>;
|
|
53
|
+
let columnIndex = context.dataIndex;
|
|
54
|
+
let rowIndex = context.datasetIndex - <%= @row_index_offset %>;
|
|
55
|
+
return context.dataset.label + " of completed work items left this column in " +full_data[rowIndex][columnIndex] + " days or less";
|
|
48
56
|
}
|
|
49
57
|
else {
|
|
50
|
-
return context.dataset.data[context.dataIndex].title
|
|
58
|
+
return context.dataset.data[context.dataIndex].title;
|
|
51
59
|
}
|
|
52
60
|
}
|
|
53
61
|
}
|
|
62
|
+
},
|
|
63
|
+
legend: {
|
|
64
|
+
labels: {
|
|
65
|
+
filter: function(item, chart) {
|
|
66
|
+
// Logic to remove a particular legend item goes here
|
|
67
|
+
return !item.text.includes('%');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
54
70
|
}
|
|
71
|
+
|
|
55
72
|
}
|
|
56
73
|
}
|
|
57
74
|
});
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
<table class='standard'>
|
|
2
2
|
<thead>
|
|
3
3
|
<tr>
|
|
4
|
-
<th
|
|
5
|
-
<th>E</th>
|
|
6
|
-
<th>B</th>
|
|
4
|
+
<th title="Age in days">Age</th>
|
|
5
|
+
<th title="Expedited">E</th>
|
|
6
|
+
<th title="Blocked / Stalled">B/S</th>
|
|
7
7
|
<th>Issue</th>
|
|
8
8
|
<th>Status</th>
|
|
9
|
+
<th>Forecast</th>
|
|
9
10
|
<th>Fix versions</th>
|
|
10
11
|
<% if any_scrum_boards %>
|
|
11
12
|
<th>Sprints</th>
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
<% end %>
|
|
42
43
|
</td>
|
|
43
44
|
<td><%= format_status issue.status, board: issue.board %></td>
|
|
45
|
+
<td><%= dates_text(issue) %></td>
|
|
44
46
|
<td><%= fix_versions_text(issue) %></td>
|
|
45
47
|
<% if any_scrum_boards %>
|
|
46
48
|
<td><%= sprints_text(issue) %></td>
|
|
@@ -1,6 +1,57 @@
|
|
|
1
1
|
<div class="chart">
|
|
2
2
|
<canvas id="<%= chart_id %>" width="<%= canvas_width %>" height="<%= canvas_height %>"></canvas>
|
|
3
3
|
</div>
|
|
4
|
+
<%
|
|
5
|
+
if show_stats
|
|
6
|
+
link_id = next_id
|
|
7
|
+
issues_id = next_id
|
|
8
|
+
%>
|
|
9
|
+
[<a id='<%= link_id %>' href="#" onclick='expand_collapse("<%= link_id %>", "<%= issues_id %>"); return false;'>Show details</a>]
|
|
10
|
+
<div id="<%= issues_id %>" style="display: none;">
|
|
11
|
+
<div>
|
|
12
|
+
<table class="standard">
|
|
13
|
+
<tr>
|
|
14
|
+
<th>Issue Type</th>
|
|
15
|
+
<th>Min</th>
|
|
16
|
+
<th>Max</th>
|
|
17
|
+
<th>Avg</th>
|
|
18
|
+
<th>Mode</th>
|
|
19
|
+
<% percentiles.each do |p| %>
|
|
20
|
+
<th><%= p %>th</th>
|
|
21
|
+
<% end %>
|
|
22
|
+
</tr>
|
|
23
|
+
<% the_stats.each do |k, v| %>
|
|
24
|
+
<tr>
|
|
25
|
+
<td><%= k %></td>
|
|
26
|
+
<td style="text-align: right;"><%= v[:min] %></td>
|
|
27
|
+
<td style="text-align: right;"><%= v[:max] %></td>
|
|
28
|
+
<td style="text-align: right;"><%= sprintf('%.2f', v[:average]) %></td>
|
|
29
|
+
<td><%= v[:mode].join(', ') %></td>
|
|
30
|
+
<% percentiles.each do |p| %>
|
|
31
|
+
<td style="text-align: right;"><%= v[:percentiles][p] %></td>
|
|
32
|
+
<% end %>
|
|
33
|
+
</tr>
|
|
34
|
+
<% end %>
|
|
35
|
+
</table>
|
|
36
|
+
</div>
|
|
37
|
+
<div>
|
|
38
|
+
<p>These statistics help understand the <i>"shape"</i> of the cycletime histogram distribution, to help us with predictions.</p>
|
|
39
|
+
<ul>
|
|
40
|
+
<li><b>Min & Max:</b> the observed spread for the data set. Useful to judge how wide the variation is. </li>
|
|
41
|
+
<li><b>Average:</b> the arithmetic mean of the data set. Useful as a <i>"typical representative"</i> of the complete set.</li>
|
|
42
|
+
<li><b>Mode:</b> the most repeated value(s) in the data set. This is the value we're most likely to remember. </li>
|
|
43
|
+
<li><b>Percentiles:</b> they partition the data set. If X is the Nth percentile, it means that N% of cycletime values are X or less. Typical percentiles of interest are:</li>
|
|
44
|
+
<ul>
|
|
45
|
+
<li><b>50%</b>: also known as the <b>Median</b>. Useful to establish short feedback loops, to monitor that it's not drifting to the right.</li>
|
|
46
|
+
<li><b>85%</b>: useful to establish service level expectations, accounting for rare events..</li>
|
|
47
|
+
<li><b>98% (or higher)</b>: useful to gauge worst case expectations..</li>
|
|
48
|
+
</ul>
|
|
49
|
+
</ul>
|
|
50
|
+
</div>
|
|
51
|
+
</div>
|
|
52
|
+
<%
|
|
53
|
+
end
|
|
54
|
+
%>
|
|
4
55
|
<script>
|
|
5
56
|
new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
6
57
|
{
|
|
@@ -21,6 +72,8 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
21
72
|
grid: {
|
|
22
73
|
color: <%= CssVariable['--grid-line-color'].to_json %>
|
|
23
74
|
},
|
|
75
|
+
min: 0,
|
|
76
|
+
offset: false, // Gets rid of the ugly padding on left.
|
|
24
77
|
},
|
|
25
78
|
y: {
|
|
26
79
|
stacked: true,
|
|
@@ -34,6 +87,27 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
34
87
|
}
|
|
35
88
|
},
|
|
36
89
|
plugins: {
|
|
90
|
+
annotation: {
|
|
91
|
+
annotations: {
|
|
92
|
+
<%
|
|
93
|
+
results = the_stats[:all][:percentiles]
|
|
94
|
+
results.each do |percentile, value|
|
|
95
|
+
%>
|
|
96
|
+
percentile<%= percentile.to_s %>: {
|
|
97
|
+
type: 'line',
|
|
98
|
+
scaleID: 'x',
|
|
99
|
+
value: <%= value %>,
|
|
100
|
+
borderWidth: 1,
|
|
101
|
+
drawTime: 'beforeDatasetsDraw',
|
|
102
|
+
label: {
|
|
103
|
+
enabled: true,
|
|
104
|
+
content: '<%= "#{percentile}%" %>',
|
|
105
|
+
position: 'start',
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
<% end %>
|
|
109
|
+
},
|
|
110
|
+
},
|
|
37
111
|
tooltip: {
|
|
38
112
|
callbacks: {
|
|
39
113
|
label: function(context) {
|
|
@@ -53,16 +53,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
53
53
|
autocolors: false,
|
|
54
54
|
annotation: {
|
|
55
55
|
annotations: {
|
|
56
|
-
|
|
57
|
-
holiday<%= index %>: {
|
|
58
|
-
drawTime: 'beforeDraw',
|
|
59
|
-
type: 'box',
|
|
60
|
-
xMin: '<%= range.begin %>T00:00:00',
|
|
61
|
-
xMax: '<%= range.end %>T23:59:59',
|
|
62
|
-
backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
|
|
63
|
-
borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
|
|
64
|
-
},
|
|
65
|
-
<% end %>
|
|
56
|
+
<%= working_days_annotation %>
|
|
66
57
|
|
|
67
58
|
<% @percentage_lines.each_with_index do |args, index| %>
|
|
68
59
|
<% percent, color = args %>
|
|
@@ -50,16 +50,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'),
|
|
|
50
50
|
},
|
|
51
51
|
annotation: {
|
|
52
52
|
annotations: {
|
|
53
|
-
|
|
54
|
-
holiday<%= index %>: {
|
|
55
|
-
drawTime: 'beforeDraw',
|
|
56
|
-
type: 'box',
|
|
57
|
-
xMin: '<%= range.begin %>T00:00:00',
|
|
58
|
-
xMax: '<%= range.end %>T23:59:59',
|
|
59
|
-
backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
|
|
60
|
-
borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
|
|
61
|
-
},
|
|
62
|
-
<% end %>
|
|
53
|
+
<%= working_days_annotation %>
|
|
63
54
|
}
|
|
64
55
|
},
|
|
65
56
|
legend: {
|
|
@@ -55,16 +55,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
55
55
|
autocolors: false,
|
|
56
56
|
annotation: {
|
|
57
57
|
annotations: {
|
|
58
|
-
|
|
59
|
-
holiday<%= index %>: {
|
|
60
|
-
drawTime: 'beforeDraw',
|
|
61
|
-
type: 'box',
|
|
62
|
-
xMin: '<%= range.begin %>T00:00:00',
|
|
63
|
-
xMax: '<%= range.end %>T23:59:59',
|
|
64
|
-
backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
|
|
65
|
-
borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
|
|
66
|
-
},
|
|
67
|
-
<% end %>
|
|
58
|
+
<%= working_days_annotation %>
|
|
68
59
|
}
|
|
69
60
|
}
|
|
70
61
|
}
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
--body-background: white;
|
|
3
3
|
--default-text-color: black;
|
|
4
4
|
--grid-line-color: lightgray;
|
|
5
|
+
--warning-banner: yellow;
|
|
5
6
|
|
|
6
7
|
--cycletime-scatterplot-overall-trendline-color: gray;
|
|
7
8
|
|
|
@@ -27,8 +28,16 @@
|
|
|
27
28
|
--throughput_chart_total_line_color: gray;
|
|
28
29
|
|
|
29
30
|
--aging-work-in-progress-chart-shading-color: lightgray;
|
|
31
|
+
--aging-work-in-progress-chart-shading-50-color: #2E8BC0; // green;
|
|
32
|
+
--aging-work-in-progress-chart-shading-85-color: #ADD8E6; // yellow;
|
|
33
|
+
--aging-work-in-progress-chart-shading-98-color: #FF8A8A; // orange;
|
|
34
|
+
--aging-work-in-progress-chart-shading-100-color: #FF2E2E; // red;
|
|
35
|
+
|
|
30
36
|
--aging-work-in-progress-by-age-trend-line-color: gray;
|
|
31
37
|
|
|
38
|
+
--aging-work-table-date-in-jeopardy: yellow;
|
|
39
|
+
--aging-work-table-date-overdue: red;
|
|
40
|
+
|
|
32
41
|
--hierarchy-table-inactive-item-text-color: gray;
|
|
33
42
|
|
|
34
43
|
--wip-chart-completed-color: #00ff00;
|
|
@@ -135,6 +144,8 @@ ul.quality_report {
|
|
|
135
144
|
|
|
136
145
|
@media screen and (prefers-color-scheme: dark) {
|
|
137
146
|
:root {
|
|
147
|
+
--warning-banner: #9F2B00;
|
|
148
|
+
|
|
138
149
|
--non-working-days-color: #2f2f2f;
|
|
139
150
|
--type-story-color: #6fb86f;
|
|
140
151
|
--type-task-color: #0021b3;
|
|
@@ -150,8 +161,6 @@ ul.quality_report {
|
|
|
150
161
|
--dead-color: black;
|
|
151
162
|
--wip-chart-active-color: #2551c1;
|
|
152
163
|
|
|
153
|
-
--aging-work-in-progress-chart-shading-color: #b4b4b4;
|
|
154
|
-
|
|
155
164
|
--status-category-inprogress-color: #1c49bb;
|
|
156
165
|
|
|
157
166
|
--cycletime-scatterplot-overall-trendline-color: gray;
|
|
@@ -23,13 +23,20 @@
|
|
|
23
23
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
|
|
24
24
|
location.reload()
|
|
25
25
|
})
|
|
26
|
-
|
|
27
26
|
</script>
|
|
28
27
|
<style>
|
|
29
28
|
<%= css %>
|
|
30
29
|
</style>
|
|
30
|
+
<script type="text/javascript">
|
|
31
|
+
Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--default-text-color');
|
|
32
|
+
</script>
|
|
31
33
|
</head>
|
|
32
34
|
<body>
|
|
35
|
+
<noscript>
|
|
36
|
+
<div style="padding: 1em; background: gray; color: white; font-size: 2em;">
|
|
37
|
+
Javascript is currently disabled and that means that almost all of the charts in this report won't render. If you'd loaded this from a folder on SharePoint then save it locally and load it again.
|
|
38
|
+
</div>
|
|
39
|
+
</noscript>
|
|
33
40
|
<%= "\n" + @sections.collect { |text, type| text if type == :header }.compact.join("\n\n") %>
|
|
34
41
|
<%= "\n" + @sections.collect { |text, type| text if type == :body }.compact.join("\n\n") %>
|
|
35
42
|
</body>
|
|
@@ -56,16 +56,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
56
56
|
},
|
|
57
57
|
annotation: {
|
|
58
58
|
annotations: {
|
|
59
|
-
|
|
60
|
-
holiday<%= index %>: {
|
|
61
|
-
drawTime: 'beforeDraw',
|
|
62
|
-
type: 'box',
|
|
63
|
-
xMin: '<%= range.begin %>T00:00:00',
|
|
64
|
-
xMax: '<%= range.end %>T23:59:59',
|
|
65
|
-
backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
|
|
66
|
-
borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
|
|
67
|
-
},
|
|
68
|
-
<% end %>
|
|
59
|
+
<%= working_days_annotation %>
|
|
69
60
|
}
|
|
70
61
|
}
|
|
71
62
|
}
|
|
@@ -52,16 +52,7 @@ new Chart(document.getElementById('<%= chart_id %>').getContext('2d'), {
|
|
|
52
52
|
},
|
|
53
53
|
annotation: {
|
|
54
54
|
annotations: {
|
|
55
|
-
|
|
56
|
-
holiday<%= index %>: {
|
|
57
|
-
drawTime: 'beforeDraw',
|
|
58
|
-
type: 'box',
|
|
59
|
-
xMin: '<%= range.begin %>T00:00:00',
|
|
60
|
-
xMax: '<%= range.end %>T23:59:59',
|
|
61
|
-
backgroundColor: <%= CssVariable.new('--non-working-days-color').to_json %>,
|
|
62
|
-
borderColor: <%= CssVariable.new('--non-working-days-color').to_json %>
|
|
63
|
-
},
|
|
64
|
-
<% end %>
|
|
55
|
+
<%= working_days_annotation %>
|
|
65
56
|
}
|
|
66
57
|
}
|
|
67
58
|
}
|
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -49,14 +49,26 @@ class Issue
|
|
|
49
49
|
|
|
50
50
|
def summary = @raw['fields']['summary']
|
|
51
51
|
|
|
52
|
-
def status = Status.from_raw(@raw['fields']['status'])
|
|
53
|
-
|
|
54
52
|
def labels = @raw['fields']['labels'] || []
|
|
55
53
|
|
|
56
54
|
def author = @raw['fields']['creator']&.[]('displayName') || ''
|
|
57
55
|
|
|
58
56
|
def resolution = @raw['fields']['resolution']&.[]('name')
|
|
59
57
|
|
|
58
|
+
def status
|
|
59
|
+
@status = Status.from_raw(@raw['fields']['status']) unless @status
|
|
60
|
+
@status
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def status= status
|
|
64
|
+
@status = status
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def due_date
|
|
68
|
+
text = @raw['fields']['duedate']
|
|
69
|
+
text.nil? ? nil : Date.parse(text)
|
|
70
|
+
end
|
|
71
|
+
|
|
60
72
|
def url
|
|
61
73
|
# Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
|
|
62
74
|
"#{@board.server_url_prefix}/browse/#{key}"
|
|
@@ -129,13 +141,16 @@ class Issue
|
|
|
129
141
|
end
|
|
130
142
|
|
|
131
143
|
def most_recent_status_change
|
|
132
|
-
#
|
|
133
|
-
|
|
144
|
+
# Any issue that we loaded from its own file will always have a status as we artificially insert a status
|
|
145
|
+
# change to represent creation. Issues that were just fragments referenced from a main issue (ie a linked issue)
|
|
146
|
+
# may not have any status changes as we have no idea when it was created. This will be nil in that case
|
|
147
|
+
status_changes.last
|
|
134
148
|
end
|
|
135
149
|
|
|
136
150
|
# Are we currently in this status? If yes, then return the most recent status change.
|
|
137
151
|
def currently_in_status *status_names
|
|
138
152
|
change = most_recent_status_change
|
|
153
|
+
return false if change.nil?
|
|
139
154
|
|
|
140
155
|
change if change.current_status_matches(*status_names)
|
|
141
156
|
end
|
|
@@ -145,6 +160,7 @@ class Issue
|
|
|
145
160
|
category_ids = find_status_category_ids_by_names category_names
|
|
146
161
|
|
|
147
162
|
change = most_recent_status_change
|
|
163
|
+
return false if change.nil?
|
|
148
164
|
|
|
149
165
|
status = find_or_create_status id: change.value_id, name: change.value
|
|
150
166
|
change if status && category_ids.include?(status.category.id)
|
|
@@ -604,12 +620,17 @@ class Issue
|
|
|
604
620
|
end
|
|
605
621
|
|
|
606
622
|
(changes + (@discarded_changes || [])).each do |change|
|
|
607
|
-
|
|
608
|
-
|
|
623
|
+
if change.status?
|
|
624
|
+
value = "#{change.value.inspect}:#{change.value_id.inspect}"
|
|
625
|
+
old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
|
|
626
|
+
else
|
|
627
|
+
value = compact_text(change.value).inspect
|
|
628
|
+
old_value = change.old_value ? compact_text(change.old_value).inspect : nil
|
|
629
|
+
end
|
|
609
630
|
|
|
610
631
|
message = +''
|
|
611
|
-
message << "#{
|
|
612
|
-
message <<
|
|
632
|
+
message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
|
|
633
|
+
message << value
|
|
613
634
|
if change.artificial?
|
|
614
635
|
message << ' (Artificial entry)' if change.artificial?
|
|
615
636
|
else
|
|
@@ -14,9 +14,15 @@ class JiraGateway
|
|
|
14
14
|
def call_url relative_url:
|
|
15
15
|
command = make_curl_command url: "#{@jira_url}#{relative_url}"
|
|
16
16
|
result = call_command command
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
begin
|
|
18
|
+
json = JSON.parse(result)
|
|
19
|
+
rescue # rubocop:disable Style/RescueStandardError
|
|
20
|
+
raise "Error when parsing result: #{result.inspect}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
raise "Download failed with: #{JSON.pretty_generate(json)}" unless json_successful?(json)
|
|
24
|
+
|
|
25
|
+
json
|
|
20
26
|
end
|
|
21
27
|
|
|
22
28
|
def call_command command
|
|
@@ -61,4 +67,11 @@ class JiraGateway
|
|
|
61
67
|
command << " --url \"#{url}\""
|
|
62
68
|
command
|
|
63
69
|
end
|
|
70
|
+
|
|
71
|
+
def json_successful? json
|
|
72
|
+
return false if json.is_a?(Hash) && (json['error'] || json['errorMessages'] || json['errorMessage'])
|
|
73
|
+
return false if json.is_a?(Array) && json.first == 'errorMessage'
|
|
74
|
+
|
|
75
|
+
true
|
|
76
|
+
end
|
|
64
77
|
end
|
data/lib/jirametrics/status.rb
CHANGED
|
@@ -36,13 +36,10 @@ class Status
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def self.from_raw raw
|
|
39
|
-
|
|
39
|
+
raise "raw cannot be nil" if raw.nil?
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
puts "Category key #{category_config['key'].inspect} should be one of #{legal_keys.inspect}. Found:\n" \
|
|
44
|
-
"#{category_config}"
|
|
45
|
-
end
|
|
41
|
+
category_config = raw['statusCategory']
|
|
42
|
+
raise "statusCategory can't be nil in #{category_config.inspect}" if category_config.nil?
|
|
46
43
|
|
|
47
44
|
Status.new(
|
|
48
45
|
name: raw['name'],
|