Rewrite vso, rename to [AzureDevops], validate SVG [readthedocs] (#2252)

1. Add validation to BaseSvgScrapingService and update readthedocs accordingly.
2. Rewrite vso and add more tests. Rename it internally to azure-devops. URLs are still `/vso` for now. Should we make a way to let a service register multiple URL patterns?
3. Handle shared code using a functional pattern instead of inheritance. This comes from a discussion https://github.com/badges/shields/pull/2031#issuecomment-417893819. I like the functional approach because it's more direct, nimble, and easy to reason about; plus it allows services to grow from a family of one to two more easily.
This commit is contained in:
Paul Melnikow
2018-11-05 16:52:53 -05:00
committed by GitHub
parent 600c369823
commit e983f7bf3b
12 changed files with 324 additions and 218 deletions

View File

@@ -0,0 +1,87 @@
'use strict'
const BaseSvgService = require('../base-svg-scraping')
const { NotFound } = require('../errors')
const { fetch, render } = require('./azure-devops-helpers')
const documentation = `
<p>
To obtain your own badge, you need to get 3 pieces of information:
<code>ORGANIZATION</code>, <code>PROJECT_ID</code> and <code>DEFINITION_ID</code>.
</p>
<p>
First, you need to edit your build definition and look at the url:
</p>
<img
src="https://user-images.githubusercontent.com/3749820/47259976-e2d9ec80-d4b2-11e8-92cc-7c81089a7a2c.png"
alt="ORGANIZATION is after the dev.azure.com part, PROJECT_NAME is right after that, DEFINITION_ID is at the end after the id= part." />
<p>
Then, you can get the <code>PROJECT_ID</code> from the <code>PROJECT_NAME</code> using Azure DevOps REST API.
Just access to: <code>https://dev.azure.com/ORGANIZATION/_apis/projects/PROJECT_NAME</code>.
</p>
<img
src="https://user-images.githubusercontent.com/3749820/47266325-1d846900-d535-11e8-9211-2ee72fb91877.png"
alt="PROJECT_ID is in the id property of the API response." />
<p>
Your badge will then have the form:
<code>https://img.shields.io/vso/build/ORGANIZATION/PROJECT_ID/DEFINITION_ID.svg</code>.
</p>
<p>
Optionally, you can specify a named branch:
<code>https://img.shields.io/vso/build/ORGANIZATION/PROJECT_ID/DEFINITION_ID/NAMED_BRANCH.svg</code>.
</p>
`
module.exports = class AzureDevOpsBuild extends BaseSvgService {
static get category() {
return 'build'
}
static get url() {
return {
base: '',
format: '(?:azure-devops|vso)/build/([^/]+)/([^/]+)/([^/]+)(?:/(.+))?',
capture: ['organization', 'projectId', 'definitionId', 'branch'],
}
}
static get examples() {
return [
{
title: 'Azure DevOps builds',
urlPattern: 'azure-devops/build/:organization/:projectId/:definitionId',
staticExample: render({ status: 'succeeded' }),
exampleUrl:
'azure-devops/build/totodem/8cf3ec0e-d0c2-4fcd-8206-ad204f254a96/2',
documentation,
},
{
title: 'Azure DevOps builds (branch)',
urlPattern:
'azure-devops/build/:organization/:projectId/:definitionId/:branch',
staticExample: render({ status: 'succeeded' }),
exampleUrl:
'azure-devops/build/totodem/8cf3ec0e-d0c2-4fcd-8206-ad204f254a96/2/master',
documentation,
},
]
}
async handle({ organization, projectId, definitionId, branch }) {
// 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}`,
qs: { branchName: branch },
errorMessages: {
404: 'user or project not found',
},
})
if (status === 'set up now') {
throw new NotFound({ prettyMessage: 'definition not found' })
}
if (status === 'unknown') {
throw new NotFound({ prettyMessage: 'project not found' })
}
return render({ status })
}
}

View File

