├── .editorconfig ├── .eslintrc ├── .gitignore ├── .prettierrc.js ├── .travis.yml ├── CHANGELOG.md ├── README.md ├── config ├── config.html ├── config.js └── config.less ├── lib ├── api.js ├── github.js ├── index.js ├── utils.js ├── webapp.js ├── webhooks.js └── worker.js ├── package.json ├── test ├── json.js ├── mocks │ └── setup_nock_repos.js ├── sample_commit.json ├── sample_commit_payload.json ├── sample_issue_comment.json ├── sample_pull_request.json ├── test_api.js └── test_webhooks.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | # Strider Editor/IDE Settings 2 | # This file is used to promote consistent source code standards 3 | # amongst all Strider-CD contributors. 4 | # More information can be found here: http://editorconfig.org/ 5 | 6 | # General Settings 7 | root = true 8 | 9 | # Settings for all files 10 | [*] 11 | indent_style = space 12 | indent_size = 2 13 | end_of_line = lf 14 | charset = utf-8 15 | trim_trailing_whitespace = true 16 | insert_final_newline = true 17 | 18 | [*.hbs] 19 | insert_final_newline = false 20 | 21 | [*.{diff,md}] 22 | trim_trailing_whitespace = false 23 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parserOptions": { 3 | "ecmaVersion": 6, 4 | "sourceType": "script" 5 | }, 6 | "env": { 7 | "node": true, 8 | "es6": true 9 | }, 10 | "extends": "eslint:recommended", 11 | "rules": { 12 | "indent": [2, 2], 13 | "brace-style": [2, "1tbs"], 14 | "semi": [2, "always"], 15 | "comma-style": [2, "last"], 16 | "one-var": [2, "never"], 17 | "strict": [2, "global"], 18 | "prefer-template": 2, 19 | "no-console": 0, 20 | "no-use-before-define": [2, "nofunc"], 21 | "no-underscore-dangle": 0, 22 | "no-constant-condition": 0, 23 | "space-before-function-paren": [ 24 | 2, 25 | { "anonymous": "always", "named": "never" } 26 | ], 27 | "func-style": [2, "declaration"] 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | *~ 3 | .DS_Store 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /.prettierrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | singleQuote: true 3 | }; 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - '10' 4 | - 'node' 5 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines. 4 | 5 | ### [3.0.4](https://github.com/Strider-CD/strider-github/compare/v3.0.3...v3.0.4) (2020-07-04) 6 | 7 | 8 | ### Bug Fixes 9 | 10 | * update strider-git to v2 ([32b325d](https://github.com/Strider-CD/strider-github/commit/32b325d2b9866c679a837acea5e6ff29cb4d6180)) 11 | 12 | ### [3.0.3](https://github.com/Strider-CD/strider-github/compare/v3.0.2...v3.0.3) (2020-07-02) 13 | 14 | 15 | ### Bug Fixes 16 | 17 | * getting repos ([a0a9007](https://github.com/Strider-CD/strider-github/commit/a0a9007b9d340ff09aabf9647fa830e0e6a8f083)) 18 | 19 | ### [3.0.2](https://github.com/Strider-CD/strider-github/compare/v3.0.1...v3.0.2) (2020-07-02) 20 | 21 | 22 | ### Bug Fixes 23 | 24 | * return after callback ([6dd9395](https://github.com/Strider-CD/strider-github/commit/6dd93959bfd54c29c6f033ca0ed4f70cc473fda6)) 25 | 26 | ### [3.0.1](https://github.com/Strider-CD/strider-github/compare/v3.0.0...v3.0.1) (2020-07-02) 27 | 28 | 29 | ### Bug Fixes 30 | 31 | * update accesstoken on reauth ([02ee9d6](https://github.com/Strider-CD/strider-github/commit/02ee9d65804744b9930c18a47ce35cd928725fa0)) 32 | * update deps and use yarn ([80def15](https://github.com/Strider-CD/strider-github/commit/80def1508a880290d63a0189b7044c6b7c79024f)) 33 | 34 | ## [3.0.0](https://github.com/Strider-CD/strider-github/compare/v2.4.1...v3.0.0) (2020-02-10) 35 | 36 | ### ⚠ BREAKING CHANGES 37 | 38 | - node version required is now 10 39 | 40 | ### Bug Fixes 41 | 42 | - update node version to 10 ([6f970af](https://github.com/Strider-CD/strider-github/commit/6f970afe7c309f5d63f9cf1ad38eb3220d3e315a)) 43 | 44 | ### [2.4.1](https://github.com/Strider-CD/strider-github/compare/v1.0.0...v2.4.1) (2020-02-10) 45 | 46 | ### Bug Fixes 47 | 48 | - accesstoken typo and tests, update deps ([ba6febd](https://github.com/Strider-CD/strider-github/commit/ba6febd05898b979e0b8869341b36c6b96261b24)) 49 | - fix lodash in github.js ([19a1e86](https://github.com/Strider-CD/strider-github/commit/19a1e8646cccfb61c76b852b53c1847f9a145126)) 50 | - handle deleted flag for tags/branches ([19a0704](https://github.com/Strider-CD/strider-github/commit/19a07048c716ec97ca1d8f9526543a55e24dd14e)) 51 | - lodash fix in index.js ([fdc8939](https://github.com/Strider-CD/strider-github/commit/fdc893910ad959c498a7a0cea47eab096727e55a)) 52 | - set email without check, since it's checked above ([1b5a7b5](https://github.com/Strider-CD/strider-github/commit/1b5a7b5fa621df7fb9674faa7daf9cfbd1286230)) 53 | - typo in github.js ([05072af](https://github.com/Strider-CD/strider-github/commit/05072af4a12bc87dedae569149dbdb505fb6660f)) 54 | - update to use authorization header ([e2e0189](https://github.com/Strider-CD/strider-github/commit/e2e018929004f4a540358d3ebe320dd054183fa5)) 55 | - update usage of lodash in api.js ([32406b2](https://github.com/Strider-CD/strider-github/commit/32406b2043422dbb5932f5330083e62092c565ce)) 56 | - update usage of superagent in webhooks ([b6e98b3](https://github.com/Strider-CD/strider-github/commit/b6e98b33b9ba5e9e156df370b43ddb22142749d6)) 57 | - update vulnerable deps ([779d54d](https://github.com/Strider-CD/strider-github/commit/779d54d4e49ad0656029414fab5101ee2db8a49c)) 58 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # strider-github 2 | 3 | A provider for strider that integrates with GitHub to provide easy setup of 4 | your projects. It registers webhooks and sets up ssh keys (if you so choose). 5 | 6 | [![NPM][npm-badge-img]][npm-badge-link] 7 | [![Build Status](https://travis-ci.org/Strider-CD/strider-github.svg)](https://travis-ci.org/Strider-CD/strider-github) 8 | 9 | Note: Supports using '[skip ci]' in your commit message to skip commits triggering a job. 10 | 11 | ## Required Configuration 12 | 13 | If you are running on `localhost:3000` the default settings should work just fine. 14 | 15 | ### Environment Variables 16 | 17 | **`SERVER_NAME`** The url of your strider server. Defaults to `http://localhost:3000`. 18 | 19 | The following variables only need to be overridden if you are using github enterprise. See 'Enterprise Setup' below. 20 | 21 | **`PLUGIN_GITHUB_APP_ID`** Defaults to client ID of Strider-CD Github App 22 | 23 | **`PLUGIN_GITHUB_APP_SECRET`** Defaults to client secret of Strider-CD Github App 24 | 25 | **`PLUGIN_GITHUB_API_DOMAIN`** Defaults to `https://github.com` 26 | 27 | **`PLUGIN_GITHUB_API_ENDPOINT`** Defaults to `https://api.github.com` 28 | 29 | ### Enterprise Setup 30 | 31 | 1. You'll need to create an Application on your GitHub Enterprise Server. Log in to GitHub Enterprise and navigate to 32 | `https://your-github-url.com/settings/applications/new` and set authentication URL to 33 | `https://your-strider-server:port/auth/github/callback`. 34 | 2. Define the environment variables. Here is an example: 35 | 36 | ```shell 37 | export SERVER_NAME="http://111.11.11.111:3000" 38 | export PLUGIN_GITHUB_APP_ID="a342d32c23c23" 39 | export PLUGIN_GITHUB_APP_SECRET="5af64a67af586847afbc6796769769d97a961" 40 | export PLUGIN_GITHUB_API_DOMAIN="https://github.my-organization.com" 41 | export PLUGIN_GITHUB_API_ENDPOINT="https://github.my-organization.com/api/v3" 42 | ``` 43 | 44 | **NOTE** `SERVER_NAME` must be the same exact host that you used for the 'Authentication URL' in step 1. For example, 45 | if you used `http://111.11.11.111:3000/auth/guthub/callback` in step 1, your `SERVER_NAME` **must** be 46 | `http://111.11.11.111:3000`. Also note that the protocol must be the same between the two (if you used `http://` 47 | in step 1, you must use `http://` in `SERVER_NAME` and not `https://`). 48 | 49 | 3. Reboot strider and navigate link a github account as normal, you should see your enterprise repos! 50 | 51 | #### Known Issues with Enterprise 52 | 53 | - If you get 'Error: Could not fetch user profile': Somehow, passport will fail to retrieve the user profile unless all 54 | of the following are set. On GitHub Enterprise, log in to the profile you are trying to link to, and navigate to 55 | `/settings/profile`. Make sure the following are defined and set properly. 56 | - Public Email 57 | - Homepage URL 58 | 59 | #### Known Issues with GitHub.com 60 | 61 | - Make sure your github profile has a public email set 62 | - Go to https://github.com/settings/profile and select an email under "Public email". 63 | - Make sure you have admin rights on the projects before adding them, 64 | since strider will need to create webhooks for the integration to work. 65 | 66 | ## Local Development 67 | 68 | Due to the fact that Github posts to the Strider app when there is an event (commit, PR, etc) 69 | it is very difficult to test all of the functionality when developing/fixing bugs locally. 70 | An alternative is to use something like [localtunnel]. 71 | 72 | ```sh 73 | $ npm install -g localtunnel 74 | $ lt --port 75 | ``` 76 | 77 | [npm-badge-img]: https://badge.fury.io/js/strider-github.svg 78 | [npm-badge-link]: http://badge.fury.io/js/strider-github 79 | [localtunnel]: https://localtunnel.github.io/www/ 80 | -------------------------------------------------------------------------------- /config/config.html: -------------------------------------------------------------------------------- 1 |
2 |
3 |
4 |

Caching

5 | 9 |
10 | 11 |
12 |

Test On Releases

13 | 17 |
18 | 19 |

Pull Requests

20 |

21 | If you have the service hooks installed, you can also choose to test pull 22 | requests. 23 |

24 |
25 |
26 | All 27 |
28 | 29 |
30 | None 31 |
32 |
33 |

34 | If your project is public, and your runner is insecure (such as the 35 | default runner), selecting All is potentially dangerous. 36 | A malicious pull request could take down your server, compromise data, 37 | etc. 38 | 40 | If you want to test pull-requests, use a secure runner, such as 41 | docker-runner. 46 |

47 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 68 | 74 | 82 | 83 | 84 |
Github UsernameRights
[[ user.name ]][[ user.level ]]
×
66 | 67 | 69 | 73 | 75 | 81 |
85 |
86 |
87 |

Service Hooks

88 |

89 | Service Hooks are the means by which Github notifies Strider of new 90 | commits and pull requests to your repository. 91 |

92 |

93 | We already registered the requisite hooks when you added this project, but 94 | if you want to remove them or add them back, you can do so here. 95 |

