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

View File

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

View File

@ -1,5 +1,5 @@
name: Test name: Test
"on": on:
push: push:
branches: branches:
- master - master
@ -7,43 +7,140 @@ name: Test
types: types:
- opened - opened
- synchronize - synchronize
permissions:
contents: read
jobs: 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: strategy:
matrix: matrix:
node-version: node-version:
- 10 - 18
- 12 - 20
- 14 - 21
os: os:
- ubuntu-latest - ubuntu-latest
- macos-latest - macos-latest
- windows-latest - windows-latest
fail-fast: false
runs-on: ${{ matrix.os }} runs-on: ${{ matrix.os }}
steps: steps:
- uses: actions/checkout@v2 - name: Check out repository
uses: actions/checkout@v4
with:
persist-credentials: false
- name: Use Node.js ${{ matrix.node-version }} - name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v2 uses: actions/setup-node@v4
with: with:
node-version: ${{ matrix.node-version }} node-version: ${{ matrix.node-version }}
cache: npm cache: npm
- run: npm ci - name: Install dependencies
- run: npm run build run: npm ci
- run: npx jest - 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: test:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: test_matrix needs:
- test-unit
- test-redis
steps: steps:
- uses: actions/checkout@v2 - run: exit 1
- uses: actions/setup-node@v2 if: ${{ needs.test-unit.result != 'success' || needs.test-redis.result != 'success' }}
- name: Check out repository
uses: actions/checkout@v4
with: with:
persist-credentials: false
- name: Use Node.js LTS
uses: actions/setup-node@v4
with:
node-version: lts/*
cache: npm cache: npm
node-version: 16 - name: Install Dependencies
- run: npm ci run: npm ci
- run: npm run lint - name: Build
- run: npm run build run: npm run build
- run: npx jest - name: Test
- name: codecov run: npm run test:coverage
- name: Update Codecov
run: npx codecov run: npx codecov
env: env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} 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. 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 ## 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

@ -8,7 +8,7 @@ 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: 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 | | 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> | | `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> | | `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 options** | |
@ -20,7 +20,7 @@ For more on the set up of these items, check out [Configuring a GitHub App](/doc
Some less common environment variables are: Some less common environment variables are:
| Environment Variable | [Programmatic Argument](/docs/development/#run-probot-programmatically) | Description | | 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_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> | | `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). | | `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). |
@ -31,7 +31,7 @@ Some less common environment variables are:
| `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> | | `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` | | `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. | | `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: `/` | | `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> | | `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). 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 # 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:** **Contents:**
@ -224,7 +224,7 @@ const app = require("./index.js");
module.exports = createNodeMiddleware(app, { probot: createProbot() }); 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 ```js
module.exports = createNodeMiddleware(app, { module.exports = createNodeMiddleware(app, {

View File

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

View File

@ -17,7 +17,7 @@ module.exports = (app) => {
response.data.issues.forEach((issue) => { response.data.issues.forEach((issue) => {
context.log.info("Issue: %s", issue.title); context.log.info("Issue: %s", issue.title);
}); });
} },
); );
}); });
}; };
@ -33,7 +33,7 @@ module.exports = (app) => {
const allIssues = await context.octokit.paginate( const allIssues = await context.octokit.paginate(
context.octokit.issues.list, context.octokit.issues.list,
context.repo(), context.repo(),
(response) => response.data (response) => response.data,
); );
console.log(allIssues); console.log(allIssues);
}); });
@ -58,7 +58,7 @@ module.exports = (app) => {
break; break;
} }
} }
} },
); );
}); });
}; };
@ -66,14 +66,14 @@ module.exports = (app) => {
## Async iterators ## 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 ```js
module.exports = (app) => { module.exports = (app) => {
app.on("issues.opened", async (context) => { app.on("issues.opened", async (context) => {
for await (const response of octokit.paginate.iterator( for await (const response of octokit.paginate.iterator(
context.octokit.issues.list, context.octokit.issues.list,
context.repo() context.repo(),
)) { )) {
for (const issue of response.data) { for (const issue of response.data) {
if (issue.body.includes("something")) { 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: 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. 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: 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/`)
});

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

View File

@ -1,25 +1,28 @@
import path from "path"; import { resolve } from "node:path";
import { ApplicationFunctionOptions, Probot } from "../index";
import type { ApplicationFunctionOptions, Probot } from "../index.js";
import { loadPackageJson } from "../helpers/load-package-json.js";
import { probotView } from "../views/probot.js";
export function defaultApp( export function defaultApp(
app: Probot, _app: Probot,
{ getRouter }: ApplicationFunctionOptions { getRouter, cwd = process.cwd() }: ApplicationFunctionOptions,
) { ) {
if (!getRouter) { if (!getRouter) {
throw new Error("getRouter() is required for defaultApp"); 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(); const router = getRouter();
router.get("/probot", (req, res) => { router.get("/probot", (_req, res) => {
let pkg; res.send(probotViewRendered);
try {
pkg = require(path.join(process.cwd(), "package.json"));
} catch (e) {
pkg = {};
}
res.render("probot.handlebars", pkg);
}); });
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 "node:child_process";
import { exec } from "child_process";
import { Request, Response } from "express"; import type { IncomingMessage, ServerResponse } from "http";
import { parse as parseQuery } from "querystring";
import express from "express";
import updateDotenv from "update-dotenv"; import updateDotenv from "update-dotenv";
import { Probot } from "../probot"; import { Probot } from "../probot.js";
import { ManifestCreation } from "../manifest-creation"; import { ManifestCreation } from "../manifest-creation.js";
import { getLoggingMiddleware } from "../server/logging-middleware"; import { getLoggingMiddleware } from "../server/logging-middleware.js";
import { ApplicationFunctionOptions } from "../types"; import type { ApplicationFunctionOptions } from "../types.js";
import { isProduction } from "../helpers/is-production"; 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 = ( export const setupAppFactory = (
host: string | undefined, host: string | undefined,
port: number | undefined port: number | undefined,
) => ) =>
async function setupApp( async function setupApp(
app: Probot, 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 setup: ManifestCreation = new ManifestCreation();
const pkg = setup.pkg;
// If not on Glitch or Production, create a smee URL // If not on Glitch or Production, create a smee URL
if ( if (
@ -31,28 +42,45 @@ export const setupAppFactory = (
await setup.createWebhookChannel(); await setup.createWebhookChannel();
} }
if (!getRouter) {
throw new Error("getRouter is required to use the setup app");
}
const route = getRouter(); const route = getRouter();
route.use(getLoggingMiddleware(app.log)); route.use(getLoggingMiddleware(app.log));
printWelcomeMessage(app, host, port); printWelcomeMessage(app, host, port);
route.get("/probot", async (req, res) => { route.get("/probot", async (req: IncomingMessage, res: ServerResponse) => {
const baseUrl = getBaseUrl(req); const baseUrl = getBaseUrl(req);
const pkg = setup.pkg;
const manifest = setup.getManifest(pkg, baseUrl); const manifest = setup.getManifest(pkg, baseUrl);
const createAppUrl = setup.createAppUrl; const createAppUrl = setup.createAppUrl;
// Pass the manifest to be POST'd // 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) => { route.get(
const { code } = req.query; "/probot/setup",
const response = await setup.createAppFromCode(code); 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 (!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,
});
// If using glitch, restart the app // If using glitch, restart the app
if (process.env.PROJECT_DOMAIN) { if (process.env.PROJECT_DOMAIN) {
@ -65,19 +93,47 @@ export const setupAppFactory = (
printRestartMessage(app); printRestartMessage(app);
} }
res.redirect(`${response}/installations/new`); res
}); .writeHead(302, {
"content-type": "text/plain",
location: `${response}/installations/new`,
})
.end(`Found. Redirecting to ${response}/installations/new`);
},
);
route.get("/probot/import", async (_req, res) => {
const { WEBHOOK_PROXY_URL, GHE_HOST } = process.env; const { WEBHOOK_PROXY_URL, GHE_HOST } = process.env;
const GH_HOST = `https://${GHE_HOST ?? "github.com"}`; const GH_HOST = `https://${GHE_HOST ?? "github.com"}`;
res.render("import.handlebars", { WEBHOOK_PROXY_URL, GH_HOST });
const importViewRendered = importView({
name: pkg.name,
WEBHOOK_PROXY_URL,
GH_HOST,
}); });
route.post("/probot/import", bodyParser.json(), async (req, res) => { route.get(
const { appId, pem, webhook_secret } = req.body; "/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) { if (!appId || !pem || !webhook_secret) {
res.status(400).send("appId and/or pem and/or webhook_secret missing"); res
.writeHead(400, {
"content-type": "text/plain",
})
.end("appId and/or pem and/or webhook_secret missing");
return; return;
} }
updateDotenv({ updateDotenv({
@ -87,19 +143,31 @@ export const setupAppFactory = (
}); });
res.end(); res.end();
printRestartMessage(app); printRestartMessage(app);
}); },
);
route.get("/probot/success", async (req, res) => { const successViewRendered = successView({ name: pkg.name });
res.render("success.handlebars");
});
route.get("/", (req, res, next) => res.redirect("/probot")); route.get(
"/probot/success",
(_req: IncomingMessage, res: ServerResponse) => {
res
.writeHead(200, { "content-type": "text/html" })
.end(successViewRendered);
},
);
route.get("/", (_req, res: ServerResponse) =>
res
.writeHead(302, { "content-type": "text/plain", location: `/probot` })
.end(`Found. Redirecting to /probot`),
);
}; };
function printWelcomeMessage( function printWelcomeMessage(
app: Probot, app: Probot,
host: string | undefined, host: string | undefined,
port: number | undefined port: number | undefined,
) { ) {
// use glitch env to get correct domain welcome message // use glitch env to get correct domain welcome message
// https://glitch.com/help/project/ // https://glitch.com/help/project/
@ -127,11 +195,16 @@ function printRestartMessage(app: Probot) {
app.log.info(""); app.log.info("");
} }
function getBaseUrl(req: Request): string { function getBaseUrl(req: IncomingMessage): string {
const protocols = req.headers["x-forwarded-proto"] || req.protocol; const protocols =
req.headers["x-forwarded-proto"] ||
// @ts-expect-error based on the functionality of express
req.socket?.encrypted
? "https"
: "http";
const protocol = const protocol =
typeof protocols === "string" ? protocols.split(",")[0] : protocols[0]; 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}`; const baseUrl = `${protocol}://${host}`;
return baseUrl; return baseUrl;
} }

View File

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

View File

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

View File

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

View File

@ -1,24 +1,30 @@
import semver from "semver"; import { resolve } from "node:path";
import program from "commander";
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)) { dotenvConfig();
console.log(
`Node.js version ${pkg.engines.node} is required. You have ${process.version}.` //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); process.exit(1);
} }
program program
.version(pkg.version) .version(pkg.version || "0.0.0-dev")
.usage("<command> [options]") .usage("<command> [options]")
.command("run", "run the bot") .command("run", "run the bot")
.command("receive", "Receive a single event and payload") .command("receive", "Receive a single event and payload")
.on("command:*", (cmd) => { .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`); console.error(`Invalid command: ${program.args.join(" ")}\n`);
program.outputHelp(); program.outputHelp();
process.exit(1); 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 { 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( export function readCliOptions(
argv: string[] argv: string[],
): Options & PinoOptions & { args: string[] } { ): Options & PinoOptions & { args: string[] } {
program program
.usage("[options] <apps...>") .usage("[options] <apps...>")
.option( .option(
"-p, --port <n>", "-p, --port <n>",
"Port to start the server on", "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("-H --host <host>", "Host to start the server on", process.env.HOST)
.option( .option(
"-W, --webhook-proxy <url>", "-W, --webhook-proxy <url>",
"URL of the webhook proxy service.`", "URL of the webhook proxy service.`",
process.env.WEBHOOK_PROXY_URL process.env.WEBHOOK_PROXY_URL,
) )
.option( .option(
"-w, --webhook-path <path>", "-w, --webhook-path <path>",
"URL path which receives webhooks. Ex: `/webhook`", "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("-a, --app <id>", "ID of the GitHub App", process.env.APP_ID)
.option( .option(
"-s, --secret <secret>", "-s, --secret <secret>",
"Webhook secret of the GitHub App", "Webhook secret of the GitHub App",
process.env.WEBHOOK_SECRET process.env.WEBHOOK_SECRET,
) )
.option( .option(
"-P, --private-key <file>", "-P, --private-key <file>",
"Path to private key file (.pem) for the GitHub App", "Path to private key file (.pem) for the GitHub App",
process.env.PRIVATE_KEY_PATH process.env.PRIVATE_KEY_PATH,
) )
.option( .option(
"-L, --log-level <level>", "-L, --log-level <level>",
'One of: "trace" | "debug" | "info" | "warn" | "error" | "fatal"', 'One of: "trace" | "debug" | "info" | "warn" | "error" | "fatal"',
process.env.LOG_LEVEL || "info" process.env.LOG_LEVEL || "info",
) )
.option( .option(
"--log-format <format>", "--log-format <format>",
'One of: "pretty", "json"', 'One of: "pretty", "json"',
process.env.LOG_FORMAT process.env.LOG_FORMAT,
) )
.option( .option(
"--log-level-in-string", "--log-level-in-string",
"Set to log levels (trace, debug, info, ...) as words instead of numbers (10, 20, 30, ...)", "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( .option(
"--sentry-dsn <dsn>", "--sentry-dsn <dsn>",
'Set to your Sentry DSN, e.g. "https://1234abcd@sentry.io/12345"', 'Set to your Sentry DSN, e.g. "https://1234abcd@sentry.io/12345"',
process.env.SENTRY_DSN process.env.SENTRY_DSN,
) )
.option( .option(
"--redis-url <url>", "--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"', '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( .option(
"--base-url <url>", "--base-url <url>",
@ -68,7 +68,7 @@ export function readCliOptions(
? `${process.env.GHE_PROTOCOL || "https"}://${ ? `${process.env.GHE_PROTOCOL || "https"}://${
process.env.GHE_HOST process.env.GHE_HOST
}/api/v3` }/api/v3`
: "https://api.github.com" : "https://api.github.com",
) )
.parse(argv); .parse(argv);
@ -77,12 +77,13 @@ export function readCliOptions(
privateKey: privateKeyPath, privateKey: privateKeyPath,
redisUrl, redisUrl,
...options ...options
} = program; } = program.opts();
return { return {
privateKey: getPrivateKey({ filepath: privateKeyPath }) || undefined, privateKey: getPrivateKey({ filepath: privateKeyPath }) || undefined,
appId, appId,
redisConfig: redisUrl, redisConfig: redisUrl,
args: program.args,
...options, ...options,
}; };
} }

View File

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

View File

@ -1,14 +1,12 @@
import path from "path"; import path from "node:path";
import { EmitterWebhookEvent as WebhookEvent } from "@octokit/webhooks";
import merge from "deepmerge"; import merge from "deepmerge";
import type {
EmitterWebhookEvent as WebhookEvent,
EmitterWebhookEventName as WebhookEvents,
} from "@octokit/webhooks";
import type { Logger } from "pino"; import type { Logger } from "pino";
import type { ProbotOctokit } from "./octokit/probot-octokit.js";
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";
export type MergeOptions = merge.Options; export type MergeOptions = merge.Options;
@ -65,20 +63,16 @@ export class Context<E extends WebhookEvents = WebhookEvents> {
public id: string; public id: string;
public payload: WebhookEvent<E>["payload"]; public payload: WebhookEvent<E>["payload"];
public octokit: InstanceType<typeof ProbotOctokit>; public octokit: ProbotOctokit;
public log: DeprecatedLogger; public log: Logger;
constructor( constructor(event: WebhookEvent<E>, octokit: ProbotOctokit, log: Logger) {
event: WebhookEvent<E>,
octokit: InstanceType<typeof ProbotOctokit>,
log: Logger
) {
this.name = event.name; this.name = event.name;
this.id = event.id; this.id = event.id;
this.payload = event.payload; this.payload = event.payload;
this.octokit = octokit; 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 { 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; const repo = this.payload.repository;
if (!repo) { if (!repo) {
throw new Error( 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, owner: repo.owner.login,
repo: repo.name, 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. * @param object - Params to be merged with the issue params.
*/ */
public issue<T>( public issue<T>(
object?: T object?: T,
): RepoResultType<E> & { issue_number: RepoIssueNumberType<E> } & T { ): RepoResultType<E> & { issue_number: RepoIssueNumberType<E> } & T {
return Object.assign( return Object.assign(
{ {
issue_number: 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) (this.payload.issue || this.payload.pull_request || this.payload)
.number, .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. * @param object - Params to be merged with the pull request params.
*/ */
public pullRequest<T>( public pullRequest<T>(
object?: T object?: T,
): RepoResultType<E> & { pull_number: RepoIssueNumberType<E> } & T { ): RepoResultType<E> & { pull_number: RepoIssueNumberType<E> } & T {
const payload = this.payload; const payload = this.payload;
return Object.assign( 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, 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>( public async config<T>(
fileName: string, fileName: string,
defaultConfig?: T, defaultConfig?: T,
deepMergeOptions?: MergeOptions deepMergeOptions?: MergeOptions,
): Promise<T | null> { ): Promise<T | null> {
const params = this.repo({ const params = this.repo({
path: path.posix.join(".github", fileName), path: path.posix.join(".github", fileName),
defaults(configs: object[]) { defaults(configs: object[]) {
const result = merge.all( const result = merge.all(
[defaultConfig || {}, ...configs], [defaultConfig || {}, ...configs],
deepMergeOptions deepMergeOptions,
); );
return result; return result;
}, },
}); });
// @ts-ignore // @ts-expect-error
const { config, files } = await this.octokit.config.get(params); const { config, files } = await this.octokit.config.get(params);
// if no default config is set, and no config files are found, return null // 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; return null;
} }

View File

@ -1,16 +1,17 @@
import { RequestListener } from "http"; import type { RequestListener } from "http";
import { createNodeMiddleware as createWebbhooksMiddleware } from "@octokit/webhooks"; import { createNodeMiddleware as createWebhooksMiddleware } from "@octokit/webhooks";
import { ApplicationFunction } from "./types"; import type { ApplicationFunction, MiddlewareOptions } from "./types.js";
import { MiddlewareOptions } from "./types"; import { defaultWebhooksPath } from "./server/server.js";
import { createProbot } from "./create-probot.js";
export function createNodeMiddleware( export function createNodeMiddleware(
appFn: ApplicationFunction, appFn: ApplicationFunction,
{ probot, webhooksPath }: MiddlewareOptions { probot = createProbot(), webhooksPath } = {} as MiddlewareOptions,
): RequestListener { ): RequestListener {
probot.load(appFn); probot.load(appFn);
return createWebbhooksMiddleware(probot.webhooks, { return createWebhooksMiddleware(probot.webhooks, {
path: webhooksPath || "/", 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 { getPrivateKey } from "@probot/get-private-key";
import { getLog, GetLogOptions } from "./helpers/get-log"; import { getLog } from "./helpers/get-log.js";
import { Options } from "./types"; import type { Options } from "./types.js";
import { Probot } from "./probot"; import { Probot } from "./probot.js";
import { defaultWebhooksPath } from "./server/server.js";
type CreateProbotOptions = { type CreateProbotOptions = {
overrides?: Options; overrides?: Options;
defaults?: Options; defaults?: Options;
env?: NodeJS.ProcessEnv; env?: Partial<NodeJS.ProcessEnv>;
}; };
const DEFAULTS = { const DEFAULTS: Partial<NodeJS.ProcessEnv> = {
APP_ID: "", APP_ID: "",
WEBHOOK_SECRET: "", WEBHOOK_SECRET: "",
WEBHOOK_PATH: defaultWebhooksPath,
GHE_HOST: "", GHE_HOST: "",
GHE_PROTOCOL: "", GHE_PROTOCOL: "https",
LOG_FORMAT: "", LOG_FORMAT: undefined,
LOG_LEVEL: "warn", LOG_LEVEL: "warn",
LOG_LEVEL_IN_STRING: "", LOG_LEVEL_IN_STRING: "false",
LOG_MESSAGE_KEY: "msg", LOG_MESSAGE_KEY: "msg",
REDIS_URL: "", REDIS_URL: "",
SENTRY_DSN: "", SENTRY_DSN: "",
@ -47,6 +49,7 @@ export function createProbot({
privateKey: (privateKey && privateKey.toString()) || undefined, privateKey: (privateKey && privateKey.toString()) || undefined,
secret: envWithDefaults.WEBHOOK_SECRET, secret: envWithDefaults.WEBHOOK_SECRET,
redisConfig: envWithDefaults.REDIS_URL, redisConfig: envWithDefaults.REDIS_URL,
webhookPath: envWithDefaults.WEBHOOK_PATH,
baseUrl: envWithDefaults.GHE_HOST baseUrl: envWithDefaults.GHE_HOST
? `${envWithDefaults.GHE_PROTOCOL || "https"}://${ ? `${envWithDefaults.GHE_PROTOCOL || "https"}://${
envWithDefaults.GHE_HOST envWithDefaults.GHE_HOST
@ -60,15 +63,13 @@ export function createProbot({
...overrides, ...overrides,
}; };
const logOptions: GetLogOptions = { const log = getLog({
level: probotOptions.logLevel, level: probotOptions.logLevel,
logFormat: envWithDefaults.LOG_FORMAT as PinoOptions["logFormat"], logFormat: envWithDefaults.LOG_FORMAT as PinoOptions["logFormat"],
logLevelInString: envWithDefaults.LOG_LEVEL_IN_STRING === "true", logLevelInString: envWithDefaults.LOG_LEVEL_IN_STRING === "true",
logMessageKey: envWithDefaults.LOG_MESSAGE_KEY, logMessageKey: envWithDefaults.LOG_MESSAGE_KEY,
sentryDsn: envWithDefaults.SENTRY_DSN, sentryDsn: envWithDefaults.SENTRY_DSN,
}; }).child({ name: "server" });
const log = getLog(logOptions).child({ name: "server" });
return new Probot({ return new Probot({
log: log.child({ name: "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 type { Logger } from "pino";
import { import type {
WebhookError, WebhookError,
EmitterWebhookEvent as WebhookEvent, EmitterWebhookEvent as WebhookEvent,
} from "@octokit/webhooks"; } from "@octokit/webhooks";
export function getErrorHandler(log: Logger) { export function getErrorHandler(log: Logger) {
return (error: Error) => { return (error: Error & { event?: WebhookEvent }) => {
const errors = ( const errors = (
error.name === "AggregateError" ? error : [error] error.name === "AggregateError" ? error : [error]
) as WebhookError[]; ) as WebhookError[];
const event = (error as any).event as WebhookEvent; const event = error.event;
for (const error of errors) { for (const error of errors) {
const errMessage = (error.message || "").toLowerCase(); const errMessage = (error.message || "").toLowerCase();
@ -18,7 +18,7 @@ export function getErrorHandler(log: Logger) {
if (errMessage.includes("x-hub-signature-256")) { if (errMessage.includes("x-hub-signature-256")) {
log.error( log.error(
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; continue;
} }
@ -26,7 +26,7 @@ export function getErrorHandler(log: Logger) {
if (errMessage.includes("pem") || errMessage.includes("json web token")) { if (errMessage.includes("pem") || errMessage.includes("json web token")) {
log.error( log.error(
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; continue;
} }
@ -34,7 +34,7 @@ export function getErrorHandler(log: Logger) {
log log
.child({ .child({
name: "event", name: "event",
id: event ? event.id : undefined, id: event?.id,
}) })
.error(error); .error(error);
} }

View File

@ -14,8 +14,10 @@
* app.log.fatal("Goodbye, cruel world!"); * app.log.fatal("Goodbye, cruel world!");
* ``` * ```
*/ */
import pino, { Logger, LoggerOptions } from "pino"; import { pino } from "pino";
import { getTransformStream, Options, LogLevel } from "@probot/pino"; import type { Logger, LoggerOptions } from "pino";
import { getTransformStream, type Options, type LogLevel } from "@probot/pino";
import { rebindLog } from "./rebind-log";
export type GetLogOptions = { export type GetLogOptions = {
level?: LogLevel; level?: LogLevel;
@ -31,9 +33,7 @@ export function getLog(options: GetLogOptions = {}): Logger {
messageKey: logMessageKey || "msg", messageKey: logMessageKey || "msg",
}; };
const transform = getTransformStream(getTransformStreamOptions); const transform = getTransformStream(getTransformStreamOptions);
// @ts-ignore TODO: check out what's wrong here
transform.pipe(pino.destination(1)); 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 = {}; const defaultOptions: ResolveOptions = {};
export const resolveAppFunction = async ( export const resolveAppFunction = async (
appFnId: string, appFnId: string,
opts?: ResolveOptions opts?: ResolveOptions,
) => { ) => {
opts = opts || defaultOptions; opts = opts || defaultOptions;
// These are mostly to ease testing // These are mostly to ease testing
const basedir = opts.basedir || process.cwd(); 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 appFnPath = resolver(appFnId, { basedir });
const mod = await import(appFnPath); // On windows, an absolute path may start with a drive letter, e.g. C:/path/to/file.js
// Note: This needs "esModuleInterop" to be set to "true" in "tsconfig.json" // This can be interpreted as a protocol, so ensure it's prefixed with file://
return mod.default; 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; export type Resolver = (appFnId: string, opts: { basedir: string }) => string;

View File

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

View File

@ -1,28 +1,154 @@
import { Logger } from "pino"; export type { Logger } from "pino";
import { Context } from "./context"; export { Context } from "./context.js";
import {
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, Options,
ApplicationFunction, ApplicationFunction,
ApplicationFunctionOptions, ApplicationFunctionOptions,
} from "./types"; } from "./types.js";
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";
export { declare global {
Logger, namespace NodeJS {
Context, interface ProcessEnv {
ProbotOctokit, /**
run, * The App ID assigned to your GitHub App.
Probot, * @example '1234'
Server, */
createNodeMiddleware, APP_ID?: string;
createProbot,
};
/** 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,51 +1,48 @@
import fs from "fs"; import fs from "node:fs";
import path from "node:path";
import yaml from "js-yaml"; import yaml from "js-yaml";
import path from "path";
import updateDotenv from "update-dotenv"; 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 { export class ManifestCreation {
get pkg() { get pkg() {
let pkg: any; return loadPackageJson();
try {
pkg = require(path.join(process.cwd(), "package.json"));
} catch (e) {
pkg = {};
}
return pkg;
} }
public async createWebhookChannel() { public async createWebhookChannel(): Promise<string | undefined> {
try { try {
// tslint:disable:no-var-requires const SmeeClient = (await import("smee-client")).default;
const SmeeClient = require("smee-client");
const WEBHOOK_PROXY_URL = await SmeeClient.createChannel();
await this.updateEnv({ await this.updateEnv({
WEBHOOK_PROXY_URL: await SmeeClient.createChannel(), WEBHOOK_PROXY_URL,
}); });
return WEBHOOK_PROXY_URL;
} catch (error) { } catch (error) {
// Smee is not available, so we'll just move on // Smee is not available, so we'll just move on
// tslint:disable:no-console
console.warn("Unable to connect to smee.io, try restarting your server."); console.warn("Unable to connect to smee.io, try restarting your server.");
return void 0;
} }
} }
public getManifest(pkg: any, baseUrl: any) { public getManifest(pkg: PackageJson, baseUrl: string) {
let manifest: any = {}; let manifest: Partial<Manifest> = {};
try { try {
const file = fs.readFileSync(path.join(process.cwd(), "app.yml"), "utf8"); const file = fs.readFileSync(path.join(process.cwd(), "app.yml"), "utf8");
manifest = yaml.safeLoad(file); manifest = yaml.load(file) as Manifest;
} catch (error) { } catch (error) {
// App config does not exist, which is ok. // App config does not exist, which is ok.
// @ts-ignore - in theory error can be anything if ((error as Error & { code?: string }).code !== "ENOENT") {
if (error.code !== "ENOENT") {
throw error; throw error;
} }
} }
const generatedManifest = JSON.stringify( const generatedManifest = JSON.stringify({
Object.assign(
{
description: manifest.description || pkg.description, description: manifest.description || pkg.description,
hook_attributes: { hook_attributes: {
url: process.env.WEBHOOK_PROXY_URL || `${baseUrl}/`, url: process.env.WEBHOOK_PROXY_URL || `${baseUrl}/`,
@ -57,17 +54,16 @@ export class ManifestCreation {
// setup_url:`${baseUrl}/probot/success`, // setup_url:`${baseUrl}/probot/success`,
url: manifest.url || pkg.homepage || pkg.repository, url: manifest.url || pkg.homepage || pkg.repository,
version: "v1", version: "v1",
}, ...manifest,
manifest });
)
);
return generatedManifest; return generatedManifest;
} }
public async createAppFromCode(code: any) { public async createAppFromCode(code: string, probotOptions?: OctokitOptions) {
const octokit = new ProbotOctokit(); const octokit = new ProbotOctokit(probotOptions);
const options: any = { const options: RequestParameters = {
...probotOptions,
code, code,
mediaType: { mediaType: {
previews: ["fury"], // needed for GHES 2.20 and older previews: ["fury"], // needed for GHES 2.20 and older
@ -80,7 +76,7 @@ export class ManifestCreation {
}; };
const response = await octokit.request( const response = await octokit.request(
"POST /app-manifests/:code/conversions", "POST /app-manifests/:code/conversions",
options options,
); );
const { id, client_id, client_secret, webhook_secret, pem } = response.data; const { id, client_id, client_secret, webhook_secret, pem } = response.data;
@ -95,7 +91,7 @@ export class ManifestCreation {
return response.data.html_url; return response.data.html_url;
} }
public async updateEnv(env: any) { public async updateEnv(env: Env) {
// Needs to be public due to tests // Needs to be public due to tests
return updateDotenv(env); return updateDotenv(env);
} }
@ -103,7 +99,7 @@ export class ManifestCreation {
get createAppUrl() { get createAppUrl() {
const githubHost = process.env.GHE_HOST || `github.com`; const githubHost = process.env.GHE_HOST || `github.com`;
return `${process.env.GHE_PROTOCOL || "https"}://${githubHost}${ 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`; }/settings/apps/new`;
} }
} }

View File

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

View File

@ -1,16 +1,38 @@
import Bottleneck from "bottleneck"; import Bottleneck from "bottleneck";
import Redis from "ioredis"; import { Redis, type RedisOptions } from "ioredis";
import { Logger } from "pino"; import type { Logger } from "pino";
import type { ThrottlingOptions } from "@octokit/plugin-throttling";
type Options = { type Options = {
log: Logger; log: Logger;
redisConfig?: Redis.RedisOptions | string; redisConfig?: RedisOptions | string;
}; };
export function getOctokitThrottleOptions(options: Options) { export function getOctokitThrottleOptions(options: Options) {
let { log, redisConfig } = 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({ const connection = new Bottleneck.IORedisConnection({
client: getRedisClient(options), client: getRedisClient(options),
@ -19,12 +41,12 @@ export function getOctokitThrottleOptions(options: Options) {
log.error(Object.assign(error, { source: "bottleneck" })); log.error(Object.assign(error, { source: "bottleneck" }));
}); });
return { throttlingOptions.Bottleneck = Bottleneck;
Bottleneck, throttlingOptions.connection = connection;
connection,
}; return throttlingOptions;
} }
function getRedisClient({ log, redisConfig }: Options): Redis.Redis | void { function getRedisClient({ redisConfig }: Options): Redis | void {
if (redisConfig) return new Redis(redisConfig as Redis.RedisOptions); if (redisConfig) return new Redis(redisConfig as RedisOptions);
} }

View File

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

View File

@ -1,18 +1,13 @@
import { Webhooks } from "@octokit/webhooks"; import { Webhooks } from "@octokit/webhooks";
import { State } from "../types"; import type { State } from "../types.js";
import { getErrorHandler } from "../helpers/get-error-handler"; import { getErrorHandler } from "../helpers/get-error-handler.js";
import { webhookTransform } from "./octokit-webhooks-transform"; import { webhookTransform } from "./octokit-webhooks-transform.js";
// import { Context } from "../context";
export function getWebhooks(state: State) { 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({ const webhooks = new Webhooks({
secret: state.webhooks.secret!, secret: state.webhooks.secret!,
transform: webhookTransform.bind(null, state), transform: (hook) => webhookTransform(state, hook),
}); });
webhooks.onError(getErrorHandler(state.log)); webhooks.onError(getErrorHandler(state.log));
return webhooks; return webhooks;

View File

@ -1,4 +1,3 @@
// tslint:disable-next-line
import type { Octokit } from "@octokit/core"; import type { Octokit } from "@octokit/core";
export function probotRequestLogging(octokit: Octokit) { export function probotRequestLogging(octokit: Octokit) {
@ -20,7 +19,7 @@ export function probotRequestLogging(octokit: Octokit) {
octokit.request.endpoint.parse(options); octokit.request.endpoint.parse(options);
const msg = `GitHub request: ${method} ${url} - ${result.status}`; 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); 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 { Context } from "../context.js";
import { State } from "../types"; import type { State } from "../types.js";
/** /**
* Probot's transform option, which extends the `event` object that is passed * Probot's transform option, which extends the `event` object that is passed

View File

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

View File

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

View File

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

View File

@ -1,19 +1,21 @@
import pinoHttp from "pino-http"; import { randomUUID as uuidv4 } from "node:crypto";
import type { Logger } from "pino";
import { v4 as uuidv4 } from "uuid";
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({ return pinoHttp({
...options, ...options,
logger: logger.child({ name: "http" }), logger: logger.child({ name: "http" }),
customSuccessMessage(res) { customSuccessMessage(_req, res) {
const responseTime = Date.now() - res[pinoHttp.startTime]; const responseTime = Date.now() - res[startTime];
// @ts-ignore
return `${res.req.method} ${res.req.url} ${res.statusCode} - ${responseTime}ms`; return `${res.req.method} ${res.req.url} ${res.statusCode} - ${responseTime}ms`;
}, },
customErrorMessage(err, res) { customErrorMessage(_err, res) {
const responseTime = Date.now() - res[pinoHttp.startTime]; const responseTime = Date.now() - res[startTime];
// @ts-ignore
return `${res.req.method} ${res.req.url} ${res.statusCode} - ${responseTime}ms`; return `${res.req.method} ${res.req.url} ${res.statusCode} - ${responseTime}ms`;
}, },
genReqId: (req) => 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 express, { Router, type Application } from "express";
import { join } from "path"; import type { Logger } from "pino";
import { Logger } from "pino";
import { createNodeMiddleware as createWebhooksMiddleware } from "@octokit/webhooks"; import { createNodeMiddleware as createWebhooksMiddleware } from "@octokit/webhooks";
import { getLog } from "../helpers/get-log"; import { getLoggingMiddleware } from "./logging-middleware.js";
import { getLoggingMiddleware } from "./logging-middleware"; import { createWebhookProxy } from "../helpers/webhook-proxy.js";
import { createWebhookProxy } from "../helpers/webhook-proxy"; import { VERSION } from "../version.js";
import { VERSION } from "../version"; import type { ApplicationFunction, ServerOptions } from "../types.js";
import { ApplicationFunction, ServerOptions } from "../types"; import type { Probot } from "../index.js";
import { Probot } from "../"; import type EventSource from "eventsource";
import { engine } from "express-handlebars"; import { rebindLog } from "../helpers/rebind-log.js";
import EventSource from "eventsource";
// the default path as defined in @octokit/webhooks
export const defaultWebhooksPath = "/api/github/webhooks";
type State = { type State = {
cwd?: string;
httpServer?: HttpServer; httpServer?: HttpServer;
port?: number; port?: number;
host?: string; host?: string;
@ -35,69 +38,66 @@ export class Server {
constructor(options: ServerOptions = {} as ServerOptions) { constructor(options: ServerOptions = {} as ServerOptions) {
this.expressApp = express(); 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 = { this.state = {
cwd: options.cwd || process.cwd(),
port: options.port, port: options.port,
host: options.host, host: options.host,
webhookPath: options.webhookPath || "/", webhookPath: options.webhookPath || defaultWebhooksPath,
webhookProxy: options.webhookProxy, webhookProxy: options.webhookProxy,
}; };
this.expressApp.use(getLoggingMiddleware(this.log, options.loggingOptions)); this.expressApp.use(getLoggingMiddleware(this.log, options.loggingOptions));
this.expressApp.use( this.expressApp.use(
"/probot/static/", "/probot/static/",
express.static(join(__dirname, "..", "..", "static")) express.static(join(__dirname, "..", "..", "static")),
); );
this.expressApp.use( this.expressApp.use(
this.state.webhookPath,
createWebhooksMiddleware(this.probotApp.webhooks, { createWebhooksMiddleware(this.probotApp.webhooks, {
path: "/", path: this.state.webhookPath,
}) }),
); );
this.expressApp.engine( this.expressApp.get("/ping", (_req, res) => res.end("PONG"));
"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"));
} }
public async load(appFn: ApplicationFunction) { public async load(appFn: ApplicationFunction) {
await appFn(this.probotApp, { await appFn(this.probotApp, {
cwd: this.state.cwd,
getRouter: (path) => this.router(path), getRouter: (path) => this.router(path),
}); });
} }
public async start() { public async start() {
this.log.info( 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 port = this.state.port || 3000;
const { host, webhookPath, webhookProxy } = this.state; const { host, webhookPath, webhookProxy } = this.state;
const printableHost = host ?? "localhost"; 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( const server = this.expressApp.listen(
port, port,
...((host ? [host] : []) as any), ...((host ? [host] : []) as any),
() => { async () => {
if (webhookProxy) { if (webhookProxy) {
this.state.eventSource = createWebhookProxy({ this.state.eventSource = await createWebhookProxy({
logger: this.log, logger: this.log,
path: webhookPath, path: webhookPath,
port: port, port: port,
url: webhookProxy, url: webhookProxy,
}) as EventSource; });
} }
this.log.info(`Listening on http://${printableHost}:${port}`); this.log.info(`Listening on http://${printableHost}:${port}`);
resolve(server); resolve(server);
} },
); );
server.on("error", (error: NodeJS.ErrnoException) => { server.on("error", (error: NodeJS.ErrnoException) => {
@ -110,7 +110,7 @@ export class Server {
this.log.error(error); this.log.error(error);
reject(error); reject(error);
}); });
})) as HttpServer; });
return this.state.httpServer; return this.state.httpServer;
} }

View File

@ -1,17 +1,18 @@
import express from "express"; import express from "express";
import { import type {
EmitterWebhookEvent as WebhookEvent, EmitterWebhookEvent as WebhookEvent,
Webhooks, Webhooks,
} from "@octokit/webhooks"; } from "@octokit/webhooks";
import LRUCache from "lru-cache"; import type { LRUCache } from "lru-cache";
import Redis from "ioredis"; import type { RedisOptions } from "ioredis";
import { Options as LoggingOptions } from "pino-http"; import type { Options as LoggingOptions } from "pino-http";
import { Probot } from "./index"; import { Probot } from "./index.js";
import { Context } from "./context"; import { Context } from "./context.js";
import { ProbotOctokit } from "./octokit/probot-octokit"; 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 { export interface Options {
privateKey?: string; privateKey?: string;
@ -20,13 +21,15 @@ export interface Options {
Octokit?: typeof ProbotOctokit; Octokit?: typeof ProbotOctokit;
log?: Logger; log?: Logger;
redisConfig?: Redis.RedisOptions | string; redisConfig?: RedisOptions | string;
secret?: string; secret?: string;
logLevel?: "trace" | "debug" | "info" | "warn" | "error" | "fatal"; logLevel?: "trace" | "debug" | "info" | "warn" | "error" | "fatal";
logMessageKey?: string; logMessageKey?: string;
port?: number; port?: number;
host?: string; host?: string;
baseUrl?: string; baseUrl?: string;
request?: RequestRequestOptions;
webhookPath?: string;
} }
export type State = { export type State = {
@ -35,7 +38,7 @@ export type State = {
githubToken?: string; githubToken?: string;
log: Logger; log: Logger;
Octokit: typeof ProbotOctokit; Octokit: typeof ProbotOctokit;
octokit: InstanceType<typeof ProbotOctokit>; octokit: ProbotOctokit;
cache?: LRUCache<number, string>; cache?: LRUCache<number, string>;
webhooks: { webhooks: {
secret?: string; secret?: string;
@ -43,23 +46,31 @@ export type State = {
port?: number; port?: number;
host?: string; host?: string;
baseUrl?: 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>; type SimplifiedObject = Omit<Context, keyof WebhookEvent>;
export type ProbotWebhooks = Webhooks<SimplifiedObject>; export type ProbotWebhooks = Webhooks<SimplifiedObject>;
export type DeprecatedLogger = LogFn & Logger;
export type ApplicationFunctionOptions = { export type ApplicationFunctionOptions = {
getRouter?: (path?: string) => express.Router; getRouter?: (path?: string) => express.Router;
cwd?: string;
[key: string]: unknown; [key: string]: unknown;
}; };
export type ApplicationFunction = ( export type ApplicationFunction = (
app: Probot, app: Probot,
options: ApplicationFunctionOptions options: ApplicationFunctionOptions,
) => void | Promise<void>; ) => void | Promise<void>;
export type ServerOptions = { export type ServerOptions = {
cwd?: string;
log?: Logger; log?: Logger;
port?: number; port?: number;
host?: string; host?: string;
@ -67,6 +78,7 @@ export type ServerOptions = {
webhookProxy?: string; webhookProxy?: string;
Probot: typeof Probot; Probot: typeof Probot;
loggingOptions?: LoggingOptions; loggingOptions?: LoggingOptions;
request?: RequestRequestOptions;
}; };
export type MiddlewareOptions = { export type MiddlewareOptions = {
@ -74,3 +86,99 @@ export type MiddlewareOptions = {
webhooksPath?: string; webhooksPath?: string;
[key: string]: unknown; [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"> <html lang="en" class="height-full" data-color-mode="auto" data-light-theme="light" data-dark-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <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="icon" href="/probot/static/probot-head.png">
<link rel="stylesheet" href="/probot/static/primer.css"> <link rel="stylesheet" href="/probot/static/primer.css">
</head> </head>
@ -20,9 +29,9 @@
<h3>Step 1:</h3> <h3>Step 1:</h3>
<p class="d-block mt-2"> <p class="d-block mt-2">
Replace your app's Webhook URL with <br> Replace your app's Webhook URL with <br>
<b>{{ WEBHOOK_PROXY_URL }}</b> <b>${WEBHOOK_PROXY_URL}</b>
</p> </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 You can do it here
</a> </a>
@ -49,7 +58,7 @@
<h4 class="alt-h4 text-gray-light">Need help?</h4> <h4 class="alt-h4 text-gray-light">Need help?</h4>
<div class="d-flex flex-justify-center mt-2"> <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.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> </div>
</div> </div>
@ -81,4 +90,5 @@
</script> </script>
</body> </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"> <html lang="en" class="height-full" data-color-mode="auto" data-light-theme="light" data-dark-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <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="icon" href="/probot/static/probot-head.png">
<link rel="stylesheet" href="/probot/static/primer.css"> <link rel="stylesheet" href="/probot/static/primer.css">
</head> </head>
@ -13,26 +22,28 @@
<img src="/probot/static/robot.svg" alt="Probot Logo" width="100" class="mb-6"> <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="box-shadow rounded-2 border p-6 bg-white">
<h1> <h1>
Welcome to {{#if name }}{{ name }}{{else}}your Probot App{{/if}} Welcome to ${name || "your Probot App"}
{{#if version}} ${
<span class="Label Label--outline v-align-middle ml-2 text-gray-light">v{{ version }}</span> version
{{/if}} ? ` <span class="Label Label--outline v-align-middle ml-2 text-gray-light">v${version}</span>\n`
</h1> : ""
} </h1>
{{#if description }} <p>${
<p>{{ description }}</p> description
{{else}} ? description
<p>This bot was built using <a href="https://github.com/probot/probot">Probot</a>, a framework for building GitHub Apps.</p> : 'This bot was built using <a href="https://github.com/probot/probot">Probot</a>, a framework for building GitHub Apps.'
{{/if}} }</p>
</div> </div>
<div class="mt-4"> <div class="mt-4">
<h4 class="alt-h4 text-gray-light">Need help?</h4> <h4 class="alt-h4 text-gray-light">Need help?</h4>
<div class="d-flex flex-justify-center mt-2"> <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.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> </div>
</div> </div>
</body> </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> <!DOCTYPE html>
<html lang="en" class="height-full" data-color-mode="auto" data-light-theme="light" data-dark-theme="dark"> <html lang="en" class="height-full" data-color-mode="auto" data-light-theme="light" data-dark-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <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="icon" href="/probot/static/probot-head.png">
<link rel="stylesheet" href="/probot/static/primer.css"> <link rel="stylesheet" href="/probot/static/primer.css">
</head> </head>
@ -13,17 +27,19 @@
<img src="/probot/static/robot.svg" alt="Probot Logo" width="100" class="mb-6"> <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="box-shadow rounded-2 border p-6 bg-white">
<h1> <h1>
Welcome to {{#if pkg.name }}{{ pkg.name }}{{else}}your Probot App{{/if}} Welcome to ${name || "your Probot App"}
{{#if version}} ${
<span class="Label Label--outline v-align-middle ml-2 text-gray-light">v{{ pkg.version }}</span> version
{{/if}} ? `<span class="Label Label--outline v-align-middle ml-2 text-gray-light">v${version}</span>`
: ""
}
</h1> </h1>
{{#if pkg.description }} <p>${
<p>{{ pkg.description }}</p> description
{{else}} ? description
<p>This app was built using <a href="https://github.com/probot/probot">Probot</a>, a framework for building GitHub Apps.</p> : 'This app was built using <a href="https://github.com/probot/probot">Probot</a>, a framework for building GitHub Apps.'
{{/if}} }</p>
<div class="text-left mt-6"> <div class="text-left mt-6">
<h2 class="alt-h3 mb-2">Getting Started</h2> <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> <p>To start building a GitHub App, you'll need to register a new app on GitHub.</p>
<br> <br>
<form action="{{ createAppUrl }}" method="post" target="_blank" class="d-flex flex-items-center"> <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> <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> <a href="/probot/import" class="ml-2">or use an existing Github App</a>
</form> </form>
</div> </div>
@ -42,9 +58,10 @@
<h4 class="alt-h4 text-gray-light">Need help?</h4> <h4 class="alt-h4 text-gray-light">Need help?</h4>
<div class="d-flex flex-justify-center mt-2"> <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.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> </div>
</div> </div>
</body> </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"> <html lang="en" class="height-full" data-color-mode="auto" data-light-theme="light" data-dark-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <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="icon" href="/probot/static/probot-head.png">
<link rel="stylesheet" href="/probot/static/primer.css"> <link rel="stylesheet" href="/probot/static/primer.css">
</head> </head>
@ -23,9 +24,10 @@
<h4 class="alt-h4 text-gray-light">Need help?</h4> <h4 class="alt-h4 text-gray-light">Need help?</h4>
<div class="d-flex flex-justify-center mt-2"> <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.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> </div>
</div> </div>
</body> </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`] = ` exports[`Setup app > GET /probot/import > renders importView 1`] = `
Array [ "<!DOCTYPE html>
Array [ <html lang=\\"en\\" class=\\"height-full\\" data-color-mode=\\"auto\\" data-light-theme=\\"light\\" data-dark-theme=\\"dark\\">
Object {
<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", "WEBHOOK_PROXY_URL": "mocked proxy URL",
}, },
], ],
Array [ [
Object { {
"WEBHOOK_PROXY_URL": "mocked proxy URL",
},
],
[
{
"APP_ID": "id", "APP_ID": "id",
"GITHUB_CLIENT_ID": "Iv1.8a61f9b3a7aba766", "GITHUB_CLIENT_ID": "Iv1.8a61f9b3a7aba766",
"GITHUB_CLIENT_SECRET": "1726be1638095a19edd134c77bde3aa2ece1e5d8", "GITHUB_CLIENT_SECRET": "1726be1638095a19edd134c77bde3aa2ece1e5d8",
@ -19,15 +113,55 @@ Array [
] ]
`; `;
exports[`Setup app POST /probot/import updates .env 1`] = ` exports[`Setup app > GET /probot/setup > throws a 400 Error if code is an empty string 1`] = `"code missing or invalid"`;
Array [
Array [ exports[`Setup app > GET /probot/setup > throws a 400 Error if code is not provided 1`] = `"code missing or invalid"`;
Object {
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", "WEBHOOK_PROXY_URL": "mocked proxy URL",
}, },
], ],
Array [ [
Object { {
"APP_ID": "foo", "APP_ID": "foo",
"PRIVATE_KEY": "\\"bar\\"", "PRIVATE_KEY": "\\"bar\\"",
"WEBHOOK_SECRET": "baz", "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 request from "supertest";
import { describe, expect, it } from "vitest";
import { Probot, Server } from "../../src"; import { Probot, Server } from "../../src/index.js";
import { defaultApp } from "../../src/apps/default"; import { defaultApp } from "../../src/apps/default.js";
describe("default app", () => { describe("default app", () => {
let server: Server; let output = [];
let output: any;
const streamLogsToOutput = new Stream.Writable({ objectMode: true }); const streamLogsToOutput = new Stream.Writable({ objectMode: true });
streamLogsToOutput._write = (object, encoding, done) => { streamLogsToOutput._write = (object, _encoding, done) => {
output.push(JSON.parse(object)); output.push(JSON.parse(object));
done(); done();
}; };
beforeEach(async () => { async function instantiateServer(cwd = process.cwd()) {
output = []; output = [];
server = new Server({ const server = new Server({
Probot: Probot.defaults({ Probot: Probot.defaults({
appId: 1, appId: 1,
privateKey: "private key", privateKey: "private key",
}), }),
log: pino(streamLogsToOutput), log: pino(streamLogsToOutput),
cwd,
}); });
await server.load(defaultApp); await server.load(defaultApp);
}); return server;
}
describe("GET /probot", () => { 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); return request(server.expressApp).get("/probot").expect(200);
}); });
describe("get info from package.json", () => { describe("get info from package.json", () => {
let cwd: string;
beforeEach(() => {
cwd = process.cwd();
});
it("returns the correct HTML with values", async () => { it("returns the correct HTML with values", async () => {
const server = await instantiateServer();
const actual = await request(server.expressApp) const actual = await request(server.expressApp)
.get("/probot") .get("/probot")
.expect(200); .expect(200);
expect(actual.text).toMatch("Welcome to probot"); expect(actual.text).toMatch("Welcome to probot");
expect(actual.text).toMatch("A framework for building GitHub Apps"); expect(actual.text).toMatch("A framework for building GitHub Apps");
expect(actual.text).toMatch(/v\d+\.\d+\.\d+/); expect(actual.text).toMatch(/v\d+\.\d+\.\d+/);
expect(actual.text).toMatchSnapshot();
}); });
it("returns the correct HTML without values", async () => { it("returns the correct HTML without values", async () => {
process.chdir(__dirname); const server = await instantiateServer(__dirname);
const actual = await request(server.expressApp) const actual = await request(server.expressApp)
.get("/probot") .get("/probot")
.expect(200); .expect(200);
expect(actual.text).toMatch("Welcome to your Probot App"); expect(actual.text).toMatch("Welcome to your Probot App");
}); expect(actual.text).toMatchSnapshot();
afterEach(() => {
process.chdir(cwd);
}); });
}); });
}); });
// Redirect does not work because webhooks middleware is using root path
describe("GET /", () => { describe("GET /", () => {
it("redirects to /probot", () => { it("redirects to /probot", async () => {
return request(server.expressApp) const server = await instantiateServer(__dirname);
await request(server.expressApp)
.get("/") .get("/")
.expect(302) .expect(302)
.expect("location", "/probot"); .expect("location", "/probot");

View File

@ -1,15 +1,26 @@
const createChannel = jest.fn().mockResolvedValue("mocked proxy URL"); import { Stream } from "node:stream";
const updateDotenv = jest.fn().mockResolvedValue({});
jest.mock("smee-client", () => ({ createChannel }));
jest.mock("update-dotenv", () => updateDotenv);
import nock from "nock"; import fetchMock from "fetch-mock";
import { Stream } from "stream"; import { pino } from "pino";
import request from "supertest"; import request from "supertest";
import pino from "pino"; import { beforeEach, afterEach, describe, expect, it, vi } from "vitest";
import { Probot, Server } from "../../src"; import { Probot, Server } from "../../src/index.js";
import { setupAppFactory } from "../../src/apps/setup"; 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", () => { describe("Setup app", () => {
let server: Server; let server: Server;
@ -37,7 +48,7 @@ describe("Setup app", () => {
}); });
afterEach(async () => { afterEach(async () => {
jest.clearAllMocks(); vi.clearAllMocks();
}); });
describe("logs", () => { describe("logs", () => {
@ -91,31 +102,98 @@ describe("Setup app", () => {
describe("GET /probot/setup", () => { describe("GET /probot/setup", () => {
it("returns a redirect", async () => { it("returns a redirect", async () => {
nock("https://api.github.com") const fetch = fetchMock
.post("/app-manifests/123/conversions") .sandbox()
.reply(201, { .postOnce("https://api.github.com/app-manifests/123/conversions", {
status: 201,
body: {
html_url: "/apps/my-app", html_url: "/apps/my-app",
id: "id", id: "id",
pem: "pem", pem: "pem",
webhook_secret: "webhook_secret", webhook_secret: "webhook_secret",
client_id: "Iv1.8a61f9b3a7aba766", client_id: "Iv1.8a61f9b3a7aba766",
client_secret: "1726be1638095a19edd134c77bde3aa2ece1e5d8", 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") .get("/probot/setup")
.query({ code: "123" }) .query({ code: "123" })
.expect(302) .expect(302)
.expect("Location", "/apps/my-app/installations/new"); .expect("Location", "/apps/my-app/installations/new");
expect(createChannel).toHaveBeenCalledTimes(1); expect(setupResponse.text).toMatchSnapshot();
expect(updateDotenv.mock.calls).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", () => { describe("GET /probot/import", () => {
it("renders import.handlebars", async () => { it("renders importView", async () => {
await request(server.expressApp).get("/probot/import").expect(200); 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(200)
.expect(""); .expect("");
expect(updateDotenv.mock.calls).toMatchSnapshot(); expect(mocks.updateDotenv.mock.calls).toMatchSnapshot();
}); });
it("400 when keys are missing", async () => { it("400 when keys are missing", async () => {
@ -144,19 +222,25 @@ describe("Setup app", () => {
webhook_secret: "baz", webhook_secret: "baz",
}); });
await request(server.expressApp) const importResponse = await request(server.expressApp)
.post("/probot/import") .post("/probot/import")
.set("content-type", "application/json") .set("content-type", "application/json")
.send(body) .send(body)
.expect(400); .expect(400);
expect(importResponse.text).toMatchSnapshot();
}); });
}); });
describe("GET /probot/success", () => { describe("GET /probot/success", () => {
it("returns a 200 response", async () => { 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 fs from "node:fs";
import path = require("path"); import path from "node:path";
import { EmitterWebhookEvent as WebhookEvent } from "@octokit/webhooks"; import type { EmitterWebhookEvent as WebhookEvent } from "@octokit/webhooks";
import WebhookExamples, { WebhookDefinition } from "@octokit/webhooks-examples"; import WebhookExamples from "@octokit/webhooks-examples";
import nock from "nock"; 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 { Context } from "../src/index.js";
import { ProbotOctokit } from "../src/octokit/probot-octokit"; import { ProbotOctokit } from "../src/octokit/probot-octokit.js";
import { PushEvent } from "@octokit/webhooks-types"; import type { PushEvent } from "@octokit/webhooks-types";
const pushEventPayload = ( const pushEventPayload = (
WebhookExamples.filter( (WebhookExamples as unknown as WebhookDefinition[]).filter(
(event) => event.name === "push" (event) => event.name === "push",
)[0] as WebhookDefinition<"push"> )[0] as WebhookDefinition<"push">
).examples[0]; ).examples[0];
const issuesEventPayload = ( const issuesEventPayload = (
WebhookExamples.filter( (WebhookExamples as unknown as WebhookDefinition[]).filter(
(event) => event.name === "issues" (event) => event.name === "issues",
)[0] as WebhookDefinition<"issues"> )[0] as WebhookDefinition<"issues">
).examples[0]; ).examples[0];
const pullRequestEventPayload = ( const pullRequestEventPayload = (
WebhookExamples.filter( (WebhookExamples as unknown as WebhookDefinition[]).filter(
(event) => event.name === "pull_request" (event) => event.name === "pull_request",
)[0] as WebhookDefinition<"pull_request"> )[0] as WebhookDefinition<"pull_request">
).examples[0]; ).examples[0] as WebhookEvent<"pull_request">["payload"];
describe("Context", () => { describe("Context", () => {
let event: WebhookEvent<"push"> = { let event: WebhookEvent<"push"> = {
@ -34,7 +36,7 @@ describe("Context", () => {
let context: Context<"push"> = new Context<"push">( let context: Context<"push"> = new Context<"push">(
event, event,
{} as any, {} as any,
{} as any {} as any,
); );
it("inherits the payload", () => { it("inherits the payload", () => {
@ -97,8 +99,8 @@ describe("Context", () => {
try { try {
context.repo(); context.repo();
} catch (e) { } catch (e) {
expect(e.message).toMatch( expect((e as Error).message).toMatch(
"context.repo() is not supported for this webhook event." "context.repo() is not supported for this webhook event.",
); );
} }
}); });
@ -179,15 +181,15 @@ describe("Context", () => {
owner: "muahaha", owner: "muahaha",
pull_number: 5, pull_number: 5,
repo: "Hello-World", repo: "Hello-World",
} },
); );
}); });
}); });
describe("config", () => { 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); const configPath = path.join(__dirname, "fixtures", "config", fileName);
return fs.readFileSync(configPath, { encoding: "utf8" }); return fs.readFileSync(configPath, { encoding: "utf8" });
} }
@ -202,9 +204,21 @@ describe("Context", () => {
}); });
it("gets a valid configuration", async () => { it("gets a valid configuration", async () => {
const mock = nock("https://api.github.com") const fetch = fetchMock
.get("/repos/Codertocat/Hello-World/contents/.github%2Ftest-file.yml") .sandbox()
.reply(200, nockConfigResponseDataFile("basic.yml")); .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"); const config = await context.config("test-file.yml");
expect(config).toEqual({ expect(config).toEqual({
@ -212,39 +226,67 @@ describe("Context", () => {
baz: 11, baz: 11,
foo: 5, foo: 5,
}); });
expect(mock.activeMocks()).toStrictEqual([]);
}); });
it("returns null when the file and base repository are missing", async () => { it("returns null when the file and base repository are missing", async () => {
const mock = nock("https://api.github.com") const NOT_FOUND_RESPONSE = {
.get("/repos/Codertocat/Hello-World/contents/.github%2Ftest-file.yml") status: 404,
.reply(404) body: {
.get("/repos/Codertocat/.github/contents/.github%2Ftest-file.yml") message: "Not Found",
.reply(404); 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(await context.config("test-file.yml")).toBe(null);
expect(mock.activeMocks()).toStrictEqual([]);
}); });
it("accepts deepmerge options", async () => { it("accepts deepmerge options", async () => {
const mock = nock("https://api.github.com") const fetch = fetchMock
.get("/repos/Codertocat/Hello-World/contents/.github%2Ftest-file.yml") .sandbox()
.reply( .getOnce(
200, "https://api.github.com/repos/Codertocat/Hello-World/contents/.github%2Ftest-file.yml",
"foo:\n - name: master\n shouldChange: changed\n_extends: .github" "foo:\n - name: master\n shouldChange: changed\n_extends: .github",
) )
.get("/repos/Codertocat/.github/contents/.github%2Ftest-file.yml") .getOnce(
.reply( "https://api.github.com/repos/Codertocat/.github/contents/.github%2Ftest-file.yml",
200, "foo:\n - name: develop\n - name: master\n shouldChange: should",
"foo:\n - name: develop\n - name: master\n shouldChange: should"
); );
const customMerge = jest.fn( const octokit = new ProbotOctokit({
(_target: any[], _source: any[], _options: any): any[] => [] 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 }); await context.config("test-file.yml", {}, { arrayMerge: customMerge });
expect(customMerge).toHaveBeenCalled(); expect(customMerge).toHaveBeenCalled();
expect(mock.activeMocks()).toStrictEqual([]);
}); });
}); });

View File

@ -1,31 +1,56 @@
import { createServer, IncomingMessage, ServerResponse } from "http"; import { createServer, IncomingMessage, ServerResponse } from "node:http";
import Stream from "stream"; import Stream from "node:stream";
import pino from "pino"; import { pino } from "pino";
import getPort from "get-port"; import getPort from "get-port";
import got from "got";
import { sign } from "@octokit/webhooks-methods"; import { sign } from "@octokit/webhooks-methods";
import { describe, expect, test, beforeEach } from "vitest";
import { createNodeMiddleware, createProbot, Probot } from "../src"; import { createNodeMiddleware, createProbot, Probot } from "../src/index.js";
import { ApplicationFunction } from "../src/types"; import type { ApplicationFunction } from "../src/types.js";
import WebhookExamples, {
type WebhookDefinition,
} from "@octokit/webhooks-examples";
const APP_ID = "1"; const APP_ID = "1";
const PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY----- const PRIVATE_KEY = `-----BEGIN RSA PRIVATE KEY-----
MIIBOQIBAAJBAIILhiN9IFpaE0pUXsesuuoaj6eeDiAqCiE49WB1tMB8ZMhC37kY MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aozN3tihPmljnnudb9G3HECdnH
Fl52NUYbUxb7JEf6pH5H9vqw1Wp69u78XeUCAwEAAQJAb88urnaXiXdmnIK71tuo lWu2/a1gB9JW5TBQ+AVpum9Okx7KfqkfBKL9mcHgSL0yWMdjMfNOqNtrQqKlN4kE
/TyHBKt9I6Rhfzz0o9Gv7coL7a537FVDvV5UCARXHJMF41tKwj+zlt9EEUw7a1HY p6RD++7sGbzbfZ9arwrlD/HSDAWGdGGJTSOBM6pHehyLmSC3DJoR/CTu0vTGTWXQ
wQIhAL4F/VHWSPHeTgXYf4EaX2OlpSOk/n7lsFtL/6bWRzRVAiEArzJs2vopJitv rO64Z8tyXQPtVPb/YXrcUhbBp8i72b9Xky0fD6PkEebOy0Ip58XVAn2UPNlNOSPS
A1yBjz3q2nX+zthk+GLXrJQkYOnIk1ECIHfeFV8TWm5gej1LxZquBTA5pINoqDVq ye+Qjtius0Md4Nie4+X8kwVI2Qjk3dSm0sw/720KJkdVDmrayeljtKBx6AtNQsSX
NKZSuZEHqGEFAiB6EDrxkovq8SYGhIQsJeqkTMO8n94xhMRZlFmIQDokEQIgAq5U gzQbeMmiqFFkwrG1+zx6E7H7jqIQ9B6bvWKXGwIDAQABAoIBAD8kBBPL6PPhAqUB
r1UQNnUExRh7ZT0kFbMfO9jKYZVlQdCL9Dn93vo= 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-----`; -----END RSA PRIVATE KEY-----`;
const WEBHOOK_SECRET = "secret"; 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", () => { describe("createNodeMiddleware", () => {
let output: any[]; let output: any[];
const streamLogsToOutput = new Stream.Writable({ objectMode: true }); const streamLogsToOutput = new Stream.Writable({ objectMode: true });
streamLogsToOutput._write = (object, encoding, done) => { streamLogsToOutput._write = (object, _encoding, done) => {
output.push(JSON.parse(object)); output.push(JSON.parse(object));
done(); done();
}; };
@ -61,7 +86,8 @@ describe("createNodeMiddleware", () => {
const body = JSON.stringify(pushEvent); 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: { headers: {
"content-type": "application/json", "content-type": "application/json",
"x-github-event": "push", "x-github-event": "push",
@ -76,6 +102,94 @@ describe("createNodeMiddleware", () => {
server.close(); 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 () => { test("loads app only once", async () => {
let counter = 0; let counter = 0;
const appFn = () => { const appFn = () => {
@ -90,11 +204,11 @@ describe("createNodeMiddleware", () => {
middleware( middleware(
{} as IncomingMessage, {} as IncomingMessage,
{ end() {}, writeHead() {} } as unknown as ServerResponse { end() {}, writeHead() {} } as unknown as ServerResponse,
); );
middleware( middleware(
{} as IncomingMessage, {} as IncomingMessage,
{ end() {}, writeHead() {} } as unknown as ServerResponse { end() {}, writeHead() {} } as unknown as ServerResponse,
); );
expect(counter).toEqual(1); expect(counter).toEqual(1);

View File

@ -1,20 +1,62 @@
import { createProbot, Probot } from "../src"; import { createProbot, Probot } from "../src/index.js";
import { captureLogOutput } from "./helpers/capture-log-output"; import { captureLogOutput } from "./helpers/capture-log-output.js";
import { describe, expect, test } from "vitest";
const env = { const env = {
APP_ID: "1", APP_ID: "1",
PRIVATE_KEY: `-----BEGIN RSA PRIVATE KEY----- PRIVATE_KEY: `-----BEGIN RSA PRIVATE KEY-----
MIIBOQIBAAJBAIILhiN9IFpaE0pUXsesuuoaj6eeDiAqCiE49WB1tMB8ZMhC37kY MIIJKAIBAAKCAgEAu0E+tR6wfOAJZ4lASzRUmvorCgbI5nQyvZl3WLu6ko2pcEnq
Fl52NUYbUxb7JEf6pH5H9vqw1Wp69u78XeUCAwEAAQJAb88urnaXiXdmnIK71tuo 1t1/W/Yaovt9W8eMFVfoFXKhsHOAM5dFlktxOlcaUQiRYSO7fBbZYVNYoawnCRqD
/TyHBKt9I6Rhfzz0o9Gv7coL7a537FVDvV5UCARXHJMF41tKwj+zlt9EEUw7a1HY HKQ1oKC6B23EKfW5NH8NLaI/+QJFG7fpr0P4HkHghLsOe7rIUDt7EjRsSSRhM2+Y
wQIhAL4F/VHWSPHeTgXYf4EaX2OlpSOk/n7lsFtL/6bWRzRVAiEArzJs2vopJitv sFmRsnj0PWESWyI5exdKys0Mw25CmGsA27ltmebgHFYQ4ac+z0Esbjujcxec5wtn
A1yBjz3q2nX+zthk+GLXrJQkYOnIk1ECIHfeFV8TWm5gej1LxZquBTA5pINoqDVq oAMT6jIBjEPHYTy0Cbe/wDN0cZkg6QyNC09lMnUx8vP1gwAVP20VXfjdFHZ8cR80
NKZSuZEHqGEFAiB6EDrxkovq8SYGhIQsJeqkTMO8n94xhMRZlFmIQDokEQIgAq5U ungLmBG0SWgVglqv52C5Gad2hEDsWyi28/XZ9/mNGatZJ1SSmA6+TSuSlrs/Dm0K
r1UQNnUExRh7ZT0kFbMfO9jKYZVlQdCL9Dn93vo= 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-----`, -----END RSA PRIVATE KEY-----`,
WEBHOOK_SECRET: "secret", WEBHOOK_SECRET: "secret",
}; };
// tslint:disable:no-empty
describe("createProbot", () => { describe("createProbot", () => {
test("createProbot()", () => { test("createProbot()", () => {
const probot = createProbot({ env }); const probot = createProbot({ env });
@ -41,6 +83,20 @@ describe("createProbot", () => {
expect(probot.log.level).toEqual("trace"); 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", () => { test("env, overrides", () => {
const probot = createProbot({ const probot = createProbot({
env: { env: {
@ -65,7 +121,6 @@ describe("createProbot", () => {
}); });
test("env, logger message key", async () => { test("env, logger message key", async () => {
const outputData = await captureLogOutput(() => {
const probot = createProbot({ const probot = createProbot({
env: { env: {
...env, ...env,
@ -75,14 +130,13 @@ describe("createProbot", () => {
}, },
defaults: { logLevel: "trace" }, defaults: { logLevel: "trace" },
}); });
const outputData = await captureLogOutput(() => {
probot.log.info("Ciao"); probot.log.info("Ciao");
}); }, probot.log);
expect(JSON.parse(outputData).myMessage).toEqual("Ciao"); expect(JSON.parse(outputData).myMessage).toEqual("Ciao");
}); });
test("env, octokit logger set", async () => { test("env, octokit logger set", async () => {
const outputData = await captureLogOutput(async () => {
const probot = createProbot({ const probot = createProbot({
env: { env: {
...env, ...env,
@ -91,10 +145,10 @@ describe("createProbot", () => {
LOG_MESSAGE_KEY: "myMessage", LOG_MESSAGE_KEY: "myMessage",
}, },
}); });
const outputData = await captureLogOutput(async () => {
const octokit = await probot.auth(); const octokit = await probot.auth();
octokit.log.info("Ciao"); octokit.log.info("Ciao");
}); }, probot.log);
expect(JSON.parse(outputData)).toMatchObject({ expect(JSON.parse(outputData)).toMatchObject({
myMessage: "Ciao", myMessage: "Ciao",
name: "octokit", name: "octokit",

View File

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

View File

@ -2,22 +2,20 @@ import execa from "execa";
import getPort from "get-port"; import getPort from "get-port";
import { sign } from "@octokit/webhooks-methods"; import { sign } from "@octokit/webhooks-methods";
import bodyParser from "body-parser";
import express from "express"; 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. * 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. * 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 * to verify they are what we expect
*/ */
describe("end-to-end-tests", () => { describe("end-to-end-tests", () => {
let server: any; let server: any;
let probotProcess: any; let probotProcess: execa.ExecaChildProcess<string> | null;
let probotPort: number; let probotPort: number;
let mockServerPort: number; let mockServerPort: number;
@ -34,7 +32,7 @@ describe("end-to-end-tests", () => {
it("hello-world app", async () => { it("hello-world app", async () => {
const app = express(); const app = express();
const httpMock = jest const httpMock = vi
.fn() .fn()
.mockImplementationOnce((req, res) => { .mockImplementationOnce((req, res) => {
expect(req.method).toEqual("POST"); expect(req.method).toEqual("POST");
@ -52,37 +50,39 @@ describe("end-to-end-tests", () => {
.mockImplementationOnce((req, res) => { .mockImplementationOnce((req, res) => {
expect(req.method).toEqual("POST"); expect(req.method).toEqual("POST");
expect(req.path).toEqual( 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!" }); expect(req.body).toStrictEqual({ body: "Hello World!" });
res.status(201).json({}); res.status(201).json({});
}); });
// tslint:disable-next-line app.use(express.json());
app.use(bodyParser.json());
app.use("/api/v3", httpMock); app.use("/api/v3", httpMock);
server = app.listen(mockServerPort); server = app.listen(mockServerPort);
probotProcess = execa( probotProcess = execa(
"bin/probot.js", "node",
["run", "./test/e2e/hello-world.js"], ["bin/probot.js", "run", "./test/e2e/hello-world.cjs"],
{ {
env: { env: {
APP_ID: "1", APP_ID: "1",
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-----`,
"-----BEGIN RSA PRIVATE KEY-----\nMIIBOQIBAAJBAIILhiN9IFpaE0pUXsesuuoaj6eeDiAqCiE49WB1tMB8ZMhC37kY\nFl52NUYbUxb7JEf6pH5H9vqw1Wp69u78XeUCAwEAAQJAb88urnaXiXdmnIK71tuo\n/TyHBKt9I6Rhfzz0o9Gv7coL7a537FVDvV5UCARXHJMF41tKwj+zlt9EEUw7a1HY\nwQIhAL4F/VHWSPHeTgXYf4EaX2OlpSOk/n7lsFtL/6bWRzRVAiEArzJs2vopJitv\nA1yBjz3q2nX+zthk+GLXrJQkYOnIk1ECIHfeFV8TWm5gej1LxZquBTA5pINoqDVq\nNKZSuZEHqGEFAiB6EDrxkovq8SYGhIQsJeqkTMO8n94xhMRZlFmIQDokEQIgAq5U\nr1UQNnUExRh7ZT0kFbMfO9jKYZVlQdCL9Dn93vo=\n-----END RSA PRIVATE KEY-----",
WEBHOOK_SECRET: "test", WEBHOOK_SECRET: "test",
PORT: String(probotPort), PORT: String(probotPort),
GHE_HOST: `127.0.0.1:${mockServerPort}`, GHE_HOST: `127.0.0.1:${mockServerPort}`,
GHE_PROTOCOL: "http", GHE_PROTOCOL: "http",
LOG_LEVEL: "trace", LOG_LEVEL: "trace",
WEBHOOK_PATH: "/",
},
stdio: "inherit",
}, },
}
); );
// give probot a moment to start // give probot a moment to start
await new Promise((resolve) => setTimeout(resolve, 3000)); await new Promise((resolve) => setTimeout(resolve, 3000));
// the probot process should be successfully started
expect(probotProcess.exitCode).toBeNull();
// send webhook event request // send webhook event request
const body = JSON.stringify({ const body = JSON.stringify({
@ -102,7 +102,8 @@ describe("end-to-end-tests", () => {
}); });
try { try {
await got.post(`http://127.0.0.1:${probotPort}`, { await fetch(`http://127.0.0.1:${probotPort}/`, {
method: "POST",
headers: { headers: {
"content-type": "application/json", "content-type": "application/json",
"x-github-event": "issues", "x-github-event": "issues",
@ -113,7 +114,9 @@ describe("end-to-end-tests", () => {
}); });
} catch (error) { } catch (error) {
probotProcess.cancel(); probotProcess.cancel();
console.log((await probotProcess).stdout); const awaitedProcess = await probotProcess;
console.log(awaitedProcess.stdout);
console.log(awaitedProcess.stderr);
} }
expect(httpMock).toHaveBeenCalledTimes(2); expect(httpMock).toHaveBeenCalledTimes(2);

View File

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

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 default () => {};
export = () => {};

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 = ""; let outputData = "";
const sbWrite = SonicBoom.prototype.write; const stdoutSpy: SpyInstance = vi.spyOn(
SonicBoom.prototype.write = function (data) { // @ts-expect-error
log[pinoSymbols.streamSym],
"write",
);
stdoutSpy.mockImplementation((data) => {
outputData += data; outputData += data;
}; });
try { try {
await action(); await action();
return outputData; return outputData;
} finally { } 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", () => { describe("isProduction", () => {
it("returns true if the NODE_ENV is set to production", () => { it("returns true if the NODE_ENV is set to production", () => {
@ -11,6 +12,6 @@ describe("isProduction", () => {
(value) => { (value) => {
process.env.NODE_ENV = value; process.env.NODE_ENV = value;
expect(isProduction()).toBe(false); 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 fs from "node:fs";
import nock from "nock"; import path from "node:path";
import pkg from "../package.json"; import fetchMock from "fetch-mock";
import { ManifestCreation } from "../src/manifest-creation"; import { describe, expect, test, beforeEach, afterEach, vi } from "vitest";
import response from "./fixtures/setup/response.json";
import { ManifestCreation } from "../src/manifest-creation.js";
import { loadPackageJson } from "../src/helpers/load-package-json.js";
describe("ManifestCreation", () => { describe("ManifestCreation", () => {
let setup: ManifestCreation; let setup: ManifestCreation;
const pkg = loadPackageJson();
const response = JSON.parse(
fs.readFileSync(
path.resolve(process.cwd(), "./test/fixtures/setup/response.json"),
"utf8",
),
);
beforeEach(() => { beforeEach(() => {
setup = new ManifestCreation(); setup = new ManifestCreation();
}); });
@ -17,10 +26,10 @@ describe("ManifestCreation", () => {
delete process.env.PROJECT_DOMAIN; delete process.env.PROJECT_DOMAIN;
delete process.env.WEBHOOK_PROXY_URL; delete process.env.WEBHOOK_PROXY_URL;
setup.updateEnv = jest.fn(); setup.updateEnv = vi.fn();
const SmeeClient: typeof import("smee-client") = require("smee-client"); const SmeeClient: typeof import("smee-client") = require("smee-client");
SmeeClient.createChannel = jest SmeeClient.createChannel = vi
.fn() .fn()
.mockReturnValue("https://smee.io/1234abc"); .mockReturnValue("https://smee.io/1234abc");
}); });
@ -52,21 +61,21 @@ describe("ManifestCreation", () => {
test("creates an app url", () => { test("creates an app url", () => {
expect(setup.createAppUrl).toEqual( 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", () => { test("creates an app url when github org is set", () => {
process.env.GH_ORG = "testorg"; process.env.GH_ORG = "testorg";
expect(setup.createAppUrl).toEqual( 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", () => { test("creates an app url when github host env is set", () => {
process.env.GHE_HOST = "hiimbex.github.com"; process.env.GHE_HOST = "hiimbex.github.com";
expect(setup.createAppUrl).toEqual( 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.GHE_HOST = "hiimbex.github.com";
process.env.GH_ORG = "testorg"; process.env.GH_ORG = "testorg";
expect(setup.createAppUrl).toEqual( 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_HOST = "hiimbex.github.com";
process.env.GHE_PROTOCOL = "http"; process.env.GHE_PROTOCOL = "http";
expect(setup.createAppUrl).toEqual( expect(setup.createAppUrl).toEqual(
"http://hiimbex.github.com/settings/apps/new" "http://hiimbex.github.com/settings/apps/new",
); );
}); });
}); });
describe("createAppFromCode", () => { describe("createAppFromCode", () => {
beforeEach(() => { beforeEach(() => {
setup.updateEnv = jest.fn(); setup.updateEnv = vi.fn();
}); });
afterEach(() => { afterEach(() => {
@ -100,11 +109,18 @@ describe("ManifestCreation", () => {
}); });
test("creates an app from a code", async () => { test("creates an app from a code", async () => {
nock("https://api.github.com") const fetch = fetchMock
.post("/app-manifests/123abc/conversions") .sandbox()
.reply(200, response); .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(createdApp).toEqual("https://github.com/apps/testerino0000000");
// expect dotenv to be called with id, webhook_secret, pem // expect dotenv to be called with id, webhook_secret, pem
expect(setup.updateEnv).toHaveBeenCalledWith({ expect(setup.updateEnv).toHaveBeenCalledWith({
@ -118,11 +134,21 @@ describe("ManifestCreation", () => {
test("creates an app from a code when github host env is set", async () => { test("creates an app from a code when github host env is set", async () => {
process.env.GHE_HOST = "swinton.github.com"; process.env.GHE_HOST = "swinton.github.com";
nock("https://swinton.github.com") const fetch = fetchMock
.post("/api/v3/app-manifests/123abc/conversions") .sandbox()
.reply(200, response); .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(createdApp).toEqual("https://github.com/apps/testerino0000000");
// expect dotenv to be called with id, webhook_secret, pem // expect dotenv to be called with id, webhook_secret, pem
expect(setup.updateEnv).toHaveBeenCalledWith({ expect(setup.updateEnv).toHaveBeenCalledWith({
@ -136,24 +162,24 @@ describe("ManifestCreation", () => {
describe("getManifest", () => { describe("getManifest", () => {
afterEach(() => { afterEach(() => {
jest.clearAllMocks(); vi.clearAllMocks();
}); });
test("creates an app from a code", () => { test("creates an app from a code", () => {
// checks that getManifest returns a JSON.stringified manifest // checks that getManifest returns a JSON.stringified manifest
expect(setup.getManifest(pkg, "localhost://3000")).toEqual( 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", () => { test("creates an app from a code with overrided values from app.yml", () => {
const appYaml = const appYaml =
"name: cool-app\ndescription: A description for a cool app"; "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 // checks that getManifest returns the correct JSON.stringified manifest
expect(setup.getManifest(pkg, "localhost://3000")).toEqual( 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 fetchMock from "fetch-mock";
import { ProbotOctokit } from "../src/octokit/probot-octokit"; import { ProbotOctokit } from "../src/octokit/probot-octokit.js";
import type { RequestError } from "@octokit/types";
type Options = ConstructorParameters<typeof ProbotOctokit>[0]; import type { OctokitOptions } from "../src/types.js";
import { describe, expect, test, vi, it } from "vitest";
describe("ProbotOctokit", () => { describe("ProbotOctokit", () => {
let octokit: InstanceType<typeof ProbotOctokit>; const defaultOptions: OctokitOptions = {
const defaultOptions: Options = {
retry: { retry: {
// disable retries to test error states // disable retries to test error states
enabled: false, enabled: false,
@ -17,106 +16,296 @@ describe("ProbotOctokit", () => {
}, },
}; };
beforeEach(() => { test("works without options", async () => {
octokit = new ProbotOctokit(defaultOptions); const fetch = fetchMock
.sandbox()
.getOnce("https://api.github.com/user", '{"login": "ohai"}');
const octokit = new ProbotOctokit({
...defaultOptions,
request: {
fetch,
},
}); });
test("works without options", async () => { expect((await octokit.users.getAuthenticated({})).data).toEqual(
octokit = new ProbotOctokit(); '{"login": "ohai"}',
const user = { login: "ohai" }; );
nock("https://api.github.com").get("/user").reply(200, user);
expect((await octokit.users.getAuthenticated({})).data).toEqual(user);
}); });
test("logs request errors", async () => { 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 { try {
await octokit.request("/"); await octokit.request("/");
throw new Error("should throw"); throw new Error("should throw");
} catch (error) { } catch (error) {
expect(error.status).toBe(500); expect((error as RequestError).status).toBe(500);
} }
}); });
describe("with retry enabled", () => { test("with retry enabled retries failed requests", async () => {
beforeEach(() => { let callCount = 0;
const options: Options = {
const octokit = new ProbotOctokit({
...defaultOptions, ...defaultOptions,
retry: { retry: {
enabled: true, 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);
octokit = new ProbotOctokit(options); if (callCount++ === 0) {
return Promise.reject({});
}
return Promise.resolve({
status: 200,
headers: new Headers(),
text: () => Promise.resolve("{}"),
});
},
},
}); });
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("/"); const response = await octokit.request("/");
expect(response.status).toBe(200); expect(response.status).toBe(200);
}); });
});
describe("with throttling enabled", () => { test("with throttling enabled retries requests when being rate limited", async () => {
beforeEach(() => { let callCount = 0;
const options: Options = {
const octokit = new ProbotOctokit({
...defaultOptions, ...defaultOptions,
throttle: { throttle: {
enabled: true, enabled: true,
minimumAbuseRetryAfter: 1, fallbackSecondaryRateRetryAfter: 1,
onRateLimit() { onRateLimit() {
return true; return true;
}, },
onAbuseLimit() { onSecondaryRateLimit() {
return true; 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);
octokit = new ProbotOctokit(options); if (callCount++ === 0) {
}); return Promise.resolve({
status: 403,
test("retries requests when being rate limited", async () => { headers: new Headers({
nock("https://api.github.com")
.get("/")
.reply(
403,
{},
{
"X-RateLimit-Limit": "60", "X-RateLimit-Limit": "60",
"X-RateLimit-Remaining": "0", "X-RateLimit-Remaining": "0",
"X-RateLimit-Reset": `${new Date().getTime() / 1000}`, "X-RateLimit-Reset": `${new Date().getTime() / 1000}`,
}),
text: () => Promise.resolve("{}"),
});
} }
)
.get("/") return Promise.resolve({
.reply(200, {}); status: 200,
headers: new Headers(),
text: () => Promise.resolve("{}"),
});
},
},
});
const { status } = await octokit.request("/"); const { status } = await octokit.request("/");
expect(status).toBe(200); expect(status).toBe(200);
}); });
test("retries requests when hitting the abuse limiter", async () => { test("with throttling enabled using default onPrimaryRateLimit", async () => {
nock("https://api.github.com").get("/").once().reply(403, { expect.assertions(14);
message: let callCount = 0;
"The throttle plugin just looks for the word abuse in the error message",
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("{}"),
});
},
},
}); });
nock("https://api.github.com").get("/").once().reply(200, {}); 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("{}"),
});
},
},
});
const response = await octokit.request("/"); const response = await octokit.request("/");
expect(response.status).toBe(200); 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("{}"),
});
},
},
});
const response = await octokit.request("/");
expect(response.status).toBe(200);
}); });
describe("paginate", () => {
// Prepare an array of issue objects // Prepare an array of issue objects
const issues = new Array(5).fill(0).map((_, i, arr) => { const issues = new Array(5).fill(0).map((_, i) => {
return { return {
id: i, id: i,
number: i, number: i,
@ -124,47 +313,99 @@ describe("ProbotOctokit", () => {
}; };
}); });
beforeEach(() => { it("paginate returns an array of pages", async () => {
nock("https://api.github.com") let callCount = 0;
.get("/repos/JasonEtco/pizza/issues?per_page=1") const octokit = new ProbotOctokit({
.reply(200, [issues[0]], { ...defaultOptions,
link: '<https://api.github.com/repositories/123/issues?per_page=1&page=2>; rel="next"', request: {
}) fetch: (url: string, options: { [key: string]: any }) => {
.get("/repositories/123/issues?per_page=1&page=2") if (callCount === 0) {
.reply(200, [issues[1]], { expect(url).toEqual(
link: '<https://api.github.com/repositories/123/issues?per_page=1&page=3>; rel="next"', "https://api.github.com/repos/JasonEtco/pizza/issues?per_page=1",
}) );
.get("/repositories/123/issues?per_page=1&page=3") } else {
.reply(200, [issues[2]], { expect(url).toMatch(
link: '<https://api.github.com/repositories/123/issues?per_page=1&page=4>; rel="next"', new RegExp(
}) "^https://api\\.github\\.com/repositories/[0-9]+/issues\\?per_page=[0-9]+&page=[0-9]+$",
.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"', }
}) expect(options.method).toEqual("GET");
.get("/repositories/123/issues?per_page=1&page=5") expect(options.headers.accept).toEqual(
.reply(200, [issues[4]], { "application/vnd.github.v3+json",
link: "", );
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"',`
: "",
}),
}); });
},
},
}); });
it("returns an array of pages", async () => { const spy = vi.fn();
const spy = jest.fn();
const res = await octokit.paginate( const res = await octokit.paginate(
octokit.issues.listForRepo.endpoint.merge({ octokit.issues.listForRepo.endpoint.merge({
owner: "JasonEtco", owner: "JasonEtco",
repo: "pizza", repo: "pizza",
per_page: 1, per_page: 1,
}), }),
spy spy,
); );
expect(Array.isArray(res)).toBeTruthy(); expect(Array.isArray(res)).toBeTruthy();
expect(res.length).toBe(5); expect(res.length).toBe(5);
expect(spy).toHaveBeenCalledTimes(5); expect(spy).toHaveBeenCalledTimes(5);
}); });
it("stops iterating if the done() function is called in the callback", async () => { it("paginate stops iterating if the done() function is called in the callback", async () => {
const spy = jest.fn((response, done) => { 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(); if (response.data[0].id === 2) done();
}) as any; }) as any;
const res = await octokit.paginate( const res = await octokit.paginate(
@ -173,21 +414,57 @@ describe("ProbotOctokit", () => {
repo: "pizza", repo: "pizza",
per_page: 1, per_page: 1,
}), }),
spy spy,
); );
expect(res.length).toBe(3); expect(res.length).toBe(3);
expect(spy).toHaveBeenCalledTimes(3); expect(spy).toHaveBeenCalledTimes(3);
}); });
it("maps the responses to data by default", async () => { 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( const res = await octokit.paginate(
octokit.issues.listForRepo.endpoint.merge({ octokit.issues.listForRepo.endpoint.merge({
owner: "JasonEtco", owner: "JasonEtco",
repo: "pizza", repo: "pizza",
per_page: 1, per_page: 1,
}) }),
); );
expect(res).toEqual(issues); expect(res).toEqual(issues);
}); });
});
}); });

View File

@ -1,37 +1,59 @@
import Stream from "stream"; import Stream from "node:stream";
import { import type {
EmitterWebhookEvent, EmitterWebhookEvent,
EmitterWebhookEvent as WebhookEvent, EmitterWebhookEvent as WebhookEvent,
} from "@octokit/webhooks"; } from "@octokit/webhooks";
import Bottleneck from "bottleneck"; import Bottleneck from "bottleneck";
import nock from "nock"; import fetchMock from "fetch-mock";
import pino from "pino"; 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 webhookExamples, {
import { EmitterWebhookEventName } from "@octokit/webhooks/dist-types/types"; type WebhookDefinition,
} from "@octokit/webhooks-examples";
import type { EmitterWebhookEventName } from "@octokit/webhooks/dist-types/types.js";
const appId = 1; const appId = 1;
const privateKey = `-----BEGIN RSA PRIVATE KEY----- const privateKey = `-----BEGIN RSA PRIVATE KEY-----
MIIBOQIBAAJBAIILhiN9IFpaE0pUXsesuuoaj6eeDiAqCiE49WB1tMB8ZMhC37kY MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aozN3tihPmljnnudb9G3HECdnH
Fl52NUYbUxb7JEf6pH5H9vqw1Wp69u78XeUCAwEAAQJAb88urnaXiXdmnIK71tuo lWu2/a1gB9JW5TBQ+AVpum9Okx7KfqkfBKL9mcHgSL0yWMdjMfNOqNtrQqKlN4kE
/TyHBKt9I6Rhfzz0o9Gv7coL7a537FVDvV5UCARXHJMF41tKwj+zlt9EEUw7a1HY p6RD++7sGbzbfZ9arwrlD/HSDAWGdGGJTSOBM6pHehyLmSC3DJoR/CTu0vTGTWXQ
wQIhAL4F/VHWSPHeTgXYf4EaX2OlpSOk/n7lsFtL/6bWRzRVAiEArzJs2vopJitv rO64Z8tyXQPtVPb/YXrcUhbBp8i72b9Xky0fD6PkEebOy0Ip58XVAn2UPNlNOSPS
A1yBjz3q2nX+zthk+GLXrJQkYOnIk1ECIHfeFV8TWm5gej1LxZquBTA5pINoqDVq ye+Qjtius0Md4Nie4+X8kwVI2Qjk3dSm0sw/720KJkdVDmrayeljtKBx6AtNQsSX
NKZSuZEHqGEFAiB6EDrxkovq8SYGhIQsJeqkTMO8n94xhMRZlFmIQDokEQIgAq5U gzQbeMmiqFFkwrG1+zx6E7H7jqIQ9B6bvWKXGwIDAQABAoIBAD8kBBPL6PPhAqUB
r1UQNnUExRh7ZT0kFbMfO9jKYZVlQdCL9Dn93vo= 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-----`; -----END RSA PRIVATE KEY-----`;
const getPayloadExamples = <TName extends EmitterWebhookEventName>( const getPayloadExamples = <TName extends EmitterWebhookEventName>(
name: TName name: TName,
) => { ) => {
return webhookExamples.filter((event) => event.name === name.split(".")[0])[0] return (webhookExamples as unknown as WebhookDefinition[]).filter(
.examples as EmitterWebhookEvent<TName>["payload"][]; (event) => event.name === name.split(".")[0],
)[0].examples as EmitterWebhookEvent<TName>["payload"][];
}; };
const getPayloadExample = <TName extends EmitterWebhookEventName>( const getPayloadExample = <TName extends EmitterWebhookEventName>(
name: TName name: TName,
) => { ) => {
const examples = getPayloadExamples<TName>(name); const examples = getPayloadExamples<TName>(name);
if (name.includes(".")) { if (name.includes(".")) {
@ -43,7 +65,6 @@ const getPayloadExample = <TName extends EmitterWebhookEventName>(
} }
return examples[0]; return examples[0];
}; };
// tslint:disable:no-empty
describe("Probot", () => { describe("Probot", () => {
let probot: Probot; let probot: Probot;
let event: WebhookEvent< let event: WebhookEvent<
@ -52,7 +73,7 @@ describe("Probot", () => {
let output: any; let output: any;
const streamLogsToOutput = new Stream.Writable({ objectMode: true }); const streamLogsToOutput = new Stream.Writable({ objectMode: true });
streamLogsToOutput._write = (object, encoding, done) => { streamLogsToOutput._write = (object, _encoding, done) => {
output.push(JSON.parse(object)); output.push(JSON.parse(object));
done(); done();
}; };
@ -69,22 +90,26 @@ describe("Probot", () => {
describe(".defaults()", () => { describe(".defaults()", () => {
test("sets default options for constructor", async () => { test("sets default options for constructor", async () => {
const mock = nock("https://api.github.com").get("/app").reply(200, { const fetch = fetchMock.sandbox().getOnce("https://api.github.com/app", {
status: 200,
body: {
id: 1, id: 1,
},
}); });
const MyProbot = Probot.defaults({ appId, privateKey }); const MyProbot = Probot.defaults({ appId, privateKey });
const probot = new MyProbot(); const probot = new MyProbot({
request: { fetch },
});
const octokit = await probot.auth(); const octokit = await probot.auth();
await octokit.apps.getAuthenticated(); await octokit.apps.getAuthenticated();
expect(mock.activeMocks()).toStrictEqual([]);
}); });
}); });
describe("constructor", () => { describe("constructor", () => {
it("no options", () => { it("no options", () => {
expect(() => new Probot()).toThrow( 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)}`", () => { it("shouldn't overwrite `options.throttle` passed to `{Octokit: ProbotOctokit.defaults(options)}`", () => {
expect.assertions(1); expect.assertions(1);
const MyOctokit = ProbotOctokit.plugin((octokit, options) => { const MyOctokit = ProbotOctokit.plugin((_octokit, options) => {
expect(options.throttle.enabled).toEqual(false); expect(options.throttle?.enabled).toEqual(true);
}).defaults({ }).defaults({
appId, appId,
privateKey, privateKey,
@ -133,7 +158,7 @@ describe("Probot", () => {
it("responds with the correct error if webhook secret does not match", async () => { it("responds with the correct error if webhook secret does not match", async () => {
expect.assertions(1); expect.assertions(1);
probot.log.error = jest.fn(); probot.log.error = vi.fn() as LogFn;
probot.webhooks.on("push", () => { probot.webhooks.on("push", () => {
throw new Error("X-Hub-Signature-256 does not match blob signature"); throw new Error("X-Hub-Signature-256 does not match blob signature");
}); });
@ -141,16 +166,14 @@ describe("Probot", () => {
try { try {
await probot.webhooks.receive(event); await probot.webhooks.receive(event);
} catch (e) { } catch (e) {
expect( expect((probot.log.error as Mock).mock.calls[0][1]).toMatchSnapshot();
(probot.log.error as jest.Mock).mock.calls[0][1]
).toMatchSnapshot();
} }
}); });
it("responds with the correct error if webhook secret is not found", async () => { it("responds with the correct error if webhook secret is not found", async () => {
expect.assertions(1); expect.assertions(1);
probot.log.error = jest.fn(); probot.log.error = vi.fn() as LogFn;
probot.webhooks.on("push", () => { probot.webhooks.on("push", () => {
throw new Error("No X-Hub-Signature-256 found on request"); throw new Error("No X-Hub-Signature-256 found on request");
}); });
@ -158,66 +181,58 @@ describe("Probot", () => {
try { try {
await probot.webhooks.receive(event); await probot.webhooks.receive(event);
} catch (e) { } catch (e) {
expect( expect((probot.log.error as Mock).mock.calls[0][1]).toMatchSnapshot();
(probot.log.error as jest.Mock).mock.calls[0][1]
).toMatchSnapshot();
} }
}); });
it("responds with the correct error if webhook secret is wrong", async () => { it("responds with the correct error if webhook secret is wrong", async () => {
expect.assertions(1); expect.assertions(1);
probot.log.error = jest.fn(); probot.log.error = vi.fn() as LogFn;
probot.webhooks.on("push", () => { probot.webhooks.on("push", () => {
throw Error( 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 { try {
await probot.webhooks.receive(event); await probot.webhooks.receive(event);
} catch (e) { } catch (e) {
expect( expect((probot.log.error as Mock).mock.calls[0][1]).toMatchSnapshot();
(probot.log.error as jest.Mock).mock.calls[0][1]
).toMatchSnapshot();
} }
}); });
it("responds with the correct error if the PEM file is missing", async () => { it("responds with the correct error if the PEM file is missing", async () => {
expect.assertions(1); expect.assertions(1);
probot.log.error = jest.fn(); probot.log.error = vi.fn() as LogFn;
probot.webhooks.onAny(() => { probot.webhooks.onAny(() => {
throw new Error( throw new Error(
"error:0906D06C:PEM routines:PEM_read_bio:no start line" "error:0906D06C:PEM routines:PEM_read_bio:no start line",
); );
}); });
try { try {
await probot.webhooks.receive(event); await probot.webhooks.receive(event);
} catch (e) { } catch (e) {
expect( expect((probot.log.error as Mock).mock.calls[0][1]).toMatchSnapshot();
(probot.log.error as jest.Mock).mock.calls[0][1]
).toMatchSnapshot();
} }
}); });
it("responds with the correct error if the jwt could not be decoded", async () => { it("responds with the correct error if the jwt could not be decoded", async () => {
expect.assertions(1); expect.assertions(1);
probot.log.error = jest.fn(); probot.log.error = vi.fn() as LogFn;
probot.webhooks.onAny(() => { probot.webhooks.onAny(() => {
throw new Error( 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 { try {
await probot.webhooks.receive(event); await probot.webhooks.receive(event);
} catch (e) { } catch (e) {
expect( expect((probot.log.error as Mock).mock.calls[0][1]).toMatchSnapshot();
(probot.log.error as jest.Mock).mock.calls[0][1]
).toMatchSnapshot();
} }
}); });
}); });
@ -227,7 +242,7 @@ describe("Probot", () => {
const appFn = async (app: Probot) => { const appFn = async (app: Probot) => {
const octokit = await app.auth(); const octokit = await app.auth();
expect(octokit.request.endpoint.DEFAULTS.baseUrl).toEqual( 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 appFn = async (app: Probot) => {
const octokit = await app.auth(); const octokit = await app.auth();
expect(octokit.request.endpoint.DEFAULTS.baseUrl).toEqual( 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 appFn = async (app: Probot) => {
const octokit = await app.auth(); const octokit = await app.auth();
expect(octokit.request.endpoint.DEFAULTS.baseUrl).toEqual( 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", () => { describe.skipIf(process.env.REDIS_URL === undefined)(
"options.redisConfig as string",
() => {
it("sets throttle options", async () => { it("sets throttle options", async () => {
expect.assertions(2); expect.assertions(2);
probot = new Probot({ probot = new Probot({
githubToken: "faketoken", githubToken: "faketoken",
redisConfig: "test", redisConfig: process.env.REDIS_URL,
Octokit: ProbotOctokit.plugin((octokit, options) => { Octokit: ProbotOctokit.plugin((_octokit, options) => {
expect(options.throttle.Bottleneck).toBe(Bottleneck); expect(options.throttle?.Bottleneck).toBe(Bottleneck);
expect(options.throttle.connection).toBeInstanceOf( expect(options.throttle?.connection).toBeInstanceOf(
Bottleneck.IORedisConnection Bottleneck.IORedisConnection,
); );
}), }),
}); });
}); });
}); },
);
describe.skip("redis configuration object", () => { describe.skipIf(process.env.REDIS_URL === undefined)(
"redis configuration object",
() => {
it("sets throttle options", async () => { it("sets throttle options", async () => {
expect.assertions(2); expect.assertions(2);
const redisConfig = { const redisConfig = {
host: "test", host: process.env.REDIS_URL,
}; };
probot = new Probot({ probot = new Probot({
githubToken: "faketoken", githubToken: "faketoken",
redisConfig, redisConfig,
Octokit: ProbotOctokit.plugin((octokit, options) => { Octokit: ProbotOctokit.plugin((_octokit, options) => {
expect(options.throttle.Bottleneck).toBe(Bottleneck); expect(options.throttle?.Bottleneck).toBe(Bottleneck);
expect(options.throttle.connection).toBeInstanceOf( expect(options.throttle?.connection).toBeInstanceOf(
Bottleneck.IORedisConnection Bottleneck.IORedisConnection,
); );
}), }),
}); });
}); });
}); },
);
describe("on", () => { describe("on", () => {
beforeEach(() => { beforeEach(() => {
@ -325,7 +346,7 @@ describe("Probot", () => {
privateKey, privateKey,
}); });
const spy = jest.fn(); const spy = vi.fn();
probot.on("pull_request", spy); probot.on("pull_request", spy);
expect(spy).toHaveBeenCalledTimes(0); expect(spy).toHaveBeenCalledTimes(0);
@ -341,9 +362,15 @@ describe("Probot", () => {
privateKey, privateKey,
}); });
const spy = jest.fn(); const spy = vi.fn();
probot.on("pull_request.opened", spy); 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); await probot.receive(event);
expect(spy).toHaveBeenCalled(); expect(spy).toHaveBeenCalled();
}); });
@ -354,7 +381,7 @@ describe("Probot", () => {
privateKey, privateKey,
}); });
const spy = jest.fn(); const spy = vi.fn();
probot.on("pull_request.closed", spy); probot.on("pull_request.closed", spy);
await probot.receive(event); await probot.receive(event);
@ -367,7 +394,7 @@ describe("Probot", () => {
privateKey, privateKey,
}); });
const spy = jest.fn(); const spy = vi.fn();
probot.onAny(spy); probot.onAny(spy);
await probot.receive(event); await probot.receive(event);
@ -380,13 +407,19 @@ describe("Probot", () => {
privateKey, privateKey,
}); });
const event: WebhookEvent<"pull_request.opened"> = {
id: "123-456",
name: "pull_request",
payload: getPayloadExample("pull_request.opened"),
};
const event2: WebhookEvent<"issues.opened"> = { const event2: WebhookEvent<"issues.opened"> = {
id: "123", id: "123",
name: "issues", name: "issues",
payload: getPayloadExample("issues.opened"), payload: getPayloadExample("issues.opened"),
}; };
const spy = jest.fn(); const spy = vi.fn();
probot.on(["pull_request.opened", "issues.opened"], spy); probot.on(["pull_request.opened", "issues.opened"], spy);
await probot.receive(event); await probot.receive(event);
@ -401,7 +434,7 @@ describe("Probot", () => {
log: pino(streamLogsToOutput), log: pino(streamLogsToOutput),
}); });
const handler = jest.fn().mockImplementation((context) => { const handler = vi.fn().mockImplementation((context) => {
expect(context.log.info).toBeDefined(); expect(context.log.info).toBeDefined();
context.log.info("testing"); context.log.info("testing");
@ -409,7 +442,7 @@ describe("Probot", () => {
expect.objectContaining({ expect.objectContaining({
id: context.id, id: context.id,
msg: "testing", msg: "testing",
}) }),
); );
}); });
@ -419,9 +452,40 @@ describe("Probot", () => {
}); });
it("returns an authenticated client for installation.created", async () => { 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({ const probot = new Probot({
appId, appId,
privateKey, privateKey,
request: {
fetch,
},
}); });
event = { event = {
@ -431,32 +495,35 @@ describe("Probot", () => {
}; };
event.payload.installation.id = 1; 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) => { probot.on("installation.created", async (context) => {
await context.octokit.request("/"); await context.octokit.request("/");
}); });
await probot.receive(event); await probot.receive(event);
expect(mock.activeMocks()).toStrictEqual([]);
}); });
it("returns an unauthenticated client for installation.deleted", async () => { 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({ const probot = new Probot({
appId, appId,
privateKey, privateKey,
request: {
fetch,
},
}); });
event = { event = {
@ -466,46 +533,50 @@ describe("Probot", () => {
}; };
event.payload.installation.id = 1; 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) => { probot.on("installation.deleted", async (context) => {
await context.octokit.request("/"); await context.octokit.request("/");
}); });
await probot.receive(event).catch(console.log); await probot.receive(event).catch(console.log);
expect(mock.activeMocks()).toStrictEqual([]);
}); });
it("returns an authenticated client for events without an installation", async () => { 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({ const probot = new Probot({
appId, appId,
privateKey, privateKey,
request: {
fetch,
},
}); });
event = { event = {
id: "123-456", id: "123-456",
name: "check_run", name: "check_run",
payload: getPayloadExamples("check_run").filter( payload: getPayloadExamples("check_run").filter(
(event) => typeof event.installation === "undefined" (event) => typeof event.installation === "undefined",
)[0], )[0],
}; };
const mock = nock("https://api.github.com")
.get("/")
.matchHeader("authorization", (value) => value === undefined)
.reply(200, {});
probot.on("check_run", async (context) => { probot.on("check_run", async (context) => {
await context.octokit.request("/"); await context.octokit.request("/");
}); });
await probot.receive(event).catch(console.log); await probot.receive(event).catch(console.log);
expect(mock.activeMocks()).toStrictEqual([]);
}); });
}); });
@ -524,7 +595,7 @@ describe("Probot", () => {
privateKey, privateKey,
}); });
const spy = jest.fn(); const spy = vi.fn();
probot.on("pull_request", spy); probot.on("pull_request", spy);
await probot.receive(event); await probot.receive(event);
@ -538,7 +609,7 @@ describe("Probot", () => {
privateKey, privateKey,
}); });
const spy = jest.fn(); const spy = vi.fn();
probot.on("pull_request", () => { probot.on("pull_request", () => {
return new Promise((resolve) => { return new Promise((resolve) => {
setTimeout(() => { setTimeout(() => {
@ -568,7 +639,7 @@ describe("Probot", () => {
await probot.receive(event); await probot.receive(event);
throw new Error("expected error to be raised from app"); throw new Error("expected error to be raised from app");
} catch (error) { } 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( const stubTranspiledAppFnPath = require.resolve(
"./fixtures/plugin/stub-typescript-transpiled-plugin" "./fixtures/plugin/stub-typescript-transpiled-plugin.ts",
); );
const basedir = process.cwd(); const basedir = process.cwd();
describe("resolver", () => { describe("resolver", () => {
it("loads the module at the resolved path", async () => { 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 }); const module = await resolveAppFunction("foo", { resolver: stubResolver });
expect(module).toBe(require(stubAppFnPath)); expect(module).toBeInstanceOf(Function);
expect(stubResolver).toHaveBeenCalledWith("foo", { basedir }); expect(stubResolver).toHaveBeenCalledWith("foo", { basedir });
}); });
it("loads module transpiled from TypeScript (https://github.com/probot/probot/issues/1447)", async () => { 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 }); const module = await resolveAppFunction("foo", { resolver: stubResolver });
expect(module).toBe(require(stubTranspiledAppFnPath).default); expect(module).toBeInstanceOf(Function);
expect(stubResolver).toHaveBeenCalledWith("foo", { basedir }); 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 request from "supertest";
import { sign } from "@octokit/webhooks-methods"; 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", () => { describe("run", () => {
let server: Server; let server: Server;
let env: NodeJS.ProcessEnv; let env: NodeJS.ProcessEnv;
@ -18,7 +21,7 @@ describe("run", () => {
PRIVATE_KEY_PATH: path.join( PRIVATE_KEY_PATH: path.join(
__dirname, __dirname,
"fixtures", "fixtures",
"test-private-key.pem" "test-private-key.pem",
), ),
WEBHOOK_PROXY_URL: "https://smee.io/EfHXC9BFfGAxbM6J", WEBHOOK_PROXY_URL: "https://smee.io/EfHXC9BFfGAxbM6J",
WEBHOOK_SECRET: "secret", WEBHOOK_SECRET: "secret",
@ -34,7 +37,7 @@ describe("run", () => {
() => { () => {
initialized = true; initialized = true;
}, },
{ env } { env },
); );
expect(initialized).toBeTruthy(); expect(initialized).toBeTruthy();
await server.stop(); await server.stop();
@ -58,10 +61,10 @@ describe("run", () => {
return new Promise(async (resolve) => { return new Promise(async (resolve) => {
server = await run( server = await run(
(app: Probot) => { (_app: Probot) => {
initialized = true; initialized = true;
}, },
{ env } { env },
); );
expect(initialized).toBeFalsy(); expect(initialized).toBeFalsy();
await server.stop(); await server.stop();
@ -71,33 +74,39 @@ describe("run", () => {
}); });
it("defaults to JSON logs if NODE_ENV is set to 'production'", async () => { it("defaults to JSON logs if NODE_ENV is set to 'production'", async () => {
const outputData = await captureLogOutput(async () => { let outputData = "";
env.NODE_ENV = "production"; env.NODE_ENV = "production";
server = await run( server = await run(
(app) => { async (app) => {
outputData = await captureLogOutput(async () => {
app.log.fatal("test"); app.log.fatal("test");
}, app.log);
}, },
{ env } { env },
); );
await server.stop(); await server.stop();
});
expect(outputData).toMatch(/"msg":"test"/); expect(outputData).toMatch(/"msg":"test"/);
}); });
}); });
describe("webhooks", () => { 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 }); server = await run(() => {}, { env });
const dataString = JSON.stringify(pushEvent); const dataString = JSON.stringify(pushEvent);
await request(server.expressApp) await request(server.expressApp)
.post("/") .post("/api/github/webhooks")
.send(dataString) .send(dataString)
.set("content-type", "application/json")
.set("x-github-event", "push") .set("x-github-event", "push")
.set("x-hub-signature-256", await sign("secret", dataString)) .set("x-hub-signature-256", await sign("secret", dataString))
.set("x-github-delivery", "123") .set("x-github-delivery", "123")
@ -120,6 +129,7 @@ describe("run", () => {
await request(server.expressApp) await request(server.expressApp)
.post("/custom-webhook") .post("/custom-webhook")
.send(dataString) .send(dataString)
.set("content-type", "application/json")
.set("x-github-event", "push") .set("x-github-event", "push")
.set("x-hub-signature-256", await sign("secret", dataString)) .set("x-hub-signature-256", await sign("secret", dataString))
.set("x-github-delivery", "123") .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 request from "supertest";
import pino from "pino"; import { pino } from "pino";
import { sign } from "@octokit/webhooks-methods"; import { sign } from "@octokit/webhooks-methods";
import getPort from "get-port"; 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 appId = 1;
const privateKey = `-----BEGIN RSA PRIVATE KEY----- const privateKey = `-----BEGIN RSA PRIVATE KEY-----
MIIBOQIBAAJBAIILhiN9IFpaE0pUXsesuuoaj6eeDiAqCiE49WB1tMB8ZMhC37kY MIIEpAIBAAKCAQEA1c7+9z5Pad7OejecsQ0bu3aozN3tihPmljnnudb9G3HECdnH
Fl52NUYbUxb7JEf6pH5H9vqw1Wp69u78XeUCAwEAAQJAb88urnaXiXdmnIK71tuo lWu2/a1gB9JW5TBQ+AVpum9Okx7KfqkfBKL9mcHgSL0yWMdjMfNOqNtrQqKlN4kE
/TyHBKt9I6Rhfzz0o9Gv7coL7a537FVDvV5UCARXHJMF41tKwj+zlt9EEUw7a1HY p6RD++7sGbzbfZ9arwrlD/HSDAWGdGGJTSOBM6pHehyLmSC3DJoR/CTu0vTGTWXQ
wQIhAL4F/VHWSPHeTgXYf4EaX2OlpSOk/n7lsFtL/6bWRzRVAiEArzJs2vopJitv rO64Z8tyXQPtVPb/YXrcUhbBp8i72b9Xky0fD6PkEebOy0Ip58XVAn2UPNlNOSPS
A1yBjz3q2nX+zthk+GLXrJQkYOnIk1ECIHfeFV8TWm5gej1LxZquBTA5pINoqDVq ye+Qjtius0Md4Nie4+X8kwVI2Qjk3dSm0sw/720KJkdVDmrayeljtKBx6AtNQsSX
NKZSuZEHqGEFAiB6EDrxkovq8SYGhIQsJeqkTMO8n94xhMRZlFmIQDokEQIgAq5U gzQbeMmiqFFkwrG1+zx6E7H7jqIQ9B6bvWKXGwIDAQABAoIBAD8kBBPL6PPhAqUB
r1UQNnUExRh7ZT0kFbMfO9jKYZVlQdCL9Dn93vo= 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-----`; -----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", () => { describe("Server", () => {
let server: Server; let server: Server;
let output: any[]; let output: any[];
const streamLogsToOutput = new Stream.Writable({ objectMode: true }); const streamLogsToOutput = new Stream.Writable({ objectMode: true });
streamLogsToOutput._write = (object, encoding, done) => { streamLogsToOutput._write = (object, _encoding, done) => {
output.push(JSON.parse(object)); output.push(JSON.parse(object));
done(); done();
}; };
@ -45,9 +71,9 @@ describe("Server", () => {
// Error handler to avoid printing logs // Error handler to avoid printing logs
server.expressApp.use( 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); 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 () => { it("should return 200 and run event handlers in app function", async () => {
expect.assertions(3); expect.assertions(3);
@ -86,28 +165,31 @@ describe("Server", () => {
const dataString = JSON.stringify(pushEvent); const dataString = JSON.stringify(pushEvent);
await request(server.expressApp) await request(server.expressApp)
.post("/") .post("/api/github/webhooks")
.send(dataString) .send(dataString)
.set("content-type", "application/json")
.set("x-github-event", "push") .set("x-github-event", "push")
.set("x-hub-signature-256", await sign("secret", dataString)) .set("x-hub-signature-256", await sign("secret", dataString))
.set("x-github-delivery", "3sw4d5f6g7h8"); .set("x-github-delivery", "3sw4d5f6g7h8");
expect(output.length).toEqual(1); 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 () => { test("respond with a friendly error when x-hub-signature-256 is missing", async () => {
await server.load(() => {}); await server.load(() => {});
await request(server.expressApp) await request(server.expressApp)
.post("/") .post("/api/github/webhooks")
.send(JSON.stringify(pushEvent)) .send(JSON.stringify(pushEvent))
.set("content-type", "application/json")
.set("x-github-event", "push") .set("x-github-event", "push")
.set("content-type", "application/json")
// Note: 'x-hub-signature-256' is missing // Note: 'x-hub-signature-256' is missing
.set("x-github-delivery", "3sw4d5f6g7h8") .set("x-github-delivery", "3sw4d5f6g7h8")
.expect( .expect(
400, 400,
'{"error":"Required headers missing: x-hub-signature-256"}' '{"error":"Required headers missing: x-hub-signature-256"}',
); );
}); });
}); });
@ -121,7 +203,8 @@ describe("Server", () => {
}); });
describe(".start() / .stop()", () => { describe(".start() / .stop()", () => {
it("should expect the correct error if port already in use", (next) => { it("should expect the correct error if port already in use", () =>
new Promise<void>((next) => {
expect.assertions(1); expect.assertions(1);
// block port 3001 // block port 3001
@ -136,15 +219,15 @@ describe("Server", () => {
try { try {
await server.start(); await server.start();
} catch (error) { } catch (error) {
expect(error.message).toEqual( expect((error as Error).message).toEqual(
"Port 3001 is already in use. You can define the PORT environment variable to use a different port." "Port 3001 is already in use. You can define the PORT environment variable to use a different port.",
); );
} }
await server.stop(); await server.stop();
blockade.close(() => next()); blockade.close(() => next());
}); });
}); }));
it("should listen to port when not in use", async () => { it("should listen to port when not in use", async () => {
const testApp = new Server({ const testApp = new Server({
@ -179,21 +262,38 @@ describe("Server", () => {
describe("router", () => { describe("router", () => {
it("prefixes paths with route name", () => { it("prefixes paths with route name", () => {
const router = server.router("/my-app"); 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"); return request(server.expressApp).get("/my-app/foo").expect(200, "foo");
}); });
it("allows routes with no path", () => { it("allows routes with no path", () => {
const router = server.router(); 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"); 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(); 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"); return request(server.expressApp).get("/").expect(200, "foo");
}); });
@ -202,12 +302,12 @@ describe("Server", () => {
["foo", "bar"].forEach((name) => { ["foo", "bar"].forEach((name) => {
const router = server.router("/" + name); const router = server.router("/" + name);
router.use((req, res, next) => { router.use((_req, res, next) => {
res.append("X-Test", name); res.append("X-Test", name);
next(); next();
}); });
router.get("/hello", (req, res) => res.end(name)); router.get("/hello", (_req, res) => res.end(name));
}); });
await request(server.expressApp) await request(server.expressApp)

View File

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

View File

@ -1,6 +1,6 @@
import { RepositoryEditedEvent } from "@octokit/webhooks-types"; import { RepositoryEditedEvent } from "@octokit/webhooks-types";
import { expectType } from "tsd"; import { expectType } from "tsd";
import { Probot } from "../../src"; import { Probot } from "../../src/index.js";
const app = new Probot({}); 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"; import { randomInt } from "node:crypto";
// tslint:disable-next-line:no-var-requires import http from "node:http";
import net from "node:net";
import express, { type Response } from "express";
const sse: ( const sse: (
req: express.Request, req: express.Request,
res: express.Response, res: express.Response,
next: express.NextFunction next: express.NextFunction,
) => void = require("connect-sse")(); ) => void = require("connect-sse")();
import fetchMock from "fetch-mock";
import EventSource from "eventsource"; import EventSource from "eventsource";
import http from "http"; import { describe, expect, afterEach, test, vi } from "vitest";
import net from "net"; import { getLog } from "../src/helpers/get-log.js";
import nock from "nock"; import { createWebhookProxy } from "../src/helpers/webhook-proxy.js";
import { getLog } from "../src/helpers/get-log";
import { createWebhookProxy } from "../src/helpers/webhook-proxy";
const targetPort = 999999; let targetPort = 999999;
interface SSEResponse extends Response { interface SSEResponse extends Response {
json(body: any, status?: string): this; json(body: any, status?: string): this;
} }
jest.setTimeout(10000);
describe("webhook-proxy", () => { describe("webhook-proxy", () => {
// tslint:disable-next-line:one-variable-per-declaration let emit: SSEResponse["json"];
let emit: SSEResponse["json"], proxy: EventSource, server: http.Server; let proxy: EventSource;
let server: http.Server;
afterEach(() => { afterEach(() => {
server && server.close(); server && server.close();
@ -30,59 +31,111 @@ describe("webhook-proxy", () => {
}); });
describe("with a valid proxy server", () => { 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(); const app = express();
app.get("/events", sse, (req, res: SSEResponse) => { app.get("/events", sse, (_req, res: SSEResponse) => {
res.json({}, "ready"); res.json({}, "ready");
emit = res.json; emit = res.json;
}); });
server = app.listen(0, () => { server = app.listen(0, async () => {
const url = `http://127.0.0.1:${ targetPort = (server.address() as net.AddressInfo).port;
(server.address() as net.AddressInfo).port const url = `http://127.0.0.1:${targetPort}/events`;
}/events`;
proxy = createWebhookProxy({ const fetch = fetchMock
.sandbox()
.postOnce(`http://localhost:${targetPort}/test`, {
status: 200,
then: () => {
finishedPromise.resolve!();
},
});
proxy = (await createWebhookProxy({
url, url,
port: targetPort, port: targetPort,
path: "/test", path: "/test",
logger: getLog({ level: "fatal" }), logger: getLog({ level: "fatal" }),
})!; fetch,
})) as EventSource;
// Wait for proxy to be ready // Wait for proxy to be ready
proxy.addEventListener("ready", () => done()); proxy.addEventListener("ready", readyPromise.resolve!);
});
}); });
test("forwards events to server", (done) => { await readyPromise.promise;
nock(`http://localhost:${targetPort}`)
.post("/test")
.reply(200, () => {
done();
});
const body = { action: "foo" };
emit({ emit({
body, body: { action: "foo" },
"x-github-event": "test", "x-github-event": "test",
}); });
await finishedPromise.promise;
}); });
}); });
test("logs an error when the proxy server is not found", (done) => { test("logs an error when the proxy server is not found", async () => {
const url = "http://bad.proxy/events"; expect.assertions(2);
nock("http://bad.proxy").get("/events").reply(404);
const log = getLog({ level: "fatal" }).child({}); let finishedPromise = {
log.error = jest.fn(); promise: undefined,
reject: undefined,
resolve: undefined,
} as {
promise?: Promise<any>;
resolve?: (value?: any) => any;
reject?: (reason?: any) => any;
};
proxy = createWebhookProxy({ url, logger: log })!; finishedPromise.promise = new Promise((resolve, reject) => {
finishedPromise.resolve = resolve;
proxy.addEventListener("error", (error: any) => { finishedPromise.reject = reject;
expect(error.status).toBe(404);
expect(log.error).toHaveBeenCalledWith(error);
done();
}); });
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": { "compilerOptions": {
"module": "Node16",
"verbatimModuleSyntax": false,
"noImplicitReturns": true, "noImplicitReturns": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"noUnusedLocals": true, "noUnusedLocals": true,
@ -12,7 +14,10 @@
"noImplicitAny": true, "noImplicitAny": true,
"esModuleInterop": true, "esModuleInterop": true,
"declaration": true, "declaration": true,
"resolveJsonModule": true "resolveJsonModule": true,
"allowJs": true,
"lib": ["es2023", "dom"],
"moduleResolution": "node16"
}, },
"include": ["src/**/*"], "include": ["src/**/*"],
"compileOnSave": false "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",
},
},
})