CHANGELOG: https://github.com/probot/probot/releases/tag/v10.0.0
This commit is contained in:
Gregor Martynus 2020-08-18 00:47:53 -07:00
parent e3f6cf583b
commit 072b8c26b5
112 changed files with 9552 additions and 10729 deletions

View File

@ -9,21 +9,23 @@ about: If something isn't working as expected 🤔.
A clear and concise description of the behavior.
```js
// Your code here
module.exports = robot => {
robot.log('There is a bug')
}
// Your code here
module.exports = (app) => {
app.log.info("There is a bug");
};
```
**Expected behavior/code**
A clear and concise description of what you expected to happen (or code).
**Environment**
- Probot version(s): [e.g. v6.0.0]
- Node/npm version: [e.g. Node 8/npm 5]
- OS: [e.g. OSX 10.13.4, Windows 10]
**Possible Solution**
<!--- Only if you have suggestions on a fix for the bug -->
**Additional context/Screenshots**

View File

@ -1,37 +0,0 @@
name: CI
on:
push:
branches:
- master
- "greenkeeper/**"
pull_request:
types:
- opened
- synchronize
jobs:
build:
strategy:
matrix:
node-version: [10.x, 12.x]
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run build
- run: npm test
- name: codecov
run: npx codecov
# run codecov only once
if: matrix.os == 'ubuntu-latest' && matrix.node-version == '12.x'
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}

View File