96 | 103 | 106 | 107 |
108 |
109 | -------------------------------------------------------------------------------- /config/config.js: -------------------------------------------------------------------------------- 1 | app.controller('GithubCtrl', [ 2 | '$scope', 3 | function ($scope) { 4 | $scope.config = $scope.providerConfig(); 5 | $scope.new_username = ''; 6 | $scope.new_level = 'tester'; 7 | $scope.config.whitelist = $scope.config.whitelist || []; 8 | $scope.config.pull_requests = $scope.config.pull_requests || 'none'; 9 | 10 | $scope.save = function () { 11 | $scope.providerConfig($scope.config, function () {}); 12 | }; 13 | 14 | $scope.$watch('config.pull_requests', function (value, old) { 15 | if (!old || value === old) return; 16 | $scope.providerConfig({ 17 | pull_requests: $scope.config.pull_requests, 18 | }); 19 | }); 20 | 21 | $scope.addWebhooks = function () { 22 | $scope.loadingWebhooks = true; 23 | $.ajax($scope.api_root + 'github/hook', { 24 | type: 'POST', 25 | success: function () { 26 | $scope.loadingWebhooks = false; 27 | $scope.success('Set github webhooks', true); 28 | }, 29 | error: function () { 30 | $scope.loadingWebhooks = false; 31 | $scope.error('Failed to set github webhooks', true); 32 | }, 33 | }); 34 | }; 35 | 36 | $scope.deleteWebhooks = function () { 37 | $scope.loadingWebhooks = true; 38 | $.ajax($scope.api_root + 'github/hook', { 39 | type: 'DELETE', 40 | success: function () { 41 | $scope.loadingWebhooks = false; 42 | $scope.success('Removed github webhooks', true); 43 | }, 44 | error: function () { 45 | $scope.loadingWebhooks = false; 46 | $scope.error('Failed to remove github webhooks', true); 47 | }, 48 | }); 49 | }; 50 | 51 | $scope.removeWL = function (user) { 52 | var idx = $scope.config.whitelist.indexOf(user); 53 | if (idx === -1) 54 | return console.error( 55 | "tried to remove a whitelist item that didn't exist" 56 | ); 57 | var whitelist = $scope.config.whitelist.slice(); 58 | whitelist.splice(idx, 1); 59 | $scope.providerConfig( 60 | { 61 | whitelist: whitelist, 62 | }, 63 | function () { 64 | $scope.config.whitelist = whitelist; 65 | } 66 | ); 67 | }; 68 | 69 | $scope.addWL = function (user) { 70 | if (!user.name || !user.level) return; 71 | var whitelist = $scope.config.whitelist.slice(); 72 | whitelist.push(user); 73 | $scope.providerConfig( 74 | { 75 | whitelist: whitelist, 76 | }, 77 | function () { 78 | $scope.config.whitelist = whitelist; 79 | } 80 | ); 81 | }; 82 | }, 83 | ]); 84 | -------------------------------------------------------------------------------- /config/config.less: -------------------------------------------------------------------------------- 1 | #provider-config { 2 | table { 3 | select, 4 | input { 5 | width: 100%; 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /lib/api.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | var async = require('async'); 5 | var debug = require('debug')('strider-github:api'); 6 | var Step = require('step'); 7 | var superagent = require('superagent'); 8 | var url = require('url'); 9 | var util = require('util'); 10 | 11 | // todo this should use the `appConfig` object from ./webapp.js, but requiring it here would create a cyclical reference 12 | var GITHUB_API_ENDPOINT = 13 | process.env.PLUGIN_GITHUB_API_ENDPOINT || 'https://api.github.com'; 14 | 15 | module.exports = { 16 | getRepos: getRepos, 17 | getFile: getFile, 18 | getBranches: getBranches, 19 | createHooks: createHooks, 20 | deleteHooks: deleteHooks, 21 | get_oauth2: get_oauth2, 22 | api_call: api_call, 23 | parse_link_header: parse_link_header, 24 | pageinated_api_call: pageinated_api_call, 25 | }; 26 | 27 | /** 28 | * set_push_hook() 29 | * 30 | * Set a push hook via the Github API for the supplied repository. Must have admin privileges for this to work. 31 | * 32 | * @param {String} reponame is "/" e.g. "BeyondFog/Strider". 33 | * @param {String} url is the URL for the webhook to post to. 34 | * @param {String} secret is the Webhook secret, which will be used to generate the HMAC-SHA1 header in the Github request. 35 | * @param {String} token OAuth2 access token 36 | * @param {Function} callback function(error) 37 | */ 38 | function createHooks(reponame, url, secret, token, callback) { 39 | var post_url = `${GITHUB_API_ENDPOINT}/repos/${reponame}/hooks`; 40 | debug('CREATE WEBHOOK URL:', post_url, url); 41 | superagent 42 | .post(post_url) 43 | .send({ 44 | name: 'web', 45 | active: true, 46 | events: ['push', 'pull_request', 'issue_comment'], 47 | config: { 48 | url: url, 49 | secret: secret, 50 | }, 51 | }) 52 | .set('Authorization', `token ${token}`) 53 | .set('User-Agent', 'StriderCD (http://stridercd.com)') 54 | .end(function (err, res) { 55 | if (err) return callback(err); 56 | 57 | var badStatusErr; 58 | if (res.statusCode === 404) { 59 | badStatusErr = new Error( 60 | `Cannot create webhooks; are you sure you have admin rights?\nFeel free to manually create a webhook for ${url}` 61 | ); 62 | badStatusErr.statusCode = res.statusCode; 63 | return callback(badStatusErr); 64 | } else if (res.statusCode !== 201) { 65 | badStatusErr = new Error(`Bad status code: ${res.statusCode}`); 66 | badStatusErr.statusCode = res.statusCode; 67 | return callback(badStatusErr); 68 | } 69 | callback(null, true); 70 | }); 71 | } 72 | 73 | /** 74 | * unset_push_hook() 75 | * 76 | * Delete push hook via the Github API for the supplied repository. Must have admin privileges for this to work. 77 | * 78 | * @param {String} reponame is "/" e.g. "BeyondFog/Strider". 79 | * @param {String} url The url to match 80 | * @param {String} token OAuth2 access token 81 | * @param {Function} callback function(error, response, body) 82 | */ 83 | function deleteHooks(reponame, url, token, callback) { 84 | var apiUrl = `${GITHUB_API_ENDPOINT}/repos/${reponame}/hooks`; 85 | debug(`Delete hooks for ${reponame}, identified by ${url}`); 86 | superagent 87 | .get(apiUrl) 88 | .set('Authorization', `token ${token}`) 89 | .set('User-Agent', 'StriderCD (http://stridercd.com)') 90 | .end(function (err, res) { 91 | if (err) return callback(err); 92 | if (res.status > 300) { 93 | debug('Error getting hooks', res.status, res.text); 94 | return callback(res.status); 95 | } 96 | var hooks = []; 97 | debug('All hooks:', res.body.length); 98 | res.body.forEach(function (hook) { 99 | if (hook.config.url !== url) return; 100 | hooks.push(function (next) { 101 | superagent 102 | .del(hook.url) 103 | .set('Authorization', `token ${token}`) 104 | .set('User-Agent', 'StriderCD (http://stridercd.com)') 105 | .end(function (err, res) { 106 | if (err) 107 | return next( 108 | new Error(`Failed to delete webhook ${hook.url}Error: ${err}`) 109 | ); 110 | if (res.status !== 204) { 111 | debug('bad status', res.status, hook.id, hook.url); 112 | return next( 113 | new Error( 114 | `Failed to delete a webhook: status for url ${hook.url}: ${res.status}` 115 | ) 116 | ); 117 | } 118 | next(); 119 | }); 120 | }); 121 | }); 122 | debug('our hooks:', hooks.length); 123 | if (!hooks.length) return callback(null, false); 124 | async.parallel(hooks, function (err) { 125 | callback(err, true); 126 | }); 127 | }); 128 | } 129 | 130 | function getBranches(accessToken, owner, repo, done) { 131 | var path = `/repos/${owner}/${repo}/git/refs/heads`; 132 | pageinated_api_call(path, accessToken, function (err, res) { 133 | var branches = []; 134 | if (res && res.data) { 135 | branches = res.data.map(function (h) { 136 | return h.ref.replace('refs/heads/', ''); 137 | }); 138 | } 139 | done(err, branches); 140 | }); 141 | } 142 | 143 | function getFile(filename, ref, accessToken, owner, repo, done) { 144 | var uri = `${GITHUB_API_ENDPOINT}/repos/${owner}/${repo}/contents/${filename}`; 145 | var req = superagent 146 | .get(uri) 147 | .set('User-Agent', 'StriderCD (http://stridercd.com)'); 148 | if (ref) { 149 | req = req.query({ ref: ref }); 150 | } 151 | if (accessToken) { 152 | req = req.set('Authorization', `token ${accessToken}`); 153 | } 154 | req.end(function (err, res) { 155 | if (err) return done(err, null); 156 | if (res.error) return done(res.error, null); 157 | if (!res.body.content) { 158 | return done(); 159 | } 160 | done(null, new Buffer(res.body.content, 'base64').toString()); 161 | }); 162 | } 163 | 164 | /** 165 | * get_oauth2() 166 | * 167 | * Do a HTTP GET w/ OAuth2 token 168 | * @param {String} url URL to GET 169 | * @param {Object} params Object representing the query params to be added to GET request 170 | * @param {String} token OAuth2 access token 171 | * @param {Function} callback function(error, response, body) 172 | * @param {Object} client An alternative superagent instance to use. 173 | */ 174 | function get_oauth2(url, params, token, callback, client) { 175 | // If the user provided a superagent instance, use that. 176 | client = client || superagent; 177 | debug('GET OAUTH2 URL:', url); 178 | debug( 179 | `Inside get_oauth2: Callback type: ${typeof callback} Number of arguments expected by: ${ 180 | callback.length 181 | }` 182 | ); 183 | client 184 | .get(url) 185 | .query(params) 186 | .set('User-Agent', 'StriderCD (http://stridercd.com)') 187 | .set('Authorization', `token ${token}`) 188 | .end(callback); 189 | } 190 | 191 | /** 192 | * api_call() 193 | * 194 | * Simple HTTP GET Github API wrapper. 195 | * Makes it easy to call most read API calls. 196 | * @param {String} path API call URL path 197 | * @param {String} token OAuth2 access token 198 | * @param {Function} callback function(error, response, de-serialized json) 199 | * @param {Object} client An alternative superagent instance to use. 200 | */ 201 | function api_call(path, token, callback, client) { 202 | // If the user provided a superagent instance, use that. 203 | client = client || superagent; 204 | var url = 205 | path.startsWith('https://') || path.startsWith('http://') 206 | ? path 207 | : GITHUB_API_ENDPOINT + path; 208 | debug('API CALL:', url, token); 209 | get_oauth2( 210 | url, 211 | {}, 212 | token, 213 | function (error, res) { 214 | if (!error && res.statusCode == 200) { 215 | var data = res.body; 216 | callback(null, res, data); 217 | } else { 218 | debug(`We get an error from the API: ${error}`); 219 | callback(error, res, null); 220 | } 221 | }, 222 | client 223 | ); 224 | } 225 | 226 | /** 227 | * parse_link_header() 228 | * 229 | * Parse the Github Link HTTP header used for pagination 230 | * http://developer.github.com/v3/#pagination 231 | */ 232 | function parse_link_header(header) { 233 | if (header.length === 0) { 234 | throw new Error('input must not be of zero length'); 235 | } 236 | 237 | // Split parts by comma 238 | var parts = header.split(','); 239 | var links = {}; 240 | // Parse each part into a named link 241 | parts.forEach(function (p) { 242 | var section = p.split(';'); 243 | if (section.length != 2) { 244 | throw new Error("section could not be split on ';'"); 245 | } 246 | var url = section[0].replace(/<(.*)>/, '$1').trim(); 247 | var name = section[1].replace(/rel="(.*)"/, '$1').trim(); 248 | links[name] = url; 249 | }); 250 | 251 | return links; 252 | } 253 | 254 | /** 255 | * pageinated_api_call() 256 | * 257 | * Simple HTTP Get Github API wrapper with support for pagination via Link header. 258 | * See: http://developer.github.com/v3/#pagination 259 | * 260 | * @param {String} path API call URL path 261 | * @param {String} accessToken OAuth2 access token 262 | * @param {Function} callback function(error, response, de-serialized json) 263 | * @param {Object} client An alternative superagent instance to use. 264 | */ 265 | function pageinated_api_call(path, accessToken, callback, client) { 266 | // If the user provided a superagent instance, use that. 267 | client = client || superagent; 268 | 269 | let baseUrl = 270 | path.startsWith('https://') || path.startsWith('http://') 271 | ? path 272 | : GITHUB_API_ENDPOINT + path; 273 | 274 | if (!accessToken) { 275 | debug('Error in request - no access token'); 276 | debug(new Error().stack); 277 | } 278 | 279 | // This is a left fold, 280 | // a recursive function closed over an accumulator 281 | 282 | var pages = []; 283 | 284 | function loop(uri, page) { 285 | debug('PAGINATED API CALL URL:', uri); 286 | get_oauth2( 287 | uri, 288 | { per_page: 30, page: page }, 289 | accessToken, 290 | function (error, res) { 291 | if (!error && res.statusCode == 200) { 292 | var data; 293 | try { 294 | data = res.body; 295 | } catch (e) { 296 | return callback(e, null); 297 | } 298 | pages.push(data); 299 | 300 | var link = res.headers['link']; 301 | var r; 302 | if (link) { 303 | r = parse_link_header(link); 304 | } 305 | // Stop condition: No link header or we think we just read the last page 306 | if (!link || r.next === undefined) { 307 | callback(null, { data: _.flatten(pages), response: res }); 308 | } else { 309 | // Request next page and continue 310 | var next_page = url.parse(r.next, true).query.page; 311 | loop(baseUrl, next_page); 312 | } 313 | } else { 314 | if (!error) { 315 | debug( 316 | `We did not get an error, but status code was: ${res.statusCode}` 317 | ); 318 | if (res.statusCode === 401 || res.statusCode === 403) { 319 | return callback( 320 | new Error( 321 | 'Github app is not authorized. Did you revoke access?' 322 | ) 323 | ); 324 | } 325 | return callback( 326 | new Error( 327 | `Status code is ${res.statusCode} not 200. Body: ${res.body}` 328 | ) 329 | ); 330 | } else { 331 | debug(`We did get an error from the API ${error}`); 332 | return callback(error, null); 333 | } 334 | } 335 | }, 336 | client 337 | ); 338 | } 339 | 340 | // Start from page 1 341 | loop(baseUrl, 1); 342 | } 343 | 344 | /** 345 | * get_github_repos() 346 | * 347 | * Fetch a list of all the repositories a given user has 348 | * "admin" privileges. Because of the structure of the Github API, 349 | * this can require many separate HTTP requests. We attempt to 350 | * parallelize as many of these as we can to do this as quickly as possible. 351 | * 352 | * @param {String} token the github oauth access token 353 | * @param {String} username the github username 354 | * @param {Function} callback function(error, result-object) 355 | */ 356 | function getRepos(token, username, callback) { 357 | var org_memberships = []; 358 | var team_repos = []; 359 | var repos = []; 360 | // needs callback(null, {groupname: [repo, ...], ...}) 361 | // see strider-extension-loader for details 362 | 363 | /* jshint -W064 */ 364 | /* jshint -W040 */ 365 | Step( 366 | function fetchReposAndOrgs() { 367 | // First fetch the user's repositories and organizations in parallel. 368 | pageinated_api_call('/user/repos', token, this.parallel()); 369 | pageinated_api_call('/user/orgs', token, this.parallel()); 370 | }, 371 | function fetchOrgTeams(err, githubRepos, githubOrgas) { 372 | if (err) { 373 | debug('get_github_repos() - Error fetching repos & orgs: %s', err); 374 | return callback(err); 375 | } 376 | if (!githubRepos) return callback('Get repos failed; no response'); 377 | 378 | org_memberships = githubOrgas.data; 379 | 380 | githubRepos.data.forEach(function parseRepo(githubRepo) { 381 | repos.push({ 382 | id: githubRepo.id, 383 | name: githubRepo.full_name.toLowerCase(), 384 | display_name: githubRepo.full_name, 385 | group: githubRepo.owner.login, 386 | display_url: githubRepo.html_url, 387 | config: { 388 | url: `git://${githubRepo.clone_url.split('//')[1]}`, 389 | owner: githubRepo.owner.login, 390 | repo: githubRepo.name, 391 | auth: { 392 | type: 'ssh', 393 | }, 394 | }, 395 | }); 396 | }); 397 | 398 | // For each Org, fetch the teams it has in parallel 399 | var group = this.group(); 400 | org_memberships.forEach(function (org) { 401 | api_call(`/orgs/${org.login}/teams`, token, group()); 402 | }); 403 | }, 404 | function fetchTeamDetails(err, results) { 405 | if (err) { 406 | debug(err.message); 407 | debug(err.name); 408 | debug(err.stack); 409 | debug( 410 | 'get_github_repos() - Error fetching Org Teams response - %s', 411 | err 412 | ); 413 | return callback(err); 414 | } 415 | var teams = []; 416 | results.forEach(function (result) { 417 | try { 418 | var team_data = result.body; 419 | team_data.forEach(function (t) { 420 | teams.push(t); 421 | }); 422 | } catch (e) { 423 | debug( 424 | 'get_github_repos(): Error parsing JSON in Org Teams response - %s', 425 | e 426 | ); 427 | } 428 | }); 429 | 430 | // For each Team, fetch the detailed info (including privileges) 431 | var group = this.group(); 432 | teams.forEach(function (team) { 433 | api_call(`/teams/${team.id}`, token, group()); 434 | }); 435 | }, 436 | function filterTeams(err, results) { 437 | if (err) { 438 | debug( 439 | 'get_github_repos() - Error fetching detailed team response - %s', 440 | err 441 | ); 442 | return callback(err); 443 | } 444 | var team_details = []; 445 | results.forEach(function (result) { 446 | try { 447 | var td = result.body; 448 | team_details.push(td); 449 | } catch (e) { 450 | debug( 451 | 'get_github_repos(): Error parsing JSON in detail team response - %s', 452 | e 453 | ); 454 | } 455 | }); 456 | // For each Team with admin privs, test for membership 457 | var group = this.group(); 458 | 459 | var team_detail_requests = {}; 460 | team_details.forEach(function (team_details) { 461 | if (team_details.permission != 'admin') { 462 | return; 463 | } 464 | team_detail_requests[team_details.id] = team_details; 465 | let url = team_details.organization.members_url.replace( 466 | '{/member}', 467 | `/${username}` 468 | ); 469 | debug('Team Members URL', url); 470 | let callback = group(); 471 | api_call(url, token, (err, res) => { 472 | callback(err, res ? { res, team: team_details } : null); 473 | }); 474 | }); 475 | this.team_detail_requests = team_detail_requests; 476 | }, 477 | // For each team with admin privileges of which user is a member, fetch 478 | // the list of repositories it has access to. 479 | function fetchFilteredTeamRepos(err, results) { 480 | if (err) { 481 | debug('get_github_repos(): Error with admin team memberships: %s', err); 482 | return callback(err); 483 | } 484 | var group = this.group(); 485 | 486 | results.forEach(function (result) { 487 | debug( 488 | `We get the following repo path: ${util.inspect( 489 | result.team.organization.repos_url 490 | )}` 491 | ); 492 | 493 | if (result.res.statusCode === 204) { 494 | pageinated_api_call( 495 | result.team.organization.repos_url, 496 | token, 497 | group() 498 | ); 499 | } 500 | }); 501 | }, 502 | // Reduce all the results and call output callback. 503 | function finalize(err, results) { 504 | if (err) { 505 | debug('get_github_repos(): Error with team repos request: %s', err); 506 | return callback(err); 507 | } 508 | 509 | results.forEach(function (result) { 510 | if (result && result.data) { 511 | result.data.forEach(function (team_repo) { 512 | team_repos.push({ 513 | id: team_repo.id, 514 | display_url: team_repo.html_url, 515 | name: team_repo.full_name.toLowerCase(), 516 | display_name: team_repo.full_name, 517 | group: team_repo.owner.login, 518 | config: { 519 | url: `git://${team_repo.clone_url.split('//')[1]}`, 520 | owner: team_repo.owner.login, 521 | repo: team_repo.name, 522 | auth: { 523 | type: 'ssh', 524 | }, 525 | }, 526 | }); 527 | }); 528 | } 529 | }); 530 | 531 | //If the user is a member of a team, we get a repository in repos 532 | //as well as team_repos. Thus we need to merge the two and de-dupe 533 | repos = _.uniqWith(repos.concat(team_repos), function (itemA, itemB) { 534 | return itemA.id === itemB.id; 535 | }); 536 | 537 | callback(null, repos); 538 | } 539 | ); 540 | } 541 | -------------------------------------------------------------------------------- /lib/github.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | var debug = require('debug')('strider-github:github'); 5 | var gh = require('../../lib/github'); 6 | var User = require('../../lib/models').User; 7 | 8 | /* 9 | * Return a list of all the repository urls which have already been configured by the user 10 | */ 11 | function configuredReposList(user) { 12 | var l = []; 13 | var gh_config = user.github_config || []; 14 | gh_config.forEach(function (item) { 15 | if (item.url !== undefined) { 16 | l.push(item.url); 17 | } 18 | }); 19 | return l; 20 | } 21 | 22 | function addConfiguredKeys(user, repos) { 23 | var already_configured = configuredReposList(user); 24 | repos.forEach(function (repo) { 25 | if (!repo) { 26 | debug('addConfiguredKeys(): repo is %s', repo); 27 | return; 28 | } 29 | if (repo.html_url === undefined) { 30 | debug( 31 | 'addConfiguredKeys(): repo.html_url is undefined. Full repo: %j', 32 | repo 33 | ); 34 | repo.configured = false; 35 | return; 36 | } 37 | repo.configured = 38 | already_configured.indexOf(repo.html_url.toLowerCase()) != -1; 39 | }); 40 | } 41 | 42 | /** 43 | * GET /api/github/metadata 44 | * Caches to user doc in github_metatdata: { "gh id" : data } 45 | * If "refresh" query param is > 0 or "true" it will refetch. 46 | */ 47 | module.exports.github_metadata = function (req, res) { 48 | res.setHeader('Content-Type', 'application/json'); 49 | 50 | var no_existing_metadata = 51 | req.user.github_metadata === undefined || 52 | req.user.github_metadata[req.user.github.id] === undefined; 53 | var refresh_requested = 54 | req.query.refresh !== undefined && 55 | req.query.refresh !== 'false' && 56 | req.query.refresh !== '0'; 57 | 58 | if (no_existing_metadata || refresh_requested) { 59 | gh.get_github_repos(req.user, function (err, results) { 60 | var data = { 61 | repos: results.repos.concat(results.orgs.team_repos), 62 | orgs: results.orgs.org_memberships, 63 | }; 64 | // Dedup repos - sometimes can have entries in both team_repos and repos. 65 | data.repos = _.uniqWith(data.repos, function (item) { 66 | return item.html_url; 67 | }); 68 | req.user['github_metadata'] = {}; 69 | req.user['github_metadata'][req.user.github.id] = data; 70 | addConfiguredKeys(req.user, data.repos); 71 | req.user.save(function () { 72 | var output = JSON.stringify(data, null, '\t'); 73 | res.end(output); 74 | }); 75 | }); 76 | } else { 77 | // Read from cache 78 | var data = req.user.github_metadata[req.user.github.id]; 79 | addConfiguredKeys(req.user, data.repos); 80 | var output = JSON.stringify(data, null, '\t'); 81 | res.end(output); 82 | } 83 | }; 84 | 85 | /** 86 | * POST /api/github/webhooks/unset 87 | * 88 | * Unset all Strider webhooks for a particular Github project. 89 | * Requires query param which is the Github html_url of the project. 90 | */ 91 | module.exports.github_webhooks_unset = function (req, res) { 92 | res.setHeader('Content-Type', 'application/json'); 93 | var url; 94 | res.statusCode = 200; 95 | var results; 96 | if ((url = req.param('url')) === undefined) { 97 | results = { 98 | status: 'error', 99 | errors: [{ message: 'you must supply a url parameter' }], 100 | }; 101 | res.statusCode = 400; 102 | res.end(JSON.stringify(results, null, '\t')); 103 | return; 104 | } 105 | var token = req.user.get('github.accessToken'); 106 | debug('url: %s', url); 107 | req.user.get_repo_config(url, function (err, repo) { 108 | if (err || !repo) { 109 | results = { 110 | status: 'error', 111 | errors: [{ message: 'invalid url for this user' }], 112 | }; 113 | res.statusCode = 400; 114 | return res.end(JSON.stringify(results, null, '\t')); 115 | } 116 | var gh_repo_path = url.replace(/^.*com/gi, ''); 117 | 118 | debug( 119 | 'github.github_webhooks_unset(): unsetting hooks for user %s on repo %s', 120 | req.user.email, 121 | gh_repo_path 122 | ); 123 | gh.unset_push_hook(gh_repo_path, token, function () { 124 | results = { status: 'ok', errors: [] }; 125 | res.end(JSON.stringify(results, null, '\t')); 126 | }); 127 | }); 128 | }; 129 | 130 | /** 131 | * POST /api/github/manual_setup 132 | */ 133 | module.exports.post_manual_setup = function (req, res) { 134 | var github_url = req.param('github_url'); 135 | // check to see if this project already exists. if it does, error on that 136 | // Check whether someone else has already configured this repository 137 | User.findOne({ 'github_config.url': github_url.toLowerCase() }, function ( 138 | err, 139 | user 140 | ) { 141 | var r; 142 | if (user) { 143 | debug(`Dupe repo: ${github_url} requested by ${req.user.email}`); 144 | res.statusCode = 400; 145 | r = { 146 | status: 'error', 147 | errors: 'Repo Already Configured', 148 | }; 149 | return res.end(JSON.stringify(r), null, '\t'); 150 | } 151 | // validate again (after backbone validation) bc api request could be from other source 152 | var p = gh.parse_github_url(github_url); 153 | if (p === null) { 154 | debug(`invalid github url: ${github_url}`); 155 | res.statusCode = 400; 156 | r = { 157 | status: 'error', 158 | errors: 'Not a valid Github URL', 159 | }; 160 | return res.end(JSON.stringify(r), null, '\t'); 161 | } 162 | debug(`org: ${p.org} - name: ${p.repo}`); 163 | 164 | var repo_url = `https://github.com/${p.org}/${p.repo}`; 165 | 166 | gh.setup_integration_manual(req, p.org, repo_url, function ( 167 | webhook, 168 | deploy_key_title, 169 | deploy_public_key 170 | ) { 171 | var obj = { 172 | webhook: webhook, 173 | deploy_key_title: deploy_key_title, 174 | deploy_public_key: deploy_public_key, 175 | org: p.org, 176 | repo: p.repo, 177 | }; 178 | 179 | var output = JSON.stringify(obj, null, '\t'); 180 | debug('post_manual_setup() - output: %j', output); 181 | res.end(output); 182 | }); 183 | }); 184 | }; 185 | -------------------------------------------------------------------------------- /lib/index.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | var config = require('./config'); 5 | var crypto = require('crypto'); 6 | var debug = require('debug')('strider-github:index'); 7 | var keypair = require('ssh-keypair'); 8 | var step = require('step'); 9 | var superagent = require('superagent'); 10 | var url = require('url'); 11 | var User = require('./models').User; 12 | 13 | var GITHUB_API_ENDPOINT = 'https://api.github.com'; 14 | 15 | module.exports = { 16 | get_oauth2: get_oauth2, 17 | api_call: api_call, 18 | parse_link_header: parse_link_header, 19 | pageinated_api_call: pageinated_api_call, 20 | get_github_repos: get_github_repos, 21 | generate_webhook_secret: generate_webhook_secret, 22 | save_repo_deploy_keys: save_repo_deploy_keys, 23 | add_deploy_key: add_deploy_key, 24 | set_push_hook: set_push_hook, 25 | unset_push_hook: unset_push_hook, 26 | setup_integration: setup_integration, 27 | setup_integration_manual: setup_integration_manual, 28 | verify_webhook_sig: verify_webhook_sig, 29 | verify_webhook_req_signature: verify_webhook_req_signature, 30 | verify_webhook_req_secret: verify_webhook_req_secret, 31 | webhook_commit_is_to_master: webhook_commit_is_to_master, 32 | webhook_extract_latest_commit_info: webhook_extract_latest_commit_info, 33 | parse_github_url: parse_github_url, 34 | make_ssh_url: make_ssh_url, 35 | }; 36 | 37 | /** 38 | * get_oauth2() 39 | * 40 | * Do a HTTP GET w/ OAuth2 token 41 | * @param {String} url URL to GET 42 | * @param {Object} params Object representing the query params to be added to GET request 43 | * @param {String} accessToken OAuth2 access token 44 | * @param {Function} callback function(error, response, body) 45 | * @param {Object} client An alternative superagent instance to use. 46 | */ 47 | function get_oauth2(url, params, accessToken, callback, client) { 48 | console.debug('OAUTH2 CALLBACK LENGTH:', callback.length); 49 | // If the user provided a superagent instance, use that. 50 | client = client || superagent; 51 | console.debug('GET OAUTH2 URL:', url); 52 | client 53 | .get(url) 54 | .query(params) 55 | .set('Authorization', `token ${accessToken}`) 56 | .set('User-Agent', 'StriderCD (http://stridercd.com)') 57 | .end(callback); 58 | } 59 | 60 | /** 61 | * api_call() 62 | * 63 | * Simple HTTP GET Github API wrapper. 64 | * Makes it easy to call most read API calls. 65 | * @param {String} path API call URL path 66 | * @param {String} accessToken OAuth2 access token 67 | * @param {Function} callback function(error, response, de-serialized json) 68 | * @param {Object} client An alternative superagent instance to use. 69 | */ 70 | function api_call(path, accessToken, callback, client) { 71 | // If the user provided a superagent instance, use that. 72 | client = client || superagent; 73 | var url = GITHUB_API_ENDPOINT + path; 74 | debug('github api_call(): path %s', path); 75 | get_oauth2( 76 | url, 77 | {}, 78 | accessToken, 79 | function (error, res) { 80 | if (!error && res.statusCode == 200) { 81 | var data = res.body; 82 | callback(null, res, data); 83 | } else { 84 | callback(error, res, null); 85 | } 86 | }, 87 | client 88 | ); 89 | } 90 | 91 | /** 92 | * parse_link_header() 93 | * 94 | * Parse the Github Link HTTP header used for pageination 95 | * http://developer.github.com/v3/#pagination 96 | */ 97 | function parse_link_header(header) { 98 | if (header.length === 0) { 99 | throw new Error('input must not be of zero length'); 100 | } 101 | 102 | // Split parts by comma 103 | var parts = header.split(','); 104 | var links = {}; 105 | // Parse each part into a named link 106 | parts.forEach(function (p) { 107 | var section = p.split(';'); 108 | if (section.length != 2) { 109 | throw new Error("section could not be split on ';'"); 110 | } 111 | var url = section[0].replace(/<(.*)>/, '$1').trim(); 112 | var name = section[1].replace(/rel="(.*)"/, '$1').trim(); 113 | links[name] = url; 114 | }); 115 | 116 | return links; 117 | } 118 | 119 | /** 120 | * pageinated_api_call() 121 | * 122 | * Simple HTTP Get Github API wrapper with support for pageination via Link header. 123 | * See: http://developer.github.com/v3/#pagination 124 | * 125 | * @param {String} path API call URL path 126 | * @param {String} accessToken OAuth2 access token 127 | * @param {Function} callback function(error, response, de-serialized json) 128 | * @param {Object} client An alternative superagent instance to use. 129 | */ 130 | function pageinated_api_call(path, accessToken, callback, client) { 131 | // If the user provided a superagent instance, use that. 132 | client = client || superagent; 133 | 134 | var base_url = GITHUB_API_ENDPOINT + path; 135 | debug('github pageinated_api_call(): path %s', path); 136 | 137 | if (!accessToken) { 138 | debug('Error in request - no access token'); 139 | debug(new Error().stack); 140 | } 141 | 142 | // This is a left fold, 143 | // a recursive function closed over an accumulator 144 | 145 | var pages = []; 146 | 147 | function loop(uri, page) { 148 | get_oauth2( 149 | uri, 150 | { per_page: 30, page: page }, 151 | accessToken, 152 | function (error, response) { 153 | if (!error && response.statusCode == 200) { 154 | var data; 155 | try { 156 | data = response.body; 157 | } catch (e) { 158 | return callback(e, null); 159 | } 160 | pages.push(data); 161 | 162 | var link = response.headers['link']; 163 | var r; 164 | if (link) { 165 | r = parse_link_header(link); 166 | } 167 | // Stop condition: No link header or we think we just read the last page 168 | if (!link || (r.next === undefined && r.first !== undefined)) { 169 | callback(null, { data: _.flatten(pages), response: response }); 170 | } else { 171 | // Request next page and continue 172 | var next_page = url.parse(r.next, true).query.page; 173 | loop(base_url, next_page); 174 | } 175 | } else { 176 | if (!error) { 177 | return callback( 178 | `Status code is ${response.statusCode} not 200. Body: ${response.body}` 179 | ); 180 | } else { 181 | return callback(error, null); 182 | } 183 | } 184 | }, 185 | client 186 | ); 187 | } 188 | 189 | // Start from page 1 190 | loop(base_url, 1); 191 | } 192 | 193 | /** 194 | * get_github_repos() 195 | * 196 | * Fetch a list of all the repositories a given user has 197 | * "admin" privileges. Because of the structure of the Github API, 198 | * this can require many separate HTTP requests. We attempt to 199 | * parallelize as many of these as we can to do this as quickly as possible. 200 | * 201 | * @param {Object} user User object 202 | * @param {Function} callback function(error, result-object) where result-object has properties: 203 | * -team_repos 204 | * -org_memberships 205 | */ 206 | function get_github_repos(user, callback) { 207 | var token = user.get('github.accessToken'); 208 | var org_memberships = []; 209 | var team_repos = []; 210 | var repos = []; 211 | debug('Fetching Github repositories for user: %s', user.email); 212 | step( 213 | function fetchReposAndOrgs() { 214 | debug('Repos API call for user: %s', user.email); 215 | // First fetch the user's repositories and organizations in parallel. 216 | pageinated_api_call('/user/repos', token, this.parallel()); 217 | pageinated_api_call('/user/orgs', token, this.parallel()); 218 | }, 219 | function fetchOrgTeams(err, r, o) { 220 | if (err) { 221 | debug('get_github_repos() - Error fetching repos & orgs: %s', err); 222 | throw err; 223 | } 224 | if (!r) { 225 | throw 'Response is null'; 226 | } 227 | debug( 228 | 'Repos API call returned for user: %s status: %s', 229 | user.email, 230 | r.response.statusCode 231 | ); 232 | debug( 233 | 'Orgs API call returned for user: %s status: %s', 234 | user.email, 235 | o.response.statusCode 236 | ); 237 | 238 | org_memberships = o.data; 239 | repos = r.data; 240 | 241 | // For each Org, fetch the teams it has in parallel 242 | var group = this.group(); 243 | org_memberships.forEach(function (org) { 244 | debug('Fetching teams for Org: %s', org.login); 245 | api_call(`/orgs/${org.login}/teams`, token, group()); 246 | }); 247 | }, 248 | function fetchTeamDetails(err, results) { 249 | if (err) { 250 | debug( 251 | 'get_github_repos() - Error fetching Org Teams response - %s', 252 | err 253 | ); 254 | throw err; 255 | } 256 | var teams = []; 257 | results.forEach(function (result) { 258 | try { 259 | debug('For Organizations: %s', result.request.uri.path.split('/')[2]); 260 | var team_data = result.body; 261 | team_data.forEach(function (t) { 262 | debug('Team details: %j', t); 263 | teams.push(t); 264 | }); 265 | } catch (e) { 266 | debug( 267 | 'get_github_repos(): Error parsing JSON in Org Teams response - %s', 268 | e 269 | ); 270 | } 271 | }); 272 | 273 | // For each Team, fetch the detailed info (including privileges) 274 | var group = this.group(); 275 | teams.forEach(function (team) { 276 | debug('Teams detail API call for user: %s', team.name); 277 | api_call(`/teams/${team.id}`, token, group()); 278 | }); 279 | }, 280 | function filterTeams(err, results) { 281 | if (err) { 282 | debug( 283 | 'get_github_repos() - Error fetching detailed team response - %s', 284 | err 285 | ); 286 | throw err; 287 | } 288 | var team_details = []; 289 | results.forEach(function (result) { 290 | try { 291 | var td = result.body; 292 | team_details.push(td); 293 | } catch (e) { 294 | debug( 295 | 'get_github_repos(): Error parsing JSON in detail team response - %s', 296 | e 297 | ); 298 | } 299 | }); 300 | // For each Team with admin privs, test for membership 301 | var group = this.group(); 302 | var team_detail_requests = {}; 303 | team_details.forEach(function (team_details) { 304 | if (team_details.permission != 'admin') { 305 | debug('Problem with team_details: %j', team_details); 306 | debug( 307 | 'Team %s does not have admin privs, ignoring', 308 | team_details.name 309 | ); 310 | return; 311 | } 312 | team_detail_requests[team_details.id] = team_details; 313 | var url = `${GITHUB_API_ENDPOINT}/teams/${team_details.id}/members/${user.github.login}`; 314 | debug( 315 | 'Starting admin team membership API call for user: %s team: %s', 316 | user.email, 317 | team_details.id 318 | ); 319 | get_oauth2(url, {}, token, group()); 320 | }); 321 | this.team_detail_requests = team_detail_requests; 322 | }, 323 | // For each team with admin privileges of which user is a member, fetch 324 | // the list of repositories it has access to. 325 | function fetchFilteredTeamRepos(err, results) { 326 | if (err) { 327 | debug('get_github_repos(): Error with admin team memberships: %s', err); 328 | throw err; 329 | } 330 | var team_detail_requests = this.team_detail_requests; 331 | var group = this.group(); 332 | results.forEach(function (response) { 333 | var team_id = response.request.uri.path.split('/')[2]; 334 | var team_detail = team_detail_requests[parseInt(team_id, 10)]; 335 | debug( 336 | 'Team membership API call returned %s for team %s (id: %s)', 337 | response.statusCode, 338 | team_detail.name, 339 | team_detail.id 340 | ); 341 | if (response.statusCode === 204) { 342 | debug( 343 | 'User is a member of team %s (id: %s)', 344 | team_detail.name, 345 | team_detail.id 346 | ); 347 | pageinated_api_call(`/teams/${team_id}/repos`, token, group()); 348 | } else { 349 | debug( 350 | 'User is NOT a member of team %s (id: %s)', 351 | team_detail.name, 352 | team_detail.id 353 | ); 354 | } 355 | }); 356 | }, 357 | // Reduce all the results and call output callback. 358 | function finalize(err, results) { 359 | if (err) { 360 | debug('get_github_repos(): Error with team repos request: %s', err); 361 | throw err; 362 | } 363 | results.forEach(function (result) { 364 | if (result && result.data) { 365 | result.data.forEach(function (team_repo) { 366 | team_repos.push(team_repo); 367 | }); 368 | } else { 369 | debug( 370 | 'get_github_repos(): finalize result was null for user %s', 371 | user.email 372 | ); 373 | } 374 | }); 375 | // Sometimes we can get multiple copies of the same team repo, so we uniq it 376 | team_repos = _.uniqWith(team_repos, function (item) { 377 | return item.html_url; 378 | }); 379 | debug( 380 | 'Github results for user %s - Repos: %j Team Repos w/ admin: %j Org memberships: %j', 381 | user.email, 382 | _.map(repos, 'name'), 383 | _.map(team_repos, 'name'), 384 | _.map(org_memberships, 'login') 385 | ); 386 | callback(null, { 387 | repos: repos, 388 | orgs: { team_repos: team_repos, org_memberships: org_memberships }, 389 | }); 390 | } 391 | ); 392 | } 393 | 394 | /** 395 | * generate_webhook_secret() 396 | * 397 | * Generate a short shared secret to send to Github to use 398 | * for verifying the Webhook data origins. 399 | * 400 | * @param {Function} callback function(secret) 401 | */ 402 | function generate_webhook_secret(callback) { 403 | crypto.randomBytes(32, function (e, buf) { 404 | callback(buf.toString('hex')); 405 | }); 406 | } 407 | 408 | /** 409 | * setup_int_deploy_keys() 410 | * 411 | * Persist an SSH keypair and a randomly generated 412 | * webhook secret to the github_config property of a supplied Mongoose ODM user object. 413 | * Keypairs are keyed by github repository ID. 414 | * Schema is user_obj["github_config"]["github_repo_id"] 415 | * 416 | * @param {Object} user_obj User object 417 | * @param {String} gh_repo_url URL of the git repository to configure (repo.html_url) 418 | * @param {String} privkey String containing SSH private key 419 | * @param {String} pubkey String containing SSH public key 420 | * @param {Function} callback function(err, user_obj) 421 | */ 422 | function save_repo_deploy_keys( 423 | user_obj, 424 | gh_repo_url, 425 | privkey, 426 | pubkey, 427 | callback 428 | ) { 429 | generate_webhook_secret(function (secret) { 430 | var config = {}; 431 | config.privkey = privkey; 432 | config.pubkey = pubkey; 433 | config.url = gh_repo_url.toLowerCase(); 434 | config.display_url = gh_repo_url; 435 | config.secret = secret; 436 | user_obj.github_config.push(config); 437 | user_obj.save(callback); 438 | }); 439 | } 440 | 441 | /** 442 | * add_deploy_key() 443 | * 444 | * Add a deploy key to the repo. Must have admin privileges for this to work. 445 | * @param {String} gh_repo_path is "//" e.g. "/BeyondFog/Strider". This doesn't add slashes, caller must get it right. 446 | * @param {String} pubkey String containing SSH public key 447 | * @param {String} title Title for key 448 | * @param {String} accessToken OAuth2 access token 449 | * @param {Function} callback function(error, response, body) 450 | * @param {Object} client An alternative superagent instance to use. 451 | */ 452 | function add_deploy_key( 453 | gh_repo_path, 454 | pubkey, 455 | title, 456 | accessToken, 457 | callback, 458 | client 459 | ) { 460 | client = client || superagent; 461 | var data = { title: title, key: pubkey }; 462 | var url = `${GITHUB_API_ENDPOINT}/repos${gh_repo_path}/keys`; 463 | 464 | client 465 | .post(url) 466 | .send(data) 467 | .set('Authorization', `token ${accessToken}`) 468 | .set('User-Agent', 'StriderCD (http://stridercd.com)') 469 | .end(callback); 470 | } 471 | 472 | /** 473 | * set_push_hook() 474 | * 475 | * Set a push hook via the Github API for the supplied repository. Must have admin privileges for this to work. 476 | * 477 | * @param {String} gh_repo_path is "//" e.g. "/BeyondFog/Strider". This doesn't add slashes, caller must get it right. 478 | * @param {String} name is the name of the Github hook (e.g. webhook) to set up. 479 | * @param {String} url is the URL for the webhook to post to. 480 | * @param {String} secret is the Webhook secret, which will be used to generate the HMAC-SHA1 header in the Github request. 481 | * @param {String} accessToken OAuth2 access token 482 | * @param {Function} callback function(error, response, body) 483 | * @param {Object} client An alternative superagent instance to use. 484 | */ 485 | function set_push_hook( 486 | gh_repo_path, 487 | name, 488 | url, 489 | secret, 490 | accessToken, 491 | callback, 492 | client 493 | ) { 494 | client = client || superagent; 495 | var data = { name: name, active: true, config: { url: url, secret: secret } }; 496 | var postUrl = `${GITHUB_API_ENDPOINT}/repos${gh_repo_path}/hooks`; 497 | 498 | client 499 | .post(postUrl) 500 | .send(data) 501 | .set('Authorization', `token ${accessToken}`) 502 | .set('User-Agent', 'StriderCD (http://stridercd.com)') 503 | .end(callback); 504 | } 505 | 506 | /** 507 | * unset_push_hook() 508 | * 509 | * Delete push hook via the Github API for the supplied repository. Must have admin privileges for this to work. 510 | * 511 | * @param {String} gh_repo_path is "//" e.g. "/BeyondFog/Strider". This doesn't add slashes, caller must get it right. 512 | * @param {String} accessToken OAuth2 access token 513 | * @param {Function} callback function(error, response, body) 514 | * @param {Object} client An alternative superagent instance to use. 515 | */ 516 | function unset_push_hook(gh_repo_path, accessToken, callback, client) { 517 | client = client || superagent; 518 | 519 | debug( 520 | 'github.unset_push_hook() deleting Github webhooks for repo path %s', 521 | gh_repo_path 522 | ); 523 | step( 524 | function () { 525 | api_call(`/repos${gh_repo_path}/hooks`, accessToken, this); 526 | }, 527 | function (error, response, results) { 528 | if (error) throw error; 529 | if (response.statusCode !== 200) { 530 | debug( 531 | 'github.unset_push_hook() GH API status code is not 200 but %s. Body: %s', 532 | response.statusCode, 533 | results 534 | ); 535 | return callback(error, response, results); 536 | } 537 | // Unset all hooks where the URL matches our webhook handler. 538 | var strider_url = `${config.strider_server_name}/webhook`; 539 | var group = this.group(); 540 | results.forEach(function (hook) { 541 | if (hook.config.url === strider_url) { 542 | debug( 543 | 'github.unset_push_hook() found hook: %j for repo %s, deleting', 544 | hook, 545 | gh_repo_path 546 | ); 547 | client 548 | .del(`${GITHUB_API_ENDPOINT}/repos${gh_repo_path}/hooks/${hook.id}`) 549 | .set('Authorization', `token ${accessToken}`) 550 | .set('User-Agent', 'StriderCD (http://stridercd.com)') 551 | .end(group()); 552 | } 553 | }); 554 | }, 555 | function (error, args) { 556 | if (error) throw error; 557 | debug('github.unset_push_hook() Hooks successfully deleted'); 558 | return callback(error, args); 559 | } 560 | ); 561 | } 562 | 563 | /** 564 | * setup_integration() 565 | * 566 | * Wraps the entire process for generating & adding a new deploy key to Github and 567 | * saving to local DB. Must have admin privileges for this to work. 568 | * 569 | * @param {Object} user_obj User object. 570 | * @param {String} gh_repo_path is "//" e.g. "/BeyondFog/Strider". This doesn't add slashes, caller must get it right. 571 | * @param {String} token OAuth2 access token. 572 | * @param {Function} callback function(). 573 | * @param {Object} socket Socket.IO handle to emit messages to. 574 | * @param {Boolean} no_ssh don't setup ssh keys; for public repos 575 | */ 576 | function setup_integration( 577 | user_obj, 578 | gh_repo_id, 579 | token, 580 | callback, 581 | socket, 582 | no_ssh 583 | ) { 584 | var gh_metadata = user_obj.github_metadata[user_obj.github.id].repos; 585 | var repo = _.find(gh_metadata, function (repo) { 586 | return gh_repo_id == repo.id; 587 | }); 588 | var config_key = repo.html_url; 589 | var gh_repo_path = url.parse(repo.html_url).pathname; 590 | if (no_ssh) { 591 | return step( 592 | function () { 593 | socket.emit('update', { msg: 'skipping ssh keys' }); 594 | save_repo_deploy_keys(user_obj, config_key, null, null, this); 595 | }, 596 | get_repo_config, 597 | function (err, repo) { 598 | if (err) { 599 | debug( 600 | 'setup_integration() - error fetching repo config for url %s: %s', 601 | config_key, 602 | err 603 | ); 604 | throw new Error(err); 605 | } 606 | this.repo = repo; 607 | this(); 608 | }, 609 | set_hook, 610 | done 611 | ); 612 | } 613 | function get_repo_config(err, user_obj) { 614 | if (err) { 615 | socket.emit('update', { msg: 'an error occurred' }); 616 | debug( 617 | 'setup_integration() - error fetching repo config for url %s: %s', 618 | config_key, 619 | err 620 | ); 621 | throw new Error(err); 622 | } 623 | user_obj.get_repo_config(config_key, this); 624 | } 625 | 626 | function set_hook() { 627 | socket.emit('update', { msg: 'configuring secure Github commit hook' }); 628 | var name = 'web'; 629 | var url = `${config.strider_server_name}/webhook`; 630 | var secret = this.repo.get('secret'); 631 | set_push_hook(gh_repo_path, name, url, secret, token, this); 632 | } 633 | 634 | function done() { 635 | socket.emit('update', { msg: 'done' }); 636 | callback(); 637 | } 638 | 639 | step( 640 | function make_keys() { 641 | socket.emit('update', { msg: 'generating deploy keys' }); 642 | keypair(user_obj.github.login, function save_keys(err, privkey, pubkey) { 643 | socket.emit('update', { msg: 'persisting deploy keys' }); 644 | if (err) throw err; 645 | save_repo_deploy_keys( 646 | user_obj, 647 | config_key, 648 | privkey.toString(), 649 | pubkey.toString(), 650 | this 651 | ); 652 | }); 653 | }, 654 | get_repo_config, 655 | function push_deploy_key(err, repo_config) { 656 | if (err) { 657 | debug( 658 | 'setup_integration() - error fetching repo config for url %s: %s', 659 | config_key, 660 | err 661 | ); 662 | throw new Error(err); 663 | } 664 | this.repo = repo_config; 665 | socket.emit('update', { msg: 'sending deploy key to Github' }); 666 | var title = `StriderDeployKey - ${config.strider_server_name} - ${user_obj.email}`; 667 | add_deploy_key(gh_repo_path, this.repo.pubkey, title, token, this); 668 | }, 669 | set_hook, 670 | done 671 | ); 672 | } 673 | 674 | /** 675 | * setup_integration_manual() 676 | * 677 | * Wraps the entire process for generating & adding a new deploy key to Github and 678 | * saving to local DB. Must have admin privileges for this to work. 679 | * 680 | * @param {Object} req Express request object. 681 | * @param {Function} callback function(). 682 | */ 683 | function setup_integration_manual(req, org, github_url, callback) { 684 | var config_key = github_url; 685 | step( 686 | function make_keys() { 687 | keypair(org, function save_keys(err, privkey, pubkey) { 688 | if (err) throw err; 689 | save_repo_deploy_keys( 690 | req.user, 691 | config_key, 692 | privkey.toString(), 693 | pubkey.toString(), 694 | this 695 | ); 696 | }); 697 | }, 698 | function get_repo_config(err, user_obj) { 699 | if (err) throw err; 700 | user_obj.get_repo_config(config_key, this); 701 | }, 702 | function push_deploy_key(err, repo_config) { 703 | if (err) { 704 | debug( 705 | 'setup_integration() - error fetching repo config for url %s: %s', 706 | config_key, 707 | err 708 | ); 709 | throw new Error(err); 710 | } 711 | this.repo = repo_config; 712 | }, 713 | function done() { 714 | var deploy_key_title = `StriderDeployKey - ${config.strider_server_name} - ${req.user.email}`; 715 | 716 | var deploy_public_key = this.repo.pubkey; 717 | var webhook = `${config.strider_server_name}/webhook/${this.repo.get( 718 | 'secret' 719 | )}`; 720 | 721 | callback(webhook, deploy_key_title, deploy_public_key); 722 | } 723 | ); 724 | } 725 | 726 | /** 727 | * verify_webhook_sig() 728 | * 729 | * Verify HMAC-SHA1 signatures. 730 | * 731 | * @param {String} sig Signature. 732 | * @param {String} secret Shared secret, the HMAC-SHA1 was supposedly generated with this. 733 | * @param {String} body The message body to sign. 734 | */ 735 | function verify_webhook_sig(sig, secret, body) { 736 | var hmac = crypto.createHmac('sha1', secret); 737 | hmac.update(body); 738 | var digest = hmac.digest('hex'); 739 | return sig == digest; 740 | } 741 | 742 | /** 743 | * verify_webhook_req_signature() 744 | * 745 | * Verify the X-Hub-Signature HMAC-SHA1 header used by Github webhooks. 746 | * 747 | * @param {Object} req Express request object. 748 | * @param {Function} callback function(boolean result, repository object, user object) 749 | */ 750 | function verify_webhook_req_signature(req, callback) { 751 | var sig = req.headers['x-hub-signature']; 752 | if (sig === undefined) { 753 | debug('verify_webhook_req() signature missing'); 754 | callback(false); 755 | return; 756 | } 757 | if (req.body.payload === undefined) { 758 | debug('verify_webhook_req_signature() payload missing'); 759 | callback(false); 760 | return; 761 | } 762 | sig = sig.replace('sha1=', ''); 763 | 764 | var payload; 765 | try { 766 | payload = JSON.parse(req.body.payload); 767 | } catch (e) { 768 | debug('verify_webhook_req_signature() JSON parse exception'); 769 | callback(false); 770 | return; 771 | } 772 | User.findOne( 773 | { 'github_config.url': payload.repository.url.toLowerCase() }, 774 | function (err, user) { 775 | if (err || user === null || user === undefined) { 776 | return callback(false); 777 | } 778 | user.get_repo_config(payload.repository.url, function (err, repo_config) { 779 | return callback( 780 | verify_webhook_sig(sig, repo_config.get('secret'), req.post_body), 781 | repo_config, 782 | user, 783 | payload 784 | ); 785 | }); 786 | } 787 | ); 788 | } 789 | 790 | /** 791 | * verify_webhook_req_secret() 792 | * 793 | * Verify the secret in the URL for a github webhook of a manually configured project 794 | * 795 | * @param {Object} req Express request object. 796 | * @param {Function} callback function(boolean result, repository object, user object) 797 | */ 798 | function verify_webhook_req_secret(req, callback) { 799 | if (req.body.payload === undefined) { 800 | debug('verify_webhook_req_secret() payload missing'); 801 | callback(false); 802 | return; 803 | } 804 | 805 | var payload; 806 | try { 807 | payload = JSON.parse(req.body.payload); 808 | } catch (e) { 809 | debug('verify_webhook_req_secret() JSON parse exception'); 810 | callback(false); 811 | return; 812 | } 813 | User.findOne( 814 | { 'github_config.url': payload.repository.url.toLowerCase() }, 815 | function (err, user) { 816 | if (err || user === null || user === undefined) { 817 | return callback(false); 818 | } 819 | user.get_repo_config(payload.repository.url, function (err, repo_config) { 820 | return callback( 821 | req.params.secret === repo_config.get('secret'), 822 | repo_config, 823 | user, 824 | payload 825 | ); 826 | }); 827 | } 828 | ); 829 | } 830 | 831 | /** 832 | * webhook_commit_is_to_master() 833 | * 834 | * Verify the supplied payload object represents a commit to the master branch. 835 | * 836 | * @param {Object} payload Decoded JSON commit object. 837 | */ 838 | function webhook_commit_is_to_master(payload) { 839 | if (payload === undefined) return false; 840 | 841 | return payload.ref === 'refs/heads/master'; 842 | } 843 | 844 | /** 845 | * webhook_extract_latest_commit_info() 846 | * 847 | * Extract the author, id, message and timestamp from the latest commit mentioned in the 848 | * webhook. This is mainly to be attached to Job objects triggered by the webhook firing. 849 | * 850 | * @param {Object} payload Decoded JSON commit object. 851 | */ 852 | function webhook_extract_latest_commit_info(payload) { 853 | var commit_id = payload.after; 854 | 855 | var commit_data = _.find(payload.commits, function (commit) { 856 | return commit_id == commit.id; 857 | }); 858 | 859 | return { 860 | id: commit_data.id, 861 | author: commit_data.author, 862 | message: commit_data.message, 863 | timestamp: commit_data.timestamp, 864 | }; 865 | } 866 | 867 | /** 868 | * parse_github_url() 869 | * 870 | * Parse a Github URL and return the organization and repo. If there is a trailing ".git" 871 | * in the path, assume it is a Git URL and strip it off. 872 | * @param {String} gh_url The URL to parse 873 | * @returns {Object} {org: "org", repo: "repo"} 874 | */ 875 | function parse_github_url(gh_url) { 876 | var myRegexp = /(?:https*:\/\/)*github\.com\/(\S+)\/(\S+)\/?/; 877 | var match = myRegexp.exec(gh_url); 878 | 879 | if (match === null) { 880 | return null; 881 | } else { 882 | var org = match[1]; 883 | var repo = match[2]; 884 | 885 | // Check whether suffix is .git and if so, remove 886 | var suffix = repo.substr(repo.length - '.git'.length, repo.length); 887 | if (suffix === '.git') { 888 | repo = repo.substr(0, repo.length - '.git'.length); 889 | } 890 | 891 | return { org: org, repo: repo }; 892 | } 893 | } 894 | 895 | /** 896 | * make_ssh_url() 897 | * 898 | * Make a Github SSH-protocol Git URL for the supplied org/user and repository. 899 | * 900 | * @param {String} org Organization or user 901 | * @param {String} repo Respository name 902 | * @returns {String} like git@github.com/org/repo 903 | */ 904 | function make_ssh_url(org, repo) { 905 | return `git@github.com:${org}/${repo}.git`; 906 | } 907 | -------------------------------------------------------------------------------- /lib/utils.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var crypto = require('crypto'); 4 | 5 | /** 6 | * generateSecret() 7 | * 8 | * Generate a short shared secret to send to Github to use 9 | * for verifying the Webhook data origins. 10 | * 11 | * @param {Function} callback function(secret) 12 | */ 13 | module.exports.generateSecret = function generateSecret(callback) { 14 | crypto.randomBytes(32, function (err, buf) { 15 | callback(err, buf && buf.toString('hex')); 16 | }); 17 | }; 18 | -------------------------------------------------------------------------------- /lib/webapp.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var api = require('./api'); 4 | var debug = require('debug')('strider-github:webapp'); 5 | var GithubStrategy = require('passport-github').Strategy; 6 | var utils = require('./utils'); 7 | var webhooks = require('./webhooks'); 8 | 9 | module.exports = { 10 | appConfig: { 11 | hostname: process.env.SERVER_NAME || 'http://localhost:3000', 12 | // you only need to override these if you are connecting to github enterprise 13 | // if you are on github enterprise, you'll need to create a new app: http://github.whatever.com/applications/new 14 | appId: process.env.PLUGIN_GITHUB_APP_ID || 'a3af4568e9d8ca4165fe', 15 | appSecret: 16 | process.env.PLUGIN_GITHUB_APP_SECRET || 17 | '18651128b57787a3336094e2ba1af240dfe44f6c', 18 | // used for web auth 19 | apiDomain: process.env.PLUGIN_GITHUB_API_DOMAIN || 'https://github.com', 20 | // enterprise endpoint urls end in /api/v3 21 | apiEndpoint: 22 | process.env.PLUGIN_GITHUB_API_ENDPOINT || 'https://api.github.com', 23 | }, 24 | fastFile: true, 25 | getBranches: function (account, config, project, done) { 26 | api.getBranches(account.accessToken, config.owner, config.repo, done); 27 | }, 28 | getFile: function (filename, ref, account, config, project, done) { 29 | var baseref = ref.id || ref.branch || ref.tag || 'master'; 30 | api.getFile( 31 | filename, 32 | baseref, 33 | account.accessToken, 34 | config.owner, 35 | config.repo, 36 | done 37 | ); 38 | }, 39 | // this is config stored on the user object under "accounts" 40 | // the account config page is expected to set it 41 | accountConfig: { 42 | accessToken: String, 43 | login: String, 44 | id: Number, 45 | email: String, 46 | gravatarId: String, 47 | name: String, 48 | }, 49 | // this is the project-level config 50 | // project.provider.config 51 | config: { 52 | url: String, 53 | owner: String, 54 | repo: String, 55 | cache: Boolean, 56 | release: Boolean, 57 | pull_requests: { type: String, enum: ['all', 'none', 'whitelist'] }, 58 | whitelist: [ 59 | { 60 | name: String, 61 | level: { type: String, enum: ['tester', 'admin'] }, 62 | }, 63 | ], 64 | // used for the webhook 65 | secret: String, 66 | // type: https || ssh 67 | auth: {}, 68 | }, 69 | // this is called when building the "manage projects" page. The 70 | // results are passed to the angular controller as "repos". 71 | listRepos: function (account, next) { 72 | api.getRepos(account.accessToken, account.login, next); 73 | }, 74 | 75 | // this attempts to connect to the github server with the stored credentials 76 | auth: function (passport) { 77 | var config = this.appConfig; 78 | 79 | if (!config.appId || !config.appSecret || !config.hostname) { 80 | throw new Error( 81 | 'Github plugin misconfigured! Need `appId`, `appSecret` and `hostname`.' 82 | ); 83 | } 84 | 85 | var callbackURL = `${config.hostname}/auth/github/callback`; 86 | 87 | passport.use( 88 | new GithubStrategy( 89 | { 90 | clientID: config.appId, 91 | clientSecret: config.appSecret, 92 | callbackURL: callbackURL, 93 | authorizationURL: `${config.apiDomain}/login/oauth/authorize`, 94 | tokenURL: `${config.apiDomain}/login/oauth/access_token`, 95 | userProfileURL: `${config.apiEndpoint}/user`, 96 | scope: ['repo'], 97 | passReqToCallback: true, 98 | }, 99 | validateAuth 100 | ) 101 | ); 102 | }, 103 | 104 | setupRepo: function (account, config, project, done) { 105 | var url = `${this.appConfig.hostname}/${project.name}/api/github/webhook`; 106 | if (!account.accessToken) 107 | return done(new Error('Github account not configured')); 108 | utils.generateSecret(function (err, secret) { 109 | if (err) return done(err); 110 | config.secret = secret; 111 | api.createHooks( 112 | project.name, 113 | url, 114 | config.secret, 115 | account.accessToken, 116 | function (err) { 117 | if (err) return done(err); 118 | done(null, config); 119 | } 120 | ); 121 | }); 122 | }, 123 | 124 | teardownRepo: function (account, config, project, done) { 125 | var url = `${this.appConfig.hostname}/${project.name}/api/github/webhook`; 126 | if (!account.accessToken) 127 | return done(new Error('Github account not configured')); 128 | api.deleteHooks(project.name, url, account.accessToken, function (err) { 129 | if (err) return done(err); 130 | done(); 131 | }); 132 | }, 133 | 134 | // will be namespaced under /:org/:repo/api/github 135 | routes: function (app, context) { 136 | var config = this.appConfig; 137 | 138 | app.post('/hook', function (req, res) { 139 | var url = `${config.hostname}/${req.project.name}/api/github/webhook`; 140 | var account = req.accountConfig(); 141 | var pconfig = req.providerConfig(); 142 | if (!account.accessToken) 143 | return res.status(400).send('Github account not configured'); 144 | api.createHooks( 145 | req.project.name, 146 | url, 147 | pconfig.secret, 148 | account.accessToken, 149 | function (err) { 150 | if (err) return res.status(500).send(err.message); 151 | res.status(200).send('Webhook registered'); 152 | } 153 | ); 154 | }); 155 | app.delete('/hook', function (req, res) { 156 | var url = `${config.hostname}/${req.project.name}/api/github/webhook`; 157 | var account = req.accountConfig(); 158 | if (!account.accessToken) 159 | return res.status(400).send('Github account not configured'); 160 | api.deleteHooks(req.project.name, url, account.accessToken, function ( 161 | err, 162 | deleted 163 | ) { 164 | if (err) return res.status(500).send(err.message); 165 | res 166 | .status(200) 167 | .send(deleted ? 'Webhook removed' : 'No webhook to delete'); 168 | }); 169 | }); 170 | 171 | // github should hit this endpoint 172 | app.anon.post( 173 | '/webhook', 174 | webhooks.receiveWebhook.bind(null, context.emitter) 175 | ); 176 | }, 177 | // app is namespaced to /ext/github, app.context isn't 178 | // we use app.context to keep the original url structure for backwards compat 179 | globalRoutes: function (app, context) { 180 | context.app.get('/auth/github', context.passport.authenticate('github')); 181 | context.app.get( 182 | '/auth/github/callback', 183 | context.passport.authenticate('github', { failureRedirect: '/login' }), 184 | function (req, res) { 185 | res.redirect('/projects'); 186 | } 187 | ); 188 | }, 189 | }; 190 | 191 | function validateAuth(req, accessToken, refreshToken, profile, done) { 192 | if (!req.user) { 193 | debug('Github OAuth but no logged-in user'); 194 | req.flash( 195 | 'account', 196 | "Cannot link a github account if you aren't logged in" 197 | ); 198 | return done(); 199 | } 200 | 201 | var account = req.user.account('github', profile.id); 202 | 203 | if (account) { 204 | debug("Updating a github account that's already attached..."); 205 | req.flash( 206 | 'account', 207 | 'That github account is already linked, we updated it for you. You can also sign out of github before you click "Add Account"' 208 | ); 209 | account.set({ 210 | config: Object.assign({}, account.config, { 211 | accessToken, 212 | }), 213 | }); 214 | 215 | req.user.save(function (err) { 216 | done(err, req.user); 217 | }); 218 | return; 219 | } 220 | 221 | req.user.accounts.push(makeAccount(accessToken, profile)); 222 | req.user.save(function (err) { 223 | done(err, req.user); 224 | }); 225 | } 226 | 227 | function makeAccount(accessToken, profile) { 228 | if (!profile.emails || !profile.emails.length) { 229 | throw new Error('A public email needs to be setup in your Github profile'); 230 | } 231 | 232 | return { 233 | provider: 'github', 234 | id: profile.id, 235 | display_url: profile.profileUrl, 236 | title: profile.username, 237 | config: { 238 | accessToken: accessToken, 239 | login: profile.username, 240 | email: profile.emails[0].value, 241 | gravatarId: profile._json.gravatar_id, 242 | name: profile.displayName, 243 | }, 244 | cache: [], 245 | }; 246 | } 247 | -------------------------------------------------------------------------------- /lib/webhooks.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var _ = require('lodash'); 4 | var crypto = require('crypto'); 5 | var debug = require('debug')('strider-github:webhooks'); 6 | var gravatar = require('gravatar'); 7 | var scmp = require('scmp'); 8 | var superagent = require('superagent'); 9 | 10 | module.exports = { 11 | receiveWebhook: receiveWebhook, 12 | verifySignature: verifySignature, 13 | pushJob: pushJob, 14 | pullRequestJob: pullRequestJob, 15 | }; 16 | 17 | function makeJob(project, config) { 18 | var now = new Date(); 19 | var deploy = false; 20 | var branch; 21 | var job; 22 | 23 | branch = project.branch(config.branch) || { 24 | active: true, 25 | mirror_master: true, 26 | deploy_on_green: false, 27 | deploy_on_pull_request: false, 28 | }; 29 | if (!branch.active) return false; 30 | if (config.branch !== 'master' && branch.mirror_master) { 31 | // mirror_master branches don't deploy 32 | deploy = false; 33 | } else { 34 | deploy = config.deploy && branch.deploy_on_green; 35 | } 36 | 37 | if (config.trigger.type === 'pull-request') { 38 | deploy = branch.deploy_on_pull_request === true; 39 | } 40 | 41 | job = { 42 | type: deploy ? 'TEST_AND_DEPLOY' : 'TEST_ONLY', 43 | trigger: config.trigger, 44 | project: project.name, 45 | ref: config.ref, 46 | plugin_data: config.plugin_data || {}, 47 | user_id: project.creator._id, 48 | created: now, 49 | }; 50 | return job; 51 | } 52 | 53 | function startFromCommit(project, payload, send) { 54 | var config = pushJob(payload); 55 | var lastCommit = payload.commits 56 | ? payload.commits[payload.commits.length - 1] 57 | : false; 58 | 59 | if (!lastCommit) { 60 | lastCommit = {}; 61 | } 62 | if (!lastCommit.message) { 63 | lastCommit.message = 'No message.'; 64 | } 65 | if (lastCommit.message.indexOf('[skip ci]') > -1) { 66 | return { skipCi: true }; 67 | } 68 | 69 | var branch = project.branch(config.branch); 70 | var job; 71 | 72 | if (branch) { 73 | job = makeJob(project, config); 74 | 75 | if (job) return send(job); 76 | } 77 | 78 | return false; 79 | } 80 | 81 | /** 82 | * post a comment to the pull request asking for confirmation by a whitelisted user 83 | * @param account 84 | * @param pull_request 85 | */ 86 | function askToTestPr(account, pull_request) { 87 | superagent 88 | .post(pull_request._links.comments) 89 | .set('Authorization', `token ${account.accessToken}`) 90 | .send({ 91 | body: 'Should this PR be tested?', 92 | }) 93 | .end(function (err, res) { 94 | if (err) { 95 | return debug('got an error asking to confirm a pull-request', err); 96 | } 97 | 98 | if (res && res.status !== 201) { 99 | debug('Unexpected response to comment creation.', res.status, res.text); 100 | } 101 | }); 102 | } 103 | 104 | function startFromPullRequest(account, config, project, payload, send) { 105 | if (payload.action !== 'opened' && payload.action !== 'synchronize') return; 106 | var user; 107 | if (config.pull_requests === 'whitelist') { 108 | user = _.find(config.whitelist, function (user) { 109 | return user.name === payload.pull_request.user.login; 110 | }); 111 | if (!user) { 112 | if (config.askToPR) askToTestPr(account, payload.pull_request); 113 | return; 114 | } 115 | } 116 | var job = makeJob(project, pullRequestJob(payload.pull_request)); 117 | if (!job) return false; 118 | send(job); 119 | } 120 | 121 | function startFromRelease(account, config, project, payload, send) { 122 | if (payload.action !== 'published' || payload.release.draft) return; 123 | var job = makeJob(project, releaseJob(payload.release)); 124 | if (!job) return false; 125 | send(job); 126 | } 127 | 128 | function pullRequestJob(pr) { 129 | var trigger = { 130 | type: 'pull-request', 131 | author: { 132 | username: pr.user.login, 133 | image: pr.user.avatar_url, 134 | }, 135 | url: pr.html_url, 136 | message: pr.title, 137 | timestamp: pr.updated_at, 138 | source: { 139 | type: 'plugin', 140 | plugin: 'github', 141 | }, 142 | }; 143 | return { 144 | branch: pr.base.ref, 145 | trigger: trigger, 146 | deploy: false, 147 | ref: { 148 | fetch: `refs/pull/${pr.number}/merge`, 149 | branch: pr.base.ref, 150 | }, 151 | plugin_data: { 152 | github: { 153 | pull_request: { 154 | user: pr.head.repo.owner.login, 155 | repo: pr.head.repo.name, 156 | sha: pr.head.sha, 157 | number: pr.number, 158 | body: pr.body, 159 | }, 160 | }, 161 | }, 162 | }; 163 | } 164 | 165 | function releaseJob(release) { 166 | var trigger = { 167 | type: 'release', 168 | author: { 169 | username: release.author.login, 170 | image: release.author.avatar_url, 171 | }, 172 | url: release.html_url, 173 | message: release.name, 174 | timestamp: release.published_at, 175 | source: { 176 | type: 'plugin', 177 | plugin: 'github', 178 | }, 179 | }; 180 | var branch = release.target_commitish; 181 | return { 182 | branch: branch, 183 | trigger: trigger, 184 | deploy: false, 185 | ref: { 186 | fetch: `refs/tags/${release.tag_name}`, 187 | branch: branch, 188 | }, 189 | plugin_data: { 190 | github: { 191 | release: { 192 | user: trigger.author.username, 193 | tag: release.tag_name, 194 | body: release.body, 195 | }, 196 | }, 197 | }, 198 | }; 199 | } 200 | 201 | /** 202 | * 203 | * @param payload 204 | * @returns {Object} returns : {trigger, branch, deploy} 205 | */ 206 | function pushJob(payload) { 207 | var branchname; 208 | var commit = payload.head_commit; 209 | var trigger; 210 | var ref; 211 | if (payload.ref && payload.ref.indexOf('refs/heads/') === 0) { 212 | branchname = payload.ref.substring('refs/heads/'.length); 213 | ref = { 214 | branch: branchname, 215 | id: payload.after, 216 | }; 217 | } else { 218 | ref = { 219 | fetch: payload.ref, 220 | }; 221 | } 222 | if (!commit) { 223 | commit = {}; 224 | } 225 | if (!commit.author) { 226 | commit.author = { 227 | name: 'No name', 228 | username: 'No login', 229 | email: 'noemail@github.com', 230 | }; 231 | } 232 | trigger = { 233 | type: 'commit', 234 | author: { 235 | name: commit.author.name, 236 | username: commit.author.username, 237 | email: commit.author.email, 238 | image: gravatar.url(commit.author.email, {}, true), 239 | }, 240 | url: commit.url, 241 | message: commit.message, 242 | timestamp: commit.timestamp, 243 | source: { 244 | type: 'plugin', 245 | plugin: 'github', 246 | }, 247 | }; 248 | return { 249 | branch: branchname, 250 | trigger: trigger, 251 | deploy: true, 252 | ref: ref, 253 | }; 254 | } 255 | 256 | function startFromComment(account, config, project, payload, send) { 257 | // not for a PR 258 | if (!payload.issue.pull_request || !payload.issue.pull_request.html_url) 259 | return; 260 | var user = _.find(config.whitelist, function (user) { 261 | return user.name === payload.comment.user.login; 262 | }); 263 | if (!user) return; 264 | user = _.find(config.whitelist, function (user) { 265 | return user.name === payload.issue.user.login; 266 | }); 267 | // if the issue was created by a whitelisted user, we assume it's been OKd 268 | if (user) return; 269 | var body = payload.comment.body; 270 | if (!(/\bstrider\b/.test(body) && /\btest\b/.test(body))) { 271 | return; // they didn't ask us to test 272 | } 273 | var pr_number = payload.issue.pull_request.html_url.split('/').slice(-1)[0]; 274 | superagent 275 | .get(payload.repository.pulls_url.replace('{/number}', pr_number)) 276 | .set('Authorization', `token ${account.accessToken}`) 277 | .end(function (err, res) { 278 | if (err) { 279 | return debug('Error occcured when getting a pull-request', err); 280 | } 281 | 282 | if (res && res.status > 299) { 283 | return debug( 284 | 'Failed to get pull request', 285 | res.text, 286 | res.headers, 287 | res.status 288 | ); 289 | } 290 | 291 | var job = makeJob(project, pullRequestJob(res.body)); 292 | if (!job) return false; 293 | send(job); 294 | }); 295 | } 296 | 297 | function receiveWebhook(emitter, req, res) { 298 | var secret = req.providerConfig().secret; 299 | var account = req.accountConfig(); 300 | var config = req.providerConfig(); 301 | var valid = verifySignature( 302 | req.headers['x-hub-signature'], 303 | secret, 304 | req.post_body 305 | ); 306 | if (!valid) { 307 | debug( 308 | `Someone hit the webhook for ${req.project.name} and it failed to validate` 309 | ); 310 | return res.status(401).send('Invalid signature'); 311 | } 312 | debug('got a body:', req.body); 313 | var payload; 314 | try { 315 | payload = JSON.parse(req.body.payload); 316 | } catch (e) { 317 | debug('Webhook payload failed to parse as JSON'); 318 | return res.status(400).send('Invalid JSON in the payload'); 319 | } 320 | 321 | /* Handle Ping Event 322 | * https://developer.github.com/webhooks/#ping-event */ 323 | if (payload.zen) return res.sendStatus(200); 324 | 325 | res.sendStatus(204); 326 | 327 | if (payload.deleted) { 328 | return debug('Branch/tag was deleted, not job created'); 329 | } 330 | 331 | // a release was published 332 | if (payload.release) { 333 | if (!config.release) { 334 | return debug('Got a release, but testing releases is disabled'); 335 | } 336 | return startFromRelease(account, config, req.project, payload, sendJob); 337 | } 338 | 339 | // a new pull request was created 340 | if (payload.pull_request) { 341 | if (config.pull_requests === 'none') { 342 | return debug('Got pull request, but testing pull requests is disabled'); 343 | } 344 | return startFromPullRequest(account, config, req.project, payload, sendJob); 345 | } 346 | 347 | // issue comment 348 | if (payload.comment) { 349 | if (config.pull_requests !== 'whitelist') return; 350 | return startFromComment(account, config, req.project, payload, sendJob); 351 | } 352 | 353 | // ingore new tags and branches 354 | if (payload.ref_type === 'tag' || payload.ref_type === 'branch') { 355 | return debug("New tags/branches aren't currently supported"); 356 | } 357 | 358 | // otherwise, this is a commit 359 | var result = startFromCommit(req.project, payload, sendJob); 360 | 361 | if (result && result.skipCi) { 362 | debug('Skipping commit due to [skip ci] tag'); 363 | } else if (!result) { 364 | debug('webhook received, but no branches matched or branch is not active'); 365 | } 366 | 367 | function sendJob(job) { 368 | emitter.emit('job.prepare', job); 369 | return true; 370 | } 371 | } 372 | 373 | /** 374 | * verifySignature 375 | * 376 | * Verify HMAC-SHA1 signatures. 377 | * 378 | * @param {String} sig Signature. 379 | * @param {String} secret Shared secret, the HMAC-SHA1 was supposedly generated with this. 380 | * @param {String} body The message body to sign. 381 | */ 382 | function verifySignature(sig, secret, body) { 383 | if (!sig || !body) return false; 384 | sig = sig.replace('sha1=', ''); 385 | var hmac = crypto.createHmac('sha1', secret); 386 | hmac.update(body); 387 | var digest = hmac.digest('hex'); 388 | return scmp(sig, digest); 389 | } 390 | -------------------------------------------------------------------------------- /lib/worker.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var git = require('strider-git/worker'); 4 | 5 | module.exports = { 6 | init: function (dirs, account, config, job, done) { 7 | return done(null, { 8 | config: config, 9 | account: account, 10 | fetch: function (context, done) { 11 | module.exports.fetch(dirs.data, account, config, job, context, done); 12 | }, 13 | }); 14 | }, 15 | 16 | fetch: function (dest, account, config, job, context, done) { 17 | if (config.auth.type === 'https' && !config.auth.username) { 18 | config.auth.username = account.accessToken; 19 | config.auth.password = ''; 20 | } 21 | git.fetch(dest, config, job, context, done); 22 | }, 23 | }; 24 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "strider-github", 3 | "version": "3.0.4", 4 | "description": "A GibHub & GitHub Enterprise provider for Strider", 5 | "main": "lib/index.js", 6 | "scripts": { 7 | "test": "npm run lint && npm run tests", 8 | "lint": "eslint lib", 9 | "tests": "mocha --timeout 5000 -R spec test/", 10 | "release": "standard-version" 11 | }, 12 | "repository": { 13 | "type": "git", 14 | "url": "https://github.com/Strider-CD/strider-github.git" 15 | }, 16 | "keywords": [ 17 | "git", 18 | "strider" 19 | ], 20 | "engines": { 21 | "node": ">= 10.13.0 || >= 11.10.1" 22 | }, 23 | "author": "Jared Forsyth ", 24 | "license": "MIT", 25 | "readmeFilename": "README.md", 26 | "strider": { 27 | "id": "github", 28 | "title": "Github", 29 | "type": "provider", 30 | "hosted": true, 31 | "config": { 32 | "controller": "GithubCtrl" 33 | }, 34 | "accountConfig": { 35 | "setupLink": "/auth/github" 36 | }, 37 | "webapp": "lib/webapp.js", 38 | "worker": "lib/worker.js", 39 | "inline_icon": "github" 40 | }, 41 | "devDependencies": { 42 | "eslint": "^7.0.0", 43 | "expect.js": "~0.3.1", 44 | "mocha": "^7.0.1", 45 | "nock": "^12.0.3", 46 | "prettier": "^2.0.5", 47 | "standard-version": "^8.0.0" 48 | }, 49 | "dependencies": { 50 | "async": "^3.1.1", 51 | "debug": "^4.1.1", 52 | "gravatar": "^1.8.0", 53 | "lodash": "^4.17.5", 54 | "passport-github": "^1.1.0", 55 | "scmp": "0.0.2", 56 | "ssh-keypair": "^2.0.0", 57 | "step": "^1.0.0", 58 | "strider-git": "^2.0.0", 59 | "superagent": "^5.2.1" 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /test/json.js: -------------------------------------------------------------------------------- 1 | 2 | process.stdin.resume() 3 | process.stdin.setEncoding('utf8') 4 | var text = '' 5 | process.stdin.on('data', function (data) { 6 | text += data || '' 7 | }) 8 | process.stdin.on('close', function () { 9 | process.stdout.write(JSON.stringify(JSON.parse(text), null, ' ')) 10 | }) 11 | 12 | -------------------------------------------------------------------------------- /test/mocks/setup_nock_repos.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | var nock = require('nock'); 4 | 5 | /* 6 | Set up the mocks for getting a list of repositories for the stridertest user 7 | The stridertest user is an admin of two organizations 8 | stridertestersunion and 9 | stridertesters1 10 | and has two repositories to which he has admin access 11 | stridertestersunion/union-proj-1 12 | stridertester/proj1 13 | 14 | This file sets up mock responses as received from github for each request fired 15 | during the get repositories operation 16 | */ 17 | 18 | module.exports = function () { 19 | nock('https://api.github.com:443', { 20 | reqheaders: { 21 | authorization: 'token 35e31a04c04b09174d20de8287f2e8ddad7d2095' 22 | } 23 | }) 24 | .persist() 25 | .get('/user/repos') 26 | .query({ per_page: '30', page: '1' }) 27 | .reply( 28 | 200, 29 | [ 30 | '1f8b0800000000000003ed9adb6ee33610865f25d06d9dc88e93366ba0d8be412fdadeb4280c5aa62d36922890548244c8bb7786a48ea07ca0e8bbbdc9da0aff6f463ccf9ffda78ed83eda3c2dbf2d978f2f8f8ba820398d365129f87fab68111daa2cdbda675209b6a74251a9a8889b16fcbda022dad451c68fac00e9a0192090bf5abfac9f9e5f5e161179238a886d2532689a2a55ca4d1c9b87f2e1c8545aed2a4945c20b450bf590f03caee246fefdedd735108fc252101dc18311ad649664e48093f138ab54e5d9280b135d6bc6ad0f3ccbf8', 31 | '3b70c6899f0d15b752ec4d8d61c5d11703d23ae62aa5d083f05a5fd8194c2a8fb4b4ac8ef19f2ddb2348c2b808babf3e352b84c4702a7cd5b1a025d7c46a2713c14ac578e191e2400e382e8ea4609fc413077209144cce23192d03397d8369e9a137ba1a960d7b23c907768fa009656fd0e3becc110090eaa3c4d5fb17cc0dec7fa6e896ec735c96079249fab580850df11534d20f16b0042f5e07ed92dfd3765821d88109a9ee703ba08982a8072e5e5bfcc995a9fb74b8d6da184839d3cd937258732086545ee9873703b5750c3fedf24860ed921d1744f173dbc0746203481df7bfe28c5094e4de096b314052cefd7b4e8b01c2a4ace8451373fa653543c6cdcc2faa7c67b6ac4be6fb34d6a8214722253b16947af7580ba8e36637dd095224a93fb2d1d7b1f9a447951cbd53442d207619df7933e05c8b35a08e654acc99a1b673b24222ea0740410fb352447d0b5462c6b8eaf410d0e2e0885230c4def935fab8b63d9891e25891a33fb105c0e8e2017a249f67af17d36ba223000eef4e82edaa791b55c7c00ccd490eebd7bf0b3b4407d45783d3378e132fddbb5ee8d7ce7376ee689ea659f9604acf44e23c1c63f1fbf91bc4e934515fc7dd7e6a366b4bf6ed4dbb5b37f9f5f9f632ee3df48d3eae7f2a894a710782302511d437592b8feb1d815bcdc3c3439d52a26fb139153356a5510386882485bb996f7e75a3879b484e94be131f30bd3ddc91334ef6de7dd902006686cc3747a3ee8f7309c59e77625adca7e52c834a9117fe7b6447e8730baed88125979401d3cb6800a9bf4b56247441b26c01b352b184c13c855a0b470c2e7dd4bf578c1ad287b2dadcf9330a53d6bb970535fa3a36455b2228dce6f75ba2e026feb85c3ddf2f5fee57bffcb97a829a7bb37efe1bdea02af767db94954ccf606037b3530d3e41d13e552e9b2b3c56e0105bcab413fdd649360e33c14a920ce6cc68525f16eb6d7c949c96417a29cf6909c778b42960fa62c5f4099f97832339e15501bd0b0fdf8982bb211c81dda3e6186f0029915bb3b0a28d12151658f0a45bb4bd87efec95f51b611eb22d9c4cfdd305ca9910dcfa2626575ed2c2c6ea25644a1fccb6f7fb41f6facb9e1e4895a9adb9a5c2dcc909ba3ad025251539bc0196ece8ead8f2d1bc0bce9226655cefe6f3d7d7a2e722adbf3db52e525500e61e4bc3fb936692d40de351f3d3ce92d1e02ed8f84b3fafd673fc259087f297dadcaea8ae5b8dbfd764bb318ce3348005f39d2c359cfb648137f2a01a7adfc8822937cf89b2d0597e9465e8030e12baa4a2c61277e4b658ca0dbca936bf81c505995a87eaf79e9787c74458a7ca461fed2743dbea0fe366df193b5bdee9c68d8d75a7b7ab3946963b053f57cbc19a6771b98061fd2e57849b985fae40f39c301731882de60287f6c85c31021866', 32 | '2e6c48f7ccc50f66a5b9e0b37c3517106bfd4026db143e8ce336450f60bf4da10379712e7c4063ce850fe4d2b9d0e12c3b17bdef01e2f93ac3bf73e17bbc8eee67e69dc06b20f07d6d38177aecc761551b928ffbc03846e3a95dedd44cbd405803d015e5166ea02b4e406bd08dd73663389fd01564b669e882dec0417485096027bab0a1bc45173ba4d1e8e2dfc27574c5b9b905e90a1acc8f74c1af31279f36cfab73e664d3e68439d934c16ddcfc5f03f874c29c74a47d955339adbfc6b69ca6c88b3d4c07038eca1f86a6fe03fe5c43f3dfff01dbcdd52a18270000' 33 | ], 34 | { 35 | server: 'GitHub.com', 36 | date: 'Mon, 17 Aug 2015 14:38:18 GMT', 37 | 'content-type': 'application/json; charset=utf-8', 38 | 'transfer-encoding': 'chunked', 39 | connection: 'close', 40 | status: '200 OK', 41 | 'x-ratelimit-limit': '5000', 42 | 'x-ratelimit-remaining': '4997', 43 | 'x-ratelimit-reset': '1439825816', 44 | 'cache-control': 'private, max-age=60, s-maxage=60', 45 | etag: 'W/"b1f3322cba51768ca7bd6d366be82d1c"', 46 | 'x-oauth-scopes': 'repo', 47 | 'x-accepted-oauth-scopes': '', 48 | 'x-oauth-client-id': 'a3af4568e9d8ca4165fe', 49 | vary: 'Accept, Authorization, Cookie, X-GitHub-OTP, Accept-Encoding', 50 | 'x-github-media-type': 'github.v3; format=json', 51 | 'x-xss-protection': '1; mode=block', 52 | 'x-frame-options': 'deny', 53 | 'content-security-policy': 'default-src \'none\'', 54 | 'access-control-allow-credentials': 'true', 55 | 'access-control-expose-headers': 56 | 'ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval', 57 | 'access-control-allow-origin': '*', 58 | 'x-github-request-id': '7E57F4A7:7C7D:AC542C2:55D1F1D9', 59 | 'strict-transport-security': 60 | 'max-age=31536000; includeSubdomains; preload', 61 | 'x-content-type-options': 'nosniff', 62 | 'x-served-by': '4c8b2d4732c413f4b9aefe394bd65569', 63 | 'content-encoding': 'gzip' 64 | } 65 | ); 66 | 67 | nock('https://api.github.com:443', { 68 | reqheaders: { 69 | authorization: 'token 35e31a04c04b09174d20de8287f2e8ddad7d2095' 70 | } 71 | }) 72 | .persist() 73 | .get('/user/orgs') 74 | .query({ per_page: '30', page: '1' }) 75 | .reply( 76 | 200, 77 | [ 78 | '1f8b0800000000000003b591cb0ac2301045ff25eb622c119182f82122d2c7500369126626dd88ff6e6c147ce0c2a8bb30cc3ddcccd91e8571bdb6a212c4a83b400662402a45217427aa52add4623957850868e2d681d9532565edf5acd77c08cdac758374d8937c25207847fb8f9372cac5063082e51c400a46c2004313bf93d1e19a3ccaf43845980f8dd1ed3e9ff908b847d763cd353ed79c86743d7420c0d6598e27996e1ee44dce665c', 79 | 'ab58af036a517bd62efab4c19853f14e6fb097a57bc56586e21b254ff394fe5675827cad3b617eaf3c71ffa2bd546fb5efce2e833594d7030000' 80 | ], 81 | { 82 | server: 'GitHub.com', 83 | date: 'Mon, 17 Aug 2015 14:38:18 GMT', 84 | 'content-type': 'application/json; charset=utf-8', 85 | 'transfer-encoding': 'chunked', 86 | connection: 'close', 87 | status: '200 OK', 88 | 'x-ratelimit-limit': '5000', 89 | 'x-ratelimit-remaining': '4996', 90 | 'x-ratelimit-reset': '1439825816', 91 | 'cache-control': 'private, max-age=60, s-maxage=60', 92 | etag: 'W/"f574132e1ed75cc6bfb5d99facddc93f"', 93 | 'x-oauth-scopes': 'repo', 94 | 'x-accepted-oauth-scopes': 'admin:org, read:org, repo, user, write:org', 95 | 'x-oauth-client-id': 'a3af4568e9d8ca4165fe', 96 | vary: 'Accept, Authorization, Cookie, X-GitHub-OTP, Accept-Encoding', 97 | 'x-github-media-type': 'github.v3; format=json', 98 | 'x-xss-protection': '1; mode=block', 99 | 'x-frame-options': 'deny', 100 | 'content-security-policy': 'default-src \'none\'', 101 | 'access-control-allow-credentials': 'true', 102 | 'access-control-expose-headers': 103 | 'ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval', 104 | 'access-control-allow-origin': '*', 105 | 'x-github-request-id': '7E57F4A7:1CB8:C04DED1:55D1F1D9', 106 | 'strict-transport-security': 107 | 'max-age=31536000; includeSubdomains; preload', 108 | 'x-content-type-options': 'nosniff', 109 | 'x-served-by': 'a7f8a126c9ed3f1c4715a34c0ddc7290', 110 | 'content-encoding': 'gzip' 111 | } 112 | ); 113 | 114 | nock('https://api.github.com:443', { 115 | reqheaders: { 116 | authorization: 'token 35e31a04c04b09174d20de8287f2e8ddad7d2095' 117 | } 118 | }) 119 | .persist() 120 | .get('/orgs/stridertestersunion/teams') 121 | .reply( 122 | 200, 123 | [ 124 | '1f8b0800000000000003958dc10ec2200c86dfa567325c3ca8bc840f608c61a3d99a502014e261d9bb4b16bcebadeddfeffb1f1b04cb0806eeef805940013930e3e5741e6f5705e2ebd2c2f80d1dca9c29158a014ca8de2b489899448e0b58c7149aa466dfb6b59424466b9b6858a8ac751ae6c8baa065d1bda33d33f2d4ba5fbf43ba235b1ff666c998a2508999f02fd5c1c1fefc00bfe427760b010000' 125 | ], 126 | { 127 | server: 'GitHub.com', 128 | date: 'Mon, 17 Aug 2015 14:38:18 GMT', 129 | 'content-type': 'application/json; charset=utf-8', 130 | 'transfer-encoding': 'chunked', 131 | connection: 'close', 132 | status: '200 OK', 133 | 'x-ratelimit-limit': '5000', 134 | 'x-ratelimit-remaining': '4995', 135 | 'x-ratelimit-reset': '1439825816', 136 | 'cache-control': 'private, max-age=60, s-maxage=60', 137 | etag: 'W/"d8f97268999d9593cee3aee8084e3316"', 138 | 'x-oauth-scopes': 'repo', 139 | 'x-accepted-oauth-scopes': 'admin:org, read:org, repo, user, write:org', 140 | 'x-oauth-client-id': 'a3af4568e9d8ca4165fe', 141 | vary: 'Accept, Authorization, Cookie, X-GitHub-OTP, Accept-Encoding', 142 | 'x-github-media-type': 'github.v3; format=json', 143 | 'x-xss-protection': '1; mode=block', 144 | 'x-frame-options': 'deny', 145 | 'content-security-policy': 'default-src \'none\'', 146 | 'access-control-allow-credentials': 'true', 147 | 'access-control-expose-headers': 148 | 'ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval', 149 | 'access-control-allow-origin': '*', 150 | 'x-github-request-id': '7E57F4A7:7C7E:B8F4286:55D1F1DA', 151 | 'strict-transport-security': 152 | 'max-age=31536000; includeSubdomains; preload', 153 | 'x-content-type-options': 'nosniff', 154 | 'x-served-by': 'dc1ce2bfb41810a06c705e83b388572d', 155 | 'content-encoding': 'gzip' 156 | } 157 | ); 158 | 159 | nock('https://api.github.com:443', { 160 | reqheaders: { 161 | authorization: 'token 35e31a04c04b09174d20de8287f2e8ddad7d2095' 162 | } 163 | }) 164 | .persist() 165 | .get('/orgs/stridertesters1/teams') 166 | .reply( 167 | 200, 168 | [ 169 | '1f8b0800000000000003958dd10ac2300c45ff25cf6575088afd093f4044ba356c81a62d4d8b0f63ff6e19f55ddf92dc9c731f1b04cb0806eeef805940013930e3f5741e6f1705e2ebd2c2f80d1dca9c29158a014ca8de2b489899448e0b58c7149aa466dfb6b59424466b9b6858a8ac751ae6c8baa065d1bda33d33f2d4ba5fbf43ba235b1ff666c998a2508999f02fd5c1c1fefc00a61ff3c40b010000' 170 | ], 171 | { 172 | server: 'GitHub.com', 173 | date: 'Mon, 17 Aug 2015 14:38:18 GMT', 174 | 'content-type': 'application/json; charset=utf-8', 175 | 'transfer-encoding': 'chunked', 176 | connection: 'close', 177 | status: '200 OK', 178 | 'x-ratelimit-limit': '5000', 179 | 'x-ratelimit-remaining': '4994', 180 | 'x-ratelimit-reset': '1439825816', 181 | 'cache-control': 'private, max-age=60, s-maxage=60', 182 | etag: 'W/"e803d410fb05ad5ac5773236d6c53a3b"', 183 | 'x-oauth-scopes': 'repo', 184 | 'x-accepted-oauth-scopes': 'admin:org, read:org, repo, user, write:org', 185 | 'x-oauth-client-id': 'a3af4568e9d8ca4165fe', 186 | vary: 'Accept, Authorization, Cookie, X-GitHub-OTP, Accept-Encoding', 187 | 'x-github-media-type': 'github.v3; format=json', 188 | 'x-xss-protection': '1; mode=block', 189 | 'x-frame-options': 'deny', 190 | 'content-security-policy': 'default-src \'none\'', 191 | 'access-control-allow-credentials': 'true', 192 | 'access-control-expose-headers': 193 | 'ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval', 194 | 'access-control-allow-origin': '*', 195 | 'x-github-request-id': '7E57F4A7:3D4C:8F50E71:55D1F1DA', 196 | 'strict-transport-security': 197 | 'max-age=31536000; includeSubdomains; preload', 198 | 'x-content-type-options': 'nosniff', 199 | 'x-served-by': '474556b853193c38f1b14328ce2d1b7d', 200 | 'content-encoding': 'gzip' 201 | } 202 | ); 203 | 204 | nock('https://api.github.com:443', { 205 | reqheaders: { 206 | authorization: 'token 35e31a04c04b09174d20de8287f2e8ddad7d2095' 207 | } 208 | }) 209 | .persist() 210 | .get('/teams/1703196') 211 | .reply( 212 | 200, 213 | [ 214 | '1f8b08000000000000039d53cb6ec23010fc179f038e1bcac352d54fe8a5a75e2293b8c1925fb2d7a016f1ef5d12a352880aedcdebf58e676776f7c40a2309272f3b2b432405512de16c51566c352f48d4a9c3a43b255b199ba03c286709b749eb8278198c8ab1bf21a235ca22480a1aa30d808f9c52e1d5b453b049eb', 215 | '69e30c05294ca4f90f7c6ca459e3dff5fd453497ecf3e18028417a1715b8a0e49fa0faba33168d4b1650828c58e7b82c880b9db0ea530cddef89761d36cb4984a05a194046c036d849c36a59cde66575430c048df41aa167f5bb226395f4d48ddc4a0bff01180aef756594c388393eadb56aeabb9c1ec5fc0970eebbd80a10e152aafe32e6a94b5186c6594049fa014c9465739eb74f15f63a36d603e3414f8eee67069d8a10c9f1e2dd69ed76c7a5f98e94c575c1dc068cbea074b600d77637410a906d2d70f2c843c91e27e572c216af6cc6cb0567abb7e34ef9f6e61bf8f0fd329f4feae1f0055b70c80fe5030000' 216 | ], 217 | { 218 | server: 'GitHub.com', 219 | date: 'Mon, 17 Aug 2015 14:38:19 GMT', 220 | 'content-type': 'application/json; charset=utf-8', 221 | 'transfer-encoding': 'chunked', 222 | connection: 'close', 223 | status: '200 OK', 224 | 'x-ratelimit-limit': '5000', 225 | 'x-ratelimit-remaining': '4993', 226 | 'x-ratelimit-reset': '1439825816', 227 | 'cache-control': 'private, max-age=60, s-maxage=60', 228 | 'last-modified': 'Mon, 17 Aug 2015 14:07:19 GMT', 229 | etag: 'W/"2cc59cbb0d16681eb561d3565e4f0a32"', 230 | 'x-oauth-scopes': 'repo', 231 | 'x-accepted-oauth-scopes': 'admin:org, read:org, repo, user, write:org', 232 | 'x-oauth-client-id': 'a3af4568e9d8ca4165fe', 233 | vary: 'Accept, Authorization, Cookie, X-GitHub-OTP, Accept-Encoding', 234 | 'x-github-media-type': 'github.v3; format=json', 235 | 'x-xss-protection': '1; mode=block', 236 | 'x-frame-options': 'deny', 237 | 'content-security-policy': 'default-src \'none\'', 238 | 'access-control-allow-credentials': 'true', 239 | 'access-control-expose-headers': 240 | 'ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval', 241 | 'access-control-allow-origin': '*', 242 | 'x-github-request-id': '7E57F4A7:3D49:AFC581B:55D1F1DB', 243 | 'strict-transport-security': 244 | 'max-age=31536000; includeSubdomains; preload', 245 | 'x-content-type-options': 'nosniff', 246 | 'x-served-by': '8a5c38021a5cd7cef7b8f49a296fee40', 247 | 'content-encoding': 'gzip' 248 | } 249 | ); 250 | 251 | nock('https://api.github.com:443', { 252 | reqheaders: { 253 | authorization: 'token 35e31a04c04b09174d20de8287f2e8ddad7d2095' 254 | } 255 | }) 256 | .persist() 257 | .get('/teams/1703198') 258 | .reply( 259 | 200, 260 | [ 261 | '1f8b08000000000000039d53cb6ec32010fc17ce4e3071dab848553fa1979e7ab1884d1d245e82c5511be5df8b6da2b891d3a4bdb12c3bcccece1e90668a238a5ef79a3b8f32241a44c9262fc85399212f431b93e6946cb8af9db0208c465407293364b953c2fbe106b146091d41829331da01584f3166562c5b01bbb0', 262 | '5dd64661e04c799cfe888f1557dbf877757f114e258774384614c7adf1028c13fc4f5043dd84456d8286284142acceb1712dd3e28b8ddd1f90346d6c96220f4e34dc01f710db08ba9722e95894c5fa9114370489c01ecfa30cec7e57e65a353e75c63baee1bf2063f1bd53baca656660366ca5a8abbba67f15f727c8d40fac63c0dca574c3a54f6e0c9ebbda6888f20cc60c98a481bd74cf45ec79ceee23eb51dbde2589412b3c7844f30c7d1829cdbe5fa67324745ca398db81921794268b316f81da7106bca95874255ae5e46191970bb279236b9a9794acdefb7db3cdcd37f06987459fbaf878fc065c6ab24001040000' 263 | ], 264 | { 265 | server: 'GitHub.com', 266 | date: 'Mon, 17 Aug 2015 14:38:19 GMT', 267 | 'content-type': 'application/json; charset=utf-8', 268 | 'transfer-encoding': 'chunked', 269 | connection: 'close', 270 | status: '200 OK', 271 | 'x-ratelimit-limit': '5000', 272 | 'x-ratelimit-remaining': '4992', 273 | 'x-ratelimit-reset': '1439825816', 274 | 'cache-control': 'private, max-age=60, s-maxage=60', 275 | 'last-modified': 'Mon, 17 Aug 2015 14:08:12 GMT', 276 | etag: 'W/"ec5c565e40d06277bb4f481f9545f347"', 277 | 'x-oauth-scopes': 'repo', 278 | 'x-accepted-oauth-scopes': 'admin:org, read:org, repo, user, write:org', 279 | 'x-oauth-client-id': 'a3af4568e9d8ca4165fe', 280 | vary: 'Accept, Authorization, Cookie, X-GitHub-OTP, Accept-Encoding', 281 | 'x-github-media-type': 'github.v3; format=json', 282 | 'x-xss-protection': '1; mode=block', 283 | 'x-frame-options': 'deny', 284 | 'content-security-policy': 'default-src \'none\'', 285 | 'access-control-allow-credentials': 'true', 286 | 'access-control-expose-headers': 287 | 'ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval', 288 | 'access-control-allow-origin': '*', 289 | 'x-github-request-id': '7E57F4A7:7C7B:80FD6DC:55D1F1DB', 290 | 'strict-transport-security': 291 | 'max-age=31536000; includeSubdomains; preload', 292 | 'x-content-type-options': 'nosniff', 293 | 'x-served-by': '01d096e6cfe28f8aea352e988c332cd3', 294 | 'content-encoding': 'gzip' 295 | } 296 | ); 297 | 298 | nock('https://api.github.com:443', { 299 | reqheaders: { 300 | authorization: 'token 35e31a04c04b09174d20de8287f2e8ddad7d2095' 301 | } 302 | }) 303 | .persist() 304 | .get('/teams/1703198/members/stridertester') 305 | .reply(204, '', { 306 | server: 'GitHub.com', 307 | date: 'Mon, 17 Aug 2015 14:38:20 GMT', 308 | connection: 'close', 309 | status: '204 No Content', 310 | 'x-ratelimit-limit': '5000', 311 | 'x-ratelimit-remaining': '4991', 312 | 'x-ratelimit-reset': '1439825816', 313 | 'x-oauth-scopes': 'repo', 314 | 'x-accepted-oauth-scopes': 'admin:org, read:org, repo, user, write:org', 315 | 'x-oauth-client-id': 'a3af4568e9d8ca4165fe', 316 | 'x-github-media-type': 'github.v3; format=json', 317 | 'x-xss-protection': '1; mode=block', 318 | 'x-frame-options': 'deny', 319 | 'content-security-policy': 'default-src \'none\'', 320 | 'access-control-allow-credentials': 'true', 321 | 'access-control-expose-headers': 322 | 'ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval', 323 | 'access-control-allow-origin': '*', 324 | 'x-github-request-id': '7E57F4A7:5BA9:BB87F3B:55D1F1DC', 325 | 'strict-transport-security': 326 | 'max-age=31536000; includeSubdomains; preload', 327 | 'x-content-type-options': 'nosniff', 328 | vary: 'Accept-Encoding', 329 | 'x-served-by': '7f48e2f7761567e923121f17538d7a6d' 330 | }); 331 | 332 | nock('https://api.github.com:443', { 333 | reqheaders: { 334 | authorization: 'token 35e31a04c04b09174d20de8287f2e8ddad7d2095' 335 | } 336 | }) 337 | .persist() 338 | .get('/teams/1703196/members/stridertester') 339 | .reply(204, '', { 340 | server: 'GitHub.com', 341 | date: 'Mon, 17 Aug 2015 14:38:20 GMT', 342 | connection: 'close', 343 | status: '204 No Content', 344 | 'x-ratelimit-limit': '5000', 345 | 'x-ratelimit-remaining': '4990', 346 | 'x-ratelimit-reset': '1439825816', 347 | 'x-oauth-scopes': 'repo', 348 | 'x-accepted-oauth-scopes': 'admin:org, read:org, repo, user, write:org', 349 | 'x-oauth-client-id': 'a3af4568e9d8ca4165fe', 350 | 'x-github-media-type': 'github.v3; format=json', 351 | 'x-xss-protection': '1; mode=block', 352 | 'x-frame-options': 'deny', 353 | 'content-security-policy': 'default-src \'none\'', 354 | 'access-control-allow-credentials': 'true', 355 | 'access-control-expose-headers': 356 | 'ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval', 357 | 'access-control-allow-origin': '*', 358 | 'x-github-request-id': '7E57F4A7:5BAB:8E9EAA7:55D1F1DC', 359 | 'strict-transport-security': 360 | 'max-age=31536000; includeSubdomains; preload', 361 | 'x-content-type-options': 'nosniff', 362 | vary: 'Accept-Encoding', 363 | 'x-served-by': '2d7a5e35115884240089368322196939' 364 | }); 365 | 366 | nock('https://api.github.com:443', { 367 | reqheaders: { 368 | authorization: 'token 35e31a04c04b09174d20de8287f2e8ddad7d2095' 369 | } 370 | }) 371 | .persist() 372 | .get('/teams/1703196/repos') 373 | .query({ per_page: '30', page: '1' }) 374 | .reply(200, [], { 375 | server: 'GitHub.com', 376 | date: 'Mon, 17 Aug 2015 14:38:21 GMT', 377 | 'content-type': 'application/json; charset=utf-8', 378 | 'content-length': '2', 379 | connection: 'close', 380 | status: '200 OK', 381 | 'x-ratelimit-limit': '5000', 382 | 'x-ratelimit-remaining': '4989', 383 | 'x-ratelimit-reset': '1439825816', 384 | 'cache-control': 'private, max-age=60, s-maxage=60', 385 | etag: '"9c458f5e39f517ebb1d513d09d33a48d"', 386 | 'x-oauth-scopes': 'repo', 387 | 'x-accepted-oauth-scopes': 'admin:org, read:org, repo, user, write:org', 388 | 'x-oauth-client-id': 'a3af4568e9d8ca4165fe', 389 | vary: 'Accept, Authorization, Cookie, X-GitHub-OTP, Accept-Encoding', 390 | 'x-github-media-type': 'github.v3; format=json', 391 | 'x-xss-protection': '1; mode=block', 392 | 'x-frame-options': 'deny', 393 | 'content-security-policy': 'default-src \'none\'', 394 | 'access-control-allow-credentials': 'true', 395 | 'access-control-expose-headers': 396 | 'ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval', 397 | 'access-control-allow-origin': '*', 398 | 'x-github-request-id': '7E57F4A7:5BA6:7EC3E47:55D1F1DD', 399 | 'strict-transport-security': 400 | 'max-age=31536000; includeSubdomains; preload', 401 | 'x-content-type-options': 'nosniff', 402 | 'x-served-by': 'a30e6f9aa7cf5731b87dfb3b9992202d' 403 | }); 404 | 405 | nock('https://api.github.com:443', { 406 | reqheaders: { 407 | authorization: 'token 35e31a04c04b09174d20de8287f2e8ddad7d2095' 408 | } 409 | }) 410 | .persist() 411 | .get('/teams/1703198/repos') 412 | .query({ per_page: '30', page: '1' }) 413 | .reply( 414 | 200, 415 | [ 416 | '1f8b0800000000000003ad98d16ee33610457f25d06b1dd35e67db5d03c5ee1ff4a17d6a5118b4444b6c245120290709e17fef1d52b264c18815874090d88a7878399c19cef01f97c82cd93eadbeaf569bef4f8ba4e69548b6495b4b553f365afdf7b84e16c9a12dcb5df72f63b5cc84b6c258a18d7f914d5e572fb5d0c9d625a5ca650ddc9531a0d2ccebcdb7cdd3afebcd22e1476eb9deb5bac480c2dac66c190b0fcd3297b668f7ad11', 417 | '3a55b515b55da6aa622deb87ff38febe0131d71d85d0091e4c688dec48613870865dd756d8aa9c68091afcc8eb630eaa2cd50b98d345cc9c969d0164720f9375fe3918008e295b085816cb3d9191a4b1774bf4831da33f3b9911ce60d7b4c8ee95d90d8748729a93635a34ca73dbbd49b56c2c1cec6eb917104095ce792ddff8a7a08018b048e8ddc2fc6040c411ce7c37258c76acd1f2c8d357329b16a99047ecc7e7c8130cc0f6b5a1ccf0c7c882b44bd28a1dcf2a0af3032f8d382d12afc6e265ff608160fe602c4df34926ce9e00057f8604f4d065a0079f7c1e285789d43ef874a5f4f379f6775380df866be13c9540c81bdb348f85200709229fc56b1c20811cc3ef2e2053640ebe579a5b752b15cd947c41746cfc955cce0a5ec5598a2781582815c9da9e04a234a615b36262a64d3cd0b03e02ebb6da87f43a27ee66ce115050cf8d91792d441c2b9f698ef567c25ef33a2d22f17b9863e193f7119ec7114f20f0f6a5dac701e258679ee69829783820ed2e9a5ec213ec82aec5219e78829de956c7f2122f9c6867368e6a0b8789a3bc8731d759bde475def23c12fe4c83af508991f3b79b85d9cca81c70605339aae5be8d986b0720690f5510724b24b30fbc81ee6bacf70bb8b9b619156dde3a55256f953733d11deb228e62f2c9dfa773d0f7dbf5d907164030c786c3221c4bdd345176a03b977ae5e3c9bad6298e23f530e67e69b82d286f62ce866b1165191d8bb93d474db95c2e5d21b8ef372aa1632589800293ebb440d11c45b9eb6128f42a6e7d477320e1193a9c52f12c8efdcf3490c39e47511f5063af69d0fbc791ec496374254bdc22a83a52ce1f70e3496a65e541a6735abe99817c41743f8cac53b1e065b980f75b994ac403da6eda72d4e12292f1020a0bc3054de8f34a81d088b3335a049863a1a74fb5400397edb845bff565b5fefab8faf6b8feedaff5d3163f5fd77f636d6d93dd7ca7694d710383b4db392e3ee1c6e7fd5b968bb68cee7220c4986220fc1cc66fafdcd25c1b9f96f0c049f0dca1e2383d413fc0c02a0a558906e54fb2ad1126d458bfe1f3eaa27a49555b6347f0f0855b94eba80986477dc5d3030a6e76219a93add52df5e17832a48dd1c317f92cc72f910e736ea043eb3b4c5449ad55775117b4aa46d4dd5c2341a1d125b5a3ff5fa8f75f3271e06d6977a15780bf559c6e16619246e80a2b406d02312ee96e19c25ac8b37ac99457c2e7d3e9dfff01e6a10cc4d2140000' 418 | ], 419 | { 420 | server: 'GitHub.com', 421 | date: 'Mon, 17 Aug 2015 14:38:21 GMT', 422 | 'content-type': 'application/json; charset=utf-8', 423 | 'transfer-encoding': 'chunked', 424 | connection: 'close', 425 | status: '200 OK', 426 | 'x-ratelimit-limit': '5000', 427 | 'x-ratelimit-remaining': '4988', 428 | 'x-ratelimit-reset': '1439825816', 429 | 'cache-control': 'private, max-age=60, s-maxage=60', 430 | etag: 'W/"9edb20d1a1c134303874d4f4280a450f"', 431 | 'x-oauth-scopes': 'repo', 432 | 'x-accepted-oauth-scopes': 'admin:org, read:org, repo, user, write:org', 433 | 'x-oauth-client-id': 'a3af4568e9d8ca4165fe', 434 | vary: 'Accept, Authorization, Cookie, X-GitHub-OTP, Accept-Encoding', 435 | 'x-github-media-type': 'github.v3; format=json', 436 | 'x-xss-protection': '1; mode=block', 437 | 'x-frame-options': 'deny', 438 | 'content-security-policy': 'default-src \'none\'', 439 | 'access-control-allow-credentials': 'true', 440 | 'access-control-expose-headers': 441 | 'ETag, Link, X-GitHub-OTP, X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset, X-OAuth-Scopes, X-Accepted-OAuth-Scopes, X-Poll-Interval', 442 | 'access-control-allow-origin': '*', 443 | 'x-github-request-id': '7E57F4A7:5BAB:8E9EC6D:55D1F1DD', 444 | 'strict-transport-security': 445 | 'max-age=31536000; includeSubdomains; preload', 446 | 'x-content-type-options': 'nosniff', 447 | 'x-served-by': '13d09b732ebe76f892093130dc088652', 448 | 'content-encoding': 'gzip' 449 | } 450 | ); 451 | }; 452 | -------------------------------------------------------------------------------- /test/sample_commit.json: -------------------------------------------------------------------------------- 1 | { 2 | "ref": "refs/heads/master", 3 | "after": "5440158e185393ddedcabcbc615f574d10134cdb", 4 | "before": "adbf016e0788825268ed28b3e620fa665ce285a8", 5 | "created": false, 6 | "deleted": false, 7 | "forced": false, 8 | "compare": "https://github.com/jaredly/django-colorfield/compare/adbf016e0788...5440158e1853", 9 | "commits": [ 10 | { 11 | "id": "0992306e0d53bfc45637f79674d67a7fc510b287", 12 | "distinct": true, 13 | "message": "Merge pull request #1 from django-stars/master\n\nBacis setup.py to allow usage with pip", 14 | "timestamp": "2013-09-18T10:44:31-07:00", 15 | "url": "https://github.com/jaredly/django-colorfield/commit/0992306e0d53bfc45637f79674d67a7fc510b287", 16 | "author": { 17 | "name": "Jared Forsyth", 18 | "email": "jared@jaredforsyth.com", 19 | "username": "jaredly" 20 | }, 21 | "committer": { 22 | "name": "Jared Forsyth", 23 | "email": "jared@jaredforsyth.com", 24 | "username": "jaredly" 25 | }, 26 | "added": [ 27 | "setup.py" 28 | ], 29 | "removed": [], 30 | "modified": [] 31 | }, 32 | { 33 | "id": "b16f59a697ee3dd27eb043781186cdbb6c4b0a72", 34 | "distinct": true, 35 | "message": "adding manifest, updating setup.py", 36 | "timestamp": "2013-09-18T10:51:12-07:00", 37 | "url": "https://github.com/jaredly/django-colorfield/commit/b16f59a697ee3dd27eb043781186cdbb6c4b0a72", 38 | "author": { 39 | "name": "Jared Forsyth", 40 | "email": "jared@jaredforsyth.com", 41 | "username": "jaredly" 42 | }, 43 | "committer": { 44 | "name": "Jared Forsyth", 45 | "email": "jared@jaredforsyth.com", 46 | "username": "jaredly" 47 | }, 48 | "added": [ 49 | ".gitignore", 50 | "MANIFEST.in" 51 | ], 52 | "removed": [], 53 | "modified": [ 54 | "setup.py" 55 | ] 56 | }, 57 | { 58 | "id": "5440158e185393ddedcabcbc615f574d10134cdb", 59 | "distinct": true, 60 | "message": "adding mit license", 61 | "timestamp": "2013-10-05T17:09:00-07:00", 62 | "url": "https://github.com/jaredly/django-colorfield/commit/5440158e185393ddedcabcbc615f574d10134cdb", 63 | "author": { 64 | "name": "Jared Forsyth", 65 | "email": "jared@jaredforsyth.com", 66 | "username": "jaredly" 67 | }, 68 | "committer": { 69 | "name": "Jared Forsyth", 70 | "email": "jared@jaredforsyth.com", 71 | "username": "jaredly" 72 | }, 73 | "added": [ 74 | "LICENSE" 75 | ], 76 | "removed": [], 77 | "modified": [] 78 | } 79 | ], 80 | "head_commit": { 81 | "id": "5440158e185393ddedcabcbc615f574d10134cdb", 82 | "distinct": true, 83 | "message": "adding mit license", 84 | "timestamp": "2013-10-05T17:09:00-07:00", 85 | "url": "https://github.com/jaredly/django-colorfield/commit/5440158e185393ddedcabcbc615f574d10134cdb", 86 | "author": { 87 | "name": "Jared Forsyth", 88 | "email": "jared@jaredforsyth.com", 89 | "username": "jaredly" 90 | }, 91 | "committer": { 92 | "name": "Jared Forsyth", 93 | "email": "jared@jaredforsyth.com", 94 | "username": "jaredly" 95 | }, 96 | "added": [ 97 | "LICENSE" 98 | ], 99 | "removed": [], 100 | "modified": [] 101 | }, 102 | "repository": { 103 | "id": 645063, 104 | "name": "django-colorfield", 105 | "url": "https://github.com/jaredly/django-colorfield", 106 | "description": "a small app providing a colorpicker field for django", 107 | "homepage": "http://jaredforsyth.com/projects/django-colorfield", 108 | "watchers": 8, 109 | "stargazers": 8, 110 | "forks": 12, 111 | "fork": false, 112 | "size": 122, 113 | "owner": { 114 | "name": "jaredly", 115 | "email": "jabapyth@gmail.com" 116 | }, 117 | "private": false, 118 | "open_issues": 0, 119 | "has_issues": true, 120 | "has_downloads": true, 121 | "has_wiki": true, 122 | "language": "JavaScript", 123 | "created_at": 1272917979, 124 | "pushed_at": 1381018140, 125 | "master_branch": "master" 126 | }, 127 | "pusher": { 128 | "name": "none" 129 | } 130 | } -------------------------------------------------------------------------------- /test/sample_commit_payload.json: -------------------------------------------------------------------------------- 1 | { 2 | "after":"1481a2de7b2a7d02428ad93446ab166be7793fbb", 3 | "before":"17c497ccc7cca9c2f735aa07e9e3813060ce9a6a", 4 | "commits":[ 5 | { 6 | "added":[ 7 | 8 | ], 9 | "author":{ 10 | "email":"lolwut@noway.biz", 11 | "name":"Garen Torikian", 12 | "username":"octokitty" 13 | }, 14 | "committer":{ 15 | "email":"lolwut@noway.biz", 16 | "name":"Garen Torikian", 17 | "username":"octokitty" 18 | }, 19 | "distinct":true, 20 | "id":"c441029cf673f84c8b7db52d0a5944ee5c52ff89", 21 | "message":"Test", 22 | "modified":[ 23 | "README.md" 24 | ], 25 | "removed":[ 26 | 27 | ], 28 | "timestamp":"2013-02-22T13:50:07-08:00", 29 | "url":"https://github.com/octokitty/testing/commit/c441029cf673f84c8b7db52d0a5944ee5c52ff89" 30 | }, 31 | { 32 | "added":[ 33 | 34 | ], 35 | "author":{ 36 | "email":"lolwut@noway.biz", 37 | "name":"Garen Torikian", 38 | "username":"octokitty" 39 | }, 40 | "committer":{ 41 | "email":"lolwut@noway.biz", 42 | "name":"Garen Torikian", 43 | "username":"octokitty" 44 | }, 45 | "distinct":true, 46 | "id":"36c5f2243ed24de58284a96f2a643bed8c028658", 47 | "message":"This is me testing the windows client.", 48 | "modified":[ 49 | "README.md" 50 | ], 51 | "removed":[ 52 | 53 | ], 54 | "timestamp":"2013-02-22T14:07:13-08:00", 55 | "url":"https://github.com/octokitty/testing/commit/36c5f2243ed24de58284a96f2a643bed8c028658" 56 | }, 57 | { 58 | "added":[ 59 | "words/madame-bovary.txt" 60 | ], 61 | "author":{ 62 | "email":"lolwut@noway.biz", 63 | "name":"Garen Torikian", 64 | "username":"octokitty" 65 | }, 66 | "committer":{ 67 | "email":"lolwut@noway.biz", 68 | "name":"Garen Torikian", 69 | "username":"octokitty" 70 | }, 71 | "distinct":true, 72 | "id":"1481a2de7b2a7d02428ad93446ab166be7793fbb", 73 | "message":"Rename madame-bovary.txt to words/madame-bovary.txt", 74 | "modified":[ 75 | 76 | ], 77 | "removed":[ 78 | "madame-bovary.txt" 79 | ], 80 | "timestamp":"2013-03-12T08:14:29-07:00", 81 | "url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb" 82 | } 83 | ], 84 | "compare":"https://github.com/octokitty/testing/compare/17c497ccc7cc...1481a2de7b2a", 85 | "created":false, 86 | "deleted":false, 87 | "forced":false, 88 | "head_commit":{ 89 | "added":[ 90 | "words/madame-bovary.txt" 91 | ], 92 | "author":{ 93 | "email":"lolwut@noway.biz", 94 | "name":"Garen Torikian", 95 | "username":"octokitty" 96 | }, 97 | "committer":{ 98 | "email":"lolwut@noway.biz", 99 | "name":"Garen Torikian", 100 | "username":"octokitty" 101 | }, 102 | "distinct":true, 103 | "id":"1481a2de7b2a7d02428ad93446ab166be7793fbb", 104 | "message":"Rename madame-bovary.txt to words/madame-bovary.txt", 105 | "modified":[ 106 | 107 | ], 108 | "removed":[ 109 | "madame-bovary.txt" 110 | ], 111 | "timestamp":"2013-03-12T08:14:29-07:00", 112 | "url":"https://github.com/octokitty/testing/commit/1481a2de7b2a7d02428ad93446ab166be7793fbb" 113 | }, 114 | "pusher":{ 115 | "email":"lolwut@noway.biz", 116 | "name":"Garen Torikian" 117 | }, 118 | "ref":"refs/heads/master", 119 | "repository":{ 120 | "created_at":1332977768, 121 | "description":"", 122 | "fork":false, 123 | "forks":0, 124 | "has_downloads":true, 125 | "has_issues":true, 126 | "has_wiki":true, 127 | "homepage":"", 128 | "id":3860742, 129 | "language":"Ruby", 130 | "master_branch":"master", 131 | "name":"testing", 132 | "open_issues":2, 133 | "owner":{ 134 | "email":"lolwut@noway.biz", 135 | "name":"octokitty" 136 | }, 137 | "private":false, 138 | "pushed_at":1363295520, 139 | "size":2156, 140 | "stargazers":1, 141 | "url":"https://github.com/octokitty/testing", 142 | "watchers":1 143 | } 144 | } -------------------------------------------------------------------------------- /test/sample_issue_comment.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "created", 3 | "issue": { 4 | "url": "https://api.github.com/repos/jaredly/petulant-wookie/issues/1", 5 | "labels_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues/1/labels{/name}", 6 | "comments_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues/1/comments", 7 | "events_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues/1/events", 8 | "html_url": "https://github.com/jaredly/petulant-wookie/pull/1", 9 | "id": 20824475, 10 | "number": 1, 11 | "title": "Example pull request", 12 | "user": { 13 | "login": "jaredly", 14 | "id": 112170, 15 | "avatar_url": "https://2.gravatar.com/avatar/313878fc8f316fc3fe4443b13913d0a4?d=https%3A%2F%2Fidenticons.github.com%2Fb12c483d8922cb5945bd4ffdae6d591d.png", 16 | "gravatar_id": "313878fc8f316fc3fe4443b13913d0a4", 17 | "url": "https://api.github.com/users/jaredly", 18 | "html_url": "https://github.com/jaredly", 19 | "followers_url": "https://api.github.com/users/jaredly/followers", 20 | "following_url": "https://api.github.com/users/jaredly/following{/other_user}", 21 | "gists_url": "https://api.github.com/users/jaredly/gists{/gist_id}", 22 | "starred_url": "https://api.github.com/users/jaredly/starred{/owner}{/repo}", 23 | "subscriptions_url": "https://api.github.com/users/jaredly/subscriptions", 24 | "organizations_url": "https://api.github.com/users/jaredly/orgs", 25 | "repos_url": "https://api.github.com/users/jaredly/repos", 26 | "events_url": "https://api.github.com/users/jaredly/events{/privacy}", 27 | "received_events_url": "https://api.github.com/users/jaredly/received_events", 28 | "type": "User", 29 | "site_admin": false 30 | }, 31 | "labels": [], 32 | "state": "open", 33 | "assignee": null, 34 | "milestone": null, 35 | "comments": 1, 36 | "created_at": "2013-10-10T18:04:25Z", 37 | "updated_at": "2013-10-10T18:22:14Z", 38 | "closed_at": null, 39 | "pull_request": { 40 | "html_url": "https://github.com/jaredly/petulant-wookie/pull/1", 41 | "diff_url": "https://github.com/jaredly/petulant-wookie/pull/1.diff", 42 | "patch_url": "https://github.com/jaredly/petulant-wookie/pull/1.patch" 43 | }, 44 | "body": "This is the body." 45 | }, 46 | "comment": { 47 | "url": "https://api.github.com/repos/jaredly/petulant-wookie/issues/comments/26078122", 48 | "html_url": "https://github.com/jaredly/petulant-wookie/pull/1#issuecomment-26078122", 49 | "issue_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues/1", 50 | "id": 26078122, 51 | "user": { 52 | "login": "jaredly", 53 | "id": 112170, 54 | "avatar_url": "https://2.gravatar.com/avatar/313878fc8f316fc3fe4443b13913d0a4?d=https%3A%2F%2Fidenticons.github.com%2Fb12c483d8922cb5945bd4ffdae6d591d.png", 55 | "gravatar_id": "313878fc8f316fc3fe4443b13913d0a4", 56 | "url": "https://api.github.com/users/jaredly", 57 | "html_url": "https://github.com/jaredly", 58 | "followers_url": "https://api.github.com/users/jaredly/followers", 59 | "following_url": "https://api.github.com/users/jaredly/following{/other_user}", 60 | "gists_url": "https://api.github.com/users/jaredly/gists{/gist_id}", 61 | "starred_url": "https://api.github.com/users/jaredly/starred{/owner}{/repo}", 62 | "subscriptions_url": "https://api.github.com/users/jaredly/subscriptions", 63 | "organizations_url": "https://api.github.com/users/jaredly/orgs", 64 | "repos_url": "https://api.github.com/users/jaredly/repos", 65 | "events_url": "https://api.github.com/users/jaredly/events{/privacy}", 66 | "received_events_url": "https://api.github.com/users/jaredly/received_events", 67 | "type": "User", 68 | "site_admin": false 69 | }, 70 | "created_at": "2013-10-10T18:22:14Z", 71 | "updated_at": "2013-10-10T18:22:14Z", 72 | "body": "strider, please test" 73 | }, 74 | "repository": { 75 | "id": 11556826, 76 | "name": "petulant-wookie", 77 | "full_name": "jaredly/petulant-wookie", 78 | "owner": { 79 | "login": "jaredly", 80 | "id": 112170, 81 | "avatar_url": "https://0.gravatar.com/avatar/313878fc8f316fc3fe4443b13913d0a4?d=https%3A%2F%2Fidenticons.github.com%2Fb12c483d8922cb5945bd4ffdae6d591d.png", 82 | "gravatar_id": "313878fc8f316fc3fe4443b13913d0a4", 83 | "url": "https://api.github.com/users/jaredly", 84 | "html_url": "https://github.com/jaredly", 85 | "followers_url": "https://api.github.com/users/jaredly/followers", 86 | "following_url": "https://api.github.com/users/jaredly/following{/other_user}", 87 | "gists_url": "https://api.github.com/users/jaredly/gists{/gist_id}", 88 | "starred_url": "https://api.github.com/users/jaredly/starred{/owner}{/repo}", 89 | "subscriptions_url": "https://api.github.com/users/jaredly/subscriptions", 90 | "organizations_url": "https://api.github.com/users/jaredly/orgs", 91 | "repos_url": "https://api.github.com/users/jaredly/repos", 92 | "events_url": "https://api.github.com/users/jaredly/events{/privacy}", 93 | "received_events_url": "https://api.github.com/users/jaredly/received_events", 94 | "type": "User", 95 | "site_admin": false 96 | }, 97 | "private": false, 98 | "html_url": "https://github.com/jaredly/petulant-wookie", 99 | "description": "repo for trying out CI solutions", 100 | "fork": false, 101 | "url": "https://api.github.com/repos/jaredly/petulant-wookie", 102 | "forks_url": "https://api.github.com/repos/jaredly/petulant-wookie/forks", 103 | "keys_url": "https://api.github.com/repos/jaredly/petulant-wookie/keys{/key_id}", 104 | "collaborators_url": "https://api.github.com/repos/jaredly/petulant-wookie/collaborators{/collaborator}", 105 | "teams_url": "https://api.github.com/repos/jaredly/petulant-wookie/teams", 106 | "hooks_url": "https://api.github.com/repos/jaredly/petulant-wookie/hooks", 107 | "issue_events_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues/events{/number}", 108 | "events_url": "https://api.github.com/repos/jaredly/petulant-wookie/events", 109 | "assignees_url": "https://api.github.com/repos/jaredly/petulant-wookie/assignees{/user}", 110 | "branches_url": "https://api.github.com/repos/jaredly/petulant-wookie/branches{/branch}", 111 | "tags_url": "https://api.github.com/repos/jaredly/petulant-wookie/tags", 112 | "blobs_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/blobs{/sha}", 113 | "git_tags_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/tags{/sha}", 114 | "git_refs_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/refs{/sha}", 115 | "trees_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/trees{/sha}", 116 | "statuses_url": "https://api.github.com/repos/jaredly/petulant-wookie/statuses/{sha}", 117 | "languages_url": "https://api.github.com/repos/jaredly/petulant-wookie/languages", 118 | "stargazers_url": "https://api.github.com/repos/jaredly/petulant-wookie/stargazers", 119 | "contributors_url": "https://api.github.com/repos/jaredly/petulant-wookie/contributors", 120 | "subscribers_url": "https://api.github.com/repos/jaredly/petulant-wookie/subscribers", 121 | "subscription_url": "https://api.github.com/repos/jaredly/petulant-wookie/subscription", 122 | "commits_url": "https://api.github.com/repos/jaredly/petulant-wookie/commits{/sha}", 123 | "git_commits_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/commits{/sha}", 124 | "comments_url": "https://api.github.com/repos/jaredly/petulant-wookie/comments{/number}", 125 | "issue_comment_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues/comments/{number}", 126 | "contents_url": "https://api.github.com/repos/jaredly/petulant-wookie/contents/{+path}", 127 | "compare_url": "https://api.github.com/repos/jaredly/petulant-wookie/compare/{base}...{head}", 128 | "merges_url": "https://api.github.com/repos/jaredly/petulant-wookie/merges", 129 | "archive_url": "https://api.github.com/repos/jaredly/petulant-wookie/{archive_format}{/ref}", 130 | "downloads_url": "https://api.github.com/repos/jaredly/petulant-wookie/downloads", 131 | "issues_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues{/number}", 132 | "pulls_url": "https://api.github.com/repos/jaredly/petulant-wookie/pulls{/number}", 133 | "milestones_url": "https://api.github.com/repos/jaredly/petulant-wookie/milestones{/number}", 134 | "notifications_url": "https://api.github.com/repos/jaredly/petulant-wookie/notifications{?since,all,participating}", 135 | "labels_url": "https://api.github.com/repos/jaredly/petulant-wookie/labels{/name}", 136 | "created_at": "2013-07-21T03:48:28Z", 137 | "updated_at": "2013-10-10T18:22:14Z", 138 | "pushed_at": "2013-10-10T17:58:29Z", 139 | "git_url": "git://github.com/jaredly/petulant-wookie.git", 140 | "ssh_url": "git@github.com:jaredly/petulant-wookie.git", 141 | "clone_url": "https://github.com/jaredly/petulant-wookie.git", 142 | "svn_url": "https://github.com/jaredly/petulant-wookie", 143 | "homepage": null, 144 | "size": 108, 145 | "watchers_count": 0, 146 | "language": null, 147 | "has_issues": true, 148 | "has_downloads": true, 149 | "has_wiki": true, 150 | "forks_count": 0, 151 | "mirror_url": null, 152 | "open_issues_count": 1, 153 | "forks": 0, 154 | "open_issues": 1, 155 | "watchers": 0, 156 | "master_branch": "master", 157 | "default_branch": "master" 158 | }, 159 | "sender": { 160 | "login": "jaredly", 161 | "id": 112170, 162 | "avatar_url": "https://0.gravatar.com/avatar/313878fc8f316fc3fe4443b13913d0a4?d=https%3A%2F%2Fidenticons.github.com%2Fb12c483d8922cb5945bd4ffdae6d591d.png", 163 | "gravatar_id": "313878fc8f316fc3fe4443b13913d0a4", 164 | "url": "https://api.github.com/users/jaredly", 165 | "html_url": "https://github.com/jaredly", 166 | "followers_url": "https://api.github.com/users/jaredly/followers", 167 | "following_url": "https://api.github.com/users/jaredly/following{/other_user}", 168 | "gists_url": "https://api.github.com/users/jaredly/gists{/gist_id}", 169 | "starred_url": "https://api.github.com/users/jaredly/starred{/owner}{/repo}", 170 | "subscriptions_url": "https://api.github.com/users/jaredly/subscriptions", 171 | "organizations_url": "https://api.github.com/users/jaredly/orgs", 172 | "repos_url": "https://api.github.com/users/jaredly/repos", 173 | "events_url": "https://api.github.com/users/jaredly/events{/privacy}", 174 | "received_events_url": "https://api.github.com/users/jaredly/received_events", 175 | "type": "User", 176 | "site_admin": false 177 | } 178 | } -------------------------------------------------------------------------------- /test/sample_pull_request.json: -------------------------------------------------------------------------------- 1 | { 2 | "action": "opened", 3 | "number": 1, 4 | "pull_request": { 5 | "url": "https://api.github.com/repos/jaredly/petulant-wookie/pulls/1", 6 | "id": 8999127, 7 | "html_url": "https://github.com/jaredly/petulant-wookie/pull/1", 8 | "diff_url": "https://github.com/jaredly/petulant-wookie/pull/1.diff", 9 | "patch_url": "https://github.com/jaredly/petulant-wookie/pull/1.patch", 10 | "issue_url": "https://github.com/jaredly/petulant-wookie/pull/1", 11 | "number": 1, 12 | "state": "open", 13 | "title": "Example pull request", 14 | "user": { 15 | "login": "jaredly", 16 | "id": 112170, 17 | "avatar_url": "https://0.gravatar.com/avatar/313878fc8f316fc3fe4443b13913d0a4?d=https%3A%2F%2Fidenticons.github.com%2Fb12c483d8922cb5945bd4ffdae6d591d.png", 18 | "gravatar_id": "313878fc8f316fc3fe4443b13913d0a4", 19 | "url": "https://api.github.com/users/jaredly", 20 | "html_url": "https://github.com/jaredly", 21 | "followers_url": "https://api.github.com/users/jaredly/followers", 22 | "following_url": "https://api.github.com/users/jaredly/following{/other_user}", 23 | "gists_url": "https://api.github.com/users/jaredly/gists{/gist_id}", 24 | "starred_url": "https://api.github.com/users/jaredly/starred{/owner}{/repo}", 25 | "subscriptions_url": "https://api.github.com/users/jaredly/subscriptions", 26 | "organizations_url": "https://api.github.com/users/jaredly/orgs", 27 | "repos_url": "https://api.github.com/users/jaredly/repos", 28 | "events_url": "https://api.github.com/users/jaredly/events{/privacy}", 29 | "received_events_url": "https://api.github.com/users/jaredly/received_events", 30 | "type": "User", 31 | "site_admin": false 32 | }, 33 | "body": "This is the body.", 34 | "created_at": "2013-10-10T18:04:25Z", 35 | "updated_at": "2013-10-10T18:04:25Z", 36 | "closed_at": null, 37 | "merged_at": null, 38 | "merge_commit_sha": null, 39 | "assignee": null, 40 | "milestone": null, 41 | "commits_url": "https://github.com/jaredly/petulant-wookie/pull/1/commits", 42 | "review_comments_url": "https://github.com/jaredly/petulant-wookie/pull/1/comments", 43 | "review_comment_url": "/repos/jaredly/petulant-wookie/pulls/comments/{number}", 44 | "comments_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues/1/comments", 45 | "statuses_url": "https://api.github.com/repos/jaredly/petulant-wookie/statuses/f65ac3101a45bb9408c0459805b496cb73ae2d5f", 46 | "head": { 47 | "label": "jaredly:otherbranch", 48 | "ref": "otherbranch", 49 | "sha": "f65ac3101a45bb9408c0459805b496cb73ae2d5f", 50 | "user": { 51 | "login": "jaredly", 52 | "id": 112170, 53 | "avatar_url": "https://0.gravatar.com/avatar/313878fc8f316fc3fe4443b13913d0a4?d=https%3A%2F%2Fidenticons.github.com%2Fb12c483d8922cb5945bd4ffdae6d591d.png", 54 | "gravatar_id": "313878fc8f316fc3fe4443b13913d0a4", 55 | "url": "https://api.github.com/users/jaredly", 56 | "html_url": "https://github.com/jaredly", 57 | "followers_url": "https://api.github.com/users/jaredly/followers", 58 | "following_url": "https://api.github.com/users/jaredly/following{/other_user}", 59 | "gists_url": "https://api.github.com/users/jaredly/gists{/gist_id}", 60 | "starred_url": "https://api.github.com/users/jaredly/starred{/owner}{/repo}", 61 | "subscriptions_url": "https://api.github.com/users/jaredly/subscriptions", 62 | "organizations_url": "https://api.github.com/users/jaredly/orgs", 63 | "repos_url": "https://api.github.com/users/jaredly/repos", 64 | "events_url": "https://api.github.com/users/jaredly/events{/privacy}", 65 | "received_events_url": "https://api.github.com/users/jaredly/received_events", 66 | "type": "User", 67 | "site_admin": false 68 | }, 69 | "repo": { 70 | "id": 11556826, 71 | "name": "petulant-wookie", 72 | "full_name": "jaredly/petulant-wookie", 73 | "owner": { 74 | "login": "jaredly", 75 | "id": 112170, 76 | "avatar_url": "https://0.gravatar.com/avatar/313878fc8f316fc3fe4443b13913d0a4?d=https%3A%2F%2Fidenticons.github.com%2Fb12c483d8922cb5945bd4ffdae6d591d.png", 77 | "gravatar_id": "313878fc8f316fc3fe4443b13913d0a4", 78 | "url": "https://api.github.com/users/jaredly", 79 | "html_url": "https://github.com/jaredly", 80 | "followers_url": "https://api.github.com/users/jaredly/followers", 81 | "following_url": "https://api.github.com/users/jaredly/following{/other_user}", 82 | "gists_url": "https://api.github.com/users/jaredly/gists{/gist_id}", 83 | "starred_url": "https://api.github.com/users/jaredly/starred{/owner}{/repo}", 84 | "subscriptions_url": "https://api.github.com/users/jaredly/subscriptions", 85 | "organizations_url": "https://api.github.com/users/jaredly/orgs", 86 | "repos_url": "https://api.github.com/users/jaredly/repos", 87 | "events_url": "https://api.github.com/users/jaredly/events{/privacy}", 88 | "received_events_url": "https://api.github.com/users/jaredly/received_events", 89 | "type": "User", 90 | "site_admin": false 91 | }, 92 | "private": false, 93 | "html_url": "https://github.com/jaredly/petulant-wookie", 94 | "description": "repo for trying out CI solutions", 95 | "fork": false, 96 | "url": "https://api.github.com/repos/jaredly/petulant-wookie", 97 | "forks_url": "https://api.github.com/repos/jaredly/petulant-wookie/forks", 98 | "keys_url": "https://api.github.com/repos/jaredly/petulant-wookie/keys{/key_id}", 99 | "collaborators_url": "https://api.github.com/repos/jaredly/petulant-wookie/collaborators{/collaborator}", 100 | "teams_url": "https://api.github.com/repos/jaredly/petulant-wookie/teams", 101 | "hooks_url": "https://api.github.com/repos/jaredly/petulant-wookie/hooks", 102 | "issue_events_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues/events{/number}", 103 | "events_url": "https://api.github.com/repos/jaredly/petulant-wookie/events", 104 | "assignees_url": "https://api.github.com/repos/jaredly/petulant-wookie/assignees{/user}", 105 | "branches_url": "https://api.github.com/repos/jaredly/petulant-wookie/branches{/branch}", 106 | "tags_url": "https://api.github.com/repos/jaredly/petulant-wookie/tags", 107 | "blobs_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/blobs{/sha}", 108 | "git_tags_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/tags{/sha}", 109 | "git_refs_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/refs{/sha}", 110 | "trees_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/trees{/sha}", 111 | "statuses_url": "https://api.github.com/repos/jaredly/petulant-wookie/statuses/{sha}", 112 | "languages_url": "https://api.github.com/repos/jaredly/petulant-wookie/languages", 113 | "stargazers_url": "https://api.github.com/repos/jaredly/petulant-wookie/stargazers", 114 | "contributors_url": "https://api.github.com/repos/jaredly/petulant-wookie/contributors", 115 | "subscribers_url": "https://api.github.com/repos/jaredly/petulant-wookie/subscribers", 116 | "subscription_url": "https://api.github.com/repos/jaredly/petulant-wookie/subscription", 117 | "commits_url": "https://api.github.com/repos/jaredly/petulant-wookie/commits{/sha}", 118 | "git_commits_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/commits{/sha}", 119 | "comments_url": "https://api.github.com/repos/jaredly/petulant-wookie/comments{/number}", 120 | "issue_comment_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues/comments/{number}", 121 | "contents_url": "https://api.github.com/repos/jaredly/petulant-wookie/contents/{+path}", 122 | "compare_url": "https://api.github.com/repos/jaredly/petulant-wookie/compare/{base}...{head}", 123 | "merges_url": "https://api.github.com/repos/jaredly/petulant-wookie/merges", 124 | "archive_url": "https://api.github.com/repos/jaredly/petulant-wookie/{archive_format}{/ref}", 125 | "downloads_url": "https://api.github.com/repos/jaredly/petulant-wookie/downloads", 126 | "issues_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues{/number}", 127 | "pulls_url": "https://api.github.com/repos/jaredly/petulant-wookie/pulls{/number}", 128 | "milestones_url": "https://api.github.com/repos/jaredly/petulant-wookie/milestones{/number}", 129 | "notifications_url": "https://api.github.com/repos/jaredly/petulant-wookie/notifications{?since,all,participating}", 130 | "labels_url": "https://api.github.com/repos/jaredly/petulant-wookie/labels{/name}", 131 | "created_at": "2013-07-21T03:48:28Z", 132 | "updated_at": "2013-10-10T18:04:25Z", 133 | "pushed_at": "2013-10-10T17:58:29Z", 134 | "git_url": "git://github.com/jaredly/petulant-wookie.git", 135 | "ssh_url": "git@github.com:jaredly/petulant-wookie.git", 136 | "clone_url": "https://github.com/jaredly/petulant-wookie.git", 137 | "svn_url": "https://github.com/jaredly/petulant-wookie", 138 | "homepage": null, 139 | "size": 108, 140 | "watchers_count": 0, 141 | "language": null, 142 | "has_issues": true, 143 | "has_downloads": true, 144 | "has_wiki": true, 145 | "forks_count": 0, 146 | "mirror_url": null, 147 | "open_issues_count": 1, 148 | "forks": 0, 149 | "open_issues": 1, 150 | "watchers": 0, 151 | "master_branch": "master", 152 | "default_branch": "master" 153 | } 154 | }, 155 | "base": { 156 | "label": "jaredly:master", 157 | "ref": "master", 158 | "sha": "b77470335f9dfb8337f31d0892b467d151c99167", 159 | "user": { 160 | "login": "jaredly", 161 | "id": 112170, 162 | "avatar_url": "https://0.gravatar.com/avatar/313878fc8f316fc3fe4443b13913d0a4?d=https%3A%2F%2Fidenticons.github.com%2Fb12c483d8922cb5945bd4ffdae6d591d.png", 163 | "gravatar_id": "313878fc8f316fc3fe4443b13913d0a4", 164 | "url": "https://api.github.com/users/jaredly", 165 | "html_url": "https://github.com/jaredly", 166 | "followers_url": "https://api.github.com/users/jaredly/followers", 167 | "following_url": "https://api.github.com/users/jaredly/following{/other_user}", 168 | "gists_url": "https://api.github.com/users/jaredly/gists{/gist_id}", 169 | "starred_url": "https://api.github.com/users/jaredly/starred{/owner}{/repo}", 170 | "subscriptions_url": "https://api.github.com/users/jaredly/subscriptions", 171 | "organizations_url": "https://api.github.com/users/jaredly/orgs", 172 | "repos_url": "https://api.github.com/users/jaredly/repos", 173 | "events_url": "https://api.github.com/users/jaredly/events{/privacy}", 174 | "received_events_url": "https://api.github.com/users/jaredly/received_events", 175 | "type": "User", 176 | "site_admin": false 177 | }, 178 | "repo": { 179 | "id": 11556826, 180 | "name": "petulant-wookie", 181 | "full_name": "jaredly/petulant-wookie", 182 | "owner": { 183 | "login": "jaredly", 184 | "id": 112170, 185 | "avatar_url": "https://0.gravatar.com/avatar/313878fc8f316fc3fe4443b13913d0a4?d=https%3A%2F%2Fidenticons.github.com%2Fb12c483d8922cb5945bd4ffdae6d591d.png", 186 | "gravatar_id": "313878fc8f316fc3fe4443b13913d0a4", 187 | "url": "https://api.github.com/users/jaredly", 188 | "html_url": "https://github.com/jaredly", 189 | "followers_url": "https://api.github.com/users/jaredly/followers", 190 | "following_url": "https://api.github.com/users/jaredly/following{/other_user}", 191 | "gists_url": "https://api.github.com/users/jaredly/gists{/gist_id}", 192 | "starred_url": "https://api.github.com/users/jaredly/starred{/owner}{/repo}", 193 | "subscriptions_url": "https://api.github.com/users/jaredly/subscriptions", 194 | "organizations_url": "https://api.github.com/users/jaredly/orgs", 195 | "repos_url": "https://api.github.com/users/jaredly/repos", 196 | "events_url": "https://api.github.com/users/jaredly/events{/privacy}", 197 | "received_events_url": "https://api.github.com/users/jaredly/received_events", 198 | "type": "User", 199 | "site_admin": false 200 | }, 201 | "private": false, 202 | "html_url": "https://github.com/jaredly/petulant-wookie", 203 | "description": "repo for trying out CI solutions", 204 | "fork": false, 205 | "url": "https://api.github.com/repos/jaredly/petulant-wookie", 206 | "forks_url": "https://api.github.com/repos/jaredly/petulant-wookie/forks", 207 | "keys_url": "https://api.github.com/repos/jaredly/petulant-wookie/keys{/key_id}", 208 | "collaborators_url": "https://api.github.com/repos/jaredly/petulant-wookie/collaborators{/collaborator}", 209 | "teams_url": "https://api.github.com/repos/jaredly/petulant-wookie/teams", 210 | "hooks_url": "https://api.github.com/repos/jaredly/petulant-wookie/hooks", 211 | "issue_events_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues/events{/number}", 212 | "events_url": "https://api.github.com/repos/jaredly/petulant-wookie/events", 213 | "assignees_url": "https://api.github.com/repos/jaredly/petulant-wookie/assignees{/user}", 214 | "branches_url": "https://api.github.com/repos/jaredly/petulant-wookie/branches{/branch}", 215 | "tags_url": "https://api.github.com/repos/jaredly/petulant-wookie/tags", 216 | "blobs_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/blobs{/sha}", 217 | "git_tags_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/tags{/sha}", 218 | "git_refs_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/refs{/sha}", 219 | "trees_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/trees{/sha}", 220 | "statuses_url": "https://api.github.com/repos/jaredly/petulant-wookie/statuses/{sha}", 221 | "languages_url": "https://api.github.com/repos/jaredly/petulant-wookie/languages", 222 | "stargazers_url": "https://api.github.com/repos/jaredly/petulant-wookie/stargazers", 223 | "contributors_url": "https://api.github.com/repos/jaredly/petulant-wookie/contributors", 224 | "subscribers_url": "https://api.github.com/repos/jaredly/petulant-wookie/subscribers", 225 | "subscription_url": "https://api.github.com/repos/jaredly/petulant-wookie/subscription", 226 | "commits_url": "https://api.github.com/repos/jaredly/petulant-wookie/commits{/sha}", 227 | "git_commits_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/commits{/sha}", 228 | "comments_url": "https://api.github.com/repos/jaredly/petulant-wookie/comments{/number}", 229 | "issue_comment_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues/comments/{number}", 230 | "contents_url": "https://api.github.com/repos/jaredly/petulant-wookie/contents/{+path}", 231 | "compare_url": "https://api.github.com/repos/jaredly/petulant-wookie/compare/{base}...{head}", 232 | "merges_url": "https://api.github.com/repos/jaredly/petulant-wookie/merges", 233 | "archive_url": "https://api.github.com/repos/jaredly/petulant-wookie/{archive_format}{/ref}", 234 | "downloads_url": "https://api.github.com/repos/jaredly/petulant-wookie/downloads", 235 | "issues_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues{/number}", 236 | "pulls_url": "https://api.github.com/repos/jaredly/petulant-wookie/pulls{/number}", 237 | "milestones_url": "https://api.github.com/repos/jaredly/petulant-wookie/milestones{/number}", 238 | "notifications_url": "https://api.github.com/repos/jaredly/petulant-wookie/notifications{?since,all,participating}", 239 | "labels_url": "https://api.github.com/repos/jaredly/petulant-wookie/labels{/name}", 240 | "created_at": "2013-07-21T03:48:28Z", 241 | "updated_at": "2013-10-10T18:04:25Z", 242 | "pushed_at": "2013-10-10T17:58:29Z", 243 | "git_url": "git://github.com/jaredly/petulant-wookie.git", 244 | "ssh_url": "git@github.com:jaredly/petulant-wookie.git", 245 | "clone_url": "https://github.com/jaredly/petulant-wookie.git", 246 | "svn_url": "https://github.com/jaredly/petulant-wookie", 247 | "homepage": null, 248 | "size": 108, 249 | "watchers_count": 0, 250 | "language": null, 251 | "has_issues": true, 252 | "has_downloads": true, 253 | "has_wiki": true, 254 | "forks_count": 0, 255 | "mirror_url": null, 256 | "open_issues_count": 1, 257 | "forks": 0, 258 | "open_issues": 1, 259 | "watchers": 0, 260 | "master_branch": "master", 261 | "default_branch": "master" 262 | } 263 | }, 264 | "_links": { 265 | "self": { 266 | "href": "https://api.github.com/repos/jaredly/petulant-wookie/pulls/1" 267 | }, 268 | "html": { 269 | "href": "https://github.com/jaredly/petulant-wookie/pull/1" 270 | }, 271 | "issue": { 272 | "href": "https://api.github.com/repos/jaredly/petulant-wookie/issues/1" 273 | }, 274 | "comments": { 275 | "href": "https://api.github.com/repos/jaredly/petulant-wookie/issues/1/comments" 276 | }, 277 | "review_comments": { 278 | "href": "https://api.github.com/repos/jaredly/petulant-wookie/pulls/1/comments" 279 | }, 280 | "statuses": { 281 | "href": "https://api.github.com/repos/jaredly/petulant-wookie/statuses/f65ac3101a45bb9408c0459805b496cb73ae2d5f" 282 | } 283 | }, 284 | "merged": false, 285 | "mergeable": null, 286 | "mergeable_state": "unknown", 287 | "merged_by": null, 288 | "comments": 0, 289 | "review_comments": 0, 290 | "commits": 1, 291 | "additions": 2, 292 | "deletions": 0, 293 | "changed_files": 1 294 | }, 295 | "repository": { 296 | "id": 11556826, 297 | "name": "petulant-wookie", 298 | "full_name": "jaredly/petulant-wookie", 299 | "owner": { 300 | "login": "jaredly", 301 | "id": 112170, 302 | "avatar_url": "https://0.gravatar.com/avatar/313878fc8f316fc3fe4443b13913d0a4?d=https%3A%2F%2Fidenticons.github.com%2Fb12c483d8922cb5945bd4ffdae6d591d.png", 303 | "gravatar_id": "313878fc8f316fc3fe4443b13913d0a4", 304 | "url": "https://api.github.com/users/jaredly", 305 | "html_url": "https://github.com/jaredly", 306 | "followers_url": "https://api.github.com/users/jaredly/followers", 307 | "following_url": "https://api.github.com/users/jaredly/following{/other_user}", 308 | "gists_url": "https://api.github.com/users/jaredly/gists{/gist_id}", 309 | "starred_url": "https://api.github.com/users/jaredly/starred{/owner}{/repo}", 310 | "subscriptions_url": "https://api.github.com/users/jaredly/subscriptions", 311 | "organizations_url": "https://api.github.com/users/jaredly/orgs", 312 | "repos_url": "https://api.github.com/users/jaredly/repos", 313 | "events_url": "https://api.github.com/users/jaredly/events{/privacy}", 314 | "received_events_url": "https://api.github.com/users/jaredly/received_events", 315 | "type": "User", 316 | "site_admin": false 317 | }, 318 | "private": false, 319 | "html_url": "https://github.com/jaredly/petulant-wookie", 320 | "description": "repo for trying out CI solutions", 321 | "fork": false, 322 | "url": "https://api.github.com/repos/jaredly/petulant-wookie", 323 | "forks_url": "https://api.github.com/repos/jaredly/petulant-wookie/forks", 324 | "keys_url": "https://api.github.com/repos/jaredly/petulant-wookie/keys{/key_id}", 325 | "collaborators_url": "https://api.github.com/repos/jaredly/petulant-wookie/collaborators{/collaborator}", 326 | "teams_url": "https://api.github.com/repos/jaredly/petulant-wookie/teams", 327 | "hooks_url": "https://api.github.com/repos/jaredly/petulant-wookie/hooks", 328 | "issue_events_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues/events{/number}", 329 | "events_url": "https://api.github.com/repos/jaredly/petulant-wookie/events", 330 | "assignees_url": "https://api.github.com/repos/jaredly/petulant-wookie/assignees{/user}", 331 | "branches_url": "https://api.github.com/repos/jaredly/petulant-wookie/branches{/branch}", 332 | "tags_url": "https://api.github.com/repos/jaredly/petulant-wookie/tags", 333 | "blobs_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/blobs{/sha}", 334 | "git_tags_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/tags{/sha}", 335 | "git_refs_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/refs{/sha}", 336 | "trees_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/trees{/sha}", 337 | "statuses_url": "https://api.github.com/repos/jaredly/petulant-wookie/statuses/{sha}", 338 | "languages_url": "https://api.github.com/repos/jaredly/petulant-wookie/languages", 339 | "stargazers_url": "https://api.github.com/repos/jaredly/petulant-wookie/stargazers", 340 | "contributors_url": "https://api.github.com/repos/jaredly/petulant-wookie/contributors", 341 | "subscribers_url": "https://api.github.com/repos/jaredly/petulant-wookie/subscribers", 342 | "subscription_url": "https://api.github.com/repos/jaredly/petulant-wookie/subscription", 343 | "commits_url": "https://api.github.com/repos/jaredly/petulant-wookie/commits{/sha}", 344 | "git_commits_url": "https://api.github.com/repos/jaredly/petulant-wookie/git/commits{/sha}", 345 | "comments_url": "https://api.github.com/repos/jaredly/petulant-wookie/comments{/number}", 346 | "issue_comment_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues/comments/{number}", 347 | "contents_url": "https://api.github.com/repos/jaredly/petulant-wookie/contents/{+path}", 348 | "compare_url": "https://api.github.com/repos/jaredly/petulant-wookie/compare/{base}...{head}", 349 | "merges_url": "https://api.github.com/repos/jaredly/petulant-wookie/merges", 350 | "archive_url": "https://api.github.com/repos/jaredly/petulant-wookie/{archive_format}{/ref}", 351 | "downloads_url": "https://api.github.com/repos/jaredly/petulant-wookie/downloads", 352 | "issues_url": "https://api.github.com/repos/jaredly/petulant-wookie/issues{/number}", 353 | "pulls_url": "https://api.github.com/repos/jaredly/petulant-wookie/pulls{/number}", 354 | "milestones_url": "https://api.github.com/repos/jaredly/petulant-wookie/milestones{/number}", 355 | "notifications_url": "https://api.github.com/repos/jaredly/petulant-wookie/notifications{?since,all,participating}", 356 | "labels_url": "https://api.github.com/repos/jaredly/petulant-wookie/labels{/name}", 357 | "created_at": "2013-07-21T03:48:28Z", 358 | "updated_at": "2013-10-10T18:04:25Z", 359 | "pushed_at": "2013-10-10T17:58:29Z", 360 | "git_url": "git://github.com/jaredly/petulant-wookie.git", 361 | "ssh_url": "git@github.com:jaredly/petulant-wookie.git", 362 | "clone_url": "https://github.com/jaredly/petulant-wookie.git", 363 | "svn_url": "https://github.com/jaredly/petulant-wookie", 364 | "homepage": null, 365 | "size": 108, 366 | "watchers_count": 0, 367 | "language": null, 368 | "has_issues": true, 369 | "has_downloads": true, 370 | "has_wiki": true, 371 | "forks_count": 0, 372 | "mirror_url": null, 373 | "open_issues_count": 1, 374 | "forks": 0, 375 | "open_issues": 1, 376 | "watchers": 0, 377 | "master_branch": "master", 378 | "default_branch": "master" 379 | }, 380 | "sender": { 381 | "login": "jaredly", 382 | "id": 112170, 383 | "avatar_url": "https://0.gravatar.com/avatar/313878fc8f316fc3fe4443b13913d0a4?d=https%3A%2F%2Fidenticons.github.com%2Fb12c483d8922cb5945bd4ffdae6d591d.png", 384 | "gravatar_id": "313878fc8f316fc3fe4443b13913d0a4", 385 | "url": "https://api.github.com/users/jaredly", 386 | "html_url": "https://github.com/jaredly", 387 | "followers_url": "https://api.github.com/users/jaredly/followers", 388 | "following_url": "https://api.github.com/users/jaredly/following{/other_user}", 389 | "gists_url": "https://api.github.com/users/jaredly/gists{/gist_id}", 390 | "starred_url": "https://api.github.com/users/jaredly/starred{/owner}{/repo}", 391 | "subscriptions_url": "https://api.github.com/users/jaredly/subscriptions", 392 | "organizations_url": "https://api.github.com/users/jaredly/orgs", 393 | "repos_url": "https://api.github.com/users/jaredly/repos", 394 | "events_url": "https://api.github.com/users/jaredly/events{/privacy}", 395 | "received_events_url": "https://api.github.com/users/jaredly/received_events", 396 | "type": "User", 397 | "site_admin": false 398 | } 399 | } -------------------------------------------------------------------------------- /test/test_api.js: -------------------------------------------------------------------------------- 1 | var expect = require('expect.js') 2 | , api = require('../lib/api') 3 | , util = require('util') 4 | , nock = require('nock'); 5 | 6 | describe('github api', function () { 7 | describe('getFile', function () { 8 | it('should get a file', function (done) { 9 | api.getFile('README.md', null, null, 'Strider-CD', 'strider-github', function (err, text) { 10 | expect(err).to.not.be.ok() 11 | expect(text).to.be.ok() 12 | done() 13 | }) 14 | }) 15 | 16 | it('should get @ a ref', function (done) { 17 | api.getFile('Readme.md', '80b2afcf786ac0eceb0c5405a06e2bb5fc9170af', null, 'Strider-CD', 'strider-github', function (err, text) { 18 | expect(err).to.not.be.ok() 19 | expect(text).to.match(/What we want from a provider/) 20 | done() 21 | }) 22 | }) 23 | }) 24 | 25 | describe('createHooks', function () { 26 | it('should fail on bad credentials', function (done) { 27 | api.createHooks('github/github-services', 'http://example.com/hook', 'testsecret', 'invalidtoken', function (err) { 28 | expect(err).to.be.a(Error) 29 | done() 30 | }) 31 | }) 32 | 33 | // if test environment hasn't been set-up with test values then 34 | // just make mocha report them as pending, rather than fail 35 | var env = process.env 36 | , t = env.TEST_HOOK_REPONAME ? 'it' : 'xit' 37 | 38 | global[t]('should create a hook', function (done) { 39 | api.createHooks(env.TEST_HOOK_REPONAME, env.TEST_HOOK_URL, 'testsecret123', env.TEST_HOOK_TOKEN, function (err) { 40 | expect(err).to.equal(null) 41 | done() 42 | }) 43 | }) 44 | }) 45 | 46 | /* 47 | Simulate a case where a user Strider Tester is registered 48 | with github and has admin access to TWO repositories 49 | one which belongs to him (stridertester/proj1) and one that 50 | belongs to a team Strider Testers Union (stridertestersunion/union-proj-1) 51 | getRepos should return an array containing the two repositories 52 | we are using actual responses received from github.com - as recorded 53 | and mocked by nock to simulate. 54 | */ 55 | 56 | describe('getRepos', function() { 57 | this.timeout(10000); 58 | before(function() { 59 | nock.cleanAll(); 60 | nock.disableNetConnect(); 61 | require('./mocks/setup_nock_repos')(); 62 | }); 63 | it('should return a list of repos for a given user', function (done) { 64 | api.getRepos("35e31a04c04b09174d20de8287f2e8ddad7d2095", "stridertester", function(err, repos) { 65 | expect(err).to.not.be.ok(); 66 | expect(repos).to.be.an('array'); 67 | expect(repos.length).to.eql(1); 68 | expect(repos).to.eql( 69 | [ { id: 40900282, 70 | name: 'stridertester/proj1', 71 | display_name: 'stridertester/proj1', 72 | group: 'stridertester', 73 | display_url: 'https://github.com/stridertester/proj1', 74 | config: 75 | { url: 'git://github.com/stridertester/proj1.git', 76 | owner: 'stridertester', 77 | repo: 'proj1', 78 | auth: { type: 'ssh' } } },/* 79 | { id: 40900394, 80 | name: 'stridertestersunion/union-proj-1', 81 | display_name: 'stridertestersunion/union-proj-1', 82 | group: 'stridertestersunion', 83 | display_url: 'https://github.com/stridertestersunion/union-proj-1', 84 | config: 85 | { url: 'git://github.com/stridertestersunion/union-proj-1.git', 86 | owner: 'stridertestersunion', 87 | repo: 'union-proj-1', 88 | auth: { type: 'ssh' } } }*/ 89 | ] 90 | ); 91 | ///console.log(util.inspect(repos, false, 10, true)); 92 | done() 93 | }); 94 | }); 95 | after(function() { 96 | nock.cleanAll(); 97 | nock.enableNetConnect(); 98 | }); 99 | }); 100 | }) 101 | -------------------------------------------------------------------------------- /test/test_webhooks.js: -------------------------------------------------------------------------------- 1 | 2 | var expect = require('expect.js') 3 | , lib = require('../lib/webhooks') 4 | 5 | describe('webhooks', function () { 6 | describe('commit hook', function () { 7 | describe('parsing', function () { 8 | it('should work', function () { 9 | var fx = require('./sample_commit.json') 10 | , config = lib.pushJob(fx) 11 | 12 | delete config.trigger.author.image 13 | 14 | expect(config).to.eql({ 15 | branch: 'master', 16 | deploy: true, 17 | ref: { 18 | branch: 'master', 19 | id: '5440158e185393ddedcabcbc615f574d10134cdb' 20 | }, 21 | trigger: { 22 | type: 'commit', 23 | author: { 24 | name: 'Jared Forsyth', 25 | username: 'jaredly', 26 | email: 'jared@jaredforsyth.com' 27 | }, 28 | url: 'https://github.com/jaredly/django-colorfield/commit/5440158e185393ddedcabcbc615f574d10134cdb', 29 | message: 'adding mit license', 30 | timestamp: '2013-10-05T17:09:00-07:00', 31 | source: { 32 | type: 'plugin', 33 | plugin: 'github' 34 | } 35 | } 36 | }) 37 | }) 38 | }) 39 | }) 40 | 41 | describe('pull request hook', function () { 42 | describe('parsing', function () { 43 | it('should work', function () { 44 | var fx = require('./sample_pull_request.json'); 45 | var config = lib.pullRequestJob(fx.pull_request, fx.action); 46 | 47 | expect(config).to.eql({ 48 | branch: 'master', 49 | deploy: false, 50 | plugin_data: { 51 | github: { 52 | pull_request: { 53 | user: 'jaredly', 54 | repo: 'petulant-wookie', 55 | sha: 'f65ac3101a45bb9408c0459805b496cb73ae2d5f', 56 | number: 1, 57 | body: 'This is the body.' 58 | } 59 | } 60 | }, 61 | ref: { 62 | fetch: 'refs/pull/1/merge', 63 | branch: 'master' 64 | }, 65 | trigger: { 66 | type: 'pull-request', 67 | author: { 68 | username: 'jaredly', 69 | image: 'https://0.gravatar.com/avatar/313878fc8f316fc3fe4443b13913d0a4?d=https%3A%2F%2Fidenticons.github.com%2Fb12c483d8922cb5945bd4ffdae6d591d.png' 70 | }, 71 | url: 'https://github.com/jaredly/petulant-wookie/pull/1', 72 | message: 'Example pull request', 73 | timestamp: '2013-10-10T18:04:25Z', 74 | source: { 75 | type: 'plugin', 76 | plugin: 'github' 77 | } 78 | } 79 | }) 80 | }) 81 | }) 82 | }) 83 | 84 | describe('verifySignature', function () { 85 | // `X-Hub-Signature` request header value from a github test hook request 86 | var goodSig = 'sha1=0a09a56a74e9e68928a35f712afaae72b010c11f' 87 | , secret = 'testsecret123' 88 | , body = 'payload=%7B%22zen%22%3A%22Avoid+administrative+distraction.%22%2C%22hook_id%22%3A1881347%7D' 89 | it('should verify valid signature', function (done) { 90 | var valid = lib.verifySignature(goodSig, secret, body) 91 | expect(valid).to.be(true) 92 | done() 93 | }) 94 | it('should not verify invalid signature', function (done) { 95 | var badSig = goodSig.replace(/.{1}$/, 'a') 96 | var valid = lib.verifySignature(badSig, secret, body) 97 | expect(valid).to.be(false) 98 | done() 99 | }) 100 | }) 101 | 102 | }) 103 | --------------------------------------------------------------------------------