├── .gitignore ├── .travis.yml ├── LICENSE.md ├── README.md ├── ghutils.js ├── package.json ├── test-util.js └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: node_js 3 | node_js: 4 | - 10 5 | - 12 6 | - lts/* 7 | - current 8 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | ===================== 3 | 4 | Copyright (c) 2015 Rod Vagg 5 | --------------------------- 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 8 | 9 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 10 | 11 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ghutils 2 | 3 | [![Build Status](https://api.travis-ci.com/rvagg/ghutils.svg?branch=master)](https://travis-ci.com/rvagg/ghutils) 4 | 5 | **A collection of utility functions for dealing with the GitHub API** 6 | 7 | [![NPM](https://nodei.co/npm/ghissues.svg)](https://nodei.co/npm/ghissues/) 8 | 9 | Used by: 10 | 11 | * [ghissues](https://github.com/rvagg/ghissues) - a Node.js library to interact with the GitHub Issues API 12 | * [ghpulls](https://github.com/rvagg/ghpulls) - a Node.js library to interact with the GitHub Pull Request API 13 | * [ghrepos](https://github.com/rvagg/ghrepos) - a Node.js library to interact with the GitHub Repos API 14 | * [ghusers](https://github.com/rvagg/ghusers) - a Node.js library to interact with the GitHub Users API 15 | * [ghteams](https://github.com/rvagg/ghteams) - a Node.js library to interact with the GitHub Teams API 16 | * [ghreleases](https://github.com/ralphtheninja/ghreleases) - a Node.js library to interact with the GitHub Releases API 17 | 18 | ## API 19 | 20 | ### makeOptions(auth, options) 21 | 22 | Helper to make options to pass to [jsonist](http://github.com/rvagg/jsonist) given a GitHub auth from [ghauth](https://github.com/rvagg/ghauth) and any additional options. 23 | 24 | ### handler(callback) 25 | 26 | Takes a JSON response from the GitHub API and turns any errors and applies them properly to the `callback`. 27 | 28 | ### ghpost(auth, url, data, options, callback) 29 | 30 | Make a GitHub API compatible POST request to the given URL via [jsonist](http://github.com/rvagg/jsonist), uses `makeOptions()` to extend the options. Requires a GitHub auth from [ghauth](https://github.com/rvagg/ghauth) and any additional options. 31 | 32 | ### ghget(auth, url, options, callback) 33 | 34 | Make a GitHub API compatible GET request to the given URL via [jsonist](http://github.com/rvagg/jsonist), uses `makeOptions()` to extend the options. Requires a GitHub auth from [ghauth](https://github.com/rvagg/ghauth) and any additional options. 35 | 36 | ### lister(auth, urlbase, options, callback) 37 | 38 | Given a paginated url resource, recursively fetch all available pages of data and return an array containing the complete list. 39 | 40 | ### apiRoot 41 | 42 | The api root url `'https://api.github.com'`. 43 | 44 | ## License & Copyright 45 | 46 | **ghutils** is Copyright (c) 2015 Rod Vagg [@rvagg](https://twitter.com/rvagg) and licensed under the MIT licence. All rights not explicitly granted in the MIT license are reserved. See the included LICENSE.md file for more details. 47 | -------------------------------------------------------------------------------- /ghutils.js: -------------------------------------------------------------------------------- 1 | const jsonist = require('jsonist') 2 | const qs = require('querystring') 3 | 4 | const apiRoot = 'https://api.github.com' 5 | 6 | function makeOptions (auth, options) { 7 | const headers = Object.assign( 8 | { 'user-agent': 'Magic Node.js application that does magic things' }, 9 | typeof options === 'object' && typeof options.headers === 'object' ? options.headers : {} 10 | ) 11 | options = Object.assign({ auth: `${auth.user}:${auth.token}` }, options) 12 | options.headers = headers 13 | if (!options.auth) { 14 | delete options.auth 15 | } 16 | return options 17 | } 18 | 19 | function handler (callback) { 20 | return function responseHandler (err, data, res) { 21 | if (err) { 22 | return callback(err) 23 | } 24 | 25 | if (data && (data.error || data.message)) { 26 | return callback(createError(data)) 27 | } 28 | 29 | callback(null, data, res) 30 | } 31 | } 32 | 33 | function createError (data) { 34 | const message = data.error || data.message 35 | const extra = data.errors ? ` (${JSON.stringify(data.errors)})` : '' 36 | return new Error(`Error from GitHub: ${message}${extra}`) 37 | } 38 | 39 | function ghget (auth, url, options, callback) { 40 | options = makeOptions(auth, options) 41 | jsonist.get(url, Object.assign(options, { followRedirects: true }), handler(callback)) 42 | } 43 | 44 | function ghpost (auth, url, data, options, callback) { 45 | options = makeOptions(auth, options) 46 | jsonist.post(url, data, options, handler(callback)) 47 | } 48 | 49 | function lister (auth, urlbase, options, callback) { 50 | let retdata = [] 51 | const afterDate = (options.afterDate instanceof Date) && options.afterDate 52 | 53 | // overloading use of 'options' is ... not great 54 | const optqsobj = Object.assign(options) 55 | delete optqsobj.afterDate 56 | delete optqsobj.headers 57 | const optqs = qs.stringify(optqsobj) 58 | 59 | ;(function next (url) { 60 | if (optqs) { 61 | url += '&' + optqs 62 | } 63 | 64 | ghget(auth, url, options, (err, data, res) => { 65 | if (err) { 66 | return callback(err) 67 | } 68 | 69 | if (data.length) { 70 | retdata.push.apply(retdata, data) 71 | } 72 | 73 | const nextUrl = getNextUrl(res.headers.link) 74 | let createdAt 75 | 76 | if (nextUrl) { 77 | if (!afterDate || ((createdAt = retdata[retdata.length - 1].created_at) && new Date(createdAt) > afterDate)) { 78 | return next(nextUrl) 79 | } 80 | } 81 | 82 | if (afterDate) { 83 | retdata = retdata.filter((data) => { 84 | return !data.created_at || new Date(data.created_at) > afterDate 85 | }) 86 | } 87 | callback(null, retdata) 88 | }) 89 | }(urlbase)) 90 | 91 | function getNextUrl (link) { 92 | if (typeof link === 'undefined') { 93 | return 94 | } 95 | const match = /<([^>]+)>; rel="next"/.exec(link) 96 | return match && match[1] 97 | } 98 | } 99 | 100 | module.exports.makeOptions = makeOptions 101 | module.exports.ghpost = ghpost 102 | module.exports.ghget = ghget 103 | module.exports.handler = handler 104 | module.exports.lister = lister 105 | module.exports.apiRoot = apiRoot 106 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ghutils", 3 | "version": "4.0.0", 4 | "description": "A collection of utility functions for dealing with the GitHub API", 5 | "main": "ghutils.js", 6 | "author": "Rod (http://r.va.gg/)", 7 | "license": "MIT", 8 | "repository": { 9 | "type": "git", 10 | "url": "https://github.com/rvagg/ghutils.git" 11 | }, 12 | "dependencies": { 13 | "jsonist": "~3.0.1" 14 | }, 15 | "scripts": { 16 | "lint": "standard *.js", 17 | "test": "npm run lint && tape test.js" 18 | }, 19 | "devDependencies": { 20 | "standard": "~14.3.1", 21 | "tape": "~4.11.0" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /test-util.js: -------------------------------------------------------------------------------- 1 | const http = require('http') 2 | const EE = require('events').EventEmitter 3 | const jsonist = require('jsonist') 4 | const _jsonistget = jsonist.get 5 | const _jsonistpost = jsonist.post 6 | 7 | function makeServer (data) { 8 | const ee = new EE() 9 | let i = 0 10 | const server = http.createServer((req, res) => { 11 | ee.emit('request', req) 12 | 13 | const _data = Array.isArray(data) ? data[i++] : data 14 | 15 | if (_data && _data.headers && _data.headers.link) { 16 | res.setHeader('link', _data.headers.link) 17 | } 18 | 19 | res.end(JSON.stringify((_data && _data.response) || _data)) 20 | 21 | if (!Array.isArray(data) || i === data.length) { 22 | server.close() 23 | } 24 | }) 25 | 26 | server.listen(0, (err) => { 27 | if (err) { 28 | return ee.emit('error', err) 29 | } 30 | 31 | jsonist.get = (url, opts, callback) => { 32 | ee.emit('get', url, opts) 33 | return _jsonistget('http://localhost:' + server.address().port, opts, callback) 34 | } 35 | 36 | jsonist.post = (url, data, opts, callback) => { 37 | ee.emit('post', url, data, opts) 38 | return _jsonistpost('http://localhost:' + server.address().port, data, opts, callback) 39 | } 40 | 41 | ee.emit('ready') 42 | }) 43 | 44 | server.on('close', ee.emit.bind(ee, 'close')) 45 | 46 | return ee 47 | } 48 | 49 | function toAuth (auth) { 50 | return `Basic ${Buffer.from(auth.user + ':' + auth.token).toString('base64')}` 51 | } 52 | 53 | function verifyRequest (t, auth) { 54 | return function (req) { 55 | t.ok(true, 'got request') 56 | t.equal(req.headers.authorization, toAuth(auth), 'got auth header') 57 | } 58 | } 59 | 60 | function verifyUrl (t, urls) { 61 | let i = 0 62 | return function (_url) { 63 | if (i === urls.length) { 64 | return t.fail('too many urls/requests') 65 | } 66 | t.equal(_url, urls[i++], 'correct url') 67 | } 68 | } 69 | 70 | function verifyClose (t) { 71 | return function () { 72 | t.ok(true, 'got close') 73 | } 74 | } 75 | 76 | function verifyData (t, data) { 77 | return function (err, _data) { 78 | t.notOk(err, 'no error') 79 | t.ok((data === '' && _data === '') || _data, 'got data') 80 | t.deepEqual(_data, data, 'got expected data') 81 | } 82 | } 83 | 84 | module.exports.makeServer = makeServer 85 | module.exports.toAuth = toAuth 86 | module.exports.verifyRequest = verifyRequest 87 | module.exports.verifyUrl = verifyUrl 88 | module.exports.verifyClose = verifyClose 89 | module.exports.verifyData = verifyData 90 | -------------------------------------------------------------------------------- /test.js: -------------------------------------------------------------------------------- 1 | const test = require('tape') 2 | const xtend = require('xtend') 3 | const util = require('./test-util') 4 | const ghutils = require('./') 5 | 6 | test('that lister follows res.headers.link', (t) => { 7 | t.plan(13) 8 | 9 | const auth = { user: 'authuser', token: 'authtoken' } 10 | const testData = [ 11 | { 12 | response: [{ test3: 'data3' }, { test4: 'data4' }], 13 | headers: { link: '; rel="next"' } 14 | }, 15 | { 16 | response: [{ test5: 'data5' }, { test6: 'data6' }], 17 | headers: { link: '; rel="next"' } 18 | }, 19 | [] 20 | ] 21 | const urlBase = 'https://api.github.com/foobar' 22 | 23 | util.makeServer(testData) 24 | .on('ready', () => { 25 | const result = testData[0].response.concat(testData[1].response) 26 | ghutils.lister(xtend(auth), urlBase, {}, util.verifyData(t, result)) 27 | }) 28 | .on('request', util.verifyRequest(t, auth)) 29 | .on('get', util.verifyUrl(t, [ 30 | 'https://api.github.com/foobar', 31 | 'https://somenexturl', 32 | 'https://somenexturl2' 33 | ])) 34 | .on('close', util.verifyClose(t)) 35 | }) 36 | 37 | test('test list multi-page pulls, options.afterDate includes all', (t) => { 38 | t.plan(13) 39 | 40 | const auth = { user: 'authuser', token: 'authtoken' } 41 | const testData = [ 42 | { 43 | response: [{ test1: 'data1', created_at: new Date('2015-12-14T05:58:14.421Z').toISOString() }, { test2: 'data2', created_at: new Date('2015-12-13T05:58:14.421Z').toISOString() }], 44 | headers: { link: '; rel="next"' } 45 | }, 46 | { 47 | response: [{ test1: 'data3', created_at: new Date('2015-12-12T05:58:14.421Z').toISOString() }, { test2: 'data4', created_at: new Date('2015-12-11T05:58:14.421Z').toISOString() }], 48 | headers: { link: '; rel="prev", ; rel="next", ; rel="last", ; rel="first"' } 49 | }, 50 | { response: [] } 51 | ] 52 | const urlBase = 'https://api.github.com/foobar' 53 | 54 | util.makeServer(testData) 55 | .on('ready', () => { 56 | const result = testData[0].response.concat(testData[1].response) 57 | ghutils.lister(xtend(auth), urlBase, { afterDate: new Date('2015-12-10T05:58:14.421Z') }, util.verifyData(t, result)) 58 | }) 59 | .on('request', util.verifyRequest(t, auth)) 60 | .on('get', util.verifyUrl(t, [ 61 | 'https://api.github.com/foobar', 62 | 'https://api.github.com/foobar?page=2', 63 | 'https://api.github.com/foobar?page=3' 64 | ])) 65 | .on('close', util.verifyClose(t)) 66 | }) 67 | 68 | test('test list multi-page pulls, options.afterDate includes all', (t) => { 69 | t.plan(10) 70 | 71 | const auth = { user: 'authuser', token: 'authtoken' } 72 | const testData = [ 73 | { 74 | response: [{ test1: 'data1', created_at: new Date('2015-12-14T05:58:14.421Z').toISOString() }, { test2: 'data2', created_at: new Date('2015-12-13T05:58:14.421Z').toISOString() }], 75 | headers: { link: '; rel="next"' } 76 | }, 77 | { 78 | response: [{ test1: 'data3', created_at: new Date('2015-12-12T05:58:14.421Z').toISOString() }, { test2: 'data4', created_at: new Date('2015-12-11T05:58:14.421Z').toISOString() }], 79 | headers: { link: '; rel="next"' } 80 | } 81 | // also tests that we don't fetch any more beyond this point, i.e. only 2 requests needed 82 | ] 83 | const urlBase = 'https://api.github.com/foobar' 84 | 85 | util.makeServer(testData) 86 | .on('ready', () => { 87 | const result = testData[0].response.concat([testData[1].response[0]]) 88 | ghutils.lister(xtend(auth), urlBase, { afterDate: new Date('2015-12-11T15:58:14.421Z') }, util.verifyData(t, result)) 89 | }) 90 | .on('request', util.verifyRequest(t, auth)) 91 | .on('get', util.verifyUrl(t, [ 92 | 'https://api.github.com/foobar', 93 | 'https://api.github.com/foobar?page=2' 94 | ])) 95 | .on('close', util.verifyClose(t)) 96 | }) 97 | 98 | test('valid response with null data calls back with null data', (t) => { 99 | t.plan(5) 100 | 101 | const auth = { user: 'authuser', token: 'authtoken' } 102 | const testData = null 103 | const urlBase = 'https://api.github.com/foobar' 104 | 105 | util.makeServer(testData) 106 | .on('ready', () => { 107 | ghutils.ghget(xtend(auth), urlBase, {}, (err, data) => { 108 | t.notOk(err, 'no error') 109 | t.deepEqual(data, testData, 'got expected data') 110 | }) 111 | }) 112 | .on('request', util.verifyRequest(t, auth)) 113 | .on('close', util.verifyClose(t)) 114 | }) 115 | 116 | test('data.message calls back with error', (t) => { 117 | t.plan(4) 118 | 119 | const auth = { user: 'authuser', token: 'authtoken' } 120 | const testData = { message: 'borked borked' } 121 | const urlBase = 'https://api.github.com/foobar' 122 | 123 | util.makeServer(testData) 124 | .on('ready', () => { 125 | ghutils.ghget(xtend(auth), urlBase, {}, (err, data) => { 126 | t.is(err.message, 'Error from GitHub: borked borked') 127 | }) 128 | }) 129 | .on('request', util.verifyRequest(t, auth)) 130 | .on('close', util.verifyClose(t)) 131 | }) 132 | 133 | test('data.message calls back with error + extra', (t) => { 134 | t.plan(4) 135 | 136 | const auth = { user: 'authuser', token: 'authtoken' } 137 | const testData = { 138 | message: 'borked borked', 139 | errors: [{ foo: 'bar' }] 140 | } 141 | const urlBase = 'https://api.github.com/foobar' 142 | 143 | util.makeServer(testData) 144 | .on('ready', () => { 145 | ghutils.ghget(xtend(auth), urlBase, {}, (err, data) => { 146 | t.is(err.message, 'Error from GitHub: borked borked ([{"foo":"bar"}])') 147 | }) 148 | }) 149 | .on('request', util.verifyRequest(t, auth)) 150 | .on('close', util.verifyClose(t)) 151 | }) 152 | 153 | test('data.error calls back with error', (t) => { 154 | t.plan(4) 155 | 156 | const auth = { user: 'authuser', token: 'authtoken' } 157 | const testData = { error: 'borked borked' } 158 | const urlBase = 'https://api.github.com/foobar' 159 | 160 | util.makeServer(testData) 161 | .on('ready', () => { 162 | ghutils.ghget(xtend(auth), urlBase, {}, (err, data) => { 163 | t.is(err.message, 'Error from GitHub: borked borked') 164 | }) 165 | }) 166 | .on('request', util.verifyRequest(t, auth)) 167 | .on('close', util.verifyClose(t)) 168 | }) 169 | 170 | test('data.error calls back with error + extra', (t) => { 171 | t.plan(4) 172 | 173 | const auth = { user: 'authuser', token: 'authtoken' } 174 | const testData = { 175 | message: 'borked borked', 176 | errors: [{ foo: 'bar' }] 177 | } 178 | const urlBase = 'https://api.github.com/foobar' 179 | 180 | util.makeServer(testData) 181 | .on('ready', () => { 182 | ghutils.ghget(xtend(auth), urlBase, {}, (err, data) => { 183 | t.is(err.message, 'Error from GitHub: borked borked ([{"foo":"bar"}])') 184 | }) 185 | }) 186 | .on('request', util.verifyRequest(t, auth)) 187 | .on('close', util.verifyClose(t)) 188 | }) 189 | --------------------------------------------------------------------------------