Getting started with a Serverless Github App

If you've considered enhancing your teams workflow with a Github app, or even developing something for a wider audience, the Github API is very good. Nevertheless, it can be a little tricky if you want to use serverless-node as the official tutorial is in Ruby and the Official Octokit node packages is catered to a more server-full solution (i.e. they have middleware for handling auth, which is not super applicable to serverless functions).

We're going to walk through how to set up a very basic Github app that's able to comment on Pull requests in response to a web hook, so that we have an end-to-end example from code to comment and you can take it from there. Finished repo is here.

Code Setup

We're going to use Redwood as an example, mostly because it makes getting setup and coding an absolute breeze, but you should have no trouble following along with this tutorial if you have a prefered node base serverless setup (netlify, serverless etc).

'cd' into a new folder where you want your app to go and run 'yarn create redwood-app ./'. This will generate a lot of code as it's setting up a fullstack app, but we're only concerned about 'api/src/functions/', creating a new file called 'githubhook.js'.

export const handler = () => {
  console.log('hi')
  return {
    statusCode: 200,
    body: 'hello'
  }
}

Spin it up with 'yarn rw dev'. It's going to serve the redwood frontend on port 8910, but the functions will be on 8911 so go to localhost:8911/githubhook and you should see 'hello' in the browser and 'hi' in the terminal.

Great, our function is setup. The reason we've set up a function is Github apps work by listening to web hooks that Github sends to our application (or function in this case). In response to these webhooks we can do most other actions on Github (leave comments, close issues etc). Before we turn out attention to Github, let's make a quick change to our handler so that we can see info about incoming web hooks. Change it to:

export const handler = (req) => {
  const theirSignature = req.headers['x-hub-signature-256']
  const eventType = req.headers['x-github-event']
  const event = JSON.parse(req.body)
  console.log(`
theirSignature is ${theirSignature}
eventType is ${eventType}
installation id is ${event.installation.id}
  `)
  return {
    statusCode: 200,
  }
}

The event type should show us if it's an 'installation' event or 'pull_request' event. Now let's set up Github.

Creating a Github app

For longevity, I'll leave a link to the official tutorial. Let's get the app settings ready in 11 quick steps

1. Go to smee.io and click "start new channel" to get yourself a unique url, we'll use this to forward webhooks to your local app, then install and run the smee client in a new terminal.

npm install --global smee-client
smee --url https://smee.io/<your-unique-hash> --path /githubhook --port 8911

2. Go to app settings and click "New Github App".

3. Give your app a name.

4. Put your smee url into the home page URL (you can change this later, but for the time being you need to put something in this field).

