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.
Files changed (68) hide show
  1. checksums.yaml +7 -0
  2. data/bin/jirametrics +4 -0
  3. data/lib/jirametrics/aggregate_config.rb +89 -0
  4. data/lib/jirametrics/aging_work_bar_chart.rb +235 -0
  5. data/lib/jirametrics/aging_work_in_progress_chart.rb +148 -0
  6. data/lib/jirametrics/aging_work_table.rb +149 -0
  7. data/lib/jirametrics/anonymizer.rb +186 -0
  8. data/lib/jirametrics/blocked_stalled_change.rb +43 -0
  9. data/lib/jirametrics/board.rb +85 -0
  10. data/lib/jirametrics/board_column.rb +14 -0
  11. data/lib/jirametrics/board_config.rb +31 -0
  12. data/lib/jirametrics/change_item.rb +80 -0
  13. data/lib/jirametrics/chart_base.rb +239 -0
  14. data/lib/jirametrics/columns_config.rb +42 -0
  15. data/lib/jirametrics/cycletime_config.rb +69 -0
  16. data/lib/jirametrics/cycletime_histogram.rb +74 -0
  17. data/lib/jirametrics/cycletime_scatterplot.rb +128 -0
  18. data/lib/jirametrics/daily_wip_by_age_chart.rb +88 -0
  19. data/lib/jirametrics/daily_wip_by_blocked_stalled_chart.rb +77 -0
  20. data/lib/jirametrics/daily_wip_chart.rb +123 -0
  21. data/lib/jirametrics/data_quality_report.rb +278 -0
  22. data/lib/jirametrics/dependency_chart.rb +217 -0
  23. data/lib/jirametrics/discard_changes_before.rb +37 -0
  24. data/lib/jirametrics/download_config.rb +41 -0
  25. data/lib/jirametrics/downloader.rb +337 -0
  26. data/lib/jirametrics/examples/aggregated_project.rb +36 -0
  27. data/lib/jirametrics/examples/standard_project.rb +111 -0
  28. data/lib/jirametrics/expedited_chart.rb +169 -0
  29. data/lib/jirametrics/experimental/generator.rb +209 -0
  30. data/lib/jirametrics/experimental/info.rb +77 -0
  31. data/lib/jirametrics/exporter.rb +127 -0
  32. data/lib/jirametrics/file_config.rb +119 -0
  33. data/lib/jirametrics/fix_version.rb +21 -0
  34. data/lib/jirametrics/groupable_issue_chart.rb +44 -0
  35. data/lib/jirametrics/grouping_rules.rb +13 -0
  36. data/lib/jirametrics/hierarchy_table.rb +31 -0
  37. data/lib/jirametrics/html/aging_work_bar_chart.erb +72 -0
  38. data/lib/jirametrics/html/aging_work_in_progress_chart.erb +52 -0
  39. data/lib/jirametrics/html/aging_work_table.erb +60 -0
  40. data/lib/jirametrics/html/collapsible_issues_panel.erb +32 -0
  41. data/lib/jirametrics/html/cycletime_histogram.erb +41 -0
  42. data/lib/jirametrics/html/cycletime_scatterplot.erb +103 -0
  43. data/lib/jirametrics/html/daily_wip_chart.erb +63 -0
  44. data/lib/jirametrics/html/data_quality_report.erb +126 -0
  45. data/lib/jirametrics/html/expedited_chart.erb +67 -0
  46. data/lib/jirametrics/html/hierarchy_table.erb +29 -0
  47. data/lib/jirametrics/html/index.erb +66 -0
  48. data/lib/jirametrics/html/sprint_burndown.erb +116 -0
  49. data/lib/jirametrics/html/story_point_accuracy_chart.erb +57 -0
  50. data/lib/jirametrics/html/throughput_chart.erb +65 -0
  51. data/lib/jirametrics/html_report_config.rb +217 -0
  52. data/lib/jirametrics/issue.rb +521 -0
  53. data/lib/jirametrics/issue_link.rb +60 -0
  54. data/lib/jirametrics/json_file_loader.rb +9 -0
  55. data/lib/jirametrics/project_config.rb +442 -0
  56. data/lib/jirametrics/rules.rb +34 -0
  57. data/lib/jirametrics/self_or_issue_dispatcher.rb +15 -0
  58. data/lib/jirametrics/sprint.rb +43 -0
  59. data/lib/jirametrics/sprint_burndown.rb +335 -0
  60. data/lib/jirametrics/sprint_issue_change_data.rb +31 -0
  61. data/lib/jirametrics/status.rb +26 -0
  62. data/lib/jirametrics/status_collection.rb +67 -0
  63. data/lib/jirametrics/story_point_accuracy_chart.rb +139 -0
  64. data/lib/jirametrics/throughput_chart.rb +91 -0
  65. data/lib/jirametrics/tree_organizer.rb +96 -0
  66. data/lib/jirametrics/trend_line_calculator.rb +74 -0
  67. data/lib/jirametrics.rb +85 -0
  68. 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
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+
5
+ class JsonFileLoader
6
+ def load filename
7
+ JSON.parse File.read(filename)
8
+ end
9
+ end