jirametrics 2.31 → 3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/lib/jirametrics/aggregate_config.rb +42 -29
- data/lib/jirametrics/aging_work_bar_chart.rb +52 -40
- data/lib/jirametrics/aging_work_in_progress_chart.rb +65 -60
- data/lib/jirametrics/aging_work_table.rb +36 -33
- data/lib/jirametrics/anonymizer.rb +31 -86
- data/lib/jirametrics/atlassian_document_format.rb +11 -4
- data/lib/jirametrics/blocked_stalled_by_date_builder.rb +64 -0
- data/lib/jirametrics/blocked_stalled_change.rb +5 -1
- data/lib/jirametrics/blocked_stalled_change_stream_builder.rb +194 -0
- data/lib/jirametrics/board.rb +23 -9
- data/lib/jirametrics/board_config.rb +0 -7
- data/lib/jirametrics/board_movement_calculator.rb +24 -21
- data/lib/jirametrics/cfd_data_builder.rb +46 -44
- data/lib/jirametrics/change_item.rb +19 -7
- data/lib/jirametrics/chart_base.rb +51 -65
- data/lib/jirametrics/cumulative_flow_diagram.rb +60 -47
- data/lib/jirametrics/cycle_time_config.rb +35 -46
- data/lib/jirametrics/cycletime_histogram.rb +7 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +7 -0
- data/lib/jirametrics/daily_view.rb +83 -61
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +24 -9
- data/lib/jirametrics/daily_wip_chart.rb +72 -45
- data/lib/jirametrics/data_quality_report.rb +70 -66
- data/lib/jirametrics/dependency_chart.rb +40 -31
- data/lib/jirametrics/download_config.rb +0 -3
- data/lib/jirametrics/downloader.rb +39 -14
- data/lib/jirametrics/downloader_for_cloud.rb +110 -74
- data/lib/jirametrics/estimate_accuracy_chart.rb +13 -13
- data/lib/jirametrics/examples/standard_project.rb +2 -2
- data/lib/jirametrics/expedited_chart.rb +47 -37
- data/lib/jirametrics/exporter.rb +203 -12
- data/lib/jirametrics/file_config.rb +19 -12
- data/lib/jirametrics/file_system.rb +16 -10
- data/lib/jirametrics/flow_efficiency_calculator.rb +62 -0
- data/lib/jirametrics/flow_efficiency_scatterplot.rb +4 -2
- data/lib/jirametrics/github_gateway.rb +52 -12
- data/lib/jirametrics/groupable_issue_chart.rb +3 -0
- data/lib/jirametrics/html/cumulative_flow_diagram.erb +3 -3
- data/lib/jirametrics/html/index.css +10 -4
- data/lib/jirametrics/html_report_config.rb +9 -29
- data/lib/jirametrics/issue.rb +293 -411
- data/lib/jirametrics/issue_collection.rb +1 -0
- data/lib/jirametrics/issue_printer.rb +60 -23
- data/lib/jirametrics/jira_gateway.rb +56 -18
- data/lib/jirametrics/mcp_server.rb +226 -210
- data/lib/jirametrics/project_config.rb +206 -133
- data/lib/jirametrics/pull_request_cycle_time_histogram.rb +1 -20
- data/lib/jirametrics/pull_request_cycle_time_scatterplot.rb +11 -33
- data/lib/jirametrics/rules.rb +1 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +1 -3
- data/lib/jirametrics/sprint_burndown.rb +142 -242
- data/lib/jirametrics/sprint_count_measure.rb +42 -0
- data/lib/jirametrics/sprint_issue_change_data.rb +1 -0
- data/lib/jirametrics/sprint_points_measure.rb +62 -0
- data/lib/jirametrics/sprint_summary_stats.rb +16 -0
- data/lib/jirametrics/status_collection.rb +29 -5
- data/lib/jirametrics/stitcher.rb +21 -16
- data/lib/jirametrics/throughput_chart.rb +38 -24
- data/lib/jirametrics/time_based_chart.rb +65 -0
- data/lib/jirametrics/time_based_histogram.rb +30 -23
- data/lib/jirametrics/time_based_scatterplot.rb +11 -2
- data/lib/jirametrics/trend_line_calculator.rb +2 -2
- data/lib/jirametrics/wip_by_column_chart.rb +75 -34
- data/lib/jirametrics.rb +35 -4
- metadata +13 -20
data/lib/jirametrics/issue.rb
CHANGED
|
@@ -26,7 +26,7 @@ class Issue
|
|
|
26
26
|
return unless @raw['fields']
|
|
27
27
|
|
|
28
28
|
# If this is an older pull of data then comments may not be there.
|
|
29
|
-
load_comments_into_changes if
|
|
29
|
+
load_comments_into_changes if raw_fields['comment']
|
|
30
30
|
|
|
31
31
|
# It might appear that Jira already returns these in order but we've found different
|
|
32
32
|
# versions of Server/Cloud return the changelog in different orders so we sort them.
|
|
@@ -36,40 +36,46 @@ class Issue
|
|
|
36
36
|
# not showing up in the change log. Create some artificial entries to capture those.
|
|
37
37
|
@changes = [
|
|
38
38
|
fabricate_change(field_name: 'status'),
|
|
39
|
-
fabricate_change(field_name: 'priority')
|
|
39
|
+
fabricate_change(field_name: 'priority'),
|
|
40
|
+
fabricate_sprint_change
|
|
40
41
|
].compact + @changes
|
|
41
|
-
rescue # rubocop:disable Style/RescueStandardError
|
|
42
|
+
rescue # rubocop:disable Style/RescueStandardError -- deliberately broad: any failure is re-raised with context
|
|
42
43
|
# All we're doing is adding information to the existing exception and letting it propogate up
|
|
43
44
|
raise "Unable to initialize #{raw['key']}"
|
|
44
45
|
end
|
|
45
46
|
|
|
46
47
|
def key = @raw['key']
|
|
47
48
|
|
|
48
|
-
|
|
49
|
-
|
|
49
|
+
# 'fields' is the one part of the issue JSON that must always be present -- its absence means the
|
|
50
|
+
# payload is malformed. (Linked-issue fragments legitimately have no fields; that case is guarded in
|
|
51
|
+
# initialize, which returns before any of the fields-based accessors below can run.)
|
|
52
|
+
def raw_fields
|
|
53
|
+
@raw['fields'] || raise("Issue(#{@raw['key']}) has no 'fields'; is this an Issue JSON?")
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def type = raw_fields['issuetype']['name']
|
|
57
|
+
def type_icon_url = raw_fields['issuetype']['iconUrl']
|
|
50
58
|
|
|
51
59
|
def priority_name = @raw.dig('fields', 'priority', 'name')
|
|
52
60
|
def priority_url = @raw.dig('fields', 'priority', 'iconUrl')
|
|
53
61
|
|
|
54
|
-
def summary =
|
|
62
|
+
def summary = raw_fields['summary']
|
|
55
63
|
|
|
56
|
-
def labels =
|
|
64
|
+
def labels = raw_fields['labels'] || []
|
|
57
65
|
|
|
58
|
-
def author =
|
|
66
|
+
def author = raw_fields['creator']&.[]('displayName') || ''
|
|
59
67
|
|
|
60
|
-
def resolution =
|
|
68
|
+
def resolution = raw_fields['resolution']&.[]('name')
|
|
61
69
|
|
|
62
70
|
def status
|
|
63
|
-
@status
|
|
71
|
+
@status ||= Status.from_raw(raw_fields['status'])
|
|
64
72
|
@status
|
|
65
73
|
end
|
|
66
74
|
|
|
67
|
-
|
|
68
|
-
@status = status
|
|
69
|
-
end
|
|
75
|
+
attr_writer :status
|
|
70
76
|
|
|
71
77
|
def due_date
|
|
72
|
-
text =
|
|
78
|
+
text = raw_fields['duedate']
|
|
73
79
|
text.nil? ? nil : Date.parse(text)
|
|
74
80
|
end
|
|
75
81
|
|
|
@@ -79,11 +85,11 @@ class Issue
|
|
|
79
85
|
end
|
|
80
86
|
|
|
81
87
|
def key_as_i
|
|
82
|
-
|
|
88
|
+
/-(?<number>\d+)$/ =~ key ? number.to_i : 0
|
|
83
89
|
end
|
|
84
90
|
|
|
85
91
|
def component_names
|
|
86
|
-
|
|
92
|
+
raw_fields['components']&.collect { |component| component['name'] } || []
|
|
87
93
|
end
|
|
88
94
|
|
|
89
95
|
def first_time_in_status *status_names
|
|
@@ -103,7 +109,7 @@ class Issue
|
|
|
103
109
|
next unless change.labels?
|
|
104
110
|
|
|
105
111
|
change_labels = change.value.split
|
|
106
|
-
return change if change_labels.
|
|
112
|
+
return change if change_labels.intersect?(labels)
|
|
107
113
|
end
|
|
108
114
|
nil
|
|
109
115
|
end
|
|
@@ -171,27 +177,8 @@ class Issue
|
|
|
171
177
|
end
|
|
172
178
|
|
|
173
179
|
def find_or_create_status id:, name:
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
unless status
|
|
177
|
-
# Have to pull this list before the call to fabricate or else the warning will incorrectly
|
|
178
|
-
# list this status as one it actually found
|
|
179
|
-
found_statuses = board.possible_statuses.to_s
|
|
180
|
-
|
|
181
|
-
status = board.possible_statuses.fabricate_status_for id: id, name: name
|
|
182
|
-
|
|
183
|
-
message = +'The history for issue '
|
|
184
|
-
message << key
|
|
185
|
-
message << ' references the status ('
|
|
186
|
-
message << "#{name.inspect}:#{id.inspect}"
|
|
187
|
-
message << ') that can\'t be found. We are guessing that this belongs to the '
|
|
188
|
-
message << status.category.to_s
|
|
189
|
-
message << ' status category but that may be wrong. See https://jirametrics.org/faq/#q1 for more '
|
|
190
|
-
message << 'details on defining statuses.'
|
|
191
|
-
board.project_config.file_system.warning message, more: "The statuses we did find are: #{found_statuses}"
|
|
192
|
-
end
|
|
193
|
-
|
|
194
|
-
status
|
|
180
|
+
board.possible_statuses.find_by_id(id) ||
|
|
181
|
+
board.possible_statuses.fabricate_status_for(id: id, name: name, example_issue_key: key)
|
|
195
182
|
end
|
|
196
183
|
|
|
197
184
|
def first_status_change_after_created
|
|
@@ -213,22 +200,24 @@ class Issue
|
|
|
213
200
|
visible_status_ids = board.visible_columns.collect(&:status_ids).flatten
|
|
214
201
|
return first_time_in_status(*visible_status_ids) unless board.scrum?
|
|
215
202
|
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
#
|
|
219
|
-
|
|
220
|
-
|
|
203
|
+
# On scrum boards an issue is only visible when its status is in a visible column AND it is in an
|
|
204
|
+
# active sprint. Each source below is a moment when the second condition became true while the first
|
|
205
|
+
# already held; the earliest of them is when it first became visible.
|
|
206
|
+
candidates = visible_status_changes_in_active_sprint(visible_status_ids) +
|
|
207
|
+
sprint_entries_while_in_visible_status(visible_status_ids)
|
|
208
|
+
candidates.min_by(&:time)
|
|
209
|
+
end
|
|
221
210
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
211
|
+
def visible_status_changes_in_active_sprint visible_status_ids
|
|
212
|
+
status_changes.select do |change|
|
|
213
|
+
visible_status_ids.include?(change.value_id) && in_active_sprint_at?(change.time)
|
|
225
214
|
end
|
|
215
|
+
end
|
|
226
216
|
|
|
227
|
-
|
|
228
|
-
|
|
217
|
+
def sprint_entries_while_in_visible_status visible_status_ids
|
|
218
|
+
sprint_entry_events.filter_map do |effective_time, representative_change|
|
|
219
|
+
representative_change if in_visible_status_at?(effective_time, visible_status_ids)
|
|
229
220
|
end
|
|
230
|
-
|
|
231
|
-
candidates.min_by(&:time)
|
|
232
221
|
end
|
|
233
222
|
|
|
234
223
|
def reasons_not_visible_on_board
|
|
@@ -244,87 +233,113 @@ class Issue
|
|
|
244
233
|
reasons_not_visible_on_board.empty?
|
|
245
234
|
end
|
|
246
235
|
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
#
|
|
236
|
+
# A sprint the issue was added to: its start (all we care about is whether it started) and the change
|
|
237
|
+
# that added it.
|
|
238
|
+
SprintMembership = Data.define(:sprint_id, :sprint_start, :change)
|
|
239
|
+
|
|
240
|
+
# Like SprintMembership but also tracks when this issue was added, used while pairing sprint entries
|
|
241
|
+
# and exits in #sprint_entry_events.
|
|
242
|
+
TrackedSprint = Data.define(:sprint_id, :sprint_start, :add_time, :change)
|
|
243
|
+
|
|
244
|
+
# If this issue is ever in an active sprint, returns the change where it was first added to that
|
|
245
|
+
# sprint (whether or not the sprint was active at that moment). It's a reasonable proxy for 'ready'
|
|
246
|
+
# when a team has no explicit 'ready' status -- you'd be better off with one, but sometimes that's
|
|
247
|
+
# not an option. Only valid for Scrum boards.
|
|
248
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
252
249
|
def first_time_added_to_active_sprint
|
|
250
|
+
# Why are complexity warnings disabled? Bottom line is that we felt the code would be less readable
|
|
251
|
+
# if we split it, so it's remaining as one longer method.
|
|
252
|
+
|
|
253
253
|
unless board.scrum?
|
|
254
254
|
raise 'first_time_added_to_active_sprint() can only be used with Scrum boards: ' \
|
|
255
255
|
"issue=#{key}, board=#{board.inspect}"
|
|
256
256
|
end
|
|
257
|
-
data_clazz = Struct.new(:sprint_id, :sprint_start, :sprint_stop, :change)
|
|
258
|
-
|
|
259
257
|
matching_changes = []
|
|
260
|
-
|
|
258
|
+
memberships = []
|
|
261
259
|
|
|
262
260
|
@changes.each do |change|
|
|
263
261
|
next unless change.sprint?
|
|
264
262
|
|
|
265
263
|
added_sprint_ids = change.value_id - change.old_value_id
|
|
266
264
|
added_sprint_ids.each do |id|
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
data.change = change
|
|
270
|
-
data.sprint_start, data.sprint_stop = find_sprint_start_end(sprint_id: id, change: change)
|
|
271
|
-
all_datas << data
|
|
265
|
+
sprint_start = find_sprint_start_end(sprint_id: id, change: change).first
|
|
266
|
+
memberships << SprintMembership.new(sprint_id: id, sprint_start:, change:)
|
|
272
267
|
end
|
|
273
268
|
|
|
274
269
|
removed_sprint_ids = change.old_value_id - change.value_id
|
|
275
270
|
removed_sprint_ids.each do |id|
|
|
276
|
-
|
|
271
|
+
membership = memberships.find { |m| m.sprint_id == id }
|
|
277
272
|
# It's possible for an issue to be created inside a sprint and therefore for
|
|
278
273
|
# that add-to-sprint not show in the history.
|
|
279
|
-
next unless
|
|
274
|
+
next unless membership
|
|
280
275
|
|
|
281
|
-
|
|
282
|
-
next
|
|
276
|
+
memberships.delete(membership)
|
|
277
|
+
next unless counts_as_sprint_start? membership
|
|
278
|
+
next if membership.sprint_start >= change.time
|
|
283
279
|
|
|
284
|
-
matching_changes <<
|
|
280
|
+
matching_changes << membership.change
|
|
285
281
|
end
|
|
286
282
|
end
|
|
287
283
|
|
|
288
284
|
# There can't be any more removes so whatever is left is a valid option
|
|
289
285
|
# Now all we care about is if the sprint has started.
|
|
290
|
-
|
|
291
|
-
matching_changes <<
|
|
286
|
+
memberships.each do |membership|
|
|
287
|
+
matching_changes << membership.change if counts_as_sprint_start? membership
|
|
292
288
|
end
|
|
293
289
|
|
|
294
290
|
matching_changes.min_by(&:time)
|
|
295
291
|
end
|
|
292
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
293
|
+
|
|
294
|
+
# Being added to a sprint only counts as a "start" if that sprint had actually started and the issue
|
|
295
|
+
# wasn't already done when it did. Joining a sprint you've already finished (e.g. a done issue swept
|
|
296
|
+
# into a later sprint) is bookkeeping on completed work, not the moment work began.
|
|
297
|
+
def counts_as_sprint_start? membership
|
|
298
|
+
return false if membership.sprint_start.nil?
|
|
299
|
+
|
|
300
|
+
!in_done_status_at?(membership.sprint_start)
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Was the issue sitting in a done-category status at the given moment? Keys off the status category
|
|
304
|
+
# (not the status name), so a status merely called "Done" that Jira categorises otherwise won't count.
|
|
305
|
+
def in_done_status_at? time
|
|
306
|
+
last = status_changes.reverse.find { |change| change.time <= time }
|
|
307
|
+
return false unless last
|
|
308
|
+
|
|
309
|
+
find_or_create_status(id: last.value_id, name: last.value).category.done?
|
|
310
|
+
end
|
|
296
311
|
|
|
297
312
|
def find_sprint_start_end sprint_id:, change:
|
|
298
313
|
# There are two different places that sprint data could be found. In theory all
|
|
299
314
|
# sprints would be found in both places. In practice, sometimes what we need is
|
|
300
315
|
# in one or the other but not both.
|
|
316
|
+
times = sprint_times_from_board(sprint_id) || sprint_times_from_issue(sprint_id, change)
|
|
317
|
+
|
|
318
|
+
# If both came up empty then the sprint can't be found anywhere, so we pretend that it never
|
|
319
|
+
# started. Is this guaranteed to be true? No. In theory if all issues were removed from
|
|
320
|
+
# an active sprint then it would also disappear, even though it had started. Nothing we
|
|
321
|
+
# can do to detect that edge-case though.
|
|
322
|
+
times || [nil, nil]
|
|
323
|
+
end
|
|
301
324
|
|
|
302
|
-
|
|
303
|
-
|
|
325
|
+
# First look in the actual sprints json. If any issues are in this sprint then it should be here.
|
|
326
|
+
def sprint_times_from_board sprint_id
|
|
304
327
|
sprint = board.sprints.find { |s| s.id == sprint_id }
|
|
305
|
-
|
|
306
|
-
|
|
328
|
+
return nil unless sprint
|
|
329
|
+
return [nil, nil] if sprint.future?
|
|
307
330
|
|
|
308
|
-
|
|
309
|
-
|
|
331
|
+
[sprint.start_time, sprint.completed_time]
|
|
332
|
+
end
|
|
310
333
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
334
|
+
# Then look at the sprints inside the issue. Even though the field id may be specified, that custom
|
|
335
|
+
# field may not be present. This happens if it was in that sprint but was then removed, whether or
|
|
336
|
+
# not that sprint had ever started.
|
|
337
|
+
def sprint_times_from_issue sprint_id, change
|
|
314
338
|
sprint_data = raw['fields'][change.field_id]&.find { |sd| sd['id'].to_i == sprint_id }
|
|
315
|
-
|
|
316
|
-
|
|
339
|
+
return nil unless sprint_data
|
|
340
|
+
return [nil, nil] if sprint_data['state'] == 'future'
|
|
317
341
|
|
|
318
|
-
|
|
319
|
-
stop = parse_time(sprint_data['completeDate'])
|
|
320
|
-
return [start, stop]
|
|
321
|
-
end
|
|
322
|
-
|
|
323
|
-
# If we got this far then the sprint can't be found anywhere, so we pretend that it never
|
|
324
|
-
# started. Is this guaranteed to be true? No. In theory if all issues were removed from
|
|
325
|
-
# an active sprint then it would also disappear, even though it had started. Nothing we
|
|
326
|
-
# can do to detect that edge-case though.
|
|
327
|
-
[nil, nil]
|
|
342
|
+
[parse_time(sprint_data['startDate']), parse_time(sprint_data['completeDate'])]
|
|
328
343
|
end
|
|
329
344
|
|
|
330
345
|
def parse_time text
|
|
@@ -339,7 +354,8 @@ class Issue
|
|
|
339
354
|
|
|
340
355
|
def created
|
|
341
356
|
# This nil check shouldn't be necessary and yet we've seen one case where it was.
|
|
342
|
-
|
|
357
|
+
created_text = raw_fields['created']
|
|
358
|
+
parse_time created_text if created_text
|
|
343
359
|
end
|
|
344
360
|
|
|
345
361
|
def time_created
|
|
@@ -347,23 +363,23 @@ class Issue
|
|
|
347
363
|
end
|
|
348
364
|
|
|
349
365
|
def updated
|
|
350
|
-
parse_time
|
|
366
|
+
parse_time raw_fields['updated']
|
|
351
367
|
end
|
|
352
368
|
|
|
353
369
|
def first_resolution
|
|
354
|
-
@changes.find
|
|
370
|
+
@changes.find(&:resolution?)
|
|
355
371
|
end
|
|
356
372
|
|
|
357
373
|
def last_resolution
|
|
358
|
-
@changes.reverse.find
|
|
374
|
+
@changes.reverse.find(&:resolution?)
|
|
359
375
|
end
|
|
360
376
|
|
|
361
377
|
def assigned_to
|
|
362
|
-
|
|
378
|
+
raw_fields['assignee']&.[]('displayName')
|
|
363
379
|
end
|
|
364
380
|
|
|
365
381
|
def assigned_to_icon_url
|
|
366
|
-
|
|
382
|
+
raw_fields['assignee']&.[]('avatarUrls')&.[]('16x16')
|
|
367
383
|
end
|
|
368
384
|
|
|
369
385
|
# Many test failures are simply unreadable because the default inspect on this class goes
|
|
@@ -382,197 +398,22 @@ class Issue
|
|
|
382
398
|
# If the day was stalled for the entire day then it's stalled
|
|
383
399
|
# If there was no activity at all on this day then the last change from the previous day carries over
|
|
384
400
|
def blocked_stalled_by_date date_range:, chart_end_time:, settings: nil
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
current_date = change.time.to_date
|
|
390
|
-
|
|
391
|
-
winning_change, _last_change = results[current_date]
|
|
392
|
-
if winning_change.nil? ||
|
|
393
|
-
change.blocked? ||
|
|
394
|
-
(change.active? && (winning_change.active? || winning_change.stalled?)) ||
|
|
395
|
-
(change.stalled? && winning_change.stalled?)
|
|
396
|
-
|
|
397
|
-
winning_change = change
|
|
398
|
-
end
|
|
399
|
-
|
|
400
|
-
results[current_date] = [winning_change, change]
|
|
401
|
-
end
|
|
402
|
-
|
|
403
|
-
last_populated_date = nil
|
|
404
|
-
(results.keys.min..results.keys.max).each do |date|
|
|
405
|
-
if results.key? date
|
|
406
|
-
last_populated_date = date
|
|
407
|
-
else
|
|
408
|
-
_winner, last = results[last_populated_date]
|
|
409
|
-
results[date] = [last, last]
|
|
410
|
-
end
|
|
411
|
-
end
|
|
412
|
-
results = results.transform_values(&:first)
|
|
413
|
-
|
|
414
|
-
# The requested date range may span outside the actual changes we find in the changelog
|
|
415
|
-
date_of_first_change = blocked_stalled_changes[0].time.to_date
|
|
416
|
-
date_of_last_change = blocked_stalled_changes[-1].time.to_date
|
|
417
|
-
date_range.each do |date|
|
|
418
|
-
results[date] = blocked_stalled_changes[0] if date < date_of_first_change
|
|
419
|
-
results[date] = blocked_stalled_changes[-1] if date > date_of_last_change
|
|
420
|
-
end
|
|
421
|
-
|
|
422
|
-
# To make the code simpler, we've been accumulating data for every date. Now remove anything
|
|
423
|
-
# that isn't in the requested date_range
|
|
424
|
-
results.select! { |date, _value| date_range.include? date }
|
|
425
|
-
|
|
426
|
-
results
|
|
401
|
+
BlockedStalledByDateBuilder.new(
|
|
402
|
+
blocked_stalled_changes: blocked_stalled_changes(end_time: chart_end_time, settings: settings),
|
|
403
|
+
date_range: date_range
|
|
404
|
+
).build
|
|
427
405
|
end
|
|
428
406
|
|
|
429
407
|
def blocked_stalled_changes end_time:, settings: nil
|
|
430
408
|
settings ||= @board.project_config.settings
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
blocking_issue_keys = []
|
|
440
|
-
|
|
441
|
-
result = []
|
|
442
|
-
previous_was_active = false # Must start as false so that the creation will insert an :active
|
|
443
|
-
previous_change_time = created
|
|
444
|
-
|
|
445
|
-
blocking_status = nil
|
|
446
|
-
blocking_is_blocked = false
|
|
447
|
-
flag = nil
|
|
448
|
-
flag_reason = nil
|
|
449
|
-
|
|
450
|
-
# This mock change is to force the writing of one last entry at the end of the time range.
|
|
451
|
-
# By doing this, we're able to eliminate a lot of duplicated code in charts.
|
|
452
|
-
mock_change = ChangeItem.new time: end_time, artificial: true, raw: { 'field' => '' }, author_raw: nil
|
|
453
|
-
|
|
454
|
-
(changes + [mock_change]).each do |change|
|
|
455
|
-
previous_was_active = false if check_for_stalled(
|
|
456
|
-
change_time: change.time,
|
|
457
|
-
previous_change_time: previous_change_time,
|
|
458
|
-
stalled_threshold: stalled_threshold,
|
|
459
|
-
blocking_stalled_changes: result
|
|
460
|
-
)
|
|
461
|
-
|
|
462
|
-
if change.flagged? && flagged_means_blocked
|
|
463
|
-
flag, flag_reason = blocked_stalled_changes_flag_logic change
|
|
464
|
-
elsif change.status?
|
|
465
|
-
blocking_status = nil
|
|
466
|
-
blocking_is_blocked = false
|
|
467
|
-
if blocked_statuses.find_by_id(change.value_id)
|
|
468
|
-
blocking_status = change.value
|
|
469
|
-
blocking_is_blocked = true
|
|
470
|
-
elsif stalled_statuses.find_by_id(change.value_id)
|
|
471
|
-
blocking_status = change.value
|
|
472
|
-
end
|
|
473
|
-
elsif change.link?
|
|
474
|
-
# Example: "This issue is satisfied by ANON-30465"
|
|
475
|
-
unless /^This (?<_>issue|work item) (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
|
|
476
|
-
puts "Issue(#{key}) Can't parse link text: #{change.value || change.old_value}"
|
|
477
|
-
next
|
|
478
|
-
end
|
|
479
|
-
|
|
480
|
-
if blocked_link_texts.include? link_text
|
|
481
|
-
if change.value
|
|
482
|
-
blocking_issue_keys << issue_key
|
|
483
|
-
else
|
|
484
|
-
blocking_issue_keys.delete issue_key
|
|
485
|
-
end
|
|
486
|
-
end
|
|
487
|
-
end
|
|
488
|
-
|
|
489
|
-
new_change = BlockedStalledChange.new(
|
|
490
|
-
flagged: flag,
|
|
491
|
-
flag_reason: flag_reason,
|
|
492
|
-
status: blocking_status,
|
|
493
|
-
status_is_blocking: blocking_status.nil? || blocking_is_blocked,
|
|
494
|
-
blocking_issue_keys: (blocking_issue_keys.empty? ? nil : blocking_issue_keys.dup),
|
|
495
|
-
time: change.time
|
|
496
|
-
)
|
|
497
|
-
|
|
498
|
-
# We don't want to dump two actives in a row as that would just be noise. Unless this is
|
|
499
|
-
# the mock change, which we always want to dump
|
|
500
|
-
result << new_change if !new_change.active? || !previous_was_active || change == mock_change
|
|
501
|
-
|
|
502
|
-
previous_was_active = new_change.active?
|
|
503
|
-
previous_change_time = change.time
|
|
504
|
-
end
|
|
505
|
-
|
|
506
|
-
if result.size >= 2
|
|
507
|
-
# The existence of the mock entry will mess with the stalled count as it will wake everything
|
|
508
|
-
# back up. This hack will clean up appropriately.
|
|
509
|
-
hack = result.pop
|
|
510
|
-
result << BlockedStalledChange.new(
|
|
511
|
-
flagged: hack.flag,
|
|
512
|
-
flag_reason: hack.flag_reason,
|
|
513
|
-
status: hack.status,
|
|
514
|
-
status_is_blocking: hack.status_is_blocking,
|
|
515
|
-
blocking_issue_keys: hack.blocking_issue_keys,
|
|
516
|
-
time: hack.time,
|
|
517
|
-
stalled_days: result[-1].stalled_days
|
|
518
|
-
)
|
|
519
|
-
end
|
|
520
|
-
|
|
521
|
-
result
|
|
522
|
-
end
|
|
523
|
-
|
|
524
|
-
def blocked_stalled_changes_flag_logic change
|
|
525
|
-
flag = change.value
|
|
526
|
-
flag = nil if change.value == ''
|
|
527
|
-
if flag
|
|
528
|
-
# When the user is adding a comment to explain why a flag was set, the flag is set immediately
|
|
529
|
-
# and the comment is inserted after the user hits enter, which means that there is some time
|
|
530
|
-
# gap. If a comment happened shortly after the flag was set, we assume they're linked. This
|
|
531
|
-
# won't always be true and so there will be false positives, but it's a reasonable assumption.
|
|
532
|
-
max_seconds_between_flag_and_comment = 30
|
|
533
|
-
comment_change = changes.find do |c|
|
|
534
|
-
c.comment? && c.time >= change.time && (c.time - change.time) <= max_seconds_between_flag_and_comment
|
|
535
|
-
end
|
|
536
|
-
flag_reason = comment_change && @board.project_config.atlassian_document_format.to_text(comment_change.value)
|
|
537
|
-
# Newer Jira instances may add this extra text but older instances did not. Strip it out if found.
|
|
538
|
-
flag_reason = flag_reason&.sub(/\A:flag_on: Flag added\s*/m, '')&.strip
|
|
539
|
-
flag_reason = nil if flag_reason&.empty?
|
|
540
|
-
else
|
|
541
|
-
flag_reason = nil
|
|
542
|
-
end
|
|
543
|
-
[flag, flag_reason]
|
|
544
|
-
end
|
|
545
|
-
|
|
546
|
-
def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
|
|
547
|
-
stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
|
|
548
|
-
|
|
549
|
-
# The most common case will be nothing to split so quick escape.
|
|
550
|
-
return false if (change_time - previous_change_time).to_i < stalled_threshold_seconds
|
|
551
|
-
|
|
552
|
-
# If the last identified change was blocked then it doesn't matter now long we've waited, we're still blocked.
|
|
553
|
-
return false if blocking_stalled_changes[-1]&.blocked?
|
|
554
|
-
|
|
555
|
-
list = [previous_change_time..change_time]
|
|
556
|
-
all_subtask_activity_times.each do |time|
|
|
557
|
-
matching_range = list.find { |range| time >= range.begin && time <= range.end }
|
|
558
|
-
next unless matching_range
|
|
559
|
-
|
|
560
|
-
list.delete matching_range
|
|
561
|
-
list << ((matching_range.begin)..time)
|
|
562
|
-
list << (time..(matching_range.end))
|
|
563
|
-
end
|
|
564
|
-
|
|
565
|
-
inserted_stalled = false
|
|
566
|
-
|
|
567
|
-
list.sort_by(&:begin).each do |range|
|
|
568
|
-
seconds = (range.end - range.begin).to_i
|
|
569
|
-
next if seconds < stalled_threshold_seconds
|
|
570
|
-
|
|
571
|
-
an_hour_later = range.begin + (60 * 60)
|
|
572
|
-
blocking_stalled_changes << BlockedStalledChange.new(stalled_days: seconds / (24 * 60 * 60), time: an_hour_later)
|
|
573
|
-
inserted_stalled = true
|
|
574
|
-
end
|
|
575
|
-
inserted_stalled
|
|
409
|
+
BlockedStalledChangeStreamBuilder.new(
|
|
410
|
+
changes: changes,
|
|
411
|
+
settings: settings,
|
|
412
|
+
created: created,
|
|
413
|
+
key: key,
|
|
414
|
+
subtask_activity_times: all_subtask_activity_times,
|
|
415
|
+
atlassian_document_format: @board.project_config.atlassian_document_format
|
|
416
|
+
).build(end_time: end_time)
|
|
576
417
|
end
|
|
577
418
|
|
|
578
419
|
# return [number of active seconds, total seconds] that this issue had up to the end_time.
|
|
@@ -581,37 +422,13 @@ class Issue
|
|
|
581
422
|
issue_start, issue_stop = started_stopped_times
|
|
582
423
|
return [0.0, 0.0] if !issue_start || issue_start > end_time
|
|
583
424
|
|
|
584
|
-
|
|
425
|
+
# Nothing after the issue finishes counts, so cap the window before we build the stream.
|
|
585
426
|
end_time = issue_stop if issue_stop && issue_stop < end_time
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
if index.zero?
|
|
592
|
-
active_start = change.time if change.active?
|
|
593
|
-
next
|
|
594
|
-
end
|
|
595
|
-
|
|
596
|
-
# Already active and we just got another active.
|
|
597
|
-
next if active_start && change.active?
|
|
598
|
-
|
|
599
|
-
if change.active?
|
|
600
|
-
active_start = change.time
|
|
601
|
-
elsif active_start && change.time >= issue_start
|
|
602
|
-
# Not active now but we have been. Record the active time.
|
|
603
|
-
change_delta = change.time - [issue_start, active_start].max
|
|
604
|
-
value_add_time += change_delta
|
|
605
|
-
active_start = nil
|
|
606
|
-
end
|
|
607
|
-
end
|
|
608
|
-
|
|
609
|
-
if active_start
|
|
610
|
-
change_delta = end_time - [issue_start, active_start].max
|
|
611
|
-
value_add_time += change_delta if change_delta.positive?
|
|
612
|
-
end
|
|
613
|
-
|
|
614
|
-
[value_add_time, end_time - issue_start]
|
|
427
|
+
FlowEfficiencyCalculator.new(
|
|
428
|
+
blocked_stalled_changes: blocked_stalled_changes(end_time: end_time, settings: settings),
|
|
429
|
+
issue_start: issue_start,
|
|
430
|
+
end_time: end_time
|
|
431
|
+
).calculate
|
|
615
432
|
end
|
|
616
433
|
|
|
617
434
|
def all_subtask_activity_times
|
|
@@ -631,26 +448,30 @@ class Issue
|
|
|
631
448
|
end
|
|
632
449
|
|
|
633
450
|
def expedited_on_date? date
|
|
634
|
-
expedited_start = nil
|
|
635
451
|
return false unless @board&.project_config
|
|
636
452
|
|
|
453
|
+
expedited_ranges.any? { |range| range.cover?(date) }
|
|
454
|
+
end
|
|
455
|
+
|
|
456
|
+
# The date ranges during which this issue sat at an expedited priority. A range that never closes
|
|
457
|
+
# (the issue was never de-prioritised) is endless.
|
|
458
|
+
def expedited_ranges
|
|
637
459
|
expedited_names = @board.project_config.settings['expedited_priority_names']
|
|
460
|
+
ranges = []
|
|
461
|
+
started_on = nil
|
|
638
462
|
|
|
639
463
|
changes.each do |change|
|
|
640
464
|
next unless change.priority?
|
|
641
465
|
|
|
642
466
|
if expedited_names.include? change.value
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
expedited_start = nil
|
|
467
|
+
started_on ||= change.time.to_date
|
|
468
|
+
elsif started_on
|
|
469
|
+
ranges << (started_on..change.time.to_date)
|
|
470
|
+
started_on = nil
|
|
648
471
|
end
|
|
649
472
|
end
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
expedited_start <= date
|
|
473
|
+
ranges << (started_on..) if started_on
|
|
474
|
+
ranges
|
|
654
475
|
end
|
|
655
476
|
|
|
656
477
|
# Return the last time there was any activity on this ticket. Starting from "now" and going backwards
|
|
@@ -671,7 +492,7 @@ class Issue
|
|
|
671
492
|
|
|
672
493
|
def issue_links
|
|
673
494
|
if @issue_links.nil?
|
|
674
|
-
@issue_links =
|
|
495
|
+
@issue_links = raw_fields['issuelinks']&.collect do |issue_link|
|
|
675
496
|
IssueLink.new origin: self, raw: issue_link
|
|
676
497
|
end || []
|
|
677
498
|
end
|
|
@@ -680,7 +501,7 @@ class Issue
|
|
|
680
501
|
|
|
681
502
|
def fix_versions
|
|
682
503
|
if @fix_versions.nil?
|
|
683
|
-
@fix_versions =
|
|
504
|
+
@fix_versions = raw_fields['fixVersions']&.collect do |fix_version|
|
|
684
505
|
FixVersion.new fix_version
|
|
685
506
|
end || []
|
|
686
507
|
end
|
|
@@ -695,34 +516,31 @@ class Issue
|
|
|
695
516
|
# Although Atlassian is trying to standardize on one way to determine the parent, today it's a mess.
|
|
696
517
|
# We try a variety of ways to get the parent and hopefully one of them will work. See this link:
|
|
697
518
|
# https://community.developer.atlassian.com/t/deprecation-of-the-epic-link-parent-link-and-other-related-fields-in-rest-apis-and-webhooks/54048
|
|
519
|
+
fields = raw_fields
|
|
698
520
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
parent
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
parent = fields['epic']&.[]('key') if parent.nil?
|
|
521
|
+
# The 'parent' field will eventually be the only way; the 'epic' field is the older form. Failing
|
|
522
|
+
# both, the parent link may be stored in a custom field.
|
|
523
|
+
parent = fields['parent']&.[]('key') || fields['epic']&.[]('key')
|
|
524
|
+
parent ||= parent_from_custom_fields(fields, project_config) if project_config
|
|
525
|
+
parent
|
|
526
|
+
end
|
|
706
527
|
|
|
707
|
-
|
|
708
|
-
# used for parent_link vs epic_link so we
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
528
|
+
def parent_from_custom_fields fields, project_config
|
|
529
|
+
# We've seen different custom fields used for parent_link vs epic_link, so we try each configured
|
|
530
|
+
# one until we find a value that looks like an issue key.
|
|
531
|
+
custom_field_names = project_config.settings['customfield_parent_links']
|
|
532
|
+
custom_field_names = [custom_field_names] if custom_field_names.is_a? String
|
|
712
533
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
534
|
+
custom_field_names&.each do |field_name|
|
|
535
|
+
parent = fields[field_name]
|
|
536
|
+
next if parent.nil?
|
|
537
|
+
return parent if looks_like_issue_key? parent
|
|
717
538
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
parent = nil
|
|
722
|
-
end
|
|
539
|
+
project_config.file_system.log(
|
|
540
|
+
"Custom field #{field_name.inspect} should point to a parent id but found #{parent.inspect}"
|
|
541
|
+
)
|
|
723
542
|
end
|
|
724
|
-
|
|
725
|
-
parent
|
|
543
|
+
nil
|
|
726
544
|
end
|
|
727
545
|
|
|
728
546
|
def in_initial_query?
|
|
@@ -746,17 +564,19 @@ class Issue
|
|
|
746
564
|
def discard_changes_before cutoff_time
|
|
747
565
|
rejected_any = false
|
|
748
566
|
@changes.reject! do |change|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
end
|
|
754
|
-
reject
|
|
567
|
+
next false unless discardable_before? change, cutoff_time
|
|
568
|
+
|
|
569
|
+
(@discarded_changes ||= []) << change
|
|
570
|
+
rejected_any = true
|
|
755
571
|
end
|
|
756
572
|
|
|
757
573
|
(@discarded_change_times ||= []) << cutoff_time if rejected_any
|
|
758
574
|
end
|
|
759
575
|
|
|
576
|
+
def discardable_before? change, cutoff_time
|
|
577
|
+
change.status? && change.time <= cutoff_time && change.artificial? == false
|
|
578
|
+
end
|
|
579
|
+
|
|
760
580
|
def dump
|
|
761
581
|
IssuePrinter.new(self).to_s
|
|
762
582
|
end
|
|
@@ -780,7 +600,7 @@ class Issue
|
|
|
780
600
|
end
|
|
781
601
|
|
|
782
602
|
def status_changes
|
|
783
|
-
@changes.select
|
|
603
|
+
@changes.select(&:status?)
|
|
784
604
|
end
|
|
785
605
|
|
|
786
606
|
def status_resolution_at_done
|
|
@@ -807,7 +627,7 @@ class Issue
|
|
|
807
627
|
changes.each do |change|
|
|
808
628
|
next unless change.sprint?
|
|
809
629
|
|
|
810
|
-
sprint_ids << change.raw['to'].split(/\s*,\s*/).collect
|
|
630
|
+
sprint_ids << change.raw['to'].split(/\s*,\s*/).collect(&:to_i)
|
|
811
631
|
end
|
|
812
632
|
sprint_ids.flatten!
|
|
813
633
|
|
|
@@ -815,17 +635,13 @@ class Issue
|
|
|
815
635
|
end
|
|
816
636
|
|
|
817
637
|
def started_sprints
|
|
818
|
-
sprints.reject
|
|
638
|
+
sprints.reject(&:future?)
|
|
819
639
|
end
|
|
820
640
|
|
|
821
641
|
def compact_text text, max: 60
|
|
822
642
|
return '' if text.nil?
|
|
823
643
|
|
|
824
|
-
text = if text.is_a? Hash
|
|
825
|
-
@board.project_config.atlassian_document_format.to_text(text)
|
|
826
|
-
else
|
|
827
|
-
text
|
|
828
|
-
end
|
|
644
|
+
text = @board.project_config.atlassian_document_format.to_text(text) if text.is_a? Hash
|
|
829
645
|
text = text.gsub(/\s+/, ' ').strip
|
|
830
646
|
text = "#{text[0...max]}..." if text.length > max
|
|
831
647
|
text
|
|
@@ -836,36 +652,47 @@ class Issue
|
|
|
836
652
|
# Returns [[effective_time, change_item]] for each moment the issue entered an active sprint.
|
|
837
653
|
# Skips sprints that were removed before they activated.
|
|
838
654
|
def sprint_entry_events
|
|
839
|
-
data_clazz = Struct.new(:sprint_id, :sprint_start, :add_time, :change)
|
|
840
655
|
events = []
|
|
841
656
|
in_sprint = []
|
|
842
657
|
|
|
843
658
|
@changes.each do |change|
|
|
844
659
|
next unless change.sprint?
|
|
845
660
|
|
|
846
|
-
(
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
end
|
|
850
|
-
|
|
851
|
-
(change.old_value_id - change.value_id).each do |sprint_id|
|
|
852
|
-
data = in_sprint.find { |d| d.sprint_id == sprint_id }
|
|
853
|
-
next unless data
|
|
661
|
+
add_tracked_sprints(in_sprint, change)
|
|
662
|
+
close_tracked_sprints(in_sprint, change, events)
|
|
663
|
+
end
|
|
854
664
|
|
|
855
|
-
|
|
856
|
-
|
|
665
|
+
# Anything still tracked at the end never left, so its entry is the moment it started (or was added).
|
|
666
|
+
in_sprint.each { |tracked| events << sprint_entry_event_for(tracked) }
|
|
667
|
+
events
|
|
668
|
+
end
|
|
857
669
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
670
|
+
# Records each sprint this change newly joined, but only those we know eventually started.
|
|
671
|
+
def add_tracked_sprints in_sprint, change
|
|
672
|
+
(change.value_id - change.old_value_id).each do |sprint_id|
|
|
673
|
+
sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
|
|
674
|
+
in_sprint << TrackedSprint.new(sprint_id:, sprint_start:, add_time: change.time, change:) if sprint_start
|
|
861
675
|
end
|
|
676
|
+
end
|
|
677
|
+
|
|
678
|
+
# Emits an entry for each sprint this change left, unless it was removed before ever activating.
|
|
679
|
+
def close_tracked_sprints in_sprint, change, events
|
|
680
|
+
(change.old_value_id - change.value_id).each do |sprint_id|
|
|
681
|
+
tracked = in_sprint.find { |candidate| candidate.sprint_id == sprint_id }
|
|
682
|
+
next unless tracked
|
|
683
|
+
|
|
684
|
+
in_sprint.delete(tracked)
|
|
685
|
+
next if tracked.sprint_start >= change.time # sprint hadn't activated before removal
|
|
862
686
|
|
|
863
|
-
|
|
864
|
-
effective_time = [data.add_time, data.sprint_start].max
|
|
865
|
-
events << [effective_time, sprint_change_at(effective_time, data.change)]
|
|
687
|
+
events << sprint_entry_event_for(tracked)
|
|
866
688
|
end
|
|
689
|
+
end
|
|
867
690
|
|
|
868
|
-
|
|
691
|
+
# The moment the issue was effectively in an active sprint - the later of when it was added and when
|
|
692
|
+
# the sprint started - paired with the change that best represents that moment.
|
|
693
|
+
def sprint_entry_event_for tracked
|
|
694
|
+
effective_time = [tracked.add_time, tracked.sprint_start].max
|
|
695
|
+
[effective_time, sprint_change_at(effective_time, tracked.change)]
|
|
869
696
|
end
|
|
870
697
|
|
|
871
698
|
def sprint_change_at effective_time, change
|
|
@@ -885,15 +712,21 @@ class Issue
|
|
|
885
712
|
break if change.time > time
|
|
886
713
|
next unless change.sprint?
|
|
887
714
|
|
|
888
|
-
(change
|
|
889
|
-
sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
|
|
890
|
-
active_ids << sprint_id if sprint_start && sprint_start <= time
|
|
891
|
-
end
|
|
892
|
-
(change.old_value_id - change.value_id).each { |id| active_ids.delete(id) }
|
|
715
|
+
apply_sprint_membership_change(active_ids, change, time)
|
|
893
716
|
end
|
|
894
717
|
active_ids.any?
|
|
895
718
|
end
|
|
896
719
|
|
|
720
|
+
# Adds the sprints newly joined by this change (only if they had already started by `time`) and
|
|
721
|
+
# removes the ones it left, mutating active_ids in place.
|
|
722
|
+
def apply_sprint_membership_change active_ids, change, time
|
|
723
|
+
(change.value_id - change.old_value_id).each do |sprint_id|
|
|
724
|
+
sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
|
|
725
|
+
active_ids << sprint_id if sprint_start && sprint_start <= time
|
|
726
|
+
end
|
|
727
|
+
(change.old_value_id - change.value_id).each { |id| active_ids.delete(id) }
|
|
728
|
+
end
|
|
729
|
+
|
|
897
730
|
def in_visible_status_at? time, visible_status_ids
|
|
898
731
|
last = status_changes.reverse.find { |c| c.time <= time }
|
|
899
732
|
last && visible_status_ids.include?(last.value_id)
|
|
@@ -904,30 +737,40 @@ class Issue
|
|
|
904
737
|
created = parse_time(history['created'])
|
|
905
738
|
|
|
906
739
|
history['items']&.each do |item|
|
|
907
|
-
if item['field'] == 'status' && item['to'].nil?
|
|
908
|
-
to_name = item['toString']
|
|
909
|
-
matches = board.possible_statuses.find_all_by_name(to_name)
|
|
910
|
-
guessed_id, id_note = if matches.length == 1
|
|
911
|
-
[matches.first.id.to_s, "Guessed id #{matches.first.id} from status name."]
|
|
912
|
-
elsif matches.length > 1
|
|
913
|
-
['0', "Multiple statuses named #{to_name.inspect} exist (ids: #{matches.map(&:id).join(', ')}); cannot disambiguate. Using id 0."]
|
|
914
|
-
else
|
|
915
|
-
['0', "No known status named #{to_name.inspect}. Using id 0."]
|
|
916
|
-
end
|
|
917
|
-
board.project_config.file_system.warning(
|
|
918
|
-
"Issue #{key} has a status change without a 'to' id " \
|
|
919
|
-
"(from #{item['fromString'].inspect} to #{to_name.inspect}). #{id_note}"
|
|
920
|
-
)
|
|
921
|
-
item = item.merge('to' => guessed_id)
|
|
922
|
-
end
|
|
923
|
-
|
|
740
|
+
item = backfill_missing_status_id(item) if item['field'] == 'status' && item['to'].nil?
|
|
924
741
|
@changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
|
|
925
742
|
end
|
|
926
743
|
end
|
|
927
744
|
end
|
|
928
745
|
|
|
746
|
+
# Jira sometimes reports a status change with a name but no id. Guess the id from the name (when we
|
|
747
|
+
# can) and return a copy of the item with a 'to' filled in, logging what we did.
|
|
748
|
+
def backfill_missing_status_id item
|
|
749
|
+
to_name = item['toString']
|
|
750
|
+
matches = board.possible_statuses.find_all_by_name(to_name)
|
|
751
|
+
guessed_id, id_note = guess_status_id(to_name, matches)
|
|
752
|
+
board.project_config.file_system.warning(
|
|
753
|
+
"Issue #{key} has a status change without a 'to' id " \
|
|
754
|
+
"(from #{item['fromString'].inspect} to #{to_name.inspect}). #{id_note}"
|
|
755
|
+
)
|
|
756
|
+
item.merge('to' => guessed_id)
|
|
757
|
+
end
|
|
758
|
+
|
|
759
|
+
# Returns [id_as_string, explanation]. A single name match gives that id; anything else falls back to
|
|
760
|
+
# id 0 because we can't safely disambiguate.
|
|
761
|
+
def guess_status_id to_name, matches
|
|
762
|
+
if matches.length == 1
|
|
763
|
+
[matches.first.id.to_s, "Guessed id #{matches.first.id} from status name."]
|
|
764
|
+
elsif matches.length > 1
|
|
765
|
+
['0', "Multiple statuses named #{to_name.inspect} exist " \
|
|
766
|
+
"(ids: #{matches.map(&:id).join(', ')}); cannot disambiguate. Using id 0."]
|
|
767
|
+
else
|
|
768
|
+
['0', "No known status named #{to_name.inspect}. Using id 0."]
|
|
769
|
+
end
|
|
770
|
+
end
|
|
771
|
+
|
|
929
772
|
def load_comments_into_changes
|
|
930
|
-
|
|
773
|
+
raw_fields['comment']['comments']&.each do |comment|
|
|
931
774
|
raw = comment.merge({
|
|
932
775
|
'field' => 'comment',
|
|
933
776
|
'to' => comment['id'],
|
|
@@ -956,26 +799,26 @@ class Issue
|
|
|
956
799
|
first_status_id = nil
|
|
957
800
|
|
|
958
801
|
# There won't be a created timestamp in cases where this was a linked issue
|
|
959
|
-
return unless
|
|
802
|
+
return unless raw_fields['created']
|
|
960
803
|
|
|
961
|
-
created_time = parse_time
|
|
804
|
+
created_time = parse_time raw_fields['created']
|
|
962
805
|
first_change = @changes.find { |change| change.field == field_name }
|
|
963
806
|
if first_change.nil?
|
|
964
807
|
# There have been no changes of this type yet so we have to look at the current one
|
|
965
|
-
return nil unless
|
|
808
|
+
return nil unless raw_fields[field_name]
|
|
966
809
|
|
|
967
|
-
first_status =
|
|
968
|
-
first_status_id =
|
|
810
|
+
first_status = raw_fields[field_name]['name']
|
|
811
|
+
first_status_id = raw_fields[field_name]['id'].to_i
|
|
969
812
|
else
|
|
970
813
|
# Otherwise, we look at what the first one had changed away from.
|
|
971
814
|
first_status = first_change.old_value
|
|
972
|
-
# old_value_id should never be nil
|
|
815
|
+
# old_value_id should never be nil - a status change must have a 'from' id - but Jira has
|
|
973
816
|
# been seen in production omitting the 'from' field entirely. Fall back to 0 so the
|
|
974
817
|
# downstream fabricate/warn path handles it rather than crashing.
|
|
975
818
|
first_status_id = first_change.old_value_id || 0
|
|
976
819
|
end
|
|
977
820
|
|
|
978
|
-
creator =
|
|
821
|
+
creator = raw_fields['creator']
|
|
979
822
|
ChangeItem.new time: created_time, artificial: true, author_raw: creator, raw: {
|
|
980
823
|
'field' => field_name,
|
|
981
824
|
'to' => first_status_id,
|
|
@@ -983,6 +826,45 @@ class Issue
|
|
|
983
826
|
}
|
|
984
827
|
end
|
|
985
828
|
|
|
829
|
+
# Jira never records an issue's *initial* sprint membership as a changelog transition, so an issue
|
|
830
|
+
# created directly inside a sprint (and never moved out) has no Sprint change at all, and one whose
|
|
831
|
+
# first recorded change already lists the sprint in its 'from' looks like it entered at that later
|
|
832
|
+
# moment. Reconstruct that initial membership as an artificial change at creation time, mirroring how
|
|
833
|
+
# we fabricate the initial status and priority. Returns nil when the issue started in no sprints.
|
|
834
|
+
def fabricate_sprint_change
|
|
835
|
+
return unless raw_fields['created']
|
|
836
|
+
|
|
837
|
+
first_sprint_change = @changes.find(&:sprint?)
|
|
838
|
+
initial_sprint_ids = first_sprint_change ? first_sprint_change.old_value_id : current_sprint_ids
|
|
839
|
+
return if initial_sprint_ids.empty?
|
|
840
|
+
|
|
841
|
+
ChangeItem.new(
|
|
842
|
+
time: parse_time(raw_fields['created']), artificial: true, author_raw: raw_fields['creator'],
|
|
843
|
+
raw: {
|
|
844
|
+
'field' => 'Sprint',
|
|
845
|
+
'fieldId' => first_sprint_change&.field_id || sprint_field_id,
|
|
846
|
+
'to' => initial_sprint_ids.join(', '),
|
|
847
|
+
'toString' => 'Sprint'
|
|
848
|
+
}
|
|
849
|
+
)
|
|
850
|
+
end
|
|
851
|
+
|
|
852
|
+
# The Sprint custom field id varies by Jira instance, so we find it by shape: its value is a list of
|
|
853
|
+
# sprint objects, each of which carries a boardId. Returns nil when the issue is in no sprints.
|
|
854
|
+
def sprint_field_id
|
|
855
|
+
field = raw_fields.find do |_field_id, value|
|
|
856
|
+
value.is_a?(Array) && value.first.is_a?(Hash) && value.first.key?('boardId')
|
|
857
|
+
end
|
|
858
|
+
field&.first
|
|
859
|
+
end
|
|
860
|
+
|
|
861
|
+
def current_sprint_ids
|
|
862
|
+
field_id = sprint_field_id
|
|
863
|
+
return [] unless field_id
|
|
864
|
+
|
|
865
|
+
raw_fields[field_id].filter_map { |sprint| sprint['id']&.to_i }
|
|
866
|
+
end
|
|
867
|
+
|
|
986
868
|
def find_status_category_ids_by_names category_names
|
|
987
869
|
category_names.filter_map do |name|
|
|
988
870
|
list = board.possible_statuses.find_all_categories_by_name name
|