tefoji 1.0.9 → 1.1.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8279bb8ebda1af1e674da5c4d16932b790e69210a5c163563746a80d44aca92b
4
- data.tar.gz: f17bbc221ed1ec7c4fde90d2e64caddb45078ee0ee2d5cb59e7226d90df14317
3
+ metadata.gz: b048f1a4325a22d2fa736626f148827792639424d448ccc0d5a09250e250bdfa
4
+ data.tar.gz: 8350c3e8d0fdf3ad2ecfdf922ef654b37ddd86f9e44b4358cc713bf54cb462b8
5
5
  SHA512:
6
- metadata.gz: d790551fe749db5eea41ce6889479c3f23e8278ad94744b8be4561d0cceadb33b609949c612224e49bbc21587b8b3cf50501a6eeef1e2d3059c860420e583c15
7
- data.tar.gz: a0b4bd8875027f141a87f2bc960c43143813500db372e0764df428896fc04f34fd3f5b91d3a4d4ca305e9949b88dd3329f4363a1981905f6b4b7d7fe6c7b9fe9
6
+ metadata.gz: fd6187add4fe25d2ea7ebcc3aadc8346cfbfeae133e1fc4c366dad99395cfcbddd0aa6c024caa9f86676651037dcacdf0f1092a98ab0a2d1cc74a9033ab7dd7c
7
+ data.tar.gz: 2d5fe6518a6a174aeb033e006fa92b168f5115bb4526cb7dbbce2b177ac185bb79b795fcf7e43e86e63f063150bb5e5e6ad2fee28f17251010bbe2d319efd4c7
@@ -34,7 +34,6 @@ module UserFunctions
34
34
  # Maps of Winston-style Jira names (projects, sprints, to the actual assets
35
35
  def jira_projects
