jirametrics 2.4 → 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 +9 -4
- data/lib/jirametrics/aging_work_bar_chart.rb +13 -11
- data/lib/jirametrics/aging_work_in_progress_chart.rb +105 -41
- data/lib/jirametrics/aging_work_table.rb +54 -7
- data/lib/jirametrics/blocked_stalled_change.rb +1 -1
- data/lib/jirametrics/board.rb +44 -15
- data/lib/jirametrics/board_config.rb +7 -3
- data/lib/jirametrics/board_movement_calculator.rb +147 -0
- data/lib/jirametrics/change_item.rb +19 -6
- data/lib/jirametrics/chart_base.rb +63 -27
- data/lib/jirametrics/css_variable.rb +1 -1
- data/lib/jirametrics/cycletime_config.rb +59 -8
- data/lib/jirametrics/cycletime_histogram.rb +68 -3
- data/lib/jirametrics/cycletime_scatterplot.rb +3 -6
- data/lib/jirametrics/daily_wip_by_age_chart.rb +2 -4
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +2 -2
- data/lib/jirametrics/daily_wip_by_parent_chart.rb +0 -4
- data/lib/jirametrics/daily_wip_chart.rb +7 -9
- data/lib/jirametrics/data_quality_report.rb +219 -41
- data/lib/jirametrics/dependency_chart.rb +37 -10
- data/lib/jirametrics/download_config.rb +12 -0
- data/lib/jirametrics/downloader.rb +68 -50
- data/lib/jirametrics/estimate_accuracy_chart.rb +1 -2
- data/lib/jirametrics/examples/aggregated_project.rb +7 -21
- data/lib/jirametrics/examples/standard_project.rb +18 -34
- data/lib/jirametrics/expedited_chart.rb +8 -9
- data/lib/jirametrics/exporter.rb +28 -11
- data/lib/jirametrics/file_config.rb +23 -6
- data/lib/jirametrics/file_system.rb +39 -3
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +111 -0
- data/lib/jirametrics/groupable_issue_chart.rb +1 -3
- data/lib/jirametrics/html/aging_work_bar_chart.erb +3 -12
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +22 -5
- data/lib/jirametrics/html/aging_work_table.erb +6 -4
- data/lib/jirametrics/html/cycletime_histogram.erb +74 -0
- data/lib/jirametrics/html/cycletime_scatterplot.erb +1 -10
- data/lib/jirametrics/html/daily_wip_chart.erb +1 -10
- data/lib/jirametrics/html/expedited_chart.erb +1 -10
- data/lib/jirametrics/html/flow_efficiency_scatterplot.erb +85 -0
- data/lib/jirametrics/html/hierarchy_table.erb +1 -1
- data/lib/jirametrics/html/index.css +28 -5
- data/lib/jirametrics/html/index.erb +8 -4
- data/lib/jirametrics/html/sprint_burndown.erb +1 -10
- data/lib/jirametrics/html/throughput_chart.erb +1 -10
- data/lib/jirametrics/html_report_config.rb +33 -23
- data/lib/jirametrics/issue.rb +232 -47
- data/lib/jirametrics/jira_gateway.rb +16 -3
- data/lib/jirametrics/project_config.rb +245 -134
- data/lib/jirametrics/rules.rb +2 -2
- data/lib/jirametrics/self_or_issue_dispatcher.rb +2 -0
- data/lib/jirametrics/settings.json +5 -2
- data/lib/jirametrics/sprint_burndown.rb +3 -3
- data/lib/jirametrics/status.rb +84 -19
- data/lib/jirametrics/status_collection.rb +77 -39
- data/lib/jirametrics/throughput_chart.rb +1 -1
- data/lib/jirametrics/value_equality.rb +2 -2
- data/lib/jirametrics.rb +22 -6
- metadata +10 -13
- data/lib/jirametrics/discard_changes_before.rb +0 -37
- data/lib/jirametrics/html/data_quality_report.erb +0 -126
|
@@ -6,7 +6,7 @@ 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
|
|
@@ -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 && !@quiet_mode
|
|
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,11 +85,11 @@ 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
|
|
|
@@ -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,22 +135,6 @@ 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['error'] || json['errorMessages'] || json['errorMessage']
|
|
152
|
-
|
|
153
|
-
log "Download failed. See #{@file_system.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
|
|
@@ -161,29 +143,56 @@ class Downloader
|
|
|
161
143
|
|
|
162
144
|
@file_system.save_json(
|
|
163
145
|
json: json,
|
|
164
|
-
filename:
|
|
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
179
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
180
|
+
@file_system.save_json(
|
|
181
|
+
json: json,
|
|
182
|
+
filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_configuration.json")
|
|
183
|
+
)
|
|
176
184
|
|
|
177
185
|
# We have a reported bug that blew up on this line. Moved it after the save so we can
|
|
178
186
|
# actually look at the returned json.
|
|
179
187
|
@board_id_to_filter_id[board_id] = json['filter']['id'].to_i
|
|
180
188
|
|
|
181
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
|
|
182
192
|
end
|
|
183
193
|
|
|
184
194
|
def download_sprints board_id:
|
|
185
195
|
log " Downloading sprints for board #{board_id}", both: true
|
|
186
|
-
file_prefix = @download_config.project_config.file_prefix
|
|
187
196
|
max_results = 100
|
|
188
197
|
start_at = 0
|
|
189
198
|
is_last = false
|
|
@@ -191,20 +200,23 @@ class Downloader
|
|
|
191
200
|
while is_last == false
|
|
192
201
|
json = @jira_gateway.call_url relative_url: "/rest/agile/1.0/board/#{board_id}/sprint?" \
|
|
193
202
|
"maxResults=#{max_results}&startAt=#{start_at}"
|
|
194
|
-
exit_if_call_failed json
|
|
195
203
|
|
|
196
204
|
@file_system.save_json(
|
|
197
205
|
json: json,
|
|
198
|
-
filename:
|
|
206
|
+
filename: File.join(@target_path, "#{file_prefix}_board_#{board_id}_sprints_#{start_at}.json")
|
|
199
207
|
)
|
|
200
208
|
is_last = json['isLast']
|
|
201
209
|
max_results = json['maxResults']
|
|
202
|
-
|
|
210
|
+
if json['values']
|
|
211
|
+
start_at += json['values'].size
|
|
212
|
+
else
|
|
213
|
+
log " No sprints found for board #{board_id}"
|
|
214
|
+
end
|
|
203
215
|
end
|
|
204
216
|
end
|
|
205
217
|
|
|
206
218
|
def metadata_pathname
|
|
207
|
-
|
|
219
|
+
File.join(@target_path, "#{file_prefix}_meta.json")
|
|
208
220
|
end
|
|
209
221
|
|
|
210
222
|
def load_metadata
|
|
@@ -247,17 +259,17 @@ class Downloader
|
|
|
247
259
|
end
|
|
248
260
|
|
|
249
261
|
def remove_old_files
|
|
250
|
-
file_prefix = @download_config.project_config.file_prefix
|
|
251
262
|
Dir.foreach @target_path do |file|
|
|
252
263
|
next unless file.match?(/^#{file_prefix}_\d+\.json$/)
|
|
264
|
+
next if file == "#{file_prefix}_status_history.json"
|
|
253
265
|
|
|
254
|
-
File.unlink
|
|
266
|
+
File.unlink File.join(@target_path, file)
|
|
255
267
|
end
|
|
256
268
|
|
|
257
269
|
return if @cached_data_format_is_current
|
|
258
270
|
|
|
259
271
|
# Also throw away all the previously downloaded issues.
|
|
260
|
-
path = File.join
|
|
272
|
+
path = File.join(@target_path, "#{file_prefix}_issues")
|
|
261
273
|
return unless File.exist? path
|
|
262
274
|
|
|
263
275
|
Dir.foreach path do |file|
|
|
@@ -271,8 +283,10 @@ class Downloader
|
|
|
271
283
|
segments = []
|
|
272
284
|
segments << "filter=#{filter_id}"
|
|
273
285
|
|
|
274
|
-
|
|
275
|
-
|
|
286
|
+
start_date = @download_config.start_date today: today
|
|
287
|
+
|
|
288
|
+
if start_date
|
|
289
|
+
@download_date_range = start_date..today.to_date
|
|
276
290
|
|
|
277
291
|
# For an incremental download, we want to query from the end of the previous one, not from the
|
|
278
292
|
# beginning of the full range.
|
|
@@ -293,4 +307,8 @@ class Downloader
|
|
|
293
307
|
|
|
294
308
|
segments.join ' AND '
|
|
295
309
|
end
|
|
310
|
+
|
|
311
|
+
def file_prefix
|
|
312
|
+
@download_config.project_config.get_file_prefix
|
|
313
|
+
end
|
|
296
314
|
end
|
|
@@ -83,8 +83,7 @@ class EstimateAccuracyChart < ChartBase
|
|
|
83
83
|
|
|
84
84
|
issues.each do |issue|
|
|
85
85
|
cycletime = issue.board.cycletime
|
|
86
|
-
start_time = cycletime.
|
|
87
|
-
stop_time = cycletime.stopped_time(issue)
|
|
86
|
+
start_time, stop_time = cycletime.started_stopped_times(issue)
|
|
88
87
|
|
|
89
88
|
next unless start_time
|
|
90
89
|
|
|
@@ -3,8 +3,6 @@
|
|
|
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.
|
|
@@ -13,6 +11,7 @@ class Exporter
|
|
|
13
11
|
def aggregated_project name:, project_names:, settings: {}
|
|
14
12
|
project name: name do
|
|
15
13
|
puts name
|
|
14
|
+
file_prefix name
|
|
16
15
|
self.settings.merge! settings
|
|
17
16
|
|
|
18
17
|
aggregate do
|
|
@@ -21,8 +20,6 @@ class Exporter
|
|
|
21
20
|
end
|
|
22
21
|
end
|
|
23
22
|
|
|
24
|
-
file_prefix name
|
|
25
|
-
|
|
26
23
|
file do
|
|
27
24
|
file_suffix '.html'
|
|
28
25
|
issues.reject! do |issue|
|
|
@@ -33,8 +30,8 @@ class Exporter
|
|
|
33
30
|
html '<h1>Boards included in this report</h1><ul>', type: :header
|
|
34
31
|
board_lines = []
|
|
35
32
|
included_projects.each do |project|
|
|
36
|
-
project.all_boards.
|
|
37
|
-
board_lines << "<a href='#{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}"
|
|
38
35
|
end
|
|
39
36
|
end
|
|
40
37
|
board_lines.sort.each { |line| html "<li>#{line}</li>", type: :header }
|
|
@@ -64,25 +61,14 @@ class Exporter
|
|
|
64
61
|
|
|
65
62
|
# By default, the issue doesn't show what board it's on and this is important for an
|
|
66
63
|
# aggregated view
|
|
64
|
+
chart = self
|
|
67
65
|
issue_rules do |issue, rules|
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
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/>')
|
|
71
68
|
end
|
|
72
69
|
|
|
73
70
|
link_rules do |link, rules|
|
|
74
|
-
|
|
75
|
-
case link.name
|
|
76
|
-
when 'Cloners'
|
|
77
|
-
# We don't want to see any clone links at all.
|
|
78
|
-
rules.ignore
|
|
79
|
-
when 'Blocks'
|
|
80
|
-
# For blocks, by default Jira will have links going both
|
|
81
|
-
# ways and we want them only going one way. Also make the
|
|
82
|
-
# link red.
|
|
83
|
-
rules.merge_bidirectional keep: 'outward'
|
|
84
|
-
rules.line_color = 'red'
|
|
85
|
-
end
|
|
71
|
+
chart.default_link_rules.call(link, rules)
|
|
86
72
|
|
|
87
73
|
# Because this is the aggregated view, let's hide any link that doesn't cross boards.
|
|
88
74
|
rules.ignore if link.origin.board == link.other_issue.board
|
|
@@ -2,47 +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, settings: {}, status_category_mappings: {}
|
|
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
|
-
|
|
13
|
+
file_prefix file_prefix
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
self.anonymize if anonymize
|
|
16
16
|
self.settings.merge! settings
|
|
17
17
|
|
|
18
18
|
status_category_mappings.each do |status, category|
|
|
19
19
|
status_category_mapping status: status, category: category
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
file_prefix file_prefix
|
|
23
22
|
download do
|
|
24
|
-
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
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
boards.each_key do |board_id|
|
|
28
28
|
block = boards[board_id]
|
|
29
29
|
if block == :default
|
|
30
30
|
block = lambda do |_|
|
|
31
|
-
start_at first_time_in_status_category(
|
|
32
|
-
stop_at still_in_status_category(
|
|
31
|
+
start_at first_time_in_status_category(:indeterminate)
|
|
32
|
+
stop_at still_in_status_category(:done)
|
|
33
33
|
end
|
|
34
34
|
end
|
|
35
35
|
board id: board_id do
|
|
36
36
|
cycletime(&block)
|
|
37
|
-
expedited_priority_names 'Critical', 'Highest', 'Immediate Gating'
|
|
38
37
|
end
|
|
39
38
|
end
|
|
40
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
|
+
|
|
41
46
|
file do
|
|
42
47
|
file_suffix '.html'
|
|
43
|
-
issues.reject! do |issue|
|
|
44
|
-
%w[Sub-task Epic].include? issue.type
|
|
45
|
-
end
|
|
46
48
|
|
|
47
49
|
issues.reject! { |issue| ignore_issues.include? issue.key } if ignore_issues
|
|
48
50
|
|
|
@@ -52,12 +54,10 @@ class Exporter
|
|
|
52
54
|
html "<H1>#{name}</H1>", type: :header
|
|
53
55
|
boards.each_key do |id|
|
|
54
56
|
board = find_board id
|
|
55
|
-
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>",
|
|
56
58
|
type: :header
|
|
57
59
|
end
|
|
58
60
|
|
|
59
|
-
discard_changes_before status_becomes: (starting_status || :backlog) # rubocop:disable Style/RedundantParentheses
|
|
60
|
-
|
|
61
61
|
cycletime_scatterplot do
|
|
62
62
|
show_trend_lines
|
|
63
63
|
end
|
|
@@ -84,27 +84,11 @@ class Exporter
|
|
|
84
84
|
daily_wip_by_age_chart
|
|
85
85
|
daily_wip_by_blocked_stalled_chart
|
|
86
86
|
daily_wip_by_parent_chart
|
|
87
|
+
flow_efficiency_scatterplot if show_experimental_charts
|
|
87
88
|
expedited_chart
|
|
88
89
|
sprint_burndown
|
|
89
90
|
estimate_accuracy_chart
|
|
90
|
-
|
|
91
|
-
dependency_chart do
|
|
92
|
-
link_rules do |link, rules|
|
|
93
|
-
case link.name
|
|
94
|
-
when 'Cloners'
|
|
95
|
-
rules.ignore
|
|
96
|
-
when 'Dependency', 'Blocks', 'Parent/Child', 'Cause', 'Satisfy Requirement', 'Relates'
|
|
97
|
-
rules.merge_bidirectional keep: 'outward'
|
|
98
|
-
rules.merge_bidirectional keep: 'outward'
|
|
99
|
-
when 'Sync'
|
|
100
|
-
rules.use_bidirectional_arrows
|
|
101
|
-
else
|
|
102
|
-
# This is a link type that we don't recognize. Dump it to standard out to draw attention
|
|
103
|
-
# to it.
|
|
104
|
-
puts "name=#{link.name}, label=#{link.label}"
|
|
105
|
-
end
|
|
106
|
-
end
|
|
107
|
-
end
|
|
91
|
+
dependency_chart
|
|
108
92
|
end
|
|
109
93
|
end
|
|
110
94
|
end
|
|
@@ -57,13 +57,13 @@ class ExpeditedChart < ChartBase
|
|
|
57
57
|
def prepare_expedite_data issue
|
|
58
58
|
expedite_start = nil
|
|
59
59
|
result = []
|
|
60
|
-
expedited_priority_names = issue.board.expedited_priority_names
|
|
60
|
+
expedited_priority_names = issue.board.project_config.settings['expedited_priority_names']
|
|
61
61
|
|
|
62
62
|
issue.changes.each do |change|
|
|
63
63
|
next unless change.priority?
|
|
64
64
|
|
|
65
65
|
if expedited_priority_names.include? change.value
|
|
66
|
-
expedite_start = change.time
|
|
66
|
+
expedite_start = change.time.to_date
|
|
67
67
|
elsif expedite_start
|
|
68
68
|
start_date = expedite_start.to_date
|
|
69
69
|
stop_date = change.time.to_date
|
|
@@ -72,7 +72,7 @@ class ExpeditedChart < ChartBase
|
|
|
72
72
|
(start_date < date_range.begin && stop_date > date_range.end)
|
|
73
73
|
|
|
74
74
|
result << [expedite_start, :expedite_start]
|
|
75
|
-
result << [change.time, :expedite_stop]
|
|
75
|
+
result << [change.time.to_date, :expedite_stop]
|
|
76
76
|
end
|
|
77
77
|
expedite_start = nil
|
|
78
78
|
end
|
|
@@ -109,12 +109,11 @@ class ExpeditedChart < ChartBase
|
|
|
109
109
|
|
|
110
110
|
def make_expedite_lines_data_set issue:, expedite_data:
|
|
111
111
|
cycletime = issue.board.cycletime
|
|
112
|
-
|
|
113
|
-
stopped_time = cycletime.stopped_time(issue)
|
|
112
|
+
started_date, stopped_date = cycletime.started_stopped_dates(issue)
|
|
114
113
|
|
|
115
|
-
expedite_data << [
|
|
116
|
-
expedite_data << [
|
|
117
|
-
expedite_data.sort_by!
|
|
114
|
+
expedite_data << [started_date, :issue_started] if started_date
|
|
115
|
+
expedite_data << [stopped_date, :issue_stopped] if stopped_date
|
|
116
|
+
expedite_data.sort_by!(&:first)
|
|
118
117
|
|
|
119
118
|
# If none of the data would be visible on the chart then skip it.
|
|
120
119
|
return nil unless expedite_data.any? { |time, _action| time.to_date >= date_range.begin }
|
|
@@ -151,7 +150,7 @@ class ExpeditedChart < ChartBase
|
|
|
151
150
|
|
|
152
151
|
unless expedite_data.empty?
|
|
153
152
|
last_change_time = expedite_data[-1][0].to_date
|
|
154
|
-
if last_change_time && last_change_time <= date_range.end &&
|
|
153
|
+
if last_change_time && last_change_time <= date_range.end && stopped_date.nil?
|
|
155
154
|
data << make_point(issue: issue, time: date_range.end, label: 'Still ongoing', expedited: expedited)
|
|
156
155
|
dot_colors << '' # It won't be visible so it doesn't matter
|
|
157
156
|
point_styles << 'dash'
|
data/lib/jirametrics/exporter.rb
CHANGED
|
@@ -2,16 +2,6 @@
|
|
|
2
2
|
|
|
3
3
|
require 'fileutils'
|
|
4
4
|
|
|
5
|
-
class Object
|
|
6
|
-
def deprecated message:, date:
|
|
7
|
-
text = +''
|
|
8
|
-
text << "Deprecated(#{date}): "
|
|
9
|
-
text << message
|
|
10
|
-
text << "\n-> Called from #{caller(1..1).first}"
|
|
11
|
-
warn text
|
|
12
|
-
end
|
|
13
|
-
end
|
|
14
|
-
|
|
15
5
|
class Exporter
|
|
16
6
|
attr_reader :project_configs
|
|
17
7
|
attr_accessor :file_system
|
|
@@ -70,6 +60,31 @@ class Exporter
|
|
|
70
60
|
puts "Full output from downloader in #{file_system.logfile_name}"
|
|
71
61
|
end
|
|
72
62
|
|
|
63
|
+
def info keys, name_filter:
|
|
64
|
+
selected = []
|
|
65
|
+
each_project_config(name_filter: name_filter) do |project|
|
|
66
|
+
project.evaluate_next_level
|
|
67
|
+
|
|
68
|
+
project.run load_only: true
|
|
69
|
+
project.issues.each do |issue|
|
|
70
|
+
selected << [project, issue] if keys.include? issue.key
|
|
71
|
+
end
|
|
72
|
+
rescue => e # rubocop:disable Style/RescueStandardError
|
|
73
|
+
# This happens when we're attempting to load an aggregated project because it hasn't been
|
|
74
|
+
# properly initialized. Since we don't care about aggregated projects, we just ignore it.
|
|
75
|
+
raise unless e.message.start_with? 'This is an aggregated project and issues should have been included'
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
if selected.empty?
|
|
79
|
+
file_system.log "No issues found to match #{keys.collect(&:inspect).join(', ')}"
|
|
80
|
+
else
|
|
81
|
+
selected.each do |project, issue|
|
|
82
|
+
file_system.log "\nProject #{project.name}", also_write_to_stderr: true
|
|
83
|
+
file_system.log issue.dump, also_write_to_stderr: true
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
73
88
|
def each_project_config name_filter:
|
|
74
89
|
@project_configs.each do |project|
|
|
75
90
|
yield project if project.name.nil? || File.fnmatch(name_filter, project.name)
|
|
@@ -101,7 +116,9 @@ class Exporter
|
|
|
101
116
|
|
|
102
117
|
def jira_config filename = nil
|
|
103
118
|
if filename
|
|
104
|
-
@jira_config = file_system.load_json(filename)
|
|
119
|
+
@jira_config = file_system.load_json(filename, fail_on_error: false)
|
|
120
|
+
raise "Unable to load Jira configuration file and cannot continue: #{filename.inspect}" if @jira_config.nil?
|
|
121
|
+
|
|
105
122
|
@jira_config['url'] = $1 if @jira_config['url'] =~ /^(.+)\/+$/
|
|
106
123
|
end
|
|
107
124
|
@jira_config
|
|
@@ -56,7 +56,7 @@ class FileConfig
|
|
|
56
56
|
def output_filename
|
|
57
57
|
segments = []
|
|
58
58
|
segments << project_config.target_path
|
|
59
|
-
segments << project_config.
|
|
59
|
+
segments << project_config.get_file_prefix
|
|
60
60
|
segments << (@file_suffix || "-#{@today}.csv")
|
|
61
61
|
segments.join
|
|
62
62
|
end
|
|
@@ -66,15 +66,20 @@ class FileConfig
|
|
|
66
66
|
# is that all empty values in the first column should be at the bottom.
|
|
67
67
|
def sort_output all_lines
|
|
68
68
|
all_lines.sort do |a, b|
|
|
69
|
+
result = nil
|
|
69
70
|
if a[0] == b[0]
|
|
70
|
-
a[1..] <=> b[1..]
|
|
71
|
+
result = a[1..] <=> b[1..]
|
|
71
72
|
elsif a[0].nil?
|
|
72
|
-
1
|
|
73
|
+
result = 1
|
|
73
74
|
elsif b[0].nil?
|
|
74
|
-
-1
|
|
75
|
+
result = -1
|
|
75
76
|
else
|
|
76
|
-
a[0] <=> b[0]
|
|
77
|
+
result = a[0] <=> b[0]
|
|
77
78
|
end
|
|
79
|
+
|
|
80
|
+
# This will only happen if one of the objects isn't comparable. Seen in production.
|
|
81
|
+
result = -1 if result.nil?
|
|
82
|
+
result
|
|
78
83
|
end
|
|
79
84
|
end
|
|
80
85
|
|
|
@@ -85,6 +90,11 @@ class FileConfig
|
|
|
85
90
|
|
|
86
91
|
def html_report &block
|
|
87
92
|
assert_only_one_filetype_config_set
|
|
93
|
+
if block.nil?
|
|
94
|
+
project_config.file_system.warning 'No charts were specified for the report. This is almost certainly a mistake.'
|
|
95
|
+
block = ->(_) {}
|
|
96
|
+
end
|
|
97
|
+
|
|
88
98
|
@html_report = HtmlReportConfig.new file_config: self, block: block
|
|
89
99
|
end
|
|
90
100
|
|
|
@@ -103,7 +113,7 @@ class FileConfig
|
|
|
103
113
|
def to_datetime object
|
|
104
114
|
return nil if object.nil?
|
|
105
115
|
|
|
106
|
-
object = object.to_datetime
|
|
116
|
+
object = object.to_time.to_datetime
|
|
107
117
|
object = object.new_offset(@timezone_offset) if @timezone_offset
|
|
108
118
|
object
|
|
109
119
|
end
|
|
@@ -120,4 +130,11 @@ class FileConfig
|
|
|
120
130
|
@file_suffix = suffix unless suffix.nil?
|
|
121
131
|
@file_suffix
|
|
122
132
|
end
|
|
133
|
+
|
|
134
|
+
def children
|
|
135
|
+
result = []
|
|
136
|
+
result << @columns if @columns
|
|
137
|
+
result << @html_report if @html_report
|
|
138
|
+
result
|
|
139
|
+
end
|
|
123
140
|
end
|
|
@@ -6,14 +6,18 @@ class FileSystem
|
|
|
6
6
|
attr_accessor :logfile, :logfile_name
|
|
7
7
|
|
|
8
8
|
# Effectively the same as File.read except it forces the encoding to UTF-8
|
|
9
|
-
def load filename
|
|
9
|
+
def load filename, supress_deprecation: false
|
|
10
|
+
if filename.end_with?('.json') && !supress_deprecation
|
|
11
|
+
deprecated(message: 'call load_json instead', date: '2024-11-13')
|
|
12
|
+
end
|
|
13
|
+
|
|
10
14
|
File.read filename, encoding: 'UTF-8'
|
|
11
15
|
end
|
|
12
16
|
|
|
13
17
|
def load_json filename, fail_on_error: true
|
|
14
18
|
return nil if fail_on_error == false && File.exist?(filename) == false
|
|
15
19
|
|
|
16
|
-
JSON.parse load(filename)
|
|
20
|
+
JSON.parse load(filename, supress_deprecation: true)
|
|
17
21
|
end
|
|
18
22
|
|
|
19
23
|
def save_json json:, filename:
|
|
@@ -27,8 +31,22 @@ class FileSystem
|
|
|
27
31
|
File.write(filename, content)
|
|
28
32
|
end
|
|
29
33
|
|
|
30
|
-
def
|
|
34
|
+
def warning message, more: nil
|
|
35
|
+
log "Warning: #{message}", more: more, also_write_to_stderr: true
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def error message, more: nil
|
|
39
|
+
log "Error: #{message}", more: more, also_write_to_stderr: true
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def log message, more: nil, also_write_to_stderr: false
|
|
43
|
+
message += " See #{logfile_name} for more details about this message." if more
|
|
44
|
+
|
|
31
45
|
logfile.puts message
|
|
46
|
+
logfile.puts more if more
|
|
47
|
+
return unless also_write_to_stderr
|
|
48
|
+
|
|
49
|
+
$stderr.puts message # rubocop:disable Style/StderrPuts
|
|
32
50
|
end
|
|
33
51
|
|
|
34
52
|
# In some Jira instances, a sizeable portion of the JSON is made up of empty fields. I've seen
|
|
@@ -42,4 +60,22 @@ class FileSystem
|
|
|
42
60
|
end
|
|
43
61
|
node
|
|
44
62
|
end
|
|
63
|
+
|
|
64
|
+
def foreach root, &block
|
|
65
|
+
Dir.foreach root, &block
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def file_exist? filename
|
|
69
|
+
File.exist? filename
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def deprecated message:, date:, depth: 2
|
|
73
|
+
text = +''
|
|
74
|
+
text << "Deprecated(#{date}): "
|
|
75
|
+
text << message
|
|
76
|
+
caller(1..depth).each do |line|
|
|
77
|
+
text << "\n-> Called from #{line}"
|
|
78
|
+
end
|
|
79
|
+
log text, also_write_to_stderr: true
|
|
80
|
+
end
|
|
45
81
|
end
|