Joe Conway

Build a GitHub+Slack Integration, Deploy to Heroku

June 5, 2017

In this post, we’ll create a web server that posts a message to Slack when a pull request is opened in a GitHub repo in your organization. The web server will be deployed to Heroku.

diagram.png

The finished code is available here. It’s only about 40 lines of code – most of this tutorial is about how to set up Heroku, Github and Slack. It’s easy.

Creating a Slack bot

Messages will be posted to Slack on behalf of a bot. Bots are registered with Slack through a web interface. Registering a bot gives us an API token that we’ll use to issue requests from our application.

Navigate to https://my.slack.com/services/new/bot. Enter a name for the bot and click Add Bot Integration. On the next page, copy the API token, we’ll use this in the next step.

slack_api_token.png

Creating the Heroku application

Navigate to https://heroku.com. If you do not have an account, create one. Once you have, you will be navigated to your dashboard. Create a new application by selecting New -> Create new app.

heroku_create.png

Leave the App Name field blank so that it creates a name for you and click Create App. You’ll be navigated to your new application’s dashboard.

Click on the Settings tab near the top of the page. Then, click on Reveal Config Vars. Add a new configuration variable named SLACK_TOKEN and paste the bot’s API token in the value field.

heroku_slack.png

Note the name of the application at the top of this page – we’ll use this in the next step. For example, the name of my application is sleepy-fjord-41247.

Configuring GitHub to send webhooks

GitHub sends an HTTP POST request to a URL for your choosing whenever an event occurs in one of your organization’s repositories. This request is called a webhook – which is a fancier, networked version of a callback.

Navigate to your organization settings page – for example, ours is: https://github.com/organizations/stablekernel/settings/profile. Click on Webhooks in the Organization settings and then click on Add webhook.

First, switch the Content type to application/json, enter whatever you want for the Secret, and toggle the radio button to Send me everything. (You can go back and edit which events will be sent later, but let’s not waste time there now.)

In the Payload URL field, you will enter a URL that your application will serve. The format of this URL is https://<your-app-name>.herokuapp.com/gh_hook.

github_hook.png

Now, GitHub will send webhooks to your Heroku application – except your Heroku application isn’t running a web server yet.

Install Dart and Aqueduct

We’ll write this web server in Dart with the Aqueduct web server framework. This tutorial is actually an advertisement for Aqueduct, but you’ll like it, so it’s a win-win. And you’re almost done!

Related: Learn all about our efforts behind Aqueduct

If you are on a Mac, use homebrew to install Dart:

brew tap dart-lang/dart
brew install dart

If you aren’t on a Mac, check out https://www.dartlang.org/install for Linux and Windows installers.

Then, install the command-line tool for aqueduct:

pub global activate aqueduct

Dart command-line tools from packages are located at ~/.pub-cache/bin, so this directory will need to be in your PATH. Instructions for your OS are emitted by pub global activate aqueduct. On a Mac, this means adding the following line to ~/.bash_profile and then opening a new terminal window:

export PATH="$PATH":~/.pub-cache/bin

The aqueduct tool has a command to create projects – it can also generate and execute database migrations, manage OAuth 2.0 clients and run aqueduct applications.

Create a new project named pr_bot:

aqueduct create pr_bot

Note that if ~/.pub-cache/bin is not in your PATH, you can still invoke an activated Dart package. For example, the above can be replaced with:

pub global run aqueduct:aqueduct create pr_bot

A new directory named pr_bot has been created in your current working directory. Open pr_bot in IntelliJ IDEA CE (or Webstorm, or Atom or VS Code). Since you’re already in the terminal, here’s a fun command to do that:

open pr_bot -a 'IntelliJ IDEA CE'

The Dart plugin for IntelliJ IDEA is an officially supported plugin by JetBrains. That is, JetBrains themselves develops the plugin. This ain’t amateur hour. Therefore, once IntelliJ IDEA has launched, install the Dart plugin by opening Settings->Plugins and selecting Install JetBrains plugin.... Find Dart and click Install.

dart_plugin.png

After it installs, IntelliJ will restart and your project will be reopened.

Open the file lib/pr_bot_sink.dart. Click Enable Dart support. Now for some code.

First, we’ll define some types that represent objects from the GitHub API. We won’t expose every value that we get – there are a lot – just the ones we need. In the project navigator, right-click on lib/ and select Add->Dart File. Name this file github_model (the .dart suffix is automatically added). Add the following to this file:

class PullRequest {
  PullRequest.fromMap(Map<String, dynamic> map) {
    title = map["title"];
    submitter = new User.fromMap(map["user"]);
    repository = new Repository.fromMap(map["head"]["repo"]);
  }

  String title;
  User submitter;
  Repository repository;
}

class User {
  User.fromMap(Map<String, dynamic> map) {
    login = map["login"];
  }

  String login;
}

class Repository {
  Repository.fromMap(Map<String, dynamic> map) {
    name = map["name"];
  }

  String name;
}

We’ll create an HTTPController subclass that handles all requests for the path /gh_hook (this is the path of the webhook we configured in GitHub). In the project navigator, open lib/, right-click on controller and select Add->Dart File. Name this file hook_controller.

At the top of this file, import your application package and the model definitions, and declare a new HTTPController subclass with a method that responds to POST requests:

import '../pr_bot.dart';
import '../github_model.dart';

class HookController extends HTTPController {
  @httpPost
  Future<Response> onHook(@HTTPHeader("x-github-event") String eventType) async {
    if (eventType != "pull_request") {
      return new Response.accepted();
    }

    var payload = request.body.asMap();
    if (payload["action"] != "opened") {
      return new Response.accepted();
    }

    var pullRequest = new PullRequest.fromMap(payload["pull_request"]);
    var message = "(${pullRequest.repository.name}) ${pullRequest.submitter.login} opened PR '${pullRequest.title}'.";

    // We'll send to Slack here

    return new Response.accepted();
  }
}

