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
|
@@ -4,11 +4,9 @@ require 'time'
|
|
|
4
4
|
require 'jirametrics/status_collection'
|
|
5
5
|
|
|
6
6
|
class ProjectConfig
|
|
7
|
-
include DiscardChangesBefore
|
|
8
|
-
|
|
9
7
|
attr_reader :target_path, :jira_config, :all_boards, :possible_statuses,
|
|
10
8
|
:download_config, :file_configs, :exporter, :data_version, :name, :board_configs,
|
|
11
|
-
:settings, :aggregate_config
|
|
9
|
+
:settings, :aggregate_config, :discarded_changes_data
|
|
12
10
|
attr_accessor :time_range, :jira_url, :id
|
|
13
11
|
|
|
14
12
|
def initialize exporter:, jira_config:, block:, target_path: '.', name: '', id: nil
|
|
@@ -24,32 +22,42 @@ class ProjectConfig
|
|
|
24
22
|
@all_boards = {}
|
|
25
23
|
@settings = load_settings
|
|
26
24
|
@id = id
|
|
25
|
+
@has_loaded_data = false
|
|
27
26
|
end
|
|
28
27
|
|
|
29
28
|
def evaluate_next_level
|
|
30
29
|
instance_eval(&@block) if @block
|
|
31
30
|
end
|
|
32
31
|
|
|
33
|
-
def
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
32
|
+
def data_downloaded?
|
|
33
|
+
file_system.file_exist? File.join(@target_path, "#{get_file_prefix}_meta.json")
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def load_data
|
|
37
|
+
return if @has_loaded_data
|
|
38
|
+
|
|
39
|
+
@has_loaded_data = true
|
|
40
|
+
@id = guess_project_id
|
|
41
|
+
load_project_metadata
|
|
42
|
+
load_sprints
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def run load_only: false
|
|
46
|
+
return if @exporter.downloading?
|
|
47
|
+
|
|
48
|
+
load_data unless aggregated_project?
|
|
41
49
|
anonymize_data if @anonymizer_needed
|
|
42
50
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
end
|
|
51
|
+
return if load_only
|
|
52
|
+
|
|
46
53
|
@file_configs.each do |file_config|
|
|
47
54
|
file_config.run
|
|
48
55
|
end
|
|
49
56
|
end
|
|
50
57
|
|
|
51
58
|
def load_settings
|
|
52
|
-
|
|
59
|
+
# This is the weird exception that we don't ever want mocked out so we skip FileSystem entirely.
|
|
60
|
+
JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
|
|
53
61
|
end
|
|
54
62
|
|
|
55
63
|
def guess_project_id
|
|
@@ -98,90 +106,188 @@ class ProjectConfig
|
|
|
98
106
|
|
|
99
107
|
def board id:, &block
|
|
100
108
|
config = BoardConfig.new(id: id, block: block, project_config: self)
|
|
109
|
+
config.run if data_downloaded?
|
|
101
110
|
@board_configs << config
|
|
102
111
|
end
|
|
103
112
|
|
|
104
|
-
def file_prefix prefix
|
|
105
|
-
|
|
113
|
+
def file_prefix prefix
|
|
114
|
+
# The file_prefix has to be set before almost everything else. It really should have been an attribute
|
|
115
|
+
# on the project declaration itself. Hindsight is 20/20.
|
|
116
|
+
if @file_prefix
|
|
117
|
+
raise "file_prefix should only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
@file_prefix = prefix
|
|
121
|
+
|
|
122
|
+
# Yes, this is a wierd place to be initializing this. Unfortunately, it has to happen after the file_prefix
|
|
123
|
+
# is set but before anything inside the project block is run. If only we had made file_prefix an attribute
|
|
124
|
+
# on project, we wouldn't have this ugliness. 🤷♂️
|
|
125
|
+
load_status_category_mappings
|
|
126
|
+
load_status_history
|
|
127
|
+
load_all_boards
|
|
128
|
+
|
|
106
129
|
@file_prefix
|
|
107
130
|
end
|
|
108
131
|
|
|
109
|
-
def
|
|
110
|
-
|
|
132
|
+
def get_file_prefix # rubocop:disable Naming/AccessorMethodName
|
|
133
|
+
raise 'file_prefix has not been set yet. Move it to the top of the project declaration.' unless @file_prefix
|
|
134
|
+
|
|
135
|
+
@file_prefix
|
|
111
136
|
end
|
|
112
137
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
138
|
+
# Walk across all the issues and find any status with that name. Return a list of ids that match.
|
|
139
|
+
def find_ids_by_status_name_across_all_issues name
|
|
140
|
+
ids = Set.new
|
|
116
141
|
|
|
117
|
-
|
|
118
|
-
|
|
142
|
+
issues.each do |issue|
|
|
143
|
+
issue.changes.each do |change|
|
|
144
|
+
next unless change.status?
|
|
145
|
+
|
|
146
|
+
ids << change.value_id.to_i if change.value == name
|
|
147
|
+
ids << change.old_value_id.to_i if change.old_value == name
|
|
148
|
+
end
|
|
119
149
|
end
|
|
120
|
-
|
|
150
|
+
ids.to_a
|
|
121
151
|
end
|
|
122
152
|
|
|
123
|
-
def
|
|
124
|
-
|
|
125
|
-
|
|
153
|
+
def status_category_mapping status:, category:
|
|
154
|
+
status, status_id = possible_statuses.parse_name_id status
|
|
155
|
+
category, category_id = possible_statuses.parse_name_id category
|
|
156
|
+
|
|
157
|
+
if status_id.nil?
|
|
158
|
+
guesses = find_ids_by_status_name_across_all_issues status
|
|
159
|
+
if guesses.empty?
|
|
160
|
+
file_system.warning "For status_category_mapping status: #{status.inspect}, category: #{category.inspect}\n" \
|
|
161
|
+
"Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
|
|
162
|
+
"histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
|
|
163
|
+
"going to ignore it. If you really want it, then you'll need to specify a status id."
|
|
164
|
+
return
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
if guesses.size > 1
|
|
168
|
+
raise "Cannot guess status id as there are multiple ids for the name #{status.inspect}. Perhaps it's one " \
|
|
169
|
+
"of #{guesses.to_a.sort.inspect}. If you need this mapping then you must specify the status_id."
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
status_id = guesses.first
|
|
173
|
+
file_system.log "status_category_mapping for #{status.inspect} has been mapped to id #{status_id}. " \
|
|
174
|
+
"If that's incorrect then specify the status_id."
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
possible_categories = possible_statuses.find_all_categories_by_name category
|
|
178
|
+
if possible_categories.empty?
|
|
179
|
+
all = possible_statuses.find_all_categories.join(', ')
|
|
180
|
+
raise "No status categories found for name #{category.inspect} in [#{all}]. " \
|
|
181
|
+
'Either fix the name or add an ID.'
|
|
182
|
+
elsif possible_categories.size > 1
|
|
183
|
+
# Theoretically impossible and yet we've seen wierder things out of Jira so we're prepared.
|
|
184
|
+
raise "More than one status category found with the name #{category.inspect} in " \
|
|
185
|
+
"[#{possible_categories.join(', ')}]. Either fix the name or add an ID"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
found_category = possible_categories.first
|
|
189
|
+
|
|
190
|
+
if category_id && category_id != found_category.id
|
|
191
|
+
raise "ID is incorrect for status category #{category.inspect}. Did you mean #{found_category.id}?"
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
add_possible_status(
|
|
195
|
+
Status.new(
|
|
196
|
+
name: status, id: status_id,
|
|
197
|
+
category_name: category, category_id: found_category.id, category_key: found_category.key
|
|
198
|
+
)
|
|
126
199
|
)
|
|
127
|
-
board.project_config = self
|
|
128
|
-
@all_boards[board_id] = board
|
|
129
200
|
end
|
|
130
201
|
|
|
131
|
-
def
|
|
132
|
-
|
|
133
|
-
message << "Could not determine categories for some of the statuses used in this data set.\n" \
|
|
134
|
-
"Use the 'status_category_mapping' declaration in your config to manually add one.\n" \
|
|
135
|
-
'The mappings we do know about are below:'
|
|
202
|
+
def add_possible_status status
|
|
203
|
+
existing_status = @possible_statuses.find_by_id status.id
|
|
136
204
|
|
|
137
|
-
|
|
138
|
-
|
|
205
|
+
if existing_status && existing_status.name != status.name
|
|
206
|
+
raise "Attempting to redefine the name for status #{status.id} from " \
|
|
207
|
+
"#{existing_status.name.inspect} to #{status.name.inspect}"
|
|
139
208
|
end
|
|
140
209
|
|
|
141
|
-
|
|
210
|
+
# If it isn't there, add it and go.
|
|
211
|
+
return @possible_statuses << status unless existing_status
|
|
142
212
|
|
|
143
|
-
|
|
144
|
-
|
|
213
|
+
unless status == existing_status
|
|
214
|
+
raise "Redefining status category for status #{status}. " \
|
|
215
|
+
"original: #{existing_status.category}, " \
|
|
216
|
+
"new: #{status.category}"
|
|
145
217
|
end
|
|
146
218
|
|
|
147
|
-
|
|
219
|
+
# We're registering one we already knew about. This may happen if someone specified a status_category_mapping
|
|
220
|
+
# for something that was already returned from jira.
|
|
221
|
+
#
|
|
222
|
+
# You may be looking at this code and thinking of changing it to spit out a warning since obviously
|
|
223
|
+
# the user has made a mistake. Unfortunately, they may not have made any mistake. Due to inconsistency with the
|
|
224
|
+
# status API, it's possible for two different people to make a request to the same API at the same time and get
|
|
225
|
+
# back a different set of statuses. So that means that some people might need more status/categories mappings than
|
|
226
|
+
# other people for exactly the same instance. See this article for more on that API:
|
|
227
|
+
# https://agiletechnicalexcellence.com/2024/04/12/jira-api-statuses.html
|
|
228
|
+
existing_status
|
|
148
229
|
end
|
|
149
230
|
|
|
150
|
-
def
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
issue.changes.each do |change|
|
|
154
|
-
next unless change.status?
|
|
231
|
+
def load_all_boards
|
|
232
|
+
Dir.foreach(@target_path) do |file|
|
|
233
|
+
next unless file =~ /^#{get_file_prefix}_board_(\d+)_configuration\.json$/
|
|
155
234
|
|
|
156
|
-
|
|
157
|
-
|
|
235
|
+
board_id = $1.to_i
|
|
236
|
+
load_board board_id: board_id, filename: "#{@target_path}#{file}"
|
|
158
237
|
end
|
|
159
|
-
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
def load_board board_id:, filename:
|
|
241
|
+
board = Board.new(
|
|
242
|
+
raw: file_system.load_json(filename), possible_statuses: @possible_statuses
|
|
243
|
+
)
|
|
244
|
+
board.project_config = self
|
|
245
|
+
@all_boards[board_id] = board
|
|
160
246
|
end
|
|
161
247
|
|
|
162
248
|
def load_status_category_mappings
|
|
163
|
-
filename = "#{
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
.map { |snippet| Status.
|
|
169
|
-
statuses
|
|
170
|
-
.find_all { |status| status.global? }
|
|
171
|
-
.each { |status| add_possible_status status }
|
|
172
|
-
statuses
|
|
173
|
-
.find_all { |status| status.project_scoped? }
|
|
249
|
+
filename = File.join @target_path, "#{get_file_prefix}_statuses.json"
|
|
250
|
+
return unless file_system.file_exist? filename
|
|
251
|
+
|
|
252
|
+
file_system
|
|
253
|
+
.load_json(filename)
|
|
254
|
+
.map { |snippet| Status.from_raw(snippet) }
|
|
174
255
|
.each { |status| add_possible_status status }
|
|
175
256
|
end
|
|
176
257
|
|
|
258
|
+
def load_status_history
|
|
259
|
+
filename = File.join @target_path, "#{get_file_prefix}_status_history.json"
|
|
260
|
+
return unless file_system.file_exist? filename
|
|
261
|
+
|
|
262
|
+
file_system.log ' Loading historical statuses', also_write_to_stderr: true
|
|
263
|
+
file_system
|
|
264
|
+
.load_json(filename)
|
|
265
|
+
.map { |snippet| Status.from_raw(snippet) }
|
|
266
|
+
.each { |status| possible_statuses.historical_status_mappings[status.to_s] = status.category }
|
|
267
|
+
|
|
268
|
+
possible_statuses
|
|
269
|
+
rescue => e # rubocop:disable Style/RescueStandardError
|
|
270
|
+
file_system.warning "Unable to load status history due to #{e.message.inspect}. If this is because of a " \
|
|
271
|
+
'malformed file then it should be fixed on the next download.'
|
|
272
|
+
end
|
|
273
|
+
|
|
177
274
|
def load_sprints
|
|
178
|
-
|
|
179
|
-
next unless file =~
|
|
275
|
+
file_system.foreach(@target_path) do |file|
|
|
276
|
+
next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
|
|
277
|
+
|
|
278
|
+
file_path = File.join(@target_path, file)
|
|
279
|
+
board = @all_boards[$1.to_i]
|
|
280
|
+
unless board
|
|
281
|
+
@exporter.file_system.log(
|
|
282
|
+
'Found sprint data but can\'t find a matching board in config. ' \
|
|
283
|
+
"File: #{file_path}, Boards: #{@all_boards.keys.sort}"
|
|
284
|
+
)
|
|
285
|
+
next
|
|
286
|
+
end
|
|
180
287
|
|
|
181
|
-
board_id = $1.to_i
|
|
182
288
|
timezone_offset = exporter.timezone_offset
|
|
183
|
-
|
|
184
|
-
|
|
289
|
+
file_system.load_json(file_path)['values']&.each do |json|
|
|
290
|
+
board.sprints << Sprint.new(raw: json, timezone_offset: timezone_offset)
|
|
185
291
|
end
|
|
186
292
|
end
|
|
187
293
|
|
|
@@ -190,60 +296,30 @@ class ProjectConfig
|
|
|
190
296
|
end
|
|
191
297
|
end
|
|
192
298
|
|
|
193
|
-
def
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
if status.project_scoped?
|
|
197
|
-
# If the project specific status doesn't change anything then we don't care whether it's
|
|
198
|
-
# our project or not.
|
|
199
|
-
return if existing_status && existing_status.category_name == status.category_name
|
|
299
|
+
def load_project_metadata
|
|
300
|
+
filename = File.join @target_path, "#{get_file_prefix}_meta.json"
|
|
301
|
+
json = file_system.load_json(filename)
|
|
200
302
|
|
|
201
|
-
|
|
303
|
+
@data_version = json['version'] || 1
|
|
202
304
|
|
|
203
|
-
|
|
204
|
-
|
|
305
|
+
start = to_time(json['date_start'] || json['time_start']) # date_start is the current format. Time is the old.
|
|
306
|
+
stop = to_time(json['date_end'] || json['time_end'], end_of_day: true)
|
|
205
307
|
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
308
|
+
# If no_earlier_than was set then make sure it's applied here.
|
|
309
|
+
if download_config
|
|
310
|
+
download_config.run
|
|
311
|
+
no_earlier = download_config.no_earlier_than
|
|
312
|
+
if no_earlier
|
|
313
|
+
no_earlier = to_time(no_earlier.to_s)
|
|
314
|
+
start = no_earlier if start < no_earlier
|
|
315
|
+
end
|
|
210
316
|
end
|
|
211
317
|
|
|
212
|
-
|
|
213
|
-
return @possible_statuses << status unless existing_status
|
|
214
|
-
|
|
215
|
-
# We're registering the same one twice. Shouldn't be possible with the new status API but it
|
|
216
|
-
# did happen with the project specific one.
|
|
217
|
-
return if status.category_name == existing_status.category_name
|
|
218
|
-
|
|
219
|
-
# If we got this far then someone has called status_category_mapping and is attempting to
|
|
220
|
-
# change the category.
|
|
221
|
-
raise "Redefining status category #{status} with #{existing_status}. Was one set in the config?"
|
|
222
|
-
end
|
|
223
|
-
|
|
224
|
-
def raise_ambiguous_project_id
|
|
225
|
-
raise 'Ambiguous project id: There is a project specific status that could affect out calculations. ' \
|
|
226
|
-
'We are unable to automatically detect the id of the project so you will have to set it manually ' \
|
|
227
|
-
'in the configuration like: "project id: 5"'
|
|
228
|
-
end
|
|
229
|
-
|
|
230
|
-
def find_status name:
|
|
231
|
-
@possible_statuses.find_by_name name
|
|
232
|
-
end
|
|
233
|
-
|
|
234
|
-
def load_project_metadata
|
|
235
|
-
filename = "#{@target_path}/#{file_prefix}_meta.json"
|
|
236
|
-
json = JSON.parse(file_system.load(filename))
|
|
237
|
-
|
|
238
|
-
@data_version = json['version'] || 1
|
|
239
|
-
|
|
240
|
-
start = json['date_start'] || json['time_start'] # date_start is the current format. Time is the old.
|
|
241
|
-
stop = json['date_end'] || json['time_end']
|
|
242
|
-
@time_range = to_time(start)..to_time(stop, end_of_day: true)
|
|
318
|
+
@time_range = start..stop
|
|
243
319
|
|
|
244
320
|
@jira_url = json['jira_url']
|
|
245
321
|
rescue Errno::ENOENT
|
|
246
|
-
|
|
322
|
+
file_system.log "Can't load #{filename}. Have you done a download?", also_write_to_stderr: true
|
|
247
323
|
raise
|
|
248
324
|
end
|
|
249
325
|
|
|
@@ -259,7 +335,7 @@ class ProjectConfig
|
|
|
259
335
|
unless all_boards&.size == 1
|
|
260
336
|
message = "If the board_id isn't set then we look for all board configurations in the target" \
|
|
261
337
|
' directory. '
|
|
262
|
-
if all_boards.
|
|
338
|
+
if all_boards.empty?
|
|
263
339
|
message += ' In this case, we couldn\'t find any configuration files in the target directory.'
|
|
264
340
|
else
|
|
265
341
|
message += 'If there is only one, we use that. In this case we found configurations for' \
|
|
@@ -291,21 +367,25 @@ class ProjectConfig
|
|
|
291
367
|
end
|
|
292
368
|
|
|
293
369
|
def issues
|
|
294
|
-
raise "issues are being loaded before boards in project #{name.inspect}" if all_boards.nil? && !aggregated_project?
|
|
295
|
-
|
|
296
370
|
unless @issues
|
|
297
|
-
if
|
|
371
|
+
if aggregated_project?
|
|
298
372
|
raise 'This is an aggregated project and issues should have been included with the include_issues_from ' \
|
|
299
373
|
'declaration but none are here. Check your config.'
|
|
300
374
|
end
|
|
301
375
|
|
|
376
|
+
return @issues = [] if @exporter.downloading?
|
|
377
|
+
raise 'No data found. Must do a download before an export' unless data_downloaded?
|
|
378
|
+
|
|
379
|
+
load_data if all_boards.empty?
|
|
380
|
+
|
|
302
381
|
timezone_offset = exporter.timezone_offset
|
|
303
382
|
|
|
304
|
-
issues_path =
|
|
383
|
+
issues_path = File.join @target_path, "#{get_file_prefix}_issues"
|
|
305
384
|
if File.exist?(issues_path) && File.directory?(issues_path)
|
|
306
385
|
issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
|
|
307
386
|
else
|
|
308
|
-
|
|
387
|
+
file_system.log "Can't find directory #{issues_path}. Has a download been done?", also_write_to_stderr: true
|
|
388
|
+
return []
|
|
309
389
|
end
|
|
310
390
|
|
|
311
391
|
# Attach related issues
|
|
@@ -351,8 +431,8 @@ class ProjectConfig
|
|
|
351
431
|
raise "No boards found for project #{name.inspect}" if all_boards.empty?
|
|
352
432
|
|
|
353
433
|
if all_boards.size != 1
|
|
354
|
-
|
|
355
|
-
"Picked #{default_board.name.inspect} to attach issues to."
|
|
434
|
+
file_system.log "Multiple boards are in use for project #{name.inspect}. " \
|
|
435
|
+
"Picked #{default_board.name.inspect} to attach issues to.", also_write_to_stderr: true
|
|
356
436
|
end
|
|
357
437
|
default_board
|
|
358
438
|
end
|
|
@@ -362,7 +442,7 @@ class ProjectConfig
|
|
|
362
442
|
default_board = nil
|
|
363
443
|
|
|
364
444
|
group_filenames_and_board_ids(path: path).each do |filename, board_ids|
|
|
365
|
-
content = file_system.
|
|
445
|
+
content = file_system.load_json(File.join(path, filename))
|
|
366
446
|
if board_ids == :unknown
|
|
367
447
|
boards = [(default_board ||= find_default_board)]
|
|
368
448
|
else
|
|
@@ -370,7 +450,7 @@ class ProjectConfig
|
|
|
370
450
|
end
|
|
371
451
|
|
|
372
452
|
boards.each do |board|
|
|
373
|
-
issues << Issue.new(raw:
|
|
453
|
+
issues << Issue.new(raw: content, timezone_offset: timezone_offset, board: board)
|
|
374
454
|
end
|
|
375
455
|
end
|
|
376
456
|
|
|
@@ -424,21 +504,52 @@ class ProjectConfig
|
|
|
424
504
|
Anonymizer.new(project_config: self).run
|
|
425
505
|
end
|
|
426
506
|
|
|
427
|
-
def
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
507
|
+
def file_system
|
|
508
|
+
@exporter.file_system
|
|
509
|
+
end
|
|
510
|
+
|
|
511
|
+
def discard_changes_before status_becomes: nil, &block
|
|
512
|
+
if status_becomes
|
|
513
|
+
status_becomes = [status_becomes] unless status_becomes.is_a? Array
|
|
514
|
+
|
|
515
|
+
block = lambda do |issue|
|
|
516
|
+
trigger_statuses = status_becomes.collect do |status_name|
|
|
517
|
+
if status_name == :backlog
|
|
518
|
+
issue.board.backlog_statuses
|
|
519
|
+
else
|
|
520
|
+
possible_statuses.find_all_by_name status_name
|
|
521
|
+
end
|
|
522
|
+
end.flatten
|
|
523
|
+
|
|
524
|
+
next if trigger_statuses.empty?
|
|
525
|
+
|
|
526
|
+
trigger_status_ids = trigger_statuses.collect(&:id)
|
|
527
|
+
|
|
528
|
+
time = nil
|
|
529
|
+
issue.status_changes.each do |change|
|
|
530
|
+
time = change.time if trigger_status_ids.include?(change.value_id) # && change.artificial? == false
|
|
531
|
+
end
|
|
532
|
+
time
|
|
435
533
|
end
|
|
436
|
-
exporter.file_system.log message
|
|
437
534
|
end
|
|
438
|
-
exporter.file_system.log "Discarded data from #{issues_cutoff_times.count} issues out of a total #{issues.size}"
|
|
439
|
-
end
|
|
440
535
|
|
|
441
|
-
|
|
442
|
-
|
|
536
|
+
issues.each do |issue|
|
|
537
|
+
cutoff_time = block.call(issue)
|
|
538
|
+
next if cutoff_time.nil?
|
|
539
|
+
|
|
540
|
+
original_start_time = issue.board.cycletime.started_stopped_times(issue).first
|
|
541
|
+
next if original_start_time.nil?
|
|
542
|
+
|
|
543
|
+
issue.discard_changes_before cutoff_time
|
|
544
|
+
|
|
545
|
+
next unless cutoff_time
|
|
546
|
+
next if original_start_time > cutoff_time # ie the cutoff would have made no difference.
|
|
547
|
+
|
|
548
|
+
(@discarded_changes_data ||= []) << {
|
|
549
|
+
cutoff_time: cutoff_time,
|
|
550
|
+
original_start_time: original_start_time,
|
|
551
|
+
issue: issue
|
|
552
|
+
}
|
|
553
|
+
end
|
|
443
554
|
end
|
|
444
555
|
end
|
data/lib/jirametrics/rules.rb
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module SelfOrIssueDispatcher
|
|
4
|
+
# rubocop:disable Style/ArgumentsForwarding
|
|
4
5
|
def method_missing method_name, *args, &block
|
|
5
6
|
raise "#{method_name} isn't a method on Issue or #{self.class}" unless ::Issue.method_defined? method_name.to_sym
|
|
6
7
|
|
|
@@ -8,6 +9,7 @@ module SelfOrIssueDispatcher
|
|
|
8
9
|
issue.__send__ method_name, *args, &block
|
|
9
10
|
end
|
|
10
11
|
end
|
|
12
|
+
# rubocop:enable Style/ArgumentsForwarding
|
|
11
13
|
|
|
12
14
|
def respond_to_missing?(method_name, include_all = false)
|
|
13
15
|
::Issue.method_defined?(method_name.to_sym) || super
|
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
"stalled_threshold_days": 5,
|
|
3
3
|
"stalled_statuses": [],
|
|
4
4
|
|
|
5
|
-
"blocked_link_text": [],
|
|
6
|
-
"blocked_statuses": []
|
|
5
|
+
"blocked_link_text": ["is blocked by"],
|
|
6
|
+
"blocked_statuses": [],
|
|
7
|
+
"flagged_means_blocked": true,
|
|
8
|
+
|
|
9
|
+
"expedited_priority_names": ["Critical", "Highest"]
|
|
7
10
|
}
|
|
@@ -18,7 +18,7 @@ class SprintBurndown < ChartBase
|
|
|
18
18
|
attr_accessor :board_id
|
|
19
19
|
|
|
20
20
|
def initialize
|
|
21
|
-
super
|
|
21
|
+
super
|
|
22
22
|
|
|
23
23
|
@summary_stats = {}
|
|
24
24
|
header_text 'Sprint burndown'
|
|
@@ -126,7 +126,7 @@ class SprintBurndown < ChartBase
|
|
|
126
126
|
currently_in_sprint = false
|
|
127
127
|
change_data = []
|
|
128
128
|
|
|
129
|
-
issue_completed_time = issue.board.cycletime.
|
|
129
|
+
issue_completed_time = issue.board.cycletime.started_stopped_times(issue).last
|
|
130
130
|
completed_has_been_tracked = false
|
|
131
131
|
|
|
132
132
|
issue.changes.each do |change|
|
|
@@ -172,7 +172,7 @@ class SprintBurndown < ChartBase
|
|
|
172
172
|
change_item.raw['to'].split(/\s*,\s*/).any? { |id| id.to_i == sprint.id }
|
|
173
173
|
end
|
|
174
174
|
|
|
175
|
-
def data_set_by_story_points sprint:, change_data_for_sprint:
|
|
175
|
+
def data_set_by_story_points sprint:, change_data_for_sprint: # rubocop:disable Metrics/CyclomaticComplexity
|
|
176
176
|
summary_stats = SprintSummaryStats.new
|
|
177
177
|
summary_stats.completed = 0.0
|
|
178
178
|
|