├── config.ru ├── Gemfile ├── LICENSE ├── .gitignore ├── Gemfile.lock ├── app_cli.rb └── server.rb /config.ru: -------------------------------------------------------------------------------- 1 | require './server' 2 | run GHAapp 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'http://rubygems.org' 2 | 3 | gem 'sinatra', '~> 2.0' 4 | gem 'jwt', '~> 2.1' 5 | gem 'octokit', '~> 4.0' 6 | gem 'puma' 7 | gem 'rubocop' 8 | gem 'dotenv' 9 | gem 'git' -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 aidenwong812 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.gem 2 | *.rbc 3 | /.config 4 | /coverage/ 5 | /InstalledFiles 6 | /pkg/ 7 | /spec/reports/ 8 | /spec/examples.txt 9 | /test/tmp/ 10 | /test/version_tmp/ 11 | /tmp/ 12 | 13 | # Used by dotenv library to load environment variables. 14 | # .env 15 | 16 | # Ignore Byebug command history file. 17 | .byebug_history 18 | 19 | ## Specific to RubyMotion: 20 | .dat* 21 | .repl_history 22 | build/ 23 | *.bridgesupport 24 | build-iPhoneOS/ 25 | build-iPhoneSimulator/ 26 | 27 | ## Specific to RubyMotion (use of CocoaPods): 28 | # 29 | # We recommend against adding the Pods directory to your .gitignore. However 30 | # you should judge for yourself, the pros and cons are mentioned at: 31 | # https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control 32 | # 33 | # vendor/Pods/ 34 | 35 | ## Documentation cache and generated files: 36 | /.yardoc/ 37 | /_yardoc/ 38 | /doc/ 39 | /rdoc/ 40 | 41 | ## Environment normalization: 42 | /.bundle/ 43 | /vendor/bundle 44 | /lib/bundler/man/ 45 | 46 | # for a library or gem, you might want to ignore these files since the code is 47 | # intended to run in multiple environments; otherwise, check them in: 48 | # Gemfile.lock 49 | # .ruby-version 50 | # .ruby-gemset 51 | 52 | # unless supporting rvm < 1.11.0 or doing something fancy, ignore this: 53 | .rvmrc 54 | 55 | # Used by RuboCop. Remote config files pulled in from inherit_from directive. 56 | # .rubocop-https?--* 57 | 58 | .env 59 | /Slick_Test -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: http://rubygems.org/ 3 | specs: 4 | activesupport (7.1.3.4) 5 | base64 6 | bigdecimal 7 | concurrent-ruby (~> 1.0, >= 1.0.2) 8 | connection_pool (>= 2.2.5) 9 | drb 10 | i18n (>= 1.6, < 2) 11 | minitest (>= 5.1) 12 | mutex_m 13 | tzinfo (~> 2.0) 14 | addressable (2.8.7) 15 | public_suffix (>= 2.0.2, < 7.0) 16 | ast (2.4.2) 17 | base64 (0.2.0) 18 | bigdecimal (3.1.8) 19 | concurrent-ruby (1.3.3) 20 | connection_pool (2.4.1) 21 | dotenv (3.1.2) 22 | drb (2.2.1) 23 | faraday (2.9.2) 24 | faraday-net_http (>= 2.0, < 3.2) 25 | faraday-net_http (3.1.0) 26 | net-http 27 | git (2.1.1) 28 | activesupport (>= 5.0) 29 | addressable (~> 2.8) 30 | process_executer (~> 1.1) 31 | rchardet (~> 1.8) 32 | i18n (1.14.5) 33 | concurrent-ruby (~> 1.0) 34 | json (2.7.2) 35 | jwt (2.8.2) 36 | base64 37 | language_server-protocol (3.17.0.3) 38 | minitest (5.24.0) 39 | mustermann (2.0.2) 40 | ruby2_keywords (~> 0.0.1) 41 | mutex_m (0.2.0) 42 | net-http (0.4.1) 43 | uri 44 | nio4r (2.7.3) 45 | octokit (4.25.1) 46 | faraday (>= 1, < 3) 47 | sawyer (~> 0.9) 48 | parallel (1.25.1) 49 | parser (3.3.3.0) 50 | ast (~> 2.4.1) 51 | racc 52 | process_executer (1.1.0) 53 | public_suffix (6.0.0) 54 | puma (6.4.2) 55 | nio4r (~> 2.0) 56 | racc (1.8.0) 57 | rack (2.2.9) 58 | rack-protection (2.2.4) 59 | rack 60 | rainbow (3.1.1) 61 | rchardet (1.8.0) 62 | regexp_parser (2.9.2) 63 | rexml (3.3.0) 64 | strscan 65 | rubocop (1.64.1) 66 | json (~> 2.3) 67 | language_server-protocol (>= 3.17.0) 68 | parallel (~> 1.10) 69 | parser (>= 3.3.0.2) 70 | rainbow (>= 2.2.2, < 4.0) 71 | regexp_parser (>= 1.8, < 3.0) 72 | rexml (>= 3.2.5, < 4.0) 73 | rubocop-ast (>= 1.31.1, < 2.0) 74 | ruby-progressbar (~> 1.7) 75 | unicode-display_width (>= 2.4.0, < 3.0) 76 | rubocop-ast (1.31.3) 77 | parser (>= 3.3.1.0) 78 | ruby-progressbar (1.13.0) 79 | ruby2_keywords (0.0.5) 80 | sawyer (0.9.2) 81 | addressable (>= 2.3.5) 82 | faraday (>= 0.17.3, < 3) 83 | sinatra (2.2.4) 84 | mustermann (~> 2.0) 85 | rack (~> 2.2) 86 | rack-protection (= 2.2.4) 87 | tilt (~> 2.0) 88 | strscan (3.1.0) 89 | tilt (2.3.0) 90 | tzinfo (2.0.6) 91 | concurrent-ruby (~> 1.0) 92 | unicode-display_width (2.5.0) 93 | uri (0.13.0) 94 | 95 | PLATFORMS 96 | x64-mingw-ucrt 97 | 98 | DEPENDENCIES 99 | dotenv 100 | git 101 | jwt (~> 2.1) 102 | octokit (~> 4.0) 103 | puma 104 | rubocop 105 | sinatra (~> 2.0) 106 | 107 | BUNDLED WITH 108 | 2.5.14 109 | -------------------------------------------------------------------------------- /app_cli.rb: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "net/http" 4 | require "json" 5 | require "uri" 6 | require "fileutils" 7 | 8 | CLIENT_ID="YOUR_CLIENT_ID" 9 | 10 | def help 11 | puts "usage: app_cli " 12 | end 13 | 14 | def main 15 | case ARGV[0] 16 | when "help" 17 | help 18 | when "login" 19 | login 20 | when "whoami" 21 | whoami 22 | else 23 | puts "Unknown command #{ARGV[0]}" 24 | end 25 | end 26 | 27 | def parse_response(response) 28 | case response 29 | when Net::HTTPOK, Net::HTTPCreated 30 | JSON.parse(response.body) 31 | when Net::HTTPUnauthorized 32 | puts "You are not authorized. Run the `login` command." 33 | exit 1 34 | else 35 | puts response 36 | puts response.body 37 | exit 1 38 | end 39 | end 40 | 41 | def request_device_code 42 | uri = URI("https://github.com/login/device/code") 43 | parameters = URI.encode_www_form("client_id" => CLIENT_ID) 44 | headers = {"Accept" => "application/json"} 45 | 46 | response = Net::HTTP.post(uri, parameters, headers) 47 | parse_response(response) 48 | end 49 | 50 | def request_token(device_code) 51 | uri = URI("https://github.com/login/oauth/access_token") 52 | parameters = URI.encode_www_form({ 53 | "client_id" => CLIENT_ID, 54 | "device_code" => device_code, 55 | "grant_type" => "urn:ietf:params:oauth:grant-type:device_code" 56 | }) 57 | headers = {"Accept" => "application/json"} 58 | response = Net::HTTP.post(uri, parameters, headers) 59 | parse_response(response) 60 | end 61 | 62 | def poll_for_token(device_code, interval) 63 | 64 | loop do 65 | response = request_token(device_code) 66 | error, access_token = response.values_at("error", "access_token") 67 | 68 | if error 69 | case error 70 | when "authorization_pending" 71 | # The user has not yet entered the code. 72 | # Wait, then poll again. 73 | sleep interval 74 | next 75 | when "slow_down" 76 | # The app polled too fast. 77 | # Wait for the interval plus 5 seconds, then poll again. 78 | sleep interval + 5 79 | next 80 | when "expired_token" 81 | # The `device_code` expired, and the process needs to restart. 82 | puts "The device code has expired. Please run `login` again." 83 | exit 1 84 | when "access_denied" 85 | # The user cancelled the process. Stop polling. 86 | puts "Login cancelled by user." 87 | exit 1 88 | else 89 | puts response 90 | exit 1 91 | end 92 | end 93 | 94 | File.write("./.token", access_token) 95 | 96 | # Set the file permissions so that only the file owner can read or modify the file 97 | FileUtils.chmod(0600, "./.token") 98 | 99 | break 100 | end 101 | end 102 | 103 | def login 104 | verification_uri, user_code, device_code, interval = request_device_code.values_at("verification_uri", "user_code", "device_code", "interval") 105 | 106 | puts "Please visit: #{verification_uri}" 107 | puts "and enter code: #{user_code}" 108 | 109 | poll_for_token(device_code, interval) 110 | 111 | puts "Successfully authenticated!" 112 | end 113 | 114 | def whoami 115 | uri = URI("https://api.github.com/user") 116 | 117 | begin 118 | token = File.read("./.token").strip 119 | rescue Errno::ENOENT => e 120 | puts "You are not authorized. Run the `login` command." 121 | exit 1 122 | end 123 | 124 | response = Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http| 125 | body = {"access_token" => token}.to_json 126 | headers = {"Accept" => "application/vnd.github+json", "Authorization" => "Bearer #{token}"} 127 | 128 | http.send_request("GET", uri.path, body, headers) 129 | end 130 | 131 | parsed_response = parse_response(response) 132 | puts "You are #{parsed_response["login"]}" 133 | end 134 | 135 | main 136 | -------------------------------------------------------------------------------- /server.rb: -------------------------------------------------------------------------------- 1 | require 'sinatra/base' # Use the Sinatra web framework 2 | require 'octokit' # Use the Octokit Ruby library to interact with GitHub's REST API 3 | require 'dotenv/load' # Manages environment variables 4 | require 'json' # Allows your app to manipulate JSON data 5 | require 'openssl' # Verifies the webhook signature 6 | require 'jwt' # Authenticates a GitHub App 7 | require 'time' # Gets ISO 8601 representation of a Time object 8 | require 'logger' # Logs debug statements 9 | 10 | # This code is a Sinatra app, for two reasons: 11 | # 1. Because the app will require a landing page for installation. 12 | # 2. To easily handle webhook events. 13 | 14 | class GHAapp < Sinatra::Application 15 | 16 | # Sets the port that's used when starting the web server. 17 | set :port, 5000 18 | set :bind, '0.0.0.0' 19 | 20 | # Expects the private key in PEM format. Converts the newlines. 21 | PRIVATE_KEY = OpenSSL::PKey::RSA.new(ENV['GITHUB_PRIVATE_KEY'].gsub('\n', "\n")) 22 | 23 | # Your registered app must have a webhook secret. 24 | # The secret is used to verify that webhooks are sent by GitHub. 25 | WEBHOOK_SECRET = ENV['GITHUB_WEBHOOK_SECRET'] 26 | 27 | # The GitHub App's identifier (type integer). 28 | APP_IDENTIFIER = ENV['GITHUB_APP_IDENTIFIER'] 29 | 30 | # Turn on Sinatra's verbose logging during development 31 | configure :development do 32 | set :logging, Logger::DEBUG 33 | end 34 | 35 | # Executed before each request to the `/event_handler` route 36 | before '/event_handler' do 37 | get_payload_request(request) 38 | verify_webhook_signature 39 | 40 | # If a repository name is provided in the webhook, validate that 41 | # it consists only of latin alphabetic characters, `-`, and `_`. 42 | unless @payload['repository'].nil? 43 | halt 400 if (@payload['repository']['name'] =~ /[0-9A-Za-z\-\_]+/).nil? 44 | end 45 | 46 | authenticate_app 47 | # Authenticate the app installation in order to run API operations 48 | authenticate_installation(@payload) 49 | end 50 | 51 | post '/event_handler' do 52 | 53 | # Get the event type from the HTTP_X_GITHUB_EVENT header 54 | case request.env['HTTP_X_GITHUB_EVENT'] 55 | 56 | when 'check_suite' 57 | # A new check_suite has been created. Create a new check run with status queued 58 | if @payload['action'] == 'requested' || @payload['action'] == 'rerequested' 59 | create_check_run 60 | end 61 | 62 | when 'check_run' 63 | # Check that the event is being sent to this app 64 | if @payload['check_run']['app']['id'].to_s === APP_IDENTIFIER 65 | case @payload['action'] 66 | when 'created' 67 | initiate_check_run 68 | when 'rerequested' 69 | create_check_run 70 | when 'requested_action' 71 | take_requested_action 72 | end 73 | end 74 | end 75 | 76 | 200 # success status 77 | end 78 | 79 | helpers do 80 | 81 | # Create a new check run with status "queued" 82 | def create_check_run 83 | @installation_client.create_check_run( 84 | # [String, Integer, Hash, Octokit Repository object] A GitHub repository. 85 | @payload['repository']['full_name'], 86 | # [String] The name of your check run. 87 | 'Octo RuboCop', 88 | # [String] The SHA of the commit to check 89 | # The payload structure differs depending on whether a check run or a check suite event occurred. 90 | @payload['check_run'].nil? ? @payload['check_suite']['head_sha'] : @payload['check_run']['head_sha'], 91 | # [Hash] 'Accept' header option, to avoid a warning about the API not being ready for production use. 92 | accept: 'application/vnd.github+json' 93 | ) 94 | end 95 | 96 | # Start the CI process 97 | def initiate_check_run 98 | # Once the check run is created, you'll update the status of the check run 99 | # to 'in_progress' and run the CI process. When the CI finishes, you'll 100 | # update the check run status to 'completed' and add the CI results. 101 | 102 | @installation_client.update_check_run( 103 | @payload['repository']['full_name'], 104 | @payload['check_run']['id'], 105 | status: 'in_progress', 106 | accept: 'application/vnd.github+json' 107 | ) 108 | 109 | full_repo_name = @payload['repository']['full_name'] 110 | repository = @payload['repository']['name'] 111 | head_sha = @payload['check_run']['head_sha'] 112 | 113 | clone_repository(full_repo_name, repository, head_sha) 114 | 115 | # Run RuboCop on all files in the repository 116 | @report = `rubocop '#{repository}' --format json` 117 | logger.debug @report 118 | `rm -rf #{repository}` 119 | @output = JSON.parse @report 120 | 121 | annotations = [] 122 | # You can create a maximum of 50 annotations per request to the Checks 123 | # API. To add more than 50 annotations, use the "Update a check run" API 124 | # endpoint. This example code limits the number of annotations to 50. 125 | # See /rest/reference/checks#update-a-check-run 126 | # for details. 127 | max_annotations = 50 128 | 129 | # RuboCop reports the number of errors found in "offense_count" 130 | if @output['summary']['offense_count'] == 0 131 | conclusion = 'success' 132 | else 133 | conclusion = 'neutral' 134 | @output['files'].each do |file| 135 | 136 | # Only parse offenses for files in this app's repository 137 | file_path = file['path'].gsub(/#{repository}\//,'') 138 | annotation_level = 'notice' 139 | 140 | # Parse each offense to get details and location 141 | file['offenses'].each do |offense| 142 | # Limit the number of annotations to 50 143 | next if max_annotations == 0 144 | max_annotations -= 1 145 | 146 | start_line = offense['location']['start_line'] 147 | end_line = offense['location']['last_line'] 148 | start_column = offense['location']['start_column'] 149 | end_column = offense['location']['last_column'] 150 | message = offense['message'] 151 | 152 | # Create a new annotation for each error 153 | annotation = { 154 | path: file_path, 155 | start_line: start_line, 156 | end_line: end_line, 157 | start_column: start_column, 158 | end_column: end_column, 159 | annotation_level: annotation_level, 160 | message: message 161 | } 162 | # Annotations only support start and end columns on the same line 163 | if start_line == end_line 164 | annotation.merge({start_column: start_column, end_column: end_column}) 165 | end 166 | 167 | annotations.push(annotation) 168 | end 169 | end 170 | end 171 | 172 | # Updated check run summary and text parameters 173 | summary = "Octo RuboCop summary\n-Offense count: #{@output['summary']['offense_count']}\n-File count: #{@output['summary']['target_file_count']}\n-Target file count: #{@output['summary']['inspected_file_count']}" 174 | text = "Octo RuboCop version: #{@output['metadata']['rubocop_version']}" 175 | 176 | # Mark the check run as complete! And if there are warnings, share them. 177 | @installation_client.update_check_run( 178 | @payload['repository']['full_name'], 179 | @payload['check_run']['id'], 180 | status: 'completed', 181 | conclusion: conclusion, 182 | output: { 183 | title: 'Octo RuboCop', 184 | summary: summary, 185 | text: text, 186 | annotations: annotations 187 | }, 188 | actions: [{ 189 | label: 'Fix this', 190 | description: 'Automatically fix all linter notices.', 191 | identifier: 'fix_rubocop_notices' 192 | }], 193 | accept: 'application/vnd.github+json' 194 | ) 195 | end 196 | 197 | # Clones the repository to the current working directory, updates the 198 | # contents using Git pull, and checks out the ref. 199 | # 200 | # full_repo_name - The owner and repo. Ex: octocat/hello-world 201 | # repository - The repository name 202 | # ref - The branch, commit SHA, or tag to check out 203 | def clone_repository(full_repo_name, repository, ref) 204 | @git = Git.clone("https://x-access-token:#{@installation_token.to_s}@github.com/#{full_repo_name}.git", repository) 205 | pwd = Dir.getwd() 206 | Dir.chdir(repository) 207 | @git.pull 208 | @git.checkout(ref) 209 | Dir.chdir(pwd) 210 | end 211 | 212 | # Handles the check run `requested_action` event 213 | # See /webhooks/event-payloads/#check_run 214 | def take_requested_action 215 | full_repo_name = @payload['repository']['full_name'] 216 | repository = @payload['repository']['name'] 217 | head_branch = @payload['check_run']['check_suite']['head_branch'] 218 | 219 | if (@payload['requested_action']['identifier'] == 'fix_rubocop_notices') 220 | clone_repository(full_repo_name, repository, head_branch) 221 | 222 | # Sets your commit username and email address 223 | @git.config('user.name', ENV['GITHUB_APP_USER_NAME']) 224 | @git.config('user.email', ENV['GITHUB_APP_USER_EMAIL']) 225 | 226 | # Automatically correct RuboCop style errors 227 | @report = `rubocop '#{repository}/*' --format json --auto-correct` 228 | 229 | pwd = Dir.getwd() 230 | Dir.chdir(repository) 231 | begin 232 | @git.commit_all('Automatically fix Octo RuboCop notices.') 233 | @git.push("https://x-access-token:#{@installation_token.to_s}@github.com/#{full_repo_name}.git", head_branch) 234 | rescue 235 | # Nothing to commit! 236 | puts 'Nothing to commit' 237 | end 238 | Dir.chdir(pwd) 239 | `rm -rf '#{repository}'` 240 | end 241 | end 242 | 243 | # Saves the raw payload and converts the payload to JSON format 244 | def get_payload_request(request) 245 | # request.body is an IO or StringIO object 246 | # Rewind in case someone already read it 247 | request.body.rewind 248 | # The raw text of the body is required for webhook signature verification 249 | @payload_raw = request.body.read 250 | begin 251 | @payload = JSON.parse @payload_raw 252 | rescue => e 253 | fail 'Invalid JSON (#{e}): #{@payload_raw}' 254 | end 255 | end 256 | 257 | # Instantiate an Octokit client authenticated as a GitHub App. 258 | # GitHub App authentication requires that you construct a 259 | # JWT (https://jwt.io/introduction/) signed with the app's private key, 260 | # so GitHub can be sure that it came from the app and not altered by 261 | # a malicious third party. 262 | def authenticate_app 263 | payload = { 264 | # The time that this JWT was issued, _i.e._ now. 265 | iat: Time.now.to_i, 266 | 267 | # JWT expiration time (10 minute maximum) 268 | exp: Time.now.to_i + (10 * 60), 269 | 270 | # Your GitHub App's identifier number 271 | iss: APP_IDENTIFIER 272 | } 273 | 274 | # Cryptographically sign the JWT. 275 | jwt = JWT.encode(payload, PRIVATE_KEY, 'RS256') 276 | 277 | # Create the Octokit client, using the JWT as the auth token. 278 | @app_client ||= Octokit::Client.new(bearer_token: jwt) 279 | end 280 | 281 | # Instantiate an Octokit client, authenticated as an installation of a 282 | # GitHub App, to run API operations. 283 | def authenticate_installation(payload) 284 | @installation_id = payload['installation']['id'] 285 | @installation_token = @app_client.create_app_installation_access_token(@installation_id)[:token] 286 | @installation_client = Octokit::Client.new(bearer_token: @installation_token) 287 | end 288 | 289 | # Check X-Hub-Signature to confirm that this webhook was generated by 290 | # GitHub, and not a malicious third party. 291 | # 292 | # GitHub uses the WEBHOOK_SECRET, registered to the GitHub App, to 293 | # create the hash signature sent in the `X-HUB-Signature` header of each 294 | # webhook. This code computes the expected hash signature and compares it to 295 | # the signature sent in the `X-HUB-Signature` header. If they don't match, 296 | # this request is an attack, and you should reject it. GitHub uses the HMAC 297 | # hexdigest to compute the signature. The `X-HUB-Signature` looks something 298 | # like this: 'sha1=123456'. 299 | def verify_webhook_signature 300 | their_signature_header = request.env['HTTP_X_HUB_SIGNATURE'] || 'sha1=' 301 | method, their_digest = their_signature_header.split('=') 302 | our_digest = OpenSSL::HMAC.hexdigest(method, WEBHOOK_SECRET, @payload_raw) 303 | halt 401 unless their_digest == our_digest 304 | 305 | # The X-GITHUB-EVENT header provides the name of the event. 306 | # The action value indicates the which action triggered the event. 307 | logger.debug "---- received event #{request.env['HTTP_X_GITHUB_EVENT']}" 308 | logger.debug "---- action #{@payload['action']}" unless @payload['action'].nil? 309 | end 310 | 311 | end 312 | 313 | # Finally some logic to let us run this server directly from the command line, 314 | # or with Rack. Don't worry too much about this code. But, for the curious: 315 | # $0 is the executed file 316 | # __FILE__ is the current file 317 | # If they are the same—that is, we are running this file directly, call the 318 | # Sinatra run method 319 | run! if __FILE__ == $0 320 | end 321 | --------------------------------------------------------------------------------