feat: `probot.on()` / `probot.receive()` / `probot.auth()` (#1407)

This commit is contained in:
Gregor Martynus 2020-11-12 09:27:21 -08:00 committed by GitHub
parent de3adc1169
commit 1812cfeb2f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 349 additions and 4 deletions

View File

@ -31,6 +31,8 @@ import { getProbotOctokitWithDefaults } from "./octokit/get-probot-octokit-with-
import { aliasLog } from "./helpers/alias-log";
import { logWarningsForObsoleteEnvironmentVariables } from "./helpers/log-warnings-for-obsolete-environment-variables";
import { getWebhooks } from "./octokit/get-webhooks";
import { webhookEventCheck } from "./helpers/webhook-event-check";
import { auth } from "./auth";
logWarningsForObsoleteEnvironmentVariables();
@ -53,6 +55,10 @@ export interface Options {
port?: number;
host?: string;
webhookProxy?: string;
/**
* @deprecated set `Octokit` to `ProbotOctokit.defaults({ throttle })` instead
*/
throttleOptions?: any;
}
// tslint:disable:no-var-requires
@ -156,6 +162,11 @@ export class Probot {
public webhooks: ProbotWebhooks;
public log: DeprecatedLogger;
public version: String;
public on: ProbotWebhooks["on"];
public auth: (
installationId?: number,
log?: Logger
) => Promise<InstanceType<typeof ProbotOctokit>>;
// These need to be public for the tests to work.
public options: Options;
@ -216,12 +227,11 @@ export class Probot {
cache,
log: this.log,
redisConfig: options.redisConfig,
throttleOptions: options.throttleOptions,
});
const octokit = new Octokit();
this.state = {
id: options.id,
privateKey: options.privateKey,
cache,
githubToken: options.githubToken,
log: this.log,
@ -231,10 +241,29 @@ export class Probot {
path: options.webhookPath,
secret: options.secret,
},
id: options.id,
privateKey: options.privateKey,
};
this.auth = auth.bind(null, this.state);
this.webhooks = getWebhooks(this.state);
this.on = (eventNameOrNames, callback) => {
// when an app subscribes to an event using `app.on(event, callback)`, Probot sends a request to `GET /app` and
// verifies if the app is subscribed to the event and logs a warning if it is not.
//
// This feature will be moved out of Probot core as it has side effects and does not work in a stateless environment.
webhookEventCheck(this.state, eventNameOrNames);
if (eventNameOrNames === "*") {
// @ts-ignore this workaround is only to surpress a warning. The `.on()` method will be deprecated soon anyway.
return this.webhooks.onAny(callback);
}
return this.webhooks.on(eventNameOrNames, callback);
};
this.server = createServer({
webhook: (this.webhooks as any).middleware,
logger: this.log,

View File

@ -1,9 +1,13 @@
import Stream from "stream";
import { WebhookEvent } from "@octokit/webhooks";
import Bottleneck from "bottleneck";
import { NextFunction, Request, Response } from "express";
import request = require("supertest");
import nock from "nock";
import pino from "pino";
import { Application, Probot, ProbotOctokit } from "../src";
import { Application, Probot, ProbotOctokit, Context } from "../src";
import path = require("path");
import { WebhookEvents } from "@octokit/webhooks";
@ -27,8 +31,17 @@ describe("Probot", () => {
name: WebhookEvents;
payload: any;
};
let output: any;
const streamLogsToOutput = new Stream.Writable({ objectMode: true });
streamLogsToOutput._write = (object, encoding, done) => {
output.push(JSON.parse(object));
done();
};
beforeEach(() => {
// Clear log output
output = [];
process.env.DISABLE_WEBHOOK_EVENT_CHECK = "true";
probot = new Probot({ githubToken: "faketoken" });
@ -249,7 +262,7 @@ describe("Probot", () => {
return request(probot.server).get("/").expect(200, "foo");
});
it("isolates apps from affecting eachother", async () => {
it("isolates apps from affecting each other", async () => {
["foo", "bar"].forEach((name) => {
probot.load(({ app, getRouter }) => {
const router = getRouter("/" + name);
@ -580,4 +593,307 @@ describe("Probot", () => {
probot.load([app, app]);
});
});
describe("on", () => {
beforeEach(() => {
event = {
id: "123-456",
name: "pull_request",
payload: {
action: "opened",
installation: { id: 1 },
},
};
});
it("calls callback when no action is specified", async () => {
const probot = new Probot({
id,
privateKey,
});
const spy = jest.fn();
probot.on("pull_request", spy);
expect(spy).toHaveBeenCalledTimes(0);
await probot.receive(event);
expect(spy).toHaveBeenCalled();
expect(spy.mock.calls[0][0]).toBeInstanceOf(Context);
expect(spy.mock.calls[0][0].payload).toBe(event.payload);
});
it("calls callback with same action", async () => {
const probot = new Probot({
id,
privateKey,
});
const spy = jest.fn();
probot.on("pull_request.opened", spy);
await probot.receive(event);
expect(spy).toHaveBeenCalled();
});
it("does not call callback with different action", async () => {
const probot = new Probot({
id,
privateKey,
});
const spy = jest.fn();
probot.on("pull_request.closed", spy);
await probot.receive(event);
expect(spy).toHaveBeenCalledTimes(0);
});
it("calls callback with *", async () => {
const probot = new Probot({
id,
privateKey,
});
const spy = jest.fn();
probot.on("*", spy);
await probot.receive(event);
expect(spy).toHaveBeenCalled();
});
it("calls callback x amount of times when an array of x actions is passed", async () => {
const probot = new Probot({
id,
privateKey,
});
const event2: WebhookEvent = {
id: "123",
name: "issues",
payload: {
action: "opened",
installation: { id: 2 },
},
};
const spy = jest.fn();
probot.on(["pull_request.opened", "issues.opened"], spy);
await probot.receive(event);
await probot.receive(event2);
expect(spy.mock.calls.length).toEqual(2);
});
it("adds a logger on the context", async () => {
const probot = new Probot({
id,
privateKey,
log: pino(streamLogsToOutput),
});
const handler = jest.fn().mockImplementation((context) => {
expect(context.log.info).toBeDefined();
context.log.info("testing");
expect(output[0]).toEqual(
expect.objectContaining({
id: context.id,
msg: "testing",
})
);
});
probot.on("pull_request", handler);
await probot.receive(event).catch(console.error);
expect(handler).toHaveBeenCalled();
});
it("returns an authenticated client for installation.created", async () => {
const probot = new Probot({
id,
privateKey,
});
event = {
id: "123-456",
name: "installation",
payload: {
action: "created",
installation: { id: 1 },
},
};
const mock = nock("https://api.github.com")
.post("/app/installations/1/access_tokens")
.reply(201, {
token: "v1.1f699f1069f60xxx",
permissions: {
issues: "write",
contents: "read",
},
})
.get("/")
.matchHeader("authorization", "token v1.1f699f1069f60xxx")
.reply(200, {});
probot.on("installation.created", async (context) => {
await context.github.request("/");
});
await probot.receive(event);
expect(mock.activeMocks()).toStrictEqual([]);
});
it("returns an unauthenticated client for installation.deleted", async () => {
const probot = new Probot({
id,
privateKey,
});
event = {
id: "123-456",
name: "installation",
payload: {
action: "deleted",
installation: { id: 1 },
},
};
const mock = nock("https://api.github.com")
.get("/")
.matchHeader("authorization", (value) => value === undefined)
.reply(200, {});
probot.on("installation.deleted", async (context) => {
await context.github.request("/");
});
await probot.receive(event).catch(console.log);
expect(mock.activeMocks()).toStrictEqual([]);
});
it("returns an authenticated client for events without an installation", async () => {
const probot = new Probot({
id,
privateKey,
});
event = {
id: "123-456",
name: "check_run",
payload: {
/* no installation */
},
};
const mock = nock("https://api.github.com")
.get("/")
.matchHeader("authorization", (value) => value === undefined)
.reply(200, {});
probot.on("check_run", async (context) => {
await context.github.request("/");
});
await probot.receive(event).catch(console.log);
expect(mock.activeMocks()).toStrictEqual([]);
});
});
describe("receive", () => {
beforeEach(() => {
event = {
id: "123-456",
name: "pull_request",
payload: {
action: "opened",
installation: { id: 1 },
},
};
});
it("delivers the event", async () => {
const probot = new Probot({
id,
privateKey,
});
const spy = jest.fn();
probot.on("pull_request", spy);
await probot.receive(event);
expect(spy).toHaveBeenCalled();
});
it("waits for async events to resolve", async () => {
const probot = new Probot({
id,
privateKey,
});
const spy = jest.fn();
probot.on("pull_request", () => {
return new Promise((resolve) => {
setTimeout(() => {
spy();
resolve();
}, 1);
});
});
await probot.receive(event);
expect(spy).toHaveBeenCalled();
});
it("returns a reject errors thrown in apps", async () => {
const probot = new Probot({
id,
privateKey,
});
probot.on("pull_request", () => {
throw new Error("error from app");
});
try {
await probot.receive(event);
throw new Error("expected error to be raised from app");
} catch (error) {
expect(error.message).toMatch(/error from app/);
}
});
});
describe("auth", () => {
it("throttleOptions", async () => {
const probot = new Probot({
Octokit: ProbotOctokit.plugin((octokit: any, options: any) => {
return {
pluginLoaded: true,
test() {
expect(options.throttle.id).toBe(1);
expect(options.throttle.foo).toBe("bar");
},
};
}),
id: 1,
privateKey: "private key",
secret: "secret",
throttleOptions: {
foo: "bar",
onAbuseLimit: () => true,
onRateLimit: () => true,
},
} as any);
const result = await probot.auth(1);
expect(result.pluginLoaded).toEqual(true);
result.test();
});
});
});