github_bot 0.1.0

Sign up to get free protection for your applications and to get access to all the features.
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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'rails'
4
+
5
+ module GithubBot
6
+ class Engine < ::Rails::Engine
7
+ isolate_namespace GithubBot
8
+ end
9
+ 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