Skip to content

Commit

Permalink
Rewrite vso, rename to [AzureDevops], validate SVG [readthedocs] (#2252)
Browse files Browse the repository at this point in the history
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 #2031 (comment). 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.
  • Loading branch information
paulmelnikow authored Nov 5, 2018
1 parent 600c369 commit e983f7b
Show file tree
Hide file tree
Showing 12 changed files with 324 additions and 218 deletions.
87 changes: 87 additions & 0 deletions services/azure-devops/azure-devops-build.service.js
Original file line number Diff line number Diff line change
@@ -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 })
}
}
45 changes: 45 additions & 0 deletions services/azure-devops/azure-devops-build.tester.js
Original file line number Diff line number Diff line change
@@ -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' })
46 changes: 46 additions & 0 deletions services/azure-devops/azure-devops-helpers.js
Original file line number Diff line number Diff line change
@@ -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 }
69 changes: 69 additions & 0 deletions services/azure-devops/azure-devops-release.service.js
Original file line number Diff line number Diff line change
@@ -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)
}
}
43 changes: 43 additions & 0 deletions services/azure-devops/azure-devops-release.tester.js
Original file line number Diff line number Diff line change
@@ -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' })
17 changes: 14 additions & 3 deletions services/base-svg-scraping.js
Original file line number Diff line number Diff line change
Expand Up @@ -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' } },
Expand All @@ -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)
}
}

Expand Down
8 changes: 8 additions & 0 deletions services/base-svg-scraping.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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'
Expand All @@ -28,6 +33,7 @@ class DummySvgScrapingService extends BaseSvgScrapingService {

async handle() {
return this._requestSvg({
schema,
url: 'http://example.com/foo.svg',
})
}
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -130,6 +137,7 @@ describe('BaseSvgScrapingService', function() {
Object.assign(serviceInstance, {
async handle() {
return this._requestSvg({
schema,
valueMatcher: />([^<>]+)<\/desc>/,
url: 'http://example.com/foo.svg',
})
Expand Down
14 changes: 5 additions & 9 deletions services/create-service-tester.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.`
Expand Down
Loading

0 comments on commit e983f7b

Please sign in to comment.