diff --git a/.gitignore b/.gitignore index 3c3629e..1a4f804 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ node_modules +lib + +# We auto-generate .npmignore from this file +.npmignore diff --git a/.travis.yml b/.travis.yml index f1d0f13..9c72688 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,4 +1,7 @@ language: node_js node_js: - - 0.4 - - 0.6 + - "6" + - "5" + - "4" + - "0.12" + - "0.10" diff --git a/Readme.md b/Readme.md index ab2fe59..4bb1de6 100644 --- a/Readme.md +++ b/Readme.md @@ -21,6 +21,8 @@ Or use it on its own: github = require('githubot') ``` +You can pass additional [options](#options) to the constructor if needed. + ## Use ## Make any call to the Github v3 API, get the parsed JSON response: @@ -35,6 +37,9 @@ github.get "users/foo/repos", {type: "owner"}, (repos) -> data = { description: "A test gist", public: true, files: { "abc.txt": { content: "abcdefg" } } } github.post "gists", data, (gist) -> console.log gist.url + +github.patch "repos/my/repo/issues/11", {status: "closed"}, (issue) -> + console.log issue.html_url ``` ## Authentication ## @@ -45,19 +50,106 @@ If `process.env.HUBOT_GITHUB_TOKEN` is present, you're automatically authenticat If you don't have a token yet, run this: - curl -i https://api.github.com/authorizations -d '{"scopes":["repo"]}' -u "yourusername" + curl -i https://api.github.com/authorizations -d '{"note":"githubot","scopes":["repo"]}' -u "yourusername" Enter your Github password when prompted. When you get a response, look for the "token" value. +If you have two-factor authentication enabled, you'll have to append `-H 'X-GitHub-OTP: 123456'` to the end of the above command, or you'll receive HTTP 401 Unauthorized instead of your token. + +## Handling errors ## + +GitHubot will log errors automatically if it has a logger. Used with Hubot, these will go to the Hubot logger. + +If your script would like to catch errors as well, define an extra callback: + +```coffeescript +github.handleErrors (response) -> + console.log "Oh no! #{response.statusCode}!" +``` + +The callback takes a `response` argument with the following keys: + +* `error`: The error message. +* `statusCode`: The status code of the API response, if present. +* `body`: The body of the API response, if present. + +You can also pass an error handler in [the options](#available-options) instead. + +## Helpful Hubot ## + +If `process.env.HUBOT_GITHUB_USER` is present, we can help you guess a repo's full name: + +```coffeescript +github.qualified_repo "githubot" # => "iangreenleaf/githubot" +``` + +This will happen with the bespoke methods as well: + +```coffeescript +gh.branches "githubot", (branches) -> +``` + +## Options ## + +### Passing options ### + +Options may be passed to githubot in three different ways, +in increasing order of precedence: + +1. Through shell environment variables. +2. Through the constructor: + + ```coffeescript + github = require('githubot')(robot, apiVersion: 'preview') + ``` +3. Using `withOptions`, which lets you pass options to only some requests: + + ```coffeescript + github = require('githubot')(robot) + preview = github.withOptions(apiVersion: 'preview') + # Uses preview API + preview.get '/preview/feature', -> # ... + # Uses regular API + github.get '/regular/feature', -> # ... + ``` + +### Available options ### + +* `token`/`process.env.HUBOT_GITHUB_TOKEN`: + GitHub API token. Required to perform authenticated actions. + +* `apiVersion`/`process.env.HUBOT_GITHUB_API_VERSION`: + [Version of the API](http://developer.github.com/v3/versions/) + to access. Defaults to 'v3'. + +* `defaultUser`/`process.env.HUBOT_GITHUB_USER`: + Default GitHub username to use if one is not given. + +* `apiRoot`/`process.env.HUBOT_GITHUB_API`: + The base API URL. This is useful for Enterprise Github installations. + + For example, `HUBOT_GITHUB_API='https://myprivate.github.int'` + +* `concurrentRequests`/`process.env.HUBOT_CONCURRENT_REQUESTS`: + Limits the allowed number of concurrent requests to the GitHub API. Defaults to 20. + +* `errorHandler`: + Function for custom error handling logic. See [handling errors](#handling-errors) for more details. + ## Bespoke API access ## Mostly a work in progress, but here's a taste of what I have in mind: +### Branches ### +#### List branches #### + ```coffeescript gh.branches "foo/bar", (branches) -> console.log branches[0].name ``` +#### Create a branch #### + ```coffeescript # Branch from master gh.branches( "foo/bar" ).create "my_radical_feature", (branch) -> @@ -68,36 +160,81 @@ gh.branches( "foo/bar" ).create "even_more_radical", from: "my_radical_feature", console.log branch.sha ``` +#### Merge a branch #### + +```coffeescript +# Merge a branch into master +gh.branches( "foo/bar" ).merge "my_radical_feature", (mergeCommit) -> + console.log mergeCommit.message + +# Merge a branch into a different target +gh.branches( "foo/bar" ).merge "my_radical_feature", into: "hotfixes", (mergeCommit) -> + console.log mergeCommit.message + +# `base` is an alias for `into` +gh.branches( "foo/bar" ).merge "my_radical_feature", base: "hotfixes", (mergeCommit) -> + console.log mergeCommit.message + +# Provide your own commit message +gh.branches( "foo/bar" ).merge "my_radical_feature", message: "Merge my radical feature!", (mergeCommit) -> + console.log mergeCommit.sha +``` + +#### Delete a branch #### + ```coffeescript gh.branches( "foo/bar" ).delete "my_radical_feature", -> console.log "Deleted my branch!" ``` -## Helpful Hubot ## +### Deployments ### -Hubot will log errors if a request fails. +*Note*: These methods are smart and automatically use the +`cannonball-preview` version header. No intervention needed! -If `process.env.HUBOT_GITHUB_USER` is present, we can help you guess a repo's full name: +#### List deployments #### ```coffeescript -github.qualified_repo "githubot" # => "iangreenleaf/githubot" +gh.deployments "foo/bar", (deploys) -> + console.log deploys.length ``` -This will happen with the bespoke methods as well: +#### Create a deployment #### ```coffeescript -gh.branches "githubot", (branches) -> +gh.deployments("foo/bar") + .create 'my-branch', payload: {env: 'staging'}, description: "Ship it!", (deploy) => + console.log deploy.url +``` + +#### Deployment statuses #### + +```coffeescript +gh.deployments("foo/bar").status deployId, (deploys) -> + console.log deploys[0].state ``` -If `process.env.HUBOT_GITHUB_API` exists, it will override the default API URL. This is useful for Enterprise Github installations. +## Contributing ## + +Install the dependencies: + + npm install + +Run the tests: + + make test + make test-all # Runs additional slower "integration" style tests, generally not necessary + +**Pull requests encouraged!** - HUBOT_GITHUB_API='http://myprivate.github.int' +I'm vastly more likely to merge code that comes with tests. If you're confused by the testing process, +ask and I can probably point you in the right direction. -`process.env.HUBOT_CONCURRENT_REQUESTS` limits the allowed number of concurrent requests to the GitHub API. Defaults to 20. +## Thanks ## -## [Contributors](https://github.com/iangreenleaf/githubot/contributors) ## +[These lovely people have contributed to githubot](https://github.com/iangreenleaf/githubot/contributors). -[Build Status]: https://secure.travis-ci.org/iangreenleaf/githubot.png?branch=master +[Build Status]: https://travis-ci.org/iangreenleaf/githubot.png?branch=master diff --git a/githubot.coffee b/githubot.coffee deleted file mode 100644 index fcfe547..0000000 --- a/githubot.coffee +++ /dev/null @@ -1,93 +0,0 @@ -http = require "scoped-http-client" -async = require "async" -querystring = require "querystring" - -process.env.HUBOT_CONCURRENT_REQUESTS ?= 20 - -class Github - constructor: (@logger) -> - @requestQueue = async.queue (task, cb) => - task.run cb - , process.env.HUBOT_CONCURRENT_REQUESTS - qualified_repo: (repo) -> - unless repo? - unless (repo = process.env.HUBOT_GITHUB_REPO)? - @logger.error "Default Github repo not specified" - return null - repo = repo.toLowerCase() - return repo unless repo.indexOf("/") is -1 - unless (user = process.env.HUBOT_GITHUB_USER)? - @logger.error "Default Github user not specified" - return repo - "#{user}/#{repo}" - request: (verb, url, data, cb) -> - unless cb? - [cb, data] = [data, null] - - url_api_base = process.env.HUBOT_GITHUB_API || "https://api.github.com" - - if url[0..3] isnt "http" - url = "/#{url}" unless url[0] is "/" - url = "#{url_api_base}#{url}" - req = http.create(url).header("Accept", "application/vnd.github.beta+json") - req = req.header("Authorization", "token #{oauth_token}") if (oauth_token = process.env.HUBOT_GITHUB_TOKEN)? - args = [] - args.push JSON.stringify data if data? - args.push "" if verb is "DELETE" and not data? - task = run: (cb) -> req[verb.toLowerCase()](args...) cb - @requestQueue.push task, (err, res, body) => - return @logger.error err if err? - - try - data = JSON.parse body if body - catch e - return @logger.error "Could not parse response: #{body}" - - if (200 <= res.statusCode < 300) - cb data - else - @logger.error "#{res.statusCode} #{data.message}" - get: (url, data, cb) -> - unless cb? - [cb, data] = [data, null] - if data? - url += "?" + querystring.stringify data - @request "GET", url, cb - post: (url, data, cb) -> - @request "POST", url, data, cb - branches: (repo, cb) -> - if cb? - @get("https://api.github.com/repos/#{@qualified_repo repo}/branches", cb) - else - create: (branchName, opts, cb) => - [opts,cb] = [{},opts] unless cb? - opts.from ?= "master" - @get "https://api.github.com/repos/#{@qualified_repo repo}/git/refs/heads/#{opts.from}", (json) => - sha = json.object.sha - @post "https://api.github.com/repos/#{@qualified_repo repo}/git/refs", - ref: "refs/heads/#{branchName}", sha: sha - , (data) -> - cb name: branchName, commit: { sha: data.object.sha, url: data.object.url } - delete: (branchNames..., cb) => - actions = [] - for branchName in branchNames - do (branchName) => - actions.push (done) => - @request "DELETE", "https://api.github.com/repos/#{@qualified_repo repo}/git/refs/heads/#{branchName}", done - async.parallel actions, cb - -module.exports = github = (robot) -> - new Github robot.logger - -github[method] = func for method,func of Github.prototype - -github.logger = { - error: (msg) -> - util = require "util" - util.error "ERROR: #{msg}" - debug: -> -} - -github.requestQueue = async.queue (task, cb) => - task.run cb -, process.env.HUBOT_CONCURRENT_REQUESTS diff --git a/package.json b/package.json index 090898b..2d7c6d0 100644 --- a/package.json +++ b/package.json @@ -2,26 +2,28 @@ "author": "Ian Young ", "name": "githubot", "description": "Hubot-compatible Github API wrapper for Node.js", - "version": "0.2.1", + "version": "1.0.1", "homepage": "https://github.com/iangreenleaf/githubot", + "license": "MIT", "repository": { "type": "git", "url": "git@github.com:iangreenleaf/githubot.git" }, - "main": "./githubot", + "main": "./lib/githubot", "scripts": { + "prepublish": "coffee -o lib -c src && cat .gitignore | grep -v '^lib$' > .npmignore", "test": "make test" }, "engines": { - "node": ">=0.4.7 <0.7.0" + "node": ">=0.10.0" }, "dependencies": { - "scoped-http-client": "https://github.com/iangreenleaf/node-scoped-http-client/tarball/content_length_published", - "async": "~0.1.22" + "scoped-http-client": ">= 0.9.8", + "async": "0.2.x" }, "devDependencies": { - "mocha": "~1.1", - "coffee-script": "~1.1.3", - "nock": "~0.13.3" + "mocha": "~2.2", + "coffee-script": "~1.9.1", + "nock": "~0.28.3" } } diff --git a/src/branches.coffee b/src/branches.coffee new file mode 100644 index 0000000..4b7b222 --- /dev/null +++ b/src/branches.coffee @@ -0,0 +1,34 @@ +async = require "async" + +module.exports = (repo, cb) -> + if cb? + @get("repos/#{@qualified_repo repo}/branches", cb) + else + create: (branchName, opts, cb) => + [opts,cb] = [{},opts] unless cb? + opts.from ?= "master" + @get "repos/#{@qualified_repo repo}/git/refs/heads/#{opts.from}", (json) => + sha = json.object.sha + @post "repos/#{@qualified_repo repo}/git/refs", + ref: "refs/heads/#{branchName}", sha: sha + , (data) -> + cb name: branchName, commit: { sha: data.object.sha, url: data.object.url } + delete: (branchNames..., cb) => + actions = [] + for branchName in branchNames + do (branchName) => + actions.push (done) => + @request "DELETE", "repos/#{@qualified_repo repo}/git/refs/heads/#{branchName}", done + async.parallel actions, cb + merge: (head, opts, cb) => + [opts,cb] = [{},opts] unless cb? + body = + base: opts.base ? opts.into ? "master" + head: head + if opts.message? + body.commit_message = opts.message + @post "repos/#{@qualified_repo repo}/merges", body, (data) => + unless data? + return @_errorHandler + error: "Nothing to merge" + cb sha: data.sha, message: data.commit.message, url: data.url diff --git a/src/deployments.coffee b/src/deployments.coffee new file mode 100644 index 0000000..548901a --- /dev/null +++ b/src/deployments.coffee @@ -0,0 +1,22 @@ +module.exports = (repo, cb) -> + # These features are in preview mode + self = @withOptions apiVersion: 'cannonball-preview' + if cb? + self.get("repos/#{self.qualified_repo repo}/deployments", cb) + else + create: (branchName, opts, cb) => + [opts,cb] = [{},opts] unless cb? + body = + ref: branchName ? "master" + if opts.force? + body.force = opts.force + if opts.payload? + body.payload = opts.payload + if opts.auto_merge? + body.auto_merge = opts.auto_merge + if opts.description? + body.description = opts.description + self.post "repos/#{self.qualified_repo repo}/deployments", body, (data) => + cb sha: data.sha, description: data.description, url: data.url + status: (id, cb) => + self.get("repos/#{self.qualified_repo repo}/deployments/#{id}/statuses", cb) diff --git a/src/githubot.coffee b/src/githubot.coffee new file mode 100644 index 0000000..0804b15 --- /dev/null +++ b/src/githubot.coffee @@ -0,0 +1,137 @@ +http = require "scoped-http-client" +async = require "async" +querystring = require "querystring" + +version = require("../package.json")["version"] + +class Github + constructor: (@logger, @options) -> + @requestQueue = async.queue (task, cb) => + task.run cb + , @_opt "concurrentRequests" + withOptions: (specialOptions) -> + newOpts = {} + newOpts[k] = v for k,v of @options + newOpts[k] = v for k,v of specialOptions + g = new Github @logger, newOpts + g.requestQueue = @requestQueue + g + qualified_repo: (repo) -> + unless repo? + unless (repo = @_opt "defaultRepo")? + @logger.error "Default Github repo not specified" + return null + repo = repo.toLowerCase() + return repo unless repo.indexOf("/") is -1 + unless (user = @_opt "defaultUser")? + @logger.error "Default Github user not specified" + return repo + "#{user}/#{repo}" + request: (verb, url, data, cb) -> + unless cb? + [cb, data] = [data, null] + + url_api_base = @_opt("apiRoot") + + if url[0..3] isnt "http" + url = "/#{url}" unless url[0] is "/" + url = "#{url_api_base}#{url}" + req = http.create(url).header("Accept", "application/vnd.github.#{@_opt "apiVersion"}+json") + req = req.header("User-Agent", "GitHubot/#{version}") + oauth_token = @_opt "token" + req = req.header("Authorization", "token #{oauth_token}") if oauth_token? + args = [] + args.push JSON.stringify data if data? + args.push "" if verb is "DELETE" and not data? + task = run: (cb) -> req[verb.toLowerCase()](args...) cb + @requestQueue.push task, (err, res, body) => + if err? + return @_errorHandler + statusCode: res?.statusCode + body: res?.body + error: err + + try + responseData = JSON.parse body if body + catch e + return @_errorHandler + statusCode: res.statusCode + body: body + error: "Could not parse response: #{body}" + + if (200 <= res.statusCode < 300) + cb responseData + else + @_errorHandler + statusCode: res.statusCode + body: body + error: responseData.message + + get: (url, data, cb) -> + unless cb? + [cb, data] = [data, null] + if data? + url += "?" + querystring.stringify data + @request "GET", url, cb + + post: (url, data, cb) -> + @request "POST", url, data, cb + + delete: (url, cb) -> + @request "DELETE", url, null, cb + + put: (url, data, cb) -> + @request "PUT", url, data, cb + + patch: (url, data, cb) -> + @request "PATCH", url, data, cb + + handleErrors: (callback) -> + @options.errorHandler = callback + + _loggerErrorHandler: (response) -> + message = response.error + message = "#{response.statusCode} #{message}" if response.statusCode? + @logger.error message + + _errorHandler: (response) -> + @options.errorHandler?(response) + @_loggerErrorHandler response + + branches: require './branches' + + deployments: require './deployments' + + _opt: (optName) -> + @options ?= {} + @options[optName] ? @_optFromEnv(optName) + _optFromEnv: (optName) -> + switch optName + when "token" + process.env.HUBOT_GITHUB_TOKEN + when "concurrentRequests" + process.env.HUBOT_CONCURRENT_REQUESTS ? 20 + when "defaultRepo" + process.env.HUBOT_GITHUB_REPO + when "defaultUser" + process.env.HUBOT_GITHUB_USER + when "apiRoot" + process.env.HUBOT_GITHUB_API ? "https://api.github.com" + when "apiVersion" + process.env.HUBOT_GITHUB_API_VERSION ? "v3" + else null + +module.exports = github = (robot, options = {}) -> + new Github robot.logger, options + +github[method] = func for method,func of Github.prototype + +github.logger = { + error: (msg) -> + console.error "ERROR: #{msg}" + debug: -> +} + +github.requestQueue = async.queue (task, cb) => + task.run cb +, process.env.HUBOT_CONCURRENT_REQUESTS ? 20 diff --git a/test/windowing.coffee b/test/acceptance/windowing.coffee similarity index 75% rename from test/windowing.coffee rename to test/acceptance/windowing.coffee index 28612fb..c0900b6 100644 --- a/test/windowing.coffee +++ b/test/acceptance/windowing.coffee @@ -1,6 +1,10 @@ -[ gh, assert, nock, mock_robot ] = require "./test_helper" +[ gh, assert, nock, mock_robot ] = require "../test_helper" http = require "http" +# nock screws up the parsed URL options in node 0.4, and I can't be bothered +# to deal with the problem. +return if process.versions.node < "0.5.0" + describe "concurrent requests", -> it "are limited", (done) -> maxRequests = 0 @@ -20,7 +24,7 @@ describe "concurrent requests", -> process.env.HUBOT_GITHUB_API = "http://localhost:#{port}" process.env.HUBOT_CONCURRENT_REQUESTS = 35 - gh = require("..") mock_robot + gh = require("../..") mock_robot for i in [1..remain] gh.request "GET", "/repos/foo/bar/branches/#{i}", -> if --remain is 0 diff --git a/test/mocha.opts b/test/mocha.opts new file mode 100644 index 0000000..8c1102b --- /dev/null +++ b/test/mocha.opts @@ -0,0 +1,2 @@ +--require coffee-script/register +--compilers coffee:coffee-script/register diff --git a/test/repo.coffee b/test/repo.coffee index 7b7c32a..0840e59 100644 --- a/test/repo.coffee +++ b/test/repo.coffee @@ -22,6 +22,17 @@ describe "repo api", -> gh.branches "foo/bar", (data) -> assert.deepEqual response, data done() + it "allows per-request overrides", (done) -> + network = nock("https://special.api.dev") + .get("/repos/bar/baz/branches") + .reply(200, response) + gh.withOptions( + apiRoot: "https://special.api.dev" + defaultUser: "bar" + defaultRepo: "baz" + ) + .branches null, success done + delete process.env.HUBOT_GITHUB_USER describe "create", -> beforeEach -> @@ -97,5 +108,133 @@ describe "repo api", -> network.delete("/repos/foo/bar/git/refs/heads/anotherBranch") .reply(204, {}) gh.branches( "foo/bar" ).delete @branchName, "anotherBranch", -> + process.nextTick -> + network.done() + done() + + describe "merge", -> + beforeEach -> + @branchName = "newbranch" + @sha = "deadbeef" + + it "succeeds", (done) -> + network = nock("https://api.github.com") + .post("/repos/foo/bar/merges", + base: "master", head: @branchName) + .reply( + 201 + , sha: @sha, url: "xyz", commit: { message: "commit message" } + ) + gh.branches("foo/bar").merge @branchName, (commit) => + assert.deepEqual commit, + sha: @sha, message: "commit message", url: "xyz" + network.done() + done() + + context "with base specified", (done) -> + beforeEach -> + @base = "targetbranch" + network = nock("https://api.github.com") + .post("/repos/foo/bar/merges", + base: @base, head: @branchName) + .reply( + 201 + , sha: @sha, url: "xyz", commit: { message: "commit message" } + ) + @cb = (done) => (commit) => + assert.deepEqual commit, + sha: @sha, message: "commit message", url: "xyz" + network.done() + done() + + it "as 'into'", (done) -> + gh.branches("foo/bar").merge @branchName, {into: @base}, @cb(done) + it "as 'base'", (done) -> + gh.branches("foo/bar").merge @branchName, {base: @base}, @cb(done) + + it "with commit message specified", (done) -> + @message = "An awesome merge!" + network = nock("https://api.github.com") + .post("/repos/foo/bar/merges", + base: "master", head: @branchName, commit_message: @message) + .reply( + 201 + , sha: @sha, url: "xyz", commit: { message: @message } + ) + gh.branches("foo/bar").merge @branchName, message: @message, (commit) => + assert.deepEqual commit, + sha: @sha, message: @message, url: "xyz" + network.done() + done() + + context "when no-op", (done) -> + beforeEach -> + network = nock("https://api.github.com") + .post("/repos/foo/bar/merges", + base: "master", head: @branchName) + .reply(204) + + it "errors", (done) -> + gh.branches("foo/bar").merge @branchName, -> + assert.fail null, null, "Should not call callback" + mock_robot.onError = (msg) -> + assert.ok /nothing to merge/i.exec msg + network.done() + done() + + it "notifies custom error handler", (done) -> + errHandler = (response) -> + assert.ok /nothing to merge/i.exec response.error + network.done() + done() + gh.withOptions(errorHandler: errHandler).branches("foo/bar").merge @branchName + + describe "deployments", -> + describe "create deployment", -> + beforeEach -> + @branchName = "newbranch" + @payload = '{"environment":"production","deploy_user":"atmos","room_id":123456}' + @description = "deploying my sweet branch" + + it "succeeds", (done) -> + network = nock("https://api.github.com") + .post("/repos/foo/bar/deployments", + payload: @payload, ref: @branchName, description: @description) + .reply( + 201 + , sha: @branchName, url: "xyz/1", description: "abc" + ) + gh.deployments("foo/bar").create @branchName, {payload: @payload, description: @description}, (status) => + assert.deepEqual status, + sha: @branchName, description: "abc", url: "xyz/1" + network.done() + done() + + describe "get deployment status", -> + beforeEach -> + @statusId = "123" + + it "succeeds", (done) -> + network = nock("https://api.github.com") + .get("/repos/foo/bar/deployments/#{@statusId}/statuses") + .reply( + 201 + , [{id: @statusId, state: "success", url: "xyz/1", description: "abc"}] + ) + gh.deployments("foo/bar").status @statusId, (status) => + assert.deepEqual status[0], + id: @statusId, state: "success", url: "xyz/1", description: "abc" + network.done() + done() + + it "uses special version header", (done) -> + network = nock("https://api.github.com") + .get("/repos/foo/bar/deployments/#{@statusId}/statuses") + .matchHeader('Accept', 'application/vnd.github.cannonball-preview+json') + .reply( + 201 + , [{id: @statusId, state: "success", url: "xyz/1", description: "abc"}] + ) + gh.deployments("foo/bar").status @statusId, (status) -> network.done() done() diff --git a/test/request.coffee b/test/request.coffee index a8c7d5c..94c878f 100644 --- a/test/request.coffee +++ b/test/request.coffee @@ -25,7 +25,38 @@ describe "github api", -> gh.request "GET", "repos/foo/bar/branches", success done delete process.env.HUBOT_GITHUB_TOKEN it "includes accept header", (done) -> - network.matchHeader('Accept', 'application/vnd.github.beta+json') + network.matchHeader('Accept', 'application/vnd.github.v3+json') + gh.request "GET", "repos/foo/bar/branches", success done + it "allows setting API version", (done) -> + ghPreview = require("../src/githubot") mock_robot, apiVersion: 'preview' + network.matchHeader('Accept', 'application/vnd.github.preview+json') + ghPreview.request "GET", "repos/foo/bar/branches", success done + it "allows setting API version for single request", (done) -> + network.matchHeader('Accept', 'application/vnd.github.special+json') + gh.withOptions(apiVersion: 'special').request "GET", "repos/foo/bar/branches", success done + it "allows setting an API version for single request without robot", (done) -> + noRobot = require("../src/githubot") + newGh = noRobot.withOptions('Accept', 'application/vnd.github.preview+json') + newGh.request "GET", "repos/foo/bar/branches", success done + it "allows setting the oauth token for single request", (done) -> + process.env.HUBOT_GITHUB_TOKEN = "789xyz" + network.matchHeader("Authorization", "token abc") + gh.withOptions(token: 'abc').request "GET", "repos/foo/bar/branches", success done + delete process.env.HUBOT_GITHUB_TOKEN + it "doesn't persist per-request options", (done) -> + network.matchHeader('Accept', 'application/vnd.github.special+json') + gh.withOptions(apiVersion: 'special').request "GET", "repos/foo/bar/branches", -> + network.done() + network2 = nock("https://api.github.com") + .get("/repos/baz/bar/branches") + .matchHeader('Accept', 'application/vnd.github.v3+json') + .reply(200, response) + # Should revert to the defaults on this request + gh.request "GET", "repos/baz/bar/branches", -> + network2.done() + done() + it "includes User-Agent header", (done) -> + network.matchHeader('User-Agent', /GitHubot\/\d+\.\d+\.\d+/) gh.request "GET", "repos/foo/bar/branches", success done it "returns parsed json", (done) -> gh.request "GET", "repos/foo/bar/branches", (data) -> @@ -71,25 +102,61 @@ describe "github api", -> it "sends request", (done) -> gh.post "gists", data, success done + describe "put", -> + data = description: "A test gist", public: true, files: { "abc.txt": { content: "abcdefg" } } + response = url: "http://api.github.com/gists/1", id: 1 + beforeEach -> + network = nock("https://api.github.com") + .put("/gists", data) + .reply(201, response) + it "sends request", (done) -> + gh.put "gists", data, success done + + describe "patch", -> + data = description: "A test gist", public: true, files: { "abc.txt": { content: "abcdefg" } } + response = url: "http://api.github.com/gists/1", id: 1 + beforeEach -> + network = nock("https://api.github.com") + .patch("/gists", data) + .reply(201, response) + it "sends request", (done) -> + gh.patch "gists", data, success done + describe "delete", -> it "sends request", (done) -> network = nock("https://api.github.com") .delete("/gists/345") .reply(204) - gh.request "DELETE", "gists/345", success done + gh.delete "gists/345", success done it "includes empty body", (done) -> network = nock("https://api.github.com") .delete("/gists/345", "") .matchHeader("Content-Length", 0) .reply(204) - gh.request "DELETE", "gists/345", success done + gh.delete "gists/345", success done describe "errors", -> network = null + http = require "scoped-http-client" never_called = -> assert.fail(null, null, "Success callback should not be invoked") beforeEach -> network = nock("https://api.github.com").get("/foo") + afterEach -> + mock_robot.onError = null + kablooie = -> + mock = { + header: -> mock, + get: () -> (cb) -> + cb new Error "Kablooie!" + } + http._old_create = http.create + http.create = -> mock + afterEach -> + if http._old_create? + http.create = http._old_create + http._old_create = null + it "complains about failed response", (done) -> network.reply(401, message: "Bad credentials") mock_robot.onError = (msg) -> @@ -103,31 +170,84 @@ describe "github api", -> done() gh.get "/foo", never_called it "complains about client errors", (done) -> - mock = { - header: -> mock, - get: () -> (cb) -> - cb new Error "Kablooie!" - } - http = require "scoped-http-client" - http._old_create = http.create - http.create = -> mock + kablooie() mock_robot.onError = (msg) -> assert.ok /kablooie/i.exec msg done() gh.get "/foo", never_called - http.create = http._old_create + + describe "with error handler", -> + defaultHandler = gh._errorHandler + beforeEach -> + network = nock("https://api.github.com") + .get("/foo") + afterEach -> + gh._errorHandler = defaultHandler + + it "calls handler on error", (done) -> + network.reply(406, message: "I hate you!") + gh.handleErrors (response) -> + assert.equal 406, response.statusCode + assert.equal "I hate you!", response.error + assert.equal '{"message":"I hate you!"}', response.body + done() + gh.get "/foo", never_called + + it "doesn't call handler on success", (done) -> + network.reply(201, message: "Hooray!") + gh.handleErrors never_called + gh.get "/foo", -> done() + + it "passes body if can't parse response", (done) -> + network.reply(500, "WTF$$%@! SERVER VOMIT") + gh.handleErrors (response) -> + assert.equal 500, response.statusCode + assert.equal "WTF$$%@! SERVER VOMIT", response.body + done() + gh.get "/foo", never_called + + it "passes error if request failed", (done) -> + kablooie() + gh.handleErrors (response) -> + assert.ok /kablooie/i.exec response.error + done() + gh.get "/foo", never_called + + it "still logs errors", (done) -> + network.reply(406, message: "I hate you!") + expected = 2 + cb = -> + expected -= 1 + done() if expected is 0 + mock_robot.onError = cb + gh.handleErrors cb + gh.get "/foo", never_called + + it "works in combination with withOptions", (done) -> + network.matchHeader('Accept', 'application/vnd.github.special+json') + network.reply(406, message: "I hate you!") + gh.handleErrors (response) -> + assert.equal 406, response.statusCode + done() + gh.withOptions(apiVersion: 'special').get "/foo", never_called + + it "can be passed as withOptions", (done) -> + network.reply(406, message: "I hate you!") + errHandler = (response) -> + assert.equal 406, response.statusCode + done() + gh.withOptions(errorHandler: errHandler).get "/foo", never_called describe "without robot given", -> before -> - gh = require("..") + gh = require("../src/githubot") it "complains to stderr", (done) -> - util = require "util" - util._old_error = util.error - util.error = (msg) -> + console._old_error = console.error + console.error = (msg) -> if msg.match /bad credentials/i - util.error = util._old_error + console.error = @_old_error done() else @_old_error.call process.stderr, msg - network.reply(401, message: "Bad credentials") + network.reply(401, message: "Bad credentials!") gh.get "/foo", never_called diff --git a/test/test_helper.coffee b/test/test_helper.coffee index cacf796..d1ec6d0 100644 --- a/test/test_helper.coffee +++ b/test/test_helper.coffee @@ -9,7 +9,7 @@ mock_robot = mock_robot.onError = null mock_robot.logs = { error: [], debug: [] } -gh = require("..") mock_robot +gh = require("../src/githubot") mock_robot nock = require("nock") module.exports = [ gh, require("assert"), nock, mock_robot ]