BREAKING CHANGE: Drop support for NodeJS < 18
BREAKING CHANGE: replace node-fetch with the Fetch API
BREAKING CHANGE: default webhookPath is now /api/github/webhooks
BREAKING CHANGE: probot receive now only supports payloads in JSON format, previously also (unintionally) allowed JS.

closes #1643


---------

Co-authored-by: Aras Abbasi <aras.abbasi@googlemail.com>
Co-authored-by: Alexander Fortin <shaftoe@users.noreply.github.com>
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Aaron Dewes <aaron.dewes@protonmail.com>
Co-authored-by: Gregor Martynus <39992+gr2m@users.noreply.github.com>
Co-authored-by: Aaron Dewes <aaron@runcitadel.space>
Co-authored-by: prettier-toc-me[bot] <56236715+prettier-toc-me[bot]@users.noreply.github.com>
Co-authored-by: Yaseen <9275716+ynx0@users.noreply.github.com>
This commit is contained in:
wolfy1339 2024-01-25 16:03:28 -05:00 committed by GitHub
parent 02d81f886c
commit 948a1b7147
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
91 changed files with 10372 additions and 20584 deletions

42
.github/workflows/codeql.yml vendored Normal file
View File

@ -0,0 +1,42 @@
name: "CodeQL"
on:
push:
branches: [ "master", "*.x", "beta", "next" ]
pull_request:
# The branches below must be a subset of the branches above
branches: [ "master", "beta" ]
schedule:
- cron: '24 7 * * 2'
jobs:
analyze:
name: Analyze
runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }}
timeout-minutes: ${{ (matrix.language == 'swift' && 120) || 360 }}
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript-typescript' ]
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
queries: security-and-quality
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2
with:
category: "/language:${{matrix.language}}"

View File

