Merge pull request #36 from bkeepers/integration

GitHub Integration
This commit is contained in:
Brandon Keepers 2016-11-20 21:40:15 -06:00 committed by GitHub
commit 3c19bc96ec
18 changed files with 216 additions and 98 deletions

4
.env.sample Normal file
View File

@ -0,0 +1,4 @@
# The ID of your GitHub integration
INTEGRATION_ID=
WEBHOOK_SECRET=development

2
.gitignore vendored
View File

@ -1,3 +1,5 @@
npm-debug.log
.DS_Store
node_modules/
private-key.pem
.env

View File

@ -4,3 +4,5 @@ node_js:
- "6"
notifications:
disabled: true
env:
- PRIVATE_KEY=ssssshhh

View File

@ -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`:

View File

@ -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).

18
app.json Normal file
View File

@ -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"
}
}
}

View File

@ -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.

38
lib/installations.js Normal file
View File

@ -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;
});
}

21
lib/jwt.js Normal file
View File

@ -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'});
}

View File

@ -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;
}
}

25
lib/robot.js Normal file
View File

@ -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();

View File

@ -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
},

View File

@ -3,3 +3,6 @@
set -e
npm install
# Copy .env template
[[ -f .env ]] || cp .env.sample .env

View File

@ -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;

View File

@ -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);

View File

@ -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");

View File

@ -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
}
}

View File

@ -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'
});
});
});
});