5. Put your smee url into the webhook URL and add a secret (the secret is optional but we're going to cover it). I normally use a password generater to create a secret.

6. Create a '.env' file in the root of your project. You should see '.env.example' already generated for you. Once you create '.env' you should notice that it's already gitignored. This is good as we don't want to commit our secrets. Add a new variable for our secret like so.

GITHUB_APP_SECRET=d7VtfAC2Uk3G49K4hygTUktzNkvyKx

7. Under repositry settings, give your app "read" permission for metadata, and "read and write" permission for pull requests.

8. Subscribe to pull-request events.

9. Click the big green "Create Github App" button.

10. Add your app id to the '.env' file:

GITHUB_APP_SECRET=d7VtfAC2Uk3G49K4hygTUktzNkvyKx

GITHUB_APP_ID=113860

11. Generate a private key.

You will then download a '.pem' file. Open it in a text editor as we need to paste the contents into our '.env' file. Unfortunatly with Node we can't use multiline environment variables, so you will have to replace all of the new lines with escaped new lines instead (i.e. '\n'). Your .env file should look something like this now:

GITHUB_APP_SECRET=d7VtfAC2Uk3G49K4hygTUktzNkvyKx

GITHUB_APP_ID=113860

GITHUB_APP_PRIVATE_KEY="-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQAA4+M/9x8bCQDI8h4CfRyWqXCyyrBIlNENhTDci51cq3JzRgoz\nNoj1A1ytvzO7pDnd3kkeQ2Vy9Yh/gB6dpsBc6r3w+40/apafzQh6RrpbSR6WCeOa\nRz8EmdbbnWwCmfDxB0YyLXvv9LIaYanNhlifbGnS5LMB7hPVsbiefKbF/UApbKnT\nME70qEhUpgtMsgFcDutH8Ru7h4sgMxUyK6trYj3IffNhOI6NCfKMzNh1zKENuKUr\nuMJvl0hdA4UpOwVzFe9N7xXsTHwGRZLKELeBS/UOoQH9fatTevSoZ7oNlsVbTd0q\nQ6G/5rkrFp3ThYMXrqi2KExn4TKKPaslqyoFIQIDAQABAoIBACjSoLbIH7OxLE4T\nCicXY/XedmjQw2/FM0LUye1Itz0PN48obJwsVJfRll5WChsVMqWLO5yfI8TQOubc\nlSk19G8or26gkuepK98y0ZSx9YBXtlD3ML/qjgxw7M56dszU2JiQ/pQfS5DuBsPQ\nAz05yvVEz76gQow/iVxY0itKRNVkvn2Vrh2cGRMwHtidLi6PVDe1P9PP1pkpzSGB\nO+k9qlAOTuOOP9h8GhDtT4IUN8uvoNkuXaX9YHYVoQfTKE51y5miAoMaDlAYJcJy\nJw6Loi9QP240UqAmNUHXZ+6Z0WC0M8D67gGwuHAfQRszWkEG7YlQFVTFx2syXvMb\n63MabAECgYEA8h9N8k5mn4el7nJn3umy+G6Te6GCZDH+PaEf3gUMybQz7ay4U5Q6\nnKG1dJvxxx5SF4HlwPSBW/FUg19RqqwmWWNL3yil6LIwmwAxpcqCDdAZSEc398oU\nrEHuMq2cuWLnYJOKzF/4q/es4NoRgRTFnxMmN/h1Ih+lehCTfELFuPECgYEA8PMU\nRlamrqJPJNsE4iTRKdGBPT3mZNT5g9xlNIWolbFW5VSmsyHBheKgWFA4XpyzsWCL\ndSNvmbjVml93FubVdf4NJN91Tchhy4JDXabhcHQ3j5u8ORDP10gZspClORvCUdaG\nBmQUqjNwC+fYLKobcRncXMMBe/zS4dQP7ikzjzECgYBeD3NEktijWRtJSwC3RKrW\ngH6jJNd2/UT7xECRC/0vzuXti5AASDGM7/WCW6LN7CWQJFKRZ2tpwJNIhhs/5qjv\nSPgMtcneYHspfCXNdqKXoyRvQ9umU8c8NFDJN1EPZDDm/+qIAzCj/hAXOiBauSsc\n5V+PluJKY2jxxsbFG1ucwQKBgQCQ/AOoK33SuVHcQHoYxcSiYDFfM38OD2Uwpg6z\n4vVFVdeO2TgRs+8p6+tGGMdCjxJFWm2wB6mgmyrU4DrdqfqqLDumg1uneTr3ZSO6\nF6+xpgzEuhYxVF9sEDN+UjFJQt3Ttr0g3Vnd7GOwlkpq3dTzYndJzgF3pPMT9jG7\nwkkHEQKBgD1+EqeubKxzmBvVinBssWKk7m26pg2Uf+tqe1Ox+CZhUi0aOfFL3NlT\nKpgTSA0iNtp9srSZrUtEWNZ/2ITLIZypBC1cMqbUG4r+VcWfLPKPrzLUuxWgJKr3\nggQlB6rrePyMxDL80uAcf6omuh1/s049XfdApQRs96SVw2kWLghw\n-----END RSA PRIVATE KEY-----"

Triggering a web hook

Okay we're ready to start building out the app. But first as a sanity check let's make sure we can recieve web hooks. It makes sense to start with an installation event since we need to install the app. Go to your app settings and click edit on your app. From there click "install app" on the left hand side and then the green "install" on the right hand side.

You will need to choose permissions for the app. Since it's your own app "all repositories" will be fine.

And with that we would have triggered a webhook. Check back in your terminal and you should see something like this:

api | eventType is installation
api | signature is sha256=f59dba407b01bbc18b625b8a27fe6d217c1602bf99bb0fc72c0838c20895f1ff
api | installation id is 16726859

If you plan on running the Github app in tandem with your own app, then you can store this installation id in your database, but for now we'll leave it. Let's now trigger a pull-request event. Make a small change to one of your non-critical repos (or create a test repo for this purpose). Editing a read me is an easy way to whip up a pull request.

And just like that we should have triggered another event and web hook. The terminal should contain something along the lines of this:

api | eventType is pull_request
api | signature is sha256=32be921ea95bd573802ceea398e0d868e6b0c04e6f848ff562f0c7addcfac481
api | installation id is 16726859

The key bit here is that the event is now 'pull_request'.

Verifying incoming web hooks

Let's handle security now. Because a web hook is nothing more than an endpoint exposed to the web waiting for another service to use it, we want to make sure requests come from Github before we proceed with taking any actions. We can do that using the signature. The signature is a SHA256 that's made with the secret and the body of the request, which means the correct signature can only be made if you know the secret. We can replicate the signature and verify it's Github sending the request. Let's put that in a function:

import { createHmac } from 'crypto'

const signRequestBody = (secret, body) =>
  'sha256=' + createHmac('sha256', secret)
  .update(body, 'utf-8')
  .digest('hex')

The 'crypto' module doesn't need to be installed as it's a core Node module. Now all we need to do is compare the signatures. Let's update our handler:

export const handler = async (req) => {
  const theirSignature = req.headers['x-hub-signature-256']
  const ourSignature = signRequestBody(process.env.GITHUB_APP_SECRET, req.body)
  if (theirSignature !== ourSignature) {
    return {
      statusCode: 401,
      body: 'Bad signature'
    }
  }
  const eventType = req.headers['x-github-event']
  const event = JSON.parse(req && req.body  || '{"installation": {}}')
  return {
    statusCode: 200,
  }
}

Now if you visit localhost:8911/githubhook in a browser, you should see "bad signature" as the browser is not signing its requests. Great!

Note, that if you're using Redwood, you can use their webhook helpers to verify the signature instead like so

import { verifyEvent } from '@redwoodjs/api/webhooks'

export const handler = async (req) => {
  verifyEvent('sha256Verifier', {
    event: req,
    secret: process.env.GITHUB_APP_SECRET,
    options: { signatureHeader: 'x-hub-signature-256' },
  })
  // rest of the handler ...
}

Let's also check that the event type is a pull request.

import { createHmac } from 'crypto'

const signRequestBody = (secret, body) =>
  'sha256=' + createHmac('sha256', secret)
  .update(body, 'utf-8')
  .digest('hex')

export const handler = async (req) => {
  const theirSignature = req.headers['x-hub-signature-256']
  const ourSignature = signRequestBody(process.env.GITHUB_APP_SECRET, req.body)
  if (theirSignature !== ourSignature) {
    return {
      statusCode: 401,
      body: 'Bad signature'
    }
  }
  const eventType = req.headers['x-github-event']
  if (eventType !== 'pull_request') {
    return { statusCode: 200 }
  }
  const event = JSON.parse(req && req.body  || '{"installation": {}}')
  return {
    statusCode: 200,
  }
}

If it's not a pull request we're returning a health status code of 200 as there's nothing wrong with the inbound request, it's just we're only interested in pull_requests.

Now that we've handled security and checked for pull request events, let's try to comment on newly opened pull requests. We're going to use Github's official npm packages for this -- 'octokit'. Go ahead and install it with 'yarn workspace api add @octokit/app'. If you're not familiar with Redwood or yarn workspaces, 'workspace api' in the previous command is just telling yarn to install this package for the backend API, and not for the web app.

Now we can initialise octokit with all the values in our '.env' file. Add the following close to the top of the file:

import { App } from '@octokit/app'

const app = new App({
  privateKey: process.env.GITHUB_APP_PRIVATE_KEY,
  appId: process.env.GITHUB_APP_ID,
  webhooks: {
    secret: process.env.GITHUB_APP_SECRET,
  },
})

Now that we've set up octokit there are a multitude of things we can do.

Finding docs

But we're aiming specifically to leave a comment on a pull request. So how do we find the docs we're after? We can start here and use the search input on the top right hand side of the page. "create comment pull" sounds like a reasonable search phrase.

The third result relates to their API so let's go there. Straight away it informs us that we're actually after the issues API with an explanation as to why and a link to the API.

More specifically, we're interested in the 'Create an issue comment' end point. Along with a great deal of information about the endpoint, it gives us the path of this endpoint "POST /repos/{owner}/{repo}/issues/{issue_number}/comments". We can use this text directly with one of the octokit helpers; it will look something like:

octokit.request(
  'POST /repos/{owner}/{repo}/issues/{issue_number}/comments',
  /* other params */
)

But before we get ahead of oursevles, let's make a function 'writePullRequestComment' that takes the web hook event, and our comment text -- i.e. it will be called like so "writePullRequestComment(event, 'Salutations, what a fine PR you have here.')".

Even though we have already initialised octokit with our '.env' secrets, there's one more step to the initialisation for setting it up for a specific installation. This is because Github needs to know the specific instance of the app that's leaving a comment. When we installed the app on our profile, this created a new instance of the app. Because this will change each time the web hook is called we do this initialisation in our new function and we can get the installation id from the event.

const writePullRequestComment = async ({ event, message }) => {
  const octokit = await app.getInstallationOctokit(event.installation.id)
  /* more code to come */
}

Now we can finish off the function by adding the request helper:

const writePullRequestComment = async ({ event, message }) => {
  const octokit = await app.getInstallationOctokit(event.installation.id)
  return octokit.request(
    'POST /repos/{owner}/{repo}/issues/{issue_number}/comments',
    {
      owner: event.repository.owner.login,
      repo: event.repository.name,
      issue_number: event.number,
      body: message,
    }
  )
}

It's now ready to be used in our handler function. Here's the handler in full:

export const handler = async (req) => {
  const theirSignature = req.headers['x-hub-signature-256']
  const ourSignature = signRequestBody(process.env.GITHUB_APP_SECRET, req.body)
  if (theirSignature !== ourSignature) {
    return {
      statusCode: 401,
      body: 'Bad signature'
    }
  }
  const eventType = req.headers['x-github-event']
  if (eventType !== 'pull_request') {
    return { statusCode: 200 }
  }
  const event = JSON.parse(req.body)
  if (['reopened', 'opened'].includes(event.action)) {
    await writePullRequestComment({event, message: 'Salutations, what a fine PR you have here.'})
  }
  return {
    statusCode: 200,
  }
}

We also added an if statement so that the bot only leaves the comment when a pull request is opened or re-opened, and not every single pull request action. Here's the result:

We've now got a mimimum example working of communication both ways between Github and our own serverless function. We're recieving a web hook from Github, and using that to take an action on Github by leaving a comment (and we verified the web hook too).

I hope you build something awesome.

Before I go I'll mention that if you're a typescript fan, then the package '@octokit/webhooks-types' is very handy. The complete example repo is typed and here's a typed version of the code:

import { createHmac } from 'crypto'
import { App } from '@octokit/app'
import type { Endpoints } from '@octokit/types'
import type { PullRequestEvent } from '@octokit/webhooks-types'

const app = new App({
  privateKey: process.env.GITHUB_APP_PRIVATE_KEY,
  appId: process.env.GITHUB_APP_ID,
  webhooks: {
    secret: process.env.GITHUB_APP_SECRET,
  },
})

const signRequestBody = (secret: string, body: string): string =>
  'sha256=' + createHmac('sha256', secret).update(body, 'utf-8').digest('hex')

const writePullRequestComment = async ({
  event,
  message,
}: {
  event: PullRequestEvent
  message: string
}): Promise<
  Endpoints['POST /repos/{owner}/{repo}/issues/{issue_number}/comments']['response']
> => {
  const octokit = await app.getInstallationOctokit(event.installation.id)
  return octokit.request(
    'POST /repos/{owner}/{repo}/issues/{issue_number}/comments',
    {
      owner: event.repository.owner.login,
      repo: event.repository.name,
      issue_number: event.number,
      body: message,
    }
  )
}

export const handler = async (req: {
  body: string
  headers: {
    'x-hub-signature-256': string
    'x-github-event': string
  }
}) => {
  const theirSignature = req.headers['x-hub-signature-256']
  const ourSignature = signRequestBody(process.env.GITHUB_APP_SECRET, req.body)
  if (theirSignature !== ourSignature) {
    return {
      statusCode: 401,
      body: 'Bad signature',
    }
  }
  const eventType = req.headers['x-github-event']
  if (eventType !== 'pull_request') {
    return { statusCode: 200 }
  }
  const event: PullRequestEvent = JSON.parse(req.body)
  if (['reopened', 'opened'].includes(event.action)) {
    await writePullRequestComment({
      event,
      message: 'Salutations, what a fine PR you have here.',
    })
  }
  return {
    statusCode: 200,
  }
}

Did you know you can get Electronic-Mail sent to your inter-web inbox, for FREE !?!?
You should stay up-to-date with my work by signing up below.