github_bot 0.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.
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