@@ -0,0 +1,45 @@
'use strict'
const Joi = require('joi')
const { isBuildStatus } = require('../test-validators')
const t = require('../create-service-tester')()
module.exports = t
// https://dev.azure.com/totodem/Shields.io is a public Azure DevOps project
// solely created for Shields.io testing.
t.create('default branch')
.get(
'/azure-devops/build/totodem/8cf3ec0e-d0c2-4fcd-8206-ad204f254a96/2.json'
)
.expectJSONTypes(
Joi.object().keys({
name: 'build',
value: isBuildStatus,
})
)
t.create('named branch')
.get(
'/azure-devops/build/totodem/8cf3ec0e-d0c2-4fcd-8206-ad204f254a96/2/master.json'
)
.expectJSONTypes(
Joi.object().keys({
name: 'build',
value: isBuildStatus,
})
)
t.create('unknown definition')
.get(
'/azure-devops/build/larsbrinkhoff/953a34b9-5966-4923-a48a-c41874cfb5f5/515.json'
)
.expectJSON({ name: 'build', value: 'definition not found' })
t.create('unknown project')
.get('/azure-devops/build/larsbrinkhoff/foo/515.json')
.expectJSON({ name: 'build', value: 'user or project not found' })
t.create('unknown user')
.get('/azure-devops/build/notarealuser/foo/515.json')
.expectJSON({ name: 'build', value: 'user or project not found' })

View File

@@ -0,0 +1,46 @@
'use strict'
const Joi = require('joi')
const schema = Joi.object({
message: Joi.equal(
'succeeded',
'partially suceeded',
'failed',
'unknown',
'set up now'
).required(),
}).required()
async function fetch(serviceInstance, { url, qs = {}, errorMessages }) {
// Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/vsts/build/status/get
const { message: status } = await serviceInstance._requestSvg({
schema,
url,
options: { qs },
errorMessages,
})
return { status }
}
function render({ status }) {
switch (status) {
case 'succeeded':
return {
message: 'passing',
color: 'brightgreen',
}
case 'partially succeeded':
return {
message: 'passing',
color: 'orange',
}
case 'failed':
return {
message: 'failing',
color: 'red',
}
}
}
module.exports = { fetch, render }

View File

@@ -0,0 +1,69 @@
'use strict'
const BaseSvgService = require('../base-svg-scraping')
const { fetch, render } = require('./azure-devops-helpers')
const documentation = `
<p>
To obtain your own badge, you need to get 4 pieces of information:
<code>ORGANIZATION</code>, <code>PROJECT_ID</code>, <code>DEFINITION_ID</code> and <code>ENVIRONMENT_ID</code>.
</p>
<p>
First, you need to enable badges for each required environments in the options of your release definition.
Once you have save the change, look at badge url:
</p>
<img
src="https://user-images.githubusercontent.com/3749820/47266694-7f939d00-d53a-11e8-9224-c2371dd2d0c9.png"
alt="ORGANIZATION is after the dev.azure.com part, PROJECT_ID is after the badge part, DEFINITION_ID and ENVIRONMENT_ID are right after that." />
<p>
Your badge will then have the form:
<code>https://img.shields.io/vso/release/ORGANIZATION/PROJECT_ID/DEFINITION_ID/ENVIRONMENT_ID.svg</code>.
</p>
`
module.exports = class AzureDevOpsRelease extends BaseSvgService {
static get category() {
return 'build'
}
static get url() {
return {
base: '',
format: '(?:azure-devops|vso)/release/([^/]+)/([^/]+)/([^/]+)/([^/]+)',
capture: ['organization', 'projectId', 'definitionId', 'environmentId'],
}
}
static get examples() {
return [
{
title: 'Azure DevOps releases',
urlPattern:
'azure-devops/release/:organization/:projectId/:definitionId/:environmentId',
staticExample: render({ status: 'succeeded' }),
exampleUrl:
'azure-devops/release/totodem/8cf3ec0e-d0c2-4fcd-8206-ad204f254a96/1/1',
documentation,
},
]
}
static get defaultBadgeData() {
return {
label: 'deployment',
}
}
async handle({ organization, projectId, definitionId, environmentId }) {
// Microsoft documentation: ?
const props = await fetch(this, {
url: `https://vsrm.dev.azure.com/${organization}/_apis/public/Release/badge/${projectId}/${definitionId}/${environmentId}`,
errorMessages: {
400: 'project not found',
404: 'user or environment not found',
500: 'inaccessible or definition not found',
},
})
return render(props)
}
}

View File

