diff --git a/.env.sample b/.env.sample new file mode 100644 index 00000000..5ded9527 --- /dev/null +++ b/.env.sample @@ -0,0 +1,4 @@ +# The ID of your GitHub integration +INTEGRATION_ID= + +WEBHOOK_SECRET=development diff --git a/.gitignore b/.gitignore index 983352d4..e2dd9a94 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ npm-debug.log .DS_Store node_modules/ +private-key.pem +.env diff --git a/.travis.yml b/.travis.yml index a4897c6c..32f95ad0 100644 --- a/.travis.yml +++ b/.travis.yml @@ -4,3 +4,5 @@ node_js: - "6" notifications: disabled: true +env: + - PRIVATE_KEY=ssssshhh diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index ccc06a62..03e565f4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,29 +1,37 @@ # Contributing -## Setup +## Running Locally -0. Clone the repo -0. Make sure you have the latest version of [Node.js](https://nodejs.org/) to develop locally. +0. Clone the repository with `git clone https://github.com/bkeepers/PRobot.git` +0. Make sure you have a recent version of [Node.js](https://nodejs.org/) installed 0. Run `$ script/bootstrap` to install all the project dependencies -0. Until this gets built into a proper [Integration](https://developer.github.com/early-access/integrations/), the bot will need a valid GitHub API token to be able to do anything useful. Create a new [Personal access token](https://github.com/settings/tokens/new) and select the `repo` scope. -0. Re-start the local server with the token by running: `$ GITHUB_TOKEN=xxx script/server` to run the server on http://localhost:3000 -0. Download [ngrok](https://ngrok.com/download) (`$ brew cask install ngrok` on a mac), which will expose a local server to the internet. -0. With the server still running, open a new terminal tab and run `ngrok http 3000`, which should output something like `Forwarding http://4397efc6.ngrok.io -> localhost:3000`. +0. Install [ngrok](https://ngrok.com/download) (`$ brew cask install ngrok` on a mac), which will expose the local server to the internet so GitHub can send webhooks +0. Run `$ ngrok http 3000`, which should output something like `Forwarding https://4397efc6.ngrok.io -> localhost:3000` +0. [Register an integration](https://developer.github.com/early-access/integrations/creating-an-integration/) on GitHub with: + - **Homepage URL**, **Callback URL**, and **Webhook URL**: The full ngrok url above. For example: `https://4397efc6.ngrok.io/` + - **Secret:** `development` + - **Permissions & events** needed will depend on how you use the bot, but for development it may be easiest to enable everything. +0. Download the private key and move it to `private-key.pem` in the project directory +0. Edit `.env` and fill in all the environment variables +0. With `ngrok` still running, open another terminal and run `$ script/server` to start the server on http://localhost:3000 + +Whenever you com back to work on the app after you've already had it running once, then you need to: + +0. Run `$ script/server` +0. Run `$ ngrok http 3000` +0. `ngrok` will use a different URL every time it is restarted, so you will have to go into the [settings for your Integration](https://github.com/settings/installations) and update all the URLs. ## Testing -To test with a real GitHub repository, you'll need to create a test repository and configure a new webhook: -0. Head over to the **Settings** page of your repository, and click on **Webhooks & services**. Then, click on **Add webhook**. Configure it with: - - **Payload URL:** Use the full `*.ngrok.io` - - **Secret:** `development` - - **Which events would you like to trigger this webhook?:** Choose **Send me everything**. -0. Create a `.probot.js` in your repo with: +To test with a real GitHub repository, you'll need to create a test repository and install the integration you created above: +0. Open up the settings for your installation and click "Install" +0. Create a `.probot.js` in your repository with: on("issues.opened").comment("Hello World! Your bot is working!"); - 0. Open a new issue. Your bot should post a comment (you may need to refresh to see it). ## Debugging + 0. Always run `$ script/bootstrap` and restart the server if package.json has changed. 0. To turn on verbose logging, start server by running ` $ DEBUG=Probot GITHUB_TOKEN=xxx script/server` 0. To see what requests are going out, enable debugging mode for GitHub client in `/server.js`: diff --git a/README.md b/README.md index fda331fd..5a423a93 100644 --- a/README.md +++ b/README.md @@ -23,3 +23,10 @@ _**Heads up!** The [demo integration](https://github.com/integration/probot-demo `); 0. Open a new issue. @probot should post a comment (you may need to refresh to see it). + +### Deploy your own bot to Heroku + +0. [![Deploy](https://www.herokucdn.com/deploy/button.svg)](https://heroku.com/deploy) - Click this button and pick an **App Name** that Heroku is happy with, like `your-name-probot`. Before you can complete this, you'll need config variables from the next step. +0. In another tab, [create an integration](https://developer.github.com/early-access/integrations/creating-an-integration/) on GitHub, using `https://your-app-name.herokuapp.com/` as the **Homepage URL**, **Callback URL**, and **Webhook URL**. The permissions and events that your bot needs access to will depend on what you use it for. +0. After creating your GitHub integration, go back to the Heroku tab and fill in the configuration variables with the values for the GitHub Integration +0. Create a `.probot.yml` file in your repository. See [Configuring](#configuring). diff --git a/app.json b/app.json new file mode 100644 index 00000000..ef3e3c9c --- /dev/null +++ b/app.json @@ -0,0 +1,18 @@ +{ + "name": "PRobot", + "description": "a trainable robot that responds to activity on GitHub", + "keywords": ["PRobot", "node", "github"], + "repository": "https://github.com/bkeepers/PRobot", + "logo": "https://github.com/probot.png", + "env": { + "PRIVATE_KEY": { + "description": "the private key you downloaded when creating the GitHub Integration" + }, + "INTEGRATION_ID": { + "description": "the ID of your GitHub Integration" + }, + "WEBHOOK_SECRET": { + "description": "the secret configured for your GitHub Integration" + } + } +} diff --git a/docs/configuration.md b/docs/configuration.md index 106f82c7..3aa5de92 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -145,12 +145,6 @@ Add labels .unassign('defunkt'); ``` -#### `react` - -``` -.react('heart'); # or +1, -1, laugh, confused, heart, hooray -``` - --- See [examples](examples.md) for ideas of behaviors you can implement by combining these configuration options. diff --git a/lib/installations.js b/lib/installations.js new file mode 100644 index 00000000..12fa54af --- /dev/null +++ b/lib/installations.js @@ -0,0 +1,38 @@ +const debug = require('debug')('PRobot'); +const GitHubApi = require('github'); +const jwt = require('./jwt'); + +module.exports = {auth}; + +const tokens = {}; + +// Authenticate as the given installation +function auth(id) { + const token = tokens[id]; + + if (!token || new Date(token.expires_at) < new Date()) { + return createToken(id); + } else { + const github = new GitHubApi(); + github.authenticate({type: 'token', token: token.token}); + return Promise.resolve(github); + } +} + +// https://developer.github.com/early-access/integrations/authentication/#as-an-installation +function createToken(id) { + const github = new GitHubApi(); + github.authenticate({type: 'integration', token: jwt()}); + + debug('creating token for installation', id); + + return github.integrations.createInstallationToken({ + installation_id: id + }).then(token => { + // cache token + tokens[id] = token; + + github.authenticate({type: 'token', token: token.token}); + return github; + }); +} diff --git a/lib/jwt.js b/lib/jwt.js new file mode 100644 index 00000000..b16c95be --- /dev/null +++ b/lib/jwt.js @@ -0,0 +1,21 @@ +const fs = require('fs'); +const process = require('process'); +const jwt = require('jsonwebtoken'); + +const cert = process.env.PRIVATE_KEY || fs.readFileSync('private-key.pem'); + +module.exports = generate; + +function generate() { + const payload = { + // issued at time + iat: Math.floor(new Date() / 1000), + // JWT expiration time + exp: Math.floor(new Date() / 1000) + 60, + // Integration's GitHub identifier + iss: process.env.INTEGRATION_ID + }; + + // sign with RSA SHA256 + return jwt.sign(payload, cert, {algorithm: 'RS256'}); +} diff --git a/lib/plugins/issues.js b/lib/plugins/issues.js index 8f88793d..cf6e16af 100644 --- a/lib/plugins/issues.js +++ b/lib/plugins/issues.js @@ -47,11 +47,6 @@ const IssuePlugin = superclass => class extends superclass { return this; } - react(reaction) { - this._setCommentData({reaction}); - return this; - } - _setCommentData(obj) { if (this.issueActions === undefined) { this.issueActions = {}; @@ -143,13 +138,6 @@ class IssueEvaluator extends Evaluator { ); } - if (workflow.issueActions.reaction !== undefined) { - const reaction = workflow.issueActions.reaction; - promises.push( - context.github.reactions.createForIssue(context.payload.toIssue({content: reaction})) - ); - } - return promises; } } diff --git a/lib/robot.js b/lib/robot.js new file mode 100644 index 00000000..501e034c --- /dev/null +++ b/lib/robot.js @@ -0,0 +1,25 @@ +const debug = require('debug')('PRobot'); +const installations = require('./installations'); +const Configuration = require('./configuration'); +const Dispatcher = require('./dispatcher'); + +class Robot { + listen(webhook) { + webhook.on('*', this.receive.bind(this)); + } + + receive(event) { + debug('webhook', event); + + if (event.payload.repository) { + installations.auth(event.payload.installation.id).then(github => { + const dispatcher = new Dispatcher(github, event); + return Configuration.load(github, event.payload.repository).then(config => { + dispatcher.call(config); + }); + }); + } + } +} + +module.exports = new Robot(); diff --git a/package.json b/package.json index d9739d1f..79fe3097 100644 --- a/package.json +++ b/package.json @@ -11,10 +11,12 @@ "license": "ISC", "dependencies": { "debug": "2.2.0", + "dotenv": "^2.0.0", "expect": "^1.20.2", - "github": "^5.2.0", + "github": "^6.0.4", "github-webhook-handler": "^0.6.0", - "handlebars": "^4.0.5" + "handlebars": "^4.0.5", + "jsonwebtoken": "^7.1.9" }, "devDependencies": { "mocha": "^3.0.2", @@ -24,6 +26,7 @@ "esnext": true, "space": true, "rules": { + "camelcase": 1, "no-else-return": 0, "key-spacing": 0 }, diff --git a/script/bootstrap b/script/bootstrap index c2b7c6da..f68fa38d 100755 --- a/script/bootstrap +++ b/script/bootstrap @@ -3,3 +3,6 @@ set -e npm install + +# Copy .env template +[[ -f .env ]] || cp .env.sample .env diff --git a/script/console b/script/console index bb56c33d..cb379283 100755 --- a/script/console +++ b/script/console @@ -1,12 +1,24 @@ #!/usr/bin/env node +// Console for experimenting with GitHub API requests. +// +// Usage: GITHUB_TOKEN=xxx script/Console +// + +require('dotenv').config(); + +if (!process.env.GITHUB_TOKEN) { + console.error('GITHUB_TOKEN environment variable must be set.'); + console.error('Create a personal access token at https://github.com/settings/tokens/new'); + process.exit(1); +} const repl = require('repl').start('> '); -let GitHubApi = require('github'); -let github = new GitHubApi(); +const GitHubApi = require('github'); +const github = new GitHubApi(); github.authenticate({ type: 'oauth', token: process.env.GITHUB_TOKEN -}); +}) repl.context.github = github; diff --git a/server.js b/server.js index 27447580..900ddffb 100644 --- a/server.js +++ b/server.js @@ -1,23 +1,16 @@ +require('dotenv').config({silent: true}); + const process = require('process'); const http = require('http'); const createHandler = require('github-webhook-handler'); -const GitHubApi = require('github'); const debug = require('debug')('PRobot'); -const Configuration = require('./lib/configuration'); -const Dispatcher = require('./lib/dispatcher'); +const robot = require('./lib/robot'); const PORT = process.env.PORT || 3000; const webhook = createHandler({path: '/', secret: process.env.WEBHOOK_SECRET || 'development'}); debug('Starting'); -const github = new GitHubApi({debug: false}); - -github.authenticate({ - type: 'token', - token: process.env.GITHUB_TOKEN -}); - http.createServer((req, res) => { webhook(req, res, err => { if (err) { @@ -31,39 +24,11 @@ http.createServer((req, res) => { }); }).listen(PORT); -webhook.on('*', event => { - debug('webhook', event); +robot.listen(webhook); - if (event.payload.repository) { - const dispatcher = new Dispatcher(github, event); - return Configuration.load(github, event.payload.repository).then( - config => dispatcher.call(config) - ); - } +// Show trace for any unhandled rejections +process.on('unhandledRejection', reason => { + console.error(reason); }); -// Check for and accept any repository invitations -function checkForInvites() { - debug('Checking for repository invites'); - github.users.getRepoInvites({}).then(invites => { - invites.forEach(invite => { - debug('Accepting repository invite', invite.full_name); - github.users.acceptRepoInvite(invite); - }); - }); - - debug('Checking for organization invites'); - github.orgs.getOrganizationMemberships({state: 'pending'}).then(invites => { - invites.forEach(invite => { - debug('Accepting organization invite', invite.organization.login); - github.users.editOrganizationMembership({ - org: invite.organization.login, - state: 'active' - }); - }); - }); -} -checkForInvites(); -setInterval(checkForInvites, Number(process.env.INVITE_CHECK_INTERVAL || 60) * 1000); - console.log('Listening on http://localhost:' + PORT); diff --git a/test/configuration.js b/test/configuration.js index 875cf141..cb3382a0 100644 --- a/test/configuration.js +++ b/test/configuration.js @@ -7,8 +7,7 @@ const createSpy = expect.createSpy; config.content = new Buffer(` on("issues.opened") .comment("Hello World!") - .assign("bkeepers") - .react("heart"); + .assign("bkeepers"); on("issues.closed") .unassign("bkeepers"); diff --git a/test/fixtures/webhook/installation.deleted.json b/test/fixtures/webhook/installation.deleted.json new file mode 100644 index 00000000..c321d7c9 --- /dev/null +++ b/test/fixtures/webhook/installation.deleted.json @@ -0,0 +1,46 @@ +{ + "action": "deleted", + "installation": { + "id": 1729, + "account": { + "login": "bkeepers-inc", + "id": 11724939, + "avatar_url": "https://avatars.githubusercontent.com/u/11724939?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/bkeepers-inc", + "html_url": "https://github.com/bkeepers-inc", + "followers_url": "https://api.github.com/users/bkeepers-inc/followers", + "following_url": "https://api.github.com/users/bkeepers-inc/following{/other_user}", + "gists_url": "https://api.github.com/users/bkeepers-inc/gists{/gist_id}", + "starred_url": "https://api.github.com/users/bkeepers-inc/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/bkeepers-inc/subscriptions", + "organizations_url": "https://api.github.com/users/bkeepers-inc/orgs", + "repos_url": "https://api.github.com/users/bkeepers-inc/repos", + "events_url": "https://api.github.com/users/bkeepers-inc/events{/privacy}", + "received_events_url": "https://api.github.com/users/bkeepers-inc/received_events", + "type": "Organization", + "site_admin": false + }, + "access_tokens_url": "https://api.github.com/installations/1729/access_tokens", + "repositories_url": "https://api.github.com/installation/repositories" + }, + "sender": { + "login": "bkeepers", + "id": 173, + "avatar_url": "https://avatars.githubusercontent.com/u/173?v=3", + "gravatar_id": "", + "url": "https://api.github.com/users/bkeepers", + "html_url": "https://github.com/bkeepers", + "followers_url": "https://api.github.com/users/bkeepers/followers", + "following_url": "https://api.github.com/users/bkeepers/following{/other_user}", + "gists_url": "https://api.github.com/users/bkeepers/gists{/gist_id}", + "starred_url": "https://api.github.com/users/bkeepers/starred{/owner}{/repo}", + "subscriptions_url": "https://api.github.com/users/bkeepers/subscriptions", + "organizations_url": "https://api.github.com/users/bkeepers/orgs", + "repos_url": "https://api.github.com/users/bkeepers/repos", + "events_url": "https://api.github.com/users/bkeepers/events{/privacy}", + "received_events_url": "https://api.github.com/users/bkeepers/received_events", + "type": "User", + "site_admin": true + } +} diff --git a/test/plugins/issues.js b/test/plugins/issues.js index 0637d96c..a8ec0bd7 100644 --- a/test/plugins/issues.js +++ b/test/plugins/issues.js @@ -7,9 +7,6 @@ const payload = require('../fixtures/webhook/comment.created.json'); const createSpy = expect.createSpy; const github = { - reactions: { - createForIssue: createSpy() - }, issues: { lock: createSpy(), unlock: createSpy(), @@ -210,18 +207,4 @@ describe('issues plugin', () => { }); }); }); - - describe('reactions', () => { - it('react', () => { - this.w.react('heart'); - - Promise.all(this.evaluator.evaluate(this.w, context)); - expect(github.reactions.createForIssue).toHaveBeenCalledWith({ - owner: 'bkeepers-inc', - repo: 'test', - number: 6, - content: 'heart' - }); - }); - }); });