@ -13,7 +13,7 @@ jobs:
- run: git fetch --depth=20 origin +refs/tags/*:refs/tags/*
- uses: actions/setup-node@v1
with:
node-version: "12.x"
node-version: 12
- run: npm ci
- run: npm run build
- run: ./script/publish-docs

48
.github/workflows/test.yml vendored Normal file
View File

@ -0,0 +1,48 @@
name: Test
on:
push:
branches:
- master
pull_request:
types:
- opened
- synchronize
jobs:
test_matrix:
strategy:
matrix:
node-version:
- 10
- 12
- 14
os: [ubuntu-latest, macos-latest, windows-latest]
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v1
with:
node-version: ${{ matrix.node-version }}
- run: npm ci
- run: npm run build
- run: npx jest --forceExit
# separate job to set as required in branch protection,
# as the above change each time the Node versions change
test:
runs-on: ubuntu-latest
needs: test_matrix
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v1
- run: npm ci
- run: npm run lint
- run: npm run build
- run: npx jest --coverage --force-exit
- name: codecov
run: npx codecov

View File

@ -8,19 +8,19 @@ In the interest of fostering an open and welcoming environment, we as contributo
Examples of behavior that contributes to creating a positive environment include:
* Using welcoming and inclusive language
* Being respectful of differing viewpoints and experiences
* Gracefully accepting constructive criticism
* Focusing on what is best for the community
* Showing empathy towards other community members
- Using welcoming and inclusive language
- Being respectful of differing viewpoints and experiences
- Gracefully accepting constructive criticism
- Focusing on what is best for the community
- Showing empathy towards other community members
Examples of unacceptable behavior by participants include:
* The use of sexualized language or imagery and unwelcome sexual attention or advances
* Trolling, insulting/derogatory comments, and personal or political attacks
* Public or private harassment
* Publishing others' private information, such as a physical or electronic address, without explicit permission
* Other conduct which could reasonably be considered inappropriate in a professional setting
- The use of sexualized language or imagery and unwelcome sexual attention or advances
- Trolling, insulting/derogatory comments, and personal or political attacks
- Public or private harassment
- Publishing others' private information, such as a physical or electronic address, without explicit permission
- Other conduct which could reasonably be considered inappropriate in a professional setting
## Our Responsibilities

View File

@ -4,17 +4,11 @@ Hi there! We're thrilled that you'd like to contribute to this project. Your hel
Please note that this project is released with a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms.
# 🏗 Probot v8 beta
Heads up: we are currently working on version 8. If you submit a pull request, please select the `beta` branch as your base.
If you need a fix in v7, please submit the fix to the `beta` branch first (if applicable). Then create another pull request against the `v7.x` branch.
## Submitting a pull request
1. [Fork][fork] and clone the repository
1. Configure and install the dependencies: `npm install`
1. Make sure the tests pass on your machine: `npm test`, note: these tests also apply the [linter][linter] and run the TypeScript compiler (`tsc`) to check for type errors, so there's no need to run these commands separately.
1. Make sure the tests pass on your machine: `npm test`, note: these tests also run the TypeScript compiler (`tsc`) to check for type errors, so there's no need to run these commands separately.
1. Create a new branch: `git checkout -b my-branch-name`
1. Make your change, add tests, and make sure the tests still pass
1. Push to your fork and [submit a pull request][pr]
@ -22,7 +16,7 @@ If you need a fix in v7, please submit the fix to the `beta` branch first (if ap
Here are a few things you can do that will increase the likelihood of your pull request being accepted:
- Follow the [style guide][style] which is using standard. Any linting errors should be shown when running `npm test`
- Follow the [style guide][style] which is using Prettier. Any linting errors should be shown when running `npm test`
- Write and update tests.
- Keep your change as focused as possible. If there are multiple changes you would like to make that are not dependent upon each other, consider submitting them as separate pull requests.
- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
@ -38,7 +32,7 @@ The following three commit message conventions determine which version is releas
1. `fix: ...` or `fix(scope name): ...` prefix in subject: bumps fix version, e.g. `1.2.3``1.2.4`
2. `feat: ...` or `feat(scope name): ...` prefix in subject: bumps feature version, e.g. `1.2.3``1.3.0`
3. `BREAKING CHANGE: ` in body: bumps breaking version, e.g. `1.2.3``2.0.0`
3. `BREAKING CHANGE:` in body: bumps breaking version, e.g. `1.2.3``2.0.0`
Only one version number is bumped at a time, the highest version change trumps the others. Besides publishing a new version to npm, semantic-release also creates a git tag and release on GitHub, generates changelogs from the commit messages and puts them into the release notes.
@ -63,7 +57,7 @@ Use [this search][good-first-issue-search] to find Probot apps that have issues
[fork]: https://github.com/probot/probot/fork
[pr]: https://github.com/probot/probot/compare
[style]: https://standardjs.com/
[style]: https://prettier.io/
[code-of-conduct]: CODE_OF_CONDUCT.md
[good-first-issue-search]: https://github.com/search?utf8=%E2%9C%93&q=topic%3Aprobot+topic%3Aprobot-app+good-first-issues%3A%3E0&type=
[linter]: https://github.com/probot/probot/blob/ts-readme/tslint.json

View File

@ -1,6 +1,6 @@
ISC License
Copyright (c) 2016-2019 Brandon Keepers
Copyright (c) 2016-2020 Brandon Keepers and Probot Contributors
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above

View File

@ -11,15 +11,17 @@ If you've ever thought, "wouldn't it be cool if GitHub could…"; I'm going to s
## How it works
**Probot is a framework for building [GitHub Apps](https://docs.github.com/en/developers/apps) in [Node.js](https://nodejs.org/)**, written in [TypeScript](https://www.typescriptlang.org/). GitHub Apps can listen to webhook events sent by a repository or organization. Probot uses its internal event emitter to perform actions based on those events. A simple Probot App might look like this:
**Probot is a framework for building [GitHub Apps](https://docs.github.com/en/developers/apps) in [Node.js](https://nodejs.org/)**, written in [TypeScript](https://www.typescriptlang.org/). GitHub Apps can listen to webhook events sent by a repository or organization. Probot uses its internal event emitter to perform actions based on those events. A simple Probot App might look like this:
```js
module.exports = (app) => {
app.on('issues.opened', async context => {
const issueComment = context.issue({ body: 'Thanks for opening this issue!' })
return context.github.issues.createComment(issueComment)
})
}
app.on("issues.opened", async (context) => {
const issueComment = context.issue({
body: "Thanks for opening this issue!",
});
return context.github.issues.createComment(issueComment);
});
};
```
## Building a Probot App

View File

@ -8,15 +8,17 @@ Se você já pensou, "não seria legal se o GitHub pudesse..."; Eu vou parar voc
## Como funciona
**Probot é um framework para construir [GitHub Apps](http://developer.github.com/apps) em [Node.js](https://nodejs.org/)**, escrito em [TypeScript]( https://www.typescriptlang.org/). O GitHub Apps pode ouvir eventos de webhook enviados por um repositório ou organização. O Probot usa seu emissor de evento interno para executar ações com base nesses eventos. Um aplicativo Probot simples pode ter esta aparência:
**Probot é um framework para construir [GitHub Apps](http://developer.github.com/apps) em [Node.js](https://nodejs.org/)**, escrito em [TypeScript](https://www.typescriptlang.org/). O GitHub Apps pode ouvir eventos de webhook enviados por um repositório ou organização. O Probot usa seu emissor de evento interno para executar ações com base nesses eventos. Um aplicativo Probot simples pode ter esta aparência:
```js
module.exports = (app) => {
app.on('issues.opened', async context => {
const issueComment = context.issue({ body: 'Obrigado por abrir esta issue!' })
return context.github.issues.createComment(issueComment)
})
}
app.on("issues.opened", async (context) => {
const issueComment = context.issue({
body: "Obrigado por abrir esta issue!",
});
return context.github.issues.createComment(issueComment);
});
};
```
## Criando um app Probot

View File

@ -1,23 +0,0 @@
{
"name": "Probot",
"description": "a trainable robot that responds to activity on GitHub",
"keywords": ["Probot", "node", "github"],
"repository": "https://github.com/probot/probot",
"logo": "https://github.com/probot.png",
"env": {
"PRIVATE_KEY": {
"description": "the private key you downloaded when creating the GitHub App"
},
"APP_ID": {
"description": "the ID of your GitHub App"
},
"WEBHOOK_SECRET": {
"description": "the secret configured for your GitHub App"
},
"LOG_LEVEL": {
"required": false,
"value": "info",
"description": "trace, debug, info, warn, error, or fatal; default: info"
}
}
}

View File

@ -1,21 +0,0 @@
# Test against the latest version of this Node.js version
environment:
nodejs_version: "8"
# Install scripts. (runs after repo cloning)
install:
# Get the latest stable version of Node.js or io.js
- ps: Install-Product node $env:nodejs_version
# install modules
- npm install
# Post-install test scripts.
test_script:
# Output useful info for debugging.
- node --version
- npm --version
# run tests
- npm test
# Don't actually build.
build: off

View File

@ -1,48 +1,70 @@
#!/usr/bin/env node
// Usage: probot receive -e push -p path/to/payload app.js
require('dotenv').config()
process.env.DISABLE_STATS = 'true'
require("dotenv").config();
const path = require('path')
const uuid = require('uuid')
const program = require('commander')
const path = require("path");
const uuid = require("uuid");
const program = require("commander");
const { findPrivateKey } = require('../lib/private-key')
const { createProbot } = require('../')
const { findPrivateKey } = require("../lib/private-key");
const {
handleDeprecatedEnvironmentVariables,
} = require("../lib/handle-deprecated-environment-variables");
const { Probot } = require("../");
handleDeprecatedEnvironmentVariables();
program
.usage('[options] [path/to/app.js...]')
.option('-e, --event <event-name>', 'Event name', process.env.GITHUB_EVENT_NAME)
.option('-p, --payload-path <payload-path>', 'Path to the event payload', process.env.GITHUB_EVENT_PATH)
.option('-t, --token <access-token>', 'Access token', process.env.GITHUB_TOKEN)
.option('-a, --app <id>', 'ID of the GitHub App', process.env.APP_ID)
.option('-P, --private-key <file>', 'Path to certificate of the GitHub App', findPrivateKey)
.parse(process.argv)
.usage("[options] [path/to/app.js...]")
.option(
"-e, --event <event-name>",
"Event name",
process.env.GITHUB_EVENT_NAME
)
.option(
"-p, --payload-path <payload-path>",
"Path to the event payload",
process.env.GITHUB_EVENT_PATH
)
.option(
"-t, --token <access-token>",
"Access token",
process.env.GITHUB_TOKEN
)
.option("-a, --app <id>", "ID of the GitHub App", process.env.APP_ID)
.option(
"-P, --private-key <file>",
"Path to certificate of the GitHub App",
findPrivateKey
)
.parse(process.argv);
const githubToken = program.token
const githubToken = program.token;
if (!program.event || !program.payloadPath) {
program.help()
program.help();
}
const cert = findPrivateKey()
if (!githubToken && (!program.app || !cert)) {
console.warn('No token specified and no certificate found, which means you will not be able to do authenticated requests to GitHub')
const privateKey = findPrivateKey();
if (!githubToken && (!program.app || !privateKey)) {
console.warn(
"No token specified and no certificate found, which means you will not be able to do authenticated requests to GitHub"
);
}
const payload = require(path.resolve(program.payloadPath))
const payload = require(path.resolve(program.payloadPath));
const probot = createProbot({
const probot = new Probot({
id: program.app,
cert,
githubToken: githubToken
})
privateKey,
githubToken: githubToken,
});
probot.setup(program.args)
probot.setup(program.args);
probot.logger.debug('Receiving event', program.event)
probot.logger.debug("Receiving event", program.event);
probot.receive({ name: program.event, payload, id: uuid.v4() }).catch(() => {
// Process must exist non-zero to indicate that the action failed to run
process.exit(1)
})
process.exit(1);
});

View File

@ -6,34 +6,32 @@ next: docs/deployment.md
When developing a Probot App, you will need to have several different fields in a `.env` file which specify environment variables. Here are some common use cases:
Variable | Description
---|---
`APP_ID` | The App ID assigned to your GitHub App. **Required** <p>_(Example: `1234`)_</p>
**Private key options** | One of the following is **Required** if there is no `.pem` file in your project's root directory
`PRIVATE_KEY_PATH` | The path to the `.pem` file for your GitHub App. <p>_(Example: `path/to/key.pem`)_</p>
`PRIVATE_KEY` | The contents of the private key for your GitHub App. If you're unable to use multiline environment variables, use base64 encoding to convert the key to a single line string. See the [Deployment](deployment.md) docs for provider specific usage. |
**Webhook options** |
`WEBHOOK_PROXY_URL` | Allows your local development environment to receive GitHub webhook events. Go to https://smee.io/new to get started. <p>_(Example: `https://smee.io/your-custom-url`)_</p>
`WEBHOOK_SECRET` | The webhook secret used when creating a GitHub App. 'development' is used as a default, but the value in `.env` needs to match the value configured in your App settings on GitHub. Note: GitHub marks this value as optional, but for optimal security it's required for Probot apps. **Required** <p>_(Example: `development`)_</p>
| Variable | Description |
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `APP_ID` | The App ID assigned to your GitHub App. **Required** <p>_(Example: `1234`)_</p> |
| **Private key options** | One of the following is **Required** if there is no `.pem` file in your project's root directory |
| `PRIVATE_KEY_PATH` | The path to the `.pem` file for your GitHub App. <p>_(Example: `path/to/key.pem`)_</p> |
| `PRIVATE_KEY` | The contents of the private key for your GitHub App. If you're unable to use multiline environment variables, use base64 encoding to convert the key to a single line string. See the [Deployment](deployment.md) docs for provider specific usage. |
| **Webhook options** |
| `WEBHOOK_PROXY_URL` | Allows your local development environment to receive GitHub webhook events. Go to https://smee.io/new to get started. <p>_(Example: `https://smee.io/your-custom-url`)_</p> |
| `WEBHOOK_SECRET` | The webhook secret used when creating a GitHub App. 'development' is used as a default, but the value in `.env` needs to match the value configured in your App settings on GitHub. Note: GitHub marks this value as optional, but for optimal security it's required for Probot apps. **Required** <p>_(Example: `development`)_</p> |
For more on the set up of these items, check out [Configuring a GitHub App](./development.md#configuring-a-github-app).
Some less common environment variables are:
Variable | Description
---|---
`DISABLE_STATS` | Set to `true` to disable the the `/probot/stats` endpoint, which gathers data about each app. Recommend for apps with a lot of installations. <p>_(Example: `true`)_</p>
`DISABLE_WEBHOOK_EVENT_CHECK` | Set to `true` to disable Probot's webhook-event-check feature. While the feature is enabled, Probot will warn you when your application is attempting to listen to an event that your GitHub App is not subscribed to. <br> Note: webhook-event-check is automatically disabled when `NODE_ENV` is set to `production`. <p>_(Default: `false`)_</p>
`GHE_HOST` | The hostname of your GitHub Enterprise instance. <p>_(Example: `github.mycompany.com`)_</p>
`GHE_PROTOCOL` | The protocol of your GitHub Enterprise instance. Defaults to HTTPS. Do not change unless you are certain. <p>_(Example: `https`)_</p>
`IGNORED_ACCOUNTS` | A comma-separated list of GitHub account names to ignore. This is currently used by the `/probot/stats`. By marking an account as ignored, that account will not be included in data collected on the website. The primary use case for this is spammy or abusive users that the GitHub API sends us but who 404. <p>_(Example: `spammyPerson,abusiveAccount`)_</p>
`LOG_FORMAT` | By default, logs are formatted for readability in development. You can set this to `short`, `long`, `simple`, `json`, `bunyan`. Default: `short`
`LOG_LEVEL` | The verbosity of logs to show when running your app, which can be `trace`, `debug`, `info`, `warn`, `error`, or `fatal`. Default: `info`
`LOG_LEVEL_IN_STRING` | By default, when using the `json` format, the level printed in the log records is an int (`10`, `20`, ..). This option tells the logger to print level as a string: `{"level": "info"}`. Default `false`
`PORT` | The port to start the local server on. Default: `3000`
`SENTRY_DSN` | Set to a [Sentry](https://sentry.io/) DSN to report all errors thrown by your app. <p>_(Example: `https://1234abcd@sentry.io/12345`)_</p>
`WEBHOOK_PATH` | The URL path which will receive webhooks. Default: `/`
`INSTALLATION_TOKEN_TTL` | The length of time installation access tokens are cached by Probot. This may be useful if your app is running long processes before accessing `context.github`. Default: `3540` (59 minutes) <p>_(Example: `3300` or 55 minutes)_</p>
`REDIS_URL` | Set to a `redis://` url as connection option for [ioredis](https://github.com/luin/ioredis#connect-to-redis) in order to enable [cluster support for request throttling](https://github.com/octokit/plugin-throttling.js#clustering). <p>_(Example: `redis://:secret@redis-123.redislabs.com:12345/0`)_</p>
| Variable | Description |
| ----------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `DISABLE_WEBHOOK_EVENT_CHECK` | Set to `true` to disable Probot's webhook-event-check feature. While the feature is enabled, Probot will warn you when your application is attempting to listen to an event that your GitHub App is not subscribed to. <br> Note: webhook-event-check is automatically disabled when `NODE_ENV` is set to `production`. <p>_(Default: `false`)_</p> |
| `GHE_HOST` | The hostname of your GitHub Enterprise instance. <p>_(Example: `github.mycompany.com`)_</p> |
| `GHE_PROTOCOL` | The protocol of your GitHub Enterprise instance. Defaults to HTTPS. Do not change unless you are certain. <p>_(Example: `https`)_</p> |
| `LOG_FORMAT` | By default, logs are formatted for readability in development. You can set this to `json` in order to disable the formatting |
| `LOG_LEVEL` | The verbosity of logs to show when running your app, which can be `fatal`, `error`, `warn`, `info`, `debug`, `trace` or `silent`. Default: `info` |
| `LOG_LEVEL_IN_STRING` | By default, when using the `json` format, the level printed in the log records is an int (`10`, `20`, ..). This option tells the logger to print level as a string: `{"level": "info"}`. Default `false` |
| `SENTRY_DSN` | Set to a [Sentry](https://sentry.io/) DSN to report all errors thrown by your app. <p>_(Example: `https://1234abcd@sentry.io/12345`)_</p> |
| `PORT` | The port to start the local server on. Default: `3000` |
| `WEBHOOK_PATH` | The URL path which will receive webhooks. Default: `/` |
| `INSTALLATION_TOKEN_TTL` | The length of time installation access tokens are cached by Probot. This may be useful if your app is running long processes before accessing `context.github`. Default: `3540` (59 minutes) <p>_(Example: `3300` or 55 minutes)_</p> |
| `REDIS_URL` | Set to a `redis://` url as connection option for [ioredis](https://github.com/luin/ioredis#connect-to-redis) in order to enable [cluster support for request throttling](https://github.com/octokit/plugin-throttling.js#clustering). <p>_(Example: `redis://:secret@redis-123.redislabs.com:12345/0`)_</p> |
For more information on the formatting conventions and rules of `.env` files, check out [the npm `dotenv` module's documentation](https://www.npmjs.com/package/dotenv#rules).

View File

@ -12,8 +12,8 @@ Every app can either be deployed stand-alone, or combined with other apps in one
1. [Create the GitHub App](#create-the-github-app)
1. [Deploy the app](#deploy-the-app)
1. [Glitch](#glitch)
1. [Heroku](#heroku)
1. [Glitch](#glitch)
1. [Heroku](#heroku)
1. [Share the app](#share-the-app)
1. [Combining apps](#combining-apps)
1. [Error tracking](#error-tracking)
@ -24,9 +24,10 @@ Every app can either be deployed stand-alone, or combined with other apps in one
Every deployment will need an [App](https://developer.github.com/apps/).
1. [Create a new GitHub App](https://github.com/settings/apps/new) with:
- **Homepage URL**: the URL to the GitHub repository for your app
- **Webhook URL**: Use `https://example.com/` for now, we'll come back in a minute to update this with the URL of your deployed app.
- **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 app.
- **Homepage URL**: the URL to the GitHub repository for your app
- **Webhook URL**: Use `https://example.com/` for now, we'll come back in a minute to update this with the URL of your deployed app.
- **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 app.
1. Download the private key from the app.
@ -71,26 +72,26 @@ Enjoy!
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.
1. Make sure you have the [Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli) client installed.
1. Clone the app that you want to deploy. e.g. `git clone https://github.com/probot/stale`
1. Clone the app that you want to deploy. e.g. `git clone https://github.com/probot/stale`
1. Create the Heroku app with the `heroku create` command:
1. Create the Heroku app with the `heroku create` command:
$ heroku create
Creating arcane-lowlands-8408... done, stack is cedar
http://arcane-lowlands-8408.herokuapp.com/ | git@heroku.com:arcane-lowlands-8408.git
Git remote heroku added
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. 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 `APP_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 APP_ID=aaa \
WEBHOOK_SECRET=bbb \
PRIVATE_KEY="$(cat ~/Downloads/*.private-key.pem)"
1. Deploy the app to heroku with `git push`:
1. Deploy the app to heroku with `git push`:
$ git push heroku master
...
@ -99,16 +100,15 @@ Probot runs like [any other Node app](https://devcenter.heroku.com/articles/depl
-----> Launching... done
http://arcane-lowlands-8408.herokuapp.com deployed to Heroku
1. Your app should be up and running! To verify that your app
is receiving webhook data, you can tail your app's logs:
1. Your app should be up and running! To verify that your app
is receiving webhook data, you can tail your app's logs:
$ heroku config:set LOG_LEVEL=trace
$ heroku logs --tail
$ heroku config:set LOG_LEVEL=trace
$ heroku logs --tail
## Share the app
The Probot website includes a list of [featured apps](https://probot.github.io/apps). Consider [adding your app to the website](https://github.com/probot/probot.github.io/blob/master/CONTRIBUTING.md#adding-your-app
) so others can discover and use it.
The Probot website includes a list of [featured apps](https://probot.github.io/apps). Consider [adding your app to the website](https://github.com/probot/probot.github.io/blob/master/CONTRIBUTING.md#adding-your-app) so others can discover and use it.
## Combining apps
@ -124,23 +124,17 @@ To deploy multiple apps in one instance, create a new app that has the existing
},
"scripts": {
"start": "probot run"
},
"probot": {
"apps": [
"probot-autoresponder",
"probot-settings"
]
}
},
"probot": {
"apps": ["probot-autoresponder", "probot-settings"]
}
}
```
## Error tracking
Probot comes bundled with a client for the [Sentry](https://github.com/getsentry/sentry) exception tracking platform. To enable Sentry:
1. [Install Sentry from Marketplace](https://github.com/marketplace/sentry) (with [10k events/month free](https://github.com/marketplace/sentry/plan/MDIyOk1hcmtldHBsYWNlTGlzdGluZ1BsYW40Nw==#pricing-and-setup)) or [host your own instance](https://github.com/getsentry/sentry) (Students can get [extra Sentry credit](https://education.github.com/pack))
2. Follow the setup instructions to find your DSN.
3. Set the `SENTRY_DSN` environment variable with the DSN you retrieved.
Probot logs messages using [pino](https://getpino.io/). There is a growing number of tools that consume these logs and send them to error tracking services: https://getpino.io/#/docs/transports.
## Serverless
Serverless abstracts away the most menial parts of building an application, leaving developers to write code and not actively manage scaling for their applications. The [Serverless Deployment](./serverless-deployment.md) section will show you how to deploy you application using functions instead of servers.

View File

@ -62,7 +62,6 @@ The most important files created are `index.js`, which is where the code for you
Now you're ready to run the app on your local machine. Run `npm run dev` to start the server:
> Note: If you're building a TypeScript app, be sure to run `npm run build` first!
```
@ -111,13 +110,13 @@ To run your app in development, you will need to configure a GitHub App to deliv
1. On your local machine, copy `.env.example` to `.env` in the same directory. We're going to be changing a few things in this new file.
1. Go to [smee.io](https://smee.io) and click **Start a new channel**. Set `WEBHOOK_PROXY_URL` to the URL that you are redirected to.<br/>
E.g. `https://smee.io/AbCd1234EfGh5678`
E.g. `https://smee.io/AbCd1234EfGh5678`
1. [Create a new GitHub App](https://github.com/settings/apps/new) with:
- **Webhook URL**: Use the same `WEBHOOK_PROXY_URL` from the previous step.
- **Webhook Secret:** `development`, or whatever you set for this in your `.env` file. (Note: For optimal security, Probot apps **require** this secret be set, even though it's optional on GitHub.).
- **Permissions & events** is located lower down the page and will depend on what data you want your app to have access to. Note: if, for example, you only enable issue events, you will not be able to listen on pull request webhooks with your app. However, for development, we recommend enabling everything.
- **Webhook URL**: Use the same `WEBHOOK_PROXY_URL` from the previous step.
- **Webhook Secret:** `development`, or whatever you set for this in your `.env` file. (Note: For optimal security, Probot apps **require** this secret be set, even though it's optional on GitHub.).
- **Permissions & events** is located lower down the page and will depend on what data you want your app to have access to. Note: if, for example, you only enable issue events, you will not be able to listen on pull request webhooks with your app. However, for development, we recommend enabling everything.
1. You must now set `APP_ID` in your `.env` to the ID of the app you just created. The App ID can be found in your app settings page here <img width="1048" alt="screen shot 2017-08-20 at 8 31 31 am" src="https://user-images.githubusercontent.com/5713670/42248717-f6bf4f10-7edb-11e8-8dd5-387181c771bc.png">
1. Finally, generate and download a private key file (using the button seen in the image above), then move it to your project's directory. As long as it's in the root of your project, Probot will find it automatically regardless of the filename.
1. Finally, generate and download a private key file (using the button seen in the image above), then move it to your project's directory. As long as it's in the root of your project, Probot will find it automatically regardless of the filename.
For more information about these and other available keys, head over to the [environmental configuration documentation](https://probot.github.io/docs/configuration/).
@ -126,8 +125,9 @@ For more information about these and other available keys, head over to the [env
You'll need to create a test repository and install your app by clicking the "Install" button on the settings page of your app, e.g. `https://github.com/apps/your-app`
**Other available scripts**
* `$ npm start` to start your app without watching files.
* `$ npm run lint` to lint your code using [standard](https://www.npmjs.com/package/standard).
- `$ npm start` to start your app without watching files.
- `$ npm run lint` to lint your code using [Prettier](https://prettier.io/).
## Debugging
@ -140,11 +140,11 @@ If you take a look to the `npm start` script, this is what it runs: `probot run
```js
// main.js
const { Probot } = require('probot')
const app = require('./index.js')
const { Probot } = require("probot");
const app = require("./index.js");
// pass a probot app as a function
Probot.run(app)
Probot.run(app);
```
Now you can run `main.js` however you want.

View File

@ -13,15 +13,15 @@ While Probot doesn't have an official extension API (yet), there are a handful o
For example, users could add labels from comments by typing `/label in-progress`.
```js
const commands = require('probot-commands')
const commands = require("probot-commands");
module.exports = app => {
module.exports = (app) => {
// Type `/label foo, bar` in a comment box for an Issue or Pull Request
commands(app, 'label', (context, command) => {
const labels = command.arguments.split(/, */)
return context.github.issues.addLabels(context.issue({ labels }))
})
}
commands(app, "label", (context, command) => {
const labels = command.arguments.split(/, */);
return context.github.issues.addLabels(context.issue({ labels }));
});
};
```
## Metadata
@ -31,21 +31,23 @@ module.exports = app => {
For example, here is a contrived app that stores the number of times that comments were edited in a discussion and comments with the edit count when the issue is closed.
```js
const metadata = require('probot-metadata')
const metadata = require("probot-metadata");
module.exports = app => {
app.on(['issues.edited', 'issue_comment.edited'], async context => {
const kv = await metadata(context)
await kv.set('edits', await kv.get('edits') || 1)
})
module.exports = (app) => {
app.on(["issues.edited", "issue_comment.edited"], async (context) => {
const kv = await metadata(context);
await kv.set("edits", (await kv.get("edits")) || 1);
});
app.on('issues.closed', async context => {
const edits = await metadata(context).get('edits')
context.github.issues.createComment(context.issue({
body: `There were ${edits} edits to issues in this thread.`
}))
})
}
app.on("issues.closed", async (context) => {
const edits = await metadata(context).get("edits");
context.github.issues.createComment(
context.issue({
body: `There were ${edits} edits to issues in this thread.`,
})
);
});
};
```
## Scheduler
@ -53,15 +55,15 @@ module.exports = app => {
[probot-scheduler](https://github.com/probot/scheduler) is an extension to trigger events on a periodic schedule. It triggers a `schedule.repository` event every hour for each repository it has access to.
```js
const createScheduler = require('probot-scheduler')
const createScheduler = require("probot-scheduler");
module.exports = app => {
createScheduler(app)
module.exports = (app) => {
createScheduler(app);
app.on('schedule.repository', context => {
app.on("schedule.repository", (context) => {
// this event is triggered on an interval, which is 1 hr by default
})
}
});
};
```
Check out [stale](https://github.com/probot/stale) to see it in action.
@ -71,16 +73,16 @@ Check out [stale](https://github.com/probot/stale) to see it in action.
[probot-attachments](https://github.com/probot/attachments) adds message attachments to comments on GitHub. This extension should be used any time an app is appending content to user comments.
```js
const attachments = require('probot-attachments')
const attachments = require("probot-attachments");
module.exports = app => {
app.on('issue_comment.created', context => {
module.exports = (app) => {
app.on("issue_comment.created", (context) => {
return attachments(context).add({
title: 'Hello World',
title_link: 'https://example.com/hello'
})
})
}
title: "Hello World",
title_link: "https://example.com/hello",
});
});
};
```
Check out [probot/unfurl](https://github.com/probot/unfurl) to see it in action.

View File

@ -15,17 +15,17 @@ Your app has access to an authenticated GitHub client that can be used to make A
Here is an example of an autoresponder app that comments on opened issues:
```js
module.exports = app => {
app.on('issues.opened', async context => {
module.exports = (app) => {
app.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! }
const params = context.issue({ body: 'Hello World!' })
const params = context.issue({ body: "Hello World!" });
// Post a comment on the issue
return context.github.issues.createComment(params)
})
}
return context.github.issues.createComment(params);
});
};
```
See the [full API docs](https://octokit.github.io/rest.js/) 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.
@ -44,17 +44,17 @@ const addComment = `
clientMutationId
}
}
`
`;
module.exports = app => {
app.on('issues.opened', async context => {
module.exports = (app) => {
app.on("issues.opened", async (context) => {
// Post a comment on the issue
context.github.graphql(addComment, {
id: context.payload.issue.node_id,
body: 'Hello World'
})
})
}
body: "Hello World",
});
});
};
```
The options in the 2nd argument will be passed as variables to the query. You can pass custom headers by using the `headers` key:
@ -67,18 +67,18 @@ const pinIssue = `
clientMutationId
}
}
`
`;
module.exports = app => {
app.on('issues.opened', async context => {
module.exports = (app) => {
app.on("issues.opened", async (context) => {
context.github.graphql(pinIssue, {
id: context.payload.issue.node_id,
headers: {
accept: 'application/vnd.github.elektra-preview+json'
}
})
})
}
accept: "application/vnd.github.elektra-preview+json",
},
});
});
};
```
Check out the [GitHub GraphQL API docs](https://developer.github.com/v4/) to learn more.
@ -101,24 +101,20 @@ If you want to run a Probot App against a GitHub Enterprise instance, you'll nee
GHE_HOST=fake.github-enterprise.com
```
> GitHub Apps are enabled in GitHub Enterprise 2.12 as an [early access technical preview](https://developer.github.com/enterprise/2.12/apps/) but are generally available in GitHub Enterprise 2.13 and above.
## Using Probot's customized Octokit class directly
## Using Probot's GitHub API class directly
Sometimes you may need to create your own instance of Probot's GitHub API class, for example when using the
[OAuth user authorization flow](https://developer.github.com/apps/building-github-apps/identifying-and-authorizing-users-for-github-apps/). You may access the class by importing `GitHubAPI`:
Sometimes you may need to create your own instance of Probot's internally used Octokit class, for example when using the
[OAuth user authorization flow](https://developer.github.com/apps/building-github-apps/identifying-and-authorizing-users-for-github-apps/). You may access the class by importing `ProbotOctokit`:
```js
const { GitHubAPI } = require('probot')
const { ProbotOctokit } = require("probot");
function myProbotApp (app) {
const github = GitHubAPI({
function myProbotApp(app) {
const github = new ProbotOctokit({
// any options you'd pass to Octokit
auth: 'token <myToken>',
// plus throttling settings
throttle: throttlingSettings,
auth: "token <myToken>",
// and a logger
logger: app.log.child({ name: 'my-github' })
})
log: app.log.child({ name: "my-github" }),
});
}
```

View File

@ -7,9 +7,9 @@ next: docs/development.md
A Probot app is just a [Node.js module](https://nodejs.org/api/modules.html) that exports a function:
```js
module.exports = app => {
module.exports = (app) => {
// your code here
}
};
```
The `app` parameter is an instance of [`Application`](https://probot.github.io/api/latest/classes/application.html) and gives you access to all of the GitHub goodness.
@ -17,12 +17,12 @@ The `app` parameter is an instance of [`Application`](https://probot.github.io/a
`app.on` will listen for any [webhook events triggered by GitHub](./webhooks.md), which will notify you when anything interesting happens on GitHub that your app wants to know about.
```js
module.exports = app => {
app.on('issues.opened', async context => {
module.exports = (app) => {
app.on("issues.opened", async (context) => {
// A new issue was opened, what should we do with it?
context.log(context.payload)
})
}
context.log.info(context.payload);
});
};
```
The `context` passed to the event handler includes everything about the event that was triggered, as well as some helpful properties for doing something useful in response to the event. `context.github` is an authenticated GitHub client that can be used to [make API calls](./github-api.md), and allows you to do almost anything programmatically that you can do through a web browser on GitHub.
@ -30,17 +30,17 @@ The `context` passed to the event handler includes everything about the event th
Here is an example of an autoresponder app that comments on opened issues:
```js
module.exports = app => {
app.on('issues.opened', async context => {
module.exports = (app) => {
app.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 !}
const params = context.issue({ body: 'Hello World!' })
const params = context.issue({ body: "Hello World!" });
// Post a comment on the issue
return context.github.issues.createComment(params)
})
}
return context.github.issues.createComment(params);
});
};
```
To get started, you can use the instructions for [Developing an App](./development.md) or remix this 'Hello World' project on Glitch:

View File

@ -7,18 +7,18 @@ next: docs/simulating-webhooks.md
Calling `app.route('/my-app')` will return an [express](http://expressjs.com/) router that you can use to expose HTTP endpoints from your app.
```js
module.exports = app => {
module.exports = (app) => {
// Get an express router to expose new HTTP endpoints
const router = app.route('/my-app')
const router = app.route("/my-app");
// Use any middleware
router.use(require('express').static('public'))
router.use(require("express").static("public"));
// Add a new route
router.get('/hello-world', (req, res) => {
res.send('Hello World')
})
}
router.get("/hello-world", (req, res) => {
res.send("Hello World");
});
};
```
Visit https://localhost:3000/my-app/hello-world to access the endpoint.

View File

@ -4,46 +4,46 @@ next: docs/pagination.md
# Logging
A good logger is a good developer's secret weapon. Probot comes with [bunyan](https://github.com/trentm/node-bunyan), which is a simple and fast logging library that supports some pretty sophisticated logging if you need it (hint: you will).
A good logger is a good developer's secret weapon. Probot comes with [pino](https://getpino.io), which is a minimal logging solution that outputs JSON data and leaves formatting, sending, and error handling to external processes.
`app.log`, `context.log` in an event handler, and `req.log` in an HTTP request are all loggers that you can use to get more information about what your app is doing.
```js
module.exports = app => {
app.log('Yay, my app is loaded')
module.exports = (app) => {
app.log.info("Yay, my app is loaded");
app.on('issues.opened', context => {
app.on("issues.opened", (context) => {
if (context.payload.issue.body.match(/bacon/)) {
context.log('This issue is about bacon')
context.log.info("This issue is about bacon");
} else {
context.log('Sadly, this issue is not about bacon')
context.log.info("Sadly, this issue is not about bacon");
}
})
});
app.route().get('/hello-world', (req, res) => {
req.log('Someone is saying hello')
})
}
app.route().get("/hello-world", (req, res) => {
req.log.info("Someone is saying hello");
});
};
```
When you start up your app with `npm start`, You should see your log message appear in your terminal.
<img width="753" alt="" src="https://user-images.githubusercontent.com/173/33234904-d43e7f14-d1f3-11e7-8dcb-6c47e58bd56b.png">
<!-- TODO: paste in log output -->
`app.log` will log messages at the `info` level, which is what your app should use for most relevant messages. Occasionally you will want to log more detailed information that is useful for debugging, but you might not want to see it all the time.
```js
module.exports = app => {
module.exports = (app) => {
// …
app.log.trace('Really low-level logging')
app.log.debug({ data: 'here' }, 'End-line specs on the rotary girder')
app.log.info('Same as using `app.log`')
app.log.trace("Really low-level logging");
app.log.debug({ data: "here" }, "End-line specs on the rotary girder");
app.log.info("Same as using `app.log`");
const err = new Error('Some error')
app.log.warn(err, 'Uh-oh, this may not be good')
app.log.error(err, 'Yeah, it was bad')
app.log.fatal(err, 'Goodbye, cruel world!')
}
const err = new Error("Some error");
app.log.warn(err, "Uh-oh, this may not be good");
app.log.error(err, "Yeah, it was bad");
app.log.fatal(err, "Goodbye, cruel world!");
};
```
By default, messages that are `info` and above will show in your logs, but you can change it by setting the
@ -57,22 +57,24 @@ $ LOG_LEVEL=debug npm start
In development, it's nice to see simple, colorized, pretty log messages. But those pretty messages don't do you any good when you have 2TB of log files and you're trying to track down why that one-in-a-million bug is happening in production.
Set `LOG_FORMAT=json` to show log messages as structured JSON, which can then be drained to a logging service that allows querying by various attributes.
When `NODE_ENV` is set (as it should be in production), the log output is structured JSON, which can then be drained to a logging service that allows querying by various attributes.
For example, given this log:
```js
module.exports = app => {
app.on('issue_comment.created', context => {
context.log('Comment created')
})
}
module.exports = (app) => {
app.on("issue_comment.created", (context) => {
context.log.info("Comment created");
});
};
```
You'll see this output:
<!-- TODO: update output -->
```
{"name":"Probot","hostname":"Brandons-MacBook-Pro-3.local","pid":96993,"event":{"id":"afdcb370-c57d-11e7-9b26-0f31120e45b8","event":"issue_comment","action":"created","repository":"robotland/test","installation":13055},"level":20,"msg":"Comment created","time":"2017-11-09T18:42:07.312Z","v":0}
```
For more about bunyan, check out [this talk about logging in production](http://trentm.com/talk-bunyan-in-prod/).
The output can then be piped to one of [pino's transport tools](https://getpino.io/#/docs/transports), or you can build your own.

View File

@ -7,18 +7,19 @@ next: docs/extensions.md
Many GitHub API endpoints are paginated. The `github.paginate` method can be used to get each page of the results.
```js
module.exports = app => {
app.on('issues.opened', context => {
module.exports = (app) => {
app.on("issues.opened", (context) => {
context.github.paginate(
context.github.issues.getAll.endpoint.merge(context.repo()),
res => {
res.data.issues.forEach(issue => {
context.log('Issue: %s', issue.title)
})
context.github.issues.getAll,
context.repo(),
(res) => {
res.data.issues.forEach((issue) => {
context.log.info("Issue: %s", issue.title);
});
}
)
})
}
);
});
};
```
## Accumulating pages
@ -26,15 +27,16 @@ module.exports = app => {
The return value of the `github.paginate` callback will be used to accumulate results.
```js
module.exports = app => {
app.on('issues.opened', async context => {
module.exports = (app) => {
app.on("issues.opened", async (context) => {
const allIssues = await context.github.paginate(
context.github.issues.getAll.endpoint.merge(context.repo()),
res => res.data
)
console.log(allIssues)
})
}
context.github.issues.getAll,
context.repo(),
(res) => res.data
);
console.log(allIssues);
});
};
```
## Early exit
@ -42,20 +44,23 @@ module.exports = app => {
Sometimes it is desirable to stop fetching pages after a certain condition has been satisfied. A second argument, `done`, is provided to the callback and can be used to stop pagination. After `done` is invoked, no additional pages will be fetched, but you still need to return the mapped value for the current page request.
```js
module.exports = app => {
app.on('issues.opened', context => {
const options = context.github.issues.getAll.endpoint.merge(context.repo())
context.github.paginate(options, (res, done) => {
for (const issue of res.data) {
if (issue.body.includes('something')) {
console.log('found it:', issue)
done()
break
module.exports = (app) => {
app.on("issues.opened", (context) => {
context.github.paginate(
context.github.issues.getAll,
context.repo(),
(res, done) => {
for (const issue of res.data) {
if (issue.body.includes("something")) {
console.log("found it:", issue);
done();
break;
}
}
}
})
})
}
);
});
};
```
## Async iterators
@ -63,16 +68,18 @@ module.exports = app => {
If your runtime environment supports async iterators (such as Node 10+), you can iterate through each response
```js
module.exports = app => {
app.on('issues.opened', async context => {
const options = context.github.issues.getAll.endpoint.merge(context.repo())
for await (const response of octokit.paginate.iterator(options)) {
module.exports = (app) => {
app.on("issues.opened", async (context) => {
for await (const response of octokit.paginate.iterator(
context.github.issues.getAll,
context.repo()
)) {
for (const issue of res.data) {
if (issue.body.includes('something')) {
return console.log('found it:', issue)
if (issue.body.includes("something")) {
return console.log("found it:", issue);
}
}
}
})
}
});
};
```

View File

@ -10,11 +10,11 @@ Generally speaking, adding database storage or persistence to your Probot App wi
Probot includes a wrapper for the GitHub API which can enable you to store and manipulate data in the GitHub environment. Since your Probot App will usually be running to supplement work done on GitHub, it makes sense to try to keep everything in one place and avoid extra complications.
- *Comments:* The API can read, write and delete comments on issues and pull requests.
- *Status:* The API can read and change the status of an issue or pull request.
- *Search:* GitHub has a powerful search API that can be used
- *Repository:* Built-in `context.config()` allows storing configuration in the repository or the organization's `.github` repository.
- *Labels:* The API can read labels, and add or remove them from issues and pull requests.
- _Comments:_ The API can read, write and delete comments on issues and pull requests.
- _Status:_ The API can read and change the status of an issue or pull request.
- _Search:_ GitHub has a powerful search API that can be used
- _Repository:_ Built-in `context.config()` allows storing configuration in the repository or the organization's `.github` repository.
- _Labels:_ The API can read labels, and add or remove them from issues and pull requests.
If your Probot App needs to store more data than Issues and Pull Requests store normally, you can use the [`probot-metadata` extension](/docs/extensions#metadata) to hide data in comments. It isn't meant to be super secure or scalable, but it's an easy way to manage some data without a full database.
@ -31,56 +31,58 @@ For when you absolutely do need external data storage, here are some examples us
```js
// PeopleSchema.js
const mongoose = require('mongoose')
const mongoose = require("mongoose");
const Schema = mongoose.Schema
const Schema = mongoose.Schema;
const PeopleSchema = new Schema({
name: {
type: String,
required: true
}
})
required: true,
},
});
module.exports = mongoose.model('People', PeopleSchema)
module.exports = mongoose.model("People", PeopleSchema);
```
```js
// index.js
const mongoose = require('mongoose')
const mongoose = require("mongoose");
// Connect to the Mongo database using credentials
// in your environment variables
const mongoUri = `mongodb://${process.env.DB_HOST}`
const mongoUri = `mongodb://${process.env.DB_HOST}`;
mongoose.connect(mongoUri, {
user: process.env.DB_USER,
pass: process.env.DB_PASS,
useMongoClient: true
})
useMongoClient: true,
});
// Register the mongoose model
const People = require('./PeopleSchema')
const People = require("./PeopleSchema");
module.exports = app => {
app.on('issues.opened', async context => {
module.exports = (app) => {
app.on("issues.opened", async (context) => {
// Find all the people in the database
const people = await People.find().exec()
const people = await People.find().exec();
// Generate a string using all the peoples' names.
// It would look like: 'Jason, Jane, James, Jennifer'
const peoplesNames = people.map(person => person.name).join(', ')
const peoplesNames = people.map((person) => person.name).join(", ");
// `context` extracts information from the event, which can be passed to
// GitHub API calls. This will return:
// { owner: 'yourname', repo: 'yourrepo', number: 123, body: 'The following people are in the database: Jason, Jane, James, Jennifer' }
const params = context.issue({ body: `The following people are in the database: ${peoplesNames}` })
const params = context.issue({
body: `The following people are in the database: ${peoplesNames}`,
});
// Post a comment on the issue
return context.github.issues.createComment(params)
})
}
return context.github.issues.createComment(params);
});
};
```
### MySQL
@ -89,37 +91,39 @@ Using the [`@databases/mysql`](https://www.atdatabases.org/docs/mysql.html) modu
```js
// connection.js
const mysql = require('@databases/mysql')
const mysql = require("@databases/mysql");
// DATABASE_URL = mysql://my-user:my-password@localhost/my-db
const connection = connect(process.env.DATABASE_URL)
const connection = connect(process.env.DATABASE_URL);
module.exports = connection
module.exports = connection;
```
```js
// index.js
const { sql } = require('@databases/mysql')
const connection = require('./connection')
const { sql } = require("@databases/mysql");
const connection = require("./connection");
module.exports = app => {
app.on('issues.opened', async context => {
module.exports = (app) => {
app.on("issues.opened", async (context) => {
// Find all the people in the database
const people = await connection.query(sql`SELECT * FROM people`)
const people = await connection.query(sql`SELECT * FROM people`);
// Generate a string using all the peoples' names.
// It would look like: 'Jason, Jane, James, Jennifer'
const peoplesNames = people.map(key => people[key].name).join(', ')
const peoplesNames = people.map((key) => people[key].name).join(", ");
// `context` extracts information from the event, which can be passed to
// GitHub API calls. This will return:
// { owner: 'yourname', repo: 'yourrepo', number: 123, body: 'The following people are in the database: Jason, Jane, James, Jennifer' }
const params = context.issue({ body: `The following people are in the database: ${peoplesNames}` })
const params = context.issue({
body: `The following people are in the database: ${peoplesNames}`,
});
// Post a comment on the issue
return context.github.issues.createComment(params)
})
}
return context.github.issues.createComment(params);
});
};
```
### Postgres
@ -128,37 +132,39 @@ Using the [`@databases/pg`](https://www.atdatabases.org/docs/pg.html) module, we
```js
// connection.js
const mysql = require('@databases/pg')
const mysql = require("@databases/pg");
// DATABASE_URL = postgresql://my-user:my-password@localhost/my-db
const connection = connect(process.env.DATABASE_URL)
const connection = connect(process.env.DATABASE_URL);
module.exports = connection
module.exports = connection;
```
```js
// index.js
const { sql } = require('@databases/pg')
const connection = require('./connection')
const { sql } = require("@databases/pg");
const connection = require("./connection");
module.exports = app => {
app.on('issues.opened', async context => {
module.exports = (app) => {
app.on("issues.opened", async (context) => {
// Find all the people in the database
const people = await connection.query(sql`SELECT * FROM people`)
const people = await connection.query(sql`SELECT * FROM people`);
// Generate a string using all the peoples' names.
// It would look like: 'Jason, Jane, James, Jennifer'
const peoplesNames = people.map(key => people[key].name).join(', ')
const peoplesNames = people.map((key) => people[key].name).join(", ");
// `context` extracts information from the event, which can be passed to
// GitHub API calls. This will return:
// { owner: 'yourname', repo: 'yourrepo', number: 123, body: 'The following people are in the database: Jason, Jane, James, Jennifer' }
const params = context.issue({ body: `The following people are in the database: ${peoplesNames}` })
const params = context.issue({
body: `The following people are in the database: ${peoplesNames}`,
});
// Post a comment on the issue
return context.github.issues.createComment(params)
})
}
return context.github.issues.createComment(params);
});
};
```
### Firebase
@ -168,36 +174,43 @@ module.exports = app => {
```js
// index.js
const firebase = require('firebase')
const firebase = require("firebase");
// Set the configuration for your app
// TODO: Replace with your project's config object
const config = {
apiKey: 'apiKey',
authDomain: 'projectId.firebaseapp.com',
databaseURL: 'https://databaseName.firebaseio.com'
}
firebase.initializeApp(config)
apiKey: "apiKey",
authDomain: "projectId.firebaseapp.com",
databaseURL: "https://databaseName.firebaseio.com",
};
firebase.initializeApp(config);
const database = firebase.database()
const database = firebase.database();
module.exports = app => {
app.on('issues.opened', async context => {
module.exports = (app) => {
app.on("issues.opened", async (context) => {
// Find all the people in the database
const people = await database.ref('/people').once('value').then((snapshot) => {
return snapshot.val()
})
const people = await database
.ref("/people")
.once("value")
.then((snapshot) => {
return snapshot.val();
});
// Generate a string using all the peoples' names.
// It would look like: 'Jason, Jane, James, Jennifer'
const peoplesNames = Object.keys(people).map(key => people[key].name).join(', ')
const peoplesNames = Object.keys(people)
.map((key) => people[key].name)
.join(", ");
// `context` extracts information from the event, which can be passed to
// GitHub API calls. This will return:
// { owner: 'yourname', repo: 'yourrepo', number: 123, body: 'The following people are in the database: Jason, Jane, James, Jennifer' }
const params = context.issue({ body: `The following people are in the database: ${peoplesNames}` })
const params = context.issue({
body: `The following people are in the database: ${peoplesNames}`,
});
// Post a comment on the issue
return context.github.issues.createComment(params)
})
}
return context.github.issues.createComment(params);
});
};
```

View File

@ -14,8 +14,8 @@ To learn more about other FaaS offerings and the concept of serverless, check ou
1. [Create the GitHub App](#create-the-github-app)
1. [Deploy the app to a FaaS provider](#deploy-the-app)
1. [AWS Lambda](#aws-lambda)
1. [Google Cloud Function](#google-cloud-function)
1. [AWS Lambda](#aws-lambda)
1. [Google Cloud Function](#google-cloud-function)
## Create the GitHub App
@ -36,6 +36,7 @@ Choosing a FaaS provider is mostly dependent on developer preference. Each Probo
### AWS Lambda
AWS Lambda is an event-driven, serverless computing platform provided by Amazon as a part of the Amazon Web Services. AWS Lamba additionally manages the computing resources required for the code and adjusts those resources in conjunction with incoming events.
1. [Install the @probot/serverless-lambda](https://github.com/probot/serverless-lambda#usage) plugin.
2. Create a `handler.js` file in the root of you probot application
```
@ -44,8 +45,8 @@ AWS Lambda is an event-driven, serverless computing platform provided by Amazon
const appFn = require('./')
module.exports.probot = serverless(appFn)
```
2. Follow the lambda [configuration steps](https://github.com/probot/serverless-lambda#configuration) using the [AWS CLI](https://aws.amazon.com/cli/) or [Serverless framework](https://github.com/serverless/serverless).
3. Once the app is is configured and you can proceed with deploying using the either [AWS CLI](https://aws.amazon.com/cli/) or [Serverless framework](https://github.com/serverless/serverless)
3. Follow the lambda [configuration steps](https://github.com/probot/serverless-lambda#configuration) using the [AWS CLI](https://aws.amazon.com/cli/) or [Serverless framework](https://github.com/serverless/serverless).
4. Once the app is is configured and you can proceed with deploying using the either [AWS CLI](https://aws.amazon.com/cli/) or [Serverless framework](https://github.com/serverless/serverless)
> note: The Serverless framework provides a more straightforward approach to setting up your AWS environment. It requires the creation of a serverless.yml in the root of your application.
@ -61,5 +62,5 @@ Google Cloud Platform, is a suite of cloud computing services that run on the sa
const appFn = require('./')
module.exports.probot = serverless(appFn)
```
2. Follow the GCF [configuration steps](https://github.com/probot/serverless-gcf#configuration) using the [gcloud CLI](https://cloud.google.com/pubsub/docs/quickstart-cli) or [Serverless framework](https://github.com/serverless/serverless).
3. Once the app is is configured and you can proceed with deploying using the either [gcloud CLI](https://cloud.google.com/pubsub/docs/quickstart-cli) or [Serverless framework](https://github.com/serverless/serverless)
3. Follow the GCF [configuration steps](https://github.com/probot/serverless-gcf#configuration) using the [gcloud CLI](https://cloud.google.com/pubsub/docs/quickstart-cli) or [Serverless framework](https://github.com/serverless/serverless).
4. Once the app is is configured and you can proceed with deploying using the either [gcloud CLI](https://cloud.google.com/pubsub/docs/quickstart-cli) or [Serverless framework](https://github.com/serverless/serverless)

View File

@ -6,7 +6,7 @@ next: docs/testing.md
As you are developing your app, you will likely want to test it by repeatedly triggering the same webhook. You can simulate a webhook being delivered by saving the payload to a file, and then calling `probot receive` from the command line.
To save a copy of the payload, go to the [settings](https://github.com/settings/apps) page for your App, and go to the **Advanced** tab. Click on one of the **Recent Deliveries** to expand it and see the details of the webhook event. Copy the JSON from the **Payload** and save it to a new file. (`test/fixtures/issues.labeled.json` in this example).
To save a copy of the payload, go to the [settings](https://github.com/settings/apps) page for your App, and go to the **Advanced** tab. Click on one of the **Recent Deliveries** to expand it and see the details of the webhook event. Copy the JSON from the **Payload** and save it to a new file. (`test/fixtures/issues.labeled.json` in this example).
**Note**: This will only receive the JSON payload, not the headers that are also sent by GitHub webhooks.

View File

@ -54,4 +54,4 @@ Amazing! Be sure to include it in your application.
## Application Requirements
* You must have created the ["Hello World" app](./hello-world.md), and provided a link to a comment that it has posted.
- You must have created the ["Hello World" app](./hello-world.md), and provided a link to a comment that it has posted.

View File

@ -9,51 +9,54 @@ We highly recommend working in the style of [test-driven development](http://agi
For our testing examples, we use [jest](https://facebook.github.io/jest/), but there are other options that can perform similar operations. We also recommend using [nock](https://github.com/nock/nock), a tool for mocking HTTP requests, which is often crucial to testing in Probot, considering how much of Probot depends on GitHub's APIs. Here's an example of creating an app instance and using nock to test that we correctly hit the GitHub API:
```js
const nock = require('nock')
const nock = require("nock");
// Requiring our app implementation
const myProbotApp = require('..')
const { Probot } = require('probot')
const myProbotApp = require("..");
const { Probot, ProbotOctokit } = require("probot");
// Requiring our fixtures
const payload = require('./fixtures/issues.opened')
const issueCreatedBody = { body: 'Thanks for opening this issue!' }
const payload = require("./fixtures/issues.opened");
const issueCreatedBody = { body: "Thanks for opening this issue!" };
describe('My Probot app', () => {
let probot
describe("My Probot app", () => {
let probot;
beforeEach(() => {
nock.disableNetConnect()
nock.disableNetConnect();
probot = new Probot({
id: 1,
githubToken: 'test',
// disable all request throttling to make tests faster
throttleOptions: { enabled: false }
})
probot.load(myProbotApp)
})
githubToken: "test",
// Disable throttling & retrying requests for easier testing
Octokit: ProbotOctokit.defaults({
retry: { enabled: false },
throttle: { enabled: false },
}),
});
probot.load(myProbotApp);
});
test('creates a passing check', async () => {
test("creates a passing check", async () => {
// Test that we correctly return a test token
nock('https://api.github.com')
.post('/app/installations/2/access_tokens')
.reply(200, { token: 'test' })
nock("https://api.github.com")
.post("/app/installations/2/access_tokens")
.reply(200, { token: "test" });
// Test that a comment is posted
nock('https://api.github.com')
.post('/repos/hiimbex/testing-things/issues/1/comments', (body) => {
expect(body).toMatchObject(issueCreatedBody)
return true
nock("https://api.github.com")
.post("/repos/hiimbex/testing-things/issues/1/comments", (body) => {
expect(body).toMatchObject(issueCreatedBody);
return true;
})
.reply(200)
.reply(200);
// Receive a webhook event
await probot.receive({ name: 'issues', payload })
})
await probot.receive({ name: "issues", payload });
});
afterEach(() => {
nock.cleanAll()
nock.enableNetConnect()
})
})
nock.cleanAll();
nock.enableNetConnect();
});
});
```
A good testing example from [dco](https://github.com/probot/dco) can be found [here](https://github.com/probot/dco/blob/master/test/index.test.js), and another one from [markdownify](https://github.com/hiimbex/markdownify) can be found [here](https://github.com/hiimbex/markdownify/blob/master/test/index.test.js).
A good testing example from [dco](https://github.com/probot/dco) can be found [here](https://github.com/probot/dco/blob/master/test/index.test.js), and another one from [markdownify](https://github.com/hiimbex/markdownify) can be found [here](https://github.com/hiimbex/markdownify/blob/master/test/index.test.js).

View File

@ -9,12 +9,12 @@ next: docs/github-api.md
Many apps will spend their entire day responding to these actions. `app.on` will listen for any GitHub webhook events:
```js
module.exports = app => {
app.on('push', async context => {
module.exports = (app) => {
app.on("push", async (context) => {
// Code was pushed to the repo, what should we do with it?
app.log(context)
})
}
app.log.info(context);
});
};
```
The app can listen to any of the [GitHub webhook events](https://developer.github.com/webhooks/#events). The `context` object includes everything about the event that was triggered, and `context.payload` has the payload delivered by GitHub.
@ -22,32 +22,32 @@ The app can listen to any of the [GitHub webhook events](https://developer.githu
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 app will only care about one type of action, so you can append it to the event name with a `.`:
```js
module.exports = app => {
app.on('issues.opened', async context => {
module.exports = (app) => {
app.on("issues.opened", async (context) => {
// An issue was just opened.
})
}
});
};
```
Sometimes you want to handle multiple webhook events the same way. `app.on` can listen to a list of events and run the same callback:
```js
module.exports = app => {
app.on(['issues.opened', 'issues.edited'], async context => {
module.exports = (app) => {
app.on(["issues.opened", "issues.edited"], async (context) => {
// An issue was opened or edited, what should we do with it?
app.log(context)
})
}
app.log.info(context);
});
};
```
You can also use the wildcard event (`*`) to listen for any event that your app is subscribed to:
```js
module.exports = app => {
app.on('*', async context => {
context.log({ event: context.event, action: context.payload.action })
})
}
module.exports = (app) => {
app.on("*", async (context) => {
context.log.info({ event: context.event, action: context.payload.action });
});
};
```
For more details, explore the [GitHub webhook documentation](https://developer.github.com/webhooks/#events) or see a list of all the named events in the [@octokit/webhooks.js](https://github.com/octokit/webhooks.js/) npm module.

11144
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -11,11 +11,12 @@
"scripts": {
"build": "rimraf lib && tsc -p tsconfig.json",
"start": "node ./bin/probot run",
"lint": "tslint --project test",
"test": "tsc --noEmit -p test && jest --coverage && npm run lint && npm run doc-lint",
"doc-lint": "standard-markdown docs/",
"doc": "typedoc --options .typedoc.json",
"prepare": "npm run build"
"lint": "prettier --check 'src/**/*.ts' 'test/**/*.ts' 'docs/*.md' *.md package.json tsconfig.json",
"lint:fix": "prettier --write 'src/**/*.ts' 'test/**/*.ts' 'docs/*.md' *.md package.json tsconfig.json",
"pretest": "tsc --noEmit -p test",
"test": "jest",
"posttest": "npm run lint",
"doc": "typedoc --options .typedoc.json"
},
"files": [
"lib",
@ -23,7 +24,100 @@
"static",
"views"
],
"keywords": [
"probot",
"github-apps",
"github",
"automation",
"robots",
"workflow"
],
"bugs": "https://github.com/probot/probot/issues",
"homepage": "https://probot.github.io",
"author": "Brandon Keepers",
"license": "ISC",
"dependencies": {
"@octokit/auth-app": "^2.4.14",
"@octokit/auth-unauthenticated": "^1.0.0",
"@octokit/core": "^3.1.0",
"@octokit/graphql": "^4.2.0",
"@octokit/plugin-enterprise-compatibility": "^1.2.1",
"@octokit/plugin-paginate-rest": "^2.2.3",
"@octokit/plugin-rest-endpoint-methods": "^3.17.0",
"@octokit/plugin-retry": "^3.0.1",
"@octokit/plugin-throttling": "^3.3.0",
"@octokit/request": "^5.1.0",
"@octokit/types": "^5.0.1",
"@octokit/webhooks": "^7.11.0",
"@probot/pino": "^1.0.0",
"@types/express": "^4.17.2",
"@types/ioredis": "^4.0.6",
"@types/pino": "^6.3.0",
"@types/pino-http": "^5.0.4",
"@types/supports-color": "^5.3.0",
"bottleneck": "^2.15.3",
"commander": "^5.0.0",
"deepmerge": "^4.1.0",
"deprecation": "^2.3.1",
"dotenv": "~8.2.0",
"eventsource": "^1.0.7",
"express": "^4.16.2",
"express-async-errors": "^3.0.0",
"hbs": "^4.1.1",
"ioredis": "^4.5.1",
"is-base64": "^1.1.0",
"js-yaml": "^3.13.1",
"jsonwebtoken": "^8.1.0",
"lru-cache": "^6.0.0",
"octokit-pagination-methods": "1.1.0",
"pino": "^6.5.0",
"pino-http": "^5.2.0",
"pino-pretty": "^4.1.0",
"pkg-conf": "^3.0.0",
"raven": "^2.4.2",
"resolve": "^1.4.0",
"semver": "^7.0.0",
"supports-color": "^7.0.0",
"update-dotenv": "^1.1.0",
"update-notifier": "^4.0.0",
"uuid": "^7.0.0"
},
"devDependencies": {
"@sinonjs/fake-timers": "^6.0.1",
"@tsconfig/node10": "^1.0.3",
"@types/eventsource": "^1.1.0",
"@types/jest": "^25.1.3",
"@types/js-yaml": "^3.10.1",
"@types/jsonwebtoken": "^8.3.0",
"@types/node": "^14.0.6",
"@types/raven": "^2.1.5",
"@types/resolve": "^1.14.0",
"@types/semver": "^7.1.0",
"@types/supertest": "^2.0.4",
"@types/uuid": "^7.0.0",
"body-parser": "^1.19.0",
"connect-sse": "^1.2.0",
"execa": "^4.0.3",
"get-port": "^5.1.1",
"got": "^11.5.1",
"jest": "^26.1.0",
"json-server": "^0.16.1",
"nock": "^13.0.3",
"prettier": "^2.0.5",
"rimraf": "^3.0.2",
"semantic-release": "^17.0.0",
"semantic-release-plugin-update-version-in-files": "^1.1.0",
"smee-client": "^1.0.1",
"supertest": "^4.0.2",
"ts-jest": "^26.1.1",
"typedoc": "^0.17.0",
"typescript": "^3.9.3"
},
"engines": {
"node": ">=10.21"
},
"jest": {
"testEnvironment": "node",
"setupFiles": [
"<rootDir>/test/setup.ts"
],
@ -38,97 +132,20 @@
],
"preset": "ts-jest"
},
"keywords": [
"probot",
"github-apps",
"github",
"automation",
"robots",
"workflow"
],
"bugs": "https://github.com/probot/probot/issues",
"homepage": "https://probot.github.io",
"author": "Brandon Keepers",
"license": "ISC",
"dependencies": {
"@octokit/app": "^4.0.0",
"@octokit/graphql": "^4.2.0",
"@octokit/plugin-enterprise-compatibility": "^1.2.1",
"@octokit/plugin-retry": "^3.0.1",
"@octokit/plugin-throttling": "^3.0.0",
"@octokit/request": "^5.1.0",
"@octokit/rest": "^16.43.1",
"@octokit/webhooks": "^6.0.0",
"@types/bunyan": "^1.8.4",
"@types/express": "^4.17.2",
"@types/ioredis": "^4.0.6",
"@types/supports-color": "^5.3.0",
"bottleneck": "^2.15.3",
"bunyan": "^1.8.12",
"bunyan-format": "^0.2.1",
"bunyan-sentry-stream": "^1.1.0",
"cache-manager": "^3.0.0",
"commander": "^5.0.0",
"deepmerge": "^4.1.0",
"dotenv": "~8.2.0",
"eventsource": "^1.0.7",
"express": "^4.16.2",
"express-async-errors": "^3.0.0",
"hbs": "^4.1.1",
"ioredis": "^4.5.1",
"is-base64": "^1.1.0",
"js-yaml": "^3.13.1",
"jsonwebtoken": "^8.1.0",
"octokit-pagination-methods": "1.1.0",
"pkg-conf": "^3.0.0",
"promise-events": "^0.1.3",
"raven": "^2.4.2",
"resolve": "^1.4.0",
"semver": "^7.0.0",
"supports-color": "^7.0.0",
"update-dotenv": "^1.1.0",
"update-notifier": "^4.0.0",
"uuid": "^7.0.0"
},
"devDependencies": {
"@types/bunyan-format": "^0.2.2",
"@types/cache-manager": "^2.10.0",
"@types/eventsource": "^1.1.0",
"@types/jest": "^25.1.3",
"@types/js-yaml": "^3.10.1",
"@types/jsonwebtoken": "^8.3.0",
"@types/node": "^14.0.6",
"@types/raven": "^2.1.5",
"@types/resolve": "^1.14.0",
"@types/semver": "^7.1.0",
"@types/supertest": "^2.0.4",
"@types/uuid": "^7.0.0",
"connect-sse": "^1.2.0",
"eslint": "^6.0.0",
"eslint-plugin-markdown": "^1.0.0-beta.8",
"jest": "^25.1.0",
"nock": "^12.0.0",
"semantic-release": "^17.0.0",
"smee-client": "^1.0.1",
"standard": "^14.0.2",
"standard-markdown": "^6.0.0",
"supertest": "^4.0.2",
"ts-jest": "^26.1.0",
"tslint": "^5.9.1",
"tslint-config-prettier": "^1.10.0",
"tslint-config-standard": "^9.0.0",
"typedoc": "^0.17.0",
"typescript": "^3.9.3"
},
"standard": {
"env": [
"jest"
],
"release": {
"plugins": [
"markdown"
"@semantic-release/commit-analyzer",
"@semantic-release/release-notes-generator",
"@semantic-release/github",
"@semantic-release/npm",
[
"semantic-release-plugin-update-version-in-files",
{
"files": [
"lib/version.*"
]
}
]
]
},
"engines": {
"node": ">=7.7"
}
}

View File

@ -1,38 +0,0 @@
/** Declaration file generated by dts-gen */
declare module 'bunyan-sentry-stream' {
export = bunyan_sentry_stream;
declare function bunyan_sentry_stream(client: any): any;
declare namespace bunyan_sentry_stream {
class SentryStream {
constructor(...args: any[]);
deserializeError(...args: any[]): void;
getSentryLevel(...args: any[]): void;
write(...args: any[]): void;
}
const prototype: {
};
namespace SentryStream {
namespace prototype {
function deserializeError(...args: any[]): void;
function getSentryLevel(...args: any[]): void;
function write(...args: any[]): void;
}
}
}
}

5
src/@types/probot__pino/index.d.ts vendored Normal file
View File

@ -0,0 +1,5 @@
declare module "@probot/pino" {
import { Transform } from "readable-stream";
export function getTransformStream(): Transform {}
}

View File

@ -1,6 +0,0 @@
declare module "promise-events" {
class EventEmitter {
emit<T = void>(event: string | symbol, ...args: any[]): Promise<T>;
on<T = void>(event: string | symbol, handler: (...args: any[]) => Promise<T>): void;
}
}

View File

@ -1,22 +1,22 @@
declare module "smee-client" {
import EventSource = require('eventsource')
import EventSource = require("eventsource");
type Severity = 'info' | 'error'
type Severity = "info" | "error";
interface Options {
source?: string
target: string
logger?: Pick<Console, Severity>
source?: string;
target: string;
logger?: Pick<Console, Severity>;
}
class Client {
constructor ({ source, target, logger}: Options)
public onmessage (msg: any): void
public onopen(): void
public onerror(err: any): void
public start(): EventSource
public static createChannel(): Promise<string>
constructor({ source, target, logger }: Options);
public onmessage(msg: any): void;
public onopen(): void;
public onerror(error: any): void;
public start(): EventSource;
public static createChannel(): Promise<string>;
}
export = Client
export = Client;
}

View File

@ -1,33 +1,41 @@
import { App as OctokitApp } from '@octokit/app'
import { Octokit } from '@octokit/rest'
import Webhooks from '@octokit/webhooks'
import express from 'express'
import { EventEmitter } from 'promise-events'
import { ApplicationFunction } from '.'
import { Cache } from './cache'
import { Context } from './context'
import { GitHubAPI, ProbotOctokit } from './github'
import { logger } from './logger'
import webhookEventCheck from './webhook-event-check'
import { LoggerWithTarget, wrapLogger } from './wrap-logger'
import express from "express";
import Redis from "ioredis";
import LRUCache from "lru-cache";
import type { Webhooks } from "@octokit/webhooks";
import type { Logger } from "pino";
import { ApplicationFunction } from ".";
import { Context } from "./context";
import { getAuthenticatedOctokit } from "./octokit/get-authenticated-octokit";
import { ProbotOctokit } from "./octokit/probot-octokit";
import { getLog } from "./helpers/get-log";
import { getOctokitThrottleOptions } from "./octokit/get-octokit-throttle-options";
import { getProbotOctokitWithDefaults } from "./octokit/get-probot-octokit-with-defaults";
import { DeprecatedLogger, ProbotWebhooks, State } from "./types";
import { webhookEventCheck } from "./helpers/webhook-event-check";
import { aliasLog } from "./helpers/alias-log";
import { getWebhooks } from "./octokit/get-webhooks";
export interface Options {
app: OctokitApp
cache: Cache
router?: express.Router
catchErrors?: boolean
githubToken?: string
throttleOptions?: any
Octokit?: Octokit.Static
// same options as Probot class
privateKey?: string;
githubToken?: string;
id?: number;
Octokit?: typeof ProbotOctokit;
log?: Logger;
redisConfig?: Redis.RedisOptions;
secret?: string;
webhookPath?: string;
// Application class specific options
cache?: LRUCache<number, string>;
octokit?: InstanceType<typeof ProbotOctokit>;
throttleOptions?: any;
webhooks?: Webhooks;
}
export type OnCallback<T> = (context: Context<T>) => Promise<void>
// Some events can't get an authenticated client (#382):
function isUnauthenticatedEvent (event: Webhooks.WebhookEvent<any>) {
return !event.payload.installation ||
(event.name === 'installation' && event.payload.action === 'deleted')
}
export type OnCallback<T> = (context: Context<T>) => Promise<void>;
/**
* The `app` parameter available to `ApplicationFunction`s
@ -35,48 +43,82 @@ function isUnauthenticatedEvent (event: Webhooks.WebhookEvent<any>) {
* @property {logger} log - A logger
*/
export class Application {
public events: EventEmitter
public app: OctokitApp
public cache: Cache
public router: express.Router
public log: LoggerWithTarget
public router: express.Router;
public log: DeprecatedLogger;
public on: ProbotWebhooks["on"];
public receive: ProbotWebhooks["receive"];
private githubToken?: string
private throttleOptions: any
private Octokit: Octokit.Static
private webhooks: ProbotWebhooks;
private state: State;
constructor (options?: Options) {
const opts = options || {} as any
this.events = new EventEmitter()
this.log = wrapLogger(logger, logger)
this.app = opts.app
this.cache = opts.cache
this.router = opts.router || express.Router() // you can do this?
this.githubToken = opts.githubToken
this.throttleOptions = opts.throttleOptions
this.Octokit = opts.Octokit || ProbotOctokit
constructor(options: Options) {
this.log = aliasLog(options.log || getLog());
// TODO: support redis backend for access token cache if `options.redisConfig || process.env.REDIS_URL`
const cache =
options.cache ||
new LRUCache<number, string>({
// cache max. 15000 tokens, that will use less than 10mb memory
max: 15000,
// Cache for 1 minute less than GitHub expiry
maxAge: Number(process.env.INSTALLATION_TOKEN_TTL) || 1000 * 60 * 59,
});
const Octokit = getProbotOctokitWithDefaults({
githubToken: options.githubToken,
Octokit: options.Octokit || ProbotOctokit,
appId: options.id,
privateKey: options.privateKey,
cache,
});
this.state = {
cache,
githubToken: options.githubToken,
log: this.log,
Octokit,
octokit: options.octokit || new Octokit(),
throttleOptions:
options.throttleOptions ||
getOctokitThrottleOptions({
log: this.log,
throttleOptions: options.throttleOptions,
redisConfig: options.redisConfig,
}),
webhooks: {
path: options.webhookPath,
secret: options.secret,
},
};
this.router = express.Router();
this.webhooks = options.webhooks || getWebhooks(this.state);
this.on = (eventNameOrNames, callback) => {
// when an app subscribes to an event using `app.on(event, callback)`, Probot sends a request to `GET /app` and
// verifies if the app is subscribed to the event and logs a warning if it is not.
//
// This feature will be moved out of Probot core as it has side effects and does not work in a stateless environment.
webhookEventCheck(this.state, eventNameOrNames);
return this.webhooks.on(eventNameOrNames, callback);
};
this.receive = this.webhooks.receive;
}
/**
* Loads an ApplicationFunction into the current Application
* @param appFn - Probot application function to load
*/
public load (appFn: ApplicationFunction | ApplicationFunction[]): Application {
public load(appFn: ApplicationFunction | ApplicationFunction[]): Application {
if (Array.isArray(appFn)) {
appFn.forEach(a => this.load(a))
appFn.forEach((a) => this.load(a));
} else {
appFn(this)
appFn(this);
}
return this
}
public async receive (event: Webhooks.WebhookEvent<any>) {
return Promise.all([
this.events.emit('*', event),
this.events.emit(event.name, event),
this.events.emit(`${ event.name }.${ event.payload.action }`, event)
])
return this;
}
/**
@ -101,360 +143,13 @@ export class Application {
* @param path - the prefix for the routes
* @returns an [express.Router](http://expressjs.com/en/4x/api.html#router)
*/
public route (path?: string): express.Router {
public route(path?: string): express.Router {
if (path) {
const router = express.Router()
this.router.use(path, router)
return router
const router = express.Router();
this.router.use(path, router);
return router;
} else {
return this.router
}
}
/**
* Listen for [GitHub webhooks](https://developer.github.com/webhooks/),
* which are fired for almost every significant action that users take on
* GitHub.
*
* @param event - the name of the [GitHub webhook
* event](https://developer.github.com/webhooks/#events). 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 `.`, like `issues.closed`.
*
* ```js
* app.on('push', context => {
* // Code was just pushed.
* });
*
* app.on('issues.opened', context => {
* // An issue was just opened.
* });
* ```
*
* @param callback - a function to call when the
* webhook is received.
*/
public on (
event:
| 'check_run'
| 'check_run.completed'
| 'check_run.created'
| 'check_run.requested_action'
| 'check_run.rerequested',
callback: OnCallback<Webhooks.WebhookPayloadCheckRun>
): void
public on (
event:
| 'check_suite'
| 'check_suite.completed'
| 'check_suite.requested'
| 'check_suite.rerequested',
callback: OnCallback<Webhooks.WebhookPayloadCheckSuite>
): void
public on (
event: 'commit_comment' | 'commit_comment.created',
callback: OnCallback<Webhooks.WebhookPayloadCommitComment>
): void
public on (
event: 'create',
callback: OnCallback<Webhooks.WebhookPayloadCreate>
): void
public on (
event: 'delete',
callback: OnCallback<Webhooks.WebhookPayloadDelete>
): void
public on (
event: 'deployment',
callback: OnCallback<Webhooks.WebhookPayloadDeployment>
): void
public on (
event: 'deployment_status',
callback: OnCallback<Webhooks.WebhookPayloadDeploymentStatus>
): void
public on (
event: 'fork',
callback: OnCallback<Webhooks.WebhookPayloadFork>
): void
public on (
event: 'github_app_authorization',
callback: OnCallback<Webhooks.WebhookPayloadGithubAppAuthorization>
): void
public on (
event: 'gollum',
callback: OnCallback<Webhooks.WebhookPayloadGollum>
): void
public on (
event: 'installation' | 'installation.created' | 'installation.deleted',
callback: OnCallback<Webhooks.WebhookPayloadInstallation>
): void
public on (
event:
| 'installation_repositories'
| 'installation_repositories.added'
| 'installation_repositories.removed',
callback: OnCallback<Webhooks.WebhookPayloadInstallationRepositories>
): void
public on (
event:
| 'issue_comment'
| 'issue_comment.created'
| 'issue_comment.deleted'
| 'issue_comment.edited',
callback: OnCallback<Webhooks.WebhookPayloadIssueComment>
): void
public on (
event:
| 'issues'
| 'issues.assigned'
| 'issues.closed'
| 'issues.deleted'
| 'issues.demilestoned'
| 'issues.edited'
| 'issues.labeled'
| 'issues.milestoned'
| 'issues.opened'
| 'issues.reopened'
| 'issues.transferred'
| 'issues.unassigned'
| 'issues.unlabeled',
callback: OnCallback<Webhooks.WebhookPayloadIssues>
): void
public on (
event: 'label' | 'label.created' | 'label.deleted' | 'label.edited',
callback: OnCallback<Webhooks.WebhookPayloadLabel>
): void
public on (
event:
| 'marketplace_purchase'
| 'marketplace_purchase.cancelled'
| 'marketplace_purchase.changed'
| 'marketplace_purchase.pending_change'
| 'marketplace_purchase.pending_change_cancelled'
| 'marketplace_purchase.purchased',
callback: OnCallback<Webhooks.WebhookPayloadMarketplacePurchase>
): void
public on (
event: 'member' | 'member.added' | 'member.deleted' | 'member.edited',
callback: OnCallback<Webhooks.WebhookPayloadMember>
): void
public on (
event: 'membership' | 'membership.added' | 'membership.removed',
callback: OnCallback<Webhooks.WebhookPayloadMembership>
): void
public on (
event:
| 'milestone'
| 'milestone.closed'
| 'milestone.created'
| 'milestone.deleted'
| 'milestone.edited'
| 'milestone.opened',
callback: OnCallback<Webhooks.WebhookPayloadMilestone>
): void
public on (
event:
| 'organization'
| 'organization.member_added'
| 'organization.member_invited'
| 'organization.member_removed',
callback: OnCallback<Webhooks.WebhookPayloadOrganization>
): void
public on (
event: 'org_block' | 'org_block.blocked' | 'org_block.unblocked',
callback: OnCallback<Webhooks.WebhookPayloadOrgBlock>
): void
public on (
event: 'page_build',
callback: OnCallback<Webhooks.WebhookPayloadPageBuild>
): void
public on (
event:
| 'project_card'
| 'project_card.converted'
| 'project_card.created'
| 'project_card.deleted'
| 'project_card.edited'
| 'project_card.moved',
callback: OnCallback<Webhooks.WebhookPayloadProjectCard>
): void
public on (
event:
| 'project_column'
| 'project_column.created'
| 'project_column.deleted'
| 'project_column.edited'
| 'project_column.moved',
callback: OnCallback<Webhooks.WebhookPayloadProjectColumn>
): void
public on (
event:
| 'project'
| 'project.closed'
| 'project.created'
| 'project.deleted'
| 'project.edited'
| 'project.reopened',
callback: OnCallback<Webhooks.WebhookPayloadProject>
): void
public on (
event: 'public',
callback: OnCallback<Webhooks.WebhookPayloadPublic>
): void
public on (
event:
| 'pull_request'
| 'pull_request.assigned'
| 'pull_request.closed'
| 'pull_request.edited'
| 'pull_request.labeled'
| 'pull_request.opened'
| 'pull_request.reopened'
| 'pull_request.review_request_removed'
| 'pull_request.review_requested'
| 'pull_request.unassigned'
| 'pull_request.unlabeled'
| 'pull_request.synchronize',
callback: OnCallback<Webhooks.WebhookPayloadPullRequest>
): void
public on (
event:
| 'pull_request_review'
| 'pull_request_review.dismissed'
| 'pull_request_review.edited'
| 'pull_request_review.submitted',
callback: OnCallback<Webhooks.WebhookPayloadPullRequestReview>
): void
public on (
event:
| 'pull_request_review_comment'
| 'pull_request_review_comment.created'
| 'pull_request_review_comment.deleted'
| 'pull_request_review_comment.edited',
callback: OnCallback<Webhooks.WebhookPayloadPullRequestReviewComment>
): void
public on (
event: 'push',
callback: OnCallback<Webhooks.WebhookPayloadPush>
): void
public on (
event: 'release' | 'release.published',
callback: OnCallback<Webhooks.WebhookPayloadRelease>
): void
public on (
event:
| 'repository'
| 'repository.archived'
| 'repository.created'
| 'repository.deleted'
| 'repository.privatized'
| 'repository.publicized'
| 'repository.unarchived',
callback: OnCallback<Webhooks.WebhookPayloadRepository>
): void
public on (
event: 'repository_import',
callback: OnCallback<Webhooks.WebhookPayloadRepositoryImport>
): void
public on (
event:
| 'repository_vulnerability_alert'
| 'repository_vulnerability_alert.create'
| 'repository_vulnerability_alert.dismiss'
| 'repository_vulnerability_alert.resolve',
callback: OnCallback<Webhooks.WebhookPayloadRepositoryVulnerabilityAlert>
): void
public on (
event:
| 'security_advisory'
| 'security_advisory.performed'
| 'security_advisory.published'
| 'security_advisory.updated',
callback: OnCallback<Webhooks.WebhookPayloadSecurityAdvisory>
): void
public on (
event: 'status',
callback: OnCallback<Webhooks.WebhookPayloadStatus>
): void
public on (
event:
| 'team'
| 'team.added_to_repository'
| 'team.created'
| 'team.deleted'
| 'team.edited'
| 'team.removed_from_repository',
callback: OnCallback<Webhooks.WebhookPayloadTeam>
): void
public on (
event: 'team_add',
callback: OnCallback<Webhooks.WebhookPayloadTeamAdd>
): void
public on (
event: 'watch' | 'watch.started',
callback: OnCallback<Webhooks.WebhookPayloadWatch>
): void
public on (eventName: string | string[], callback: OnCallback<any>): void
public on (eventName: string | string[], callback: (context: Context) => Promise<void>) {
if (typeof eventName === 'string') {
void webhookEventCheck(this, eventName)
return this.events.on(eventName, async (event: Webhooks.WebhookEvent<any>) => {
const log = this.log.child({ name: 'event', id: event.id })
try {
const github = await this.authenticateEvent(event, log)
const context = new Context(event, github, log)
await callback(context)
} catch (err) {
log.error({ err, event })
throw err
}
})
} else {
eventName.forEach(e => this.on(e, callback))
return this.router;
}
}
@ -482,71 +177,14 @@ export class Application {
* [app APIs](https://developer.github.com/v3/apps/).
*
* @returns An authenticated GitHub API client
* @private
*/
public async auth (id?: number, log = this.log): Promise<GitHubAPI> {
if (process.env.GHE_HOST && /^https?:\/\//.test(process.env.GHE_HOST)) {
throw new Error('Your \`GHE_HOST\` environment variable should not begin with https:// or http://')
}
// if installation ID passed, instantiate and authenticate Octokit, then cache the instance
// so that it can be used across received webhook events.
if (id) {
const options = {
Octokit: this.Octokit,
auth: async () => {
const accessToken = await this.app.getInstallationAccessToken({ installationId: id })
return `token ${accessToken}`
},
baseUrl: process.env.GHE_HOST && `${process.env.GHE_PROTOCOL || 'https'}://${process.env.GHE_HOST}/api/v3`,
logger: log.child({ name: 'github', installation: String(id) })
}
if (this.throttleOptions) {
return GitHubAPI({
...options,
throttle: {
id,
...this.throttleOptions
}
})
}
// Cache for 1 minute less than GitHub expiry
const installationTokenTTL = parseInt(process.env.INSTALLATION_TOKEN_TTL || '3540', 10)
return this.cache.wrap(`app:${id}`, () => GitHubAPI(options), { ttl: installationTokenTTL })
}
const token = this.githubToken || this.app.getSignedJsonWebToken()
// we assume that if throttling is disabled, retrying requests should be disabled, too.
const retryOptions =
this.throttleOptions && this.throttleOptions.enabled === false
? this.throttleOptions
: undefined
const github = GitHubAPI({
Octokit: this.Octokit,
auth: `Bearer ${token}`,
baseUrl: process.env.GHE_HOST && `${process.env.GHE_PROTOCOL || 'https'}://${process.env.GHE_HOST}/api/v3`,
logger: log.child({ name: 'github' }),
retry: retryOptions,
throttle: this.throttleOptions
})
return github
}
private authenticateEvent (event: Webhooks.WebhookEvent<any>, log: LoggerWithTarget): Promise<GitHubAPI> {
if (this.githubToken) {
return this.auth()
}
if (isUnauthenticatedEvent(event)) {
log.debug('`context.github` is unauthenticated. See https://probot.github.io/docs/github-api/#unauthenticated-events')
return this.auth()
}
return this.auth(event.payload.installation.id, log)
public async auth(
installationId?: number,
log?: Logger
): Promise<InstanceType<typeof ProbotOctokit>> {
return getAuthenticatedOctokit(
Object.assign({}, this.state, log ? { log } : null),
installationId
);
}
}

View File

@ -1,18 +1,18 @@
import path from 'path'
import { Application } from '../application'
import path from "path";
import { Application } from "../application";
export = (app: Application) => {
const route = app.route()
const route = app.route();
route.get('/probot', (req, res) => {
let pkg
route.get("/probot", (req, res) => {
let pkg;
try {
pkg = require(path.join(process.cwd(), 'package.json'))
pkg = require(path.join(process.cwd(), "package.json"));
} catch (e) {
pkg = {}
pkg = {};
}
res.render('probot.hbs', pkg)
})
route.get('/', (req, res, next) => res.redirect('/probot'))
}
res.render("probot.hbs", pkg);
});
route.get("/", (req, res, next) => res.redirect("/probot"));
};

View File

@ -1,16 +0,0 @@
import sentryStream from 'bunyan-sentry-stream'
import Raven from 'raven'
import { Application } from '../application'
export = (app: Application) => {
// If sentry is configured, report all logged errors
if (process.env.SENTRY_DSN) {
app.log.debug(process.env.SENTRY_DSN, 'Errors will be reported to Sentry')
Raven.disableConsoleAlerts()
Raven.config(process.env.SENTRY_DSN, {
autoBreadcrumbs: true
}).install()
app.log.target.addStream(sentryStream(Raven))
}
}

View File

@ -1,55 +1,66 @@
import { exec } from 'child_process'
import { Request, Response } from 'express'
import { Application } from '../application'
import { ManifestCreation } from '../manifest-creation'
import { exec } from "child_process";
import { Request, Response } from "express";
import { Application } from "../application";
import { ManifestCreation } from "../manifest-creation";
import { getLoggingMiddleware } from "../server/logging-middleware";
// use glitch env to get correct domain welcome message
// https://glitch.com/help/project/
const domain = process.env.PROJECT_DOMAIN || `http://localhost:${process.env.PORT || 3000}`
const welcomeMessage = `\nWelcome to Probot! Go to ${domain} to get started.\n`
const domain =
process.env.PROJECT_DOMAIN || `http://localhost:${process.env.PORT || 3000}`;
const welcomeMessage = `Welcome to Probot! Go to ${domain} to get started.`;
export async function setupApp(app: Application) {
const setup: ManifestCreation = new ManifestCreation();
export = async (app: Application, setup: ManifestCreation = new ManifestCreation()) => {
// If not on Glitch or Production, create a smee URL
if (process.env.NODE_ENV !== 'production' && !(process.env.PROJECT_DOMAIN || process.env.WEBHOOK_PROXY_URL)) {
await setup.createWebhookChannel()
if (
process.env.NODE_ENV !== "production" &&
!(process.env.PROJECT_DOMAIN || process.env.WEBHOOK_PROXY_URL)
) {
await setup.createWebhookChannel();
}
const route = app.route()
const route = app.route();
app.log.info(welcomeMessage)
route.use(getLoggingMiddleware(app.log));
route.get('/probot', async (req, res) => {
const protocols = req.headers['x-forwarded-proto'] || req.protocol
const protocol = typeof protocols === 'string' ? protocols.split(',')[0] : protocols[0]
const host = req.headers['x-forwarded-host'] || req.get('host')
const baseUrl = `${protocol}://${host}`
app.log.info(welcomeMessage);
const pkg = setup.pkg
const manifest = setup.getManifest(pkg, baseUrl)
const createAppUrl = setup.createAppUrl
route.get("/probot", async (req, res) => {
const protocols = req.headers["x-forwarded-proto"] || req.protocol;
const protocol =
typeof protocols === "string" ? protocols.split(",")[0] : protocols[0];
const host = req.headers["x-forwarded-host"] || req.get("host");
const baseUrl = `${protocol}://${host}`;
const pkg = setup.pkg;
const manifest = setup.getManifest(pkg, baseUrl);
const createAppUrl = setup.createAppUrl;
// Pass the manifest to be POST'd
res.render('setup.hbs', { pkg, createAppUrl, manifest })
})
res.render("setup.hbs", { pkg, createAppUrl, manifest });
});
route.get('/probot/setup', async (req: Request, res: Response) => {
const { code } = req.query
const response = await setup.createAppFromCode(code)
route.get("/probot/setup", async (req: Request, res: Response) => {
const { code } = req.query;
const response = await setup.createAppFromCode(code);
// If using glitch, restart the app
if (process.env.PROJECT_DOMAIN) {
exec('refresh', (err, stdout, stderr) => {
if (err) {
app.log.error(err, stderr)
exec("refresh", (error) => {
if (error) {
app.log.error(error);
}
})
});
}
res.redirect(`${response}/installations/new`)
})
res.redirect(`${response}/installations/new`);
});
route.get('/probot/success', async (req, res) => {
res.render('success.hbs')
})
route.get("/probot/success", async (req, res) => {
res.render("success.hbs");
});
route.get('/', (req, res, next) => res.redirect('/probot'))
route.get("/", (req, res, next) => res.redirect("/probot"));
}

View File

@ -1,94 +0,0 @@
import { Octokit } from '@octokit/rest'
import { Request, Response } from 'express'
import { Application } from '../application'
// Built-in app to expose stats about the deployment
export = async (app: Application): Promise<void> => {
if (process.env.DISABLE_STATS) {
return
}
const REFRESH_INTERVAL = 60 * 60 * 1000
// Cache of stats that get reported
const stats = { installations: 0, popular: [{}] }
// Refresh the stats when the ApplicationFunction is loaded
const initializing = refresh()
// Refresh the stats on an interval
setInterval(refresh, REFRESH_INTERVAL)
// Check for accounts (typically spammy or abusive) to ignore
const ignoredAccounts = (process.env.IGNORED_ACCOUNTS || '').toLowerCase().split(',')
// Setup /probot/stats endpoint to return cached stats
app.router.get('/probot/stats', async (req: Request, res: Response) => {
// Deprecated since 9.12.0
// tslint:disable-next-line:no-console
console.warn(
`[Probot] "GET /probot/stats" is deprecated and will be removed in Probot v10. See https://github.com/probot/probot/issues/1267 for an alternative`
)
// ensure stats are loaded
await initializing
res.json(stats)
})
async function refresh () {
const installations = await getInstallations()
stats.installations = installations.length
stats.popular = await popularInstallations(installations)
}
async function getInstallations (): Promise<Installation[]> {
const github = await app.auth()
return github.paginate(github.apps.listInstallations.endpoint.merge({ per_page: 100 }), (response: Octokit.AnyResponse) => {
return response.data
})
}
async function popularInstallations (installations: Installation[]): Promise<Account[]> {
let popular = await Promise.all(installations.map(async (installation) => {
const { account } = installation
if (ignoredAccounts.includes(account.login.toLowerCase())) {
account.stars = 0
app.log.debug({ installation }, 'Installation is ignored')
return account
}
const github = await app.auth(installation.id)
const repositories: Repository[] = await github.paginate(github.apps.listRepos.endpoint.merge({ per_page: 100 }), (response: Octokit.AnyResponse) => {
return response.data.filter((repository: Repository) => !repository.private)
})
account.stars = repositories.reduce((stars, repository) => {
return stars + repository.stargazers_count
}, 0)
return account
}))
popular = popular.filter(installation => installation.stars > 0)
return popular.sort((a, b) => b.stars - a.stars).slice(0, 10)
}
}
interface Installation {
id: number
account: Account
}
interface Account {
stars: number
login: string
}
interface Repository {
private: boolean
stargazers_count: number
}

View File

@ -1,16 +0,0 @@
import cacheManager from 'cache-manager'
// The TypeScript definition for cache-manager does not export the Cache interface so we recreate it here
export interface Cache {
wrap<T> (key: string, wrapper: (callback: (error: any, result: T) => void) => any, options: CacheConfig): Promise<any>
}
export interface CacheConfig {
ttl: number
}
export function createDefaultCache (): Cache {
return cacheManager.caching({
store: 'memory',
ttl: 60 * 60 // 1 hour
})
}

View File

@ -1,49 +1,56 @@
import { Octokit } from '@octokit/rest'
import Webhooks, { PayloadRepository } from '@octokit/webhooks'
import merge from 'deepmerge'
import yaml from 'js-yaml'
import path from 'path'
import { GitHubAPI } from './github'
import { LoggerWithTarget } from './wrap-logger'
import path from "path";
const CONFIG_PATH = '.github'
const BASE_KEY = '_extends'
import { Endpoints } from "@octokit/types";
import { EventNames, EventPayloads, WebhookEvent } from "@octokit/webhooks";
import merge from "deepmerge";
import yaml from "js-yaml";
import type { Logger } from "pino";
import { ProbotOctokit } from "./octokit/probot-octokit";
import { aliasLog } from "./helpers/alias-log";
import { DeprecatedLogger } from "./types";
type ReposGetContentsParams = Endpoints["GET /repos/:owner/:repo/contents/:path"]["parameters"];
const CONFIG_PATH = ".github";
const BASE_KEY = "_extends";
const BASE_REGEX = new RegExp(
'^' +
'(?:([a-z\\d](?:[a-z\\d]|-(?=[a-z\\d])){0,38})/)?' + // org
'([-_.\\w\\d]+)' + // project
'(?::([-_./\\w\\d]+\\.ya?ml))?' + // filename
'$',
'i'
)
const DEFAULT_BASE = '.github'
"^" +
"(?:([a-z\\d](?:[a-z\\d]|-(?=[a-z\\d])){0,38})/)?" + // org
"([-_.\\w\\d]+)" + // project
"(?::([-_./\\w\\d]+\\.ya?ml))?" + // filename
"$",
"i"
);
const DEFAULT_BASE = ".github";
export type MergeOptions = merge.Options
export type MergeOptions = merge.Options;
interface WebhookPayloadWithRepository {
[key: string]: any
repository?: PayloadRepository
[key: string]: any;
repository?: EventPayloads.PayloadRepository;
issue?: {
[key: string]: any
number: number
html_url?: string
body?: string
}
[key: string]: any;
number: number;
html_url?: string;
body?: string;
};
pull_request?: {
[key: string]: any
number: number
html_url?: string
body?: string
}
[key: string]: any;
number: number;
html_url?: string;
body?: string;
};
sender?: {
[key: string]: any
type: string
}
action?: string
[key: string]: any;
type: string;
};
action?: string;
installation?: {
id: number
[key: string]: any
}
id: number;
[key: string]: any;
};
}
/**
@ -53,7 +60,7 @@ interface WebhookPayloadWithRepository {
* ```js
* module.exports = app => {
* app.on('push', context => {
* context.log('Code was pushed to the repo, what should we do with it?');
* context.log.info('Code was pushed to the repo, what should we do with it?');
* });
* };
* ```
@ -62,33 +69,31 @@ interface WebhookPayloadWithRepository {
* @property {payload} payload - The webhook event payload
* @property {logger} log - A logger
*/
export class Context<E extends WebhookPayloadWithRepository = any>
implements WebhookEvent<E> {
public name: EventNames.StringNames;
public id: string;
public payload: E;
export class Context<E extends WebhookPayloadWithRepository = any> implements Webhooks.WebhookEvent<E> {
public name: string
public id: string
public payload: E
public protocol?: 'http' | 'https'
public host?: string
public url?: string
public github: InstanceType<typeof ProbotOctokit>;
public log: DeprecatedLogger;
public github: GitHubAPI
public log: LoggerWithTarget
constructor(
event: WebhookEvent<E>,
github: InstanceType<typeof ProbotOctokit>,
log: Logger
) {
this.name = event.name;
this.id = event.id;
this.payload = event.payload;
constructor (event: Webhooks.WebhookEvent<E>, github: GitHubAPI, log: LoggerWithTarget) {
this.name = event.name
this.id = event.id
this.payload = event.payload
this.protocol = event.protocol
this.host = event.host
this.url = event.url
this.github = github
this.log = log
this.github = github;
this.log = aliasLog(log);
}
// Maintain backward compatibility
public get event (): string {
return this.name
public get event(): string {
return this.name;
}
/**
@ -103,44 +108,74 @@ export class Context<E extends WebhookPayloadWithRepository = any> implements We
* @param object - Params to be merged with the repo params.
*
*/
public repo<T> (object?: T) {
const repo = this.payload.repository
public repo<T>(object?: T) {
const repo = this.payload.repository;
if (!repo) {
throw new Error('context.repo() is not supported for this webhook event.')
throw new Error(
"context.repo() is not supported for this webhook event."
);
}
return Object.assign({
owner: repo.owner.login || repo.owner.name!,
repo: repo.name
}, object)
return Object.assign(
{
owner: repo.owner.login || repo.owner.name!,
repo: repo.name,
},
object
);
}
/**
* Return the `owner`, `repo`, and `number` params for making API requests
* against an issue or pull request. The object passed in will be merged with
* the repo params.
* Return the `owner`, `repo`, and `issue_number` params for making API requests
* against an issue. The object passed in will be merged with the repo params.
*
*
* ```js
* const params = context.issue({body: 'Hello World!'})
* // Returns: {owner: 'username', repo: 'reponame', number: 123, body: 'Hello World!'}
* // Returns: {owner: 'username', repo: 'reponame', issue_number: 123, body: 'Hello World!'}
* ```
*
* @param object - Params to be merged with the issue params.
*/
public issue<T> (object?: T) {
const payload = this.payload
return Object.assign({
number: (payload.issue || payload.pull_request || payload).number
}, this.repo(object))
public issue<T>(object?: T) {
const payload = this.payload;
return Object.assign(
{
issue_number: (payload.issue || payload.pull_request || payload).number,
},
this.repo(object)
);
}
/**
* Return the `owner`, `repo`, and `issue_number` params for making API requests
* against an issue. The object passed in will be merged with the repo params.
*
*
* ```js
* const params = context.pullRequest({body: 'Hello World!'})
* // Returns: {owner: 'username', repo: 'reponame', pull_number: 123, body: 'Hello World!'}
* ```
*
* @param object - Params to be merged with the pull request params.
*/
public pullRequest<T>(object?: T) {
const payload = this.payload;
return Object.assign(
{
pull_number: (payload.issue || payload.pull_request || payload).number,
},
this.repo(object)
);
}
/**
* Returns a boolean if the actor on the event was a bot.
* @type {boolean}
*/
get isBot () {
return this.payload.sender!.type === 'Bot'
get isBot() {
return this.payload.sender!.type === "Bot";
}
/**
@ -189,37 +224,42 @@ export class Context<E extends WebhookPayloadWithRepository = any> implements We
* @param deepMergeOptions - Controls merging configs (from the [deepmerge](https://github.com/TehShrike/deepmerge) module)
* @return Configuration object read from the file
*/
public async config<T> (fileName: string, defaultConfig?: T, deepMergeOptions?: MergeOptions): Promise<T | null> {
const params = this.repo({ path: path.posix.join(CONFIG_PATH, fileName) })
const config = await this.loadYaml(params)
public async config<T>(
fileName: string,
defaultConfig?: T,
deepMergeOptions?: MergeOptions
): Promise<T | null> {
const params = this.repo({ path: path.posix.join(CONFIG_PATH, fileName) });
let baseRepo
const config = await this.loadYaml(params);
let baseRepo;
if (config == null) {
baseRepo = DEFAULT_BASE
baseRepo = DEFAULT_BASE;
} else if (config != null && BASE_KEY in config) {
baseRepo = config[BASE_KEY]
delete config[BASE_KEY]
baseRepo = config[BASE_KEY];
delete config[BASE_KEY];
}
let baseConfig
let baseConfig;
if (baseRepo) {
if (typeof baseRepo !== 'string') {
throw new Error(`Invalid repository name in key "${BASE_KEY}"`)
if (typeof baseRepo !== "string") {
throw new Error(`Invalid repository name in key "${BASE_KEY}"`);
}
const baseParams = this.getBaseParams(params, baseRepo)
baseConfig = await this.loadYaml(baseParams)
const baseParams = this.getBaseParams(params, baseRepo);
baseConfig = await this.loadYaml(baseParams);
}
if (config == null && baseConfig == null && !defaultConfig) {
return null
return null;
}
return merge.all(
return (merge.all(
// filter out null configs
[defaultConfig, baseConfig, config].filter(conf => conf),
[defaultConfig, baseConfig, config].filter((conf) => conf),
deepMergeOptions
) as unknown as T
) as unknown) as T;
}
/**
@ -228,30 +268,39 @@ export class Context<E extends WebhookPayloadWithRepository = any> implements We
* @param params Params to fetch the file with
* @return The parsed YAML file
*/
private async loadYaml<T> (params: Octokit.ReposGetContentsParams): Promise<any> {
private async loadYaml<T>(params: ReposGetContentsParams): Promise<any> {
try {
const response = await this.github.repos.getContents(params)
// https://docs.github.com/en/rest/reference/repos#get-repository-content
const response = await this.github.request(
"GET /repos/{owner}/{repo}/contents/{path}",
params
);
// Ignore in case path is a folder
// - https://developer.github.com/v3/repos/contents/#response-if-content-is-a-directory
if (Array.isArray(response.data)) {
return null
return null;
}
// we don't handle symlinks or submodule
// - https://developer.github.com/v3/repos/contents/#response-if-content-is-a-symlink
// - https://developer.github.com/v3/repos/contents/#response-if-content-is-a-submodule
if (typeof response.data.content !== 'string') {
return
// tslint:disable-next-line
if (typeof response.data.content !== "string") {
return;
}
return yaml.safeLoad(Buffer.from(response.data.content, 'base64').toString()) || {}
return (
yaml.safeLoad(
Buffer.from(response.data.content, "base64").toString()
) || {}
);
} catch (e) {
if (e.status === 404) {
return null
return null;
}
throw e
throw e;
}
}
@ -265,16 +314,19 @@ export class Context<E extends WebhookPayloadWithRepository = any> implements We
* @param base A string specifying the base repository
* @return The params of the base configuration
*/
private getBaseParams (params: Octokit.ReposGetContentsParams, base: string): Octokit.ReposGetContentsParams {
const match = base.match(BASE_REGEX)
private getBaseParams(
params: ReposGetContentsParams,
base: string
): ReposGetContentsParams {
const match = base.match(BASE_REGEX);
if (match === null) {
throw new Error(`Invalid repository name in key "${BASE_KEY}": ${base}`)
throw new Error(`Invalid repository name in key "${BASE_KEY}": ${base}`);
}
return {
owner: match[1] || params.owner,
path: match[3] || params.path,
repo: match[2]
}
repo: match[2],
};
}
}

View File

@ -1,44 +0,0 @@
// tslint:disable-next-line
import { withCustomRequest } from '@octokit/graphql'
import { request } from '@octokit/request'
import {
GitHubAPI
} from './'
export interface GraphQLError {
message: string,
locations?: Array<{ line: number, column: number }>,
path?: Array<string | number>,
extensions?: {
[key: string]: any
}
}
export function addGraphQL (client: GitHubAPI) {
const graphqlRequest = (client.request as any as typeof request).defaults({
...(process.env.GHE_HOST ? { baseUrl: `${process.env.GHE_PROTOCOL || 'https'}://${process.env.GHE_HOST}/api` } : {})
})
const graphql = withCustomRequest(graphqlRequest)
client.graphql = (...args: any[]): any => {
if (args[2]) {
// tslint:disable-next-line:no-console
console.warn(`github.graphql: passing extra headers as 3rd argument is deprecated. You can pass headers in the 2nd argument instead, using the "headers" key:
github.graphql(query, { headers })
See https://probot.github.io/docs/github-api/#graphql-api`)
args[1] = Object.assign(args[1] || {}, { headers: args[2] })
}
return graphql(args[0], args[1])
}
// tslint:disable-next-line:deprecation
client.query = (...args: any[]): any => {
// tslint:disable-next-line:no-console
console.warn('github.query is deprecated. Use github.graphql instead')
return client.graphql(args[0], args[1], args[2])
}
}

View File

@ -1,106 +0,0 @@
import { graphql } from '@octokit/graphql'
import { enterpriseCompatibility } from '@octokit/plugin-enterprise-compatibility'
import { retry } from '@octokit/plugin-retry'
import { throttling } from '@octokit/plugin-throttling'
import { Octokit } from '@octokit/rest'
import { addGraphQL } from './graphql'
import { addLogging, Logger } from './logging'
import { addPagination } from './pagination'
export const ProbotOctokit = Octokit
.plugin([throttling, retry, enterpriseCompatibility])
/**
* the [@octokit/rest Node.js module](https://github.com/octokit/rest.js),
* 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.
* @see {@link https://github.com/octokit/rest.js}
*/
export function GitHubAPI (options: Options = {} as any) {
const OctokitFromOptions = options.Octokit || ProbotOctokit
const octokit = new OctokitFromOptions(Object.assign(options, {
throttle: Object.assign({
onAbuseLimit: (retryAfter: number) => {
options.logger.warn(`Abuse limit hit, retrying in ${retryAfter} seconds`)
return true
},
onRateLimit: (retryAfter: number) => {
options.logger.warn(`Rate limit hit, retrying in ${retryAfter} seconds`)
return true
}
}, options.throttle)
})) as GitHubAPI
addPagination(octokit)
addLogging(octokit, options.logger)
addGraphQL(octokit)
return octokit
}
export interface Options extends Octokit.Options {
debug?: boolean
logger: Logger
Octokit?: Octokit.Static
}
export interface RequestOptions {
baseUrl?: string
method?: string
url?: string
headers?: any
query?: string
variables?: Variables
data?: any
}
export interface Result {
headers: {
status: string
}
}
export interface OctokitError extends Error {
status: number
}
interface Paginate extends Octokit.Paginate {
(
responsePromise: Promise<Octokit.AnyResponse>,
callback?: (response: Octokit.AnyResponse, done: () => void) => any
): Promise<any[]>
}
type Graphql = (query: string, variables?: Variables, headers?: Headers) => ReturnType<typeof graphql>
export interface GitHubAPI extends Octokit {
paginate: Paginate
graphql: Graphql
/**
* @deprecated `.query()` is deprecated, use `.graphql()` instead
*/
query: Graphql
}
export interface GraphQlQueryResponse {
data: { [ key: string ]: any } | null
errors?: [{
message: string
path: [string]
extensions: { [ key: string ]: any }
locations: [{
line: number,
column: number
}]
}]
}
export interface Headers {
[key: string]: string
}
export interface Variables { [key: string]: any }
export { GraphQLError } from './graphql'

View File

@ -1,22 +0,0 @@
import Logger from 'bunyan'
import { GitHubAPI } from './'
export function addLogging (client: GitHubAPI, logger: Logger) {
if (!logger) {
return
}
client.hook.error('request', (error, options) => {
const { method, url, headers, ...params } = options
const msg = `GitHub request: ${method} ${url} - ${error.status}`
logger.debug({ params }, msg)
throw error
})
client.hook.after('request', (result, options) => {
const { method, url, headers, ...params } = options
const msg = `GitHub request: ${method} ${url} - ${result.headers.status}`
logger.debug({ params }, msg)
})
}
export { Logger }

View File

@ -1,61 +0,0 @@
import { Octokit } from '@octokit/rest'
// tslint:disable-next-line
const octokitGetNextPage = require('octokit-pagination-methods/lib/get-next-page')
// tslint:disable-next-line
const octokitHasNextPage = require('octokit-pagination-methods/lib/has-next-page')
export function addPagination (octokit: Octokit) {
const octokitPaginate = octokit.paginate
octokit.paginate = Object.assign(
(...args: any[]) => paginate(octokit, octokitPaginate, args[0], args[1], args[2]),
{ iterator: octokit.paginate.iterator }
)
}
const defaultCallback = (response: Octokit.AnyResponse, done: () => void) => response
async function paginate (octokit: Octokit, octokitPaginate: Octokit.Paginate, ...args: any[]) {
// Until we fully deprecate the old paginate method, we need to check if the
// first argument. If it is a promise we return the old function signature
if (!args[0].then) {
return octokitPaginate(args[0], args[1], args[2])
}
const responsePromise = args[0]
const callback = args[1] || defaultCallback
// Deprecated since 8.0.0
// tslint:disable-next-line:no-console
console.warn(new Error(`.paginate(promise) is deprecated. Use .paginate(endpointOptions) instead.
For example, instead of
context.github.paginate(context.github.issues.getAll(context.repo())
do
context.github.paginate(context.github.issues.getAll.endpoint.merge(context.repo())
Note that when using the new syntax, the responses will be mapped to its data only by default.
See https://probot.github.io/docs/pagination/`))
let collection: any[] = []
let getNextPage = true
const done = () => {
getNextPage = false
}
let response = await responsePromise
collection = collection.concat(callback(response, done))
// eslint-disable-next-line no-unmodified-loop-condition
while (getNextPage && octokitHasNextPage(response)) {
response = await octokitGetNextPage(octokit, response)
collection = collection.concat(callback(response, done))
}
return collection
}

23
src/helpers/alias-log.ts Normal file
View File

@ -0,0 +1,23 @@
import type { Logger } from "pino";
import type { DeprecatedLogger } from "../types";
/**
* `probot.log()`, `app.log()` and `context.log()` are aliasing `.log.info()`.
* We will probably remove the aliasing in future.
*/
export function aliasLog(log: Logger): DeprecatedLogger {
function logInfo() {
// @ts-ignore
log.info(...arguments);
}
for (const key in log) {
// @ts-ignore
logInfo[key] =
typeof log[key] === "function" ? log[key].bind(log) : log[key];
}
// @ts-ignore
return logInfo;
}

View File

@ -0,0 +1,37 @@
import type { Logger } from "pino";
import { WebhookError } from "@octokit/webhooks";
export function getErrorHandler(log: Logger) {
return (error: Error) => {
const errors = (error.name === "AggregateError"
? error
: [error]) as WebhookError[];
for (const error of errors) {
const errMessage = (error.message || "").toLowerCase();
if (errMessage.includes("x-hub-signature")) {
log.error(
error,
"Go to https://github.com/settings/apps/YOUR_APP and verify that the Webhook secret matches the value of the WEBHOOK_SECRET environment variable."
);
continue;
}
if (errMessage.includes("pem") || errMessage.includes("json web token")) {
log.error(
error,
"Your private key (usually a .pem file) is not correct. Go to https://github.com/settings/apps/YOUR_APP and generate a new PEM file. If you're deploying to Now, visit https://probot.github.io/docs/deployment/#now."
);
continue;
}
log
.child({
name: "event",
id: error.event ? error.event.id : undefined,
})
.error(error);
}
};
}

33
src/helpers/get-log.ts Normal file
View File

@ -0,0 +1,33 @@
/**
* A logger backed by [pino](https://getpino.io/)
*
* The default log level is `info`, but you can change it by setting the
* `LOG_LEVEL` environment variable to `trace`, `debug`, `info`, `warn`,
* `error`, or `fatal`.
*
* By default, logs are formatted for readability in development. If you intend
* to drain logs to a logging service, set the `NODE_ENV` variable, e.g. `NODE_ENV=production probot run index.js`.
*
* ```js
* app.log.debug("…so is this");
* app.log.trace("Now we're talking");
* app.log.info("I thought you should know…");
* app.log.warn("Woah there");
* app.log.error("ETOOMANYLOGS");
* app.log.fatal("Goodbye, cruel world!");
* ```
*/
import pino from "pino";
import type { LoggerOptions } from "pino";
import { getTransformStream } from "@probot/pino";
export function getLog() {
const pinoOptions: LoggerOptions = {
level: process.env.LOG_LEVEL || "info",
name: "probot",
};
const transform = getTransformStream();
transform.pipe(pino.destination(1));
return pino(pinoOptions, transform);
}

View File

@ -0,0 +1,69 @@
import fs from "fs";
// tslint:disable-next-line:no-var-requires
const isBase64 = require("is-base64");
const hint = `please use:
* \`--private-key=/path/to/private-key\` flag, or
* \`PRIVATE_KEY\` environment variable, or
* \`PRIVATE_KEY_PATH\` environment variable
`;
/**
* Finds a private key through various user-(un)specified methods.
* Order of precedence:
* 1. Explicit path (CLI option)
* 2. `PRIVATE_KEY` env var
* 3. `PRIVATE_KEY_PATH` env var
* 4. Any file w/ `.pem` extension in current working dir
* @param filepath - Explicit, user-defined path to keyfile
* @returns Private key
* @private
*/
export function findPrivateKey(filepath?: string): string | null {
if (filepath) {
return fs.readFileSync(filepath, "utf8");
}
if (process.env.PRIVATE_KEY) {
let privateKey = process.env.PRIVATE_KEY;
if (isBase64(privateKey)) {
// Decode base64-encoded certificate
privateKey = Buffer.from(privateKey, "base64").toString();
}
const begin = "-----BEGIN RSA PRIVATE KEY-----";
const end = "-----END RSA PRIVATE KEY-----";
if (privateKey.includes(begin) && privateKey.includes(end)) {
// Full key with new lines
return privateKey.replace(/\\n/g, "\n");
}
throw new Error(
"The contents of `PRIVATE_KEY` could not be validated. Please check to ensure you have copied the contents of the .pem file correctly."
);
}
if (process.env.PRIVATE_KEY_PATH) {
if (fs.existsSync(process.env.PRIVATE_KEY_PATH)) {
return fs.readFileSync(process.env.PRIVATE_KEY_PATH, "utf8");
} else {
throw new Error(
`Private key does not exists at path: ${process.env.PRIVATE_KEY_PATH}. Please check to ensure that the PRIVATE_KEY_PATH is correct.`
);
}
}
const pemFiles = fs
.readdirSync(process.cwd())
.filter((path) => path.endsWith(".pem"));
if (pemFiles.length > 1) {
throw new Error(
`Found several private keys: ${pemFiles.join(", ")}. ` +
`To avoid ambiguity ${hint}`
);
} else if (pemFiles[0]) {
return findPrivateKey(pemFiles[0]);
}
return null;
}

View File

@ -0,0 +1,17 @@
import { getLog } from "./get-log";
export function logWarningsForObsoleteEnvironmentVariables() {
const log = getLog();
// TODO: remove deprecation warning in v11
if ("DISABLE_STATS" in process.env) {
// tslint:disable:no-console
log.warn('[probot] "DISABLE_STATS" has been removed in v10');
}
// TODO: remove deprecation warning in v11
if ("IGNORED_ACCOUNTS" in process.env) {
// tslint:disable:no-console
log.warn('[probot] "IGNORED_ACCOUNTS" has been removed in v10');
}
}

View File

@ -0,0 +1,18 @@
import { sync } from "resolve";
const defaultOptions: ResolveOptions = {};
export const resolveAppFunction = (appFnId: string, opts?: ResolveOptions) => {
opts = opts || defaultOptions;
// These are mostly to ease testing
const basedir = opts.basedir || process.cwd();
const resolver: Resolver = opts.resolver || sync;
return require(resolver(appFnId, { basedir }));
};
export type Resolver = (appFnId: string, opts: { basedir: string }) => string;
export interface ResolveOptions {
basedir?: string;
resolver?: Resolver;
}

View File

@ -0,0 +1,190 @@
import { Endpoints } from "@octokit/types";
import { State } from "../types";
type AppsGetAuthenticatedResponse = Endpoints["GET /app"]["response"]["data"];
let appMeta: Promise<AppsGetAuthenticatedResponse> | null = null;
let didFailRetrievingAppMeta = false;
/**
* Check if an application is subscribed to an event.
*
* @returns Returns `false` if the app is not subscribed to an event. Otherwise,
* returns `true`. Returns `undefined` if the webhook-event-check feature is
* disabled or if Probot failed to retrieve the GitHub App's metadata.
*/
export async function webhookEventCheck(
state: State,
eventNameOrNames: string | string[]
) {
if (isWebhookEventCheckEnabled() === false) {
return;
}
const eventNames = Array.isArray(eventNameOrNames)
? eventNameOrNames
: [eventNameOrNames];
const uniqueBaseEventNames = [
...new Set(eventNames.map((name) => name.split(".")[0])),
];
let subscribedCount = 0;
for (const baseEventName of uniqueBaseEventNames) {
if (await isSubscribedToEvent(state, baseEventName)) {
subscribedCount++;
} else if (didFailRetrievingAppMeta === false) {
const subscribedTo = JSON.stringify(eventNameOrNames);
const humanName = baseEventName.split(/_/).join(" ");
state.log.error(
`Your app is attempting to listen to ${subscribedTo}, but your GitHub App is not subscribed to the "${humanName}" event.`
);
}
}
if (subscribedCount === uniqueBaseEventNames.length) {
return true;
}
return didFailRetrievingAppMeta ? undefined : false;
}
/**
* @param {string} baseEventName The base event name refers to the part before
* the first period mark (e.g. the `issues` part in `issues.opened`).
* @returns Returns `false` when the application is not subscribed to a webhook
* event. Otherwise, returns `true`. Returns `undefined` if Probot failed to
* retrieve GitHub App metadata.
*
* **Note**: Probot will only check against a list of events known to be in the
* `GET /app` response. Therefore, only the `false` value should be considered
* truthy.
*/
async function isSubscribedToEvent(state: State, baseEventName: string) {
// A list of events known to be in the response of `GET /app`. This list can
// be retrieved by calling `GET /app` from an authenticated app that has
// maximum permissions and is subscribed to all available webhook events.
const knownBaseEvents = [
"check_run",
"check_suite",
"commit_comment",
"content_reference",
"create",
"delete",
"deployment",
"deployment_status",
"deploy_key",
"fork",
"gollum",
"issues",
"issue_comment",
"label",
"member",
"membership",
"milestone",
"organization",
"org_block",
"page_build",
"project",
"project_card",
"project_column",
"public",
"pull_request",
"pull_request_review",
"pull_request_review_comment",
"push",
"release",
"repository",
"repository_dispatch",
"star",
"status",
"team",
"team_add",
"watch",
];
// Because `GET /app` does not include all events - such as default events
// that all GitHub Apps are subscribed to (e.g.`installation`, `meta`, or
// `marketplace_purchase`) - we can only check `baseEventName` if it is known
// to be in the `GET /app` response.
const eventMayExistInAppResponse = knownBaseEvents.includes(baseEventName);
if (!eventMayExistInAppResponse) {
return true;
}
let events;
try {
events = (await retrieveAppMeta(state)).events;
} catch (e) {
if (!didFailRetrievingAppMeta) {
state.log.warn(e);
}
didFailRetrievingAppMeta = true;
return;
}
return events.includes(baseEventName);
}
async function retrieveAppMeta(state: State) {
if (appMeta) return appMeta;
appMeta = new Promise(async (resolve, reject) => {
try {
const { data } = await state.octokit.apps.getAuthenticated();
return resolve(data);
} catch (e) {
state.log.trace(e);
/**
* There are a few reasons why Probot might be unable to retrieve
* application metadata.
*
* - Probot may not be connected to the Internet.
* - The GitHub API is not responding to requests (see
* https://www.githubstatus.com/).
* - The user has incorrectly configured environment variables (e.g.
* APP_ID, PRIVATE_KEY, etc.) used for authentication between the Probot
* app and the GitHub API.
*/
return reject(
[
"Probot is unable to retrieve app information from GitHub for event subscription verification.",
"",
"If this error persists, feel free to raise an issue at:",
" - https://github.com/probot/probot/issues",
].join("\n")
);
}
});
return appMeta;
}
function isWebhookEventCheckEnabled() {
if (process.env.DISABLE_WEBHOOK_EVENT_CHECK?.toLowerCase() === "true") {
return false;
} else if (process.env.NODE_ENV?.toLowerCase() === "production") {
return false;
} else if (inTestEnvironment()) {
// We disable the feature in test environments to avoid requiring developers
// to add a stub mocking the `GET /app` route this feature calls.
return false;
}
return true;
}
/**
* Detects if Probot is likely running in a test environment.
*
* **Note**: This method only detects Jest environments or when NODE_ENV starts
* with `test`.
* @returns Returns `true` if Probot is in a test environment.
*/
function inTestEnvironment(): boolean {
const nodeEnvContainsTest =
process.env.NODE_ENV?.substr(0, 4).toLowerCase() === "test";
const isRunningJest = process.env.JEST_WORKER_ID !== undefined;
return nodeEnvContainsTest || isRunningJest;
}

View File

@ -0,0 +1,29 @@
import EventSource from "eventsource";
import type { Logger } from "pino";
export const createWebhookProxy = (
opts: WebhookProxyOptions
): EventSource | undefined => {
try {
const SmeeClient = require("smee-client");
const smee = new SmeeClient({
logger: opts.logger,
source: opts.url,
target: `http://localhost:${opts.port}${opts.path}`,
});
return smee.start();
} catch (error) {
opts.logger.warn(
"Run `npm install --save-dev smee-client` to proxy webhooks to localhost."
);
return;
}
};
export interface WebhookProxyOptions {
url?: string;
port?: number;
path?: string;
logger: Logger;
}

View File

@ -1,253 +1,346 @@
// tslint:disable-next-line: no-var-requires
require('dotenv').config()
require("dotenv").config();
import { App as OctokitApp } from '@octokit/app'
import { Octokit } from '@octokit/rest'
import Webhooks from '@octokit/webhooks'
import Bottleneck from 'bottleneck'
import Logger from 'bunyan'
import express from 'express'
import Redis from 'ioredis'
import express from "express";
import Redis from "ioredis";
import LRUCache from "lru-cache";
import { Deprecation } from "deprecation";
import pinoHttp from "pino-http";
import { Server } from 'http'
import { Application } from './application'
import setupApp from './apps/setup'
import { createDefaultCache } from './cache'
import { Context } from './context'
import { GitHubAPI, ProbotOctokit } from './github'
import { logger } from './logger'
import { logRequestErrors } from './middleware/log-request-errors'
import { findPrivateKey } from './private-key'
import { resolve } from './resolver'
import { createServer } from './server'
import { createWebhookProxy } from './webhook-proxy'
import type { WebhookEvent, Webhooks } from "@octokit/webhooks";
import type { Logger } from "pino";
const cache = createDefaultCache()
import { Server } from "http";
import { Application } from "./application";
import { setupApp } from "./apps/setup";
import { Context } from "./context";
import { ProbotOctokit } from "./octokit/probot-octokit";
import { getLog } from "./helpers/get-log";
import { findPrivateKey } from "./helpers/get-private-key";
import { resolveAppFunction } from "./helpers/resolve-app-function";
import { createServer } from "./server/create-server";
import { createWebhookProxy } from "./helpers/webhook-proxy";
import { getErrorHandler } from "./helpers/get-error-handler";
import { DeprecatedLogger, ProbotWebhooks, State } from "./types";
import { getOctokitThrottleOptions } from "./octokit/get-octokit-throttle-options";
import { getProbotOctokitWithDefaults } from "./octokit/get-probot-octokit-with-defaults";
import { aliasLog } from "./helpers/alias-log";
import { logWarningsForObsoleteEnvironmentVariables } from "./helpers/log-warnings-for-obsolete-environment-variables";
import { getWebhooks } from "./octokit/get-webhooks";
logWarningsForObsoleteEnvironmentVariables();
export interface Options {
// same options as Application class
privateKey?: string;
githubToken?: string;
id?: number;
Octokit?: typeof ProbotOctokit;
log?: Logger;
redisConfig?: Redis.RedisOptions;
secret?: string;
webhookPath?: string;
// Probot class-specific options
/**
* @deprecated `cert` options is deprecated. Use `privateKey` instead
*/
cert?: string;
port?: number;
webhookProxy?: string;
}
// tslint:disable:no-var-requires
const defaultAppFns: ApplicationFunction[] = [
require('./apps/default'),
require('./apps/sentry'),
require('./apps/stats')
]
const defaultAppFns: ApplicationFunction[] = [require("./apps/default")];
// tslint:enable:no-var-requires
export class Probot {
public static async run (appFn: ApplicationFunction | string[]) {
const pkgConf = require('pkg-conf')
const program = require('commander')
public static async run(appFn: ApplicationFunction | string[]) {
const pkgConf = require("pkg-conf");
const program = require("commander");
const readOptions = (): Options => {
if (Array.isArray(appFn)) {
program
.usage('[options] <apps...>')
.option('-p, --port <n>', 'Port to start the server on', process.env.PORT || 3000)
.option('-W, --webhook-proxy <url>', 'URL of the webhook proxy service.`', process.env.WEBHOOK_PROXY_URL)
.option('-w, --webhook-path <path>', 'URL path which receives webhooks. Ex: `/webhook`', process.env.WEBHOOK_PATH)
.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, --private-key <file>', 'Path to certificate of the GitHub App', process.env.PRIVATE_KEY_PATH)
.parse(appFn)
.usage("[options] <apps...>")
.option(
"-p, --port <n>",
"Port to start the server on",
process.env.PORT || 3000
)
.option(
"-W, --webhook-proxy <url>",
"URL of the webhook proxy service.`",
process.env.WEBHOOK_PROXY_URL
)
.option(
"-w, --webhook-path <path>",
"URL path which receives webhooks. Ex: `/webhook`",
process.env.WEBHOOK_PATH
)
.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, --private-key <file>",
"Path to certificate of the GitHub App",
process.env.PRIVATE_KEY_PATH
)
.parse(appFn);
return {
cert: findPrivateKey(program.privateKey) || undefined,
privateKey: findPrivateKey(program.privateKey) || undefined,
id: program.app,
port: program.port,
secret: program.secret,
webhookPath: program.webhookPath,
webhookProxy: program.webhookProxy
}
webhookProxy: program.webhookProxy,
};
}
const privateKey = findPrivateKey()
const privateKey = findPrivateKey();
return {
cert: (privateKey && privateKey.toString()) || undefined,
privateKey: (privateKey && privateKey.toString()) || undefined,
id: Number(process.env.APP_ID),
port: Number(process.env.PORT) || 3000,
secret: process.env.WEBHOOK_SECRET,
webhookPath: process.env.WEBHOOK_PATH,
webhookProxy: process.env.WEBHOOK_PROXY_URL
}
}
webhookProxy: process.env.WEBHOOK_PROXY_URL,
};
};
const options = readOptions()
const probot = new Probot(options)
if (!options.id || !options.cert) {
if (process.env.NODE_ENV === 'production') {
const options = readOptions();
const probot = new Probot(options);
if (!options.id || !options.privateKey) {
if (process.env.NODE_ENV === "production") {
if (!options.id) {
throw new Error(
'Application ID is missing, and is required to run in production mode. ' +
'To resolve, ensure the APP_ID environment variable is set.'
)
} else if (!options.cert) {
"Application ID is missing, and is required to run in production mode. " +
"To resolve, ensure the APP_ID environment variable is set."
);
} else if (!options.privateKey) {
throw new Error(
'Certificate is missing, and is required to run in production mode. ' +
'To resolve, ensure either the PRIVATE_KEY or PRIVATE_KEY_PATH environment variable is set and contains a valid certificate'
)
"Certificate is missing, and is required to run in production mode. " +
"To resolve, ensure either the PRIVATE_KEY or PRIVATE_KEY_PATH environment variable is set and contains a valid certificate"
);
}
}
probot.load(setupApp)
probot.load(setupApp);
} else if (Array.isArray(appFn)) {
const pkg = await pkgConf('probot')
probot.setup(program.args.concat(pkg.apps || pkg.plugins || []))
const pkg = await pkgConf("probot");
probot.setup(program.args.concat(pkg.apps || pkg.plugins || []));
} else {
probot.load(appFn)
probot.load(appFn);
}
probot.start()
probot.start();
return probot
return probot;
}
public server: express.Application
public httpServer?: Server
public webhook: Webhooks
public logger: Logger
public server: express.Application;
public webhooks: ProbotWebhooks;
public log: DeprecatedLogger;
// These 3 need to be public for the tests to work.
public options: Options
public app?: OctokitApp
public throttleOptions: any
public options: Options;
public throttleOptions: any;
private apps: Application[]
private githubToken?: string
private Octokit: Octokit.Static
private httpServer?: Server;
private apps: Application[];
private state: State;
constructor (options: Options) {
options.webhookPath = options.webhookPath || '/'
options.secret = options.secret || 'development'
this.options = options
this.logger = logger
this.apps = []
this.webhook = new Webhooks({
path: options.webhookPath,
secret: options.secret
})
this.githubToken = options.githubToken
this.Octokit = options.Octokit || ProbotOctokit
if (this.options.id) {
if (process.env.GHE_HOST && /^https?:\/\//.test(process.env.GHE_HOST)) {
throw new Error('Your \`GHE_HOST\` environment variable should not begin with https:// or http://')
}
this.app = new OctokitApp({
baseUrl: process.env.GHE_HOST && `${process.env.GHE_PROTOCOL || 'https'}://${process.env.GHE_HOST}/api/v3`,
id: options.id as number,
privateKey: options.cert as string
})
}
this.throttleOptions = options.throttleOptions
this.server = createServer({ webhook: (this.webhook as any).middleware, logger })
// Log all received webhooks
this.webhook.on('*', async (event: Webhooks.WebhookEvent<any>) => {
await this.receive(event)
})
// Log all webhook errors
this.webhook.on('error', this.errorHandler)
if (options.redisConfig || process.env.REDIS_URL) {
let client
if (options.redisConfig) {
client = new Redis(options.redisConfig)
} else if (process.env.REDIS_URL) {
client = new Redis(process.env.REDIS_URL)
}
const connection = new Bottleneck.IORedisConnection({ client })
connection.on('error', this.logger.error)
this.throttleOptions = Object.assign({
Bottleneck,
connection
}, this.throttleOptions)
}
/**
* @deprecated use probot.log instead
*/
public get logger() {
this.log.warn(
new Deprecation(
`[probot] "probot.logger" is deprecated. Use "probot.log" instead`
)
);
return this.log;
}
public errorHandler (err: Error) {
const errMessage = (err.message || '').toLowerCase()
if (errMessage.includes('x-hub-signature')) {
logger.error({ err }, 'Go to https://github.com/settings/apps/YOUR_APP and verify that the Webhook secret matches the value of the WEBHOOK_SECRET environment variable.')
} else if (errMessage.includes('pem') || errMessage.includes('json web token')) {
logger.error({ err }, 'Your private key (usually a .pem file) is not correct. Go to https://github.com/settings/apps/YOUR_APP and generate a new PEM file. If you\'re deploying to Now, visit https://probot.github.io/docs/deployment/#now.')
} else {
logger.error(err)
constructor(options: Options) {
if (process.env.GHE_HOST && /^https?:\/\//.test(process.env.GHE_HOST)) {
throw new Error(
"Your `GHE_HOST` environment variable should not begin with https:// or http://"
);
}
}
public receive (event: Webhooks.WebhookEvent<any>) {
this.logger.debug({ event }, 'Webhook received')
return Promise.all(this.apps.map(app => app.receive(event)))
}
//
// Probot class-specific options (Express server & Webhooks)
//
options.webhookPath = options.webhookPath || "/";
options.secret = options.secret || "development";
public load (appFn: string | ApplicationFunction) {
if (typeof appFn === 'string') {
appFn = resolve(appFn) as ApplicationFunction
this.log = aliasLog(options.log || getLog());
if (options.cert) {
this.log.warn(
new Deprecation(
`[probot] "cert" option is deprecated. Use "privateKey" instead`
)
);
options.privateKey = options.cert;
}
const app = new Application({
Octokit: this.Octokit,
app: this.app as OctokitApp,
this.apps = [];
// TODO: Refactor tests so we don't need to make this public
this.options = options;
// TODO: support redis backend for access token cache if `options.redisConfig || process.env.REDIS_URL`
const cache = new LRUCache<number, string>({
// cache max. 15000 tokens, that will use less than 10mb memory
max: 15000,
// Cache for 1 minute less than GitHub expiry
maxAge: Number(process.env.INSTALLATION_TOKEN_TTL) || 1000 * 60 * 59,
});
const Octokit = getProbotOctokitWithDefaults({
githubToken: options.githubToken,
Octokit: options.Octokit || ProbotOctokit,
appId: options.id,
privateKey: options.privateKey,
cache,
githubToken: this.githubToken,
throttleOptions: this.throttleOptions
})
});
const octokit = new Octokit();
this.throttleOptions = getOctokitThrottleOptions({
log: this.log,
redisConfig: options.redisConfig,
});
this.state = {
cache,
githubToken: options.githubToken,
log: this.log,
Octokit,
octokit,
throttleOptions: this.throttleOptions,
webhooks: {
path: options.webhookPath,
secret: options.secret,
},
};
this.webhooks = getWebhooks(this.state);
this.server = createServer({
webhook: (this.webhooks as any).middleware,
logger: this.log,
});
}
/**
* @deprecated `probot.webhook` is deprecated. Use `probot.webhooks` instead
*/
public get webhook(): Webhooks {
this.log.warn(
new Deprecation(
`[probot] "probot.webhook" is deprecated. Use "probot.webhooks" instead instead`
)
);
return this.webhooks;
}
public receive(event: WebhookEvent) {
this.log.debug({ event }, "Webhook received");
return Promise.all(this.apps.map((app) => app.receive(event)));
}
public load(appFn: string | ApplicationFunction) {
if (typeof appFn === "string") {
appFn = resolveAppFunction(appFn) as ApplicationFunction;
}
const app = new Application({
log: this.state.log.child({ name: "app" }),
cache: this.state.cache,
githubToken: this.state.githubToken,
Octokit: this.state.Octokit,
octokit: this.state.octokit,
throttleOptions: this.throttleOptions,
webhooks: this.webhooks,
});
// Connect the router from the app to the server
this.server.use(app.router)
this.server.use(app.router);
// Initialize the ApplicationFunction
app.load(appFn)
this.apps.push(app)
app.load(appFn);
this.apps.push(app);
return app
return app;
}
public setup (appFns: Array<string | ApplicationFunction>) {
public setup(appFns: Array<string | ApplicationFunction>) {
// Log all unhandled rejections
(process as NodeJS.EventEmitter).on('unhandledRejection', this.errorHandler)
(process as NodeJS.EventEmitter).on(
"unhandledRejection",
getErrorHandler(this.log)
);
// Load the given appFns along with the default ones
appFns.concat(defaultAppFns).forEach(appFn => this.load(appFn))
appFns.concat(defaultAppFns).forEach((appFn) => this.load(appFn));
// Register error handler as the last middleware
this.server.use(logRequestErrors)
this.server.use(
pinoHttp({
logger: this.log,
})
);
}
public start () {
return this.httpServer = this.server.listen(this.options.port, () => {
if (this.options.webhookProxy) {
createWebhookProxy({
logger,
path: this.options.webhookPath,
port: this.options.port,
url: this.options.webhookProxy
})
}
logger.info('Listening on http://localhost:' + this.options.port)
}).on('error', (err: NodeJS.ErrnoException) => {
if (err.code === 'EADDRINUSE') {
logger.error(`Port ${this.options.port} is already in use. You can define the PORT environment variable to use a different port.`)
} else {
logger.error(err)
}
process.exit(1)
})
public start() {
this.httpServer = this.server
.listen(this.options.port, () => {
if (this.options.webhookProxy) {
createWebhookProxy({
logger: this.log,
path: this.options.webhookPath,
port: this.options.port,
url: this.options.webhookProxy,
});
}
this.log.info("Listening on http://localhost:" + this.options.port);
})
.on("error", (error: NodeJS.ErrnoException) => {
if (error.code === "EADDRINUSE") {
this.log.error(
`Port ${this.options.port} is already in use. You can define the PORT environment variable to use a different port.`
);
} else {
this.log.error(error);
}
process.exit(1);
});
return this.httpServer;
}
public stop() {
if (!this.httpServer) return;
this.httpServer.close();
}
}
export const createProbot = (options: Options) => new Probot(options)
export const createProbot = (options: Options) => {
options.log = options.log || getLog();
options.log.warn(
new Deprecation(
`[probot] "createProbot(options)" is deprecated, use "new Probot(options)" instead`
)
);
return new Probot(options);
};
export type ApplicationFunction = (app: Application) => void
export type ApplicationFunction = (app: Application) => void;
export interface Options {
webhookPath?: string
secret?: string,
id?: number,
cert?: string,
githubToken?: string,
webhookProxy?: string,
port?: number,
redisConfig?: Redis.RedisOptions,
Octokit?: Octokit.Static
throttleOptions?: any
}
export { Logger, Context, Application, Octokit, GitHubAPI }
export { Logger, Context, Application, ProbotOctokit };

View File

@ -1,69 +0,0 @@
/**
* A logger backed by [bunyan](https://github.com/trentm/node-bunyan)
*
* The default log level is `info`, but you can change it by setting the
* `LOG_LEVEL` environment variable to `trace`, `debug`, `info`, `warn`,
* `error`, or `fatal`.
*
* By default, logs are formatted for readability in development. If you intend
* to drain logs to a logging service, set `LOG_FORMAT=json`.
*
* **Note**: All exceptions reported with `logger.error` will be forwarded to
* [sentry](https://github.com/getsentry/sentry) if the `SENTRY_DSN` environment
* variable is set.
*
* ```js
* app.log("This is an info message");
* app.log.debug("…so is this");
* app.log.trace("Now we're talking");
* app.log.info("I thought you should know…");
* app.log.warn("Woah there");
* app.log.error("ETOOMANYLOGS");
* app.log.fatal("Goodbye, cruel world!");
* ```
*/
import Logger from 'bunyan'
import bunyanFormat from 'bunyan-format'
import supportsColor from 'supports-color'
import { serializers } from './serializers'
function toBunyanLogLevel (level: string) {
switch (level) {
case 'info':
case 'trace':
case 'debug':
case 'warn':
case 'error':
case 'fatal':
case undefined:
return level
default:
throw new Error('Invalid log level')
}
}
function toBunyanFormat (format: string) {
switch (format) {
case 'short':
case 'long':
case 'simple':
case 'json':
case 'bunyan':
case undefined:
return format
default:
throw new Error('Invalid log format')
}
}
export const logger = new Logger({
level: toBunyanLogLevel(process.env.LOG_LEVEL || 'info'),
name: 'probot',
serializers,
stream: new bunyanFormat({
color: supportsColor.stdout,
levelInString: !!process.env.LOG_LEVEL_IN_STRING,
outputMode: toBunyanFormat(process.env.LOG_FORMAT || 'short')
})
})

View File

@ -1,86 +1,106 @@
import fs from 'fs'
import yaml from 'js-yaml'
import path from 'path'
import updateDotenv from 'update-dotenv'
import { GitHubAPI } from './github'
import fs from "fs";
import yaml from "js-yaml";
import path from "path";
import updateDotenv from "update-dotenv";
import { ProbotOctokit } from "./octokit/probot-octokit";
export class ManifestCreation {
get pkg () {
let pkg: any
get pkg() {
let pkg: any;
try {
pkg = require(path.join(process.cwd(), 'package.json'))
pkg = require(path.join(process.cwd(), "package.json"));
} catch (e) {
pkg = {}
pkg = {};
}
return pkg
return pkg;
}
public async createWebhookChannel () {
public async createWebhookChannel() {
try {
// tslint:disable:no-var-requires
const SmeeClient = require('smee-client')
await this.updateEnv({ WEBHOOK_PROXY_URL: await SmeeClient.createChannel() })
} catch (err) {
const SmeeClient = require("smee-client");
await this.updateEnv({
WEBHOOK_PROXY_URL: await SmeeClient.createChannel(),
});
} catch (error) {
// Smee is not available, so we'll just move on
// tslint:disable:no-console
console.warn('Unable to connect to smee.io, try restarting your server.')
console.warn("Unable to connect to smee.io, try restarting your server.");
}
}
public getManifest (pkg: any, baseUrl: any) {
let manifest: any = {}
public getManifest(pkg: any, baseUrl: any) {
let manifest: any = {};
try {
const file = fs.readFileSync(path.join(process.cwd(), 'app.yml'), 'utf8')
manifest = yaml.safeLoad(file)
} catch (err) {
const file = fs.readFileSync(path.join(process.cwd(), "app.yml"), "utf8");
manifest = yaml.safeLoad(file);
} catch (error) {
// App config does not exist, which is ok.
if (err.code !== 'ENOENT') {
throw err
if (error.code !== "ENOENT") {
throw error;
}
}
const generatedManifest = JSON.stringify(Object.assign({
description: manifest.description || pkg.description,
hook_attributes: {
url: process.env.WEBHOOK_PROXY_URL || `${baseUrl}/`
},
name: process.env.PROJECT_DOMAIN || manifest.name || pkg.name,
public: manifest.public || true,
redirect_url: `${baseUrl}/probot/setup`,
// TODO: add setup url
// setup_url:`${baseUrl}/probot/success`,
url: manifest.url || pkg.homepage || pkg.repository,
version: 'v1'
}, manifest))
const generatedManifest = JSON.stringify(
Object.assign(
{
description: manifest.description || pkg.description,
hook_attributes: {
url: process.env.WEBHOOK_PROXY_URL || `${baseUrl}/`,
},
name: process.env.PROJECT_DOMAIN || manifest.name || pkg.name,
public: manifest.public || true,
redirect_url: `${baseUrl}/probot/setup`,
// TODO: add setup url
// setup_url:`${baseUrl}/probot/success`,
url: manifest.url || pkg.homepage || pkg.repository,
version: "v1",
},
manifest
)
);
return generatedManifest
return generatedManifest;
}
public async createAppFromCode (code: any) {
const github = GitHubAPI()
public async createAppFromCode(code: any) {
const github = new ProbotOctokit();
const options: any = {
code,
headers: { accept: 'application/vnd.github.fury-preview+json' },
...process.env.GHE_HOST && { baseUrl: `${process.env.GHE_PROTOCOL || 'https'}://${process.env.GHE_HOST}/api/v3` }
}
const response = await github.request('POST /app-manifests/:code/conversions', options)
mediaType: {
previews: ["fury"], // needed for GHES 2.20 and older
},
...(process.env.GHE_HOST && {
baseUrl: `${process.env.GHE_PROTOCOL || "https"}://${
process.env.GHE_HOST
}/api/v3`,
}),
};
const response = await github.request(
"POST /app-manifests/:code/conversions",
options
);
const { id, webhook_secret, pem } = response.data
const { id, webhook_secret, pem } = response.data;
await this.updateEnv({
APP_ID: id.toString(),
PRIVATE_KEY: `"${pem}"`,
WEBHOOK_SECRET: webhook_secret
})
WEBHOOK_SECRET: webhook_secret,
});
return response.data.html_url
return response.data.html_url;
}
public async updateEnv (env: any) { // Needs to be public due to tests
return updateDotenv(env)
public async updateEnv(env: any) {
// Needs to be public due to tests
return updateDotenv(env);
}
get createAppUrl () {
const githubHost = process.env.GHE_HOST || `github.com`
return `${process.env.GHE_PROTOCOL || 'https'}://${githubHost}/settings/apps/new`
get createAppUrl() {
const githubHost = process.env.GHE_HOST || `github.com`;
return `${
process.env.GHE_PROTOCOL || "https"
}://${githubHost}/settings/apps/new`;
}
}

View File

@ -1,9 +0,0 @@
import { ErrorRequestHandler, NextFunction } from 'express'
import { Request, Response } from './logging'
export const logRequestErrors: ErrorRequestHandler = (err: Error, req: Request, res: Response, next: NextFunction): void => {
if (req.log) {
req.log.error(err)
}
next()
}

View File

@ -1,53 +0,0 @@
// Borrowed from https://github.com/vvo/bunyan-request
// Copyright (c) Christian Tellnes <christian@tellnes.no>
// tslint:disable
import {wrapLogger} from '../wrap-logger'
import {v4 as uuidv4} from 'uuid'
import express from 'express'
import Logger from 'bunyan'
export const logRequest = function ({logger}: any): express.RequestHandler {
return function (req: Request, res: Response, next: NextFunction) {
// Use X-Request-ID from request if it is set, otherwise generate a uuid
req.id = req.headers['x-request-id'] ||
req.headers['x-github-delivery'] ||
uuidv4()
res.setHeader('x-request-id', req.id)
// Make a logger available on the request
req.log = wrapLogger(logger, logger.target).child({name: 'http', id: req.id})
// Request started
req.log.trace({req}, `${req.method} ${req.url}`)
// Start the request timer
const time = process.hrtime()
res.on('finish', () => {
// Calculate how long the request took
const [seconds, nanoseconds] = process.hrtime(time)
res.duration = (seconds * 1e3 + nanoseconds * 1e-6).toFixed(2)
const message = `${req.method} ${req.url} ${res.statusCode} - ${res.duration} ms`
if (req.log) {
req.log.info(message)
req.log.trace({res})
}
})
next()
}
}
export interface Request extends express.Request {
id?: string | number | string[]
log?: Logger
}
export interface Response extends express.Response {
duration?: string
log?: Logger
}
export interface NextFunction extends express.NextFunction { }

View File

@ -0,0 +1,33 @@
import { WebhookEvent } from "@octokit/webhooks";
import { createUnauthenticatedAuth } from "@octokit/auth-unauthenticated";
import { getAuthenticatedOctokit } from "./get-authenticated-octokit";
import { State } from "../types";
export function getAuthenticatedOctokitForEvent(
state: State,
event: WebhookEvent
) {
if (state.githubToken) return getAuthenticatedOctokit(state);
if (isUnauthenticatedEvent(event)) {
return new state.Octokit({
authStrategy: createUnauthenticatedAuth,
auth: {
reason:
"`context.github` is unauthenticated. See https://probot.github.io/docs/github-api/#unauthenticated-events",
},
});
}
return getAuthenticatedOctokit(state, event.payload.installation.id);
}
// Some events can't get an authenticated client (#382):
function isUnauthenticatedEvent(event: WebhookEvent) {
return (
!event.payload.installation ||
(event.name === "installation" && event.payload.action === "deleted")
);
}

View File

@ -0,0 +1,38 @@
import { State } from "../types";
export async function getAuthenticatedOctokit(
state: State,
installationId?: number
) {
const { githubToken, log, Octokit, octokit, throttleOptions } = state;
if (!installationId) return octokit;
const constructorAuthOptions = githubToken
? {}
: { auth: { installationId: installationId } };
const constructorThrottleOptions = throttleOptions
? {
throttle: {
id: installationId,
...throttleOptions,
},
}
: {};
const pinoLog = log.child({ name: "github" });
const options = {
log: {
fatal: pinoLog.fatal.bind(pinoLog),
error: pinoLog.error.bind(pinoLog),
warn: pinoLog.warn.bind(pinoLog),
info: pinoLog.info.bind(pinoLog),
debug: pinoLog.debug.bind(pinoLog),
trace: pinoLog.trace.bind(pinoLog),
},
...constructorAuthOptions,
...constructorThrottleOptions,
};
return new Octokit(options);
}

View File

@ -0,0 +1,30 @@
import Bottleneck from "bottleneck";
import Redis from "ioredis";
import type { Logger } from "pino";
type Options = {
log: Logger;
throttleOptions?: any;
redisConfig?: Redis.RedisOptions;
};
export function getOctokitThrottleOptions(options: Options) {
if (!options.redisConfig && !process.env.REDIS_URL) return;
const connection = new Bottleneck.IORedisConnection({
client: getRedisClient(options.redisConfig),
});
connection.on("error", (error) => {
options.log.error(Object.assign(error, { source: "bottleneck" }));
});
return {
Bottleneck,
connection,
};
}
function getRedisClient(redisConfig?: Redis.RedisOptions): Redis.Redis | void {
if (redisConfig) return new Redis(redisConfig);
if (process.env.REDIS_URL) return new Redis(process.env.REDIS_URL);
}

View File

@ -0,0 +1,50 @@
import { createAppAuth } from "@octokit/auth-app";
import LRUCache from "lru-cache";
import { ProbotOctokit } from "./probot-octokit";
type Options = {
cache: LRUCache<number, string>;
Octokit: typeof ProbotOctokit;
githubToken?: string;
appId?: number;
privateKey?: string;
};
/**
* Returns an Octokit instance with default settings for authentication. If
* a `githubToken` is passed explicitly, the Octokit instance will be
* pre-authenticated with that token when instantiated. Otherwise Octokit's
* app authentication strategy is used, and `options.auth` options are merged
* deeply when instantiated.
*
* Besides the authentication, the Octokit's baseUrl is set as well when run
* against a GitHub Enterprise Server with a custom domain.
*/
export function getProbotOctokitWithDefaults(options: Options) {
const authOptions = options.githubToken
? { auth: options.githubToken }
: {
auth: {
cache: options.cache,
id: options.appId,
privateKey: options.privateKey,
},
authStrategy: createAppAuth,
};
const defaultOptions = {
baseUrl:
process.env.GHE_HOST &&
`${process.env.GHE_PROTOCOL || "https"}://${process.env.GHE_HOST}/api/v3`,
...authOptions,
};
return options.Octokit.defaults((instanceOptions: any) => {
const options = Object.assign({}, defaultOptions, instanceOptions, {
auth: instanceOptions.auth
? Object.assign({}, defaultOptions.auth, instanceOptions.auth)
: defaultOptions.auth,
});
return options;
});
}

View File

@ -0,0 +1,16 @@
import { Webhooks } from "@octokit/webhooks";
import { State } from "../types";
import { getErrorHandler } from "../helpers/get-error-handler";
import { webhookTransform } from "./octokit-webhooks-transform";
export function getWebhooks(state: State) {
const webhooks = new Webhooks({
path: state.webhooks.path,
secret: state.webhooks.secret,
transform: webhookTransform.bind(null, state),
});
webhooks.on("error", getErrorHandler(state.log));
return webhooks;
}

View File

@ -0,0 +1,25 @@
// tslint:disable-next-line
import type { Octokit } from "@octokit/core";
export function probotRequestLogging(octokit: Octokit) {
octokit.hook.error("request", (error, options) => {
const { method, url, request, ...params } = octokit.request.endpoint.parse(
options
);
const msg = `GitHub request: ${method} ${url} - ${error.status}`;
// @ts-ignore log.debug is a pino log method and accepts a fields object
octokit.log.debug(params.body || {}, msg);
throw error;
});
octokit.hook.after("request", (result, options) => {
const { method, url, request, ...params } = octokit.request.endpoint.parse(
options
);
const msg = `GitHub request: ${method} ${url} - ${result.status}`;
// @ts-ignore log.debug is a pino log method and accepts a fields object
octokit.log.debug(params.body || {}, msg);
});
}

View File

@ -0,0 +1,16 @@
import { WebhookEvent } from "@octokit/webhooks";
import { getAuthenticatedOctokitForEvent } from "./get-authenticated-octokit-for-event";
import { Context } from "../context";
import { State } from "../types";
/**
* Probot's transform option, which extends the `event` object that is passed
* to webhook event handlers by `@octokit/webhooks`
* @see https://github.com/octokit/webhooks.js/#constructor
*/
export async function webhookTransform(state: State, event: WebhookEvent) {
const log = state.log.child({ name: "event", id: event.id });
const github = await getAuthenticatedOctokitForEvent(state, event);
return new Context(event, github, log);
}

View File

@ -0,0 +1,44 @@
import { Octokit } from "@octokit/core";
import { enterpriseCompatibility } from "@octokit/plugin-enterprise-compatibility";
import { RequestOptions } from "@octokit/types";
import { paginateRest } from "@octokit/plugin-paginate-rest";
import { restEndpointMethods } from "@octokit/plugin-rest-endpoint-methods";
import { retry } from "@octokit/plugin-retry";
import { throttling } from "@octokit/plugin-throttling";
import { probotRequestLogging } from "./octokit-plugin-probot-request-logging";
import { VERSION } from "../version";
export const ProbotOctokit = Octokit.plugin(
throttling,
retry,
paginateRest,
restEndpointMethods,
enterpriseCompatibility,
probotRequestLogging
).defaults({
throttle: {
onAbuseLimit: (
retryAfter: number,
options: RequestOptions,
octokit: Octokit
) => {
octokit.log.warn(
`Abuse limit hit with "${options.method} ${options.url}", retrying in ${retryAfter} seconds.`
);
return true;
},
onRateLimit: (
retryAfter: number,
options: RequestOptions,
octokit: Octokit
) => {
octokit.log.warn(
`Rate limit hit with "${options.method} ${options.url}", retrying in ${retryAfter} seconds.`
);
return true;
},
},
userAgent: `probot/${VERSION}`,
});

View File

@ -1,62 +0,0 @@
import fs from 'fs'
// tslint:disable-next-line:no-var-requires
const isBase64 = require('is-base64')
const hint = `please use:
* \`--private-key=/path/to/private-key\` flag, or
* \`PRIVATE_KEY\` environment variable, or
* \`PRIVATE_KEY_PATH\` environment variable
`
/**
* Finds a private key through various user-(un)specified methods.
* Order of precedence:
* 1. Explicit path (CLI option)
* 2. `PRIVATE_KEY` env var
* 3. `PRIVATE_KEY_PATH` env var
* 4. Any file w/ `.pem` extension in current working dir
* @param filepath - Explicit, user-defined path to keyfile
* @returns Private key
* @private
*/
export function findPrivateKey (filepath?: string): string | null {
if (filepath) {
return fs.readFileSync(filepath, 'utf8')
}
if (process.env.PRIVATE_KEY) {
let cert = process.env.PRIVATE_KEY
if (isBase64(cert)) {
// Decode base64-encoded certificate
cert = Buffer.from(cert, 'base64').toString()
}
const begin = '-----BEGIN RSA PRIVATE KEY-----'
const end = '-----END RSA PRIVATE KEY-----'
if (cert.includes(begin) && cert.includes(end)) {
// Full key with new lines
return cert.replace(/\\n/g, '\n')
}
throw new Error('The contents of `PRIVATE_KEY` could not be validated. Please check to ensure you have copied the contents of the .pem file correctly.')
}
if (process.env.PRIVATE_KEY_PATH) {
if (fs.existsSync(process.env.PRIVATE_KEY_PATH)) {
return fs.readFileSync(process.env.PRIVATE_KEY_PATH, 'utf8')
} else {
throw new Error(`Private key does not exists at path: ${process.env.PRIVATE_KEY_PATH}. Please check to ensure that the PRIVATE_KEY_PATH is correct.`)
}
}
const pemFiles = fs.readdirSync(process.cwd())
.filter(path => path.endsWith('.pem'))
if (pemFiles.length > 1) {
throw new Error(
`Found several private keys: ${pemFiles.join(', ')}. ` +
`To avoid ambiguity ${hint}`
)
} else if (pemFiles[0]) {
return findPrivateKey(pemFiles[0])
}
return null
}

View File

@ -1,18 +0,0 @@
import { sync } from 'resolve'
const defaultOptions: ResolveOptions = {}
export const resolve = (appFnId: string, opts?: ResolveOptions) => {
opts = opts || defaultOptions
// These are mostly to ease testing
const basedir = opts.basedir || process.cwd()
const resolver: Resolver = opts.resolver || sync
return require(resolver(appFnId, { basedir }))
}
export type Resolver = (appFnId: string, opts: {basedir: string}) => string
export interface ResolveOptions {
basedir?: string
resolver?: Resolver
}

View File

@ -1,62 +0,0 @@
import { PayloadRepository, WebhookEvent } from '@octokit/webhooks'
import bunyan from 'bunyan'
import express from 'express'
export const serializers: bunyan.StdSerializers = {
event: (event: WebhookEvent<any> | any) => {
if (typeof event !== 'object' || !event.payload) {
return event
} else {
let name = event.name
if (event.payload && event.payload.action) {
name = `${name}.${event.payload.action}`
}
return {
event: name,
id: event.id,
installation: event.payload.installation && event.payload.installation.id,
repository: event.payload.repository && event.payload.repository.full_name
}
}
},
installation: (installation: Installation) => {
if (installation.account) {
return installation.account.login
} else {
return installation
}
},
err: bunyan.stdSerializers.err,
repository: (repository: PayloadRepository) => repository.full_name,
req: bunyan.stdSerializers.req,
// Same as bunyan's standard serializers, but gets headers as an object
// instead of a string.
// https://github.com/trentm/node-bunyan/blob/fe31b83e42d9c7f784e83fdcc528a7c76e0dacae/lib/bunyan.js#L1105-L1113
res: (res: ExpressResponseWithDuration) => {
if (!res || !res.statusCode) {
return res
} else {
return {
duration: res.duration,
headers: res.getHeaders(),
statusCode: res.statusCode
}
}
}
}
interface Installation {
account: {
login: string
}
}
interface ExpressResponseWithDuration extends express.Response {
duration: any
}

View File

@ -1,27 +0,0 @@
import Logger from 'bunyan'
import express from 'express'
import path from 'path'
// Teach express to properly handle async errors
// tslint:disable-next-line:no-var-requires
require('express-async-errors')
import { logRequest } from './middleware/logging'
export const createServer = (args: ServerArgs) => {
const app: express.Application = express()
app.use(logRequest({ logger: args.logger }))
app.use('/probot/static/', express.static(path.join(__dirname, '..', 'static')))
app.use(args.webhook)
app.set('view engine', 'hbs')
app.set('views', path.join(__dirname, '..', 'views'))
app.get('/ping', (req, res) => res.end('PONG'))
return app
}
export interface ServerArgs {
webhook: express.Application
logger: Logger
}

View File

@ -0,0 +1,32 @@
import path from "path";
import express from "express";
import { getLoggingMiddleware } from "./logging-middleware";
import type { Logger } from "pino";
// Teach express to properly handle async errors
// tslint:disable-next-line:no-var-requires
require("express-async-errors");
export const createServer = (options: ServerOptions) => {
const app: express.Application = express();
app.use(getLoggingMiddleware(options.logger));
app.use(
"/probot/static/",
express.static(path.join(__dirname, "..", "..", "static"))
);
app.use(options.webhook);
app.set("view engine", "hbs");
app.set("views", path.join(__dirname, "..", "..", "views"));
app.get("/ping", (req, res) => res.end("PONG"));
return app;
};
export interface ServerOptions {
webhook: express.Application;
logger: Logger;
}

View File

@ -0,0 +1,20 @@
import pinoHttp from "pino-http";
import type { Logger } from "pino";
import { v4 as uuidv4 } from "uuid";
export function getLoggingMiddleware(logger: Logger) {
return pinoHttp({
logger: logger.child({ name: "http" }),
customSuccessMessage(res) {
const responseTime = Date.now() - res[pinoHttp.startTime];
// @ts-ignore
const route = `${res.req.method} ${res.req.url} ${res.statusCode} - ${responseTime}ms`;
return route;
},
genReqId: (req) =>
req.headers["x-request-id"] ||
req.headers["x-github-delivery"] ||
uuidv4(),
});
}

24
src/types.ts Normal file
View File

@ -0,0 +1,24 @@
import { Webhooks } from "@octokit/webhooks";
import LRUCache from "lru-cache";
import { Context } from "./context";
import { ProbotOctokit } from "./octokit/probot-octokit";
import type { Logger, LogFn } from "pino";
export type State = {
githubToken?: string;
log: Logger;
Octokit: typeof ProbotOctokit;
octokit: InstanceType<typeof ProbotOctokit>;
throttleOptions: any;
cache?: LRUCache<number, string>;
webhooks: {
path?: string;
secret?: string;
};
};
export type ProbotWebhooks = Webhooks<Context>;
export type DeprecatedLogger = LogFn & Logger;

2
src/version.ts Normal file
View File

@ -0,0 +1,2 @@
// The version is set automatically before publish to npm
export const VERSION = "0.0.0-development";

View File

@ -1,173 +0,0 @@
import { Application } from './application'
import { GitHubAPI } from './github'
let appMeta: ReturnType<GitHubAPI['apps']['getAuthenticated']> | null = null
let didFailRetrievingAppMeta = false
/**
* Check if an application is subscribed to an event.
*
* @returns Returns `false` if the app is not subscribed to an event. Otherwise,
* returns `true`. Returns `undefined` if the webhook-event-check feature is
* disabled or if Probot failed to retrieve the GitHub App's metadata.
*/
async function webhookEventCheck (app: Application, eventName: string) {
if (isWebhookEventCheckEnabled() === false) {
return
}
const baseEventName = eventName.split('.')[0]
if (await isSubscribedToEvent(app, baseEventName)) {
return true
} else if (didFailRetrievingAppMeta === false) {
const userFriendlyBaseEventName = baseEventName.split('_').join(' ')
app.log.error(`Your app is attempting to listen to "${eventName}", but your GitHub App is not subscribed to the "${userFriendlyBaseEventName}" event.`)
}
return didFailRetrievingAppMeta ? undefined : false
}
/**
* @param {string} baseEventName The base event name refers to the part before
* the first period mark (e.g. the `issues` part in `issues.opened`).
* @returns Returns `false` when the application is not subscribed to a webhook
* event. Otherwise, returns `true`. Returns `undefined` if Probot failed to
* retrieve GitHub App metadata.
*
* **Note**: Probot will only check against a list of events known to be in the
* `GET /app` response. Therefore, only the `false` value should be considered
* truthy.
*/
async function isSubscribedToEvent (app: Application, baseEventName: string) {
// A list of events known to be in the response of `GET /app`. This list can
// be retrieved by calling `GET /app` from an authenticated app that has
// maximum permissions and is subscribed to all available webhook events.
const knownBaseEvents = [
'check_run',
'check_suite',
'commit_comment',
'content_reference',
'create',
'delete',
'deployment',
'deployment_status',
'deploy_key',
'fork',
'gollum',
'issues',
'issue_comment',
'label',
'member',
'membership',
'milestone',
'organization',
'org_block',
'page_build',
'project',
'project_card',
'project_column',
'public',
'pull_request',
'pull_request_review',
'pull_request_review_comment',
'push',
'release',
'repository',
'repository_dispatch',
'star',
'status',
'team',
'team_add',
'watch'
]
// Because `GET /app` does not include all events - such as default events
// that all GitHub Apps are subscribed to (e.g.`installation`, `meta`, or
// `marketplace_purchase`) - we can only check `baseEventName` if it is known
// to be in the `GET /app` response.
const eventMayExistInAppResponse = knownBaseEvents.includes(baseEventName)
if (!eventMayExistInAppResponse) {
return true
}
let events
try {
events = (await retrieveAppMeta(app)).data.events
} catch (e) {
if (!didFailRetrievingAppMeta) {
app.log.warn(e)
}
didFailRetrievingAppMeta = true
return
}
return events.includes(baseEventName)
}
async function retrieveAppMeta (app: Application) {
if (appMeta) return appMeta
appMeta = new Promise(async (resolve, reject) => {
const api = await app.auth()
try {
const meta = await api.apps.getAuthenticated()
return resolve(meta)
} catch (e) {
app.log.trace(e)
/**
* There are a few reasons why Probot might be unable to retrieve
* application metadata.
*
* - Probot may not be connected to the Internet.
* - The GitHub API is not responding to requests (see
* https://www.githubstatus.com/).
* - The user has incorrectly configured environment variables (e.g.
* APP_ID, PRIVATE_KEY, etc.) used for authentication between the Probot
* app and the GitHub API.
*/
return reject([
'Probot is unable to retrieve app information from GitHub for event subscription verification.',
'',
'If this error persists, feel free to raise an issue at:',
' - https://github.com/probot/probot/issues'
].join('\n'))
}
})
return appMeta
}
function isWebhookEventCheckEnabled () {
if (process.env.DISABLE_WEBHOOK_EVENT_CHECK?.toLowerCase() === 'true') {
return false
} else if (process.env.NODE_ENV?.toLowerCase() === 'production') {
return false
} else if (inTestEnvironment()) {
// We disable the feature in test environments to avoid requiring developers
// to add a stub mocking the `GET /app` route this feature calls.
return false
}
return true
}
/**
* Detects if Probot is likely running in a test environment.
*
* **Note**: This method only detects Jest environments or when NODE_ENV starts
* with `test`.
* @returns Returns `true` if Probot is in a test environment.
*/
function inTestEnvironment (): boolean {
const nodeEnvContainsTest = process.env.NODE_ENV?.substr(0, 4).toLowerCase() === 'test'
const isRunningJest = process.env.JEST_WORKER_ID !== undefined
return nodeEnvContainsTest || isRunningJest
}
export default webhookEventCheck
/**
* A helper function used in testing that resets the cached result of /app.
*/
export function clearCache () {
appMeta = null
didFailRetrievingAppMeta = false
}

View File

@ -1,24 +0,0 @@
import Logger from 'bunyan'
import EventSource from 'eventsource'
export const createWebhookProxy = (opts: WebhookProxyOptions): EventSource | undefined => {
try {
const SmeeClient = require('smee-client')
const smee = new SmeeClient({
logger: opts.logger,
source: opts.url,
target: `http://localhost:${opts.port}${opts.path}`
})
return smee.start()
} catch (err) {
opts.logger.warn('Run `npm install --save-dev smee-client` to proxy webhooks to localhost.')
return
}
}
export interface WebhookProxyOptions {
url?: string
port?: number
path?: string
logger: Logger
}

View File

@ -1,59 +0,0 @@
import { Logger } from './'
// Return a function that defaults to "info" level, and has properties for
// other levels:
//
// app.log("info")
// app.log.trace("verbose details");
//
export const wrapLogger = (logger: Logger, baseLogger?: Logger): LoggerWithTarget => {
const fn = Object.assign(logger.info.bind(logger), {
// Add level methods on the logger
debug: logger.debug.bind(logger),
error: logger.error.bind(logger),
fatal: logger.fatal.bind(logger),
info: logger.info.bind(logger),
trace: logger.trace.bind(logger),
warn: logger.warn.bind(logger),
// Expose `child` method for creating new wrapped loggers
child: (attrs: ChildArgs) => {
// Bunyan doesn't allow you to overwrite name…
const name = attrs.name
delete attrs.name
const log = logger.child(attrs, true)
// …Sorry, bunyan, doing it anyway
if (name) {
log.fields.name = name
}
return wrapLogger(log, baseLogger || logger)
},
// Expose target logger
target: baseLogger || logger
}) as LoggerWithTarget
return fn
}
export interface LoggerWithTarget extends Logger {
(): boolean
(...params: any[]): void
target: Logger
child: (attrs: ChildArgs) => LoggerWithTarget
trace: LoggerWithTarget
debug: LoggerWithTarget
info: LoggerWithTarget
warn: LoggerWithTarget
error: LoggerWithTarget
fatal: LoggerWithTarget
}
export interface ChildArgs {
options?: object
name?: string
id?: string | number | string[]
installation?: string
[key: string]: any
}

View File

@ -2,15 +2,13 @@
exports[`Probot ghe support throws if the GHE host includes a protocol 1`] = `[Error: Your \`GHE_HOST\` environment variable should not begin with https:// or http://]`;
exports[`Probot ghe support throws if the GHE host includes a protocol 2`] = `[Error: Your \`GHE_HOST\` environment variable should not begin with https:// or http://]`;
exports[`Probot ghe support with http throws if the GHE host includes a protocol 1`] = `[Error: Your \`GHE_HOST\` environment variable should not begin with https:// or http://]`;
exports[`Probot ghe support with http throws if the GHE host includes a protocol 2`] = `[Error: Your \`GHE_HOST\` environment variable should not begin with https:// or http://]`;
exports[`Probot run runs with a function as argument 1`] = `
Object {
"cert": "-----BEGIN RSA PRIVATE KEY-----
"id": 1,
"port": 3003,
"privateKey": "-----BEGIN RSA PRIVATE KEY-----
MIIJKAIBAAKCAgEAu0E+tR6wfOAJZ4lASzRUmvorCgbI5nQyvZl3WLu6ko2pcEnq
1t1/W/Yaovt9W8eMFVfoFXKhsHOAM5dFlktxOlcaUQiRYSO7fBbZYVNYoawnCRqD
HKQ1oKC6B23EKfW5NH8NLaI/+QJFG7fpr0P4HkHghLsOe7rIUDt7EjRsSSRhM2+Y
@ -62,9 +60,7 @@ bHNB2yFIuUmmT92T7Pw28wJZ6Wd/3T+5s4CBe+FWplQcgquPGIFkq4dVxPpVg6uq
wB98bfAGtcuCZWzgjgL67CS0pcNxadFA/TFo/NnynLBC4qRXSfFslKVE+Og=
-----END RSA PRIVATE KEY-----
",
"id": 1,
"port": 3003,
"secret": "development",
"secret": "secret",
"webhookPath": "/",
"webhookProxy": "https://smee.io/EfHXC9BFfGAxbM6J",
}
@ -72,7 +68,9 @@ wB98bfAGtcuCZWzgjgL67CS0pcNxadFA/TFo/NnynLBC4qRXSfFslKVE+Og=
exports[`Probot run runs with an array of strings 1`] = `
Object {
"cert": "-----BEGIN RSA PRIVATE KEY-----
"id": "1",
"port": "3003",
"privateKey": "-----BEGIN RSA PRIVATE KEY-----
MIIJKAIBAAKCAgEAu0E+tR6wfOAJZ4lASzRUmvorCgbI5nQyvZl3WLu6ko2pcEnq
1t1/W/Yaovt9W8eMFVfoFXKhsHOAM5dFlktxOlcaUQiRYSO7fBbZYVNYoawnCRqD
HKQ1oKC6B23EKfW5NH8NLaI/+QJFG7fpr0P4HkHghLsOe7rIUDt7EjRsSSRhM2+Y
@ -124,9 +122,7 @@ bHNB2yFIuUmmT92T7Pw28wJZ6Wd/3T+5s4CBe+FWplQcgquPGIFkq4dVxPpVg6uq
wB98bfAGtcuCZWzgjgL67CS0pcNxadFA/TFo/NnynLBC4qRXSfFslKVE+Og=
-----END RSA PRIVATE KEY-----
",
"id": "1",
"port": "3003",
"secret": "development",
"secret": "secret",
"webhookPath": "/",
"webhookProxy": "https://smee.io/EfHXC9BFfGAxbM6J",
}
@ -134,56 +130,21 @@ wB98bfAGtcuCZWzgjgL67CS0pcNxadFA/TFo/NnynLBC4qRXSfFslKVE+Og=
exports[`Probot run runs without config and loads the setup app 1`] = `
Object {
"cert": undefined,
"id": 1,
"port": 3003,
"secret": "development",
"privateKey": undefined,
"secret": "secret",
"webhookPath": "/",
"webhookProxy": "https://smee.io/EfHXC9BFfGAxbM6J",
}
`;
exports[`Probot webhook delivery responds with the correct error if the PEM file is missing 1`] = `
Array [
Object {
"err": [Error: error:0906D06C:PEM routines:PEM_read_bio:no start line],
},
"Your private key (usually a .pem file) is not correct. Go to https://github.com/settings/apps/YOUR_APP and generate a new PEM file. If you're deploying to Now, visit https://probot.github.io/docs/deployment/#now.",
]
`;
exports[`Probot webhook delivery responds with the correct error if the PEM file is missing 1`] = `"Your private key (usually a .pem file) is not correct. Go to https://github.com/settings/apps/YOUR_APP and generate a new PEM file. If you're deploying to Now, visit https://probot.github.io/docs/deployment/#now."`;
exports[`Probot webhook delivery responds with the correct error if the jwt could not be decoded 1`] = `
Array [
Object {
"err": [Error: {"message":"A JSON web token could not be decoded","documentation_url":"https://developer.github.com/v3"}],
},
"Your private key (usually a .pem file) is not correct. Go to https://github.com/settings/apps/YOUR_APP and generate a new PEM file. If you're deploying to Now, visit https://probot.github.io/docs/deployment/#now.",
]
`;
exports[`Probot webhook delivery responds with the correct error if the jwt could not be decoded 1`] = `"Your private key (usually a .pem file) is not correct. Go to https://github.com/settings/apps/YOUR_APP and generate a new PEM file. If you're deploying to Now, visit https://probot.github.io/docs/deployment/#now."`;
exports[`Probot webhook delivery responds with the correct error if webhook secret does not match 1`] = `
Array [
Object {
"err": [Error: X-Hub-Signature does not match blob signature],
},
"Go to https://github.com/settings/apps/YOUR_APP and verify that the Webhook secret matches the value of the WEBHOOK_SECRET environment variable.",
]
`;
exports[`Probot webhook delivery responds with the correct error if webhook secret does not match 1`] = `"Go to https://github.com/settings/apps/YOUR_APP and verify that the Webhook secret matches the value of the WEBHOOK_SECRET environment variable."`;
exports[`Probot webhook delivery responds with the correct error if webhook secret is not found 1`] = `
Array [
Object {
"err": [Error: No X-Hub-Signature found on request],
},
"Go to https://github.com/settings/apps/YOUR_APP and verify that the Webhook secret matches the value of the WEBHOOK_SECRET environment variable.",
]
`;
exports[`Probot webhook delivery responds with the correct error if webhook secret is not found 1`] = `"Go to https://github.com/settings/apps/YOUR_APP and verify that the Webhook secret matches the value of the WEBHOOK_SECRET environment variable."`;
exports[`Probot webhook delivery responds with the correct error if webhook secret is wrong 1`] = `
Array [
Object {
"err": [Error: webhooks:receiver ignored: POST / due to missing headers: x-hub-signature],
},
"Go to https://github.com/settings/apps/YOUR_APP and verify that the Webhook secret matches the value of the WEBHOOK_SECRET environment variable.",
]
`;
exports[`Probot webhook delivery responds with the correct error if webhook secret is wrong 1`] = `"Go to https://github.com/settings/apps/YOUR_APP and verify that the Webhook secret matches the value of the WEBHOOK_SECRET environment variable."`;

View File

@ -1,6 +1,6 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`webhook-event-check caches result of /app 1`] = `
exports[`webhook-event-check caches result of /app, logs error for event that the app is not subscribed to 1`] = `
[MockFunction] {
"calls": Array [
Array [
@ -36,10 +36,7 @@ exports[`webhook-event-check warn user when unable to retrieve app metadata 1`]
[MockFunction] {
"calls": Array [
Array [
"Probot is unable to retrieve app information from GitHub for event subscription verification.
If this error persists, feel free to raise an issue at:
- https://github.com/probot/probot/issues",
"Your app is attempting to listen to \\"pull_request.opened\\", but your GitHub App is not subscribed to the \\"pull request\\" event.",
],
],
"results": Array [

View File

@ -1,288 +1,338 @@
import Webhooks from '@octokit/webhooks'
import { WebhookEvent } from "@octokit/webhooks";
import nock from "nock";
import pino from "pino";
import { Application } from '../src/application'
import { Context } from '../src/context'
import * as GitHubApiModule from '../src/github'
import { logger } from '../src/logger'
import { Application } from "../src/application";
import { Context } from "../src/context";
import { ProbotOctokit } from "../src/octokit/probot-octokit";
import Stream from "stream";
describe('Application', () => {
let app: Application
let event: Webhooks.WebhookEvent<any>
let output: any
describe("Application", () => {
let app: Application;
let event: WebhookEvent;
let output: any;
beforeAll(() => {
// Add a new stream for testing the logger
// https://github.com/trentm/node-bunyan#adding-a-stream
logger.addStream({
level: 'trace',
stream: { write: (log: any) => output.push(log) },
type: 'raw'
} as any)
})
const streamLogsToOutput = new Stream.Writable({ objectMode: true });
streamLogsToOutput._write = (object, encoding, done) => {
output.push(JSON.parse(object));
done();
};
beforeEach(() => {
// Clear log output
output = []
output = [];
app = new Application({} as any)
app.auth = jest.fn().mockReturnValue({})
app = new Application({
secret: "secret",
id: 1,
privateKey:
"-----BEGIN RSA PRIVATE KEY-----\nMIIBOQIBAAJBAIILhiN9IFpaE0pUXsesuuoaj6eeDiAqCiE49WB1tMB8ZMhC37kY\nFl52NUYbUxb7JEf6pH5H9vqw1Wp69u78XeUCAwEAAQJAb88urnaXiXdmnIK71tuo\n/TyHBKt9I6Rhfzz0o9Gv7coL7a537FVDvV5UCARXHJMF41tKwj+zlt9EEUw7a1HY\nwQIhAL4F/VHWSPHeTgXYf4EaX2OlpSOk/n7lsFtL/6bWRzRVAiEArzJs2vopJitv\nA1yBjz3q2nX+zthk+GLXrJQkYOnIk1ECIHfeFV8TWm5gej1LxZquBTA5pINoqDVq\nNKZSuZEHqGEFAiB6EDrxkovq8SYGhIQsJeqkTMO8n94xhMRZlFmIQDokEQIgAq5U\nr1UQNnUExRh7ZT0kFbMfO9jKYZVlQdCL9Dn93vo=\n-----END RSA PRIVATE KEY-----",
Octokit: ProbotOctokit.defaults({
retry: { enabled: false },
throttle: { enabled: false },
}),
log: pino(streamLogsToOutput),
});
event = {
id: '123-456',
name: 'test',
id: "123-456",
name: "pull_request",
payload: {
action: 'foo',
installation: { id: 1 }
}
}
})
action: "opened",
installation: { id: 1 },
},
};
});
describe('on', () => {
it('calls callback when no action is specified', async () => {
const spy = jest.fn()
app.on('test', spy)
beforeAll(() => {
nock.disableNetConnect();
});
expect(spy).toHaveBeenCalledTimes(0)
await app.receive(event)
expect(spy).toHaveBeenCalled()
expect(spy.mock.calls[0][0]).toBeInstanceOf(Context)
expect(spy.mock.calls[0][0].payload).toBe(event.payload)
})
afterAll(() => {
nock.restore();
});
it('calls callback with same action', async () => {
const spy = jest.fn()
app.on('test.foo', spy)
describe("on", () => {
it("calls callback when no action is specified", async () => {
const spy = jest.fn();
app.on("pull_request", spy);
await app.receive(event)
expect(spy).toHaveBeenCalled()
})
expect(spy).toHaveBeenCalledTimes(0);
await app.receive(event);
expect(spy).toHaveBeenCalled();
expect(spy.mock.calls[0][0]).toBeInstanceOf(Context);
expect(spy.mock.calls[0][0].payload).toBe(event.payload);
});
it('does not call callback with different action', async () => {
const spy = jest.fn()
app.on('test.nope', spy)
it("calls callback with same action", async () => {
const spy = jest.fn();
app.on("pull_request.opened", spy);
await app.receive(event)
expect(spy).toHaveBeenCalledTimes(0)
})
await app.receive(event);
expect(spy).toHaveBeenCalled();
});
it('calls callback with *', async () => {
const spy = jest.fn()
app.on('*', spy)
it("does not call callback with different action", async () => {
const spy = jest.fn();
app.on("pull_request.closed", spy);
await app.receive(event)
expect(spy).toHaveBeenCalled()
})
await app.receive(event);
expect(spy).toHaveBeenCalledTimes(0);
});
it('calls callback x amount of times when an array of x actions is passed', async () => {
const event2: Webhooks.WebhookEvent<any> = {
id: '123',
name: 'arrayTest',
it("calls callback with *", async () => {
const spy = jest.fn();
app.on("*", spy);
await app.receive(event);
expect(spy).toHaveBeenCalled();
});
it("calls callback x amount of times when an array of x actions is passed", async () => {
const event2: WebhookEvent = {
id: "123",
name: "issues",
payload: {
action: 'bar',
installation: { id: 2 }
}
}
action: "opened",
installation: { id: 2 },
},
};
const spy = jest.fn()
app.on(['test.foo', 'arrayTest.bar'], spy)
const spy = jest.fn();
app.on(["pull_request.opened", "issues.opened"], spy);
await app.receive(event)
await app.receive(event2)
expect(spy.mock.calls.length).toEqual(2)
})
await app.receive(event);
await app.receive(event2);
expect(spy.mock.calls.length).toEqual(2);
});
it('adds a logger on the context', async () => {
const handler = jest.fn().mockImplementation(context => {
expect(context.log).toBeDefined()
context.log('testing')
it("adds a logger on the context", async () => {
const handler = jest.fn().mockImplementation((context) => {
expect(context.log.info).toBeDefined();
context.log.info("testing");
expect(output[0]).toEqual(expect.objectContaining({
id: context.id,
msg: 'testing'
}))
})
expect(output[0]).toEqual(
expect.objectContaining({
id: context.id,
msg: "testing",
})
);
});
app.on('test', handler)
await app.receive(event)
expect(handler).toHaveBeenCalled()
})
app.on("pull_request", handler);
await app.receive(event).catch(console.error);
expect(handler).toHaveBeenCalled();
});
it('returns an authenticated client for installation.created', async () => {
it("returns an authenticated client for installation.created", async () => {
event = {
id: '123-456',
name: 'installation',
id: "123-456",
name: "installation",
payload: {
action: 'created',
installation: { id: 1 }
}
}
action: "created",
installation: { id: 1 },
},
};
app.on('installation.created', async context => {
// no-op
})
await app.receive(event)
expect(app.auth).toHaveBeenCalledWith(1, expect.anything())
})
it('returns an unauthenticated client for installation.deleted', async () => {
event = {
id: '123-456',
name: 'installation',
payload: {
action: 'deleted',
installation: { id: 1 }
}
}
app.on('installation.deleted', async context => {
// no-op
})
await app.receive(event)
expect(app.auth).toHaveBeenCalledWith()
})
it('returns an authenticated client for events without an installation', async () => {
event = {
id: '123-456',
name: 'foobar',
payload: { /* no installation */ }
}
app.on('foobar', async context => {
// no-op
})
await app.receive(event)
expect(app.auth).toHaveBeenCalledWith()
})
})
describe('receive', () => {
it('delivers the event', async () => {
const spy = jest.fn()
app.on('test', spy)
await app.receive(event)
expect(spy).toHaveBeenCalled()
})
it('waits for async events to resolve', async () => {
const spy = jest.fn()
app.on('test', () => {
return new Promise(resolve => {
setTimeout(() => {
spy()
resolve()
}, 1)
const mock = nock("https://api.github.com")
.post("/app/installations/1/access_tokens")
.reply(201, {
token: "v1.1f699f1069f60xxx",
permissions: {
issues: "write",
contents: "read",
},
})
})
.get("/")
.matchHeader("authorization", "token v1.1f699f1069f60xxx")
.reply(200, {});
await app.receive(event)
app.on("installation.created", async (context) => {
await context.github.request("/");
});
expect(spy).toHaveBeenCalled()
})
await app.receive(event);
it('returns a reject errors thrown in apps', async () => {
app.on('test', () => {
throw new Error('error from app')
})
expect(mock.activeMocks()).toStrictEqual([]);
});
it("returns an unauthenticated client for installation.deleted", async () => {
event = {
id: "123-456",
name: "installation",
payload: {
action: "deleted",
installation: { id: 1 },
},
};
const mock = nock("https://api.github.com")
.get("/")
.matchHeader("authorization", (value) => value === undefined)
.reply(200, {});
app.on("installation.deleted", async (context) => {
await context.github.request("/");
});
await app.receive(event).catch(console.log);
expect(mock.activeMocks()).toStrictEqual([]);
});
it("returns an authenticated client for events without an installation", async () => {
event = {
id: "123-456",
name: "check_run",
payload: {
/* no installation */
},
};
const mock = nock("https://api.github.com")
.get("/")
.matchHeader("authorization", (value) => value === undefined)
.reply(200, {});
app.on("check_run", async (context) => {
await context.github.request("/");
});
await app.receive(event).catch(console.log);
expect(mock.activeMocks()).toStrictEqual([]);
});
});
describe("receive", () => {
it("delivers the event", async () => {
const spy = jest.fn();
app.on("pull_request", spy);
await app.receive(event);
expect(spy).toHaveBeenCalled();
});
it("waits for async events to resolve", async () => {
const spy = jest.fn();
app.on("pull_request", () => {
return new Promise((resolve) => {
setTimeout(() => {
spy();
resolve();
}, 1);
});
});
await app.receive(event);
expect(spy).toHaveBeenCalled();
});
it("returns a reject errors thrown in apps", async () => {
app.on("pull_request", () => {
throw new Error("error from app");
});
try {
await app.receive(event)
throw new Error('expected error to be raised from app')
} catch (err) {
expect(err.message).toEqual('error from app')
await app.receive(event);
throw new Error("expected error to be raised from app");
} catch (error) {
expect(error.message).toMatch(/error from app/);
}
})
})
});
});
describe('load', () => {
it('loads one app', async () => {
const spy = jest.fn()
const myApp = (a: any) => a.on('test', spy)
describe("load", () => {
it("loads one app", async () => {
const spy = jest.fn();
const myApp = (a: any) => a.on("pull_request", spy);
app.load(myApp)
await app.receive(event)
expect(spy).toHaveBeenCalled()
})
app.load(myApp);
await app.receive(event);
expect(spy).toHaveBeenCalled();
});
it('loads multiple apps', async () => {
const spy = jest.fn()
const spy2 = jest.fn()
const myApp = (a: any) => a.on('test', spy)
const myApp2 = (a: any) => a.on('test', spy2)
it("loads multiple apps", async () => {
const spy = jest.fn();
const spy2 = jest.fn();
const myApp = (a: any) => a.on("pull_request", spy);
const myApp2 = (a: any) => a.on("pull_request", spy2);
app.load([myApp, myApp2])
await app.receive(event)
expect(spy).toHaveBeenCalled()
expect(spy2).toHaveBeenCalled()
})
})
app.load([myApp, myApp2]);
await app.receive(event);
expect(spy).toHaveBeenCalled();
expect(spy2).toHaveBeenCalled();
});
});
describe('auth', () => {
it('throttleOptions', async () => {
const appWithRedis = new Application({
describe("auth", () => {
it("throttleOptions", async () => {
const testApp = new Application({
Octokit: ProbotOctokit.plugin((octokit: any, options: any) => {
return {
pluginLoaded: true,
test() {
expect(options.throttle.id).toBe(1);
expect(options.throttle.foo).toBe("bar");
},
};
}),
id: 1,
privateKey: "private key",
secret: "secret",
throttleOptions: {
foo: 'bar'
}
} as any)
foo: "bar",
onAbuseLimit: () => true,
onRateLimit: () => true,
},
} as any);
Object.defineProperty(GitHubApiModule, 'GitHubAPI', {
value (options: any) {
expect(options.throttle.id).toBe(1)
expect(options.throttle.foo).toBe('bar')
return 'github mock'
}
})
const result = await testApp.auth(1);
expect(result.pluginLoaded).toEqual(true);
result.test();
});
});
const result = await appWithRedis.auth(1)
expect(result).toBe('github mock')
})
})
describe('error handling', () => {
let error: any
describe("error handling", () => {
let error: any;
beforeEach(() => {
error = new Error('testing')
app.log.error = jest.fn() as any
})
error = new Error("testing");
app.log.error = jest.fn() as any;
});
it('logs errors thrown from handlers', async () => {
app.on('test', () => {
throw error
})
it("logs errors thrown from handlers", async () => {
app.on("pull_request", () => {
throw error;
});
try {
await app.receive(event)
} catch (err) {
await app.receive(event);
} catch (error) {
// Expected
}
expect(output.length).toBe(1)
expect(output[0].err.message).toEqual('testing')
expect(output[0].event.id).toEqual(event.id)
})
expect(output.length).toBe(1);
it('logs errors from rejected promises', async () => {
app.on('test', () => Promise.reject(error))
expect(output[0].msg).toMatch(/testing/);
expect(output[0].event.id).toEqual(event.id);
});
it("logs errors from rejected promises", async () => {
app.on("pull_request", () => Promise.reject(error));
try {
await app.receive(event)
} catch (err) {
await app.receive(event);
} catch (error) {
// Expected
}
expect(output.length).toBe(1)
expect(output[0].err.message).toEqual('testing')
expect(output[0].event.id).toEqual(event.id)
})
})
})
expect(output.length).toBe(1);
expect(output[0].msg).toMatch(/testing/);
expect(output[0].event.id).toEqual(event.id);
});
});
});

View File

@ -0,0 +1,18 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Setup app GET /probot/setup returns a redirect 1`] = `
Array [
Array [
Object {
"WEBHOOK_PROXY_URL": "mocked proxy URL",
},
],
Array [
Object {
"APP_ID": "id",
"PRIVATE_KEY": "\\"pem\\"",
"WEBHOOK_SECRET": "webhook_secret",
},
],
]
`;

View File

@ -1,52 +1,57 @@
import express from 'express'
import request from 'supertest'
import { Application } from '../../src'
import appFn = require('../../src/apps/default')
import { createApp } from './helper'
import request from "supertest";
import { Probot } from "../../src";
import defaultApp = require("../../src/apps/default");
describe('default app', () => {
let server: express.Application
let app: Application
describe("default app", () => {
let probot: Probot;
beforeEach(async () => {
app = createApp(appFn)
server = express()
server.use(app.router)
})
probot = new Probot({
id: 1,
privateKey: "private key",
});
probot.load(defaultApp);
describe('GET /probot', () => {
it('returns a 200 response', () => {
return request(server).get('/probot').expect(200)
})
// there is currently no way to await probot.load, so we do hacky hack hack
await new Promise((resolve) => setTimeout(resolve, 100));
});
describe('get info from package.json', () => {
let cwd: string
describe("GET /probot", () => {
it("returns a 200 response", () => {
return request(probot.server).get("/probot").expect(200);
});
describe("get info from package.json", () => {
let cwd: string;
beforeEach(() => {
cwd = process.cwd()
})
cwd = process.cwd();
});
it('returns the correct HTML with values', async () => {
const actual = await request(server).get('/probot').expect(200)
expect(actual.text).toMatch('Welcome to probot')
expect(actual.text).toMatch('A framework for building GitHub Apps')
expect(actual.text).toMatch(/v\d+\.\d+\.\d+/)
})
it("returns the correct HTML with values", async () => {
const actual = await request(probot.server).get("/probot").expect(200);
expect(actual.text).toMatch("Welcome to probot");
expect(actual.text).toMatch("A framework for building GitHub Apps");
expect(actual.text).toMatch(/v\d+\.\d+\.\d+/);
});
it('returns the correct HTML without values', async () => {
process.chdir(__dirname)
const actual = await request(server).get('/probot').expect(200)
expect(actual.text).toMatch('Welcome to your Probot App')
})
it("returns the correct HTML without values", async () => {
process.chdir(__dirname);
const actual = await request(probot.server).get("/probot").expect(200);
expect(actual.text).toMatch("Welcome to your Probot App");
});
afterEach(() => {
process.chdir(cwd)
})
})
})
process.chdir(cwd);
});
});
});
describe('GET /', () => {
it('redirects to /probot', () => {
return request(server).get('/').expect(302).expect('location', '/probot')
})
})
})
describe("GET /", () => {
it("redirects to /probot", () => {
return request(probot.server)
.get("/")
.expect(302)
.expect("location", "/probot");
});
});
});

View File

@ -1,19 +0,0 @@
// FIXME: move this to a test helper that can be used by other apps
import cacheManager from 'cache-manager'
import { Application, ApplicationFunction } from '../../src'
const cache = cacheManager.caching({ store: 'memory', ttl: 0 })
export function newApp (): Application {
return new Application({ app: {
getInstallationAccessToken: jest.fn().mockResolvedValue('test'),
getSignedJsonWebToken: jest.fn().mockReturnValue('test')
}, cache })
}
export function createApp (appFn?: ApplicationFunction) {
const app = newApp()
appFn && appFn(app)
return app
}

View File

@ -1,43 +0,0 @@
import Raven from 'raven'
import { Application } from '../../src'
import appFn = require('../../src/apps/sentry')
import { createApp } from './helper'
describe('sentry app', () => {
let app: Application
beforeEach(async () => {
app = createApp()
})
beforeEach(() => {
// Clean up env variable
delete process.env.SENTRY_DSN
})
describe('with an invalid SENTRY_DSN', () => {
test('throws an error', () => {
process.env.SENTRY_DSN = '1233'
expect(() => {
appFn(app)
}).toThrow(/Invalid Sentry DSN: 1233/)
})
})
describe('with a SENTRY_DSN', () => {
beforeEach(() => {
process.env.SENTRY_DSN = 'https://user:pw@sentry.io/123'
appFn(app)
Raven.captureException = jest.fn()
})
test('sends reported errors to sentry', () => {
const err = new Error('test message')
app.log.error(err)
expect(Raven.captureException).toHaveBeenCalledWith(err, expect.objectContaining({
extra: expect.anything()
}))
})
})
})

View File

@ -1,47 +1,62 @@
import express from 'express'
import request from 'supertest'
import { Application } from '../../src'
import appFn from '../../src/apps/setup'
import { ManifestCreation } from '../../src/manifest-creation'
import { newApp } from './helper'
const createChannel = jest.fn().mockResolvedValue("mocked proxy URL");
const updateDotenv = jest.fn().mockResolvedValue({});
jest.mock("smee-client", () => ({ createChannel }));
jest.mock("update-dotenv", () => updateDotenv);
describe('Setup app', () => {
let server: express.Application
let app: Application
let setup: ManifestCreation
import nock from "nock";
import request from "supertest";
import { Probot } from "../../src";
import { setupApp } from "../../src/apps/setup";
describe("Setup app", () => {
let probot: Probot;
beforeEach(async () => {
app = newApp()
setup = new ManifestCreation()
delete process.env.WEBHOOK_PROXY_URL;
probot = new Probot({});
probot.load(setupApp);
setup.createWebhookChannel = jest.fn()
// there is currently no way to await probot.load, so we do hacky hack hack
await new Promise((resolve) => setTimeout(resolve, 100));
});
await appFn(app, setup)
server = express()
server.use(app.router)
})
afterEach(() => {
jest.clearAllMocks();
});
describe('GET /probot', () => {
it('returns a 200 response', () => {
return request(server)
.get('/probot')
.expect(200)
})
})
describe("GET /probot", () => {
it("returns a 200 response", async () => {
await request(probot.server).get("/probot").expect(200);
});
});
describe('GET /probot/setup', () => {
it('returns a 200 response', () => {
return request(server)
.get('/probot')
.expect(200)
})
})
describe("GET /probot/setup", () => {
it("returns a redirect", async () => {
nock("https://api.github.com")
.post("/app-manifests/123/conversions")
.reply(201, {
html_url: "/apps/my-app",
id: "id",
pem: "pem",
webhook_secret: "webhook_secret",
});
describe('GET /probot/success', () => {
it('returns a 200 response', () => {
return request(server)
.get('/probot/success')
.expect(200)
})
})
})
await request(probot.server)
.get("/probot/setup")
.query({ code: "123" })
.expect(302)
.expect("Location", "/apps/my-app/installations/new");
expect(createChannel).toHaveBeenCalledTimes(1);
expect(updateDotenv.mock.calls).toMatchSnapshot();
});
});
describe("GET /probot/success", () => {
it("returns a 200 response", async () => {
await request(probot.server).get("/probot/success").expect(200);
expect(createChannel).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -1,82 +0,0 @@
import express from 'express'
import nock from 'nock'
import request from 'supertest'
import { Application } from '../../src'
import appFn = require('../../src/apps/stats')
import { createApp } from './helper'
describe('stats app', () => {
let app: Application
let server: express.Application
beforeEach(() => {
// Clean up env variable
delete process.env.DISABLE_STATS
server = express()
})
describe('GET /probot/stats', () => {
beforeEach(async () => {
nock('https://api.github.com')
.defaultReplyHeaders({ 'Content-Type': 'application/json' })
.post('/app/installations/1/access_tokens').reply(200, { token: 'test' })
.get('/app/installations?per_page=100').reply(200, [{ id: 1, account: { login: 'testing' } }])
.get('/installation/repositories?per_page=100').reply(200, {
repositories: [
{ private: true, stargazers_count: 1 },
{ private: false, stargazers_count: 2 }
]
})
app = createApp(appFn)
server.use(app.router)
})
it('returns installation count and popular accounts', () => {
return request(server).get('/probot/stats')
.expect(200, { 'installations': 1, 'popular': [{ login: 'testing', stars: 2 }] })
})
})
describe('can be disabled', () => {
beforeEach(async () => {
process.env.DISABLE_STATS = 'true'
app = createApp(appFn)
server.use(app.router)
})
it('/probot/stats returns 404', () => {
return request(server).get('/probot/stats').expect(404)
})
})
describe('it ignores spammy users', () => {
beforeEach(async () => {
process.env.IGNORED_ACCOUNTS = 'hiimbex,spammyUser'
nock('https://api.github.com')
.defaultReplyHeaders({ 'Content-Type': 'application/json' })
.post('/app/installations/1/access_tokens').reply(200, { token: 'test' })
.get('/app/installations?per_page=100').reply(200, [{ id: 1, account: { login: 'spammyUser' } }])
.get('/installation/repositories?per_page=100').reply(200, {
repositories: [
{ private: true, stargazers_count: 1 },
{ private: false, stargazers_count: 2 }
]
})
app = createApp(appFn)
server.use(app.router)
})
it('returns installation count and popular accounts while excluding spammy users', () => {
return request(server).get('/probot/stats')
.expect(200, { 'installations': 1, 'popular': [] })
})
afterEach(async () => {
delete process.env.IGNORED_ACCOUNTS
})
})
})

File diff suppressed because it is too large Load Diff

65
test/deprecations.test.ts Normal file
View File

@ -0,0 +1,65 @@
import Stream from "stream";
import { Webhooks } from "@octokit/webhooks";
import pino from "pino";
import { createProbot, Probot } from "../src";
describe("Deprecations", () => {
let output: any;
const streamLogsToOutput = new Stream.Writable({ objectMode: true });
streamLogsToOutput._write = (object, encoding, done) => {
output.push(JSON.parse(object));
done();
};
beforeEach(() => {
output = [];
});
it("createProbot", () => {
const probot = createProbot({ log: pino(streamLogsToOutput) });
expect(probot).toBeInstanceOf(Probot);
expect(output.length).toEqual(1);
expect(output[0].msg).toContain(
`[probot] "createProbot(options)" is deprecated, use "new Probot(options)" instead`
);
});
it("probot.webhook", () => {
const probot = new Probot({ log: pino(streamLogsToOutput) });
expect(probot).toBeInstanceOf(Probot);
expect(probot.webhook).toBeInstanceOf(Webhooks);
expect(output.length).toEqual(1);
expect(output[0].msg).toContain(
`[probot] "probot.webhook" is deprecated. Use "probot.webhooks" instead instead`
);
});
it("new Probot({ cert })", () => {
new Probot({
id: 1,
cert: "private key",
log: pino(streamLogsToOutput),
});
expect(output.length).toEqual(1);
expect(output[0].msg).toContain(
`[probot] "cert" option is deprecated. Use "privateKey" instead`
);
});
it("probot.logger", () => {
const probot = new Probot({ log: pino(streamLogsToOutput) });
probot.logger.info("test");
expect(output.length).toEqual(2);
expect(output[0].msg).toContain(
`[probot] "probot.logger" is deprecated. Use "probot.log" instead`
);
});
});

122
test/e2e/e2e.test.ts Normal file
View File

@ -0,0 +1,122 @@
import execa from "execa";
import getPort from "get-port";
// import nock from 'nock'
import { sign } from "@octokit/webhooks";
import bodyParser from "body-parser";
import express from "express";
import got from "got";
jest.setTimeout(10000);
/**
* In these tests we are starting probot apps by running "npm run [path to app.js]" using ghub.io/execa.
* This allows us to pass dynamic environment variables for configuration.
*
* We also spawn a mock server which receives the Octokit requests from the app and uses jest assertions
* to verify they are what we expect
*/
describe("end-to-end-tests", () => {
let server: any;
let probotProcess: any;
let probotPort: number;
let mockServerPort: number;
beforeEach(async () => {
server = null;
probotProcess = null;
mockServerPort = await getPort();
probotPort = await getPort();
});
afterEach(() => {
if (server) server.close();
if (probotProcess) probotProcess.cancel();
});
it("hello-world app", async () => {
const app = express();
const httpMock = jest
.fn()
.mockImplementationOnce((req, res) => {
expect(req.method).toEqual("POST");
expect(req.path).toEqual("/app/installations/1/access_tokens");
res.status(201).json({
token: "v1.1f699f1069f60xxx",
permissions: {
issues: "write",
contents: "read",
},
repository_selection: "all",
});
})
.mockImplementationOnce((req, res) => {
expect(req.method).toEqual("POST");
expect(req.path).toEqual(
"/repos/octocat/hello-world/issues/1/comments"
);
expect(req.body).toStrictEqual({ body: "Hello World!" });
res.status(201).json({});
});
// tslint:disable-next-line
app.use(bodyParser.json());
app.use("/api/v3", httpMock);
server = app.listen(mockServerPort);
probotProcess = execa(
"bin/probot.js",
["run", "./test/e2e/hello-world.js"],
{
env: {
APP_ID: "1",
PRIVATE_KEY:
"-----BEGIN RSA PRIVATE KEY-----\nMIIBOQIBAAJBAIILhiN9IFpaE0pUXsesuuoaj6eeDiAqCiE49WB1tMB8ZMhC37kY\nFl52NUYbUxb7JEf6pH5H9vqw1Wp69u78XeUCAwEAAQJAb88urnaXiXdmnIK71tuo\n/TyHBKt9I6Rhfzz0o9Gv7coL7a537FVDvV5UCARXHJMF41tKwj+zlt9EEUw7a1HY\nwQIhAL4F/VHWSPHeTgXYf4EaX2OlpSOk/n7lsFtL/6bWRzRVAiEArzJs2vopJitv\nA1yBjz3q2nX+zthk+GLXrJQkYOnIk1ECIHfeFV8TWm5gej1LxZquBTA5pINoqDVq\nNKZSuZEHqGEFAiB6EDrxkovq8SYGhIQsJeqkTMO8n94xhMRZlFmIQDokEQIgAq5U\nr1UQNnUExRh7ZT0kFbMfO9jKYZVlQdCL9Dn93vo=\n-----END RSA PRIVATE KEY-----",
WEBHOOK_SECRET: "test",
PORT: String(probotPort),
GHE_HOST: `127.0.0.1:${mockServerPort}`,
GHE_PROTOCOL: "http",
DISABLE_WEBHOOK_EVENT_CHECK: "true",
},
}
);
// give probot a moment to start
await new Promise((resolve) => setTimeout(resolve, 3000));
// send webhook event request
const body = JSON.stringify({
action: "opened",
issue: {
number: "1",
},
repository: {
owner: {
login: "octocat",
},
name: "hello-world",
},
installation: {
id: 1,
},
});
try {
await got.post(`http://127.0.0.1:${probotPort}`, {
headers: {
"content-type": "application/json",
"x-github-event": "issues",
"x-github-delivery": "1",
"x-hub-signature": sign("test", body),
},
body,
});
} catch (error) {
probotProcess.cancel();
console.log((await probotProcess).stdout);
}
expect(httpMock).toHaveBeenCalledTimes(2);
});
});

17
test/e2e/hello-world.js Normal file
View File

@ -0,0 +1,17 @@
console.log("waddafugg");
module.exports = (app) => {
// Your code here
app.log.info("Yay! The app was loaded!");
// example of probot responding 'Hello World' to a new issue being opened
app.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', issue_number: 123, body: 'Hello World!}
const params = context.issue({ body: "Hello World!" });
// Post a comment on the issue
await context.github.issues.createComment(params);
});
};

View File

@ -1 +1 @@
evil: !<tag:yaml.org,2002:js/function> 'function () {}'
evil: !<tag:yaml.org,2002:js/function> "function () {}"

View File

@ -1,9 +1,9 @@
import { Application } from '../../src/application'
import { Application } from "../../src/application";
export = (app: Application) => {
app.log('loaded app')
app.log.info("loaded app");
app.on('issue_comment.created', async context => {
context.log('Comment created')
})
}
app.on("issue_comment.created", async (context) => {
context.log.info("Comment created");
});
};

View File

@ -1,24 +1,22 @@
import { Octokit } from '@octokit/rest'
import { OctokitResponse } from "@octokit/types";
export const toGitHubResponse = <T = any>(data: T): Octokit.Response<T> => ({
export const toGitHubResponse = <T = any>(data: T): OctokitResponse<T> => ({
data,
headers: {
date: '',
etag: '',
'last-modified': '',
link: '',
status: '',
'x-Octokit-media-type': '',
'x-Octokit-request-id': '',
'x-ratelimit-limit': '',
'x-ratelimit-remaining': '',
'x-ratelimit-reset': ''
date: "",
etag: "",
"last-modified": "",
link: "",
status: "",
"x-Octokit-media-type": "",
"x-Octokit-request-id": "",
"x-ratelimit-limit": "",
"x-ratelimit-remaining": "",
"x-ratelimit-reset": "",
},
status: 200,
*[Symbol.iterator] () {
yield 0
}
})
url: "",
});
export const createMockResponse = <T>(data: T): Promise<Octokit.Response<T>> =>
Promise.resolve(toGitHubResponse(data))
export const createMockResponse = <T>(data: T): Promise<OctokitResponse<T>> =>
Promise.resolve(toGitHubResponse(data));

View File

@ -1,2 +1,2 @@
// tslint:disable-next-line:no-empty
export = () => {}
export = () => {};

View File

@ -25,15 +25,9 @@
"email": "noreply@github.com",
"username": "web-flow"
},
"added": [
],
"removed": [
],
"modified": [
"README.md"
]
"added": [],
"removed": [],
"modified": ["README.md"]
}
],
"head_commit": {
@ -53,15 +47,9 @@
"email": "noreply@github.com",
"username": "web-flow"
},
"added": [
],
"removed": [
],
"modified": [
"README.md"
]
"added": [],
"removed": [],
"modified": ["README.md"]
},
"repository": {
"id": 68474533,

View File

@ -1,198 +1,197 @@
import nock from 'nock'
import { GitHubAPI, Options, ProbotOctokit } from '../src/github'
import { logger } from '../src/logger'
import nock from "nock";
import { ProbotOctokit } from "../src/octokit/probot-octokit";
describe('GitHubAPI', () => {
let github: GitHubAPI
type Options = ConstructorParameters<typeof ProbotOctokit>[0];
describe("ProbotOctokit", () => {
let github: InstanceType<typeof ProbotOctokit>;
const defaultOptions: Options = {
Octokit: ProbotOctokit,
logger,
retry: {
// disable retries to test error states
enabled: false
enabled: false,
},
throttle: {
// disable throttling, otherwise tests are _slow_
enabled: false
}
}
enabled: false,
},
};
beforeEach(() => {
github = GitHubAPI(defaultOptions)
})
github = new ProbotOctokit(defaultOptions);
});
test('works without options', async () => {
github = GitHubAPI()
const user = { login: 'ohai' }
test("works without options", async () => {
github = new ProbotOctokit();
const user = { login: "ohai" };
nock('https://api.github.com').get('/user').reply(200, user)
expect((await github.users.getAuthenticated({})).data).toEqual(user)
})
nock("https://api.github.com").get("/user").reply(200, user);
expect((await github.users.getAuthenticated({})).data).toEqual(user);
});
test('logs request errors', async () => {
nock('https://api.github.com')
.get('/')
.reply(500, {})
test("logs request errors", async () => {
nock("https://api.github.com").get("/").reply(500, {});
try {
await github.request('/')
throw new Error('should throw')
await github.request("/");
throw new Error("should throw");
} catch (error) {
expect(error.status).toBe(500)
expect(error.status).toBe(500);
}
})
});
describe('with retry enabled', () => {
describe("with retry enabled", () => {
beforeEach(() => {
const options: Options = {
...defaultOptions,
retry: {
enabled: true
}
}
enabled: true,
},
};
github = GitHubAPI(options)
})
github = new ProbotOctokit(options);
});
test('retries failed requests', async () => {
nock('https://api.github.com')
.get('/')
.once()
.reply(500, {})
test("retries failed requests", async () => {
nock("https://api.github.com").get("/").once().reply(500, {});
nock('https://api.github.com')
.get('/')
.once()
.reply(200, {})
nock("https://api.github.com").get("/").once().reply(200, {});
const response = await github.request('/')
expect(response.status).toBe(200)
})
})
const response = await github.request("/");
expect(response.status).toBe(200);
});
});
describe('with throttling enabled', () => {
describe("with throttling enabled", () => {
beforeEach(() => {
const options: Options = {
...defaultOptions,
throttle: {
enabled: true,
minimumAbuseRetryAfter: 1
}
}
minimumAbuseRetryAfter: 1,
onRateLimit() {
return true;
},
onAbuseLimit() {
return true;
},
},
};
github = GitHubAPI(options)
})
github = new ProbotOctokit(options);
});
test('retries requests when being rate limited', async () => {
nock('https://api.github.com')
.get('/')
.once()
.reply(403, {}, {
'X-RateLimit-Limit': '60',
'X-RateLimit-Remaining': '0',
'X-RateLimit-Reset': `${new Date().getTime() / 1000}`
})
test("retries requests when being rate limited", async () => {
nock("https://api.github.com")
.get("/")
.reply(
403,
{},
{
"X-RateLimit-Limit": "60",
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": `${new Date().getTime() / 1000}`,
}
)
nock('https://api.github.com')
.get('/')
.once()
.reply(200, {})
.get("/")
.reply(200, {});
const response = await github.request('/')
expect(response.status).toBe(200)
})
const { status } = await github.request("/");
expect(status).toBe(200);
});
test('retries requests when hitting the abuse limiter', async () => {
nock('https://api.github.com')
.get('/')
.once()
.reply(403, { message: 'The throttle plugin just looks for the word abuse in the error message' })
test("retries requests when hitting the abuse limiter", async () => {
nock("https://api.github.com").get("/").once().reply(403, {
message:
"The throttle plugin just looks for the word abuse in the error message",
});
nock('https://api.github.com')
.get('/')
.once()
.reply(200, {})
nock("https://api.github.com").get("/").once().reply(200, {});
const response = await github.request('/')
expect(response.status).toBe(200)
})
})
const response = await github.request("/");
expect(response.status).toBe(200);
});
});
describe('paginate', () => {
describe("paginate", () => {
// Prepare an array of issue objects
const issues = new Array(5).fill(0).map((_, i, arr) => {
return {
id: i,
number: i,
title: `Issue number ${i}`
}
})
title: `Issue number ${i}`,
};
});
beforeEach(() => {
nock('https://api.github.com')
.get('/repos/JasonEtco/pizza/issues?per_page=1').reply(200, [issues[0]], {
link: '<https://api.github.com/repositories/123/issues?per_page=1&page=2>; rel="next"'
nock("https://api.github.com")
.get("/repos/JasonEtco/pizza/issues?per_page=1")
.reply(200, [issues[0]], {
link:
'<https://api.github.com/repositories/123/issues?per_page=1&page=2>; rel="next"',
})
.get('/repositories/123/issues?per_page=1&page=2').reply(200, [issues[1]], {
link: '<https://api.github.com/repositories/123/issues?per_page=1&page=3>; rel="next"'
.get("/repositories/123/issues?per_page=1&page=2")
.reply(200, [issues[1]], {
link:
'<https://api.github.com/repositories/123/issues?per_page=1&page=3>; rel="next"',
})
.get('/repositories/123/issues?per_page=1&page=3').reply(200, [issues[2]], {
link: '<https://api.github.com/repositories/123/issues?per_page=1&page=4>; rel="next"'
.get("/repositories/123/issues?per_page=1&page=3")
.reply(200, [issues[2]], {
link:
'<https://api.github.com/repositories/123/issues?per_page=1&page=4>; rel="next"',
})
.get('/repositories/123/issues?per_page=1&page=4').reply(200, [issues[3]], {
link: '<https://api.github.com/repositories/123/issues?per_page=1&page=5>; rel="next"'
.get("/repositories/123/issues?per_page=1&page=4")
.reply(200, [issues[3]], {
link:
'<https://api.github.com/repositories/123/issues?per_page=1&page=5>; rel="next"',
})
.get('/repositories/123/issues?per_page=1&page=5').reply(200, [issues[4]], {
link: ''
})
})
.get("/repositories/123/issues?per_page=1&page=5")
.reply(200, [issues[4]], {
link: "",
});
});
it('returns an array of pages', async () => {
const spy = jest.fn()
const res = await github.paginate(github.issues.listForRepo.endpoint.merge({ owner: 'JasonEtco', repo: 'pizza', per_page: 1 }), spy)
expect(Array.isArray(res)).toBeTruthy()
expect(res.length).toBe(5)
expect(spy).toHaveBeenCalledTimes(5)
})
it("returns an array of pages", async () => {
const spy = jest.fn();
const res = await github.paginate(
github.issues.listForRepo.endpoint.merge({
owner: "JasonEtco",
repo: "pizza",
per_page: 1,
}),
spy
);
expect(Array.isArray(res)).toBeTruthy();
expect(res.length).toBe(5);
expect(spy).toHaveBeenCalledTimes(5);
});
it('stops iterating if the done() function is called in the callback', async () => {
it("stops iterating if the done() function is called in the callback", async () => {
const spy = jest.fn((response, done) => {
if (response.data[0].id === 2) done()
}) as any
const res = await github.paginate(github.issues.listForRepo.endpoint.merge({ owner: 'JasonEtco', repo: 'pizza', per_page: 1 }), spy)
expect(res.length).toBe(3)
expect(spy).toHaveBeenCalledTimes(3)
})
if (response.data[0].id === 2) done();
}) as any;
const res = await github.paginate(
github.issues.listForRepo.endpoint.merge({
owner: "JasonEtco",
repo: "pizza",
per_page: 1,
}),
spy
);
expect(res.length).toBe(3);
expect(spy).toHaveBeenCalledTimes(3);
});
it('maps the responses to data by default', async () => {
const res = await github.paginate(github.issues.listForRepo.endpoint.merge({ owner: 'JasonEtco', repo: 'pizza', per_page: 1 }))
expect(res).toEqual(issues)
})
describe('deprecations', () => {
let consoleWarnSpy: any
beforeEach(() => {
consoleWarnSpy = jest.spyOn(global.console, 'warn').mockImplementation(() => null)
})
afterEach(() => {
consoleWarnSpy.mockReset()
})
it('github.paginate(promise)', async () => {
const res = await github.paginate(github.issues.listForRepo({ owner: 'JasonEtco', repo: 'pizza', per_page: 1 }))
expect(Array.isArray(res)).toBeTruthy()
expect(res.length).toBe(5)
expect(consoleWarnSpy).toHaveBeenCalled()
})
it('maps to each response by default when using deprecated syntax', async () => {
const res = await github.paginate(github.issues.listForRepo({ owner: 'JasonEtco', repo: 'pizza', per_page: 1 }))
expect(Array.isArray(res)).toBeTruthy()
expect(res.length).toBe(5)
expect('headers' in res[0]).toBeTruthy()
expect(consoleWarnSpy).toHaveBeenCalled()
})
})
})
})
it("maps the responses to data by default", async () => {
const res = await github.paginate(
github.issues.listForRepo.endpoint.merge({
owner: "JasonEtco",
repo: "pizza",
per_page: 1,
})
);
expect(res).toEqual(issues);
});
});
});

View File

@ -1,178 +0,0 @@
import nock from 'nock'
import { GitHubAPI, Options, ProbotOctokit } from '../../src/github'
import { logger } from '../../src/logger'
describe('github/graphql', () => {
let github: GitHubAPI
// Expect there are no more pending nock requests
beforeEach(async () => nock.cleanAll())
afterEach(() => expect(nock.pendingMocks()).toEqual([]))
beforeEach(() => {
const options: Options = {
Octokit: ProbotOctokit,
auth: 'token testing',
logger
}
github = GitHubAPI(options)
})
describe('query', () => {
const query = 'query { viewer { login } }'
let data: any
test('makes an authenticated graphql query', async () => {
data = { viewer: { login: 'bkeepers' } }
nock('https://api.github.com', {
reqheaders: {
authorization: 'token testing',
'content-type': 'application/json; charset=utf-8'
}
})
.post('/graphql', { query })
.reply(200, { data })
expect(await github.graphql(query)).toEqual(data)
})
test('makes a graphql query with variables', async () => {
const variables = { owner: 'probot', repo: 'test' }
nock('https://api.github.com').post('/graphql', { query, variables })
.reply(200, { data })
expect(await github.graphql(query, variables)).toEqual(data)
})
test('allows custom headers', async () => {
nock('https://api.github.com', {
reqheaders: { foo: 'bar' }
}).post('/graphql', { query })
.reply(200, { data })
await github.graphql(query, { headers: { foo: 'bar' } })
})
test('raises errors', async () => {
const response = { 'data': null, 'errors': [{ 'message': 'Unexpected end of document' }] }
nock('https://api.github.com').post('/graphql', { query })
.reply(200, response)
let thrownError
try {
await github.graphql(query)
} catch (err) {
thrownError = err
}
expect(thrownError).not.toBeUndefined()
expect(thrownError.name).toEqual('GraphqlError')
expect(thrownError.toString()).toContain('Unexpected end of document')
expect(thrownError.request.query).toEqual(query)
expect(thrownError.errors).toEqual(response.errors)
})
})
describe('ghe support', () => {
const query = 'query { viewer { login } }'
let data
beforeEach(() => {
process.env.GHE_HOST = 'notreallygithub.com'
const options: Options = {
Octokit: ProbotOctokit,
logger
}
github = GitHubAPI(options)
})
afterEach(() => {
delete process.env.GHE_HOST
})
test('makes a graphql query', async () => {
data = { viewer: { login: 'bkeepers' } }
nock('https://notreallygithub.com', {
reqheaders: { 'content-type': 'application/json; charset=utf-8' }
}).post('/api/graphql', { query })
.reply(200, { data })
expect(await github.graphql(query)).toEqual(data)
})
})
describe('ghe support with http', () => {
const query = 'query { viewer { login } }'
let data
beforeEach(() => {
process.env.GHE_HOST = 'notreallygithub.com'
process.env.GHE_PROTOCOL = 'http'
const options: Options = {
Octokit: ProbotOctokit,
logger
}
github = GitHubAPI(options)
})
afterEach(() => {
delete process.env.GHE_HOST
delete process.env.GHE_PROTOCOL
})
test('makes a graphql query', async () => {
data = { viewer: { login: 'bkeepers' } }
nock('http://notreallygithub.com', {
reqheaders: { 'content-type': 'application/json; charset=utf-8' }
}).post('/api/graphql', { query })
.reply(200, { data })
expect(await github.graphql(query)).toEqual(data)
})
})
describe('deprecations', () => {
const query = 'query { viewer { login } }'
let data: any
let consoleWarnSpy: any
beforeEach(() => {
consoleWarnSpy = jest.spyOn(global.console, 'warn').mockImplementation(() => null)
})
afterEach(() => {
consoleWarnSpy.mockReset()
})
test('github.query', async () => {
data = { viewer: { login: 'bkeepers' } }
nock('https://api.github.com', {
reqheaders: { 'content-type': 'application/json; charset=utf-8' }
})
.post('/graphql', { query })
.reply(200, { data })
// tslint:disable-next-line:deprecation
expect(await github.query(query)).toEqual(data)
expect(consoleWarnSpy).toHaveBeenCalled()
})
test('headers as 3rd argument', async () => {
nock('https://api.github.com', {
reqheaders: { 'foo': 'bar' }
}).post('/graphql', { query })
.reply(200, { data })
await github.graphql(query, undefined, { foo: 'bar' })
})
})
})

View File

@ -1,475 +1,457 @@
import { Octokit } from '@octokit/rest'
import Bottleneck from 'bottleneck'
import { NextFunction, Request, Response } from 'express'
import nock = require('nock')
import request = require('supertest')
import { Application, createProbot, Probot } from '../src'
import { GitHubAPI } from '../src/github'
import { EventNames } from "@octokit/webhooks";
import Bottleneck from "bottleneck";
import { NextFunction, Request, Response } from "express";
import request = require("supertest");
import path = require('path')
import helper = require('./apps/helper')
import { Application, Probot } from "../src";
import { ProbotOctokit } from "../src/octokit/probot-octokit";
import path = require("path");
const id = 1;
const privateKey = `-----BEGIN RSA PRIVATE KEY-----
MIIBOQIBAAJBAIILhiN9IFpaE0pUXsesuuoaj6eeDiAqCiE49WB1tMB8ZMhC37kY
Fl52NUYbUxb7JEf6pH5H9vqw1Wp69u78XeUCAwEAAQJAb88urnaXiXdmnIK71tuo
/TyHBKt9I6Rhfzz0o9Gv7coL7a537FVDvV5UCARXHJMF41tKwj+zlt9EEUw7a1HY
wQIhAL4F/VHWSPHeTgXYf4EaX2OlpSOk/n7lsFtL/6bWRzRVAiEArzJs2vopJitv
A1yBjz3q2nX+zthk+GLXrJQkYOnIk1ECIHfeFV8TWm5gej1LxZquBTA5pINoqDVq
NKZSuZEHqGEFAiB6EDrxkovq8SYGhIQsJeqkTMO8n94xhMRZlFmIQDokEQIgAq5U
r1UQNnUExRh7ZT0kFbMfO9jKYZVlQdCL9Dn93vo=
-----END RSA PRIVATE KEY-----`;
// tslint:disable:no-empty
describe('Probot', () => {
let probot: Probot
describe("Probot", () => {
let probot: Probot;
let event: {
id: string
name: string
payload: any
}
id: string;
name: EventNames.StringNames;
payload: any;
};
beforeEach(() => {
probot = createProbot({ githubToken: 'faketoken' })
// process.env.DISABLE_WEBHOOK_EVENT_CHECK = "true";
probot = new Probot({ githubToken: "faketoken" });
event = {
id: '0',
name: 'push',
payload: require('./fixtures/webhook/push')
}
})
id: "0",
name: "push",
payload: require("./fixtures/webhook/push"),
};
});
it('constructor', () => {
it("constructor", () => {
// probot with token. Should not throw
createProbot({ githubToken: 'faketoken' })
// probot with id/cert
createProbot({ id: 1234, cert: 'xxxx' })
})
new Probot({ githubToken: "faketoken" });
describe('run', () => {
// probot with id/privateKey
new Probot({ id, privateKey });
});
let env: NodeJS.ProcessEnv
describe("run", () => {
let env: NodeJS.ProcessEnv;
beforeAll(() => {
env = { ...process.env }
process.env.APP_ID = '1'
process.env.PRIVATE_KEY_PATH = path.join(__dirname, 'test-private-key.pem')
process.env.WEBHOOK_PROXY_URL = 'https://smee.io/EfHXC9BFfGAxbM6J'
process.env.DISABLE_STATS = 'true'
})
env = { ...process.env };
process.env.APP_ID = "1";
process.env.PRIVATE_KEY_PATH = path.join(
__dirname,
"test-private-key.pem"
);
process.env.WEBHOOK_PROXY_URL = "https://smee.io/EfHXC9BFfGAxbM6J";
process.env.WEBHOOK_SECRET = "secret";
});
afterAll(() => {
process.env = env
})
process.env = env;
});
it('runs with a function as argument', async () => {
process.env.PORT = '3003'
let initialized = false
it("runs with a function as argument", async () => {
process.env.PORT = "3003";
let initialized = false;
probot = await Probot.run((app) => {
initialized = true
})
expect(probot.options).toMatchSnapshot()
expect(initialized).toBeTruthy()
probot.httpServer!.close()
})
initialized = true;
});
expect(probot.options).toMatchSnapshot();
expect(initialized).toBeTruthy();
probot.stop();
});
it('runs with an array of strings', async () => {
probot = await Probot.run(['run', 'file.js'])
expect(probot.options).toMatchSnapshot()
probot.httpServer!.close()
})
it("runs with an array of strings", async () => {
probot = await Probot.run(["run", "file.js"]);
expect(probot.options).toMatchSnapshot();
probot.stop();
});
it('runs without config and loads the setup app', async () => {
let initialized = false
delete process.env.PRIVATE_KEY_PATH
it("runs without config and loads the setup app", async () => {
let initialized = false;
delete process.env.PRIVATE_KEY_PATH;
probot = await Probot.run((app) => {
initialized = true
})
expect(probot.options).toMatchSnapshot()
expect(initialized).toBeFalsy()
probot.httpServer!.close()
})
})
initialized = true;
});
expect(probot.options).toMatchSnapshot();
expect(initialized).toBeFalsy();
probot.stop();
});
});
describe('webhook delivery', () => {
it('forwards webhooks to the app', async () => {
const app = probot.load(() => {})
app.receive = jest.fn()
await probot.webhook.receive(event)
expect(app.receive).toHaveBeenCalledWith(event)
})
it('responds with the correct error if webhook secret does not match', async () => {
probot.logger.error = jest.fn()
probot.webhook.on('push', () => { throw new Error('X-Hub-Signature does not match blob signature') })
describe("webhook delivery", () => {
it("responds with the correct error if webhook secret does not match", async () => {
probot.log.error = jest.fn();
probot.webhooks.on("push", () => {
throw new Error("X-Hub-Signature does not match blob signature");
});
try {
await probot.webhook.receive(event)
await probot.webhooks.receive(event);
} catch (e) {
expect((probot.logger.error as jest.Mock).mock.calls[0]).toMatchSnapshot()
expect(
(probot.log.error as jest.Mock).mock.calls[0][1]
).toMatchSnapshot();
}
})
});
it('responds with the correct error if webhook secret is not found', async () => {
probot.logger.error = jest.fn()
probot.webhook.on('push', () => { throw new Error('No X-Hub-Signature found on request') })
it("responds with the correct error if webhook secret is not found", async () => {
probot.log.error = jest.fn();
probot.webhooks.on("push", () => {
throw new Error("No X-Hub-Signature found on request");
});
try {
await probot.webhook.receive(event)
await probot.webhooks.receive(event);
} catch (e) {
expect((probot.logger.error as jest.Mock).mock.calls[0]).toMatchSnapshot()
expect(
(probot.log.error as jest.Mock).mock.calls[0][1]
).toMatchSnapshot();
}
})
});
it('responds with the correct error if webhook secret is wrong', async () => {
probot.logger.error = jest.fn()
probot.webhook.on('push', () => { throw new Error('webhooks:receiver ignored: POST / due to missing headers: x-hub-signature') })
it("responds with the correct error if webhook secret is wrong", async () => {
probot.log.error = jest.fn();
probot.webhooks.on("push", () => {
throw new Error(
"webhooks:receiver ignored: POST / due to missing headers: x-hub-signature"
);
});
try {
await probot.webhook.receive(event)
await probot.webhooks.receive(event);
} catch (e) {
expect((probot.logger.error as jest.Mock).mock.calls[0]).toMatchSnapshot()
expect(
(probot.log.error as jest.Mock).mock.calls[0][1]
).toMatchSnapshot();
}
})
});
it('responds with the correct error if the PEM file is missing', async () => {
probot.logger.error = jest.fn()
probot.webhook.on('*', () => { throw new Error('error:0906D06C:PEM routines:PEM_read_bio:no start line') })
it("responds with the correct error if the PEM file is missing", async () => {
probot.log.error = jest.fn();
probot.webhooks.on("*", () => {
throw new Error(
"error:0906D06C:PEM routines:PEM_read_bio:no start line"
);
});
try {
await probot.webhook.receive(event)
await probot.webhooks.receive(event);
} catch (e) {
expect((probot.logger.error as jest.Mock).mock.calls[0]).toMatchSnapshot()
expect(
(probot.log.error as jest.Mock).mock.calls[0][1]
).toMatchSnapshot();
}
})
});
it('responds with the correct error if the jwt could not be decoded', async () => {
probot.logger.error = jest.fn()
probot.webhook.on('*', () => { throw new Error('{"message":"A JSON web token could not be decoded","documentation_url":"https://developer.github.com/v3"}') })
it("responds with the correct error if the jwt could not be decoded", async () => {
probot.log.error = jest.fn();
probot.webhooks.on("*", () => {
throw new Error(
'{"message":"A JSON web token could not be decoded","documentation_url":"https://developer.github.com/v3"}'
);
});
try {
await probot.webhook.receive(event)
await probot.webhooks.receive(event);
} catch (e) {
expect((probot.logger.error as jest.Mock).mock.calls[0]).toMatchSnapshot()
expect(
(probot.log.error as jest.Mock).mock.calls[0][1]
).toMatchSnapshot();
}
})
})
});
});
describe('server', () => {
it('prefixes paths with route name', () => {
probot.load(app => {
const route = app.route('/my-app')
route.get('/foo', (req, res) => res.end('foo'))
})
describe("server", () => {
it("prefixes paths with route name", () => {
probot.load((app) => {
const route = app.route("/my-app");
route.get("/foo", (req, res) => res.end("foo"));
});
return request(probot.server).get('/my-app/foo').expect(200, 'foo')
})
return request(probot.server).get("/my-app/foo").expect(200, "foo");
});
it('allows routes with no path', () => {
probot.load(app => {
const route = app.route()
route.get('/foo', (req, res) => res.end('foo'))
})
it("allows routes with no path", () => {
probot.load((app) => {
const route = app.route();
route.get("/foo", (req, res) => res.end("foo"));
});
return request(probot.server).get('/foo').expect(200, 'foo')
})
return request(probot.server).get("/foo").expect(200, "foo");
});
it('allows you to overwrite the root path', () => {
probot.load(app => {
const route = app.route()
route.get('/', (req, res) => res.end('foo'))
})
it("allows you to overwrite the root path", () => {
probot.load((app) => {
const route = app.route();
route.get("/", (req, res) => res.end("foo"));
});
return request(probot.server).get('/').expect(200, 'foo')
})
return request(probot.server).get("/").expect(200, "foo");
});
it('isolates apps from affecting eachother', async () => {
['foo', 'bar'].forEach(name => {
probot.load(app => {
const route = app.route('/' + name)
it("isolates apps from affecting eachother", async () => {
["foo", "bar"].forEach((name) => {
probot.load((app) => {
const route = app.route("/" + name);
route.use((req, res, next) => {
res.append('X-Test', name)
next()
})
res.append("X-Test", name);
next();
});
route.get('/hello', (req, res) => res.end(name))
})
})
route.get("/hello", (req, res) => res.end(name));
});
});
await request(probot.server).get('/foo/hello')
.expect(200, 'foo')
.expect('X-Test', 'foo')
await request(probot.server)
.get("/foo/hello")
.expect(200, "foo")
.expect("X-Test", "foo");
await request(probot.server).get('/bar/hello')
.expect(200, 'bar')
.expect('X-Test', 'bar')
})
await request(probot.server)
.get("/bar/hello")
.expect(200, "bar")
.expect("X-Test", "bar");
});
it('allows users to configure webhook paths', async () => {
probot = createProbot({ webhookPath: '/webhook', githubToken: 'faketoken' })
it("allows users to configure webhook paths", async () => {
probot = new Probot({
webhookPath: "/webhook",
githubToken: "faketoken",
});
// Error handler to avoid printing logs
// tslint:disable-next-line handle-callback-err
probot.server.use((err: any, req: Request, res: Response, next: NextFunction) => { })
// tslint:disable-next-line handle-callback-error
probot.server.use(
(error: any, req: Request, res: Response, next: NextFunction) => {}
);
probot.load(app => {
const route = app.route()
route.get('/webhook', (req, res) => res.end('get-webhook'))
route.post('/webhook', (req, res) => res.end('post-webhook'))
})
probot.load((app) => {
const route = app.route();
route.get("/webhook", (req, res) => res.end("get-webhook"));
route.post("/webhook", (req, res) => res.end("post-webhook"));
});
// GET requests should succeed
await request(probot.server).get('/webhook')
.expect(200, 'get-webhook')
await request(probot.server).get("/webhook").expect(200, "get-webhook");
// POST requests should fail b/c webhook path has precedence
await request(probot.server).post('/webhook')
.expect(400)
})
await request(probot.server).post("/webhook").expect(400);
});
it('defaults webhook path to `/`', async () => {
it("defaults webhook path to `/`", async () => {
// Error handler to avoid printing logs
// tslint:disable-next-line handle-callback-err
probot.server.use((err: any, req: Request, res: Response, next: NextFunction) => { })
// tslint:disable-next-line handle-callback-error
probot.server.use(
(error: any, req: Request, res: Response, next: NextFunction) => {}
);
// POST requests to `/` should 400 b/c webhook signature will fail
await request(probot.server).post('/')
.expect(400)
})
await request(probot.server).post("/").expect(400);
});
it('responds with 500 on error', async () => {
probot.server.get('/boom', () => {
throw new Error('boom')
})
it("responds with 500 on error", async () => {
probot.server.get("/boom", () => {
throw new Error("boom");
});
await request(probot.server).get('/boom').expect(500)
})
await request(probot.server).get("/boom").expect(500);
});
it('responds with 500 on async error', async () => {
probot.server.get('/boom', () => {
return Promise.reject(new Error('boom'))
})
it("responds with 500 on async error", async () => {
probot.server.get("/boom", () => {
return Promise.reject(new Error("boom"));
});
await request(probot.server).get('/boom').expect(500)
})
})
await request(probot.server).get("/boom").expect(500);
});
});
describe('receive', () => {
it('forwards events to each app', async () => {
const spy = jest.fn()
const app = probot.load(appl => appl.on('push', spy))
app.auth = jest.fn().mockReturnValue(Promise.resolve({}))
describe("receive", () => {
it("forwards events to each app", async () => {
const spy = jest.fn();
await probot.receive(event)
probot.load((app) => app.on("push", spy));
expect(spy).toHaveBeenCalled()
})
})
await probot.receive(event);
describe('ghe support', () => {
let app: Application
expect(spy).toHaveBeenCalledTimes(1);
});
});
describe("ghe support", () => {
beforeEach(() => {
process.env.GHE_HOST = 'notreallygithub.com'
nock('https://notreallygithub.com/api/v3')
.defaultReplyHeaders({ 'Content-Type': 'application/json' })
.get('/app/installations').reply(200, ['I work!'])
.post('/app/installations/5/access_tokens').reply(200, { token: 'github_token' })
app = helper.createApp()
})
process.env.GHE_HOST = "notreallygithub.com";
});
afterEach(() => {
delete process.env.GHE_HOST
})
it('requests from the correct API URL', async () => {
const spy = jest.fn()
delete process.env.GHE_HOST;
});
it("requests from the correct API URL", async () => {
const appFn = async (appl: Application) => {
const github = await appl.auth()
const res = await github.apps.listInstallations({})
return spy(res)
}
const github = await appl.auth();
expect(github.request.endpoint.DEFAULTS.baseUrl).toEqual(
"https://notreallygithub.com/api/v3"
);
};
await appFn(app)
await app.receive(event)
expect(spy.mock.calls[0][0].data[0]).toBe('I work!')
})
new Probot({}).load(appFn);
});
it('passes GHE host to the app', async () => {
probot = createProbot({
id: 1234,
// Some valid RSA key to be able to sign the initial token
// tslint:disable-next-line:object-literal-sort-keys
cert: '-----BEGIN RSA PRIVATE KEY-----\n' +
'MIIBOQIBAAJBAIILhiN9IFpaE0pUXsesuuoaj6eeDiAqCiE49WB1tMB8ZMhC37kY\n' +
'Fl52NUYbUxb7JEf6pH5H9vqw1Wp69u78XeUCAwEAAQJAb88urnaXiXdmnIK71tuo\n' +
'/TyHBKt9I6Rhfzz0o9Gv7coL7a537FVDvV5UCARXHJMF41tKwj+zlt9EEUw7a1HY\n' +
'wQIhAL4F/VHWSPHeTgXYf4EaX2OlpSOk/n7lsFtL/6bWRzRVAiEArzJs2vopJitv\n' +
'A1yBjz3q2nX+zthk+GLXrJQkYOnIk1ECIHfeFV8TWm5gej1LxZquBTA5pINoqDVq\n' +
'NKZSuZEHqGEFAiB6EDrxkovq8SYGhIQsJeqkTMO8n94xhMRZlFmIQDokEQIgAq5U\n' +
'r1UQNnUExRh7ZT0kFbMfO9jKYZVlQdCL9Dn93vo=\n' +
'-----END RSA PRIVATE KEY-----'
})
expect(await probot.app!.getInstallationAccessToken({ installationId: 5 })).toBe('github_token')
})
it('throws if the GHE host includes a protocol', async () => {
process.env.GHE_HOST = 'https://notreallygithub.com'
it("throws if the GHE host includes a protocol", async () => {
process.env.GHE_HOST = "https://notreallygithub.com";
try {
await app.auth()
new Probot({ id, privateKey });
} catch (e) {
expect(e).toMatchSnapshot()
expect(e).toMatchSnapshot();
}
});
});
try {
createProbot({ id: 1234, cert: 'xxxx' })
} catch (e) {
expect(e).toMatchSnapshot()
}
})
})
describe('ghe support with http', () => {
let app: Application
describe("ghe support with http", () => {
beforeEach(() => {
process.env.GHE_HOST = 'notreallygithub.com'
process.env.GHE_PROTOCOL = 'http'
nock('http://notreallygithub.com/api/v3')
.defaultReplyHeaders({ 'Content-Type': 'application/json' })
.get('/app/installations').reply(200, ['I work!'])
.post('/app/installations/5/access_tokens').reply(200, { token: 'github_token' })
app = helper.createApp()
})
process.env.GHE_HOST = "notreallygithub.com";
process.env.GHE_PROTOCOL = "http";
});
afterEach(() => {
delete process.env.GHE_HOST
delete process.env.GHE_PROTOCOL
})
it('requests from the correct API URL', async () => {
const spy = jest.fn()
delete process.env.GHE_HOST;
delete process.env.GHE_PROTOCOL;
});
it("requests from the correct API URL", async () => {
const appFn = async (appl: Application) => {
const github = await appl.auth()
const res = await github.apps.listInstallations({})
return spy(res)
}
const github = await appl.auth();
expect(github.request.endpoint.DEFAULTS.baseUrl).toEqual(
"http://notreallygithub.com/api/v3"
);
};
await appFn(app)
await app.receive(event)
expect(spy.mock.calls[0][0].data[0]).toBe('I work!')
})
new Probot({}).load(appFn);
});
it('passes GHE host to the app', async () => {
probot = createProbot({
id: 1234,
// Some valid RSA key to be able to sign the initial token
// tslint:disable-next-line:object-literal-sort-keys
cert: '-----BEGIN RSA PRIVATE KEY-----\n' +
'MIIBOQIBAAJBAIILhiN9IFpaE0pUXsesuuoaj6eeDiAqCiE49WB1tMB8ZMhC37kY\n' +
'Fl52NUYbUxb7JEf6pH5H9vqw1Wp69u78XeUCAwEAAQJAb88urnaXiXdmnIK71tuo\n' +
'/TyHBKt9I6Rhfzz0o9Gv7coL7a537FVDvV5UCARXHJMF41tKwj+zlt9EEUw7a1HY\n' +
'wQIhAL4F/VHWSPHeTgXYf4EaX2OlpSOk/n7lsFtL/6bWRzRVAiEArzJs2vopJitv\n' +
'A1yBjz3q2nX+zthk+GLXrJQkYOnIk1ECIHfeFV8TWm5gej1LxZquBTA5pINoqDVq\n' +
'NKZSuZEHqGEFAiB6EDrxkovq8SYGhIQsJeqkTMO8n94xhMRZlFmIQDokEQIgAq5U\n' +
'r1UQNnUExRh7ZT0kFbMfO9jKYZVlQdCL9Dn93vo=\n' +
'-----END RSA PRIVATE KEY-----'
})
expect(await probot.app!.getInstallationAccessToken({ installationId: 5 })).toBe('github_token')
})
it('throws if the GHE host includes a protocol', async () => {
process.env.GHE_HOST = 'http://notreallygithub.com'
it("throws if the GHE host includes a protocol", async () => {
process.env.GHE_HOST = "http://notreallygithub.com";
try {
await app.auth()
new Probot({ id, privateKey });
} catch (e) {
expect(e).toMatchSnapshot()
expect(e).toMatchSnapshot();
}
});
});
try {
createProbot({ id: 1234, cert: 'xxxx' })
} catch (e) {
expect(e).toMatchSnapshot()
}
})
})
describe('process.env.REDIS_URL', () => {
describe("process.env.REDIS_URL", () => {
beforeEach(() => {
process.env.REDIS_URL = 'test'
})
process.env.REDIS_URL = "test";
});
afterEach(() => {
delete process.env.REDIS_URL
})
delete process.env.REDIS_URL;
});
it('sets throttleOptions', async () => {
probot = createProbot({ webhookPath: '/webhook', githubToken: 'faketoken' })
it("sets throttleOptions", async () => {
probot = new Probot({
webhookPath: "/webhook",
githubToken: "faketoken",
});
expect(probot.throttleOptions.Bottleneck).toBe(Bottleneck)
expect(probot.throttleOptions.connection).toBeInstanceOf(Bottleneck.IORedisConnection)
})
})
expect(probot.throttleOptions.Bottleneck).toBe(Bottleneck);
expect(probot.throttleOptions.connection).toBeInstanceOf(
Bottleneck.IORedisConnection
);
});
});
describe('redis configuration object', () => {
it('sets throttleOptions', async () => {
describe("redis configuration object", () => {
it("sets throttleOptions", async () => {
const redisConfig = {
host: 'test'
}
probot = createProbot({ webhookPath: '/webhook', githubToken: 'faketoken', redisConfig })
host: "test",
};
probot = new Probot({
webhookPath: "/webhook",
githubToken: "faketoken",
redisConfig,
});
expect(probot.throttleOptions.Bottleneck).toBe(Bottleneck)
expect(probot.throttleOptions.connection).toBeInstanceOf(Bottleneck.IORedisConnection)
})
})
expect(probot.throttleOptions.Bottleneck).toBe(Bottleneck);
expect(probot.throttleOptions.connection).toBeInstanceOf(
Bottleneck.IORedisConnection
);
});
});
describe('custom Octokit constructor', () => {
describe("custom Octokit constructor", () => {
beforeEach(() => {
const MyOctokit = Octokit.plugin((octokit: Octokit & { [key: string]: any}) => {
octokit.foo = 'bar'
})
const MyOctokit = ProbotOctokit.plugin(function fooBar() {
return {
foo: "bar",
};
});
probot = createProbot({
probot = new Probot({
Octokit: MyOctokit,
githubToken: 'faketoken'
})
})
githubToken: "faketoken",
});
});
it('is propagated to GithubAPI', async () => {
const app = probot.load(() => {})
const githubApi: GitHubAPI & { [key: string]: any } = await app.auth()
expect(githubApi.foo).toBe('bar')
})
})
describe('start', () => {
it("is propagated to Octokit", async () => {
const app = probot.load(() => {});
const octokit: InstanceType<typeof ProbotOctokit> = await app.auth();
expect(octokit.foo).toBe("bar");
});
});
describe("start", () => {
beforeEach(() => {
process.exit = jest.fn() as any // we dont want to terminate the test
})
it('should expect the correct error if port already in use', (next) => {
expect.assertions(2)
process.exit = jest.fn() as any; // we dont want to terminate the test
});
it("should expect the correct error if port already in use", (next) => {
expect.assertions(2);
// block port 3001
const http = require('http')
const http = require("http");
const blockade = http.createServer().listen(3001, () => {
const testApp = new Probot({ port: 3001 });
testApp.log.error = jest.fn();
const testApp = createProbot({ port: 3001 })
testApp.logger.error = jest.fn()
const server = testApp.start().addListener("error", () => {
expect(testApp.log.error).toHaveBeenCalledWith(
"Port 3001 is already in use. You can define the PORT environment variable to use a different port."
);
expect(process.exit).toHaveBeenCalledWith(1);
server.close(() => blockade.close(() => next()));
});
});
});
const server = testApp.start().addListener('error', () => {
expect(testApp.logger.error).toHaveBeenCalledWith('Port 3001 is already in use. You can define the PORT environment variable to use a different port.')
expect(process.exit).toHaveBeenCalledWith(1)
server.close(() => blockade.close(() => next()))
})
})
})
it('should listen to port when not in use', (next) => {
expect.assertions(1)
const testApp = createProbot({ port: 3001, webhookProxy: undefined })
testApp.logger.info = jest.fn()
const server = testApp.start().on('listening', () => {
expect(testApp.logger.info).toHaveBeenCalledWith('Listening on http://localhost:3001')
server.close(() => next())
})
})
})
})
it("should listen to port when not in use", (next) => {
expect.assertions(1);
const testApp = new Probot({ port: 3001, webhookProxy: undefined });
testApp.log.info = jest.fn();
const server = testApp.start().on("listening", () => {
expect(testApp.log.info).toHaveBeenCalledWith(
"Listening on http://localhost:3001"
);
server.close(() => next());
});
});
});
});

