diff --git a/README.md b/README.md index 75318b9..69eb3e6 100755 --- a/README.md +++ b/README.md @@ -22,20 +22,40 @@ Draft Example ## Getting Started Chronicler is a simple express.js app that receives GitHub Webhook events via its `/webhooks` route. You'll need to clone and set the app up on a server or cloud service (e.x. Google Cloud Platform, AWS, Digital Ocean) to use it for your own projects. -### Setup - -#### Environment Variables +### Environment Variables The following variables must be set up and available to Chronicler via the node.js `process.env` object. +It is possible to run both as a Github app or as an individual user. +To run as a Github app: SET AUTH_AS_APP=true, APP_IDENTIFIER and SECRET to their respective values. GH_TOKEN can then be omitted. **Also make sure to set the private key for the app (listed under basic information about the app) in /run/secrets/private-key** +To run the service as a user: SET GH_TOKEN and SECRET to their respective values. AUTH_AS_APP and PRIVATE_KEY, APP_IDENTIFIER can then be omitted. **Variable Name** | **Description** | **Default** --- | --- | :---: -`GH_TOKEN` | The Github [personal access token](https://github.com/settings/tokens) to use for this app. Used for authentication when making calls to the GitHub API. | - -`SECRET` | The GitHub Webhook secret passed along with every Webhooks request. Allows your app to authenticate the request and make sure the request is coming from a trusted source. Generate a [random string with high entropy](https://developer.github.com/webhooks/securing/#setting-your-secret-token) for your secure secret or create one using an online [generator](https://randomkeygen.com/). | - +`SECRET` (required) | The GitHub Webhook secret passed along with every Webhooks request. Allows your app to authenticate the request and make sure the request is coming from a trusted source. Generate a [random string with high entropy](https://developer.github.com/webhooks/securing/#setting-your-secret-token) for your secure secret or create one using an online [generator](https://randomkeygen.com/). | - +`AUTH_AS_APP` (required if github app)| Whether to authenticate as a Github app | false +`APP_IDENTIFIER` (required if github app)| The unique identifier for the Github app, listed under basic information about the app | - +`GH_TOKEN` (required if set as a user)| The Github [personal access token](https://github.com/settings/tokens) to use for this app. Used for authentication when making calls to the GitHub API. | - `APP_NAME` (optional) | Name of the app to send as the `User-Agent` value in the API requests. | `Chronicler` `PORT` (optional) | App port. | `8080` +### Setup as Github app + +#### Creating the app +A Github app can be created on both a user and an organization. The latter is recommended when working together with a team whose members might change. Thus the service is uncoupled from a specific user that might leave. +To set it up, go to https://github.com/organizations/``/settings/apps and create new. Fill out the required fields and give it the following permissions: + +1. Repository contents **Read & Write** +2. Repository metadata **Read** +3. Pull requests **Read** + - Subscribe to events: Pull Requests + +When created, go to the general-tab for the app and collect set the **Webhook secret (SECRET)** the **PRIVATE_KEY** and **APP_IDENTIFIER** to be set in your environment. + +Lastly. Install the app on your organization/repository through the organization/repository settings + +### Setup as User + ##### A Note on Personal Access Tokens -Chronicler requires a personal access token (PAT) to create or edit a release draft via the GitHub API. PATs are tied to a user's account. For GitHub teams or organizations using Chronicler we reccommend creating a dedicated GitHub account that owns the PAT. By creating the PAT with a dedicated GitHub account instead of with a team member's account, you can avoid interuptions to Chronicler if the team member leaves or is removed from the organization. +When setting up Chronicler as a user it requires a personal access token (PAT) to create or edit a release draft via the GitHub API. PATs are tied to a user's account. For GitHub teams or organizations using Chronicler we reccommend creating a dedicated GitHub account that owns the PAT. By creating the PAT with a dedicated GitHub account instead of with a team member's account, you can avoid interuptions to Chronicler if the team member leaves or is removed from the organization. To generate a new PAT for Chronicler, go to your [account settings](https://github.com/settings/tokens/new). Add a "token description" (e.x "chronicler-app") and grant it `repo` scope. diff --git a/__tests__/auth.test.js b/__tests__/auth.test.js index 776ab9d..bd7bc4d 100644 --- a/__tests__/auth.test.js +++ b/__tests__/auth.test.js @@ -1,5 +1,5 @@ import test from 'ava' -import auth from '../src/helpers/auth' +import { auth } from '../src/auth/webhook' import { mockRequest } from './fixtures/webhook-event' // fake request object for auth test diff --git a/__tests__/githubApp.test.js b/__tests__/githubApp.test.js new file mode 100644 index 0000000..52cd7e3 --- /dev/null +++ b/__tests__/githubApp.test.js @@ -0,0 +1,37 @@ +import test from 'ava' +import sinon from 'sinon' +import proxyquire from 'proxyquire' + +let githubApp, signStub, momentStub +test.beforeEach(() => { + process.env.APP_IDENTIFIER = 'MOCKED_APP_IDENTIFIER' + const fs = { + readFileSync: sinon.stub().returns('MOCKED_PRIVATE_KEY') + } + signStub = sinon.stub().returns('jwt123') + momentStub = { + unix: sinon.stub() + } + momentStub.add = () => momentStub + githubApp = proxyquire('../src/auth/githubApp.js', { + 'jsonwebtoken': { + sign: signStub + }, + 'moment': () => momentStub, + 'fs': fs + }) +}) + +test('calls jwt encode with iat, exp, iss', t => { + momentStub.unix.onFirstCall().returns(1) + momentStub.unix.onSecondCall().returns(2) + t.is(githubApp.auth(), 'jwt123') + + t.true(signStub.calledWith({ + iss: 'MOCKED_APP_IDENTIFIER', + iat: 1, + exp: 2, + }, 'MOCKED_PRIVATE_KEY', { + algorithm: 'RS256' + })) +}) diff --git a/__tests__/installation.test.js b/__tests__/installation.test.js new file mode 100644 index 0000000..fd3e272 --- /dev/null +++ b/__tests__/installation.test.js @@ -0,0 +1,30 @@ +import test from 'ava' +import sinon from 'sinon' +import proxyquire from 'proxyquire' + +let installation, axiosStub +test.beforeEach(() => { + axiosStub = sinon.stub().resolves({ + data: { + token: 'accesstokenfromgithub' + } + }) + installation = proxyquire('../src/auth/installation.js', { + 'axios': axiosStub + }) +}) + +test('requests bearer token with installation ID and jwt', async t => { + const id = 5 + const req = { body: { installation: { id } } } + + t.is(await installation.auth(req, 'jwttokenstring'), 'accesstokenfromgithub') + t.true(axiosStub.calledWith({ + url: `https://api.github.com/app/installations/${id}/access_tokens`, + headers: { + Authorization: 'Bearer ' + 'jwttokenstring', + Accept: 'application/vnd.github.machine-man-preview+json' + }, + method: 'POST' + })) +}) diff --git a/__tests__/pr.test.js b/__tests__/pr.test.js index 9b1cebb..e87bb43 100644 --- a/__tests__/pr.test.js +++ b/__tests__/pr.test.js @@ -1,49 +1,43 @@ import test from 'ava' import webhookData from './fixtures/webhook-event' import { drafts } from './fixtures/releases' -import { - isTooOld, - getReleasesUrl, - getSingleReleaseUrl, - getPrDesc, - getPrData, - updateReleaseDraft -} from '../src/helpers/pr' -const pr = getPrData(webhookData) -const draft = drafts[0] +import WebhookHandler from '../src/helpers/pr' +const draft = drafts[0] +let prHelper, pr test.beforeEach(() => { - process.env.GH_TOKEN = 'MOCK_TOKEN' + prHelper = new WebhookHandler('MOCK_TOKEN') + pr = prHelper.getPrData(webhookData) }) test('isTooOld returns true if PR was merged more than 5 minutes ago', t => { - t.true(isTooOld(pr.merged_at)) + t.true(prHelper.isTooOld(pr.merged_at)) }) test('isTooOld returns false if PR was merged within 5 minutes', t => { // the current time - t.false(isTooOld(new Date())) + t.false(prHelper.isTooOld(new Date())) }) test('getReleasesUrl returns the url for the github repo releases endpoint', t => { const expected = 'https://api.github.com/repos/NYTimes/Chronicler/releases?access_token=MOCK_TOKEN' - t.is(getReleasesUrl(pr), expected) + t.is(prHelper.getReleasesUrl(pr), expected) }) test('getSingleReleaseUrl returns the github release url for a given release id', t => { const expected = 'https://api.github.com/repos/NYTimes/Chronicler/releases/9797693?access_token=MOCK_TOKEN' - t.is(getSingleReleaseUrl(pr, draft), expected) + t.is(prHelper.getSingleReleaseUrl(pr, draft), expected) }) test('getPrDesc should return the formatted description for a pull request with title and number', t => { const expected = '- Update README.md (#16)' - t.is(getPrDesc(pr), expected) + t.is(prHelper.getPrDesc(pr), expected) }) test('getPrData should return a pull request object with the repository url', t => { @@ -57,7 +51,7 @@ test('getPrData should return a pull request object with the repository url', t test('updateReleaseDraft should append the pull request title and number to existing draft', t => { const expect = '- Title Change (#4) - Give Props (#3) - Test permissions (#6) - Another Permissions test (#7) - Update README.md (#10) - Update README.md (#12) - Update README.md (#13) - Update README.md (#14) - Update README.md (#15) - Update README.md (#16) - Update README.md (#16) - Add webhook url to readme (#5)\n- Update README.md (#16)' - t.is(updateReleaseDraft(pr, draft), expect) + t.is(prHelper.updateReleaseDraft(pr, draft), expect) }) test.todo( diff --git a/package-lock.json b/package-lock.json index 40a9f36..4f1c6a6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -403,6 +403,35 @@ "arrify": "^1.0.1" } }, + "@sinonjs/commons": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-1.3.0.tgz", + "integrity": "sha512-j4ZwhaHmwsCb4DlDOIWnI5YyKDNMoNThsmwEpfHx6a1EpsGZ9qYLxP++LMlmBRjtGptGHFsGItJ768snllFWpA==", + "dev": true, + "requires": { + "type-detect": "4.0.8" + } + }, + "@sinonjs/formatio": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@sinonjs/formatio/-/formatio-3.1.0.tgz", + "integrity": "sha512-ZAR2bPHOl4Xg6eklUGpsdiIJ4+J1SNag1DHHrG/73Uz/nVwXqjgUtRPLoS+aVyieN9cSbc0E4LsU984tWcDyNg==", + "dev": true, + "requires": { + "@sinonjs/samsam": "^2 || ^3" + } + }, + "@sinonjs/samsam": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-3.0.2.tgz", + "integrity": "sha512-m08g4CS3J6lwRQk1pj1EO+KEVWbrbXsmi9Pw0ySmrIbcVxVaedoFgLvFsV8wHLwh01EpROVz3KvVcD1Jmks9FQ==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.0.2", + "array-from": "^2.1.1", + "lodash.get": "^4.4.2" + } + }, "abbrev": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", @@ -535,6 +564,12 @@ "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", "integrity": "sha1-ml9pkFGx5wczKPKgCJaLZOopVdI=" }, + "array-from": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/array-from/-/array-from-2.1.1.tgz", + "integrity": "sha1-z+nYwmYoudxa7MYqn12PHzUsEZU=", + "dev": true + }, "array-union": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/array-union/-/array-union-1.0.2.tgz", @@ -1053,6 +1088,11 @@ } } }, + "buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=" + }, "buffer-from": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.0.tgz", @@ -1637,6 +1677,12 @@ "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.0.4.tgz", "integrity": "sha1-l4hXRCxEdJ5CBmE+N5RiBYJqvYA=" }, + "diff": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-3.5.0.tgz", + "integrity": "sha512-A46qtFgd+g7pDZinpnwiRJtxbC1hpgf0uzP3iG89scHk0AUC7A1TGxf5OiiOUv/JMZR8GOt8hL900hV0bOy5xA==", + "dev": true + }, "dir-glob": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/dir-glob/-/dir-glob-2.0.0.tgz", @@ -1677,6 +1723,14 @@ "integrity": "sha1-7gHdHKwO08vH/b6jfcCo8c4ALOI=", "dev": true }, + "ecdsa-sig-formatter": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.10.tgz", + "integrity": "sha1-HFlQAPBKiJffuFAAiSoPTDOvhsM=", + "requires": { + "safe-buffer": "^5.0.1" + } + }, "ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -2250,6 +2304,15 @@ "object-assign": "^4.0.1" } }, + "fill-keys": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/fill-keys/-/fill-keys-1.0.2.tgz", + "integrity": "sha1-mo+jb06K1jTjv2tPPIiCVRRS6yA=", + "requires": { + "is-object": "~1.0.1", + "merge-descriptors": "~1.0.0" + } + }, "fill-range": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-4.0.0.tgz", @@ -3494,6 +3557,11 @@ "integrity": "sha1-PkcprB9f3gJc19g6iW2rn09n2w8=", "dev": true }, + "is-object": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-object/-/is-object-1.0.1.tgz", + "integrity": "sha1-iVJojF7C/9awPsyF52ngKQMINHA=" + }, "is-observable": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/is-observable/-/is-observable-1.1.0.tgz", @@ -3675,6 +3743,54 @@ "integrity": "sha1-Hq3nrMASA0rYTiOWdn6tn6VJWCE=", "dev": true }, + "jsonwebtoken": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-8.4.0.tgz", + "integrity": "sha512-coyXjRTCy0pw5WYBpMvWOMN+Kjaik2MwTUIq9cna/W7NpO9E+iYbumZONAz3hcr+tXFJECoQVrtmIoC3Oz0gvg==", + "requires": { + "jws": "^3.1.5", + "lodash.includes": "^4.3.0", + "lodash.isboolean": "^3.0.3", + "lodash.isinteger": "^4.0.4", + "lodash.isnumber": "^3.0.3", + "lodash.isplainobject": "^4.0.6", + "lodash.isstring": "^4.0.1", + "lodash.once": "^4.0.0", + "ms": "^2.1.1" + }, + "dependencies": { + "ms": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.1.tgz", + "integrity": "sha512-tgp+dl5cGk28utYktBsrFqA7HKgrhgPsg6Z/EfhWI4gl1Hwq8B/GmY/0oXZ6nF8hDVesS/FpnYaD/kOWhYQvyg==" + } + } + }, + "just-extend": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/just-extend/-/just-extend-4.0.2.tgz", + "integrity": "sha512-FrLwOgm+iXrPV+5zDU6Jqu4gCRXbWEQg2O3SKONsWE4w7AXFRkryS53bpWdaL9cNol+AmR3AEYz6kn+o0fCPnw==", + "dev": true + }, + "jwa": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.2.0.tgz", + "integrity": "sha512-Grku9ZST5NNQ3hqNUodSkDfEBqAmGA1R8yiyPHOnLzEKI0GaCQC/XhFmsheXYuXzFQJdILbh+lYBiliqG5R/Vg==", + "requires": { + "buffer-equal-constant-time": "1.0.1", + "ecdsa-sig-formatter": "1.0.10", + "safe-buffer": "^5.0.1" + } + }, + "jws": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.1.tgz", + "integrity": "sha512-bGA2omSrFUkd72dhh05bIAN832znP4wOU3lfuXtRBuGTbsmNmDXMQg28f0Vsxaxgk4myF5YkKQpz6qeRpMgX9g==", + "requires": { + "jwa": "^1.2.0", + "safe-buffer": "^5.0.1" + } + }, "kind-of": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.2.tgz", @@ -3770,12 +3886,53 @@ "integrity": "sha1-+wMJF/hqMTTlvJvsDWngAT3f7bI=", "dev": true }, + "lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha1-LRd/ZS+jHpObRDjVNBSZ36OCXpk=", + "dev": true + }, + "lodash.includes": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz", + "integrity": "sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=" + }, + "lodash.isboolean": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz", + "integrity": "sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=" + }, + "lodash.isinteger": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/lodash.isinteger/-/lodash.isinteger-4.0.4.tgz", + "integrity": "sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=" + }, + "lodash.isnumber": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/lodash.isnumber/-/lodash.isnumber-3.0.3.tgz", + "integrity": "sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=" + }, + "lodash.isplainobject": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", + "integrity": "sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=" + }, + "lodash.isstring": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz", + "integrity": "sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=" + }, "lodash.merge": { "version": "4.6.1", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.1.tgz", "integrity": "sha512-AOYza4+Hf5z1/0Hztxpm2/xiPZgi/cjMqdnKTUWTBSKchJlxXXuUSxCCl8rJlf4g6yww/j6mA8nC8Hw/EZWxKQ==", "dev": true }, + "lodash.once": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/lodash.once/-/lodash.once-4.1.1.tgz", + "integrity": "sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=" + }, "log-symbols": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-2.2.0.tgz", @@ -3785,6 +3942,12 @@ "chalk": "^2.0.1" } }, + "lolex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-3.0.0.tgz", + "integrity": "sha512-hcnW80h3j2lbUfFdMArd5UPA/vxZJ+G8vobd+wg3nVEQA0EigStbYcrG030FJxL6xiDDPEkoMatV9xIh5OecQQ==", + "dev": true + }, "loose-envify": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.3.1.tgz", @@ -4012,6 +4175,11 @@ "minimist": "0.0.8" } }, + "module-not-found-error": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/module-not-found-error/-/module-not-found-error-1.0.1.tgz", + "integrity": "sha1-z4tP9PKWQGdNbN0CsOO8UjwrvcA=" + }, "moment": { "version": "2.22.2", "resolved": "https://registry.npmjs.org/moment/-/moment-2.22.2.tgz", @@ -4083,6 +4251,42 @@ "integrity": "sha512-2NpiFHqC87y/zFke0fC0spBXL3bBsoh/p5H1EFhshxjCR5+0g2d6BiXbUFz9v1sAcxsk2htp2eQnNIci2dIYcA==", "dev": true }, + "nise": { + "version": "1.4.8", + "resolved": "https://registry.npmjs.org/nise/-/nise-1.4.8.tgz", + "integrity": "sha512-kGASVhuL4tlAV0tvA34yJYZIVihrUt/5bDwpp4tTluigxUr2bBlJeDXmivb6NuEdFkqvdv/Ybb9dm16PSKUhtw==", + "dev": true, + "requires": { + "@sinonjs/formatio": "^3.1.0", + "just-extend": "^4.0.2", + "lolex": "^2.3.2", + "path-to-regexp": "^1.7.0", + "text-encoding": "^0.6.4" + }, + "dependencies": { + "isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha1-ihis/Kmo9Bd+Cav8YDiTmwXR7t8=", + "dev": true + }, + "lolex": { + "version": "2.7.5", + "resolved": "https://registry.npmjs.org/lolex/-/lolex-2.7.5.tgz", + "integrity": "sha512-l9x0+1offnKKIzYVjyXU2SiwhXDLekRzKyhnbyldPHvC7BvLPVpdNUNR2KeMAiCN2D/kLNttZgQD5WjSxuBx3Q==", + "dev": true + }, + "path-to-regexp": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.7.0.tgz", + "integrity": "sha1-Wf3g9DW62suhA6hOnTvGTpa5k30=", + "dev": true, + "requires": { + "isarray": "0.0.1" + } + } + } + }, "nodemon": { "version": "1.17.5", "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-1.17.5.tgz", @@ -4410,8 +4614,7 @@ "path-parse": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.5.tgz", - "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=", - "dev": true + "integrity": "sha1-PBrfhx6pzWyUMbbqK9dKD/BVxME=" }, "path-to-regexp": { "version": "0.1.7", @@ -4539,6 +4742,16 @@ "ipaddr.js": "1.6.0" } }, + "proxyquire": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxyquire/-/proxyquire-2.1.0.tgz", + "integrity": "sha512-kptdFArCfGRtQFv3Qwjr10lwbEV0TBJYvfqzhwucyfEXqVgmnAkyEw/S3FYzR5HI9i5QOq4rcqQjZ6AlknlCDQ==", + "requires": { + "fill-keys": "^1.0.2", + "module-not-found-error": "^1.0.0", + "resolve": "~1.8.1" + } + }, "ps-tree": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/ps-tree/-/ps-tree-1.1.0.tgz", @@ -4825,7 +5038,6 @@ "version": "1.8.1", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.8.1.tgz", "integrity": "sha512-AicPrAC7Qu1JxPCZ9ZgCZlY35QgFnNqc+0LtbRNxnVw4TXvjQ72wnuL9JQcEBgXkI9JM8MsT9kaQoHcpCRJOYA==", - "dev": true, "requires": { "path-parse": "^1.0.5" } @@ -5043,6 +5255,32 @@ "integrity": "sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0=", "dev": true }, + "sinon": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/sinon/-/sinon-7.2.3.tgz", + "integrity": "sha512-i6j7sqcLEqTYqUcMV327waI745VASvYuSuQMCjbAwlpAeuCgKZ3LtrjDxAbu+GjNQR0FEDpywtwGCIh8GicNyg==", + "dev": true, + "requires": { + "@sinonjs/commons": "^1.3.0", + "@sinonjs/formatio": "^3.1.0", + "@sinonjs/samsam": "^3.0.2", + "diff": "^3.5.0", + "lolex": "^3.0.0", + "nise": "^1.4.8", + "supports-color": "^5.5.0" + }, + "dependencies": { + "supports-color": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", + "integrity": "sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==", + "dev": true, + "requires": { + "has-flag": "^3.0.0" + } + } + } + }, "slash": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-2.0.0.tgz", @@ -5445,6 +5683,12 @@ "execa": "^0.7.0" } }, + "text-encoding": { + "version": "0.6.4", + "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz", + "integrity": "sha1-45mpgiV6J22uQou5KEXLcb3CbRk=", + "dev": true + }, "text-table": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/text-table/-/text-table-0.2.0.tgz", @@ -5562,6 +5806,12 @@ "prelude-ls": "~1.1.2" } }, + "type-detect": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", + "integrity": "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g==", + "dev": true + }, "type-is": { "version": "1.6.16", "resolved": "https://registry.npmjs.org/type-is/-/type-is-1.6.16.tgz", diff --git a/package.json b/package.json index d21b893..b3bd305 100755 --- a/package.json +++ b/package.json @@ -25,14 +25,17 @@ "babel-eslint": "^8.2.3", "eslint": "^5.0.0", "husky": "^0.14.3", - "nodemon": "^1.17.5" + "nodemon": "^1.17.5", + "sinon": "^7.2.3" }, "dependencies": { + "axios": "^0.18.0", "body-parser": "^1.17.2", "esm": "^3.0.52", "express": "^4.15.3", + "jsonwebtoken": "^8.4.0", "moment": "^2.19.4", - "axios": "^0.18.0" + "proxyquire": "^2.1.0" }, "ava": { "require": [ diff --git a/src/auth/githubApp.js b/src/auth/githubApp.js new file mode 100644 index 0000000..9e970d8 --- /dev/null +++ b/src/auth/githubApp.js @@ -0,0 +1,17 @@ + +import jwt from 'jsonwebtoken' +import moment from 'moment' +import fs from 'fs' + +const PRIVATE_KEY = fs.readFileSync('/run/secrets/private-key', 'utf8') + +const auth = () => { + const options = { + iss: process.env.APP_IDENTIFIER, + iat: moment().unix() , + exp: moment().add(10, 'minutes').unix() + } + return jwt.sign(options, PRIVATE_KEY, { algorithm: 'RS256' }) +} + +export { auth } \ No newline at end of file diff --git a/src/auth/installation.js b/src/auth/installation.js new file mode 100644 index 0000000..4e7aa1e --- /dev/null +++ b/src/auth/installation.js @@ -0,0 +1,19 @@ +import axios from 'axios' + +const auth = async (req, jwt) => { + + const { body: { installation: { id } } } = req + const options = { + url: `https://api.github.com/app/installations/${id}/access_tokens`, + headers: { + Authorization: 'Bearer ' + jwt, + Accept: 'application/vnd.github.machine-man-preview+json' + }, + method: 'POST' + } + const { data: { token } } = await axios(options) + + return token +} + +export { auth } \ No newline at end of file diff --git a/src/helpers/auth.js b/src/auth/webhook.js similarity index 97% rename from src/helpers/auth.js rename to src/auth/webhook.js index f6e4da5..2dfccfe 100755 --- a/src/helpers/auth.js +++ b/src/auth/webhook.js @@ -38,4 +38,4 @@ const auth = req => { } } -export default auth +export { auth } diff --git a/src/helpers/pr.js b/src/helpers/pr.js index b5f3a70..c8672ce 100755 --- a/src/helpers/pr.js +++ b/src/helpers/pr.js @@ -1,193 +1,207 @@ import axios from 'axios' import moment from 'moment' -// github needs a user agent in the request, setting as app name -const userAgent = { - 'User-Agent': process.env.APP_NAME -} - -/** - * Compares the merged time to the current time and determines if the pr was - * merged too long ago. PRs should be merged within 5 minutes of a webhook - * event in order to be added to the release draft. Prevents duplicates if - * multiple webhook events are sent for older PRs. - * - * @param {String} time pr merge timestamp - */ -export const isTooOld = time => { - const now = moment() - const mergedAt = moment(time) - const diff = now.diff(mergedAt, 'minutes') - - return diff > 5 -} -/** - * Create a pull request object that includes repository url - * @param {Object} param0 webhook event data payload - * - * @returns {Object} - */ -export const getPrData = ({ pull_request, repository }) => ({ - ...pull_request, - repoUrl: repository.url -}) - -/** - * Get the url for a specific release from ID - * @param {Object} pr webhookpull request object - * @param {Object} release github release object - * - * @returns {String} - */ -export const getSingleReleaseUrl = (pr, release) => - `${pr.repoUrl}/releases/${release.id}?access_token=${process.env.GH_TOKEN}` - -/** - * Get the releases url for the github repo passed - * @param {String} pull_request pull request object - * - * @returns {String} - */ -export const getReleasesUrl = pr => - `${pr.repoUrl}/releases?access_token=${process.env.GH_TOKEN}` - -/** - * Get the formatted pull request description to add to the release draft. - * @param {Object} pull_request pull request object from webhook event data - * - * @returns {String} - */ -export const getPrDesc = ({ number, title }) => `- ${title} (#${number})` - -/** - * Update the existing release draft with the new pull request - * @param {Object} pr webhook pull request object - * @param {Object} release github release object - * - * @returns {String} - */ -export const updateReleaseDraft = (pr, release) => - `${release.body}\n${getPrDesc(pr)}` - -/** - * Make a request to github to edit and existing release draft - * @param {Object} release github release object - * @param {Object} pr webhook pull request object - */ -export const editReleaseDraft = (release, pr) => { - const options = { - method: 'PATCH', - url: getSingleReleaseUrl(pr, release), - headers: userAgent, - data: { - body: updateReleaseDraft(pr, release) // setting to the updated body with new line +class WebhookHandler { + constructor(token) { + this.token = token + // github needs a user agent in the request, setting as app name + this.userAgent = { + 'User-Agent': process.env.APP_NAME } } - // make PATCH request to create new release - return axios(options) - .then(result => { - if (result.status !== 200) { - return Promise.reject(result.data.message) - } - }) - .catch(error => { - return { - error: `${error.response.status} Could not edit release draft: ${ - error.response.data.message - }` - } - }) -} -/** - * Create a new release draft using the pull request data - * @param {Object} pr webhook pull request object - */ -export const createReleaseDraft = pr => { - // line item formatted as "PR_Title (#PR_number)" - const newRelease = { - name: 'NEXT RELEASE', - draft: true, // set to true so it doesn't auto publish, - prerelease: false, - body: getPrDesc(pr), - tag_name: 'UNTAGGED' + /** + * Compares the merged time to the current time and determines if the pr was + * merged too long ago. PRs should be merged within 5 minutes of a webhook + * event in order to be added to the release draft. Prevents duplicates if + * multiple webhook events are sent for older PRs. + * + * @param {String} time pr merge timestamp + */ + isTooOld (time) { + const now = moment() + const mergedAt = moment(time) + const diff = now.diff(mergedAt, 'minutes') + + return diff > 5 } - const options = { - method: 'POST', - url: getReleasesUrl(pr), - headers: userAgent, - data: newRelease + /** + * Create a pull request object that includes repository url + * @param {Object} param0 webhook event data payload + * + * @returns {Object} + */ + getPrData ({ pull_request, repository }) { + return { ...pull_request, repoUrl: repository.url } + } + + /** + * Get the url for a specific release from ID + * @param {Object} pr webhookpull request object + * @param {Object} release github release object + * + * @returns {String} + */ + getSingleReleaseUrl (pr, release) { + return `${pr.repoUrl}/releases/${release.id}?access_token=${this.token}` } - return axios(options) - .then(result => { - if (result.status !== 201) { - return Promise.reject(result.data.message) - } - }) - .catch(error => { - return { - error: `${error.response.status} Could not create release draft: ${ - error.response.data.message - }` - } - }) -} -/** - * Handle the releases endpoint response by either creating a new release draft - * if no draft exists or editing the existing draft. - * - * @param {Object} response releases response object - * @param {Object} pr github pull request object - */ -export const handleReleasesResponse = (response, pr) => { - const { data } = response - - // the first item in the data should be the most recent release - const release = data.length ? data[0] : null - - // if there's a release draft, append the line item - if (release && release.draft) { - return editReleaseDraft(release, pr).then(result => result) + /** + * Get the releases url for the github repo passed + * @param {String} pull_request pull request object + * + * @returns {String} + */ + getReleasesUrl (pr) { + return `${pr.repoUrl}/releases?access_token=${this.token}` } - // if there are no releases or the release is not a draft, create a new draft - if (!release || (release && !release.draft)) { - return createReleaseDraft(pr).then(result => result) + /** + * Get the formatted pull request description to add to the release draft. + * @param {Object} pull_request pull request object from webhook event data + * + * @returns {String} + */ + getPrDesc ({ number, title }) { + return `- ${title} (#${number})` } -} -export const handleWebhookEvent = webhookData => { - const pr = getPrData(webhookData) + /** + * Update the existing release draft with the new pull request + * @param {Object} pr webhook pull request object + * @param {Object} release github release object + * + * @returns {String} + */ + updateReleaseDraft (pr, release) { + return `${release.body}\n${this.getPrDesc(pr)}` + } - if (pr.merged && !isTooOld(pr.merged_at)) { - // release request options + /** + * Make a request to github to edit and existing release draft + * @param {Object} release github release object + * @param {Object} pr webhook pull request object + */ + editReleaseDraft (release, pr) { const options = { - method: 'GET', - url: getReleasesUrl(pr), - headers: userAgent + method: 'PATCH', + url: this.getSingleReleaseUrl(pr, release), + headers: this.userAgent, + data: { + body: this.updateReleaseDraft(pr, release) // setting to the updated body with new line + } } - // make request to releases endpoint + // make PATCH request to create new release return axios(options) - .then(response => handleReleasesResponse(response, pr)) - .then(results => results) - .catch(err => { + .then(result => { + if (result.status !== 200) { + return Promise.reject(result.data.message) + } + }) + .catch(error => { return { - error: `${ - err.response.status - } Request to GitHub releases endpoint failed. ${ - err.response.data.message + error: `${error.response.status} Could not edit release draft: ${ + error.response.data.message }` } }) } - return Promise.resolve(true) + /** + * Create a new release draft using the pull request data + * @param {Object} pr webhook pull request object + */ + createReleaseDraft (pr) { + // line item formatted as "PR_Title (#PR_number)" + const newRelease = { + name: 'NEXT RELEASE', + draft: true, // set to true so it doesn't auto publish, + prerelease: false, + body: this.getPrDesc(pr), + tag_name: 'UNTAGGED' + } + + const options = { + method: 'POST', + url: this.getReleasesUrl(pr), + headers: this.userAgent, + data: newRelease + } + + return axios(options) + .then(result => { + if (result.status !== 201) { + return Promise.reject(result.data.message) + } + }) + .catch(error => { + return { + error: `${error.response.status} Could not create release draft: ${ + error.response.data.message + }` + } + }) + } + + /** + * Handle the releases endpoint response by either creating a new release draft + * if no draft exists or editing the existing draft. + * + * @param {Object} response releases response object + * @param {Object} pr github pull request object + */ + handleReleasesResponse (response, pr) { + const { + data + } = response + + // the first item in the data should be the most recent release + const release = data.length ? data[0] : null + + // if there's a release draft, append the line item + if (release && release.draft) { + return this.editReleaseDraft(release, pr).then(result => result) + } + + // if there are no releases or the release is not a draft, create a new draft + if (!release || (release && !release.draft)) { + return this.createReleaseDraft(pr).then(result => result) + } + } + + handleWebhookEvent (webhookData) { + const pr = this.getPrData(webhookData) + + if (pr.merged && !this.isTooOld(pr.merged_at)) { + // release request options + const options = { + method: 'GET', + url: this.getReleasesUrl(pr), + headers: this.userAgent + } + + // make request to releases endpoint + return axios(options) + .then(response => this.handleReleasesResponse(response, pr)) + .then(results => results) + .catch(err => { + return { + error: `${ + err.response.status + } Request to GitHub releases endpoint failed. ${ + err.response.data.message + }` + } + }) + } + + return Promise.resolve(true) + } } -export default handleWebhookEvent +export default WebhookHandler diff --git a/src/main.js b/src/main.js index f69a9be..47ad0f4 100755 --- a/src/main.js +++ b/src/main.js @@ -1,10 +1,12 @@ import express from 'express' import bodyParser from 'body-parser' -import auth from './helpers/auth' -import handleWebhookEvent from './helpers/pr' +import * as webhook from './auth/webhook' +import * as githubApp from './auth/githubApp' +import * as installation from './auth/installation' +import WebhookHandler from './helpers/pr' const app = express() -const PORT = process.env.NODE_PORT || 8080 +const PORT = process.env.PORT || 8080 app.use(bodyParser.json()) @@ -13,14 +15,21 @@ app.get('/ping', (req, res) => { return res.status(200).send('OK') }) -app.post('/webhooks', (req, res) => { +app.post('/webhooks', async (req, res) => { // authenticate request - const authentication = auth(req) + const authentication = webhook.auth(req) if (authentication.error) { return res.status(authentication.error).send(authentication) } - - handleWebhookEvent(req.body) + let token + if (process.env.AUTH_AS_APP) { + const appJwt = githubApp.auth() + token = await installation.auth(req, appJwt) + } else { + token = process.env.GH_TOKEN + } + const handler = new WebhookHandler(token) + return handler.handleWebhookEvent(req.body) .then(result => { if (result && result.error) { return Promise.reject(result.error)