jirametrics 2.0 → 2.12.1
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 +19 -26
- data/lib/jirametrics/aging_work_bar_chart.rb +79 -54
- data/lib/jirametrics/aging_work_in_progress_chart.rb +106 -40
- data/lib/jirametrics/aging_work_table.rb +84 -54
- data/lib/jirametrics/anonymizer.rb +6 -5
- data/lib/jirametrics/blocked_stalled_change.rb +24 -4
- data/lib/jirametrics/board.rb +51 -23
- data/lib/jirametrics/board_config.rb +9 -4
- data/lib/jirametrics/board_movement_calculator.rb +155 -0
- data/lib/jirametrics/change_item.rb +56 -21
- data/lib/jirametrics/chart_base.rb +101 -61
- data/lib/jirametrics/columns_config.rb +4 -0
- data/lib/jirametrics/css_variable.rb +33 -0
- data/lib/jirametrics/cycletime_config.rb +59 -8
- data/lib/jirametrics/cycletime_histogram.rb +69 -4
- data/lib/jirametrics/cycletime_scatterplot.rb +11 -15
- data/lib/jirametrics/daily_view.rb +277 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +44 -20
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +37 -35
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +38 -0
- data/lib/jirametrics/daily_wip_chart.rb +61 -14
- data/lib/jirametrics/data_quality_report.rb +222 -41
- data/lib/jirametrics/dependency_chart.rb +54 -23
- data/lib/jirametrics/download_config.rb +12 -0
- data/lib/jirametrics/downloader.rb +86 -56
- data/lib/jirametrics/estimate_accuracy_chart.rb +173 -0
- data/lib/jirametrics/estimation_configuration.rb +25 -0
- data/lib/jirametrics/examples/aggregated_project.rb +22 -39
- data/lib/jirametrics/examples/standard_project.rb +26 -48
- data/lib/jirametrics/expedited_chart.rb +28 -25
- data/lib/jirametrics/exporter.rb +59 -32
- data/lib/jirametrics/file_config.rb +35 -14
- data/lib/jirametrics/file_system.rb +48 -3
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
- data/lib/jirametrics/groupable_issue_chart.rb +2 -6
- data/lib/jirametrics/grouping_rules.rb +7 -1
- data/lib/jirametrics/hierarchy_table.rb +4 -4
- data/lib/jirametrics/html/aging_work_bar_chart.erb +13 -16
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +28 -5
- data/lib/jirametrics/html/aging_work_table.erb +21 -25
- data/lib/jirametrics/html/cycletime_histogram.erb +83 -3
- data/lib/jirametrics/html/cycletime_scatterplot.erb +9 -12
- data/lib/jirametrics/html/daily_wip_chart.erb +17 -13
- data/lib/jirametrics/html/{story_point_accuracy_chart.erb → estimate_accuracy_chart.erb} +9 -4
- data/lib/jirametrics/html/expedited_chart.erb +10 -13
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
- data/lib/jirametrics/html/hierarchy_table.erb +2 -2
- data/lib/jirametrics/html/index.css +280 -0
- data/lib/jirametrics/html/index.erb +33 -39
- data/lib/jirametrics/html/sprint_burndown.erb +10 -14
- data/lib/jirametrics/html/throughput_chart.erb +10 -13
- data/lib/jirametrics/html_report_config.rb +110 -86
- data/lib/jirametrics/issue.rb +390 -109
- data/lib/jirametrics/issue_collection.rb +33 -0
- data/lib/jirametrics/jira_gateway.rb +33 -12
- data/lib/jirametrics/project_config.rb +276 -147
- data/lib/jirametrics/rules.rb +2 -2
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +11 -0
- data/lib/jirametrics/sprint.rb +1 -0
- data/lib/jirametrics/sprint_burndown.rb +59 -40
- data/lib/jirametrics/sprint_issue_change_data.rb +3 -3
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +86 -39
- data/lib/jirametrics/throughput_chart.rb +12 -4
- data/lib/jirametrics/user.rb +12 -0
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics.rb +29 -7
- metadata +20 -17
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/experimental/generator.rb +0 -210
- data/lib/jirametrics/experimental/info.rb +0 -77
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
- data/lib/jirametrics/story_point_accuracy_chart.rb +0 -134
|
@@ -6,11 +6,11 @@ require 'json'
|
|
|
6
6
|
class Downloader
|
|
7
7
|
CURRENT_METADATA_VERSION = 4
|
|
8
8
|
|
|
9
|
-
attr_accessor :metadata
|
|
9
|
+
attr_accessor :metadata
|
|
10
10
|
attr_reader :file_system
|
|
11
11
|
|
|
12
12
|
# For testing only
|
|
13
|
-
attr_reader :start_date_in_query
|
|
13
|
+
attr_reader :start_date_in_query, :board_id_to_filter_id
|
|
14
14
|
|
|
15
15
|
def initialize download_config:, file_system:, jira_gateway:
|
|
16
16
|
@metadata = {}
|
|
@@ -39,11 +39,13 @@ class Downloader
|
|
|
39
39
|
# board_ids = @download_config.board_ids
|
|
40
40
|
|
|
41
41
|
remove_old_files
|
|
42
|
+
update_status_history_file
|
|
42
43
|
download_statuses
|
|
43
44
|
find_board_ids.each do |id|
|
|
44
|
-
download_board_configuration board_id: id
|
|
45
|
-
download_issues
|
|
45
|
+
board = download_board_configuration board_id: id
|
|
46
|
+
download_issues board: board
|
|
46
47
|
end
|
|
48
|
+
download_users
|
|
47
49
|
|
|
48
50
|
save_metadata
|
|
49
51
|
end
|
|
@@ -54,8 +56,7 @@ class Downloader
|
|
|
54
56
|
end
|
|
55
57
|
|
|
56
58
|
def log text, both: false
|
|
57
|
-
@file_system.log text
|
|
58
|
-
puts text if both
|
|
59
|
+
@file_system.log text, also_write_to_stderr: both
|
|
59
60
|
end
|
|
60
61
|
|
|
61
62
|
def find_board_ids
|
|
@@ -65,19 +66,19 @@ class Downloader
|
|
|
65
66
|
ids
|
|
66
67
|
end
|
|
67
68
|
|
|
68
|
-
def download_issues
|
|
69
|
-
log " Downloading primary issues for board #{
|
|
70
|
-
path =
|
|
69
|
+
def download_issues board:
|
|
70
|
+
log " Downloading primary issues for board #{board.id}", both: true
|
|
71
|
+
path = File.join(@target_path, "#{file_prefix}_issues/")
|
|
71
72
|
unless Dir.exist?(path)
|
|
72
73
|
log " Creating path #{path}"
|
|
73
74
|
Dir.mkdir(path)
|
|
74
75
|
end
|
|
75
76
|
|
|
76
|
-
filter_id = @board_id_to_filter_id[
|
|
77
|
+
filter_id = @board_id_to_filter_id[board.id]
|
|
77
78
|
jql = make_jql(filter_id: filter_id)
|
|
78
|
-
jira_search_by_jql(jql: jql, initial_query: true,
|
|
79
|
+
jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
|
|
79
80
|
|
|
80
|
-
log " Downloading linked issues for board #{
|
|
81
|
+
log " Downloading linked issues for board #{board.id}", both: true
|
|
81
82
|
loop do
|
|
82
83
|
@issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
|
|
83
84
|
break if @issue_keys_pending_download.empty?
|
|
@@ -85,15 +86,15 @@ class Downloader
|
|
|
85
86
|
keys_to_request = @issue_keys_pending_download[0..99]
|
|
86
87
|
@issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
|
|
87
88
|
jql = "key in (#{keys_to_request.join(', ')})"
|
|
88
|
-
jira_search_by_jql(jql: jql, initial_query: false,
|
|
89
|
+
jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
|
|
89
90
|
end
|
|
90
91
|
end
|
|
91
92
|
|
|
92
|
-
def jira_search_by_jql jql:, initial_query:,
|
|
93
|
+
def jira_search_by_jql jql:, initial_query:, board:, path:
|
|
93
94
|
intercept_jql = @download_config.project_config.settings['intercept_jql']
|
|
94
95
|
jql = intercept_jql.call jql if intercept_jql
|
|
95
96
|
|
|
96
|
-
log " #{jql}"
|
|
97
|
+
log " JQL: #{jql}"
|
|
97
98
|
escaped_jql = CGI.escape jql
|
|
98
99
|
|
|
99
100
|
max_results = 100
|
|
@@ -103,14 +104,12 @@ class Downloader
|
|
|
103
104
|
json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
|
|
104
105
|
"?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
|
|
105
106
|
|
|
106
|
-
exit_if_call_failed json
|
|
107
|
-
|
|
108
107
|
json['issues'].each do |issue_json|
|
|
109
108
|
issue_json['exporter'] = {
|
|
110
109
|
'in_initial_query' => initial_query
|
|
111
110
|
}
|
|
112
|
-
identify_other_issues_to_be_downloaded issue_json
|
|
113
|
-
file = "#{issue_json['key']}-#{
|
|
111
|
+
identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
|
|
112
|
+
file = "#{issue_json['key']}-#{board.id}.json"
|
|
114
113
|
|
|
115
114
|
@file_system.save_json(json: issue_json, filename: File.join(path, file))
|
|
116
115
|
end
|
|
@@ -125,8 +124,8 @@ class Downloader
|
|
|
125
124
|
end
|
|
126
125
|
end
|
|
127
126
|
|
|
128
|
-
def identify_other_issues_to_be_downloaded raw_issue
|
|
129
|
-
issue = Issue.new raw: raw_issue, board:
|
|
127
|
+
def identify_other_issues_to_be_downloaded raw_issue:, board:
|
|
128
|
+
issue = Issue.new raw: raw_issue, board: board
|
|
130
129
|
@issue_keys_downloaded_in_current_run << issue.key
|
|
131
130
|
|
|
132
131
|
# Parent
|
|
@@ -137,51 +136,74 @@ class Downloader
|
|
|
137
136
|
issue.raw['fields']['subtasks']&.each do |raw_subtask|
|
|
138
137
|
@issue_keys_pending_download << raw_subtask['key']
|
|
139
138
|
end
|
|
140
|
-
|
|
141
|
-
# Links
|
|
142
|
-
# We shouldn't blindly follow links as some, like cloners, aren't valuable and are just wasting time/effort
|
|
143
|
-
# to download
|
|
144
|
-
# issue.raw['fields']['issuelinks'].each do |raw_link|
|
|
145
|
-
# @issue_keys_pending_download << IssueLink(raw: raw_link).other_issue.key
|
|
146
|
-
# end
|
|
147
139
|
end
|
|
148
140
|
|
|
149
|
-
def
|
|
150
|
-
|
|
151
|
-
|
|
141
|
+
def download_statuses
|
|
142
|
+
log ' Downloading all statuses', both: true
|
|
143
|
+
json = @jira_gateway.call_url relative_url: '/rest/api/2/status'
|
|
152
144
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
145
|
+
@file_system.save_json(
|
|
146
|
+
json: json,
|
|
147
|
+
filename: File.join(@target_path, "#{file_prefix}_statuses.json")
|
|
148
|
+
)
|
|
156
149
|
end
|
|
157
150
|
|
|
158
|
-
def
|
|
159
|
-
log ' Downloading all
|
|
160
|
-
json = @jira_gateway.call_url relative_url:
|
|
151
|
+
def download_users
|
|
152
|
+
log ' Downloading all users', both: true
|
|
153
|
+
json = @jira_gateway.call_url relative_url: '/rest/api/2/users'
|
|
161
154
|
|
|
162
155
|
@file_system.save_json(
|
|
163
|
-
json: json,
|
|
164
|
-
filename:
|
|
156
|
+
json: json,
|
|
157
|
+
filename: File.join(@target_path, "#{file_prefix}_users.json")
|
|
165
158
|
)
|
|
166
159
|
end
|
|
167
160
|
|
|
161
|
+
def update_status_history_file
|
|
162
|
+
status_filename = File.join(@target_path, "#{file_prefix}_statuses.json")
|
|
163
|
+
return unless file_system.file_exist? status_filename
|
|
164
|
+
|
|
165
|
+
status_json = file_system.load_json(status_filename)
|
|
166
|
+
|
|
167
|
+
history_filename = File.join(@target_path, "#{file_prefix}_status_history.json")
|
|
168
|
+
history_json = file_system.load_json(history_filename) if file_system.file_exist? history_filename
|
|
169
|
+
|
|
170
|
+
if history_json
|
|
171
|
+
file_system.log ' Updating status history file', also_write_to_stderr: true
|
|
172
|
+
else
|
|
173
|
+
file_system.log ' Creating status history file', also_write_to_stderr: true
|
|
174
|
+
history_json = []
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
status_json.each do |status_item|
|
|
178
|
+
id = status_item['id']
|
|
179
|
+
history_item = history_json.find { |s| s['id'] == id }
|
|
180
|
+
history_json.delete(history_item) if history_item
|
|
181
|
+
history_json << status_item
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
file_system.save_json(filename: history_filename, json: history_json)
|
|
185
|
+
end
|
|
186
|
+
|
|
168
187
|
def download_board_configuration board_id:
|
|
169
188
|
log " Downloading board configuration for board #{board_id}", both: true
|
|
170
189
|
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
|
|
171
|
-
exit_if_call_failed json
|
|
172
190
|
|
|
173
|
-
@
|
|
174
|
-
|
|
191
|
+
@file_system.save_json(
|
|
192
|
+
json: json,
|
|
193
|
+
filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
|
|
194
|
+
)
|
|
175
195
|
|
|
176
|
-
|
|
177
|
-
|
|
196
|
+
# We have a reported bug that blew up on this line. Moved it after the save so we can
|
|
197
|
+
# actually look at the returned json.
|
|
198
|
+
@board_id_to_filter_id[board_id] = json['filter']['id'].to_i
|
|
178
199
|
|
|
179
200
|
download_sprints board_id: board_id if json['type'] == 'scrum'
|
|
201
|
+
# TODO: Should be passing actual statuses, not empty list
|
|
202
|
+
Board.new raw: json, possible_statuses: StatusCollection.new
|
|
180
203
|
end
|
|
181
204
|
|
|
182
205
|
def download_sprints board_id:
|
|
183
206
|
log " Downloading sprints for board #{board_id}", both: true
|
|
184
|
-
file_prefix = @download_config.project_config.file_prefix
|
|
185
207
|
max_results = 100
|
|
186
208
|
start_at = 0
|
|
187
209
|
is_last = false
|
|
@@ -189,20 +211,23 @@ class Downloader
|
|
|
189
211
|
while is_last == false
|
|
190
212
|
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/sprint?" \
|
|
191
213
|
"maxResults=#{max_results}&startAt=#{start_at}"
|
|
192
|
-
exit_if_call_failed json
|
|
193
214
|
|
|
194
215
|
@file_system.save_json(
|
|
195
216
|
json: json,
|
|
196
|
-
filename:
|
|
217
|
+
filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json")
|
|
197
218
|
)
|
|
198
219
|
is_last = json['isLast']
|
|
199
220
|
max_results = json['maxResults']
|
|
200
|
-
|
|
221
|
+
if json['values']
|
|
222
|
+
start_at += json['values'].size
|
|
223
|
+
else
|
|
224
|
+
log " No sprints found for board #{board_id}"
|
|
225
|
+
end
|
|
201
226
|
end
|
|
202
227
|
end
|
|
203
228
|
|
|
204
229
|
def metadata_pathname
|
|
205
|
-
|
|
230
|
+
File.join(@target_path, "#{file_prefix}_meta.json")
|
|
206
231
|
end
|
|
207
232
|
|
|
208
233
|
def load_metadata
|
|
@@ -245,17 +270,17 @@ class Downloader
|
|
|
245
270
|
end
|
|
246
271
|
|
|
247
272
|
def remove_old_files
|
|
248
|
-
file_prefix = @download_config.project_config.file_prefix
|
|
249
273
|
Dir.foreach @target_path do |file|
|
|
250
274
|
next unless file.match?(/^#{file_prefix}_\d+\.json$/)
|
|
275
|
+
next if file == "#{file_prefix}_status_history.json"
|
|
251
276
|
|
|
252
|
-
File.unlink
|
|
277
|
+
File.unlink File.join(@target_path, file)
|
|
253
278
|
end
|
|
254
279
|
|
|
255
280
|
return if @cached_data_format_is_current
|
|
256
281
|
|
|
257
282
|
# Also throw away all the previously downloaded issues.
|
|
258
|
-
path = File.join
|
|
283
|
+
path = File.join(@target_path, "#{file_prefix}_issues")
|
|
259
284
|
return unless File.exist? path
|
|
260
285
|
|
|
261
286
|
Dir.foreach path do |file|
|
|
@@ -269,8 +294,10 @@ class Downloader
|
|
|
269
294
|
segments = []
|
|
270
295
|
segments << "filter=#{filter_id}"
|
|
271
296
|
|
|
272
|
-
|
|
273
|
-
|
|
297
|
+
start_date = @download_config.start_date today: today
|
|
298
|
+
|
|
299
|
+
if start_date
|
|
300
|
+
@download_date_range = start_date..today.to_date
|
|
274
301
|
|
|
275
302
|
# For an incremental download, we want to query from the end of the previous one, not from the
|
|
276
303
|
# beginning of the full range.
|
|
@@ -283,13 +310,16 @@ class Downloader
|
|
|
283
310
|
|
|
284
311
|
# Pick up any issues that had a status change in the range
|
|
285
312
|
start_date_text = @start_date_in_query.strftime '%Y-%m-%d'
|
|
286
|
-
end_date_text = today.strftime '%Y-%m-%d'
|
|
287
313
|
# find_in_range = %((status changed DURING ("#{start_date_text} 00:00","#{end_date_text} 23:59")))
|
|
288
|
-
find_in_range = %(
|
|
314
|
+
find_in_range = %(updated >= "#{start_date_text} 00:00")
|
|
289
315
|
|
|
290
316
|
segments << "(#{find_in_range} OR #{catch_all})"
|
|
291
317
|
end
|
|
292
318
|
|
|
293
319
|
segments.join ' AND '
|
|
294
320
|
end
|
|
321
|
+
|
|
322
|
+
def file_prefix
|
|
323
|
+
@download_config.project_config.get_file_prefix
|
|
324
|
+
end
|
|
295
325
|
end
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class EstimateAccuracyChart < ChartBase
|
|
4
|
+
def initialize configuration_block
|
|
5
|
+
super()
|
|
6
|
+
|
|
7
|
+
header_text 'Estimate Accuracy'
|
|
8
|
+
description_text <<-HTML
|
|
9
|
+
<div class="p">
|
|
10
|
+
This chart graphs estimates against actual recorded cycle times. Since
|
|
11
|
+
estimates can change over time, we're graphing the estimate at the time that the story started.
|
|
12
|
+
</div>
|
|
13
|
+
<div class="p">
|
|
14
|
+
The #{color_block '--estimate-accuracy-chart-completed-fill-color'} completed dots indicate
|
|
15
|
+
cycletimes.
|
|
16
|
+
<% if @has_aging_data %>
|
|
17
|
+
The #{color_block '--estimate-accuracy-chart-active-fill-color'} aging dots
|
|
18
|
+
(click on the legend to turn them on) show the current
|
|
19
|
+
age of items, which will give you a hint as to where they might end up. If they're already
|
|
20
|
+
far to the right then you know you have a problem.
|
|
21
|
+
<% end %>
|
|
22
|
+
</div>
|
|
23
|
+
HTML
|
|
24
|
+
|
|
25
|
+
@y_axis_type = 'linear'
|
|
26
|
+
@y_axis_block = ->(issue, start_time) { estimate_at(issue: issue, start_time: start_time)&.to_f }
|
|
27
|
+
@y_axis_sort_order = nil
|
|
28
|
+
|
|
29
|
+
instance_eval(&configuration_block)
|
|
30
|
+
end
|
|
31
|
+
|
|
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
|
|
37
|
+
data_sets = scan_issues
|
|
38
|
+
|
|
39
|
+
return '' if data_sets.empty?
|
|
40
|
+
|
|
41
|
+
wrap_and_render(binding, __FILE__)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def scan_issues
|
|
45
|
+
completed_hash, aging_hash = split_into_completed_and_aging issues: issues
|
|
46
|
+
|
|
47
|
+
estimation_units = current_board.estimation_configuration.units
|
|
48
|
+
@has_aging_data = !aging_hash.empty?
|
|
49
|
+
|
|
50
|
+
[
|
|
51
|
+
[completed_hash, 'Completed', 'completed', false],
|
|
52
|
+
[aging_hash, 'Still in progress', 'active', true]
|
|
53
|
+
].filter_map do |hash, label, completed_or_active, starts_hidden|
|
|
54
|
+
fill_color = CssVariable["--estimate-accuracy-chart-#{completed_or_active}-fill-color"]
|
|
55
|
+
border_color = CssVariable["--estimate-accuracy-chart-#{completed_or_active}-border-color"]
|
|
56
|
+
|
|
57
|
+
# We sort so that the smaller circles are in front of the bigger circles.
|
|
58
|
+
data = hash.sort(&hash_sorter).collect do |key, values|
|
|
59
|
+
estimate, cycle_time = *key
|
|
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
|
+
|
|
67
|
+
{
|
|
68
|
+
'x' => cycle_time,
|
|
69
|
+
'y' => estimate,
|
|
70
|
+
'r' => values.size * 2,
|
|
71
|
+
'title' => title
|
|
72
|
+
}
|
|
73
|
+
end
|
|
74
|
+
next if data.empty?
|
|
75
|
+
|
|
76
|
+
{
|
|
77
|
+
'label' => label,
|
|
78
|
+
'data' => data,
|
|
79
|
+
'fill' => false,
|
|
80
|
+
'showLine' => false,
|
|
81
|
+
'backgroundColor' => fill_color,
|
|
82
|
+
'borderColor' => border_color,
|
|
83
|
+
'hidden' => starts_hidden
|
|
84
|
+
}
|
|
85
|
+
end
|
|
86
|
+
end
|
|
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
|
+
|
|
100
|
+
def split_into_completed_and_aging issues:
|
|
101
|
+
aging_hash = {}
|
|
102
|
+
completed_hash = {}
|
|
103
|
+
|
|
104
|
+
issues.each do |issue|
|
|
105
|
+
cycletime = issue.board.cycletime
|
|
106
|
+
start_time, stop_time = cycletime.started_stopped_times(issue)
|
|
107
|
+
|
|
108
|
+
next unless start_time
|
|
109
|
+
|
|
110
|
+
hash = stop_time ? completed_hash : aging_hash
|
|
111
|
+
|
|
112
|
+
estimate = @y_axis_block.call issue, start_time
|
|
113
|
+
cycle_time = ((stop_time&.to_date || date_range.end) - start_time.to_date).to_i + 1
|
|
114
|
+
|
|
115
|
+
next if estimate.nil?
|
|
116
|
+
|
|
117
|
+
key = [estimate, cycle_time]
|
|
118
|
+
(hash[key] ||= []) << issue
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
[completed_hash, aging_hash]
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
def hash_sorter
|
|
125
|
+
lambda do |arg1, arg2|
|
|
126
|
+
estimate1 = arg1[0][0]
|
|
127
|
+
estimate2 = arg2[0][0]
|
|
128
|
+
sample_count1 = arg1.size
|
|
129
|
+
sample_count2 = arg2.size
|
|
130
|
+
|
|
131
|
+
if @y_axis_sort_order
|
|
132
|
+
index1 = @y_axis_sort_order.index estimate1
|
|
133
|
+
index2 = @y_axis_sort_order.index estimate2
|
|
134
|
+
|
|
135
|
+
if index1.nil?
|
|
136
|
+
comparison = 1
|
|
137
|
+
elsif index2.nil?
|
|
138
|
+
comparison = -1
|
|
139
|
+
else
|
|
140
|
+
comparison = index1 <=> index2
|
|
141
|
+
end
|
|
142
|
+
return comparison unless comparison.zero?
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
sample_count2 <=> sample_count1
|
|
146
|
+
end
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
def estimate_at issue:, start_time:, estimation_configuration: current_board.estimation_configuration
|
|
150
|
+
estimate = nil
|
|
151
|
+
|
|
152
|
+
issue.changes.each do |change|
|
|
153
|
+
return estimate if change.time >= start_time
|
|
154
|
+
|
|
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
|
|
159
|
+
end
|
|
160
|
+
estimate
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def y_axis label:, sort_order: nil, &block
|
|
164
|
+
@y_axis_sort_order = sort_order
|
|
165
|
+
@y_axis_label = label
|
|
166
|
+
if sort_order
|
|
167
|
+
@y_axis_type = 'category'
|
|
168
|
+
else
|
|
169
|
+
@y_axis_type = 'linear'
|
|
170
|
+
end
|
|
171
|
+
@y_axis_block = block
|
|
172
|
+
end
|
|
173
|
+
end
|
|
@@ -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
|
|
@@ -3,24 +3,23 @@
|
|
|
3
3
|
# This file is really intended to give you ideas about how you might configure your own reports, not
|
|
4
4
|
# as a complete setup that will work in every case.
|
|
5
5
|
#
|
|
6
|
-
# See https://github.com/mikebowler/jirametrics/wiki/Examples-folder for more details
|
|
7
|
-
#
|
|
8
6
|
# The point of an AGGREGATED report is that we're now looking at a higher level. We might use this in a
|
|
9
7
|
# S2 meeting (Scrum of Scrums) to talk about the things that are happening across teams, not within a
|
|
10
8
|
# single team. For that reason, we look at slightly different things that we would on a single team board.
|
|
11
9
|
|
|
12
10
|
class Exporter
|
|
13
|
-
def aggregated_project name:, project_names:
|
|
11
|
+
def aggregated_project name:, project_names:, settings: {}
|
|
14
12
|
project name: name do
|
|
15
13
|
puts name
|
|
14
|
+
file_prefix name
|
|
15
|
+
self.settings.merge! settings
|
|
16
|
+
|
|
16
17
|
aggregate do
|
|
17
18
|
project_names.each do |project_name|
|
|
18
19
|
include_issues_from project_name
|
|
19
20
|
end
|
|
20
21
|
end
|
|
21
22
|
|
|
22
|
-
file_prefix name
|
|
23
|
-
|
|
24
23
|
file do
|
|
25
24
|
file_suffix '.html'
|
|
26
25
|
issues.reject! do |issue|
|
|
@@ -28,32 +27,27 @@ class Exporter
|
|
|
28
27
|
end
|
|
29
28
|
|
|
30
29
|
html_report do
|
|
30
|
+
html '<h1>Boards included in this report</h1><ul>', type: :header
|
|
31
|
+
board_lines = []
|
|
32
|
+
included_projects.each do |project|
|
|
33
|
+
project.all_boards.each_value do |board|
|
|
34
|
+
board_lines << "<a href='#{project.get_file_prefix}.html'>#{board.name}</a> from project #{project.name}"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
board_lines.sort.each { |line| html "<li>#{line}</li>", type: :header }
|
|
38
|
+
html '</ul>', type: :header
|
|
39
|
+
|
|
31
40
|
cycletime_scatterplot do
|
|
32
41
|
show_trend_lines
|
|
42
|
+
# For an aggregated report we group by board rather than by type
|
|
33
43
|
grouping_rules do |issue, rules|
|
|
34
44
|
rules.label = issue.board.name
|
|
35
45
|
end
|
|
36
46
|
end
|
|
37
47
|
# aging_work_in_progress_chart
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
<p>How much work is in progress, grouped by the parent of the issue. This will give us an
|
|
42
|
-
indication of how focused we are on higher level objectives. If there are many parent
|
|
43
|
-
tickets in progress at the same time, either this team has their focus scattered or we
|
|
44
|
-
aren't doing a good job of
|
|
45
|
-
<a href="https://improvingflow.com/2024/02/21/slicing-epics.html">splitting those parent
|
|
46
|
-
tickets</a>. Neither of those is desirable.</p>
|
|
47
|
-
<p>If you're expecting all work items to have parents and there are a lot that don't,
|
|
48
|
-
that's also something to look at. Consider whether there is even value in aggregating
|
|
49
|
-
these projects if they don't share parent dependencies. Aggregation helps us when we're
|
|
50
|
-
looking at related work and if there aren't parent dependencies then the work may not
|
|
51
|
-
be related.</p>
|
|
52
|
-
TEXT
|
|
53
|
-
grouping_rules do |issue, rules|
|
|
54
|
-
rules.label = issue.parent&.key || 'No parent'
|
|
55
|
-
rules.color = 'white' if rules.label == 'No parent'
|
|
56
|
-
end
|
|
48
|
+
daily_wip_by_parent_chart do
|
|
49
|
+
# When aggregating, the chart tends to need more vertical space
|
|
50
|
+
canvas height: 400, width: 800
|
|
57
51
|
end
|
|
58
52
|
aging_work_table do
|
|
59
53
|
# In an aggregated report, we likely only care about items that are old so exclude anything
|
|
@@ -67,25 +61,14 @@ class Exporter
|
|
|
67
61
|
|
|
68
62
|
# By default, the issue doesn't show what board it's on and this is important for an
|
|
69
63
|
# aggregated view
|
|
64
|
+
chart = self
|
|
70
65
|
issue_rules do |issue, rules|
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
rules.label = "<#{key} [#{issue.type}]<BR/>#{issue.board.name}<BR/>#{word_wrap issue.summary}>"
|
|
66
|
+
chart.default_issue_rules.call(issue, rules)
|
|
67
|
+
rules.label = rules.label.split('<BR/>').insert(1, "Board: #{issue.board.name}").join('<BR/>')
|
|
74
68
|
end
|
|
75
69
|
|
|
76
70
|
link_rules do |link, rules|
|
|
77
|
-
|
|
78
|
-
case link.name
|
|
79
|
-
when 'Cloners'
|
|
80
|
-
# We don't want to see any clone links at all.
|
|
81
|
-
rules.ignore
|
|
82
|
-
when 'Blocks'
|
|
83
|
-
# For blocks, by default Jira will have links going both
|
|
84
|
-
# ways and we want them only going one way. Also make the
|
|
85
|
-
# link red.
|
|
86
|
-
rules.merge_bidirectional keep: 'outward'
|
|
87
|
-
rules.line_color = 'red'
|
|
88
|
-
end
|
|
71
|
+
chart.default_link_rules.call(link, rules)
|
|
89
72
|
|
|
90
73
|
# Because this is the aggregated view, let's hide any link that doesn't cross boards.
|
|
91
74
|
rules.ignore if link.origin.board == link.other_issue.board
|