@@ -0,0 +1,43 @@
'use strict'
const Joi = require('joi')
const { isBuildStatus } = require('../test-validators')
const t = require('../create-service-tester')()
module.exports = t
// https://dev.azure.com/totodem/Shields.io is a public Azure DevOps project
// solely created for Shields.io testing.
t.create('release status is succeeded')
.get(
'/azure-devops/release/totodem/8cf3ec0e-d0c2-4fcd-8206-ad204f254a96/1/1.json'
)
.expectJSONTypes(
Joi.object().keys({
name: 'deployment',
value: isBuildStatus,
})
)
t.create('unknown environment')
.get(
'/azure-devops/release/totodem/8cf3ec0e-d0c2-4fcd-8206-ad204f254a96/1/515.json'
)
.expectJSON({ name: 'deployment', value: 'user or environment not found' })
t.create('unknown definition')
.get(
'/azure-devops/release/totodem/8cf3ec0e-d0c2-4fcd-8206-ad204f254a96/515/515.json'
)
.expectJSON({
name: 'deployment',
value: 'inaccessible or definition not found',
})
t.create('unknown project')
.get('/azure-devops/release/totodem/515/515/515.json')
.expectJSON({ name: 'deployment', value: 'project not found' })
t.create('unknown user')
.get('/azure-devops/release/this-repo/does-not-exist/1/2.json')
.expectJSON({ name: 'deployment', value: 'user or environment not found' })

View File

@@ -26,7 +26,13 @@ class BaseSvgScrapingService extends BaseService {
}
}
async _requestSvg({ valueMatcher, url, options = {}, errorMessages = {} }) {
async _requestSvg({
schema,
valueMatcher,
url,
options = {},
errorMessages = {},
}) {
const logTrace = (...args) => trace.logTrace('fetch', ...args)
const mergedOptions = {
...{ headers: { Accept: 'image/svg+xml' } },
@@ -38,8 +44,13 @@ class BaseSvgScrapingService extends BaseService {
errorMessages,
})
logTrace(emojic.dart, 'Response SVG', buffer)
const message = this.constructor.valueFromSvgBadge(buffer, valueMatcher)
return { message }
const data = {
message: this.constructor.valueFromSvgBadge(buffer, valueMatcher),
}
logTrace(emojic.dart, 'Response SVG (before validation)', data, {
deep: true,
})
return this.constructor._validate(data, schema)
}
}

View File

