github_bot 0.1.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.github/ISSUE_TEMPLATE/bug_report.md +32 -0
- data/.github/ISSUE_TEMPLATE/config.yml +6 -0
- data/.github/ISSUE_TEMPLATE/feature_request.md +30 -0
- data/.github/pull_request_template.md +32 -0
- data/.github/workflows/cd.yml +53 -0
- data/.github/workflows/ci.yml +39 -0
- data/.gitignore +23 -0
- data/.rspec +4 -0
- data/.rubocop.yml +927 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/CHANGELOG.md +2 -0
- data/CODE_OF_CONDUCT.md +74 -0
- data/CONTRIBUTING.md +40 -0
- data/CONTRIBUTORS.md +3 -0
- data/Gemfile +22 -0
- data/LICENSE +205 -0
- data/NOTICE +13 -0
- data/README.md +66 -0
- data/Rakefile +8 -0
- data/app/controllers/github_bot/application_controller.rb +40 -0
- data/app/controllers/github_bot/concerns/response.rb +25 -0
- data/app/controllers/github_bot/webhooks/github_controller.rb +36 -0
- data/app/helpers/github_bot/github_request_helper.rb +123 -0
- data/bin/bundle-audit +29 -0
- data/bin/bundler-audit +29 -0
- data/bin/rails +26 -0
- data/bin/rake +29 -0
- data/bin/rspec +29 -0
- data/bin/rubocop +29 -0
- data/config/routes.rb +13 -0
- data/github_bot.gemspec +36 -0
- data/lib/github_bot.rb +10 -0
- data/lib/github_bot/engine.rb +9 -0
- data/lib/github_bot/github/check_run.rb +56 -0
- data/lib/github_bot/github/client.rb +203 -0
- data/lib/github_bot/github/payload.rb +225 -0
- data/lib/github_bot/rails/railtie.rb +15 -0
- data/lib/github_bot/tasks/base.rb +42 -0
- data/lib/github_bot/validator/base.rb +232 -0
- data/lib/github_bot/version.rb +11 -0
- metadata +154 -0
data/lib/github_bot.rb
ADDED
@@ -0,0 +1,10 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
Dir.glob(File.join(File.dirname(__FILE__), 'github_bot', '**/*.rb'), &method(:require))
|
4
|
+
|
5
|
+
# Public: The github bot is utilized for assistance with webhook interactions provided by
|
6
|
+
# the incoming github events
|
7
|
+
module GithubBot
|
8
|
+
mattr_accessor :draw_routes_in_host_app
|
9
|
+
self.draw_routes_in_host_app = true
|
10
|
+
end
|
@@ -0,0 +1,56 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module GithubBot
|
4
|
+
module Github
|
5
|
+
# Public: Class to keep track of the check run that has been created for execution
|
6
|
+
class CheckRun
|
7
|
+
# The name/identifier of the check run
|
8
|
+
attr_reader :name
|
9
|
+
|
10
|
+
# Public: Create a new instance of the CheckRun
|
11
|
+
#
|
12
|
+
# @params opts [Hash] A hash of options to utilized within the check run
|
13
|
+
# @option opts [:symbol] :name The name of the check run
|
14
|
+
# @option opts [:symbol] :repo The repository the checked run will be associated
|
15
|
+
# @option opts [:symbol] :sha The SHA commit for the check run to execute
|
16
|
+
# @option opts [:symbol] :client_api The GitHub API
|
17
|
+
def initialize(name:, repo:, sha:, client_api:, **opts)
|
18
|
+
@client_api = client_api
|
19
|
+
@repo = repo
|
20
|
+
@sha = sha
|
21
|
+
@name = name
|
22
|
+
@run = @client_api.create_check_run(
|
23
|
+
repo,
|
24
|
+
name,
|
25
|
+
sha,
|
26
|
+
opts.merge(
|
27
|
+
status: 'queued'
|
28
|
+
)
|
29
|
+
)
|
30
|
+
end
|
31
|
+
|
32
|
+
# Public: Updates the check run to be in progress
|
33
|
+
def in_progress!(**options)
|
34
|
+
update(status: 'in_progress', started_at: Time.now, **options)
|
35
|
+
end
|
36
|
+
|
37
|
+
# Public: Updates the check run to be complete
|
38
|
+
def complete!(**options)
|
39
|
+
update(status: 'completed', conclusion: 'success', completed_at: Time.now, **options)
|
40
|
+
end
|
41
|
+
|
42
|
+
# Public: Updates the check run to require action
|
43
|
+
def action_required!(**options)
|
44
|
+
update(status: 'completed', conclusion: 'action_required', completed_at: Time.now, **options)
|
45
|
+
end
|
46
|
+
|
47
|
+
private
|
48
|
+
|
49
|
+
def update(**options)
|
50
|
+
options[:accept] = Octokit::Preview::PREVIEW_TYPES[:checks]
|
51
|
+
|
52
|
+
@client_api.update_check_run(@repo, @run.id, options)
|
53
|
+
end
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
@@ -0,0 +1,203 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'octokit'
|
4
|
+
require 'uri'
|
5
|
+
require_relative 'payload'
|
6
|
+
|
7
|
+
module GithubBot
|
8
|
+
module Github
|
9
|
+
# Public: The Client class manages the client interactions with GitHub
|
10
|
+
# such as file retrieval, pull request comments, and pull request checks
|
11
|
+
class Client
|
12
|
+
# include the payload attributes
|
13
|
+
include GithubBot::Github::Payload
|
14
|
+
FILE_REMOVED_STATUS = 'removed'
|
15
|
+
RAW_TYPE = 'application/vnd.github.v3.raw'
|
16
|
+
|
17
|
+
class << self
|
18
|
+
# Public: Initialize the singleton with the incoming request information
|
19
|
+
#
|
20
|
+
# @param request [Object] The incoming request payload
|
21
|
+
def initialize(request)
|
22
|
+
@instance = new(request)
|
23
|
+
end
|
24
|
+
|
25
|
+
# Public: Returns the current instance of the Client
|
26
|
+
#
|
27
|
+
# @raise [StandardError] Raises error if instance has not been initialized before usage
|
28
|
+
def instance
|
29
|
+
raise StandardError, 'client not initialize' unless @instance
|
30
|
+
|
31
|
+
@instance
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
# Public: Creates a new instance of the Client to manage the GitHub api transactions
|
36
|
+
#
|
37
|
+
# @param request [Object] The incoming request payload
|
38
|
+
def initialize(request)
|
39
|
+
@request = request
|
40
|
+
end
|
41
|
+
|
42
|
+
# Public: Retrieve the contents of a file
|
43
|
+
#
|
44
|
+
# @param file [Sawyer::Resource] The file for retrieving contents
|
45
|
+
def file_content(file)
|
46
|
+
raw_contents file
|
47
|
+
rescue Octokit::NotFound
|
48
|
+
''
|
49
|
+
rescue Octokit::Forbidden => e
|
50
|
+
if e.errors.any? && e.errors.first[:code] == 'too_large'
|
51
|
+
# revert to using the raw_url for getting the content
|
52
|
+
return URI.parse(file.raw_url).open.read
|
53
|
+
end
|
54
|
+
|
55
|
+
raise e
|
56
|
+
end
|
57
|
+
|
58
|
+
# Public: Return the modified files, excluding those that have been removed, from the pull request
|
59
|
+
#
|
60
|
+
# @return [Array<Sawyer::Resource>] A list of modified files impacted with the current pull request
|
61
|
+
def modified_files
|
62
|
+
files.reject do |github_file|
|
63
|
+
github_file.status == FILE_REMOVED_STATUS
|
64
|
+
end
|
65
|
+
end
|
66
|
+
|
67
|
+
# Public: Returns an array of all the files impacted with the current pull request
|
68
|
+
#
|
69
|
+
# @return [Array<Sawyer::Resource>] A list of all files impacted with the current pull request
|
70
|
+
def files
|
71
|
+
return [] if pull_request.nil?
|
72
|
+
|
73
|
+
@files ||= client.pull_request_files(repository_full_name, pull_request_number)
|
74
|
+
end
|
75
|
+
|
76
|
+
# Public: Added a comment to the existing pull request
|
77
|
+
#
|
78
|
+
# @param opts [Hash] The parameter options for adding a comment
|
79
|
+
# @option opts [:symbol] :message The message to add to the pull request
|
80
|
+
def comment(message:, **opts)
|
81
|
+
client.add_comment(repository_full_name, pull_request_number, message, **opts)
|
82
|
+
end
|
83
|
+
|
84
|
+
# Public: Creates a GitHub check run for execution
|
85
|
+
#
|
86
|
+
# @return [GithubBot::Github::CheckRun] The created check run
|
87
|
+
def create_check_run(name:, **opts)
|
88
|
+
CheckRun.new(
|
89
|
+
name: name,
|
90
|
+
repo: repository_full_name,
|
91
|
+
sha: head_sha,
|
92
|
+
client_api: client,
|
93
|
+
**opts
|
94
|
+
)
|
95
|
+
end
|
96
|
+
|
97
|
+
# Public: Returns a GitHub pull request object with the details of the current request
|
98
|
+
#
|
99
|
+
# @return [Sawyer::Resource] The pull request details associated to current request
|
100
|
+
def pull_request_details
|
101
|
+
@pull_request_details ||= client.pull_request(repository[:full_name], pull_request[:number])
|
102
|
+
end
|
103
|
+
|
104
|
+
# Public: Returns the current list of pull request reviewers
|
105
|
+
#
|
106
|
+
# @return [Array<Sawyer::Resource>] The list of current pull request reviewers
|
107
|
+
def pull_request_reviewers
|
108
|
+
@pull_request_reviewers ||= client.pull_request_reviews(
|
109
|
+
repository[:full_name],
|
110
|
+
pull_request[:number]
|
111
|
+
).sort_by(&:submitted_at)
|
112
|
+
end
|
113
|
+
|
114
|
+
# Public: Returns the current list of approving pull request reviewers
|
115
|
+
#
|
116
|
+
# @return [Array<Sawyer::Resource>] The list of approving pull request reviewers
|
117
|
+
def approving_reviewers
|
118
|
+
pull_request_reviewers.select { |r| r.state == 'APPROVED' }
|
119
|
+
end
|
120
|
+
|
121
|
+
# Public: Returns the current list of request comments
|
122
|
+
#
|
123
|
+
# @return [Array<Sawyer::Resource>] The list of pull request comments
|
124
|
+
def pull_request_comments
|
125
|
+
@pull_request_comments ||= client.issue_comments(
|
126
|
+
repository[:full_name],
|
127
|
+
pull_request[:number]
|
128
|
+
).sort_by(&:created_at)
|
129
|
+
end
|
130
|
+
|
131
|
+
private
|
132
|
+
|
133
|
+
# Decode the content retrieved from GitHub
|
134
|
+
#
|
135
|
+
# @param content [Sawyer::Resource] The content returned from GitHub to decode
|
136
|
+
# @return [String] Returns the Base64-decoded version of the content
|
137
|
+
def decode(content)
|
138
|
+
content&.content ? Base64.decode64(content.content).force_encoding('UTF-8') : ''
|
139
|
+
end
|
140
|
+
|
141
|
+
# Decode the contents of the file
|
142
|
+
def decode_contents(file)
|
143
|
+
decode(client.contents(repository_full_name, path: file.filename, ref: head_sha))
|
144
|
+
end
|
145
|
+
|
146
|
+
# Get raw contents from passed file
|
147
|
+
def raw_contents(file)
|
148
|
+
client.contents(repository_full_name, path: file.filename, ref: head_sha, headers: { 'Accept': RAW_TYPE })
|
149
|
+
end
|
150
|
+
|
151
|
+
# Returns an instance of the GitHub client API to utilize
|
152
|
+
def client
|
153
|
+
@client ||= Octokit::Client.new(access_token: installation_access_token, auto_paginate: true)
|
154
|
+
end
|
155
|
+
|
156
|
+
# Gets an access token associated to the request
|
157
|
+
def installation_access_token
|
158
|
+
Octokit::Client.new(bearer_token: private_key).create_installation_access_token(installation_id)[:token]
|
159
|
+
end
|
160
|
+
|
161
|
+
# Gets the private key associated to the GitHub application
|
162
|
+
def private_key
|
163
|
+
private_pem =
|
164
|
+
if ENV['GITHUB_APP_PEM_PATH']
|
165
|
+
File.read(Rails.root.join(ENV['GITHUB_APP_PEM_PATH']))
|
166
|
+
elsif ENV['GITHUB_APP_PEM_V2']
|
167
|
+
ENV['GITHUB_APP_PEM_V2'].gsub('\\\n', "\n")
|
168
|
+
elsif ENV['GITHUB_APP_PEM']
|
169
|
+
ENV['GITHUB_APP_PEM'].gsub('\n', "\n")
|
170
|
+
else
|
171
|
+
raise StandardError, "'GITHUB_APP_PEM_PATH' or 'GITHUB_APP_PEM' needs to be set"
|
172
|
+
end
|
173
|
+
|
174
|
+
private_key = OpenSSL::PKey::RSA.new(private_pem)
|
175
|
+
|
176
|
+
current = Time.current.to_i
|
177
|
+
|
178
|
+
payload = {
|
179
|
+
iat: current,
|
180
|
+
exp: current + (10 * 60),
|
181
|
+
iss: ENV['GITHUB_APP_IDENTIFIER']
|
182
|
+
}
|
183
|
+
JWT.encode(payload, private_key, 'RS256')
|
184
|
+
end
|
185
|
+
|
186
|
+
# relay messages to Octokit::Client if responds to allow extension
|
187
|
+
# of the client and extend/overwrite those concerned with
|
188
|
+
def method_missing(method, *args, &block)
|
189
|
+
return super unless respond_to_missing?(method)
|
190
|
+
|
191
|
+
return payload[method] if payload.key?(method)
|
192
|
+
|
193
|
+
client.send(method, *args, &block)
|
194
|
+
end
|
195
|
+
|
196
|
+
# because tasks are executed by a validator, some methods are relayed across
|
197
|
+
# from the task back to the validator. this override checks for that existence
|
198
|
+
def respond_to_missing?(method, *args)
|
199
|
+
payload.key?(method) || client.respond_to?(method, *args)
|
200
|
+
end
|
201
|
+
end
|
202
|
+
end
|
203
|
+
end
|
@@ -0,0 +1,225 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require 'open-uri'
|
4
|
+
require 'json'
|
5
|
+
require 'yaml'
|
6
|
+
|
7
|
+
module GithubBot
|
8
|
+
module Github
|
9
|
+
# The GitHub webhook payload information
|
10
|
+
# @see https://developer.github.com/webhooks/event-payloads/#webhook-payload-object-common-properties
|
11
|
+
module Payload
|
12
|
+
# Public: Returns the installation identifier associated to the event
|
13
|
+
#
|
14
|
+
# @return [Integer] identifier of the GitHub App installation
|
15
|
+
def installation_id
|
16
|
+
payload[:installation][:id]
|
17
|
+
end
|
18
|
+
|
19
|
+
# Public: Return <true> if the payload event type is of type 'pull_request'; otherwise, <false>
|
20
|
+
def pull_request?
|
21
|
+
payload_type == 'pull_request'
|
22
|
+
end
|
23
|
+
|
24
|
+
# Public: Return <true> if the payload event type is of type 'check_run'; otherwise, <false>
|
25
|
+
def check_run?
|
26
|
+
payload_type == 'check_run'
|
27
|
+
end
|
28
|
+
|
29
|
+
# Public: Return <true> if the payload event type is of type 'review_requested'; otherwise, <false>
|
30
|
+
def review_requested?
|
31
|
+
payload_type == 'review_requested'
|
32
|
+
end
|
33
|
+
|
34
|
+
# Public: Return <true> if the payload event type is of type 'review_request_removed'; otherwise, <false>
|
35
|
+
def review_request_removed?
|
36
|
+
payload_type == 'review_request_removed'
|
37
|
+
end
|
38
|
+
|
39
|
+
# Public: Return <true> if the action type is of type 'issue_comment'; otherwise, <false>
|
40
|
+
# This is used for all other comment triggered issue_comment events
|
41
|
+
def issue_comment?
|
42
|
+
payload_type == 'issue_comment'
|
43
|
+
end
|
44
|
+
|
45
|
+
# Public: Return <true> if the payload event type is of type 'labeled'; otherwise, <false>
|
46
|
+
def labeled?
|
47
|
+
payload_type == 'labeled'
|
48
|
+
end
|
49
|
+
|
50
|
+
# Public: Return <true> if the payload event type is of type 'unlabeled'; otherwise, <false>
|
51
|
+
def unlabeled?
|
52
|
+
payload_type == 'unlabeled'
|
53
|
+
end
|
54
|
+
|
55
|
+
# Public: Return the activity related to the pull request
|
56
|
+
#
|
57
|
+
# @return [Hash] The activity related to the pull request
|
58
|
+
def pull_request
|
59
|
+
if pull_request?
|
60
|
+
payload[:pull_request]
|
61
|
+
elsif check_run?
|
62
|
+
payload[:check_run][:pull_requests].first
|
63
|
+
elsif issue_comment?
|
64
|
+
payload[:issue]
|
65
|
+
else
|
66
|
+
payload.key?(:pull_request) ? payload[:pull_request] : {}
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
# Public: Returns <true> if the sender is of type 'Bot'; otherwise, <false>
|
71
|
+
def sender_type_bot?
|
72
|
+
payload[:sender][:type].downcase == 'bot' # rubocop:disable Performance/Casecmp
|
73
|
+
end
|
74
|
+
|
75
|
+
# Public: Returns the pull request number from the payload
|
76
|
+
#
|
77
|
+
# @return [Integer] The pull request number from the payload
|
78
|
+
def pull_request_number
|
79
|
+
pull_request[:number]
|
80
|
+
end
|
81
|
+
|
82
|
+
# Public: Returns the SHA of the most recent commit for this pull request
|
83
|
+
#
|
84
|
+
# @return [String] The SHA of the most recent commit for this pull request
|
85
|
+
def head_sha
|
86
|
+
if issue_comment?
|
87
|
+
'HEAD'
|
88
|
+
else
|
89
|
+
pull_request[:head][:sha]
|
90
|
+
end
|
91
|
+
end
|
92
|
+
|
93
|
+
# Public: Returns the head branch that the changes are on
|
94
|
+
#
|
95
|
+
# @return [String] The head branch that the changes are on
|
96
|
+
def head_branch
|
97
|
+
pull_request[:head][:ref]
|
98
|
+
end
|
99
|
+
|
100
|
+
# Public: Returns the base branch that the head was based on
|
101
|
+
#
|
102
|
+
# @return [String] The base branch that the head was based on
|
103
|
+
def base_branch
|
104
|
+
pull_request[:base][:ref]
|
105
|
+
end
|
106
|
+
|
107
|
+
# Public: Returns the pull request body content
|
108
|
+
#
|
109
|
+
# @return [String] The pull request body content
|
110
|
+
def pull_request_body
|
111
|
+
pull_request[:body]
|
112
|
+
end
|
113
|
+
|
114
|
+
# Public: Returns the name of the repository where the event was triggered
|
115
|
+
#
|
116
|
+
# @return [String] The name of the repository where the event was triggered
|
117
|
+
def repository_name
|
118
|
+
repository[:name]
|
119
|
+
end
|
120
|
+
|
121
|
+
# Public: Returns the organization and repository name
|
122
|
+
#
|
123
|
+
# @return [String] The organization and repository name
|
124
|
+
def repository_full_name
|
125
|
+
repository[:full_name]
|
126
|
+
end
|
127
|
+
|
128
|
+
# Public: Returns the repository URL utilized for performing a 'git clone'
|
129
|
+
#
|
130
|
+
# @return [String] The repository URL utilized for performing a 'git clone'
|
131
|
+
def repository_clone_url
|
132
|
+
repository[:clone_url]
|
133
|
+
end
|
134
|
+
|
135
|
+
# Public: Returns the repository fork URL from the original project with the most
|
136
|
+
# recent updated forked instance first
|
137
|
+
#
|
138
|
+
# @return [Array] The array of [String] URLs associated to the forked repositories
|
139
|
+
def repository_fork_urls
|
140
|
+
return @repository_fork_urls if @repository_fork_urls
|
141
|
+
|
142
|
+
@repository_fork_urls =
|
143
|
+
[].tap do |ar|
|
144
|
+
json = URI.parse(repository[:forks_url]).open.read
|
145
|
+
JSON.parse(json).sort_by { |i| Date.parse i['updated_at'] }.reverse_each do |fork|
|
146
|
+
ar << fork['clone_url']
|
147
|
+
end
|
148
|
+
end
|
149
|
+
end
|
150
|
+
|
151
|
+
# Public: Returns the repository default branch
|
152
|
+
#
|
153
|
+
# @return [String] The repository default branch
|
154
|
+
def repository_default_branch
|
155
|
+
repository[:default_branch]
|
156
|
+
end
|
157
|
+
|
158
|
+
# Public: Returns a list of class object names to create for validation
|
159
|
+
#
|
160
|
+
# @return [Array<String>] A list of strings associated to the class object validators
|
161
|
+
def repository_pull_request_bots
|
162
|
+
return @repository_pull_request_bots if @repository_pull_request_bots
|
163
|
+
|
164
|
+
file = raw_file_url('.github-bots')
|
165
|
+
@repository_pull_request_bots = [].tap do |ar|
|
166
|
+
if file
|
167
|
+
resp = YAML.safe_load(URI.parse(file).open.read)
|
168
|
+
ar << resp['pull_request'] if resp['pull_request']
|
169
|
+
end
|
170
|
+
end.flatten
|
171
|
+
rescue SyntaxError => e
|
172
|
+
Rails.logger.error message: "Error parsing file '#{file}'", exception: e
|
173
|
+
|
174
|
+
# Allow continuation of process just won't utilize any bot validations until
|
175
|
+
# parsing error of file is corrected
|
176
|
+
{}
|
177
|
+
end
|
178
|
+
|
179
|
+
private
|
180
|
+
|
181
|
+
def method_missing(method_name, *args)
|
182
|
+
payload[method_name] || super
|
183
|
+
end
|
184
|
+
|
185
|
+
def respond_to_missing?(method, *args)
|
186
|
+
payload.key?(method) || super
|
187
|
+
end
|
188
|
+
|
189
|
+
def raw_file_url(path)
|
190
|
+
# https://github.com/api/v3/repos/poloka/foo-config/contents/.github-bot?ref=
|
191
|
+
content_file = File.join(repository_contents_url, "#{path}?ref=#{head_sha}")
|
192
|
+
JSON.parse(URI.parse(content_file).open(&:read))['download_url']
|
193
|
+
rescue ::OpenURI::HTTPError => e
|
194
|
+
raise StandardError, "file '#{content_file}' not found" unless /404 Not Found/i.match?(e.message)
|
195
|
+
end
|
196
|
+
|
197
|
+
def repository_contents_url
|
198
|
+
# drop the last 'path' portion of the API endpoint
|
199
|
+
# https://github.com/api/v3/repos/poloka/foo-config/contents/{+path}
|
200
|
+
repository[:contents_url].split('/')[0...-1].join('/')
|
201
|
+
end
|
202
|
+
|
203
|
+
def payload
|
204
|
+
@payload ||= verify_webhook_signature(payload_body, @request.env['HTTP_X_HUB_SIGNATURE'])
|
205
|
+
end
|
206
|
+
|
207
|
+
def payload_body
|
208
|
+
@payload_body ||= @request.body.read
|
209
|
+
end
|
210
|
+
|
211
|
+
def payload_type
|
212
|
+
@request.env['HTTP_X_GITHUB_EVENT'] if @request
|
213
|
+
end
|
214
|
+
|
215
|
+
def verify_webhook_signature(payload, payload_signature)
|
216
|
+
signature = 'sha1=' + OpenSSL::HMAC.hexdigest(
|
217
|
+
OpenSSL::Digest.new('sha1'),
|
218
|
+
ENV['GITHUB_WEBHOOK_SECRET'],
|
219
|
+
payload
|
220
|
+
)
|
221
|
+
Rack::Utils.secure_compare(signature, payload_signature) ? JSON.parse(payload).with_indifferent_access : false
|
222
|
+
end
|
223
|
+
end
|
224
|
+
end
|
225
|
+
end
|