This code will unpack the hook event JSON from the webhook request and prepare a message that we’ll send to Slack, which our bot will then post in a channel on our behalf.

We must include the API token we generated earlier in our requests to Slack. Heroku hangs on to this value for us in an environment variable – we set this up earlier. When our application starts up, the application will read that value and inject it into HookController. We’ll write that code shortly, but for now, let’s have HookController take the token as an argument in its constructor:

class HookController extends HTTPController {
  HookController(this.slackToken);

  String slackToken;

  @httpPost
  Future<Response> onHook(@HTTPHeader("x-github-event") String eventType) async {
  ...
}

Finish the implementation of onHook by sending an HTTP POST request to Slack. Make sure you replace channel-name with the channel you want your messages to be posted to.

  ...

  var message = "(${pullRequest.repository.name}) ${pullRequest.submitter.login} opened PR '${pullRequest.title}'.";

  // We'll send to Slack here
  var parameters = {
    "channel": "channel-name",
    "text": message,
    "token": slackToken
  };

  // If you are feeling adventurous, you can replace this code
  // with a one-liner using the Dart `http` package.
  var url = new Uri.https("slack.com", "/api/chat.postMessage", parameters);
  var client = new HttpClient();
  var slackRequest = await client.postUrl(url);
  slackRequest.headers.contentType =
    new ContentType("application", "x-www-form-urlencoded");
  await slackRequest.close();

  return new Response.accepted();
}

Whenever a HookController gets a POST request with an x-github-event header, it will post a message to a Slack channel. We still need to route requests with the path /gh_hook to HookController and make sure it has the Slack API token.

Open the file pr_bot_sink.dart. This file contains a subclass of RequestSink specific to your application named PrBotSink. A RequestSink overrides methods like setupRouter to initialize an application. This includes tasks like setting up routes, reading configuration values, establishing database connections, and so on.

First, we’ll read the Slack API token from Heroku’s environment variables. Reading a configuration file is straightforward: declare a type with properties for each key in a YAML file. This type must be a subclass of ConfigurationItem. We only have one value we need to read – the Slack API token – so declare the following type at the bottom of pr_bot_sink.dart.

class PRBotConfiguration extends ConfigurationItem {
  PRBotConfiguration(String filename) : super.fromFile(filename);

  String slackToken;
}

This configuration item reads the value slackToken from a YAML file and assigns it to the property of the same name. In config.yaml, add the following:

slackToken: $SLACK_TOKEN

(The leading $ means this value is read from an environment variable. Without the $, whatever value in the configuration file is used.)

And finally, we tie it all together. At the top of pr_bot_sink.dart, import hook_controller.dart:

import 'controller/hook_controller.dart';

In setupRouter, route requests with the path /gh_hook to an instance of HookController, injecting the Slack API token from the configuration file:

  @override
  void setupRouter(Router router) {
    var config = new PRBotConfiguration(configuration.configurationFilePath);

    router
        .route("/gh_hook")
        .generate(() => new HookController(config.slackToken));
  }

Deploy!

We’re done writing code and now we can push our code to Heroku. The best way to get code to Heroku is to use its git deployment feature. Deploying is as easy as running git push heroku master once set up.

Initialize a git repository by running the following command inside the project directory:

git init

Make sure you have installed the Heroku CLI with the following (see also: Installing Heroku).

brew install heroku
heroku login

Now, there is handy tool in aqueduct that will configure your application to run on Heroku once the Heroku CLI is installed. From your project directory, run the following command and replace the project name with your project name:

aqueduct setup --heroku=sleepy-fjord-41247

This creates Procfile in your project directory and sets up your Heroku app to receive pushes from this repository. The Procfile automatically adds a release command to run database migrations, but if there is no database this command will fail and halt your deployment. Remove the release command from Procfile so that the contents of this file are simply:

web: /app/dart-sdk/bin/pub global run aqueduct:aqueduct serve --port $PORT --no-monitor

Now all that’s left is to push it to Heroku and the application will start running:

git add .
git commit -m "initial commit"
git push heroku master

Open up a PR in your organization and check your Slack channel.

Challenge: securing the webhook

As one last precaution: anyone can send a webhook to your application and it’ll happily post a message to your Slack. The secret you added when registering the webhook is used to verify that the webhook did, in fact, come from GitHub. This is left as an exercise to the reader, but it’s mostly stuff you just learned:

  1. Add the secret as a Heroku config var.
  2. Add the crypto package to pubspec.yaml and run pub get.
  3. Add secret as a key to config.yaml and property to PRBotConfiguration.
  4. Pass the secret from the configuration into HookController‘s constructor.
  5. Add one more HTTPHeader binding for x-hub-signature to HookController.onHook.
  6. Verify the secret by importing package:crypto/crypto.dart and dart:convert and adding some code at the very top of HookController.onHook:
var hmac = new Hmac(sha1, UTF8.encode(secret));
var signature = "sha1=" + hmac.convert(request.body.asBytes()).toString();
if (signature != xHubSignatureHeader) {
  return new Response.forbidden();
}

By default, aqueduct discards the raw bytes of a request body after it has been decoded. The above hash requires that we keep the original payload. You must retain the original bytes by overriding the following method in HookController:

class HookController extends HTTPController {
  ...

  @override
  void willDecodeRequestBody(HTTPRequestBody body) {
    body.retainOriginalBytes = true;
  }
}

 

Published June 5, 2017
Joe Conway

CEO & Founder at Stable Kernel

Tags:

Leave a Reply

Your email address will not be published. Required fields are marked *