standard_automation_library 0.2.1.pre.temp

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 (37) hide show
  1. checksums.yaml +7 -0
  2. data/lib/standard_automation_library/api_clients/app_center.rb +104 -0
  3. data/lib/standard_automation_library/api_clients/bugsnag.rb +94 -0
  4. data/lib/standard_automation_library/api_clients/github.rb +371 -0
  5. data/lib/standard_automation_library/api_clients/jira.rb +326 -0
  6. data/lib/standard_automation_library/api_clients/pagerduty.rb +101 -0
  7. data/lib/standard_automation_library/api_clients/slack.rb +499 -0
  8. data/lib/standard_automation_library/danger/danger_jira.rb +169 -0
  9. data/lib/standard_automation_library/errors/slack_api_error.rb +6 -0
  10. data/lib/standard_automation_library/personnel/release_management_team.rb +85 -0
  11. data/lib/standard_automation_library/personnel/team.rb +41 -0
  12. data/lib/standard_automation_library/personnel/user.rb +68 -0
  13. data/lib/standard_automation_library/services/bugsnag_service.rb +251 -0
  14. data/lib/standard_automation_library/services/jira_service.rb +64 -0
  15. data/lib/standard_automation_library/services/merge_driver_service.rb +48 -0
  16. data/lib/standard_automation_library/services/mobile_tech_debt_logging_service.rb +176 -0
  17. data/lib/standard_automation_library/services/monorepo_platform_service.rb +18 -0
  18. data/lib/standard_automation_library/services/perf_tracker_logging_service.rb +87 -0
  19. data/lib/standard_automation_library/services/platform_service.rb +34 -0
  20. data/lib/standard_automation_library/services/repo_service.rb +17 -0
  21. data/lib/standard_automation_library/services/slack_service.rb +383 -0
  22. data/lib/standard_automation_library/util/automerge_configuration.rb +134 -0
  23. data/lib/standard_automation_library/util/bundler.rb +18 -0
  24. data/lib/standard_automation_library/util/datetime_helper.rb +23 -0
  25. data/lib/standard_automation_library/util/file_content.rb +15 -0
  26. data/lib/standard_automation_library/util/git.rb +235 -0
  27. data/lib/standard_automation_library/util/git_merge_error_message_cleaner.rb +27 -0
  28. data/lib/standard_automation_library/util/network.rb +39 -0
  29. data/lib/standard_automation_library/util/path_container.rb +17 -0
  30. data/lib/standard_automation_library/util/platform_picker.rb +150 -0
  31. data/lib/standard_automation_library/util/shared_constants.rb +27 -0
  32. data/lib/standard_automation_library/util/shell_helper.rb +54 -0
  33. data/lib/standard_automation_library/util/slack_constants.rb +40 -0
  34. data/lib/standard_automation_library/util/version.rb +31 -0
  35. data/lib/standard_automation_library/version.rb +5 -0
  36. data/lib/standard_automation_library.rb +8 -0
  37. metadata +296 -0
