jirametrics 1.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/bin/jirametrics +4 -0
- data/lib/jirametrics/aggregate_config.rb +89 -0
- data/lib/jirametrics/aging_work_bar_chart.rb +235 -0
- data/lib/jirametrics/aging_work_in_progress_chart.rb +148 -0
- data/lib/jirametrics/aging_work_table.rb +149 -0
- data/lib/jirametrics/anonymizer.rb +186 -0
- data/lib/jirametrics/blocked_stalled_change.rb +43 -0
- data/lib/jirametrics/board.rb +85 -0
- data/lib/jirametrics/board_column.rb +14 -0
- data/lib/jirametrics/board_config.rb +31 -0
- data/lib/jirametrics/change_item.rb +80 -0
- data/lib/jirametrics/chart_base.rb +239 -0
- data/lib/jirametrics/columns_config.rb +42 -0
- data/lib/jirametrics/cycletime_config.rb +69 -0
- data/lib/jirametrics/cycletime_histogram.rb +74 -0
- data/lib/jirametrics/cycletime_scatterplot.rb +128 -0
- data/lib/jirametrics/daily_wip_by_age_chart.rb +88 -0
- data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +77 -0
- data/lib/jirametrics/daily_wip_chart.rb +123 -0
- data/lib/jirametrics/data_quality_report.rb +278 -0
- data/lib/jirametrics/dependency_chart.rb +217 -0
- data/lib/jirametrics/discard_changes_before.rb +37 -0
- data/lib/jirametrics/download_config.rb +41 -0
- data/lib/jirametrics/downloader.rb +337 -0
- data/lib/jirametrics/examples/aggregated_project.rb +36 -0
- data/lib/jirametrics/examples/standard_project.rb +111 -0
- data/lib/jirametrics/expedited_chart.rb +169 -0
- data/lib/jirametrics/experimental/generator.rb +209 -0
- data/lib/jirametrics/experimental/info.rb +77 -0
- data/lib/jirametrics/exporter.rb +127 -0
- data/lib/jirametrics/file_config.rb +119 -0
- data/lib/jirametrics/fix_version.rb +21 -0
- data/lib/jirametrics/groupable_issue_chart.rb +44 -0
- data/lib/jirametrics/grouping_rules.rb +13 -0
- data/lib/jirametrics/hierarchy_table.rb +31 -0
- data/lib/jirametrics/html/aging_work_bar_chart.erb +72 -0
- data/lib/jirametrics/html/aging_work_in_progress_chart.erb +52 -0
- data/lib/jirametrics/html/aging_work_table.erb +60 -0
- data/lib/jirametrics/html/collapsible_issues_panel.erb +32 -0
- data/lib/jirametrics/html/cycletime_histogram.erb +41 -0
- data/lib/jirametrics/html/cycletime_scatterplot.erb +103 -0
- data/lib/jirametrics/html/daily_wip_chart.erb +63 -0
- data/lib/jirametrics/html/data_quality_report.erb +126 -0
- data/lib/jirametrics/html/expedited_chart.erb +67 -0
- data/lib/jirametrics/html/hierarchy_table.erb +29 -0
- data/lib/jirametrics/html/index.erb +66 -0
- data/lib/jirametrics/html/sprint_burndown.erb +116 -0
- data/lib/jirametrics/html/story_point_accuracy_chart.erb +57 -0
- data/lib/jirametrics/html/throughput_chart.erb +65 -0
- data/lib/jirametrics/html_report_config.rb +217 -0
- data/lib/jirametrics/issue.rb +521 -0
- data/lib/jirametrics/issue_link.rb +60 -0
- data/lib/jirametrics/json_file_loader.rb +9 -0
- data/lib/jirametrics/project_config.rb +442 -0
- data/lib/jirametrics/rules.rb +34 -0
- data/lib/jirametrics/self_or_issue_dispatcher.rb +15 -0
- data/lib/jirametrics/sprint.rb +43 -0
- data/lib/jirametrics/sprint_burndown.rb +335 -0
- data/lib/jirametrics/sprint_issue_change_data.rb +31 -0
- data/lib/jirametrics/status.rb +26 -0
- data/lib/jirametrics/status_collection.rb +67 -0
- data/lib/jirametrics/story_point_accuracy_chart.rb +139 -0
- data/lib/jirametrics/throughput_chart.rb +91 -0
- data/lib/jirametrics/tree_organizer.rb +96 -0
- data/lib/jirametrics/trend_line_calculator.rb +74 -0
- data/lib/jirametrics.rb +85 -0
- metadata +167 -0
@@ -0,0 +1,521 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'time'
|
4
|
+
|
5
|
+
class Issue
|
6
|
+
attr_reader :changes, :raw, :subtasks, :board
|
7
|
+
attr_accessor :parent
|
8
|
+
|
9
|
+
def initialize raw:, board:, timezone_offset: '+00:00'
|
10
|
+
@raw = raw
|
11
|
+
@timezone_offset = timezone_offset
|
12
|
+
@subtasks = []
|
13
|
+
@changes = []
|
14
|
+
@board = board
|
15
|
+
|
16
|
+
return unless @raw['changelog']
|
17
|
+
|
18
|
+
load_history_into_changes
|
19
|
+
|
20
|
+
# If this is an older pull of data then comments may not be there.
|
21
|
+
load_comments_into_changes if @raw['fields']['comment']
|
22
|
+
|
23
|
+
# It might appear that Jira already returns these in order but we've found different
|
24
|
+
# versions of Server/Cloud return the changelog in different orders so we sort them.
|
25
|
+
sort_changes!
|
26
|
+
|
27
|
+
# It's possible to have a ticket created with certain things already set and therefore
|
28
|
+
# not showing up in the change log. Create some artificial entries to capture those.
|
29
|
+
@changes = [
|
30
|
+
fabricate_change(field_name: 'status'),
|
31
|
+
fabricate_change(field_name: 'priority')
|
32
|
+
].compact + @changes
|
33
|
+
end
|
34
|
+
|
35
|
+
def sort_changes!
|
36
|
+
@changes.sort! do |a, b|
|
37
|
+
# It's common that a resolved will happen at the same time as a status change.
|
38
|
+
# Put them in a defined order so tests can be deterministic.
|
39
|
+
compare = a.time <=> b.time
|
40
|
+
compare = 1 if compare.zero? && a.resolution?
|
41
|
+
compare
|
42
|
+
end
|
43
|
+
end
|
44
|
+
|
45
|
+
def key = @raw['key']
|
46
|
+
|
47
|
+
def type = @raw['fields']['issuetype']['name']
|
48
|
+
|
49
|
+
def type_icon_url = @raw['fields']['issuetype']['iconUrl']
|
50
|
+
|
51
|
+
def summary = @raw['fields']['summary']
|
52
|
+
|
53
|
+
def status
|
54
|
+
raw_status = @raw['fields']['status']
|
55
|
+
raw_category = raw_status['statusCategory']
|
56
|
+
|
57
|
+
Status.new(
|
58
|
+
name: raw_status['name'],
|
59
|
+
id: raw_status['id'].to_i,
|
60
|
+
category_name: raw_category['name'],
|
61
|
+
category_id: raw_category['id'].to_i
|
62
|
+
)
|
63
|
+
end
|
64
|
+
|
65
|
+
def status_id
|
66
|
+
puts 'DEPRECATED(Issue.status_id) Call Issue.status.id instead'
|
67
|
+
status.id
|
68
|
+
end
|
69
|
+
|
70
|
+
def labels = @raw['fields']['labels'] || []
|
71
|
+
|
72
|
+
def author = @raw['fields']['creator']['displayName']
|
73
|
+
|
74
|
+
def resolution = @raw['fields']['resolution']&.[]('name')
|
75
|
+
|
76
|
+
def url
|
77
|
+
# Strangely, the URL isn't anywhere in the returned data so we have to fabricate it.
|
78
|
+
"#{$1}/browse/#{key}" if @raw['self'] =~ /^(https?:\/\/[^\/]+)\//
|
79
|
+
end
|
80
|
+
|
81
|
+
def key_as_i
|
82
|
+
$1.to_i if key =~ /-(\d+)$/
|
83
|
+
end
|
84
|
+
|
85
|
+
def component_names
|
86
|
+
@raw['fields']['components']&.collect { |component| component['name'] } || []
|
87
|
+
end
|
88
|
+
|
89
|
+
def fabricate_change field_name:
|
90
|
+
first_status = nil
|
91
|
+
first_status_id = nil
|
92
|
+
|
93
|
+
created_time = parse_time @raw['fields']['created']
|
94
|
+
first_change = @changes.find { |change| change.field == field_name }
|
95
|
+
if first_change.nil?
|
96
|
+
# There have been no changes of this type yet so we have to look at the current one
|
97
|
+
return nil unless @raw['fields'][field_name]
|
98
|
+
|
99
|
+
first_status = @raw['fields'][field_name]['name']
|
100
|
+
first_status_id = @raw['fields'][field_name]['id'].to_i
|
101
|
+
else
|
102
|
+
# Otherwise, we look at what the first one had changed away from.
|
103
|
+
first_status = first_change.old_value
|
104
|
+
first_status_id = first_change.old_value_id
|
105
|
+
end
|
106
|
+
ChangeItem.new time: created_time, artificial: true, author: author, raw: {
|
107
|
+
'field' => field_name,
|
108
|
+
'to' => first_status_id,
|
109
|
+
'toString' => first_status
|
110
|
+
}
|
111
|
+
end
|
112
|
+
|
113
|
+
def first_time_in_status *status_names
|
114
|
+
@changes.find { |change| change.current_status_matches(*status_names) }&.time
|
115
|
+
end
|
116
|
+
|
117
|
+
def first_time_not_in_status *status_names
|
118
|
+
@changes.find { |change| change.status? && status_names.include?(change.value) == false }&.time
|
119
|
+
end
|
120
|
+
|
121
|
+
def first_time_in_or_right_of_column column_name
|
122
|
+
first_time_in_status(*board.status_ids_in_or_right_of_column(column_name))
|
123
|
+
end
|
124
|
+
|
125
|
+
def still_in_or_right_of_column column_name
|
126
|
+
still_in_status(*board.status_ids_in_or_right_of_column(column_name))
|
127
|
+
end
|
128
|
+
|
129
|
+
def still_in
|
130
|
+
time = nil
|
131
|
+
@changes.each do |change|
|
132
|
+
next unless change.status?
|
133
|
+
|
134
|
+
current_status_matched = yield change
|
135
|
+
|
136
|
+
if current_status_matched && time.nil?
|
137
|
+
time = change.time
|
138
|
+
elsif !current_status_matched && time
|
139
|
+
time = nil
|
140
|
+
end
|
141
|
+
end
|
142
|
+
time
|
143
|
+
end
|
144
|
+
private :still_in
|
145
|
+
|
146
|
+
# If it ever entered one of these statuses and it's still there then what was the last time it entered
|
147
|
+
def still_in_status *status_names
|
148
|
+
still_in do |change|
|
149
|
+
status_names.include?(change.value) || status_names.include?(change.value_id)
|
150
|
+
end
|
151
|
+
end
|
152
|
+
|
153
|
+
# If it ever entered one of these categories and it's still there then what was the last time it entered
|
154
|
+
def still_in_status_category *category_names
|
155
|
+
still_in do |change|
|
156
|
+
status = find_status_by_name change.value
|
157
|
+
category_names.include?(status.category_name) || category_names.include?(status.category_id)
|
158
|
+
end
|
159
|
+
end
|
160
|
+
|
161
|
+
def most_recent_status_change
|
162
|
+
changes.reverse.find { |change| change.status? }
|
163
|
+
end
|
164
|
+
|
165
|
+
# Are we currently in this status? If yes, then return the time of the most recent status change.
|
166
|
+
def currently_in_status *status_names
|
167
|
+
change = most_recent_status_change
|
168
|
+
return false if change.nil?
|
169
|
+
|
170
|
+
change.time if change.current_status_matches(*status_names)
|
171
|
+
end
|
172
|
+
|
173
|
+
# Are we currently in this status category? If yes, then return the time of the most recent status change.
|
174
|
+
def currently_in_status_category *category_names
|
175
|
+
change = most_recent_status_change
|
176
|
+
return false if change.nil?
|
177
|
+
|
178
|
+
status = find_status_by_name change.value
|
179
|
+
change.time if status && category_names.include?(status.category_name)
|
180
|
+
end
|
181
|
+
|
182
|
+
def find_status_by_name name
|
183
|
+
status = board.possible_statuses.find_by_name(name)
|
184
|
+
return status if status
|
185
|
+
|
186
|
+
raise "Status name #{name.inspect} for issue #{key} not found in #{board.possible_statuses.collect(&:name).inspect}"
|
187
|
+
end
|
188
|
+
|
189
|
+
def first_status_change_after_created
|
190
|
+
@changes.find { |change| change.status? && change.artificial? == false }&.time
|
191
|
+
end
|
192
|
+
|
193
|
+
def first_time_in_status_category *category_names
|
194
|
+
@changes.each do |change|
|
195
|
+
next unless change.status?
|
196
|
+
|
197
|
+
category = find_status_by_name(change.value).category_name
|
198
|
+
return change.time if category_names.include? category
|
199
|
+
end
|
200
|
+
nil
|
201
|
+
end
|
202
|
+
|
203
|
+
def parse_time text
|
204
|
+
Time.parse(text).getlocal(@timezone_offset)
|
205
|
+
end
|
206
|
+
|
207
|
+
def created
|
208
|
+
# This shouldn't be necessary and yet we've seen one case where it was.
|
209
|
+
parse_time @raw['fields']['created'] if @raw['fields']['created']
|
210
|
+
end
|
211
|
+
|
212
|
+
def updated
|
213
|
+
parse_time @raw['fields']['updated']
|
214
|
+
end
|
215
|
+
|
216
|
+
def first_resolution
|
217
|
+
@changes.find { |change| change.resolution? }&.time
|
218
|
+
end
|
219
|
+
|
220
|
+
def last_resolution
|
221
|
+
@changes.reverse.find { |change| change.resolution? }&.time
|
222
|
+
end
|
223
|
+
|
224
|
+
def assigned_to
|
225
|
+
@raw['fields']&.[]('assignee')&.[]('displayName')
|
226
|
+
end
|
227
|
+
|
228
|
+
# Many test failures are simply unreadable because the default inspect on this class goes
|
229
|
+
# on for pages. Shorten it up.
|
230
|
+
def inspect
|
231
|
+
"Issue(#{key.inspect})"
|
232
|
+
end
|
233
|
+
|
234
|
+
def blocked_on_date? date, end_time:
|
235
|
+
blocked_stalled_changes_on_date(date: date, end_time: end_time) do |change|
|
236
|
+
return true if change.blocked?
|
237
|
+
end
|
238
|
+
false
|
239
|
+
end
|
240
|
+
|
241
|
+
def blocked_stalled_changes_on_date date:, end_time:
|
242
|
+
next_day_start_time = (date + 1).to_time
|
243
|
+
this_day_start_time = date.to_time
|
244
|
+
|
245
|
+
# changes_affecting_date = []
|
246
|
+
previous_change_time = nil
|
247
|
+
blocked_stalled_changes(end_time: end_time).each do |change|
|
248
|
+
if previous_change_time.nil?
|
249
|
+
previous_change_time = change.time
|
250
|
+
next
|
251
|
+
end
|
252
|
+
|
253
|
+
yield change if previous_change_time < next_day_start_time && change.time >= this_day_start_time
|
254
|
+
end
|
255
|
+
end
|
256
|
+
|
257
|
+
def blocked_stalled_changes end_time:, settings: @board.project_config.settings
|
258
|
+
blocked_statuses = settings['blocked_statuses']
|
259
|
+
stalled_statuses = settings['stalled_statuses']
|
260
|
+
unless blocked_statuses.is_a?(Array) && stalled_statuses.is_a?(Array)
|
261
|
+
raise "blocked_statuses(#{blocked_statuses.inspect}) and " \
|
262
|
+
"stalled_statuses(#{stalled_statuses.inspect}) must both be arrays"
|
263
|
+
end
|
264
|
+
|
265
|
+
blocked_link_texts = settings['blocked_link_text']
|
266
|
+
stalled_threshold = settings['stalled_threshold']
|
267
|
+
|
268
|
+
blocking_issue_keys = []
|
269
|
+
|
270
|
+
result = []
|
271
|
+
previous_was_active = true
|
272
|
+
previous_change_time = created
|
273
|
+
|
274
|
+
blocking_status = nil
|
275
|
+
flag = nil
|
276
|
+
|
277
|
+
# This mock change is to force the writing of one last entry at the end of the time range.
|
278
|
+
# By doing this, we're able to eliminate a lot of duplicated code in charts.
|
279
|
+
mock_change = ChangeItem.new time: end_time, author: '', artificial: true, raw: { 'field' => '' }
|
280
|
+
(changes + [mock_change]).each do |change|
|
281
|
+
|
282
|
+
previous_was_active = false if check_for_stalled(
|
283
|
+
change_time: change.time,
|
284
|
+
previous_change_time: previous_change_time,
|
285
|
+
stalled_threshold: stalled_threshold,
|
286
|
+
blocking_stalled_changes: result
|
287
|
+
)
|
288
|
+
|
289
|
+
if change.flagged?
|
290
|
+
flag = change.value
|
291
|
+
flag = nil if change.value == ''
|
292
|
+
elsif change.status?
|
293
|
+
blocking_status = nil
|
294
|
+
if blocked_statuses.include?(change.value) || stalled_statuses.include?(change.value)
|
295
|
+
blocking_status = change.value
|
296
|
+
end
|
297
|
+
elsif change.link?
|
298
|
+
unless /^This issue (?<link_text>.+) (?<issue_key>.+)$/ =~ (change.value || change.old_value)
|
299
|
+
puts "Can't parse link text: #{change.value || change.old_value}"
|
300
|
+
next
|
301
|
+
end
|
302
|
+
|
303
|
+
if blocked_link_texts.include? link_text
|
304
|
+
if change.value
|
305
|
+
blocking_issue_keys << issue_key
|
306
|
+
else
|
307
|
+
blocking_issue_keys.delete issue_key
|
308
|
+
end
|
309
|
+
end
|
310
|
+
end
|
311
|
+
|
312
|
+
new_change = BlockedStalledChange.new(
|
313
|
+
flagged: flag,
|
314
|
+
status: blocking_status,
|
315
|
+
status_is_blocking: blocking_status.nil? || blocked_statuses.include?(blocking_status),
|
316
|
+
blocking_issue_keys: (blocking_issue_keys.empty? ? nil : blocking_issue_keys.dup),
|
317
|
+
time: change.time
|
318
|
+
)
|
319
|
+
|
320
|
+
# We don't want to dump two actives in a row as that would just be noise. Unless this is
|
321
|
+
# the mock change, which we always want to dump
|
322
|
+
result << new_change if !new_change.active? || !previous_was_active || change == mock_change
|
323
|
+
|
324
|
+
previous_was_active = new_change.active?
|
325
|
+
previous_change_time = change.time
|
326
|
+
end
|
327
|
+
|
328
|
+
if result.size >= 2
|
329
|
+
# The existence of the mock entry will mess with the stalled count as it will wake everything
|
330
|
+
# back up. This hack will clean up appropriately.
|
331
|
+
hack = result.pop
|
332
|
+
result << BlockedStalledChange.new(
|
333
|
+
flagged: hack.flag,
|
334
|
+
status: hack.status,
|
335
|
+
status_is_blocking: hack.status_is_blocking,
|
336
|
+
blocking_issue_keys: hack.blocking_issue_keys,
|
337
|
+
time: hack.time,
|
338
|
+
stalled_days: result[-1].stalled_days
|
339
|
+
)
|
340
|
+
end
|
341
|
+
result
|
342
|
+
end
|
343
|
+
|
344
|
+
def check_for_stalled change_time:, previous_change_time:, stalled_threshold:, blocking_stalled_changes:
|
345
|
+
stalled_threshold_seconds = stalled_threshold * 60 * 60 * 24
|
346
|
+
|
347
|
+
# The most common case will be nothing to split so quick escape.
|
348
|
+
return false if (change_time - previous_change_time).to_i < stalled_threshold_seconds
|
349
|
+
|
350
|
+
list = [previous_change_time..change_time]
|
351
|
+
all_subtask_activity_times.each do |time|
|
352
|
+
matching_range = list.find { |range| time >= range.begin && time <= range.end }
|
353
|
+
next unless matching_range
|
354
|
+
|
355
|
+
list.delete matching_range
|
356
|
+
list << ((matching_range.begin)..time)
|
357
|
+
list << (time..(matching_range.end))
|
358
|
+
end
|
359
|
+
|
360
|
+
inserted_stalled = false
|
361
|
+
|
362
|
+
list.sort_by(&:begin).each do |range|
|
363
|
+
seconds = (range.end - range.begin).to_i
|
364
|
+
next if seconds < stalled_threshold_seconds
|
365
|
+
|
366
|
+
an_hour_later = range.begin + (60 * 60)
|
367
|
+
blocking_stalled_changes << BlockedStalledChange.new(stalled_days: seconds / (24 * 60 * 60), time: an_hour_later)
|
368
|
+
inserted_stalled = true
|
369
|
+
end
|
370
|
+
inserted_stalled
|
371
|
+
end
|
372
|
+
|
373
|
+
def all_subtask_activity_times
|
374
|
+
subtask_activity_times = []
|
375
|
+
@subtasks.each do |subtask|
|
376
|
+
subtask_activity_times += subtask.changes.collect(&:time)
|
377
|
+
end
|
378
|
+
subtask_activity_times
|
379
|
+
end
|
380
|
+
|
381
|
+
def expedited?
|
382
|
+
names = @board&.expedited_priority_names
|
383
|
+
return false unless names
|
384
|
+
|
385
|
+
current_priority = raw['fields']['priority']&.[]('name')
|
386
|
+
names.include? current_priority
|
387
|
+
end
|
388
|
+
|
389
|
+
def expedited_on_date? date
|
390
|
+
expedited_start = nil
|
391
|
+
expedited_names = @board&.expedited_priority_names
|
392
|
+
|
393
|
+
changes.each do |change|
|
394
|
+
next unless change.priority?
|
395
|
+
|
396
|
+
if expedited_names.include? change.value
|
397
|
+
expedited_start = change.time.to_date if expedited_start.nil?
|
398
|
+
else
|
399
|
+
return true if expedited_start && (expedited_start..change.time.to_date).include?(date)
|
400
|
+
|
401
|
+
expedited_start = nil
|
402
|
+
end
|
403
|
+
end
|
404
|
+
|
405
|
+
return false if expedited_start.nil?
|
406
|
+
|
407
|
+
expedited_start <= date
|
408
|
+
end
|
409
|
+
|
410
|
+
# Return the last time there was any activity on this ticket. Starting from "now" and going backwards
|
411
|
+
# Returns nil if there was no activity before that time.
|
412
|
+
def last_activity now: Time.now
|
413
|
+
result = @changes.reverse.find { |change| change.time <= now }&.time
|
414
|
+
|
415
|
+
# The only condition where this could be nil is if "now" is before creation
|
416
|
+
return nil if result.nil?
|
417
|
+
|
418
|
+
@subtasks.each do |subtask|
|
419
|
+
subtask_last_activity = subtask.last_activity now: now
|
420
|
+
result = subtask_last_activity if subtask_last_activity && subtask_last_activity > result
|
421
|
+
end
|
422
|
+
|
423
|
+
result
|
424
|
+
end
|
425
|
+
|
426
|
+
def issue_links
|
427
|
+
if @issue_links.nil?
|
428
|
+
@issue_links = @raw['fields']['issuelinks']&.collect do |issue_link|
|
429
|
+
IssueLink.new origin: self, raw: issue_link
|
430
|
+
end || []
|
431
|
+
end
|
432
|
+
@issue_links
|
433
|
+
end
|
434
|
+
|
435
|
+
def fix_versions
|
436
|
+
if @fix_versions.nil?
|
437
|
+
@fix_versions = @raw['fields']['fixVersions']&.collect do |fix_version|
|
438
|
+
FixVersion.new fix_version
|
439
|
+
end || []
|
440
|
+
end
|
441
|
+
@fix_versions
|
442
|
+
end
|
443
|
+
|
444
|
+
def parent_key project_config: @board.project_config
|
445
|
+
# Although Atlassian is trying to standardize on one way to determine the parent, today it's a mess.
|
446
|
+
# We try a variety of ways to get the parent and hopefully one of them will work. See this link:
|
447
|
+
# https://community.developer.atlassian.com/t/deprecation-of-the-epic-link-parent-link-and-other-related-fields-in-rest-apis-and-webhooks/54048
|
448
|
+
|
449
|
+
fields = @raw['fields']
|
450
|
+
|
451
|
+
# At some point in the future, this will be the only way to retrieve the parent so we try this first.
|
452
|
+
parent = fields['parent']&.[]('key')
|
453
|
+
|
454
|
+
# The epic field
|
455
|
+
parent = fields['epic']&.[]('key') if parent.nil?
|
456
|
+
|
457
|
+
# Otherwise the parent link will be stored in one of the custom fields. We've seen different custom fields
|
458
|
+
# used for parent_link vs epic_link so we have to support more than one.
|
459
|
+
if parent.nil? && project_config
|
460
|
+
custom_field_names = project_config.settings['customfield_parent_links']
|
461
|
+
custom_field_names = [custom_field_names] if custom_field_names.is_a? String
|
462
|
+
|
463
|
+
custom_field_names&.each do |field_name|
|
464
|
+
parent = fields[field_name]
|
465
|
+
# A break would be more appropriate than a return but the runtime caused an error when we do that
|
466
|
+
return parent if parent
|
467
|
+
end
|
468
|
+
end
|
469
|
+
|
470
|
+
parent
|
471
|
+
end
|
472
|
+
|
473
|
+
def in_initial_query?
|
474
|
+
@raw['exporter'].nil? || @raw['exporter']['in_initial_query']
|
475
|
+
end
|
476
|
+
|
477
|
+
# It's artificial if it wasn't downloaded from a Jira instance.
|
478
|
+
def artificial?
|
479
|
+
@raw['exporter'].nil?
|
480
|
+
end
|
481
|
+
|
482
|
+
# Sort by key
|
483
|
+
def <=> other
|
484
|
+
/(?<project_code1>[^-]+)-(?<id1>.+)/ =~ key
|
485
|
+
/(?<project_code2>[^-]+)-(?<id2>.+)/ =~ other.key
|
486
|
+
comparison = project_code1 <=> project_code2
|
487
|
+
comparison = id1 <=> id2 if comparison.zero?
|
488
|
+
comparison
|
489
|
+
end
|
490
|
+
|
491
|
+
private
|
492
|
+
|
493
|
+
def assemble_author raw
|
494
|
+
raw['author']&.[]('displayName') || raw['author']&.[]('name') || 'Unknown author'
|
495
|
+
end
|
496
|
+
|
497
|
+
def load_history_into_changes
|
498
|
+
@raw['changelog']['histories'].each do |history|
|
499
|
+
created = parse_time(history['created'])
|
500
|
+
|
501
|
+
# It should be impossible to not have an author but we've seen it in production
|
502
|
+
author = assemble_author history
|
503
|
+
history['items'].each do |item|
|
504
|
+
@changes << ChangeItem.new(raw: item, time: created, author: author)
|
505
|
+
end
|
506
|
+
end
|
507
|
+
end
|
508
|
+
|
509
|
+
def load_comments_into_changes
|
510
|
+
@raw['fields']['comment']['comments'].each do |comment|
|
511
|
+
raw = {
|
512
|
+
'field' => 'comment',
|
513
|
+
'to' => comment['id'],
|
514
|
+
'toString' => comment['body']
|
515
|
+
}
|
516
|
+
author = assemble_author comment
|
517
|
+
created = parse_time(comment['created'])
|
518
|
+
@changes << ChangeItem.new(raw: raw, time: created, author: author, artificial: true)
|
519
|
+
end
|
520
|
+
end
|
521
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'time'
|
4
|
+
|
5
|
+
class IssueLink
|
6
|
+
attr_reader :origin, :raw
|
7
|
+
attr_writer :other_issue
|
8
|
+
|
9
|
+
def initialize origin:, raw:
|
10
|
+
@origin = origin
|
11
|
+
@raw = raw
|
12
|
+
end
|
13
|
+
|
14
|
+
def other_issue
|
15
|
+
if @other_issue.nil?
|
16
|
+
@other_issue = Issue.new(raw: (inward? ? raw['inwardIssue'] : raw['outwardIssue']), board: origin.board)
|
17
|
+
end
|
18
|
+
@other_issue
|
19
|
+
end
|
20
|
+
|
21
|
+
def direction
|
22
|
+
assert_jira_behaviour_false(raw['inwardIssue'].nil? && raw['outwardIssue'].nil?) do
|
23
|
+
"Found an issue link with neither inward nor outward references: #{raw}"
|
24
|
+
end
|
25
|
+
assert_jira_behaviour_false(raw['inwardIssue'] && raw['outwardIssue']) do
|
26
|
+
"Found an issue link that has both inward and outward references in the same link: #{raw}"
|
27
|
+
end
|
28
|
+
|
29
|
+
if raw['inwardIssue']
|
30
|
+
:inward
|
31
|
+
else
|
32
|
+
:outward
|
33
|
+
end
|
34
|
+
end
|
35
|
+
|
36
|
+
def inward?
|
37
|
+
direction == :inward
|
38
|
+
end
|
39
|
+
|
40
|
+
def outward?
|
41
|
+
direction == :outward
|
42
|
+
end
|
43
|
+
|
44
|
+
def label
|
45
|
+
if inward?
|
46
|
+
@raw['type']['inward']
|
47
|
+
else
|
48
|
+
@raw['type']['outward']
|
49
|
+
end
|
50
|
+
end
|
51
|
+
|
52
|
+
def name
|
53
|
+
@raw['type']['name']
|
54
|
+
end
|
55
|
+
|
56
|
+
def inspect
|
57
|
+
"IssueLink(origin=#{origin.key}, other=#{other_issue.key}, direction=#{direction}, " \
|
58
|
+
"label=#{label.inspect}, name=#{name.inspect}"
|
59
|
+
end
|
60
|
+
end
|