From 768d2b591a630389dfee045c30be2aac6167928a Mon Sep 17 00:00:00 2001 From: chris48s Date: Tue, 28 Aug 2018 15:48:55 +0100 Subject: [PATCH] fix Circle CI badge for projects that use workflows --- services/circleci/circleci.helpers.js | 121 ++++++++++++++ services/circleci/circleci.helpers.spec.js | 177 +++++++++++++++++++++ services/circleci/circleci.service.js | 51 +++--- services/circleci/circleci.tester.js | 32 ++-- 4 files changed, 346 insertions(+), 35 deletions(-) create mode 100644 services/circleci/circleci.helpers.js create mode 100644 services/circleci/circleci.helpers.spec.js diff --git a/services/circleci/circleci.helpers.js b/services/circleci/circleci.helpers.js new file mode 100644 index 0000000000000..fef161901af0b --- /dev/null +++ b/services/circleci/circleci.helpers.js @@ -0,0 +1,121 @@ +'use strict' + +const Joi = require('joi') + +const getWorkflowId = function(build) { + return build.workflows.workflow_id +} + +// find all the builds with the same workflow id +const getBuildsForWorkflow = function(builds, workflowId) { + return builds.filter(build => getWorkflowId(build) === workflowId) +} + +// find all the builds with the same workflow id which are also complete +const getCompleteBuildsForWorkflow = function(builds, workflowId) { + return builds.filter( + build => getWorkflowId(build) === workflowId && build.outcome !== null + ) +} + +// Find the most recent workflow which contains only complete builds +// and return all the builds with that workflow id +const getBuildsForLatestCompleteWorkflow = function(builds) { + let allBuilds, completeBuilds + for (let i = 0; i < builds.length; i++) { + allBuilds = getBuildsForWorkflow(builds, getWorkflowId(builds[i])) + completeBuilds = getCompleteBuildsForWorkflow( + builds, + getWorkflowId(builds[i]) + ) + if (allBuilds.length === completeBuilds.length) { + return completeBuilds + } + } + throw new Error('No complete workflows found') +} + +const countOutcomes = function(builds) { + let total = 0 + const counts = { + canceled: 0, + infrastructure_fail: 0, + timedout: 0, + failed: 0, + no_tests: 0, + success: 0, + } + for (let i = 0; i < builds.length; i++) { + if (!(builds[i].outcome in counts)) { + throw new Error('Found unexpected outcome') + } + counts[builds[i].outcome]++ + total++ + } + return { total, counts } +} + +// reduce the outcomes for an array of builds to a single status +const summarizeBuilds = function(builds) { + const { total, counts } = countOutcomes(builds) + + if (total === counts.success) { + return 'passing' + } else if (counts.no_tests >= 1) { + return 'no tests' + } else if (counts.infrastructure_fail >= 1) { + return 'infrastructure fail' + } else if (counts.canceled >= 1) { + return 'canceled' + } else if (counts.timedout >= 1) { + return 'timed out' + } else if (counts.failed >= 1) { + return 'failed' + } + throw new Error('Failed to summarize build status') +} + +const summarizeBuildsForLatestCompleteWorkflow = function(builds) { + return summarizeBuilds(getBuildsForLatestCompleteWorkflow(builds)) +} + +// return the status of the latest complete build +// we need this if a project doesn't use workflows +const getLatestCompleteBuildOutcome = function(builds) { + for (let i = 0; i < builds.length; i++) { + if (builds[i].outcome != null) { + return summarizeBuilds([builds[i]]) + } + } + throw new Error('No complete builds found') +} + +const populatedArraySchema = Joi.array() + .items( + Joi.object({ + // if we have >0 items in our array, every object must have an 'outcome' key + outcome: Joi.string() + .allow(null) + .required(), + + // 'workflows' key is optional - not all projects have workflows + workflows: Joi.object({ + // if there is a 'workflows' key, it must have a string workflow_id + workflow_id: Joi.string().required(), + }), + }).required() + ) + .min(1) + .max(100) + .required() +const emptyArraySchema = Joi.array() + .min(0) + .max(0) + .required() // [] is also a valid response from Circle CI +const circleSchema = Joi.alternatives(populatedArraySchema, emptyArraySchema) + +module.exports = { + circleSchema, + getLatestCompleteBuildOutcome, + summarizeBuildsForLatestCompleteWorkflow, +} diff --git a/services/circleci/circleci.helpers.spec.js b/services/circleci/circleci.helpers.spec.js new file mode 100644 index 0000000000000..928e0e91b97ad --- /dev/null +++ b/services/circleci/circleci.helpers.spec.js @@ -0,0 +1,177 @@ +'use strict' + +const Joi = require('joi') +const { test, given } = require('sazerac') +const { expect } = require('chai') +const { + circleSchema, + getLatestCompleteBuildOutcome, + summarizeBuildsForLatestCompleteWorkflow, +} = require('./circleci.helpers.js') + +describe('circleci: getLatestCompleteBuildOutcome() function', function() { + test(getLatestCompleteBuildOutcome, () => { + given([{ outcome: 'success' }]).expect('passing') + given([{ outcome: 'no_tests' }]).expect('no tests') + given([{ outcome: 'failed' }]).expect('failed') + + given([{ outcome: 'success' }, { outcome: 'failed' }]).expect('passing') + given([{ outcome: null }, { outcome: 'failed' }]).expect('failed') + + expect(() => + getLatestCompleteBuildOutcome([{ outcome: 'cheese' }]) + ).to.throw(Error, 'Found unexpected outcome') + expect(() => getLatestCompleteBuildOutcome([{ outcome: null }])).to.throw( + Error, + 'No complete builds found' + ) + expect(() => getLatestCompleteBuildOutcome([{}])).to.throw( + Error, + 'No complete builds found' + ) + expect(() => getLatestCompleteBuildOutcome([])).to.throw( + Error, + 'No complete builds found' + ) + }) +}) + +describe('circleci: summarizeBuildsForLatestCompleteWorkflow() function', function() { + test(summarizeBuildsForLatestCompleteWorkflow, () => { + given([ + // these 2 successful builds are part of the same workflow + { + outcome: 'success', + workflows: { workflow_id: 'aaaaaaaa-1111-aaaa-1111-aaaaaaaaaaaa' }, + }, + { + outcome: 'success', + workflows: { workflow_id: 'aaaaaaaa-1111-aaaa-1111-aaaaaaaaaaaa' }, + }, + // this failed build is part of a different workflow + { + outcome: 'failed', + workflows: { workflow_id: 'bbbbbbbb-1111-aaaa-1111-aaaaaaaaaaaa' }, + }, + ]).expect('passing') + + given([ + // this workflow contains 3 builds: 2 passing builds and one which failed + { + outcome: 'success', + workflows: { workflow_id: 'aaaaaaaa-1111-aaaa-1111-aaaaaaaaaaaa' }, + }, + { + outcome: 'success', + workflows: { workflow_id: 'aaaaaaaa-1111-aaaa-1111-aaaaaaaaaaaa' }, + }, + { + outcome: 'failed', + workflows: { workflow_id: 'aaaaaaaa-1111-aaaa-1111-aaaaaaaaaaaa' }, + }, + // we should summarize the status of this build as 'failed' + ]).expect('failed') + + given([ + // not all of the builds in the most recent wokflow are complete + { + outcome: 'success', + workflows: { workflow_id: 'aaaaaaaa-1111-aaaa-1111-aaaaaaaaaaaa' }, + }, + { + outcome: null, + workflows: { workflow_id: 'aaaaaaaa-1111-aaaa-1111-aaaaaaaaaaaa' }, + }, + // so we should report the status of this older workflow instead + // because all of its builds have finished + { + outcome: 'failed', + workflows: { workflow_id: 'bbbbbbbb-1111-aaaa-1111-aaaaaaaaaaaa' }, + }, + { + outcome: 'success', + workflows: { workflow_id: 'bbbbbbbb-1111-aaaa-1111-aaaaaaaaaaaa' }, + }, + ]).expect('failed') + + expect(() => + summarizeBuildsForLatestCompleteWorkflow([ + // we have no completed workflows + { + outcome: null, + workflows: { workflow_id: 'aaaaaaaa-1111-aaaa-1111-aaaaaaaaaaaa' }, + }, + { + outcome: null, + workflows: { workflow_id: 'aaaaaaaa-1111-aaaa-1111-aaaaaaaaaaaa' }, + }, + ]) + ).to.throw(Error, 'No complete workflows found') + + expect(() => + summarizeBuildsForLatestCompleteWorkflow([ + { + outcome: 'failed', + workflows: { workflow_id: 'aaaaaaaa-1111-aaaa-1111-aaaaaaaaaaaa' }, + }, + // this status value is not expected + { + outcome: 'cheese', + workflows: { workflow_id: 'aaaaaaaa-1111-aaaa-1111-aaaaaaaaaaaa' }, + }, + ]) + ).to.throw(Error, 'Found unexpected outcome') + + expect(() => + summarizeBuildsForLatestCompleteWorkflow([ + // this response doesn't have workflows + { outcome: 'failed' }, + { outcome: 'success' }, + ]) + ).to.throw(Error, "Cannot read property 'workflow_id' of undefined") + }) +}) + +describe('circleci: schema validation', function() { + const validate = function(data, schema) { + const { error, value } = Joi.validate(data, schema, { + allowUnknown: true, + stripUnknown: true, + }) + return { error, value } + } + + expect(validate([], circleSchema)).to.deep.equal({ error: null, value: [] }) + + expect( + validate([{ outcome: 'success' }, { outcome: null }], circleSchema) + ).to.deep.equal({ + error: null, + value: [{ outcome: 'success' }, { outcome: null }], + }) + + expect( + validate( + [ + { outcome: 'success', workflows: { workflow_id: 'aa111' } }, + { outcome: null, workflows: { workflow_id: 'bb222' } }, + ], + circleSchema + ) + ).to.deep.equal({ + error: null, + value: [ + { outcome: 'success', workflows: { workflow_id: 'aa111' } }, + { outcome: null, workflows: { workflow_id: 'bb222' } }, + ], + }) + + // object does not have an 'outcome' key + expect(validate([{ foo: 'bar' }], circleSchema).error).to.not.equal(null) + + // 'workflows' key doesn't have a workflow_id + expect( + validate([{ outcome: 'failed', workflows: { foo: 'bar' } }], circleSchema) + .error + ).to.not.equal(null) +}) diff --git a/services/circleci/circleci.service.js b/services/circleci/circleci.service.js index a9109ca579653..dc0ce7e8dde8b 100644 --- a/services/circleci/circleci.service.js +++ b/services/circleci/circleci.service.js @@ -1,13 +1,12 @@ 'use strict' -const Joi = require('joi') const BaseJsonService = require('../base-json') - -const circleSchema = Joi.array() - .items(Joi.object({ status: Joi.string().required() })) - .min(1) - .max(1) - .required() +const { InvalidResponse } = require('../errors') +const { + circleSchema, + getLatestCompleteBuildOutcome, + summarizeBuildsForLatestCompleteWorkflow, +} = require('./circleci.helpers.js') module.exports = class CircleCi extends BaseJsonService { async fetch({ token, vcsType, userRepo, branch }) { @@ -15,7 +14,7 @@ module.exports = class CircleCi extends BaseJsonService { if (branch != null) { url += `/tree/${branch}` } - const query = { filter: 'completed', limit: 1 } + const query = { limit: 50 } if (token) { query['circle-token'] = token } @@ -28,20 +27,32 @@ module.exports = class CircleCi extends BaseJsonService { } static render({ status }) { - if (['success', 'fixed'].includes(status)) { - return { message: 'passing', color: 'brightgreen' } - } else if (status === 'failed') { - return { message: 'failed', color: 'red' } - } else if (['no_tests', 'scheduled', 'not_run'].includes(status)) { - return { message: status.replace('_', ' '), color: 'yellow' } - } else { - return { message: status.replace('_', ' '), color: 'lightgrey' } + let color = 'lightgrey' + if (status === 'passing') { + color = 'brightgreen' + } else if ( + ['failed', 'infrastructure fail', 'canceled', 'timed out'].includes(status) + ) { + color = 'red' + } else if (status === 'no tests') { + color = 'yellow' } + return { message: status, color } } async handle({ token, vcsType, userRepo, branch }) { const json = await this.fetch({ token, vcsType, userRepo, branch }) - return this.constructor.render({ status: json[0].status }) + try { + const status = + 'workflows' in json[0] + ? summarizeBuildsForLatestCompleteWorkflow(json) + : getLatestCompleteBuildOutcome(json) + return this.constructor.render({ status }) + } catch (e) { + throw new InvalidResponse({ + prettyMessage: 'could not summarize build status', + }) + } } // Metadata @@ -68,13 +79,13 @@ module.exports = class CircleCi extends BaseJsonService { title: 'CircleCI (all branches)', exampleUrl: 'project/github/RedSparr0w/node-csgo-parser', urlPattern: 'project/:vcsType/:owner/:repo', - staticExample: this.render({ status: 'success' }), + staticExample: this.render({ status: 'passing' }), }, { title: 'CircleCI branch', exampleUrl: 'project/github/RedSparr0w/node-csgo-parser/master', urlPattern: 'project/:vcsType/:owner/:repo/:branch', - staticExample: this.render({ status: 'success' }), + staticExample: this.render({ status: 'passing' }), }, { title: 'CircleCI token', @@ -82,7 +93,7 @@ module.exports = class CircleCi extends BaseJsonService { 'circleci/token/:token/project/:vcsType/:owner/:repo/:branch', exampleUrl: 'circleci/token/b90b5c49e59a4c67ba3a92f7992587ac7a0408c2/project/github/RedSparr0w/node-csgo-parser/master', - staticExample: this.render({ status: 'success' }), + staticExample: this.render({ status: 'passing' }), }, ] } diff --git a/services/circleci/circleci.tester.js b/services/circleci/circleci.tester.js index cdcd26e914863..efe38eda7f853 100644 --- a/services/circleci/circleci.tester.js +++ b/services/circleci/circleci.tester.js @@ -25,6 +25,15 @@ t.create('circle ci (valid, with branch)') }) ) +t.create('circle ci (valid, project that uses workflows)') + .get('/project/github/badges/shields/master.json') + .expectJSONTypes( + Joi.object().keys({ + name: 'build', + value: isBuildStatus, + }) + ) + t.create('circle ci (not found)') .get('/project/github/PyvesB/EmptyRepo.json') .expectJSON({ name: 'build', value: 'project not found' }) @@ -38,25 +47,18 @@ t.create('circle ci (no response data)') .get('/project/github/RedSparr0w/node-csgo-parser.json') .intercept(nock => nock('https://circleci.com') - .get( - '/api/v1.1/project/github/RedSparr0w/node-csgo-parser?filter=completed&limit=1' - ) + .get('/api/v1.1/project/github/RedSparr0w/node-csgo-parser?limit=50') .reply(200) ) .expectJSON({ name: 'build', value: 'unparseable json response' }) -// we're passing &limit=1 so we expect exactly one array element -t.create('circle ci (invalid json)') - .get('/project/github/RedSparr0w/node-csgo-parser.json?style=_shields_test') +t.create( + "circle ci (valid response that we can't generate a build status from)" +) + .get('/project/github/RedSparr0w/node-csgo-parser.json') .intercept(nock => nock('https://circleci.com') - .get( - '/api/v1.1/project/github/RedSparr0w/node-csgo-parser?filter=completed&limit=1' - ) - .reply(200, [{ status: 'success' }, { status: 'fixed' }]) + .get('/api/v1.1/project/github/RedSparr0w/node-csgo-parser?limit=50') + .reply(200, []) ) - .expectJSON({ - name: 'build', - value: 'invalid json response', - colorB: '#9f9f9f', - }) + .expectJSON({ name: 'build', value: 'could not summarize build status' })