jirametrics 1.0.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 +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
|