Skip to content

Commit f6946a0

Browse files
bkeepershiimbex
authored andcommitted
Probot Support for GitHub App Manifests (probot#650)
* Add button to setup GitHub app * Add app.yml * Add some comments to app.yml * Associate events with permissions * Allow configuring app with manifest * Hacky version of callback URL for configuring app * Remove manifest stuff for now * Return nil if private key is not found * Move setup stuff to a separate plugin * Revert changes to default plugin * Remove FIXME * Revert changes to default template * Use separate template for setup * Fix lint warnings * Convert test helper to typescript * Initial test for setup app * Require tests files to match `.test.(js|ts)` * Account for multiple protocols in x-forwarded-proto * Wrap pem in quotes * Collapse class into request method for now * run `refresh` after updating .env on Glitch * Extract update-dotenv to a node module * Create a smee url if one does not exist * Hacky version of serving up the manifest * WIP Manifest with code * add comments for plan + figure out port * using user-agent header for review lab * add success view to redirect to after installation * api is actually on github * start making post * fix quoting issue on POST * everything is off review lab and on dotcom * working version of app manifest flow * Start trying to write tests against setup * more refactor into thingerator; basic tests; write plans for other tests * merge better.. * ok atom conflict handling broke * hack the tests back together * pass the tests 👊🏼 * moar test * make it open in a new tab for Wil 💖 * make boolean work * clean up logic, move messgae, just return html url not response * clean up tests 👷🏾‍♀️ * rename Brandon's thingerator to manifest-creation
1 parent fc99246 commit f6946a0

13 files changed

Lines changed: 408 additions & 46 deletions

File tree

bin/probot-run.js

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,7 @@ program
1717
.option('-P, --private-key <file>', 'Path to certificate of the GitHub App', findPrivateKey)
1818
.parse(process.argv)
1919

20-
if (!program.app) {
21-
console.warn('Missing GitHub App ID.\nUse --app flag or set APP_ID environment variable.')
22-
program.help()
23-
}
24-
25-
if (!program.privateKey) {
26-
program.privateKey = findPrivateKey()
27-
}
20+
program.privateKey = findPrivateKey()
2821

2922
const {createProbot} = require('../')
3023

@@ -37,7 +30,15 @@ const probot = createProbot({
3730
webhookProxy: program.webhookProxy
3831
})
3932

40-
pkgConf('probot').then(pkg => {
41-
probot.setup(program.args.concat(pkg.apps || pkg.plugins || []))
33+
const setupMode = !program.app || !program.privateKey
34+
35+
if (setupMode) {
36+
const setupApp = require('../lib/apps/setup')
37+
probot.load(setupApp)
4238
probot.start()
43-
})
39+
} else {
40+
pkgConf('probot').then(pkg => {
41+
probot.setup(program.args.concat(pkg.apps || pkg.plugins || []))
42+
probot.start()
43+
})
44+
}

package.json

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -42,12 +42,7 @@
4242
".+\\.tsx?$": "ts-jest"
4343
},
4444
"testMatch": [
45-
"<rootDir>/test/**/*.(ts|js)"
46-
],
47-
"testPathIgnorePatterns": [
48-
"<rootDir>/test/setup.js",
49-
"<rootDir>/test/fixtures/",
50-
"<rootDir>/test/apps/helper.js"
45+
"<rootDir>/test/**/*.test.(ts|js)"
5146
],
5247
"globals": {
5348
"ts-jest": {
@@ -86,9 +81,11 @@
8681
"jsonwebtoken": "^8.1.0",
8782
"pkg-conf": "^2.0.0",
8883
"promise-events": "^0.1.3",
84+
"qs": "^6.5.2",
8985
"raven": "^2.4.2",
9086
"resolve": "^1.4.0",
9187
"semver": "^5.5.0",
88+
"update-dotenv": "^1.1.0",
9289
"uuid": "^3.2.1"
9390
},
9491
"devDependencies": {
@@ -102,6 +99,7 @@
10299
"@types/jsonwebtoken": "^7.2.5",
103100
"@types/nock": "^9.1.0",
104101
"@types/node": "^10.7.0",
102+
"@types/qs": "^6.5.1",
105103
"@types/raven": "^2.1.5",
106104
"@types/resolve": "^0.0.4",
107105
"@types/semver": "^5.4.0",
@@ -110,7 +108,7 @@
110108
"connect-sse": "^1.2.0",
111109
"eslint": "^5.0.1",
112110
"eslint-plugin-markdown": "^1.0.0-beta.8",
113-
"jest": "^23.4.1",
111+
"jest": "^23.6.0",
114112
"minami": "^1.1.1",
115113
"nock": "^10.0.0",
116114
"semantic-release": "^15.0.0",

src/apps/setup.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { exec } from 'child_process'
2+
import { Request, Response } from 'express'
3+
import { Application } from '../application'
4+
import { ManifestCreation } from '../manifest-creation'
5+
6+
// use glitch env to get correct domain welcome message
7+
// https://glitch.com/help/project/
8+
const domain = process.env.PROJECT_DOMAIN || `http://localhost:${process.env.PORT || 3000}`
9+
const welcomeMessage = `\nWelcome to Probot! Go to ${domain} to get started.\n`
10+
11+
export = async (app: Application, setup: ManifestCreation = new ManifestCreation()) => {
12+
// If not on Glitch or Production, create a smee URL
13+
if (process.env.NODE_ENV !== 'production' && !(process.env.PROJECT_DOMAIN || process.env.WEBHOOK_PROXY_URL)) {
14+
await setup.createWebhookChannel()
15+
}
16+
17+
const route = app.route()
18+
19+
app.log.info(welcomeMessage)
20+
21+
route.get('/probot', async (req, res) => {
22+
const protocols = req.headers['x-forwarded-proto'] || req.protocol
23+
const protocol = typeof protocols === 'string' ? protocols.split(',')[0] : protocols[0]
24+
const host = req.headers['x-forwarded-host'] || req.get('host')
25+
const baseUrl = `${protocol}://${host}`
26+
27+
const pkg = setup.pkg
28+
const manifest = setup.getManifest(pkg, baseUrl)
29+
const createAppUrl = setup.createAppUrl
30+
// Pass the manifest to be POST'd
31+
res.render('setup.hbs', { pkg, createAppUrl, manifest })
32+
})
33+
34+
route.get('/probot/setup', async (req: Request, res: Response) => {
35+
const { code } = req.query
36+
const response = await setup.createAppFromCode(code)
37+
38+
// If using glitch, restart the app
39+
if (process.env.PROJECT_DOMAIN) {
40+
exec('refresh', (err, stdout, stderr) => {
41+
if (err) {
42+
app.log.error(err, stderr)
43+
}
44+
})
45+
}
46+
47+
res.redirect(`${response}/installations/new`)
48+
})
49+
50+
route.get('/probot/success', async (req, res) => {
51+
res.render('success.hbs')
52+
})
53+
54+
route.get('/', (req, res, next) => res.redirect('/probot'))
55+
}

src/manifest-creation.ts

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
import fs from 'fs'
2+
import yaml from 'js-yaml'
3+
import path from 'path'
4+
import updateDotenv from 'update-dotenv'
5+
import { GitHubAPI } from './github'
6+
7+
export class ManifestCreation {
8+
get pkg () {
9+
let pkg: any
10+
try {
11+
pkg = require(path.join(process.cwd(), 'package.json'))
12+
} catch (e) {
13+
pkg = {}
14+
}
15+
return pkg
16+
}
17+
18+
public async createWebhookChannel () {
19+
try {
20+
// tslint:disable:no-var-requires
21+
const SmeeClient = require('smee-client')
22+
await this.updateEnv({ WEBHOOK_PROXY_URL: await SmeeClient.createChannel() })
23+
} catch (err) {
24+
// Smee is not available, so we'll just move on
25+
// tslint:disable:no-console
26+
console.warn('Unable to connect to smee.io, try restarting your server.')
27+
}
28+
}
29+
30+
public getManifest (pkg: any, baseUrl: any) {
31+
let manifest: any = {}
32+
try {
33+
const file = fs.readFileSync(path.join(process.cwd(), 'app.yml'), 'utf8')
34+
manifest = yaml.safeLoad(file)
35+
} catch (err) {
36+
// App config does not exist, which is ok.
37+
if (err.code !== 'ENOENT') {
38+
throw err
39+
}
40+
}
41+
42+
const generatedManifest = JSON.stringify(Object.assign({
43+
description: manifest.description || pkg.description,
44+
hook_attributes: {
45+
url: process.env.WEBHOOK_PROXY_URL || `${baseUrl}/`
46+
},
47+
name: manifest.name || pkg.name,
48+
public: manifest.public || true,
49+
redirect_url: `${baseUrl}/probot/setup`,
50+
// TODO: add setup url
51+
// setup_url:`${baseUrl}/probot/success`,
52+
url: manifest.url || pkg.homepage || pkg.repository
53+
}, manifest))
54+
55+
return generatedManifest
56+
}
57+
58+
public async createAppFromCode (code: any) {
59+
const github = GitHubAPI()
60+
const response = await github.request({
61+
headers: { accept: 'application/vnd.github.fury-preview+json' },
62+
method: 'POST',
63+
url: `/app-manifests/${code}/conversions`
64+
})
65+
66+
const { id, webhook_secret, pem } = response.data
67+
await this.updateEnv({
68+
APP_ID: id.toString(),
69+
PRIVATE_KEY: pem,
70+
WEBHOOK_SECRET: webhook_secret
71+
})
72+
73+
return response.data.html_url
74+
}
75+
76+
private async updateEnv (env: any) {
77+
return updateDotenv(env)
78+
}
79+
80+
get createAppUrl () {
81+
const githubHost = process.env.GHE_HOST || `github.com`
82+
return `http://${githubHost}/settings/apps/new`
83+
}
84+
}

src/private-key.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ const hint = `please use:
2020
* @returns Private key
2121
* @private
2222
*/
23-
export function findPrivateKey (filepath?: string): Buffer | string {
23+
export function findPrivateKey (filepath?: string): Buffer | string | null {
2424
if (filepath) {
2525
return fs.readFileSync(filepath)
2626
}
@@ -54,5 +54,5 @@ export function findPrivateKey (filepath?: string): Buffer | string {
5454
} else if (pemFiles[0]) {
5555
return findPrivateKey(pemFiles[0])
5656
}
57-
throw new Error(`Missing private key for GitHub App, ${hint}`)
57+
return null
5858
}

test/apps/helper.js

Lines changed: 0 additions & 16 deletions
This file was deleted.

test/apps/helper.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
// FIXME: move this to a test helper that can be used by other apps
2+
3+
import cacheManager from 'cache-manager'
4+
import { Application, ApplicationFunction } from '../../src'
5+
6+
const cache = cacheManager.caching({ store: 'memory', ttl: 0 })
7+
8+
const jwt = jest.fn().mockReturnValue('test')
9+
10+
export function newApp (): Application {
11+
return new Application({ app: jwt, cache })
12+
}
13+
14+
export function createApp (appFn?: ApplicationFunction) {
15+
const app = newApp()
16+
appFn && appFn(app)
17+
return app
18+
}

test/apps/setup.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
import express from 'express'
2+
import request from 'supertest'
3+
import { Application } from '../../src'
4+
import appFn from '../../src/apps/setup'
5+
import { ManifestCreation } from '../../src/manifest-creation'
6+
import { newApp } from './helper'
7+
8+
describe('Setup app', () => {
9+
let server: express.Application
10+
let app: Application
11+
let setup: ManifestCreation
12+
13+
beforeEach(async () => {
14+
app = newApp()
15+
setup = new ManifestCreation()
16+
17+
setup.createWebhookChannel = jest.fn()
18+
19+
await appFn(app, setup)
20+
server = express()
21+
server.use(app.router)
22+
})
23+
24+
describe('GET /probot', () => {
25+
it('returns a 200 response', () => {
26+
return request(server)
27+
.get('/probot')
28+
.expect(200)
29+
})
30+
})
31+
32+
describe('GET /probot/setup', () => {
33+
it('returns a 200 response', () => {
34+
return request(server)
35+
.get('/probot')
36+
.expect(200)
37+
})
38+
})
39+
40+
describe('GET /probot/success', () => {
41+
it('returns a 200 response', () => {
42+
return request(server)
43+
.get('/probot/success')
44+
.expect(200)
45+
})
46+
})
47+
})

test/fixtures/setup/response.json

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
2+
{
3+
"id": "6666",
4+
"webhook_secret": "12345abcde",
5+
"pem": "-----BEGIN RSA PRIVATE KEY-----\nsecrets\n-----END RSA PRIVATE KEY-----\n",
6+
"html_url": "https://github.com/apps/testerino0000000"
7+
}

0 commit comments

Comments
 (0)