diff --git a/services/azure-devops/azure-devops-build.service.js b/services/azure-devops/azure-devops-build.service.js index 24390e94f5f4f..06d2cbf02566c 100644 --- a/services/azure-devops/azure-devops-build.service.js +++ b/services/azure-devops/azure-devops-build.service.js @@ -1,18 +1,38 @@ import Joi from 'joi' import { renderBuildStatusBadge } from '../build-status.js' -import { - BaseSvgScrapingService, - NotFound, - queryParam, - pathParam, -} from '../index.js' -import { fetch } from './azure-devops-helpers.js' +import { NotFound, queryParam, pathParam } from '../index.js' +import AzureDevOpsBase from './azure-devops-base.js' const queryParamSchema = Joi.object({ stage: Joi.string(), job: Joi.string(), }) +const buildSchema = Joi.object({ + count: Joi.number().required(), + value: Joi.array() + .items( + Joi.object({ + id: Joi.number().required(), + status: Joi.string().required(), + result: Joi.string().allow(null), + }), + ) + .required(), +}).required() + +const timelineSchema = Joi.object({ + records: Joi.array() + .items( + Joi.object({ + type: Joi.string().required(), + name: Joi.string().required(), + result: Joi.string().allow(null), + }), + ) + .required(), +}).required() + const description = ` [Azure Devops](https://dev.azure.com/) (formerly VSO, VSTS) is Microsoft Azure's CI/CD platform. @@ -35,7 +55,7 @@ Navigate to \`https://dev.azure.com/ORGANIZATION/_apis/projects/PROJECT_NAME\` alt="PROJECT_ID is in the id property of the API response." /> ` -export default class AzureDevOpsBuild extends BaseSvgScrapingService { +export default class AzureDevOpsBuild extends AzureDevOpsBase { static category = 'build' static route = { @@ -107,28 +127,100 @@ export default class AzureDevOpsBuild extends BaseSvgScrapingService { }, } + static defaultBadgeData = { label: 'build' } + + // Map Azure DevOps `result` values (both build-level and timeline + // stage/job-level) onto the status vocabulary understood by + // renderBuildStatusBadge (services/build-status.js). + static resultMap = { + // build-level results (builds list) + canceled: 'canceled', + failed: 'failed', + none: 'no builds', + partiallySucceeded: 'partially succeeded', + succeeded: 'succeeded', + // timeline record-level results (stage / job) + abandoned: 'canceled', + skipped: 'skipped', + succeededWithIssues: 'partially succeeded', + } + + // Look up the result of a single stage or job within a build via the + // Timeline API. A job takes precedence over a stage when both are given. + // Note: the Timeline endpoint does not accept the api-version used by the + // other Azure DevOps build endpoints. + async getStageOrJobResult( + organization, + projectId, + buildId, + stage, + job, + httpErrors, + ) { + const url = `https://dev.azure.com/${organization}/${projectId}/_apis/build/builds/${buildId}/timeline` + const { records } = await this.fetch({ + url, + options: {}, + schema: timelineSchema, + httpErrors, + }) + const recordType = job ? 'Job' : 'Stage' + const recordName = job || stage + const record = records.find( + r => r.type === recordType && r.name === recordName, + ) + if (!record) { + throw new NotFound({ + prettyMessage: `${recordType.toLowerCase()} not found`, + }) + } + return record.result + } + async handle( { organization, projectId, definitionId, branch }, { stage, job }, ) { - // Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/vsts/build/status/get - const { status } = await fetch(this, { - url: `https://dev.azure.com/${organization}/${projectId}/_apis/build/status/${definitionId}`, + const httpErrors = { + 404: 'build pipeline not found', + } + // Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/azure/devops/build/builds/list + const url = `https://dev.azure.com/${organization}/${projectId}/_apis/build/builds` + const options = { searchParams: { - branchName: branch, - stageName: stage, - jobName: job, - }, - httpErrors: { - 404: 'user or project not found', + definitions: definitionId, + $top: 1, + statusFilter: 'completed', + 'api-version': '5.0-preview.4', }, - }) - if (status === 'set up now') { - throw new NotFound({ prettyMessage: 'definition not found' }) } - if (status === 'unknown') { - throw new NotFound({ prettyMessage: 'project not found' }) + if (branch) { + options.searchParams.branchName = `refs/heads/${branch}` } + + const { count, value } = await this.fetch({ + url, + options, + schema: buildSchema, + httpErrors, + }) + + if (count !== 1) { + throw new NotFound({ prettyMessage: 'build pipeline not found' }) + } + + const result = + stage || job + ? await this.getStageOrJobResult( + organization, + projectId, + value[0].id, + stage, + job, + httpErrors, + ) + : value[0].result + const status = this.constructor.resultMap[result] || result return renderBuildStatusBadge({ status }) } } diff --git a/services/azure-devops/azure-devops-build.spec.js b/services/azure-devops/azure-devops-build.spec.js new file mode 100644 index 0000000000000..386362bdcc569 --- /dev/null +++ b/services/azure-devops/azure-devops-build.spec.js @@ -0,0 +1,20 @@ +import { testAuth } from '../test-helpers.js' +import AzureDevOpsBuild from './azure-devops-build.service.js' + +describe('AzureDevOpsBuild', function () { + describe('auth', function () { + it('sends the auth information as configured', async function () { + // Exercise the overall-build path (no stage/job) so a single mocked + // request is sufficient; the auth header is sent the same way regardless. + return testAuth( + AzureDevOpsBuild, + 'BasicAuth', + { + count: 1, + value: [{ id: 12345, status: 'completed', result: 'succeeded' }], + }, + { exampleOverride: { stage: undefined, job: undefined } }, + ) + }) + }) +}) diff --git a/services/azure-devops/azure-devops-build.tester.js b/services/azure-devops/azure-devops-build.tester.js index 7376a29a9e0f7..8278c58daee52 100644 --- a/services/azure-devops/azure-devops-build.tester.js +++ b/services/azure-devops/azure-devops-build.tester.js @@ -31,23 +31,93 @@ t.create('job badge') message: isBuildStatus, }) -t.create('never built definition') +// A pipeline that exists but has no completed builds, and a bad org/project, +// both surface as 'build pipeline not found' — consistent with the sibling +// Azure DevOps badges (coverage, tests). +t.create('unknown build definition') .get('/swellaby/opensource/112.json') - .expectBadge({ label: 'build', message: 'never built' }) + .expectBadge({ label: 'build', message: 'build pipeline not found' }) -t.create('unknown definition') - .get('/larsbrinkhoff/953a34b9-5966-4923-a48a-c41874cfb5f5/515.json') - .expectBadge({ label: 'build', message: 'definition not found' }) - -t.create('unknown project') - .get('/larsbrinkhoff/foo/515.json') - .expectBadge({ label: 'build', message: 'user or project not found' }) - -t.create('unknown user') +t.create('unknown user or project') .get('/notarealuser/foo/515.json') - .expectBadge({ label: 'build', message: 'user or project not found' }) + .expectBadge({ label: 'build', message: 'build pipeline not found' }) // The following build definition has always a partially succeeded status t.create('partially succeeded build') .get('/totodem/shields.io/4/master.json') .expectBadge({ label: 'build', message: 'passing', color: 'orange' }) + +// Overall build is `succeeded` but the requested stage `failed` — the badge +// must reflect the stage, proving the Timeline lookup is used. +t.create('stage status reflects the stage, not the overall build') + .get('/totodem/someproject/5.json?stage=Failing%20Stage') + .intercept(nock => + nock('https://dev.azure.com/totodem/someproject/_apis') + .get('/build/builds') + .query({ + definitions: 5, + $top: 1, + statusFilter: 'completed', + 'api-version': '5.0-preview.4', + }) + .reply(200, { + count: 1, + value: [{ id: 999, status: 'completed', result: 'succeeded' }], + }) + .get('/build/builds/999/timeline') + .reply(200, { + records: [ + { type: 'Stage', name: 'Successful Stage', result: 'succeeded' }, + { type: 'Stage', name: 'Failing Stage', result: 'failed' }, + ], + }), + ) + .expectBadge({ label: 'build', message: 'failing', color: 'red' }) + +// A job takes precedence over a stage, and timeline `succeededWithIssues` +// maps to the orange partially-succeeded badge. +t.create('job status (succeededWithIssues -> partially succeeded)') + .get('/totodem/someproject/5.json?stage=Successful%20Stage&job=Flaky%20Job') + .intercept(nock => + nock('https://dev.azure.com/totodem/someproject/_apis') + .get('/build/builds') + .query({ + definitions: 5, + $top: 1, + statusFilter: 'completed', + 'api-version': '5.0-preview.4', + }) + .reply(200, { + count: 1, + value: [{ id: 999, status: 'completed', result: 'succeeded' }], + }) + .get('/build/builds/999/timeline') + .reply(200, { + records: [ + { type: 'Job', name: 'Flaky Job', result: 'succeededWithIssues' }, + ], + }), + ) + .expectBadge({ label: 'build', message: 'passing', color: 'orange' }) + +t.create('unknown stage') + .get('/totodem/someproject/5.json?stage=Nonexistent%20Stage') + .intercept(nock => + nock('https://dev.azure.com/totodem/someproject/_apis') + .get('/build/builds') + .query({ + definitions: 5, + $top: 1, + statusFilter: 'completed', + 'api-version': '5.0-preview.4', + }) + .reply(200, { + count: 1, + value: [{ id: 999, status: 'completed', result: 'succeeded' }], + }) + .get('/build/builds/999/timeline') + .reply(200, { + records: [{ type: 'Stage', name: 'Other Stage', result: 'succeeded' }], + }), + ) + .expectBadge({ label: 'build', message: 'stage not found' })