diff --git a/config/custom-environment-variables.yml b/config/custom-environment-variables.yml index 352d72dd4c2db..87b75a1589463 100644 --- a/config/custom-environment-variables.yml +++ b/config/custom-environment-variables.yml @@ -45,6 +45,7 @@ private: azure_devops_token: 'AZURE_DEVOPS_TOKEN' bintray_user: 'BINTRAY_USER' bintray_apikey: 'BINTRAY_API_KEY' + drone_token: 'DRONE_TOKEN' gh_client_id: 'GH_CLIENT_ID' gh_client_secret: 'GH_CLIENT_SECRET' gh_token: 'GH_TOKEN' diff --git a/core/base-service/redirector.js b/core/base-service/redirector.js index e8876f6995295..e738ce8352c3b 100644 --- a/core/base-service/redirector.js +++ b/core/base-service/redirector.js @@ -14,6 +14,7 @@ const { isValidRoute, prepareRoute, namedParamsForMatch } = require('./route') const trace = require('./trace') const attrSchema = Joi.object({ + name: Joi.string().min(3), category: isValidCategory, route: isValidRoute, transformPath: Joi.func() @@ -30,6 +31,7 @@ const attrSchema = Joi.object({ module.exports = function redirector(attrs) { const { + name, category, route, transformPath, @@ -51,9 +53,13 @@ module.exports = function redirector(attrs) { } static get name() { - return `${camelcase(route.base.replace(/\//g, '_'), { - pascalCase: true, - })}Redirect` + if (name) { + return name + } else { + return `${camelcase(route.base.replace(/\//g, '_'), { + pascalCase: true, + })}Redirect` + } } static register({ camp, requestCounter }) { diff --git a/core/base-service/redirector.spec.js b/core/base-service/redirector.spec.js index 525c0734cd645..3bc19e960482d 100644 --- a/core/base-service/redirector.spec.js +++ b/core/base-service/redirector.spec.js @@ -24,6 +24,15 @@ describe('Redirector', function() { expect(redirector(attrs).name).to.equal('VeryOldServiceRedirect') }) + it('overrides the name', function() { + expect( + redirector({ + ...attrs, + name: 'ShinyRedirect', + }).name + ).to.equal('ShinyRedirect') + }) + it('sets specified route', function() { expect(redirector(attrs).route).to.deep.equal(route) }) diff --git a/doc/server-secrets.md b/doc/server-secrets.md index cd7a7ff3605f8..c4bf55a3c6b98 100644 --- a/doc/server-secrets.md +++ b/doc/server-secrets.md @@ -43,6 +43,13 @@ An Azure DevOps Token (PAT) is required for accessing [private Azure DevOps proj The bintray API [requires authentication](https://bintray.com/docs/api/#_authentication) Create an account and obtain a token from the user profile page. +## Drone + +- `DRONE_TOKEN` (yml: `drone_token`) + +The self-hosted Drone API [requires authentication](https://0-8-0.docs.drone.io/api-authentication/) +Login to your Drone instance and obtain a token from the user profile page. + ## GitHub - `GH_TOKEN` (yml: `gh_token`) diff --git a/package-lock.json b/package-lock.json index aafd96aee3a2b..4e5a0cc1c2a4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2468,6 +2468,12 @@ "integrity": "sha512-Y9J6ZjXtoYh8RnXVCMOU/ttDmk1aBjunq9vO0ta5x85WDQiQfUF9sIPBITdbiiIVcBo03Hi3jMxigBtsddlXRw==", "dev": true }, + "arg": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz", + "integrity": "sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg==", + "dev": true + }, "argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -5972,9 +5978,9 @@ } }, "config": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/config/-/config-3.0.1.tgz", - "integrity": "sha512-TBNrrk2b6AybUohqXw2AydglFBL9b/+1GG93Di6Fm6x1SyVJ5PYgo+mqY2X0KpU9m0PJDSbFaC5H95utSphtLw==", + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/config/-/config-3.1.0.tgz", + "integrity": "sha512-t6oDeNQbsIWa+D/KF4959TANzjSHLv1BA/hvL8tHEA3OUSWgBXELKaONSI6nr9oanbKs0DXonjOWLcrtZ3yTAA==", "requires": { "json5": "^1.0.1" }, @@ -19062,6 +19068,12 @@ "integrity": "sha1-Q2CxfGETatOAeDl/8RQW4Ybc+7g=", "dev": true }, + "quote": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/quote/-/quote-0.4.0.tgz", + "integrity": "sha1-EIOSF/bBNiuJGUBE0psjP9fzLwE=", + "dev": true + }, "r-json": { "version": "1.2.9", "resolved": "https://registry.npmjs.org/r-json/-/r-json-1.2.9.tgz", @@ -20766,14 +20778,15 @@ } }, "snap-shot-core": { - "version": "7.1.13", - "resolved": "https://registry.npmjs.org/snap-shot-core/-/snap-shot-core-7.1.13.tgz", - "integrity": "sha512-ylPfUBV1gjE5s/rlViEqRZGI4E4Cwn9ZeGss/0ujgGnXc73LsXpo7YY+f/LXfBlDP1HZbltVKoEbdqq8KWYtUA==", + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/snap-shot-core/-/snap-shot-core-8.0.0.tgz", + "integrity": "sha512-QhnM9tTqOPo/KQLbHjgmLyJXZPWQZsybjAvgzCobX9t1nubD93aokLfDhG6txEmlTQz9kY4Vd6iLjiLJao7rOg==", "dev": true, "requires": { + "arg": "4.1.0", "check-more-types": "2.24.0", "common-tags": "1.8.0", - "debug": "3.2.6", + "debug": "4.1.1", "escape-quotes": "1.0.2", "folktale": "2.3.2", "is-ci": "2.0.0", @@ -20781,6 +20794,7 @@ "lazy-ass": "1.6.0", "mkdirp": "0.5.1", "pluralize": "7.0.0", + "quote": "0.4.0", "ramda": "0.26.1" }, "dependencies": { @@ -20791,9 +20805,9 @@ "dev": true }, "debug": { - "version": "3.2.6", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.6.tgz", - "integrity": "sha512-mel+jf7nrtEl5Pn1Qx46zARXKDpBbvzezse7p7LqINmdoIk8PYP5SySaxEmYv6TZ0JyEKA1hsCId6DIhgITtWQ==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.1.1.tgz", + "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", "dev": true, "requires": { "ms": "^2.1.1" @@ -20829,9 +20843,9 @@ } }, "snap-shot-it": { - "version": "6.2.10", - "resolved": "https://registry.npmjs.org/snap-shot-it/-/snap-shot-it-6.2.10.tgz", - "integrity": "sha512-BfYhkbJda5orPz8NZWz7KWskCdujCImvCXDRH8wg07ALVf5EFMUtJLt14nZzWGBtmasnxK7koK2l2YSxIw9pNg==", + "version": "6.3.3", + "resolved": "https://registry.npmjs.org/snap-shot-it/-/snap-shot-it-6.3.3.tgz", + "integrity": "sha512-TCzkPrxF407HQiqVuhclQp6bti2EqifOzUuxl3wHZ0zCGtVLu89TAWhMW+tX87Ge1QvC4ji9NBdTC/2YVtNSZA==", "dev": true, "requires": { "@bahmutov/data-driven": "1.0.0", @@ -20841,7 +20855,7 @@ "pluralize": "7.0.0", "ramda": "0.26.1", "snap-shot-compare": "2.7.1", - "snap-shot-core": "7.1.13" + "snap-shot-core": "8.0.0" }, "dependencies": { "debug": { diff --git a/package.json b/package.json index d3c0f000b5591..d0fa764e2de01 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "chalk": "^2.4.2", "check-node-version": "^3.1.0", "chrome-web-store-item-property": "~1.1.2", - "config": "^3.0.1", + "config": "^3.1.0", "cross-env": "^5.2.0", "decamelize": "^3.2.0", "dotenv": "^7.0.0", @@ -215,7 +215,7 @@ "sazerac": "^0.4.2", "sinon": "^7.3.1", "sinon-chai": "^3.3.0", - "snap-shot-it": "^6.2.10", + "snap-shot-it": "^6.3.3", "start-server-and-test": "^1.7.12", "styled-components": "^4.2.0", "tmp": "0.1.0", diff --git a/services/build-status.js b/services/build-status.js index 82c9221307fff..971f803869576 100644 --- a/services/build-status.js +++ b/services/build-status.js @@ -13,7 +13,13 @@ const greenStatuses = [ const orangeStatuses = ['partially succeeded', 'unstable', 'timeout'] -const redStatuses = ['error', 'failed', 'failing', 'infrastructure_failure'] +const redStatuses = [ + 'error', + 'failed', + 'failing', + 'failure', + 'infrastructure_failure', +] const otherStatuses = [ 'building', diff --git a/services/build-status.spec.js b/services/build-status.spec.js index ce8f9e9169a84..e0293f482182d 100644 --- a/services/build-status.spec.js +++ b/services/build-status.spec.js @@ -58,6 +58,7 @@ test(renderBuildStatusBadge, () => { given({ status: 'error' }), given({ status: 'failed' }), given({ status: 'failing' }), + given({ status: 'failure' }), given({ status: 'infrastructure_failure' }), ]).assert('should be red', b => expect(b).to.include({ color: 'red' })) }) diff --git a/services/codeclimate/codeclimate-common.js b/services/codeclimate/codeclimate-common.js new file mode 100644 index 0000000000000..86b43ba34c9a9 --- /dev/null +++ b/services/codeclimate/codeclimate-common.js @@ -0,0 +1,48 @@ +'use strict' + +const Joi = require('joi') +const { NotFound } = require('..') + +const keywords = ['codeclimate'] + +const repoSchema = Joi.object({ + data: Joi.array() + .max(1) + .items( + Joi.object({ + id: Joi.string().required(), + relationships: Joi.object({ + latest_default_branch_snapshot: Joi.object({ + data: Joi.object({ + id: Joi.string().required(), + }).allow(null), + }).required(), + latest_default_branch_test_report: Joi.object({ + data: Joi.object({ + id: Joi.string().required(), + }).allow(null), + }).required(), + }).required(), + }) + ) + .required(), +}).required() + +async function fetchRepo(serviceInstance, { user, repo }) { + const { + data: [repoInfo], + } = await serviceInstance._requestJson({ + schema: repoSchema, + url: 'https://api.codeclimate.com/v1/repos', + options: { qs: { github_slug: `${user}/${repo}` } }, + }) + if (repoInfo === undefined) { + throw new NotFound({ prettyMessage: 'repo not found' }) + } + return repoInfo +} + +module.exports = { + keywords, + fetchRepo, +} diff --git a/services/codeclimate/codeclimate-coverage-redirector.service.js b/services/codeclimate/codeclimate-coverage-redirector.service.js new file mode 100644 index 0000000000000..9b6b8f7d8660b --- /dev/null +++ b/services/codeclimate/codeclimate-coverage-redirector.service.js @@ -0,0 +1,39 @@ +'use strict' + +const { redirector } = require('..') + +module.exports = [ + // http://github.com/badges/shields/issues/1387 + // https://github.com/badges/shields/pull/3320#issuecomment-483795000 + redirector({ + name: 'CodeclimateCoveragePercentageRedirect', + category: 'coverage', + route: { + base: 'codeclimate', + pattern: ':which(c|coverage-percentage)/:user/:repo', + }, + transformPath: ({ user, repo }) => `/codeclimate/coverage/${user}/${repo}`, + dateAdded: new Date('2019-04-15'), + }), + redirector({ + name: 'CodeclimateCoverageLetterRedirect', + category: 'coverage', + route: { + base: 'codeclimate/c-letter', + pattern: ':user/:repo', + }, + transformPath: ({ user, repo }) => + `/codeclimate/coverage-letter/${user}/${repo}`, + dateAdded: new Date('2019-04-15'), + }), + redirector({ + name: 'CodeclimateTopLevelCoverageRedirect', + category: 'coverage', + route: { + base: 'codeclimate', + pattern: ':user/:repo', + }, + transformPath: ({ user, repo }) => `/codeclimate/coverage/${user}/${repo}`, + dateAdded: new Date('2019-04-15'), + }), +] diff --git a/services/codeclimate/codeclimate-coverage-redirector.tester.js b/services/codeclimate/codeclimate-coverage-redirector.tester.js new file mode 100644 index 0000000000000..0c4647adf7721 --- /dev/null +++ b/services/codeclimate/codeclimate-coverage-redirector.tester.js @@ -0,0 +1,37 @@ +'use strict' + +const { ServiceTester } = require('../tester') + +const t = (module.exports = new ServiceTester({ + id: 'CodeclimateCoverageRedirector', + title: 'Code Climate Coverage Redirector', + pathPrefix: '/codeclimate', +})) + +t.create('Top-level coverage shortcut') + .get('/jekyll/jekyll.svg', { + followRedirect: false, + }) + .expectStatus(301) + .expectHeader('Location', '/codeclimate/coverage/jekyll/jekyll.svg') + +t.create('Coverage shortcut') + .get('/c/jekyll/jekyll.svg', { + followRedirect: false, + }) + .expectStatus(301) + .expectHeader('Location', '/codeclimate/coverage/jekyll/jekyll.svg') + +t.create('Coverage letter shortcut') + .get('/c-letter/jekyll/jekyll.svg', { + followRedirect: false, + }) + .expectStatus(301) + .expectHeader('Location', '/codeclimate/coverage-letter/jekyll/jekyll.svg') + +t.create('Coverage percentage shortcut') + .get('/coverage-percentage/jekyll/jekyll.svg', { + followRedirect: false, + }) + .expectStatus(301) + .expectHeader('Location', '/codeclimate/coverage/jekyll/jekyll.svg') diff --git a/services/codeclimate/codeclimate-coverage.service.js b/services/codeclimate/codeclimate-coverage.service.js new file mode 100644 index 0000000000000..8f2f5f1d7eaa8 --- /dev/null +++ b/services/codeclimate/codeclimate-coverage.service.js @@ -0,0 +1,94 @@ +'use strict' + +const Joi = require('joi') +const { BaseJsonService, NotFound } = require('..') +const { coveragePercentage, letterScore } = require('../color-formatters') +const { keywords, fetchRepo } = require('./codeclimate-common') + +const schema = Joi.object({ + data: Joi.object({ + attributes: Joi.object({ + covered_percent: Joi.number().required(), + rating: Joi.object({ + letter: Joi.equal('A', 'B', 'C', 'D', 'E', 'F').required(), + }).required(), + }).required(), + }).allow(null), +}).required() + +module.exports = class CodeclimateCoverage extends BaseJsonService { + static get route() { + return { + base: 'codeclimate', + pattern: ':which(coverage|coverage-letter)/:user/:repo', + } + } + + static get category() { + return 'coverage' + } + + static get examples() { + return [ + { + title: 'Code Climate coverage', + namedParams: { which: 'coverage', user: 'jekyll', repo: 'jekyll' }, + staticPreview: this.render({ + which: 'coverage', + percentage: 95.123, + letter: 'A', + }), + keywords, + }, + ] + } + + async fetch({ user, repo }) { + const { + id: repoId, + relationships: { + latest_default_branch_test_report: { data: testReportInfo }, + }, + } = await fetchRepo(this, { user, repo }) + if (testReportInfo === null) { + throw new NotFound({ prettyMessage: 'test report not found' }) + } + const { data } = await this._requestJson({ + schema, + url: `https://api.codeclimate.com/v1/repos/${repoId}/test_reports/${ + testReportInfo.id + }`, + errorMessages: { 404: 'test report not found' }, + }) + return data + } + + static render({ wantLetter, percentage, letter }) { + if (wantLetter) { + return { + message: letter, + color: letterScore(letter), + } + } else { + return { + message: `${percentage.toFixed(0)}%`, + color: coveragePercentage(percentage), + } + } + } + + async handle({ which, user, repo }) { + const { + attributes: { + rating: { letter }, + covered_percent: percentage, + }, + } = await this.fetch({ user, repo }) + + return this.constructor.render({ + wantLetter: which === 'coverage-letter', + letter, + percentage, + }) + } +} diff --git a/services/codeclimate/codeclimate-coverage.tester.js b/services/codeclimate/codeclimate-coverage.tester.js index 8379e15f1bd9f..ebda92499ace8 100644 --- a/services/codeclimate/codeclimate-coverage.tester.js +++ b/services/codeclimate/codeclimate-coverage.tester.js @@ -1,53 +1,33 @@ 'use strict' const Joi = require('joi') -const { ServiceTester } = require('../tester') const { isIntegerPercentage } = require('../test-validators') - -const t = (module.exports = new ServiceTester({ - id: 'CodeClimateCoverage', - title: 'Code Climate', - pathPrefix: '/codeclimate', -})) +const t = (module.exports = require('../tester').createServiceTester()) t.create('test coverage percentage') - .get('/c/jekyll/jekyll.json') - .expectBadge({ - label: 'coverage', - message: isIntegerPercentage, - }) - -t.create('test coverage percentage alternative coverage URL') .get('/coverage/jekyll/jekyll.json') .expectBadge({ label: 'coverage', message: isIntegerPercentage, }) -t.create('test coverage percentage alternative top-level URL') - .get('/jekyll/jekyll.json') - .expectBadge({ - label: 'coverage', - message: isIntegerPercentage, - }) - t.create('test coverage letter') - .get('/c-letter/jekyll/jekyll.json') + .get('/coverage-letter/jekyll/jekyll.json') .expectBadge({ label: 'coverage', message: Joi.equal('A', 'B', 'C', 'D', 'E', 'F'), }) t.create('test coverage percentage for non-existent repo') - .get('/c/unknown/unknown.json') + .get('/coverage/unknown/unknown.json') .expectBadge({ label: 'coverage', - message: 'not found', + message: 'repo not found', }) t.create('test coverage percentage for repo without test reports') - .get('/c/angular/angular.js.json') + .get('/coverage/angular/angular.js.json') .expectBadge({ label: 'coverage', - message: 'unknown', + message: 'test report not found', }) diff --git a/services/codeclimate/codeclimate.service.js b/services/codeclimate/codeclimate.service.js index 28184d85e972b..18d58f50adf93 100644 --- a/services/codeclimate/codeclimate.service.js +++ b/services/codeclimate/codeclimate.service.js @@ -8,46 +8,6 @@ const { colorScale, } = require('../color-formatters') -class CodeclimateCoverage extends LegacyService { - static get route() { - return { - base: 'codeclimate', - pattern: ':which(coverage|coverage-letter)/:userRepo*', - } - } - - static get category() { - return 'coverage' - } - - static get examples() { - return [ - { - title: 'Code Climate coverage', - pattern: 'coverage/:userRepo', - namedParams: { userRepo: 'jekyll/jekyll' }, - staticPreview: { - label: 'coverage', - message: '95%', - color: 'green', - }, - }, - { - title: 'Code Climate coverage (letter)', - pattern: 'coverage-letter/:userRepo', - namedParams: { userRepo: 'jekyll/jekyll' }, - staticPreview: { - label: 'coverage', - message: 'A', - color: 'brightgreen', - }, - }, - ] - } - - static registerLegacyRouteHandler() {} -} - // This legacy service should be rewritten to use e.g. BaseJsonService. // // Tips for rewriting: @@ -106,7 +66,7 @@ class Codeclimate extends LegacyService { static registerLegacyRouteHandler({ camp, cache }) { camp.route( - /^\/codeclimate(\/(c|coverage|maintainability|issues|tech-debt)(-letter|-percentage)?)?\/(.+)\.(svg|png|gif|jpg|json)$/, + /^\/codeclimate(\/(maintainability|issues|tech-debt)(-letter|-percentage)?)\/(.+)\.(svg|png|gif|jpg|json)$/, cache((data, match, sendBadge, request) => { let type if (match[2] === 'c' || !match[2]) { @@ -232,6 +192,5 @@ class Codeclimate extends LegacyService { } module.exports = { - CodeclimateCoverage, Codeclimate, } diff --git a/services/drone/drone-build.service.js b/services/drone/drone-build.service.js new file mode 100644 index 0000000000000..9d918fb932279 --- /dev/null +++ b/services/drone/drone-build.service.js @@ -0,0 +1,107 @@ +'use strict' + +const Joi = require('joi') +const serverSecrets = require('../../lib/server-secrets') +const { isBuildStatus, renderBuildStatusBadge } = require('../build-status') +const { optionalUrl } = require('../validators') +const { BaseJsonService } = require('..') + +const DroneBuildSchema = Joi.object({ + status: Joi.alternatives() + .try(isBuildStatus, Joi.equal('none')) + .required(), +}).required() + +const queryParamSchema = Joi.object({ + server: optionalUrl, +}).required() + +module.exports = class DroneBuild extends BaseJsonService { + static get category() { + return 'build' + } + + static get route() { + return { + queryParamSchema, + base: 'drone/build', + pattern: ':user/:repo/:branch*', + } + } + + static get defaultBadgeData() { + return { + label: 'build', + } + } + + async handle({ user, repo, branch }, { server }) { + const options = { + qs: { + ref: branch ? `refs/heads/${branch}` : undefined, + }, + } + if (serverSecrets.drone_token) { + options.headers = { + Authorization: `Bearer ${serverSecrets.drone_token}`, + } + } + if (!server) { + server = 'https://cloud.drone.io' + } + const json = await this._requestJson({ + options, + schema: DroneBuildSchema, + url: `${server}/api/repos/${user}/${repo}/builds/latest`, + errorMessages: { + 401: 'repo not found or not authorized', + }, + }) + return renderBuildStatusBadge({ status: json.status }) + } + + static get examples() { + return [ + { + title: 'Drone (cloud)', + pattern: ':user/:repo', + namedParams: { + user: 'drone', + repo: 'drone', + }, + staticPreview: renderBuildStatusBadge({ status: 'success' }), + }, + { + title: 'Drone (cloud) with branch', + pattern: ':user/:repo/:branch', + namedParams: { + user: 'drone', + repo: 'drone', + branch: 'master', + }, + staticPreview: renderBuildStatusBadge({ status: 'success' }), + }, + { + title: 'Drone (self-hosted)', + pattern: ':user/:repo', + queryParams: { server: 'https://drone.shields.io' }, + namedParams: { + user: 'badges', + repo: 'shields', + }, + staticPreview: renderBuildStatusBadge({ status: 'success' }), + }, + { + title: 'Drone (self-hosted) with branch', + pattern: ':user/:repo/:branch', + queryParams: { server: 'https://drone.shields.io' }, + namedParams: { + user: 'badges', + repo: 'shields', + branch: 'feat/awesome-thing', + }, + staticPreview: renderBuildStatusBadge({ status: 'success' }), + }, + ] + } +} diff --git a/services/drone/drone-build.tester.js b/services/drone/drone-build.tester.js new file mode 100644 index 0000000000000..1ea88667cfb38 --- /dev/null +++ b/services/drone/drone-build.tester.js @@ -0,0 +1,62 @@ +'use strict' + +const Joi = require('joi') +const { isBuildStatus } = require('../build-status') +const t = (module.exports = require('../tester').createServiceTester()) +const { mockDroneCreds, token, restore } = require('./drone-test-helpers') + +t.create('cloud-hosted build status on default branch') + .get('/drone/drone.json') + .expectBadge({ + label: 'build', + message: Joi.alternatives().try(isBuildStatus, Joi.equal('none')), + }) + +t.create('cloud-hosted build status on named branch') + .get('/drone/drone/master.json') + .expectBadge({ + label: 'build', + message: Joi.alternatives().try(isBuildStatus, Joi.equal('none')), + }) + +t.create('cloud-hosted build status on unknown repo') + .get('/this-repo/does-not-exist.json') + .expectBadge({ + label: 'build', + message: 'repo not found or not authorized', + }) + +t.create('self-hosted build status on default branch') + .before(mockDroneCreds) + .get('/badges/shields.json?server=https://drone.shields.io') + .intercept(nock => + nock('https://drone.shields.io/api/repos', { + reqheaders: { authorization: `Bearer ${token}` }, + }) + .get('/badges/shields/builds/latest') + .reply(200, { status: 'success' }) + ) + .finally(restore) + .expectBadge({ + label: 'build', + message: 'passing', + }) + +t.create('self-hosted build status on named branch') + .before(mockDroneCreds) + .get( + '/badges/shields/feat/awesome-thing.json?server=https://drone.shields.io' + ) + .intercept(nock => + nock('https://drone.shields.io/api/repos', { + reqheaders: { authorization: `Bearer ${token}` }, + }) + .get('/badges/shields/builds/latest') + .query({ ref: 'refs/heads/feat/awesome-thing' }) + .reply(200, { status: 'success' }) + ) + .finally(restore) + .expectBadge({ + label: 'build', + message: 'passing', + }) diff --git a/services/drone/drone-test-helpers.js b/services/drone/drone-test-helpers.js new file mode 100644 index 0000000000000..8cc3c5b2c5838 --- /dev/null +++ b/services/drone/drone-test-helpers.js @@ -0,0 +1,21 @@ +'use strict' + +const sinon = require('sinon') +const serverSecrets = require('../../lib/server-secrets') + +const token = 'my-token' + +function mockDroneCreds() { + serverSecrets['drone_token'] = undefined + sinon.stub(serverSecrets, 'drone_token').value(token) +} + +function restore() { + sinon.restore() +} + +module.exports = { + token, + mockDroneCreds, + restore, +} diff --git a/services/dub/dub-download.service.js b/services/dub/dub-download.service.js index ac9061a559f82..188a1cb690e90 100644 --- a/services/dub/dub-download.service.js +++ b/services/dub/dub-download.service.js @@ -2,7 +2,7 @@ const Joi = require('joi') const { metric } = require('../text-formatters') -const { downloadCount } = require('../color-formatters') +const { downloadCount: downloadCountColor } = require('../color-formatters') const { BaseJsonService } = require('..') const { nonNegativeInteger } = require('../validators') @@ -15,106 +15,101 @@ const schema = Joi.object({ }).required(), }) -function DownloadsForInterval(interval) { - const { base, messageSuffix, name } = { - daily: { - base: 'dub/dd', - messageSuffix: '/day', - name: 'DubDownloadsDay', - }, - weekly: { - base: 'dub/dw', - messageSuffix: '/week', - name: 'DubDownloadsWeek', - }, - monthly: { - base: 'dub/dm', - messageSuffix: '/month', - name: 'DubDownloadsMonth', - }, - total: { - base: 'dub/dt', - messageSuffix: '', - name: 'DubDownloadsTotal', - }, - }[interval] +const intervalMap = { + dd: { + transform: json => json.downloads.daily, + messageSuffix: '/day', + }, + dw: { + transform: json => json.downloads.weekly, + messageSuffix: '/week', + }, + dm: { + transform: json => json.downloads.monthly, + messageSuffix: '/month', + }, + dt: { + transform: json => json.downloads.total, + messageSuffix: '', + }, +} - return class DubDownloads extends BaseJsonService { - static get name() { - return name - } +module.exports = class DubDownloads extends BaseJsonService { + static get category() { + return 'downloads' + } - static render({ downloads, version }) { - const label = version ? `downloads@${version}` : 'downloads' - return { - label, - message: `${metric(downloads)}${messageSuffix}`, - color: downloadCount(downloads), - } + static get route() { + return { + base: 'dub', + pattern: ':interval(dd|dw|dm|dt)/:packageName/:version*', } + } - async fetch({ packageName, version }) { - let url = `https://code.dlang.org/api/packages/${packageName}` - if (version) { - url += `/${version}` - } - url += '/stats' - return this._requestJson({ schema, url }) - } + static get examples() { + return [ + { + title: 'DUB', + namedParams: { interval: 'dm', packageName: 'vibe-d' }, + staticPreview: this.render({ interval: 'dm', downloadCount: 5000 }), + }, + { + title: 'DUB (version)', + namedParams: { + interval: 'dm', + packageName: 'vibe-d', + version: '0.8.4', + }, + staticPreview: this.render({ + interval: 'dm', + version: '0.8.4', + downloadCount: 100, + }), + }, + { + title: 'DUB (latest)', + namedParams: { + interval: 'dm', + packageName: 'vibe-d', + version: 'latest', + }, + staticPreview: this.render({ + interval: 'dm', + version: 'latest', + downloadCount: 100, + }), + }, + ] + } - async handle({ packageName, version }) { - const data = await this.fetch({ packageName, version }) - return this.constructor.render({ - downloads: data.downloads[interval], - version, - }) - } + static get defaultBadgeData() { + return { label: 'downloads' } + } - static get defaultBadgeData() { - return { label: 'downloads' } - } + static render({ interval, version, downloadCount }) { + const { messageSuffix } = intervalMap[interval] - static get category() { - return 'downloads' + return { + label: version ? `downloads@${version}` : 'downloads', + message: `${metric(downloadCount)}${messageSuffix}`, + color: downloadCountColor(downloadCount), } + } - static get route() { - return { - base, - pattern: ':packageName/:version*', - } + async fetch({ packageName, version }) { + let url = `https://code.dlang.org/api/packages/${packageName}` + if (version) { + url += `/${version}` } + url += '/stats' + return this._requestJson({ schema, url }) + } - static get examples() { - let examples = [ - { - title: 'DUB', - pattern: ':packageName', - namedParams: { packageName: 'vibe-d' }, - staticPreview: this.render({ downloads: 5000 }), - }, - ] - if (interval === 'monthly') { - examples = examples.concat([ - { - title: 'DUB (version)', - pattern: ':packageName/:version', - namedParams: { packageName: 'vibe-d', version: '0.8.4' }, - staticPreview: this.render({ downloads: 100, version: '0.8.4' }), - }, - { - title: 'DUB (latest)', - pattern: ':packageName/:version', - namedParams: { packageName: 'vibe-d', version: 'latest' }, - staticPreview: this.render({ downloads: 100, version: 'latest' }), - }, - ]) - } - return examples - } + async handle({ interval, packageName, version }) { + const { transform } = intervalMap[interval] + + const json = await this.fetch({ packageName, version }) + const downloadCount = transform(json) + return this.constructor.render({ interval, downloadCount, version }) } } - -module.exports = ['daily', 'weekly', 'monthly', 'total'].map( - DownloadsForInterval -) diff --git a/services/dub/dub-download.tester.js b/services/dub/dub-download.tester.js index dc33826d10229..22d3407ecf6c6 100644 --- a/services/dub/dub-download.tester.js +++ b/services/dub/dub-download.tester.js @@ -1,8 +1,8 @@ 'use strict' const Joi = require('joi') -const { ServiceTester } = require('../tester') const { isMetric, isMetricOverTimePeriod } = require('../test-validators') +const t = (module.exports = require('../tester').createServiceTester()) const isDownloadsColor = Joi.equal( 'red', @@ -12,11 +12,6 @@ const isDownloadsColor = Joi.equal( 'brightgreen' ) -const t = (module.exports = new ServiceTester({ - id: 'dub', - title: 'DubDownloads', -})) - t.create('total downloads (valid)') .get('/dt/vibe-d.json') .expectBadge({ @@ -29,7 +24,7 @@ t.create('total downloads, specific version (valid)') .get('/dt/vibe-d/0.8.4.json') .expectBadge({ label: 'downloads@0.8.4', - message: Joi.string().regex(/^[1-9][0-9]*[kMGTPEZY]?$/), + message: isMetric, color: isDownloadsColor, }) .timeout(15000) @@ -38,7 +33,7 @@ t.create('total downloads, latest version (valid)') .get('/dt/vibe-d/latest.json') .expectBadge({ label: 'downloads@latest', - message: Joi.string().regex(/^[1-9][0-9]*[kMGTPEZY]?$/), + message: isMetric, color: isDownloadsColor, }) diff --git a/services/npm/npm-downloads.service.js b/services/npm/npm-downloads.service.js index 922b7248c5b3d..f5f5f64c70a79 100644 --- a/services/npm/npm-downloads.service.js +++ b/services/npm/npm-downloads.service.js @@ -10,95 +10,92 @@ const pointResponseSchema = Joi.object({ downloads: nonNegativeInteger, }).required() -// https://github.com/npm/registry/blob/master/docs/download-counts.md#output-1 -const rangeResponseSchema = Joi.object({ - downloads: Joi.array() - .items(pointResponseSchema) - .required(), -}).required() - -function DownloadsForInterval(interval) { - const { base, messageSuffix = '', query, isRange = false, name } = { - week: { - base: 'npm/dw', - messageSuffix: '/w', - query: 'point/last-week', - name: 'NpmDownloadsWeek', - }, - month: { - base: 'npm/dm', - messageSuffix: '/m', - query: 'point/last-month', - name: 'NpmDownloadsMonth', - }, - year: { - base: 'npm/dy', - messageSuffix: '/y', - query: 'point/last-year', - name: 'NpmDownloadsYear', - }, - total: { - base: 'npm/dt', - query: 'range/1000-01-01:3000-01-01', - isRange: true, - name: 'NpmDownloadsTotal', - }, - }[interval] +const intervalMap = { + dw: { + query: 'point/last-week', + schema: pointResponseSchema, + transform: json => json.downloads, + messageSuffix: '/week', + }, + dm: { + query: 'point/last-month', + schema: pointResponseSchema, + transform: json => json.downloads, + messageSuffix: '/month', + }, + dy: { + query: 'point/last-year', + schema: pointResponseSchema, + transform: json => json.downloads, + messageSuffix: '/year', + }, + dt: { + query: 'range/1000-01-01:3000-01-01', + // https://github.com/npm/registry/blob/master/docs/download-counts.md#output-1 + schema: Joi.object({ + downloads: Joi.array() + .items(pointResponseSchema) + .required(), + }).required(), + transform: json => + json.downloads + .map(item => item.downloads) + .reduce((accum, current) => accum + current), + messageSuffix: '', + }, +} - const schema = isRange ? rangeResponseSchema : pointResponseSchema +// This hits an entirely different API from the rest of the NPM services, so +// it does not use NpmBase. +module.exports = class NpmDownloads extends BaseJsonService { + static get category() { + return 'downloads' + } - // This hits an entirely different API from the rest of the NPM services, so - // it does not use NpmBase. - return class NpmDownloads extends BaseJsonService { - static get name() { - return name + static get route() { + return { + base: 'npm', + pattern: ':interval(dw|dm|dy|dt)/:scope(@.+)?/:packageName', } + } - static get category() { - return 'downloads' - } + static get examples() { + return [ + { + title: 'npm', + namedParams: { interval: 'dw', packageName: 'localeval' }, + staticPreview: this.render({ interval: 'dw', downloadCount: 30000 }), + keywords: ['node'], + }, + ] + } - static get route() { - return { - base, - pattern: ':scope(@.+)?/:packageName', - } - } + // For testing. + static get _intervalMap() { + return intervalMap + } - static get examples() { - return [ - { - title: 'npm', - pattern: ':packageName', - namedParams: { packageName: 'localeval' }, - staticPreview: this.render({ downloads: 30000 }), - keywords: ['node'], - }, - ] - } + static render({ interval, downloadCount }) { + const { messageSuffix } = intervalMap[interval] - static render({ downloads }) { - return { - message: `${metric(downloads)}${messageSuffix}`, - color: downloads > 0 ? 'brightgreen' : 'red', - } + return { + message: `${metric(downloadCount)}${messageSuffix}`, + color: downloadCount > 0 ? 'brightgreen' : 'red', } + } - async handle({ scope, packageName }) { - const slug = scope ? `${scope}/${packageName}` : packageName - let { downloads } = await this._requestJson({ - schema, - url: `https://api.npmjs.org/downloads/${query}/${slug}`, - errorMessages: { 404: 'package not found or too new' }, - }) - if (isRange) { - downloads = downloads - .map(item => item.downloads) - .reduce((accum, current) => accum + current) - } - return this.constructor.render({ downloads }) - } + async handle({ interval, scope, packageName }) { + const { query, schema, transform } = intervalMap[interval] + + const slug = scope ? `${scope}/${packageName}` : packageName + const json = await this._requestJson({ + schema, + url: `https://api.npmjs.org/downloads/${query}/${slug}`, + errorMessages: { 404: 'package not found or too new' }, + }) + + const downloadCount = transform(json) + + return this.constructor.render({ interval, downloadCount }) } } - -module.exports = ['week', 'month', 'year', 'total'].map(DownloadsForInterval) diff --git a/services/npm/npm-downloads.spec.js b/services/npm/npm-downloads.spec.js new file mode 100644 index 0000000000000..0e610057f166c --- /dev/null +++ b/services/npm/npm-downloads.spec.js @@ -0,0 +1,25 @@ +'use strict' + +const { test, given } = require('sazerac') +const NpmDownloads = require('./npm-downloads.service') + +describe('NpmDownloads', function() { + test(NpmDownloads._intervalMap.dt.transform, () => { + given({ + downloads: [ + { downloads: 2, day: '2018-01-01' }, + { downloads: 3, day: '2018-01-02' }, + ], + }).expect(5) + }) + + test(NpmDownloads.render, () => { + given({ + interval: 'dt', + downloadCount: 0, + }).expect({ + message: '0', + color: 'red', + }) + }) +}) diff --git a/services/npm/npm-downloads.tester.js b/services/npm/npm-downloads.tester.js index 6fa58d3212fce..b7ac2bf82ce3a 100644 --- a/services/npm/npm-downloads.tester.js +++ b/services/npm/npm-downloads.tester.js @@ -1,14 +1,19 @@ 'use strict' -const { ServiceTester } = require('../tester') -const { isMetric } = require('../test-validators') +const { isMetricOverTimePeriod, isMetric } = require('../test-validators') +const t = (module.exports = require('../tester').createServiceTester()) -const t = new ServiceTester({ - id: 'NpmDownloads', - title: 'NpmDownloads', - pathPrefix: '/npm', -}) -module.exports = t +t.create('weekly downloads of left-pad') + .get('/dw/left-pad.json') + .expectBadge({ + label: 'downloads', + message: isMetricOverTimePeriod, + color: 'brightgreen', + }) + +t.create('weekly downloads of @cycle/core') + .get('/dw/@cycle/core.json') + .expectBadge({ label: 'downloads', message: isMetricOverTimePeriod }) t.create('total downloads of left-pad') .get('/dt/left-pad.json') @@ -22,32 +27,7 @@ t.create('total downloads of @cycle/core') .get('/dt/@cycle/core.json') .expectBadge({ label: 'downloads', message: isMetric }) -t.create('total downloads of package with zero downloads') - .get('/dt/package-no-downloads.json') - .intercept(nock => - nock('https://api.npmjs.org') - .get('/downloads/range/1000-01-01:3000-01-01/package-no-downloads') - .reply(200, { - downloads: [{ downloads: 0, day: '2018-01-01' }], - }) - ) - .expectBadge({ label: 'downloads', message: '0', color: 'red' }) - -t.create('exact total downloads value') - .get('/dt/exact-value.json') - .intercept(nock => - nock('https://api.npmjs.org') - .get('/downloads/range/1000-01-01:3000-01-01/exact-value') - .reply(200, { - downloads: [ - { downloads: 2, day: '2018-01-01' }, - { downloads: 3, day: '2018-01-02' }, - ], - }) - ) - .expectBadge({ label: 'downloads', message: '5' }) - -t.create('total downloads of unknown package') +t.create('downloads of unknown package') .get('/dt/npm-api-does-not-have-this-package.json') .expectBadge({ label: 'downloads', diff --git a/services/sonar/sonar-base.js b/services/sonar/sonar-base.js new file mode 100644 index 0000000000000..b25afc2e79146 --- /dev/null +++ b/services/sonar/sonar-base.js @@ -0,0 +1,91 @@ +'use strict' + +const Joi = require('joi') +const serverSecrets = require('../../lib/server-secrets') +const { BaseJsonService } = require('..') +const { isLegacyVersion } = require('./sonar-helpers') + +const schema = Joi.object({ + component: Joi.object({ + measures: Joi.array() + .items( + Joi.object({ + metric: Joi.string().required(), + value: Joi.alternatives( + Joi.number().min(0), + Joi.allow('OK', 'ERROR') + ).required(), + }).required() + ) + .required(), + }).required(), +}).required() + +const legacyApiSchema = Joi.array() + .items( + Joi.object({ + msr: Joi.array() + .items( + Joi.object({ + key: Joi.string().required(), + val: Joi.alternatives( + Joi.number().min(0), + Joi.allow('OK', 'ERROR') + ).required(), + }).required() + ) + .required(), + }).required() + ) + .required() + +module.exports = class SonarBase extends BaseJsonService { + transform({ json, sonarVersion }) { + const useLegacyApi = isLegacyVersion({ sonarVersion }) + const rawValue = useLegacyApi + ? json[0].msr[0].val + : json.component.measures[0].value + const value = parseInt(rawValue) + + // Most values are numeric, but not all of them. + return { metricValue: value || rawValue } + } + + async fetch({ sonarVersion, protocol, host, component, metricName }) { + let qs, url + const useLegacyApi = isLegacyVersion({ sonarVersion }) + + if (useLegacyApi) { + url = `${protocol}://${host}/api/resources` + qs = { + resource: component, + depth: 0, + metrics: metricName, + includeTrends: true, + } + } else { + url = `${protocol}://${host}/api/measures/component` + qs = { + componentKey: component, + metricKeys: metricName, + } + } + + const options = { qs } + + if (serverSecrets.sonarqube_token) { + options.auth = { + user: serverSecrets.sonarqube_token, + } + } + + return this._requestJson({ + schema: useLegacyApi ? legacyApiSchema : schema, + url, + options, + errorMessages: { + 404: 'component or metric not found, or legacy API not supported', + }, + }) + } +} diff --git a/services/sonar/sonar-coverage.service.js b/services/sonar/sonar-coverage.service.js new file mode 100644 index 0000000000000..1009679396e1e --- /dev/null +++ b/services/sonar/sonar-coverage.service.js @@ -0,0 +1,69 @@ +'use strict' + +const { coveragePercentage } = require('../color-formatters') +const SonarBase = require('./sonar-base') +const { + documentation, + keywords, + patternBase, + queryParamSchema, +} = require('./sonar-helpers') + +module.exports = class SonarCoverage extends SonarBase { + static get category() { + return 'coverage' + } + + static get defaultBadgeData() { + return { label: 'coverage' } + } + + static render({ coverage }) { + return { + message: `${coverage.toFixed(0)}%`, + color: coveragePercentage(coverage), + } + } + + static get route() { + return { + base: 'sonar', + pattern: `${patternBase}/coverage`, + queryParamSchema, + } + } + + static get examples() { + return [ + { + title: 'Sonar Coverage', + namedParams: { + protocol: 'http', + host: 'sonar.petalslink.com', + component: 'org.ow2.petals:petals-se-ase', + }, + queryParams: { + sonarVersion: '4.2', + }, + staticPreview: this.render({ coverage: 63 }), + keywords, + documentation, + }, + ] + } + + async handle({ protocol, host, component }, { sonarVersion }) { + const json = await this.fetch({ + sonarVersion, + protocol, + host, + component, + metricName: 'coverage', + }) + const { metricValue: coverage } = this.transform({ + json, + sonarVersion, + }) + return this.constructor.render({ coverage }) + } +} diff --git a/services/sonar/sonar-coverage.tester.js b/services/sonar/sonar-coverage.tester.js new file mode 100644 index 0000000000000..47482a7f3da29 --- /dev/null +++ b/services/sonar/sonar-coverage.tester.js @@ -0,0 +1,22 @@ +'use strict' + +const t = (module.exports = require('../tester').createServiceTester()) +const { isIntegerPercentage } = require('../test-validators') + +t.create('Coverage') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/coverage.json' + ) + .expectBadge({ + label: 'coverage', + message: isIntegerPercentage, + }) + +t.create('Coverage (legacy API supported)') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/coverage.json?sonarVersion=4.2' + ) + .expectBadge({ + label: 'coverage', + message: isIntegerPercentage, + }) diff --git a/services/sonar/sonar-documented-api-density.service.js b/services/sonar/sonar-documented-api-density.service.js new file mode 100644 index 0000000000000..f45b8865c7864 --- /dev/null +++ b/services/sonar/sonar-documented-api-density.service.js @@ -0,0 +1,69 @@ +'use strict' + +const SonarBase = require('./sonar-base') +const { + patternBase, + queryParamSchema, + getLabel, + positiveMetricColorScale, + keywords, + documentation, +} = require('./sonar-helpers') + +const metric = 'public_documented_api_density' + +module.exports = class SonarDocumentedApiDensity extends SonarBase { + static get category() { + return 'analysis' + } + + static get defaultBadgeData() { + return { label: getLabel({ metric }) } + } + + static render({ density }) { + return { + message: `${density}%`, + color: positiveMetricColorScale(density), + } + } + + static get route() { + return { + base: 'sonar', + pattern: `${patternBase}/${metric}`, + queryParamSchema, + } + } + + static get examples() { + return [ + { + title: 'Sonar Documented API Density', + namedParams: { + protocol: 'http', + host: 'sonar.petalslink.com', + component: 'org.ow2.petals:petals-se-ase', + }, + queryParams: { + sonarVersion: '4.2', + }, + staticPreview: this.render({ density: 82 }), + keywords, + documentation, + }, + ] + } + + async handle({ protocol, host, component }, { sonarVersion }) { + const json = await this.fetch({ + sonarVersion, + protocol, + host, + component, + metricName: metric, + }) + const { metricValue: density } = this.transform({ json, sonarVersion }) + return this.constructor.render({ density }) + } +} diff --git a/services/sonar/sonar-documented-api-density.spec.js b/services/sonar/sonar-documented-api-density.spec.js new file mode 100644 index 0000000000000..e27a25dc1377c --- /dev/null +++ b/services/sonar/sonar-documented-api-density.spec.js @@ -0,0 +1,29 @@ +'use strict' + +const { test, given } = require('sazerac') +const SonarDocumentedApiDensity = require('./sonar-documented-api-density.service') + +describe('SonarDocumentedApiDensity', function() { + test(SonarDocumentedApiDensity.render, () => { + given({ density: 0 }).expect({ + message: '0%', + color: 'red', + }) + given({ density: 10 }).expect({ + message: '10%', + color: 'orange', + }) + given({ density: 20 }).expect({ + message: '20%', + color: 'yellow', + }) + given({ density: 50 }).expect({ + message: '50%', + color: 'yellowgreen', + }) + given({ density: 100 }).expect({ + message: '100%', + color: 'brightgreen', + }) + }) +}) diff --git a/services/sonar/sonar-documented-api-density.tester.js b/services/sonar/sonar-documented-api-density.tester.js new file mode 100644 index 0000000000000..a55c17cab54c1 --- /dev/null +++ b/services/sonar/sonar-documented-api-density.tester.js @@ -0,0 +1,22 @@ +'use strict' + +const { isIntegerPercentage } = require('../test-validators') +const t = (module.exports = require('../tester').createServiceTester()) + +t.create('Documented API Density') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/public_documented_api_density.json' + ) + .expectBadge({ + label: 'public documented api density', + message: isIntegerPercentage, + }) + +t.create('Documented API Density (legacy API supported)') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/public_documented_api_density.json?sonarVersion=4.2' + ) + .expectBadge({ + label: 'public documented api density', + message: isIntegerPercentage, + }) diff --git a/services/sonar/sonar-fortify-rating.service.js b/services/sonar/sonar-fortify-rating.service.js new file mode 100644 index 0000000000000..f2353ceaefc18 --- /dev/null +++ b/services/sonar/sonar-fortify-rating.service.js @@ -0,0 +1,81 @@ +'use strict' + +const SonarBase = require('./sonar-base') +const { + patternBase, + queryParamSchema, + keywords, + documentation, +} = require('./sonar-helpers') + +const colorMap = { + 0: 'red', + 1: 'orange', + 2: 'yellow', + 3: 'yellowgreen', + 4: 'green', + 5: 'brightgreen', +} + +module.exports = class SonarFortifyRating extends SonarBase { + static get category() { + return 'analysis' + } + + static get defaultBadgeData() { + return { label: 'fortify-security-rating' } + } + + static render({ rating }) { + return { + message: `${rating}/5`, + color: colorMap[rating], + } + } + + static get route() { + return { + base: 'sonar', + pattern: `${patternBase}/fortify-security-rating`, + queryParamSchema, + } + } + + static get examples() { + return [ + { + title: 'Sonar Fortify Security Rating', + namedParams: { + protocol: 'http', + host: 'sonar.petalslink.com', + component: 'org.ow2.petals:petals-se-ase', + }, + queryParams: { + sonarVersion: '4.2', + }, + staticPreview: this.render({ rating: 4 }), + keywords, + documentation: ` +

+ Note that the Fortify Security Rating badge will only work on Sonar instances that have the Fortify SonarQube Plugin installed. + The badge is not available for projects analyzed on SonarCloud.io +

+ ${documentation} + `, + }, + ] + } + + async handle({ protocol, host, component }, { sonarVersion }) { + const json = await this.fetch({ + sonarVersion, + protocol, + host, + component, + metricName: 'fortify-security-rating', + }) + + const { metricValue: rating } = this.transform({ json, sonarVersion }) + return this.constructor.render({ rating }) + } +} diff --git a/services/sonar/sonar-fortify-rating.tester.js b/services/sonar/sonar-fortify-rating.tester.js new file mode 100644 index 0000000000000..dad09132c80c1 --- /dev/null +++ b/services/sonar/sonar-fortify-rating.tester.js @@ -0,0 +1,90 @@ +'use strict' + +const sinon = require('sinon') +const t = (module.exports = require('../tester').createServiceTester()) +const serverSecrets = require('../../lib/server-secrets') +const sonarToken = 'abc123def456' + +// The below tests are using a mocked API response because +// neither SonarCloud.io nor any known public SonarQube deployments +// have the Fortify plugin installed and in use, so there are no +// available live endpoints to hit. +t.create('Fortify Security Rating') + .before(() => { + serverSecrets['sonarqube_token'] = undefined + sinon.stub(serverSecrets, 'sonarqube_token').value(sonarToken) + }) + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/fortify-security-rating.json' + ) + .intercept(nock => + nock('http://sonar.petalslink.com/api/measures') + .get('/component') + .query({ + componentKey: 'org.ow2.petals:petals-se-ase', + metricKeys: 'fortify-security-rating', + }) + .basicAuth({ user: sonarToken }) + .reply(200, { + component: { + measures: [ + { + metric: 'fortify-security-rating', + value: 4, + }, + ], + }, + }) + ) + .finally(sinon.restore) + .expectBadge({ + label: 'fortify-security-rating', + message: '4/5', + }) + +t.create('Fortify Security Rating (legacy API supported)') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/fortify-security-rating.json?sonarVersion=4.2' + ) + .intercept(nock => + nock('http://sonar.petalslink.com/api') + .get('/resources') + .query({ + resource: 'org.ow2.petals:petals-se-ase', + depth: 0, + metrics: 'fortify-security-rating', + includeTrends: true, + }) + .reply(200, [ + { + msr: [ + { + key: 'fortify-security-rating', + val: 3, + }, + ], + }, + ]) + ) + .expectBadge({ + label: 'fortify-security-rating', + message: '3/5', + }) + +t.create('Fortify Security Rating (legacy API not supported)') + .get( + '/https/sonarcloud.io/swellaby:azdo-shellcheck/fortify-security-rating.json?sonarVersion=4.2' + ) + .expectBadge({ + label: 'fortify-security-rating', + message: 'component or metric not found, or legacy API not supported', + }) + +t.create('Fortify Security Rating (nonexistent component)') + .get( + '/https/sonarcloud.io/not-a-real-component-fakeness/fortify-security-rating.json' + ) + .expectBadge({ + label: 'fortify-security-rating', + message: 'component or metric not found, or legacy API not supported', + }) diff --git a/services/sonar/sonar-generic.service.js b/services/sonar/sonar-generic.service.js new file mode 100644 index 0000000000000..956ef3b2ac365 --- /dev/null +++ b/services/sonar/sonar-generic.service.js @@ -0,0 +1,152 @@ +'use strict' + +const { metric } = require('../text-formatters') +const SonarBase = require('./sonar-base') +const { patternBase, queryParamSchema, getLabel } = require('./sonar-helpers') + +// This service is intended to be a temporary solution to avoid breaking +// any existing users/badges that were utilizing the "other" Sonar metrics +// with the Legacy Shields service implementation for Sonar badges. +// The legacy implementation simply rendered a brightgreen badge with the value, +// regardless of what the value actually was. +// +// See https://github.com/badges/shields/issues/3236 for more information. +// +// This service should gradually be replaced by services that handle +// their respective metrics by providing badges with more context +// (i.e. a red/error badge when there are multiple security issues) +// +// https://docs.sonarqube.org/latest/user-guide/metric-definitions +const complexityMetricNames = ['complexity', 'cognitive_complexity'] +const duplicationMetricNames = [ + 'duplicated_blocks', + 'duplicated_files', + 'duplicated_lines', + 'duplicated_lines_density', +] +// Sonar seemingly has used the terms 'issues' and 'violations' interchangeably +// so it's possible users are using both/either for badges +const issuesMetricNames = [ + 'new_violations', + 'new_blocker_violations', + 'new_critical_violations', + 'new_major_violations', + 'new_minor_violations', + 'new_info_violations', + 'blocker_issues', + 'critical_issues', + 'major_issues', + 'minor_issues', + 'info_issues', + 'false_positive_issues', + 'open_issues', + 'confirmed_issues', + 'reopened_issues', +] +const maintainabilityMetricNames = [ + 'code_smells', + 'new_code_smells', + 'sqale_rating', + 'sqale_index', + 'sqale_index', + 'new_technical_debt', + 'new_sqale_debt_ratio', +] +const reliabilityMetricNames = [ + 'bugs', + 'new_bugs', + 'reliability_rating', + 'reliability_remediation_effort', + 'new_reliability_remediation_effort', +] +const securityMetricNames = [ + 'vulnerabilities', + 'new_vulnerabilities', + 'security_rating', + 'security_remediation_effort', + 'new_security_remediation_effort', +] +const sizeMetricNames = [ + 'classes', + 'comment_lines', + 'comment_lines_density', + 'directories', + 'files', + 'lines', + 'nloc', + 'projects', + 'statements', +] +const testsMetricNames = [ + 'branch_coverage', + 'new_branch_coverage', + 'branch_coverage_hits_data', + 'conditions_by_line', + 'covered_conditions_by_line', + 'new_coverage', + 'line_coverage', + 'new_line_coverage', + 'coverage_line_hits_data', + 'lines_to_cover', + 'new_lines_to_cover', + 'skipped_tests', + 'uncovered_conditions', + 'new_uncovered_conditions', + 'uncovered_lines', + 'new_uncovered_lines', + 'tests', + 'test_execution_time', + 'test_errors', + 'test_failures', + 'test_success_density', +] +const metricNames = [ + ...complexityMetricNames, + ...duplicationMetricNames, + ...issuesMetricNames, + ...maintainabilityMetricNames, + ...reliabilityMetricNames, + ...securityMetricNames, + ...sizeMetricNames, + ...testsMetricNames, +] +const metricNameRouteParam = metricNames.join('|') + +module.exports = class SonarGeneric extends SonarBase { + static get category() { + return 'analysis' + } + + static get defaultBadgeData() { + return { label: 'sonar' } + } + + static render({ metricName, metricValue }) { + return { + label: getLabel({ metric: metricName }), + message: metric(metricValue), + color: 'informational', + } + } + + static get route() { + return { + base: 'sonar', + pattern: `${patternBase}/:metricName(${metricNameRouteParam})`, + queryParamSchema, + } + } + + async handle({ protocol, host, component, metricName }, { sonarVersion }) { + const json = await this.fetch({ + sonarVersion, + protocol, + host, + component, + metricName, + }) + + const { metricValue } = this.transform({ json, sonarVersion }) + return this.constructor.render({ metricName, metricValue }) + } +} diff --git a/services/sonar/sonar-generic.tester.js b/services/sonar/sonar-generic.tester.js new file mode 100644 index 0000000000000..79a5d6a8766e4 --- /dev/null +++ b/services/sonar/sonar-generic.tester.js @@ -0,0 +1,12 @@ +'use strict' + +const { isMetric } = require('../test-validators') +const t = (module.exports = require('../tester').createServiceTester()) + +t.create('Security Rating') + .get('/https/sonarcloud.io/com.luckybox:luckybox/security_rating.json') + .expectBadge({ + label: 'security rating', + message: isMetric, + color: 'blue', + }) diff --git a/services/sonar/sonar-helpers.js b/services/sonar/sonar-helpers.js new file mode 100644 index 0000000000000..aa1093a12447e --- /dev/null +++ b/services/sonar/sonar-helpers.js @@ -0,0 +1,73 @@ +'use strict' + +const Joi = require('joi') +const { colorScale } = require('../color-formatters') + +const patternBase = ':protocol(http|https)/:host(.+)/:component(.+)' +const ratingPercentageScaleSteps = [10, 20, 50, 100] +const ratingScaleColors = [ + 'brightgreen', + 'yellowgreen', + 'yellow', + 'orange', + 'red', +] +const negativeMetricColorScale = colorScale( + ratingPercentageScaleSteps, + ratingScaleColors +) +const positiveMetricColorScale = colorScale( + ratingPercentageScaleSteps, + ratingScaleColors, + true +) + +function isLegacyVersion({ sonarVersion }) { + sonarVersion = parseFloat(sonarVersion) + return !!sonarVersion && sonarVersion < 5.4 +} + +function getLabel({ metric }) { + return metric ? metric.replace(/_/g, ' ') : undefined +} +const sonarVersionSchema = Joi.alternatives( + Joi.string() + .regex(/[0-9.]+/) + .optional(), + Joi.number().optional() +) + +const queryParamSchema = Joi.object({ + sonarVersion: sonarVersionSchema, +}).required() + +const queryParamWithFormatSchema = Joi.object({ + sonarVersion: sonarVersionSchema, + format: Joi.string() + .allow('short', 'long') + .optional(), +}).required() + +const keywords = ['sonarcloud', 'sonarqube'] +const documentation = ` +

+ The Sonar badges will work with both SonarCloud.io and self-hosted SonarQube instances. + Just enter the correct protocol and path for your target Sonar deployment. +

+

+ If you are targeting a legacy SonarQube instance that is version 5.3 or earlier, then be sure + to include the version query parameter with the value of your SonarQube version. +

{ + given({ qualityState: 'OK' }).expect({ + message: 'passed', + color: 'success', + }) + given({ qualityState: 'ERROR' }).expect({ + message: 'failed', + color: 'critical', + }) + }) +}) diff --git a/services/sonar/sonar-quality-gate.tester.js b/services/sonar/sonar-quality-gate.tester.js new file mode 100644 index 0000000000000..eeed30dd3bb8f --- /dev/null +++ b/services/sonar/sonar-quality-gate.tester.js @@ -0,0 +1,22 @@ +'use strict' + +const Joi = require('joi') +const t = (module.exports = require('../tester').createServiceTester()) + +const isQualityGateStatus = Joi.allow('passed', 'failed') + +t.create('Quality Gate') + .get('/https/sonarcloud.io/swellaby%3Aazdo-shellcheck/quality_gate.json') + .expectBadge({ + label: 'quality gate', + message: isQualityGateStatus, + }) + +t.create('Quality Gate (Alert Status)') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/alert_status.json?sonarVersion=4.2' + ) + .expectBadge({ + label: 'quality gate', + message: isQualityGateStatus, + }) diff --git a/services/sonar/sonar-redirector.service.js b/services/sonar/sonar-redirector.service.js new file mode 100644 index 0000000000000..89e510383694b --- /dev/null +++ b/services/sonar/sonar-redirector.service.js @@ -0,0 +1,18 @@ +'use strict' + +const { redirector } = require('..') + +module.exports = [ + redirector({ + category: 'analysis', + route: { + base: 'sonar', + pattern: + ':sonarVersion/:protocol(http|https)/:host(.+)/:component(.+)/:metric', + }, + transformPath: ({ protocol, host, component, metric }) => + `/sonar/${protocol}/${host}/${component}/${metric}`, + transformQueryParams: ({ sonarVersion }) => ({ sonarVersion }), + dateAdded: new Date('2019-04-02'), + }), +] diff --git a/services/sonar/sonar-redirector.tester.js b/services/sonar/sonar-redirector.tester.js new file mode 100644 index 0000000000000..27b0f45f93f90 --- /dev/null +++ b/services/sonar/sonar-redirector.tester.js @@ -0,0 +1,22 @@ +'use strict' + +const { ServiceTester } = require('../tester') + +const t = (module.exports = new ServiceTester({ + id: 'SonarRedirect', + title: 'SonarRedirect', + pathPrefix: '/sonar', +})) + +t.create('sonar version') + .get( + '/4.2/http/sonar.petalslink.com/org.ow2.petals:petals-se-ase/alert_status.svg', + { + followRedirect: false, + } + ) + .expectStatus(301) + .expectHeader( + 'Location', + '/sonar/http/sonar.petalslink.com/org.ow2.petals:petals-se-ase/alert_status.svg?sonarVersion=4.2' + ) diff --git a/services/sonar/sonar-tech-debt.service.js b/services/sonar/sonar-tech-debt.service.js new file mode 100644 index 0000000000000..16307a679e09a --- /dev/null +++ b/services/sonar/sonar-tech-debt.service.js @@ -0,0 +1,73 @@ +'use strict' + +const SonarBase = require('./sonar-base') +const { + negativeMetricColorScale, + getLabel, + documentation, + keywords, + patternBase, + queryParamSchema, +} = require('./sonar-helpers') + +module.exports = class SonarTechDebt extends SonarBase { + static get category() { + return 'analysis' + } + + static get defaultBadgeData() { + return { label: 'tech debt' } + } + + static render({ debt, metric }) { + return { + label: getLabel({ metric }), + message: `${debt}%`, + color: negativeMetricColorScale(debt), + } + } + + static get route() { + return { + base: 'sonar', + pattern: `${patternBase}/:metric(tech_debt|sqale_debt_ratio)`, + queryParamSchema, + } + } + + static get examples() { + return [ + { + title: 'Sonar Tech Debt', + namedParams: { + protocol: 'http', + host: 'sonar.petalslink.com', + component: 'org.ow2.petals:petals-se-ase', + metric: 'tech_debt', + }, + queryParams: { + sonarVersion: '4.2', + }, + staticPreview: this.render({ + debt: 1, + metric: 'tech_debt', + }), + keywords, + documentation, + }, + ] + } + + async handle({ protocol, host, component, metric }, { sonarVersion }) { + const json = await this.fetch({ + sonarVersion, + protocol, + host, + component, + //special condition for backwards compatibility + metricName: 'sqale_debt_ratio', + }) + const { metricValue: debt } = this.transform({ json, sonarVersion }) + return this.constructor.render({ debt, metric }) + } +} diff --git a/services/sonar/sonar-tech-debt.spec.js b/services/sonar/sonar-tech-debt.spec.js new file mode 100644 index 0000000000000..534df84a6162b --- /dev/null +++ b/services/sonar/sonar-tech-debt.spec.js @@ -0,0 +1,34 @@ +'use strict' + +const { test, given } = require('sazerac') +const SonarTechDebt = require('./sonar-tech-debt.service') + +describe('SonarTechDebt', function() { + test(SonarTechDebt.render, () => { + given({ debt: 0 }).expect({ + label: undefined, + message: '0%', + color: 'brightgreen', + }) + given({ debt: 10 }).expect({ + label: undefined, + message: '10%', + color: 'yellowgreen', + }) + given({ debt: 20 }).expect({ + label: undefined, + message: '20%', + color: 'yellow', + }) + given({ debt: 50, metric: 'tech_debt' }).expect({ + label: 'tech debt', + message: '50%', + color: 'orange', + }) + given({ debt: 100, metric: 'sqale_debt_ratio' }).expect({ + label: 'sqale debt ratio', + message: '100%', + color: 'red', + }) + }) +}) diff --git a/services/sonar/sonar-tech-debt.tester.js b/services/sonar/sonar-tech-debt.tester.js new file mode 100644 index 0000000000000..1020e7ac1d884 --- /dev/null +++ b/services/sonar/sonar-tech-debt.tester.js @@ -0,0 +1,22 @@ +'use strict' + +const { isIntegerPercentage } = require('../test-validators') +const t = (module.exports = require('../tester').createServiceTester()) + +t.create('Tech Debt') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/tech_debt.json' + ) + .expectBadge({ + label: 'tech debt', + message: isIntegerPercentage, + }) + +t.create('Tech Debt (legacy API supported)') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/tech_debt.json?sonarVersion=4.2' + ) + .expectBadge({ + label: 'tech debt', + message: isIntegerPercentage, + }) diff --git a/services/sonar/sonar-violations.service.js b/services/sonar/sonar-violations.service.js new file mode 100644 index 0000000000000..6d23c56c9e242 --- /dev/null +++ b/services/sonar/sonar-violations.service.js @@ -0,0 +1,201 @@ +'use strict' + +const { colorScale } = require('../color-formatters') +const { metric } = require('../text-formatters') +const SonarBase = require('./sonar-base') +const { + getLabel, + documentation, + isLegacyVersion, + keywords, + patternBase, + queryParamWithFormatSchema, +} = require('./sonar-helpers') + +const violationsColorScale = colorScale( + [1, 2, 3, 5], + ['brightgreen', 'yellowgreen', 'yellow', 'orange', 'red'] +) + +const violationCategoryColorMap = { + blocker_violations: 'red', + critical_violations: 'orange', + major_violations: 'yellow', + minor_violations: 'yellowgreen', + info_violations: 'green', +} + +module.exports = class SonarViolations extends SonarBase { + static get category() { + return 'analysis' + } + + static get defaultBadgeData() { + return { label: 'violations' } + } + + static renderLongViolationsBadge(violations) { + if (violations.violations === 0) { + return { + message: 0, + color: 'brightgreen', + } + } + + let color + const violationSummary = [] + + if (violations.info_violations > 0) { + violationSummary.push(`${violations.info_violations} info`) + color = violationCategoryColorMap.info_violations + } + if (violations.minor_violations > 0) { + violationSummary.unshift(`${violations.minor_violations} minor`) + color = violationCategoryColorMap.minor_violations + } + if (violations.major_violations > 0) { + violationSummary.unshift(`${violations.major_violations} major`) + color = violationCategoryColorMap.major_violations + } + if (violations.critical_violations > 0) { + violationSummary.unshift(`${violations.critical_violations} critical`) + color = violationCategoryColorMap.critical_violations + } + if (violations.blocker_violations > 0) { + violationSummary.unshift(`${violations.blocker_violations} blocker`) + color = violationCategoryColorMap.blocker_violations + } + + return { + message: violationSummary.join(', '), + color, + } + } + + static render({ violations, metricName, format }) { + if (metricName === 'violations') { + if (format === 'long') { + return this.renderLongViolationsBadge(violations) + } + return { + message: metric(violations), + color: violationsColorScale(violations), + } + } + + const color = + violations === 0 ? 'brightgreen' : violationCategoryColorMap[metricName] + + return { + label: getLabel({ metric: metricName }), + message: `${metric(violations)}`, + color, + } + } + + static get route() { + return { + base: 'sonar', + pattern: `${patternBase}/:metric(violations|blocker_violations|critical_violations|major_violations|minor_violations|info_violations)`, + queryParamSchema: queryParamWithFormatSchema, + } + } + + static get examples() { + return [ + { + title: 'Sonar Violations (short format)', + namedParams: { + protocol: 'https', + host: 'sonarcloud.io', + component: 'swellaby:azdo-shellcheck', + metric: 'violations', + }, + queryParams: { + format: 'short', + sonarVersion: '4.2', + }, + staticPreview: this.render({ + violations: 0, + metricName: 'violations', + format: 'short', + }), + keywords, + documentation, + }, + { + title: 'Sonar Violations (long format)', + namedParams: { + protocol: 'http', + host: 'sonar.petalslink.com', + component: 'org.ow2.petals:petals-se-ase', + metric: 'violations', + }, + queryParams: { + format: 'long', + }, + staticPreview: this.render({ + violations: { + info_violations: 2, + minor_violations: 1, + }, + metricName: 'violations', + format: 'long', + }), + keywords, + documentation, + }, + ] + } + + transformViolations({ json, sonarVersion, metric, format }) { + // We can use the standard transform function in all cases + // except when the requested badge is the long format of violations + if (metric !== 'violations' || format !== 'long') { + const { metricValue: violations } = this.transform({ json, sonarVersion }) + return { violations } + } + + const useLegacyApi = isLegacyVersion({ sonarVersion }) + const measures = useLegacyApi ? json[0].msr : json.component.measures + const violations = {} + + measures.forEach(measure => { + if (useLegacyApi) { + violations[measure.key] = measure.val + } else { + violations[measure.metric] = measure.value + } + }) + + return { violations } + } + + async handle( + { protocol, host, component, metric }, + { sonarVersion, format } + ) { + // If the user has requested the long format for the violations badge + // then we need to include each individual violation metric in the call to the API + // in order to get the count breakdown per each violation category. + const metricKeys = + metric === 'violations' && format === 'long' + ? 'violations,blocker_violations,critical_violations,major_violations,minor_violations,info_violations' + : metric + const json = await this.fetch({ + sonarVersion, + protocol, + host, + component, + metricName: metricKeys, + }) + + const { violations } = this.transformViolations({ + json, + sonarVersion, + metric, + format, + }) + return this.constructor.render({ violations, metricName: metric, format }) + } +} diff --git a/services/sonar/sonar-violations.spec.js b/services/sonar/sonar-violations.spec.js new file mode 100644 index 0000000000000..6983c6da97e2f --- /dev/null +++ b/services/sonar/sonar-violations.spec.js @@ -0,0 +1,115 @@ +'use strict' + +const { test, given } = require('sazerac') +const { metric } = require('../text-formatters') +const SonarViolations = require('./sonar-violations.service') + +describe('SonarViolations', function() { + test(SonarViolations.render, () => { + given({ metricName: 'violations', violations: 1003 }).expect({ + message: metric(1003), + color: 'red', + }) + given({ metricName: 'violations', violations: 0, format: 'short' }).expect({ + message: '0', + color: 'brightgreen', + }) + given({ metricName: 'violations', violations: 1 }).expect({ + message: '1', + color: 'yellowgreen', + }) + given({ metricName: 'violations', violations: 2 }).expect({ + message: '2', + color: 'yellow', + }) + given({ metricName: 'violations', violations: 3 }).expect({ + message: '3', + color: 'orange', + }) + given({ metricName: 'violations', violations: 4 }).expect({ + message: '4', + color: 'orange', + }) + given({ metricName: 'violations', violations: 5 }).expect({ + message: '5', + color: 'red', + }) + given({ metricName: 'blocker_violations', violations: 0 }).expect({ + label: 'blocker violations', + message: '0', + color: 'brightgreen', + }) + given({ metricName: 'blocker_violations', violations: 1 }).expect({ + label: 'blocker violations', + message: '1', + color: 'red', + }) + given({ metricName: 'critical_violations', violations: 0 }).expect({ + label: 'critical violations', + message: '0', + color: 'brightgreen', + }) + given({ metricName: 'critical_violations', violations: 2 }).expect({ + label: 'critical violations', + message: '2', + color: 'orange', + }) + given({ metricName: 'major_violations', violations: 0 }).expect({ + label: 'major violations', + message: '0', + color: 'brightgreen', + }) + given({ metricName: 'major_violations', violations: 3 }).expect({ + label: 'major violations', + message: '3', + color: 'yellow', + }) + given({ metricName: 'minor_violations', violations: 0 }).expect({ + label: 'minor violations', + message: '0', + color: 'brightgreen', + }) + given({ metricName: 'minor_violations', violations: 1 }).expect({ + label: 'minor violations', + message: '1', + color: 'yellowgreen', + }) + given({ metricName: 'info_violations', violations: 0 }).expect({ + label: 'info violations', + message: '0', + color: 'brightgreen', + }) + given({ metricName: 'info_violations', violations: 4 }).expect({ + label: 'info violations', + message: '4', + color: 'green', + }) + }) + + test(SonarViolations.renderLongViolationsBadge, () => { + given({ violations: 0 }).expect({ + message: 0, + color: 'brightgreen', + }) + given({ violations: 3, info_violations: 3 }).expect({ + message: '3 info', + color: 'green', + }) + given({ violations: 2, info_violations: 1, minor_violations: 1 }).expect({ + message: '1 minor, 1 info', + color: 'yellowgreen', + }) + given({ violations: 1, major_violations: 1 }).expect({ + message: '1 major', + color: 'yellow', + }) + given({ violations: 2, critical_violations: 2 }).expect({ + message: '2 critical', + color: 'orange', + }) + given({ violations: 6, info_violations: 5, blocker_violations: 1 }).expect({ + message: '1 blocker, 5 info', + color: 'red', + }) + }) +}) diff --git a/services/sonar/sonar-violations.tester.js b/services/sonar/sonar-violations.tester.js new file mode 100644 index 0000000000000..44929da52dba5 --- /dev/null +++ b/services/sonar/sonar-violations.tester.js @@ -0,0 +1,83 @@ +'use strict' + +const Joi = require('joi') +const { isMetric, withRegex } = require('../test-validators') +const t = (module.exports = require('../tester').createServiceTester()) +const isViolationsLongFormMetric = Joi.alternatives( + Joi.allow(0), + withRegex( + /(([\d]+) (blocker|critical|major|minor|info))(,\s([\d]+) (critical|major|minor|info))?/ + ) +) + +t.create('Violations') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/violations.json' + ) + .expectBadge({ + label: 'violations', + message: isMetric, + }) + +t.create('Violations (legacy API supported)') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/violations.json?sonarVersion=4.2' + ) + .expectBadge({ + label: 'violations', + message: isMetric, + }) + +t.create('Violations Long Format') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/violations.json?format=long' + ) + .expectBadge({ + label: 'violations', + message: isViolationsLongFormMetric, + }) + +t.create('Violations Long Format (legacy API supported)') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/violations.json?sonarVersion=4.2&format=long' + ) + .expectBadge({ + label: 'violations', + message: isViolationsLongFormMetric, + }) + +t.create('Blocker Violations') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/blocker_violations.json' + ) + .expectBadge({ + label: 'blocker violations', + message: isMetric, + }) + +t.create('Blocker Violations (legacy API supported)') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/blocker_violations.json?sonarVersion=4.2' + ) + .expectBadge({ + label: 'blocker violations', + message: isMetric, + }) + +t.create('Critical Violations') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/critical_violations.json' + ) + .expectBadge({ + label: 'critical violations', + message: isMetric, + }) + +t.create('Critical Violations (legacy API supported)') + .get( + '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/critical_violations.json?sonarVersion=4.2' + ) + .expectBadge({ + label: 'critical violations', + message: isMetric, + }) diff --git a/services/sonarqube/sonarqube.service.js b/services/sonarqube/sonarqube.service.js deleted file mode 100644 index 784f81591810a..0000000000000 --- a/services/sonarqube/sonarqube.service.js +++ /dev/null @@ -1,263 +0,0 @@ -'use strict' - -const LegacyService = require('../legacy-service') -const { makeBadgeData: getBadgeData } = require('../../lib/badge-data') -const serverSecrets = require('../../lib/server-secrets') -const { metric } = require('../text-formatters') -const { - coveragePercentage: coveragePercentageColor, -} = require('../color-formatters') - -class SonarqubeCoverage extends LegacyService { - static get category() { - return 'coverage' - } - - static get route() { - return { - base: 'sonar', - pattern: '', - } - } - - static get examples() { - return [ - { - title: 'SonarQube coverage', - pattern: ':scheme(http|https)/:host/:buildType/:metricName', - namedParams: { - scheme: 'http', - host: 'sonar.petalslink.com', - buildType: 'org.ow2.petals:petals-se-ase', - metricName: 'coverage', - }, - staticPreview: { - label: 'coverage', - message: '63%', - color: 'yellow', - }, - }, - { - title: 'SonarQube coverage (legacy API)', - pattern: ':apiVersion/:scheme(http|https)/:host/:buildType/:metricName', - namedParams: { - apiVersion: '4.2', - scheme: 'http', - host: 'sonar.petalslink.com', - buildType: 'org.ow2.petals:petals-se-ase', - metricName: 'coverage', - }, - staticPreview: { - label: 'coverage', - message: '63%', - color: 'yellow', - }, - }, - ] - } - - static registerLegacyRouteHandler() {} -} - -// 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. -class Sonarqube extends LegacyService { - static get category() { - return 'analysis' - } - - static get route() { - return { - base: 'sonar', - pattern: '', - } - } - - static get examples() { - return [ - { - title: 'SonarQube tech debt', - pattern: ':scheme(http|https)/:host/:buildType/:metricName', - namedParams: { - scheme: 'http', - host: 'sonar.petalslink.com', - buildType: 'org.ow2.petals:petals-se-ase', - metricName: 'tech_debt', - }, - staticPreview: { - label: 'tech debt', - message: '2%', - color: 'brightgreen', - }, - }, - { - title: 'SonarQube tech debt (legacy API)', - pattern: ':apiVersion/:scheme(http|https)/:host/:buildType/:metricName', - namedParams: { - apiVersion: '4.2', - scheme: 'http', - host: 'sonar.petalslink.com', - buildType: 'org.ow2.petals:petals-se-ase', - metricName: 'tech_debt', - }, - staticPreview: { - label: 'tech debt', - message: '2%', - color: 'brightgreen', - }, - }, - ] - } - - static registerLegacyRouteHandler({ camp, cache }) { - camp.route( - /^\/sonar\/?([0-9.]+)?\/(http|https)\/(.*)\/(.*)\/(.*)\.(svg|png|gif|jpg|json)$/, - cache((data, match, sendBadge, request) => { - const version = parseFloat(match[1]) - const scheme = match[2] - const serverUrl = match[3] - const buildType = match[4] - const metricName = match[5] - const format = match[6] - - let sonarMetricName = metricName - if (metricName === 'tech_debt') { - //special condition for backwards compatibility - sonarMetricName = 'sqale_debt_ratio' - } - - const useLegacyApi = !!version && version < 5.4 - - const uri = useLegacyApi - ? `${scheme}://${serverUrl}/api/resources?resource=${buildType}&depth=0&metrics=${encodeURIComponent( - sonarMetricName - )}&includetrends=true` - : `${scheme}://${serverUrl}/api/measures/component?componentKey=${buildType}&metricKeys=${encodeURIComponent( - sonarMetricName - )}` - - const options = { - uri, - headers: { - Accept: 'application/json', - }, - } - if (serverSecrets.sonarqube_token) { - options.auth = { - user: serverSecrets.sonarqube_token, - } - } - - const badgeData = getBadgeData(metricName.replace(/_/g, ' '), data) - - request(options, (err, res, buffer) => { - if (err != null) { - badgeData.text[1] = 'inaccessible' - sendBadge(format, badgeData) - return - } - try { - const data = JSON.parse(buffer) - - const value = parseInt( - useLegacyApi - ? data[0].msr[0].val - : data.component.measures[0].value - ) - - if (value === undefined) { - badgeData.text[1] = 'unknown' - sendBadge(format, badgeData) - return - } - - if (metricName.indexOf('coverage') !== -1) { - badgeData.text[1] = `${value.toFixed(0)}%` - badgeData.colorscheme = coveragePercentageColor(value) - } else if (/^\w+_violations$/.test(metricName)) { - badgeData.text[1] = value - badgeData.colorscheme = 'brightgreen' - if (value > 0) { - if (metricName === 'blocker_violations') { - badgeData.colorscheme = 'red' - } else if (metricName === 'critical_violations') { - badgeData.colorscheme = 'orange' - } else if (metricName === 'major_violations') { - badgeData.colorscheme = 'yellow' - } else if (metricName === 'minor_violations') { - badgeData.colorscheme = 'yellowgreen' - } else if (metricName === 'info_violations') { - badgeData.colorscheme = 'green' - } - } - } else if (metricName === 'fortify-security-rating') { - badgeData.text[1] = `${value}/5` - - if (value === 0) { - badgeData.colorscheme = 'red' - } else if (value === 1) { - badgeData.colorscheme = 'orange' - } else if (value === 2) { - badgeData.colorscheme = 'yellow' - } else if (value === 3) { - badgeData.colorscheme = 'yellowgreen' - } else if (value === 4) { - badgeData.colorscheme = 'green' - } else if (value === 5) { - badgeData.colorscheme = 'brightgreen' - } else { - badgeData.colorscheme = 'lightgrey' - } - } else if ( - metricName === 'sqale_debt_ratio' || - metricName === 'tech_debt' || - metricName === 'public_documented_api_density' - ) { - // colors are based on sonarqube default rating grid and display colors - // [0,0.1) ==> A (green) - // [0.1,0.2) ==> B (yellowgreen) - // [0.2,0.5) ==> C (yellow) - // [0.5,1) ==> D (orange) - // [1,) ==> E (red) - let colorValue = value - if (metricName === 'public_documented_api_density') { - //Some metrics higher % is better - colorValue = 100 - value - } - badgeData.text[1] = `${value}%` - if (colorValue >= 100) { - badgeData.colorscheme = 'red' - } else if (colorValue >= 50) { - badgeData.colorscheme = 'orange' - } else if (colorValue >= 20) { - badgeData.colorscheme = 'yellow' - } else if (colorValue >= 10) { - badgeData.colorscheme = 'yellowgreen' - } else if (colorValue >= 0) { - badgeData.colorscheme = 'brightgreen' - } else { - badgeData.colorscheme = 'lightgrey' - } - } else { - badgeData.text[1] = metric(value) - badgeData.colorscheme = 'brightgreen' - } - sendBadge(format, badgeData) - } catch (e) { - badgeData.text[1] = 'invalid' - sendBadge(format, badgeData) - } - }) - }) - ) - } -} - -module.exports = { - SonarqubeCoverage, - Sonarqube, -} diff --git a/services/sonarqube/sonarqube.tester.js b/services/sonarqube/sonarqube.tester.js deleted file mode 100644 index 884b5014629f7..0000000000000 --- a/services/sonarqube/sonarqube.tester.js +++ /dev/null @@ -1,56 +0,0 @@ -'use strict' - -const { ServiceTester } = require('../tester') -const { isIntegerPercentage } = require('../test-validators') - -const t = new ServiceTester({ id: 'sonar', title: 'SonarQube' }) -module.exports = t - -t.create('Tech Debt') - .get( - '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/tech_debt.json' - ) - .expectBadge({ - label: 'tech debt', - message: isIntegerPercentage, - }) - -t.create('Coverage') - .get( - '/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/coverage.json' - ) - .expectBadge({ - label: 'coverage', - message: isIntegerPercentage, - }) - -t.create('Tech Debt (legacy API supported)') - .get( - '/4.2/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/tech_debt.json' - ) - .expectBadge({ - label: 'tech debt', - message: isIntegerPercentage, - }) - -t.create('Coverage (legacy API supported)') - .get( - '/4.2/http/sonar.petalslink.com/org.ow2.petals%3Apetals-se-ase/coverage.json' - ) - .expectBadge({ - label: 'coverage', - message: isIntegerPercentage, - }) - -t.create('Tech Debt (legacy API unsupported)') - .timeout(15000) - .get( - '/4.2/https/sonarqube.com/com.github.dannil:scb-java-client/tech_debt.json' - ) - .expectBadge({ label: 'tech debt', message: 'invalid' }) - -t.create('Coverage (legacy API unsupported)') - .get( - '/4.2/https/sonarqube.com/com.github.dannil:scb-java-client/coverage.json' - ) - .expectBadge({ label: 'coverage', message: 'invalid' })