@ -1,5 +1,5 @@
name: Docs
"on":
on:
push:
branches:
- master
@ -8,14 +8,18 @@ jobs:
name: docs
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Check out repository
uses: actions/checkout@v4
- run: git fetch --depth=20 origin +refs/tags/*:refs/tags/*
- uses: actions/setup-node@v2
- uses: actions/setup-node@v4
with:
node-version: 16
node-version: lts/*
cache: npm
- run: npm ci
- run: npm run build
- run: ./script/publish-docs
- name: Install dependencies
run: npm ci
- name: Build probot
run: npm run build
- name: Publish documentation
run: ./script/publish-docs
env:
OCTOKITBOT_PAT: ${{ secrets.OCTOKITBOT_PAT }}

View File

@ -1,5 +1,5 @@
name: Release
"on":
on:
push:
branches:
- master
@ -11,14 +11,19 @@ jobs:
name: release
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- name: Check out repository
uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- run: npm ci
- run: npm run build
- run: npx semantic-release
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Release
run: npx semantic-release
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NPM_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@ -1,5 +1,5 @@
name: Test
"on":
on:
push:
branches:
- master
@ -7,43 +7,140 @@ name: Test
types:
- opened
- synchronize
permissions:
contents: read
jobs:
test_matrix:
dependency-review:
name: Dependency Review
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Dependency review
uses: actions/dependency-review-action@v3
license-check:
name: Check Licenses
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: lts/*
- name: Install dependencies
run: npm ci
- name: Check Licenses
run: npx license-checker --production --summary --onlyAllow="0BSD;Apache-2.0;Apache 2.0;Python-2.0;BSD-2-Clause;BSD-3-Clause;ISC;MIT"
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Use Node.js
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- name: Install dependencies
run: npm ci
- name: Lint
run: npm run lint
test-unit:
name: Test on Node ${{ matrix.node-version }} and ${{ matrix.os }}
strategy:
matrix:
node-version:
- 10
- 12
- 14
- 18
- 20
- 21
os:
- ubuntu-latest
- macos-latest
- windows-latest
fail-fast: false
runs-on: ${{ matrix.os }}
steps:
- uses: actions/checkout@v2
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2
uses: actions/setup-node@v4
with:
node-version: ${{ matrix.node-version }}
cache: npm
- run: npm ci
- run: npm run build
- run: npx jest
- name: Install dependencies
run: npm ci
- name: Test
run: npm run test
test-redis:
name: Test on Node LTS and Redis 7
runs-on: ubuntu-latest
services:
redis:
image: 'redis:7'
ports:
- '6379:6379'
options: '--entrypoint redis-server'
steps:
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Use Node.js LTS
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
- name: Install Dependencies
run: npm ci
- name: Test
run: npm run test
env:
REDIS_HOST: 127.0.0.1
test:
runs-on: ubuntu-latest
needs: test_matrix
needs:
- test-unit
- test-redis
steps:
- uses: actions/checkout@v2
- uses: actions/setup-node@v2
- run: exit 1
if: ${{ needs.test-unit.result != 'success' || needs.test-redis.result != 'success' }}
- name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Use Node.js LTS
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm
node-version: 16
- run: npm ci
- run: npm run lint
- run: npm run build
- run: npx jest
- name: codecov
- name: Install Dependencies
run: npm ci
- name: Build
run: npm run build
- name: Test
run: npm run test:coverage
- name: Update Codecov
run: npx codecov
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
if: ${{ always() }}

View File

@ -44,7 +44,7 @@ Probot is built by people just like you! Most of the interesting things are buil
If you're interested in contributing to Probot itself, check out our [contributing docs](CONTRIBUTING.md) to get started.
Want to chat with Probot users and contributors? [Join us in Slack](https://probot-slackin.herokuapp.com/)!
Want to discuss with Probot users and contributors? [Discuss on GitHub](https://github.com/probot/probot/discussions)!
## Ideas

View File

@ -1,40 +0,0 @@
# Probot
[![npm version](https://img.shields.io/npm/v/probot.svg)](https://www.npmjs.com/package/probot) [![](https://img.shields.io/twitter/follow/ProbotTheRobot.svg?style=social&logo=twitter&label=Follow)](https://twitter.com/ProbotTheRobot)
> 🤖 Um framework para criar aplicativos do GitHub para automatizar e melhorar seu fluxo de trabalho
Se você já pensou, "não seria legal se o GitHub pudesse..."; Eu vou parar você aí mesmo. A maioria dos recursos pode realmente ser adicionada via [GitHub Apps](https://docs.github.com/apps/), que estende o GitHub e pode ser instalado diretamente em organizações e contas de usuários e com acesso a repositórios específicos. Eles vêm com permissões granulares e webhooks integrados. Os aplicativos são atores de primeira classe no GitHub.
## Como funciona
**Probot é um framework para construir [GitHub Apps](https://docs.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.octokit.issues.createComment(issueComment);
});
};
```
## Criando um app Probot
Se você acessou este repositório GitHub e está procurando começar a construir seu próprio aplicativo Probot, não precisa procurar mais do que [probot.github.io](https://probot.github.io/docs/)! O site Probot contém nossa extensa documentação inicial e o guiará pelo processo de configuração.
Este repositório hospeda o código do pacote npm Probot, que é o que todos os Apps Probot executam. A maioria das pessoas que vem neste repositório provavelmente estão querendo começar [construindo seu próprio aplicativo](https://probot.github.io/docs/).
## Contribuindo
Probot é construído por pessoas como você! A maioria das coisas interessantes são construídas com o Probot, então considere começar [escrevendo um novo aplicativo](https://probot.github.io/docs/) ou melhorando [um dos existentes](https://github.com/search?q=topic%3Aprobot-app&type=Repositories).
Se você estiver interessado em contribuir com o Probot, confira nossa [doc de contribuição](CONTRIBUTING.md) para começar.
Quer conversar com usuários Probot e colaboradores? [Junte-se a nós no Slack](https://probot-slackin.herokuapp.com/)!
## Ideias
Tem uma ideia para um novo app GitHub legal (feito com o Probot)? Isso é ótimo! Se você quer feedback, ajuda, ou apenas para compartilhá-lo com o mundo, você pode fazer isso [criando uma issue no repositório `probot/ideas`](https://github.com/probot/ideas/issues/new)!

View File

@ -7,31 +7,31 @@ title: Configuration
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:
| Environment Variable | [Programmatic Argument](/docs/development/#run-probot-programmatically) | Description |
| ----------------------------------- | ------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `APP_ID` | `new Probot({ appId })` | The App ID assigned to your GitHub App. **Required** <p>_(Example: `1234`)_</p> |
| `PRIVATE_KEY` or `PRIVATE_KEY_PATH` | `new Probot({ privateKey })` | 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](/docs/deployment) docs for provider specific usage. When using the `PRIVATE_KEY_PATH` environment variable, set it to the path of the `.pem` file that you downloaded from your GitHub App registration. <p>_(Example: `path/to/key.pem`)_</p> |
| **Webhook options** | |
| `WEBHOOK_PROXY_URL` | `new Server({ webhookProxy })` | 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` | `new Probot({ 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> |
| Environment Variable | [Programmatic Argument](/docs/development/#run-probot-programmatically) | Description |
| ----------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `APP_ID` | `new Probot({ appId })` | The App ID assigned to your GitHub App. **Required** <p>_(Example: `1234`)_</p> |
| `PRIVATE_KEY` or `PRIVATE_KEY_PATH` | `new Probot({ privateKey })` | 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](/docs/deployment) docs for provider specific usage. When using the `PRIVATE_KEY_PATH` environment variable, set it to the path of the `.pem` file that you downloaded from your GitHub App registration. <p>_(Example: `path/to/key.pem`)_</p> |
| **Webhook options** | |
| `WEBHOOK_PROXY_URL` | `new Server({ webhookProxy })` | 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` | `new Probot({ 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](/docs/development/#configuring-a-github-app).
Some less common environment variables are:
| Environment Variable | [Programmatic Argument](/docs/development/#run-probot-programmatically) | Description |
| --------------------- | ------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GHE_HOST` | `new Probot({ Octokit: ProbotOctokit.defaults({ baseUrl }) })` | The hostname of your GitHub Enterprise instance. <p>_(Example: `github.mycompany.com`)_</p> |
| `GHE_PROTOCOL` | `new Probot({ Octokit: ProbotOctokit.defaults({ baseUrl }) })` | The protocol of your GitHub Enterprise instance. Defaults to HTTPS. Do not change unless you are certain. <p>_(Example: `https`)_</p> |
| `GH_ORG` | - | The organization where you want to register the app in the app creation manifest flow. If set, the app is registered for an organization (https://github.com/organizations/ORGANIZATION/settings/apps/new), if not set, the GitHub app would be registered for the user account (https://github.com/settings/apps/new). |
| `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` | `const log = require('pino')({ 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` |
| `LOG_MESSAGE_KEY` | `const log = require('pino')({ messageKey: 'msg' })` | Only relevant when `LOG_FORMAT` is set to `json`. Sets the json key for the log message. Default: `msg` |
| `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` | `new Server({ port })` | The port to start the local server on. Default: `3000` |
| `HOST` | `new Server({ host })` | The host to start the local server on. |
| `WEBHOOK_PATH` | `new Server({ webhookPath })` | The URL path which will receive webhooks. Default: `/` |
| `REDIS_URL` | `new Probot({ redisConfig })` | 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> |
| --------------------- | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `GHE_HOST` | `new Probot({ Octokit: ProbotOctokit.defaults({ baseUrl }) })` | The hostname of your GitHub Enterprise instance. <p>_(Example: `github.mycompany.com`)_</p> |
| `GHE_PROTOCOL` | `new Probot({ Octokit: ProbotOctokit.defaults({ baseUrl }) })` | The protocol of your GitHub Enterprise instance. Defaults to HTTPS. Do not change unless you are certain. <p>_(Example: `https`)_</p> |
| `GH_ORG` | - | The organization where you want to register the app in the app creation manifest flow. If set, the app is registered for an organization (https://github.com/organizations/ORGANIZATION/settings/apps/new), if not set, the GitHub app would be registered for the user account (https://github.com/settings/apps/new). |
| `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` | `const log = require('pino')({ 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` |
| `LOG_MESSAGE_KEY` | `const log = require('pino')({ messageKey: 'msg' })` | Only relevant when `LOG_FORMAT` is set to `json`. Sets the json key for the log message. Default: `msg` |
| `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` | `new Server({ port })` | The port to start the local server on. Default: `3000` |
| `HOST` | `new Server({ host })` | The host to start the local server on. |
| `WEBHOOK_PATH` | `new Server({ webhookPath })` | The URL path which will receive webhooks. Default: `/api/github/webhooks` |
| `REDIS_URL` | `new Probot({ redisConfig })` | Set to a `redis://` url as connection option for [ioredis](https://github.com/luin/ioredis#connect-to-redis) in order to enable [cluster support for request throttling](https://github.com/octokit/plugin-throttling.js#clustering). <p>_(Example: `redis://:secret@redis-123.redislabs.com:12345/0`)_</p> |
For more information on the formatting conventions and rules of `.env` files, check out [the npm `dotenv` module's documentation](https://www.npmjs.com/package/dotenv#rules).

View File

@ -5,7 +5,7 @@ title: Developing an app
# Developing an app
To develop a Probot app, you will first need a recent version of [Node.js](https://nodejs.org/) installed. Open a terminal and run `node -v` to verify that it is installed and is at least 10.0.0 or later. Otherwise, [install the latest version](https://nodejs.org/).
To develop a Probot app, you will first need a recent version of [Node.js](https://nodejs.org/) installed. Open a terminal and run `node -v` to verify that it is installed and is at least 18.0.0 or later. Otherwise, [install the latest version](https://nodejs.org/).
**Contents:**
@ -224,7 +224,7 @@ const app = require("./index.js");
module.exports = createNodeMiddleware(app, { probot: createProbot() });
```
By default, `createNodeMiddleware()` uses `/` as the webhook endpoint. To customize this behaviour, you can use the `webhooksPath` option.
By default, `createNodeMiddleware()` uses `/api/github/webhooks` as the webhook endpoint. To customize this behaviour, you can use the `webhooksPath` option.
```js
module.exports = createNodeMiddleware(app, {

View File

@ -53,7 +53,7 @@ module.exports = (app) => {
context.octokit.issues.createComment(
context.issue({
body: `There were ${edits} edits to issues in this thread.`,
})
}),
);
});
};

View File

@ -17,7 +17,7 @@ module.exports = (app) => {
response.data.issues.forEach((issue) => {
context.log.info("Issue: %s", issue.title);
});
}
},
);
});
};
@ -33,7 +33,7 @@ module.exports = (app) => {
const allIssues = await context.octokit.paginate(
context.octokit.issues.list,
context.repo(),
(response) => response.data
(response) => response.data,
);
console.log(allIssues);
});
@ -58,7 +58,7 @@ module.exports = (app) => {
break;
}
}
}
},
);
});
};
@ -66,14 +66,14 @@ module.exports = (app) => {
## Async iterators
If your runtime environment supports async iterators (such as Node 10+), you can iterate through each response
Using async iterators you can iterate through each response
```js
module.exports = (app) => {
app.on("issues.opened", async (context) => {
for await (const response of octokit.paginate.iterator(
context.octokit.issues.list,
context.repo()
context.repo(),
)) {
for (const issue of response.data) {
if (issue.body.includes("something")) {

View File

@ -15,10 +15,14 @@ To save a copy of the payload, go to the [settings](https://github.com/settings/
Next, simulate receiving this event being delivered by running:
$ node_modules/.bin/probot receive -e <event-name> -p <path-to-fixture> <path-to-app>
```bash
$ node_modules/.bin/probot receive -e <event-name> -p <path-to-fixture> <path-to-app>
```
Note that `event-name` here is just the name of the event (like pull_request or issues) and not the action (like labeled). You can find it in the GitHub deliveries history under the `X-GitHub-Event` header.
For example, to simulate receiving the `pull_request.labeled` event, run:
$ node_modules/.bin/probot receive -e pull_request -p test/fixtures/pull_request.labeled.json ./index.js
```bash
$ node_modules/.bin/probot receive -e pull_request -p test/fixtures/pull_request.labeled.json ./index.js
```

View File

@ -0,0 +1,70 @@
'use strict';
const { createServer } = require("http");
const { createNodeMiddleware } = require('../lib/create-node-middleware');
const { createProbot } = require('../lib/create-probot');
const { sign } = require("@octokit/webhooks-methods");
const WebhookExamples = require("@octokit/webhooks-examples");
process.env.APP_ID = "123";
process.env.PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aozN3tihPmljnnudb9G3HECdnH
lWu2/a1gB9JW5TBQ+AVpum9Okx7KfqkfBKL9mcHgSL0yWMdjMfNOqNtrQqKlN4kE
p6RD++7sGbzbfZ9arwrlD/HSDAWGdGGJTSOBM6pHehyLmSC3DJoR/CTu0vTGTWXQ
rO64Z8tyXQPtVPb/YXrcUhbBp8i72b9Xky0fD6PkEebOy0Ip58XVAn2UPNlNOSPS
ye+Qjtius0Md4Nie4+X8kwVI2Qjk3dSm0sw/720KJkdVDmrayeljtKBx6AtNQsSX
gzQbeMmiqFFkwrG1+zx6E7H7jqIQ9B6bvWKXGwIDAQABAoIBAD8kBBPL6PPhAqUB
K1r1/gycfDkUCQRP4DbZHt+458JlFHm8QL6VstKzkrp8mYDRhffY0WJnYJL98tr4
4tohsDbqFGwmw2mIaHjl24LuWXyyP4xpAGDpl9IcusjXBxLQLp2m4AKXbWpzb0OL
Ulrfc1ZooPck2uz7xlMIZOtLlOPjLz2DuejVe24JcwwHzrQWKOfA11R/9e50DVse
hnSH/w46Q763y4I0E3BIoUMsolEKzh2ydAAyzkgabGQBUuamZotNfvJoDXeCi1LD
8yNCWyTlYpJZJDDXooBU5EAsCvhN1sSRoaXWrlMSDB7r/E+aQyKua4KONqvmoJuC
21vSKeECgYEA7yW6wBkVoNhgXnk8XSZv3W+Q0xtdVpidJeNGBWnczlZrummt4xw3
xs6zV+rGUDy59yDkKwBKjMMa42Mni7T9Fx8+EKUuhVK3PVQyajoyQqFwT1GORJNz
c/eYQ6VYOCSC8OyZmsBM2p+0D4FF2/abwSPMmy0NgyFLCUFVc3OECpkCgYEA5OAm
I3wt5s+clg18qS7BKR2DuOFWrzNVcHYXhjx8vOSWV033Oy3yvdUBAhu9A1LUqpwy
Ma+unIgxmvmUMQEdyHQMcgBsVs10dR/g2xGjMLcwj6kn+xr3JVIZnbRT50YuPhf+
ns1ScdhP6upo9I0/sRsIuN96Gb65JJx94gQ4k9MCgYBO5V6gA2aMQvZAFLUicgzT
u/vGea+oYv7tQfaW0J8E/6PYwwaX93Y7Q3QNXCoCzJX5fsNnoFf36mIThGHGiHY6
y5bZPPWFDI3hUMa1Hu/35XS85kYOP6sGJjf4kTLyirEcNKJUWH7CXY+00cwvTkOC
S4Iz64Aas8AilIhRZ1m3eQKBgQCUW1s9azQRxgeZGFrzC3R340LL530aCeta/6FW
CQVOJ9nv84DLYohTVqvVowdNDTb+9Epw/JDxtDJ7Y0YU0cVtdxPOHcocJgdUGHrX
ZcJjRIt8w8g/s4X6MhKasBYm9s3owALzCuJjGzUKcDHiO2DKu1xXAb0SzRcTzUCn
7daCswKBgQDOYPZ2JGmhibqKjjLFm0qzpcQ6RPvPK1/7g0NInmjPMebP0K6eSPx0
9/49J6WTD++EajN7FhktUSYxukdWaCocAQJTDNYP0K88G4rtC2IYy5JFn9SWz5oh
x//0u+zd/R/QRUzLOw4N72/Hu+UG6MNt5iDZFCtapRaKt6OvSBwy8w==
-----END RSA PRIVATE KEY-----`;
process.env.WEBHOOK_SECRET = "secret";
process.env.WEBHOOK_PATH = "/";
const pushEvent = JSON.stringify((
WebhookExamples.filter(
(event) => event.name === "push",
)[0]
).examples[0]);
const appFn = (app) => {
app.on("issues.opened", async (context) => {
const issueComment = context.issue({
body: "Thanks for opening this issue!",
});
return context.octokit.issues.createComment(issueComment);
});
app.onAny(async (context) => {
context.log.info({ event: context.name, action: context.payload.action });
});
app.onError(async (error) => {
app.log.error(error);
});
};
const middleware = createNodeMiddleware(appFn, { probot: createProbot() });
const server = createServer(middleware);
server.listen(3000, async () => {
console.log("Probot started http://localhost:3000/")
console.log(`autocannon -m POST -b '${pushEvent}' -H content-type=application/json -H x-github-event=push -H x-github-delivery=1 -H x-hub-signature-256=${await sign("secret", pushEvent)} http://127.0.0.1:3000/`)
});

25621
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,9 +12,10 @@
"build": "rimraf lib && tsc -p tsconfig.json",
"lint": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\" \"docs/*.md\" *.md package.json tsconfig.json --end-of-line auto",
"lint:fix": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"docs/*.md\" *.md package.json tsconfig.json --end-of-line auto",
"pretest": "tsc --noEmit -p test",
"test": "jest",
"posttest": "npm run lint",
"pretest": "npm run build && tsc --noEmit -p test",
"test": "vitest run",
"test:coverage": "vitest run --coverage",
"test:dev": "vitest --ui --coverage",
"doc": "typedoc --options .typedoc.json"
},
"files": [
@ -35,92 +36,65 @@
"author": "Brandon Keepers",
"license": "ISC",
"dependencies": {
"@octokit/core": "^3.2.4",
"@octokit/plugin-enterprise-compatibility": "^1.2.8",
"@octokit/plugin-paginate-rest": "^2.6.2",
"@octokit/plugin-rest-endpoint-methods": "^5.0.1",
"@octokit/plugin-retry": "^3.0.6",
"@octokit/plugin-throttling": "^3.3.4",
"@octokit/types": "^8.0.0",
"@octokit/webhooks": "^9.26.3",
"@probot/get-private-key": "^1.1.0",
"@probot/octokit-plugin-config": "^1.0.0",
"@probot/pino": "^2.2.0",
"@types/express": "^4.17.9",
"@types/ioredis": "^4.27.1",
"@types/pino": "^6.3.4",
"@types/pino-http": "^5.0.6",
"commander": "^6.2.0",
"deepmerge": "^4.2.2",
"deprecation": "^2.3.1",
"dotenv": "^8.2.0",
"@octokit/core": "^5.0.2",
"@octokit/plugin-enterprise-compatibility": "^4.0.1",
"@octokit/plugin-paginate-rest": "^9.1.4",
"@octokit/plugin-rest-endpoint-methods": "^10.1.5",
"@octokit/plugin-retry": "^6.0.1",
"@octokit/plugin-throttling": "^8.1.3",
"@octokit/request": "^8.1.6",
"@octokit/types": "^12.3.0",
"@octokit/webhooks": "^12.0.10",
"@probot/get-private-key": "^1.1.2",
"@probot/octokit-plugin-config": "^2.0.1",
"@probot/pino": "^2.3.5",
"@types/express": "^4.17.21",
"commander": "^11.1.0",
"deepmerge": "^4.3.1",
"dotenv": "^16.3.1",
"eventsource": "^2.0.2",
"express": "^4.17.1",
"express-handlebars": "^6.0.3",
"ioredis": "^4.27.8",
"js-yaml": "^3.14.1",
"lru-cache": "^6.0.0",
"octokit-auth-probot": "^1.2.2",
"pino": "^6.7.0",
"pino-http": "^5.3.0",
"express": "^4.18.2",
"ioredis": "^5.3.2",
"js-yaml": "^4.1.0",
"lru-cache": "^10.0.3",
"octokit-auth-probot": "^2.0.0",
"pino": "^8.16.1",
"pino-http": "^8.5.1",
"pkg-conf": "^3.1.0",
"resolve": "^1.19.0",
"semver": "^7.3.4",
"update-dotenv": "^1.1.1",
"uuid": "^8.3.2"
"resolve": "^1.22.8",
"update-dotenv": "^1.1.1"
},
"devDependencies": {
"@octokit/webhooks-examples": "^4.0.0",
"@octokit/webhooks-methods": "^3.0.0",
"@octokit/webhooks-types": "^4.0.0",
"@tsconfig/node10": "^1.0.7",
"@types/eventsource": "^1.1.5",
"@types/jest": "^26.0.18",
"@types/js-yaml": "^3.12.5",
"@types/jsonwebtoken": "^8.5.0",
"@types/node": "^16.0.0",
"@types/readable-stream": "^2.3.9",
"@types/resolve": "^1.17.1",
"@types/semver": "^7.3.4",
"@types/supertest": "^2.0.10",
"@types/uuid": "^8.3.0",
"body-parser": "^1.19.0",
"@octokit/tsconfig": "^2.0.0",
"@octokit/webhooks-examples": "^7.3.1",
"@octokit/webhooks-methods": "^4.0.0",
"@octokit/webhooks-types": "^7.3.1",
"@types/eventsource": "^1.1.15",
"@types/js-yaml": "^4.0.9",
"@types/node": "^18.18.1",
"@types/resolve": "^1.20.5",
"@types/supertest": "^2.0.16",
"@vitest/coverage-v8": "^0.34.6",
"@vitest/ui": "^0.34.6",
"bottleneck": "^2.19.5",
"connect-sse": "^1.2.0",
"execa": "^5.0.0",
"fetch-mock": "npm:@gr2m/fetch-mock@9.11.0-pull-request-644.1",
"get-port": "^5.1.1",
"got": "^11.8.0",
"jest": "^26.6.3",
"nock": "^13.0.5",
"prettier": "^2.2.1",
"rimraf": "^3.0.2",
"semantic-release": "^19.0.3",
"prettier": "^3.0.3",
"rimraf": "^5.0.5",
"semantic-release": "^22.0.7",
"semantic-release-plugin-update-version-in-files": "^1.1.0",
"smee-client": "^1.2.2",
"supertest": "^6.0.1",
"ts-jest": "^26.4.4",
"tsd": "^0.23.0",
"typedoc": "^0.22.10",
"typescript": "^4.1.2"
"smee-client": "^2.0.0",
"sonic-boom": "^3.7.0",
"supertest": "^6.3.3",
"tsd": "^0.29.0",
"typedoc": "^0.25.3",
"typescript": "^5.2.2",
"vitest": "^0.34.6"
},
"engines": {
"node": ">=10.21"
},
"jest": {
"testEnvironment": "node",
"coveragePathIgnorePatterns": [
"<rootDir>/lib/"
],
"moduleFileExtensions": [
"ts",
"js",
"json",
"node"
],
"preset": "ts-jest"
},
"tsd": {
"directory": "test/types"
"node": ">=18"
},
"release": {
"plugins": [

View File

@ -1,25 +1,28 @@
import path from "path";
import { ApplicationFunctionOptions, Probot } from "../index";
import { resolve } from "node:path";
import type { ApplicationFunctionOptions, Probot } from "../index.js";
import { loadPackageJson } from "../helpers/load-package-json.js";
import { probotView } from "../views/probot.js";
export function defaultApp(
app: Probot,
{ getRouter }: ApplicationFunctionOptions
_app: Probot,
{ getRouter, cwd = process.cwd() }: ApplicationFunctionOptions,
) {
if (!getRouter) {
throw new Error("getRouter() is required for defaultApp");
}
const pkg = loadPackageJson(resolve(cwd, "package.json"));
const probotViewRendered = probotView({
name: pkg.name,
version: pkg.version,
description: pkg.description,
});
const router = getRouter();
router.get("/probot", (req, res) => {
let pkg;
try {
pkg = require(path.join(process.cwd(), "package.json"));
} catch (e) {
pkg = {};
}
res.render("probot.handlebars", pkg);
router.get("/probot", (_req, res) => {
res.send(probotViewRendered);
});
router.get("/", (req, res, next) => res.redirect("/probot"));
router.get("/", (_req, res) => res.redirect("/probot"));
}

View File

@ -1,23 +1,34 @@
import bodyParser from "body-parser";
import { exec } from "child_process";
import { Request, Response } from "express";
import { exec } from "node:child_process";
import type { IncomingMessage, ServerResponse } from "http";
import { parse as parseQuery } from "querystring";
import express from "express";
import updateDotenv from "update-dotenv";
import { Probot } from "../probot";
import { ManifestCreation } from "../manifest-creation";
import { getLoggingMiddleware } from "../server/logging-middleware";
import { ApplicationFunctionOptions } from "../types";
import { isProduction } from "../helpers/is-production";
import { Probot } from "../probot.js";
import { ManifestCreation } from "../manifest-creation.js";
import { getLoggingMiddleware } from "../server/logging-middleware.js";
import type { ApplicationFunctionOptions } from "../types.js";
import { isProduction } from "../helpers/is-production.js";
import { importView } from "../views/import.js";
import { setupView } from "../views/setup.js";
import { successView } from "../views/success.js";
export const setupAppFactory = (
host: string | undefined,
port: number | undefined
port: number | undefined,
) =>
async function setupApp(
app: Probot,
{ getRouter }: ApplicationFunctionOptions
{ getRouter }: ApplicationFunctionOptions,
) {
if (!getRouter) {
throw new Error("getRouter is required to use the setup app");
}
const setup: ManifestCreation = new ManifestCreation();
const pkg = setup.pkg;
// If not on Glitch or Production, create a smee URL
if (
@ -31,75 +42,132 @@ export const setupAppFactory = (
await setup.createWebhookChannel();
}
if (!getRouter) {
throw new Error("getRouter is required to use the setup app");
}
const route = getRouter();
route.use(getLoggingMiddleware(app.log));
printWelcomeMessage(app, host, port);
route.get("/probot", async (req, res) => {
route.get("/probot", async (req: IncomingMessage, res: ServerResponse) => {
const baseUrl = getBaseUrl(req);
const pkg = setup.pkg;
const manifest = setup.getManifest(pkg, baseUrl);
const createAppUrl = setup.createAppUrl;
// Pass the manifest to be POST'd
res.render("setup.handlebars", { pkg, createAppUrl, manifest });
res.writeHead(200, { "content-type": "text/html" }).end(
setupView({
name: pkg.name,
version: pkg.version,
description: pkg.description,
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: IncomingMessage, res: ServerResponse) => {
// @ts-expect-error query could be set by a framework, e.g. express
const { code } = req.query || parseQuery(req.url?.split("?")[1] || "");
// If using glitch, restart the app
if (process.env.PROJECT_DOMAIN) {
exec("refresh", (error) => {
if (error) {
app.log.error(error);
}
if (!code || typeof code !== "string" || code.length === 0) {
res
.writeHead(400, { "content-type": "text/plain" })
.end("code missing or invalid");
return;
}
const response = await setup.createAppFromCode(code, {
// @ts-expect-error
request: app.state.request,
});
} else {
// If using glitch, restart the app
if (process.env.PROJECT_DOMAIN) {
exec("refresh", (error) => {
if (error) {
app.log.error(error);
}
});
} else {
printRestartMessage(app);
}
res
.writeHead(302, {
"content-type": "text/plain",
location: `${response}/installations/new`,
})
.end(`Found. Redirecting to ${response}/installations/new`);
},
);
const { WEBHOOK_PROXY_URL, GHE_HOST } = process.env;
const GH_HOST = `https://${GHE_HOST ?? "github.com"}`;
const importViewRendered = importView({
name: pkg.name,
WEBHOOK_PROXY_URL,
GH_HOST,
});
route.get(
"/probot/import",
(_req: IncomingMessage, res: ServerResponse) => {
res
.writeHead(200, {
"content-type": "text/html",
})
.end(importViewRendered);
},
);
route.post(
"/probot/import",
express.json(),
(req: IncomingMessage, res: ServerResponse) => {
const { appId, pem, webhook_secret } = (req as unknown as { body: any })
.body;
if (!appId || !pem || !webhook_secret) {
res
.writeHead(400, {
"content-type": "text/plain",
})
.end("appId and/or pem and/or webhook_secret missing");
return;
}
updateDotenv({
APP_ID: appId,
PRIVATE_KEY: `"${pem}"`,
WEBHOOK_SECRET: webhook_secret,
});
res.end();
printRestartMessage(app);
}
},
);
res.redirect(`${response}/installations/new`);
});
const successViewRendered = successView({ name: pkg.name });
route.get("/probot/import", async (_req, res) => {
const { WEBHOOK_PROXY_URL, GHE_HOST } = process.env;
const GH_HOST = `https://${GHE_HOST ?? "github.com"}`;
res.render("import.handlebars", { WEBHOOK_PROXY_URL, GH_HOST });
});
route.get(
"/probot/success",
(_req: IncomingMessage, res: ServerResponse) => {
res
.writeHead(200, { "content-type": "text/html" })
.end(successViewRendered);
},
);
route.post("/probot/import", bodyParser.json(), async (req, res) => {
const { appId, pem, webhook_secret } = req.body;
if (!appId || !pem || !webhook_secret) {
res.status(400).send("appId and/or pem and/or webhook_secret missing");
return;
}
updateDotenv({
APP_ID: appId,
PRIVATE_KEY: `"${pem}"`,
WEBHOOK_SECRET: webhook_secret,
});
res.end();
printRestartMessage(app);
});
route.get("/probot/success", async (req, res) => {
res.render("success.handlebars");
});
route.get("/", (req, res, next) => res.redirect("/probot"));
route.get("/", (_req, res: ServerResponse) =>
res
.writeHead(302, { "content-type": "text/plain", location: `/probot` })
.end(`Found. Redirecting to /probot`),
);
};
function printWelcomeMessage(
app: Probot,
host: string | undefined,
port: number | undefined
port: number | undefined,
) {
// use glitch env to get correct domain welcome message
// https://glitch.com/help/project/
@ -127,11 +195,16 @@ function printRestartMessage(app: Probot) {
app.log.info("");
}
function getBaseUrl(req: Request): string {
const protocols = req.headers["x-forwarded-proto"] || req.protocol;
function getBaseUrl(req: IncomingMessage): string {
const protocols =
req.headers["x-forwarded-proto"] ||
// @ts-expect-error based on the functionality of express
req.socket?.encrypted
? "https"
: "http";
const protocol =
typeof protocols === "string" ? protocols.split(",")[0] : protocols[0];
const host = req.headers["x-forwarded-host"] || req.get("host");
const host = req.headers["x-forwarded-host"] || req.headers.host;
const baseUrl = `${protocol}://${host}`;
return baseUrl;
}

View File

@ -1,8 +1,6 @@
import type { Logger } from "pino";
import { getAuthenticatedOctokit } from "./octokit/get-authenticated-octokit";
import { ProbotOctokit } from "./octokit/probot-octokit";
import { State } from "./types";
import { getAuthenticatedOctokit } from "./octokit/get-authenticated-octokit.js";
import { ProbotOctokit } from "./octokit/probot-octokit.js";
import type { State } from "./types.js";
/**
* Authenticate and get a GitHub client that can be used to make API calls.
@ -20,6 +18,7 @@ import { State } from "./types";
* });
* };
* ```
* @param state - Probot application instance state, which is used to persist
*
* @param id - ID of the installation, which can be extracted from
* `context.payload.installation.id`. If called without this parameter, the
@ -32,10 +31,6 @@ import { State } from "./types";
export async function auth(
state: State,
installationId?: number,
log?: Logger
): Promise<InstanceType<typeof ProbotOctokit>> {
return getAuthenticatedOctokit(
Object.assign({}, state, log ? { log } : null),
installationId
);
return getAuthenticatedOctokit(Object.assign({}, state), installationId);
}

View File

@ -1,17 +1,18 @@
// Usage: probot receive -e push -p path/to/payload app.js
import fs from "node:fs";
import path from "node:path";
import { randomUUID as uuidv4 } from "node:crypto";
import express, { Router } from "express";
import { config as dotenvConfig } from "dotenv";
dotenvConfig();
require("dotenv").config();
import path from "path";
import { v4 as uuidv4 } from "uuid";
import program from "commander";
import { program } from "commander";
import { getPrivateKey } from "@probot/get-private-key";
import { getLog } from "../helpers/get-log";
import { getLog } from "../helpers/get-log.js";
import { ApplicationFunctionOptions, Probot } from "../";
import { resolveAppFunction } from "../helpers/resolve-app-function";
import { Probot, type ApplicationFunctionOptions } from "../index.js";
import { resolveAppFunction } from "../helpers/resolve-app-function.js";
async function main() {
program
@ -19,48 +20,48 @@ async function main() {
.option(
"-e, --event <event-name>",
"Event name",
process.env.GITHUB_EVENT_NAME
process.env.GITHUB_EVENT_NAME,
)
.option(
"-p, --payload-path <payload-path>",
"Path to the event payload",
process.env.GITHUB_EVENT_PATH
process.env.GITHUB_EVENT_PATH,
)
.option(
"-t, --token <access-token>",
"Access token",
process.env.GITHUB_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 private key file (.pem) for the GitHub App",
process.env.PRIVATE_KEY_PATH
process.env.PRIVATE_KEY_PATH,
)
.option(
"-L, --log-level <level>",
'One of: "trace" | "debug" | "info" | "warn" | "error" | "fatal"',
process.env.LOG_LEVEL
process.env.LOG_LEVEL,
)
.option(
"--log-format <format>",
'One of: "pretty", "json"',
process.env.LOG_LEVEL || "pretty"
process.env.LOG_LEVEL || "pretty",
)
.option(
"--log-level-in-string",
"Set to log levels (trace, debug, info, ...) as words instead of numbers (10, 20, 30, ...)",
process.env.LOG_LEVEL_IN_STRING === "true"
process.env.LOG_LEVEL_IN_STRING === "true",
)
.option(
"--log-message-key",
"Set to the string key for the 'message' in the log JSON object",
process.env.LOG_MESSAGE_KEY || "msg"
process.env.LOG_MESSAGE_KEY || "msg",
)
.option(
"--sentry-dsn <dsn>",
'Set to your Sentry DSN, e.g. "https://1234abcd@sentry.io/12345"',
process.env.SENTRY_DSN
process.env.SENTRY_DSN,
)
.option(
"--base-url <url>",
@ -69,38 +70,51 @@ async function main() {
? `${process.env.GHE_PROTOCOL || "https"}://${
process.env.GHE_HOST
}/api/v3`
: "https://api.github.com"
: "https://api.github.com",
)
.parse(process.argv);
const githubToken = program.token;
const {
app: appId,
baseUrl,
token: githubToken,
event,
payloadPath,
logLevel,
logFormat,
logLevelInString,
logMessageKey,
sentryDsn,
} = program.opts();
if (!program.event || !program.payloadPath) {
if (!event || !payloadPath) {
program.help();
}
const privateKey = getPrivateKey();
if (!githubToken && (!program.app || !privateKey)) {
if (!githubToken && (!appId || !privateKey)) {
console.warn(
"No token specified and no certificate found, which means you will not be able to do authenticated requests to GitHub"
"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 = JSON.parse(
fs.readFileSync(path.resolve(payloadPath), "utf8"),
);
const log = getLog({
level: program.logLevel,
logFormat: program.logFormat,
logLevelInString: program.logLevelInString,
logMessageKey: program.logMessageKey,
sentryDsn: program.sentryDsn,
level: logLevel,
logFormat,
logLevelInString,
logMessageKey,
sentryDsn,
});
const probot = new Probot({
appId: program.app,
appId,
privateKey: String(privateKey),
githubToken: githubToken,
log,
baseUrl: program.baseUrl,
baseUrl: baseUrl,
});
const expressApp = express();
@ -113,12 +127,12 @@ async function main() {
};
const appFn = await resolveAppFunction(
path.resolve(process.cwd(), program.args[0])
path.resolve(process.cwd(), program.args[0]),
);
await probot.load(appFn, options);
probot.log.debug("Receiving event", program.event);
probot.receive({ name: program.event, payload, id: uuidv4() }).catch(() => {
probot.log.debug("Receiving event", event);
probot.receive({ name: event, payload, id: uuidv4() }).catch(() => {
// Process must exist non-zero to indicate that the action failed to run
process.exit(1);
});

View File

@ -1,3 +1,3 @@
import { run } from "../";
import { run } from "../index.js";
run(process.argv);

View File

@ -1,24 +1,30 @@
import semver from "semver";
import program from "commander";
import { resolve } from "node:path";
require("dotenv").config();
import { program } from "commander";
import { config as dotenvConfig } from "dotenv";
import { isSupportedNodeVersion } from "../helpers/is-supported-node-version.js";
import { loadPackageJson } from "../helpers/load-package-json.js";
const pkg = require("../../package");
/*import { dirname } from 'path';
import { fileURLToPath } from 'url';*/
if (!semver.satisfies(process.version, pkg.engines.node)) {
console.log(
`Node.js version ${pkg.engines.node} is required. You have ${process.version}.`
);
dotenvConfig();
//const __dirname = dirname(fileURLToPath(import.meta.url));
const pkg = loadPackageJson(resolve(__dirname, "package.json"));
if (!isSupportedNodeVersion()) {
console.log(`Node.js version 18 is required. You have ${process.version}.`);
process.exit(1);
}
program
.version(pkg.version)
.version(pkg.version || "0.0.0-dev")
.usage("<command> [options]")
.command("run", "run the bot")
.command("receive", "Receive a single event and payload")
.on("command:*", (cmd) => {
if (!program.commands.find((c) => c._name == cmd[0])) {
if (!program.commands.find((c) => c.name() == cmd[0])) {
console.error(`Invalid command: ${program.args.join(" ")}\n`);
program.outputHelp();
process.exit(1);

View File

@ -1,65 +1,65 @@
import program from "commander";
import { program } from "commander";
import { getPrivateKey } from "@probot/get-private-key";
import { Options as PinoOptions } from "@probot/pino";
import type { Options as PinoOptions } from "@probot/pino";
import { Options } from "../types";
import type { Options } from "../types.js";
export function readCliOptions(
argv: string[]
argv: string[],
): Options & PinoOptions & { args: string[] } {
program
.usage("[options] <apps...>")
.option(
"-p, --port <n>",
"Port to start the server on",
String(process.env.PORT || 3000)
String(process.env.PORT || 3000),
)
.option("-H --host <host>", "Host to start the server on", process.env.HOST)
.option(
"-W, --webhook-proxy <url>",
"URL of the webhook proxy service.`",
process.env.WEBHOOK_PROXY_URL
process.env.WEBHOOK_PROXY_URL,
)
.option(
"-w, --webhook-path <path>",
"URL path which receives webhooks. Ex: `/webhook`",
process.env.WEBHOOK_PATH
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
process.env.WEBHOOK_SECRET,
)
.option(
"-P, --private-key <file>",
"Path to private key file (.pem) for the GitHub App",
process.env.PRIVATE_KEY_PATH
process.env.PRIVATE_KEY_PATH,
)
.option(
"-L, --log-level <level>",
'One of: "trace" | "debug" | "info" | "warn" | "error" | "fatal"',
process.env.LOG_LEVEL || "info"
process.env.LOG_LEVEL || "info",
)
.option(
"--log-format <format>",
'One of: "pretty", "json"',
process.env.LOG_FORMAT
process.env.LOG_FORMAT,
)
.option(
"--log-level-in-string",
"Set to log levels (trace, debug, info, ...) as words instead of numbers (10, 20, 30, ...)",
process.env.LOG_LEVEL_IN_STRING === "true"
process.env.LOG_LEVEL_IN_STRING === "true",
)
.option(
"--sentry-dsn <dsn>",
'Set to your Sentry DSN, e.g. "https://1234abcd@sentry.io/12345"',
process.env.SENTRY_DSN
process.env.SENTRY_DSN,
)
.option(
"--redis-url <url>",
'Set to a "redis://" url in order to enable cluster support for request throttling. Example: "redis://:secret@redis-123.redislabs.com:12345/0"',
process.env.REDIS_URL
process.env.REDIS_URL,
)
.option(
"--base-url <url>",
@ -68,7 +68,7 @@ export function readCliOptions(
? `${process.env.GHE_PROTOCOL || "https"}://${
process.env.GHE_HOST
}/api/v3`
: "https://api.github.com"
: "https://api.github.com",
)
.parse(argv);
@ -77,12 +77,13 @@ export function readCliOptions(
privateKey: privateKeyPath,
redisUrl,
...options
} = program;
} = program.opts();
return {
privateKey: getPrivateKey({ filepath: privateKeyPath }) || undefined,
appId,
redisConfig: redisUrl,
args: program.args,
...options,
};
}

View File

@ -1,13 +1,17 @@
import { getPrivateKey } from "@probot/get-private-key";
import { Options as PinoOptions, LogLevel } from "@probot/pino";
import type { Options as PinoOptions, LogLevel } from "@probot/pino";
export function readEnvOptions(
env: Record<string, string | undefined> = process.env
) {
export function readEnvOptions(env = process.env) {
const privateKey = getPrivateKey({ env });
const logFormat =
env.LOG_FORMAT || (env.NODE_ENV === "production" ? "json" : "pretty");
const logFormat: PinoOptions["logFormat"] =
env.LOG_FORMAT && env.LOG_FORMAT.length !== 0
? env.LOG_FORMAT === "pretty"
? "pretty"
: "json"
: env.NODE_ENV === "production"
? "json"
: "pretty";
return {
args: [],
@ -19,7 +23,7 @@ export function readEnvOptions(
webhookPath: env.WEBHOOK_PATH,
webhookProxy: env.WEBHOOK_PROXY_URL,
logLevel: env.LOG_LEVEL as LogLevel,
logFormat: logFormat as PinoOptions["logFormat"],
logFormat: logFormat,
logLevelInString: env.LOG_LEVEL_IN_STRING === "true",
logMessageKey: env.LOG_MESSAGE_KEY,
sentryDsn: env.SENTRY_DSN,

View File

@ -1,14 +1,12 @@
import path from "path";
import { EmitterWebhookEvent as WebhookEvent } from "@octokit/webhooks";
import path from "node:path";
import merge from "deepmerge";
import type {
EmitterWebhookEvent as WebhookEvent,
EmitterWebhookEventName as WebhookEvents,
} from "@octokit/webhooks";
import type { Logger } from "pino";
import { ProbotOctokit } from "./octokit/probot-octokit";
import { aliasLog } from "./helpers/alias-log";
import { DeprecatedLogger } from "./types";
import { EmitterWebhookEventName as WebhookEvents } from "@octokit/webhooks/dist-types/types";
import type { ProbotOctokit } from "./octokit/probot-octokit.js";
export type MergeOptions = merge.Options;
@ -31,12 +29,12 @@ type RepoIssueNumberType<T extends WebhookEvents> =
WebhookEvent<T>["payload"] extends { issue: { number: number } }
? number
: never | WebhookEvent<T>["payload"] extends {
pull_request: { number: number };
}
? number
: never | WebhookEvent<T>["payload"] extends { number: number }
? number
: never;
pull_request: { number: number };
}
? number
: never | WebhookEvent<T>["payload"] extends { number: number }
? number
: never;
/** Context.repo return type */
type RepoResultType<E extends WebhookEvents> = {
@ -65,20 +63,16 @@ export class Context<E extends WebhookEvents = WebhookEvents> {
public id: string;
public payload: WebhookEvent<E>["payload"];
public octokit: InstanceType<typeof ProbotOctokit>;
public log: DeprecatedLogger;
public octokit: ProbotOctokit;
public log: Logger;
constructor(
event: WebhookEvent<E>,
octokit: InstanceType<typeof ProbotOctokit>,
log: Logger
) {
constructor(event: WebhookEvent<E>, octokit: ProbotOctokit, log: Logger) {
this.name = event.name;
this.id = event.id;
this.payload = event.payload;
this.octokit = octokit;
this.log = aliasLog(log);
this.log = log;
}
/**
@ -94,12 +88,12 @@ export class Context<E extends WebhookEvents = WebhookEvents> {
*
*/
public repo<T>(object?: T): RepoResultType<E> & T {
// @ts-ignore `repository` is not always present in this.payload
// @ts-expect-error `repository` is not always present in this.payload
const repo = this.payload.repository;
if (!repo) {
throw new Error(
"context.repo() is not supported for this webhook event."
"context.repo() is not supported for this webhook event.",
);
}
@ -108,7 +102,7 @@ export class Context<E extends WebhookEvents = WebhookEvents> {
owner: repo.owner.login,
repo: repo.name,
},
object
object,
);
}
@ -125,16 +119,16 @@ export class Context<E extends WebhookEvents = WebhookEvents> {
* @param object - Params to be merged with the issue params.
*/
public issue<T>(
object?: T
object?: T,
): RepoResultType<E> & { issue_number: RepoIssueNumberType<E> } & T {
return Object.assign(
{
issue_number:
// @ts-ignore - this.payload may not have `issue` or `pull_request` keys
// @ts-expect-error - this.payload may not have `issue` or `pull_request` keys
(this.payload.issue || this.payload.pull_request || this.payload)
.number,
},
this.repo(object)
this.repo(object),
);
}
@ -151,15 +145,15 @@ export class Context<E extends WebhookEvents = WebhookEvents> {
* @param object - Params to be merged with the pull request params.
*/
public pullRequest<T>(
object?: T
object?: T,
): RepoResultType<E> & { pull_number: RepoIssueNumberType<E> } & T {
const payload = this.payload;
return Object.assign(
{
// @ts-ignore - this.payload may not have `issue` or `pull_request` keys
// @ts-expect-error - this.payload may not have `issue` or `pull_request` keys
pull_number: (payload.issue || payload.pull_request || payload).number,
},
this.repo(object)
this.repo(object),
);
}
@ -225,25 +219,25 @@ export class Context<E extends WebhookEvents = WebhookEvents> {
public async config<T>(
fileName: string,
defaultConfig?: T,
deepMergeOptions?: MergeOptions
deepMergeOptions?: MergeOptions,
): Promise<T | null> {
const params = this.repo({
path: path.posix.join(".github", fileName),
defaults(configs: object[]) {
const result = merge.all(
[defaultConfig || {}, ...configs],
deepMergeOptions
deepMergeOptions,
);
return result;
},
});
// @ts-ignore
// @ts-expect-error
const { config, files } = await this.octokit.config.get(params);
// if no default config is set, and no config files are found, return null
if (!defaultConfig && !files.find((file: any) => file.config !== null)) {
if (!defaultConfig && !files.find((file) => file.config !== null)) {
return null;
}

View File

@ -1,16 +1,17 @@
import { RequestListener } from "http";
import { createNodeMiddleware as createWebbhooksMiddleware } from "@octokit/webhooks";
import type { RequestListener } from "http";
import { createNodeMiddleware as createWebhooksMiddleware } from "@octokit/webhooks";
import { ApplicationFunction } from "./types";
import { MiddlewareOptions } from "./types";
import type { ApplicationFunction, MiddlewareOptions } from "./types.js";
import { defaultWebhooksPath } from "./server/server.js";
import { createProbot } from "./create-probot.js";
export function createNodeMiddleware(
appFn: ApplicationFunction,
{ probot, webhooksPath }: MiddlewareOptions
{ probot = createProbot(), webhooksPath } = {} as MiddlewareOptions,
): RequestListener {
probot.load(appFn);
return createWebbhooksMiddleware(probot.webhooks, {
path: webhooksPath || "/",
return createWebhooksMiddleware(probot.webhooks, {
path: webhooksPath || probot.webhookPath || defaultWebhooksPath,
});
}

View File

@ -1,24 +1,26 @@
import { LogLevel, Options as PinoOptions } from "@probot/pino";
import type { LogLevel, Options as PinoOptions } from "@probot/pino";
import { getPrivateKey } from "@probot/get-private-key";
import { getLog, GetLogOptions } from "./helpers/get-log";
import { Options } from "./types";
import { Probot } from "./probot";
import { getLog } from "./helpers/get-log.js";
import type { Options } from "./types.js";
import { Probot } from "./probot.js";
import { defaultWebhooksPath } from "./server/server.js";
type CreateProbotOptions = {
overrides?: Options;
defaults?: Options;
env?: NodeJS.ProcessEnv;
env?: Partial<NodeJS.ProcessEnv>;
};
const DEFAULTS = {
const DEFAULTS: Partial<NodeJS.ProcessEnv> = {
APP_ID: "",
WEBHOOK_SECRET: "",
WEBHOOK_PATH: defaultWebhooksPath,
GHE_HOST: "",
GHE_PROTOCOL: "",
LOG_FORMAT: "",
GHE_PROTOCOL: "https",
LOG_FORMAT: undefined,
LOG_LEVEL: "warn",
LOG_LEVEL_IN_STRING: "",
LOG_LEVEL_IN_STRING: "false",
LOG_MESSAGE_KEY: "msg",
REDIS_URL: "",
SENTRY_DSN: "",
@ -47,6 +49,7 @@ export function createProbot({
privateKey: (privateKey && privateKey.toString()) || undefined,
secret: envWithDefaults.WEBHOOK_SECRET,
redisConfig: envWithDefaults.REDIS_URL,
webhookPath: envWithDefaults.WEBHOOK_PATH,
baseUrl: envWithDefaults.GHE_HOST
? `${envWithDefaults.GHE_PROTOCOL || "https"}://${
envWithDefaults.GHE_HOST
@ -60,15 +63,13 @@ export function createProbot({
...overrides,
};
const logOptions: GetLogOptions = {
const log = getLog({
level: probotOptions.logLevel,
logFormat: envWithDefaults.LOG_FORMAT as PinoOptions["logFormat"],
logLevelInString: envWithDefaults.LOG_LEVEL_IN_STRING === "true",
logMessageKey: envWithDefaults.LOG_MESSAGE_KEY,
sentryDsn: envWithDefaults.SENTRY_DSN,
};
const log = getLog(logOptions).child({ name: "server" });
}).child({ name: "server" });
return new Probot({
log: log.child({ name: "probot" }),

View File

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

View File

@ -1,16 +1,16 @@
import type { Logger } from "pino";
import {
import type {
WebhookError,
EmitterWebhookEvent as WebhookEvent,
} from "@octokit/webhooks";
export function getErrorHandler(log: Logger) {
return (error: Error) => {
return (error: Error & { event?: WebhookEvent }) => {
const errors = (
error.name === "AggregateError" ? error : [error]
) as WebhookError[];
const event = (error as any).event as WebhookEvent;
const event = error.event;
for (const error of errors) {
const errMessage = (error.message || "").toLowerCase();
@ -18,7 +18,7 @@ export function getErrorHandler(log: Logger) {
if (errMessage.includes("x-hub-signature-256")) {
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."
"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;
}
@ -26,7 +26,7 @@ export function getErrorHandler(log: Logger) {
if (errMessage.includes("pem") || errMessage.includes("json web token")) {
log.error(
error,
"Your private key (a .pem file or PRIVATE_KEY environment variable) or APP_ID is incorrect. Go to https://github.com/settings/apps/YOUR_APP, verify that APP_ID is set correctly, and generate a new PEM file if necessary."
"Your private key (a .pem file or PRIVATE_KEY environment variable) or APP_ID is incorrect. Go to https://github.com/settings/apps/YOUR_APP, verify that APP_ID is set correctly, and generate a new PEM file if necessary.",
);
continue;
}
@ -34,7 +34,7 @@ export function getErrorHandler(log: Logger) {
log
.child({
name: "event",
id: event ? event.id : undefined,
id: event?.id,
})
.error(error);
}

View File

@ -14,8 +14,10 @@
* app.log.fatal("Goodbye, cruel world!");
* ```
*/
import pino, { Logger, LoggerOptions } from "pino";
import { getTransformStream, Options, LogLevel } from "@probot/pino";
import { pino } from "pino";
import type { Logger, LoggerOptions } from "pino";
import { getTransformStream, type Options, type LogLevel } from "@probot/pino";
import { rebindLog } from "./rebind-log";
export type GetLogOptions = {
level?: LogLevel;
@ -31,9 +33,7 @@ export function getLog(options: GetLogOptions = {}): Logger {
messageKey: logMessageKey || "msg",
};
const transform = getTransformStream(getTransformStreamOptions);
// @ts-ignore TODO: check out what's wrong here
transform.pipe(pino.destination(1));
const log = pino(pinoOptions, transform);
return log;
return rebindLog(pino(pinoOptions, transform));
}

View File

@ -0,0 +1,3 @@
export function isSupportedNodeVersion(nodeVersion = process.versions.node) {
return Number(nodeVersion.split(".", 10)[0]) >= 18;
}

View File

@ -0,0 +1,24 @@
import fs from "node:fs";
import path from "node:path";
import type { PackageJson } from "../types.js";
export function loadPackageJson(
filepath = path.join(process.cwd(), "package.json"),
): PackageJson {
let pkgContent;
try {
pkgContent = fs.readFileSync(filepath, "utf8");
} catch {
return {};
}
try {
const pkg = pkgContent && JSON.parse(pkgContent);
if (pkg && typeof pkg === "object") {
return pkg;
}
return {};
} catch {
return {};
}
}

17
src/helpers/rebind-log.ts Normal file
View File

@ -0,0 +1,17 @@
import type { Logger } from "pino";
const kIsBound = Symbol("is-bound");
export function rebindLog(log: Logger): Logger {
// @ts-ignore
if (log[kIsBound]) return log;
for (const key in log) {
// @ts-ignore
if (typeof log[key] !== "function") continue;
// @ts-ignore
log[key] = log[key].bind(log);
}
// @ts-ignore
log[kIsBound] = true;
return log;
}

View File

@ -1,19 +1,25 @@
import { sync } from "resolve";
import resolveModule from "resolve";
const defaultOptions: ResolveOptions = {};
export const resolveAppFunction = async (
appFnId: string,
opts?: ResolveOptions
opts?: ResolveOptions,
) => {
opts = opts || defaultOptions;
// These are mostly to ease testing
const basedir = opts.basedir || process.cwd();
const resolver: Resolver = opts.resolver || sync;
const resolver: Resolver = opts.resolver || resolveModule.sync;
const appFnPath = resolver(appFnId, { basedir });
const mod = await import(appFnPath);
// Note: This needs "esModuleInterop" to be set to "true" in "tsconfig.json"
return mod.default;
// On windows, an absolute path may start with a drive letter, e.g. C:/path/to/file.js
// This can be interpreted as a protocol, so ensure it's prefixed with file://
const appFnPathWithFileProtocol = appFnPath.replace(
/^([a-zA-Z]:)/,
"file://$1",
);
const { default: mod } = await import(appFnPathWithFileProtocol);
// mod.default gets exported by transpiled TypeScript code
return mod.__esModule && mod.default ? mod.default : mod;
};
export type Resolver = (appFnId: string, opts: { basedir: string }) => string;

View File

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

View File

@ -1,28 +1,154 @@
import { Logger } from "pino";
export type { Logger } from "pino";
import { Context } from "./context";
import {
export { Context } from "./context.js";
export { Probot } from "./probot.js";
export { Server } from "./server/server.js";
export { ProbotOctokit } from "./octokit/probot-octokit.js";
export { run } from "./run.js";
export { createNodeMiddleware } from "./create-node-middleware.js";
export { createProbot } from "./create-probot.js";
/** NOTE: exported types might change at any point in time */
export type {
Options,
ApplicationFunction,
ApplicationFunctionOptions,
} from "./types";
import { Probot } from "./probot";
import { Server } from "./server/server";
import { ProbotOctokit } from "./octokit/probot-octokit";
import { run } from "./run";
import { createNodeMiddleware } from "./create-node-middleware";
import { createProbot } from "./create-probot";
} from "./types.js";
export {
Logger,
Context,
ProbotOctokit,
run,
Probot,
Server,
createNodeMiddleware,
createProbot,
};
declare global {
namespace NodeJS {
interface ProcessEnv {
/**
* The App ID assigned to your GitHub App.
* @example '1234'
*/
APP_ID?: string;
/** NOTE: exported types might change at any point in time */
export { Options, ApplicationFunction, ApplicationFunctionOptions };
/**
* By default, logs are formatted for readability in development. You can
* set this to `json` in order to disable the formatting.
*/
LOG_FORMAT?: "json" | "pretty";
/**
* 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?:
| "trace"
| "debug"
| "info"
| "warn"
| "error"
| "fatal"
| "silent";
/**
* 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`
*/
LOG_LEVEL_IN_STRING?: "true" | "false";
/**
* Only relevant when `LOG_FORMAT` is set to `json`. Sets the json key for the log message.
* @default 'msg'
*/
LOG_MESSAGE_KEY?: string;
/**
* The organization where you want to register the app in the app
* creation manifest flow. If set, the app is registered for an
* organization
* (https://github.com/organizations/ORGANIZATION/settings/apps/new), if
* not set, the GitHub app would be registered for the user account
* (https://github.com/settings/apps/new).
*/
GH_ORG?: string;
/**
* The hostname of your GitHub Enterprise instance.
* @example github.mycompany.com
*/
GHE_HOST?: string;
/**
* The protocol of your GitHub Enterprise instance. Defaults to HTTPS.
* Do not change unless you are certain.
* @default 'https'
*/
GHE_PROTOCOL?: string;
/**
* 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 docs for
* provider specific usage.
*/
PRIVATE_KEY?: string;
/**
* When using the `PRIVATE_KEY_PATH` environment variable, set it to the
* path of the `.pem` file that you downloaded from your GitHub App registration.
* @example 'path/to/key.pem'
*/
PRIVATE_KEY_PATH?: string;
/**
* The port to start the local server on.
* @default '3000'
*/
PORT?: string;
/**
* The host to start the local server on.
*/
HOST?: string;
/**
* 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).
* @example 'redis://:secret@redis-123.redislabs.com:12345/0'
*/
REDIS_URL?: string;
/**
* Set to a [Sentry](https://sentry.io/) DSN to report all errors thrown
* by your app.
* @example 'https://1234abcd@sentry.io/12345'
*/
SENTRY_DSN?: string;
/**
* The URL path which will receive webhooks.
* @default '/api/github/webhooks'
*/
WEBHOOK_PATH?: string;
/**
* Allows your local development environment to receive GitHub webhook
* events. Go to https://smee.io/new to get started.
* @example 'https://smee.io/your-custom-url'
*/
WEBHOOK_PROXY_URL?: string;
/**
* **Required**
* 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.
*
* @example 'development'
* @default 'development'
*/
WEBHOOK_SECRET?: string;
NODE_ENV?: string;
}
}
}

View File

@ -1,73 +1,69 @@
import fs from "fs";
import fs from "node:fs";
import path from "node:path";
import yaml from "js-yaml";
import path from "path";
import updateDotenv from "update-dotenv";
import { ProbotOctokit } from "./octokit/probot-octokit";
import type { RequestParameters } from "@octokit/types";
import { ProbotOctokit } from "./octokit/probot-octokit.js";
import { loadPackageJson } from "./helpers/load-package-json.js";
import type { Env, Manifest, OctokitOptions, PackageJson } from "./types.js";
export class ManifestCreation {
get pkg() {
let pkg: any;
try {
pkg = require(path.join(process.cwd(), "package.json"));
} catch (e) {
pkg = {};
}
return pkg;
return loadPackageJson();
}
public async createWebhookChannel() {
public async createWebhookChannel(): Promise<string | undefined> {
try {
// tslint:disable:no-var-requires
const SmeeClient = require("smee-client");
const SmeeClient = (await import("smee-client")).default;
const WEBHOOK_PROXY_URL = await SmeeClient.createChannel();
await this.updateEnv({
WEBHOOK_PROXY_URL: await SmeeClient.createChannel(),
WEBHOOK_PROXY_URL,
});
return WEBHOOK_PROXY_URL;
} 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.");
return void 0;
}
}
public getManifest(pkg: any, baseUrl: any) {
let manifest: any = {};
public getManifest(pkg: PackageJson, baseUrl: string) {
let manifest: Partial<Manifest> = {};
try {
const file = fs.readFileSync(path.join(process.cwd(), "app.yml"), "utf8");
manifest = yaml.safeLoad(file);
manifest = yaml.load(file) as Manifest;
} catch (error) {
// App config does not exist, which is ok.
// @ts-ignore - in theory error can be anything
if (error.code !== "ENOENT") {
if ((error as Error & { code?: string }).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({
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;
}
public async createAppFromCode(code: any) {
const octokit = new ProbotOctokit();
const options: any = {
public async createAppFromCode(code: string, probotOptions?: OctokitOptions) {
const octokit = new ProbotOctokit(probotOptions);
const options: RequestParameters = {
...probotOptions,
code,
mediaType: {
previews: ["fury"], // needed for GHES 2.20 and older
@ -80,7 +76,7 @@ export class ManifestCreation {
};
const response = await octokit.request(
"POST /app-manifests/:code/conversions",
options
options,
);
const { id, client_id, client_secret, webhook_secret, pem } = response.data;
@ -95,7 +91,7 @@ export class ManifestCreation {
return response.data.html_url;
}
public async updateEnv(env: any) {
public async updateEnv(env: Env) {
// Needs to be public due to tests
return updateDotenv(env);
}
@ -103,7 +99,7 @@ export class ManifestCreation {
get createAppUrl() {
const githubHost = process.env.GHE_HOST || `github.com`;
return `${process.env.GHE_PROTOCOL || "https"}://${githubHost}${
process.env.GH_ORG ? "/organizations/".concat(process.env.GH_ORG) : ""
process.env.GH_ORG ? `/organizations/${process.env.GH_ORG}` : ""
}/settings/apps/new`;
}
}

View File

@ -1,18 +1,17 @@
import { State } from "../types";
import { ProbotOctokit } from "./probot-octokit";
import type { State } from "../types.js";
import type { ProbotOctokit } from "./probot-octokit.js";
import type { OctokitOptions } from "../types.js";
import type { LogFn, Level } from "pino";
type FactoryOptions = {
octokit: InstanceType<typeof ProbotOctokit>;
octokitOptions: ConstructorParameters<typeof ProbotOctokit> & {
throttle?: Record<string, unknown>;
auth?: Record<string, unknown>;
};
octokit: ProbotOctokit;
octokitOptions: OctokitOptions;
[key: string]: unknown;
};
export async function getAuthenticatedOctokit(
state: State,
installationId?: number
installationId?: number,
) {
const { log, octokit } = state;
@ -24,7 +23,9 @@ export async function getAuthenticatedOctokit(
factory: ({ octokit, octokitOptions, ...otherOptions }: FactoryOptions) => {
const pinoLog = log.child({ name: "github" });
const options = {
const options: ConstructorParameters<typeof ProbotOctokit>[0] & {
log: Record<Level, LogFn>;
} = {
...octokitOptions,
log: {
fatal: pinoLog.fatal.bind(pinoLog),
@ -34,10 +35,12 @@ export async function getAuthenticatedOctokit(
debug: pinoLog.debug.bind(pinoLog),
trace: pinoLog.trace.bind(pinoLog),
},
throttle: {
...octokitOptions.throttle,
id: installationId,
},
throttle: octokitOptions.throttle?.enabled
? {
...octokitOptions.throttle,
id: String(installationId),
}
: { enabled: false },
auth: {
...octokitOptions.auth,
otherOptions,
@ -49,5 +52,5 @@ export async function getAuthenticatedOctokit(
return new Octokit(options);
},
}) as Promise<InstanceType<typeof ProbotOctokit>>;
}) as Promise<ProbotOctokit>;
}

View File

@ -1,16 +1,38 @@
import Bottleneck from "bottleneck";
import Redis from "ioredis";
import { Logger } from "pino";
import { Redis, type RedisOptions } from "ioredis";
import type { Logger } from "pino";
import type { ThrottlingOptions } from "@octokit/plugin-throttling";
type Options = {
log: Logger;
redisConfig?: Redis.RedisOptions | string;
redisConfig?: RedisOptions | string;
};
export function getOctokitThrottleOptions(options: Options) {
let { log, redisConfig } = options;
if (!redisConfig) return;
const throttlingOptions: ThrottlingOptions = {
onRateLimit: (retryAfter, options: { [key: string]: any }) => {
log.warn(
`Request quota exhausted for request ${options.method} ${options.url}`,
);
// Retry twice after hitting a rate limit error, then give up
if (options.request.retryCount <= 2) {
log.info(`Retrying after ${retryAfter} seconds!`);
return true;
}
return false;
},
onSecondaryRateLimit: (_retryAfter, options: { [key: string]: any }) => {
// does not retry, only logs a warning
log.warn(
`Secondary quota detected for request ${options.method} ${options.url}`,
);
},
};
if (!redisConfig) return throttlingOptions;
const connection = new Bottleneck.IORedisConnection({
client: getRedisClient(options),
@ -19,12 +41,12 @@ export function getOctokitThrottleOptions(options: Options) {
log.error(Object.assign(error, { source: "bottleneck" }));
});
return {
Bottleneck,
connection,
};
throttlingOptions.Bottleneck = Bottleneck;
throttlingOptions.connection = connection;
return throttlingOptions;
}
function getRedisClient({ log, redisConfig }: Options): Redis.Redis | void {
if (redisConfig) return new Redis(redisConfig as Redis.RedisOptions);
function getRedisClient({ redisConfig }: Options): Redis | void {
if (redisConfig) return new Redis(redisConfig as RedisOptions);
}

View File

@ -1,11 +1,13 @@
import LRUCache from "lru-cache";
import { ProbotOctokit } from "./probot-octokit";
import Redis from "ioredis";
import type { LRUCache } from "lru-cache";
import { ProbotOctokit } from "./probot-octokit.js";
import type { RedisOptions } from "ioredis";
import { request } from "@octokit/request";
import { getOctokitThrottleOptions } from "./get-octokit-throttle-options";
import { aliasLog } from "../helpers/alias-log";
import { getOctokitThrottleOptions } from "./get-octokit-throttle-options.js";
import type { Logger } from "pino";
import type { RequestRequestOptions } from "@octokit/types";
import type { OctokitOptions } from "../types.js";
type Options = {
cache: LRUCache<number, string>;
@ -14,8 +16,10 @@ type Options = {
githubToken?: string;
appId?: number;
privateKey?: string;
redisConfig?: Redis.RedisOptions | string;
redisConfig?: RedisOptions | string;
webhookPath?: string;
baseUrl?: string;
request?: RequestRequestOptions;
};
/**
@ -32,11 +36,21 @@ export function getProbotOctokitWithDefaults(options: Options) {
const authOptions = options.githubToken
? {
token: options.githubToken,
request: request.defaults({
request: {
fetch: options.request?.fetch,
},
}),
}
: {
cache: options.cache,
appId: options.appId,
privateKey: options.privateKey,
request: request.defaults({
request: {
fetch: options.request?.fetch,
},
}),
};
const octokitThrottleOptions = getOctokitThrottleOptions({
@ -44,10 +58,10 @@ export function getProbotOctokitWithDefaults(options: Options) {
redisConfig: options.redisConfig,
});
let defaultOptions: any = {
let defaultOptions: Partial<OctokitOptions> = {
auth: authOptions,
log: options.log.child
? aliasLog(options.log.child({ name: "octokit" }))
? options.log.child({ name: "octokit" })
: options.log,
};
@ -59,7 +73,7 @@ export function getProbotOctokitWithDefaults(options: Options) {
defaultOptions.throttle = octokitThrottleOptions;
}
return options.Octokit.defaults((instanceOptions: any) => {
return options.Octokit.defaults((instanceOptions: OctokitOptions) => {
const options = Object.assign({}, defaultOptions, instanceOptions, {
auth: instanceOptions.auth
? Object.assign({}, defaultOptions.auth, instanceOptions.auth)
@ -70,7 +84,7 @@ export function getProbotOctokitWithDefaults(options: Options) {
options.throttle = Object.assign(
{},
defaultOptions.throttle,
instanceOptions.throttle
instanceOptions.throttle,
);
}

View File

@ -1,18 +1,13 @@
import { Webhooks } from "@octokit/webhooks";
import { State } from "../types";
import { getErrorHandler } from "../helpers/get-error-handler";
import { webhookTransform } from "./octokit-webhooks-transform";
// import { Context } from "../context";
import type { State } from "../types.js";
import { getErrorHandler } from "../helpers/get-error-handler.js";
import { webhookTransform } from "./octokit-webhooks-transform.js";
export function getWebhooks(state: State) {
// TODO: This should be webhooks = new Webhooks<Context>({...}) but fails with
// > The context of the event that was triggered, including the payload and
// helpers for extracting information can be passed to GitHub API calls
const webhooks = new Webhooks({
secret: state.webhooks.secret!,
transform: webhookTransform.bind(null, state),
transform: (hook) => webhookTransform(state, hook),
});
webhooks.onError(getErrorHandler(state.log));
return webhooks;

View File

@ -1,4 +1,3 @@
// tslint:disable-next-line
import type { Octokit } from "@octokit/core";
export function probotRequestLogging(octokit: Octokit) {
@ -20,7 +19,7 @@ export function probotRequestLogging(octokit: Octokit) {
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
// @ts-expect-error log.debug is a pino log method and accepts a fields object
octokit.log.debug(params.body || {}, msg);
});
}

View File

@ -1,7 +1,7 @@
import { EmitterWebhookEvent as WebhookEvent } from "@octokit/webhooks";
import type { EmitterWebhookEvent as WebhookEvent } from "@octokit/webhooks";
import { Context } from "../context";
import { State } from "../types";
import { Context } from "../context.js";
import type { State } from "../types.js";
/**
* Probot's transform option, which extends the `event` object that is passed

View File

@ -1,6 +1,6 @@
import { Octokit } from "@octokit/core";
import { enterpriseCompatibility } from "@octokit/plugin-enterprise-compatibility";
import { RequestOptions } from "@octokit/types";
import type { RequestOptions } from "@octokit/types";
import { paginateRest } from "@octokit/plugin-paginate-rest";
import { legacyRestEndpointMethods } from "@octokit/plugin-rest-endpoint-methods";
import { retry } from "@octokit/plugin-retry";
@ -8,29 +8,30 @@ import { throttling } from "@octokit/plugin-throttling";
import { config } from "@probot/octokit-plugin-config";
import { createProbotAuth } from "octokit-auth-probot";
import { probotRequestLogging } from "./octokit-plugin-probot-request-logging";
import { VERSION } from "../version";
import { probotRequestLogging } from "./octokit-plugin-probot-request-logging.js";
import { VERSION } from "../version.js";
const defaultOptions = {
authStrategy: createProbotAuth,
throttle: {
onAbuseLimit: (
enabled: true,
onSecondaryRateLimit: (
retryAfter: number,
options: RequestOptions,
octokit: Octokit
octokit: Octokit,
) => {
octokit.log.warn(
`Abuse limit hit with "${options.method} ${options.url}", retrying in ${retryAfter} seconds.`
`Secondary Rate limit hit with "${options.method} ${options.url}", retrying in ${retryAfter} seconds.`,
);
return true;
},
onRateLimit: (
retryAfter: number,
options: RequestOptions,
octokit: Octokit
octokit: Octokit,
) => {
octokit.log.warn(
`Rate limit hit with "${options.method} ${options.url}", retrying in ${retryAfter} seconds.`
`Rate limit hit with "${options.method} ${options.url}", retrying in ${retryAfter} seconds.`,
);
return true;
},
@ -45,14 +46,18 @@ export const ProbotOctokit = Octokit.plugin(
legacyRestEndpointMethods,
enterpriseCompatibility,
probotRequestLogging,
config
config,
).defaults((instanceOptions: any) => {
// merge throttle options deeply
const options = Object.assign({}, defaultOptions, instanceOptions, {
throttle: instanceOptions.throttle
? Object.assign({}, defaultOptions.throttle, instanceOptions.throttle)
: defaultOptions.throttle,
});
const options = {
...defaultOptions,
...instanceOptions,
...{
throttle: { ...defaultOptions.throttle, ...instanceOptions?.throttle },
},
};
return options;
});
export type ProbotOctokit = InstanceType<typeof ProbotOctokit>;

View File

@ -1,28 +1,28 @@
import LRUCache from "lru-cache";
import { Logger } from "pino";
import { EmitterWebhookEvent as WebhookEvent } from "@octokit/webhooks";
import { LRUCache } from "lru-cache";
import type { Logger } from "pino";
import type { EmitterWebhookEvent as WebhookEvent } from "@octokit/webhooks";
import { aliasLog } from "./helpers/alias-log";
import { auth } from "./auth";
import { getLog } from "./helpers/get-log";
import { getProbotOctokitWithDefaults } from "./octokit/get-probot-octokit-with-defaults";
import { getWebhooks } from "./octokit/get-webhooks";
import { ProbotOctokit } from "./octokit/probot-octokit";
import { VERSION } from "./version";
import {
import { auth } from "./auth.js";
import { getLog } from "./helpers/get-log.js";
import { getProbotOctokitWithDefaults } from "./octokit/get-probot-octokit-with-defaults.js";
import { getWebhooks } from "./octokit/get-webhooks.js";
import { ProbotOctokit } from "./octokit/probot-octokit.js";
import { VERSION } from "./version.js";
import type {
ApplicationFunction,
ApplicationFunctionOptions,
DeprecatedLogger,
Options,
ProbotWebhooks,
State,
} from "./types";
} from "./types.js";
import { defaultWebhooksPath } from "./server/server.js";
import { rebindLog } from "./helpers/rebind-log.js";
export type Constructor<T> = new (...args: any[]) => T;
export type Constructor<T = any> = new (...args: any[]) => T;
export class Probot {
static version = VERSION;
static defaults<S extends Constructor<any>>(this: S, defaults: Options) {
static defaults<S extends Constructor>(this: S, defaults: Options) {
const ProbotWithDefaults = class extends this {
constructor(...args: any[]) {
const options = args[0] || {};
@ -34,15 +34,16 @@ export class Probot {
}
public webhooks: ProbotWebhooks;
public log: DeprecatedLogger;
public webhookPath: string;
public log: Logger;
public version: String;
public on: ProbotWebhooks["on"];
public onAny: ProbotWebhooks["onAny"];
public onError: ProbotWebhooks["onError"];
public auth: (
installationId?: number,
log?: Logger
) => Promise<InstanceType<typeof ProbotOctokit>>;
log?: Logger,
) => Promise<ProbotOctokit>;
private state: State;
@ -52,14 +53,16 @@ export class Probot {
let level = options.logLevel;
const logMessageKey = options.logMessageKey;
this.log = aliasLog(options.log || getLog({ level, logMessageKey }));
this.log = options.log
? rebindLog(options.log)
: getLog({ level, logMessageKey });
// TODO: support redis backend for access token cache if `options.redisConfig`
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: 1000 * 60 * 59,
ttl: 1000 * 60 * 59,
});
const Octokit = getProbotOctokitWithDefaults({
@ -68,16 +71,25 @@ export class Probot {
appId: Number(options.appId),
privateKey: options.privateKey,
cache,
log: this.log,
log: rebindLog(this.log),
redisConfig: options.redisConfig,
baseUrl: options.baseUrl,
});
const octokit = new Octokit();
const octokitLogger = rebindLog(this.log.child({ name: "octokit" }));
const octokit = new Octokit({
request: options.request,
log: {
debug: octokitLogger.debug.bind(octokitLogger),
info: octokitLogger.info.bind(octokitLogger),
warn: octokitLogger.warn.bind(octokitLogger),
error: octokitLogger.error.bind(octokitLogger),
},
});
this.state = {
cache,
githubToken: options.githubToken,
log: this.log,
log: rebindLog(this.log),
Octokit,
octokit,
webhooks: {
@ -87,11 +99,14 @@ export class Probot {
privateKey: options.privateKey,
host: options.host,
port: options.port,
webhookPath: options.webhookPath || defaultWebhooksPath,
request: options.request,
};
this.auth = auth.bind(null, this.state);
this.webhooks = getWebhooks(this.state);
this.webhookPath = this.state.webhookPath;
this.on = this.webhooks.on;
this.onAny = this.webhooks.onAny;
@ -107,7 +122,7 @@ export class Probot {
public async load(
appFn: ApplicationFunction | ApplicationFunction[],
options: ApplicationFunctionOptions = {}
options: ApplicationFunctionOptions = {},
) {
if (Array.isArray(appFn)) {
for (const fn of appFn) {

View File

@ -1,18 +1,19 @@
import pkgConf from "pkg-conf";
import { ApplicationFunction, Options, ServerOptions } from "./types";
import { Probot } from "./index";
import { setupAppFactory } from "./apps/setup";
import { getLog, GetLogOptions } from "./helpers/get-log";
import { readCliOptions } from "./bin/read-cli-options";
import { readEnvOptions } from "./bin/read-env-options";
import { Server } from "./server/server";
import { defaultApp } from "./apps/default";
import { resolveAppFunction } from "./helpers/resolve-app-function";
import { isProduction } from "./helpers/is-production";
import type { ApplicationFunction, Options, ServerOptions } from "./types.js";
import { Probot } from "./index.js";
import { setupAppFactory } from "./apps/setup.js";
import { getLog } from "./helpers/get-log.js";
import { readCliOptions } from "./bin/read-cli-options.js";
import { readEnvOptions } from "./bin/read-env-options.js";
import { Server } from "./server/server.js";
import { defaultApp } from "./apps/default.js";
import { resolveAppFunction } from "./helpers/resolve-app-function.js";
import { isProduction } from "./helpers/is-production.js";
import { config as dotenvConfig } from "dotenv";
type AdditionalOptions = {
env: Record<string, string | undefined>;
env: NodeJS.ProcessEnv;
};
/**
@ -21,9 +22,9 @@ type AdditionalOptions = {
*/
export async function run(
appFnOrArgv: ApplicationFunction | string[],
additionalOptions?: AdditionalOptions
additionalOptions?: AdditionalOptions,
) {
require("dotenv").config();
dotenvConfig();
const envOptions = readEnvOptions(additionalOptions?.env);
const cliOptions = Array.isArray(appFnOrArgv)
@ -55,15 +56,13 @@ export async function run(
args,
} = { ...envOptions, ...cliOptions };
const logOptions: GetLogOptions = {
const log = getLog({
level,
logFormat,
logLevelInString,
logMessageKey,
sentryDsn,
};
const log = getLog(logOptions);
});
const probotOptions: Options = {
appId,
@ -90,12 +89,12 @@ export async function run(
if (!appId) {
throw new Error(
"App ID is missing, and is required to run in production mode. " +
"To resolve, ensure the APP_ID environment variable is set."
"To resolve, ensure the APP_ID environment variable is set.",
);
} else if (!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"
"To resolve, ensure either the PRIVATE_KEY or PRIVATE_KEY_PATH environment variable is set and contains a valid certificate",
);
}
}
@ -122,7 +121,7 @@ export async function run(
if (Array.isArray(appFnOrArgv)) {
const pkg = await pkgConf("probot");
const combinedApps: ApplicationFunction = async (app) => {
const combinedApps: ApplicationFunction = async (_app) => {
await server.load(defaultApp);
if (Array.isArray(pkg.apps)) {

View File

@ -1,19 +1,21 @@
import pinoHttp from "pino-http";
import type { Logger } from "pino";
import { v4 as uuidv4 } from "uuid";
import { randomUUID as uuidv4 } from "node:crypto";
export function getLoggingMiddleware(logger: Logger, options?: pinoHttp.Options) {
import { pinoHttp, startTime, type Options, type HttpLogger } from "pino-http";
import type { Logger } from "pino";
export function getLoggingMiddleware(
logger: Logger,
options?: Options,
): HttpLogger {
return pinoHttp({
...options,
logger: logger.child({ name: "http" }),
customSuccessMessage(res) {
const responseTime = Date.now() - res[pinoHttp.startTime];
// @ts-ignore
customSuccessMessage(_req, res) {
const responseTime = Date.now() - res[startTime];
return `${res.req.method} ${res.req.url} ${res.statusCode} - ${responseTime}ms`;
},
customErrorMessage(err, res) {
const responseTime = Date.now() - res[pinoHttp.startTime];
// @ts-ignore
customErrorMessage(_err, res) {
const responseTime = Date.now() - res[startTime];
return `${res.req.method} ${res.req.url} ${res.statusCode} - ${responseTime}ms`;
},
genReqId: (req) =>

View File

@ -1,20 +1,23 @@
import { Server as HttpServer } from "http";
import type { Server as HttpServer } from "node:http";
import { join } from "node:path";
import express, { Application, Router } from "express";
import { join } from "path";
import { Logger } from "pino";
import express, { Router, type Application } from "express";
import type { Logger } from "pino";
import { createNodeMiddleware as createWebhooksMiddleware } from "@octokit/webhooks";
import { getLog } from "../helpers/get-log";
import { getLoggingMiddleware } from "./logging-middleware";
import { createWebhookProxy } from "../helpers/webhook-proxy";
import { VERSION } from "../version";
import { ApplicationFunction, ServerOptions } from "../types";
import { Probot } from "../";
import { engine } from "express-handlebars";
import EventSource from "eventsource";
import { getLoggingMiddleware } from "./logging-middleware.js";
import { createWebhookProxy } from "../helpers/webhook-proxy.js";
import { VERSION } from "../version.js";
import type { ApplicationFunction, ServerOptions } from "../types.js";
import type { Probot } from "../index.js";
import type EventSource from "eventsource";
import { rebindLog } from "../helpers/rebind-log.js";
// the default path as defined in @octokit/webhooks
export const defaultWebhooksPath = "/api/github/webhooks";
type State = {
cwd?: string;
httpServer?: HttpServer;
port?: number;
host?: string;
@ -35,69 +38,66 @@ export class Server {
constructor(options: ServerOptions = {} as ServerOptions) {
this.expressApp = express();
this.log = options.log || getLog().child({ name: "server" });
this.probotApp = new options.Probot();
this.probotApp = new options.Probot({
request: options.request,
});
this.log = options.log
? rebindLog(options.log)
: rebindLog(this.probotApp.log.child({ name: "server" }));
this.state = {
cwd: options.cwd || process.cwd(),
port: options.port,
host: options.host,
webhookPath: options.webhookPath || "/",
webhookPath: options.webhookPath || defaultWebhooksPath,
webhookProxy: options.webhookProxy,
};
this.expressApp.use(getLoggingMiddleware(this.log, options.loggingOptions));
this.expressApp.use(
"/probot/static/",
express.static(join(__dirname, "..", "..", "static"))
express.static(join(__dirname, "..", "..", "static")),
);
this.expressApp.use(
this.state.webhookPath,
createWebhooksMiddleware(this.probotApp.webhooks, {
path: "/",
})
path: this.state.webhookPath,
}),
);
this.expressApp.engine(
"handlebars",
engine({
defaultLayout: false,
})
);
this.expressApp.set("view engine", "handlebars");
this.expressApp.set("views", join(__dirname, "..", "..", "views"));
this.expressApp.get("/ping", (req, res) => res.end("PONG"));
this.expressApp.get("/ping", (_req, res) => res.end("PONG"));
}
public async load(appFn: ApplicationFunction) {
await appFn(this.probotApp, {
cwd: this.state.cwd,
getRouter: (path) => this.router(path),
});
}
public async start() {
this.log.info(
`Running Probot v${this.version} (Node.js: ${process.version})`
`Running Probot v${this.version} (Node.js: ${process.version})`,
);
const port = this.state.port || 3000;
const { host, webhookPath, webhookProxy } = this.state;
const printableHost = host ?? "localhost";
this.state.httpServer = (await new Promise((resolve, reject) => {
this.state.httpServer = await new Promise((resolve, reject) => {
const server = this.expressApp.listen(
port,
...((host ? [host] : []) as any),
() => {
async () => {
if (webhookProxy) {
this.state.eventSource = createWebhookProxy({
this.state.eventSource = await createWebhookProxy({
logger: this.log,
path: webhookPath,
port: port,
url: webhookProxy,
}) as EventSource;
});
}
this.log.info(`Listening on http://${printableHost}:${port}`);
resolve(server);
}
},
);
server.on("error", (error: NodeJS.ErrnoException) => {
@ -110,7 +110,7 @@ export class Server {
this.log.error(error);
reject(error);
});
})) as HttpServer;
});
return this.state.httpServer;
}

View File

@ -1,17 +1,18 @@
import express from "express";
import {
import type {
EmitterWebhookEvent as WebhookEvent,
Webhooks,
} from "@octokit/webhooks";
import LRUCache from "lru-cache";
import Redis from "ioredis";
import { Options as LoggingOptions } from "pino-http";
import type { LRUCache } from "lru-cache";
import type { RedisOptions } from "ioredis";
import type { Options as LoggingOptions } from "pino-http";
import { Probot } from "./index";
import { Context } from "./context";
import { ProbotOctokit } from "./octokit/probot-octokit";
import { Probot } from "./index.js";
import { Context } from "./context.js";
import { ProbotOctokit } from "./octokit/probot-octokit.js";
import type { Logger, LogFn } from "pino";
import type { Logger } from "pino";
import type { RequestRequestOptions } from "@octokit/types";
export interface Options {
privateKey?: string;
@ -20,13 +21,15 @@ export interface Options {
Octokit?: typeof ProbotOctokit;
log?: Logger;
redisConfig?: Redis.RedisOptions | string;
redisConfig?: RedisOptions | string;
secret?: string;
logLevel?: "trace" | "debug" | "info" | "warn" | "error" | "fatal";
logMessageKey?: string;
port?: number;
host?: string;
baseUrl?: string;
request?: RequestRequestOptions;
webhookPath?: string;
}
export type State = {
@ -35,7 +38,7 @@ export type State = {
githubToken?: string;
log: Logger;
Octokit: typeof ProbotOctokit;
octokit: InstanceType<typeof ProbotOctokit>;
octokit: ProbotOctokit;
cache?: LRUCache<number, string>;
webhooks: {
secret?: string;
@ -43,23 +46,31 @@ export type State = {
port?: number;
host?: string;
baseUrl?: string;
webhookPath: string;
request?: RequestRequestOptions;
};
// Omit the `payload`, `id`,`name` properties from the `Context` class as they are already present in the types of `WebhookEvent`
// The `Webhooks` class accepts a type parameter (`TTransformed`) that is used to transform the event payload in the form of
// WebhookEvent["payload"] & T
// Simply passing `Context` as `TTransformed` would result in the payload types being too complex for TypeScript to infer
// See https://github.com/probot/probot/issues/1388
// See https://github.com/probot/probot/issues/1815 as for why this is in a seperate type, and not directly passed to `Webhooks`
type SimplifiedObject = Omit<Context, keyof WebhookEvent>;
export type ProbotWebhooks = Webhooks<SimplifiedObject>;
export type DeprecatedLogger = LogFn & Logger;
export type ApplicationFunctionOptions = {
getRouter?: (path?: string) => express.Router;
cwd?: string;
[key: string]: unknown;
};
export type ApplicationFunction = (
app: Probot,
options: ApplicationFunctionOptions
options: ApplicationFunctionOptions,
) => void | Promise<void>;
export type ServerOptions = {
cwd?: string;
log?: Logger;
port?: number;
host?: string;
@ -67,6 +78,7 @@ export type ServerOptions = {
webhookProxy?: string;
Probot: typeof Probot;
loggingOptions?: LoggingOptions;
request?: RequestRequestOptions;
};
export type MiddlewareOptions = {
@ -74,3 +86,99 @@ export type MiddlewareOptions = {
webhooksPath?: string;
[key: string]: unknown;
};
export type OctokitOptions = NonNullable<
ConstructorParameters<typeof ProbotOctokit>[0]
>;
export type PackageJson = {
name?: string;
version?: string;
description?: string;
homepage?: string;
repository?: string;
engines?: {
[key: string]: string;
};
};
export type Env = Record<Uppercase<string>, string>;
type ManifestPermissionValue = "read" | "write" | "none";
type ManifestPermissionScope =
| "actions"
| "checks"
| "contents"
| "deployments"
| "id-token"
| "issues"
| "discussions"
| "packages"
| "pages"
| "pull-requests"
| "repository-projects"
| "security-events"
| "statuses";
export type Manifest = {
/**
* The name of the GitHub App.
*/
name?: string;
/**
* __Required.__ The homepage of your GitHub App.
*/
url: string;
/**
* The configuration of the GitHub App's webhook.
*/
hook_attributes?: {
/*
* __Required.__ The URL of the server that will receive the webhook POST requests.
*/
url: string;
/*
* Deliver event details when this hook is triggered, defaults to true.
*/
active?: boolean;
};
/**
* The full URL to redirect to after a user initiates the registration of a GitHub App from a manifest.
*/
redirect_url?: string;
/**
* A full URL to redirect to after someone authorizes an installation. You can provide up to 10 callback URLs.
*/
callback_urls?: string[];
/**
* A full URL to redirect users to after they install your GitHub App if additional setup is required.
*/
setup_url?: string;
/**
* A description of the GitHub App.
*/
description?: string;
/**
* Set to `true` when your GitHub App is available to the public or `false` when it is only accessible to the owner of the app.
*/
public?: boolean;
/**
* The list of events the GitHub App subscribes to.
*/
default_events?: WebhookEvent[];
/**
* The set of permissions needed by the GitHub App. The format of the object uses the permission name for the key (for example, `issues`) and the access type for the value (for example, `write`).
*/
default_permissions?:
| "read-all"
| "write-all"
| Record<ManifestPermissionScope, ManifestPermissionValue>;
/**
* Set to `true` to request the user to authorize the GitHub App, after the GitHub App is installed.
*/
request_oauth_on_install?: boolean;
/**
* Set to `true` to redirect users to the setup_url after they update your GitHub App installation.
*/
setup_on_update?: boolean;
};

View File

@ -1,11 +1,20 @@
<!DOCTYPE html>
export function importView({
name,
GH_HOST,
WEBHOOK_PROXY_URL = "",
}: {
name?: string;
GH_HOST: string;
WEBHOOK_PROXY_URL?: string;
}): string {
return `<!DOCTYPE html>
<html lang="en" class="height-full" data-color-mode="auto" data-light-theme="light" data-dark-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Import {{#if pkg.name }}{{ pkg.name }}{{else}}Your App{{/if}} | built with Probot</title>
<title>Import ${name || "Your App"} | built with Probot</title>
<link rel="icon" href="/probot/static/probot-head.png">
<link rel="stylesheet" href="/probot/static/primer.css">
</head>
@ -20,9 +29,9 @@
<h3>Step 1:</h3>
<p class="d-block mt-2">
Replace your app's Webhook URL with <br>
<b>{{ WEBHOOK_PROXY_URL }}</b>
<b>${WEBHOOK_PROXY_URL}</b>
</p>
<a class="d-block mt-2" href="{{ GH_HOST }}/settings/apps" target="__blank" rel="noreferrer">
<a class="d-block mt-2" href="${GH_HOST}/settings/apps" target="__blank" rel="noreferrer">
You can do it here
</a>
@ -49,7 +58,7 @@
<h4 class="alt-h4 text-gray-light">Need help?</h4>
<div class="d-flex flex-justify-center mt-2">
<a href="https://probot.github.io/docs/" class="btn btn-outline mr-2">Documentation</a>
<a href="https://probot-slackin.herokuapp.com/" class="btn btn-outline">Chat on Slack</a>
<a href="https://github.com/probot/probot/discussions" class="btn btn-outline">Discuss on GitHub</a>
</div>
</div>
</div>
@ -81,4 +90,5 @@
</script>
</body>
</html>
</html>`;
}

View File

@ -1,10 +1,19 @@
<!DOCTYPE html>
export function probotView({
name,
description,
version,
}: {
name?: string;
description?: string;
version?: string;
}): string {
return `<!DOCTYPE html>
<html lang="en" class="height-full" data-color-mode="auto" data-light-theme="light" data-dark-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>{{#if name }}{{ name }}{{else}}Your App{{/if}} | built with Probot</title>
<title>${name || "Your App"} | built with Probot</title>
<link rel="icon" href="/probot/static/probot-head.png">
<link rel="stylesheet" href="/probot/static/primer.css">
</head>
@ -13,26 +22,28 @@
<img src="/probot/static/robot.svg" alt="Probot Logo" width="100" class="mb-6">
<div class="box-shadow rounded-2 border p-6 bg-white">
<h1>
Welcome to {{#if name }}{{ name }}{{else}}your Probot App{{/if}}
{{#if version}}
<span class="Label Label--outline v-align-middle ml-2 text-gray-light">v{{ version }}</span>
{{/if}}
</h1>
Welcome to ${name || "your Probot App"}
${
version
? ` <span class="Label Label--outline v-align-middle ml-2 text-gray-light">v${version}</span>\n`
: ""
} </h1>
{{#if description }}
<p>{{ description }}</p>
{{else}}
<p>This bot was built using <a href="https://github.com/probot/probot">Probot</a>, a framework for building GitHub Apps.</p>
{{/if}}
<p>${
description
? description
: 'This bot was built using <a href="https://github.com/probot/probot">Probot</a>, a framework for building GitHub Apps.'
}</p>
</div>
<div class="mt-4">
<h4 class="alt-h4 text-gray-light">Need help?</h4>
<div class="d-flex flex-justify-center mt-2">
<a href="https://probot.github.io/docs/" class="btn btn-outline mr-2">Documentation</a>
<a href="https://probot-slackin.herokuapp.com/" class="btn btn-outline">Chat on Slack</a>
<a href="https://github.com/probot/probot/discussions" class="btn btn-outline">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>
</html>`;
}

View File

@ -1,10 +1,24 @@
export function setupView({
name,
description,
version,
createAppUrl,
manifest,
}: {
name?: string;
description?: string;
version?: string;
createAppUrl: string;
manifest: string;
}): string {
return `<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en" class="height-full" data-color-mode="auto" data-light-theme="light" data-dark-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Setup {{#if pkg.name }}{{ pkg.name }}{{else}}Your App{{/if}} | built with Probot</title>
<title>Setup ${name || "Your App"} | built with Probot</title>
<link rel="icon" href="/probot/static/probot-head.png">
<link rel="stylesheet" href="/probot/static/primer.css">
</head>
@ -13,17 +27,19 @@
<img src="/probot/static/robot.svg" alt="Probot Logo" width="100" class="mb-6">
<div class="box-shadow rounded-2 border p-6 bg-white">
<h1>
Welcome to {{#if pkg.name }}{{ pkg.name }}{{else}}your Probot App{{/if}}
{{#if version}}
<span class="Label Label--outline v-align-middle ml-2 text-gray-light">v{{ pkg.version }}</span>
{{/if}}
Welcome to ${name || "your Probot App"}
${
version
? `<span class="Label Label--outline v-align-middle ml-2 text-gray-light">v${version}</span>`
: ""
}
</h1>
{{#if pkg.description }}
<p>{{ pkg.description }}</p>
{{else}}
<p>This app was built using <a href="https://github.com/probot/probot">Probot</a>, a framework for building GitHub Apps.</p>
{{/if}}
<p>${
description
? description
: 'This app was built using <a href="https://github.com/probot/probot">Probot</a>, a framework for building GitHub Apps.'
}</p>
<div class="text-left mt-6">
<h2 class="alt-h3 mb-2">Getting Started</h2>
@ -31,8 +47,8 @@
<p>To start building a GitHub App, you'll need to register a new app on GitHub.</p>
<br>
<form action="{{ createAppUrl }}" method="post" target="_blank" class="d-flex flex-items-center">
<button class="btn btn-outline" name="manifest" id="manifest" value='{{ manifest }}' >Register GitHub App</button>
<form action="${createAppUrl}" method="post" target="_blank" class="d-flex flex-items-center">
<button class="btn btn-outline" name="manifest" id="manifest" value='${manifest}' >Register GitHub App</button>
<a href="/probot/import" class="ml-2">or use an existing Github App</a>
</form>
</div>
@ -42,9 +58,10 @@
<h4 class="alt-h4 text-gray-light">Need help?</h4>
<div class="d-flex flex-justify-center mt-2">
<a href="https://probot.github.io/docs/" class="btn btn-outline mr-2">Documentation</a>
<a href="https://probot-slackin.herokuapp.com/" class="btn btn-outline">Chat on Slack</a>
<a href="https://github.com/probot/probot/discussions" class="btn btn-outline">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>
</html>`;
}

View File

@ -1,10 +1,11 @@
<!DOCTYPE html>
export function successView({ name }: { name?: string }): string {
return `<!DOCTYPE html>
<html lang="en" class="height-full" data-color-mode="auto" data-light-theme="light" data-dark-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Setup {{#if pkg.name }}{{ pkg.name }}{{else}}Your App{{/if}} | built with Probot</title>
<title>Setup ${name || "Your App"} | built with Probot</title>
<link rel="icon" href="/probot/static/probot-head.png">
<link rel="stylesheet" href="/probot/static/primer.css">
</head>
@ -23,9 +24,10 @@
<h4 class="alt-h4 text-gray-light">Need help?</h4>
<div class="d-flex flex-justify-center mt-2">
<a href="https://probot.github.io/docs/" class="btn btn-outline mr-2">Documentation</a>
<a href="https://probot-slackin.herokuapp.com/" class="btn btn-outline">Chat on Slack</a>
<a href="https://github.com/probot/probot/discussions" class="btn btn-outline">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>
</html>`;
}

View File

@ -1,11 +1,11 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Probot webhooks responds with the correct error if the PEM file is missing 1`] = `"Your private key (a .pem file or PRIVATE_KEY environment variable) or APP_ID is incorrect. Go to https://github.com/settings/apps/YOUR_APP, verify that APP_ID is set correctly, and generate a new PEM file if necessary."`;
exports[`Probot > webhooks > responds with the correct error if the PEM file is missing 1`] = `"Your private key (a .pem file or PRIVATE_KEY environment variable) or APP_ID is incorrect. Go to https://github.com/settings/apps/YOUR_APP, verify that APP_ID is set correctly, and generate a new PEM file if necessary."`;
exports[`Probot webhooks responds with the correct error if the jwt could not be decoded 1`] = `"Your private key (a .pem file or PRIVATE_KEY environment variable) or APP_ID is incorrect. Go to https://github.com/settings/apps/YOUR_APP, verify that APP_ID is set correctly, and generate a new PEM file if necessary."`;
exports[`Probot > webhooks > responds with the correct error if the jwt could not be decoded 1`] = `"Your private key (a .pem file or PRIVATE_KEY environment variable) or APP_ID is incorrect. Go to https://github.com/settings/apps/YOUR_APP, verify that APP_ID is set correctly, and generate a new PEM file if necessary."`;
exports[`Probot webhooks 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 > webhooks > 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 webhooks 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 > webhooks > 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 webhooks 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."`;
exports[`Probot > webhooks > responds with the correct error if webhook secret is wrong 1`] = `"Go to https://github.com/settings/apps/YOUR_APP and verify that the Webhook secret matches the value of the WEBHOOK_SECRET environment variable."`;

View File

@ -0,0 +1,70 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`default app > GET /probot > get info from package.json > returns the correct HTML with values 1`] = `
"<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>probot | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"height-full bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full\\">
<img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\">
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<h1>
Welcome to probot
<span class=\\"Label Label--outline v-align-middle ml-2 text-gray-light\\">v0.0.0-development</span>
</h1>
<p>A framework for building GitHub Apps to automate and improve your workflow</p>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>"
`;
exports[`default app > GET /probot > get info from package.json > returns the correct HTML without values 1`] = `
"<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>Your App | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"height-full bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full\\">
<img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\">
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<h1>
Welcome to your Probot App
</h1>
<p>This bot was built using <a href=\\"https://github.com/probot/probot\\">Probot</a>, a framework for building GitHub Apps.</p>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>"
`;

View File

@ -1,14 +1,108 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`Setup app GET /probot/setup returns a redirect 1`] = `
Array [
Array [
Object {
exports[`Setup app > GET /probot/import > renders importView 1`] = `
"<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>Import probot | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full py-6\\">
<a href=\\"/probot\\"><img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\"></a>
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<h2>Use existing Github App</h2>
<br>
<h3>Step 1:</h3>
<p class=\\"d-block mt-2\\">
Replace your app's Webhook URL with <br>
<b></b>
</p>
<a class=\\"d-block mt-2\\" href=\\"https://github.com/settings/apps\\" target=\\"__blank\\" rel=\\"noreferrer\\">
You can do it here
</a>
<br>
<h3>Step 2:</h3>
<p class=\\"mt-2\\">Fill out this form</p>
<form onsubmit=\\"return onSubmit(event) || false\\">
<label class=\\"d-block mt-2\\" for=\\"appId\\">App Id</label>
<input class=\\"form-control width-full\\" type=\\"text\\" required=\\"true\\" id=\\"appId\\" name=\\"appId\\"><br>
<label class=\\"d-block mt-3\\" for=\\"whs\\">Webhook secret (required!)</label>
<input class=\\"form-control width-full\\" type=\\"password\\" required=\\"true\\" id=\\"whs\\" name=\\"whs\\"><br>
<label class=\\"d-block mt-3\\" for=\\"pem\\">Private Key</label>
<input class=\\"form-control width-full m-2\\" type=\\"file\\" accept=\\".pem\\" required=\\"true\\" id=\\"pem\\"
name=\\"pem\\">
<br>
<button class=\\"btn btn-outline m-2\\" type=\\"submit\\">Submit</button>
</form>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
<script>
function onSubmit(e) {
e.preventDefault();
const idEl = document.getElementById('appId');
const appId = idEl.value;
const secretEl = document.getElementById('whs');
const webhook_secret = secretEl.value;
const fileEl = document.getElementById('pem');
const file = fileEl.files[0];
file.text().then((text) => fetch('', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ appId, pem: text, webhook_secret })
})).then((r) => {
if (r.ok) {
location.replace('/probot/success');
}
}).catch((e) => alert(e));
return false;
}
</script>
</body>
</html>"
`;
exports[`Setup app > GET /probot/setup > returns a redirect 1`] = `"Found. Redirecting to /apps/my-app/installations/new"`;
exports[`Setup app > GET /probot/setup > returns a redirect 2`] = `
[
[
{
"WEBHOOK_PROXY_URL": "mocked proxy URL",
},
],
Array [
Object {
[
{
"WEBHOOK_PROXY_URL": "mocked proxy URL",
},
],
[
{
"APP_ID": "id",
"GITHUB_CLIENT_ID": "Iv1.8a61f9b3a7aba766",
"GITHUB_CLIENT_SECRET": "1726be1638095a19edd134c77bde3aa2ece1e5d8",
@ -19,15 +113,55 @@ Array [
]
`;
exports[`Setup app POST /probot/import updates .env 1`] = `
Array [
Array [
Object {
exports[`Setup app > GET /probot/setup > throws a 400 Error if code is an empty string 1`] = `"code missing or invalid"`;
exports[`Setup app > GET /probot/setup > throws a 400 Error if code is not provided 1`] = `"code missing or invalid"`;
exports[`Setup app > GET /probot/success > returns a 200 response 1`] = `
"<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>Setup probot | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"height-full bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full\\">
<img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\">
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<div class=\\"text-center\\">
<h1 class=\\"alt-h3 mb-2\\">Congrats! You have successfully installed your app!
<br>
Checkout <a href=\\"https://probot.github.io/docs/webhooks/\\">Receiving webhooks</a> and <a href=\\"https://probot.github.io/docs/github-api/\\">Interacting with GitHub</a> to learn more!</h1>
</div>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>"
`;
exports[`Setup app > POST /probot/import > 400 when keys are missing 1`] = `"appId and/or pem and/or webhook_secret missing"`;
exports[`Setup app > POST /probot/import > updates .env 1`] = `
[
[
{
"WEBHOOK_PROXY_URL": "mocked proxy URL",
},
],
Array [
Object {
[
{
"APP_ID": "foo",
"PRIVATE_KEY": "\\"bar\\"",
"WEBHOOK_SECRET": "baz",

View File

@ -1,71 +1,70 @@
import Stream from "stream";
import Stream from "node:stream";
import pino from "pino";
import { pino } from "pino";
import request from "supertest";
import { describe, expect, it } from "vitest";
import { Probot, Server } from "../../src";
import { defaultApp } from "../../src/apps/default";
import { Probot, Server } from "../../src/index.js";
import { defaultApp } from "../../src/apps/default.js";
describe("default app", () => {
let server: Server;
let output: any;
let output = [];
const streamLogsToOutput = new Stream.Writable({ objectMode: true });
streamLogsToOutput._write = (object, encoding, done) => {
streamLogsToOutput._write = (object, _encoding, done) => {
output.push(JSON.parse(object));
done();
};
beforeEach(async () => {
async function instantiateServer(cwd = process.cwd()) {
output = [];
server = new Server({
const server = new Server({
Probot: Probot.defaults({
appId: 1,
privateKey: "private key",
}),
log: pino(streamLogsToOutput),
cwd,
});
await server.load(defaultApp);
});
return server;
}
describe("GET /probot", () => {
it("returns a 200 response", () => {
it("returns a 200 response", async () => {
const server = await instantiateServer();
return request(server.expressApp).get("/probot").expect(200);
});
describe("get info from package.json", () => {
let cwd: string;
beforeEach(() => {
cwd = process.cwd();
});
it("returns the correct HTML with values", async () => {
const server = await instantiateServer();
const actual = await request(server.expressApp)
.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+/);
expect(actual.text).toMatchSnapshot();
});
it("returns the correct HTML without values", async () => {
process.chdir(__dirname);
const server = await instantiateServer(__dirname);
const actual = await request(server.expressApp)
.get("/probot")
.expect(200);
expect(actual.text).toMatch("Welcome to your Probot App");
});
afterEach(() => {
process.chdir(cwd);
expect(actual.text).toMatchSnapshot();
});
});
});
// Redirect does not work because webhooks middleware is using root path
describe("GET /", () => {
it("redirects to /probot", () => {
return request(server.expressApp)
it("redirects to /probot", async () => {
const server = await instantiateServer(__dirname);
await request(server.expressApp)
.get("/")
.expect(302)
.expect("location", "/probot");

View File

@ -1,15 +1,26 @@
const createChannel = jest.fn().mockResolvedValue("mocked proxy URL");
const updateDotenv = jest.fn().mockResolvedValue({});
jest.mock("smee-client", () => ({ createChannel }));
jest.mock("update-dotenv", () => updateDotenv);
import { Stream } from "node:stream";
import nock from "nock";
import { Stream } from "stream";
import fetchMock from "fetch-mock";
import { pino } from "pino";
import request from "supertest";
import pino from "pino";
import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { Probot, Server } from "../../src";
import { setupAppFactory } from "../../src/apps/setup";
import { Probot, Server } from "../../src/index.js";
import { setupAppFactory } from "../../src/apps/setup.js";
const mocks = vi.hoisted(() => {
return {
createChannel: vi.fn().mockResolvedValue("mocked proxy URL"),
updateDotenv: vi.fn().mockResolvedValue({}),
};
});
vi.mock("smee-client", () => ({
default: { createChannel: mocks.createChannel },
createChannel: mocks.createChannel,
}));
vi.mock("update-dotenv", () => ({
default: mocks.updateDotenv,
}));
describe("Setup app", () => {
let server: Server;
@ -37,7 +48,7 @@ describe("Setup app", () => {
});
afterEach(async () => {
jest.clearAllMocks();
vi.clearAllMocks();
});
describe("logs", () => {
@ -91,31 +102,98 @@ describe("Setup app", () => {
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",
client_id: "Iv1.8a61f9b3a7aba766",
client_secret: "1726be1638095a19edd134c77bde3aa2ece1e5d8",
const fetch = fetchMock
.sandbox()
.postOnce("https://api.github.com/app-manifests/123/conversions", {
status: 201,
body: {
html_url: "/apps/my-app",
id: "id",
pem: "pem",
webhook_secret: "webhook_secret",
client_id: "Iv1.8a61f9b3a7aba766",
client_secret: "1726be1638095a19edd134c77bde3aa2ece1e5d8",
},
});
await request(server.expressApp)
const server = new Server({
Probot: Probot.defaults({
log: pino(streamLogsToOutput),
// workaround for https://github.com/probot/probot/issues/1512
appId: 1,
privateKey: "dummy value for setup, see #1512",
}),
log: pino(streamLogsToOutput),
request: {
fetch: async (url: string, options: { [key: string]: any }) => {
return fetch(url, options);
},
},
});
await server.load(setupAppFactory(undefined, undefined));
const setupResponse = await request(server.expressApp)
.get("/probot/setup")
.query({ code: "123" })
.expect(302)
.expect("Location", "/apps/my-app/installations/new");
expect(createChannel).toHaveBeenCalledTimes(1);
expect(updateDotenv.mock.calls).toMatchSnapshot();
expect(setupResponse.text).toMatchSnapshot();
expect(mocks.createChannel).toHaveBeenCalledTimes(2);
expect(mocks.updateDotenv.mock.calls).toMatchSnapshot();
});
it("throws a 400 Error if code is not provided", async () => {
const server = new Server({
Probot: Probot.defaults({
log: pino(streamLogsToOutput),
// workaround for https://github.com/probot/probot/issues/1512
appId: 1,
privateKey: "dummy value for setup, see #1512",
}),
log: pino(streamLogsToOutput),
});
await server.load(setupAppFactory(undefined, undefined));
const setupResponse = await request(server.expressApp)
.get("/probot/setup")
.expect(400);
expect(setupResponse.text).toMatchSnapshot();
});
it("throws a 400 Error if code is an empty string", async () => {
const server = new Server({
Probot: Probot.defaults({
log: pino(streamLogsToOutput),
// workaround for https://github.com/probot/probot/issues/1512
appId: 1,
privateKey: "dummy value for setup, see #1512",
}),
log: pino(streamLogsToOutput),
});
await server.load(setupAppFactory(undefined, undefined));
const setupResponse = await request(server.expressApp)
.get("/probot/setup")
.query({ code: "" })
.expect(400);
expect(setupResponse.text).toMatchSnapshot();
});
});
describe("GET /probot/import", () => {
it("renders import.handlebars", async () => {
await request(server.expressApp).get("/probot/import").expect(200);
it("renders importView", async () => {
const importView = await request(server.expressApp)
.get("/probot/import")
.expect(200);
expect(importView.text).toMatchSnapshot();
});
});
@ -134,7 +212,7 @@ describe("Setup app", () => {
.expect(200)
.expect("");
expect(updateDotenv.mock.calls).toMatchSnapshot();
expect(mocks.updateDotenv.mock.calls).toMatchSnapshot();
});
it("400 when keys are missing", async () => {
@ -144,19 +222,25 @@ describe("Setup app", () => {
webhook_secret: "baz",
});
await request(server.expressApp)
const importResponse = await request(server.expressApp)
.post("/probot/import")
.set("content-type", "application/json")
.send(body)
.expect(400);
expect(importResponse.text).toMatchSnapshot();
});
});
describe("GET /probot/success", () => {
it("returns a 200 response", async () => {
await request(server.expressApp).get("/probot/success").expect(200);
const successResponse = await request(server.expressApp)
.get("/probot/success")
.expect(200);
expect(createChannel).toHaveBeenCalledTimes(1);
expect(successResponse.text).toMatchSnapshot();
expect(mocks.createChannel).toHaveBeenCalledTimes(1);
});
});
});

View File

@ -0,0 +1,24 @@
import { describe, it, expect } from "vitest";
import { isSupportedNodeVersion } from "../../src/helpers/is-supported-node-version.js";
import { loadPackageJson } from "../../src/helpers/load-package-json.js";
describe("isSupportedNodeVersion", () => {
const { engines } = loadPackageJson();
it(`engines value is set to ">=18"`, () => {
expect(engines!.node).toBe(">=18");
});
it("returns true if node is bigger or equal v18", () => {
expect(isSupportedNodeVersion("18.0.0")).toBe(true);
expect(isSupportedNodeVersion("19.0.0")).toBe(true);
expect(isSupportedNodeVersion("20.0.0")).toBe(true);
expect(isSupportedNodeVersion("21.0.0")).toBe(true);
});
it("returns false if node is smaller than v18", () => {
expect(isSupportedNodeVersion("17.0.0")).toBe(false);
expect(isSupportedNodeVersion("17.9.0")).toBe(false);
expect(isSupportedNodeVersion("17.9.9")).toBe(false);
});
});

View File

@ -1,29 +1,31 @@
import fs = require("fs");
import path = require("path");
import fs from "node:fs";
import path from "node:path";
import { EmitterWebhookEvent as WebhookEvent } from "@octokit/webhooks";
import WebhookExamples, { WebhookDefinition } from "@octokit/webhooks-examples";
import nock from "nock";
import type { EmitterWebhookEvent as WebhookEvent } from "@octokit/webhooks";
import WebhookExamples from "@octokit/webhooks-examples";
import type { WebhookDefinition } from "@octokit/webhooks-examples";
import fetchMock from "fetch-mock";
import { describe, expect, test, beforeEach, it, vi } from "vitest";
import { Context } from "../src";
import { ProbotOctokit } from "../src/octokit/probot-octokit";
import { PushEvent } from "@octokit/webhooks-types";
import { Context } from "../src/index.js";
import { ProbotOctokit } from "../src/octokit/probot-octokit.js";
import type { PushEvent } from "@octokit/webhooks-types";
const pushEventPayload = (
WebhookExamples.filter(
(event) => event.name === "push"
(WebhookExamples as unknown as WebhookDefinition[]).filter(
(event) => event.name === "push",
)[0] as WebhookDefinition<"push">
).examples[0];
const issuesEventPayload = (
WebhookExamples.filter(
(event) => event.name === "issues"
(WebhookExamples as unknown as WebhookDefinition[]).filter(
(event) => event.name === "issues",
)[0] as WebhookDefinition<"issues">
).examples[0];
const pullRequestEventPayload = (
WebhookExamples.filter(
(event) => event.name === "pull_request"
(WebhookExamples as unknown as WebhookDefinition[]).filter(
(event) => event.name === "pull_request",
)[0] as WebhookDefinition<"pull_request">
).examples[0];
).examples[0] as WebhookEvent<"pull_request">["payload"];
describe("Context", () => {
let event: WebhookEvent<"push"> = {
@ -34,7 +36,7 @@ describe("Context", () => {
let context: Context<"push"> = new Context<"push">(
event,
{} as any,
{} as any
{} as any,
);
it("inherits the payload", () => {
@ -97,8 +99,8 @@ describe("Context", () => {
try {
context.repo();
} catch (e) {
expect(e.message).toMatch(
"context.repo() is not supported for this webhook event."
expect((e as Error).message).toMatch(
"context.repo() is not supported for this webhook event.",
);
}
});
@ -179,15 +181,15 @@ describe("Context", () => {
owner: "muahaha",
pull_number: 5,
repo: "Hello-World",
}
},
);
});
});
describe("config", () => {
let octokit: InstanceType<typeof ProbotOctokit>;
let octokit: ProbotOctokit;
function nockConfigResponseDataFile(fileName: string) {
function getConfigFile(fileName: string) {
const configPath = path.join(__dirname, "fixtures", "config", fileName);
return fs.readFileSync(configPath, { encoding: "utf8" });
}
@ -202,9 +204,21 @@ describe("Context", () => {
});
it("gets a valid configuration", async () => {
const mock = nock("https://api.github.com")
.get("/repos/Codertocat/Hello-World/contents/.github%2Ftest-file.yml")
.reply(200, nockConfigResponseDataFile("basic.yml"));
const fetch = fetchMock
.sandbox()
.getOnce(
"https://api.github.com/repos/Codertocat/Hello-World/contents/.github%2Ftest-file.yml",
getConfigFile("basic.yml"),
);
const octokit = new ProbotOctokit({
retry: { enabled: false },
throttle: { enabled: false },
request: {
fetch,
},
});
const context = new Context(event, octokit, {} as any);
const config = await context.config("test-file.yml");
expect(config).toEqual({
@ -212,39 +226,67 @@ describe("Context", () => {
baz: 11,
foo: 5,
});
expect(mock.activeMocks()).toStrictEqual([]);
});
it("returns null when the file and base repository are missing", async () => {
const mock = nock("https://api.github.com")
.get("/repos/Codertocat/Hello-World/contents/.github%2Ftest-file.yml")
.reply(404)
.get("/repos/Codertocat/.github/contents/.github%2Ftest-file.yml")
.reply(404);
const NOT_FOUND_RESPONSE = {
status: 404,
body: {
message: "Not Found",
documentation_url:
"https://docs.github.com/rest/reference/repos#get-repository-content",
},
};
const fetch = fetchMock
.sandbox()
.getOnce(
"https://api.github.com/repos/Codertocat/Hello-World/contents/.github%2Ftest-file.yml",
NOT_FOUND_RESPONSE,
)
.getOnce(
"https://api.github.com/repos/Codertocat/.github/contents/.github%2Ftest-file.yml",
NOT_FOUND_RESPONSE,
);
const octokit = new ProbotOctokit({
retry: { enabled: false },
throttle: { enabled: false },
request: {
fetch,
},
});
const context = new Context(event, octokit, {} as any);
expect(await context.config("test-file.yml")).toBe(null);
expect(mock.activeMocks()).toStrictEqual([]);
});
it("accepts deepmerge options", async () => {
const mock = nock("https://api.github.com")
.get("/repos/Codertocat/Hello-World/contents/.github%2Ftest-file.yml")
.reply(
200,
"foo:\n - name: master\n shouldChange: changed\n_extends: .github"
const fetch = fetchMock
.sandbox()
.getOnce(
"https://api.github.com/repos/Codertocat/Hello-World/contents/.github%2Ftest-file.yml",
"foo:\n - name: master\n shouldChange: changed\n_extends: .github",
)
.get("/repos/Codertocat/.github/contents/.github%2Ftest-file.yml")
.reply(
200,
"foo:\n - name: develop\n - name: master\n shouldChange: should"
.getOnce(
"https://api.github.com/repos/Codertocat/.github/contents/.github%2Ftest-file.yml",
"foo:\n - name: develop\n - name: master\n shouldChange: should",
);
const customMerge = jest.fn(
(_target: any[], _source: any[], _options: any): any[] => []
const octokit = new ProbotOctokit({
retry: { enabled: false },
throttle: { enabled: false },
request: {
fetch,
},
});
const context = new Context(event, octokit, {} as any);
const customMerge = vi.fn(
(_target: any[], _source: any[], _options: any): any[] => [],
);
await context.config("test-file.yml", {}, { arrayMerge: customMerge });
expect(customMerge).toHaveBeenCalled();
expect(mock.activeMocks()).toStrictEqual([]);
});
});

View File

@ -1,31 +1,56 @@
import { createServer, IncomingMessage, ServerResponse } from "http";
import Stream from "stream";
import { createServer, IncomingMessage, ServerResponse } from "node:http";
import Stream from "node:stream";
import pino from "pino";
import { pino } from "pino";
import getPort from "get-port";
import got from "got";
import { sign } from "@octokit/webhooks-methods";
import { describe, expect, test, beforeEach } from "vitest";
import { createNodeMiddleware, createProbot, Probot } from "../src";
import { ApplicationFunction } from "../src/types";
import { createNodeMiddleware, createProbot, Probot } from "../src/index.js";
import type { ApplicationFunction } from "../src/types.js";
import WebhookExamples, {
type WebhookDefinition,
} from "@octokit/webhooks-examples";
const APP_ID = "1";
const PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY-----
MIIBOQIBAAJBAIILhiN9IFpaE0pUXsesuuoaj6eeDiAqCiE49WB1tMB8ZMhC37kY
Fl52NUYbUxb7JEf6pH5H9vqw1Wp69u78XeUCAwEAAQJAb88urnaXiXdmnIK71tuo
/TyHBKt9I6Rhfzz0o9Gv7coL7a537FVDvV5UCARXHJMF41tKwj+zlt9EEUw7a1HY
wQIhAL4F/VHWSPHeTgXYf4EaX2OlpSOk/n7lsFtL/6bWRzRVAiEArzJs2vopJitv
A1yBjz3q2nX+zthk+GLXrJQkYOnIk1ECIHfeFV8TWm5gej1LxZquBTA5pINoqDVq
NKZSuZEHqGEFAiB6EDrxkovq8SYGhIQsJeqkTMO8n94xhMRZlFmIQDokEQIgAq5U
r1UQNnUExRh7ZT0kFbMfO9jKYZVlQdCL9Dn93vo=
MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aozN3tihPmljnnudb9G3HECdnH
lWu2/a1gB9JW5TBQ+AVpum9Okx7KfqkfBKL9mcHgSL0yWMdjMfNOqNtrQqKlN4kE
p6RD++7sGbzbfZ9arwrlD/HSDAWGdGGJTSOBM6pHehyLmSC3DJoR/CTu0vTGTWXQ
rO64Z8tyXQPtVPb/YXrcUhbBp8i72b9Xky0fD6PkEebOy0Ip58XVAn2UPNlNOSPS
ye+Qjtius0Md4Nie4+X8kwVI2Qjk3dSm0sw/720KJkdVDmrayeljtKBx6AtNQsSX
gzQbeMmiqFFkwrG1+zx6E7H7jqIQ9B6bvWKXGwIDAQABAoIBAD8kBBPL6PPhAqUB
K1r1/gycfDkUCQRP4DbZHt+458JlFHm8QL6VstKzkrp8mYDRhffY0WJnYJL98tr4
4tohsDbqFGwmw2mIaHjl24LuWXyyP4xpAGDpl9IcusjXBxLQLp2m4AKXbWpzb0OL
Ulrfc1ZooPck2uz7xlMIZOtLlOPjLz2DuejVe24JcwwHzrQWKOfA11R/9e50DVse
hnSH/w46Q763y4I0E3BIoUMsolEKzh2ydAAyzkgabGQBUuamZotNfvJoDXeCi1LD
8yNCWyTlYpJZJDDXooBU5EAsCvhN1sSRoaXWrlMSDB7r/E+aQyKua4KONqvmoJuC
21vSKeECgYEA7yW6wBkVoNhgXnk8XSZv3W+Q0xtdVpidJeNGBWnczlZrummt4xw3
xs6zV+rGUDy59yDkKwBKjMMa42Mni7T9Fx8+EKUuhVK3PVQyajoyQqFwT1GORJNz
c/eYQ6VYOCSC8OyZmsBM2p+0D4FF2/abwSPMmy0NgyFLCUFVc3OECpkCgYEA5OAm
I3wt5s+clg18qS7BKR2DuOFWrzNVcHYXhjx8vOSWV033Oy3yvdUBAhu9A1LUqpwy
Ma+unIgxmvmUMQEdyHQMcgBsVs10dR/g2xGjMLcwj6kn+xr3JVIZnbRT50YuPhf+
ns1ScdhP6upo9I0/sRsIuN96Gb65JJx94gQ4k9MCgYBO5V6gA2aMQvZAFLUicgzT
u/vGea+oYv7tQfaW0J8E/6PYwwaX93Y7Q3QNXCoCzJX5fsNnoFf36mIThGHGiHY6
y5bZPPWFDI3hUMa1Hu/35XS85kYOP6sGJjf4kTLyirEcNKJUWH7CXY+00cwvTkOC
S4Iz64Aas8AilIhRZ1m3eQKBgQCUW1s9azQRxgeZGFrzC3R340LL530aCeta/6FW
CQVOJ9nv84DLYohTVqvVowdNDTb+9Epw/JDxtDJ7Y0YU0cVtdxPOHcocJgdUGHrX
ZcJjRIt8w8g/s4X6MhKasBYm9s3owALzCuJjGzUKcDHiO2DKu1xXAb0SzRcTzUCn
7daCswKBgQDOYPZ2JGmhibqKjjLFm0qzpcQ6RPvPK1/7g0NInmjPMebP0K6eSPx0
9/49J6WTD++EajN7FhktUSYxukdWaCocAQJTDNYP0K88G4rtC2IYy5JFn9SWz5oh
x//0u+zd/R/QRUzLOw4N72/Hu+UG6MNt5iDZFCtapRaKt6OvSBwy8w==
-----END RSA PRIVATE KEY-----`;
const WEBHOOK_SECRET = "secret";
const pushEvent = require("./fixtures/webhook/push.json");
const pushEvent = (
(WebhookExamples as unknown as WebhookDefinition[]).filter(
(event) => event.name === "push",
)[0] as WebhookDefinition<"push">
).examples[0];
describe("createNodeMiddleware", () => {
let output: any[];
const streamLogsToOutput = new Stream.Writable({ objectMode: true });
streamLogsToOutput._write = (object, encoding, done) => {
streamLogsToOutput._write = (object, _encoding, done) => {
output.push(JSON.parse(object));
done();
};
@ -61,7 +86,8 @@ describe("createNodeMiddleware", () => {
const body = JSON.stringify(pushEvent);
await got.post(`http://127.0.0.1:${port}`, {
await fetch(`http://127.0.0.1:${port}/api/github/webhooks`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-github-event": "push",
@ -76,6 +102,94 @@ describe("createNodeMiddleware", () => {
server.close();
});
test("with createProbot and setting the webhookPath via WEBHOOK_PATH to the root", async () => {
expect.assertions(1);
const app: ApplicationFunction = (app) => {
app.on("push", (event) => {
expect(event.name).toEqual("push");
});
};
const middleware = createNodeMiddleware(app, {
probot: createProbot({
overrides: {
log: pino(streamLogsToOutput),
},
env: {
APP_ID,
PRIVATE_KEY,
WEBHOOK_SECRET,
WEBHOOK_PATH: "/",
},
}),
});
const server = createServer(middleware);
const port = await getPort();
server.listen(port);
const body = JSON.stringify(pushEvent);
await fetch(`http://127.0.0.1:${port}`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-github-event": "push",
"x-github-delivery": "1",
"x-hub-signature-256": await sign("secret", body),
},
body,
});
await new Promise((resolve) => setTimeout(resolve, 100));
server.close();
});
test("with createProbot and setting the webhookPath to the root via the deprecated webhooksPath", async () => {
expect.assertions(1);
const app: ApplicationFunction = (app) => {
app.on("push", (event) => {
expect(event.name).toEqual("push");
});
};
const middleware = createNodeMiddleware(app, {
webhooksPath: "/",
probot: createProbot({
overrides: {
log: pino(streamLogsToOutput),
},
env: {
APP_ID,
PRIVATE_KEY,
WEBHOOK_SECRET,
},
}),
});
const server = createServer(middleware);
const port = await getPort();
server.listen(port);
const body = JSON.stringify(pushEvent);
await fetch(`http://127.0.0.1:${port}`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-github-event": "push",
"x-github-delivery": "1",
"x-hub-signature-256": await sign("secret", body),
},
body,
});
await new Promise((resolve) => setTimeout(resolve, 100));
server.close();
}, 1000);
test("loads app only once", async () => {
let counter = 0;
const appFn = () => {
@ -90,11 +204,11 @@ describe("createNodeMiddleware", () => {
middleware(
{} as IncomingMessage,
{ end() {}, writeHead() {} } as unknown as ServerResponse
{ end() {}, writeHead() {} } as unknown as ServerResponse,
);
middleware(
{} as IncomingMessage,
{ end() {}, writeHead() {} } as unknown as ServerResponse
{ end() {}, writeHead() {} } as unknown as ServerResponse,
);
expect(counter).toEqual(1);

View File

@ -1,20 +1,62 @@
import { createProbot, Probot } from "../src";
import { captureLogOutput } from "./helpers/capture-log-output";
import { createProbot, Probot } from "../src/index.js";
import { captureLogOutput } from "./helpers/capture-log-output.js";
import { describe, expect, test } from "vitest";
const env = {
APP_ID: "1",
PRIVATE_KEY: `-----BEGIN RSA PRIVATE KEY-----
MIIBOQIBAAJBAIILhiN9IFpaE0pUXsesuuoaj6eeDiAqCiE49WB1tMB8ZMhC37kY
Fl52NUYbUxb7JEf6pH5H9vqw1Wp69u78XeUCAwEAAQJAb88urnaXiXdmnIK71tuo
/TyHBKt9I6Rhfzz0o9Gv7coL7a537FVDvV5UCARXHJMF41tKwj+zlt9EEUw7a1HY
wQIhAL4F/VHWSPHeTgXYf4EaX2OlpSOk/n7lsFtL/6bWRzRVAiEArzJs2vopJitv
A1yBjz3q2nX+zthk+GLXrJQkYOnIk1ECIHfeFV8TWm5gej1LxZquBTA5pINoqDVq
NKZSuZEHqGEFAiB6EDrxkovq8SYGhIQsJeqkTMO8n94xhMRZlFmIQDokEQIgAq5U
r1UQNnUExRh7ZT0kFbMfO9jKYZVlQdCL9Dn93vo=
MIIJKAIBAAKCAgEAu0E+tR6wfOAJZ4lASzRUmvorCgbI5nQyvZl3WLu6ko2pcEnq
1t1/W/Yaovt9W8eMFVfoFXKhsHOAM5dFlktxOlcaUQiRYSO7fBbZYVNYoawnCRqD
HKQ1oKC6B23EKfW5NH8NLaI/+QJFG7fpr0P4HkHghLsOe7rIUDt7EjRsSSRhM2+Y
sFmRsnj0PWESWyI5exdKys0Mw25CmGsA27ltmebgHFYQ4ac+z0Esbjujcxec5wtn
oAMT6jIBjEPHYTy0Cbe/wDN0cZkg6QyNC09lMnUx8vP1gwAVP20VXfjdFHZ8cR80
ungLmBG0SWgVglqv52C5Gad2hEDsWyi28/XZ9/mNGatZJ1SSmA6+TSuSlrs/Dm0K
hjOx21SdPAii38fBs6xtMk8d8WhGqwUR0nAVDdm1H/03BJssuh78xL5/WEcDZ2Gn
QSQERNna/nP7uwbIXYORYLcPTY80RrYp6MCTrHydIArurGrtGW9f2RU2cP5+5SkV
qvSSU6NefYYw55XyVXrIfkTZXJ4UejlnpWZ+syXbYIRn/CNBPbQa6OY/ZBUgSDKW
xjiQQcr71ANeW41Od+k+TCiCkoK2fEPbtD/LXDXKZNTwzZqUA5ol//wOk+cDms9z
A+vbA8IWP6TBBqxVMe8z8D7AVytQTNHPBf/33tNfneWvuElHP9CG3q8/FYkCAwEA
AQKCAf9Punf4phh/EuTuMIIvgxiC5AFvQ3RGqzLvh2hJX6UQwUBjjxVuZuDTRvYQ
bwPxEAWVENjASQ6PEp6DWOVIGNcc//k0h3Fe6tfo/dGQnuwd6i60sZUhnMk4mzaZ
8yKSuw0gTPhPdcXHQDAsnSHifg4m0+XEneCMzfp8Ltc36RoyCktYmytn6rseQmG7
wJkQNIJE5qXxs1y72TaBrw2ugEUqQiMp7XtCmPMlS5qfVMVDO8qSlUiJ2MWh8ai3
ECTUQgRmHtaF/2KU+54HnFBxgFyWH1AlIbpnDKH/X3K5kDyReeGCSMcqnfJRzTf2
CVsfJX3ABm7JfYP4y6vXJH7BYOxs7YMBEiR0o/7mhcBNbj8reEy42hUIaomQQNRE
mw5iiHiCBE/P6Y46SFyddnwuwD9HVk9ojyz5A70OjZLEWBfRajLOqSEp8VO7aM7H
YEQ00Jj9nNAKkaRh3BP9zEuL3dtYF//myr1QHgDCg0lsKacmJOFxxJwzmiTgvXFd
y6ZajugDY//7kA4iXPmRY0/nIznyee9AiAUvf2kvJov/jL36HH8fFWFH+RVS3/+V
BGM5hlWdVyGr+y+PNU6wTz637Qg/23GhwuF0Wi6qie0jertuzPW0RkUlOzX5y2v2
p6mTTJxpOmXCPjq1UZQUz+KkUZuUVlWTRmL3l+133eh6jTr1AoIBAQDxKAXhDBxR
kvgIomBF0Q9lQGkIpT6yw+YsuzL9WiJM0ZvvnES/2XqwGSMOfNHnwsrrKXxxgLRY
vpN/5pEJK8TYrLyvargWW6qQptGqnkt4unb30EENgDT3SARKfhM9ytXaCn+JrJyI
4yN0qAVDOEkv1TqP0oIjMO5QVqVEYhO8CAyKdBiXc0XY7FYZTYMLbHs7tkqZFHF+
OgfEi6pnH61hFoCdPtskmjxlmPbwRP4K18J6rovlq3/KMbSw2NQEADFZUmaalcSa
nn9O+0MkzvrCcanDmA1ZgZkd/06izo76u7vUoHdMflWoOAwBYvjQJntN7wUzX/3z
QNiFg1HEDqtrAoIBAQDGx+EZz1RUI+6o+3Swy9yNQk4jGAueH1OXsdazn6lOpzBt
YvG7BxIbyMfJuRBrIN7q0FiyRFSChXgjenD3aq5r84DAegMDHHL6bnLQTfnuzvHL
oQ5TZ0i8a29V3FinamYEaFziZQuFs1nCPdnPd41GX3oaTvlYyfTc2J9UjxBtRIoA
vTViJ2NKxaklFMEBhRoUsqQXT4Jh3a6+3r9xpFkaQ/LYRp8XzWXJntqwhy6+Nvf1
B4CVYF9My3r4KGNa6UmnK7A92VqnkHuN4rAlDnu1Q0BZa5dy+vw+Kkxsg4qSoTAF
41tCI5aJd5t+THQMAJmrOG9Wzfwk83g3V0oTzJPbAoIBAEqKJW8PUD2CoPoCPqG1
4f1Y8F5EvWGCHcZbwoH+9zUpYPqqIbHvJfYCfwx+Vl89nX0coKNwtc3scikJenEM
P1b95YCPCwGWKd12Qr5rGUbi09z7WPA0XarFbtYbrBTgekNgFVXXrba+BnqLaL0D
S9PmI6jK14DLIg5hCcpeSl1HW6D8C5Hcho1rV52QkN3aFSk6ykoQwJfUlgwRY4Vm
jC/DRdPU1uW0atC4fDN+D8wILsu+4e0GmoRD4ub6zmXCLX6/col7m35zWURvc6yP
8YBio6eaex3cahiUjpjSIe2sU32Ab/+L2SwaztMq5V9pVZmcNM5RcGxc8dAq6/4e
zqsCggEBAKKevNHvos6fAs1twd4tOUa7Gs9tCXwXprxwOfSDRvBYqK6khpv6Qd9H
F+M4qmzp3FR/lEBq1DRfWpSzw501wnIAKLHOX455BLtKBlXRpQmwdXGgVeb3lTLI
NbIpbMGxsroiYvK3tYBw5JqbHQi0hngu/eZt+2Ge/tp5wYdc7xRlQP0vzW96R6nR
IPp8CxXiPR73snR7kG/d+uqdskMXL+nj8tTqmZbQa1hRxBkszpnAwIPN2mzaBbz+
rqA78mRae+3uOOWwXpC9C8dcz7vRKHV3CjrdYW4oVJnK4vDXgFNK2M3IXU0zbiES
H7xocXusNgs0RSnfpEraf9vOZoTiFYcCggEBANnrbN0StE2Beni2sWl9+9H2CbXS
s4sFJ1Q+KGN1QdKXHQ28qc8eYbifF/gA7TaSXmSiEXWinTQQKRSfZ/Xj573RZtaf
nvrsLNmx/Mxj/KPHKrlgep5aJ+JsHsb93PpHacG38OBgcWhzDf6PYLQilzY12jr7
vAWZUbyCsH+5FYuz0ahl6Cef8w6IrFpvk0hW2aQsoVWvgH727D+uM48DZ9mpVy9I
bHNB2yFIuUmmT92T7Pw28wJZ6Wd/3T+5s4CBe+FWplQcgquPGIFkq4dVxPpVg6uq
wB98bfAGtcuCZWzgjgL67CS0pcNxadFA/TFo/NnynLBC4qRXSfFslKVE+Og=
-----END RSA PRIVATE KEY-----`,
WEBHOOK_SECRET: "secret",
};
// tslint:disable:no-empty
describe("createProbot", () => {
test("createProbot()", () => {
const probot = createProbot({ env });
@ -41,6 +83,20 @@ describe("createProbot", () => {
expect(probot.log.level).toEqual("trace");
});
test("defaults, custom host", () => {
const probot = createProbot({
env: {
...env,
GHE_HOST: "github.acme-inc.com",
GHE_PROTOCOL: "https",
},
});
// @ts-expect-error This is private
expect(probot.state.octokit.request.endpoint.DEFAULTS.baseUrl).toEqual(
"https://github.acme-inc.com/api/v3",
);
});
test("env, overrides", () => {
const probot = createProbot({
env: {
@ -65,36 +121,34 @@ describe("createProbot", () => {
});
test("env, logger message key", async () => {
const outputData = await captureLogOutput(() => {
const probot = createProbot({
env: {
...env,
LOG_LEVEL: "info",
LOG_FORMAT: "json",
LOG_MESSAGE_KEY: "myMessage",
},
defaults: { logLevel: "trace" },
});
probot.log.info("Ciao");
const probot = createProbot({
env: {
...env,
LOG_LEVEL: "info",
LOG_FORMAT: "json",
LOG_MESSAGE_KEY: "myMessage",
},
defaults: { logLevel: "trace" },
});
const outputData = await captureLogOutput(() => {
probot.log.info("Ciao");
}, probot.log);
expect(JSON.parse(outputData).myMessage).toEqual("Ciao");
});
test("env, octokit logger set", async () => {
const probot = createProbot({
env: {
...env,
LOG_LEVEL: "info",
LOG_FORMAT: "json",
LOG_MESSAGE_KEY: "myMessage",
},
});
const outputData = await captureLogOutput(async () => {
const probot = createProbot({
env: {
...env,
LOG_LEVEL: "info",
LOG_FORMAT: "json",
LOG_MESSAGE_KEY: "myMessage",
},
});
const octokit = await probot.auth();
octokit.log.info("Ciao");
});
}, probot.log);
expect(JSON.parse(outputData)).toMatchObject({
myMessage: "Ciao",
name: "octokit",

View File

@ -1,3 +1,5 @@
import { describe, it } from "vitest";
describe("Deprecations", () => {
it("no deprecations exists at this point", () => {});
});

View File

@ -2,22 +2,20 @@ import execa from "execa";
import getPort from "get-port";
import { sign } from "@octokit/webhooks-methods";
import bodyParser from "body-parser";
import express from "express";
import got from "got";
jest.setTimeout(10000);
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
/**
* 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
* We also spawn a mock server which receives the Octokit requests from the app and uses vitest assertions
* to verify they are what we expect
*/
describe("end-to-end-tests", () => {
let server: any;
let probotProcess: any;
let probotProcess: execa.ExecaChildProcess<string> | null;
let probotPort: number;
let mockServerPort: number;
@ -34,7 +32,7 @@ describe("end-to-end-tests", () => {
it("hello-world app", async () => {
const app = express();
const httpMock = jest
const httpMock = vi
.fn()
.mockImplementationOnce((req, res) => {
expect(req.method).toEqual("POST");
@ -52,37 +50,39 @@ describe("end-to-end-tests", () => {
.mockImplementationOnce((req, res) => {
expect(req.method).toEqual("POST");
expect(req.path).toEqual(
"/repos/octocat/hello-world/issues/1/comments"
"/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(express.json());
app.use("/api/v3", httpMock);
server = app.listen(mockServerPort);
probotProcess = execa(
"bin/probot.js",
["run", "./test/e2e/hello-world.js"],
"node",
["bin/probot.js", "run", "./test/e2e/hello-world.cjs"],
{
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-----",
PRIVATE_KEY: `-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aozN3tihPmljnnudb9G3HECdnH\nlWu2/a1gB9JW5TBQ+AVpum9Okx7KfqkfBKL9mcHgSL0yWMdjMfNOqNtrQqKlN4kE\np6RD++7sGbzbfZ9arwrlD/HSDAWGdGGJTSOBM6pHehyLmSC3DJoR/CTu0vTGTWXQ\nrO64Z8tyXQPtVPb/YXrcUhbBp8i72b9Xky0fD6PkEebOy0Ip58XVAn2UPNlNOSPS\nye+Qjtius0Md4Nie4+X8kwVI2Qjk3dSm0sw/720KJkdVDmrayeljtKBx6AtNQsSX\ngzQbeMmiqFFkwrG1+zx6E7H7jqIQ9B6bvWKXGwIDAQABAoIBAD8kBBPL6PPhAqUB\nK1r1/gycfDkUCQRP4DbZHt+458JlFHm8QL6VstKzkrp8mYDRhffY0WJnYJL98tr4\n4tohsDbqFGwmw2mIaHjl24LuWXyyP4xpAGDpl9IcusjXBxLQLp2m4AKXbWpzb0OL\nUlrfc1ZooPck2uz7xlMIZOtLlOPjLz2DuejVe24JcwwHzrQWKOfA11R/9e50DVse\nhnSH/w46Q763y4I0E3BIoUMsolEKzh2ydAAyzkgabGQBUuamZotNfvJoDXeCi1LD\n8yNCWyTlYpJZJDDXooBU5EAsCvhN1sSRoaXWrlMSDB7r/E+aQyKua4KONqvmoJuC\n21vSKeECgYEA7yW6wBkVoNhgXnk8XSZv3W+Q0xtdVpidJeNGBWnczlZrummt4xw3\nxs6zV+rGUDy59yDkKwBKjMMa42Mni7T9Fx8+EKUuhVK3PVQyajoyQqFwT1GORJNz\nc/eYQ6VYOCSC8OyZmsBM2p+0D4FF2/abwSPMmy0NgyFLCUFVc3OECpkCgYEA5OAm\nI3wt5s+clg18qS7BKR2DuOFWrzNVcHYXhjx8vOSWV033Oy3yvdUBAhu9A1LUqpwy\nMa+unIgxmvmUMQEdyHQMcgBsVs10dR/g2xGjMLcwj6kn+xr3JVIZnbRT50YuPhf+\nns1ScdhP6upo9I0/sRsIuN96Gb65JJx94gQ4k9MCgYBO5V6gA2aMQvZAFLUicgzT\nu/vGea+oYv7tQfaW0J8E/6PYwwaX93Y7Q3QNXCoCzJX5fsNnoFf36mIThGHGiHY6\ny5bZPPWFDI3hUMa1Hu/35XS85kYOP6sGJjf4kTLyirEcNKJUWH7CXY+00cwvTkOC\nS4Iz64Aas8AilIhRZ1m3eQKBgQCUW1s9azQRxgeZGFrzC3R340LL530aCeta/6FW\nCQVOJ9nv84DLYohTVqvVowdNDTb+9Epw/JDxtDJ7Y0YU0cVtdxPOHcocJgdUGHrX\nZcJjRIt8w8g/s4X6MhKasBYm9s3owALzCuJjGzUKcDHiO2DKu1xXAb0SzRcTzUCn\n7daCswKBgQDOYPZ2JGmhibqKjjLFm0qzpcQ6RPvPK1/7g0NInmjPMebP0K6eSPx0\n9/49J6WTD++EajN7FhktUSYxukdWaCocAQJTDNYP0K88G4rtC2IYy5JFn9SWz5oh\nx//0u+zd/R/QRUzLOw4N72/Hu+UG6MNt5iDZFCtapRaKt6OvSBwy8w==\n-----END RSA PRIVATE KEY-----`,
WEBHOOK_SECRET: "test",
PORT: String(probotPort),
GHE_HOST: `127.0.0.1:${mockServerPort}`,
GHE_PROTOCOL: "http",
LOG_LEVEL: "trace",
WEBHOOK_PATH: "/",
},
}
stdio: "inherit",
},
);
// give probot a moment to start
await new Promise((resolve) => setTimeout(resolve, 3000));
// the probot process should be successfully started
expect(probotProcess.exitCode).toBeNull();
// send webhook event request
const body = JSON.stringify({
@ -102,7 +102,8 @@ describe("end-to-end-tests", () => {
});
try {
await got.post(`http://127.0.0.1:${probotPort}`, {
await fetch(`http://127.0.0.1:${probotPort}/`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-github-event": "issues",
@ -113,7 +114,9 @@ describe("end-to-end-tests", () => {
});
} catch (error) {
probotProcess.cancel();
console.log((await probotProcess).stdout);
const awaitedProcess = await probotProcess;
console.log(awaitedProcess.stdout);
console.log(awaitedProcess.stderr);
}
expect(httpMock).toHaveBeenCalledTimes(2);

View File

@ -13,9 +13,7 @@ module.exports = (app) => {
const params = context.issue({ body: "Hello World!" });
// Post a comment on the issue
await context.octokit.issues.createComment(params).then(
() => console.log("issue comment created"),
(error) => console.log("not ok", error)
);
await context.octokit.issues.createComment(params);
console.log("issue comment created");
});
};

View File

View File

View File

@ -0,0 +1 @@
{

View File

@ -0,0 +1 @@
"null"

View File

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

View File

@ -1,18 +1,27 @@
import SonicBoom from "sonic-boom";
import { symbols as pinoSymbols } from "pino";
import type { Logger } from "pino";
import { type SpyInstance, vi } from "vitest";
export async function captureLogOutput(action: () => any): Promise<string> {
export async function captureLogOutput(
action: () => any,
log: Logger,
): Promise<string> {
let outputData = "";
const sbWrite = SonicBoom.prototype.write;
SonicBoom.prototype.write = function (data) {
const stdoutSpy: SpyInstance = vi.spyOn(
// @ts-expect-error
log[pinoSymbols.streamSym],
"write",
);
stdoutSpy.mockImplementation((data) => {
outputData += data;
};
});
try {
await action();
return outputData;
} finally {
SonicBoom.prototype.write = sbWrite;
stdoutSpy.mockRestore();
}
}

View File

@ -1,4 +1,5 @@
import { isProduction } from "../../src/helpers/is-production";
import { isProduction } from "../../src/helpers/is-production.js";
import { describe, expect, it } from "vitest";
describe("isProduction", () => {
it("returns true if the NODE_ENV is set to production", () => {
@ -11,6 +12,6 @@ describe("isProduction", () => {
(value) => {
process.env.NODE_ENV = value;
expect(isProduction()).toBe(false);
}
},
);
});

View File

@ -0,0 +1,62 @@
import { loadPackageJson } from "../../src/helpers/load-package-json.js";
import { resolve } from "node:path";
import { describe, expect, it } from "vitest";
describe("loadPackageJson", () => {
it("returns empty object if filepath is invalid", () => {
expect(JSON.stringify(loadPackageJson("/invalid/path/package.json"))).toBe(
"{}",
);
});
it("returns package.json content", () => {
expect(loadPackageJson()).toHaveProperty("name");
expect(loadPackageJson().name).toBe("probot");
});
it("returns package.json content if filepath is valid", () => {
expect(
loadPackageJson(resolve(process.cwd(), "package.json")),
).toHaveProperty("name");
expect(loadPackageJson(resolve(process.cwd(), "package.json")).name).toBe(
"probot",
);
});
it("returns empty object if file is invalid", () => {
expect(
JSON.stringify(
loadPackageJson(
resolve(
process.cwd(),
"test/fixtures/load-package-json/invalid.json",
),
),
),
).toBe("{}");
});
it("returns empty object if file is empty string", () => {
expect(
JSON.stringify(
loadPackageJson(
resolve(
process.cwd(),
"test/fixtures/load-package-json/empty-string.json",
),
),
),
).toBe("{}");
});
it("returns empty object if file is 'null'", () => {
expect(
JSON.stringify(
loadPackageJson(
resolve(
process.cwd(),
"test/fixtures/load-package-json/empty-string.json",
),
),
),
).toBe("{}");
});
});

View File

@ -0,0 +1,141 @@
import { Writable } from "node:stream";
import { ManifestCreation } from "../../src/manifest-creation.js";
import { describe, test, expect, afterEach } from "vitest";
import getPort from "get-port";
import { ApplicationFunction, Probot, Server } from "../../src/index.js";
import { pino } from "pino";
import WebhookExamples, {
type WebhookDefinition,
} from "@octokit/webhooks-examples";
import { sign } from "@octokit/webhooks-methods";
describe("smee-client", () => {
afterEach(async () => {
delete process.env.WEBHOOK_PROXY_URL;
await new Promise((resolve) => setTimeout(resolve, 5000));
});
describe("ManifestCreation", () => {
test("create a smee proxy", async () => {
await new ManifestCreation().createWebhookChannel();
expect(process.env.WEBHOOK_PROXY_URL).toMatch(
/^https:\/\/smee\.io\/[0-9a-zA-Z]{10,}$/,
);
});
});
describe("Server", () => {
const APP_ID = "1";
const PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY-----
MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aozN3tihPmljnnudb9G3HECdnH
lWu2/a1gB9JW5TBQ+AVpum9Okx7KfqkfBKL9mcHgSL0yWMdjMfNOqNtrQqKlN4kE
p6RD++7sGbzbfZ9arwrlD/HSDAWGdGGJTSOBM6pHehyLmSC3DJoR/CTu0vTGTWXQ
rO64Z8tyXQPtVPb/YXrcUhbBp8i72b9Xky0fD6PkEebOy0Ip58XVAn2UPNlNOSPS
ye+Qjtius0Md4Nie4+X8kwVI2Qjk3dSm0sw/720KJkdVDmrayeljtKBx6AtNQsSX
gzQbeMmiqFFkwrG1+zx6E7H7jqIQ9B6bvWKXGwIDAQABAoIBAD8kBBPL6PPhAqUB
K1r1/gycfDkUCQRP4DbZHt+458JlFHm8QL6VstKzkrp8mYDRhffY0WJnYJL98tr4
4tohsDbqFGwmw2mIaHjl24LuWXyyP4xpAGDpl9IcusjXBxLQLp2m4AKXbWpzb0OL
Ulrfc1ZooPck2uz7xlMIZOtLlOPjLz2DuejVe24JcwwHzrQWKOfA11R/9e50DVse
hnSH/w46Q763y4I0E3BIoUMsolEKzh2ydAAyzkgabGQBUuamZotNfvJoDXeCi1LD
8yNCWyTlYpJZJDDXooBU5EAsCvhN1sSRoaXWrlMSDB7r/E+aQyKua4KONqvmoJuC
21vSKeECgYEA7yW6wBkVoNhgXnk8XSZv3W+Q0xtdVpidJeNGBWnczlZrummt4xw3
xs6zV+rGUDy59yDkKwBKjMMa42Mni7T9Fx8+EKUuhVK3PVQyajoyQqFwT1GORJNz
c/eYQ6VYOCSC8OyZmsBM2p+0D4FF2/abwSPMmy0NgyFLCUFVc3OECpkCgYEA5OAm
I3wt5s+clg18qS7BKR2DuOFWrzNVcHYXhjx8vOSWV033Oy3yvdUBAhu9A1LUqpwy
Ma+unIgxmvmUMQEdyHQMcgBsVs10dR/g2xGjMLcwj6kn+xr3JVIZnbRT50YuPhf+
ns1ScdhP6upo9I0/sRsIuN96Gb65JJx94gQ4k9MCgYBO5V6gA2aMQvZAFLUicgzT
u/vGea+oYv7tQfaW0J8E/6PYwwaX93Y7Q3QNXCoCzJX5fsNnoFf36mIThGHGiHY6
y5bZPPWFDI3hUMa1Hu/35XS85kYOP6sGJjf4kTLyirEcNKJUWH7CXY+00cwvTkOC
S4Iz64Aas8AilIhRZ1m3eQKBgQCUW1s9azQRxgeZGFrzC3R340LL530aCeta/6FW
CQVOJ9nv84DLYohTVqvVowdNDTb+9Epw/JDxtDJ7Y0YU0cVtdxPOHcocJgdUGHrX
ZcJjRIt8w8g/s4X6MhKasBYm9s3owALzCuJjGzUKcDHiO2DKu1xXAb0SzRcTzUCn
7daCswKBgQDOYPZ2JGmhibqKjjLFm0qzpcQ6RPvPK1/7g0NInmjPMebP0K6eSPx0
9/49J6WTD++EajN7FhktUSYxukdWaCocAQJTDNYP0K88G4rtC2IYy5JFn9SWz5oh
x//0u+zd/R/QRUzLOw4N72/Hu+UG6MNt5iDZFCtapRaKt6OvSBwy8w==
-----END RSA PRIVATE KEY-----`;
const WEBHOOK_SECRET = "secret";
const pushEvent = (
(WebhookExamples as unknown as WebhookDefinition[]).filter(
(event) => event.name === "push",
)[0] as WebhookDefinition<"push">
).examples[0];
let output: any[] = [];
const streamLogsToOutput = new Writable({ objectMode: true });
streamLogsToOutput._write = (object, _encoding, done) => {
output.push(JSON.parse(object));
done();
};
test(
"with createProbot and setting the webhookPath via WEBHOOK_PATH to the root",
async () => {
expect.assertions(1);
const promise: {
resolve: any;
reject: any;
promise: any;
} = {
resolve: null,
reject: null,
promise: null,
};
promise.promise = new Promise((resolve, reject) => {
promise.resolve = resolve;
promise.reject = reject;
});
const WEBHOOK_PROXY_URL =
await new ManifestCreation().createWebhookChannel();
const app: ApplicationFunction = (app) => {
app.on("push", (event) => {
expect(event.name).toEqual("push");
promise.resolve();
});
};
const port = await getPort();
const server = new Server({
Probot: Probot.defaults({
appId: APP_ID,
privateKey: PRIVATE_KEY,
secret: WEBHOOK_SECRET,
}),
log: pino(streamLogsToOutput),
port,
webhookProxy: WEBHOOK_PROXY_URL,
webhookPath: "/",
});
server.load(app);
await server.start();
const body = JSON.stringify(pushEvent);
await fetch(`${WEBHOOK_PROXY_URL}/`, {
method: "POST",
headers: {
"content-type": "application/json",
"x-github-event": "push",
"x-github-delivery": "1",
"x-hub-signature-256": await sign(WEBHOOK_SECRET, body),
},
body,
});
await promise.promise;
server.stop();
},
{ retry: 10, timeout: 3000 },
);
});
});

View File

@ -1,12 +1,21 @@
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 "node:fs";
import path from "node:path";
import fetchMock from "fetch-mock";
import { describe, expect, test, beforeEach, afterEach, vi } from "vitest";
import { ManifestCreation } from "../src/manifest-creation.js";
import { loadPackageJson } from "../src/helpers/load-package-json.js";
describe("ManifestCreation", () => {
let setup: ManifestCreation;
const pkg = loadPackageJson();
const response = JSON.parse(
fs.readFileSync(
path.resolve(process.cwd(), "./test/fixtures/setup/response.json"),
"utf8",
),
);
beforeEach(() => {
setup = new ManifestCreation();
});
@ -17,10 +26,10 @@ describe("ManifestCreation", () => {
delete process.env.PROJECT_DOMAIN;
delete process.env.WEBHOOK_PROXY_URL;
setup.updateEnv = jest.fn();
setup.updateEnv = vi.fn();
const SmeeClient: typeof import("smee-client") = require("smee-client");
SmeeClient.createChannel = jest
SmeeClient.createChannel = vi
.fn()
.mockReturnValue("https://smee.io/1234abc");
});
@ -52,21 +61,21 @@ describe("ManifestCreation", () => {
test("creates an app url", () => {
expect(setup.createAppUrl).toEqual(
"https://github.com/settings/apps/new"
"https://github.com/settings/apps/new",
);
});
test("creates an app url when github org is set", () => {
process.env.GH_ORG = "testorg";
expect(setup.createAppUrl).toEqual(
"https://github.com/organizations/testorg/settings/apps/new"
"https://github.com/organizations/testorg/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"
"https://hiimbex.github.com/settings/apps/new",
);
});
@ -74,7 +83,7 @@ describe("ManifestCreation", () => {
process.env.GHE_HOST = "hiimbex.github.com";
process.env.GH_ORG = "testorg";
expect(setup.createAppUrl).toEqual(
"https://hiimbex.github.com/organizations/testorg/settings/apps/new"
"https://hiimbex.github.com/organizations/testorg/settings/apps/new",
);
});
@ -82,14 +91,14 @@ describe("ManifestCreation", () => {
process.env.GHE_HOST = "hiimbex.github.com";
process.env.GHE_PROTOCOL = "http";
expect(setup.createAppUrl).toEqual(
"http://hiimbex.github.com/settings/apps/new"
"http://hiimbex.github.com/settings/apps/new",
);
});
});
describe("createAppFromCode", () => {
beforeEach(() => {
setup.updateEnv = jest.fn();
setup.updateEnv = vi.fn();
});
afterEach(() => {
@ -100,11 +109,18 @@ describe("ManifestCreation", () => {
});
test("creates an app from a code", async () => {
nock("https://api.github.com")
.post("/app-manifests/123abc/conversions")
.reply(200, response);
const fetch = fetchMock
.sandbox()
.postOnce("https://api.github.com/app-manifests/123abc/conversions", {
status: 200,
body: response,
});
const createdApp = await setup.createAppFromCode("123abc");
const createdApp = await setup.createAppFromCode("123abc", {
request: {
fetch,
},
});
expect(createdApp).toEqual("https://github.com/apps/testerino0000000");
// expect dotenv to be called with id, webhook_secret, pem
expect(setup.updateEnv).toHaveBeenCalledWith({
@ -118,11 +134,21 @@ describe("ManifestCreation", () => {
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);
const fetch = fetchMock
.sandbox()
.postOnce(
"https://swinton.github.com/api/v3/app-manifests/123abc/conversions",
{
status: 200,
body: response,
},
);
const createdApp = await setup.createAppFromCode("123abc");
const createdApp = await setup.createAppFromCode("123abc", {
request: {
fetch,
},
});
expect(createdApp).toEqual("https://github.com/apps/testerino0000000");
// expect dotenv to be called with id, webhook_secret, pem
expect(setup.updateEnv).toHaveBeenCalledWith({
@ -136,24 +162,24 @@ describe("ManifestCreation", () => {
describe("getManifest", () => {
afterEach(() => {
jest.clearAllMocks();
vi.clearAllMocks();
});
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"}'
'{"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);
vi.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"}'
'{"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"}',
);
});
});

View File

@ -1,12 +1,11 @@
import nock from "nock";
import { ProbotOctokit } from "../src/octokit/probot-octokit";
type Options = ConstructorParameters<typeof ProbotOctokit>[0];
import fetchMock from "fetch-mock";
import { ProbotOctokit } from "../src/octokit/probot-octokit.js";
import type { RequestError } from "@octokit/types";
import type { OctokitOptions } from "../src/types.js";
import { describe, expect, test, vi, it } from "vitest";
describe("ProbotOctokit", () => {
let octokit: InstanceType<typeof ProbotOctokit>;
const defaultOptions: Options = {
const defaultOptions: OctokitOptions = {
retry: {
// disable retries to test error states
enabled: false,
@ -17,177 +16,455 @@ describe("ProbotOctokit", () => {
},
};
beforeEach(() => {
octokit = new ProbotOctokit(defaultOptions);
});
test("works without options", async () => {
octokit = new ProbotOctokit();
const user = { login: "ohai" };
const fetch = fetchMock
.sandbox()
.getOnce("https://api.github.com/user", '{"login": "ohai"}');
nock("https://api.github.com").get("/user").reply(200, user);
expect((await octokit.users.getAuthenticated({})).data).toEqual(user);
const octokit = new ProbotOctokit({
...defaultOptions,
request: {
fetch,
},
});
expect((await octokit.users.getAuthenticated({})).data).toEqual(
'{"login": "ohai"}',
);
});
test("logs request errors", async () => {
nock("https://api.github.com").get("/").reply(500, {});
const fetch = fetchMock.sandbox().getOnce("https://api.github.com/", {
status: 500,
body: {
message: "Internal Server Error",
documentation_url:
"https://docs.github.com/rest/reference/repos#get-repository-content",
},
});
const octokit = new ProbotOctokit({
...defaultOptions,
request: {
fetch,
},
});
try {
await octokit.request("/");
throw new Error("should throw");
} catch (error) {
expect(error.status).toBe(500);
expect((error as RequestError).status).toBe(500);
}
});
describe("with retry enabled", () => {
beforeEach(() => {
const options: Options = {
...defaultOptions,
retry: {
enabled: true,
},
};
test("with retry enabled retries failed requests", async () => {
let callCount = 0;
octokit = new ProbotOctokit(options);
});
const octokit = new ProbotOctokit({
...defaultOptions,
retry: {
enabled: true,
},
request: {
fetch: (url: string, options: { [key: string]: any }) => {
expect(url).toEqual("https://api.github.com/");
expect(options.method).toEqual("GET");
expect(options.headers.accept).toEqual(
"application/vnd.github.v3+json",
);
expect(options.headers["user-agent"]).toMatch(/^probot\//);
expect(options.signal).toEqual(undefined);
expect(options.body).toEqual(undefined);
test("retries failed requests", async () => {
nock("https://api.github.com").get("/").once().reply(500, {});
nock("https://api.github.com").get("/").once().reply(200, {});
const response = await octokit.request("/");
expect(response.status).toBe(200);
});
});
describe("with throttling enabled", () => {
beforeEach(() => {
const options: Options = {
...defaultOptions,
throttle: {
enabled: true,
minimumAbuseRetryAfter: 1,
onRateLimit() {
return true;
},
onAbuseLimit() {
return true;
},
},
};
octokit = new ProbotOctokit(options);
});
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}`,
if (callCount++ === 0) {
return Promise.reject({});
}
)
.get("/")
.reply(200, {});
const { status } = await octokit.request("/");
expect(status).toBe(200);
return Promise.resolve({
status: 200,
headers: new Headers(),
text: () => Promise.resolve("{}"),
});
},
},
});
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, {});
const response = await octokit.request("/");
expect(response.status).toBe(200);
});
const response = await octokit.request("/");
expect(response.status).toBe(200);
});
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}`,
};
test("with throttling enabled retries requests when being rate limited", async () => {
let callCount = 0;
const octokit = new ProbotOctokit({
...defaultOptions,
throttle: {
enabled: true,
fallbackSecondaryRateRetryAfter: 1,
onRateLimit() {
return true;
},
onSecondaryRateLimit() {
return true;
},
},
request: {
fetch: (url: string, options: { [key: string]: any }) => {
expect(url).toEqual("https://api.github.com/");
expect(options.method).toEqual("GET");
expect(options.headers.accept).toEqual(
"application/vnd.github.v3+json",
);
expect(options.headers["user-agent"]).toMatch(/^probot\//);
expect(options.signal).toEqual(undefined);
expect(options.body).toEqual(undefined);
if (callCount++ === 0) {
return Promise.resolve({
status: 403,
headers: new Headers({
"X-RateLimit-Limit": "60",
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": `${new Date().getTime() / 1000}`,
}),
text: () => Promise.resolve("{}"),
});
}
return Promise.resolve({
status: 200,
headers: new Headers(),
text: () => Promise.resolve("{}"),
});
},
},
});
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"',
})
.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=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: "",
});
const { status } = await octokit.request("/");
expect(status).toBe(200);
});
test("with throttling enabled using default onPrimaryRateLimit", async () => {
expect.assertions(14);
let callCount = 0;
const octokit = new ProbotOctokit({
...defaultOptions,
// @ts-expect-error just need to mock the warn method
log: {
warn(message) {
expect(message).toEqual(
'Rate limit hit with "GET /", retrying in 1 seconds.',
);
},
},
// @ts-expect-error
throttle: {
enabled: true,
fallbackSecondaryRateRetryAfter: 1,
},
request: {
fetch: (url: string, options: { [key: string]: any }) => {
expect(url).toEqual("https://api.github.com/");
expect(options.method).toEqual("GET");
expect(options.headers.accept).toEqual(
"application/vnd.github.v3+json",
);
expect(options.headers["user-agent"]).toMatch(/^probot\//);
expect(options.signal).toEqual(undefined);
expect(options.body).toEqual(undefined);
if (callCount++ === 0) {
return Promise.resolve({
status: 403,
headers: new Headers({
"X-RateLimit-Limit": "60",
"X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": `${new Date().getTime() / 1000}`,
}),
text: () => Promise.resolve("{}"),
});
}
return Promise.resolve({
status: 200,
headers: new Headers(),
text: () => Promise.resolve("{}"),
});
},
},
});
it("returns an array of pages", async () => {
const spy = jest.fn();
const res = await octokit.paginate(
octokit.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);
const { status } = await octokit.request("/");
expect(status).toBe(200);
});
test("with throttling enabled retries requests when hitting the secondary rate limiter", async () => {
let callCount = 0;
const octokit = new ProbotOctokit({
...defaultOptions,
throttle: {
enabled: true,
fallbackSecondaryRateRetryAfter: 1,
onRateLimit() {
return true;
},
onSecondaryRateLimit() {
return true;
},
},
request: {
fetch: (url: string, options: { [key: string]: any }) => {
expect(url).toEqual("https://api.github.com/");
expect(options.method).toEqual("GET");
expect(options.headers.accept).toEqual(
"application/vnd.github.v3+json",
);
expect(options.headers["user-agent"]).toMatch(/^probot\//);
expect(options.signal).toEqual(undefined);
expect(options.body).toEqual(undefined);
if (callCount++ === 0) {
return Promise.resolve({
status: 403,
headers: new Headers(),
text: () =>
Promise.resolve(
"The throttle plugin just looks for the word secondary rate in the error message",
),
});
}
return Promise.resolve({
status: 200,
headers: new Headers(),
text: () => Promise.resolve("{}"),
});
},
},
});
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 octokit.paginate(
octokit.issues.listForRepo.endpoint.merge({
owner: "JasonEtco",
repo: "pizza",
per_page: 1,
}),
spy
);
expect(res.length).toBe(3);
expect(spy).toHaveBeenCalledTimes(3);
const response = await octokit.request("/");
expect(response.status).toBe(200);
});
test("with throttling enabled using default onSecondaryRateLimit", async () => {
expect.assertions(14);
let callCount = 0;
const octokit = new ProbotOctokit({
...defaultOptions,
// @ts-expect-error just need to mock the warn method
log: {
warn(message) {
expect(message).toEqual(
'Secondary Rate limit hit with "GET /", retrying in 1 seconds.',
);
},
},
// @ts-expect-error
throttle: {
enabled: true,
fallbackSecondaryRateRetryAfter: 1,
},
request: {
fetch: (url: string, options: { [key: string]: any }) => {
expect(url).toEqual("https://api.github.com/");
expect(options.method).toEqual("GET");
expect(options.headers.accept).toEqual(
"application/vnd.github.v3+json",
);
expect(options.headers["user-agent"]).toMatch(/^probot\//);
expect(options.signal).toEqual(undefined);
expect(options.body).toEqual(undefined);
if (callCount++ === 0) {
return Promise.resolve({
status: 403,
headers: new Headers(),
text: () =>
Promise.resolve(
"The throttle plugin just looks for the word secondary rate in the error message",
),
});
}
return Promise.resolve({
status: 200,
headers: new Headers(),
text: () => Promise.resolve("{}"),
});
},
},
});
it("maps the responses to data by default", async () => {
const res = await octokit.paginate(
octokit.issues.listForRepo.endpoint.merge({
owner: "JasonEtco",
repo: "pizza",
per_page: 1,
})
);
expect(res).toEqual(issues);
const response = await octokit.request("/");
expect(response.status).toBe(200);
});
// Prepare an array of issue objects
const issues = new Array(5).fill(0).map((_, i) => {
return {
id: i,
number: i,
title: `Issue number ${i}`,
};
});
it("paginate returns an array of pages", async () => {
let callCount = 0;
const octokit = new ProbotOctokit({
...defaultOptions,
request: {
fetch: (url: string, options: { [key: string]: any }) => {
if (callCount === 0) {
expect(url).toEqual(
"https://api.github.com/repos/JasonEtco/pizza/issues?per_page=1",
);
} else {
expect(url).toMatch(
new RegExp(
"^https://api\\.github\\.com/repositories/[0-9]+/issues\\?per_page=[0-9]+&page=[0-9]+$",
),
);
}
expect(options.method).toEqual("GET");
expect(options.headers.accept).toEqual(
"application/vnd.github.v3+json",
);
expect(options.headers["user-agent"]).toMatch(/^probot\//);
expect(options.signal).toEqual(undefined);
expect(options.body).toEqual(undefined);
return Promise.resolve({
status: 200,
text: () => Promise.resolve([issues[callCount++]]),
headers: new Headers({
link:
callCount !== 4
? `link: '<https://api.github.com/repositories/123/issues?per_page=1&page=${callCount}>; rel="next"',`
: "",
}),
});
},
},
});
const spy = vi.fn();
const res = await octokit.paginate(
octokit.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("paginate stops iterating if the done() function is called in the callback", async () => {
let callCount = 0;
const octokit = new ProbotOctokit({
...defaultOptions,
request: {
fetch: (url: string, options: { [key: string]: any }) => {
if (callCount === 0) {
expect(url).toEqual(
"https://api.github.com/repos/JasonEtco/pizza/issues?per_page=1",
);
} else {
expect(url).toMatch(
new RegExp(
"^https://api\\.github\\.com/repositories/[0-9]+/issues\\?per_page=[0-9]+&page=[0-9]+$",
),
);
}
expect(options.method).toEqual("GET");
expect(options.headers.accept).toEqual(
"application/vnd.github.v3+json",
);
expect(options.headers["user-agent"]).toMatch(/^probot\//);
expect(options.signal).toEqual(undefined);
expect(options.body).toEqual(undefined);
return Promise.resolve({
status: 200,
text: () => Promise.resolve([issues[callCount++]]),
headers: new Headers({
link:
callCount !== 4
? `link: '<https://api.github.com/repositories/123/issues?per_page=1&page=${callCount}>; rel="next"',`
: "",
}),
});
},
},
});
const spy = vi.fn((response, done) => {
if (response.data[0].id === 2) done();
}) as any;
const res = await octokit.paginate(
octokit.issues.listForRepo.endpoint.merge({
owner: "JasonEtco",
repo: "pizza",
per_page: 1,
}),
spy,
);
expect(res.length).toBe(3);
expect(spy).toHaveBeenCalledTimes(3);
});
it("paginate maps the responses to data by default", async () => {
let callCount = 0;
const octokit = new ProbotOctokit({
...defaultOptions,
request: {
fetch: (url: string, options: { [key: string]: any }) => {
if (callCount === 0) {
expect(url).toEqual(
"https://api.github.com/repos/JasonEtco/pizza/issues?per_page=1",
);
} else {
expect(url).toMatch(
new RegExp(
"^https://api\\.github\\.com/repositories/[0-9]+/issues\\?per_page=[0-9]+&page=[0-9]+$",
),
);
}
expect(options.method).toEqual("GET");
expect(options.headers.accept).toEqual(
"application/vnd.github.v3+json",
);
expect(options.headers["user-agent"]).toMatch(/^probot\//);
expect(options.signal).toEqual(undefined);
expect(options.body).toEqual(undefined);
return Promise.resolve({
status: 200,
text: () => Promise.resolve([issues[callCount++]]),
headers: new Headers({
link:
callCount !== 4
? `link: '<https://api.github.com/repositories/123/issues?per_page=1&page=${callCount}>; rel="next"',`
: "",
}),
});
},
},
});
const res = await octokit.paginate(
octokit.issues.listForRepo.endpoint.merge({
owner: "JasonEtco",
repo: "pizza",
per_page: 1,
}),
);
expect(res).toEqual(issues);
});
});

View File

@ -1,37 +1,59 @@
import Stream from "stream";
import Stream from "node:stream";
import {
import type {
EmitterWebhookEvent,
EmitterWebhookEvent as WebhookEvent,
} from "@octokit/webhooks";
import Bottleneck from "bottleneck";
import nock from "nock";
import pino from "pino";
import fetchMock from "fetch-mock";
import { pino, type LogFn } from "pino";
import { describe, expect, test, beforeEach, it, vi, type Mock } from "vitest";
import { Probot, ProbotOctokit, Context } from "../src";
import { Probot, ProbotOctokit, Context } from "../src/index.js";
import webhookExamples from "@octokit/webhooks-examples";
import { EmitterWebhookEventName } from "@octokit/webhooks/dist-types/types";
import webhookExamples, {
type WebhookDefinition,
} from "@octokit/webhooks-examples";
import type { EmitterWebhookEventName } from "@octokit/webhooks/dist-types/types.js";
const appId = 1;
const privateKey = `-----BEGIN RSA PRIVATE KEY-----
MIIBOQIBAAJBAIILhiN9IFpaE0pUXsesuuoaj6eeDiAqCiE49WB1tMB8ZMhC37kY
Fl52NUYbUxb7JEf6pH5H9vqw1Wp69u78XeUCAwEAAQJAb88urnaXiXdmnIK71tuo
/TyHBKt9I6Rhfzz0o9Gv7coL7a537FVDvV5UCARXHJMF41tKwj+zlt9EEUw7a1HY
wQIhAL4F/VHWSPHeTgXYf4EaX2OlpSOk/n7lsFtL/6bWRzRVAiEArzJs2vopJitv
A1yBjz3q2nX+zthk+GLXrJQkYOnIk1ECIHfeFV8TWm5gej1LxZquBTA5pINoqDVq
NKZSuZEHqGEFAiB6EDrxkovq8SYGhIQsJeqkTMO8n94xhMRZlFmIQDokEQIgAq5U
r1UQNnUExRh7ZT0kFbMfO9jKYZVlQdCL9Dn93vo=
MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aozN3tihPmljnnudb9G3HECdnH
lWu2/a1gB9JW5TBQ+AVpum9Okx7KfqkfBKL9mcHgSL0yWMdjMfNOqNtrQqKlN4kE
p6RD++7sGbzbfZ9arwrlD/HSDAWGdGGJTSOBM6pHehyLmSC3DJoR/CTu0vTGTWXQ
rO64Z8tyXQPtVPb/YXrcUhbBp8i72b9Xky0fD6PkEebOy0Ip58XVAn2UPNlNOSPS
ye+Qjtius0Md4Nie4+X8kwVI2Qjk3dSm0sw/720KJkdVDmrayeljtKBx6AtNQsSX
gzQbeMmiqFFkwrG1+zx6E7H7jqIQ9B6bvWKXGwIDAQABAoIBAD8kBBPL6PPhAqUB
K1r1/gycfDkUCQRP4DbZHt+458JlFHm8QL6VstKzkrp8mYDRhffY0WJnYJL98tr4
4tohsDbqFGwmw2mIaHjl24LuWXyyP4xpAGDpl9IcusjXBxLQLp2m4AKXbWpzb0OL
Ulrfc1ZooPck2uz7xlMIZOtLlOPjLz2DuejVe24JcwwHzrQWKOfA11R/9e50DVse
hnSH/w46Q763y4I0E3BIoUMsolEKzh2ydAAyzkgabGQBUuamZotNfvJoDXeCi1LD
8yNCWyTlYpJZJDDXooBU5EAsCvhN1sSRoaXWrlMSDB7r/E+aQyKua4KONqvmoJuC
21vSKeECgYEA7yW6wBkVoNhgXnk8XSZv3W+Q0xtdVpidJeNGBWnczlZrummt4xw3
xs6zV+rGUDy59yDkKwBKjMMa42Mni7T9Fx8+EKUuhVK3PVQyajoyQqFwT1GORJNz
c/eYQ6VYOCSC8OyZmsBM2p+0D4FF2/abwSPMmy0NgyFLCUFVc3OECpkCgYEA5OAm
I3wt5s+clg18qS7BKR2DuOFWrzNVcHYXhjx8vOSWV033Oy3yvdUBAhu9A1LUqpwy
Ma+unIgxmvmUMQEdyHQMcgBsVs10dR/g2xGjMLcwj6kn+xr3JVIZnbRT50YuPhf+
ns1ScdhP6upo9I0/sRsIuN96Gb65JJx94gQ4k9MCgYBO5V6gA2aMQvZAFLUicgzT
u/vGea+oYv7tQfaW0J8E/6PYwwaX93Y7Q3QNXCoCzJX5fsNnoFf36mIThGHGiHY6
y5bZPPWFDI3hUMa1Hu/35XS85kYOP6sGJjf4kTLyirEcNKJUWH7CXY+00cwvTkOC
S4Iz64Aas8AilIhRZ1m3eQKBgQCUW1s9azQRxgeZGFrzC3R340LL530aCeta/6FW
CQVOJ9nv84DLYohTVqvVowdNDTb+9Epw/JDxtDJ7Y0YU0cVtdxPOHcocJgdUGHrX
ZcJjRIt8w8g/s4X6MhKasBYm9s3owALzCuJjGzUKcDHiO2DKu1xXAb0SzRcTzUCn
7daCswKBgQDOYPZ2JGmhibqKjjLFm0qzpcQ6RPvPK1/7g0NInmjPMebP0K6eSPx0
9/49J6WTD++EajN7FhktUSYxukdWaCocAQJTDNYP0K88G4rtC2IYy5JFn9SWz5oh
x//0u+zd/R/QRUzLOw4N72/Hu+UG6MNt5iDZFCtapRaKt6OvSBwy8w==
-----END RSA PRIVATE KEY-----`;
const getPayloadExamples = <TName extends EmitterWebhookEventName>(
name: TName
name: TName,
) => {
return webhookExamples.filter((event) => event.name === name.split(".")[0])[0]
.examples as EmitterWebhookEvent<TName>["payload"][];
return (webhookExamples as unknown as WebhookDefinition[]).filter(
(event) => event.name === name.split(".")[0],
)[0].examples as EmitterWebhookEvent<TName>["payload"][];
};
const getPayloadExample = <TName extends EmitterWebhookEventName>(
name: TName
name: TName,
) => {
const examples = getPayloadExamples<TName>(name);
if (name.includes(".")) {
@ -43,7 +65,6 @@ const getPayloadExample = <TName extends EmitterWebhookEventName>(
}
return examples[0];
};
// tslint:disable:no-empty
describe("Probot", () => {
let probot: Probot;
let event: WebhookEvent<
@ -52,7 +73,7 @@ describe("Probot", () => {
let output: any;
const streamLogsToOutput = new Stream.Writable({ objectMode: true });
streamLogsToOutput._write = (object, encoding, done) => {
streamLogsToOutput._write = (object, _encoding, done) => {
output.push(JSON.parse(object));
done();
};
@ -69,22 +90,26 @@ describe("Probot", () => {
describe(".defaults()", () => {
test("sets default options for constructor", async () => {
const mock = nock("https://api.github.com").get("/app").reply(200, {
id: 1,
const fetch = fetchMock.sandbox().getOnce("https://api.github.com/app", {
status: 200,
body: {
id: 1,
},
});
const MyProbot = Probot.defaults({ appId, privateKey });
const probot = new MyProbot();
const probot = new MyProbot({
request: { fetch },
});
const octokit = await probot.auth();
await octokit.apps.getAuthenticated();
expect(mock.activeMocks()).toStrictEqual([]);
});
});
describe("constructor", () => {
it("no options", () => {
expect(() => new Probot()).toThrow(
"[@octokit/auth-app] appId option is required"
"[@octokit/auth-app] appId option is required",
);
});
@ -101,8 +126,8 @@ describe("Probot", () => {
it("shouldn't overwrite `options.throttle` passed to `{Octokit: ProbotOctokit.defaults(options)}`", () => {
expect.assertions(1);
const MyOctokit = ProbotOctokit.plugin((octokit, options) => {
expect(options.throttle.enabled).toEqual(false);
const MyOctokit = ProbotOctokit.plugin((_octokit, options) => {
expect(options.throttle?.enabled).toEqual(true);
}).defaults({
appId,
privateKey,
@ -133,7 +158,7 @@ describe("Probot", () => {
it("responds with the correct error if webhook secret does not match", async () => {
expect.assertions(1);
probot.log.error = jest.fn();
probot.log.error = vi.fn() as LogFn;
probot.webhooks.on("push", () => {
throw new Error("X-Hub-Signature-256 does not match blob signature");
});
@ -141,16 +166,14 @@ describe("Probot", () => {
try {
await probot.webhooks.receive(event);
} catch (e) {
expect(
(probot.log.error as jest.Mock).mock.calls[0][1]
).toMatchSnapshot();
expect((probot.log.error as Mock).mock.calls[0][1]).toMatchSnapshot();
}
});
it("responds with the correct error if webhook secret is not found", async () => {
expect.assertions(1);
probot.log.error = jest.fn();
probot.log.error = vi.fn() as LogFn;
probot.webhooks.on("push", () => {
throw new Error("No X-Hub-Signature-256 found on request");
});
@ -158,66 +181,58 @@ describe("Probot", () => {
try {
await probot.webhooks.receive(event);
} catch (e) {
expect(
(probot.log.error as jest.Mock).mock.calls[0][1]
).toMatchSnapshot();
expect((probot.log.error as Mock).mock.calls[0][1]).toMatchSnapshot();
}
});
it("responds with the correct error if webhook secret is wrong", async () => {
expect.assertions(1);
probot.log.error = jest.fn();
probot.log.error = vi.fn() as LogFn;
probot.webhooks.on("push", () => {
throw Error(
"webhooks:receiver ignored: POST / due to missing headers: x-hub-signature-256"
"webhooks:receiver ignored: POST / due to missing headers: x-hub-signature-256",
);
});
try {
await probot.webhooks.receive(event);
} catch (e) {
expect(
(probot.log.error as jest.Mock).mock.calls[0][1]
).toMatchSnapshot();
expect((probot.log.error as Mock).mock.calls[0][1]).toMatchSnapshot();
}
});
it("responds with the correct error if the PEM file is missing", async () => {
expect.assertions(1);
probot.log.error = jest.fn();
probot.log.error = vi.fn() as LogFn;
probot.webhooks.onAny(() => {
throw new Error(
"error:0906D06C:PEM routines:PEM_read_bio:no start line"
"error:0906D06C:PEM routines:PEM_read_bio:no start line",
);
});
try {
await probot.webhooks.receive(event);
} catch (e) {
expect(
(probot.log.error as jest.Mock).mock.calls[0][1]
).toMatchSnapshot();
expect((probot.log.error as Mock).mock.calls[0][1]).toMatchSnapshot();
}
});
it("responds with the correct error if the jwt could not be decoded", async () => {
expect.assertions(1);
probot.log.error = jest.fn();
probot.log.error = vi.fn() as LogFn;
probot.webhooks.onAny(() => {
throw new Error(
'{"message":"A JSON web token could not be decoded","documentation_url":"https://developer.github.com/v3"}'
'{"message":"A JSON web token could not be decoded","documentation_url":"https://developer.github.com/v3"}',
);
});
try {
await probot.webhooks.receive(event);
} catch (e) {
expect(
(probot.log.error as jest.Mock).mock.calls[0][1]
).toMatchSnapshot();
expect((probot.log.error as Mock).mock.calls[0][1]).toMatchSnapshot();
}
});
});
@ -227,7 +242,7 @@ describe("Probot", () => {
const appFn = async (app: Probot) => {
const octokit = await app.auth();
expect(octokit.request.endpoint.DEFAULTS.baseUrl).toEqual(
"https://notreallygithub.com/api/v3"
"https://notreallygithub.com/api/v3",
);
};
@ -242,7 +257,7 @@ describe("Probot", () => {
const appFn = async (app: Probot) => {
const octokit = await app.auth();
expect(octokit.request.endpoint.DEFAULTS.baseUrl).toEqual(
"https://notreallygithub.com/api/v3"
"https://notreallygithub.com/api/v3",
);
};
@ -261,7 +276,7 @@ describe("Probot", () => {
const appFn = async (app: Probot) => {
const octokit = await app.auth();
expect(octokit.request.endpoint.DEFAULTS.baseUrl).toEqual(
"http://notreallygithub.com/api/v3"
"http://notreallygithub.com/api/v3",
);
};
@ -273,42 +288,48 @@ describe("Probot", () => {
});
});
describe.skip("options.redisConfig as string", () => {
it("sets throttle options", async () => {
expect.assertions(2);
describe.skipIf(process.env.REDIS_URL === undefined)(
"options.redisConfig as string",
() => {
it("sets throttle options", async () => {
expect.assertions(2);
probot = new Probot({
githubToken: "faketoken",
redisConfig: "test",
Octokit: ProbotOctokit.plugin((octokit, options) => {
expect(options.throttle.Bottleneck).toBe(Bottleneck);
expect(options.throttle.connection).toBeInstanceOf(
Bottleneck.IORedisConnection
);
}),
probot = new Probot({
githubToken: "faketoken",
redisConfig: process.env.REDIS_URL,
Octokit: ProbotOctokit.plugin((_octokit, options) => {
expect(options.throttle?.Bottleneck).toBe(Bottleneck);
expect(options.throttle?.connection).toBeInstanceOf(
Bottleneck.IORedisConnection,
);
}),
});
});
});
});
},
);
describe.skip("redis configuration object", () => {
it("sets throttle options", async () => {
expect.assertions(2);
const redisConfig = {
host: "test",
};
describe.skipIf(process.env.REDIS_URL === undefined)(
"redis configuration object",
() => {
it("sets throttle options", async () => {
expect.assertions(2);
const redisConfig = {
host: process.env.REDIS_URL,
};
probot = new Probot({
githubToken: "faketoken",
redisConfig,
Octokit: ProbotOctokit.plugin((octokit, options) => {
expect(options.throttle.Bottleneck).toBe(Bottleneck);
expect(options.throttle.connection).toBeInstanceOf(
Bottleneck.IORedisConnection
);
}),
probot = new Probot({
githubToken: "faketoken",
redisConfig,
Octokit: ProbotOctokit.plugin((_octokit, options) => {
expect(options.throttle?.Bottleneck).toBe(Bottleneck);
expect(options.throttle?.connection).toBeInstanceOf(
Bottleneck.IORedisConnection,
);
}),
});
});
});
});
},
);
describe("on", () => {
beforeEach(() => {
@ -325,7 +346,7 @@ describe("Probot", () => {
privateKey,
});
const spy = jest.fn();
const spy = vi.fn();
probot.on("pull_request", spy);
expect(spy).toHaveBeenCalledTimes(0);
@ -341,9 +362,15 @@ describe("Probot", () => {
privateKey,
});
const spy = jest.fn();
const spy = vi.fn();
probot.on("pull_request.opened", spy);
const event: WebhookEvent<"pull_request.opened"> = {
id: "123-456",
name: "pull_request",
payload: getPayloadExample("pull_request.opened"),
};
await probot.receive(event);
expect(spy).toHaveBeenCalled();
});
@ -354,7 +381,7 @@ describe("Probot", () => {
privateKey,
});
const spy = jest.fn();
const spy = vi.fn();
probot.on("pull_request.closed", spy);
await probot.receive(event);
@ -367,7 +394,7 @@ describe("Probot", () => {
privateKey,
});
const spy = jest.fn();
const spy = vi.fn();
probot.onAny(spy);
await probot.receive(event);
@ -380,13 +407,19 @@ describe("Probot", () => {
privateKey,
});
const event: WebhookEvent<"pull_request.opened"> = {
id: "123-456",
name: "pull_request",
payload: getPayloadExample("pull_request.opened"),
};
const event2: WebhookEvent<"issues.opened"> = {
id: "123",
name: "issues",
payload: getPayloadExample("issues.opened"),
};
const spy = jest.fn();
const spy = vi.fn();
probot.on(["pull_request.opened", "issues.opened"], spy);
await probot.receive(event);
@ -401,7 +434,7 @@ describe("Probot", () => {
log: pino(streamLogsToOutput),
});
const handler = jest.fn().mockImplementation((context) => {
const handler = vi.fn().mockImplementation((context) => {
expect(context.log.info).toBeDefined();
context.log.info("testing");
@ -409,7 +442,7 @@ describe("Probot", () => {
expect.objectContaining({
id: context.id,
msg: "testing",
})
}),
);
});
@ -419,9 +452,40 @@ describe("Probot", () => {
});
it("returns an authenticated client for installation.created", async () => {
const fetch = fetchMock
.sandbox()
.postOnce("https://api.github.com/app/installations/1/access_tokens", {
status: 201,
body: {
token: "v1.1f699f1069f60xxx",
permissions: {
issues: "write",
contents: "read",
},
},
})
.getOnce(
function (url, opts) {
if (url === "https://api.github.com/") {
expect(
(opts.headers as Record<string, string>).authorization,
).toEqual("token v1.1f699f1069f60xxx");
return true;
}
throw new Error("Should have matched");
},
{
status: 200,
body: {},
},
);
const probot = new Probot({
appId,
privateKey,
request: {
fetch,
},
});
event = {
@ -431,32 +495,35 @@ describe("Probot", () => {
};
event.payload.installation.id = 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, {});
probot.on("installation.created", async (context) => {
await context.octokit.request("/");
});
await probot.receive(event);
expect(mock.activeMocks()).toStrictEqual([]);
});
it("returns an unauthenticated client for installation.deleted", async () => {
const fetch = fetchMock.sandbox().getOnce(
function (url, opts) {
if (url === "https://api.github.com/") {
expect(
(opts.headers as Record<string, string>).authorization,
).toEqual(undefined);
return true;
}
throw new Error("Should have matched");
},
{
body: {},
},
);
const probot = new Probot({
appId,
privateKey,
request: {
fetch,
},
});
event = {
@ -466,46 +533,50 @@ describe("Probot", () => {
};
event.payload.installation.id = 1;
const mock = nock("https://api.github.com")
.get("/")
.matchHeader("authorization", (value) => value === undefined)
.reply(200, {});
probot.on("installation.deleted", async (context) => {
await context.octokit.request("/");
});
await probot.receive(event).catch(console.log);
expect(mock.activeMocks()).toStrictEqual([]);
});
it("returns an authenticated client for events without an installation", async () => {
const fetch = fetchMock.sandbox().mock(
function (url, opts) {
if (url === "https://api.github.com/") {
expect(
(opts.headers as Record<string, string>).authorization,
).toEqual(undefined);
return true;
}
throw new Error("Should have matched");
},
{
body: {},
},
);
const probot = new Probot({
appId,
privateKey,
request: {
fetch,
},
});
event = {
id: "123-456",
name: "check_run",
payload: getPayloadExamples("check_run").filter(
(event) => typeof event.installation === "undefined"
(event) => typeof event.installation === "undefined",
)[0],
};
const mock = nock("https://api.github.com")
.get("/")
.matchHeader("authorization", (value) => value === undefined)
.reply(200, {});
probot.on("check_run", async (context) => {
await context.octokit.request("/");
});
await probot.receive(event).catch(console.log);
expect(mock.activeMocks()).toStrictEqual([]);
});
});
@ -524,7 +595,7 @@ describe("Probot", () => {
privateKey,
});
const spy = jest.fn();
const spy = vi.fn();
probot.on("pull_request", spy);
await probot.receive(event);
@ -538,7 +609,7 @@ describe("Probot", () => {
privateKey,
});
const spy = jest.fn();
const spy = vi.fn();
probot.on("pull_request", () => {
return new Promise((resolve) => {
setTimeout(() => {
@ -568,7 +639,7 @@ describe("Probot", () => {
await probot.receive(event);
throw new Error("expected error to be raised from app");
} catch (error) {
expect(error.message).toMatch(/error from app/);
expect((error as Error).message).toMatch(/error from app/);
}
});
});

View File

@ -1,23 +1,24 @@
import { resolveAppFunction } from "../src/helpers/resolve-app-function";
import { resolveAppFunction } from "../src/helpers/resolve-app-function.js";
import { describe, expect, vi, it } from "vitest";
const stubAppFnPath = require.resolve("./fixtures/plugin/stub-plugin");
const stubAppFnPath = require.resolve("./fixtures/plugin/stub-plugin.ts");
const stubTranspiledAppFnPath = require.resolve(
"./fixtures/plugin/stub-typescript-transpiled-plugin"
"./fixtures/plugin/stub-typescript-transpiled-plugin.ts",
);
const basedir = process.cwd();
describe("resolver", () => {
it("loads the module at the resolved path", async () => {
const stubResolver = jest.fn().mockReturnValue(stubAppFnPath);
const stubResolver = vi.fn().mockReturnValue(stubAppFnPath);
const module = await resolveAppFunction("foo", { resolver: stubResolver });
expect(module).toBe(require(stubAppFnPath));
expect(module).toBeInstanceOf(Function);
expect(stubResolver).toHaveBeenCalledWith("foo", { basedir });
});
it("loads module transpiled from TypeScript (https://github.com/probot/probot/issues/1447)", async () => {
const stubResolver = jest.fn().mockReturnValue(stubTranspiledAppFnPath);
const stubResolver = vi.fn().mockReturnValue(stubTranspiledAppFnPath);
const module = await resolveAppFunction("foo", { resolver: stubResolver });
expect(module).toBe(require(stubTranspiledAppFnPath).default);
expect(module).toBeInstanceOf(Function);
expect(stubResolver).toHaveBeenCalledWith("foo", { basedir });
});
});

View File

@ -1,13 +1,16 @@
import path = require("path");
import path from "node:path";
import request from "supertest";
import { sign } from "@octokit/webhooks-methods";
import { describe, expect, it, beforeEach } from "vitest";
import { Probot, run, Server } from "../src";
import { Probot, run, Server } from "../src/index.js";
import { captureLogOutput } from "./helpers/capture-log-output";
import { captureLogOutput } from "./helpers/capture-log-output.js";
import WebhookExamples, {
type WebhookDefinition,
} from "@octokit/webhooks-examples";
// tslint:disable:no-empty
describe("run", () => {
let server: Server;
let env: NodeJS.ProcessEnv;
@ -18,7 +21,7 @@ describe("run", () => {
PRIVATE_KEY_PATH: path.join(
__dirname,
"fixtures",
"test-private-key.pem"
"test-private-key.pem",
),
WEBHOOK_PROXY_URL: "https://smee.io/EfHXC9BFfGAxbM6J",
WEBHOOK_SECRET: "secret",
@ -34,7 +37,7 @@ describe("run", () => {
() => {
initialized = true;
},
{ env }
{ env },
);
expect(initialized).toBeTruthy();
await server.stop();
@ -58,10 +61,10 @@ describe("run", () => {
return new Promise(async (resolve) => {
server = await run(
(app: Probot) => {
(_app: Probot) => {
initialized = true;
},
{ env }
{ env },
);
expect(initialized).toBeFalsy();
await server.stop();
@ -71,33 +74,39 @@ describe("run", () => {
});
it("defaults to JSON logs if NODE_ENV is set to 'production'", async () => {
const outputData = await captureLogOutput(async () => {
env.NODE_ENV = "production";
let outputData = "";
env.NODE_ENV = "production";
server = await run(
(app) => {
server = await run(
async (app) => {
outputData = await captureLogOutput(async () => {
app.log.fatal("test");
},
{ env }
);
await server.stop();
});
}, app.log);
},
{ env },
);
await server.stop();
expect(outputData).toMatch(/"msg":"test"/);
});
});
describe("webhooks", () => {
const pushEvent = require("./fixtures/webhook/push.json");
const pushEvent = (
(WebhookExamples as unknown as WebhookDefinition[]).filter(
(event) => event.name === "push",
)[0] as WebhookDefinition<"push">
).examples[0];
it("POST /", async () => {
it("POST /api/github/webhooks", async () => {
server = await run(() => {}, { env });
const dataString = JSON.stringify(pushEvent);
await request(server.expressApp)
.post("/")
.post("/api/github/webhooks")
.send(dataString)
.set("content-type", "application/json")
.set("x-github-event", "push")
.set("x-hub-signature-256", await sign("secret", dataString))
.set("x-github-delivery", "123")
@ -120,6 +129,7 @@ describe("run", () => {
await request(server.expressApp)
.post("/custom-webhook")
.send(dataString)
.set("content-type", "application/json")
.set("x-github-event", "push")
.set("x-hub-signature-256", await sign("secret", dataString))
.set("x-github-delivery", "123")

View File

@ -1,31 +1,57 @@
import Stream from "stream";
import Stream from "node:stream";
import { NextFunction, Request, Response } from "express";
import type { NextFunction, Request, Response } from "express";
import request from "supertest";
import pino from "pino";
import { pino } from "pino";
import { sign } from "@octokit/webhooks-methods";
import getPort from "get-port";
import WebhookExamples, {
type WebhookDefinition,
} from "@octokit/webhooks-examples";
import { describe, expect, it, beforeEach, test } from "vitest";
import { Server, Probot } from "../src";
import { Server, Probot } from "../src/index.js";
const appId = 1;
const privateKey = `-----BEGIN RSA PRIVATE KEY-----
MIIBOQIBAAJBAIILhiN9IFpaE0pUXsesuuoaj6eeDiAqCiE49WB1tMB8ZMhC37kY
Fl52NUYbUxb7JEf6pH5H9vqw1Wp69u78XeUCAwEAAQJAb88urnaXiXdmnIK71tuo
/TyHBKt9I6Rhfzz0o9Gv7coL7a537FVDvV5UCARXHJMF41tKwj+zlt9EEUw7a1HY
wQIhAL4F/VHWSPHeTgXYf4EaX2OlpSOk/n7lsFtL/6bWRzRVAiEArzJs2vopJitv
A1yBjz3q2nX+zthk+GLXrJQkYOnIk1ECIHfeFV8TWm5gej1LxZquBTA5pINoqDVq
NKZSuZEHqGEFAiB6EDrxkovq8SYGhIQsJeqkTMO8n94xhMRZlFmIQDokEQIgAq5U
r1UQNnUExRh7ZT0kFbMfO9jKYZVlQdCL9Dn93vo=
MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aozN3tihPmljnnudb9G3HECdnH
lWu2/a1gB9JW5TBQ+AVpum9Okx7KfqkfBKL9mcHgSL0yWMdjMfNOqNtrQqKlN4kE
p6RD++7sGbzbfZ9arwrlD/HSDAWGdGGJTSOBM6pHehyLmSC3DJoR/CTu0vTGTWXQ
rO64Z8tyXQPtVPb/YXrcUhbBp8i72b9Xky0fD6PkEebOy0Ip58XVAn2UPNlNOSPS
ye+Qjtius0Md4Nie4+X8kwVI2Qjk3dSm0sw/720KJkdVDmrayeljtKBx6AtNQsSX
gzQbeMmiqFFkwrG1+zx6E7H7jqIQ9B6bvWKXGwIDAQABAoIBAD8kBBPL6PPhAqUB
K1r1/gycfDkUCQRP4DbZHt+458JlFHm8QL6VstKzkrp8mYDRhffY0WJnYJL98tr4
4tohsDbqFGwmw2mIaHjl24LuWXyyP4xpAGDpl9IcusjXBxLQLp2m4AKXbWpzb0OL
Ulrfc1ZooPck2uz7xlMIZOtLlOPjLz2DuejVe24JcwwHzrQWKOfA11R/9e50DVse
hnSH/w46Q763y4I0E3BIoUMsolEKzh2ydAAyzkgabGQBUuamZotNfvJoDXeCi1LD
8yNCWyTlYpJZJDDXooBU5EAsCvhN1sSRoaXWrlMSDB7r/E+aQyKua4KONqvmoJuC
21vSKeECgYEA7yW6wBkVoNhgXnk8XSZv3W+Q0xtdVpidJeNGBWnczlZrummt4xw3
xs6zV+rGUDy59yDkKwBKjMMa42Mni7T9Fx8+EKUuhVK3PVQyajoyQqFwT1GORJNz
c/eYQ6VYOCSC8OyZmsBM2p+0D4FF2/abwSPMmy0NgyFLCUFVc3OECpkCgYEA5OAm
I3wt5s+clg18qS7BKR2DuOFWrzNVcHYXhjx8vOSWV033Oy3yvdUBAhu9A1LUqpwy
Ma+unIgxmvmUMQEdyHQMcgBsVs10dR/g2xGjMLcwj6kn+xr3JVIZnbRT50YuPhf+
ns1ScdhP6upo9I0/sRsIuN96Gb65JJx94gQ4k9MCgYBO5V6gA2aMQvZAFLUicgzT
u/vGea+oYv7tQfaW0J8E/6PYwwaX93Y7Q3QNXCoCzJX5fsNnoFf36mIThGHGiHY6
y5bZPPWFDI3hUMa1Hu/35XS85kYOP6sGJjf4kTLyirEcNKJUWH7CXY+00cwvTkOC
S4Iz64Aas8AilIhRZ1m3eQKBgQCUW1s9azQRxgeZGFrzC3R340LL530aCeta/6FW
CQVOJ9nv84DLYohTVqvVowdNDTb+9Epw/JDxtDJ7Y0YU0cVtdxPOHcocJgdUGHrX
ZcJjRIt8w8g/s4X6MhKasBYm9s3owALzCuJjGzUKcDHiO2DKu1xXAb0SzRcTzUCn
7daCswKBgQDOYPZ2JGmhibqKjjLFm0qzpcQ6RPvPK1/7g0NInmjPMebP0K6eSPx0
9/49J6WTD++EajN7FhktUSYxukdWaCocAQJTDNYP0K88G4rtC2IYy5JFn9SWz5oh
x//0u+zd/R/QRUzLOw4N72/Hu+UG6MNt5iDZFCtapRaKt6OvSBwy8w==
-----END RSA PRIVATE KEY-----`;
const pushEvent = require("./fixtures/webhook/push.json");
const pushEvent = (
(WebhookExamples as unknown as WebhookDefinition[]).filter(
(event) => event.name === "push",
)[0] as WebhookDefinition<"push">
).examples[0];
describe("Server", () => {
let server: Server;
let output: any[];
const streamLogsToOutput = new Stream.Writable({ objectMode: true });
streamLogsToOutput._write = (object, encoding, done) => {
streamLogsToOutput._write = (object, _encoding, done) => {
output.push(JSON.parse(object));
done();
};
@ -45,9 +71,9 @@ describe("Server", () => {
// Error handler to avoid printing logs
server.expressApp.use(
(error: Error, req: Request, res: Response, next: NextFunction) => {
(error: Error, _req: Request, res: Response, _next: NextFunction) => {
res.status(500).send(error.message);
}
},
);
});
@ -63,7 +89,60 @@ describe("Server", () => {
});
});
describe("webhook handler (POST /)", () => {
describe("webhook handler by providing webhookPath (POST /)", () => {
it("should return 200 and run event handlers in app function", async () => {
expect.assertions(3);
server = new Server({
webhookPath: "/",
Probot: Probot.defaults({
appId,
privateKey,
secret: "secret",
}),
log: pino(streamLogsToOutput),
port: await getPort(),
});
await server.load((app) => {
app.on("push", (event) => {
expect(event.name).toEqual("push");
});
});
const dataString = JSON.stringify(pushEvent);
await request(server.expressApp)
.post("/")
.send(dataString)
.set("content-type", "application/json")
.set("x-github-event", "push")
.set("x-hub-signature-256", await sign("secret", dataString))
.set("x-github-delivery", "3sw4d5f6g7h8");
expect(output.length).toEqual(1);
expect(output[0].msg).toContain("POST / 200 -");
});
test("respond with a friendly error when x-hub-signature-256 is missing", async () => {
await server.load(() => {});
await request(server.expressApp)
.post("/api/github/webhooks")
.send(JSON.stringify(pushEvent))
.set("content-type", "application/json")
.set("x-github-event", "push")
.set("content-type", "application/json")
// Note: 'x-hub-signature-256' is missing
.set("x-github-delivery", "3sw4d5f6g7h8")
.expect(
400,
'{"error":"Required headers missing: x-hub-signature-256"}',
);
});
});
describe("webhook handler (POST /api/github/webhooks)", () => {
it("should return 200 and run event handlers in app function", async () => {
expect.assertions(3);
@ -86,28 +165,31 @@ describe("Server", () => {
const dataString = JSON.stringify(pushEvent);
await request(server.expressApp)
.post("/")
.post("/api/github/webhooks")
.send(dataString)
.set("content-type", "application/json")
.set("x-github-event", "push")
.set("x-hub-signature-256", await sign("secret", dataString))
.set("x-github-delivery", "3sw4d5f6g7h8");
expect(output.length).toEqual(1);
expect(output[0].msg).toContain("POST / 200 -");
expect(output[0].msg).toContain("POST /api/github/webhooks 200 -");
});
test("respond with a friendly error when x-hub-signature-256 is missing", async () => {
await server.load(() => {});
await request(server.expressApp)
.post("/")
.post("/api/github/webhooks")
.send(JSON.stringify(pushEvent))
.set("content-type", "application/json")
.set("x-github-event", "push")
.set("content-type", "application/json")
// Note: 'x-hub-signature-256' is missing
.set("x-github-delivery", "3sw4d5f6g7h8")
.expect(
400,
'{"error":"Required headers missing: x-hub-signature-256"}'
'{"error":"Required headers missing: x-hub-signature-256"}',
);
});
});
@ -121,30 +203,31 @@ describe("Server", () => {
});
describe(".start() / .stop()", () => {
it("should expect the correct error if port already in use", (next) => {
expect.assertions(1);
it("should expect the correct error if port already in use", () =>
new Promise<void>((next) => {
expect.assertions(1);
// block port 3001
const http = require("http");
const blockade = http.createServer().listen(3001, async () => {
const server = new Server({
Probot: Probot.defaults({ appId, privateKey }),
log: pino(streamLogsToOutput),
port: 3001,
// block port 3001
const http = require("http");
const blockade = http.createServer().listen(3001, async () => {
const server = new Server({
Probot: Probot.defaults({ appId, privateKey }),
log: pino(streamLogsToOutput),
port: 3001,
});
try {
await server.start();
} catch (error) {
expect((error as Error).message).toEqual(
"Port 3001 is already in use. You can define the PORT environment variable to use a different port.",
);
}
await server.stop();
blockade.close(() => next());
});
try {
await server.start();
} catch (error) {
expect(error.message).toEqual(
"Port 3001 is already in use. You can define the PORT environment variable to use a different port."
);
}
await server.stop();
blockade.close(() => next());
});
});
}));
it("should listen to port when not in use", async () => {
const testApp = new Server({
@ -179,21 +262,38 @@ describe("Server", () => {
describe("router", () => {
it("prefixes paths with route name", () => {
const router = server.router("/my-app");
router.get("/foo", (req, res) => res.end("foo"));
router.get("/foo", (_req, res) => res.end("foo"));
return request(server.expressApp).get("/my-app/foo").expect(200, "foo");
});
it("allows routes with no path", () => {
const router = server.router();
router.get("/foo", (req, res) => res.end("foo"));
router.get("/foo", (_req, res) => res.end("foo"));
return request(server.expressApp).get("/foo").expect(200, "foo");
});
it("allows you to overwrite the root path", () => {
it("allows you to overwrite the root path when webhookPath is not defined", () => {
const log = pino(streamLogsToOutput);
server = new Server({
Probot: Probot.defaults({
appId,
privateKey,
secret: "secret",
log: log.child({ name: "probot" }),
}),
log: log.child({ name: "server" }),
});
// Error handler to avoid printing logs
server.expressApp.use(
(error: Error, _req: Request, res: Response, _next: NextFunction) => {
res.status(500).send(error.message);
},
);
const router = server.router();
router.get("/", (req, res) => res.end("foo"));
router.get("/", (_req, res) => res.end("foo"));
return request(server.expressApp).get("/").expect(200, "foo");
});
@ -202,12 +302,12 @@ describe("Server", () => {
["foo", "bar"].forEach((name) => {
const router = server.router("/" + name);
router.use((req, res, next) => {
router.use((_req, res, next) => {
res.append("X-Test", name);
next();
});
router.get("/hello", (req, res) => res.end(name));
router.get("/hello", (_req, res) => res.end(name));
});
await request(server.expressApp)

View File

@ -1,11 +1,12 @@
import Stream from "stream";
import Stream from "node:stream";
import express from "express";
import request from "supertest";
import pino from "pino";
import { Options } from "pino-http";
import { pino } from "pino";
import type { Options } from "pino-http";
import { describe, expect, test, beforeEach } from "vitest";
import { getLoggingMiddleware } from "../../src/server/logging-middleware";
import { getLoggingMiddleware } from "../../src/server/logging-middleware.js";
describe("logging", () => {
let server: express.Express;
@ -13,7 +14,7 @@ describe("logging", () => {
let options: Options;
const streamLogsToOutput = new Stream.Writable({ objectMode: true });
streamLogsToOutput._write = (object, encoding, done) => {
streamLogsToOutput._write = (object, _encoding, done) => {
output.push(JSON.parse(object));
done();
};
@ -22,11 +23,11 @@ describe("logging", () => {
function applyMiddlewares() {
server.use(express.json());
server.use(getLoggingMiddleware(logger, options));
server.get("/", (req, res) => {
server.get("/", (_req, res) => {
res.set("X-Test-Header", "testing");
res.send("OK");
});
server.post("/", (req, res) => res.send("OK"));
server.post("/", (_req, res) => res.send("OK"));
}
beforeEach(() => {
@ -40,7 +41,7 @@ describe("logging", () => {
return request(server)
.get("/")
.expect(200)
.expect((res) => {
.expect((_res) => {
// logs id with request and response
expect(output[0].req.id).toBeTruthy();
expect(typeof output[0].responseTime).toEqual("number");
@ -54,7 +55,7 @@ describe("logging", () => {
method: "GET",
remoteAddress: "::ffff:127.0.0.1",
url: "/",
})
}),
);
expect(output[0].res).toEqual(
@ -62,7 +63,7 @@ describe("logging", () => {
headers: expect.objectContaining({
"x-test-header": "testing",
}),
})
}),
);
});
});
@ -73,7 +74,7 @@ describe("logging", () => {
.get("/")
.set("X-Request-ID", "42")
.expect(200)
.expect((res) => {
.expect((_res) => {
expect(output[0].req.id).toEqual("42");
});
});
@ -84,7 +85,7 @@ describe("logging", () => {
.get("/")
.set("X-GitHub-Delivery", "a-b-c")
.expect(200)
.expect((res) => {
.expect((_res) => {
expect(output[0].req.id).toEqual("a-b-c");
});
});
@ -92,14 +93,14 @@ describe("logging", () => {
test("sets ignorePaths option to ignore logging", () => {
options = {
autoLogging: {
ignorePaths: ["/"],
ignore: (req) => ["/"].includes(req.url!),
},
};
applyMiddlewares();
return request(server)
.get("/")
.expect(200)
.expect((res) => {
.expect((_res) => {
expect(output.length).toEqual(0);
});
});

View File

@ -1,6 +1,6 @@
import { RepositoryEditedEvent } from "@octokit/webhooks-types";
import { expectType } from "tsd";
import { Probot } from "../../src";
import { Probot } from "../../src/index.js";
const app = new Probot({});

View File

@ -0,0 +1,262 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`importView > only providing GH_HOST 1`] = `
"<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>Import Your App | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full py-6\\">
<a href=\\"/probot\\"><img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\"></a>
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<h2>Use existing Github App</h2>
<br>
<h3>Step 1:</h3>
<p class=\\"d-block mt-2\\">
Replace your app's Webhook URL with <br>
<b></b>
</p>
<a class=\\"d-block mt-2\\" href=\\"https://github.com/settings/apps\\" target=\\"__blank\\" rel=\\"noreferrer\\">
You can do it here
</a>
<br>
<h3>Step 2:</h3>
<p class=\\"mt-2\\">Fill out this form</p>
<form onsubmit=\\"return onSubmit(event) || false\\">
<label class=\\"d-block mt-2\\" for=\\"appId\\">App Id</label>
<input class=\\"form-control width-full\\" type=\\"text\\" required=\\"true\\" id=\\"appId\\" name=\\"appId\\"><br>
<label class=\\"d-block mt-3\\" for=\\"whs\\">Webhook secret (required!)</label>
<input class=\\"form-control width-full\\" type=\\"password\\" required=\\"true\\" id=\\"whs\\" name=\\"whs\\"><br>
<label class=\\"d-block mt-3\\" for=\\"pem\\">Private Key</label>
<input class=\\"form-control width-full m-2\\" type=\\"file\\" accept=\\".pem\\" required=\\"true\\" id=\\"pem\\"
name=\\"pem\\">
<br>
<button class=\\"btn btn-outline m-2\\" type=\\"submit\\">Submit</button>
</form>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
<script>
function onSubmit(e) {
e.preventDefault();
const idEl = document.getElementById('appId');
const appId = idEl.value;
const secretEl = document.getElementById('whs');
const webhook_secret = secretEl.value;
const fileEl = document.getElementById('pem');
const file = fileEl.files[0];
file.text().then((text) => fetch('', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ appId, pem: text, webhook_secret })
})).then((r) => {
if (r.ok) {
location.replace('/probot/success');
}
}).catch((e) => alert(e));
return false;
}
</script>
</body>
</html>"
`;
exports[`importView > providing "My App" as name 1`] = `
"<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>Import My App | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full py-6\\">
<a href=\\"/probot\\"><img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\"></a>
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<h2>Use existing Github App</h2>
<br>
<h3>Step 1:</h3>
<p class=\\"d-block mt-2\\">
Replace your app's Webhook URL with <br>
<b></b>
</p>
<a class=\\"d-block mt-2\\" href=\\"https://github.com/settings/apps\\" target=\\"__blank\\" rel=\\"noreferrer\\">
You can do it here
</a>
<br>
<h3>Step 2:</h3>
<p class=\\"mt-2\\">Fill out this form</p>
<form onsubmit=\\"return onSubmit(event) || false\\">
<label class=\\"d-block mt-2\\" for=\\"appId\\">App Id</label>
<input class=\\"form-control width-full\\" type=\\"text\\" required=\\"true\\" id=\\"appId\\" name=\\"appId\\"><br>
<label class=\\"d-block mt-3\\" for=\\"whs\\">Webhook secret (required!)</label>
<input class=\\"form-control width-full\\" type=\\"password\\" required=\\"true\\" id=\\"whs\\" name=\\"whs\\"><br>
<label class=\\"d-block mt-3\\" for=\\"pem\\">Private Key</label>
<input class=\\"form-control width-full m-2\\" type=\\"file\\" accept=\\".pem\\" required=\\"true\\" id=\\"pem\\"
name=\\"pem\\">
<br>
<button class=\\"btn btn-outline m-2\\" type=\\"submit\\">Submit</button>
</form>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
<script>
function onSubmit(e) {
e.preventDefault();
const idEl = document.getElementById('appId');
const appId = idEl.value;
const secretEl = document.getElementById('whs');
const webhook_secret = secretEl.value;
const fileEl = document.getElementById('pem');
const file = fileEl.files[0];
file.text().then((text) => fetch('', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ appId, pem: text, webhook_secret })
})).then((r) => {
if (r.ok) {
location.replace('/probot/success');
}
}).catch((e) => alert(e));
return false;
}
</script>
</body>
</html>"
`;
exports[`importView > providing a smee-url as WEBHOOK_PROXY_URL 1`] = `
"<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>Import Your App | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full py-6\\">
<a href=\\"/probot\\"><img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\"></a>
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<h2>Use existing Github App</h2>
<br>
<h3>Step 1:</h3>
<p class=\\"d-block mt-2\\">
Replace your app's Webhook URL with <br>
<b>https://smee.io/1234</b>
</p>
<a class=\\"d-block mt-2\\" href=\\"https://github.com/settings/apps\\" target=\\"__blank\\" rel=\\"noreferrer\\">
You can do it here
</a>
<br>
<h3>Step 2:</h3>
<p class=\\"mt-2\\">Fill out this form</p>
<form onsubmit=\\"return onSubmit(event) || false\\">
<label class=\\"d-block mt-2\\" for=\\"appId\\">App Id</label>
<input class=\\"form-control width-full\\" type=\\"text\\" required=\\"true\\" id=\\"appId\\" name=\\"appId\\"><br>
<label class=\\"d-block mt-3\\" for=\\"whs\\">Webhook secret (required!)</label>
<input class=\\"form-control width-full\\" type=\\"password\\" required=\\"true\\" id=\\"whs\\" name=\\"whs\\"><br>
<label class=\\"d-block mt-3\\" for=\\"pem\\">Private Key</label>
<input class=\\"form-control width-full m-2\\" type=\\"file\\" accept=\\".pem\\" required=\\"true\\" id=\\"pem\\"
name=\\"pem\\">
<br>
<button class=\\"btn btn-outline m-2\\" type=\\"submit\\">Submit</button>
</form>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
<script>
function onSubmit(e) {
e.preventDefault();
const idEl = document.getElementById('appId');
const appId = idEl.value;
const secretEl = document.getElementById('whs');
const webhook_secret = secretEl.value;
const fileEl = document.getElementById('pem');
const file = fileEl.files[0];
file.text().then((text) => fetch('', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ appId, pem: text, webhook_secret })
})).then((r) => {
if (r.ok) {
location.replace('/probot/success');
}
}).catch((e) => alert(e));
return false;
}
</script>
</body>
</html>"
`;

View File

@ -0,0 +1,138 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`probotView > not providing parameters 1`] = `
"<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>Your App | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"height-full bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full\\">
<img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\">
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<h1>
Welcome to your Probot App
</h1>
<p>This bot was built using <a href=\\"https://github.com/probot/probot\\">Probot</a>, a framework for building GitHub Apps.</p>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>"
`;
exports[`probotView > providing description 1`] = `
"<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>Your App | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"height-full bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full\\">
<img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\">
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<h1>
Welcome to your Probot App
</h1>
<p>My App with Probot</p>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>"
`;
exports[`probotView > providing description 2`] = `
"<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>Your App | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"height-full bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full\\">
<img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\">
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<h1>
Welcome to your Probot App
<span class=\\"Label Label--outline v-align-middle ml-2 text-gray-light\\">v1.0.0</span>
</h1>
<p>This bot was built using <a href=\\"https://github.com/probot/probot\\">Probot</a>, a framework for building GitHub Apps.</p>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>"
`;
exports[`probotView > providing name 1`] = `
"<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>My App | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"height-full bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full\\">
<img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\">
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<h1>
Welcome to My App
</h1>
<p>This bot was built using <a href=\\"https://github.com/probot/probot\\">Probot</a>, a framework for building GitHub Apps.</p>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>"
`;

View File

@ -0,0 +1,193 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`setupView > provide also description 1`] = `
"<!DOCTYPE html>
<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>Setup Your App | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"height-full bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full\\">
<img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\">
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<h1>
Welcome to your Probot App
</h1>
<p>Awesome App with Probot</p>
<div class=\\"text-left mt-6\\">
<h2 class=\\"alt-h3 mb-2\\">Getting Started</h2>
<p>To start building a GitHub App, you'll need to register a new app on GitHub.</p>
<br>
<form action=\\"https://github.com/organizations/probot/settings/apps/new\\" method=\\"post\\" target=\\"_blank\\" class=\\"d-flex flex-items-center\\">
<button class=\\"btn btn-outline\\" name=\\"manifest\\" id=\\"manifest\\" value='{\\"name\\":\\"My App\\"}' >Register GitHub App</button>
<a href=\\"/probot/import\\" class=\\"ml-2\\">or use an existing Github App</a>
</form>
</div>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>"
`;
exports[`setupView > provide also name 1`] = `
"<!DOCTYPE html>
<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>Setup My App | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"height-full bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full\\">
<img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\">
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<h1>
Welcome to My App
</h1>
<p>This app was built using <a href=\\"https://github.com/probot/probot\\">Probot</a>, a framework for building GitHub Apps.</p>
<div class=\\"text-left mt-6\\">
<h2 class=\\"alt-h3 mb-2\\">Getting Started</h2>
<p>To start building a GitHub App, you'll need to register a new app on GitHub.</p>
<br>
<form action=\\"https://github.com/organizations/probot/settings/apps/new\\" method=\\"post\\" target=\\"_blank\\" class=\\"d-flex flex-items-center\\">
<button class=\\"btn btn-outline\\" name=\\"manifest\\" id=\\"manifest\\" value='{\\"name\\":\\"My App\\"}' >Register GitHub App</button>
<a href=\\"/probot/import\\" class=\\"ml-2\\">or use an existing Github App</a>
</form>
</div>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>"
`;
exports[`setupView > provide also version 1`] = `
"<!DOCTYPE html>
<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>Setup Your App | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"height-full bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full\\">
<img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\">
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<h1>
Welcome to your Probot App
<span class=\\"Label Label--outline v-align-middle ml-2 text-gray-light\\">v1.0.0</span>
</h1>
<p>This app was built using <a href=\\"https://github.com/probot/probot\\">Probot</a>, a framework for building GitHub Apps.</p>
<div class=\\"text-left mt-6\\">
<h2 class=\\"alt-h3 mb-2\\">Getting Started</h2>
<p>To start building a GitHub App, you'll need to register a new app on GitHub.</p>
<br>
<form action=\\"https://github.com/organizations/probot/settings/apps/new\\" method=\\"post\\" target=\\"_blank\\" class=\\"d-flex flex-items-center\\">
<button class=\\"btn btn-outline\\" name=\\"manifest\\" id=\\"manifest\\" value='{\\"name\\":\\"My App\\"}' >Register GitHub App</button>
<a href=\\"/probot/import\\" class=\\"ml-2\\">or use an existing Github App</a>
</form>
</div>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>"
`;
exports[`setupView > providing bare minimum 1`] = `
"<!DOCTYPE html>
<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>Setup Your App | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"height-full bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full\\">
<img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\">
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<h1>
Welcome to your Probot App
</h1>
<p>This app was built using <a href=\\"https://github.com/probot/probot\\">Probot</a>, a framework for building GitHub Apps.</p>
<div class=\\"text-left mt-6\\">
<h2 class=\\"alt-h3 mb-2\\">Getting Started</h2>
<p>To start building a GitHub App, you'll need to register a new app on GitHub.</p>
<br>
<form action=\\"https://github.com/organizations/probot/settings/apps/new\\" method=\\"post\\" target=\\"_blank\\" class=\\"d-flex flex-items-center\\">
<button class=\\"btn btn-outline\\" name=\\"manifest\\" id=\\"manifest\\" value='{\\"name\\":\\"My App\\"}' >Register GitHub App</button>
<a href=\\"/probot/import\\" class=\\"ml-2\\">or use an existing Github App</a>
</form>
</div>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>"
`;

View File

@ -0,0 +1,69 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`setupView > not providing name 1`] = `
"<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>Setup Your App | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"height-full bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full\\">
<img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\">
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<div class=\\"text-center\\">
<h1 class=\\"alt-h3 mb-2\\">Congrats! You have successfully installed your app!
<br>
Checkout <a href=\\"https://probot.github.io/docs/webhooks/\\">Receiving webhooks</a> and <a href=\\"https://probot.github.io/docs/github-api/\\">Interacting with GitHub</a> to learn more!</h1>
</div>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>"
`;
exports[`setupView > providing with name 1`] = `
"<!DOCTYPE html>
<html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
<head>
<meta charset=\\"UTF-8\\">
<meta name=\\"viewport\\" content=\\"width=device-width, initial-scale=1.0\\">
<meta http-equiv=\\"X-UA-Compatible\\" content=\\"ie=edge\\">
<title>Setup My App | built with Probot</title>
<link rel=\\"icon\\" href=\\"/probot/static/probot-head.png\\">
<link rel=\\"stylesheet\\" href=\\"/probot/static/primer.css\\">
</head>
<body class=\\"height-full bg-gray-light\\">
<div class=\\"d-flex flex-column flex-justify-center flex-items-center text-center height-full\\">
<img src=\\"/probot/static/robot.svg\\" alt=\\"Probot Logo\\" width=\\"100\\" class=\\"mb-6\\">
<div class=\\"box-shadow rounded-2 border p-6 bg-white\\">
<div class=\\"text-center\\">
<h1 class=\\"alt-h3 mb-2\\">Congrats! You have successfully installed your app!
<br>
Checkout <a href=\\"https://probot.github.io/docs/webhooks/\\">Receiving webhooks</a> and <a href=\\"https://probot.github.io/docs/github-api/\\">Interacting with GitHub</a> to learn more!</h1>
</div>
</div>
<div class=\\"mt-4\\">
<h4 class=\\"alt-h4 text-gray-light\\">Need help?</h4>
<div class=\\"d-flex flex-justify-center mt-2\\">
<a href=\\"https://probot.github.io/docs/\\" class=\\"btn btn-outline mr-2\\">Documentation</a>
<a href=\\"https://github.com/probot/probot/discussions\\" class=\\"btn btn-outline\\">Discuss on GitHub</a>
</div>
</div>
</div>
</body>
</html>"
`;

31
test/views/import.test.ts Normal file
View File

@ -0,0 +1,31 @@
import { describe, expect, test } from "vitest";
import { importView } from "../../src/views/import.js";
describe("importView", () => {
test("only providing GH_HOST ", () => {
expect(
importView({
GH_HOST: "https://github.com",
}),
).toMatchSnapshot();
});
test('providing "My App" as name ', () => {
expect(
importView({
name: "My App",
GH_HOST: "https://github.com",
}),
).toMatchSnapshot();
});
test("providing a smee-url as WEBHOOK_PROXY_URL ", () => {
expect(
importView({
WEBHOOK_PROXY_URL: "https://smee.io/1234",
GH_HOST: "https://github.com",
}),
).toMatchSnapshot();
});
});

33
test/views/probot.test.ts Normal file
View File

@ -0,0 +1,33 @@
import { describe, expect, test } from "vitest";
import { probotView } from "../../src/views/probot.js";
describe("probotView", () => {
test("not providing parameters", () => {
expect(probotView({})).toMatchSnapshot();
});
test("providing name", () => {
expect(
probotView({
name: "My App",
}),
).toMatchSnapshot();
});
test("providing description", () => {
expect(
probotView({
description: "My App with Probot",
}),
).toMatchSnapshot();
});
test("providing description", () => {
expect(
probotView({
version: "1.0.0",
}),
).toMatchSnapshot();
});
});

48
test/views/setup.test.ts Normal file
View File

@ -0,0 +1,48 @@
import { describe, expect, test } from "vitest";
import { setupView } from "../../src/views/setup.js";
describe("setupView", () => {
test("providing bare minimum ", () => {
expect(
setupView({
createAppUrl:
"https://github.com/organizations/probot/settings/apps/new",
manifest: JSON.stringify({ name: "My App" }),
}),
).toMatchSnapshot();
});
test("provide also name", () => {
expect(
setupView({
name: "My App",
createAppUrl:
"https://github.com/organizations/probot/settings/apps/new",
manifest: JSON.stringify({ name: "My App" }),
}),
).toMatchSnapshot();
});
test("provide also version", () => {
expect(
setupView({
version: "1.0.0",
createAppUrl:
"https://github.com/organizations/probot/settings/apps/new",
manifest: JSON.stringify({ name: "My App" }),
}),
).toMatchSnapshot();
});
test("provide also description", () => {
expect(
setupView({
description: "Awesome App with Probot",
createAppUrl:
"https://github.com/organizations/probot/settings/apps/new",
manifest: JSON.stringify({ name: "My App" }),
}),
).toMatchSnapshot();
});
});

View File

@ -0,0 +1,16 @@
import { describe, expect, test } from "vitest";
import { successView } from "../../src/views/success.js";
describe("setupView", () => {
test("not providing name ", () => {
expect(successView({})).toMatchSnapshot();
});
test("providing with name ", () => {
expect(
successView({
name: "My App",
}),
).toMatchSnapshot();
});
});

View File

@ -1,28 +1,29 @@
import express, { Response } from "express";
// tslint:disable-next-line:no-var-requires
import { randomInt } from "node:crypto";
import http from "node:http";
import net from "node:net";
import express, { type Response } from "express";
const sse: (
req: express.Request,
res: express.Response,
next: express.NextFunction
next: express.NextFunction,
) => void = require("connect-sse")();
import fetchMock from "fetch-mock";
import EventSource from "eventsource";
import http from "http";
import net from "net";
import nock from "nock";
import { getLog } from "../src/helpers/get-log";
import { createWebhookProxy } from "../src/helpers/webhook-proxy";
import { describe, expect, afterEach, test, vi } from "vitest";
import { getLog } from "../src/helpers/get-log.js";
import { createWebhookProxy } from "../src/helpers/webhook-proxy.js";
const targetPort = 999999;
let targetPort = 999999;
interface SSEResponse extends Response {
json(body: any, status?: string): this;
}
jest.setTimeout(10000);
describe("webhook-proxy", () => {
// tslint:disable-next-line:one-variable-per-declaration
let emit: SSEResponse["json"], proxy: EventSource, server: http.Server;
let emit: SSEResponse["json"];
let proxy: EventSource;
let server: http.Server;
afterEach(() => {
server && server.close();
@ -30,59 +31,111 @@ describe("webhook-proxy", () => {
});
describe("with a valid proxy server", () => {
beforeEach((done) => {
test("forwards events to server", async () => {
let readyPromise = {
promise: undefined,
reject: undefined,
resolve: undefined,
} as {
promise?: Promise<any>;
resolve?: (value?: any) => any;
reject?: (reason?: any) => any;
};
readyPromise.promise = new Promise((resolve, reject) => {
readyPromise.resolve = resolve;
readyPromise.reject = reject;
});
let finishedPromise = {
promise: undefined,
reject: undefined,
resolve: undefined,
} as {
promise?: Promise<any>;
resolve?: (value?: any) => any;
reject?: (reason?: any) => any;
};
finishedPromise.promise = new Promise((resolve, reject) => {
finishedPromise.resolve = resolve;
finishedPromise.reject = reject;
});
const app = express();
app.get("/events", sse, (req, res: SSEResponse) => {
app.get("/events", sse, (_req, res: SSEResponse) => {
res.json({}, "ready");
emit = res.json;
});
server = app.listen(0, () => {
const url = `http://127.0.0.1:${
(server.address() as net.AddressInfo).port
}/events`;
proxy = createWebhookProxy({
server = app.listen(0, async () => {
targetPort = (server.address() as net.AddressInfo).port;
const url = `http://127.0.0.1:${targetPort}/events`;
const fetch = fetchMock
.sandbox()
.postOnce(`http://localhost:${targetPort}/test`, {
status: 200,
then: () => {
finishedPromise.resolve!();
},
});
proxy = (await createWebhookProxy({
url,
port: targetPort,
path: "/test",
logger: getLog({ level: "fatal" }),
})!;
fetch,
})) as EventSource;
// Wait for proxy to be ready
proxy.addEventListener("ready", () => done());
proxy.addEventListener("ready", readyPromise.resolve!);
});
});
test("forwards events to server", (done) => {
nock(`http://localhost:${targetPort}`)
.post("/test")
.reply(200, () => {
done();
});
const body = { action: "foo" };
await readyPromise.promise;
emit({
body,
body: { action: "foo" },
"x-github-event": "test",
});
await finishedPromise.promise;
});
});
test("logs an error when the proxy server is not found", (done) => {
const url = "http://bad.proxy/events";
nock("http://bad.proxy").get("/events").reply(404);
test("logs an error when the proxy server is not found", async () => {
expect.assertions(2);
const log = getLog({ level: "fatal" }).child({});
log.error = jest.fn();
let finishedPromise = {
promise: undefined,
reject: undefined,
resolve: undefined,
} as {
promise?: Promise<any>;
resolve?: (value?: any) => any;
reject?: (reason?: any) => any;
};
proxy = createWebhookProxy({ url, logger: log })!;
proxy.addEventListener("error", (error: any) => {
expect(error.status).toBe(404);
expect(log.error).toHaveBeenCalledWith(error);
done();
finishedPromise.promise = new Promise((resolve, reject) => {
finishedPromise.resolve = resolve;
finishedPromise.reject = reject;
});
const url = `http://bad.n${randomInt(1e10).toString(36)}.proxy/events`;
const logger = getLog({ level: "fatal" }).child({});
logger.error = vi.fn() as any;
createWebhookProxy({ url, logger })!.then((proxy) => {
(proxy as EventSource).addEventListener("error", (error: any) => {
expect(error.message).toMatch(/^getaddrinfo ENOTFOUND/);
expect(logger.error).toHaveBeenCalledWith(error);
finishedPromise.resolve!();
});
});
await finishedPromise.promise;
});
});

View File

@ -1,6 +1,8 @@
{
"extends": "@tsconfig/node10/tsconfig.json",
"extends": "@octokit/tsconfig",
"compilerOptions": {
"module": "Node16",
"verbatimModuleSyntax": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"noUnusedLocals": true,
@ -12,7 +14,10 @@
"noImplicitAny": true,
"esModuleInterop": true,
"declaration": true,
"resolveJsonModule": true
"resolveJsonModule": true,
"allowJs": true,
"lib": ["es2023", "dom"],
"moduleResolution": "node16"
},
"include": ["src/**/*"],
"compileOnSave": false

10
vitest.config.ts Normal file
View File

@ -0,0 +1,10 @@
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
include: ['test/**/*.test.ts'],
coverage: {
provider: "v8",
},
},
})