jirametrics 2.31 → 3.0
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 +50 -64
- data/lib/jirametrics/cumulative_flow_diagram.rb +59 -46
- 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 +61 -63
- data/lib/jirametrics/dependency_chart.rb +40 -31
- data/lib/jirametrics/download_config.rb +0 -3
- data/lib/jirametrics/downloader.rb +8 -7
- data/lib/jirametrics/downloader_for_cloud.rb +108 -72
- 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 +19 -10
- 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 +23 -9
- data/lib/jirametrics/groupable_issue_chart.rb +3 -0
- data/lib/jirametrics/html/index.css +6 -0
- data/lib/jirametrics/html_report_config.rb +9 -29
- data/lib/jirametrics/issue.rb +290 -389
- data/lib/jirametrics/issue_collection.rb +1 -0
- data/lib/jirametrics/issue_printer.rb +60 -23
- data/lib/jirametrics/jira_gateway.rb +29 -18
- data/lib/jirametrics/mcp_server.rb +225 -209
- data/lib/jirametrics/project_config.rb +158 -134
- 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 +2 -2
- 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 +73 -32
- data/lib/jirametrics.rb +6 -2
- 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
|
|
@@ -213,22 +219,24 @@ class Issue
|
|
|
213
219
|
visible_status_ids = board.visible_columns.collect(&:status_ids).flatten
|
|
214
220
|
return first_time_in_status(*visible_status_ids) unless board.scrum?
|
|
215
221
|
|
|
216
|
-
#
|
|
217
|
-
#
|
|
218
|
-
#
|
|
219
|
-
|
|
220
|
-
|
|
222
|
+
# On scrum boards an issue is only visible when its status is in a visible column AND it is in an
|
|
223
|
+
# active sprint. Each source below is a moment when the second condition became true while the first
|
|
224
|
+
# already held; the earliest of them is when it first became visible.
|
|
225
|
+
candidates = visible_status_changes_in_active_sprint(visible_status_ids) +
|
|
226
|
+
sprint_entries_while_in_visible_status(visible_status_ids)
|
|
227
|
+
candidates.min_by(&:time)
|
|
228
|
+
end
|
|
221
229
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
230
|
+
def visible_status_changes_in_active_sprint visible_status_ids
|
|
231
|
+
status_changes.select do |change|
|
|
232
|
+
visible_status_ids.include?(change.value_id) && in_active_sprint_at?(change.time)
|
|
225
233
|
end
|
|
234
|
+
end
|
|
226
235
|
|
|
227
|
-
|
|
228
|
-
|
|
236
|
+
def sprint_entries_while_in_visible_status visible_status_ids
|
|
237
|
+
sprint_entry_events.filter_map do |effective_time, representative_change|
|
|
238
|
+
representative_change if in_visible_status_at?(effective_time, visible_status_ids)
|
|
229
239
|
end
|
|
230
|
-
|
|
231
|
-
candidates.min_by(&:time)
|
|
232
240
|
end
|
|
233
241
|
|
|
234
242
|
def reasons_not_visible_on_board
|
|
@@ -244,87 +252,113 @@ class Issue
|
|
|
244
252
|
reasons_not_visible_on_board.empty?
|
|
245
253
|
end
|
|
246
254
|
|
|
247
|
-
#
|
|
248
|
-
#
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
#
|
|
255
|
+
# A sprint the issue was added to: its start (all we care about is whether it started) and the change
|
|
256
|
+
# that added it.
|
|
257
|
+
SprintMembership = Data.define(:sprint_id, :sprint_start, :change)
|
|
258
|
+
|
|
259
|
+
# Like SprintMembership but also tracks when this issue was added, used while pairing sprint entries
|
|
260
|
+
# and exits in #sprint_entry_events.
|
|
261
|
+
TrackedSprint = Data.define(:sprint_id, :sprint_start, :add_time, :change)
|
|
262
|
+
|
|
263
|
+
# If this issue is ever in an active sprint, returns the change where it was first added to that
|
|
264
|
+
# sprint (whether or not the sprint was active at that moment). It's a reasonable proxy for 'ready'
|
|
265
|
+
# when a team has no explicit 'ready' status -- you'd be better off with one, but sometimes that's
|
|
266
|
+
# not an option. Only valid for Scrum boards.
|
|
267
|
+
# rubocop:disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
252
268
|
def first_time_added_to_active_sprint
|
|
269
|
+
# Why are complexity warnings disabled? Bottom line is that we felt the code would be less readable
|
|
270
|
+
# if we split it, so it's remaining as one longer method.
|
|
271
|
+
|
|
253
272
|
unless board.scrum?
|
|
254
273
|
raise 'first_time_added_to_active_sprint() can only be used with Scrum boards: ' \
|
|
255
274
|
"issue=#{key}, board=#{board.inspect}"
|
|
256
275
|
end
|
|
257
|
-
data_clazz = Struct.new(:sprint_id, :sprint_start, :sprint_stop, :change)
|
|
258
|
-
|
|
259
276
|
matching_changes = []
|
|
260
|
-
|
|
277
|
+
memberships = []
|
|
261
278
|
|
|
262
279
|
@changes.each do |change|
|
|
263
280
|
next unless change.sprint?
|
|
264
281
|
|
|
265
282
|
added_sprint_ids = change.value_id - change.old_value_id
|
|
266
283
|
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
|
|
284
|
+
sprint_start = find_sprint_start_end(sprint_id: id, change: change).first
|
|
285
|
+
memberships << SprintMembership.new(sprint_id: id, sprint_start:, change:)
|
|
272
286
|
end
|
|
273
287
|
|
|
274
288
|
removed_sprint_ids = change.old_value_id - change.value_id
|
|
275
289
|
removed_sprint_ids.each do |id|
|
|
276
|
-
|
|
290
|
+
membership = memberships.find { |m| m.sprint_id == id }
|
|
277
291
|
# It's possible for an issue to be created inside a sprint and therefore for
|
|
278
292
|
# that add-to-sprint not show in the history.
|
|
279
|
-
next unless
|
|
293
|
+
next unless membership
|
|
280
294
|
|
|
281
|
-
|
|
282
|
-
next
|
|
295
|
+
memberships.delete(membership)
|
|
296
|
+
next unless counts_as_sprint_start? membership
|
|
297
|
+
next if membership.sprint_start >= change.time
|
|
283
298
|
|
|
284
|
-
matching_changes <<
|
|
299
|
+
matching_changes << membership.change
|
|
285
300
|
end
|
|
286
301
|
end
|
|
287
302
|
|
|
288
303
|
# There can't be any more removes so whatever is left is a valid option
|
|
289
304
|
# Now all we care about is if the sprint has started.
|
|
290
|
-
|
|
291
|
-
matching_changes <<
|
|
305
|
+
memberships.each do |membership|
|
|
306
|
+
matching_changes << membership.change if counts_as_sprint_start? membership
|
|
292
307
|
end
|
|
293
308
|
|
|
294
309
|
matching_changes.min_by(&:time)
|
|
295
310
|
end
|
|
311
|
+
# rubocop:enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity
|
|
312
|
+
|
|
313
|
+
# Being added to a sprint only counts as a "start" if that sprint had actually started and the issue
|
|
314
|
+
# wasn't already done when it did. Joining a sprint you've already finished (e.g. a done issue swept
|
|
315
|
+
# into a later sprint) is bookkeeping on completed work, not the moment work began.
|
|
316
|
+
def counts_as_sprint_start? membership
|
|
317
|
+
return false if membership.sprint_start.nil?
|
|
318
|
+
|
|
319
|
+
!in_done_status_at?(membership.sprint_start)
|
|
320
|
+
end
|
|
321
|
+
|
|
322
|
+
# Was the issue sitting in a done-category status at the given moment? Keys off the status category
|
|
323
|
+
# (not the status name), so a status merely called "Done" that Jira categorises otherwise won't count.
|
|
324
|
+
def in_done_status_at? time
|
|
325
|
+
last = status_changes.reverse.find { |change| change.time <= time }
|
|
326
|
+
return false unless last
|
|
327
|
+
|
|
328
|
+
find_or_create_status(id: last.value_id, name: last.value).category.done?
|
|
329
|
+
end
|
|
296
330
|
|
|
297
331
|
def find_sprint_start_end sprint_id:, change:
|
|
298
332
|
# There are two different places that sprint data could be found. In theory all
|
|
299
333
|
# sprints would be found in both places. In practice, sometimes what we need is
|
|
300
334
|
# in one or the other but not both.
|
|
335
|
+
times = sprint_times_from_board(sprint_id) || sprint_times_from_issue(sprint_id, change)
|
|
301
336
|
|
|
302
|
-
#
|
|
303
|
-
# be
|
|
337
|
+
# If both came up empty then the sprint can't be found anywhere, so we pretend that it never
|
|
338
|
+
# started. Is this guaranteed to be true? No. In theory if all issues were removed from
|
|
339
|
+
# an active sprint then it would also disappear, even though it had started. Nothing we
|
|
340
|
+
# can do to detect that edge-case though.
|
|
341
|
+
times || [nil, nil]
|
|
342
|
+
end
|
|
343
|
+
|
|
344
|
+
# First look in the actual sprints json. If any issues are in this sprint then it should be here.
|
|
345
|
+
def sprint_times_from_board sprint_id
|
|
304
346
|
sprint = board.sprints.find { |s| s.id == sprint_id }
|
|
305
|
-
|
|
306
|
-
|
|
347
|
+
return nil unless sprint
|
|
348
|
+
return [nil, nil] if sprint.future?
|
|
307
349
|
|
|
308
|
-
|
|
309
|
-
|
|
350
|
+
[sprint.start_time, sprint.completed_time]
|
|
351
|
+
end
|
|
310
352
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
353
|
+
# Then look at the sprints inside the issue. Even though the field id may be specified, that custom
|
|
354
|
+
# field may not be present. This happens if it was in that sprint but was then removed, whether or
|
|
355
|
+
# not that sprint had ever started.
|
|
356
|
+
def sprint_times_from_issue sprint_id, change
|
|
314
357
|
sprint_data = raw['fields'][change.field_id]&.find { |sd| sd['id'].to_i == sprint_id }
|
|
315
|
-
|
|
316
|
-
|
|
358
|
+
return nil unless sprint_data
|
|
359
|
+
return [nil, nil] if sprint_data['state'] == 'future'
|
|
317
360
|
|
|
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]
|
|
361
|
+
[parse_time(sprint_data['startDate']), parse_time(sprint_data['completeDate'])]
|
|
328
362
|
end
|
|
329
363
|
|
|
330
364
|
def parse_time text
|
|
@@ -339,7 +373,8 @@ class Issue
|
|
|
339
373
|
|
|
340
374
|
def created
|
|
341
375
|
# This nil check shouldn't be necessary and yet we've seen one case where it was.
|
|
342
|
-
|
|
376
|
+
created_text = raw_fields['created']
|
|
377
|
+
parse_time created_text if created_text
|
|
343
378
|
end
|
|
344
379
|
|
|
345
380
|
def time_created
|
|
@@ -347,23 +382,23 @@ class Issue
|
|
|
347
382
|
end
|
|
348
383
|
|
|
349
384
|
def updated
|
|
350
|
-
parse_time
|
|
385
|
+
parse_time raw_fields['updated']
|
|
351
386
|
end
|
|
352
387
|
|
|
353
388
|
def first_resolution
|
|
354
|
-
@changes.find
|
|
389
|
+
@changes.find(&:resolution?)
|
|
355
390
|
end
|
|
356
391
|
|
|
357
392
|
def last_resolution
|
|
358
|
-
@changes.reverse.find
|
|
393
|
+
@changes.reverse.find(&:resolution?)
|
|
359
394
|
end
|
|
360
395
|
|
|
361
396
|
def assigned_to
|
|
362
|
-
|
|
397
|
+
raw_fields['assignee']&.[]('displayName')
|
|
363
398
|
end
|
|
364
399
|
|
|
365
400
|
def assigned_to_icon_url
|
|
366
|
-
|
|
401
|
+
raw_fields['assignee']&.[]('avatarUrls')&.[]('16x16')
|
|
367
402
|
end
|
|
368
403
|
|
|
369
404
|
# Many test failures are simply unreadable because the default inspect on this class goes
|
|
@@ -382,197 +417,22 @@ class Issue
|
|
|
382
417
|
# If the day was stalled for the entire day then it's stalled
|
|
383
418
|
# If there was no activity at all on this day then the last change from the previous day carries over
|
|
384
419
|
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
|
|
420
|
+
BlockedStalledByDateBuilder.new(
|
|
421
|
+
blocked_stalled_changes: blocked_stalled_changes(end_time: chart_end_time, settings: settings),
|
|
422
|
+
date_range: date_range
|
|
423
|
+
).build
|
|
427
424
|
end
|
|
428
425
|
|
|
429
426
|
def blocked_stalled_changes end_time:, settings: nil
|
|
430
427
|
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
|
|
428
|
+
BlockedStalledChangeStreamBuilder.new(
|
|
429
|
+
changes: changes,
|
|
430
|
+
settings: settings,
|
|
431
|
+
created: created,
|
|
432
|
+
key: key,
|
|
433
|
+
subtask_activity_times: all_subtask_activity_times,
|
|
434
|
+
atlassian_document_format: @board.project_config.atlassian_document_format
|
|
435
|
+
).build(end_time: end_time)
|
|
576
436
|
end
|
|
577
437
|
|
|
578
438
|
# return [number of active seconds, total seconds] that this issue had up to the end_time.
|
|
@@ -581,37 +441,13 @@ class Issue
|
|
|
581
441
|
issue_start, issue_stop = started_stopped_times
|
|
582
442
|
return [0.0, 0.0] if !issue_start || issue_start > end_time
|
|
583
443
|
|
|
584
|
-
|
|
444
|
+
# Nothing after the issue finishes counts, so cap the window before we build the stream.
|
|
585
445
|
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]
|
|
446
|
+
FlowEfficiencyCalculator.new(
|
|
447
|
+
blocked_stalled_changes: blocked_stalled_changes(end_time: end_time, settings: settings),
|
|
448
|
+
issue_start: issue_start,
|
|
449
|
+
end_time: end_time
|
|
450
|
+
).calculate
|
|
615
451
|
end
|
|
616
452
|
|
|
617
453
|
def all_subtask_activity_times
|
|
@@ -631,26 +467,30 @@ class Issue
|
|
|
631
467
|
end
|
|
632
468
|
|
|
633
469
|
def expedited_on_date? date
|
|
634
|
-
expedited_start = nil
|
|
635
470
|
return false unless @board&.project_config
|
|
636
471
|
|
|
472
|
+
expedited_ranges.any? { |range| range.cover?(date) }
|
|
473
|
+
end
|
|
474
|
+
|
|
475
|
+
# The date ranges during which this issue sat at an expedited priority. A range that never closes
|
|
476
|
+
# (the issue was never de-prioritised) is endless.
|
|
477
|
+
def expedited_ranges
|
|
637
478
|
expedited_names = @board.project_config.settings['expedited_priority_names']
|
|
479
|
+
ranges = []
|
|
480
|
+
started_on = nil
|
|
638
481
|
|
|
639
482
|
changes.each do |change|
|
|
640
483
|
next unless change.priority?
|
|
641
484
|
|
|
642
485
|
if expedited_names.include? change.value
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
expedited_start = nil
|
|
486
|
+
started_on ||= change.time.to_date
|
|
487
|
+
elsif started_on
|
|
488
|
+
ranges << (started_on..change.time.to_date)
|
|
489
|
+
started_on = nil
|
|
648
490
|
end
|
|
649
491
|
end
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
expedited_start <= date
|
|
492
|
+
ranges << (started_on..) if started_on
|
|
493
|
+
ranges
|
|
654
494
|
end
|
|
655
495
|
|
|
656
496
|
# Return the last time there was any activity on this ticket. Starting from "now" and going backwards
|
|
@@ -671,7 +511,7 @@ class Issue
|
|
|
671
511
|
|
|
672
512
|
def issue_links
|
|
673
513
|
if @issue_links.nil?
|
|
674
|
-
@issue_links =
|
|
514
|
+
@issue_links = raw_fields['issuelinks']&.collect do |issue_link|
|
|
675
515
|
IssueLink.new origin: self, raw: issue_link
|
|
676
516
|
end || []
|
|
677
517
|
end
|
|
@@ -680,7 +520,7 @@ class Issue
|
|
|
680
520
|
|
|
681
521
|
def fix_versions
|
|
682
522
|
if @fix_versions.nil?
|
|
683
|
-
@fix_versions =
|
|
523
|
+
@fix_versions = raw_fields['fixVersions']&.collect do |fix_version|
|
|
684
524
|
FixVersion.new fix_version
|
|
685
525
|
end || []
|
|
686
526
|
end
|
|
@@ -695,34 +535,31 @@ class Issue
|
|
|
695
535
|
# Although Atlassian is trying to standardize on one way to determine the parent, today it's a mess.
|
|
696
536
|
# We try a variety of ways to get the parent and hopefully one of them will work. See this link:
|
|
697
537
|
# https://community.developer.atlassian.com/t/deprecation-of-the-epic-link-parent-link-and-other-related-fields-in-rest-apis-and-webhooks/54048
|
|
538
|
+
fields = raw_fields
|
|
698
539
|
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
parent
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
parent = fields['epic']&.[]('key') if parent.nil?
|
|
540
|
+
# The 'parent' field will eventually be the only way; the 'epic' field is the older form. Failing
|
|
541
|
+
# both, the parent link may be stored in a custom field.
|
|
542
|
+
parent = fields['parent']&.[]('key') || fields['epic']&.[]('key')
|
|
543
|
+
parent ||= parent_from_custom_fields(fields, project_config) if project_config
|
|
544
|
+
parent
|
|
545
|
+
end
|
|
706
546
|
|
|
707
|
-
|
|
708
|
-
# used for parent_link vs epic_link so we
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
547
|
+
def parent_from_custom_fields fields, project_config
|
|
548
|
+
# We've seen different custom fields used for parent_link vs epic_link, so we try each configured
|
|
549
|
+
# one until we find a value that looks like an issue key.
|
|
550
|
+
custom_field_names = project_config.settings['customfield_parent_links']
|
|
551
|
+
custom_field_names = [custom_field_names] if custom_field_names.is_a? String
|
|
712
552
|
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
553
|
+
custom_field_names&.each do |field_name|
|
|
554
|
+
parent = fields[field_name]
|
|
555
|
+
next if parent.nil?
|
|
556
|
+
return parent if looks_like_issue_key? parent
|
|
717
557
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
parent = nil
|
|
722
|
-
end
|
|
558
|
+
project_config.file_system.log(
|
|
559
|
+
"Custom field #{field_name.inspect} should point to a parent id but found #{parent.inspect}"
|
|
560
|
+
)
|
|
723
561
|
end
|
|
724
|
-
|
|
725
|
-
parent
|
|
562
|
+
nil
|
|
726
563
|
end
|
|
727
564
|
|
|
728
565
|
def in_initial_query?
|
|
@@ -746,17 +583,19 @@ class Issue
|
|
|
746
583
|
def discard_changes_before cutoff_time
|
|
747
584
|
rejected_any = false
|
|
748
585
|
@changes.reject! do |change|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
end
|
|
754
|
-
reject
|
|
586
|
+
next false unless discardable_before? change, cutoff_time
|
|
587
|
+
|
|
588
|
+
(@discarded_changes ||= []) << change
|
|
589
|
+
rejected_any = true
|
|
755
590
|
end
|
|
756
591
|
|
|
757
592
|
(@discarded_change_times ||= []) << cutoff_time if rejected_any
|
|
758
593
|
end
|
|
759
594
|
|
|
595
|
+
def discardable_before? change, cutoff_time
|
|
596
|
+
change.status? && change.time <= cutoff_time && change.artificial? == false
|
|
597
|
+
end
|
|
598
|
+
|
|
760
599
|
def dump
|
|
761
600
|
IssuePrinter.new(self).to_s
|
|
762
601
|
end
|
|
@@ -780,7 +619,7 @@ class Issue
|
|
|
780
619
|
end
|
|
781
620
|
|
|
782
621
|
def status_changes
|
|
783
|
-
@changes.select
|
|
622
|
+
@changes.select(&:status?)
|
|
784
623
|
end
|
|
785
624
|
|
|
786
625
|
def status_resolution_at_done
|
|
@@ -807,7 +646,7 @@ class Issue
|
|
|
807
646
|
changes.each do |change|
|
|
808
647
|
next unless change.sprint?
|
|
809
648
|
|
|
810
|
-
sprint_ids << change.raw['to'].split(/\s*,\s*/).collect
|
|
649
|
+
sprint_ids << change.raw['to'].split(/\s*,\s*/).collect(&:to_i)
|
|
811
650
|
end
|
|
812
651
|
sprint_ids.flatten!
|
|
813
652
|
|
|
@@ -815,17 +654,13 @@ class Issue
|
|
|
815
654
|
end
|
|
816
655
|
|
|
817
656
|
def started_sprints
|
|
818
|
-
sprints.reject
|
|
657
|
+
sprints.reject(&:future?)
|
|
819
658
|
end
|
|
820
659
|
|
|
821
660
|
def compact_text text, max: 60
|
|
822
661
|
return '' if text.nil?
|
|
823
662
|
|
|
824
|
-
text = if text.is_a? Hash
|
|
825
|
-
@board.project_config.atlassian_document_format.to_text(text)
|
|
826
|
-
else
|
|
827
|
-
text
|
|
828
|
-
end
|
|
663
|
+
text = @board.project_config.atlassian_document_format.to_text(text) if text.is_a? Hash
|
|
829
664
|
text = text.gsub(/\s+/, ' ').strip
|
|
830
665
|
text = "#{text[0...max]}..." if text.length > max
|
|
831
666
|
text
|
|
@@ -836,36 +671,47 @@ class Issue
|
|
|
836
671
|
# Returns [[effective_time, change_item]] for each moment the issue entered an active sprint.
|
|
837
672
|
# Skips sprints that were removed before they activated.
|
|
838
673
|
def sprint_entry_events
|
|
839
|
-
data_clazz = Struct.new(:sprint_id, :sprint_start, :add_time, :change)
|
|
840
674
|
events = []
|
|
841
675
|
in_sprint = []
|
|
842
676
|
|
|
843
677
|
@changes.each do |change|
|
|
844
678
|
next unless change.sprint?
|
|
845
679
|
|
|
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
|
|
680
|
+
add_tracked_sprints(in_sprint, change)
|
|
681
|
+
close_tracked_sprints(in_sprint, change, events)
|
|
682
|
+
end
|
|
854
683
|
|
|
855
|
-
|
|
856
|
-
|
|
684
|
+
# Anything still tracked at the end never left, so its entry is the moment it started (or was added).
|
|
685
|
+
in_sprint.each { |tracked| events << sprint_entry_event_for(tracked) }
|
|
686
|
+
events
|
|
687
|
+
end
|
|
857
688
|
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
689
|
+
# Records each sprint this change newly joined, but only those we know eventually started.
|
|
690
|
+
def add_tracked_sprints in_sprint, change
|
|
691
|
+
(change.value_id - change.old_value_id).each do |sprint_id|
|
|
692
|
+
sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
|
|
693
|
+
in_sprint << TrackedSprint.new(sprint_id:, sprint_start:, add_time: change.time, change:) if sprint_start
|
|
861
694
|
end
|
|
695
|
+
end
|
|
696
|
+
|
|
697
|
+
# Emits an entry for each sprint this change left, unless it was removed before ever activating.
|
|
698
|
+
def close_tracked_sprints in_sprint, change, events
|
|
699
|
+
(change.old_value_id - change.value_id).each do |sprint_id|
|
|
700
|
+
tracked = in_sprint.find { |candidate| candidate.sprint_id == sprint_id }
|
|
701
|
+
next unless tracked
|
|
862
702
|
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
703
|
+
in_sprint.delete(tracked)
|
|
704
|
+
next if tracked.sprint_start >= change.time # sprint hadn't activated before removal
|
|
705
|
+
|
|
706
|
+
events << sprint_entry_event_for(tracked)
|
|
866
707
|
end
|
|
708
|
+
end
|
|
867
709
|
|
|
868
|
-
|
|
710
|
+
# The moment the issue was effectively in an active sprint - the later of when it was added and when
|
|
711
|
+
# the sprint started - paired with the change that best represents that moment.
|
|
712
|
+
def sprint_entry_event_for tracked
|
|
713
|
+
effective_time = [tracked.add_time, tracked.sprint_start].max
|
|
714
|
+
[effective_time, sprint_change_at(effective_time, tracked.change)]
|
|
869
715
|
end
|
|
870
716
|
|
|
871
717
|
def sprint_change_at effective_time, change
|
|
@@ -885,15 +731,21 @@ class Issue
|
|
|
885
731
|
break if change.time > time
|
|
886
732
|
next unless change.sprint?
|
|
887
733
|
|
|
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) }
|
|
734
|
+
apply_sprint_membership_change(active_ids, change, time)
|
|
893
735
|
end
|
|
894
736
|
active_ids.any?
|
|
895
737
|
end
|
|
896
738
|
|
|
739
|
+
# Adds the sprints newly joined by this change (only if they had already started by `time`) and
|
|
740
|
+
# removes the ones it left, mutating active_ids in place.
|
|
741
|
+
def apply_sprint_membership_change active_ids, change, time
|
|
742
|
+
(change.value_id - change.old_value_id).each do |sprint_id|
|
|
743
|
+
sprint_start, = find_sprint_start_end(sprint_id: sprint_id, change: change)
|
|
744
|
+
active_ids << sprint_id if sprint_start && sprint_start <= time
|
|
745
|
+
end
|
|
746
|
+
(change.old_value_id - change.value_id).each { |id| active_ids.delete(id) }
|
|
747
|
+
end
|
|
748
|
+
|
|
897
749
|
def in_visible_status_at? time, visible_status_ids
|
|
898
750
|
last = status_changes.reverse.find { |c| c.time <= time }
|
|
899
751
|
last && visible_status_ids.include?(last.value_id)
|
|
@@ -904,30 +756,40 @@ class Issue
|
|
|
904
756
|
created = parse_time(history['created'])
|
|
905
757
|
|
|
906
758
|
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
|
-
|
|
759
|
+
item = backfill_missing_status_id(item) if item['field'] == 'status' && item['to'].nil?
|
|
924
760
|
@changes << ChangeItem.new(raw: item, time: created, author_raw: history['author'])
|
|
925
761
|
end
|
|
926
762
|
end
|
|
927
763
|
end
|
|
928
764
|
|
|
765
|
+
# Jira sometimes reports a status change with a name but no id. Guess the id from the name (when we
|
|
766
|
+
# can) and return a copy of the item with a 'to' filled in, logging what we did.
|
|
767
|
+
def backfill_missing_status_id item
|
|
768
|
+
to_name = item['toString']
|
|
769
|
+
matches = board.possible_statuses.find_all_by_name(to_name)
|
|
770
|
+
guessed_id, id_note = guess_status_id(to_name, matches)
|
|
771
|
+
board.project_config.file_system.warning(
|
|
772
|
+
"Issue #{key} has a status change without a 'to' id " \
|
|
773
|
+
"(from #{item['fromString'].inspect} to #{to_name.inspect}). #{id_note}"
|
|
774
|
+
)
|
|
775
|
+
item.merge('to' => guessed_id)
|
|
776
|
+
end
|
|
777
|
+
|
|
778
|
+
# Returns [id_as_string, explanation]. A single name match gives that id; anything else falls back to
|
|
779
|
+
# id 0 because we can't safely disambiguate.
|
|
780
|
+
def guess_status_id to_name, matches
|
|
781
|
+
if matches.length == 1
|
|
782
|
+
[matches.first.id.to_s, "Guessed id #{matches.first.id} from status name."]
|
|
783
|
+
elsif matches.length > 1
|
|
784
|
+
['0', "Multiple statuses named #{to_name.inspect} exist " \
|
|
785
|
+
"(ids: #{matches.map(&:id).join(', ')}); cannot disambiguate. Using id 0."]
|
|
786
|
+
else
|
|
787
|
+
['0', "No known status named #{to_name.inspect}. Using id 0."]
|
|
788
|
+
end
|
|
789
|
+
end
|
|
790
|
+
|
|
929
791
|
def load_comments_into_changes
|
|
930
|
-
|
|
792
|
+
raw_fields['comment']['comments']&.each do |comment|
|
|
931
793
|
raw = comment.merge({
|
|
932
794
|
'field' => 'comment',
|
|
933
795
|
'to' => comment['id'],
|
|
@@ -956,16 +818,16 @@ class Issue
|
|
|
956
818
|
first_status_id = nil
|
|
957
819
|
|
|
958
820
|
# There won't be a created timestamp in cases where this was a linked issue
|
|
959
|
-
return unless
|
|
821
|
+
return unless raw_fields['created']
|
|
960
822
|
|
|
961
|
-
created_time = parse_time
|
|
823
|
+
created_time = parse_time raw_fields['created']
|
|
962
824
|
first_change = @changes.find { |change| change.field == field_name }
|
|
963
825
|
if first_change.nil?
|
|
964
826
|
# There have been no changes of this type yet so we have to look at the current one
|
|
965
|
-
return nil unless
|
|
827
|
+
return nil unless raw_fields[field_name]
|
|
966
828
|
|
|
967
|
-
first_status =
|
|
968
|
-
first_status_id =
|
|
829
|
+
first_status = raw_fields[field_name]['name']
|
|
830
|
+
first_status_id = raw_fields[field_name]['id'].to_i
|
|
969
831
|
else
|
|
970
832
|
# Otherwise, we look at what the first one had changed away from.
|
|
971
833
|
first_status = first_change.old_value
|
|
@@ -975,7 +837,7 @@ class Issue
|
|
|
975
837
|
first_status_id = first_change.old_value_id || 0
|
|
976
838
|
end
|
|
977
839
|
|
|
978
|
-
creator =
|
|
840
|
+
creator = raw_fields['creator']
|
|
979
841
|
ChangeItem.new time: created_time, artificial: true, author_raw: creator, raw: {
|
|
980
842
|
'field' => field_name,
|
|
981
843
|
'to' => first_status_id,
|
|
@@ -983,6 +845,45 @@ class Issue
|
|
|
983
845
|
}
|
|
984
846
|
end
|
|
985
847
|
|
|
848
|
+
# Jira never records an issue's *initial* sprint membership as a changelog transition, so an issue
|
|
849
|
+
# created directly inside a sprint (and never moved out) has no Sprint change at all, and one whose
|
|
850
|
+
# first recorded change already lists the sprint in its 'from' looks like it entered at that later
|
|
851
|
+
# moment. Reconstruct that initial membership as an artificial change at creation time, mirroring how
|
|
852
|
+
# we fabricate the initial status and priority. Returns nil when the issue started in no sprints.
|
|
853
|
+
def fabricate_sprint_change
|
|
854
|
+
return unless raw_fields['created']
|
|
855
|
+
|
|
856
|
+
first_sprint_change = @changes.find(&:sprint?)
|
|
857
|
+
initial_sprint_ids = first_sprint_change ? first_sprint_change.old_value_id : current_sprint_ids
|
|
858
|
+
return if initial_sprint_ids.empty?
|
|
859
|
+
|
|
860
|
+
ChangeItem.new(
|
|
861
|
+
time: parse_time(raw_fields['created']), artificial: true, author_raw: raw_fields['creator'],
|
|
862
|
+
raw: {
|
|
863
|
+
'field' => 'Sprint',
|
|
864
|
+
'fieldId' => first_sprint_change&.field_id || sprint_field_id,
|
|
865
|
+
'to' => initial_sprint_ids.join(', '),
|
|
866
|
+
'toString' => 'Sprint'
|
|
867
|
+
}
|
|
868
|
+
)
|
|
869
|
+
end
|
|
870
|
+
|
|
871
|
+
# The Sprint custom field id varies by Jira instance, so we find it by shape: its value is a list of
|
|
872
|
+
# sprint objects, each of which carries a boardId. Returns nil when the issue is in no sprints.
|
|
873
|
+
def sprint_field_id
|
|
874
|
+
field = raw_fields.find do |_field_id, value|
|
|
875
|
+
value.is_a?(Array) && value.first.is_a?(Hash) && value.first.key?('boardId')
|
|
876
|
+
end
|
|
877
|
+
field&.first
|
|
878
|
+
end
|
|
879
|
+
|
|
880
|
+
def current_sprint_ids
|
|
881
|
+
field_id = sprint_field_id
|
|
882
|
+
return [] unless field_id
|
|
883
|
+
|
|
884
|
+
raw_fields[field_id].filter_map { |sprint| sprint['id']&.to_i }
|
|
885
|
+
end
|
|
886
|
+
|
|
986
887
|
def find_status_category_ids_by_names category_names
|
|
987
888
|
category_names.filter_map do |name|
|
|
988
889
|
list = board.possible_statuses.find_all_categories_by_name name
|