Skip to content

Commit 478c682

Browse files
authored
Merge pull request #185 from github/build-your-first-github-app
Add sample code for "Build Your First GitHub App" quickstart
2 parents cadd1df + e89af18 commit 478c682

File tree

6 files changed

+376
-0
lines changed

6 files changed

+376
-0
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
source 'http://rubygems.org'
2+
3+
gem 'sinatra', '~> 2.0'
4+
gem 'jwt', '~> 2.1'
5+
gem 'octokit', '~> 4.0'
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
GEM
2+
remote: http://rubygems.org/
3+
specs:
4+
addressable (2.5.2)
5+
public_suffix (>= 2.0.2, < 4.0)
6+
faraday (0.15.2)
7+
multipart-post (>= 1.2, < 3)
8+
jwt (2.1.0)
9+
multipart-post (2.0.0)
10+
mustermann (1.0.2)
11+
octokit (4.9.0)
12+
sawyer (~> 0.8.0, >= 0.5.3)
13+
public_suffix (3.0.2)
14+
rack (2.0.5)
15+
rack-protection (2.0.3)
16+
rack
17+
sawyer (0.8.1)
18+
addressable (>= 2.3.5, < 2.6)
19+
faraday (~> 0.8, < 1.0)
20+
sinatra (2.0.3)
21+
mustermann (~> 1.0)
22+
rack (~> 2.0)
23+
rack-protection (= 2.0.3)
24+
tilt (~> 2.0)
25+
tilt (2.0.8)
26+
27+
PLATFORMS
28+
ruby
29+
30+
DEPENDENCIES
31+
jwt (~> 2.1)
32+
octokit (~> 4.0)
33+
sinatra (~> 2.0)
34+
35+
BUNDLED WITH
36+
1.14.6
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
This is the sample project built by following the "[Building Your First GitHub App](https://developer.github.com/apps/building-your-first-github-app)" Quickstart guide on developer.github.com.
2+
3+
It consists of two different servers: `server.rb` (boilerplate) and `advanced_server.rb` (completed project).
4+
5+
## Install and run
6+
7+
To run the code, make sure you have [Bundler](http://gembundler.com/) installed; then enter `bundle install` on the command line.
8+
9+
* For the boilerplate project, enter `ruby server.rb` on the command line.
10+
11+
* For the completed project, enter `ruby advanced_server.rb` on the command line.
12+
13+
Both commands will run the server at `localhost:3000`.
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
require 'sinatra'
2+
require 'logger'
3+
require 'json'
4+
require 'openssl'
5+
require 'octokit'
6+
require 'jwt'
7+
require 'time' # This is necessary to get the ISO 8601 representation of a Time object
8+
9+
set :port, 3000
10+
11+
#
12+
#
13+
# This is a customized server for the GitHub App you can build by following
14+
# https://developer.github.com/build-your-first-github-app.
15+
#
16+
#
17+
18+
class GHAapp < Sinatra::Application
19+
20+
# Never, ever, hardcode app tokens or other secrets in your code!
21+
# Always extract from a runtime source, like an environment variable.
22+
23+
24+
# Notice that the private key must be in PEM format, but the newlines should be stripped and replaced with
25+
# the literal `\n`. This can be done in the terminal as such:
26+
# export GITHUB_PRIVATE_KEY=`awk '{printf "%s\\n", $0}' private-key.pem`
27+
PRIVATE_KEY = OpenSSL::PKey::RSA.new(ENV['GITHUB_PRIVATE_KEY'].gsub('\n', "\n")) # convert newlines
28+
29+
# You set the webhook secret when you create your app. This verifies that the webhook is really coming from GH.
30+
WEBHOOK_SECRET = ENV['GITHUB_WEBHOOK_SECRET']
31+
32+
# Get the app identifier—an integer—from your app page after you create your app. This isn't actually a secret,
33+
# but it is something easier to configure at runtime.
34+
APP_IDENTIFIER = ENV['GITHUB_APP_IDENTIFIER']
35+
36+
# You need to authenticate to the REST API to do much of anything
37+
38+
########## Configure Sinatra
39+
#
40+
# Let's turn on verbose logging during development
41+
#
42+
configure :development do
43+
set :logging, Logger::DEBUG
44+
end
45+
46+
47+
########## Before each request to our app
48+
#
49+
# Before each request to our app, we want to instantiate an Octokit client. Doing so requires that we construct a JWT.
50+
# https://jwt.io/introduction/
51+
# We have to also sign that JWT with our private key, so GitHub can be sure that
52+
# a) it came from us
53+
# b) it hasn't been altered by a malicious third party
54+
#
55+
before do
56+
payload = {
57+
# The time that this JWT was issued, _i.e._ now.
58+
iat: Time.now.to_i,
59+
60+
# How long is the JWT good for (in seconds)?
61+
# Let's say it can be used for 10 minutes before it needs to be refreshed.
62+
# TODO we don't actually cache this token, we regenerate a new one every time!
63+
exp: Time.now.to_i + (10 * 60),
64+
65+
# Your GitHub App's identifier number, so GitHub knows who issued the JWT, and know what permissions
66+
# this token has.
67+
iss: APP_IDENTIFIER
68+
}
69+
70+
# Cryptographically sign the JWT
71+
jwt = JWT.encode(payload, PRIVATE_KEY, 'RS256')
72+
73+
# Create the Octokit client, using the JWT as the auth token.
74+
# Notice that this client will _not_ have sufficient permissions to do many interesting things!
75+
# The helper methods below include one that generates an installation token (using the JWT) and
76+
# instantiates a new client object.
77+
@client ||= Octokit::Client.new(bearer_token: jwt)
78+
79+
end
80+
81+
82+
83+
84+
########## Events
85+
#
86+
# This is the webhook endpoint that GH will call with events, and hence where we will do our event handling
87+
#
88+
89+
post '/' do
90+
request.body.rewind
91+
payload_raw = request.body.read # We need the raw text of the body to check the webhook signature
92+
begin
93+
payload = JSON.parse payload_raw
94+
rescue
95+
payload = {}
96+
end
97+
98+
# Check X-Hub-Signature to confirm that this webhook was generated by GitHub, and not a malicious third party.
99+
# The way this works is: We have registered with GitHub a secret, and we have stored it locally in WEBHOOK_SECRET.
100+
# GitHub will cryptographically sign the request payload with this secret. We will do the same, and if the results
101+
# match, then we know that the request is from GitHub (or, at least, from someone who knows the secret!)
102+
# If they don't match, this request is an attack, and we should reject it.
103+
# The signature comes in with header x-hub-signature, and looks like "sha1=123456"
104+
# We should take the left hand side as the signature method, and the right hand side as the
105+
# HMAC digest (the signature) itself.
106+
their_signature_header = request.env['HTTP_X_HUB_SIGNATURE'] || 'sha1='
107+
method, their_digest = their_signature_header.split('=')
108+
our_digest = OpenSSL::HMAC.hexdigest(method, WEBHOOK_SECRET, payload_raw)
109+
halt 401 unless their_digest == our_digest
110+
111+
# Determine what kind of event this is, and take action as appropriate
112+
# TODO we assume that GitHub will always provide an X-GITHUB-EVENT header in this case, which is a reasonable
113+
# assumption, however we should probably be more careful!
114+
logger.debug "---- received event #{request.env['HTTP_X_GITHUB_EVENT']}"
115+
logger.debug "---- action #{payload['action']}" unless payload['action'].nil?
116+
117+
case request.env['HTTP_X_GITHUB_EVENT']
118+
when 'issues'
119+
authenticate_installation(payload)
120+
if payload['action'] === 'opened'
121+
handle_issue_opened_event(payload)
122+
end
123+
end
124+
125+
'ok' # we have to return _something_ ;)
126+
end
127+
128+
129+
########## Helpers
130+
#
131+
# These functions are going to help us do some tasks that we don't want clogging up the happy paths above, or
132+
# that need to be done repeatedly. You can add anything you like here, really!
133+
#
134+
135+
helpers do
136+
137+
# Authenticate each installation of the app in order to run API operations
138+
def authenticate_installation(payload)
139+
installation_id = payload['installation']['id']
140+
installation_token = @client.create_app_installation_access_token(installation_id)[:token]
141+
@bot_client = Octokit::Client.new(bearer_token: installation_token)
142+
end
143+
144+
# When an issue is opened, add a label
145+
def handle_issue_opened_event(payload)
146+
repo = payload['repository']['full_name']
147+
issue_number = payload['issue']['number']
148+
@bot_client.add_labels_to_an_issue(repo, issue_number, ['needs-response'])
149+
end
150+
151+
end
152+
153+
154+
# Finally some logic to let us run this server directly from the commandline, or with Rack
155+
# Don't worry too much about this code ;) But, for the curious:
156+
# $0 is the executed file
157+
# __FILE__ is the current file
158+
# If they are the same—that is, we are running this file directly, call the Sinatra run method
159+
run! if __FILE__ == $0
160+
end
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
require "./server"
2+
run GHAapp
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
require 'sinatra'
2+
require 'logger'
3+
require 'json'
4+
require 'openssl'
5+
require 'octokit'
6+
require 'jwt'
7+
require 'time' # This is necessary to get the ISO 8601 representation of a Time object
8+
9+
set :port, 3000
10+
11+
#
12+
#
13+
# This is a boilerplate server for your own GitHub App. You can read more about GitHub Apps here:
14+
# https://developer.github.com/apps/
15+
#
16+
# On its own, this app does absolutely nothing, except that it can be installed.
17+
# It's up to you to add fun functionality!
18+
# You can check out one example in advanced_server.rb.
19+
#
20+
# This code is a Sinatra app, for two reasons.
21+
# First, because the app will require a landing page for installation.
22+
# Second, in anticipation that you will want to receive events over a webhook from GitHub, and respond to those
23+
# in some way. Of course, not all apps need to receive and process events! Feel free to rip out the event handling
24+
# code if you don't need it.
25+
#
26+
# Have fun! Please reach out to us if you have any questions, or just to show off what you've built!
27+
#
28+
29+
class GHAapp < Sinatra::Application
30+
31+
# Never, ever, hardcode app tokens or other secrets in your code!
32+
# Always extract from a runtime source, like an environment variable.
33+
34+
35+
# Notice that the private key must be in PEM format, but the newlines should be stripped and replaced with
36+
# the literal `\n`. This can be done in the terminal as such:
37+
# export GITHUB_PRIVATE_KEY=`awk '{printf "%s\\n", $0}' private-key.pem`
38+
PRIVATE_KEY = OpenSSL::PKey::RSA.new(ENV['GITHUB_PRIVATE_KEY'].gsub('\n', "\n")) # convert newlines
39+
40+
# You set the webhook secret when you create your app. This verifies that the webhook is really coming from GH.
41+
WEBHOOK_SECRET = ENV['GITHUB_WEBHOOK_SECRET']
42+
43+
# Get the app identifier—an integer—from your app page after you create your app. This isn't actually a secret,
44+
# but it is something easier to configure at runtime.
45+
APP_IDENTIFIER = ENV['GITHUB_APP_IDENTIFIER']
46+
47+
48+
########## Configure Sinatra
49+
#
50+
# Let's turn on verbose logging during development
51+
#
52+
configure :development do
53+
set :logging, Logger::DEBUG
54+
end
55+
56+
57+
########## Before each request to our app
58+
#
59+
# Before each request to our app, we want to instantiate an Octokit client. Doing so requires that we construct a JWT.
60+
# https://jwt.io/introduction/
61+
# We have to also sign that JWT with our private key, so GitHub can be sure that
62+
# a) it came from us
63+
# b) it hasn't been altered by a malicious third party
64+
#
65+
before do
66+
payload = {
67+
# The time that this JWT was issued, _i.e._ now.
68+
iat: Time.now.to_i,
69+
70+
# How long is the JWT good for (in seconds)?
71+
# Let's say it can be used for 10 minutes before it needs to be refreshed.
72+
# TODO we don't actually cache this token, we regenerate a new one every time!
73+
exp: Time.now.to_i + (10 * 60),
74+
75+
# Your GitHub App's identifier number, so GitHub knows who issued the JWT, and know what permissions
76+
# this token has.
77+
iss: APP_IDENTIFIER
78+
}
79+
80+
# Cryptographically sign the JWT
81+
jwt = JWT.encode(payload, PRIVATE_KEY, 'RS256')
82+
83+
# Create the Octokit client, using the JWT as the auth token.
84+
# Notice that this client will _not_ have sufficient permissions to do many interesting things!
85+
# We might, for particular endpoints, need to generate an installation token (using the JWT), and instantiate
86+
# a new client object. But we'll cross that bridge when/if we get there!
87+
@client ||= Octokit::Client.new(bearer_token: jwt)
88+
end
89+
90+
91+
92+
93+
########## Events
94+
#
95+
# This is the webhook endpoint that GH will call with events, and hence where we will do our event handling
96+
#
97+
98+
post '/' do
99+
request.body.rewind
100+
payload_raw = request.body.read # We need the raw text of the body to check the webhook signature
101+
begin
102+
payload = JSON.parse payload_raw
103+
rescue
104+
payload = {}
105+
end
106+
107+
# Check X-Hub-Signature to confirm that this webhook was generated by GitHub, and not a malicious third party.
108+
# The way this works is: We have registered with GitHub a secret, and we have stored it locally in WEBHOOK_SECRET.
109+
# GitHub will cryptographically sign the request payload with this secret. We will do the same, and if the results
110+
# match, then we know that the request is from GitHub (or, at least, from someone who knows the secret!)
111+
# If they don't match, this request is an attack, and we should reject it.
112+
# The signature comes in with header x-hub-signature, and looks like "sha1=123456"
113+
# We should take the left hand side as the signature method, and the right hand side as the
114+
# HMAC digest (the signature) itself.
115+
their_signature_header = request.env['HTTP_X_HUB_SIGNATURE'] || 'sha1='
116+
method, their_digest = their_signature_header.split('=')
117+
our_digest = OpenSSL::HMAC.hexdigest(method, WEBHOOK_SECRET, payload_raw)
118+
halt 401 unless their_digest == our_digest
119+
120+
# Determine what kind of event this is, and take action as appropriate
121+
# TODO we assume that GitHub will always provide an X-GITHUB-EVENT header in this case, which is a reasonable
122+
# assumption, however we should probably be more careful!
123+
logger.debug "---- recevied event #{request.env['HTTP_X_GITHUB_EVENT']}"
124+
logger.debug "---- action #{payload['action']}" unless payload['action'].nil?
125+
126+
case request.env['HTTP_X_GITHUB_EVENT']
127+
when :the_event_that_i_care_about
128+
# Add code here to handle the event that you care about!
129+
handle_the_event_that_i_care_about(payload)
130+
end
131+
132+
'ok' # we have to return _something_ ;)
133+
end
134+
135+
136+
########## Helpers
137+
#
138+
# These functions are going to help us do some tasks that we don't want clogging up the happy paths above, or
139+
# that need to be done repeatedly. You can add anything you like here, really!
140+
#
141+
142+
helpers do
143+
144+
# This is our handler for the event that you care about! Of course, you'll want to change the name to reflect
145+
# the actual event name! But this is where you will add code to process the event.
146+
def handle_the_event_that_i_care_about(payload)
147+
logger.debug 'Handling the event that we care about!'
148+
true
149+
end
150+
151+
end
152+
153+
154+
# Finally some logic to let us run this server directly from the commandline, or with Rack
155+
# Don't worry too much about this code ;) But, for the curious:
156+
# $0 is the executed file
157+
# __FILE__ is the current file
158+
# If they are the same—that is, we are running this file directly, call the Sinatra run method
159+
run! if __FILE__ == $0
160+
end

0 commit comments

Comments
 (0)