Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 114 additions & 22 deletions services/azure-devops/azure-devops-build.service.js
Original file line number Diff line number Diff line change
@@ -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.

Expand All @@ -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 = {
Expand Down Expand Up @@ -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: '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 =
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 })
}
}
20 changes: 20 additions & 0 deletions services/azure-devops/azure-devops-build.spec.js
Original file line number Diff line number Diff line change
@@ -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 } },
)
})
})
})
105 changes: 97 additions & 8 deletions services/azure-devops/azure-devops-build.tester.js
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,108 @@ 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
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' })
Loading