View File

@ -1,43 +1,73 @@
import request from 'supertest'
import { createProbot, Probot } from '../../src'
import * as data from '../fixtures/webhook/push.json'
import Stream from "stream";
describe('webhooks', () => {
let logger: any
let probot: Probot
import request from "supertest";
import pino from "pino";
import { Probot } from "../../src";
import * as data from "../fixtures/webhook/push.json";
describe("webhooks", () => {
let probot: Probot;
let output: any;
const streamLogsToOutput = new Stream.Writable({ objectMode: true });
streamLogsToOutput._write = (object, encoding, done) => {
output.push(JSON.parse(object));
done();
};
beforeEach(() => {
logger = jest.fn()
output = [];
probot = createProbot({ id: 1, cert: 'bexo🥪' })
probot.logger.addStream({
level: 'trace',
stream: { write: logger } as any,
type: 'raw'
})
})
probot = new Probot({
id: 1,
privateKey: "bexo🥪",
secret: "secret",
log: pino(streamLogsToOutput),
});
});
test("it works when all headers are properly passed onto the event", async () => {
const dataString = JSON.stringify(data);
test('it works when all headers are properly passed onto the event', async () => {
await request(probot.server)
.post('/')
.send(data)
.set('x-github-event', 'push')
.set('x-hub-signature', probot.webhook.sign(data))
.set('x-github-delivery', '3sw4d5f6g7h8')
.expect(200)
})
.post("/")
.send(dataString)
.set("x-github-event", "push")
.set("x-hub-signature", probot.webhooks.sign(dataString))
.set("x-github-delivery", "3sw4d5f6g7h8")
.expect(200);
});
test('shows a friendly error when x-hub-signature is missing', async () => {
test("shows a friendly error when x-hub-signature is missing", async () => {
await request(probot.server)
.post('/')
.post("/")
.send(data)
.set('x-github-event', 'push')
.set("x-github-event", "push")
// Note: 'x-hub-signature' is missing
.set('x-github-delivery', '3sw4d5f6g7h8')
.expect(400)
.set("x-github-delivery", "3sw4d5f6g7h8")
.expect(400);
expect(logger).toHaveBeenCalledWith(expect.objectContaining({
msg: 'Go to https://github.com/settings/apps/YOUR_APP and verify that the Webhook secret matches the value of the WEBHOOK_SECRET environment variable.'
}))
})
})
expect(output[0]).toEqual(
expect.objectContaining({
msg:
"Go to https://github.com/settings/apps/YOUR_APP and verify that the Webhook secret matches the value of the WEBHOOK_SECRET environment variable.",
})
);
});
test.only("logs webhook error exactly once", async () => {
probot.load(() => {});
await request(probot.server)
.post("/")
.send(data)
.set("x-github-event", "push")
// Note: 'x-hub-signature' is missing
.set("x-github-delivery", "3sw4d5f6g7h8")
.expect(400);
const errorLogs = output.filter((output: any) => output.level === 50);
expect(errorLogs.length).toEqual(1);
});
});

