diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..f2304f1 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +charset = utf-8 +max_line_length = 120 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..50791cc --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/node_modules/ +/npm-debug.log +/se-edu-bot-*.tgz +/dist/ +/.env diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..a6113f7 --- /dev/null +++ b/.travis.yml @@ -0,0 +1,6 @@ +language: node_js + +node_js: + - "7" + +script: npm run all build lint test diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..84220f2 --- /dev/null +++ b/LICENSE @@ -0,0 +1,19 @@ +Copyright 2017 seedu-bot contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Procfile b/Procfile new file mode 100644 index 0000000..063b78f --- /dev/null +++ b/Procfile @@ -0,0 +1 @@ +web: npm start diff --git a/README.adoc b/README.adoc new file mode 100644 index 0000000..4f0d368 --- /dev/null +++ b/README.adoc @@ -0,0 +1,298 @@ += se-edu-bot +:toc: preamble +:toc-title: +:sectnums: +ifdef::env-github[] +:tip-caption: :bulb: +:note-caption: :information_source: +endif::[] +ifdef::env-github,env-browser[:outfilesuffix: .adoc] + +Helps out with managing SE-EDU projects. + +== Local development setup + +=== Prerequisites + +. Node `7.9.0` or later. +. An editor with Typescript language service support is strongly recommended. + (e.g. https://code.visualstudio.com/[VS Code]) +. https://ngrok.com/[ngrok] + +=== Cloning the repo + +. Fork this repo, and clone the fork to your computer. +. `cd` into the project directory. +. Run `npm install` to install dependencies. + +=== Setting up ngrok + +We use https://ngrok.com/[ngrok] to expose our local server to the internet for Webhook testing. +Run + +---- +ngrok http 5000 +---- + +You should see a line like this: + +---- +Forwarding https://abcdefg123.ngrok.io -> localhost:5000 +---- + +This is your temporary ngrok URL which exposes your local server to the internet. +It is valid until the `ngrok` process terminates. +Take note of it, we'll use it in later steps. + +=== Setting up a GitHub App + +.Creating the GitHub App +. Go to your https://github.com/settings/apps[User Owned GitHub Apps] page and click on `Register a new GitHub App`. +. Fill in the details as follows: + (replace `https://abc123.ngrok.io` with your temporary ngrok URL which you got in <>) + + Github App Name:: + Give a name to identify the bot (e.g. `se-edu-bot`) + + Homepage URL:: + The URL to your fork. + + User authorization callback URL:: + `https://abc123.ngrok.io/auth/login/callback` + + Webhook URL:: + `https://abc123.ngrok.io/webhook` + + Webhook Secret:: + Fill in a random string (preferably generated with a cryptographically secure random string generator). + Take note of it, as you will need it later for setting `GITHUB_WEBHOOK_SECRET` in the <>. ++ +NOTE: Yes, *any* random string. + + Permissions:: + Select the following, leaving the rest at their default: + + * Issues: `Read & write` + ** Webhook events: `Issue comment`, `Issues`. + * Pull requests: `Read & write` + ** Webhook events: `Pull request`, `Pull request review`, `Pull request review comment` + + Where can this app be installed?:: + Ensure `Only on this account` is selected. + +. Click `Create GitHub App` to create your GitHub App. +. You will then be taken to your GitHub App's page. + This page will show your GitHub App's ID. + You will need to set it later as the `GITHUB_APP_ID` in the <>. ++ +This page will also show the OAuth client ID and secret of your GitHub App. +You will need to set it later as the `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` respectively in the <>. + +. You will also need to generate a private key for the GitHub App. + Click on `Generate private key`, and save the file to a known location. + You will need its *full* contents later for setting the `GITHUB_APP_PRIVATE_KEY` in the <>. + +.Installing the GitHub App +. Next, we will need to install the GitHub App. Click on `Install`. +. Select `All repositories` and then click `Install` to install your GitHub App on all your GitHub repositories. +. You will be taken to the installation page which has the URL with the format + `https://github.com/settings/installations/INSTALLATION_ID` + (or `https://github.com/organizations/ORGANIZATION_NAME/settings/installations/INSTALLATION_ID` for organizations) + where `INSTALLATION_ID` is the installation ID number. + You will need to set it later as the `GITHUB_INSTALLATION_ID` in the <>. + +NOTE: Since your ngrok URL is temporary, +you will need to reconfigure your GitHub App each time you get a new temporary URL. +(Not applicable for herokuapp URLs) + +=== Configuring environment variables + +Environment variables are used to configure the bot. +See <> for a full list of settings. + +For ease of development, +se-edu-bot will automatically load environment variables defined in the `.env` file in the project root. + +This is done using https://github.com/motdotla/dotenv[dotenv]. +See the aforementioned link for information on the `.env` file format. + +=== Building the project + +* Build the project once. ++ +[source,shell] +---- +npm run build +---- + +* Do a clean build. ++ +[source,shell] +---- +npm run all clean build +---- + +=== Running the server + +The project must be built first (`npm run build`). + +* Run the server ++ +[source,shell] +---- +npm start +---- + +* Run the server and watch for changes, + incrementally rebuilding the project and restarting the server whenever source files change. ++ +[source,shell] +---- +npm run watch +---- + +The server should be accessible via your ngrok address which you got in <>. + +=== Linting + +[source,shell] +---- +npm run lint +---- + +=== Running tests + +The project must be built first (`npm run build`). + +* Run tests once. ++ +[source,shell] +---- +npm test +---- + +* Run tests and watch for changes, + incrementally rebuilding the project and re-running tests whenever source files change. ++ +[source,shell] +---- +npm run test-watch +---- + +=== Cleaning build products + +[source,shell] +---- +npm run clean +---- + +== Deploying to Heroku + +This repository is setup to automatically deploy to Heroku whenever new commits are pushed to `master`. +As such, there is no need for any manual deployment. + +Below is a guide for setting up the Heroku application from scratch should there be any need to (e.g. for testing). + +=== Setting up the Heroku application from scratch + +.Part 1: Set up Heroku App +. Go to the https://dashboard.heroku.com[Heroku dashboard] and login. +. `New` -> `Create new App`. +. Enter the app name (i.e. `se-edu-bot`) and click `Create App`. +. Under `Deployment method`, select `Github`. +. If Heroku Dashboard does not have access to your GitHub account, + it will display a single `Connect to GitHub` button. + Click on it, and then authorize the Heroku Dashboard to access your GitHub account and `se-edu` organization. +. Follow the instructions to connect the Heroku app to the `se-edu/se-edu-bot` repo. +. Ensure the `Wait for CI to pass before deploy` checkbox is checked, and then click `Enable Automatic Deploys`. + +.Part 2: Set up GitHub App +. On GitHub, go to https://github.com/settings/profile[Your profile page] -> Organization Settings -> `se-edu`. +. Click on `Github Apps`. +. Click on `Register a new GitHub App` +. Follow the same steps as <>, + except instead of using the `ngrok.io` hostname use the Heroku App's hostname (e.g. `https://se-edu-bot.herokuapp.com`) + +.Part 3: Continue setting up Heroku App +. Go back to the https://dashboard.heroku.com[Heroku dashboard] and click on the `se-edu-bot` app to go to its page. +. Go to `Settings` -> `Config Variables`, and set `NPM_CONFIG_PRODUCTION` to `false`. +. Configure the rest of the <>. ++ +WARNING: Make sure you set *all* required environment variables! + +. Next, to complete setting up the Heroku App, go back to the `Deploy` tab. + Look for `Manual deploy`, ensure the `master` branch is selected and then click `Deploy`. +. Hopefully, the deployment is successful. + Visit the Heroku App's URL (e.g. `https://se-edu-bot.herokuapp.com`) and it should show `Hello world!`, + indicating that the app was successfully set up. + +== Environment variables + +`PROXY`:: +(Required) Set to `true` if se-edu-bot is served behind a reverse proxy (e.g. ngrok or heroku). +Given that we host se-edu-bot on heroku and use ngrok for development, +this should usually be set to `true`. + +`PORT`:: +TCP port which the server will listen on. +There is no need to explicitly set this on Heroku, +as Heroku will automatically set the `PORT` environment variable. +(Default: 5000) + +`GITHUB_WEBHOOK_SECRET`:: +(Required) The webhook secret of the GitHub App. (See <>) + +`GITHUB_APP_ID`:: +(Required) The GitHub App ID. (See <>) + +`GITHUB_APP_PRIVATE_KEY`:: +(Required) The *full* contents of the GitHub App private key file. +Newlines must be preserved. +(See <>) + +`GITHUB_INSTALLATION_ID`:: +(Required) The installation ID of the GitHub App. +(See <>) + +`GITHUB_CLIENT_ID`:: +(Required) The OAuth Client ID of the GitHub App. +(See <>) + +`GITHUB_CLIENT_SECRET`:: +(Required) The OAuth Client secret of the GitHub App. +(See <>) + +== Architecture + +`lib/`:: + Utility libraries. + Try to avoid encoding policy within the code, + instead pass options to them via the entry point (`server.ts`). + +`logic/`:: + Code controlling the behavior of the bot. + +=== `logic` + +The bot's behavior is split into multiple logic components, +each doing one thing. +Each logic component is implemented as an individual file within the `logic/` directory. + +All logic components implement the `Logic` interface. +Most logic components also inherit from the `BaseLogic` class, +which provides some useful base functionality such as splitting the `webhookMiddleware()` method into event-specific callback methods. + +== Logging + +To facilitate debugging problems in production, +se-edu-bot exposes its logs via the `/logs` endpoint. + +You need to be a member of the `se-edu` organization in order to access them. + +== Coding standard + +We follow the oss-generic coding standard. + +== License + +MIT License diff --git a/lib/Auth.ts b/lib/Auth.ts new file mode 100644 index 0000000..9f62bed --- /dev/null +++ b/lib/Auth.ts @@ -0,0 +1,161 @@ +import Koa = require('koa'); +import koaCompose = require('koa-compose'); +import koaRoute = require('koa-route'); +import simpleOauth2 = require('simple-oauth2'); +import createError = require('http-errors'); +import * as github from '../lib/github'; + +/** + * Options to pass to {@link Auth}. + */ +export interface AuthOptions { + clientId: string; + clientSecret: string; + baseRoute: string; + accessTokenCookieName: string; + redirectCookieName?: string; + defaultRedirect?: string; + userAgent: string; +} + +/** + * GitHub user authentication. + */ +export class Auth { + readonly middleware: Koa.Middleware; + + private readonly oauth2: simpleOauth2.OAuthClient; + private readonly loginRoute: string; + private readonly loginCallbackRoute: string; + private readonly logoutRoute: string; + private readonly accessTokenCookieName: string; + private readonly redirectCookieName: string; + private readonly defaultRedirect: string; + private readonly userAgent: string; + + constructor(options: AuthOptions) { + this.oauth2 = simpleOauth2.create({ + auth: { + authorizePath: '/login/oauth/authorize', + tokenHost: 'https://github.com', + tokenPath: '/login/oauth/access_token', + }, + client: { + id: options.clientId, + secret: options.clientSecret, + }, + }); + this.loginRoute = `${options.baseRoute}/login`; + this.loginCallbackRoute = `${options.baseRoute}/login/callback`; + this.logoutRoute = `${options.baseRoute}/logout`; + this.accessTokenCookieName = options.accessTokenCookieName; + this.redirectCookieName = options.redirectCookieName || 'AUTH_REDIRECT'; + this.defaultRedirect = options.defaultRedirect || '/'; + this.userAgent = options.userAgent; + + this.middleware = koaCompose([ + koaRoute.get(this.loginRoute, this.loginMiddleware.bind(this)), + koaRoute.get(this.loginCallbackRoute, this.loginCallbackMiddleware.bind(this)), + koaRoute.get(this.logoutRoute, this.logoutMiddleware.bind(this)), + ]); + } + + createAccessControlByInstallationId(installationId: number): Koa.Middleware { + return async (ctx: Koa.Context, next?: () => Promise): Promise => { + const ghUserApi = this.getGhUserApi(ctx); + if (!ghUserApi) { + ctx.redirect(this.getLoginRedirect(ctx)); + return; + } + + // Check that the user has access to our installation + const installationIds: number[] = []; + await github.forEachPage(ghUserApi, { + url: 'user/installations', + }, body => { + const installations: any[] = body.installations; + installations.forEach(installation => installationIds.push(installation.id)); + }); + + if (!installationIds.includes(installationId)) { + throw createError(403, 'User is not authorized to access this page'); + } + + if (next) { + await next(); + } + }; + } + + private getGhUserApi(ctx: Koa.Context): github.RequestApi | undefined { + const accessToken = ctx.cookies.get(this.accessTokenCookieName); + if (!accessToken) { + return; + } + return github.createAccessTokenApi({ + accessToken, + userAgent: this.userAgent, + }); + } + + private async loginMiddleware(ctx: Koa.Context): Promise { + const authorizationUri = this.oauth2.authorizationCode.authorizeURL({ + redirect_uri: this.getOauthRedirectUri(ctx), + }); + if (ctx.query.redirect) { + ctx.cookies.set(this.redirectCookieName, ctx.query.redirect, { + overwrite: true, + }); + } else { + ctx.cookies.set(this.redirectCookieName); + } + ctx.redirect(authorizationUri); + } + + private async loginCallbackMiddleware(ctx: Koa.Context): Promise { + const redirect: string | undefined = ctx.cookies.get(this.redirectCookieName); + ctx.cookies.set(this.redirectCookieName); + + const code: string | undefined = ctx.query.code; + if (typeof code !== 'string') { + throw createError(400, 'code not provided'); + } + + const token = await this.oauth2.authorizationCode.getToken({ + code, + redirect_uri: this.getOauthRedirectUri(ctx), + }); + if (!token.access_token) { + throw createError(403, token.error_description || 'Unknown error'); + } + + ctx.cookies.set(this.accessTokenCookieName, token.access_token, { + overwrite: true, + }); + + ctx.redirect(this.getRedirect(redirect)); + } + + private async logoutMiddleware(ctx: Koa.Context): Promise { + ctx.cookies.set(this.accessTokenCookieName); + ctx.redirect(this.getRedirect(ctx.query.redirect)); + } + + private getOauthRedirectUri(ctx: Koa.Context): string { + return `${ctx.origin}${this.loginCallbackRoute}`; + } + + private getRedirect(redirect?: string): string { + if (!redirect || !redirect.startsWith('/')) { + return this.defaultRedirect; + } + return redirect; + } + + private getLoginRedirect(ctx: Koa.Context): string { + const redirect = `${ctx.path}${ctx.search}`; + return `${this.loginRoute}?redirect=${encodeURIComponent(redirect)}`; + } +} + +export default Auth; diff --git a/lib/Logger.ts b/lib/Logger.ts new file mode 100644 index 0000000..451d87f --- /dev/null +++ b/lib/Logger.ts @@ -0,0 +1,102 @@ +import rotatingFileStream = require('rotating-file-stream'); +import os = require('os'); +import stream = require('stream'); +import assert = require('assert'); +import fs = require('fs'); +import path = require('path'); + +/** + * Options to pass to {@link Logger} + */ +export interface LoggerOptions { + /** + * Number of log files to rotate. + * They will be numbered from `0` to `NUM_FILES-1`. + * (Default: 3) + */ + numFiles?: number; + + /** + * The base log file name. + */ + fileName: string; +} + +/** + * Represents a set of log files which are automatically rotated when they reach a certain size. + */ +export class Logger { + private numFiles: number; + private fileName: string; + private writableStream?: NodeJS.WritableStream; + + constructor(options: LoggerOptions) { + this.numFiles = options.numFiles || 3; + this.fileName = options.fileName; + } + + /** + * Returns a writable stream which writes to the log file, + * and rotates and prunes the log files as needed. + */ + getWritableStream(): NodeJS.WritableStream { + if (this.writableStream) { + return this.writableStream; + } + + const fileNameGenerator = (index: number | null): string => { + assert.notStrictEqual(index, 0); + return this.getLogFileName(index === null ? 0 : index); + }; + this.writableStream = rotatingFileStream(fileNameGenerator, { + path: os.tmpdir(), + rotate: this.numFiles - 1, + size: '1M', + }); + return this.writableStream; + } + + /** + * Creates a new readable stream which accesses the contents of the log files, + * in the order of the oldest un-pruned log file to newest log file. + */ + createReadableStream(): NodeJS.ReadableStream { + let currentIndex = this.numFiles - 1; + let currentStream: NodeJS.ReadableStream; + + const concatStream = new stream.Transform({ + transform: (chunk, encoding, callback) => { + concatStream.push(chunk); + callback(); + }, + }); + const openFile = () => { + if (currentIndex < 0) { + concatStream.push(null); + return; + } + currentStream = fs.createReadStream(path.join(os.tmpdir(), this.getLogFileName(currentIndex))); + currentStream.on('error', (e: any) => { + if (e && e.code === 'ENOENT') { + currentIndex--; + return openFile(); + } + concatStream.emit('error', e); + }); + currentStream.on('end', () => { + currentIndex--; + openFile(); + }); + currentStream.pipe(concatStream, { end: false }); + }; + + openFile(); + return concatStream; + } + + private getLogFileName(index: number): string { + return `${this.fileName}.${index}`; + } +} + +export default Logger; diff --git a/lib/github/api.spec.ts b/lib/github/api.spec.ts new file mode 100644 index 0000000..6644860 --- /dev/null +++ b/lib/github/api.spec.ts @@ -0,0 +1,52 @@ +import assert = require('assert'); +import nock = require('nock'); +import { + suite, + test, +} from 'mocha-typescript'; +import { + acceptHeader, + baseUrl, + createApi, + RequestApi, +} from './api'; + +/** + * Tests for {@link createApi} + */ +@suite +export class CreateApiTest { + private static userAgent = 'se-edu-bot'; + private static authorization = 'token abcdefg123'; + private ghApi: RequestApi; + private baseNock: nock.Scope; + + before(): void { + this.ghApi = createApi({ + authorization: CreateApiTest.authorization, + userAgent: CreateApiTest.userAgent, + }); + this.baseNock = nock(baseUrl, { + reqheaders: { + 'Accept': acceptHeader, + 'Authorization': CreateApiTest.authorization, + 'User-Agent': CreateApiTest.userAgent, + }, + }); + } + + after(): void { + const nockDone = nock.isDone(); + nock.cleanAll(); + assert(nockDone, '!nock.isDone()'); + } + + @test + async 'get'(): Promise { + const expectedReply = { hello: 'world' }; + this.baseNock.get('/') + .reply(200, expectedReply); + const actualReply = await this.ghApi.get(''); + assert.deepStrictEqual(actualReply, expectedReply); + } +} diff --git a/lib/github/api.ts b/lib/github/api.ts new file mode 100644 index 0000000..cd205d7 --- /dev/null +++ b/lib/github/api.ts @@ -0,0 +1,105 @@ +import request = require('request-promise-native'); +import jwt = require('jsonwebtoken'); + +/** + * GitHub API base endpoint. + */ +export const baseUrl = 'https://api.github.com/'; + +/** + * Accept header sent to the GitHub API. + */ +export const acceptHeader = [ + 'application/vnd.github.machine-man-preview+json', + 'application/json', +].join(' '); + +const dummyRequestApi = request.defaults({}); + +/** + * Request API that provides methods for accessing the GitHub API. + */ +export type RequestApi = typeof dummyRequestApi; + +/** + * Options to pass to {@link createApi} + */ +export interface CreateApiOptions { + userAgent: string; + authorization?: string; +} + +/** + * Creates a {@link RequestApi} that accesses the GitHub API. + */ +export function createApi(options: CreateApiOptions): RequestApi { + const headers: {[key: string]: string} = {}; + headers['user-agent'] = options.userAgent; + headers['accept'] = acceptHeader; + if (options.authorization) { + headers['authorization'] = options.authorization; + } + + return request.defaults({ + baseUrl: baseUrl, + headers, + json: true, + }); +} + +/** + * Options to pass to {@link createAppApi} + */ +export interface CreateAppApiOptions { + userAgent: string; + + /** + * PEM-encoded RSA private key. + */ + privateKey: string; + + /** + * Application ID. + */ + appId: number; + + /** + * Lifetime of the authorization token, in seconds. (10 minute maximum) + */ + expiresIn: number; +} + +/** + * Creates a {@link RequestApi} that authorizes as a GitHub App. + */ +export function createAppApi(options: CreateAppApiOptions): RequestApi { + const payload: object = { + iss: options.appId, + }; + const token: string = jwt.sign(payload, options.privateKey, { + algorithm: 'RS256', + expiresIn: options.expiresIn, + }); + return createApi({ + authorization: `Bearer ${token}`, + userAgent: options.userAgent, + }); +} + +/** + * Options to pass to {@link createAccessTokenApi}. + */ +export interface CreateAccessTokenApiOptions { + userAgent: string; + accessToken: string; +} + +/** + * Creates a {@link RequestApi} that authorizes as a GitHub user or installation using an access token. + */ +export function createAccessTokenApi(options: CreateAccessTokenApiOptions): RequestApi { + return createApi({ + authorization: `token ${options.accessToken}`, + userAgent: options.userAgent, + }); +} diff --git a/lib/github/events/PingEvent.ts b/lib/github/events/PingEvent.ts new file mode 100644 index 0000000..9f5eacf --- /dev/null +++ b/lib/github/events/PingEvent.ts @@ -0,0 +1,10 @@ +/** + * Triggered when a new webhook is created or the ping endpoint is called. + */ +export interface PingEvent { + hook: { + app_id: number; + }; +} + +export default PingEvent; diff --git a/lib/github/events/PullRequestEvent.ts b/lib/github/events/PullRequestEvent.ts new file mode 100644 index 0000000..4704508 --- /dev/null +++ b/lib/github/events/PullRequestEvent.ts @@ -0,0 +1,40 @@ +import { + Enum, +} from 'typescript-string-enums'; + +const Action = Enum( + 'assigned', + 'unassigned', + 'review_requested', + 'review_request_removed', + 'labeled', + 'unlabeled', + 'opened', + 'edited', + 'closed', + 'reopened'); +type Action = Enum; + +/** + * Triggered when a pull request is assigned, unassigned, labeled, unlabeled, opened, + * edited, closed, reopened or synchronized. + * Also triggered when a pull request review is requested, or when a review request is removed. + */ +export interface PullRequestEvent { + action: Action; + number: number; + pull_request: { + merged: boolean; + user: { + login: string; + }; + }; + repository: { + name: string; + owner: { + login: string; + }; + }; +} + +export default PullRequestEvent; diff --git a/lib/github/events/index.ts b/lib/github/events/index.ts new file mode 100644 index 0000000..88bceca --- /dev/null +++ b/lib/github/events/index.ts @@ -0,0 +1,32 @@ +import { + Enum, +} from 'typescript-string-enums'; + +/** + * Webhook event name. + */ +export const EventName = Enum( + 'ping', + 'pull_request'); +export type EventName = Enum; + +/** + * Returns true if `value` is a valid {@link EventName} + */ +export function isEventName(value: any): value is EventName { + return typeof value === 'string' && Enum.isType(EventName, value); +} + +/** + * Extracts event name from the Koa context. + */ +export function getEventName(ctx: { get(key: string): string | undefined }): EventName { + const eventName = ctx.get('x-github-event'); + if (!isEventName(eventName)) { + throw new Error(`BUG: invalid eventName ${eventName}`); + } + return eventName; +} + +export { PingEvent } from './PingEvent'; +export { PullRequestEvent } from './PullRequestEvent'; diff --git a/lib/github/index.ts b/lib/github/index.ts new file mode 100644 index 0000000..b6d91b7 --- /dev/null +++ b/lib/github/index.ts @@ -0,0 +1,31 @@ +export { + EventName, + PingEvent, + PullRequestEvent, + getEventName, + isEventName, +} from './events'; +export { + koaWebhookValidator, +} from './koaWebhookValidator'; +export { + CreateAccessTokenApiOptions, + CreateApiOptions, + CreateAppApiOptions, + RequestApi, + createAccessTokenApi, + createApi, + createAppApi, +} from './api'; +export { + GhAppApiCtx, + GhInstallationApiCtx, + KoaGhInstallationApiOptions, + koaGhAppApi, + koaGhInstallationApi, + requireGhAppApi, + requireGhInstallationApi, +} from './koaGhApi'; +export { + forEachPage, +} from './pagination'; diff --git a/lib/github/koaGhApi.ts b/lib/github/koaGhApi.ts new file mode 100644 index 0000000..dfd6c0a --- /dev/null +++ b/lib/github/koaGhApi.ts @@ -0,0 +1,97 @@ +import Koa = require('koa'); +import { + createAccessTokenApi, + createAppApi, + CreateAppApiOptions, + RequestApi, +} from './api'; + +/** + * Context mixed-in into the Koa context by {@link koaGhAppApi} middleware. + */ +export interface GhAppApiCtx { + ghAppApi: RequestApi; +} + +/** + * Returns a {@link Koa.Middleware} which will set `ctx.ghAppApi` to a {@link RequestApi} + * which authorizes as the specified GitHub App. + */ +export function koaGhAppApi(options: CreateAppApiOptions): Koa.Middleware { + return async (ctx: Partial, next) => { + ctx.ghAppApi = createAppApi(options); + + if (next) { + await next(); + } + + delete ctx.ghAppApi; + }; +} + +/** + * Retrieves the `ghAppApi` from the `ctx` object. + * + * @throws TypeError if the ctx object does not have `ghAppApi`. + */ +export function requireGhAppApi(ctx: object): RequestApi { + const ghAppApi = (ctx as Partial).ghAppApi; + if (!ghAppApi) { + throw new TypeError('ctx.ghAppApi not present'); + } + return ghAppApi; +} + +/** + * Context mixed-in into the Koa context by {@link koaGhInstallationApi} middleware. + */ +export interface GhInstallationApiCtx { + ghInstallationApi: RequestApi; +} + +/** + * Options to pass to {@link koaGhInstallationApi}. + */ +export interface KoaGhInstallationApiOptions { + installationId: number; + userAgent: string; +} + +/** + * Returns a {@link Koa.Middleware} which will set `ctx.ghInstallationApi` to a {@link RequestApi} + * which authorizes as the specified installation. + */ +export function koaGhInstallationApi(options: KoaGhInstallationApiOptions): Koa.Middleware { + return async (ctx: Partial, next) => { + const ghAppApi = requireGhAppApi(ctx); + + // grab an access token + const resp = await ghAppApi.post(`installations/${options.installationId}/access_tokens`); + if (typeof resp.token !== 'string') { + throw new Error('invalid response'); + } + ctx.ghInstallationApi = createAccessTokenApi({ + accessToken: resp.token, + userAgent: options.userAgent, + }); + + if (next) { + await next(); + } + + delete ctx.ghInstallationApi; + }; +} + +/** + * Retrieves the `ghInstallationApi` from the `ctx` object. + * + * @throws TypeError if the ctx object does not have `ghInstallationApi`. + */ +export function requireGhInstallationApi(ctx: object): RequestApi { + const ghInstallationApi = (ctx as Partial).ghInstallationApi; + if (!ghInstallationApi) { + throw new TypeError('ctx.ghInstallationApi not present'); + } + return ghInstallationApi; +} diff --git a/lib/github/koaWebhookValidator.ts b/lib/github/koaWebhookValidator.ts new file mode 100644 index 0000000..5a9b2d2 --- /dev/null +++ b/lib/github/koaWebhookValidator.ts @@ -0,0 +1,50 @@ +import Koa = require('koa'); +import crypto = require('crypto'); +import createError = require('http-errors'); +import { isEventName } from './events'; + +/** + * Returns a {@link Koa.Middleware} which will check that the request is a valid GitHub Webhook request. + * + * @param secret Secret that the request body should be signed with. + */ +export function koaWebhookValidator(secret: string): Koa.Middleware { + return async (ctx, next) => { + const eventName: string | undefined = ctx.headers['x-github-event']; + if (typeof eventName !== 'string') { + throw createError(400, 'X-Github-Event not sent'); + } + if (typeof ctx.headers['x-github-delivery'] !== 'string') { + throw createError(400, 'X-Github-Delivery not sent'); + } + if (typeof ctx.request.body !== 'object') { + throw createError(400, 'body is not an object'); + } + const rawBody: string | Buffer | undefined = (ctx.request as any).rawBody; + if (typeof rawBody === 'undefined') { + throw createError(500, 'rawBody not provided'); + } + const actualSignature = ctx.headers['x-hub-signature']; + if (typeof actualSignature !== 'string') { + throw createError(400, 'X-Hub-Signature not sent'); + } + + // validate signature + const hmac = crypto.createHmac('sha1', secret); + hmac.update(rawBody); + const expectedSignature = `sha1=${hmac.digest('hex')}`; + if (actualSignature !== expectedSignature) { + throw createError(400, 'signature validation failed'); + } + + // validate event name + if (!isEventName(eventName)) { + throw createError(400, 'invalid event name'); + } + + ctx.body = { ok: true }; + if (next) { + await next(); + } + }; +} diff --git a/lib/github/pagination.spec.ts b/lib/github/pagination.spec.ts new file mode 100644 index 0000000..88a9238 --- /dev/null +++ b/lib/github/pagination.spec.ts @@ -0,0 +1,112 @@ +import { + suite, + test, +} from 'mocha-typescript'; +import { + baseUrl, + RequestApi, +} from './api'; +import { + forEachPage, +} from './pagination'; +import nock = require('nock'); +import request = require('request-promise-native'); +import assert = require('assert'); + +/** + * Tests for {@link forEachPage} + */ +@suite +export class ForEachPageTest { + private ghApi: RequestApi; + private baseNock: nock.Scope; + + before(): void { + this.ghApi = request.defaults({ + baseUrl, + }); + this.baseNock = nock(baseUrl); + } + + after(): void { + const nockDone = nock.isDone(); + nock.cleanAll(); + assert(nockDone, '!nock.isDone()'); + } + + @test + async 'goes to the next page if present'(): Promise { + const expectedReply1 = 'Hello World'; + const expectedReply2 = 'Goodbye World!'; + this.baseNock + .get('/a') + .reply(200, expectedReply1, { + link: `<${baseUrl}b?page=2>; rel="next", <${baseUrl}>a>; rel="prev"`, + }) + .get('/b?page=2') + .reply(200, expectedReply2, { + link: `<${baseUrl}a>; rel="prev"`, + }); + let i = 0; + await forEachPage(this.ghApi, { + url: '/a', + }, body => { + switch (i++) { + case 0: + assert.strictEqual(body, expectedReply1); + break; + case 1: + assert.strictEqual(body, expectedReply2); + break; + default: + throw new Error('callback called too many times'); + } + }); + } + + @test + async 'resolves promises returned by the callback'(): Promise { + this.baseNock + .get('/a') + .reply(200, '', { + link: `<${baseUrl}b>; rel="next"`, + }) + .get('/b') + .reply(200); + let i = 0; + let promiseResolved = false; + await forEachPage(this.ghApi, { + url: '/a', + }, async body => { + switch (i++) { + case 0: + await Promise.resolve(); + promiseResolved = true; + break; + case 1: + break; + default: + throw new Error('callback called too many times'); + } + }); + assert(promiseResolved, 'promise was not resolved'); + } + + @test + async 'throws error if next page url is not github'(): Promise { + this.baseNock + .get('/a') + .reply(200, '', { + link: `; rel="next"`, + }); + try { + await forEachPage(this.ghApi, { + url: '/a', + }, body => {}); + } catch (e) { + assert.strictEqual(e.message, 'invalid next page url https://badapi.github.com/b'); + return; + } + throw new Error('exception not thrown'); + } +} diff --git a/lib/github/pagination.ts b/lib/github/pagination.ts new file mode 100644 index 0000000..1abbbc5 --- /dev/null +++ b/lib/github/pagination.ts @@ -0,0 +1,53 @@ +import { + baseUrl, + RequestApi, +} from './api'; +import request = require('request-promise-native'); +import url = require('url'); + +export async function forEachPage(ghApi: RequestApi, opts: request.OptionsWithUrl, + fn: (body: any) => (Promise | void)): Promise { + const fullOpts = Object.assign({}, opts, { + resolveWithFullResponse: true, + }); + + while (true) { + const resp = await ghApi(fullOpts); + + const fnRet = fn(resp.body); + if (fnRet) { + await fnRet; + } + + if (!resp.headers['link']) { + return; + } + + const links = parseLinks(resp.headers['link']); + const linksNext = links.next; + if (!linksNext) { + return; + } + + if (!linksNext.startsWith(baseUrl)) { + throw new Error(`invalid next page url ${links.next}`); + } + + const nextPath = url.parse(linksNext).path; + Object.assign(fullOpts, { + method: 'GET', + url: nextPath, + }); + } +} + +function parseLinks(link: string): { [key: string]: string | undefined } { + const links: { [key: string]: string | undefined } = {}; + + link.replace(/<([^>]*)>;\s*rel="([\w]*)\"/g, (m: any, uri: string, type: string): string => { + links[type] = uri; + return ''; + }); + + return links; +} diff --git a/logic/BaseLogic.ts b/logic/BaseLogic.ts new file mode 100644 index 0000000..4c385db --- /dev/null +++ b/logic/BaseLogic.ts @@ -0,0 +1,39 @@ +import * as github from '../lib/github'; +import Logic from './Logic'; +import Koa = require('koa'); + +/** + * Useful {@link Logic} base class. + */ +export default class BaseLogic implements Logic { + async onPing(event: github.PingEvent, ghInstallationApi: github.RequestApi): Promise { + // do nothing + } + + async onPullRequest(event: github.PullRequestEvent, ghInstallationApi: github.RequestApi): Promise { + // do nothing + } + + async webhookMiddleware(ctx: Koa.Context, next?: () => Promise): Promise { + const event = ctx.request.body; + const ghInstallationApi = github.requireGhInstallationApi(ctx); + + switch (github.getEventName(ctx)) { + case 'ping': + this.onPing(event, ghInstallationApi).catch(onError); + break; + case 'pull_request': + this.onPullRequest(event, ghInstallationApi).catch(onError); + break; + default: + } + + if (next) { + await next(); + } + + function onError(e: any): void { + console.error(e); + } + } +} diff --git a/logic/LogWebhookLogic.ts b/logic/LogWebhookLogic.ts new file mode 100644 index 0000000..5a0e5f4 --- /dev/null +++ b/logic/LogWebhookLogic.ts @@ -0,0 +1,12 @@ +import BaseLogic from './BaseLogic'; +import Koa = require('koa'); + +/** + * Log all webhook payloads to the console. + */ +export default class LogWebhookLogic extends BaseLogic { + async webhookMiddleware(ctx: Koa.Context, next?: () => Promise): Promise { + console.log(ctx.request.body); + await super.webhookMiddleware(ctx, next); + } +} diff --git a/logic/Logic.ts b/logic/Logic.ts new file mode 100644 index 0000000..4e0f91d --- /dev/null +++ b/logic/Logic.ts @@ -0,0 +1,10 @@ +import Koa = require('koa'); + +/** + * A piece of logic which defines the behavior of the bot. + */ +export interface Logic { + webhookMiddleware: Koa.Middleware; +} + +export default Logic; diff --git a/logic/PraiseMergedPrLogic.ts b/logic/PraiseMergedPrLogic.ts new file mode 100644 index 0000000..2ffba47 --- /dev/null +++ b/logic/PraiseMergedPrLogic.ts @@ -0,0 +1,21 @@ +import * as github from '../lib/github'; +import BaseLogic from './BaseLogic'; + +/** + * Praises Pull Requests when they are merged. + */ +export default class PraiseMergedPrLogic extends BaseLogic { + async onPullRequest(event: github.PullRequestEvent, ghApi: github.RequestApi): Promise { + if (event.action !== 'closed' || !event.pull_request.merged) { + return; + } + + const owner = event.repository.owner.login; + const repo = event.repository.name; + await ghApi.post(`repos/${owner}/${repo}/issues/${event.number}/comments`, { + json: { + body: `Well done @${event.pull_request.user.login}!`, + }, + }); + } +} diff --git a/logic/index.ts b/logic/index.ts new file mode 100644 index 0000000..fd4703e --- /dev/null +++ b/logic/index.ts @@ -0,0 +1,25 @@ +import Logic from './Logic'; +import PraiseMergedPrLogic from './PraiseMergedPrLogic'; +import koaCompose = require('koa-compose'); + +/** + * Options to be passed to {@link createLogic}. + */ +export interface CreateLogicOptions { +} + +/** + * Constructs the bot logic configured with `options`. + */ +export function createLogic(options: CreateLogicOptions): Logic { + const logics: Logic[] = []; + + logics.push(new PraiseMergedPrLogic()); + + const webhookMiddleware = koaCompose(logics.map(logic => logic.webhookMiddleware.bind(logic))); + return { + webhookMiddleware, + }; +} + +export { Logic } from './Logic'; diff --git a/mocha.opts b/mocha.opts new file mode 100644 index 0000000..8d9691d --- /dev/null +++ b/mocha.opts @@ -0,0 +1,2 @@ +--require source-map-support/register +dist/**/*.spec.js diff --git a/package.json b/package.json new file mode 100644 index 0000000..6b3be33 --- /dev/null +++ b/package.json @@ -0,0 +1,65 @@ +{ + "name": "se-edu-bot", + "description": "Helps out with SE-EDU tasks", + "license": "MIT", + "private": true, + "engines": { + "node": ">=7.9.0" + }, + "files": [ + "dist" + ], + "scripts": { + "all": "npm-run-all", + "build": "tsc", + "postinstall": "npm run build", + "start": "node dist/serverWithLogging.js", + "watch:tsc": "tsc -w", + "watch:server": "nodemon --watch dist dist/serverWithLogging.js", + "watch": "npm-run-all --parallel \"watch:*\"", + "clean": "rimraf dist", + "lint": "tslint -p tsconfig.json --type-check", + "test": "mocha --opts mocha.opts", + "test-watch:mocha": "mocha --opts mocha.opts -w", + "test-watch": "npm-run-all --parallel watch:tsc test-watch:mocha" + }, + "dependencies": { + "dotenv": "^4.0.0", + "http-errors": "^1.6.1", + "jsonwebtoken": "^7.4.1", + "koa": "^2.2.0", + "koa-bodyparser": "^4.2.0", + "koa-compose": "^4.0.0", + "koa-route": "^3.2.0", + "request": "^2.81.0", + "request-promise-native": "^1.0.4", + "rotating-file-stream": "^1.2.2", + "simple-oauth2": "^1.2.0", + "source-map-support": "^0.4.15", + "typescript-string-enums": "^0.3.4" + }, + "devDependencies": { + "@types/dotenv": "^4.0.0", + "@types/http-errors": "^1.5.34", + "@types/jsonwebtoken": "^7.2.1", + "@types/koa": "^2.0.39", + "@types/koa-bodyparser": "^3.0.23", + "@types/koa-compose": "^3.2.2", + "@types/koa-route": "^3.2.0", + "@types/mocha": "^2.2.41", + "@types/nock": "^8.2.1", + "@types/node": "^7.0.31", + "@types/request-promise-native": "^1.0.5", + "@types/simple-oauth2": "^1.0.2", + "@types/source-map-support": "^0.4.0", + "mocha": "^3.4.2", + "mocha-typescript": "^1.1.4", + "nock": "^9.0.13", + "nodemon": "^1.11.0", + "npm-run-all": "^4.0.2", + "rimraf": "^2.6.1", + "tslint": "^5.4.3", + "tslint-language-service": "^0.9.6", + "typescript": "^2.3.4" + } +} diff --git a/server.spec.ts b/server.spec.ts new file mode 100644 index 0000000..591bc7c --- /dev/null +++ b/server.spec.ts @@ -0,0 +1,26 @@ +import { + suite, + test, +} from 'mocha-typescript'; +import { + createApp, +} from './server'; + +/** + * Tests for {@link createApp}. + */ +@suite +export class CreateAppTest { + @test + 'works'(): void { + createApp({ + githubAppId: 123, + githubAppPrivateKey: '', + githubClientId: 'abc', + githubClientSecret: 'abc', + githubInstallationId: 123, + githubWebhookSecret: 'abcd123', + proxy: false, + }); + } +} diff --git a/server.ts b/server.ts new file mode 100644 index 0000000..522bc70 --- /dev/null +++ b/server.ts @@ -0,0 +1,150 @@ +import sourceMapSupport = require('source-map-support'); +sourceMapSupport.install(); + +import dotenv = require('dotenv'); +dotenv.config(); + +import Koa = require('koa'); +import koaRoute = require('koa-route'); +import koaCompose = require('koa-compose'); +import koaBodyParser = require('koa-bodyparser'); +import Auth from './lib/Auth'; +import * as github from './lib/github'; +import Logger from './lib/Logger'; +import { + createLogic, +} from './logic'; +import { logFileName } from './serverWithLogging'; + +/** + * Server app configuration. + */ +export interface AppConfig { + /** + * True if the server is being served behind a proxy. + */ + proxy: boolean; + + /** + * GitHub webhook secret. + */ + githubWebhookSecret: string; + + /** + * GitHub App Id. + */ + githubAppId: number; + + /** + * GitHub App PEM-encoded private key. + */ + githubAppPrivateKey: string; + + /** + * Installation Id of the installation in the se-edu organization. + */ + githubInstallationId: number; + + /** + * Github App Client Id. + */ + githubClientId: string; + + /** + * Github App Client secret. + */ + githubClientSecret: string; +} + +/** + * Creates a Koa Application with the provided `appConfig`. + */ +export function createApp(appConfig: AppConfig): Koa { + const userAgent = 'se-edu-bot'; + + const app = new Koa(); + app.proxy = appConfig.proxy; + + const logic = createLogic({ + }); + + // Auth support + const auth = new Auth({ + accessTokenCookieName: 'SE_EDU_BOT_ACCESS_TOKEN', + baseRoute: '/auth', + clientId: appConfig.githubClientId, + clientSecret: appConfig.githubClientSecret, + userAgent, + }); + app.use(auth.middleware); + + // GitHub webhook + app.use(koaRoute.post('/webhook', koaCompose([ + koaBodyParser(), + github.koaWebhookValidator(appConfig.githubWebhookSecret), + github.koaGhAppApi({ + appId: appConfig.githubAppId, + expiresIn: 60, + privateKey: appConfig.githubAppPrivateKey, + userAgent, + }), + github.koaGhInstallationApi({ + installationId: appConfig.githubInstallationId, + userAgent, + }), + logic.webhookMiddleware, + ]))); + + // Logs access + const logger = new Logger({ + fileName: logFileName, + }); + app.use(koaRoute.get('/logs', koaCompose([ + auth.createAccessControlByInstallationId(appConfig.githubInstallationId), + async ctx => { + ctx.type = 'text'; + ctx.body = logger.createReadableStream(); + }, + ]))); + + app.use(koaRoute.get('/', async ctx => { + ctx.body = 'Hello world!'; + })); + + return app; +} + +/** + * Extracts {@link AppConfig} from `process.env`. + */ +function extractAppConfigFromEnv(): AppConfig { + return { + githubAppId: parseInt(extractEnvVar('GITHUB_APP_ID'), 10), + githubAppPrivateKey: extractEnvVar('GITHUB_APP_PRIVATE_KEY'), + githubClientId: extractEnvVar('GITHUB_CLIENT_ID'), + githubClientSecret: extractEnvVar('GITHUB_CLIENT_SECRET'), + githubInstallationId: parseInt(extractEnvVar('GITHUB_INSTALLATION_ID'), 10), + githubWebhookSecret: extractEnvVar('GITHUB_WEBHOOK_SECRET'), + proxy: !!extractEnvVar('PROXY', ''), + }; +} + +/** + * Extracts an environment variable from `process.env`. + * @throws {Error} Environment variable not defined and `defaultValue` not provided. + */ +function extractEnvVar(key: string, defaultValue?: string): string { + if (typeof process.env[key] === 'string' && process.env[key]) { + return process.env[key]; + } else if (typeof defaultValue !== 'undefined') { + return defaultValue; + } else { + throw new Error(`$${key} not defined`); + } +} + +if (require.main === module) { + const port = parseInt(extractEnvVar('PORT', '5000'), 10); + const app = createApp(extractAppConfigFromEnv()); + app.listen(port); +} diff --git a/serverWithLogging.ts b/serverWithLogging.ts new file mode 100644 index 0000000..d6f2059 --- /dev/null +++ b/serverWithLogging.ts @@ -0,0 +1,45 @@ +import sourceMapSupport = require('source-map-support'); +sourceMapSupport.install(); + +import Logger from './lib/Logger'; +import childProcess = require('child_process'); +import path = require('path'); + +export const logFileName = 'se-edu-bot.log'; + +function runServerWithLogging(): void { + const logger = new Logger({ + fileName: 'se-edu-bot.log', + }); + const stream = logger.getWritableStream(); + stream.write('Starting server...\n'); + const cp = childProcess.spawn(process.execPath, [ + path.resolve(__dirname, 'server.js'), + ], { + stdio: ['ignore', 'pipe', 'pipe'], + }); + let exited = false; + cp.on('exit', (code, signal) => { + exited = true; + stream.end(`Server terminated with code ${code}, signal ${signal}\n`); + process.exitCode = code; + }); + cp.stdout.pipe(stream, { end: false }); + cp.stdout.pipe(process.stdout, { end: false }); + cp.stderr.pipe(stream, { end: false }); + cp.stderr.pipe(process.stderr, { end: false }); + const signalHandler = (signal: string) => { + if (exited) { + return; + } + stream.write(`Sending ${signal} to server...\n`); + cp.kill(signal); + }; + process.on('SIGUSR2', signalHandler.bind(undefined, 'SIGUSR2')); + process.on('SIGINT', signalHandler.bind(undefined, 'SIGINT')); + process.on('SIGTERM', signalHandler.bind(undefined, 'SIGTERM')); +} + +if (require.main === module) { + runServerWithLogging(); +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..8c7904d --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "experimentalDecorators": true, + "module": "commonjs", + "newLine": "LF", + "noImplicitReturns": true, + "noUnusedLocals": true, + "outDir": "dist", + "paths": { + "*": ["types/*"] + }, + "plugins": [ + {"name": "tslint-language-service"} + ], + "pretty": true, + "sourceMap": true, + "strict": true, + "target": "ES2017" + } +} diff --git a/tslint.json b/tslint.json new file mode 100644 index 0000000..37a2614 --- /dev/null +++ b/tslint.json @@ -0,0 +1,81 @@ +{ + "defaultSeverity": "error", + "lintOptions": { + "typeCheck": true + }, + "rules": { + "adjacent-overload-signatures": true, + "await-promise": [true, "RequestPromise", "Bluebird"], + "class-name": true, + "comment-format": [true, "check-space"], + "curly": true, + "eofline": true, + "indent": [true, "spaces", 4], + "linebreak-style": [true, "LF"], + "max-line-length": [true, 120], + "member-access": [true, "no-public"], + "member-ordering": [true, { + "order": [ + "public-static-field", + "protected-static-field", + "private-static-field", + "public-instance-field", + "protected-instance-field", + "private-instance-field", + "constructor", + "public-static-method", + "protected-static-method", + "private-static-method", + "public-instance-method", + "protected-instance-method", + "private-instance-method" + ] + }], + "no-consecutive-blank-lines": true, + "no-floating-promises": [true, "RequestPromise", "Bluebird"], + "no-inferrable-types": [true, "ignore-params", "ignore-properties"], + "no-trailing-whitespace": true, + "no-var-keyword": true, + "object-literal-sort-keys": true, + "only-arrow-functions": [true, "allow-declarations"], + "ordered-imports": true, + "prefer-const": true, + "prefer-template": [true, "allow-single-concat"], + "promise-function-async": true, + "quotemark": [true, "single"], + "semicolon": [true, "always"], + "space-before-function-paren": [ true, { + "anonymous": "never", + "named": "never", + "asyncArrow": "always" + }], + "trailing-comma": [true, {"multiline": "always", "singleline": "never"}], + "triple-equals": true, + "typedef": [true, "call-signature", "parameter", "property-declaration"], + "typedef-whitespace": [true, { + "call-signature": "nospace", + "index-signature": "nospace", + "parameter": "nospace", + "property-declaration": "nospace", + "variable-declaration": "nospace" + }, { + "call-signature": "onespace", + "index-signature": "onespace", + "parameter": "onespace", + "property-declaration": "onespace", + "variable-declaration": "onespace" + }], + "whitespace": [ + true, + "check-branch", + "check-decl", + "check-operator", + "check-module", + "check-separator", + "check-type", + "check-typecast", + "check-preblock" + ] + }, + "rulesDirectory": [] +} diff --git a/types/rotating-file-stream/index.d.ts b/types/rotating-file-stream/index.d.ts new file mode 100644 index 0000000..eb8900c --- /dev/null +++ b/types/rotating-file-stream/index.d.ts @@ -0,0 +1,28 @@ +interface RotatingFileStream extends NodeJS.WritableStream { +} + +declare namespace RotatingFileStream { + interface Options { + compress?: boolean | 'gzip' | CompressFunction; + highWaterMark?: number; + history?: string; + interval?: string; + maxFiles?: number; + maxSize?: string; + path?: string; + rotate?: number; + size?: string; + } + + interface Constructor { + (filename: string | RotatedFileNameGenerator, options?: Options): RotatingFileStream; + new(filename: string | RotatedFileNameGenerator, options?: Options): RotatingFileStream; + } + + export type RotatedFileNameGenerator = ((time: Date | null, index: number) => string) | ((index: number | null) => string); + export type CompressFunction = (src: string, dst: string) => string; +} + +declare const RotatingFileStream: RotatingFileStream.Constructor; + +export = RotatingFileStream;