diff --git a/.env.example b/.env.example index e69de29..81a0189 100644 --- a/.env.example +++ b/.env.example @@ -0,0 +1,45 @@ +WEBHOOK_PROXY_URL=https://smee.io/-------- +# GitHub API endpoint +API_BASE_URL="https://api.github.com" + +# +# GitHub User's USERNAME +# +GH_USER="" + +# +# GitHub User's PERSONAL ACCESS TOKEN which can be created here: +# +# https://github.com/settings/tokens/new?scopes=public_repo,read:user +# +GH_USER_TOKEN="" + +# +# Once you've created a GITHUB APP here: +# +# https://github.com/settings/apps/new +# +# you'll see an "app id", "client id" and "client secret" at the top of the +# settings page +# +# https://github.com/settings/apps/YOUR_APP_NAME#private-key +# +# for your app. Fill in those values below +GH_APP_ID="" +GH_APP_CLIENT_ID="" +GH_APP_CLIENT_SECRET="" + +# +# At the very bottom of your app's settings page +# +# https://github.com/settings/apps/YOUR_APP_NAME#private-key +# +# you'll find a "Private Keys"section, with a "Generate Private Key" button. +# Clicking it will download a file to your computer. +# +# (1) Place that file in the `./private` folder so that it will be ignored by Git. +# (2) Ensure that the GH_APP_PRIVATE_KEY_PATH variable leads to the relative path +# of that file +# +# +GH_APP_PRIVATE_KEY_PATH="./private/gh-app.key" diff --git a/README.md b/README.md index b6b368c..1845347 100644 --- a/README.md +++ b/README.md @@ -1,20 +1,41 @@ # Python GitHub App -This app is meant to serve as an application to help you onboard to the GitHub ecosystem and start using GitHub Apps. +This app is meant to serve as an application to help you onboard to the GitHub ecosystem and start using GitHub Webhooks & Apps. -## How to set it up and use it +## Getting Started + +### Initial Project Setup - Clone/Fork this repo -- Generate your virutal environment - `python3 -m venv venv` -- Activate your environment - `source venv/bin/activate` -- Install dependencies - `pip3 install -r requirements.txt` -- Run the app - `flask run` +- Generate your virutal environment + +```sh +python3 -m venv venv +``` + +- Activate your environment + +```sh +source venv/bin/activate +``` + +- Install dependencies + +```sh +pip3 install -r requirements.txt +``` + +- Run the app + +``` +flask run +``` -## How to use setup and install the app +### Connecting to GitHub -- Create a new repository at https://github.com/li-playground/ +- Create a new public repository at https://github.com/new - Visit https://smee.io/ and click on `Start a new Channel` and note the URL -- Create a new GitHub App - https://github.com/organizations/li-playground/settings/apps/new +- Create a new GitHub App - https://github.com/settings/apps/new - Give it a distinct name and description (prefix your LDAP) - Set `Homepage URL` = `http://localhost:5000/` - Set `User authorization callback URL` = `http://localhost:5000/authenticate/` @@ -23,7 +44,7 @@ This app is meant to serve as an application to help you onboard to the GitHub e - Select the Radio Button for `Enable SSL verification` - Under permissions, give `Read & Write` permissions for `Pull Requests` - Under `Subscribe to Events`, check `Pull Request` -- Generate and Download the `Private key`, move it to `private` folder in your app on local machine and name it `gh-app.key` +- Generate and Download the `Private key`, move it to your app folder on local machine and name it `./private/gh-app.key` - Hit `Save Changes` Now you should be redirected to the App Settings - diff --git a/app.py b/app.py index 179abcb..b3fc637 100644 --- a/app.py +++ b/app.py @@ -1,5 +1,5 @@ from bot_config import API_BASE_URL, validate_env_variables -from gh_token import get_token, store_token +# from gh_oauth_token import get_token, store_token from gh_utils import make_github_rest_api_call from webhook_handlers import add_pr_comment @@ -12,7 +12,6 @@ import markdown2 from flask import Flask, request, redirect, render_template -from flask_apscheduler import APScheduler from objectify_json import ObjectifyJSON log = logging.getLogger(__name__) @@ -38,22 +37,23 @@ def welcome(): ============ Dynamic routes that are needed to facilitate the authentication flow -""" +We will let you know when it's appropriate to un-comment this +""" -@app.route("/authenticate/", methods=["GET"]) -def authenticate(app_id): - """Incoming Installation Request. Accept and get a new token.""" - try: - app_id = str(app_id) - installation_id = request.args.get('installation_id') - store_token(get_token(app_id, installation_id)) +# @app.route("/authenticate/", methods=["GET"]) +# def authenticate(app_id): +# """Incoming Installation Request. Accept and get a new token.""" +# try: +# app_id = str(app_id) +# installation_id = request.args.get('installation_id') +# store_token(get_token(app_id, installation_id)) - except Exception: - log.error("Unable to get and store token.") - traceback.print_exc(file=sys.stderr) +# except Exception: +# log.error("Unable to get and store token.") +# traceback.print_exc(file=sys.stderr) - return redirect("https://www.github.com", code=302) +# return redirect("https://www.github.com", code=302) @app.route('/webhook', methods=['POST']) @@ -74,8 +74,9 @@ def process_message(): - Is github SENDING webhooks to the same https://smee.io URL you're RECEIVING from? """ - log.info('Incoming webhook') webhook = ObjectifyJSON(request.json) + log.info( + f'Incoming webhook [{webhook.action}]: {json.dumps(webhook, sort_keys=True, indent=4)}') # Let's react only when a new Pull Requests has been opened. if request.headers['X-Github-Event'] == 'pull_request' and str(webhook.action).lower() == 'opened': diff --git a/bin/start_smee b/bin/start_smee new file mode 100755 index 0000000..06e53f2 --- /dev/null +++ b/bin/start_smee @@ -0,0 +1,26 @@ +#!/bin/bash +test ! -f .env && \ + echo "Could not find a .env file. You may want to run:" && \ + echo "" && \ + echo " cp .env.example .env" && \ + echo "" && exit 1 + +# setup environment based on .env +export $(egrep -v '^#' .env | xargs) + +# check for the presence of WEBHOOK_PROXY_URL +eval val=\""\$WEBHOOK_PROXY_URL"\" +if [[ -z "${val}" ]]; then + echo "🛑 WEBHOOK_PROXY_URL has not been set. Please provide a value in your .env file" + echo "" + exit 1 +else + echo "✅ Starting webhook proxy w/ WEBHOOK_PROXY_URL=$WEBHOOK_PROXY_URL" + echo "" +fi + +# allow for the detection of the running webhook proxy +export WEBHOOK_PROXY_IS_RUNNING=1 +pysmee forward $WEBHOOK_PROXY_URL http://localhost:5000/webhook +# indicate that the webhook proxy has been shut down +unset WEBHOOK_PROXY_IS_RUNNING diff --git a/bot_config.py b/bot_config.py index e75f713..2ff76d3 100644 --- a/bot_config.py +++ b/bot_config.py @@ -1,6 +1,7 @@ import os import sys import logging +from functools import reduce logging.basicConfig(stream=sys.stdout, level=logging.INFO, format='[%(levelname)8s] %(process)s:%(name)s\t%(message)s') @@ -18,15 +19,29 @@ """ -GITHUB_USER = os.getenv("GITHUB_USER", -1) -GITHUB_TOKEN = os.getenv("GITHUB_TOKEN", -1) +GH_USER = os.getenv("GH_USER", -1) +GH_USER_TOKEN = os.getenv("GH_USER_TOKEN", -1) API_BASE_URL = os.getenv("API_BASE_URL", -1) +GH_APP_ID = os.getenv("GH_APP_ID", -1) +GH_APP_CLIENT_ID = os.getenv("GH_APP_CLIENT_ID", -1) +GH_APP_CLIENT_SECRET = os.getenv("GH_APP_CLIENT_SECRET", -1) +GH_APP_PRIVATE_KEY_PATH = os.getenv("GH_APP_PRIVATE_KEY_PATH", -1) def validate_env_variables(): - if (GITHUB_USER == -1 or GITHUB_TOKEN.count == 0): - log.warn("GITHUB_USER env variable has not yet been set") - if (GITHUB_TOKEN == -1 or GITHUB_TOKEN.count == 0): - log.warn("GITHUB_TOKEN env variable has not yet been set") - if (API_BASE_URL == -1 or API_BASE_URL.count == 0): - log.warn("API_BASE_URL env variable has not yet been set") + env_vars = { + "GH_USER": GH_USER, + "GH_USER_TOKEN": GH_USER_TOKEN, + "API_BASE_URL": API_BASE_URL, + "GH_APP_ID": GH_APP_ID, + "GH_APP_CLIENT_ID": GH_APP_CLIENT_ID, + "GH_APP_CLIENT_SECRET": GH_APP_CLIENT_SECRET, + "GH_APP_PRIVATE_KEY_PATH": GH_APP_PRIVATE_KEY_PATH + } + + blanks_msg = reduce(lambda acc, item: + acc + "\n\t\t\t\t" + item[0] if (item[1] == -1 or item[1] == "") else acc, env_vars.items(), "") + log.warn( + "The following environment variables were found to be empty:\n" + + blanks_msg + + "\n\n\t\t\t\tThis may be fine, depending on which exercise you're currently working on") diff --git a/gh_token.py b/gh_oauth_token.py similarity index 82% rename from gh_token.py rename to gh_oauth_token.py index c2b6864..31c51db 100644 --- a/gh_token.py +++ b/gh_oauth_token.py @@ -13,6 +13,20 @@ log = logging.getLogger(__name__) +""" +TO WORKSHOP ATTENDEES: +====================== + +You should not have to touch anything in this file. It deals with +building and signing the JWT necessary to facilitate OAuth 2.0 +authentication and authorization w/ GitHub. + +""" + +# The paths of two things that should never be checked into git +_token_storage_path = f'private/.secret' +_private_key_path = f'private/gh-app.key' + def get_token(app_id, installation_id): """Get a token from GitHub.""" @@ -33,7 +47,8 @@ def get_token(app_id, installation_id): encoded = jwt.encode(params, private_key, algorithm='RS256').decode("utf-8") headers = {'Accept': 'application/vnd.github.machine-man-preview+json', - 'Authorization': f'Bearer {encoded}'} + 'Authorization': f'Bearer {encoded}' # OAuth 2.0 + } # Send request to GitHub. response = requests.post(token_url, headers=headers) @@ -54,10 +69,10 @@ def get_token(app_id, installation_id): def store_token(token_json): if token_json: try: - if os.path.exists(f".secret"): - os.unlink(f".secret") + if os.path.exists(_token_storage_path): + os.unlink(_token_storage_path) - with open(f".secret", 'w') as secret_file: + with open(_token_storage_path, 'w') as secret_file: secret_file.write(json.dumps(token_json)) except Exception as exc: @@ -65,16 +80,16 @@ def store_token(token_json): traceback.print_exc(file=sys.stderr) else: - log.error("Invalid token for app") + log.error("Invalid (empty) token for app") def peek_app_token(): """Peek on secret file that has the token, deserialize it and return the dict.""" - if not os.path.exists(f".secret"): + if not os.path.exists(_token_storage_path): return None try: - with open(f".secret") as secret_file: + with open(_token_storage_path) as secret_file: return json.loads(secret_file.read()) except Exception as exc: @@ -125,11 +140,11 @@ def retrieve_token(): def get_private_key(): """Read private key from hidden file and return it.""" - if not os.path.exists(".private-key"): + if not os.path.exists(_private_key_path): return None try: - with open(".private-key") as secret_file: + with open(_private_key_path) as secret_file: return secret_file.read() except Exception as exc: diff --git a/gh_utils.py b/gh_utils.py index 46580e0..c7b5430 100644 --- a/gh_utils.py +++ b/gh_utils.py @@ -5,52 +5,64 @@ from requests.auth import HTTPBasicAuth -from gh_token import retrieve_token -from bot_config import API_BASE_URL, GITHUB_TOKEN, GITHUB_USER +# from gh_oauth_token import retrieve_token +from bot_config import API_BASE_URL, GH_USER_TOKEN, GH_USER log = logging.getLogger(__name__) -""" -GITHUB REST API CALL FN -======================== +def make_github_rest_api_call(api_path, method='GET', params=None): + """Send API call to Github using a personal token. Use this function to make API calls to the GitHub REST api For example: -# GET the current user +`GET` the current user +--- +```py me = make_github_rest_api_call('login') +``` -# POST to create a comment on a PR - +`POST` to create a comment on a PR +--- +```py new_comment = make_github_rest_api_call( 'repos/my_org/my_repo/issues/31/comments', 'POST', { 'body': "Hello there, thanks for creating a new Pull Request!" } ) +``` + """ - -""" - - -def make_github_rest_api_call(api_path, method='GET', params=None): - """Send API call to Github using a personal token.""" - - token = retrieve_token() + # token = retrieve_token() + token = GH_USER_TOKEN # Required headers. headers = {'Accept': 'application/vnd.github.antiope-preview+json', 'Content-Type': 'application/json', - 'Authorization': f'Bearer {token}'} + # 'Authorization': f'Bearer {token}' + } + # API url + url = f'{API_BASE_URL}/{api_path}' + log.info( + f'sending {method.upper()} request to {url} w/ data {json.dumps(params)}') try: if method.upper() == 'POST': - response = requests.post(f'{API_BASE_URL}/{api_path}', headers=headers, data=json.dumps( - params)) + response = requests.post( + url, + headers=headers, + data=json.dumps(params), + auth=HTTPBasicAuth(GH_USER, GH_USER_TOKEN) # basic auth + ) elif method.upper() == 'GET': - response = requests.get(f'{API_BASE_URL}/{api_path}', headers=headers) + response = requests.get( + url, + headers=headers, + auth=HTTPBasicAuth(GH_USER, GH_USER_TOKEN) # basic auth + ) else: raise Exception('Invalid Request Method.') except: