forked from mirrors/probot
v10.0.0
CHANGELOG: https://github.com/probot/probot/releases/tag/v10.0.0
This commit is contained in:
parent
e3f6cf583b
commit
072b8c26b5
|
@ -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**
|
||||
|
|
|
@ -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 }}
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
2
LICENSE
2
LICENSE
|
@ -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
|
||||
|
|
14
README.md
14
README.md
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
23
app.json
23
app.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
21
appveyor.yml
21
appveyor.yml
|
@ -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
|
|
@ -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);
|
||||
});
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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" }),
|
||||
});
|
||||
}
|
||||
```
|
||||
|
|
|
@ -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:
|
||||
|
|
14
docs/http.md
14
docs/http.md
|
@ -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.
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
});
|
||||
};
|
||||
```
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
```
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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.
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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.
|
||||
|
|
File diff suppressed because it is too large
Load Diff
207
package.json
207
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
declare module "@probot/pino" {
|
||||
import { Transform } from "readable-stream";
|
||||
|
||||
export function getTransformStream(): Transform {}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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"));
|
||||
};
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
}
|
|
@ -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"));
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
16
src/cache.ts
16
src/cache.ts
|
@ -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
|
||||
})
|
||||
}
|
264
src/context.ts
264
src/context.ts
|
@ -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],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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])
|
||||
}
|
||||
}
|
|
@ -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'
|
|
@ -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 }
|
|
@ -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
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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');
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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;
|
||||
}
|
467
src/index.ts
467
src/index.ts
|
@ -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 };
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
|
@ -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`;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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()
|
||||
}
|
|
@ -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 { }
|
|
@ -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")
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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;
|
||||
});
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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);
|
||||
});
|
||||
}
|
|
@ -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);
|
||||
}
|
|
@ -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}`,
|
||||
});
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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(),
|
||||
});
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
|||
// The version is set automatically before publish to npm
|
||||
export const VERSION = "0.0.0-development";
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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
|
||||
}
|
|
@ -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."`;
|
||||
|
|
|
@ -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 [
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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",
|
||||
},
|
||||
],
|
||||
]
|
||||
`;
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
||||
}
|
|
@ -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()
|
||||
}))
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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
|
@ -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`
|
||||
);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
};
|
|
@ -1 +1 @@
|
|||
evil: !<tag:yaml.org,2002:js/function> 'function () {}'
|
||||
evil: !<tag:yaml.org,2002:js/function> "function () {}"
|
||||
|
|
|
@ -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");
|
||||
});
|
||||
};
|
||||
|
|
|
@ -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));
|
||||
|
|
|
@ -1,2 +1,2 @@
|
|||
// tslint:disable-next-line:no-empty
|
||||
export = () => {}
|
||||
export = () => {};
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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' })
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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());
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
})
|
|
@ -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
Loading…
Reference in New Issue