diff --git a/.circleci/config.yml b/.circleci/config.yml index 5cf3242c..c5230856 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -35,6 +35,9 @@ jobs: command: |- mkdir -p /home/node/.npm-global ./.circleci/npm-install-retry.js + cd functions + ../.circleci/npm-install-retry.js + cd .. environment: NPM_CONFIG_PREFIX: /home/node/.npm-global - run: @@ -59,7 +62,7 @@ jobs: done fi - run: *npm_install_and_link - - run: cp defaultsettings.json settings.json + - run: cp functions/settings.tmpl.json functions/settings.json - run: name: Run the tests. command: npm test diff --git a/.eslintignore b/.eslintignore index 8d87b1d2..944c0d70 100644 --- a/.eslintignore +++ b/.eslintignore @@ -1 +1,2 @@ node_modules/* +functions/node_modules/* diff --git a/README.md b/README.md index e5ff229b..8dca2745 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,9 @@ animal, that's why you need LabelCat. 1. `cd LabelCat` 1. `npm install` 1. `npm link .` -1. `cp defaultsettings.json settings.json` (`settings.json` is where you +1. `cd functions` + + `cp settings.tmpl.json settings.json` (`settings.json` is where you customize the app) 1. Modify `settings.json` as necessary. @@ -169,6 +171,14 @@ Examples: labelcat createModel 123ABCD456789 firstModel +## Deploy Cloud Functions +1. `cd Functions` +1. `npm install` +1. `cp settings.tmpl.json settings.json` +1. Modify `settings.json` as necessary. +1. `gcloud alpha functions deploy handleNewIssue --trigger-http --runtime nodejs8` +1. `gcloud functions deploy triage --runtime nodejs8 --trigger-resource YOUR-PUB/SUB-TOPIC-NAME --trigger-event google.pubsub.topic.publish +` ## Contributing See [CONTRIBUTING][3]. diff --git a/bin/labelcat.js b/bin/labelcat.js index 998836f9..42404194 100755 --- a/bin/labelcat.js +++ b/bin/labelcat.js @@ -1,7 +1,7 @@ #!/usr/bin/env node const util = require('../src/util.js'); -const settings = require('../settings.json'); // eslint-disable-line node/no-missing-require +const settings = require('../functions/settings.json'); // eslint-disable-line node/no-missing-require require(`yargs`) .demand(1) @@ -76,7 +76,7 @@ require(`yargs`) ) .command( `listDatasets`, - `Train an AutoML NL model using existing dataset.`, + `Lists all AutoML NL datasets for current Google Cloud Platform project.`, {}, () => { const projectId = settings.projectId; diff --git a/defaultsettings.json b/defaultsettings.json deleted file mode 100644 index 8aacb45b..00000000 --- a/defaultsettings.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "githubClientID": "YOUR GITHUB CLIENT ID HERE", - "githubClientSecret": "YOUR GITHUB CLIENT SECRET HERE", - "projectID": "YOUR GCP PROJECT ID HERE", - "computeRegion": "YOUR GCP PROJECT COMPUTE REGION HERE" -} diff --git a/functions/.gcloudignore b/functions/.gcloudignore new file mode 100644 index 00000000..ccc4eb24 --- /dev/null +++ b/functions/.gcloudignore @@ -0,0 +1,16 @@ +# This file specifies files that are *not* uploaded to Google Cloud Platform +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +node_modules diff --git a/functions/index.js b/functions/index.js new file mode 100644 index 00000000..cd01dda7 --- /dev/null +++ b/functions/index.js @@ -0,0 +1,144 @@ +const settings = require('./settings.json'); // eslint-disable-line node/no-missing-require +const crypto = require('crypto'); +const automl = require('@google-cloud/automl'); +const PubSub = require(`@google-cloud/pubsub`); +const octokit = require('@octokit/rest')(); +const client = new automl.v1beta1.PredictionServiceClient(); +const log = require('loglevel'); +log.setLevel('info'); + +const PROJECT_ID = settings.PROJECT_ID; +const COMPUTE_REGION = settings.COMPUTE_REGION; +const MODEL_ID = settings.MODEL_ID; +const TOPIC_NAME = settings.TOPIC_NAME; +const SECRET_TOKEN = settings.SECRET_TOKEN; +const SCORE_THRESHOLD = 70; + +/** + * Verifies request has come from Github and publishes + * message to specified Pub/Sub topic. + * + * @param {object} req + * @param {object} res + */ +async function handleNewIssue(req, res) { + try { + if (req.body.action !== 'opened') { + res.status(400).send('Wrong action.'); + return; + } + + await validateRequest(req); + const messageId = await publishMessage(req); + res.status(200).send(messageId); + } catch (err) { + log.error(err.stack); + res.status(403).send({error: err.message}); + } +} + +function validateRequest(req) { + return Promise.resolve().then(() => { + const digest = crypto + .createHmac('sha1', SECRET_TOKEN) + .update(JSON.stringify(req.body)) + .digest('hex'); + if (req.headers['x-hub-signature'] !== `sha1=${digest}`) { + const error = new Error('Unauthorized'); + error.statusCode = 403; + throw error; + } + }); +} + +async function publishMessage(req) { + try { + const text = `${req.body.issue.title} ${req.body.issue.body}`; + const data = JSON.stringify({ + owner: req.body.repository.owner.login, + repo: req.body.repository.name, + number: req.body.issue.number, + text: text, + }); + const dataBuffer = Buffer.from(data); + + const pubsubClient = new PubSub({ + PROJECT_ID: PROJECT_ID, + }); + + const response = await pubsubClient + .topic(TOPIC_NAME) + .publisher() + .publish(dataBuffer); + return response; + } catch (err) { + log.error('ERROR:', err); + } +} + +/** + * receives message from handleNewIssue cloud function, + * runs label prediction using assigned Google AutoML Natural Language model + * @param {object} req + * @param {object} res + */ +async function triage(req) { + octokit.authenticate({ + type: 'oauth', + token: settings.SECRET_TOKEN, + }); + + const pubSubMessage = req.data; + let issueData = Buffer.from(pubSubMessage.data, 'base64').toString(); + issueData = JSON.parse(issueData); + + issueData.labeled = false; + let results = await predict(issueData.text); + + if (results) { + const response = await octokit.issues.addLabels({ + owner: issueData.owner, + repo: issueData.repo, + number: issueData.number, + labels: ['bug'], + }); + + if (response.status === 200) { + issueData.labeled = true; + } + } + + return issueData; +} + +async function predict(text) { + const modelFullId = client.modelPath(PROJECT_ID, COMPUTE_REGION, MODEL_ID); + + const payload = { + textSnippet: { + content: text, + mimeType: `text/plain`, + }, + }; + + try { + const response = await client.predict({ + name: modelFullId, + payload: payload, + params: {}, + }); + + if (response[0].payload[1].classification.score > SCORE_THRESHOLD) { + return true; + } + + return false; + } catch (err) { + log.error(err); + } +} + +module.exports = { + handleNewIssue, + triage, +}; diff --git a/functions/package.json b/functions/package.json new file mode 100644 index 00000000..a9709cf6 --- /dev/null +++ b/functions/package.json @@ -0,0 +1,12 @@ +{ + "name": "labelcat-functions", + "engines": { + "node": ">= 8.x" + }, + "dependencies": { + "@google-cloud/automl": "^0.1.2", + "@google-cloud/pubsub": "^0.20.1", + "@octokit/rest": "^15.15.1", + "loglevel": "^1.6.1" + } +} diff --git a/functions/settings.tmpl.json b/functions/settings.tmpl.json new file mode 100644 index 00000000..fb04feeb --- /dev/null +++ b/functions/settings.tmpl.json @@ -0,0 +1,7 @@ +{ + "secretToken": "YOUR GITHUB CLIENT ID HERE", + "projectId": "YOUR GCP PROJECT ID HERE", + "computeRegion": "YOUR GCP PROJECT COMPUTE REGION HERE", + "modelId": "YOUR AUTOML NL MODEL ID HERE", + "topicName": "YOUR PUB/SUB TOPIC NAME HERE" +} diff --git a/package.json b/package.json index 0141165f..acbbf53f 100644 --- a/package.json +++ b/package.json @@ -26,16 +26,16 @@ "node": ">= 8.x" }, "scripts": { - "cover": "nyc --reporter=lcov mocha test/*.js && nyc report", - "lint": "eslint src/ system-test/ test/ bin/", - "prettier": "prettier --write bin/*.js src/*.js test/*.js system-test/*.js", + "cover": "nyc --reporter=lcov mocha test/*.js system-test/*.js && nyc report", + "lint": "eslint src/ system-test/ test/ bin/ functions/", + "prettier": "prettier --write bin/*.js src/*.js test/*.js system-test/*.js functions/*.js", "system-test": "mocha system-test/*.js --timeout 600000", "test-no-cover": "mocha test/*.js", "test": "npm run cover" }, "dependencies": { "@google-cloud/automl": "^0.1.2", - "@octokit/rest": "^15.12.1", + "@octokit/rest": "^15.15.1", "csv-write-stream": "^2.0.0", "json2csv": "^4.2.1", "loglevel": "^1.6.1", @@ -43,17 +43,22 @@ "yargs": "^12.0.2" }, "devDependencies": { + "body-parser": "^1.18.3", "codecov": "^3.0.2", + "crypto": "^1.0.1", "eslint": "^5.0.0", "eslint-config-prettier": "^3.0.0", "eslint-plugin-node": "^7.0.0", "eslint-plugin-prettier": "^2.6.0", + "express": "^4.16.4", "intelli-espower-loader": "^1.0.1", "mocha": "^5.2.0", "nyc": "^13.0.0", "power-assert": "^1.6.0", "prettier": "^1.13.5", "proxyquire": "^2.1.0", - "sinon": "^6.3.4" + "sinon": "^6.3.4", + "supertest": "^3.3.0", + "uuid": "^3.3.2" } } diff --git a/src/util.js b/src/util.js index 031177c4..39d79141 100755 --- a/src/util.js +++ b/src/util.js @@ -1,25 +1,26 @@ 'use strict'; const fs = require('fs'); -const settings = require('../settings.json'); // eslint-disable-line node/no-missing-require +const settings = require('../functions/settings.json'); // eslint-disable-line node/no-missing-require const octokit = require('@octokit/rest')(); -const log = require('loglevel'); const Papa = require('papaparse'); +const log = require('loglevel'); log.setLevel('info'); const automl = require(`@google-cloud/automl`); /** - * Take a filepath to a json object of issues - * and a filename to save the resulting issue data, + * Take a filepath to a json object of issues, + * the issue label to train the model on, + * and alternative names for the label, * then makes api call to GitHub to retrieve current data * @param {string} data - * @param {string} file + * @param {string} label + * @param {array} alternatives */ async function retrieveIssues(data, label, alternatives) { octokit.authenticate({ type: 'oauth', - key: settings.githubClientID, - secret: settings.githubClientSecret, + token: settings.SECRET_TOKEN, }); log.info('RETRIEVING ISSUES...'); @@ -49,11 +50,11 @@ async function retrieveIssues(data, label, alternatives) { }); } - let opts = [label]; + let labelList = [label]; if (alternatives) { - opts = opts.concat(alternatives); + labelList = labelList.concat(alternatives); } - issueResults = issueResults.map(issue => cleanLabels(issue, opts)); + issueResults = issueResults.map(issue => cleanLabels(issue, labelList)); log.info(`ISSUES RETRIEVED: ${issueResults.length}`); return issueResults; @@ -65,6 +66,13 @@ async function retrieveIssues(data, label, alternatives) { } } +/** + * handles pagination for GitHub API call + * + * @param {object} method + * @param {string} repo + * @param {string} owner + */ async function paginate(method, repo, owner) { let response = await method({ owner: owner, @@ -85,12 +93,12 @@ async function paginate(method, repo, owner) { /** * determines whether label is present on issue * - * @param {array} issues - * @param {string} label + * @param {object} issue + * @param {array} labelList */ -function cleanLabels(issue, opts) { +function cleanLabels(issue, labelList) { let info; - if (issue.labels.some(r => opts.includes(r))) { + if (issue.labels.some(label => labelList.includes(label))) { info = {text: issue.text, label: 1}; } else { info = {text: issue.text, label: 0}; @@ -106,7 +114,10 @@ function cleanLabels(issue, opts) { */ function getIssueInfo(issue) { try { - const text = issue.title + ' ' + issue.body; + const raw = `${issue.title} ${issue.body}`; + + // remove punctuation that will interfere with csv + const text = raw.replace(/[^\w\s]/gi, ''); const labels = issue.labels.map(labelObject => labelObject.name); return {text, labels}; @@ -149,13 +160,13 @@ async function createDataset( const client = new automl.v1beta1.AutoMlClient(); const projectLocation = client.locationPath(projectId, computeRegion); - // Classification type is assigned based on multiClass value. + // Classification type is assigned based on multiClass value let classificationType = `MULTICLASS`; if (multiLabel) { classificationType = `MULTILABEL`; } - // Set dataset name and metadata. + // Set dataset name and metadata const myDataset = { displayName: datasetName, textClassificationDatasetMetadata: { @@ -190,7 +201,7 @@ async function createDataset( } /** - * Import data into Google AutoML NL dataset + * Import data into Google AutoML Natural Language dataset * @param {string} projectId * @param {string} computeRegion * @param {string} datasetId @@ -222,6 +233,11 @@ async function importData(projectId, computeRegion, datasetId, path) { } } +/** + * List AutoML Natural Language datasets for current GCP project + * @param {string} projectId + * @param {string} computeRegion + */ async function listDatasets(projectId, computeRegion) { const client = new automl.v1beta1.AutoMlClient(); const projectLocation = client.locationPath(projectId, computeRegion); @@ -247,6 +263,13 @@ async function listDatasets(projectId, computeRegion) { } } +/** + * Create Google AutoML Natural Language model + * @param {string} projectId + * @param {string} computeRegion + * @param {string} datasetId + * @param {string} modelName + */ async function createModel(projectId, computeRegion, datasetId, modelName) { const client = new automl.v1beta1.AutoMlClient(); diff --git a/system-test/functions_system_test.js b/system-test/functions_system_test.js new file mode 100644 index 00000000..345aa122 --- /dev/null +++ b/system-test/functions_system_test.js @@ -0,0 +1,145 @@ +const express = require('express'); +const issueEvent = require('./issuePayload.js'); +const mocha = require('mocha'); +const describe = mocha.describe; +const it = mocha.it; +const beforeEach = mocha.beforeEach; +const proxyquire = require('proxyquire').noCallThru(); +const sinon = require('sinon'); +const assert = require('assert'); +const supertest = require(`supertest`); +const bodyParser = require('body-parser'); +const crypto = require('crypto'); + +const settingsMock = { + SECRET_TOKEN: 'foo', +}; + +function setup() { + let event = issueEvent; + return { + getHeader: () => { + const digest = crypto + .createHmac('sha1', settingsMock.SECRET_TOKEN) + .update(JSON.stringify(event.body)) + .digest('hex'); + return `sha1=${digest}`; + }, + }; +} + +describe('handleNewIssue()', function() { + let app, codeUnderTest, functs; + + const publishMock = sinon.stub().returns('123'); + const publisherMock = sinon.stub().returns({publish: publishMock}); + const topicMock = sinon.stub().returns({publisher: publisherMock}); + const pubsubMock = sinon.stub().returns({topic: topicMock}); + const automlClientMock = {}; + const automlMock = { + v1beta1: { + PredictionServiceClient: sinon.stub().returns(automlClientMock), + }, + }; + + beforeEach(() => { + codeUnderTest = setup(); + app = express(); + const requestLimit = '1024mb'; + + const rawBodySaver = (req, res, buf) => { + req.rawBody = buf; + }; + + const defaultBodySavingOptions = { + limit: requestLimit, + verify: rawBodySaver, + }; + + const rawBodySavingOptions = { + limit: requestLimit, + verify: rawBodySaver, + type: '*/*', + }; + + // Use extended query string parsing for URL-encoded bodies. + const urlEncodedOptions = { + limit: requestLimit, + verify: rawBodySaver, + extended: true, + }; + + // Parse request body + app.use(bodyParser.json(defaultBodySavingOptions)); + app.use(bodyParser.text(defaultBodySavingOptions)); + app.use(bodyParser.urlencoded(urlEncodedOptions)); + + // MUST be last in the list of body parsers as subsequent parsers will be + // skipped when one is matched. + app.use(bodyParser.raw(rawBodySavingOptions)); + + functs = proxyquire('../functions/index.js', { + '@google-cloud/automl': automlMock, + '@google-cloud/pubsub': pubsubMock, + './settings.json': settingsMock, + }); + + app.post(`/handleNewIssue`, functs.handleNewIssue); + }); + + afterEach(() => { + issueEvent.body.action = 'opened'; + issueEvent.body.issue.title = 'LABELCAT-TEST'; + }); + + it('should validate request', function(done) { + supertest(app) + .post(`/handleNewIssue`) + .send(issueEvent.body) + .set('x-hub-signature', 'foo') + .end(function(err, res) { + assert.strictEqual(403, res.statusCode); + sinon.assert.notCalled(publishMock); + done(); + }); + }); + + it('should publish message and return messageId', function(done) { + supertest(app) + .post(`/handleNewIssue`) + .send(issueEvent.body) + .set('x-hub-signature', codeUnderTest.getHeader()) + .end(function(err, res) { + assert.strictEqual(200, res.statusCode); + assert.strictEqual('123', res.text); + sinon.assert.calledOnce(publishMock); + done(); + }); + }); + + it('should return if action is not opened', function(done) { + issueEvent.body.action = 'edited'; + supertest(app) + .post(`/handleNewIssue`) + .send(issueEvent.body) + .set('x-hub-signature', codeUnderTest.getHeader()) + .end(function(err, res) { + assert.strictEqual(400, res.statusCode); + assert.ok(res.text === 'Wrong action.'); + done(); + }); + }); + + it('should not publish request with incorrect data', function(done) { + issueEvent.body.action = 'opened'; + issueEvent.body.issue.title = undefined; + supertest(app) + .post(`/handleNewIssue`) + .send({body: null}) + .set('x-hub-signature', codeUnderTest.getHeader()) + .end(function(err, res) { + assert.strictEqual(400, res.statusCode); + done(); + }); + }); +}); diff --git a/system-test/issuePayload.js b/system-test/issuePayload.js new file mode 100644 index 00000000..3f86b52f --- /dev/null +++ b/system-test/issuePayload.js @@ -0,0 +1,27 @@ +module.exports = { + body: { + action: 'opened', + issue: { + number: 2, + title: 'LABELCAT-TEST', + labels: [ + { + id: 949737505, + node_id: 'MDU6TGFk3Mzc1MDU=', + url: 'https://api.github.com/repos/Codertocat/Hello-World/labels/bug', + name: 'bug', + color: 'd73a4a', + default: true, + }, + ], + state: 'open', + body: "It looks like you accidently spelled 'commit' with two 't's.", + }, + repository: { + name: 'Hello-World', + owner: { + login: 'Codertocat', + }, + }, + }, +}; diff --git a/test/triage_test.js b/test/triage_test.js new file mode 100644 index 00000000..248ba345 --- /dev/null +++ b/test/triage_test.js @@ -0,0 +1,123 @@ +const mocha = require('mocha'); +const describe = mocha.describe; +const it = mocha.it; +const proxyquire = require('proxyquire').noCallThru(); +const sinon = require('sinon'); +const assert = require('assert'); + +// update this value to match SCORE_THRESHOLD in functions/index.js +const SCORE_THRESHOLD = 70; +const ISSUE_NUMBER = 22; + +let functions, autoMlMock, octoMock, dataBuffer, settingsMock; + +const makePayload = (classification, displayName) => { + return { + annotationSpecId: '', + displayName: displayName, + classification: classification, + detail: 'classification', + }; +}; + +beforeEach(() => { + const data = JSON.stringify({ + owner: 'GoogleCloudPlatform', + repo: 'labelcat', + number: ISSUE_NUMBER, + text: 'some issue information', + }); + + dataBuffer = Buffer.from(data); + + const reject = sinon.stub(); + reject.withArgs(true).throws(401); + + const mockAdd = sinon.stub().returns( + Promise.resolve({ + data: [ + { + id: 271022241, + node_id: 'MDwMjIyNDE=', + url: + 'https://api.github.com/repos/GoogleCloudPlatform/LabelCat/labels/bug', + name: 'bug', + color: 'fc2929', + default: true, + }, + ], + status: 200, + }) + ); + + octoMock = { + authenticate: sinon.stub(), + issues: {addLabels: mockAdd}, + }; + + const model = sinon.spy(); + const predict = sinon.stub(); + + predict.onCall(0).returns([ + { + payload: [makePayload([Object], 0), makePayload({score: 90}, 1)], + }, + ]); + + predict.onCall(1).returns([ + { + payload: [ + makePayload([Object], 0), + makePayload({score: SCORE_THRESHOLD}, 1), + ], + }, + ]); + + const clientMock = sinon.stub().returns({ + modelPath: model, + predict: predict, + }); + + autoMlMock = {v1beta1: {PredictionServiceClient: clientMock}}; + + settingsMock = { + SECRET_TOKEN: 'foo', + PROJECT_ID: 'test-project', + COMPUTE_REGION: 'uscentral', + TOPIC_NAME: 'testTopic', + MODEL_ID: 'test-model', + }; +}); + +describe('triage()', function() { + it('should run prediction and returns correct boolean', async () => { + functions = proxyquire('../functions/index.js', { + '@octokit/rest': () => octoMock, + '@google-cloud/automl': autoMlMock, + '@google-cloud/pubsub': sinon.stub(), + './settings.json': settingsMock, + }); + + let result = await functions.triage({data: {data: dataBuffer}}); + assert(result.labeled === true); + assert(result.number === ISSUE_NUMBER); + result = await functions.triage({data: {data: dataBuffer}}); + assert(result.labeled === false); + assert(result.number === ISSUE_NUMBER); + }); + + it('should throw error if unauthorized gitHub user', async () => { + functions = proxyquire('../functions/index.js', { + './settings.json': settingsMock, + '@google-cloud/automl': autoMlMock, + '@google-cloud/pubsub': sinon.stub(), + }); + + try { + await functions.triage({data: {data: dataBuffer}}); + assert.fail('triage should have failed'); + } catch (err) { + assert(JSON.parse(err.message).message === 'Bad credentials'); + } + }); +}); diff --git a/test/util_test.js b/test/util_test.js index 794eed21..3f1b78c9 100644 --- a/test/util_test.js +++ b/test/util_test.js @@ -1,14 +1,75 @@ -const util = require('../src/util.js'); const fs = require('fs'); const mocha = require('mocha'); const describe = mocha.describe; const it = mocha.it; const beforeEach = mocha.beforeEach; -const proxyquire = require('proxyquire'); +const proxyquire = require('proxyquire').noCallThru(); const sinon = require('sinon'); const assert = require('assert'); +function getMocks() { + const path = sinon.stub(); + const imports = sinon.stub().returns(); + const location = sinon.spy(); + const create = sinon.stub(); + const list = sinon.stub(); + const getNext = sinon.stub(); + const hasNext = sinon.stub(); + const getRepoMock = sinon.stub(); + + const settingsMock = { + secretToken: 'foo', + }; + + const octoMock = { + authenticate: sinon.stub(), + issues: {getForRepo: getRepoMock}, + hasNextPage: hasNext, + getNextPage: getNext, + }; + + const clientMock = sinon.stub().returns({ + datasetPath: path, + importData: imports, + locationPath: location, + createDataset: create, + listDatasets: list, + createModel: create, + }); + + const autoMlMock = {v1beta1: {AutoMlClient: clientMock}}; + + return { + util: proxyquire('../src/util.js', { + '@google-cloud/automl': autoMlMock, + '../functions/settings.json': settingsMock, + '@octokit/rest': () => octoMock, + }), + mocks: { + list: list, + path: path, + create: create, + imports: imports, + getNext: getNext, + hasNext: hasNext, + location: location, + clientMock: clientMock, + autoMlMock: autoMlMock, + getRepoMock: getRepoMock, + settingsMock: settingsMock, + }, + }; +} + describe('makeCSV()', function() { + const settingsMock = { + secretToken: 'foo', + }; + + const util = proxyquire('../src/util.js', { + '../functions/settings.json': settingsMock, + }); + it('should create a csv of issues', function() { const issues = [ { @@ -39,7 +100,7 @@ describe('makeCSV()', function() { }); describe('retrieveIssues', () => { - let util, octoMock; + let mockData; beforeEach(() => { const issueData = { @@ -62,28 +123,21 @@ describe('retrieveIssues', () => { ], }; - const getNext = sinon.stub().returns(issueData); - const hasNext = sinon.stub(); - hasNext.returns(true); - hasNext.onCall(1).returns(false); - - const mockGet = sinon.stub().returns(Promise.resolve(issueData)); - - octoMock = { - authenticate: sinon.stub(), - issues: {getForRepo: mockGet}, - hasNextPage: hasNext, - getNextPage: getNext, - }; - util = proxyquire('../src/util.js', { - '@octokit/rest': () => octoMock, - }); + mockData = getMocks(); + mockData.mocks.getNext.returns(issueData); + mockData.mocks.hasNext.returns(true); + mockData.mocks.hasNext.onCall(1).returns(false); + mockData.mocks.getRepoMock.returns(Promise.resolve(issueData)); }); it('should pass new issue object to makeCSV', async () => { const label = 'type: bug'; const alt = ['bug']; - const result = await util.retrieveIssues('test/test_repos.txt', label, alt); + const result = await mockData.util.retrieveIssues( + 'test/test_repos.txt', + label, + alt + ); assert(result.length === 6); assert(result[0].text === 'issue details'); @@ -91,6 +145,7 @@ describe('retrieveIssues', () => { assert(result[1].text === 'another issue more details'); assert(result[1].label === 1); }); + it('should throw an error', async () => { let label = 'type: bug'; @@ -101,20 +156,28 @@ describe('retrieveIssues', () => { }, }); - octoMock.issues.getForRepo.returns(expectedResponse); + mockData.mocks.getRepoMock.returns(expectedResponse); + mockData.mocks.hasNextPage = sinon.spy(); + mockData.mocks.getNextPage = sinon.spy(); - const result = await util.retrieveIssues('test/test_repos.txt', label); + const result = await mockData.util.retrieveIssues( + 'test/test_repos.txt', + label + ); assert(result === undefined); - sinon.assert.calledOnce(octoMock.issues.getForRepo); - sinon.assert.notCalled(octoMock.hasNextPage); - sinon.assert.notCalled(octoMock.getNextPage); + sinon.assert.calledOnce(mockData.mocks.getRepoMock); + sinon.assert.notCalled(mockData.mocks.hasNextPage); + sinon.assert.notCalled(mockData.mocks.getNextPage); }); }); describe('getIssueInfo()', function() { - let originalIssue, returnedIssue, labelCount; + let originalIssue, returnedIssue, labelCount, mockData; + beforeEach(() => { + mockData = getMocks(); + originalIssue = { id: 1, node_id: 'MDU6SXNWUx', @@ -133,7 +196,8 @@ describe('getIssueInfo()', function() { }); it('should return issue object with text & labels keys', async function() { - const result = await util.getIssueInfo(originalIssue); + const result = await mockData.util.getIssueInfo(originalIssue); + assert.strictEqual( Object.keys(result).length, Object.keys(returnedIssue).length @@ -149,20 +213,24 @@ describe('getIssueInfo()', function() { body: 'issue body', }; - let result = await util.getIssueInfo(badIssue, labelCount); + const result = await mockData.util.getIssueInfo(badIssue, labelCount); assert(result === undefined); }); }); describe('createDataset()', function() { + let mockData; const projectId = 'test-project'; const computeRegion = 'us-central1'; const datasetName = 'testSet'; const multiLabel = 'false'; + beforeEach(() => { + mockData = getMocks(); + }); + it('should create a Google AutoML Natural Language dataset', function() { - const location = sinon.spy(); - const create = sinon.stub().returns([ + mockData.mocks.create.returns([ { name: 'dataset/location/378646', displayName: 'testSet', @@ -172,23 +240,19 @@ describe('createDataset()', function() { }, ]); - const mockClient = sinon.stub().returns({ - locationPath: location, - createDataset: create, - }); - - const autoMlMock = {v1beta1: {AutoMlClient: mockClient}}; - const util = proxyquire('../src/util.js', { - '@google-cloud/automl': autoMlMock, - }); + mockData.util.createDataset( + projectId, + computeRegion, + datasetName, + multiLabel + ); - util.createDataset(projectId, computeRegion, datasetName, multiLabel); - sinon.assert.calledOnce(location); - assert(location.calledWith(projectId, computeRegion)); + sinon.assert.calledOnce(mockData.mocks.location); + assert(mockData.mocks.location.calledWith(projectId, computeRegion)); }); + it('should throw an error', function() { - const location = sinon.spy(); - const create = sinon.stub().returns([ + mockData.mocks.create.returns([ { err: 'error', name: 'dataset/location/378646', @@ -199,70 +263,52 @@ describe('createDataset()', function() { }, ]); - const mockClient = sinon.stub().returns({ - locationPath: location, - createDataset: create, - }); - - const autoMlMock = {v1beta1: {AutoMlClient: mockClient}}; - const util = proxyquire('../src/util.js', { - '@google-cloud/automl': autoMlMock, - }); - - util.createDataset(projectId, computeRegion, datasetName, multiLabel); + mockData.util.createDataset( + projectId, + computeRegion, + datasetName, + multiLabel + ); }); }); describe('importData()', function() { + let mockData; + const projectId = 'test-project'; const computeRegion = 'us-central1'; const datasetId = '123TEST4567'; const file = 'gs://testbucket-lcm/testIssues.csv'; - it('should import data into AutoML NL dataset', function() { - const path = sinon.spy(); - const imports = sinon.stub().returns(); - - const mockClient = sinon.stub().returns({ - datasetPath: path, - importData: imports, - }); - - const autoMlMock = {v1beta1: {AutoMlClient: mockClient}}; - const util = proxyquire('../src/util.js', { - '@google-cloud/automl': autoMlMock, - }); - - util.importData(projectId, computeRegion, datasetId, file); - sinon.assert.calledOnce(path); - assert(path.calledWith(projectId, computeRegion, datasetId)); - sinon.assert.calledOnce(imports); + beforeEach(() => { + mockData = getMocks(); }); - it('should throw an error', function() { - const path = sinon.spy(); - const imports = sinon.stub().throws(); - const mockClient = sinon.stub().returns({ - datasetPath: path, - importData: imports, - }); + it('should import data into AutoML NL dataset', function() { + mockData.util.importData(projectId, computeRegion, datasetId, file); - const autoMlMock = {v1beta1: {AutoMlClient: mockClient}}; - const util = proxyquire('../src/util.js', { - '@google-cloud/automl': autoMlMock, - }); + sinon.assert.calledOnce(mockData.mocks.path); + assert(mockData.mocks.path.calledWith(projectId, computeRegion, datasetId)); + sinon.assert.calledOnce(mockData.mocks.imports); + }); - util.importData(projectId, computeRegion, datasetId, file); + it('should throw an error', function() { + mockData.mocks.imports.throws(); + mockData.util.importData(projectId, computeRegion, datasetId, file); }); }); describe('listDatasets()', function() { + let mockData; const projectId = 'test-project'; const computeRegion = 'us-central1'; + beforeEach(() => { + mockData = getMocks(); + }); + it('should return a list of datasets', async function() { - const location = sinon.spy(); - const list = sinon.stub().returns([ + mockData.mocks.list.returns([ [ { name: 'projects/12345/locations/us-central1/datasets/12345', @@ -275,25 +321,15 @@ describe('listDatasets()', function() { ], ]); - const mockClient = sinon.stub().returns({ - locationPath: location, - listDatasets: list, - }); - - const autoMlMock = {v1beta1: {AutoMlClient: mockClient}}; - const util = proxyquire('../src/util.js', { - '@google-cloud/automl': autoMlMock, - }); - - await util.listDatasets(projectId, computeRegion); + await mockData.util.listDatasets(projectId, computeRegion); - sinon.assert.calledOnce(list); - sinon.assert.calledOnce(location); - assert(location.calledWith(projectId, computeRegion)); + sinon.assert.calledOnce(mockData.mocks.list); + sinon.assert.calledOnce(mockData.mocks.location); + assert(mockData.mocks.location.calledWith(projectId, computeRegion)); }); + it('should throw an error', function() { - const location = sinon.spy(); - const list = sinon.stub().returns([ + mockData.mocks.list.returns([ [ { name: 'projects/12345/locations/us-central1/datasets/12345', @@ -302,27 +338,21 @@ describe('listDatasets()', function() { ], ]); - const mockClient = sinon.stub().returns({ - locationPath: location, - listDatasets: list, - }); - - const autoMlMock = {v1beta1: {AutoMlClient: mockClient}}; - const util = proxyquire('../src/util.js', { - '@google-cloud/automl': autoMlMock, - }); - - util.listDatasets(projectId, computeRegion); + mockData.util.listDatasets(projectId, computeRegion); }); }); -// createModel(projectId, computeRegion, datasetId, modelName); + describe('createModel()', function() { + let mockData; const projectId = 'test-project'; const computeRegion = 'us-central1'; + beforeEach(() => { + mockData = getMocks(); + }); + it('should call AutoML NL API to train model', async function() { - const location = sinon.spy(); - const create = sinon.stub().returns([ + mockData.mocks.create.returns([ {Operation: 'data'}, { name: @@ -336,36 +366,26 @@ describe('createModel()', function() { }, ]); - const mockClient = sinon.stub().returns({ - locationPath: location, - createModel: create, - }); - - const autoMlMock = {v1beta1: {AutoMlClient: mockClient}}; - const util = proxyquire('../src/util.js', { - '@google-cloud/automl': autoMlMock, - }); - - await util.createModel(projectId, computeRegion, '123456ABC', 'testModel'); + await mockData.util.createModel( + projectId, + computeRegion, + '123456ABC', + 'testModel' + ); - sinon.assert.calledOnce(create); - sinon.assert.calledOnce(location); - assert(location.calledWith(projectId, computeRegion)); + sinon.assert.calledOnce(mockData.mocks.create); + sinon.assert.calledOnce(mockData.mocks.location); + assert(mockData.mocks.location.calledWith(projectId, computeRegion)); }); - it('should throw an error', async function() { - const location = sinon.spy(); - const create = sinon.stub().returns([]); - - const mockClient = sinon.stub().returns({ - locationPath: location, - createModel: create, - }); - const autoMlMock = {v1beta1: {AutoMlClient: mockClient}}; - const util = proxyquire('../src/util.js', { - '@google-cloud/automl': autoMlMock, - }); + it('should throw an error', async function() { + mockData.mocks.create.returns([]); - await util.createModel(projectId, computeRegion, '123456ABC', 'testModel'); + await mockData.util.createModel( + projectId, + computeRegion, + '123456ABC', + 'testModel' + ); }); });