diff --git a/services/codecov/codecov-redirect.service.js b/services/codecov/codecov-redirect.service.js new file mode 100644 index 0000000000..9d56de406e --- /dev/null +++ b/services/codecov/codecov-redirect.service.js @@ -0,0 +1,26 @@ +'use strict' + +const { redirector } = require('..') + +const vcsSNameShortFormMap = { + bb: 'bitbucket', + gh: 'github', + gl: 'gitlab', +} + +module.exports = [ + redirector({ + category: 'coverage', + route: { + base: 'codecov/c', + pattern: + 'token/:token/:vcsName(github|gh|bitbucket|bb|gl|gitlab)/:user/:repo/:branch*', + }, + transformPath: ({ vcsName, user, repo, branch }) => { + const vcs = vcsSNameShortFormMap[vcsName] || vcsName + return `/codecov/c/${vcs}/${user}/${repo}${branch ? `/${branch}` : ''}` + }, + transformQueryParams: ({ token }) => ({ token }), + dateAdded: new Date('2019-03-04'), + }), +] diff --git a/services/codecov/codecov-redirect.tester.js b/services/codecov/codecov-redirect.tester.js new file mode 100644 index 0000000000..fddbc0c696 --- /dev/null +++ b/services/codecov/codecov-redirect.tester.js @@ -0,0 +1,39 @@ +'use strict' + +const { ServiceTester } = require('../tester') + +const t = (module.exports = new ServiceTester({ + id: 'CodecovTokenRedirect', + title: 'CodecovTokenRedirect', + pathPrefix: '/codecov', +})) + +t.create('codecov token') + .get('/c/token/abc123def456/gh/codecov/private-example.svg', { + followRedirect: false, + }) + .expectStatus(301) + .expectHeader( + 'Location', + '/codecov/c/github/codecov/private-example.svg?token=abc123def456' + ) + +t.create('codecov branch token') + .get('/c/token/abc123def456/bb/private-shields/private-badges/master.svg', { + followRedirect: false, + }) + .expectStatus(301) + .expectHeader( + 'Location', + '/codecov/c/bitbucket/private-shields/private-badges/master.svg?token=abc123def456' + ) + +t.create('codecov gl short form expanded to long form') + .get('/c/token/abc123def456/gl/private-shields/private-badges/master.svg', { + followRedirect: false, + }) + .expectStatus(301) + .expectHeader( + 'Location', + '/codecov/c/gitlab/private-shields/private-badges/master.svg?token=abc123def456' + ) diff --git a/services/codecov/codecov.service.js b/services/codecov/codecov.service.js index a2df0e7c1a..a73b878ae1 100644 --- a/services/codecov/codecov.service.js +++ b/services/codecov/codecov.service.js @@ -1,27 +1,67 @@ 'use strict' -const queryString = require('query-string') -const LegacyService = require('../legacy-service') -const { makeBadgeData: getBadgeData } = require('../../lib/badge-data') -const { - coveragePercentage: coveragePercentageColor, -} = require('../color-formatters') +const Joi = require('joi') +const { coveragePercentage } = require('../color-formatters') +const { BaseJsonService } = require('..') -// This legacy service should be rewritten to use e.g. BaseJsonService. -// -// Tips for rewriting: -// https://github.com/badges/shields/blob/master/doc/rewriting-services.md -// -// Do not base new services on this code. -module.exports = class Codecov extends LegacyService { +// 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({ + commit: Joi.object({ + totals: Joi.object({ + c: Joi.number().required(), + }), + }), +}).required() + +const queryParamSchema = Joi.object({ + token: Joi.string(), +}).required() + +const documentation = ` +
+ You may specify a Codecov token to get coverage for a private repository. +
++ See the Codecov Docs + for more information about creating a token. +
+` + +module.exports = class Codecov extends BaseJsonService { static get category() { return 'coverage' } + static get defaultBadgeData() { + return { label: 'coverage' } + } + + static render({ coverage }) { + if (coverage === 'unknown') { + return { + message: coverage, + color: 'lightgrey', + } + } + return { + message: `${coverage.toFixed(0)}%`, + color: coveragePercentage(coverage), + } + } + static get route() { return { base: 'codecov/c', - pattern: '', + // https://docs.codecov.io/docs#section-common-questions + // Github, BitBucket, and GitLab are the only supported options (long or short form) + pattern: + ':vcsName(github|gh|bitbucket|bb|gl|gitlab)/:user/:repo/:branch*', + queryParamSchema, } } @@ -29,80 +69,71 @@ module.exports = class Codecov extends LegacyService { return [ { title: 'Codecov', - pattern: ':vcsName/:user/:repo', + pattern: ':vcsName(github|gh|bitbucket|bb|gl|gitlab)/:user/:repo', namedParams: { vcsName: 'github', user: 'codecov', repo: 'example-python', }, - staticPreview: { label: 'coverage', message: '90%', color: 'green' }, + queryParams: { + token: 'abc123def456', + }, + staticPreview: this.render({ coverage: 90 }), + documentation, }, { title: 'Codecov branch', - pattern: ':vcsName/:user/:repo/:branch', + pattern: + ':vcsName(github|gh|bitbucket|bb|gl|gitlab)/:user/:repo/:branch', namedParams: { vcsName: 'github', user: 'codecov', repo: 'example-python', branch: 'master', }, - staticPreview: { label: 'coverage', message: '90%', color: 'green' }, - }, - { - title: 'Codecov private', - pattern: 'token/:token/:vcsName/:user/:repo', - namedParams: { - token: 'My0A8VL917', - vcsName: 'github', - user: 'codecov', - repo: 'example-python', + queryParams: { + token: 'abc123def456', }, - staticPreview: { label: 'coverage', message: '90%', color: 'green' }, + staticPreview: this.render({ coverage: 90 }), + documentation, }, ] } - static registerLegacyRouteHandler({ camp, cache }) { - camp.route( - /^\/codecov\/c\/(?:token\/(\w+))?[+/]?([^/]+\/[^/]+\/[^/]+)(?:\/(.+))?\.(svg|png|gif|jpg|json)$/, - cache((data, match, sendBadge, request) => { - const token = match[1] - const userRepo = match[2] // eg, `github/codecov/example-python`. - const branch = match[3] - const format = match[4] - let apiUrl - if (branch) { - apiUrl = `https://codecov.io/${userRepo}/branch/${branch}/graphs/badge.txt` - } else { - apiUrl = `https://codecov.io/${userRepo}/graphs/badge.txt` - } - if (token) { - apiUrl += `?${queryString.stringify({ token })}` - } - const badgeData = getBadgeData('coverage', data) - request(apiUrl, (err, res, body) => { - if (err != null) { - badgeData.text[1] = 'invalid' - sendBadge(format, badgeData) - return - } - try { - // Body: range(0, 100) or "unknown" - const coverage = body.trim() - if (Number.isNaN(+coverage)) { - badgeData.text[1] = 'unknown' - sendBadge(format, badgeData) - return - } - badgeData.text[1] = `${coverage}%` - badgeData.colorscheme = coveragePercentageColor(coverage) - sendBadge(format, badgeData) - } catch (e) { - badgeData.text[1] = 'malformed' - sendBadge(format, badgeData) - } - }) - }) - ) + async fetch({ 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, + url, + errorMessages: { + 401: 'not authorized to access repository', + 404: 'repository not found', + }, + }) + } + + transform({ 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 }) + return this.constructor.render({ coverage }) } } diff --git a/services/codecov/codecov.spec.js b/services/codecov/codecov.spec.js new file mode 100644 index 0000000000..9a60f7b8bb --- /dev/null +++ b/services/codecov/codecov.spec.js @@ -0,0 +1,19 @@ +'use strict' + +const { test, forCases, given } = require('sazerac') +const Codecov = require('./codecov.service') + +describe('Codecov', function() { + test(Codecov.prototype.transform, () => { + forCases([given({ json: {} }), given({ json: { commit: {} } })]).expect({ + coverage: 'unknown', + }) + }) + + test(Codecov.render, () => { + given({ coverage: 'unknown' }).expect({ + message: 'unknown', + color: 'lightgrey', + }) + }) +}) diff --git a/services/codecov/codecov.tester.js b/services/codecov/codecov.tester.js index 088b167041..85bcc253ae 100644 --- a/services/codecov/codecov.tester.js +++ b/services/codecov/codecov.tester.js @@ -1,23 +1,61 @@ 'use strict' -const { ServiceTester } = require('../tester') const { isIntegerPercentage } = require('../test-validators') - -const t = (module.exports = new ServiceTester({ - id: 'codecov', - title: 'Codecov.io', -})) +const t = (module.exports = require('../tester').createServiceTester()) t.create('gets coverage status') - .get('/c/github/codecov/example-python.json') + .get('/github/codecov/example-python.json') .expectBadge({ label: 'coverage', message: isIntegerPercentage, }) -t.create('gets coverate status for branch') - .get('/c/github/codecov/example-python/master.json') +t.create('gets coverage status for branch') + .get('/github/codecov/example-python/master.json') .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', + }) + +// 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') + .get('/github/codecov/private-example-python.json') + .intercept(nock => + nock('https://codecov.io/api') + .get('/github/codecov/private-example-python') + .reply(401) + ) + .expectBadge({ + label: 'coverage', + message: 'not authorized to access repository', + }) + +t.create('gets coverage for private repository') + .get('/github/codecov/private-example-python.json?token=abc123def456') + .intercept(nock => + nock('https://codecov.io/api', { + reqheaders: { + authorization: 'token abc123def456', + }, + }) + .get('/github/codecov/private-example-python') + .reply(200, { + commit: { + totals: { + c: 94.75, + }, + }, + }) + ) + .expectBadge({ + label: 'coverage', + message: '95%', + })