From 75c5f41d675d49968d37fe85b8c93247df59e392 Mon Sep 17 00:00:00 2001 From: azizu06 Date: Sun, 21 Jun 2026 10:28:56 -0400 Subject: [PATCH 1/2] [AzureDevops] Authenticate build badge via REST API (#10162) --- .../azure-devops-build.service.js | 81 +++++++++++++------ .../azure-devops/azure-devops-build.spec.js | 13 +++ .../azure-devops/azure-devops-build.tester.js | 30 +++++-- 3 files changed, 90 insertions(+), 34 deletions(-) create mode 100644 services/azure-devops/azure-devops-build.spec.js diff --git a/services/azure-devops/azure-devops-build.service.js b/services/azure-devops/azure-devops-build.service.js index 24390e94f5f4f..b3c92378e00a2 100644 --- a/services/azure-devops/azure-devops-build.service.js +++ b/services/azure-devops/azure-devops-build.service.js @@ -1,18 +1,26 @@ 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 { 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 description = ` [Azure Devops](https://dev.azure.com/) (formerly VSO, VSTS) is Microsoft Azure's CI/CD platform. @@ -35,7 +43,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 +115,49 @@ export default class AzureDevOpsBuild extends BaseSvgScrapingService { }, } - 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}`, + static defaultBadgeData = { label: 'build' } + + // Map Azure DevOps build `result` values onto the status vocabulary + // understood by renderBuildStatusBadge (services/build-status.js). + static resultMap = { + canceled: 'canceled', + failed: 'failed', + none: 'no builds', + partiallySucceeded: 'partially succeeded', + succeeded: 'succeeded', + } + + async handle({ organization, projectId, definitionId, branch }) { + const httpErrors = { + 404: 'user or project 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 === 0) { + return renderBuildStatusBadge({ status: 'never built' }) } + + const { result } = value[0] + 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..bf10a12b9cd4d --- /dev/null +++ b/services/azure-devops/azure-devops-build.spec.js @@ -0,0 +1,13 @@ +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 () { + return testAuth(AzureDevOpsBuild, 'BasicAuth', { + count: 1, + value: [{ id: 12345, status: 'completed', result: 'succeeded' }], + }) + }) + }) +}) diff --git a/services/azure-devops/azure-devops-build.tester.js b/services/azure-devops/azure-devops-build.tester.js index 7376a29a9e0f7..4e55b4428e079 100644 --- a/services/azure-devops/azure-devops-build.tester.js +++ b/services/azure-devops/azure-devops-build.tester.js @@ -35,16 +35,30 @@ t.create('never built definition') .get('/swellaby/opensource/112.json') .expectBadge({ label: 'build', message: 'never built' }) -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') +t.create('unknown user or project') + .get('/notarealuser/foo/515.json') .expectBadge({ label: 'build', message: 'user or project not found' }) -t.create('unknown user') - .get('/notarealuser/foo/515.json') +// The Azure DevOps build REST API returns the same response for a missing +// definition as for a missing project/user (a redirect to sign-in), so unlike +// the old status-image endpoint the build badge cannot distinguish +// 'definition not found'. The 404 path is consolidated into +// 'user or project not found', consistent with the sibling Azure DevOps +// badges; a missing definition under an accessible project returns zero builds +// and renders 'never built' (see above). +t.create('404 latest build error response') + .get('/swellaby/fake/14.json') + .intercept(nock => + nock('https://dev.azure.com/swellaby/fake/_apis') + .get('/build/builds') + .query({ + definitions: 14, + $top: 1, + statusFilter: 'completed', + 'api-version': '5.0-preview.4', + }) + .reply(404), + ) .expectBadge({ label: 'build', message: 'user or project not found' }) // The following build definition has always a partially succeeded status From 094a156d005d93c7f883a26f07ab81267c413f0a Mon Sep 17 00:00:00 2001 From: azizu06 Date: Sun, 21 Jun 2026 10:38:25 -0400 Subject: [PATCH 2/2] [AzureDevops] Restore stage/job parity for build badge via Timeline API (#10162) --- .../azure-devops-build.service.js | 73 ++++++++++++++++-- .../azure-devops/azure-devops-build.spec.js | 15 +++- .../azure-devops/azure-devops-build.tester.js | 75 +++++++++++++++++++ 3 files changed, 154 insertions(+), 9 deletions(-) diff --git a/services/azure-devops/azure-devops-build.service.js b/services/azure-devops/azure-devops-build.service.js index b3c92378e00a2..0f06c9f496e97 100644 --- a/services/azure-devops/azure-devops-build.service.js +++ b/services/azure-devops/azure-devops-build.service.js @@ -1,6 +1,6 @@ import Joi from 'joi' import { renderBuildStatusBadge } from '../build-status.js' -import { queryParam, pathParam } from '../index.js' +import { NotFound, queryParam, pathParam } from '../index.js' import AzureDevOpsBase from './azure-devops-base.js' const queryParamSchema = Joi.object({ @@ -21,6 +21,18 @@ const buildSchema = Joi.object({ .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. @@ -117,17 +129,58 @@ export default class AzureDevOpsBuild extends AzureDevOpsBase { static defaultBadgeData = { label: 'build' } - // Map Azure DevOps build `result` values onto the status vocabulary - // understood by renderBuildStatusBadge (services/build-status.js). + // 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 }) { + async handle( + { organization, projectId, definitionId, branch }, + { stage, job }, + ) { const httpErrors = { 404: 'user or project not found', } @@ -156,7 +209,17 @@ export default class AzureDevOpsBuild extends AzureDevOpsBase { return renderBuildStatusBadge({ status: 'never built' }) } - const { result } = value[0] + 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 index bf10a12b9cd4d..386362bdcc569 100644 --- a/services/azure-devops/azure-devops-build.spec.js +++ b/services/azure-devops/azure-devops-build.spec.js @@ -4,10 +4,17 @@ import AzureDevOpsBuild from './azure-devops-build.service.js' describe('AzureDevOpsBuild', function () { describe('auth', function () { it('sends the auth information as configured', async function () { - return testAuth(AzureDevOpsBuild, 'BasicAuth', { - count: 1, - value: [{ id: 12345, status: 'completed', result: 'succeeded' }], - }) + // 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 4e55b4428e079..3d1618ff7e132 100644 --- a/services/azure-devops/azure-devops-build.tester.js +++ b/services/azure-devops/azure-devops-build.tester.js @@ -65,3 +65,78 @@ t.create('404 latest build error response') 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' })