jirametrics 2.10 → 2.12pre9
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 +50 -2
- data/lib/jirametrics/board.rb +34 -1
- data/lib/jirametrics/board_config.rb +1 -0
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/change_item.rb +8 -3
- data/lib/jirametrics/chart_base.rb +4 -1
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/estimate_accuracy_chart.rb +34 -10
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/exporter.rb +2 -2
- data/lib/jirametrics/file_system.rb +2 -0
- 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 +5 -3
- data/lib/jirametrics/html/index.css +11 -2
- data/lib/jirametrics/html/index.erb +8 -1
- data/lib/jirametrics/issue.rb +36 -11
- data/lib/jirametrics/project_config.rb +6 -1
- 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.rb +1 -0
- metadata +4 -2
checksums.yaml
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
---
|
2
2
|
SHA256:
|
3
|
-
metadata.gz:
|
4
|
-
data.tar.gz:
|
3
|
+
metadata.gz: b2e99113dff452fc39af119d35fe1b480ef991dba7ef3ef8c3335bf471fed9d8
|
4
|
+
data.tar.gz: e05ad4ed94cb690856293244e23f28d1007fdb1b608820bcfee40aa082232722
|
5
5
|
SHA512:
|
6
|
-
metadata.gz:
|
7
|
-
data.tar.gz:
|
6
|
+
metadata.gz: 0ece3dea23ac0f9141342ae2be782dfcbf034b39db6fb6ec7cbe28d8afe85865e41906083e9fbf587ed2de4aba9f6efa586a26f6899faa00a890505f5602e125
|
7
|
+
data.tar.gz: 7fac5a0b4fdbdda9808febf6b6f3043dcdf0965a3613145458cc3fb8c88cb3c0872e370fac12c0b0c50642172caebcb9c1b932252eb78de313c1fbbe14656de8
|
@@ -2,6 +2,7 @@
|
|
2
2
|
|
3
3
|
require 'jirametrics/chart_base'
|
4
4
|
require 'jirametrics/groupable_issue_chart'
|
5
|
+
require 'jirametrics/board_movement_calculator'
|
5
6
|
|
6
7
|
class AgingWorkInProgressChart < ChartBase
|
7
8
|
include GroupableIssueChart
|
@@ -16,13 +17,33 @@ class AgingWorkInProgressChart < ChartBase
|
|
16
17
|
This chart shows only work items that have started but not completed, grouped by the column
|
17
18
|
they're currently in. Hovering over a dot will show you the ID of that work item.
|
18
19
|
</p>
|
19
|
-
<
|
20
|
-
The
|
21
|
-
|
22
|
-
|
23
|
-
|
20
|
+
<p>
|
21
|
+
The shaded areas indicate what percentage of the work has passed that column within that time.
|
22
|
+
Notes:
|
23
|
+
<ul>
|
24
|
+
<li>It only shows columns that are considered "in progress". If you see a column that wouldn't normally
|
25
|
+
be thought of that way, then likely issues were moving backwards or continued to progress after hitting
|
26
|
+
that column.</li>
|
27
|
+
<li>If you see a colour group that drops as it moves to the right, that generally indicates that
|
28
|
+
a different number of data points is being included in each column. Probably because tickets moved
|
29
|
+
backwards athough it could also indicate that a ticket jumped over columns as it moved to the right.
|
30
|
+
</li>
|
31
|
+
</ul>
|
32
|
+
</p>
|
33
|
+
<div style="border: 1px solid gray; padding: 0.2em">
|
34
|
+
<% @percentiles.keys.sort.reverse.each do |percent| %>
|
35
|
+
<span style="padding-left: 0.5em; padding-right: 0.5em; vertical-align: middle;"><%= color_block @percentiles[percent] %> <%= percent %>%</span>
|
36
|
+
<% end %>
|
24
37
|
</div>
|
25
38
|
HTML
|
39
|
+
percentiles(
|
40
|
+
50 => '--aging-work-in-progress-chart-shading-50-color',
|
41
|
+
85 => '--aging-work-in-progress-chart-shading-85-color',
|
42
|
+
98 => '--aging-work-in-progress-chart-shading-98-color',
|
43
|
+
100 => '--aging-work-in-progress-chart-shading-100-color'
|
44
|
+
)
|
45
|
+
show_all_columns false
|
46
|
+
|
26
47
|
init_configuration_block(block) do
|
27
48
|
grouping_rules do |issue, rule|
|
28
49
|
rule.label = issue.type
|
@@ -36,13 +57,17 @@ class AgingWorkInProgressChart < ChartBase
|
|
36
57
|
|
37
58
|
@header_text += " on board: #{@all_boards[@board_id].name}"
|
38
59
|
data_sets = make_data_sets
|
39
|
-
column_headings = @board_columns.collect(&:name)
|
40
60
|
|
41
|
-
adjust_visibility_of_unmapped_status_column data_sets: data_sets
|
61
|
+
adjust_visibility_of_unmapped_status_column data_sets: data_sets
|
62
|
+
adjust_chart_height
|
42
63
|
|
43
64
|
wrap_and_render(binding, __FILE__)
|
44
65
|
end
|
45
66
|
|
67
|
+
def show_all_columns show = true # rubocop:disable Style/OptionalBooleanParameter
|
68
|
+
@show_all_columns = show
|
69
|
+
end
|
70
|
+
|
46
71
|
def determine_board_columns
|
47
72
|
unmapped_statuses = current_board.possible_statuses.collect(&:id)
|
48
73
|
|
@@ -51,7 +76,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
51
76
|
|
52
77
|
@fake_column = BoardColumn.new({
|
53
78
|
'name' => '[Unmapped Statuses]',
|
54
|
-
'statuses' => unmapped_statuses.collect { |id| { 'id' => id.to_s } }.uniq
|
79
|
+
'statuses' => unmapped_statuses.collect { |id| { 'id' => id.to_s } }.uniq # rubocop:disable Performance/ChainArrayAllocation
|
55
80
|
})
|
56
81
|
@board_columns = columns + [@fake_column]
|
57
82
|
end
|
@@ -62,7 +87,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
62
87
|
board.id == @board_id && board.cycletime.in_progress?(issue)
|
63
88
|
end
|
64
89
|
|
65
|
-
|
90
|
+
@max_age = 20
|
66
91
|
rules_to_issues = group_issues aging_issues
|
67
92
|
data_sets = rules_to_issues.keys.collect do |rules|
|
68
93
|
{
|
@@ -73,7 +98,10 @@ class AgingWorkInProgressChart < ChartBase
|
|
73
98
|
column = column_for issue: issue
|
74
99
|
next if column.nil?
|
75
100
|
|
76
|
-
|
101
|
+
@max_age = age if age > @max_age
|
102
|
+
|
103
|
+
{
|
104
|
+
'y' => age,
|
77
105
|
'x' => column.name,
|
78
106
|
'title' => ["#{issue.key} : #{issue.summary} (#{label_days age})"]
|
79
107
|
}
|
@@ -83,45 +111,70 @@ class AgingWorkInProgressChart < ChartBase
|
|
83
111
|
'backgroundColor' => rules.color
|
84
112
|
}
|
85
113
|
end
|
86
|
-
data_sets << {
|
87
|
-
'type' => 'bar',
|
88
|
-
'label' => "#{percentage}%",
|
89
|
-
'barPercentage' => 1.0,
|
90
|
-
'categoryPercentage' => 1.0,
|
91
|
-
'backgroundColor' => CssVariable['--aging-work-in-progress-chart-shading-color'],
|
92
|
-
'data' => days_at_percentage_threshold_for_all_columns(percentage: percentage, issues: @issues).drop(1)
|
93
|
-
}
|
94
|
-
end
|
95
114
|
|
96
|
-
|
97
|
-
|
98
|
-
|
99
|
-
|
100
|
-
|
115
|
+
calculator = BoardMovementCalculator.new board: @all_boards[@board_id], issues: issues, today: date_range.end
|
116
|
+
|
117
|
+
column_indexes_to_remove = []
|
118
|
+
unless @show_all_columns
|
119
|
+
column_indexes_to_remove = indexes_of_leading_and_trailing_zeros(calculator.age_data_for(percentage: 100))
|
120
|
+
|
121
|
+
column_indexes_to_remove.reverse_each do |index|
|
122
|
+
@board_columns.delete_at index
|
123
|
+
end
|
124
|
+
end
|
125
|
+
|
126
|
+
@row_index_offset = data_sets.size
|
127
|
+
|
128
|
+
bar_data = []
|
129
|
+
calculator.stacked_age_data_for(percentages: @percentiles.keys).each do |percentage, data|
|
130
|
+
column_indexes_to_remove.reverse_each { |index| data.delete_at index }
|
131
|
+
color = @percentiles[percentage]
|
132
|
+
|
133
|
+
data_sets << {
|
134
|
+
'type' => 'bar',
|
135
|
+
'label' => "#{percentage}%",
|
136
|
+
'barPercentage' => 1.0,
|
137
|
+
'categoryPercentage' => 1.0,
|
138
|
+
'backgroundColor' => color,
|
139
|
+
'data' => data
|
140
|
+
}
|
141
|
+
bar_data << data
|
101
142
|
end
|
143
|
+
@bar_data = adjust_bar_data bar_data
|
144
|
+
|
145
|
+
data_sets
|
102
146
|
end
|
103
147
|
|
104
|
-
def
|
105
|
-
|
106
|
-
|
107
|
-
|
148
|
+
def adjust_bar_data input
|
149
|
+
return [] if input.empty?
|
150
|
+
|
151
|
+
row_size = input.first.size
|
108
152
|
|
109
|
-
|
110
|
-
|
111
|
-
|
153
|
+
output = []
|
154
|
+
output << input.first
|
155
|
+
input.drop(1).each do |row|
|
156
|
+
previous_row = output.last
|
157
|
+
output << 0.upto(row_size - 1).collect { |i| row[i] + previous_row[i] }
|
158
|
+
end
|
159
|
+
|
160
|
+
output
|
112
161
|
end
|
113
162
|
|
114
|
-
def
|
115
|
-
|
116
|
-
|
117
|
-
|
163
|
+
def indexes_of_leading_and_trailing_zeros list
|
164
|
+
result = []
|
165
|
+
0.upto(list.size - 1) do |index|
|
166
|
+
break unless list[index].zero?
|
118
167
|
|
119
|
-
|
120
|
-
|
121
|
-
next if stop < start
|
168
|
+
result << index
|
169
|
+
end
|
122
170
|
|
123
|
-
|
171
|
+
stop_at = result.empty? ? 0 : (result.last + 1)
|
172
|
+
(list.size - 1).downto(stop_at).each do |index|
|
173
|
+
break unless list[index].zero?
|
174
|
+
|
175
|
+
result << index if list[index].zero?
|
124
176
|
end
|
177
|
+
result
|
125
178
|
end
|
126
179
|
|
127
180
|
def column_for issue:
|
@@ -130,7 +183,7 @@ class AgingWorkInProgressChart < ChartBase
|
|
130
183
|
end
|
131
184
|
end
|
132
185
|
|
133
|
-
def adjust_visibility_of_unmapped_status_column data_sets
|
186
|
+
def adjust_visibility_of_unmapped_status_column data_sets:
|
134
187
|
column_name = @fake_column.name
|
135
188
|
|
136
189
|
has_unmapped = data_sets.any? do |set|
|
@@ -143,8 +196,19 @@ class AgingWorkInProgressChart < ChartBase
|
|
143
196
|
@description_text += "<p>The items shown in #{column_name.inspect} are not visible on the " \
|
144
197
|
'board but are still active. Most likely everyone has forgotten about them.</p>'
|
145
198
|
else
|
146
|
-
column_headings.pop
|
199
|
+
# @column_headings.pop
|
147
200
|
@board_columns.pop
|
148
201
|
end
|
149
202
|
end
|
203
|
+
|
204
|
+
def percentiles percentile_color_hash
|
205
|
+
@percentiles = percentile_color_hash.transform_values { |value| CssVariable[value] }
|
206
|
+
end
|
207
|
+
|
208
|
+
def adjust_chart_height
|
209
|
+
min_height = @max_age * 5
|
210
|
+
|
211
|
+
@canvas_height = min_height if min_height > @canvas_height
|
212
|
+
@canvas_height = 400 if min_height > 400
|
213
|
+
end
|
150
214
|
end
|
@@ -3,7 +3,7 @@
|
|
3
3
|
require 'jirametrics/chart_base'
|
4
4
|
|
5
5
|
class AgingWorkTable < ChartBase
|
6
|
-
attr_accessor :today
|
6
|
+
attr_accessor :today
|
7
7
|
attr_reader :any_scrum_boards
|
8
8
|
|
9
9
|
def initialize block
|
@@ -22,18 +22,32 @@ class AgingWorkTable < ChartBase
|
|
22
22
|
If there are expedited items that haven't yet started then they're at the bottom of the table.
|
23
23
|
By the very definition of expedited, if we haven't started them already, we'd better get on that.
|
24
24
|
</p>
|
25
|
+
<p>
|
26
|
+
Legend:
|
27
|
+
<ul>
|
28
|
+
<li><b>E:</b> Whether this item is <b>E</b>xpedited.</li>
|
29
|
+
<li><b>B/S:</b> Whether this item is either <b>B</b>locked or <b>S</b>talled.</li>
|
30
|
+
<li><b>Forecast:</b> A forecast of how long it is likely to take to finish this work item.</li>
|
31
|
+
</ul>
|
32
|
+
</p>
|
25
33
|
TEXT
|
26
34
|
|
27
35
|
instance_eval(&block)
|
28
36
|
end
|
29
37
|
|
30
38
|
def run
|
31
|
-
|
39
|
+
initialize_calculator
|
32
40
|
aging_issues = select_aging_issues + expedited_but_not_started
|
33
41
|
|
34
42
|
wrap_and_render(binding, __FILE__)
|
35
43
|
end
|
36
44
|
|
45
|
+
# This is its own method simply so the tests can initialize the calculator without doing a full run.
|
46
|
+
def initialize_calculator
|
47
|
+
@today = date_range.end
|
48
|
+
@calculator = BoardMovementCalculator.new board: current_board, issues: issues, today: @today
|
49
|
+
end
|
50
|
+
|
37
51
|
def expedited_but_not_started
|
38
52
|
@issues.select do |issue|
|
39
53
|
started_time, stopped_time = issue.board.cycletime.started_stopped_times(issue)
|
@@ -114,6 +128,40 @@ class AgingWorkTable < ChartBase
|
|
114
128
|
end.join('<br />')
|
115
129
|
end
|
116
130
|
|
131
|
+
def dates_text issue
|
132
|
+
date = date_range.end
|
133
|
+
due = issue.due_date
|
134
|
+
message = nil
|
135
|
+
|
136
|
+
days_remaining, error = @calculator.forecasted_days_remaining_and_message issue: issue, today: @today
|
137
|
+
|
138
|
+
unless error
|
139
|
+
if due
|
140
|
+
if due < date
|
141
|
+
message = "Due: <b>#{due}</b> (#{label_days (@today - due).to_i} ago)"
|
142
|
+
error = 'Overdue'
|
143
|
+
elsif due == date
|
144
|
+
message = 'Due: <b>today</b>'
|
145
|
+
else
|
146
|
+
error = 'Due date at risk' if date_range.end + days_remaining > due
|
147
|
+
message = "Due: <b>#{due}</b> (#{label_days (due - @today).to_i})"
|
148
|
+
end
|
149
|
+
else
|
150
|
+
"#{label_days days_remaining} left."
|
151
|
+
end
|
152
|
+
end
|
153
|
+
|
154
|
+
text = +''
|
155
|
+
text << "<span title='#{error}' style='color: red'>ⓘ </span>" if error
|
156
|
+
if days_remaining
|
157
|
+
text << "#{label_days days_remaining} left"
|
158
|
+
else
|
159
|
+
text << 'Unable to forecast'
|
160
|
+
end
|
161
|
+
text << ' | ' << message if message
|
162
|
+
text
|
163
|
+
end
|
164
|
+
|
117
165
|
def age_cutoff age = nil
|
118
166
|
@age_cutoff = age.to_i if age
|
119
167
|
@age_cutoff
|
data/lib/jirametrics/board.rb
CHANGED
@@ -11,11 +11,12 @@ class Board
|
|
11
11
|
@sprints = []
|
12
12
|
|
13
13
|
columns = raw['columnConfig']['columns']
|
14
|
+
ensure_uniqueness_of_column_names! columns
|
14
15
|
|
15
16
|
# For a Kanban board, the first column here will always be called 'Backlog' and will NOT be
|
16
17
|
# visible on the board. If the board is configured to have a kanban backlog then it will have
|
17
18
|
# statuses matched to it and otherwise, there will be no statuses.
|
18
|
-
columns = columns
|
19
|
+
columns = columns.drop(1) if kanban?
|
19
20
|
|
20
21
|
@backlog_statuses = []
|
21
22
|
@visible_columns = columns.filter_map do |column|
|
@@ -88,4 +89,36 @@ class Board
|
|
88
89
|
def name
|
89
90
|
@raw['name']
|
90
91
|
end
|
92
|
+
|
93
|
+
def accumulated_status_ids_per_column
|
94
|
+
accumulated_status_ids = []
|
95
|
+
visible_columns.reverse.filter_map do |column|
|
96
|
+
next if column == @fake_column
|
97
|
+
|
98
|
+
accumulated_status_ids += column.status_ids
|
99
|
+
[column.name, accumulated_status_ids.dup]
|
100
|
+
end.reverse
|
101
|
+
end
|
102
|
+
|
103
|
+
def ensure_uniqueness_of_column_names! json
|
104
|
+
all_names = []
|
105
|
+
json.each do |column_json|
|
106
|
+
name = column_json['name']
|
107
|
+
if all_names.include? name
|
108
|
+
(2..).each do |i|
|
109
|
+
new_name = "#{name}-#{i}"
|
110
|
+
next if all_names.include?(new_name)
|
111
|
+
|
112
|
+
name = new_name
|
113
|
+
column_json['name'] = new_name
|
114
|
+
break
|
115
|
+
end
|
116
|
+
end
|
117
|
+
all_names << name
|
118
|
+
end
|
119
|
+
end
|
120
|
+
|
121
|
+
def estimation_configuration
|
122
|
+
EstimationConfiguration.new raw: raw['estimation']
|
123
|
+
end
|
91
124
|
end
|
@@ -0,0 +1,155 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
class BoardMovementCalculator
|
4
|
+
attr_reader :board, :issues, :today
|
5
|
+
|
6
|
+
def initialize board:, issues:, today:
|
7
|
+
@board = board
|
8
|
+
@issues = issues.select { |issue| issue.board == board && issue.done? && !moves_backwards?(issue) }
|
9
|
+
@today = today
|
10
|
+
end
|
11
|
+
|
12
|
+
def moves_backwards? issue
|
13
|
+
started, stopped = issue.board.cycletime.started_stopped_times(issue)
|
14
|
+
return false unless started
|
15
|
+
|
16
|
+
previous_column = nil
|
17
|
+
issue.status_changes.each do |change|
|
18
|
+
column = board.visible_columns.index { |c| c.status_ids.include?(change.value_id) }
|
19
|
+
next if change.time < started
|
20
|
+
next if column.nil? # It disappeared from the board for a bit
|
21
|
+
return true if previous_column && column && column < previous_column
|
22
|
+
|
23
|
+
previous_column = column
|
24
|
+
end
|
25
|
+
false
|
26
|
+
end
|
27
|
+
|
28
|
+
def stacked_age_data_for percentages:
|
29
|
+
data_list = percentages.sort.collect do |percentage|
|
30
|
+
[percentage, age_data_for(percentage: percentage)]
|
31
|
+
end
|
32
|
+
|
33
|
+
stack_data data_list
|
34
|
+
end
|
35
|
+
|
36
|
+
def stack_data data_list
|
37
|
+
remainder = nil
|
38
|
+
data_list.collect do |percentage, data|
|
39
|
+
unless remainder.nil?
|
40
|
+
data = (0...data.length).collect do |i|
|
41
|
+
data[i] - remainder[i]
|
42
|
+
end
|
43
|
+
|
44
|
+
end
|
45
|
+
remainder = data
|
46
|
+
|
47
|
+
[percentage, data]
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
def age_data_for percentage:
|
52
|
+
data = []
|
53
|
+
board.visible_columns.each_with_index do |_column, column_index|
|
54
|
+
ages = ages_of_issues_when_leaving_column column_index: column_index, today: today
|
55
|
+
|
56
|
+
if ages.empty?
|
57
|
+
data << 0
|
58
|
+
else
|
59
|
+
index = ((ages.size - 1) * percentage / 100).to_i
|
60
|
+
data << ages[index]
|
61
|
+
end
|
62
|
+
end
|
63
|
+
data
|
64
|
+
end
|
65
|
+
|
66
|
+
def ages_of_issues_when_leaving_column column_index:, today:
|
67
|
+
this_column = board.visible_columns[column_index]
|
68
|
+
next_column = board.visible_columns[column_index + 1]
|
69
|
+
|
70
|
+
@issues.filter_map do |issue|
|
71
|
+
this_column_start = issue.first_time_in_or_right_of_column(this_column.name)&.time
|
72
|
+
next_column_start = next_column.nil? ? nil : issue.first_time_in_or_right_of_column(next_column.name)&.time
|
73
|
+
issue_start, issue_done = issue.board.cycletime.started_stopped_times(issue)
|
74
|
+
|
75
|
+
# Skip if we can't tell when it started.
|
76
|
+
next if issue_start.nil?
|
77
|
+
|
78
|
+
# Skip if it never entered this column
|
79
|
+
next if this_column_start.nil?
|
80
|
+
|
81
|
+
# Skip if it left this column before the item is considered started.
|
82
|
+
next 0 if next_column_start && next_column_start <= issue_start
|
83
|
+
|
84
|
+
# Skip if it was already done by the time it got to this column or it became done when it got to this column
|
85
|
+
next if issue_done && issue_done <= this_column_start
|
86
|
+
|
87
|
+
end_date = case # rubocop:disable Style/EmptyCaseCondition
|
88
|
+
when next_column_start.nil?
|
89
|
+
# If this is the last column then base age against today
|
90
|
+
today
|
91
|
+
when issue_done && issue_done < next_column_start
|
92
|
+
# it completed while in this column
|
93
|
+
issue_done.to_date
|
94
|
+
else
|
95
|
+
# It passed through this whole column
|
96
|
+
next_column_start.to_date
|
97
|
+
end
|
98
|
+
(end_date - issue_start.to_date).to_i + 1
|
99
|
+
end.sort
|
100
|
+
end
|
101
|
+
|
102
|
+
# Figure out what column this is issue is currently in and what time it entered that column. We need this for
|
103
|
+
# aging and forecasting purposes
|
104
|
+
def find_current_column_and_entry_time_in_column issue
|
105
|
+
column = board.visible_columns.find { |c| c.status_ids.include?(issue.status.id) }
|
106
|
+
return [] if column.nil? # This issue isn't visible on the board
|
107
|
+
|
108
|
+
status_ids = column.status_ids
|
109
|
+
|
110
|
+
entry_at = issue.changes.reverse.find { |change| change.status? && status_ids.include?(change.value_id) }&.time
|
111
|
+
|
112
|
+
[column.name, entry_at]
|
113
|
+
end
|
114
|
+
|
115
|
+
def label_days days
|
116
|
+
"#{days} day#{'s' unless days == 1}"
|
117
|
+
end
|
118
|
+
|
119
|
+
def forecasted_days_remaining_and_message issue:, today:
|
120
|
+
return [nil, 'Already done'] if issue.done?
|
121
|
+
|
122
|
+
likely_age_data = age_data_for percentage: 85
|
123
|
+
|
124
|
+
column_name, entry_time = find_current_column_and_entry_time_in_column issue
|
125
|
+
return [nil, 'This issue is not visible on the board. No way to predict when it will be done.'] if column_name.nil?
|
126
|
+
|
127
|
+
# This condition has been reported in production so we have a check for it. Having said that, we have no
|
128
|
+
# idea what conditions might make this possible and so there is no test for it.
|
129
|
+
if entry_time.nil?
|
130
|
+
message = "Unable to determine the time this issue entered column #{column_name.inspect} so no way to " \
|
131
|
+
'predict when it will be done'
|
132
|
+
return [nil, message]
|
133
|
+
end
|
134
|
+
|
135
|
+
age_in_column = (today - entry_time.to_date).to_i + 1
|
136
|
+
|
137
|
+
message = nil
|
138
|
+
column_index = board.visible_columns.index { |c| c.name == column_name }
|
139
|
+
|
140
|
+
last_non_zero_datapoint = likely_age_data.reverse.find { |d| !d.zero? }
|
141
|
+
return [nil, 'There is no historical data for this board. No forecast can be made.'] if last_non_zero_datapoint.nil?
|
142
|
+
|
143
|
+
remaining_in_current_column = likely_age_data[column_index] - age_in_column
|
144
|
+
if remaining_in_current_column.negative?
|
145
|
+
message = "This item is an outlier at #{label_days issue.board.cycletime.age(issue, today: today)} " \
|
146
|
+
"in the #{column_name.inspect} column. Most items on this board have left this column in " \
|
147
|
+
"#{label_days likely_age_data[column_index]} or less, so we cannot forecast when it will be done."
|
148
|
+
remaining_in_current_column = 0
|
149
|
+
return [nil, message]
|
150
|
+
end
|
151
|
+
|
152
|
+
forecasted_days = last_non_zero_datapoint - likely_age_data[column_index] + remaining_in_current_column
|
153
|
+
[forecasted_days, message]
|
154
|
+
end
|
155
|
+
end
|
@@ -31,8 +31,6 @@ class ChangeItem
|
|
31
31
|
|
32
32
|
def sprint? = (field == 'Sprint')
|
33
33
|
|
34
|
-
def story_points? = (field == 'Story Points')
|
35
|
-
|
36
34
|
def link? = (field == 'Link')
|
37
35
|
|
38
36
|
def labels? = (field == 'labels')
|
@@ -42,7 +40,14 @@ class ChangeItem
|
|
42
40
|
|
43
41
|
def to_s
|
44
42
|
message = +''
|
45
|
-
message << "ChangeItem(field: #{field.inspect}
|
43
|
+
message << "ChangeItem(field: #{field.inspect}"
|
44
|
+
message << ", value: #{value.inspect}"
|
45
|
+
message << ':' << value_id.inspect if status?
|
46
|
+
if old_value
|
47
|
+
message << ", old_value: #{old_value.inspect}"
|
48
|
+
message << ':' << old_value_id.inspect if status?
|
49
|
+
end
|
50
|
+
message << ", time: #{time_to_s(@time).inspect}"
|
46
51
|
message << ', artificial' if artificial?
|
47
52
|
message << ')'
|
48
53
|
message
|
@@ -260,7 +260,10 @@ class ChartBase
|
|
260
260
|
|
261
261
|
def color_block color, title: nil
|
262
262
|
result = +''
|
263
|
-
result << "<div class='color_block' style='
|
263
|
+
result << "<div class='color_block' style='"
|
264
|
+
result << "background: #{CssVariable[color]};" if color
|
265
|
+
result << 'visibility: hidden;' unless color
|
266
|
+
result << "'"
|
264
267
|
result << " title=#{title.inspect}" if title
|
265
268
|
result << '></div>'
|
266
269
|
result
|
@@ -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
|
@@ -52,6 +52,8 @@ class FileSystem
|
|
52
52
|
# In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
|
53
53
|
# cases where this simple compression will drop the filesize by half.
|
54
54
|
def compress node
|
55
|
+
return node
|
56
|
+
|
55
57
|
if node.is_a? Hash
|
56
58
|
node.reject! { |_key, value| value.nil? || (value.is_a?(Array) && value.empty?) }
|
57
59
|
node.each_value { |value| compress value }
|
@@ -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,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>
|
@@ -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>
|
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)
|
@@ -595,21 +611,30 @@ class Issue
|
|
595
611
|
end
|
596
612
|
history = [] # time, type, detail
|
597
613
|
|
598
|
-
|
599
|
-
|
600
|
-
|
614
|
+
if board.cycletime
|
615
|
+
started_at, stopped_at = board.cycletime.started_stopped_times(self)
|
616
|
+
history << [started_at, nil, '↓↓↓↓ Started here ↓↓↓↓', true] if started_at
|
617
|
+
history << [stopped_at, nil, '↑↑↑↑ Finished here ↑↑↑↑', true] if stopped_at
|
618
|
+
else
|
619
|
+
result << " Unable to determine start/end times as board #{board.id} has no cycletime specified\n"
|
620
|
+
end
|
601
621
|
|
602
622
|
@discarded_change_times&.each do |time|
|
603
623
|
history << [time, nil, '↑↑↑↑ Changes discarded ↑↑↑↑', true]
|
604
624
|
end
|
605
625
|
|
606
626
|
(changes + (@discarded_changes || [])).each do |change|
|
607
|
-
|
608
|
-
|
627
|
+
if change.status?
|
628
|
+
value = "#{change.value.inspect}:#{change.value_id.inspect}"
|
629
|
+
old_value = change.old_value ? "#{change.old_value.inspect}:#{change.old_value_id.inspect}" : nil
|
630
|
+
else
|
631
|
+
value = compact_text(change.value).inspect
|
632
|
+
old_value = change.old_value ? compact_text(change.old_value).inspect : nil
|
633
|
+
end
|
609
634
|
|
610
635
|
message = +''
|
611
|
-
message << "#{
|
612
|
-
message <<
|
636
|
+
message << "#{old_value} -> " unless old_value.nil? || old_value.empty?
|
637
|
+
message << value
|
613
638
|
if change.artificial?
|
614
639
|
message << ' (Artificial entry)' if change.artificial?
|
615
640
|
else
|
@@ -151,6 +151,8 @@ class ProjectConfig
|
|
151
151
|
end
|
152
152
|
|
153
153
|
def status_category_mapping status:, category:
|
154
|
+
return if @exporter.downloading?
|
155
|
+
|
154
156
|
status, status_id = possible_statuses.parse_name_id status
|
155
157
|
category, category_id = possible_statuses.parse_name_id category
|
156
158
|
|
@@ -450,6 +452,9 @@ class ProjectConfig
|
|
450
452
|
end
|
451
453
|
|
452
454
|
boards.each do |board|
|
455
|
+
if board.cycletime.nil?
|
456
|
+
raise "The board declaration for board #{board.id} must come before the first usage of 'issues' in the configuration"
|
457
|
+
end
|
453
458
|
issues << Issue.new(raw: content, timezone_offset: timezone_offset, board: board)
|
454
459
|
end
|
455
460
|
end
|
@@ -462,7 +467,7 @@ class ProjectConfig
|
|
462
467
|
# board ids appropriately.
|
463
468
|
def group_filenames_and_board_ids path:
|
464
469
|
hash = {}
|
465
|
-
|
470
|
+
file_system.foreach(path) do |filename|
|
466
471
|
# Matches either FAKE-123.json or FAKE-123-456.json
|
467
472
|
if /^(?<key>[^-]+-\d+)(?<_>-(?<board_id>\d+))?\.json$/ =~ filename
|
468
473
|
(hash[key] ||= []) << [filename, board_id&.to_i || :unknown]
|
@@ -121,11 +121,13 @@ class SprintBurndown < ChartBase
|
|
121
121
|
|
122
122
|
# select all the changes that are relevant for the sprint. If this issue never appears in this sprint then return [].
|
123
123
|
def changes_for_one_issue issue:, sprint:
|
124
|
-
|
124
|
+
estimate = 0.0
|
125
125
|
ever_in_sprint = false
|
126
126
|
currently_in_sprint = false
|
127
127
|
change_data = []
|
128
128
|
|
129
|
+
estimate_display_name = current_board.estimation_configuration.display_name
|
130
|
+
|
129
131
|
issue_completed_time = issue.board.cycletime.started_stopped_times(issue).last
|
130
132
|
completed_has_been_tracked = false
|
131
133
|
|
@@ -140,26 +142,26 @@ class SprintBurndown < ChartBase
|
|
140
142
|
if currently_in_sprint == false && in_change_item
|
141
143
|
action = :enter_sprint
|
142
144
|
ever_in_sprint = true
|
143
|
-
value =
|
145
|
+
value = estimate
|
144
146
|
elsif currently_in_sprint && in_change_item == false
|
145
147
|
action = :leave_sprint
|
146
|
-
value = -
|
148
|
+
value = -estimate
|
147
149
|
end
|
148
150
|
currently_in_sprint = in_change_item
|
149
|
-
elsif change.
|
151
|
+
elsif change.field == estimate_display_name && (issue_completed_time.nil? || change.time < issue_completed_time)
|
150
152
|
action = :story_points
|
151
|
-
|
152
|
-
value =
|
153
|
+
estimate = change.value.to_f
|
154
|
+
value = estimate - change.old_value.to_f
|
153
155
|
elsif completed_has_been_tracked == false && change.time == issue_completed_time
|
154
156
|
completed_has_been_tracked = true
|
155
157
|
action = :issue_stopped
|
156
|
-
value = -
|
158
|
+
value = -estimate
|
157
159
|
end
|
158
160
|
|
159
161
|
next unless action
|
160
162
|
|
161
163
|
change_data << SprintIssueChangeData.new(
|
162
|
-
time: change.time, issue: issue, action: action, value: value,
|
164
|
+
time: change.time, issue: issue, action: action, value: value, estimate: estimate
|
163
165
|
)
|
164
166
|
end
|
165
167
|
|
@@ -176,7 +178,7 @@ class SprintBurndown < ChartBase
|
|
176
178
|
summary_stats = SprintSummaryStats.new
|
177
179
|
summary_stats.completed = 0.0
|
178
180
|
|
179
|
-
|
181
|
+
estimate = 0.0
|
180
182
|
start_data_written = false
|
181
183
|
data_set = []
|
182
184
|
|
@@ -185,11 +187,11 @@ class SprintBurndown < ChartBase
|
|
185
187
|
change_data_for_sprint.each do |change_data|
|
186
188
|
if start_data_written == false && change_data.time >= sprint.start_time
|
187
189
|
data_set << {
|
188
|
-
y:
|
190
|
+
y: estimate,
|
189
191
|
x: chart_format(sprint.start_time),
|
190
|
-
title: "Sprint started with #{
|
192
|
+
title: "Sprint started with #{estimate} points"
|
191
193
|
}
|
192
|
-
summary_stats.started =
|
194
|
+
summary_stats.started = estimate
|
193
195
|
start_data_written = true
|
194
196
|
end
|
195
197
|
|
@@ -198,12 +200,12 @@ class SprintBurndown < ChartBase
|
|
198
200
|
case change_data.action
|
199
201
|
when :enter_sprint
|
200
202
|
issues_currently_in_sprint << change_data.issue.key
|
201
|
-
|
203
|
+
estimate += change_data.estimate
|
202
204
|
when :leave_sprint
|
203
205
|
issues_currently_in_sprint.delete change_data.issue.key
|
204
|
-
|
206
|
+
estimate -= change_data.estimate
|
205
207
|
when :story_points
|
206
|
-
|
208
|
+
estimate += change_data.value if issues_currently_in_sprint.include? change_data.issue.key
|
207
209
|
end
|
208
210
|
|
209
211
|
next unless change_data.time >= sprint.start_time
|
@@ -213,26 +215,26 @@ class SprintBurndown < ChartBase
|
|
213
215
|
when :story_points
|
214
216
|
next unless issues_currently_in_sprint.include? change_data.issue.key
|
215
217
|
|
216
|
-
|
217
|
-
message = "Story points changed from #{
|
218
|
+
old_estimate = change_data.estimate - change_data.value
|
219
|
+
message = "Story points changed from #{old_estimate} points to #{change_data.estimate} points"
|
218
220
|
summary_stats.points_values_changed = true
|
219
221
|
when :enter_sprint
|
220
|
-
message = "Added to sprint with #{change_data.
|
221
|
-
summary_stats.added += change_data.
|
222
|
+
message = "Added to sprint with #{change_data.estimate || 'no'} points"
|
223
|
+
summary_stats.added += change_data.estimate
|
222
224
|
when :issue_stopped
|
223
|
-
|
224
|
-
message = "Completed with #{change_data.
|
225
|
+
estimate -= change_data.estimate
|
226
|
+
message = "Completed with #{change_data.estimate || 'no'} points"
|
225
227
|
issues_currently_in_sprint.delete change_data.issue.key
|
226
|
-
summary_stats.completed += change_data.
|
228
|
+
summary_stats.completed += change_data.estimate
|
227
229
|
when :leave_sprint
|
228
|
-
message = "Removed from sprint with #{change_data.
|
229
|
-
summary_stats.removed += change_data.
|
230
|
+
message = "Removed from sprint with #{change_data.estimate || 'no'} points"
|
231
|
+
summary_stats.removed += change_data.estimate
|
230
232
|
else
|
231
233
|
raise "Unexpected action: #{change_data.action}"
|
232
234
|
end
|
233
235
|
|
234
236
|
data_set << {
|
235
|
-
y:
|
237
|
+
y: estimate,
|
236
238
|
x: chart_format(change_data.time),
|
237
239
|
title: "#{change_data.issue.key} #{message}"
|
238
240
|
}
|
@@ -241,27 +243,27 @@ class SprintBurndown < ChartBase
|
|
241
243
|
unless start_data_written
|
242
244
|
# There was nothing that triggered us to write the sprint started block so do it now.
|
243
245
|
data_set << {
|
244
|
-
y:
|
246
|
+
y: estimate,
|
245
247
|
x: chart_format(sprint.start_time),
|
246
|
-
title: "Sprint started with #{
|
248
|
+
title: "Sprint started with #{estimate} points"
|
247
249
|
}
|
248
|
-
summary_stats.started =
|
250
|
+
summary_stats.started = estimate
|
249
251
|
end
|
250
252
|
|
251
253
|
if sprint.completed_time
|
252
254
|
data_set << {
|
253
|
-
y:
|
255
|
+
y: estimate,
|
254
256
|
x: chart_format(sprint.completed_time),
|
255
|
-
title: "Sprint ended with #{
|
257
|
+
title: "Sprint ended with #{estimate} points unfinished"
|
256
258
|
}
|
257
|
-
summary_stats.remaining =
|
259
|
+
summary_stats.remaining = estimate
|
258
260
|
end
|
259
261
|
|
260
262
|
unless sprint.completed_at?(time_range.end)
|
261
263
|
data_set << {
|
262
|
-
y:
|
264
|
+
y: estimate,
|
263
265
|
x: chart_format(time_range.end),
|
264
|
-
title: "Sprint still active. #{
|
266
|
+
title: "Sprint still active. #{estimate} points still in progress."
|
265
267
|
}
|
266
268
|
end
|
267
269
|
|
@@ -4,14 +4,14 @@ require 'jirametrics/value_equality'
|
|
4
4
|
|
5
5
|
class SprintIssueChangeData
|
6
6
|
include ValueEquality
|
7
|
-
attr_reader :time, :action, :value, :issue, :
|
7
|
+
attr_reader :time, :action, :value, :issue, :estimate
|
8
8
|
|
9
|
-
def initialize time:, action:, value:, issue:,
|
9
|
+
def initialize time:, action:, value:, issue:, estimate:
|
10
10
|
@time = time
|
11
11
|
@action = action
|
12
12
|
@value = value
|
13
13
|
@issue = issue
|
14
|
-
@
|
14
|
+
@estimate = estimate
|
15
15
|
end
|
16
16
|
|
17
17
|
def inspect
|
data/lib/jirametrics/status.rb
CHANGED
@@ -36,7 +36,10 @@ class Status
|
|
36
36
|
end
|
37
37
|
|
38
38
|
def self.from_raw raw
|
39
|
+
raise "raw cannot be nil" if raw.nil?
|
40
|
+
|
39
41
|
category_config = raw['statusCategory']
|
42
|
+
raise "statusCategory can't be nil in #{category_config.inspect}" if category_config.nil?
|
40
43
|
|
41
44
|
Status.new(
|
42
45
|
name: raw['name'],
|
data/lib/jirametrics.rb
CHANGED
@@ -112,6 +112,7 @@ class JiraMetrics < Thor
|
|
112
112
|
require 'jirametrics/download_config'
|
113
113
|
require 'jirametrics/columns_config'
|
114
114
|
require 'jirametrics/hierarchy_table'
|
115
|
+
require 'jirametrics/estimation_configuration'
|
115
116
|
require 'jirametrics/board'
|
116
117
|
load config_file
|
117
118
|
end
|
metadata
CHANGED
@@ -1,13 +1,13 @@
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
2
2
|
name: jirametrics
|
3
3
|
version: !ruby/object:Gem::Version
|
4
|
-
version:
|
4
|
+
version: 2.12pre9
|
5
5
|
platform: ruby
|
6
6
|
authors:
|
7
7
|
- Mike Bowler
|
8
8
|
bindir: bin
|
9
9
|
cert_chain: []
|
10
|
-
date: 2025-
|
10
|
+
date: 2025-04-16 00:00:00.000000000 Z
|
11
11
|
dependencies:
|
12
12
|
- !ruby/object:Gem::Dependency
|
13
13
|
name: random-word
|
@@ -69,6 +69,7 @@ files:
|
|
69
69
|
- lib/jirametrics/board.rb
|
70
70
|
- lib/jirametrics/board_column.rb
|
71
71
|
- lib/jirametrics/board_config.rb
|
72
|
+
- lib/jirametrics/board_movement_calculator.rb
|
72
73
|
- lib/jirametrics/change_item.rb
|
73
74
|
- lib/jirametrics/chart_base.rb
|
74
75
|
- lib/jirametrics/columns_config.rb
|
@@ -85,6 +86,7 @@ files:
|
|
85
86
|
- lib/jirametrics/download_config.rb
|
86
87
|
- lib/jirametrics/downloader.rb
|
87
88
|
- lib/jirametrics/estimate_accuracy_chart.rb
|
89
|
+
- lib/jirametrics/estimation_configuration.rb
|
88
90
|
- lib/jirametrics/examples/aggregated_project.rb
|
89
91
|
- lib/jirametrics/examples/standard_project.rb
|
90
92
|
- lib/jirametrics/expedited_chart.rb
|