diff --git a/services/azure-devops/azure-devops-coverage.service.js b/services/azure-devops/azure-devops-coverage.service.js new file mode 100644 index 0000000000..b3007da7c7 --- /dev/null +++ b/services/azure-devops/azure-devops-coverage.service.js @@ -0,0 +1,182 @@ +'use strict' + +const Joi = require('joi') +const BaseJsonService = require('../base-json') +const { NotFound } = require('../errors') +const { getHeaders } = require('./azure-devops-helpers') + +const documentation = ` +

+ To obtain your own badge, you need to get 3 pieces of information: + ORGANIZATION, PROJECT and DEFINITION_ID. +

+

+ First, you need to select your build definition and look at the url: +

+ORGANIZATION is after the dev.azure.com part, PROJECT is right after that, DEFINITION_ID is at the end after the id= part. +

+ Your badge will then have the form: + https://img.shields.io/azure-devops/coverage/ORGANIZATION/PROJECT/DEFINITION_ID.svg. +

+

+ Optionally, you can specify a named branch: + https://img.shields.io/azure-devops/coverage/ORGANIZATION/PROJECT/DEFINITION_ID/NAMED_BRANCH.svg. +

+` +const { + coveragePercentage: coveragePercentageColor, +} = require('../../lib/color-formatters') + +const latestBuildSchema = Joi.object({ + count: Joi.number().required(), + value: Joi.array() + .items( + Joi.object({ + id: Joi.number().required(), + }) + ) + .required(), +}).required() + +const buildCodeCoverageSchema = Joi.object({ + coverageData: Joi.array() + .items( + Joi.object({ + coverageStats: Joi.array() + .items( + Joi.object({ + label: Joi.string().required(), + total: Joi.number().required(), + covered: Joi.number().required(), + }) + ) + .min(1) + .required(), + }) + ) + .required(), +}).required() + +module.exports = class AzureDevOpsCoverage extends BaseJsonService { + static render({ coverage }) { + return { + message: `${coverage.toFixed(0)}%`, + color: coveragePercentageColor(coverage), + } + } + + static get defaultBadgeData() { + return { label: 'coverage' } + } + + static get category() { + return 'build' + } + + static get examples() { + return [ + { + title: 'Azure DevOps coverage', + urlPattern: ':organization/:project/:definitionId', + staticExample: this.render({ coverage: 100 }), + exampleUrl: 'swellaby/opensource/25', + keywords: ['vso', 'vsts', 'azure-devops'], + documentation, + }, + { + title: 'Azure DevOps coverage (branch)', + urlPattern: ':organization/:project/:definitionId/:branch', + staticExample: this.render({ coverage: 100 }), + exampleUrl: 'swellaby/opensource/25/master', + keywords: ['vso', 'vsts', 'azure-devops'], + documentation, + }, + ] + } + + static get route() { + return { + base: 'azure-devops/coverage', + format: '([^/]+)/([^/]+)/([^/]+)(?:/(.+))?', + capture: ['organization', 'project', 'definitionId', 'branch'], + } + } + + async fetch({ url, options, schema }) { + return this._requestJson({ + schema, + url, + options, + errorMessages: { + 404: 'build pipeline or coverage not found', + }, + }) + } + + async getLatestBuildId(organization, project, definitionId, branch, headers) { + // Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/azure/devops/build/builds/list?view=azure-devops-rest-5.0 + const url = `https://dev.azure.com/${organization}/${project}/_apis/build/builds` + const options = { + qs: { + definitions: definitionId, + $top: 1, + 'api-version': '5.0-preview.4', + }, + headers, + } + if (branch) { + options.qs.branch = branch + } + const json = await this.fetch({ + url, + options, + schema: latestBuildSchema, + }) + + if (json.count !== 1) { + throw new NotFound({ prettyMessage: 'build pipeline not found' }) + } + + return json.value[0].id + } + + async handle({ organization, project, definitionId, branch }) { + const headers = getHeaders() + const buildId = await this.getLatestBuildId( + organization, + project, + definitionId, + branch, + headers + ) + // Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/azure/devops/test/code%20coverage/get%20build%20code%20coverage?view=azure-devops-rest-5.0 + const url = `https://dev.azure.com/${organization}/${project}/_apis/test/codecoverage` + const options = { + qs: { + buildId, + 'api-version': '5.0-preview.1', + }, + headers, + } + const json = await this.fetch({ + url, + options, + schema: buildCodeCoverageSchema, + }) + + let covered = 0 + let total = 0 + json.coverageData.forEach(cd => { + cd.coverageStats.forEach(coverageStat => { + if (coverageStat.label === 'Lines') { + covered += coverageStat.covered + total += coverageStat.total + } + }) + }) + const coverage = covered ? (covered / total) * 100 : 0 + return this.constructor.render({ coverage }) + } +} diff --git a/services/azure-devops/azure-devops-coverage.tester.js b/services/azure-devops/azure-devops-coverage.tester.js new file mode 100644 index 0000000000..401e4267ce --- /dev/null +++ b/services/azure-devops/azure-devops-coverage.tester.js @@ -0,0 +1,210 @@ +'use strict' + +const Joi = require('joi') +const { isIntegerPercentage } = require('../test-validators') +const t = require('../create-service-tester')() +module.exports = t + +const org = 'swellaby' +const project = 'opensource' +const linuxDefinitionId = 21 +const macDefinitionId = 26 +const windowsDefinitionId = 24 +const nonExistentDefinitionId = 234421 +const buildId = 946 +const uriPrefix = `/${org}/${project}` +const azureDevOpsApiBaseUri = `https://dev.azure.com/${org}/${project}/_apis` +const mockBadgeUriPath = `${uriPrefix}/${macDefinitionId}.json` +const mockLatestBuildApiUriPath = `/build/builds?definitions=${macDefinitionId}&%24top=1&api-version=5.0-preview.4` +const mockCodeCoverageApiUriPath = `/test/codecoverage?buildId=${buildId}&api-version=5.0-preview.1` +const latestBuildResponse = { + count: 1, + value: [{ id: buildId }], +} +const firstLinesCovStat = { + label: 'Lines', + total: 23, + covered: 19, +} + +const branchCovStat = { + label: 'Branches', + total: 11, + covered: 7, +} + +const secondLinesCovStat = { + label: 'Lines', + total: 47, + covered: 35, +} + +const expCoverageSingleReport = '83%' +const expCoverageMultipleReports = '77%' + +t.create('default branch coverage') + .get(`${uriPrefix}/${linuxDefinitionId}.json`) + .expectJSONTypes( + Joi.object().keys({ + name: 'coverage', + value: isIntegerPercentage, + }) + ) + +t.create('named branch') + .get(`${uriPrefix}/${windowsDefinitionId}/docs.json`) + .expectJSONTypes( + Joi.object().keys({ + name: 'coverage', + value: isIntegerPercentage, + }) + ) + +t.create('unknown build definition') + .get(`${uriPrefix}/${nonExistentDefinitionId}.json`) + .expectJSON({ name: 'coverage', value: 'build pipeline not found' }) + +t.create('404 latest build error response') + .get(mockBadgeUriPath) + .intercept(nock => + nock(azureDevOpsApiBaseUri) + .get(mockLatestBuildApiUriPath) + .reply(404) + ) + .expectJSON({ + name: 'coverage', + value: 'build pipeline or coverage not found', + }) + +t.create('no build response') + .get(`${uriPrefix}/${nonExistentDefinitionId}.json`) + .intercept(nock => + nock(azureDevOpsApiBaseUri) + .get( + `/build/builds?definitions=${nonExistentDefinitionId}&%24top=1&api-version=5.0-preview.4` + ) + .reply(200, { + count: 0, + value: [], + }) + ) + .expectJSON({ name: 'coverage', value: 'build pipeline not found' }) + +t.create('404 code coverage error response') + .get(mockBadgeUriPath) + .intercept(nock => + nock(azureDevOpsApiBaseUri) + .get(mockLatestBuildApiUriPath) + .reply(200, latestBuildResponse) + .get(mockCodeCoverageApiUriPath) + .reply(404) + ) + .expectJSON({ + name: 'coverage', + value: 'build pipeline or coverage not found', + }) + +t.create('invalid code coverage response') + .get(mockBadgeUriPath) + .intercept(nock => + nock(azureDevOpsApiBaseUri) + .get(mockLatestBuildApiUriPath) + .reply(200, latestBuildResponse) + .get(mockCodeCoverageApiUriPath) + .reply(200, {}) + ) + .expectJSON({ name: 'coverage', value: 'invalid response data' }) + +t.create('no code coverage reports') + .get(mockBadgeUriPath) + .intercept(nock => + nock(azureDevOpsApiBaseUri) + .get(mockLatestBuildApiUriPath) + .reply(200, latestBuildResponse) + .get(mockCodeCoverageApiUriPath) + .reply(200, { coverageData: [] }) + ) + .expectJSON({ name: 'coverage', value: '0%' }) + +t.create('no code coverage reports') + .get(mockBadgeUriPath) + .intercept(nock => + nock(azureDevOpsApiBaseUri) + .get(mockLatestBuildApiUriPath) + .reply(200, latestBuildResponse) + .get(mockCodeCoverageApiUriPath) + .reply(200, { coverageData: [] }) + ) + .expectJSON({ name: 'coverage', value: '0%' }) + +t.create('no line coverage stats') + .get(mockBadgeUriPath) + .intercept(nock => + nock(azureDevOpsApiBaseUri) + .get(mockLatestBuildApiUriPath) + .reply(200, latestBuildResponse) + .get(mockCodeCoverageApiUriPath) + .reply(200, { + coverageData: [ + { + coverageStats: [branchCovStat], + }, + ], + }) + ) + .expectJSON({ name: 'coverage', value: '0%' }) + +t.create('single line coverage stats') + .get(mockBadgeUriPath) + .intercept(nock => + nock(azureDevOpsApiBaseUri) + .get(mockLatestBuildApiUriPath) + .reply(200, latestBuildResponse) + .get(mockCodeCoverageApiUriPath) + .reply(200, { + coverageData: [ + { + coverageStats: [firstLinesCovStat], + }, + ], + }) + ) + .expectJSON({ name: 'coverage', value: expCoverageSingleReport }) + +t.create('mixed line and branch coverage stats') + .get(mockBadgeUriPath) + .intercept(nock => + nock(azureDevOpsApiBaseUri) + .get(mockLatestBuildApiUriPath) + .reply(200, latestBuildResponse) + .get(mockCodeCoverageApiUriPath) + .reply(200, { + coverageData: [ + { + coverageStats: [firstLinesCovStat, branchCovStat], + }, + ], + }) + ) + .expectJSON({ name: 'coverage', value: expCoverageSingleReport }) + +t.create('multiple line coverage stat reports') + .get(mockBadgeUriPath) + .intercept(nock => + nock(azureDevOpsApiBaseUri) + .get(mockLatestBuildApiUriPath) + .reply(200, latestBuildResponse) + .get(mockCodeCoverageApiUriPath) + .reply(200, { + coverageData: [ + { + coverageStats: [ + firstLinesCovStat, + branchCovStat, + secondLinesCovStat, + ], + }, + ], + }) + ) + .expectJSON({ name: 'coverage', value: expCoverageMultipleReports }) diff --git a/services/azure-devops/azure-devops-helpers.js b/services/azure-devops/azure-devops-helpers.js index 2fee2c217e..cae224c894 100644 --- a/services/azure-devops/azure-devops-helpers.js +++ b/services/azure-devops/azure-devops-helpers.js @@ -1,6 +1,7 @@ 'use strict' const Joi = require('joi') +const serverSecrets = require('../../lib/server-secrets') const schema = Joi.object({ message: Joi.equal( @@ -43,4 +44,15 @@ function render({ status }) { } } -module.exports = { fetch, render } +function getHeaders() { + const headers = {} + if (serverSecrets && serverSecrets.azure_devops_token) { + const pat = serverSecrets.azure_devops_token + const auth = Buffer.from(`:${pat}`).toString('base64') + headers.Authorization = `basic ${auth}` + } + + return headers +} + +module.exports = { fetch, render, getHeaders } diff --git a/services/test-validators.js b/services/test-validators.js index 0d12fba0fa..4e1c6f3840 100644 --- a/services/test-validators.js +++ b/services/test-validators.js @@ -65,7 +65,7 @@ const isMetricOverTimePeriod = withRegex( /^[1-9][0-9]*[kMGTPEZY]?\/(year|month|4 weeks|week|day)$/ ) -const isIntegerPercentage = withRegex(/^[0-9]+%$/) +const isIntegerPercentage = withRegex(/^[1-9][0-9]?%|^100%|^0%$/) const isDecimalPercentage = withRegex(/^[0-9]+\.[0-9]*%$/) const isPercentage = Joi.alternatives().try( isIntegerPercentage,