From 6be9f1ec7cf0bd10bab5b8a9b6fcaad6555d4e0c Mon Sep 17 00:00:00 2001 From: Dao Lam Date: Fri, 14 Oct 2016 11:37:46 -0700 Subject: [PATCH 1/2] Do not mock circuit breaker --- test/index.test.js | 100 +++++++++++++++++++-------------------------- 1 file changed, 41 insertions(+), 59 deletions(-) diff --git a/test/index.test.js b/test/index.test.js index cd27a78..030b594 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -10,17 +10,10 @@ const testPayloadPush = require('./data/repo.push.json'); require('sinon-as-promised'); sinon.assert.expose(assert, { prefix: '' }); -/** - * Stub for circuit-fuses wrapper - * @method BreakerMock - */ -function BreakerMock() {} - describe('index', () => { let BitbucketScm; let scm; let requestMock; - let breakRunMock; before(() => { mockery.enable({ @@ -31,32 +24,15 @@ describe('index', () => { beforeEach(() => { requestMock = sinon.stub(); - breakRunMock = { - runCommand: sinon.stub(), - stats: sinon.stub().returns({ - requests: { - total: 1, - timeouts: 2, - success: 3, - failure: 4, - concurrent: 5, - averageTime: 6 - }, - breaker: { - isClosed: false - } - }) - }; - - BreakerMock.prototype = breakRunMock; - mockery.registerMock('circuit-fuses', BreakerMock); mockery.registerMock('request', requestMock); /* eslint-disable global-require */ BitbucketScm = require('../index'); /* eslint-enable global-require */ - scm = new BitbucketScm(); + scm = new BitbucketScm({ + fusebox: { retry: { minTimeout: 1 } } + }); }); afterEach(() => { @@ -88,7 +64,7 @@ describe('index', () => { uuid: '{de7d7695-1196-46a1-b87d-371b7b2945ab}' } }; - breakRunMock.runCommand.resolves(fakeResponse); + requestMock.yieldsAsync(null, fakeResponse, fakeResponse.body); }); it('resolves to the correct parsed url for ssh', () => { @@ -105,7 +81,7 @@ describe('index', () => { checkoutUrl: 'git@bitbucket.org:batman/test.git#master', token }).then((parsed) => { - assert.calledWith(breakRunMock.runCommand, expectedOptions); + assert.calledWith(requestMock, expectedOptions); assert.equal(parsed, expected); }); }); @@ -124,7 +100,7 @@ describe('index', () => { checkoutUrl: 'https://batman@bitbucket.org/batman/test.git#mynewbranch', token: 'myAccessToken' }).then((parsed) => { - assert.calledWith(breakRunMock.runCommand, expectedOptions); + assert.calledWith(requestMock, expectedOptions); assert.equal(parsed, expected); }); }); @@ -138,17 +114,17 @@ describe('index', () => { oauth_access_token: 'myAccessToken' }; - breakRunMock.runCommand.rejects(err); + requestMock.yieldsAsync(err); return scm.parseUrl({ checkoutUrl: 'https://batman@bitbucket.org/batman/test.git#mynewbranch', token: 'myAccessToken' }) - .then(() => assert.fail('Should not get here')) - .catch((error) => { - assert.calledWith(breakRunMock.runCommand, expectedOptions); - assert.deepEqual(error, err); - }); + .then(() => assert.fail('Should not get here')) + .catch((error) => { + assert.calledWith(requestMock, expectedOptions); + assert.deepEqual(error, err); + }); }); it('rejects if status code is not 200', () => { @@ -169,17 +145,17 @@ describe('index', () => { } }; - breakRunMock.runCommand.resolves(fakeResponse); + requestMock.yieldsAsync(null, fakeResponse, fakeResponse.body); return scm.parseUrl({ checkoutUrl: 'https://batman@bitbucket.org/batman/test.git#mynewbranch', token: 'myAccessToken' }) - .then(() => assert.fail('Should not get here')) - .catch((error) => { - assert.calledWith(breakRunMock.runCommand, expectedOptions); - assert.match(error.message, 'STATUS CODE 404'); - }); + .then(() => assert.fail('Should not get here')) + .catch((error) => { + assert.calledWith(requestMock, expectedOptions); + assert.match(error.message, 'STATUS CODE 404'); + }); }); }); @@ -239,32 +215,38 @@ describe('index', () => { .then(result => assert.deepEqual(result, expected)); }); - it('throws error if events are not supported', () => { + it('throws error if events are not supported: repoFork', () => { const repoFork = { 'X-Event-Key': 'repo:fork' }; - const prComment = { - 'X-Event-Key': 'pullrequest:comment_created' - }; - const issueCreated = { - 'X-Event-Key': 'issue:created' - }; - scm.parseHook(repoFork, {}) + return scm.parseHook(repoFork, {}) .then(() => assert.fail('Should not get here')) .catch((error) => { assert.deepEqual(error.message, 'Only push event is supported for repository'); }); + }); + + it('throws error if events are not supported: prComment', () => { + const prComment = { + 'X-Event-Key': 'pullrequest:comment_created' + }; - scm.parseHook(prComment, {}) + return scm.parseHook(prComment, {}) .then(() => assert.fail('Should not get here')) .catch((error) => { assert.deepEqual(error.message, 'Only created and fullfilled events are supported for pullrequest'); }); + }); + + it('throws error if events are not supported: issueCreated', () => { + const issueCreated = { + 'X-Event-Key': 'issue:created' + }; - scm.parseHook(issueCreated, {}) + return scm.parseHook(issueCreated, {}) .then(() => assert.fail('Should not get here')) .catch((error) => { assert.deepEqual(error.message, @@ -277,15 +259,15 @@ describe('index', () => { it('returns the correct stats', () => { assert.deepEqual(scm.stats(), { requests: { - total: 1, - timeouts: 2, - success: 3, - failure: 4, - concurrent: 5, - averageTime: 6 + total: 0, + timeouts: 0, + success: 0, + failure: 0, + concurrent: 0, + averageTime: 0 }, breaker: { - isClosed: false + isClosed: true } }); }); From 3d11683b75eded0317f04a56eab84c9e59b0c53f Mon Sep 17 00:00:00 2001 From: Dao Lam Date: Fri, 14 Oct 2016 11:38:23 -0700 Subject: [PATCH 2/2] Add decorated functions --- README.md | 78 ++++++++++- index.js | 125 ++++++++++++++++- package.json | 2 +- test/index.test.js | 331 +++++++++++++++++++++++++++++++++++++++++---- 4 files changed, 502 insertions(+), 34 deletions(-) diff --git a/README.md b/README.md index 683703a..e4fe55b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Required parameters: | config.token | String | Access token for scm | #### Output: Promise -1. Resolves to an scm uri for the repository. Ex: `bitbucket.org:{1234}:branchName`, where `{1234}` is repository's uuid. +1. Resolves to an scm uri for the repository. Ex: `bitbucket.org:batman/{1234}:branchName`, where `batman` is the repository's owner and `{1234}` is repository's uuid. 2. Rejects if not able to parse url ### parseHook @@ -45,6 +45,82 @@ Required parameters: ``` 2. Rejects if not able to parse webhook payload +### decorateUrl +Required parameters: + +| Parameter | Type | Description | +| :------------- | :---- | :-------------| +| config | Object | Configuration Object | +| config.scmUri | String | Scm uri (ex: `bitbucket.org:batman/{1234}:branchName`) | +| config.token | String | Access token for scm | + +#### Expected Outcome +Decorated url in the form of: +```js +{ + url: 'https://bitbucket.org/batman/test.git', + name: 'batman/test', + branch: 'mybranch' +} +``` + +#### Expected Promise response +1. Resolve with a decorated url object for the repository +2. Reject if not able to get decorate url + +### decorateCommit +Required parameters: + +| Parameter | Type | Description | +| :------------- | :---- | :-------------| +| config | Object | Configuration Object | +| config.sha | String | Commit sha to decorate | +| config.scmUri | String | Scm uri (ex: `bitbucket.org:1234:branchName`) | +| config.token | String | Access token for scm | + +#### Expected Outcome +Decorated commit in the form of: +```js +{ + url: 'https://bitbucket.org/screwdriver-cd/scm-base/commit/5c3b2cc64ee4bdab73e44c394ad1f92208441411', + message: 'Use screwdriver to publish', + author: { + url: 'https://bitbucket.org/d2lam', + name: 'Dao Lam', + username: 'd2lam', + avatar: 'https://bitbucket.org/account/d2lam/avatar/32/' + } +} +``` + +#### Expected Promise response +1. Resolve with a decorate commit object for the repository +2. Reject if not able to decorate commit + +### decorateAuthor +Required parameters: + +| Parameter | Type | Description | +| :------------- | :---- | :-------------| +| config | Object | Configuration Object | +| config.username | String | Author to decorate | +| config.token | String | Access token for scm | + +#### Expected Outcome +Decorated author in the form of: +```js +{ + url: 'https://bitbucket.org/d2lam', + name: 'Dao Lam', + username: 'd2lam', + avatar: 'https://bitbucket.org/account/d2lam/avatar/32/' +} +``` + +#### Expected Promise response +1. Resolve with a decorate author object for the repository +2. Reject if not able to decorate author + ## Testing ```bash diff --git a/index.js b/index.js index 83859d2..9a9a262 100644 --- a/index.js +++ b/index.js @@ -30,17 +30,31 @@ function getRepoInfo(checkoutUrl) { }; } +/** + * @method getScmUriParts + * @param {String} scmUri + * @return {Object} + */ +function getScmUriParts(scmUri) { + const scm = {}; + + [scm.hostname, scm.repoId, scm.branch] = scmUri.split(':'); + + return scm; +} + class BitbucketScm extends Scm { /** * Constructor for Scm * @method constructor - * @param {Object} config Configuration + * @param {Object} config Configuration + * @param {String} [config.fusebox] Options for the circuit breaker * @return {ScmBase} */ constructor(config) { super(config); - this.breaker = new Fusebox(request); + this.breaker = new Fusebox(request, config.fusebox); } /** @@ -54,12 +68,10 @@ class BitbucketScm extends Scm { _parseUrl(config) { const repoInfo = getRepoInfo(config.checkoutUrl); const getBranchUrl = `${API_URL}/repositories/${repoInfo.username}/${repoInfo.repo}` + - `/refs/branches/${repoInfo.branch}`; + `/refs/branches/${repoInfo.branch}?access_key=${config.token}`; const options = { url: getBranchUrl, - method: 'GET', - login_type: 'oauth2', - oauth_access_token: config.token + method: 'GET' }; return this.breaker.runCommand(options) @@ -130,6 +142,107 @@ class BitbucketScm extends Scm { } } + /** + * Decorate the author based on the Bitbucket + * @method _decorateAuthor + * @param {Object} config Configuration object + * @param {Object} config.token Access token to authenticate with Bitbucket + * @param {Object} config.username Username to query more information for + * @return {Promise} + */ + _decorateAuthor(config) { + const options = { + url: `${API_URL}/users/${config.username}?access_key=${config.token}`, + method: 'GET' + }; + + return this.breaker.runCommand(options) + .then((response) => { + const body = response.body; + + if (response.statusCode !== 200) { + throw new Error(`STATUS CODE ${response.statusCode}: ${body}`); + } + + return { + url: body.links.html.href, + name: body.display_name, + username: body.username, + avatar: body.links.avatar.href + }; + }); + } + + /** + * Decorate a given SCM URI with additional data to better display + * related information. If a branch suffix is not provided, it will default + * to the master branch + * @method decorateUrl + * @param {Config} config Configuration object + * @param {String} config.scmUri The SCM URI the commit belongs to + * @param {String} config.token Service token to authenticate with Github + * @return {Object} + */ + _decorateUrl(config) { + const scm = getScmUriParts(config.scmUri); + const options = { + url: `${API_URL}/repositories/${scm.repoId}?access_key=${config.token}`, + method: 'GET' + }; + + return this.breaker.runCommand(options) + .then((response) => { + const body = response.body; + + if (response.statusCode !== 200) { + throw new Error(`STATUS CODE ${response.statusCode}: ${body}`); + } + + return { + url: body.links.html.href, + name: body.full_name, + branch: scm.branch + }; + }); + } + + /** + * Decorate the commit based on the repository + * @method _decorateCommit + * @param {Object} config Configuration object + * @param {Object} config.sha Commit sha to decorate + * @param {Object} config.scmUri SCM URI the commit belongs to + * @param {Object} config.token Service token to authenticate with Github + * @return {Promise} + */ + _decorateCommit(config) { + const scm = getScmUriParts(config.scmUri); + const options = { + url: `${API_URL}/repositories/${scm.repoId}` + + `/commit/${config.sha}?access_key=${config.token}`, + method: 'GET' + }; + + return this.breaker.runCommand(options) + .then((response) => { + const body = response.body; + + if (response.statusCode !== 200) { + throw new Error(`STATUS CODE ${response.statusCode}: ${body}`); + } + + // eslint-disable-next-line + return this._decorateAuthor({ + username: body.author.user.username, + token: config.token + }).then(author => ({ + url: body.links.html.href, + message: body.message, + author + })); + }); + } + /** * Retreive stats for the scm * @method stats diff --git a/package.json b/package.json index fffc357..7a17f3e 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,6 @@ ], "devDependencies": { "chai": "^3.5.0", - "circuit-fuses": "^2.0.3", "eslint": "^3.2.2", "eslint-config-screwdriver": "^2.0.0", "eslint-plugin-import": "^1.12.0", @@ -40,6 +39,7 @@ "sinon-as-promised": "^4.0.2" }, "dependencies": { + "circuit-fuses": "^2.1.0", "hoek": "^4.1.0", "request": "^2.75.0", "screwdriver-data-schema": "^14.0.1", diff --git a/test/index.test.js b/test/index.test.js index 030b594..7aab1b4 100644 --- a/test/index.test.js +++ b/test/index.test.js @@ -6,6 +6,8 @@ const sinon = require('sinon'); const testPayloadOpen = require('./data/pr.opened.json'); const testPayloadClose = require('./data/pr.closed.json'); const testPayloadPush = require('./data/repo.push.json'); +const token = 'myAccessToken'; +const API_URL_V2 = 'https://api.bitbucket.org/2.0'; require('sinon-as-promised'); sinon.assert.expose(assert, { prefix: '' }); @@ -45,9 +47,9 @@ describe('index', () => { }); describe('parseUrl', () => { - const apiUrl = 'https://api.bitbucket.org/2.0/repositories/batman/test/refs/branches/'; - const token = 'myAccessToken'; + const apiUrl = `${API_URL_V2}/repositories/batman/test/refs/branches`; let fakeResponse; + let expectedOptions; beforeEach(() => { fakeResponse = { @@ -64,17 +66,20 @@ describe('index', () => { uuid: '{de7d7695-1196-46a1-b87d-371b7b2945ab}' } }; + expectedOptions = { + url: `${apiUrl}/mynewbranch?access_key=myAccessToken`, + method: 'GET' + }; requestMock.yieldsAsync(null, fakeResponse, fakeResponse.body); }); it('resolves to the correct parsed url for ssh', () => { const expected = 'bitbucket.org:batman/{de7d7695-1196-46a1-b87d-371b7b2945ab}:master'; - const expectedOptions = { - url: `${apiUrl}master`, - method: 'GET', - login_type: 'oauth2', - oauth_access_token: token + + expectedOptions = { + url: `${apiUrl}/master?access_key=myAccessToken`, + method: 'GET' }; return scm.parseUrl({ @@ -89,12 +94,6 @@ describe('index', () => { it('resolves to the correct parsed url for https', () => { const expected = 'bitbucket.org:batman/{de7d7695-1196-46a1-b87d-371b7b2945ab}:mynewbranch'; - const expectedOptions = { - url: `${apiUrl}mynewbranch`, - method: 'GET', - login_type: 'oauth2', - oauth_access_token: 'myAccessToken' - }; return scm.parseUrl({ checkoutUrl: 'https://batman@bitbucket.org/batman/test.git#mynewbranch', @@ -107,12 +106,6 @@ describe('index', () => { it('rejects if request fails', () => { const err = new Error('Bitbucket API error'); - const expectedOptions = { - url: `${apiUrl}mynewbranch`, - method: 'GET', - login_type: 'oauth2', - oauth_access_token: 'myAccessToken' - }; requestMock.yieldsAsync(err); @@ -128,13 +121,6 @@ describe('index', () => { }); it('rejects if status code is not 200', () => { - const expectedOptions = { - url: `${apiUrl}mynewbranch`, - method: 'GET', - login_type: 'oauth2', - oauth_access_token: 'myAccessToken' - }; - fakeResponse = { statusCode: 404, body: { @@ -255,6 +241,299 @@ describe('index', () => { }); }); + describe('decorateAuthor', () => { + const apiUrl = `${API_URL_V2}/users/batman?access_key=${token}`; + const expectedOptions = { + url: apiUrl, + method: 'GET' + }; + let fakeResponse; + + beforeEach(() => { + fakeResponse = { + statusCode: 200, + body: { + username: 'batman', + display_name: 'Batman', + uuid: '{4f1a9b7f-586e-4e80-b9eb-a7589b4a165f}', + links: { + html: { + href: 'https://bitbucket.org/batman/' + }, + avatar: { + href: 'https://bitbucket.org/account/batman/avatar/32/' + } + } + } + }; + requestMock.yieldsAsync(null, fakeResponse, fakeResponse.body); + }); + + it('resolves to correct decorated author', () => { + const expected = { + url: 'https://bitbucket.org/batman/', + name: 'Batman', + username: 'batman', + avatar: 'https://bitbucket.org/account/batman/avatar/32/' + }; + + return scm.decorateAuthor({ + username: 'batman', + token + }).then((decorated) => { + assert.calledWith(requestMock, expectedOptions); + assert.deepEqual(decorated, expected); + }); + }); + + it('rejects if status code is not 200', () => { + fakeResponse = { + statusCode: 404, + body: { + error: { + message: 'Resource not found', + detail: 'There is no API hosted at this URL' + } + } + }; + + requestMock.yieldsAsync(null, fakeResponse, fakeResponse.body); + + return scm.decorateAuthor({ + username: 'batman', + token + }).then(() => { + assert.fail('Should not get here'); + }).catch((error) => { + assert.calledWith(requestMock, expectedOptions); + assert.match(error.message, 'STATUS CODE 404'); + }); + }); + + it('rejects if fails', () => { + const err = new Error('Bitbucket API error'); + + requestMock.yieldsAsync(err); + + return scm.decorateAuthor({ + username: 'batman', + token + }).then(() => { + assert.fail('Should not get here'); + }).catch((error) => { + assert.calledWith(requestMock, expectedOptions); + assert.equal(error, err); + }); + }); + }); + + describe('decorateUrl', () => { + const apiUrl = `${API_URL_V2}/repositories/batman/{1234}?access_key=${token}`; + const selfLink = 'https://bitbucket.org/d2lam2/test'; + const repoOptions = { + url: apiUrl, + method: 'GET' + }; + let fakeResponse; + let expectedOptions; + + beforeEach(() => { + fakeResponse = { + statusCode: 200, + body: { + full_name: 'batman/mybranch', + links: { + html: { + href: selfLink + } + } + } + }; + expectedOptions = { + url: apiUrl, + method: 'GET' + }; + requestMock.withArgs(repoOptions) + .yieldsAsync(null, fakeResponse, fakeResponse.body); + }); + + it('resolves to correct decorated url object', () => { + const expected = { + url: selfLink, + name: 'batman/mybranch', + branch: 'mybranch' + }; + + return scm.decorateUrl({ + scmUri: 'bitbucket.org:batman/{1234}:mybranch', + token + }).then((decorated) => { + assert.calledWith(requestMock, expectedOptions); + assert.deepEqual(decorated, expected); + }); + }); + + it('rejects if status code is not 200', () => { + fakeResponse = { + statusCode: 404, + body: { + error: { + message: 'Resource not found', + detail: 'There is no API hosted at this URL' + } + } + }; + + requestMock.withArgs(repoOptions).yieldsAsync(null, fakeResponse, fakeResponse.body); + + return scm.decorateUrl({ + scmUri: 'bitbucket.org:batman/{1234}:mybranch', + token + }).then(() => { + assert.fail('Should not get here'); + }).catch((error) => { + assert.calledWith(requestMock, expectedOptions); + assert.match(error.message, 'STATUS CODE 404'); + }); + }); + + it('rejects if fails', () => { + const err = new Error('Bitbucket API error'); + + requestMock.withArgs(repoOptions).yieldsAsync(err); + + return scm.decorateUrl({ + scmUri: 'bitbucket.org:batman/{1234}:mybranch', + token + }).then(() => { + assert.fail('Should not get here'); + }).catch((error) => { + assert.called(requestMock); + assert.equal(error, err); + }); + }); + }); + + describe('decorateCommit', () => { + const repoUrl = + `${API_URL_V2}/repositories/batman/{1234}/commit/40171b678527?access_key=${token}`; + const authorUrl = `${API_URL_V2}/users/batman?access_key=${token}`; + const selfLink = 'https://bitbucket.org/batman/test/commits/40171b678527'; + const repoOptions = { + url: repoUrl, + method: 'GET' + }; + const authorOptions = { + url: authorUrl, + method: 'GET' + }; + let fakeResponse; + let fakeAuthorResponse; + + beforeEach(() => { + fakeResponse = { + statusCode: 200, + body: { + message: 'testing', + links: { + html: { + href: selfLink + } + }, + author: { + user: { + username: 'batman' + } + } + } + }; + fakeAuthorResponse = { + statusCode: 200, + body: { + username: 'batman', + display_name: 'Batman', + uuid: '{4f1a9b7f-586e-4e80-b9eb-a7589b4a165f}', + links: { + html: { + href: 'https://bitbucket.org/batman/' + }, + avatar: { + href: 'https://bitbucket.org/account/batman/avatar/32/' + } + } + } + }; + requestMock.withArgs(repoOptions) + .yieldsAsync(null, fakeResponse, fakeResponse.body); + requestMock.withArgs(authorOptions) + .yieldsAsync(null, fakeAuthorResponse, fakeAuthorResponse.body); + }); + + it('resolves to correct decorated object', () => { + const expected = { + url: selfLink, + message: 'testing', + author: { + url: 'https://bitbucket.org/batman/', + name: 'Batman', + username: 'batman', + avatar: 'https://bitbucket.org/account/batman/avatar/32/' + } + }; + + return scm.decorateCommit({ + sha: '40171b678527', + scmUri: 'bitbucket.org:batman/{1234}:test', + token + }).then((decorated) => { + assert.calledTwice(requestMock); + assert.deepEqual(decorated, expected); + }); + }); + + it('rejects if status code is not 200', () => { + fakeResponse = { + statusCode: 404, + body: { + error: { + message: 'Resource not found', + detail: 'There is no API hosted at this URL' + } + } + }; + + requestMock.withArgs(repoOptions).yieldsAsync(null, fakeResponse, fakeResponse.body); + + return scm.decorateCommit({ + sha: '40171b678527', + scmUri: 'bitbucket.org:batman/{1234}:test', + token + }).then(() => { + assert.fail('Should not get here'); + }).catch((error) => { + assert.calledOnce(requestMock); + assert.match(error.message, 'STATUS CODE 404'); + }); + }); + + it('rejects if fails', () => { + const err = new Error('Bitbucket API error'); + + requestMock.withArgs(repoOptions).yieldsAsync(err); + + return scm.decorateCommit({ + sha: '40171b678527', + scmUri: 'bitbucket.org:batman/{1234}:test', + token + }).then(() => { + assert.fail('Should not get here'); + }).catch((error) => { + assert.called(requestMock); + assert.equal(error, err); + }); + }); + }); + describe('stats', () => { it('returns the correct stats', () => { assert.deepEqual(scm.stats(), {