jirametrics 2.0 → 2.11
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/jirametrics/aggregate_config.rb +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 +78 -43
- data/lib/jirametrics/anonymizer.rb +6 -5
- data/lib/jirametrics/blocked_stalled_change.rb +24 -4
- data/lib/jirametrics/board.rb +44 -15
- data/lib/jirametrics/board_config.rb +8 -4
- data/lib/jirametrics/board_movement_calculator.rb +147 -0
- data/lib/jirametrics/change_item.rb +31 -10
- data/lib/jirametrics/chart_base.rb +102 -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_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 +76 -57
- data/lib/jirametrics/{story_point_accuracy_chart.rb → estimate_accuracy_chart.rb} +48 -33
- data/lib/jirametrics/examples/aggregated_project.rb +22 -39
- data/lib/jirametrics/examples/standard_project.rb +25 -49
- data/lib/jirametrics/expedited_chart.rb +28 -25
- data/lib/jirametrics/exporter.rb +59 -32
- data/lib/jirametrics/file_config.rb +34 -13
- 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 +19 -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 +209 -0
- data/lib/jirametrics/html/index.erb +16 -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 +108 -86
- data/lib/jirametrics/issue.rb +357 -96
- data/lib/jirametrics/jira_gateway.rb +29 -11
- data/lib/jirametrics/project_config.rb +256 -144
- data/lib/jirametrics/rules.rb +2 -2
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +10 -0
- data/lib/jirametrics/sprint_burndown.rb +24 -7
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +80 -39
- data/lib/jirametrics/throughput_chart.rb +12 -4
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics.rb +25 -7
- metadata +16 -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
|
@@ -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,10 +39,11 @@ 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
|
|
47
48
|
|
|
48
49
|
save_metadata
|
|
@@ -54,8 +55,7 @@ class Downloader
|
|
|
54
55
|
end
|
|
55
56
|
|
|
56
57
|
def log text, both: false
|
|
57
|
-
@file_system.log text
|
|
58
|
-
puts text if both
|
|
58
|
+
@file_system.log text, also_write_to_stderr: both
|
|
59
59
|
end
|
|
60
60
|
|
|
61
61
|
def find_board_ids
|
|
@@ -65,19 +65,19 @@ class Downloader
|
|
|
65
65
|
ids
|
|
66
66
|
end
|
|
67
67
|
|
|
68
|
-
def download_issues
|
|
69
|
-
log " Downloading primary issues for board #{
|
|
70
|
-
path =
|
|
68
|
+
def download_issues board:
|
|
69
|
+
log " Downloading primary issues for board #{board.id}", both: true
|
|
70
|
+
path = File.join(@target_path, "#{file_prefix}_issues/")
|
|
71
71
|
unless Dir.exist?(path)
|
|
72
72
|
log " Creating path #{path}"
|
|
73
73
|
Dir.mkdir(path)
|
|
74
74
|
end
|
|
75
75
|
|
|
76
|
-
filter_id = @board_id_to_filter_id[
|
|
76
|
+
filter_id = @board_id_to_filter_id[board.id]
|
|
77
77
|
jql = make_jql(filter_id: filter_id)
|
|
78
|
-
jira_search_by_jql(jql: jql, initial_query: true,
|
|
78
|
+
jira_search_by_jql(jql: jql, initial_query: true, board: board, path: path)
|
|
79
79
|
|
|
80
|
-
log " Downloading linked issues for board #{
|
|
80
|
+
log " Downloading linked issues for board #{board.id}", both: true
|
|
81
81
|
loop do
|
|
82
82
|
@issue_keys_pending_download.reject! { |key| @issue_keys_downloaded_in_current_run.include? key }
|
|
83
83
|
break if @issue_keys_pending_download.empty?
|
|
@@ -85,15 +85,15 @@ class Downloader
|
|
|
85
85
|
keys_to_request = @issue_keys_pending_download[0..99]
|
|
86
86
|
@issue_keys_pending_download.reject! { |key| keys_to_request.include? key }
|
|
87
87
|
jql = "key in (#{keys_to_request.join(', ')})"
|
|
88
|
-
jira_search_by_jql(jql: jql, initial_query: false,
|
|
88
|
+
jira_search_by_jql(jql: jql, initial_query: false, board: board, path: path)
|
|
89
89
|
end
|
|
90
90
|
end
|
|
91
91
|
|
|
92
|
-
def jira_search_by_jql jql:, initial_query:,
|
|
92
|
+
def jira_search_by_jql jql:, initial_query:, board:, path:
|
|
93
93
|
intercept_jql = @download_config.project_config.settings['intercept_jql']
|
|
94
94
|
jql = intercept_jql.call jql if intercept_jql
|
|
95
95
|
|
|
96
|
-
log " #{jql}"
|
|
96
|
+
log " JQL: #{jql}"
|
|
97
97
|
escaped_jql = CGI.escape jql
|
|
98
98
|
|
|
99
99
|
max_results = 100
|
|
@@ -103,14 +103,12 @@ class Downloader
|
|
|
103
103
|
json = @jira_gateway.call_url relative_url: '/rest/api/2/search' \
|
|
104
104
|
"?jql=#{escaped_jql}&maxResults=#{max_results}&startAt=#{start_at}&expand=changelog&fields=*all"
|
|
105
105
|
|
|
106
|
-
exit_if_call_failed json
|
|
107
|
-
|
|
108
106
|
json['issues'].each do |issue_json|
|
|
109
107
|
issue_json['exporter'] = {
|
|
110
108
|
'in_initial_query' => initial_query
|
|
111
109
|
}
|
|
112
|
-
identify_other_issues_to_be_downloaded issue_json
|
|
113
|
-
file = "#{issue_json['key']}-#{
|
|
110
|
+
identify_other_issues_to_be_downloaded raw_issue: issue_json, board: board
|
|
111
|
+
file = "#{issue_json['key']}-#{board.id}.json"
|
|
114
112
|
|
|
115
113
|
@file_system.save_json(json: issue_json, filename: File.join(path, file))
|
|
116
114
|
end
|
|
@@ -125,8 +123,8 @@ class Downloader
|
|
|
125
123
|
end
|
|
126
124
|
end
|
|
127
125
|
|
|
128
|
-
def identify_other_issues_to_be_downloaded raw_issue
|
|
129
|
-
issue = Issue.new raw: raw_issue, board:
|
|
126
|
+
def identify_other_issues_to_be_downloaded raw_issue:, board:
|
|
127
|
+
issue = Issue.new raw: raw_issue, board: board
|
|
130
128
|
@issue_keys_downloaded_in_current_run << issue.key
|
|
131
129
|
|
|
132
130
|
# Parent
|
|
@@ -137,51 +135,64 @@ class Downloader
|
|
|
137
135
|
issue.raw['fields']['subtasks']&.each do |raw_subtask|
|
|
138
136
|
@issue_keys_pending_download << raw_subtask['key']
|
|
139
137
|
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
|
-
end
|
|
148
|
-
|
|
149
|
-
def exit_if_call_failed json
|
|
150
|
-
# Sometimes Jira returns the singular form of errorMessage and sometimes the plural. Consistency FTW.
|
|
151
|
-
return unless json['errorMessages'] || json['errorMessage']
|
|
152
|
-
|
|
153
|
-
log "Download failed. See #{@logfile_name} for details.", both: true
|
|
154
|
-
log " #{JSON.pretty_generate(json)}"
|
|
155
|
-
exit 1
|
|
156
138
|
end
|
|
157
139
|
|
|
158
140
|
def download_statuses
|
|
159
141
|
log ' Downloading all statuses', both: true
|
|
160
|
-
json = @jira_gateway.call_url relative_url:
|
|
142
|
+
json = @jira_gateway.call_url relative_url: '/rest/api/2/status'
|
|
161
143
|
|
|
162
144
|
@file_system.save_json(
|
|
163
|
-
json: json,
|
|
164
|
-
filename:
|
|
145
|
+
json: json,
|
|
146
|
+
filename: File.join(@target_path, "#{file_prefix}_statuses.json")
|
|
165
147
|
)
|
|
166
148
|
end
|
|
167
149
|
|
|
150
|
+
def update_status_history_file
|
|
151
|
+
status_filename = File.join(@target_path, "#{file_prefix}_statuses.json")
|
|
152
|
+
return unless file_system.file_exist? status_filename
|
|
153
|
+
|
|
154
|
+
status_json = file_system.load_json(status_filename)
|
|
155
|
+
|
|
156
|
+
history_filename = File.join(@target_path, "#{file_prefix}_status_history.json")
|
|
157
|
+
history_json = file_system.load_json(history_filename) if file_system.file_exist? history_filename
|
|
158
|
+
|
|
159
|
+
if history_json
|
|
160
|
+
file_system.log ' Updating status history file', also_write_to_stderr: true
|
|
161
|
+
else
|
|
162
|
+
file_system.log ' Creating status history file', also_write_to_stderr: true
|
|
163
|
+
history_json = []
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
status_json.each do |status_item|
|
|
167
|
+
id = status_item['id']
|
|
168
|
+
history_item = history_json.find { |s| s['id'] == id }
|
|
169
|
+
history_json.delete(history_item) if history_item
|
|
170
|
+
history_json << status_item
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
file_system.save_json(filename: history_filename, json: history_json)
|
|
174
|
+
end
|
|
175
|
+
|
|
168
176
|
def download_board_configuration board_id:
|
|
169
177
|
log " Downloading board configuration for board #{board_id}", both: true
|
|
170
178
|
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/configuration"
|
|
171
|
-
exit_if_call_failed json
|
|
172
179
|
|
|
173
|
-
@
|
|
174
|
-
|
|
180
|
+
@file_system.save_json(
|
|
181
|
+
json: json,
|
|
182
|
+
filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
|
|
183
|
+
)
|
|
175
184
|
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
# We have a reported bug that blew up on this line. Moved it after the save so we can
|
|
186
|
+
# actually look at the returned json.
|
|
187
|
+
@board_id_to_filter_id[board_id] = json['filter']['id'].to_i
|
|
178
188
|
|
|
179
189
|
download_sprints board_id: board_id if json['type'] == 'scrum'
|
|
190
|
+
# TODO: Should be passing actual statuses, not empty list
|
|
191
|
+
Board.new raw: json, possible_statuses: StatusCollection.new
|
|
180
192
|
end
|
|
181
193
|
|
|
182
194
|
def download_sprints board_id:
|
|
183
195
|
log " Downloading sprints for board #{board_id}", both: true
|
|
184
|
-
file_prefix = @download_config.project_config.file_prefix
|
|
185
196
|
max_results = 100
|
|
186
197
|
start_at = 0
|
|
187
198
|
is_last = false
|
|
@@ -189,20 +200,23 @@ class Downloader
|
|
|
189
200
|
while is_last == false
|
|
190
201
|
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/sprint?" \
|
|
191
202
|
"maxResults=#{max_results}&startAt=#{start_at}"
|
|
192
|
-
exit_if_call_failed json
|
|
193
203
|
|
|
194
204
|
@file_system.save_json(
|
|
195
205
|
json: json,
|
|
196
|
-
filename:
|
|
206
|
+
filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json")
|
|
197
207
|
)
|
|
198
208
|
is_last = json['isLast']
|
|
199
209
|
max_results = json['maxResults']
|
|
200
|
-
|
|
210
|
+
if json['values']
|
|
211
|
+
start_at += json['values'].size
|
|
212
|
+
else
|
|
213
|
+
log " No sprints found for board #{board_id}"
|
|
214
|
+
end
|
|
201
215
|
end
|
|
202
216
|
end
|
|
203
217
|
|
|
204
218
|
def metadata_pathname
|
|
205
|
-
|
|
219
|
+
File.join(@target_path, "#{file_prefix}_meta.json")
|
|
206
220
|
end
|
|
207
221
|
|
|
208
222
|
def load_metadata
|
|
@@ -245,17 +259,17 @@ class Downloader
|
|
|
245
259
|
end
|
|
246
260
|
|
|
247
261
|
def remove_old_files
|
|
248
|
-
file_prefix = @download_config.project_config.file_prefix
|
|
249
262
|
Dir.foreach @target_path do |file|
|
|
250
263
|
next unless file.match?(/^#{file_prefix}_\d+\.json$/)
|
|
264
|
+
next if file == "#{file_prefix}_status_history.json"
|
|
251
265
|
|
|
252
|
-
File.unlink
|
|
266
|
+
File.unlink File.join(@target_path, file)
|
|
253
267
|
end
|
|
254
268
|
|
|
255
269
|
return if @cached_data_format_is_current
|
|
256
270
|
|
|
257
271
|
# Also throw away all the previously downloaded issues.
|
|
258
|
-
path = File.join
|
|
272
|
+
path = File.join(@target_path, "#{file_prefix}_issues")
|
|
259
273
|
return unless File.exist? path
|
|
260
274
|
|
|
261
275
|
Dir.foreach path do |file|
|
|
@@ -269,8 +283,10 @@ class Downloader
|
|
|
269
283
|
segments = []
|
|
270
284
|
segments << "filter=#{filter_id}"
|
|
271
285
|
|
|
272
|
-
|
|
273
|
-
|
|
286
|
+
start_date = @download_config.start_date today: today
|
|
287
|
+
|
|
288
|
+
if start_date
|
|
289
|
+
@download_date_range = start_date..today.to_date
|
|
274
290
|
|
|
275
291
|
# For an incremental download, we want to query from the end of the previous one, not from the
|
|
276
292
|
# beginning of the full range.
|
|
@@ -283,13 +299,16 @@ class Downloader
|
|
|
283
299
|
|
|
284
300
|
# Pick up any issues that had a status change in the range
|
|
285
301
|
start_date_text = @start_date_in_query.strftime '%Y-%m-%d'
|
|
286
|
-
end_date_text = today.strftime '%Y-%m-%d'
|
|
287
302
|
# find_in_range = %((status changed DURING ("#{start_date_text} 00:00","#{end_date_text} 23:59")))
|
|
288
|
-
find_in_range = %(
|
|
303
|
+
find_in_range = %(updated >= "#{start_date_text} 00:00")
|
|
289
304
|
|
|
290
305
|
segments << "(#{find_in_range} OR #{catch_all})"
|
|
291
306
|
end
|
|
292
307
|
|
|
293
308
|
segments.join ' AND '
|
|
294
309
|
end
|
|
310
|
+
|
|
311
|
+
def file_prefix
|
|
312
|
+
@download_config.project_config.get_file_prefix
|
|
313
|
+
end
|
|
295
314
|
end
|
|
@@ -1,20 +1,25 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class
|
|
4
|
-
def initialize configuration_block
|
|
3
|
+
class EstimateAccuracyChart < ChartBase
|
|
4
|
+
def initialize configuration_block
|
|
5
5
|
super()
|
|
6
6
|
|
|
7
7
|
header_text 'Estimate Accuracy'
|
|
8
8
|
description_text <<-HTML
|
|
9
|
-
<p>
|
|
9
|
+
<div class="p">
|
|
10
10
|
This chart graphs estimates against actual recorded cycle times. Since
|
|
11
11
|
estimates can change over time, we're graphing the estimate at the time that the story started.
|
|
12
|
-
</
|
|
13
|
-
<p>
|
|
14
|
-
The completed dots indicate
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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>
|
|
18
23
|
HTML
|
|
19
24
|
|
|
20
25
|
@y_axis_label = 'Story Point Estimates'
|
|
@@ -22,7 +27,7 @@ class StoryPointAccuracyChart < ChartBase
|
|
|
22
27
|
@y_axis_block = ->(issue, start_time) { story_points_at(issue: issue, start_time: start_time)&.to_f }
|
|
23
28
|
@y_axis_sort_order = nil
|
|
24
29
|
|
|
25
|
-
instance_eval(&configuration_block)
|
|
30
|
+
instance_eval(&configuration_block)
|
|
26
31
|
end
|
|
27
32
|
|
|
28
33
|
def run
|
|
@@ -34,31 +39,17 @@ class StoryPointAccuracyChart < ChartBase
|
|
|
34
39
|
end
|
|
35
40
|
|
|
36
41
|
def scan_issues
|
|
37
|
-
aging_hash =
|
|
38
|
-
completed_hash = {}
|
|
39
|
-
|
|
40
|
-
issues.each do |issue|
|
|
41
|
-
cycletime = issue.board.cycletime
|
|
42
|
-
start_time = cycletime.started_time(issue)
|
|
43
|
-
stop_time = cycletime.stopped_time(issue)
|
|
44
|
-
|
|
45
|
-
next unless start_time
|
|
46
|
-
|
|
47
|
-
hash = stop_time ? completed_hash : aging_hash
|
|
48
|
-
|
|
49
|
-
estimate = @y_axis_block.call issue, start_time
|
|
50
|
-
cycle_time = ((stop_time&.to_date || date_range.end) - start_time.to_date).to_i + 1
|
|
42
|
+
completed_hash, aging_hash = split_into_completed_and_aging issues: issues
|
|
51
43
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
key = [estimate, cycle_time]
|
|
55
|
-
(hash[key] ||= []) << issue
|
|
56
|
-
end
|
|
44
|
+
@has_aging_data = !aging_hash.empty?
|
|
57
45
|
|
|
58
46
|
[
|
|
59
|
-
[completed_hash, 'Completed', '
|
|
60
|
-
[aging_hash, 'Still in progress', '
|
|
61
|
-
].filter_map do |hash, label,
|
|
47
|
+
[completed_hash, 'Completed', 'completed', false],
|
|
48
|
+
[aging_hash, 'Still in progress', 'active', true]
|
|
49
|
+
].filter_map do |hash, label, completed_or_active, starts_hidden|
|
|
50
|
+
fill_color = CssVariable["--estimate-accuracy-chart-#{completed_or_active}-fill-color"]
|
|
51
|
+
border_color = CssVariable["--estimate-accuracy-chart-#{completed_or_active}-border-color"]
|
|
52
|
+
|
|
62
53
|
# We sort so that the smaller circles are in front of the bigger circles.
|
|
63
54
|
data = hash.sort(&hash_sorter).collect do |key, values|
|
|
64
55
|
estimate, cycle_time = *key
|
|
@@ -83,7 +74,31 @@ class StoryPointAccuracyChart < ChartBase
|
|
|
83
74
|
'borderColor' => border_color,
|
|
84
75
|
'hidden' => starts_hidden
|
|
85
76
|
}
|
|
86
|
-
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def split_into_completed_and_aging issues:
|
|
81
|
+
aging_hash = {}
|
|
82
|
+
completed_hash = {}
|
|
83
|
+
|
|
84
|
+
issues.each do |issue|
|
|
85
|
+
cycletime = issue.board.cycletime
|
|
86
|
+
start_time, stop_time = cycletime.started_stopped_times(issue)
|
|
87
|
+
|
|
88
|
+
next unless start_time
|
|
89
|
+
|
|
90
|
+
hash = stop_time ? completed_hash : aging_hash
|
|
91
|
+
|
|
92
|
+
estimate = @y_axis_block.call issue, start_time
|
|
93
|
+
cycle_time = ((stop_time&.to_date || date_range.end) - start_time.to_date).to_i + 1
|
|
94
|
+
|
|
95
|
+
next if estimate.nil?
|
|
96
|
+
|
|
97
|
+
key = [estimate, cycle_time]
|
|
98
|
+
(hash[key] ||= []) << issue
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
[completed_hash, aging_hash]
|
|
87
102
|
end
|
|
88
103
|
|
|
89
104
|
def hash_sorter
|
|
@@ -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
|
|
@@ -2,41 +2,49 @@
|
|
|
2
2
|
|
|
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
|
-
#
|
|
6
|
-
# See https://github.com/mikebowler/jirametrics/wiki/Examples-folder for more
|
|
7
5
|
class Exporter
|
|
8
6
|
def standard_project name:, file_prefix:, ignore_issues: nil, starting_status: nil, boards: {},
|
|
9
|
-
default_board: nil, anonymize: false
|
|
7
|
+
default_board: nil, anonymize: false, settings: {}, status_category_mappings: {},
|
|
8
|
+
rolling_date_count: 90, no_earlier_than: nil, ignore_types: %w[Sub-task Subtask Epic],
|
|
9
|
+
show_experimental_charts: false
|
|
10
10
|
|
|
11
11
|
project name: name do
|
|
12
12
|
puts name
|
|
13
|
+
file_prefix file_prefix
|
|
14
|
+
|
|
13
15
|
self.anonymize if anonymize
|
|
16
|
+
self.settings.merge! settings
|
|
17
|
+
|
|
18
|
+
status_category_mappings.each do |status, category|
|
|
19
|
+
status_category_mapping status: status, category: category
|
|
20
|
+
end
|
|
14
21
|
|
|
15
|
-
settings['blocked_link_text'] = ['is blocked by']
|
|
16
|
-
file_prefix file_prefix
|
|
17
22
|
download do
|
|
18
|
-
rolling_date_count
|
|
23
|
+
self.rolling_date_count(rolling_date_count) if rolling_date_count
|
|
24
|
+
self.no_earlier_than(no_earlier_than) if no_earlier_than
|
|
19
25
|
end
|
|
20
26
|
|
|
21
27
|
boards.each_key do |board_id|
|
|
22
28
|
block = boards[board_id]
|
|
23
29
|
if block == :default
|
|
24
30
|
block = lambda do |_|
|
|
25
|
-
start_at first_time_in_status_category(
|
|
26
|
-
stop_at still_in_status_category(
|
|
31
|
+
start_at first_time_in_status_category(:indeterminate)
|
|
32
|
+
stop_at still_in_status_category(:done)
|
|
27
33
|
end
|
|
28
34
|
end
|
|
29
35
|
board id: board_id do
|
|
30
36
|
cycletime(&block)
|
|
31
|
-
expedited_priority_names 'Critical', 'Highest', 'Immediate Gating'
|
|
32
37
|
end
|
|
33
38
|
end
|
|
34
39
|
|
|
40
|
+
issues.reject! do |issue|
|
|
41
|
+
ignore_types.include? issue.type
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
|
|
45
|
+
|
|
35
46
|
file do
|
|
36
47
|
file_suffix '.html'
|
|
37
|
-
issues.reject! do |issue|
|
|
38
|
-
%w[Sub-task Epic].include? issue.type
|
|
39
|
-
end
|
|
40
48
|
|
|
41
49
|
issues.reject! { |issue| ignore_issues.include? issue.key } if ignore_issues
|
|
42
50
|
|
|
@@ -46,12 +54,10 @@ class Exporter
|
|
|
46
54
|
html "<H1>#{name}</H1>", type: :header
|
|
47
55
|
boards.each_key do |id|
|
|
48
56
|
board = find_board id
|
|
49
|
-
html "<div><a href='#{board.url}'>#{id} #{board.name}</a
|
|
57
|
+
html "<div><a href='#{board.url}'>#{id} #{board.name}</a> (#{board.board_type})</div>",
|
|
50
58
|
type: :header
|
|
51
59
|
end
|
|
52
60
|
|
|
53
|
-
discard_changes_before status_becomes: (starting_status || :backlog)
|
|
54
|
-
|
|
55
61
|
cycletime_scatterplot do
|
|
56
62
|
show_trend_lines
|
|
57
63
|
end
|
|
@@ -77,42 +83,12 @@ class Exporter
|
|
|
77
83
|
aging_work_table
|
|
78
84
|
daily_wip_by_age_chart
|
|
79
85
|
daily_wip_by_blocked_stalled_chart
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
description_text <<-TEXT
|
|
83
|
-
How much work is in progress, grouped by the parent of the issue. This will give us an
|
|
84
|
-
indication of how focused we are on higher level objectives. If there are many parent
|
|
85
|
-
tickets in progress at the same time, either this team has their focus scattered or we
|
|
86
|
-
aren't doing a good job of
|
|
87
|
-
<a href="https://improvingflow.com/2024/02/21/slicing-epics.html">splitting those parent
|
|
88
|
-
tickets</a>. Neither of those is desirable.
|
|
89
|
-
TEXT
|
|
90
|
-
grouping_rules do |issue, rules|
|
|
91
|
-
rules.label = issue.parent&.key || 'No parent'
|
|
92
|
-
rules.color = 'white' if rules.label == 'No parent'
|
|
93
|
-
end
|
|
94
|
-
end
|
|
86
|
+
daily_wip_by_parent_chart
|
|
87
|
+
flow_efficiency_scatterplot if show_experimental_charts
|
|
95
88
|
expedited_chart
|
|
96
89
|
sprint_burndown
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
dependency_chart do
|
|
100
|
-
link_rules do |link, rules|
|
|
101
|
-
case link.name
|
|
102
|
-
when 'Cloners'
|
|
103
|
-
rules.ignore
|
|
104
|
-
when 'Dependency', 'Blocks', 'Parent/Child', 'Cause', 'Satisfy Requirement', 'Relates'
|
|
105
|
-
rules.merge_bidirectional keep: 'outward'
|
|
106
|
-
rules.merge_bidirectional keep: 'outward'
|
|
107
|
-
when 'Sync'
|
|
108
|
-
rules.use_bidirectional_arrows
|
|
109
|
-
else
|
|
110
|
-
# This is a link type that we don't recognized. Dump it to standard out to draw attention
|
|
111
|
-
# to it.
|
|
112
|
-
puts "name=#{link.name}, label=#{link.label}"
|
|
113
|
-
end
|
|
114
|
-
end
|
|
115
|
-
end
|
|
90
|
+
estimate_accuracy_chart
|
|
91
|
+
dependency_chart
|
|
116
92
|
end
|
|
117
93
|
end
|
|
118
94
|
end
|