View File

@ -1,29 +0,0 @@
import { logger } from '../src/logger'
import { wrapLogger } from '../src/wrap-logger'
describe('logger', () => {
let output: string[]
beforeEach(() => {
output = []
logger.addStream({
level: 'trace',
stream: { write: (log: any) => output.push(log) } as any,
type: 'raw'
})
})
describe('child', () => {
test('sets attributes', () => {
const child = wrapLogger(logger).child({ id: '1234' })
child.debug('attributes will get added to this')
expect(output[0]).toHaveProperty('id', '1234')
})
test('allows setting the name', () => {
const child = wrapLogger(logger).child({ name: 'test' })
child.debug('hello')
expect(output[0]).toHaveProperty('name', 'test')
})
})
})

View File

@ -1,119 +1,144 @@
import fs from 'fs'
import nock from 'nock'
import pkg from '../package.json'
import { ManifestCreation } from '../src/manifest-creation'
import response from './fixtures/setup/response.json'
import fs from "fs";
import nock from "nock";
import pkg from "../package.json";
import { ManifestCreation } from "../src/manifest-creation";
import response from "./fixtures/setup/response.json";
describe('ManifestCreation', () => {
let setup: ManifestCreation
describe("ManifestCreation", () => {
let setup: ManifestCreation;
beforeEach(() => {
setup = new ManifestCreation()
})
setup = new ManifestCreation();
});
describe('createWebhookChannel', () => {
describe("createWebhookChannel", () => {
beforeEach(() => {
delete process.env.NODE_ENV
delete process.env.PROJECT_DOMAIN
delete process.env.WEBHOOK_PROXY_URL
delete process.env.NODE_ENV;
delete process.env.PROJECT_DOMAIN;
delete process.env.WEBHOOK_PROXY_URL;
setup.updateEnv = jest.fn()
setup.updateEnv = jest.fn();
const SmeeClient: typeof import('smee-client') = require('smee-client')
SmeeClient.createChannel = jest.fn().mockReturnValue('https://smee.io/1234abc')
})
const SmeeClient: typeof import("smee-client") = require("smee-client");
SmeeClient.createChannel = jest
.fn()
.mockReturnValue("https://smee.io/1234abc");
});
afterEach(() => {
delete process.env.WEBHOOK_PROXY_URL
})
delete process.env.WEBHOOK_PROXY_URL;
});
test('writes new webhook channel to .env', async () => {
await setup.createWebhookChannel()
expect(setup.updateEnv).toHaveBeenCalledWith({ 'WEBHOOK_PROXY_URL': 'https://smee.io/1234abc' })
})
})
test("writes new webhook channel to .env", async () => {
await setup.createWebhookChannel();
expect(setup.updateEnv).toHaveBeenCalledWith({
WEBHOOK_PROXY_URL: "https://smee.io/1234abc",
});
});
});
describe('pkg', () => {
test('gets pkg from package.json', () => {
expect(setup.pkg).toEqual(pkg)
})
})
describe("pkg", () => {
test("gets pkg from package.json", () => {
expect(setup.pkg).toEqual(pkg);
});
});
describe('createAppUrl', () => {
describe("createAppUrl", () => {
afterEach(() => {
delete process.env.GHE_HOST
delete process.env.GHE_PROTOCOL
})
delete process.env.GHE_HOST;
delete process.env.GHE_PROTOCOL;
});
test('creates an app url', () => {
expect(setup.createAppUrl).toEqual('https://github.com/settings/apps/new')
})
test("creates an app url", () => {
expect(setup.createAppUrl).toEqual(
"https://github.com/settings/apps/new"
);
});
test('creates an app url when github host env is set', () => {
process.env.GHE_HOST = 'hiimbex.github.com'
expect(setup.createAppUrl).toEqual('https://hiimbex.github.com/settings/apps/new')
})
test("creates an app url when github host env is set", () => {
process.env.GHE_HOST = "hiimbex.github.com";
expect(setup.createAppUrl).toEqual(
"https://hiimbex.github.com/settings/apps/new"
);
});
test('creates an app url when github host env and protocol are set', () => {
process.env.GHE_HOST = 'hiimbex.github.com'
process.env.GHE_PROTOCOL = 'http'
expect(setup.createAppUrl).toEqual('http://hiimbex.github.com/settings/apps/new')
})
})
test("creates an app url when github host env and protocol are set", () => {
process.env.GHE_HOST = "hiimbex.github.com";
process.env.GHE_PROTOCOL = "http";
expect(setup.createAppUrl).toEqual(
"http://hiimbex.github.com/settings/apps/new"
);
});
});
describe('createAppFromCode', () => {
describe("createAppFromCode", () => {
beforeEach(() => {
setup.updateEnv = jest.fn()
})
setup.updateEnv = jest.fn();
});
afterEach(() => {
delete process.env.APP_ID
delete process.env.PRIVATE_KEY
delete process.env.WEBHOOK_SECRET
delete process.env.GHE_HOST
})
delete process.env.APP_ID;
delete process.env.PRIVATE_KEY;
delete process.env.WEBHOOK_SECRET;
delete process.env.GHE_HOST;
});
test('creates an app from a code', async () => {
nock('https://api.github.com')
.post('/app-manifests/123abc/conversions')
.reply(200, response)
test("creates an app from a code", async () => {
nock("https://api.github.com")
.post("/app-manifests/123abc/conversions")
.reply(200, response);
const createdApp = await setup.createAppFromCode('123abc')
expect(createdApp).toEqual('https://github.com/apps/testerino0000000')
const createdApp = await setup.createAppFromCode("123abc");
expect(createdApp).toEqual("https://github.com/apps/testerino0000000");
// expect dotenv to be called with id, webhook_secret, pem
expect(setup.updateEnv).toHaveBeenCalledWith({ 'APP_ID': '6666', 'PRIVATE_KEY': '"-----BEGIN RSA PRIVATE KEY-----\nsecrets\n-----END RSA PRIVATE KEY-----\n"', 'WEBHOOK_SECRET': '12345abcde' })
})
expect(setup.updateEnv).toHaveBeenCalledWith({
APP_ID: "6666",
PRIVATE_KEY:
'"-----BEGIN RSA PRIVATE KEY-----\nsecrets\n-----END RSA PRIVATE KEY-----\n"',
WEBHOOK_SECRET: "12345abcde",
});
});
test('creates an app from a code when github host env is set', async () => {
process.env.GHE_HOST = 'swinton.github.com'
test("creates an app from a code when github host env is set", async () => {
process.env.GHE_HOST = "swinton.github.com";
nock('https://swinton.github.com')
.post('/api/v3/app-manifests/123abc/conversions')
.reply(200, response)
nock("https://swinton.github.com")
.post("/api/v3/app-manifests/123abc/conversions")
.reply(200, response);
const createdApp = await setup.createAppFromCode('123abc')
expect(createdApp).toEqual('https://github.com/apps/testerino0000000')
const createdApp = await setup.createAppFromCode("123abc");
expect(createdApp).toEqual("https://github.com/apps/testerino0000000");
// expect dotenv to be called with id, webhook_secret, pem
expect(setup.updateEnv).toHaveBeenCalledWith({ 'APP_ID': '6666', 'PRIVATE_KEY': '"-----BEGIN RSA PRIVATE KEY-----\nsecrets\n-----END RSA PRIVATE KEY-----\n"', 'WEBHOOK_SECRET': '12345abcde' })
})
})
expect(setup.updateEnv).toHaveBeenCalledWith({
APP_ID: "6666",
PRIVATE_KEY:
'"-----BEGIN RSA PRIVATE KEY-----\nsecrets\n-----END RSA PRIVATE KEY-----\n"',
WEBHOOK_SECRET: "12345abcde",
});
});
});
describe('getManifest', () => {
describe("getManifest", () => {
afterEach(() => {
jest.clearAllMocks()
})
jest.clearAllMocks();
});
test('creates an app from a code', () => {
test("creates an app from a code", () => {
// checks that getManifest returns a JSON.stringified manifest
expect(setup.getManifest(pkg, 'localhost://3000')).toEqual('{"description":"🤖 A framework for building GitHub Apps to automate and improve your workflow","hook_attributes":{"url":"localhost://3000/"},"name":"probot","public":true,"redirect_url":"localhost://3000/probot/setup","url":"https://probot.github.io","version":"v1"}')
})
expect(setup.getManifest(pkg, "localhost://3000")).toEqual(
'{"description":"🤖 A framework for building GitHub Apps to automate and improve your workflow","hook_attributes":{"url":"localhost://3000/"},"name":"probot","public":true,"redirect_url":"localhost://3000/probot/setup","url":"https://probot.github.io","version":"v1"}'
);
});
test('creates an app from a code with overrided values from app.yml', () => {
const appYaml = 'name: cool-app\ndescription: A description for a cool app'
jest.spyOn(fs, 'readFileSync').mockReturnValue(appYaml)
test("creates an app from a code with overrided values from app.yml", () => {
const appYaml =
"name: cool-app\ndescription: A description for a cool app";
jest.spyOn(fs, "readFileSync").mockReturnValue(appYaml);
// checks that getManifest returns the correct JSON.stringified manifest
expect(setup.getManifest(pkg, 'localhost://3000')).toEqual('{"description":"A description for a cool app","hook_attributes":{"url":"localhost://3000/"},"name":"cool-app","public":true,"redirect_url":"localhost://3000/probot/setup","url":"https://probot.github.io","version":"v1"}')
})
})
})
expect(setup.getManifest(pkg, "localhost://3000")).toEqual(
'{"description":"A description for a cool app","hook_attributes":{"url":"localhost://3000/"},"name":"cool-app","public":true,"redirect_url":"localhost://3000/probot/setup","url":"https://probot.github.io","version":"v1"}'
);
});
});
});

Some files were not shown because too many files have changed in this diff Show More