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:
+
+
+
+ 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,