36
36
  {
37
- BOLT: 'BOLT',
38
37
  BUILD_TOOLS: 'BUILD',
39
38
  CLIENT_TOOLS: 'CT',
40
39
  CODE_MANAGEMENT: 'CODEMGMT',
@@ -50,14 +49,13 @@ module UserFunctions
50
49
  OPERATIONS: 'OPS',
51
50
  PDK: 'CONT',
52
51
  PE_INTERNAL: 'PE',
52
+ POOLER: 'POOLER',
53
53
  PROJECT_CENTRAL: 'PC',
54
54
  PUPPETDB: 'PDB',
55
55
  PUPPETSERVER: 'SERVER',
56
56
  PUPPET_AGENT: 'PA',
57
57
  PUPPET: 'PUP',
58
58
  QUALITY_ENGINEERING: 'QENG',
59
- DIO: 'DIO',
60
- RAZOR: 'RAZOR',
61
59
  RELEASE_ENGINEERING: 'RE',
62
60
  SLV: 'SLV',
63
61
  SUPPORT: 'SUP',
data/lib/tefoji/cli.rb CHANGED
@@ -6,8 +6,11 @@ require 'yaml'
6
6
  module Tefoji
7
7
  class CLI
8
8
  DEFAULT_JIRA_URL = 'https://tickets.puppetlabs.com'
9
- JIRA_TEST_URL = 'https://jira-app-dev-1.ops.puppetlabs.net'
9
+ JIRA_TEST_URL = 'https://jira-app-test-1.ops.puppetlabs.net/'
10
10
  DEFAULT_JIRA_AUTH_FILE = "#{ENV['HOME']}/.tefoji-auth.yaml"
11
+ JIRA_CLOUD_URLS = [
12
+ 'https://puppet.atlassian.net'
13
+ ]
11
14
 
12
15
  DOCUMENTATION = <<~DOCOPT
13
16
  Generate Jira issues from YAML files.
@@ -47,19 +50,24 @@ module Tefoji
47
50
  exit 0
48
51
  end
49
52
 
53
+ @user_options['--jira'] = JIRA_TEST_URL if @user_options['--jira-test']
54
+ @user_options['--jira'] = DEFAULT_JIRA_URL if @user_options['--jira'].nil?
55
+
50
56
  @user_options['jira-auth-string'] = nil
51
57
  @user_options['jira-auth-file'] = @user_options['--jira-auth'] || DEFAULT_JIRA_AUTH_FILE
52
58
 
59
+ @user_options['jira-cloud'] = false
60
+ @user_options['jira-cloud'] = true if JIRA_CLOUD_URLS.include?(@user_options['--jira'])
61
+
53
62
  jira_auth_file = @user_options['jira-auth-file']
54
- if File.file?(jira_auth_file)
55
- authentication = YAML.load_file(jira_auth_file)
56
- if authentication.key?('jira-auth')
57
- @user_options['jira-auth-string'] = authentication['jira-auth']
58
- end
59
- end
63
+ return unless File.file?(jira_auth_file)
60
64
 
61
- @user_options['--jira'] = JIRA_TEST_URL if @user_options['--jira-test']
62
- @user_options['--jira'] = DEFAULT_JIRA_URL if @user_options['--jira'].nil?
65
+ authentication = YAML.load_file(jira_auth_file)
66
+ if @user_options['jira-cloud'] && authentication.key?('jira-cloud-auth')
67
+ @user_options['jira-auth-string'] = authentication['jira-cloud-auth']
68
+ elsif authentication.key?('jira-auth')
69
+ @user_options['jira-auth-string'] = authentication['jira-auth']
70
+ end
63
71
  end
64
72
 
65
73
  # Iterate through issue templates, validating each. If that goes well, generate
@@ -2,6 +2,7 @@ require 'base64'
2
2
  require 'io/console'
3
3
  require 'json'
4
4
  require 'rest-client'
5
+ RestClient.log = 'stdout'
5
6
 
6
7
  module Tefoji
7
8
  ## An interface to send API request to Jira using the rest-client gem.
@@ -14,6 +15,7 @@ module Tefoji
14
15
  include Logging
15
16
 
16
17
  ## Jira field keys
18
+ FIELD_ACCOUNT_ID = 'accountId'
17
19
  FIELD_ID = 'id'
18
20
  FIELD_KEY = 'key'
19
21
  FIELD_NAME = 'name'
@@ -21,7 +23,6 @@ module Tefoji
21
23
 
22
24
  ## Issue type constants
23
25
  ISSUE_EPIC = 'Epic'
24
- ISSUE_FEATURE = 'Feature'
25
26
  ISSUE_NEW_FEATURE = 'New Feature'
26
27
  ISSUE_SUB_TASK = 'Sub-task'
27
28
  ISSUE_TASK = 'Task'
@@ -74,20 +75,26 @@ module Tefoji
74
75
 
75
76
  # Do this so we can inform the user quickly that their credentials didn't work
76
77
  # There may be a better test, but this is the one the original Winston uses
77
- def test_authentication
78
- get_username(@jira_username)
78
+ def test_authentication(jira_cloud)
79
+ get_username(@jira_username, jira_cloud)
79
80
  rescue RestClient::Forbidden
80
81
  fatal 'Forbidden: either the authentication is incorrect or ' \
81
82
  'Jira might be requiring a CAPTCHA response from the web interface.'
82
83
  end
83
84
 
84
85
  # Get information about user in Jira
85
- def get_username(username, fail_if_not_found = true)
86
- jira_get("user?username=#{username}", fail_if_not_found)
86
+ def get_username(username, jira_cloud, fail_if_not_found = true)
87
+ search_parameters = if jira_cloud
88
+ "user/search?query=#{username}"
89
+ else
90
+ "user?username=#{username}"
91
+ end
92
+
93
+ jira_get(search_parameters, fail_if_not_found)
87
94
  end
88
95
 
89
96
  # Save authentication YAML to the a file for reuse.
90
- def save_authentication(save_file_name)
97
+ def save_authentication(save_file_name, jira_cloud)
91
98
  backup_file_name = "#{save_file_name}.bak"
92
99
 
93
100
  if File.readable?(save_file_name)
@@ -95,18 +102,22 @@ module Tefoji
95
102
  @logger.info "Saved #{save_file_name} to #{backup_file_name}"
96
103
  end
97
104
 
98
- File.open(save_file_name, 'w') do |f|
99
- f.puts "jira-auth: #{@jira_auth_string}"
100
- end
105
+ jira_auth_string = if jira_cloud
106
+ "jira-auth: #{@jira_auth_string}\n"
107
+ else
108
+ "jira-cloud-auth: #{@jira_auth_string}\n"
109
+ end
110
+ File.write(save_file_name, jira_auth_string)
111
+
101
112
  File.chmod(0o600, save_file_name)
102
113
 
103
114
  @logger.info "Saved Jira authentication to #{save_file_name}"
104
115
  end
105
116
 
106
117
  # Create a Jira issue
107
- def create_issue(issue_data)
118
+ def create_issue(issue_data, jira_cloud)
108
119
  request_path = 'issue'
109
- jira_fields = issue_data_to_jira_fields(issue_data)
120
+ jira_fields = issue_data_to_jira_fields(issue_data, jira_cloud)
110
121
  jira_post(request_path, jira_fields)
111
122
  rescue RestClient::Forbidden
112
123
  fatal "Forbidden: could not send #{request_path} request to Jira. "\
@@ -160,12 +171,25 @@ module Tefoji
160
171
  end
161
172
 
162
173
  # https://www.youtube.com/watch?v=JsntlJZ9h1U
163
- def add_watcher(issue_key, watcher)
174
+ def add_watcher(issue_key, watcher, jira_cloud)
164
175
  request_path = "issue/#{issue_key}/watchers"
176
+ watcher = useremail_to_id(watcher) if jira_cloud
165
177
  request_data = watcher
166
178
  jira_post(request_path, request_data)
167
179
  end
168
180
 
181
+ # jira cloud api calls now require an accountId rather than a username. This method will convert
182
+ # emails or display names to accountIds.
183
+ def useremail_to_id(email)
184
+ response = get_username(email, true, true)[0]
185
+ id = response['accountId']
186
+ if id.nil? || id.empty?
187
+ @logger.error "accountId not found for #{email}."
188
+ exit 1
189
+ end
190
+ return id
191
+ end
192
+
169
193
  private
170
194
 
171
195
  def jira_get(jira_request_path, fail_if_not_found = true)
@@ -177,7 +201,6 @@ module Tefoji
177
201
  url = "#{@jira_base_rest_url}/#{jira_request_path}"
178
202
  end
179
203
  headers = @authentication_header
180
-
181
204
  begin
182
205
  response = RestClient.get(url, headers)
183
206
  rescue RestClient::MovedPermanently,
@@ -244,7 +267,7 @@ module Tefoji
244
267
  # Provide the needed translation of user-created data to required format
245
268
  # of Jira requests. This is mostly a list of fussing with the Jira field
246
269
  # names.
247
- def issue_data_to_jira_fields(issue_data)
270
+ def issue_data_to_jira_fields(issue_data, jira_cloud)
248
271
  # Check to ensure we have what we need to create a issue
249
272
 
250
273
  unless issue_data['summary']
@@ -257,64 +280,130 @@ module Tefoji
257
280
  end
258
281
 
259
282
  # build the jira_fields hash describing the issue
260
-
261
283
  # These are required for all issues
262
284
  jira_fields = {
263
285
  'summary' => issue_data['summary'],
264
286
  'project' => { 'key' => issue_data['project'] }
265
287
  }
266
288
 
289
+ set_common_jira_fields(issue_data, jira_fields)
290
+
291
+ if jira_cloud
292
+ set_cloud_jira_fields(issue_data, jira_fields)
293
+ else
294
+ set_onprem_jira_fields(issue_data, jira_fields)
295
+ end
296
+
297
+ return { 'fields' => jira_fields }
298
+ end
299
+
300
+ def set_common_jira_fields(issue_data, jira_fields)
267
301
  # The following are optional
268
302
  if issue_data['description']
269
303
  jira_fields['description'] = issue_data['description']
270
304
  end
305
+
306
+ if issue_data['labels']
307
+ labels = issue_data['labels'].to_a.flatten.reject { |t| t =~ /^\s*$/ }
308
+ jira_fields['labels'] = labels unless labels.empty?
309
+ end
310
+ if issue_data['duedate']
311
+ jira_fields['duedate'] = issue_data['duedate']
312
+ end
313
+ if issue_data['fix_version']
314
+ jira_fields['fixVersions'] = [issue_data['fix_version']]
315
+ end
316
+
317
+ if issue_data['components']
318
+ components = issue_data[:components].to_a.flatten.reject { |t| t =~ /^\s*$/ }
319
+ unless components.empty?
320
+ jira_fields['components'] = components.map { |component| { FIELD_NAME => component } }
321
+ end
322
+ end
323
+
324
+ # Default issue type to ISSUE_TASK if it isn't already set
325
+ jira_fields['issuetype'] = { FIELD_NAME => ISSUE_TASK }
326
+
327
+ security = ENV['SECURITY'] || issue_data['security']
328
+ return unless security
329
+
330
+ case security.downcase
331
+ when 'confidential'
332
+ jira_fields['security'] = { FIELD_ID => '10002' }
333
+ when 'internal'
334
+ jira_fields['security'] = { FIELD_ID => '10001' }
335
+ when 'public'
336
+ # Nothing to do here - public is default
337
+ else
338
+ @logger.fatal "Unknown security type: #{security}"
339
+ exit 1
340
+ end
341
+ end
342
+
343
+ def set_cloud_jira_fields(issue_data, jira_fields)
271
344
  if issue_data['assignee']
272
- jira_fields['assignee'] = { FIELD_NAME => issue_data['assignee'] }
345
+ assignee = issue_data['assignee']
346
+ assignee = useremail_to_id(assignee)
347
+ jira_fields['assignee'] = { FIELD_ACCOUNT_ID => assignee }
273
348
  end
349
+
350
+ if issue_data['type']
351
+ jira_fields['issuetype'] = { FIELD_NAME => issue_data['type'] }
352
+ # If this is an epic, we need to add an epic name
353
+ if issue_data['type'].casecmp?(ISSUE_EPIC)
354
+ jira_fields['customfield_10011'] = issue_data['epic_name'] || issue_data['summary']
355
+ end
356
+ end
357
+
274
358
  if issue_data['story_points']
275
- jira_fields['customfield_10002'] = issue_data['story_points'].to_i
359
+ jira_fields['customfield_10038'] = issue_data['story_points'].to_i
276
360
  end
277
361
  if issue_data['team']
278
- jira_fields['customfield_14200'] = { FIELD_VALUE => issue_data['team'] }
362
+ jira_fields['customfield_10052'] = { FIELD_VALUE => issue_data['team'] }
279
363
  end
280
364
  if issue_data['teams']
281
365
  teams = issue_data['teams'].to_a.flatten.reject { |t| t =~ /^\s*$/ }
282
366
  unless teams.empty?
283
- jira_fields['customfield_14201'] = teams.map { |team| { FIELD_VALUE => team } }
367
+ jira_fields['customfield_10066'] = teams.map { |team| { FIELD_VALUE => team } }
284
368
  end
285
369
  end
286
370
  if issue_data['subteam']
287
- jira_fields['customfield_11700'] = [issue_data['subteam']]
371
+ jira_fields['customfield_10045'] = [issue_data['subteam']]
288
372
  end
289
373
  if issue_data['sprint']
290
- jira_fields['customfield_10005'] = issue_data['sprint'].to_i
374
+ jira_fields['customfield_10020'] = issue_data['sprint'].to_i
291
375
  end
292
376
  if issue_data['acceptance']
293
- jira_fields['customfield_11501'] = issue_data['acceptance']
294
- end
295
- if issue_data['labels']
296
- labels = issue_data['labels'].to_a.flatten.reject { |t| t =~ /^\s*$/ }
297
- jira_fields['labels'] = labels unless labels.empty?
298
- end
299
- if issue_data['duedate']
300
- jira_fields['duedate'] = issue_data['duedate']
301
- end
302
- if issue_data['fix_version']
303
- jira_fields['fixVersions'] = [issue_data['fix_version']]
377
+ jira_fields['customfield_10062'] = issue_data['acceptance']
304
378
  end
305
379
  if issue_data['release_notes']
306
- jira_fields['customfield_11100'] = { FIELD_VALUE => issue_data['release_notes'] }
380
+ jira_fields['customfield_10043'] = { FIELD_VALUE => issue_data['release_notes'] }
307
381
  end
308
382
 
309
- if issue_data['components']
310
- components = issue_data[:components].to_a.flatten.reject { |t| t =~ /^\s*$/ }
311
- unless components.empty?
312
- jira_fields['components'] = components.map { |component| { FIELD_NAME => component } }
383
+ # If a issue has a specified parent issue, prefer that. The parent issue *should* already
384
+ # be linked to the main epic. Otherwise, we need to set it to have an epic_parent. This can
385
+ # either be an epic linked to the main epic or the main epic itself.
386
+
387
+ if issue_data['parent']
388
+ unless issue_data['type'].casecmp?(ISSUE_SUB_TASK) || !issue_data['type']
389
+ @logger.fatal "A issue with a parent must be classified as a Sub-issue\n\n#{issue_data}"
390
+ exit 1
391
+ end
392
+ jira_fields['issuetype'] = { FIELD_NAME => ISSUE_SUB_TASK }
393
+ jira_fields['parent'] = { FIELD_KEY => issue_data['parent'] }
394
+ elsif issue_data['epic_parent']
395
+ if issue_data['type'].casecmp?(ISSUE_SUB_TASK)
396
+ @logger.fatal "This issue cannot be a subtask of an epic\n\n#{issue_data}"
397
+ exit 1
313
398
  end
399
+ jira_fields['customfield_10018'] = issue_data['epic_parent']
314
400
  end
401
+ end
315
402
 
316
- # Default issue type to ISSUE_TASK if it isn't already set
317
- jira_fields['issuetype'] = { FIELD_NAME => ISSUE_TASK }
403
+ def set_onprem_jira_fields(issue_data, jira_fields)
404
+ if issue_data['assignee']
405
+ jira_fields['assignee'] = { FIELD_NAME => issue_data['assignee'] }
406
+ end
318
407
 
319
408
  if issue_data['type']
320
409
  jira_fields['issuetype'] = { FIELD_NAME => issue_data['type'] }
@@ -324,6 +413,31 @@ module Tefoji
324
413
  end
325
414
  end
326
415
 
416
+ if issue_data['story_points']
417
+ jira_fields['customfield_10002'] = issue_data['story_points'].to_i
418
+ end
419
+ if issue_data['team']
420
+ jira_fields['customfield_14200'] = { FIELD_VALUE => issue_data['team'] }
421
+ end
422
+ if issue_data['teams']
423
+ teams = issue_data['teams'].to_a.flatten.reject { |t| t =~ /^\s*$/ }
424
+ unless teams.empty?
425
+ jira_fields['customfield_14201'] = teams.map { |team| { FIELD_VALUE => team } }
426
+ end
427
+ end
428
+ if issue_data['subteam']
429
+ jira_fields['customfield_11700'] = [issue_data['subteam']]
430
+ end
431
+ if issue_data['sprint']
432
+ jira_fields['customfield_10005'] = issue_data['sprint'].to_i
433
+ end
434
+ if issue_data['acceptance']
435
+ jira_fields['customfield_11501'] = issue_data['acceptance']
436
+ end
437
+ if issue_data['release_notes']
438
+ jira_fields['customfield_11100'] = { FIELD_VALUE => issue_data['release_notes'] }
439
+ end
440
+
327
441
  # If a issue has a specified parent issue, prefer that. The parent issue *should* already
328
442
  # be linked to the main epic. Otherwise, we need to set it to have an epic_parent. This can
329
443
  # either be an epic linked to the main epic or the main epic itself.
@@ -342,23 +456,6 @@ module Tefoji
342
456
  end
343
457
  jira_fields['customfield_10006'] = issue_data['epic_parent']
344
458
  end
345
-
346
- security = ENV['SECURITY'] || issue_data['security']
347
- if security
348
- case security.downcase
349
- when 'confidential'
350
- jira_fields['security'] = { FIELD_ID => '10002' }
351
- when 'internal'
352
- jira_fields['security'] = { FIELD_ID => '10001' }
353
- when 'public'
354
- # Nothing to do here - public is default
355
- else
356
- @logger.fatal "Unknown security type: #{security}"
357
- exit 1
358
- end
359
- end
360
-
361
- return { 'fields' => jira_fields }
362
459
  end
363
460
  end
364
461
 
data/lib/tefoji.rb CHANGED
@@ -28,6 +28,7 @@ module Tefoji
28
28
  @jira_auth_string = user_options['jira-auth-string']
29
29
  @jira_auth_file = user_options['jira-auth-file']
30
30
  @jira_mock = user_options['--jira-mock']
31
+ @jira_cloud = user_options['jira-cloud']
31
32
 
32
33
  @no_notes = user_options['--no-notes']
33
34
  @template_data = {}
@@ -272,13 +273,13 @@ module Tefoji
272
273
  @jira_api.logger = @logger
273
274
 
274
275
  @jira_api.authenticate(@jira_url, @jira_user, @jira_auth_string)
275
- @jira_api.test_authentication unless @jira_mock
276
+ @jira_api.test_authentication(@jira_cloud) unless @jira_mock
276
277
  end
277
278
 
278
279
  # Save Jira auth data
279
280
  def save_authentication
280
281
  authenticate unless @jira_api
281
- @jira_api.save_authentication(@jira_auth_file)
282
+ @jira_api.save_authentication(@jira_auth_file, @jira_cloud)
282
283
  end
283
284
 
284
285
  private
@@ -297,8 +298,9 @@ module Tefoji
297
298
  feature_to_do = @template_data['feature']
298
299
 
299
300
  feature = variable_substitute(feature_to_do)
300
- feature['type'] = JiraApi::ISSUE_FEATURE
301
- @feature_issue = @jira_api.create_issue(feature)
301
+ feature['type'] = JiraApi::ISSUE_NEW_FEATURE
302
+
303
+ @feature_issue = @jira_api.create_issue(feature, @jira_cloud)
302
304
  @logger.info "Feature issue: #{@feature_issue['key']}"
303
305
  @feature_issue
304
306
  end
@@ -360,7 +362,7 @@ module Tefoji
360
362
  epic['security'] = 'internal'
361
363
  end
362
364
 
363
- epic_issue = @jira_api.create_issue(epic)
365
+ epic_issue = @jira_api.create_issue(epic, @jira_cloud)
364
366
  epic_issue['short_name'] = short_name
365
367
  @logger.info 'Epic: %16s [%s]' % [epic_issue['key'], short_name]
366
368
  @jira_api.link_issues(@feature_issue['key'], epic_issue['key']) if @feature_issue
@@ -400,7 +402,7 @@ module Tefoji
400
402
  jira_ready_data, raw_issue_data = prepare_jira_ready_data(issue, issue_defaults)
401
403
  next if jira_ready_data.nil? || raw_issue_data.nil?
402
404
 
403
- response_data = @jira_api.create_issue(jira_ready_data)
405
+ response_data = @jira_api.create_issue(jira_ready_data, @jira_cloud)
404
406
  jira_issue = @jira_api.retrieve_issue(response_data['self'])
405
407
  jira_issue['short_name'] = raw_issue_data['short_name']
406
408
 
@@ -610,7 +612,7 @@ module Tefoji
610
612
  issue_key = jira_issue_data['key']
611
613
  watchers = raw_issue_data[deferred_tag].value
612
614
  watchers.each do |watcher|
613
- @jira_api.add_watcher(issue_key, watcher)
615
+ @jira_api.add_watcher(issue_key, watcher, @jira_cloud)
614
616
  @logger.info '%14s: watched by %s' % [issue_key, watcher]
615
617
  end
616
618
  end
@@ -932,7 +934,7 @@ module Tefoji
932
934
  def update_user_validity(username, issue_name, valid_users, invalid_users)
933
935
  if invalid_users.key?(username)
934
936
  invalid_users[username] += [issue_name]
935
- elsif @jira_api.get_username(username, false)
937
+ elsif @jira_api.get_username(username, @jira_cloud, false)
936
938
  valid_users << username
937
939
  else
938
940
  invalid_users[username] = invalid_users.fetch(username, []) + [issue_name]
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tefoji
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.0.9
4
+ version: 1.1.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Puppet Labs
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2023-02-01 00:00:00.000000000 Z
11
+ date: 2023-05-17 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: pry-byebug