Skip to content

Commit

Permalink
Support [Codecov] coverage badge with flag (#4968)
Browse files Browse the repository at this point in the history
* Support Codecov coverage badge with flag

* Tweak Codecov service and tests

* Maintain Codecov backward-compatibility

* Tweak Codecov docs

Co-authored-by: Caleb Cartwright <calebcartwright@users.noreply.github.com>
Co-authored-by: repo-ranger[bot] <39074581+repo-ranger[bot]@users.noreply.github.com>
  • Loading branch information
3 people committed May 4, 2020
1 parent 6e9e254 commit 594e14c
Show file tree
Hide file tree
Showing 3 changed files with 174 additions and 34 deletions.
108 changes: 80 additions & 28 deletions services/codecov/codecov.service.js
Expand Up @@ -2,15 +2,16 @@

const Joi = require('@hapi/joi')
const { coveragePercentage } = require('../color-formatters')
const { BaseJsonService } = require('..')
const { BaseSvgScrapingService } = require('..')
const { parseJson } = require('../../core/base-service/json')

// https://docs.codecov.io/reference#totals
// A new repository that's been added but never had any coverage reports
// uploaded will not have a `commit` object in the response and sometimes
// the `totals` object can also be missing for the latest commit.
// Accordingly the schema is a bit relaxed to support those scenarios
// and then they are handled in the transform and render functions.
const schema = Joi.object({
const legacySchema = Joi.object({
commit: Joi.object({
totals: Joi.object({
c: Joi.number().required(),
Expand All @@ -20,19 +21,31 @@ const schema = Joi.object({

const queryParamSchema = Joi.object({
token: Joi.string(),
// https://docs.codecov.io/docs/flags
// Flags must be lowercase, alphanumeric, and not exceed 45 characters
flag: Joi.string().regex(/^[a-z0-9_]{1,45}$/),
}).required()

const schema = Joi.object({
message: Joi.string()
.regex(/^\d{1,3}%|unknown$/)
.required(),
})

const svgValueMatcher = />(\d{1,3}%|unknown)<\/text><\/g>/

const badgeTokenPattern = /^\w{10}$/

const documentation = `
<p>
You may specify a Codecov token to get coverage for a private repository.
You may specify a Codecov badge token to get coverage for a private repository.
</p>
<p>
See the <a href="https://docs.codecov.io/reference#authorization">Codecov Docs</a>
for more information about creating a token.
You can find the token under the badge section of your project settings page, in this url: <code>https://codecov.io/{vcsName}/{user}/{repo}/settings/badge</code>.
</p>
`

module.exports = class Codecov extends BaseJsonService {
module.exports = class Codecov extends BaseSvgScrapingService {
static get category() {
return 'coverage'
}
Expand All @@ -56,10 +69,11 @@ module.exports = class Codecov extends BaseJsonService {
namedParams: {
vcsName: 'github',
user: 'codecov',
repo: 'example-python',
repo: 'example-node',
},
queryParams: {
token: 'abc123def456',
token: 'a1b2c3d4e5',
flag: 'flag_name',
},
staticPreview: this.render({ coverage: 90 }),
documentation,
Expand All @@ -71,11 +85,12 @@ module.exports = class Codecov extends BaseJsonService {
namedParams: {
vcsName: 'github',
user: 'codecov',
repo: 'example-python',
repo: 'example-node',
branch: 'master',
},
queryParams: {
token: 'abc123def456',
token: 'a1b2c3d4e5',
flag: 'flag_name',
},
staticPreview: this.render({ coverage: 90 }),
documentation,
Expand All @@ -100,40 +115,77 @@ module.exports = class Codecov extends BaseJsonService {
}
}

async fetch({ vcsName, user, repo, branch, token }) {
// Here for backward-compatibility purpose.
async legacyFetch({ vcsName, user, repo, branch, token }) {
// Codecov Docs: https://docs.codecov.io/reference#section-get-a-single-repository
let url = `https://codecov.io/api/${vcsName}/${user}/${repo}`
if (branch) {
url += `/branches/${branch}`
}
const options = {}
if (token) {
options.headers = {
Authorization: `token ${token}`,
}
}
return this._requestJson({
schema,
options,
const url = `https://codecov.io/api/${vcsName}/${user}/${repo}${
branch ? `/branches/${branch}` : ''
}`
const { buffer } = await this._request({
url,
options: {
headers: {
Accept: 'application/json',
Authorization: `token ${token}`,
},
},
errorMessages: {
401: 'not authorized to access repository',
404: 'repository not found',
},
})
const json = parseJson(buffer)
return this.constructor._validate(json, legacySchema)
}

transform({ json }) {
// Here for backward-compatibility purpose.
legacyTransform({ json }) {
if (!json.commit || !json.commit.totals) {
return { coverage: 'unknown' }
}

return { coverage: +json.commit.totals.c }
}

async handle({ vcsName, user, repo, branch }, { token }) {
const json = await this.fetch({ vcsName, user, repo, branch, token })
const { coverage } = this.transform({ json })
// Doesn't support `flag` feature. Here for backward-compatibility purpose.
async legacyHandle({ vcsName, user, repo, branch }, { token }) {
const json = await this.legacyFetch({ vcsName, user, repo, branch, token })
const { coverage } = this.legacyTransform({ json })
return this.constructor.render({ coverage })
}

async fetch({ vcsName, user, repo, branch, token, flag }) {
const url = `https://codecov.io/${vcsName}/${user}/${repo}${
branch ? `/branches/${branch}` : ''
}/graph/badge.svg`
return this._requestSvg({
schema,
valueMatcher: svgValueMatcher,
url,
options: {
qs: { token, flag },
},
errorMessages: token ? { 400: 'invalid token pattern' } : {},
})
}

transform({ data }) {
// data extracted from svg. e.g.: 42% / unknown
let coverage = data.message || 'unknown'
if (coverage.slice(-1) === '%') {
// remove the trailing %
coverage = Number(coverage.slice(0, -1))
}
return { coverage }
}

async handle({ vcsName, user, repo, branch }, { token, flag }) {
if (!flag && token && !badgeTokenPattern.test(token)) {
return this.legacyHandle({ vcsName, user, repo, branch }, { token })
}

const data = await this.fetch({ vcsName, user, repo, branch, token, flag })
const { coverage } = this.transform({ data })
return this.constructor.render({ coverage })
}
}
8 changes: 7 additions & 1 deletion services/codecov/codecov.spec.js
Expand Up @@ -4,12 +4,18 @@ const { test, forCases, given } = require('sazerac')
const Codecov = require('./codecov.service')

describe('Codecov', function() {
test(Codecov.prototype.transform, () => {
test(Codecov.prototype.legacyTransform, () => {
forCases([given({ json: {} }), given({ json: { commit: {} } })]).expect({
coverage: 'unknown',
})
})

test(Codecov.prototype.transform, () => {
forCases([given({ data: { message: 'unknown' } })]).expect({
coverage: 'unknown',
})
})

test(Codecov.render, () => {
given({ coverage: 'unknown' }).expect({
message: 'unknown',
Expand Down
92 changes: 87 additions & 5 deletions services/codecov/codecov.tester.js
Expand Up @@ -10,26 +10,74 @@ t.create('gets coverage status')
message: isIntegerPercentage,
})

t.create('gets coverage status with flag')
.get('/github/codecov/example-node.json?flag=istanbul_mocha')
.expectBadge({
label: 'coverage',
message: isIntegerPercentage,
})

t.create('gets coverage status for branch')
.get('/github/codecov/example-python/master.json')
.expectBadge({
label: 'coverage',
message: isIntegerPercentage,
})

t.create('gets coverage status for branch with flag')
.get('/github/codecov/example-node/master.json?flag=istanbul_mocha')
.expectBadge({
label: 'coverage',
message: isIntegerPercentage,
})

t.create('handles unknown repository')
.get('/github/codecov2/fake-not-even-a-little-bit-real-python.json')
.expectBadge({
label: 'coverage',
message: 'repository not found',
message: 'unknown',
})

t.create('handles unknown repository with flag')
.get(
'/github/codecov2/fake-not-even-a-little-bit-real-node.json?flag=istanbul_mocha'
)
.expectBadge({
label: 'coverage',
message: 'unknown',
})

t.create('gets coverage status for unknown flag')
.get('/github/codecov/example-node.json?flag=unknown_flag')
.expectBadge({
label: 'coverage',
message: 'unknown',
})

// Using a mocked response here because we did not have a known
// private repository hooked up with Codecov that we could use.
t.create('handles unauthorized error')
t.create('handles unauthorized private repository')
.get('/github/codecov/private-example-python.json')
.intercept(nock =>
nock('https://codecov.io/api')
nock('https://codecov.io')
.get('/github/codecov/private-example-python/graph/badge.svg')
.reply(200, `<g><text x="105.5" y="14">unknown</text></g>`, {
'Content-Type': 'image/svg+xml',
})
)
.expectBadge({
label: 'coverage',
message: 'unknown',
})

t.create('handles unauthorized error (with api token)')
.get('/github/codecov/private-example-python.json?token=a1b2c3d4e5f6g7h8')
.intercept(nock =>
nock('https://codecov.io/api', {
reqheaders: {
authorization: 'token a1b2c3d4e5f6g7h8',
},
})
.get('/github/codecov/private-example-python')
.reply(401)
)
Expand All @@ -38,12 +86,46 @@ t.create('handles unauthorized error')
message: 'not authorized to access repository',
})

t.create('handles unknown repository (with api token)')
.get(
'/github/codecov2/fake-not-even-a-little-bit-real-python.json?token=a1b2c3d4e5f6g7h8'
)
.intercept(nock =>
nock('https://codecov.io/api', {
reqheaders: {
authorization: 'token a1b2c3d4e5f6g7h8',
},
})
.get('/github/codecov2/fake-not-even-a-little-bit-real-python')
.reply(404)
)
.expectBadge({
label: 'coverage',
message: 'repository not found',
})

t.create('gets coverage for private repository')
.get('/github/codecov/private-example-python.json?token=abc123def456')
.get('/gh/codecov/private-example-python.json?token=a1b2c3d4e5')
.intercept(nock =>
nock('https://codecov.io')
.get(
'/gh/codecov/private-example-python/graph/badge.svg?token=a1b2c3d4e5'
)
.reply(200, `<g><text x="105.5" y="14">100%</text></g>`, {
'Content-Type': 'image/svg+xml',
})
)
.expectBadge({
label: 'coverage',
message: '100%',
})

t.create('gets coverage for private repository (with api token)')
.get('/github/codecov/private-example-python.json?token=a1b2c3d4e5f6g7h8')
.intercept(nock =>
nock('https://codecov.io/api', {
reqheaders: {
authorization: 'token abc123def456',
authorization: 'token a1b2c3d4e5f6g7h8',
},
})
.get('/github/codecov/private-example-python')
Expand Down

0 comments on commit 594e14c

Please sign in to comment.