@@ -3,6 +3,7 @@
const chai = require('chai')
const { expect } = require('chai')
const sinon = require('sinon')
const Joi = require('joi')
const { makeBadgeData } = require('../lib/badge-data')
const testHelpers = require('../lib/make-badge-test-helpers')
const BaseSvgScrapingService = require('./base-svg-scraping')
@@ -15,6 +16,10 @@ function makeExampleSvg({ label, message }) {
return testHelpers.makeBadge()(badgeData)
}
const schema = Joi.object({
message: Joi.string().required(),
}).required()
class DummySvgScrapingService extends BaseSvgScrapingService {
static get category() {
return 'cat'
@@ -28,6 +33,7 @@ class DummySvgScrapingService extends BaseSvgScrapingService {
async handle() {
return this._requestSvg({
schema,
url: 'http://example.com/foo.svg',
})
}
@@ -79,6 +85,7 @@ describe('BaseSvgScrapingService', function() {
Object.assign(serviceInstance, {
async handle() {
const { value } = await this._requestSvg({
schema,
url: 'http://example.com/foo.svg',
options: {
method: 'POST',
@@ -130,6 +137,7 @@ describe('BaseSvgScrapingService', function() {
Object.assign(serviceInstance, {
async handle() {
return this._requestSvg({
schema,
valueMatcher: />([^<>]+)<\/desc>/,
url: 'http://example.com/foo.svg',
})

View File

@@ -4,20 +4,16 @@ const caller = require('caller')
const ServiceTester = require('./service-tester')
const BaseService = require('./base')
// Automatically create a ServiceTester. When run from e.g.
// `gem-rank.tester.js`, this will create a tester that attaches to the
// service found in `gem-rank.service.js`.
// Automatically create a ServiceTester.
//
// When run from e.g. `gem-rank.tester.js`, this will create a tester that
// attaches to the service found in `gem-rank.service.js`.
//
// This can't be used for `.service.js` files which export more than one
// service.
function createServiceTester() {
const servicePath = caller().replace('.tester.js', '.service.js')
let ServiceClass
try {
ServiceClass = require(servicePath)
} catch (e) {
throw Error(`Couldn't load service from ${servicePath}`)
}
const ServiceClass = require(servicePath)
if (!(ServiceClass.prototype instanceof BaseService)) {
throw Error(
`${servicePath} does not export a single service. Invoke new ServiceTester() directly.`

View File

@@ -1,8 +1,13 @@
'use strict'
const Joi = require('joi')
const BaseSvgScrapingService = require('../base-svg-scraping')
const { NotFound } = require('../errors')
const schema = Joi.object({
message: Joi.string().required(),
}).required()
module.exports = class ReadTheDocs extends BaseSvgScrapingService {
static get category() {
return 'build'
@@ -60,6 +65,7 @@ module.exports = class ReadTheDocs extends BaseSvgScrapingService {
async handle({ project, version }) {
const { message: status } = await this._requestSvg({
schema,
url: `https://readthedocs.org/projects/${encodeURIComponent(
project
)}/badge/`,

View File

@@ -31,7 +31,7 @@ class ServiceTester {
static forServiceClass(ServiceClass) {
const id = ServiceClass.name
const pathPrefix = `/${ServiceClass.url.base}`
const pathPrefix = ServiceClass.url.base ? `/${ServiceClass.url.base}` : ''
return new this({
id,
title: id,

View File

@@ -1,142 +0,0 @@
'use strict'
const LegacyService = require('../legacy-service')
const { fetchFromSvg } = require('../../lib/svg-badge-parser')
const { makeBadgeData: getBadgeData } = require('../../lib/badge-data')
const devOpsBuildDoc = `
<p>
To obtain your own badge, you need to get 3 pieces of information:
<code>ORGANIZATION</code>, <code>PROJECT_ID</code> and <code>DEFINITION_ID</code>.
</p>
<p>
First, you need to edit your build definition and look at the url:
</p>
<img
src="https://user-images.githubusercontent.com/3749820/47259976-e2d9ec80-d4b2-11e8-92cc-7c81089a7a2c.png"
alt="ORGANIZATION is after the dev.azure.com part, PROJECT_NAME is right after that, DEFINITION_ID is at the end after the id= part." />
<p>
Then, you can get the <code>PROJECT_ID</code> from the <code>PROJECT_NAME</code> using Azure DevOps REST API.
Just access to: <code>https://dev.azure.com/ORGANIZATION/_apis/projects/PROJECT_NAME</code>.
</p>
<img
src="https://user-images.githubusercontent.com/3749820/47266325-1d846900-d535-11e8-9211-2ee72fb91877.png"
alt="PROJECT_ID is in the id property of the API response." />
<p>
Your badge will then have the form:
<code>https://img.shields.io/vso/build/ORGANIZATION/PROJECT_ID/DEFINITION_ID.svg</code>.
</p>
<p>
Optionally, you can specify a named branch:
<code>https://img.shields.io/vso/build/ORGANIZATION/PROJECT_ID/DEFINITION_ID/NAMED_BRANCH.svg</code>.
</p>
`
const devOpsReleaseDoc = `
<p>
To obtain your own badge, you need to get 4 pieces of information:
<code>ORGANIZATION</code>, <code>PROJECT_ID</code>, <code>DEFINITION_ID</code> and <code>ENVIRONMENT_ID</code>.
</p>
<p>
First, you need to enable badges for each required environments in the options of your release definition.
Once you have save the change, look at badge url:
</p>
<img
src="https://user-images.githubusercontent.com/3749820/47266694-7f939d00-d53a-11e8-9224-c2371dd2d0c9.png"
alt="ORGANIZATION is after the dev.azure.com part, PROJECT_ID is after the badge part, DEFINITION_ID and ENVIRONMENT_ID are right after that." />
<p>
Your badge will then have the form:
<code>https://img.shields.io/vso/release/ORGANIZATION/PROJECT_ID/DEFINITION_ID/ENVIRONMENT_ID.svg</code>.
</p>
`
const fetchVstsBadge = (request, url, badgeData, sendBadge, format) => {
fetchFromSvg(request, url, />([^<>]+)<\/text><\/g>/, (err, res) => {
if (err != null) {
badgeData.text[1] = 'inaccessible'
sendBadge(format, badgeData)
return
}
try {
badgeData.text[1] = res.toLowerCase()
if (res === 'succeeded') {
badgeData.colorscheme = 'brightgreen'
badgeData.text[1] = 'passing'
} else if (res === 'partially succeeded') {
badgeData.colorscheme = 'orange'
badgeData.text[1] = 'passing'
} else if (res === 'failed') {
badgeData.colorscheme = 'red'
badgeData.text[1] = 'failing'
}
sendBadge(format, badgeData)
} catch (e) {
badgeData.text[1] = 'invalid'
sendBadge(format, badgeData)
}
})
}
module.exports = class Vso extends LegacyService {
static get category() {
return 'build'
}
static get url() {
return {
base: 'vso',
}
}
static get examples() {
return [
{
title: 'Azure DevOps builds',
previewUrl: 'build/totodem/8cf3ec0e-d0c2-4fcd-8206-ad204f254a96/2',
documentation: devOpsBuildDoc,
},
{
title: 'Azure DevOps releases',
previewUrl: 'release/totodem/8cf3ec0e-d0c2-4fcd-8206-ad204f254a96/1/1',
documentation: devOpsReleaseDoc,
},
]
}
static registerLegacyRouteHandler({ camp, cache }) {
// For Azure DevOps builds.
camp.route(
/^\/vso\/build\/([^/]+)\/([^/]+)\/([^/]+)(?:\/(.+))?\.(svg|png|gif|jpg|json)$/,
cache((data, match, sendBadge, request) => {
// Microsoft documentation: https://docs.microsoft.com/en-us/rest/api/vsts/build/status/get
const organization = match[1] // The name (string) of the Azure DevOps organization.
const projectId = match[2] // The ID (uuid) of the project.
const definitionId = match[3] // The ID (int) of the definition.
const branchName = match[4] // The name (string) of the branch.
const format = match[5]
let url = `https://dev.azure.com/${organization}/${projectId}/_apis/build/status/${definitionId}`
if (branchName != null) {
url += `?branchName=${branchName}`
}
const badgeData = getBadgeData('build', data)
fetchVstsBadge(request, url, badgeData, sendBadge, format)
})
)
// For Azure DevOps releases.
camp.route(
/^\/vso\/release\/([^/]+)\/([^/]+)\/([^/]+)\/([^/]+)\.(svg|png|gif|jpg|json)$/,
cache((data, match, sendBadge, request) => {
// Microsoft documentation: ?
const organization = match[1] // The name (string) of the Azure DevOps organization.
const projectId = match[2] // The ID (uuid) of the project.
const definitionId = match[3] // The ID (int) of the definition.
const environmentId = match[4] // The ID (int) of the release environment.
const format = match[5]
const url = `https://vsrm.dev.azure.com/${organization}/_apis/public/Release/badge/${projectId}/${definitionId}/${environmentId}`
const badgeData = getBadgeData('deployment', data)
fetchVstsBadge(request, url, badgeData, sendBadge, format)
})
)
}
}

View File

@@ -1,63 +0,0 @@
'use strict'
const Joi = require('joi')
const ServiceTester = require('../service-tester')
const isValidStatus = Joi.equal('passing', 'failing')
const t = new ServiceTester({
id: 'vso',
title: 'Visual Studio Team Services',
})
module.exports = t
// https://dev.azure.com/totodem/Shields.io is a public Azure DevOps project solely created for Shield.io testing
// Builds
t.create('build status on default branch')
.get('/build/totodem/8cf3ec0e-d0c2-4fcd-8206-ad204f254a96/2.json')
.expectJSONTypes(
Joi.object().keys({
name: 'build',
value: isValidStatus,
})
)
t.create('build status on named branch')
.get('/build/totodem/8cf3ec0e-d0c2-4fcd-8206-ad204f254a96/2/master.json')
.expectJSONTypes(
Joi.object().keys({
name: 'build',
value: isValidStatus,
})
)
t.create('build status on unknown repo')
.get('/build/this-repo/does-not/exist.json')
.expectJSON({ name: 'build', value: 'inaccessible' })
t.create('build status with connection error')
.get('/build/foo/bar/foobar.json')
.networkOff()
.expectJSON({ name: 'build', value: 'inaccessible' })
// Releases
t.create('release status is succeeded')
.get('/release/totodem/8cf3ec0e-d0c2-4fcd-8206-ad204f254a96/1/1.json')
.expectJSONTypes(
Joi.object().keys({
name: 'deployment',
value: isValidStatus,
})
)
t.create('release status on unknown repo')
.get('/release/this-repo/does-not-exist/1/2.json')
.expectJSON({ name: 'deployment', value: 'inaccessible' })
t.create('release status with connection error')
.get('/release/foo/bar/1/2.json')
.networkOff()
.expectJSON({ name: 'deployment', value: 'inaccessible' })