Skip to content

Commit

Permalink
fix Circle CI badge for projects that use workflows
Browse files Browse the repository at this point in the history
  • Loading branch information
chris48s committed Aug 28, 2018
1 parent e294dc4 commit 768d2b5
Show file tree
Hide file tree
Showing 4 changed files with 346 additions and 35 deletions.
121 changes: 121 additions & 0 deletions services/circleci/circleci.helpers.js
Original file line number Diff line number Diff line change
@@ -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,
}
177 changes: 177 additions & 0 deletions services/circleci/circleci.helpers.spec.js
Original file line number Diff line number Diff line change
@@ -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)
})
51 changes: 31 additions & 20 deletions services/circleci/circleci.service.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,20 @@
'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 }) {
let url = `https://circleci.com/api/v1.1/project/${vcsType}/${userRepo}`
if (branch != null) {
url += `/tree/${branch}`
}
const query = { filter: 'completed', limit: 1 }
const query = { limit: 50 }
if (token) {
query['circle-token'] = token
}
Expand All @@ -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
Expand All @@ -68,21 +79,21 @@ 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',
urlPattern:
'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' }),
},
]
}
Expand Down
Loading

0 comments on commit 768d2b5

Please sign in to comment.