jirametrics 2.7.1 → 2.8
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 +3 -3
- data/lib/jirametrics/aging_work_bar_chart.rb +1 -1
- data/lib/jirametrics/aging_work_in_progress_chart.rb +1 -1
- data/lib/jirametrics/board.rb +1 -1
- data/lib/jirametrics/change_item.rb +11 -5
- data/lib/jirametrics/chart_base.rb +11 -11
- data/lib/jirametrics/cycletime_config.rb +28 -2
- data/lib/jirametrics/cycletime_histogram.rb +2 -0
- data/lib/jirametrics/data_quality_report.rb +7 -7
- data/lib/jirametrics/download_config.rb +2 -2
- data/lib/jirametrics/downloader.rb +44 -11
- data/lib/jirametrics/examples/aggregated_project.rb +2 -3
- data/lib/jirametrics/examples/standard_project.rb +2 -2
- data/lib/jirametrics/expedited_chart.rb +7 -7
- data/lib/jirametrics/exporter.rb +2 -2
- data/lib/jirametrics/file_config.rb +2 -2
- data/lib/jirametrics/file_system.rb +18 -2
- data/lib/jirametrics/html/index.css +7 -0
- data/lib/jirametrics/html/index.erb +0 -3
- data/lib/jirametrics/html_report_config.rb +14 -0
- data/lib/jirametrics/issue.rb +47 -32
- data/lib/jirametrics/project_config.rb +143 -97
- data/lib/jirametrics/sprint_burndown.rb +1 -1
- data/lib/jirametrics/status.rb +61 -25
- data/lib/jirametrics/status_collection.rb +38 -5
- data/lib/jirametrics/throughput_chart.rb +1 -1
- data/lib/jirametrics.rb +7 -0
- metadata +2 -2
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -44,7 +44,7 @@ class Issue
|
|
|
44
44
|
|
|
45
45
|
def summary = @raw['fields']['summary']
|
|
46
46
|
|
|
47
|
-
def status = Status.
|
|
47
|
+
def status = Status.from_raw(@raw['fields']['status'])
|
|
48
48
|
|
|
49
49
|
def labels = @raw['fields']['labels'] || []
|
|
50
50
|
|
|
@@ -66,35 +66,45 @@ class Issue
|
|
|
66
66
|
end
|
|
67
67
|
|
|
68
68
|
def first_time_in_status *status_names
|
|
69
|
-
@changes.find { |change| change.current_status_matches(*status_names) }
|
|
69
|
+
@changes.find { |change| change.current_status_matches(*status_names) }
|
|
70
70
|
end
|
|
71
71
|
|
|
72
72
|
def first_time_not_in_status *status_names
|
|
73
|
-
@changes.find { |change| change.status? && status_names.include?(change.value) == false }
|
|
73
|
+
@changes.find { |change| change.status? && status_names.include?(change.value) == false }
|
|
74
74
|
end
|
|
75
75
|
|
|
76
76
|
def first_time_in_or_right_of_column column_name
|
|
77
77
|
first_time_in_status(*board.status_ids_in_or_right_of_column(column_name))
|
|
78
78
|
end
|
|
79
79
|
|
|
80
|
+
def first_time_label_added *labels
|
|
81
|
+
@changes.each do |change|
|
|
82
|
+
next unless change.labels?
|
|
83
|
+
|
|
84
|
+
change_labels = change.value.split
|
|
85
|
+
return change if change_labels.any? { |l| labels.include?(l) }
|
|
86
|
+
end
|
|
87
|
+
nil
|
|
88
|
+
end
|
|
89
|
+
|
|
80
90
|
def still_in_or_right_of_column column_name
|
|
81
91
|
still_in_status(*board.status_ids_in_or_right_of_column(column_name))
|
|
82
92
|
end
|
|
83
93
|
|
|
84
94
|
def still_in
|
|
85
|
-
|
|
95
|
+
result = nil
|
|
86
96
|
@changes.each do |change|
|
|
87
97
|
next unless change.status?
|
|
88
98
|
|
|
89
99
|
current_status_matched = yield change
|
|
90
100
|
|
|
91
|
-
if current_status_matched &&
|
|
92
|
-
|
|
93
|
-
elsif !current_status_matched &&
|
|
94
|
-
|
|
101
|
+
if current_status_matched && result.nil?
|
|
102
|
+
result = change
|
|
103
|
+
elsif !current_status_matched && result
|
|
104
|
+
result = nil
|
|
95
105
|
end
|
|
96
106
|
end
|
|
97
|
-
|
|
107
|
+
result
|
|
98
108
|
end
|
|
99
109
|
private :still_in
|
|
100
110
|
|
|
@@ -108,8 +118,8 @@ class Issue
|
|
|
108
118
|
# If it ever entered one of these categories and it's still there then what was the last time it entered
|
|
109
119
|
def still_in_status_category *category_names
|
|
110
120
|
still_in do |change|
|
|
111
|
-
status =
|
|
112
|
-
category_names.include?(status.
|
|
121
|
+
status = find_status_by_id change.value_id, name: change.value
|
|
122
|
+
category_names.include?(status.category.name) || category_names.include?(status.category.id)
|
|
113
123
|
end
|
|
114
124
|
end
|
|
115
125
|
|
|
@@ -117,48 +127,53 @@ class Issue
|
|
|
117
127
|
changes.reverse.find { |change| change.status? }
|
|
118
128
|
end
|
|
119
129
|
|
|
120
|
-
# Are we currently in this status? If yes, then return the
|
|
130
|
+
# Are we currently in this status? If yes, then return the most recent status change.
|
|
121
131
|
def currently_in_status *status_names
|
|
122
132
|
change = most_recent_status_change
|
|
123
133
|
return false if change.nil?
|
|
124
134
|
|
|
125
|
-
change
|
|
135
|
+
change if change.current_status_matches(*status_names)
|
|
126
136
|
end
|
|
127
137
|
|
|
128
|
-
# Are we currently in this status category? If yes, then return the
|
|
138
|
+
# Are we currently in this status category? If yes, then return the most recent status change.
|
|
129
139
|
def currently_in_status_category *category_names
|
|
130
140
|
change = most_recent_status_change
|
|
131
141
|
return false if change.nil?
|
|
132
142
|
|
|
133
|
-
status =
|
|
134
|
-
change
|
|
143
|
+
status = find_status_by_id change.value_id, name: change.value
|
|
144
|
+
change if status && category_names.include?(status.category.name)
|
|
135
145
|
end
|
|
136
146
|
|
|
137
|
-
def
|
|
138
|
-
status = board.possible_statuses.
|
|
147
|
+
def find_status_by_id id, name: nil
|
|
148
|
+
status = board.possible_statuses.find_by_id(id)
|
|
139
149
|
return status if status
|
|
140
150
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
151
|
+
status = board.possible_statuses.fabricate_status_for id: id, name: name
|
|
152
|
+
|
|
153
|
+
message = +'The history for issue '
|
|
154
|
+
message << key
|
|
155
|
+
message << ' references a status ('
|
|
156
|
+
message << name.inspect << ':' if name
|
|
157
|
+
message << id.to_s
|
|
158
|
+
message << ') that can\'t be found in ['
|
|
159
|
+
message << board.possible_statuses.collect(&:to_s).join(', ')
|
|
160
|
+
message << "]. We are guessing that this belongs to the #{status.category} status category "
|
|
161
|
+
message << 'and that may be wrong. See https://jirametrics.org/faq/#q1 for more details'
|
|
162
|
+
board.project_config.file_system.warning message
|
|
163
|
+
|
|
149
164
|
status
|
|
150
165
|
end
|
|
151
166
|
|
|
152
167
|
def first_status_change_after_created
|
|
153
|
-
@changes.find { |change| change.status? && change.artificial? == false }
|
|
168
|
+
@changes.find { |change| change.status? && change.artificial? == false }
|
|
154
169
|
end
|
|
155
170
|
|
|
156
171
|
def first_time_in_status_category *category_names
|
|
157
172
|
@changes.each do |change|
|
|
158
173
|
next unless change.status?
|
|
159
174
|
|
|
160
|
-
category =
|
|
161
|
-
return change
|
|
175
|
+
category = find_status_by_id(change.value_id).category.name
|
|
176
|
+
return change if category_names.include? category
|
|
162
177
|
end
|
|
163
178
|
nil
|
|
164
179
|
end
|
|
@@ -177,11 +192,11 @@ class Issue
|
|
|
177
192
|
end
|
|
178
193
|
|
|
179
194
|
def first_resolution
|
|
180
|
-
@changes.find { |change| change.resolution? }
|
|
195
|
+
@changes.find { |change| change.resolution? }
|
|
181
196
|
end
|
|
182
197
|
|
|
183
198
|
def last_resolution
|
|
184
|
-
@changes.reverse.find { |change| change.resolution? }
|
|
199
|
+
@changes.reverse.find { |change| change.resolution? }
|
|
185
200
|
end
|
|
186
201
|
|
|
187
202
|
def assigned_to
|
|
@@ -624,7 +639,7 @@ class Issue
|
|
|
624
639
|
# This was probably loaded as a linked issue, which means we don't know what board it really
|
|
625
640
|
# belonged to. The best we can do is look at the status category. This case should be rare but
|
|
626
641
|
# it can happen.
|
|
627
|
-
status.
|
|
642
|
+
status.category.name == 'Done'
|
|
628
643
|
else
|
|
629
644
|
board.cycletime.done? self
|
|
630
645
|
end
|
|
@@ -31,18 +31,23 @@ class ProjectConfig
|
|
|
31
31
|
instance_eval(&@block) if @block
|
|
32
32
|
end
|
|
33
33
|
|
|
34
|
+
def data_downloaded?
|
|
35
|
+
File.exist? File.join(@target_path, "#{get_file_prefix}_meta.json")
|
|
36
|
+
end
|
|
37
|
+
|
|
34
38
|
def load_data
|
|
35
39
|
return if @has_loaded_data
|
|
36
40
|
|
|
37
41
|
@has_loaded_data = true
|
|
38
42
|
load_all_boards
|
|
39
43
|
@id = guess_project_id
|
|
40
|
-
load_status_category_mappings
|
|
41
44
|
load_project_metadata
|
|
42
45
|
load_sprints
|
|
43
46
|
end
|
|
44
47
|
|
|
45
48
|
def run load_only: false
|
|
49
|
+
return if @exporter.downloading?
|
|
50
|
+
|
|
46
51
|
load_data unless aggregated_project?
|
|
47
52
|
anonymize_data if @anonymizer_needed
|
|
48
53
|
|
|
@@ -57,7 +62,8 @@ class ProjectConfig
|
|
|
57
62
|
end
|
|
58
63
|
|
|
59
64
|
def load_settings
|
|
60
|
-
|
|
65
|
+
# This is the wierd exception that we don't ever want mocked out so we skip FileSystem entirely.
|
|
66
|
+
JSON.parse(File.read(File.join(__dir__, 'settings.json'), encoding: 'UTF-8'))
|
|
61
67
|
end
|
|
62
68
|
|
|
63
69
|
def guess_project_id
|
|
@@ -109,87 +115,153 @@ class ProjectConfig
|
|
|
109
115
|
@board_configs << config
|
|
110
116
|
end
|
|
111
117
|
|
|
112
|
-
def file_prefix prefix
|
|
113
|
-
|
|
118
|
+
def file_prefix prefix
|
|
119
|
+
# The file_prefix has to be set before almost everything else. It really should have been an attribute
|
|
120
|
+
# on the project declaration itself. Hindsight is 20/20.
|
|
121
|
+
if @file_prefix
|
|
122
|
+
raise "file_prefix should only be set once. Was #{@file_prefix.inspect} and now changed to #{prefix.inspect}."
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
@file_prefix = prefix
|
|
126
|
+
|
|
127
|
+
# Yes, this is a wierd place to be initializing this. Unfortunately, it has to happen after the file_prefix
|
|
128
|
+
# is set but before anything inside the project block is run. If only we had made file_prefix an attribute
|
|
129
|
+
# on project, we wouldn't have this ugliness. 🤷♂️
|
|
130
|
+
load_status_category_mappings
|
|
131
|
+
|
|
114
132
|
@file_prefix
|
|
115
133
|
end
|
|
116
134
|
|
|
117
|
-
def
|
|
118
|
-
|
|
135
|
+
def get_file_prefix # rubocop:disable Naming/AccessorMethodName
|
|
136
|
+
raise 'file_prefix has not been set yet. Move it to the top of the project declaration.' unless @file_prefix
|
|
137
|
+
|
|
138
|
+
@file_prefix
|
|
119
139
|
end
|
|
120
140
|
|
|
121
|
-
def
|
|
122
|
-
|
|
123
|
-
|
|
141
|
+
def status_guesses
|
|
142
|
+
statuses = {}
|
|
143
|
+
issues.each do |issue|
|
|
144
|
+
issue.changes.each do |change|
|
|
145
|
+
next unless change.status?
|
|
124
146
|
|
|
125
|
-
|
|
126
|
-
|
|
147
|
+
(statuses[change.value] ||= Set.new) << change.value_id
|
|
148
|
+
(statuses[change.old_value] ||= Set.new) << change.old_value_id if change.old_value
|
|
149
|
+
end
|
|
127
150
|
end
|
|
128
|
-
|
|
151
|
+
statuses
|
|
129
152
|
end
|
|
130
153
|
|
|
131
|
-
def
|
|
132
|
-
|
|
133
|
-
|
|
154
|
+
def status_category_mapping status:, category:
|
|
155
|
+
# Status might just be a name like "Review" or it might be a name:id pair like "Review:4"
|
|
156
|
+
parse_status = ->(line) { line =~ /^(.+):(\d+)$/ ? [$1, $2.to_i] : [line, nil] }
|
|
157
|
+
|
|
158
|
+
status, status_id = parse_status.call status
|
|
159
|
+
category, category_id = parse_status.call category
|
|
160
|
+
|
|
161
|
+
if status_id.nil?
|
|
162
|
+
guesses = status_guesses[status]
|
|
163
|
+
if guesses.nil?
|
|
164
|
+
file_system.warning "For status_category_mapping status: #{status.inspect}, category: #{category.inspect}\n" \
|
|
165
|
+
"Cannot guess status id for #{status.inspect} as no statuses found anywhere in the issues " \
|
|
166
|
+
"histories with that name. Since we can't find it, you probably don't need this mapping anymore so we're " \
|
|
167
|
+
"going to ignore it. If you really want it, then you'll need to specify a status id."
|
|
168
|
+
return
|
|
169
|
+
end
|
|
170
|
+
|
|
171
|
+
if guesses.size > 1
|
|
172
|
+
raise "Cannot guess status id as there are multiple ids for the name #{status.inspect}. Perhaps it's one " \
|
|
173
|
+
"of #{guesses.to_a.sort.inspect}. If you need this mapping then you must specify the status_id."
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
status_id = guesses.first
|
|
177
|
+
# TODO: This should be a deprecation instead but we can't currently assert against those
|
|
178
|
+
file_system.log "status_category_mapping for #{status.inspect} has been mapped to id #{status_id}. " \
|
|
179
|
+
"If that's incorrect then specify the status_id."
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
found_category = possible_statuses.find_category_by_name category
|
|
183
|
+
found_category_id = found_category&.id # possible_statuses.find_category_id_by_name category
|
|
184
|
+
if category_id && category_id != found_category_id
|
|
185
|
+
raise "ID is incorrect for status category #{category.inspect}. Did you mean #{found_category_id}?"
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
add_possible_status(
|
|
189
|
+
Status.new(
|
|
190
|
+
name: status, id: status_id,
|
|
191
|
+
category_name: category, category_id: found_category_id, category_key: found_category.key
|
|
192
|
+
)
|
|
134
193
|
)
|
|
135
|
-
board.project_config = self
|
|
136
|
-
@all_boards[board_id] = board
|
|
137
194
|
end
|
|
138
195
|
|
|
139
|
-
def
|
|
140
|
-
|
|
141
|
-
message << "Could not determine categories for some of the statuses used in this data set.\n" \
|
|
142
|
-
"Use the 'status_category_mapping' declaration in your config to manually add one.\n" \
|
|
143
|
-
'The mappings we do know about are below:'
|
|
196
|
+
def add_possible_status status
|
|
197
|
+
existing_status = @possible_statuses.find_by_id status.id
|
|
144
198
|
|
|
145
|
-
|
|
146
|
-
|
|
199
|
+
if existing_status && existing_status.name != status.name
|
|
200
|
+
raise "Attempting to redefine the name for status #{status.id} from " \
|
|
201
|
+
"#{existing_status.name.inspect} to #{status.name.inspect}"
|
|
147
202
|
end
|
|
148
203
|
|
|
149
|
-
|
|
204
|
+
# If it isn't there, add it and go.
|
|
205
|
+
return @possible_statuses << status unless existing_status
|
|
150
206
|
|
|
151
|
-
|
|
152
|
-
|
|
207
|
+
unless status == existing_status
|
|
208
|
+
raise "Redefining status category for status #{status}. " \
|
|
209
|
+
"original: #{existing_status.category}, " \
|
|
210
|
+
"new: #{status.category}"
|
|
153
211
|
end
|
|
154
212
|
|
|
155
|
-
|
|
213
|
+
# We're registering one we already knew about. This may happen if someone specified a status_category_mapping
|
|
214
|
+
# for something that was already returned from jira. This isn't problem so just ignore it.
|
|
215
|
+
existing_status
|
|
156
216
|
end
|
|
157
217
|
|
|
158
|
-
def
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
issue.changes.each do |change|
|
|
162
|
-
next unless change.status?
|
|
218
|
+
def load_all_boards
|
|
219
|
+
Dir.foreach(@target_path) do |file|
|
|
220
|
+
next unless file =~ /^#{get_file_prefix}_board_(\d+)_configuration\.json$/
|
|
163
221
|
|
|
164
|
-
|
|
165
|
-
|
|
222
|
+
board_id = $1.to_i
|
|
223
|
+
load_board board_id: board_id, filename: "#{@target_path}#{file}"
|
|
166
224
|
end
|
|
167
|
-
|
|
225
|
+
raise "No boards found for #{get_file_prefix.inspect} in #{@target_path.inspect}" if @all_boards.empty?
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def load_board board_id:, filename:
|
|
229
|
+
board = Board.new(
|
|
230
|
+
raw: file_system.load_json(filename), possible_statuses: @possible_statuses
|
|
231
|
+
)
|
|
232
|
+
board.project_config = self
|
|
233
|
+
@all_boards[board_id] = board
|
|
168
234
|
end
|
|
169
235
|
|
|
170
236
|
def load_status_category_mappings
|
|
171
|
-
filename = "#{
|
|
237
|
+
filename = File.join @target_path, "#{get_file_prefix}_statuses.json"
|
|
238
|
+
|
|
172
239
|
# We may not always have this file. Load it if we can.
|
|
173
240
|
return unless File.exist? filename
|
|
174
241
|
|
|
175
|
-
|
|
176
|
-
.
|
|
177
|
-
|
|
178
|
-
.find_all { |status| status.global? }
|
|
179
|
-
.each { |status| add_possible_status status }
|
|
180
|
-
statuses
|
|
181
|
-
.find_all { |status| status.project_scoped? }
|
|
242
|
+
file_system
|
|
243
|
+
.load_json(filename)
|
|
244
|
+
.map { |snippet| Status.from_raw(snippet) }
|
|
182
245
|
.each { |status| add_possible_status status }
|
|
183
246
|
end
|
|
184
247
|
|
|
185
248
|
def load_sprints
|
|
186
|
-
|
|
187
|
-
next unless file =~
|
|
249
|
+
file_system.foreach(@target_path) do |file|
|
|
250
|
+
next unless file =~ /^#{get_file_prefix}_board_(\d+)_sprints_\d+.json$/
|
|
251
|
+
|
|
252
|
+
file_path = File.join(@target_path, file)
|
|
253
|
+
board = @all_boards[$1.to_i]
|
|
254
|
+
unless board
|
|
255
|
+
@exporter.file_system.log(
|
|
256
|
+
'Found sprint data but can\'t find a matching board in config. ' \
|
|
257
|
+
"File: #{file_path}, Boards: #{@all_boards.keys.sort}"
|
|
258
|
+
)
|
|
259
|
+
next
|
|
260
|
+
end
|
|
188
261
|
|
|
189
|
-
board_id = $1.to_i
|
|
190
262
|
timezone_offset = exporter.timezone_offset
|
|
191
|
-
|
|
192
|
-
|
|
263
|
+
file_system.load_json(file_path)['values']&.each do |json|
|
|
264
|
+
board.sprints << Sprint.new(raw: json, timezone_offset: timezone_offset)
|
|
193
265
|
end
|
|
194
266
|
end
|
|
195
267
|
|
|
@@ -198,56 +270,26 @@ class ProjectConfig
|
|
|
198
270
|
end
|
|
199
271
|
end
|
|
200
272
|
|
|
201
|
-
def
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
if status.project_scoped?
|
|
205
|
-
# If the project specific status doesn't change anything then we don't care whether it's
|
|
206
|
-
# our project or not.
|
|
207
|
-
return if existing_status && existing_status.category_name == status.category_name
|
|
273
|
+
def load_project_metadata
|
|
274
|
+
filename = File.join @target_path, "#{get_file_prefix}_meta.json"
|
|
275
|
+
json = file_system.load_json(filename)
|
|
208
276
|
|
|
209
|
-
|
|
277
|
+
@data_version = json['version'] || 1
|
|
210
278
|
|
|
211
|
-
|
|
212
|
-
|
|
279
|
+
start = to_time(json['date_start'] || json['time_start']) # date_start is the current format. Time is the old.
|
|
280
|
+
stop = to_time(json['date_end'] || json['time_end'], end_of_day: true)
|
|
213
281
|
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
282
|
+
# If no_earlier_than was set then make sure it's applied here.
|
|
283
|
+
if download_config
|
|
284
|
+
download_config.run
|
|
285
|
+
no_earlier = download_config.no_earlier_than
|
|
286
|
+
if no_earlier
|
|
287
|
+
no_earlier = to_time(no_earlier.to_s)
|
|
288
|
+
start = no_earlier if start < no_earlier
|
|
289
|
+
end
|
|
218
290
|
end
|
|
219
291
|
|
|
220
|
-
|
|
221
|
-
return @possible_statuses << status unless existing_status
|
|
222
|
-
|
|
223
|
-
# We're registering the same one twice. Shouldn't be possible with the new status API but it
|
|
224
|
-
# did happen with the project specific one.
|
|
225
|
-
return if status.category_name == existing_status.category_name
|
|
226
|
-
|
|
227
|
-
# If we got this far then someone has called status_category_mapping and is attempting to
|
|
228
|
-
# change the category.
|
|
229
|
-
raise "Redefining status category #{status} with #{existing_status}. Was one set in the config?"
|
|
230
|
-
end
|
|
231
|
-
|
|
232
|
-
def raise_ambiguous_project_id
|
|
233
|
-
raise 'Ambiguous project id: There is a project specific status that could affect out calculations. ' \
|
|
234
|
-
'We are unable to automatically detect the id of the project so you will have to set it manually ' \
|
|
235
|
-
'in the configuration like: "project id: 5"'
|
|
236
|
-
end
|
|
237
|
-
|
|
238
|
-
def find_status name:
|
|
239
|
-
@possible_statuses.find_by_name name
|
|
240
|
-
end
|
|
241
|
-
|
|
242
|
-
def load_project_metadata
|
|
243
|
-
filename = File.join @target_path, "#{file_prefix}_meta.json"
|
|
244
|
-
json = JSON.parse(file_system.load(filename))
|
|
245
|
-
|
|
246
|
-
@data_version = json['version'] || 1
|
|
247
|
-
|
|
248
|
-
start = json['date_start'] || json['time_start'] # date_start is the current format. Time is the old.
|
|
249
|
-
stop = json['date_end'] || json['time_end']
|
|
250
|
-
@time_range = to_time(start)..to_time(stop, end_of_day: true)
|
|
292
|
+
@time_range = start..stop
|
|
251
293
|
|
|
252
294
|
@jira_url = json['jira_url']
|
|
253
295
|
rescue Errno::ENOENT
|
|
@@ -304,11 +346,15 @@ class ProjectConfig
|
|
|
304
346
|
raise 'This is an aggregated project and issues should have been included with the include_issues_from ' \
|
|
305
347
|
'declaration but none are here. Check your config.'
|
|
306
348
|
end
|
|
349
|
+
|
|
350
|
+
return @issues = [] if @exporter.downloading?
|
|
351
|
+
raise 'No data found. Must do a download before an export' unless data_downloaded?
|
|
352
|
+
|
|
307
353
|
load_data if all_boards.empty?
|
|
308
354
|
|
|
309
355
|
timezone_offset = exporter.timezone_offset
|
|
310
356
|
|
|
311
|
-
issues_path = File.join @target_path, "#{
|
|
357
|
+
issues_path = File.join @target_path, "#{get_file_prefix}_issues"
|
|
312
358
|
if File.exist?(issues_path) && File.directory?(issues_path)
|
|
313
359
|
issues = load_issues_from_issues_directory path: issues_path, timezone_offset: timezone_offset
|
|
314
360
|
else
|
|
@@ -370,7 +416,7 @@ class ProjectConfig
|
|
|
370
416
|
default_board = nil
|
|
371
417
|
|
|
372
418
|
group_filenames_and_board_ids(path: path).each do |filename, board_ids|
|
|
373
|
-
content = file_system.
|
|
419
|
+
content = file_system.load_json(File.join(path, filename))
|
|
374
420
|
if board_ids == :unknown
|
|
375
421
|
boards = [(default_board ||= find_default_board)]
|
|
376
422
|
else
|
|
@@ -378,7 +424,7 @@ class ProjectConfig
|
|
|
378
424
|
end
|
|
379
425
|
|
|
380
426
|
boards.each do |board|
|
|
381
|
-
issues << Issue.new(raw:
|
|
427
|
+
issues << Issue.new(raw: content, timezone_offset: timezone_offset, board: board)
|
|
382
428
|
end
|
|
383
429
|
end
|
|
384
430
|
|
|
@@ -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
|
|
data/lib/jirametrics/status.rb
CHANGED
|
@@ -4,29 +4,57 @@ require 'jirametrics/value_equality'
|
|
|
4
4
|
|
|
5
5
|
class Status
|
|
6
6
|
include ValueEquality
|
|
7
|
-
attr_reader :id, :
|
|
7
|
+
attr_reader :id, :project_id, :category
|
|
8
8
|
attr_accessor :name
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
class Category
|
|
11
|
+
attr_reader :id, :name, :key
|
|
12
|
+
|
|
13
|
+
def initialize id:, name:, key:
|
|
14
|
+
@id = id
|
|
15
|
+
@name = name
|
|
16
|
+
@key = key
|
|
17
|
+
end
|
|
16
18
|
|
|
17
|
-
|
|
19
|
+
def to_s
|
|
20
|
+
"#{name.inspect}:#{id.inspect}"
|
|
21
|
+
end
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
23
|
+
def new? = (@key == 'new')
|
|
24
|
+
def indeterminate? = (@key == 'indeterminate')
|
|
25
|
+
def done? = (@key == 'done')
|
|
26
|
+
end
|
|
22
27
|
|
|
28
|
+
def self.from_raw raw
|
|
23
29
|
category_config = raw['statusCategory']
|
|
24
|
-
@category_name = category_config['name']
|
|
25
|
-
@category_id = category_config['id'].to_i
|
|
26
30
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
31
|
+
legal_keys = %w[new indeterminate done]
|
|
32
|
+
unless legal_keys.include? category_config['key']
|
|
33
|
+
puts "Category key #{category_config['key'].inspect} should be one of #{legal_keys.inspect}. Found:\n" \
|
|
34
|
+
"#{category_config}"
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
Status.new(
|
|
38
|
+
name: raw['name'],
|
|
39
|
+
id: raw['id'].to_i,
|
|
40
|
+
category_name: category_config['name'],
|
|
41
|
+
category_id: category_config['id'].to_i,
|
|
42
|
+
category_key: category_config['key'],
|
|
43
|
+
project_id: raw['scope']&.[]('project')&.[]('id'),
|
|
44
|
+
artificial: false
|
|
45
|
+
)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize name:, id:, category_name:, category_id:, category_key:, project_id: nil, artificial: true
|
|
49
|
+
# These checks are needed because nils used to be possible and now they aren't.
|
|
50
|
+
raise 'id cannot be nil' if id.nil?
|
|
51
|
+
raise 'category_id cannot be nil' if category_id.nil?
|
|
52
|
+
|
|
53
|
+
@name = name
|
|
54
|
+
@id = id
|
|
55
|
+
@category = Category.new id: category_id, name: category_name, key: category_key
|
|
56
|
+
@project_id = project_id
|
|
57
|
+
@artificial = artificial
|
|
30
58
|
end
|
|
31
59
|
|
|
32
60
|
def project_scoped?
|
|
@@ -38,18 +66,26 @@ class Status
|
|
|
38
66
|
end
|
|
39
67
|
|
|
40
68
|
def to_s
|
|
41
|
-
|
|
42
|
-
" id=#{@id.inspect}," \
|
|
43
|
-
" category_name=#{@category_name.inspect}," \
|
|
44
|
-
" category_id=#{@category_id.inspect}," \
|
|
45
|
-
" project_id=#{@project_id}"
|
|
46
|
-
result << ' artificial' if artificial?
|
|
47
|
-
result << ')'
|
|
48
|
-
result
|
|
69
|
+
"#{name.inspect}:#{id.inspect}"
|
|
49
70
|
end
|
|
50
71
|
|
|
51
72
|
def artificial?
|
|
52
|
-
@
|
|
73
|
+
@artificial
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def == other
|
|
77
|
+
@id == other.id && @name == other.name && @category.id == other.category.id && @category.name == other.category.name
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def inspect
|
|
81
|
+
result = []
|
|
82
|
+
result << "Status(name: #{@name.inspect}"
|
|
83
|
+
result << "id: #{@id.inspect}"
|
|
84
|
+
result << "project_id: #{@project_id}" if @project_id
|
|
85
|
+
category = self.category
|
|
86
|
+
result << "category: {name:#{category.name.inspect}, id: #{category.id.inspect}, key: #{category.key.inspect}}"
|
|
87
|
+
result << 'artificial' if artificial?
|
|
88
|
+
result.join(', ') << ')'
|
|
53
89
|
end
|
|
54
90
|
|
|
55
91
|
def value_equality_ignored_variables
|