@@ -0,0 +1,85 @@
1
+ require_relative 'team'
2
+ require_relative 'user'
3
+
4
+ # A team which automatically determines the members via querying pagerduty.
5
+ # Users are not computed at instantiation for performance reasons.
6
+ class ReleaseManagementTeam < Team
7
+ def initialize(
8
+ github_client,
9
+ slack_service,
10
+ pagerduty_client,
11
+ pagerduty_schedule_id,
12
+ pagerduty_cuttoff_hour = 10,
13
+ pagerduty_time_zone = 'America/Los_Angeles',
14
+ slack_user_group_id = nil,
15
+ error_reporting_channels: []
16
+ )
17
+ super(slack_user_group_id: slack_user_group_id)
18
+ @github_client = github_client
19
+ @slack_service = slack_service
20
+ @pagerduty_client = pagerduty_client
21
+ @pagerduty_schedule_id = pagerduty_schedule_id
22
+ @pagerduty_cuttoff_hour = pagerduty_cuttoff_hour
23
+ @pagerduty_time_zone = pagerduty_time_zone
24
+ @users = []
25
+ @error_reporting_channels = error_reporting_channels
26
+ end
27
+
28
+ def users
29
+ return @users unless @users.empty?
30
+
31
+ begin
32
+ # get name of the release manager from pagerduty
33
+ release_manager_name = @pagerduty_client.who_is_on_duty(
34
+ @pagerduty_schedule_id,
35
+ @pagerduty_cuttoff_hour,
36
+ time_zone: @pagerduty_time_zone
37
+ )
38
+
39
+ if release_manager_name
40
+ @users = [
41
+ User.new(
42
+ release_manager_name,
43
+ github_client: @github_client,
44
+ slack_service: @slack_service,
45
+ error_reporting_channels: @error_reporting_channels
46
+ )
47
+ ]
48
+ end
49
+ rescue StandardError => e
50
+ send_slack_failure(e.message)
51
+ end
52
+ @users
53
+ end
54
+
55
+ # get user of the next release manager from pagerduty (two weeks from now)
56
+ def next_release_manager(team_domain)
57
+ next_release_manager_name = @pagerduty_client.who_is_on_duty(
58
+ @pagerduty_schedule_id,
59
+ @pagerduty_cuttoff_hour,
60
+ time: Time.now + (14 * 24 * 60 * 60),
61
+ time_zone: @pagerduty_time_zone
62
+ )
63
+
64
+ return nil if next_release_manager_name.nil?
65
+
66
+ users_param = [User.new(
67
+ next_release_manager_name,
68
+ github_client: @github_client,
69
+ slack_service: @slack_service,
70
+ error_reporting_channels: @error_reporting_channels
71
+ )]
72
+ render_slack_ids(team_domain, users_param)
73
+ rescue StandardError => e
74
+ send_slack_failure(e.message)
75
+ nil
76
+ end
77
+
78
+ def send_slack_failure(error_message)
79
+ message = "There was an error fetching data from PagerDuty\n#{error_message}\n#{ENV.fetch('BUILD_URL', nil)}"
80
+ @slack_service.send_message(
81
+ channel_strings: @error_reporting_channels,
82
+ message: message
83
+ )
84
+ end
85
+ end
@@ -0,0 +1,41 @@
1
+ # Represents a collection of users
2
+ class Team
3
+ def initialize(users: [], slack_user_group_id: nil)
4
+ # Don't access @users. Use the users method instead because a subclass may overwrite it.
5
+ @users = users
6
+ @slack_user_group_id = slack_user_group_id
7
+ end
8
+
9
+ attr_accessor :users
10
+
11
+ attr_accessor :slack_user_group_id
12
+
13
+ def names(users_param = nil)
14
+ users_to_use = users_param || users
15
+ users_to_use.map(&:name)
16
+ end
17
+
18
+ def github_ids(users_param = nil)
19
+ users_to_use = users_param || users
20
+ users_to_use.map(&:github_id)
21
+ end
22
+
23
+ def slack_user_id_hashes(users_param = nil)
24
+ users_to_use = users_param || users
25
+ users_to_use.map(&:slack_user_id_hash)
26
+ end
27
+
28
+ def slack_ids(team_domain, users_param = nil)
29
+ slack_user_id_hashes(users_param).map { |id_map| id_map[team_domain] }
30
+ end
31
+
32
+ def render_slack_ids(team_domain, users_param = nil)
33
+ slack_ids(team_domain, users_param).compact.map do |slack_id|
34
+ "<@#{slack_id}>"
35
+ end
36
+ end
37
+
38
+ def empty?
39
+ users.empty?
40
+ end
41
+ end
@@ -0,0 +1,68 @@
1
+ # Holds user id information for various 3rd party services
2
+ # If services are supplied to compute the service usernames they
3
+ # should only be used to provide just in time computation. No information
4
+ # should be computed at instantiation.
5
+ class User
6
+ def initialize(
7
+ name,
8
+ github_id: nil,
9
+ slack_user_id_hash: {},
10
+ github_client: nil,
11
+ slack_service: nil,
12
+ error_reporting_channels: []
13
+ )
14
+ @name = name
15
+ @github_id = github_id
16
+ @slack_user_id_hash = slack_user_id_hash
17
+ @github_client = github_client
18
+ @slack_service = slack_service
19
+ @error_reporting_channels = error_reporting_channels
20
+ end
21
+
22
+ attr_accessor :name
23
+
24
+ def github_id
25
+ return @github_id if @github_client.nil?
26
+
27
+ @github_id = @github_client.find_user_id(@name)
28
+ if @github_id.nil?
29
+ message = "Unable to resolve Github id for #{@name}. Ensure their name in Github matches #{@name}. " \
30
+ 'The name field can be found by going to Settings > Profile in Github.'
31
+ send_error_message(message, true)
32
+ end
33
+ @github_id
34
+ end
35
+
36
+ def slack_user_id_hash
37
+ unless @slack_service.nil?
38
+ user_hash = @slack_service.user_hash([@name])
39
+ user_hash.each do |key, value|
40
+ user_id = value[0]
41
+ @slack_user_id_hash[key] = user_id unless user_id.nil?
42
+ end
43
+ if @slack_user_id_hash.empty?
44
+ message = "Unable to resolve slack user id for User object with name #{name}. " \
45
+ 'Ensure the name being searched for matches their name in slack.'
46
+ send_error_message(message, false)
47
+ end
48
+ end
49
+ @slack_user_id_hash
50
+ end
51
+
52
+ def send_error_message(message, tag_user)
53
+ return if @slack_service.nil? || @error_reporting_channels.empty?
54
+
55
+ slack_client = @slack_service.slack_client
56
+ if tag_user
57
+ user_id = slack_user_id_hash[slack_client.team_domain]
58
+ message = if user_id
59
+ "<@#{user_id}> #{message}"
60
+ else
61
+ "<!here> #{message}"
62
+ end
63
+ end
64
+ @slack_service.with_multiple_channels(@error_reporting_channels) do |channel_string|
65
+ slack_client.send_message(channel_name: channel_string, message: message)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,251 @@
1
+ require_relative '../api_clients/bugsnag'
2
+ require_relative '../util/slack_constants'
3
+ require 'yaml'
4
+
5
+ # Service for querying information from Bugsnag
6
+ class BugsnagService
7
+ include SlackConstants
8
+
9
+ STABILITY_ABOVE_TARGET = (99.9..100).freeze
10
+ STABILITY_BELOW_TARGET = (99.5...99.9).freeze
11
+
12
+ def initialize(
13
+ auth_token,
14
+ platform,
15
+ bugsnag_mapping_file_path,
16
+ slack_service,
17
+ slack_health_channels,
18
+ slack_release_channels,
19
+ release_manager_team
20
+ )
21
+ @client = BugsnagClient.new(platform, auth_token)
22
+ @slack_service = slack_service
23
+ @slack_health_channels = slack_health_channels
24
+ @slack_release_channels = slack_release_channels
25
+ @bugsnag_mapping_file_path_container = PathContainer.new([bugsnag_mapping_file_path])
26
+ @release_manager_team = release_manager_team
27
+ @platform = platform
28
+ end
29
+
30
+ ## Finds the top open and unhandled errors for the provided app_version and posts them to Slack.
31
+ def crash_report(
32
+ app_version,
33
+ count,
34
+ days
35
+ )
36
+ user_mappings = YAML.load_file(@bugsnag_mapping_file_path_container&.path)
37
+ formatted_errors = []
38
+ assigned_collaborators = []
39
+ @client.top_errors(
40
+ app_version,
41
+ count: count,
42
+ events_since: "#{days}d"
43
+ ).each do |error|
44
+ details = @client.error_details(error.id)
45
+ possible_team = find_team(user_mappings, details)
46
+ error_message, collaborator = make_slack_block_from_bugsnag_error(details, possible_team)
47
+ formatted_errors.append(error_message)
48
+ assigned_collaborators.append(collaborator)
49
+ end
50
+ initial_message, release_manager_slack_id = make_initial_message(app_version)
51
+ assigned_collaborators.append(release_manager_slack_id)
52
+ @slack_service.send_message_in_thread(
53
+ @slack_release_channels,
54
+ initial_message,
55
+ formatted_errors,
56
+ assigned_collaborators.compact
57
+ )
58
+ end
59
+
60
+ def platform_emoji
61
+ case @platform
62
+ when IOS
63
+ IOS_SLACK_EMOJI
64
+ when ANDROID
65
+ ANDROID_SLACK_EMOJI
66
+ else
67
+ ''
68
+ end
69
+ end
70
+
71
+ ## Makes the initial thread message, contains the platform emojis and tags the current release manager
72
+ def make_initial_message(app_version)
73
+ release_manager_slack_id = nil
74
+
75
+ if release_manager_slack_id.nil?
76
+ begin
77
+ release_manager_slack_id = @release_manager_team.slack_ids(@slack_service.slack_client.team_domain).compact[0]
78
+ rescue RuntimeError
79
+ # Ignored
80
+ end
81
+ end
82
+
83
+ formatted_manager_id = release_manager_slack_id ? "<@#{release_manager_slack_id}>" : ''
84
+ ["#{platform_emoji} :mega: ACC #{@platform} version " \
85
+ "*#{app_version}* top crashes :mega: #{platform_emoji}\n" \
86
+ "#{formatted_manager_id} please link Bugsnags to Jira tickets and create " \
87
+ 'Jira tickets if an appropriate ticket does not exist already, per ' \
88
+ '<https://wiki.autodesk.com/display/SCFICOE/Critical+Crash+Rate+Playbook|Crash Rate Playbook>. ' \
89
+ ':point_down: :bc-thread:', release_manager_slack_id]
90
+ end
91
+
92
+ ## Tries to find a team from the mappings file, that has a keyword contained in the error's stack trace
93
+ def find_team(user_mappings, details)
94
+ extras = details.grouping_fields
95
+ possible_team = nil
96
+ ## Here we'll have the file and line that generated the error
97
+ unless extras&.file.nil?
98
+ error_file = extras.file
99
+ ## search based on the defined keywords for each team
100
+ possible_team = user_mappings.select { |t| t['keywords'].any? { |k| error_file.downcase.include? k } }[0]
101
+ end
102
+ possible_team
103
+ end
104
+
105
+ ## Generates the required Slack message in blocks format to be replied on a slack thread
106
+ def make_slack_block_from_bugsnag_error(details, team)
107
+ linked_issue = details.linked_issues&.first
108
+ message_emoji = details.error_class == 'ANR' ? ':hourglass:' : ':boom:'
109
+
110
+ header_block = {
111
+ type: 'section',
112
+ text: {
113
+ type: 'mrkdwn',
114
+ text: "#{message_emoji} *#{details.error_class} on #{details.context}*\n#{details.message}"
115
+ }
116
+ }
117
+
118
+ button_block = {
119
+ type: 'actions',
120
+ elements: [
121
+ {
122
+ type: 'button',
123
+ text: {
124
+ type: 'plain_text',
125
+ emoji: true,
126
+ text: 'View in BugSnag'
127
+ },
128
+ url: "https://app.bugsnag.com/plangrid/#{@platform.downcase}/errors/#{details.id}"
129
+ },
130
+ {
131
+ type: 'button',
132
+ text: {
133
+ type: 'plain_text',
134
+ emoji: true,
135
+ text: linked_issue ? 'View in Jira' : 'UNLINKED'
136
+ },
137
+ url: linked_issue ? linked_issue.url : 'https://jira.autodesk.com/secure/Dashboard.jspa'
138
+ }
139
+ ]
140
+ }
141
+
142
+ ## If no ticket is linked, then highlight the button
143
+ button_block[:elements][1][:style] = 'danger' unless linked_issue
144
+
145
+ field_block = {
146
+ type: 'section',
147
+ fields: [
148
+ {
149
+ type: 'mrkdwn',
150
+ text: "*Occurrences:*\n#{details.events} times"
151
+ },
152
+ {
153
+ type: 'mrkdwn',
154
+ text: "*Affected users:*\n#{details.users}"
155
+ }
156
+ ]
157
+ }
158
+
159
+ if details.grouping_fields
160
+ error_type = details.grouping_fields.errorClass
161
+ error_file = details.grouping_fields.file
162
+ error_place = details.grouping_fields.lineNumber
163
+ extra_info = "*Stack details:*\n #{error_type} at `#{error_file}:#{error_place}`"
164
+ field_block[:fields].append({ type: 'mrkdwn', text: extra_info })
165
+ end
166
+
167
+ collaborator = nil
168
+ if team
169
+ collaborator = team['collaborators'].sample
170
+ ## Tagging the possible team associated with this error
171
+ reason = "Might it be related to the *#{team['team']}* team <@#{collaborator}>?"
172
+ field_block[:fields].append({ type: 'mrkdwn', text: reason })
173
+ end
174
+
175
+ [[header_block, button_block, field_block], collaborator]
176
+ end
177
+
178
+ def stability_report(current_version)
179
+ release_groups = @client.release_groups(current_version)
180
+
181
+ message = "#{platform_emoji} :mega: ACC #{@platform} " \
182
+ "<https://app.bugsnag.com/plangrid/#{@platform.downcase}/releases" \
183
+ "?release_stage=production&releases=top|Stability Report> :mega: #{platform_emoji}\n"
184
+
185
+ release_groups.each do |release_group|
186
+ session_stability_change = release_group[:session_stability_change] || 0.00
187
+ change_emoji = session_stability_change.negative? ? ':red_arrow_decrease:' : ':green_arrow_increase:'
188
+ change_sign = session_stability_change.negative? ? ' -' : '+'
189
+ rating_emoji = case release_group[:session_stability]
190
+ when STABILITY_ABOVE_TARGET then ':hds_up:'
191
+ when STABILITY_BELOW_TARGET then ':hds_degraded:'
192
+ else ':hds_down:'
193
+ end
194
+
195
+ message += "*#{release_group[:version]}* #{rating_emoji} " \
196
+ "#{format('%.2f%%', release_group[:session_stability].round(2))} | " \
197
+ "#{change_sign}#{format('%.2f%%', session_stability_change.round(2).abs)} #{change_emoji} | " \
198
+ "Adoption: #{format('%.2f%%', release_group[:adoption_rate].round(2))}\n"
199
+ end
200
+
201
+ @slack_service.send_message_in_thread(
202
+ @slack_release_channels,
203
+ message,
204
+ [],
205
+ []
206
+ )
207
+ end
208
+
209
+ def check_stability_drop(current_version, release_manager_team, platform, drop_threshold, minimum_adoption,
210
+ use_test_channel)
211
+ release_group = @client.release_groups(current_version).first
212
+ session_stability_change = release_group[:session_stability_change] || 0.00
213
+ adoption_rate = release_group[:adoption_rate]
214
+ puts "#{platform} stability data - Stability Change: #{session_stability_change} Adoption Rate: #{adoption_rate}"
215
+ return if session_stability_change > drop_threshold.to_f || adoption_rate < minimum_adoption.to_f
216
+
217
+ slack_client = @slack_service.slack_client
218
+
219
+ current_manager_user_id = release_manager_team.render_slack_ids(slack_client.team_domain).join(' ').strip
220
+ bugsnag_url = "https://app.bugsnag.com/plangrid/#{platform.downcase}/errors/?filters[error.status]=open" \
221
+ "&filters[event.since]=30d&filters[release.seen_in]=#{current_version}&sort=last_seen"
222
+
223
+ message_identifier = "detected in version #{release_group[:version]}."
224
+
225
+ message = "Elevated crash rate (Crash-free: -#{format('%.2f%%', session_stability_change.round(2).abs)} " \
226
+ ":red_arrow_decrease:) #{message_identifier} Has anyone shipped code to production lately " \
227
+ "that could result in crashes? :face_with_monocle:\n#{current_manager_user_id} " \
228
+ "please investigate in <#{bugsnag_url}|Bugsnag>."
229
+
230
+ channels_to_use = use_test_channel ? @slack_health_channels : @slack_release_channels
231
+
232
+ @slack_service.with_multiple_channels(channels_to_use) do |channel_name|
233
+ timestamp = slack_client.old_message_timestamp(
234
+ channel_name,
235
+ message_identifier,
236
+ options: { limit: 500 }
237
+ )
238
+
239
+ unless timestamp.nil?
240
+ puts "Message found for version #{release_group[:version]} in channel #{channel_name}. " \
241
+ 'Not posting again.'
242
+ next
243
+ end
244
+
245
+ @slack_service.send_message(
246
+ channel_strings: [channel_name],
247
+ message: message
248
+ )
249
+ end
250
+ end
251
+ end
@@ -0,0 +1,64 @@
1
+ require_relative '../api_clients/jira'
2
+
3
+ # Service for all things JIRA related
4
+ class JiraService
5
+ def initialize(
6
+ username,
7
+ api_key,
8
+ site: 'https://jira.autodesk.com',
9
+ is_dry_run: false,
10
+ whitelisted_project_keys: []
11
+ )
12
+ @username = username
13
+ @jira = Jira.new(@username, api_key, site: site, is_dry_run: is_dry_run)
14
+ @whitelisted_project_keys = whitelisted_project_keys
15
+ end
16
+
17
+ attr_reader :whitelisted_project_keys
18
+
19
+ attr_reader :username
20
+
21
+ # Get a JiraIssue representing the supplied issue key
22
+ # Will be nil if the issue does not exist or the credentials are wrong.
23
+ def issue(issue_key)
24
+ @jira.issue(issue_key)
25
+ end
26
+
27
+ # Get a JiraProject representing the supplied project key
28
+ # Will be nil if the project does not exist or the credentials are wrong.
29
+ def project(project_key)
30
+ @jira.project(project_key)
31
+ end
32
+
33
+ def all_project_keys
34
+ @jira.all_project_keys
35
+ end
36
+
37
+ def filtered_project_keys
38
+ project_keys = all_project_keys
39
+ project_keys.select! { |item| @whitelisted_project_keys.include?(item) } unless @whitelisted_project_keys.empty?
40
+ project_keys
41
+ end
42
+
43
+ def find_issue_references_in_text(text, whitelisted_project_keys = filtered_project_keys)
44
+ project_list_pattern = '[A-Z]+'
45
+ project_list_pattern = whitelisted_project_keys.to_a.join('|') unless whitelisted_project_keys.empty?
46
+ # Surround project key or statement with parens and make non-capturing group with ?:
47
+ regex_pattern = /(?:#{project_list_pattern})-\d+/
48
+ text.scan(regex_pattern).flatten.uniq
49
+ end
50
+
51
+ def find_issue_references_in_text_list(text_list, whitelisted_project_keys = filtered_project_keys)
52
+ text_list.flat_map do |text|
53
+ find_issue_references_in_text(text, whitelisted_project_keys)
54
+ end.uniq.compact
55
+ end
56
+
57
+ def comment_on_all_unverified_tickets_for_version(fix_version, release_date)
58
+ @jira.comment_on_all_unverified_tickets_for_version(fix_version, release_date)
59
+ end
60
+
61
+ def get_all_unverified_tickets_for_version(fix_version, platform = nil)
62
+ @jira.all_unverified_tickets(fix_version, platform)
63
+ end
64
+ end
@@ -0,0 +1,48 @@
1
+ require_relative '../util/git'
2
+
3
+ # Class for operating on git merge drivers.
4
+ class MergeDriverService
5
+ def initialize(merge_driver_map)
6
+ @merge_driver_map = merge_driver_map
7
+ end
8
+
9
+ attr_accessor :merge_driver_map
10
+
11
+ # Modify the git config to "install" the merge drivers
12
+ def install_merge_drivers
13
+ @merge_driver_map.each do |key, value|
14
+ Git.config_command("#{merge_driver_name_command(key)} \"#{value['name']}\"")
15
+ Git.config_command("#{merge_driver_driver_command(key)} \"#{value['command']}\"")
16
+ end
17
+ end
18
+
19
+ # Remove the merge drivers if they are configured
20
+ # Leaving these around can cause problems with the 'checkout scm'
21
+ # step in the jenkinsfile.
22
+ def uninstall_merge_drivers
23
+ @merge_driver_map.each_key do |key|
24
+ Git.config_command("--remove-section merge.#{key}") if Git.merge_driver_installed?(key)
25
+ end
26
+ end
27
+
28
+ def merge_driver_name_command(merge_driver_key)
29
+ "merge.#{merge_driver_key}.name"
30
+ end
31
+
32
+ def merge_driver_driver_command(merge_driver_key)
33
+ "merge.#{merge_driver_key}.driver"
34
+ end
35
+
36
+ def +(other)
37
+ other_driver_map = other.merge_driver_map
38
+ other_driver_map.each do |other_key, other_value|
39
+ current_value = @merge_driver_map[other_key]
40
+ if !current_value.nil? && other_value != current_value
41
+ raise "Attempting to add existing merge driver key '#{other_key}' with new value: #{other_value}"
42
+ end
43
+
44
+ @merge_driver_map[other_key] = other_value
45
+ end
46
+ self
47
+ end
48
+ end