jirametrics 2.10 → 2.13
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_in_progress_chart.rb +105 -41
- data/lib/jirametrics/aging_work_table.rb +56 -13
- data/lib/jirametrics/atlassian_document_format.rb +156 -0
- data/lib/jirametrics/board.rb +38 -10
- data/lib/jirametrics/board_config.rb +1 -0
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/change_item.rb +38 -16
- data/lib/jirametrics/chart_base.rb +7 -5
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cycletime_config.rb +1 -1
- data/lib/jirametrics/daily_view.rb +274 -0
- data/lib/jirametrics/downloader.rb +61 -21
- data/lib/jirametrics/estimate_accuracy_chart.rb +34 -10
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/standard_project.rb +2 -0
- data/lib/jirametrics/exporter.rb +2 -2
- data/lib/jirametrics/file_config.rb +1 -1
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +1 -1
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
- data/lib/jirametrics/html/aging_work_table.erb +7 -3
- data/lib/jirametrics/html/index.css +82 -2
- data/lib/jirametrics/html/index.erb +25 -1
- data/lib/jirametrics/html_report_config.rb +2 -0
- data/lib/jirametrics/issue.rb +69 -28
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/jira_gateway.rb +8 -1
- data/lib/jirametrics/project_config.rb +24 -7
- data/lib/jirametrics/settings.json +2 -1
- data/lib/jirametrics/sprint.rb +1 -0
- data/lib/jirametrics/sprint_burndown.rb +35 -33
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +3 -0
- data/lib/jirametrics/status_collection.rb +7 -0
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics.rb +5 -0
- metadata +8 -2
|
@@ -22,15 +22,18 @@ class EstimateAccuracyChart < ChartBase
|
|
|
22
22
|
</div>
|
|
23
23
|
HTML
|
|
24
24
|
|
|
25
|
-
@y_axis_label = 'Story Point Estimates'
|
|
26
25
|
@y_axis_type = 'linear'
|
|
27
|
-
@y_axis_block = ->(issue, start_time) {
|
|
26
|
+
@y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
|
|
28
27
|
@y_axis_sort_order = nil
|
|
29
28
|
|
|
30
29
|
instance_eval(&configuration_block)
|
|
31
30
|
end
|
|
32
31
|
|
|
33
32
|
def run
|
|
33
|
+
if @y_axis_label.nil?
|
|
34
|
+
text = current_board.estimation_configuration.units == :story_points ? 'Story Points' : 'Days'
|
|
35
|
+
@y_axis_label = "Estimated #{text}"
|
|
36
|
+
end
|
|
34
37
|
data_sets = scan_issues
|
|
35
38
|
|
|
36
39
|
return '' if data_sets.empty?
|
|
@@ -41,6 +44,7 @@ class EstimateAccuracyChart < ChartBase
|
|
|
41
44
|
def scan_issues
|
|
42
45
|
completed_hash, aging_hash = split_into_completed_and_aging issues: issues
|
|
43
46
|
|
|
47
|
+
estimation_units = current_board.estimation_configuration.units
|
|
44
48
|
@has_aging_data = !aging_hash.empty?
|
|
45
49
|
|
|
46
50
|
[
|
|
@@ -53,9 +57,13 @@ class EstimateAccuracyChart < ChartBase
|
|
|
53
57
|
# We sort so that the smaller circles are in front of the bigger circles.
|
|
54
58
|
data = hash.sort(&hash_sorter).collect do |key, values|
|
|
55
59
|
estimate, cycle_time = *key
|
|
56
|
-
|
|
57
|
-
title = [
|
|
58
|
-
|
|
60
|
+
|
|
61
|
+
title = [
|
|
62
|
+
"Estimate: #{estimate_label(estimate: estimate, estimation_units: estimation_units)}, " \
|
|
63
|
+
"Cycletime: #{label_days(cycle_time)}, " \
|
|
64
|
+
"#{values.size} issues"
|
|
65
|
+
] + values.collect { |issue| "#{issue.key}: #{issue.summary}" }
|
|
66
|
+
|
|
59
67
|
{
|
|
60
68
|
'x' => cycle_time,
|
|
61
69
|
'y' => estimate,
|
|
@@ -77,6 +85,18 @@ class EstimateAccuracyChart < ChartBase
|
|
|
77
85
|
end
|
|
78
86
|
end
|
|
79
87
|
|
|
88
|
+
def estimate_label estimate:, estimation_units:
|
|
89
|
+
if @y_axis_type == 'linear'
|
|
90
|
+
if estimation_units == :story_points
|
|
91
|
+
estimate_label = "#{estimate}pts"
|
|
92
|
+
elsif estimation_units == :seconds
|
|
93
|
+
estimate_label = label_days estimate
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
estimate_label = estimate.to_s if estimate_label.nil?
|
|
97
|
+
estimate_label
|
|
98
|
+
end
|
|
99
|
+
|
|
80
100
|
def split_into_completed_and_aging issues:
|
|
81
101
|
aging_hash = {}
|
|
82
102
|
completed_hash = {}
|
|
@@ -126,14 +146,18 @@ class EstimateAccuracyChart < ChartBase
|
|
|
126
146
|
end
|
|
127
147
|
end
|
|
128
148
|
|
|
129
|
-
def
|
|
130
|
-
|
|
149
|
+
def estimate_at issue:, start_time:, estimation_configuration: current_board.estimation_configuration
|
|
150
|
+
estimate = nil
|
|
151
|
+
|
|
131
152
|
issue.changes.each do |change|
|
|
132
|
-
return
|
|
153
|
+
return estimate if change.time >= start_time
|
|
133
154
|
|
|
134
|
-
|
|
155
|
+
if change.field == estimation_configuration.display_name || change.field == estimation_configuration.field_id
|
|
156
|
+
estimate = change.value
|
|
157
|
+
estimate = estimate.to_f / (24 * 60 * 60) if estimation_configuration.units == :seconds
|
|
158
|
+
end
|
|
135
159
|
end
|
|
136
|
-
|
|
160
|
+
estimate
|
|
137
161
|
end
|
|
138
162
|
|
|
139
163
|
def y_axis label:, sort_order: nil, &block
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class EstimationConfiguration
|
|
4
|
+
attr_reader :units, :display_name, :field_id
|
|
5
|
+
|
|
6
|
+
def initialize raw:
|
|
7
|
+
@units = :story_points
|
|
8
|
+
@display_name = 'Story Points'
|
|
9
|
+
|
|
10
|
+
# If there wasn't an estimation section they rely on all defaults
|
|
11
|
+
return if raw.nil?
|
|
12
|
+
|
|
13
|
+
if raw['type'] == 'field'
|
|
14
|
+
@field_id = raw['field']['fieldId']
|
|
15
|
+
@display_name = raw['field']['displayName']
|
|
16
|
+
if @field_id == 'timeoriginalestimate'
|
|
17
|
+
@units = :seconds
|
|
18
|
+
@display_name = 'Original estimate'
|
|
19
|
+
end
|
|
20
|
+
elsif raw['type'] == 'issueCount'
|
|
21
|
+
@display_name = 'Issue Count'
|
|
22
|
+
@units = :issue_count
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -79,8 +79,8 @@ class Exporter
|
|
|
79
79
|
file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
|
|
80
80
|
else
|
|
81
81
|
selected.each do |project, issue|
|
|
82
|
-
file_system.log "\nProject #{project.name}"
|
|
83
|
-
file_system.log issue.dump
|
|
82
|
+
file_system.log "\nProject #{project.name}", also_write_to_stderr: true
|
|
83
|
+
file_system.log issue.dump, also_write_to_stderr: true
|
|
84
84
|
end
|
|
85
85
|
end
|
|
86
86
|
end
|
|
@@ -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>
|
|
@@ -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,13 @@
|
|
|
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
|
+
<th title="Priority">P</th>
|
|
7
8
|
<th>Issue</th>
|
|
8
9
|
<th>Status</th>
|
|
10
|
+
<th>Forecast</th>
|
|
9
11
|
<th>Fix versions</th>
|
|
10
12
|
<% if any_scrum_boards %>
|
|
11
13
|
<th>Sprints</th>
|
|
@@ -28,6 +30,7 @@
|
|
|
28
30
|
<td style="text-align: right;"><%= issue_age || 'Not started' %></td>
|
|
29
31
|
<td><%= expedited_text(issue) %></td>
|
|
30
32
|
<td><%= blocked_text(issue) %></td>
|
|
33
|
+
<td><%= priority_text(issue) %></td>
|
|
31
34
|
<td>
|
|
32
35
|
<% parent_hierarchy(issue).each_with_index do |parent, index| %>
|
|
33
36
|
<% color = parent != issue ? "var(--hierarchy-table-inactive-item-text-color)" : 'var(--default-text-color)' %>
|
|
@@ -41,6 +44,7 @@
|
|
|
41
44
|
<% end %>
|
|
42
45
|
</td>
|
|
43
46
|
<td><%= format_status issue.status, board: issue.board %></td>
|
|
47
|
+
<td><%= dates_text(issue) %></td>
|
|
44
48
|
<td><%= fix_versions_text(issue) %></td>
|
|
45
49
|
<% if any_scrum_boards %>
|
|
46
50
|
<td><%= sprints_text(issue) %></td>
|
|
@@ -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;
|
|
@@ -58,6 +67,9 @@
|
|
|
58
67
|
--sprint-burndown-sprint-color-4: red;
|
|
59
68
|
--sprint-burndown-sprint-color-5: brown;
|
|
60
69
|
|
|
70
|
+
--daily-view-selected-issue-background: lightgray;
|
|
71
|
+
--daily-view-issue-border: green;
|
|
72
|
+
--daily-view-selected-issue-border: red;
|
|
61
73
|
|
|
62
74
|
}
|
|
63
75
|
|
|
@@ -133,8 +145,68 @@ ul.quality_report {
|
|
|
133
145
|
border-top: 1px solid gray;
|
|
134
146
|
}
|
|
135
147
|
|
|
148
|
+
div.daily_issue:hover {
|
|
149
|
+
background: var(--daily-view-selected-issue-background);
|
|
150
|
+
border-color: var(--daily-view-selected-issue-border);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
div.daily_issue {
|
|
154
|
+
border: 1px solid var(--daily-view-issue-border);
|
|
155
|
+
padding: 0.5em;
|
|
156
|
+
.heading {
|
|
157
|
+
vertical-align: middle;
|
|
158
|
+
display: flex;
|
|
159
|
+
flex-wrap: wrap;
|
|
160
|
+
column-gap: 0.5em;
|
|
161
|
+
align-items: center;
|
|
162
|
+
}
|
|
163
|
+
table {
|
|
164
|
+
margin-left: 1em;
|
|
165
|
+
td {
|
|
166
|
+
vertical-align: top;
|
|
167
|
+
}
|
|
168
|
+
.time {
|
|
169
|
+
white-space: nowrap;
|
|
170
|
+
font-size: 0.8em;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
.icon {
|
|
174
|
+
width: 1em;
|
|
175
|
+
height: 1em;
|
|
176
|
+
}
|
|
177
|
+
.account_id {
|
|
178
|
+
font-weight: bold;
|
|
179
|
+
}
|
|
180
|
+
.field {
|
|
181
|
+
border: 1px solid black;
|
|
182
|
+
color: white;
|
|
183
|
+
background: black;
|
|
184
|
+
padding-left: 0.2em;
|
|
185
|
+
padding-right: 0.2em;
|
|
186
|
+
border-radius: 0.2em;
|
|
187
|
+
}
|
|
188
|
+
.label {
|
|
189
|
+
border: 1px solid black;
|
|
190
|
+
padding-left: 0.2em;
|
|
191
|
+
padding-right: 0.2em;
|
|
192
|
+
border-radius: 0.2em;
|
|
193
|
+
}
|
|
194
|
+
margin-bottom: 0.5em;
|
|
195
|
+
}
|
|
196
|
+
div.child_issue:hover {
|
|
197
|
+
background: var(--body-background);
|
|
198
|
+
}
|
|
199
|
+
div.child_issue {
|
|
200
|
+
border: 1px dashed green;
|
|
201
|
+
margin: 0.2em;
|
|
202
|
+
margin-left: 1.5em;
|
|
203
|
+
padding: 0.5em;
|
|
204
|
+
}
|
|
205
|
+
|
|
136
206
|
@media screen and (prefers-color-scheme: dark) {
|
|
137
207
|
:root {
|
|
208
|
+
--warning-banner: #9F2B00;
|
|
209
|
+
|
|
138
210
|
--non-working-days-color: #2f2f2f;
|
|
139
211
|
--type-story-color: #6fb86f;
|
|
140
212
|
--type-task-color: #0021b3;
|
|
@@ -150,8 +222,6 @@ ul.quality_report {
|
|
|
150
222
|
--dead-color: black;
|
|
151
223
|
--wip-chart-active-color: #2551c1;
|
|
152
224
|
|
|
153
|
-
--aging-work-in-progress-chart-shading-color: #b4b4b4;
|
|
154
|
-
|
|
155
225
|
--status-category-inprogress-color: #1c49bb;
|
|
156
226
|
|
|
157
227
|
--cycletime-scatterplot-overall-trendline-color: gray;
|
|
@@ -165,6 +235,8 @@ ul.quality_report {
|
|
|
165
235
|
--wip-chart-duration-two-weeks-or-less-color: #cf9400;
|
|
166
236
|
--wip-chart-duration-four-weeks-or-less-color: #c25e00;
|
|
167
237
|
--wip-chart-duration-more-than-four-weeks-color: #8e0000;
|
|
238
|
+
|
|
239
|
+
--daily-view-selected-issue-background: #474747;
|
|
168
240
|
}
|
|
169
241
|
|
|
170
242
|
h1 {
|
|
@@ -197,4 +269,12 @@ ul.quality_report {
|
|
|
197
269
|
div.color_block {
|
|
198
270
|
border: 1px solid lightgray;
|
|
199
271
|
}
|
|
272
|
+
|
|
273
|
+
div.daily_issue {
|
|
274
|
+
.field {
|
|
275
|
+
color: var(--default-text-color);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
200
280
|
}
|
|
@@ -18,18 +18,42 @@
|
|
|
18
18
|
document.getElementById(issues_id).style.display = 'none'
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
|
+
|
|
22
|
+
function toggle_visibility(open_link_id, close_link_id, toggleable_id) {
|
|
23
|
+
let open_link = document.getElementById(open_link_id)
|
|
24
|
+
let close_link = document.getElementById(close_link_id)
|
|
25
|
+
let toggleable_element = document.getElementById(toggleable_id)
|
|
26
|
+
|
|
27
|
+
if(open_link.style.display == 'none') {
|
|
28
|
+
open_link.style.display = 'block'
|
|
29
|
+
close_link.style.display = 'none'
|
|
30
|
+
toggleable_element.style.display = 'none'
|
|
31
|
+
}
|
|
32
|
+
else {
|
|
33
|
+
open_link.style.display = 'none'
|
|
34
|
+
close_link.style.display = 'block'
|
|
35
|
+
toggleable_element.style.display = 'block'
|
|
36
|
+
}
|
|
37
|
+
}
|
|
21
38
|
// If we switch between light/dark mode then force a refresh so all charts will redraw correctly
|
|
22
39
|
// in the other colour scheme.
|
|
23
40
|
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', event => {
|
|
24
41
|
location.reload()
|
|
25
42
|
})
|
|
26
|
-
|
|
27
43
|
</script>
|
|
28
44
|
<style>
|
|
29
45
|
<%= css %>
|
|
30
46
|
</style>
|
|
47
|
+
<script type="text/javascript">
|
|
48
|
+
Chart.defaults.color = getComputedStyle(document.documentElement).getPropertyValue('--default-text-color');
|
|
49
|
+
</script>
|
|
31
50
|
</head>
|
|
32
51
|
<body>
|
|
52
|
+
<noscript>
|
|
53
|
+
<div style="padding: 1em; background: red; color: white; font-size: 2em;">
|
|
54
|
+
Javascript is currently disabled and that means that almost all of the charts in this report won't render. If you've loaded this from a folder on SharePoint then save it locally and load it again.
|
|
55
|
+
</div>
|
|
56
|
+
</noscript>
|
|
33
57
|
<%= "\n" + @sections.collect { |text, type| text if type == :header }.compact.join("\n\n") %>
|
|
34
58
|
<%= "\n" + @sections.collect { |text, type| text if type == :body }.compact.join("\n\n") %>
|
|
35
59
|
</body>
|
|
@@ -33,6 +33,7 @@ class HtmlReportConfig
|
|
|
33
33
|
define_chart name: 'estimate_accuracy_chart', classname: 'EstimateAccuracyChart'
|
|
34
34
|
define_chart name: 'hierarchy_table', classname: 'HierarchyTable'
|
|
35
35
|
define_chart name: 'flow_efficiency_scatterplot', classname: 'FlowEfficiencyScatterplot'
|
|
36
|
+
define_chart name: 'daily_view', classname: 'DailyView'
|
|
36
37
|
|
|
37
38
|
define_chart name: 'daily_wip_by_type', classname: 'DailyWipChart',
|
|
38
39
|
deprecated_warning: 'This is the same as daily_wip_chart. Please use that one', deprecated_date: '2024-05-23'
|
|
@@ -159,6 +160,7 @@ class HtmlReportConfig
|
|
|
159
160
|
chart.time_range = project_config.time_range
|
|
160
161
|
chart.timezone_offset = timezone_offset
|
|
161
162
|
chart.settings = settings
|
|
163
|
+
chart.users = project_config.users
|
|
162
164
|
|
|
163
165
|
chart.all_boards = project_config.all_boards
|
|
164
166
|
chart.board_id = find_board_id
|
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -44,12 +44,12 @@ class Issue
|
|
|
44
44
|
def key = @raw['key']
|
|
45
45
|
|
|
46
46
|
def type = @raw['fields']['issuetype']['name']
|
|
47
|
-
|
|
48
47
|
def type_icon_url = @raw['fields']['issuetype']['iconUrl']
|
|
49
48
|
|
|
50
|
-
def
|
|
49
|
+
def priority_name = @raw['fields']['priority']['name']
|
|
50
|
+
def priority_url = @raw['fields']['priority']['iconUrl']
|
|
51
51
|
|
|
52
|
-
def
|
|
52
|
+
def summary = @raw['fields']['summary']
|
|
53
53
|
|
|
54
54
|
def labels = @raw['fields']['labels'] || []
|
|
55
55
|
|
|
@@ -57,6 +57,20 @@ class Issue
|
|
|
57
57
|
|
|
58
58
|
def resolution = @raw['fields']['resolution']&.[]('name')
|
|
59
59
|
|
|
60
|
+
def status
|
|
61
|
+
@status = Status.from_raw(@raw['fields']['status']) unless @status
|
|
62
|
+
@status
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def status= status
|
|
66
|
+
@status = status
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def due_date
|
|
70
|
+
text = @raw['fields']['duedate']
|
|
71
|
+
text.nil? ? nil : Date.parse(text)
|
|
72
|
+
end
|
|
73
|
+
|
|
60
74
|
def url
|
|
61
75
|
# Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
|
|
62
76
|
"#{@board.server_url_prefix}/browse/#{key}"
|
|
@@ -129,13 +143,16 @@ class Issue
|
|
|
129
143
|
end
|
|
130
144
|
|
|
131
145
|
def most_recent_status_change
|
|
132
|
-
#
|
|
133
|
-
|
|
146
|
+
# Any issue that we loaded from its own file will always have a status as we artificially insert a status
|
|
147
|
+
# change to represent creation. Issues that were just fragments referenced from a main issue (ie a linked issue)
|
|
148
|
+
# may not have any status changes as we have no idea when it was created. This will be nil in that case
|
|
149
|
+
status_changes.last
|
|
134
150
|
end
|
|
135
151
|
|
|
136
152
|
# Are we currently in this status? If yes, then return the most recent status change.
|
|
137
153
|
def currently_in_status *status_names
|
|
138
154
|
change = most_recent_status_change
|
|
155
|
+
return false if change.nil?
|
|
139
156
|
|
|
140
157
|
change if change.current_status_matches(*status_names)
|
|
141
158
|
end
|
|
@@ -145,6 +162,7 @@ class Issue
|
|
|
145
162
|
category_ids = find_status_category_ids_by_names category_names
|
|
146
163
|
|
|
147
164
|
change = most_recent_status_change
|
|
165
|
+
return false if change.nil?
|
|
148
166
|
|
|
149
167
|
status = find_or_create_status id: change.value_id, name: change.value
|
|
150
168
|
change if status && category_ids.include?(status.category.id)
|
|
@@ -189,6 +207,10 @@ class Issue
|
|
|
189
207
|
nil
|
|
190
208
|
end
|
|
191
209
|
|
|
210
|
+
def first_time_visible_on_board
|
|
211
|
+
first_time_in_status(*board.visible_columns.collect(&:status_ids).flatten)
|
|
212
|
+
end
|
|
213
|
+
|
|
192
214
|
def parse_time text
|
|
193
215
|
Time.parse(text).getlocal(@timezone_offset)
|
|
194
216
|
end
|
|
@@ -214,6 +236,10 @@ class Issue
|
|
|
214
236
|
@raw['fields']&.[]('assignee')&.[]('displayName')
|
|
215
237
|
end
|
|
216
238
|
|
|
239
|
+
def assigned_to_icon_url
|
|
240
|
+
@raw['fields']&.[]('assignee')&.[]('avatarUrls')&.[]('16x16')
|
|
241
|
+
end
|
|
242
|
+
|
|
217
243
|
# Many test failures are simply unreadable because the default inspect on this class goes
|
|
218
244
|
# on for pages. Shorten it up.
|
|
219
245
|
def inspect
|
|
@@ -299,7 +325,7 @@ class Issue
|
|
|
299
325
|
|
|
300
326
|
# This mock change is to force the writing of one last entry at the end of the time range.
|
|
301
327
|
# By doing this, we're able to eliminate a lot of duplicated code in charts.
|
|
302
|
-
mock_change = ChangeItem.new time: end_time,
|
|
328
|
+
mock_change = ChangeItem.new time: end_time, artificial: true, raw: { 'field' => '' }, author_raw: nil
|
|
303
329
|
|
|
304
330
|
(changes + [mock_change]).each do |change|
|
|
305
331
|
previous_was_active = false if check_for_stalled(
|
|
@@ -319,7 +345,7 @@ class Issue
|
|
|
319
345
|
end
|
|
320
346
|
elsif change.link?
|
|
321
347
|
# Example: "This issue is satisfied by ANON-30465"
|
|
322
|
-
unless /^This issue (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
|
|
348
|
+
unless /^This (?<_>issue|work item) (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
|
|
323
349
|
puts "Issue(#{key}) Can't parse link text: #{change.value || change.old_value}"
|
|
324
350
|
next
|
|
325
351
|
end
|
|
@@ -446,8 +472,6 @@ class Issue
|
|
|
446
472
|
end
|
|
447
473
|
|
|
448
474
|
def expedited?
|
|
449
|
-
return false unless @board&.project_config
|
|
450
|
-
|
|
451
475
|
names = @board.project_config.settings['expedited_priority_names']
|
|
452
476
|
return false unless names
|
|
453
477
|
|
|
@@ -564,7 +588,7 @@ class Issue
|
|
|
564
588
|
/(?<project_code1>[^-]+)-(?<id1>.+)/ =~ key
|
|
565
589
|
/(?<project_code2>[^-]+)-(?<id2>.+)/ =~ other.key
|
|
566
590
|
comparison = project_code1 <=> project_code2
|
|
567
|
-
comparison = id1 <=> id2 if comparison.zero?
|
|
591
|
+
comparison = id1.to_i <=> id2.to_i if comparison.zero?
|
|
568
592
|
comparison
|
|
569
593
|
end
|
|
570
594
|
|
|
@@ -595,21 +619,30 @@ class Issue
|
|
|
595
619
|
end
|
|
596
620
|
history = [] # time, type, detail
|
|
597
621
|
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
622
|
+
if board.cycletime
|
|
623
|
+
started_at, stopped_at = board.cycletime.started_stopped_times(self)
|
|
624
|
+
history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
|
|
625
|
+
history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
|
|
626
|
+
else
|
|
627
|
+
result << " Unable to determine start/end times as board #{board.id} has no cycletime specified\n"
|
|
628
|
+
end
|
|
601
629
|
|
|
602
630
|
@discarded_change_times&.each do |time|
|
|
603
631
|
history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
|
|
604
632
|
end
|
|
605
633
|
|
|
606
634
|
(changes + (@discarded_changes || [])).each do |change|
|
|
607
|
-
|
|
608
|
-
|
|
635
|
+
if change.status?
|
|
636
|
+
value = "#{change.value.inspect}:#{change.value_id.inspect}"
|
|
637
|
+
old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
|
|
638
|
+
else
|
|
639
|
+
value = compact_text(change.value).inspect
|
|
640
|
+
old_value = change.old_value ? compact_text(change.old_value).inspect : nil
|
|
641
|
+
end
|
|
609
642
|
|
|
610
643
|
message = +''
|
|
611
|
-
message << "#{
|
|
612
|
-
message <<
|
|
644
|
+
message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
|
|
645
|
+
message << value
|
|
613
646
|
if change.artificial?
|
|
614
647
|
message << ' (Artificial entry)' if change.artificial?
|
|
615
648
|
else
|
|
@@ -660,34 +693,40 @@ class Issue
|
|
|
660
693
|
@changes.select { |change| change.status? }
|
|
661
694
|
end
|
|
662
695
|
|
|
663
|
-
|
|
696
|
+
def sprints
|
|
697
|
+
sprint_ids = []
|
|
664
698
|
|
|
665
|
-
|
|
666
|
-
|
|
699
|
+
changes.each do |change|
|
|
700
|
+
next unless change.sprint?
|
|
701
|
+
|
|
702
|
+
sprint_ids << change.raw['to'].split(/\s*,\s*/).collect { |id| id.to_i }
|
|
703
|
+
end
|
|
704
|
+
sprint_ids.flatten!
|
|
705
|
+
|
|
706
|
+
board.sprints.select { |s| sprint_ids.include? s.id }
|
|
667
707
|
end
|
|
668
708
|
|
|
709
|
+
private
|
|
710
|
+
|
|
669
711
|
def load_history_into_changes
|
|
670
712
|
@raw['changelog']['histories']&.each do |history|
|
|
671
713
|
created = parse_time(history['created'])
|
|
672
714
|
|
|
673
|
-
# It should be impossible to not have an author but we've seen it in production
|
|
674
|
-
author = assemble_author history
|
|
675
715
|
history['items']&.each do |item|
|
|
676
|
-
@changes << ChangeItem.new(raw: item, time: created,
|
|
716
|
+
@changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
|
|
677
717
|
end
|
|
678
718
|
end
|
|
679
719
|
end
|
|
680
720
|
|
|
681
721
|
def load_comments_into_changes
|
|
682
722
|
@raw['fields']['comment']['comments']&.each do |comment|
|
|
683
|
-
raw = {
|
|
723
|
+
raw = comment.merge({
|
|
684
724
|
'field' => 'comment',
|
|
685
725
|
'to' => comment['id'],
|
|
686
726
|
'toString' => comment['body']
|
|
687
|
-
}
|
|
688
|
-
author = assemble_author comment
|
|
727
|
+
})
|
|
689
728
|
created = parse_time(comment['created'])
|
|
690
|
-
@changes << ChangeItem.new(raw: raw, time: created,
|
|
729
|
+
@changes << ChangeItem.new(raw: raw, time: created, artificial: true, author_raw: comment['author'])
|
|
691
730
|
end
|
|
692
731
|
end
|
|
693
732
|
|
|
@@ -729,7 +768,9 @@ class Issue
|
|
|
729
768
|
first_status = first_change.old_value
|
|
730
769
|
first_status_id = first_change.old_value_id
|
|
731
770
|
end
|
|
732
|
-
|
|
771
|
+
|
|
772
|
+
creator = raw['fields']['creator']
|
|
773
|
+
ChangeItem.new time: created_time, artificial: true, author_raw: creator, raw: {
|
|
733
774
|
'field' => field_name,
|
|
734
775
|
'to' => first_status_id,
|
|
735
776
|
'toString' => first_status
|