jirametrics 1.0.0

Sign up to get free protection for your applications and to get access to all the features.
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