Merge remote-tracking branch 'origin/master' into simulate

* origin/master: (65 commits)
  0.7.4
  Update CHANGLEOG for 0.7.4
  Fix bug introduced in 0.7.3
  Consolidate Robot tests
  Fix changelog for 0.7.3 release
  0.7.3
  Update changelog
  Raise errors by default
  Raise errors in tests
  Wait for async events to resolve before returning
  Fix lint errors
  Expose method to create robot
  Test with taking an argument and not
  Ensure * event still works
  Make Robot self-sustaining
  Allow creating robot without a logger
  Fix typo
  Move default secret so it works for programatic uses
  Test for manually delivering events
  Define receive method for manually delivering events
  ...
This commit is contained in:
Brandon Keepers 2017-07-18 20:05:34 +02:00
commit 18bd6644f0
No known key found for this signature in database
GPG Key ID: F9533396D5FACBF6
20 changed files with 3294 additions and 175 deletions

View File

@ -1,5 +1,5 @@
# The ID of your GitHub integration
INTEGRATION_ID=
# The ID of your GitHub App
APP_ID=
WEBHOOK_SECRET=development
# Uncomment this to get verbose logging

94
CHANGELOG.md Normal file
View File

@ -0,0 +1,94 @@
# Changelog
## 0.7.4 (2017-06-30)
Fixes:
- This fixes a bug introduced in [#165](https://github.com/probot/probot/pull/165) and adds a test to show that webhook delivery is happening. ([#168](https://github.com/probot/probot/pull/168))
[View full changelog](https://github.com/probot/probot/compare/v0.7.3...v0.7.4)
## 0.7.3 (2017-06-30)
Enhancements:
- Refactor some internals to improve the testability of plugins. Docs coming soon after building out tests for several of the plugins.
[View full changelog](https://github.com/probot/probot/compare/v0.7.2...v0.7.3)
## 0.7.2 (2017-06-27)
- Internal: update from `github-integration` to renamed `github-app` for handling GitHub App authentication.
[View full changelog](https://github.com/probot/probot/compare/v0.7.1...v0.7.2)
## 0.7.1 (2017-06-16)
Fixes:
- Fix error introduced in 0.7.0 that was preventing events from being received. ([#161](https://github.com/probot/probot/pull/161))
[View full changelog](https://github.com/probot/probot/compare/v0.7.0...v0.7.1)
## 0.7.0 (2017-06-15)
Breaking Changes:
- Callbacks passed to `robot.on` used to take two arguments—`event` and `context`. The second was pretty much just a fancy version of the first, and you really need the second to do anything useful, so the first argument has been dropped. (Technically, the second is passed as both arguments for now to preserve backward compatibility, but this won't be the case forever, so go update your plugins). You will see this warning when loading plugins:
```
DEPRECATED: Event callbacks now only take a single `context` argument.
at module.exports.robot (/path/to/your/plugin.js:3:9)
```
Before:
```js
robot.on('issues.opened', async (event, context) => {
log('Event and context? What is the difference?', events, context);
});
```
After:
```js
robot.on('issues.opened', async context => {
log('Sweet, just one arg', context, context.payload);
});
```
Enhancements:
- Fix issue where localtunnel would often not reconnect when you restart the probot process. ([#157](https://github.com/probot/probot/pull/157))
[View full changelog](https://github.com/probot/probot/compare/v0.6.0...v0.7.0)
## v0.6.0 (2017-06-09)
Breaking Changes:
- Update to [node-github](https://github.com/mikedeboer/node-github) v9, which namespaces all responses with a `data` attribute. (https://github.com/mikedeboer/node-github/pull/505). This is likely to be a breaking change for all plugins.
Before:
```js
const data = await github.search.issues(params);
data.items.forEach(item => doTheThing(issue));
```
After:
```js
const res = await github.search.issues(params);
res.data.items.forEach(item => doTheThing(issue));
```
- "GitHub Integrations" were renamed to "GitHub Apps". The `INTEGRATION_ID` environment variable has been deprecated. Use `APP_ID` instead. ([#154](https://github.com/probot/probot/pull/154))
Enhancements:
- Errors thrown in handlers from plugins were being silently ignored. That's not cool. Now, they're being caught and logged.
([#152](https://github.com/probot/probot/pull/152))
[View full changelog](https://github.com/probot/probot/compare/v0.5.0...v0.6.0)

View File

@ -2,13 +2,9 @@
Probot is a bot framework for GitHub. It's like [Hubot](https://hubot.github.com/), but for GitHub instead of chat.
If you've ever thought, "wouldn't it be cool if GitHub could…"; imma stop you right there. Most features can actually be added via [GitHub Integrations](https://developer.github.com/early-access/integrations/):
If you've ever thought, "wouldn't it be cool if GitHub could…"; imma stop you right there. Most features can actually be added via [GitHub Apps](https://developer.github.com/apps/), which extend GitHub and can be installed directly on organizations and user accounts and granted access to specific repositories. They come with granular permissions and built-in webhooks. Apps are first class actors within GitHub.
> Integrations are a new way to extend GitHub. They can be installed directly on organizations and user accounts and granted access to specific repositories. They come with granular permissions and built-in webhooks. Integrations are first class actors within GitHub.
>
> Documentation on [GitHub Integrations](https://developer.github.com/early-access/integrations/)
There are some great services that offer [hosted integrations](https://github.com/integrations), but you can build a bunch of really cool things yourself. Probot aims to make that easy.
There are some great services that offer [apps in the GitHub Marketplace](https://github.com/marketplace), and you can build a bunch of really cool things yourself. Probot aims to make that easy.
## Plugins

View File

@ -6,13 +6,13 @@
"logo": "https://github.com/probot.png",
"env": {
"PRIVATE_KEY": {
"description": "the private key you downloaded when creating the GitHub Integration"
"description": "the private key you downloaded when creating the GitHub App"
},
"INTEGRATION_ID": {
"description": "the ID of your GitHub Integration"
"APP_ID": {
"description": "the ID of your GitHub App"
},
"WEBHOOK_SECRET": {
"description": "the secret configured for your GitHub Integration"
"description": "the secret configured for your GitHub App"
},
"LOG_LEVEL": {
"required": false,

View File

@ -9,15 +9,25 @@ const {findPrivateKey} = require('../lib/private-key');
program
.usage('[options] <plugins...>')
.option('-i, --integration <id>', 'ID of the GitHub Integration', process.env.INTEGRATION_ID)
.option('-s, --secret <secret>', 'Webhook secret of the GitHub Integration', process.env.WEBHOOK_SECRET || 'development')
.option('-i, --integration <id>', 'DEPRECATED: ID of the GitHub App', process.env.INTEGRATION_ID)
.option('-a, --app <id>', 'ID of the GitHub App', process.env.APP_ID)
.option('-s, --secret <secret>', 'Webhook secret of the GitHub App', process.env.WEBHOOK_SECRET)
.option('-p, --port <n>', 'Port to start the server on', process.env.PORT || 3000)
.option('-P, --private-key <file>', 'Path to certificate of the GitHub Integration', findPrivateKey)
.option('-P, --private-key <file>', 'Path to certificate of the GitHub App', findPrivateKey)
.option('-t, --tunnel <subdomain>', 'Expose your local bot to the internet', process.env.SUBDOMAIN || process.env.NODE_ENV !== 'production')
.parse(process.argv);
if (!program.integration) {
console.warn('Missing GitHub Integration ID.\nUse --integration flag or set INTEGRATION_ID environment variable.');
if (program.integration) {
// FIXME: remove in v0.7.0
console.warn(
`DEPRECATION: The --integration flag and INTEGRATION_ID environment variable are\n` +
`deprecated. Use the --app flag or set APP_ID environment variable instead.`
);
program.app = program.integration;
}
if (!program.app) {
console.warn('Missing GitHub App ID.\nUse --app flag or set APP_ID environment variable.');
program.help();
}
@ -27,36 +37,21 @@ if (!program.privateKey) {
if (program.tunnel) {
try {
setupTunnel();
const setupTunnel = require('../lib/tunnel');
setupTunnel(program.tunnel, program.port).then(tunnel => {
console.log('Listening on ' + tunnel.url);
}).catch(err => {
console.warn('Could not open tunnel: ', err.message);
});
} catch (err) {
console.warn('Run `npm install --save-dev localtunnel` to enable localtunnel.');
}
}
function setupTunnel() {
// eslint-disable-next-line import/no-extraneous-dependencies, import/no-unresolved
const localtunnel = require('localtunnel');
const subdomain = typeof program.tunnel === 'string' ?
program.tunnel :
require('os').userInfo().username;
const tunnel = localtunnel(program.port, {subdomain}, (err, tunnel) => {
if (err) {
console.warn('Could not open tunnel: ', err.message);
} else {
console.log('Listening on ' + tunnel.url);
}
});
tunnel.on('close', () => {
console.warn('Local tunnel closed');
});
}
const createProbot = require('../');
const probot = createProbot({
id: program.integration,
id: program.app,
secret: program.secret,
cert: program.privateKey,
port: program.port

View File

@ -2,47 +2,47 @@
Every plugin can either be deployed as a stand-alone bot, or combined with other plugins in one deployment.
> **Heads up!** Note that most [plugins in the @probot organization](https://github.com/search?q=topic%3Aprobot-plugin+org%3Aprobot&type=Repositories) have an official hosted integration that you can use for your open source project. Use the hosted instance if you don't want to deploy your own.
> **Heads up!** Note that most [plugins in the @probot organization](https://github.com/search?q=topic%3Aprobot-plugin+org%3Aprobot&type=Repositories) have an official hosted app that you can use for your open source project. Use the hosted instance if you don't want to deploy your own.
**Contents:**
1. [Create the GitHub Integration](#create-the-github-integration)
1. [Create the GitHub App](#create-the-github-app)
1. [Deploy the plugin](#deploy-the-plugin)
1. [Heroku](#heroku)
1. [Now](#now)
1. [Combining plugins](#combining-plugins)
## Create the GitHub Integration
## Create the GitHub App
Every deployment will need an [Integration](https://developer.github.com/early-access/integrations/).
Every deployment will need an [App](https://developer.github.com/apps/).
1. [Create a new GitHub Integration](https://github.com/settings/integrations/new) with:
1. [Create a new GitHub App](https://github.com/settings/apps/new) with:
- **Homepage URL**: the URL to the GitHub repository for your plugin
- **Webhook URL**: Use `https://example.com/` for now, we'll come back in a minute to update this with the URL of your deployed plugin.
- **Webhook Secret**: Generate a unique secret with `openssl rand -base64 32` and save it because you'll need it in a minute to configure your deployed plugin.
- **Permissions & events**: See `docs/deploy.md` in the plugin for a list of the permissions and events that it needs access to.
1. Download the private key from the Integration.
1. Download the private key from the app.
1. Make sure that you click the green **Install** button on the top left of the integration page. This gives you an option of installing the integration on all or a subset of your repositories.
1. Make sure that you click the green **Install** button on the top left of the app page. This gives you an option of installing the app on all or a subset of your repositories.
## Deploy the plugin
To deploy a plugin to any cloud provider, you will need 3 environment variables:
- `INTEGRATION_ID`: the ID of the integration, which you can get from the [integration settings page](https://github.com/settings/integrations).
- `WEBHOOK_SECRET`: the **Webhook Secret** that you generated when you created the integration.
- `APP_ID`: the ID of the app, which you can get from the [app settings page](https://github.com/settings/apps).
- `WEBHOOK_SECRET`: the **Webhook Secret** that you generated when you created the app.
And one of:
- `PRIVATE_KEY`: the contents of the private key you downloaded after creating the integration, OR...
- `PRIVATE_KEY`: the contents of the private key you downloaded after creating the app, OR...
- `PRIVATE_KEY_PATH`: the path to a private key file.
`PRIVATE_KEY` takes precedence over `PRIVATE_KEY_PATH`.
### Heroku
Probot runs like [any other Node app](https://devcenter.heroku.com/articles/deploying-nodejs) on Heroku. After [creating the GitHub Integration](#create-the-github-integration):
Probot runs like [any other Node app](https://devcenter.heroku.com/articles/deploying-nodejs) on Heroku. After [creating the GitHub App](#create-the-github-app):
1. Make sure you have the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli) client installed.
@ -55,11 +55,11 @@ Probot runs like [any other Node app](https://devcenter.heroku.com/articles/depl
http://arcane-lowlands-8408.herokuapp.com/ | git@heroku.com:arcane-lowlands-8408.git
Git remote heroku added
1. Go back to your [integration settings page](https://github.com/settings/integrations) and update the **Webhook URL** to the URL of your deployment, e.g. `http://arcane-lowlands-8408.herokuapp.com/`.
1. Go back to your [app settings page](https://github.com/settings/apps) and update the **Webhook URL** to the URL of your deployment, e.g. `http://arcane-lowlands-8408.herokuapp.com/`.
1. Configure the Heroku app, replacing the `INTEGRATION_ID` and `WEBHOOK_SECRET` with the values for those variables, and setting the path for the `PRIVATE_KEY`:
1. Configure the Heroku app, replacing the `APP_ID` and `WEBHOOK_SECRET` with the values for those variables, and setting the path for the `PRIVATE_KEY`:
$ heroku config:set INTEGRATION_ID=aaa \
$ heroku config:set APP_ID=aaa \
WEBHOOK_SECRET=bbb \
PRIVATE_KEY="$(cat ~/Downloads/*.private-key.pem)"
@ -76,19 +76,19 @@ Your plugin should be up and running!
### Now
Zeit [Now](http://zeit.co/now) is a great service for running Probot plugins. After [creating the GitHub Integration](#create-the-github-integration):
Zeit [Now](http://zeit.co/now) is a great service for running Probot plugins. After [creating the GitHub App](#create-the-github-app):
1. Install the now CLI with `npm i -g now`
1. Clone the plugin that you want to deploy. e.g. `git clone https://github.com/probot/stale`
1. Run `now` to deploy, replacing the `INTEGRATION_ID` and `WEBHOOK_SECRET` with the values for those variables, and setting the path for the `PRIVATE_KEY`:
1. Run `now` to deploy, replacing the `APP_ID` and `WEBHOOK_SECRET` with the values for those variables, and setting the path for the `PRIVATE_KEY`:
$ now -e INTEGRATION_ID=aaa \
$ now -e APP_ID=aaa \
-e WEBHOOK_SECRET=bbb \
-e PRIVATE_KEY="$(cat ~/Downloads/*.private-key.pem)"
1. Once the deploy is started, go back to your [integration settings page](https://github.com/settings/integrations) and update the **Webhook URL** to the URL of your deployment (which `now` has kindly copied to your clipboard).
1. Once the deploy is started, go back to your [app settings page](https://github.com/settings/apps) and update the **Webhook URL** to the URL of your deployment (which `now` has kindly copied to your clipboard).
Your plugin should be up and running!

View File

@ -1,20 +1,20 @@
# Development
To run a plugin locally, you'll need to create a GitHub Integration and configure it to deliver webhooks to your local machine.
To run a plugin locally, you'll need to create a GitHub App and configure it to deliver webhooks to your local machine.
1. Make sure you have a recent version of [Node.js](https://nodejs.org/) installed
1. [Create a new GitHub Integration](https://github.com/settings/integrations/new) with:
1. [Create a new GitHub App](https://github.com/settings/apps/new) with:
- **Webhook URL**: Set to `https://example.com/` and we'll update it in a minute.
- **Webhook Secret:** `development`
- **Permissions & events** needed will depend on how you use the bot, but for development it may be easiest to enable everything.
1. Download the private key and move it to the project directory
1. Edit `.env` and set `INTEGRATION_ID` to the ID of the integration you just created.
1. Edit `.env` and set `APP_ID` to the ID of the app you just created.
1. Run `$ npm start` to start the server, which will output `Listening on https://yourname.localtunnel.me`;
1. Update the **Webhook URL** in the [integration settings](https://github.com/settings/integrations) to use the `localtunnel.me` URL.
1. Update the **Webhook URL** in the [app settings](https://github.com/settings/apps) to use the `localtunnel.me` URL.
You'll need to create a test repository and install your Integration by clicking the "Install" button on the settings page.
You'll need to create a test repository and install your app by clicking the "Install" button on the settings page of your app.
Whenever you com back to work on the app after you've already had it running once, you should only need to run `$ npm start`.
Whenever you come back to work on the app after you've already had it running once, you should only need to run `$ npm start`.
## Debugging

View File

@ -18,20 +18,20 @@ Many robots will spend their entire day responding to these actions. `robot.on`
```js
module.exports = robot => {
robot.on('push', async (event, context) => {
robot.on('push', async context => {
// Code was pushed to the repo, what should we do with it?
robot.log(event);
robot.log(context);
});
};
```
The robot can listen to any of the [GitHub webhook events](https://developer.github.com/webhooks/#events). `event` object includes all of the information about the event that was triggered, and `event.payload` has the payload delivered by GitHub.
The robot can listen to any of the [GitHub webhook events](https://developer.github.com/webhooks/#events). The `context` object includes all of the information about the event that was triggered, and `context.payload` has the payload delivered by GitHub.
Most events also include an "action". For example, the [`issues`](https://developer.github.com/v3/activity/events/types/#issuesevent) event has actions of `assigned`, `unassigned`, `labeled`, `unlabeled`, `opened`, `edited`, `milestoned`, `demilestoned`, `closed`, and `reopened`. Often, your bot will only care about one type of action, so you can append it to the event name with a `.`:
```js
module.exports = robot => {
robot.on('issues.opened', async (event, context) => {
robot.on('issues.opened', async context => {
// An issue was just opened.
});
};
@ -39,7 +39,7 @@ module.exports = robot => {
## Interacting with GitHub
Probot uses [GitHub Integrations](https://developer.github.com/early-access/integrations/). An integration is a first-class actor on GitHub, like a user (e.g. [@defunkt](https://github/defunkt)) or a organization (e.g. [@github](https://github.com/github)). The integration is given access to a repository or repositories by being "installed" on a user or organization account and can perform actions through the API like [commenting on an issue](https://developer.github.com/v3/issues/comments/#create-a-comment) or [creating a status](https://developer.github.com/v3/repos/statuses/#create-a-status).
Probot uses [GitHub Apps](https://developer.github.com/apps/). An app is a first-class actor on GitHub, like a user (e.g. [@defunkt](https://github/defunkt)) or an organization (e.g. [@github](https://github.com/github)). The app is given access to a repository or repositories by being "installed" on a user or organization account and can perform actions through the API like [commenting on an issue](https://developer.github.com/v3/issues/comments/#create-a-comment) or [creating a status](https://developer.github.com/v3/repos/statuses/#create-a-status).
`context.github` is an authenticated GitHub client that can be used to make API calls. It is an instance of the [github Node.js module](https://github.com/mikedeboer/node-github), which wraps the [GitHub API](https://developer.github.com/v3/) and allows you to do almost anything programmatically that you can do through a web browser.
@ -47,7 +47,7 @@ Here is an example of an autoresponder plugin that comments on opened issues:
```js
module.exports = robot => {
robot.on('issues.opened', async (event, context) => {
robot.on('issues.opened', async context => {
// `context` extracts information from the event, which can be passed to
// GitHub API calls. This will return:
// {owner: 'yourname', repo: 'yourrepo', number: 123, body: 'Hello World!}
@ -59,15 +59,15 @@ module.exports = robot => {
}
```
See the [full API docs](https://mikedeboer.github.io/node-github/) to see all the ways you can interact with GitHub. Some API endpoints are not available on GitHub Integrations yet, so check [which ones are available](https://developer.github.com/early-access/integrations/available-endpoints/) first.
See the [full API docs](https://mikedeboer.github.io/node-github/) to see all the ways you can interact with GitHub. Some API endpoints are not available on GitHub Apps yet, so check [which ones are available](https://developer.github.com/v3/apps/available-endpoints/) first.
### Pagination
Many GitHub API endpoints are paginated. The `github.paginate` method can be used to get each page of the results.
```js
context.github.paginate(context.github.issues.getAll(context.repo()), issues => {
issues.forEach(issue => {
context.github.paginate(context.github.issues.getAll(context.repo()), res => {
res.data.issues.forEach(issue => {
robot.console.log('Issue: %s', issue.title);
});
});
@ -75,18 +75,18 @@ context.github.paginate(context.github.issues.getAll(context.repo()), issues =>
## Running plugins
Before you can run your plugin against GitHub, you'll need to set up your [development environment](development.md) and configure a GitHub Integration for testing. You will the the ID and private key of a GitHub Integration to run the bot.
Before you can run your plugin against GitHub, you'll need to set up your [development environment](development.md) and configure a GitHub App for testing. You will need the ID and private key of a GitHub App to run the bot.
Once you have an integration created, install `probot`:
Once you have an app created, install `probot`:
```
$ npm install -g probot
```
and run your bot, replacing `9999` and `private-key.pem` below with the ID and path to the private key of your integration.
and run your bot from your plugin's directory, replacing `APP_ID` and `private-key.pem` below with your App's ID and the path to the private key of your app.
```
$ probot run -i 9999 -P private-key.pem ./autoresponder.js
$ probot run -a APP_ID -P private-key.pem ./index.js
Listening on http://localhost:3000
```
@ -98,7 +98,7 @@ Use the [plugin-template](https://github.com/probot/plugin-template) repository
```
$ curl -L https://github.com/probot/plugin-template/archive/master.tar.gz | tar xvz
$ mv plugin-template probot-myplugin && cd probot-myplugin
$ mv plugin-template-master probot-myplugin && cd probot-myplugin
```
## Next

View File

@ -2,14 +2,14 @@ const bunyan = require('bunyan');
const bunyanFormat = require('bunyan-format');
const sentryStream = require('bunyan-sentry-stream');
const cacheManager = require('cache-manager');
const createIntegration = require('github-integration');
const createApp = require('github-app');
const createWebhook = require('github-webhook-handler');
const Raven = require('raven');
const createRobot = require('./lib/robot');
const createServer = require('./lib/server');
module.exports = options => {
module.exports = (options = {}) => {
const cache = cacheManager.caching({
store: 'memory',
ttl: 60 * 60 // 1 hour
@ -18,40 +18,47 @@ module.exports = options => {
const logger = bunyan.createLogger({
name: 'PRobot',
level: process.env.LOG_LEVEL || 'debug',
stream: bunyanFormat({outputMode: process.env.LOG_FORMAT || 'short'})
stream: bunyanFormat({outputMode: process.env.LOG_FORMAT || 'short'}),
serializers: {
repository: repository => repository.full_name
}
});
const webhook = createWebhook({path: '/', secret: options.secret});
const integration = createIntegration({
const webhook = createWebhook({path: '/', secret: options.secret || 'development'});
const app = createApp({
id: options.id,
cert: options.cert,
debug: process.env.LOG_LEVEL === 'trace'
});
const server = createServer(webhook);
const robot = createRobot({integration, webhook, cache, logger});
const robot = createRobot({app, webhook, cache, logger, catchErrors: true});
// Forward webhooks to robot
webhook.on('*', event => {
logger.trace(event, 'webhook received');
robot.receive(event);
});
// Log all webhook errors
webhook.on('error', logger.error.bind(logger));
// Log all unhandled rejections
process.on('unhandledRejection', logger.error.bind(logger));
// If sentry is configured, report all logged errors
if (process.env.SENTRY_URL) {
Raven.disableConsoleAlerts();
Raven.config(process.env.SENTRY_URL, {
captureUnhandledRejections: true,
autoBreadcrumbs: true
}).install({});
logger.addStream(sentryStream(Raven));
} else {
process.on('unhandledRejection', reason => {
robot.log.error(reason);
});
}
// Handle case when webhook creation fails
webhook.on('error', err => {
logger.error(err);
});
return {
server,
robot,
webhook,
start() {
server.listen(options.port);
@ -60,6 +67,12 @@ module.exports = options => {
load(plugin) {
plugin(robot);
},
receive(event) {
return robot.receive(event);
}
};
};
module.exports.createRobot = createRobot;

View File

@ -3,11 +3,11 @@
* passed to GitHub API calls.
*
* @property {github} github - An authenticated GitHub API client
* @property {event} event - The webhook event
* @property {payload} payload - The webhook event payload
*/
class Context {
constructor(event, github) {
this.event = event;
Object.assign(this, event);
this.github = github;
}
@ -24,7 +24,7 @@ class Context {
*
*/
repo(object) {
const repo = this.event.payload.repository;
const repo = this.payload.repository;
return Object.assign({
owner: repo.owner.login || repo.owner.name,
@ -45,7 +45,7 @@ class Context {
* @param {object} [object] - Params to be merged with the issue params.
*/
issue(object) {
const payload = this.event.payload;
const payload = this.payload;
return Object.assign({
number: (payload.issue || payload.pull_request || payload).number
}, this.repo(), object);
@ -56,7 +56,7 @@ class Context {
* @type {boolean}
*/
get isBot() {
return this.event.payload.sender.type === 'Bot';
return this.payload.sender.type === 'Bot';
}
}

View File

@ -26,7 +26,7 @@ function findPrivateKey(filepath) {
if (foundPath) {
return findPrivateKey(foundPath);
}
throw new Error(`Missing private key for GitHub Integration. Please use:
throw new Error(`Missing private key for GitHub App. Please use:
* \`--private-key=/path/to/private-key\` flag, or
* \`PRIVATE_KEY\` environment variable, or
* \`PRIVATE_KEY_PATH\` environment variable

View File

@ -1,3 +1,4 @@
const {EventEmitter} = require('promise-events');
const GitHubApi = require('github');
const Bottleneck = require('bottleneck');
const Context = require('./context');
@ -8,13 +9,18 @@ const Context = require('./context');
* @property {logger} log - A logger
*/
class Robot {
constructor({integration, webhook, cache, logger}) {
this.integration = integration;
this.webhook = webhook;
constructor({app, cache, logger, catchErrors} = {}) {
this.events = new EventEmitter();
this.app = app;
this.cache = cache;
this.log = wrapLogger(logger);
this.catchErrors = catchErrors;
}
this.webhook.on('*', event => this.log.trace(event, 'webhook received'));
async receive(event) {
return this.events.emit('*', event).then(() => {
return this.events.emit(event.event, event);
});
}
/**
@ -36,21 +42,35 @@ class Robot {
*
* @example
*
* robot.on('push', (event, context) => {
* robot.on('push', context => {
* // Code was just pushed.
* });
*
* robot.on('issues.opened', (event, context) => {
* robot.on('issues.opened', context => {
* // An issue was just opened.
* });
*/
on(event, callback) {
if (callback.length === 2) {
const caller = (new Error()).stack.split('\n')[2];
console.warn('DEPRECATED: Event callbacks now only take a single `context` argument.');
console.warn(caller);
}
const [name, action] = event.split('.');
return this.webhook.on(name, async event => {
return this.events.on(name, async event => {
if (!action || action === event.payload.action) {
const github = await this.auth(event.payload.installation.id);
return callback(event, new Context(event, github));
try {
const github = await this.auth(event.payload.installation.id);
const context = new Context(event, github);
return callback(context, context /* DEPRECATED: for backward compat */);
} catch (err) {
this.log.error(err);
if (!this.catchErrors) {
throw err;
}
}
}
});
}
@ -67,16 +87,16 @@ class Robot {
* @example
*
* module.exports = function(robot) {
* robot.on('issues.opened', async (event, context) => {
* robot.on('issues.opened', async context => {
* const github = await robot.auth();
* });
* };
*
* @param {number} [id] - ID of the installation, which can be extracted from
* `event.payload.installation.id`. If called without this parameter, the
* client wil authenticate [as the integration](https://developer.github.com/early-access/integrations/authentication/#as-an-integration)
* `context.payload.installation.id`. If called without this parameter, the
* client wil authenticate [as the app](https://developer.github.com/apps/building-integrations/setting-up-and-registering-github-apps/about-authentication-options-for-github-apps/#authenticating-as-a-github-app)
* instead of as a specific installation, which means it can only be used for
* [integration APIs](https://developer.github.com/v3/integrations/).
* [app APIs](https://developer.github.com/v3/apps/).
*
* @returns {Promise<github>} - An authenticated GitHub API client
* @private
@ -85,15 +105,15 @@ class Robot {
let github;
if (id) {
const token = await this.cache.wrap(`integration:${id}:token`, () => {
const res = await this.cache.wrap(`app:${id}:token`, () => {
this.log.trace(`creating token for installation ${id}`);
return this.integration.createToken(id);
return this.app.createToken(id);
}, {ttl: 60 * 60});
github = new GitHubApi({debug: process.env.LOG_LEVEL === 'trace'});
github.authenticate({type: 'token', token: token.token});
github.authenticate({type: 'token', token: res.data.token});
} else {
github = await this.integration.asIntegration();
github = await this.app.asApp();
}
return probotEnhancedClient(github);
@ -126,11 +146,11 @@ function rateLimitedClient(github) {
// robot.log.trace("verbose details");
//
function wrapLogger(logger) {
const fn = logger.debug.bind(logger);
const fn = logger ? logger.debug.bind(logger) : function () { };
// Add level methods on the logger
['trace', 'debug', 'info', 'warn', 'error', 'fatal'].forEach(level => {
fn[level] = logger[level].bind(logger);
fn[level] = logger ? logger[level].bind(logger) : function () { };
});
return fn;
@ -141,25 +161,24 @@ module.exports = (...args) => new Robot(...args);
/**
* Do the thing
* @callback Robot~webhookCallback
* @param {event} event - the event that was triggered, including `event.payload` which has
* @param {Context} context - helpers for extracting information from the event, which can be passed to GitHub API calls
* @param {Context} context - the context of the event that was triggered,
* including `context.payload`, and helpers for extracting information from
* the payload, which can be passed to GitHub API calls.
*
* ```js
* module.exports = robot => {
* robot.on('push', (event, context) => {
* robot.on('push', context => {
* // Code was pushed to the repo, what should we do with it?
* robot.log(event);
* robot.log(context);
* });
* };
* ```
*/
/**
* A [GitHub webhook event](https://developer.github.com/webhooks/#events)
* A [GitHub webhook event](https://developer.github.com/webhooks/#events) payload
*
* @typedef event
* @property {string} event - the name of the event that was triggered
* @property {object} payload - the payload from the webhook
* @typedef payload
*/
/**

View File

@ -7,6 +7,8 @@ module.exports = function (webhook) {
console.error(err);
res.statusCode = 500;
res.end('Something has gone terribly wrong.');
} else if (req.url.split('?').shift() === '/ping') {
res.end('PONG');
} else {
res.statusCode = 404;
res.end('no such location');

44
lib/tunnel.js Normal file
View File

@ -0,0 +1,44 @@
const https = require('https');
// eslint-disable-next-line import/no-extraneous-dependencies, import/no-unresolved
const localtunnel = require('localtunnel');
module.exports = function setupTunnel(subdomain, port, retries = 0) {
if (typeof subdomain !== 'string') {
subdomain = require('os').userInfo().username;
}
return new Promise((resolve, reject) => {
localtunnel(port, {subdomain}, (err, tunnel) => {
if (err) {
reject(err);
} else {
testTunnel(subdomain).then(() => resolve(tunnel)).catch(() => {
if (retries < 3) {
console.warn(`Failed to connect to localtunnel.me. Trying again (tries: ${retries + 1})`);
resolve(setupTunnel(subdomain, port, retries + 1));
} else {
reject(new Error('Failed to connect to localtunnel.me. Giving up.'));
}
});
}
});
});
};
// When a tunnel is closed and then immediately reopened (e.g. restarting the
// server to reload changes), then localtunnel.me may connect but not pass
// requests through. This test that the tunnel returns 200 for /ping.
function testTunnel(subdomain) {
const options = {
host: `${subdomain}.localtunnel.me`,
port: 443,
path: '/ping',
method: 'GET'
};
return new Promise((resolve, reject) => {
https.request(options, res => {
return res.statusCode === 200 ? resolve() : reject();
}).end();
});
}

2872
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "probot",
"version": "0.4.1",
"version": "0.7.4",
"description": "a trainable robot that responds to activity on GitHub",
"repository": "https://github.com/probot/probot",
"main": "index.js",
@ -9,29 +9,31 @@
},
"scripts": {
"start": "node ./bin/probot run",
"test": "mocha && xo",
"test": "mocha && xo --extend eslint-config-probot",
"doc": "jsdoc -c .jsdoc.json"
},
"author": "Brandon Keepers",
"license": "ISC",
"dependencies": {
"bottleneck": "^1.15.1",
"bottleneck": "^1.16.0",
"bunyan": "^1.8.5",
"bunyan-format": "^0.2.1",
"bunyan-sentry-stream": "^1.1.0",
"cache-manager": "^2.4.0",
"commander": "^2.9.0",
"commander": "^2.10.0",
"dotenv": "~4.0.0",
"github": "^8.1.0",
"github-integration": "^1.0.0",
"github": "^9.2.0",
"github-app": "^3.0.0",
"github-webhook-handler": "^0.6.0",
"load-plugins": "^2.1.2",
"pkg-conf": "^2.0.0",
"raven": "^1.2.0",
"promise-events": "^0.1.3",
"raven": "^2.1.0",
"resolve": "^1.3.2",
"semver": "^5.3.0"
},
"devDependencies": {
"eslint-config-probot": "^0.1.0",
"expect": "^1.20.2",
"jsdoc": "^3.4.3",
"jsdoc-strip-async-await": "^0.1.0",
@ -40,23 +42,6 @@
"xo": "^0.19.0"
},
"xo": {
"esnext": true,
"space": true,
"rules": {
"camelcase": 1,
"no-else-return": 0,
"key-spacing": 0
},
"ignores": [],
"envs": [
"node",
"mocha"
],
"globals": [
"on",
"include",
"contents"
],
"overrides": [
{
"files": "test/**/*.js",
@ -68,6 +53,6 @@
]
},
"engines": {
"node": "^7.7"
"node": ">=7.7"
}
}

View File

@ -7,6 +7,7 @@ describe('Context', function () {
beforeEach(function () {
event = {
event: 'push',
payload: {
repository: {
owner: {login: 'bkeepers'},
@ -18,6 +19,10 @@ describe('Context', function () {
context = new Context(event);
});
it('inherits the payload', () => {
expect(context.payload).toBe(event.payload);
});
describe('repo', function () {
it('returns attributes from repository payload', function () {
expect(context.repo()).toEqual({owner: 'bkeepers', repo:'probot'});

26
test/index.js Normal file
View File

@ -0,0 +1,26 @@
const expect = require('expect');
const createProbot = require('..');
describe('Probot', () => {
let probot;
let event;
beforeEach(() => {
probot = createProbot();
// Mock out GitHub App authentication
probot.robot.auth = () => Promise.resolve({});
event = {
event: 'push',
payload: require('./fixtures/webhook/push')
};
});
describe('webhook delivery', () => {
it('forwards webhooks to the robot', async () => {
probot.robot.receive = expect.createSpy();
probot.webhook.emit('*', event);
expect(probot.robot.receive).toHaveBeenCalledWith(event);
});
});
});

View File

@ -99,7 +99,7 @@ describe('private-key', function () {
it('should throw an error', function () {
expect(findPrivateKey)
.toThrow(Error, /missing private key for github integrationy/i);
.toThrow(Error, /missing private key for GitHub App/i);
});
});
});

View File

@ -2,33 +2,17 @@ const expect = require('expect');
const Context = require('../lib/context');
const createRobot = require('../lib/robot');
const nullLogger = {};
['trace', 'debug', 'info', 'warn', 'error', 'fatal'].forEach(level => {
nullLogger[level] = function () { };
});
describe('Robot', function () {
let webhook;
let robot;
let event;
let callbacks;
let spy;
beforeEach(function () {
callbacks = {};
webhook = {
on: (name, callback) => {
callbacks[name] = callback;
},
emit: (name, event) => {
return callbacks[name](event);
}
};
robot = createRobot({webhook, logger: nullLogger});
robot = createRobot();
robot.auth = () => {};
event = {
event: 'test',
payload: {
action: 'foo',
installation: {id: 1}
@ -38,29 +22,113 @@ describe('Robot', function () {
spy = expect.createSpy();
});
describe('constructor', () => {
it('takes a logger', () => {
const logger = {
trace: expect.createSpy(),
debug: expect.createSpy(),
info: expect.createSpy(),
warn: expect.createSpy(),
error: expect.createSpy(),
fatal: expect.createSpy()
};
robot = createRobot({logger});
robot.log('hello world');
expect(logger.debug).toHaveBeenCalledWith('hello world');
});
});
describe('on', function () {
it('calls callback when no action is specified', async function () {
robot.on('test', spy);
expect(spy).toNotHaveBeenCalled();
await webhook.emit('test', event);
await robot.receive(event);
expect(spy).toHaveBeenCalled();
expect(spy.calls[0].arguments[0]).toBe(event);
expect(spy.calls[0].arguments[1]).toBeA(Context);
expect(spy.calls[0].arguments[0]).toBeA(Context);
expect(spy.calls[0].arguments[0].payload).toBe(event.payload);
});
it('calls callback with same action', async function () {
robot.on('test.foo', spy);
await webhook.emit('test', event);
await robot.receive(event);
expect(spy).toHaveBeenCalled();
});
it('does not call callback with different action', async function () {
robot.on('test.nope', spy);
await webhook.emit('test', event);
await robot.receive(event);
expect(spy).toNotHaveBeenCalled();
});
it('calls callback with *', async function () {
robot.on('*', spy);
await robot.receive(event);
expect(spy).toHaveBeenCalled();
});
});
describe('receive', () => {
it('delivers the event', async () => {
const spy = expect.createSpy();
robot.on('test', spy);
await robot.receive(event);
expect(spy).toHaveBeenCalled();
});
it('waits for async events to resolve', async () => {
const spy = expect.createSpy();
robot.on('test', () => {
return new Promise(resolve => {
setTimeout(() => {
spy();
resolve();
}, 1);
});
});
await robot.receive(event);
expect(spy).toHaveBeenCalled();
});
it('returns a reject errors thrown in plugins', async () => {
robot.on('test', () => {
throw new Error('error from plugin');
});
try {
await robot.receive(event);
throw new Error('expected error to be raised from plugin');
} catch (err) {
expect(err.message).toEqual('error from plugin');
}
});
});
describe('error handling', () => {
it('logs errors throw from handlers', async () => {
const error = new Error('testing');
robot.log.error = expect.createSpy();
robot.on('test', () => {
throw error;
});
try {
await robot.receive(event);
} catch (err) {
// Expected
}
expect(robot.log.error).toHaveBeenCalledWith(error);
});
});
});