Refactor [TeamCity] and add tests (#2601)

This commit is contained in:
Caleb Cartwright
2019-01-01 23:19:33 -06:00
committed by Paul Melnikow
parent a9839845a1
commit 3bbe2482bc
6 changed files with 533 additions and 167 deletions

View File

@@ -0,0 +1,44 @@
'use strict'
const BaseJsonService = require('../base-json')
const serverSecrets = require('../../lib/server-secrets')
module.exports = class TeamCityBase extends BaseJsonService {
async fetch({
protocol,
hostAndPath,
apiPath,
schema,
qs = {},
errorMessages = {},
}) {
if (!hostAndPath) {
// If hostAndPath is undefined then the user specified the legacy default path
protocol = 'https'
hostAndPath = 'teamcity.jetbrains.com'
}
const url = `${protocol}://${hostAndPath}/${apiPath}`
const options = { qs }
// JetBrains API Auth Docs: https://confluence.jetbrains.com/display/TCD18/REST+API#RESTAPI-RESTAuthentication
if (serverSecrets && serverSecrets.teamcity_user) {
options.auth = {
user: serverSecrets.teamcity_user,
pass: serverSecrets.teamcity_pass,
}
} else {
qs.guest = 1
}
const defaultErrorMessages = {
404: 'build not found',
}
const errors = { ...defaultErrorMessages, ...errorMessages }
return this._requestJson({
url,
schema,
options,
errorMessages: errors,
})
}
}

View File

@@ -1,59 +1,42 @@
'use strict'
const LegacyService = require('../legacy-service')
const { makeBadgeData: getBadgeData } = require('../../lib/badge-data')
const Joi = require('joi')
const TeamCityBase = require('./teamcity-base')
// This legacy service should be rewritten to use e.g. BaseJsonService.
//
// Tips for rewriting:
// https://github.com/badges/shields/blob/master/doc/rewriting-services.md
//
// Do not base new services on this code.
function teamcityBadge(
url,
buildId,
advanced,
format,
data,
sendBadge,
request
) {
const apiUrl = `${url}/app/rest/builds/buildType:(id:${buildId})?guest=1`
const badgeData = getBadgeData('build', data)
request(
apiUrl,
{ headers: { Accept: 'application/json' } },
(err, res, buffer) => {
if (err != null) {
badgeData.text[1] = 'inaccessible'
sendBadge(format, badgeData)
return
const buildStatusTextRegex = /^Success|Failure|Error|Tests( failed: \d+( \(\d+ new\))?)?(,)?( passed: \d+)?(,)?( ignored: \d+)?(,)?( muted: \d+)?$/
const buildStatusSchema = Joi.object({
status: Joi.equal('SUCCESS', 'FAILURE', 'ERROR').required(),
statusText: Joi.string()
.regex(buildStatusTextRegex)
.required(),
}).required()
module.exports = class TeamCityBuild extends TeamCityBase {
static render({ status, statusText, useVerbose }) {
if (status === 'SUCCESS') {
return {
message: 'passing',
color: 'brightgreen',
}
try {
const data = JSON.parse(buffer)
if (advanced)
badgeData.text[1] = (
data.statusText ||
data.status ||
''
).toLowerCase()
else badgeData.text[1] = (data.status || '').toLowerCase()
if (data.status === 'SUCCESS') {
badgeData.colorscheme = 'brightgreen'
badgeData.text[1] = 'passing'
} else {
badgeData.colorscheme = 'red'
}
sendBadge(format, badgeData)
} catch (e) {
badgeData.text[1] = 'invalid'
sendBadge(format, badgeData)
} else if (statusText && useVerbose) {
return {
message: statusText.toLowerCase(),
color: 'red',
}
} else {
return {
message: status.toLowerCase(),
color: 'red',
}
}
)
}
}
static get defaultBadgeData() {
return {
label: 'build',
}
}
module.exports = class TeamcityBuild extends LegacyService {
static get category() {
return 'build'
}
@@ -61,64 +44,69 @@ module.exports = class TeamcityBuild extends LegacyService {
static get route() {
return {
base: 'teamcity',
format: '(?:codebetter|(http|https)/(.+)/(s|e))/([^/]+)',
capture: ['protocol', 'hostAndPath', 'verbosity', 'buildId'],
}
}
static get examples() {
return [
{
title: 'TeamCity CodeBetter',
previewUrl: 'codebetter/bt428',
title: 'TeamCity Build Status (CodeBetter)',
pattern: 'codebetter/:buildId',
namedParams: {
buildId: 'bt428',
},
staticPreview: this.render({
status: 'SUCCESS',
}),
},
{
title: 'TeamCity (simple build status)',
previewUrl: 'http/teamcity.jetbrains.com/s/bt345',
title: 'TeamCity Simple Build Status',
pattern: ':protocol/:hostAndPath/s/:buildId',
namedParams: {
protocol: 'https',
hostAndPath: 'https/teamcity.jetbrains.com',
buildId: 'bt428',
},
staticPreview: this.render({
status: 'SUCCESS',
}),
},
{
title: 'TeamCity (full build status)',
previewUrl: 'http/teamcity.jetbrains.com/e/bt345',
title: 'TeamCity Full Build Status',
pattern: ':protocol/:hostAndPath/e/:buildId',
namedParams: {
protocol: 'https',
hostAndPath: 'https/teamcity.jetbrains.com',
buildId: 'bt345',
},
staticPreview: this.render({
status: 'FAILURE',
statusText: 'Tests failed: 4, passed: 1103, ignored: 2',
useVerbose: true,
}),
keywords: ['test', 'test results'],
},
]
}
static registerLegacyRouteHandler({ camp, cache }) {
// Old url for CodeBetter TeamCity instance.
camp.route(
/^\/teamcity\/codebetter\/(.*)\.(svg|png|gif|jpg|json)$/,
cache((data, match, sendBadge, request) => {
const buildType = match[1] // eg, `bt428`.
const format = match[2]
teamcityBadge(
'http://teamcity.codebetter.com',
buildType,
false,
format,
data,
sendBadge,
request
)
})
)
// Generic TeamCity instance
camp.route(
/^\/teamcity\/(http|https)\/(.*)\/(s|e)\/(.*)\.(svg|png|gif|jpg|json)$/,
cache((data, match, sendBadge, request) => {
const scheme = match[1]
const serverUrl = match[2]
const advanced = match[3] === 'e'
const buildType = match[4] // eg, `bt428`.
const format = match[5]
teamcityBadge(
`${scheme}://${serverUrl}`,
buildType,
advanced,
format,
data,
sendBadge,
request
)
})
)
async handle({ protocol, hostAndPath, verbosity, buildId }) {
// JetBrains Docs: https://confluence.jetbrains.com/display/TCD18/REST+API#RESTAPI-BuildStatusIcon
const buildLocator = `buildType:(id:${buildId})`
const apiPath = `app/rest/builds/${encodeURIComponent(buildLocator)}`
const json = await this.fetch({
protocol,
hostAndPath,
apiPath,
schema: buildStatusSchema,
})
// If the verbosity is 'e' then the user has requested the verbose (full) build status.
const useVerbose = verbosity === 'e'
return this.constructor.render({
status: json.status,
statusText: json.statusText,
useVerbose,
})
}
}

View File

@@ -0,0 +1,177 @@
'use strict'
const Joi = require('joi')
const { colorScheme } = require('../test-helpers')
const { withRegex } = require('../test-validators')
const {
mockTeamCityCreds,
pass,
user,
restore,
} = require('./teamcity-test-helpers')
const t = (module.exports = require('../create-service-tester')())
const buildStatusValues = Joi.equal('passing', 'failure', 'error').required()
const buildStatusTextRegex = /^success|failure|error|tests( failed: \d+( \(\d+ new\))?)?(,)?( passed: \d+)?(,)?( ignored: \d+)?(,)?( muted: \d+)?$/
t.create('live: codebetter unknown build')
.get('/codebetter/btabc.json')
.expectJSON({ name: 'build', value: 'build not found' })
t.create('live: codebetter known build')
.get('/codebetter/bt428.json')
.expectJSONTypes(
Joi.object().keys({
name: 'build',
value: buildStatusValues,
})
)
t.create('live: simple status for known build')
.get('/https/teamcity.jetbrains.com/s/bt345.json')
.expectJSONTypes(
Joi.object().keys({
name: 'build',
value: buildStatusValues,
})
)
t.create('live: full status for known build')
.get('/https/teamcity.jetbrains.com/e/bt345.json')
.expectJSONTypes(
Joi.object().keys({
name: 'build',
value: withRegex(buildStatusTextRegex),
})
)
t.create('codebetter success build')
.get('/codebetter/bt123.json?style=_shields_test')
.intercept(nock =>
nock('https://teamcity.jetbrains.com/app/rest/builds')
.get(`/${encodeURIComponent('buildType:(id:bt123)')}`)
.query({ guest: 1 })
.reply(200, {
status: 'SUCCESS',
statusText: 'Success',
})
)
.expectJSON({
name: 'build',
value: 'passing',
colorB: colorScheme.brightgreen,
})
t.create('codebetter failure build')
.get('/codebetter/bt123.json?style=_shields_test')
.intercept(nock =>
nock('https://teamcity.jetbrains.com/app/rest/builds')
.get(`/${encodeURIComponent('buildType:(id:bt123)')}`)
.query({ guest: 1 })
.reply(200, {
status: 'FAILURE',
statusText: 'Tests failed: 2',
})
)
.expectJSON({
name: 'build',
value: 'failure',
colorB: colorScheme.red,
})
t.create('simple build status with passed build')
.get('/https/myteamcity.com:8080/s/bt321.json?style=_shields_test')
.intercept(nock =>
nock('https://myteamcity.com:8080/app/rest/builds')
.get(`/${encodeURIComponent('buildType:(id:bt321)')}`)
.query({ guest: 1 })
.reply(200, {
status: 'SUCCESS',
statusText: 'Tests passed: 100',
})
)
.expectJSON({
name: 'build',
value: 'passing',
colorB: colorScheme.brightgreen,
})
t.create('simple build status with failed build')
.get('/https/myteamcity.com:8080/s/bt999.json?style=_shields_test')
.intercept(nock =>
nock('https://myteamcity.com:8080/app/rest/builds')
.get(`/${encodeURIComponent('buildType:(id:bt999)')}`)
.query({ guest: 1 })
.reply(200, {
status: 'FAILURE',
statusText: 'Tests failed: 10 (2 new)',
})
)
.expectJSON({
name: 'build',
value: 'failure',
colorB: colorScheme.red,
})
t.create('full build status with passed build')
.get('/https/selfhosted.teamcity.com:4000/e/bt321.json?style=_shields_test')
.intercept(nock =>
nock('https://selfhosted.teamcity.com:4000/app/rest/builds')
.get(`/${encodeURIComponent('buildType:(id:bt321)')}`)
.query({ guest: 1 })
.reply(200, {
status: 'SUCCESS',
statusText: 'Tests passed: 100, ignored: 3',
})
)
.expectJSON({
name: 'build',
value: 'passing',
colorB: colorScheme.brightgreen,
})
t.create('full build status with failed build')
.get(
'/https/selfhosted.teamcity.com:4000/tc/e/bt567.json?style=_shields_test'
)
.intercept(nock =>
nock('https://selfhosted.teamcity.com:4000/tc/app/rest/builds')
.get(`/${encodeURIComponent('buildType:(id:bt567)')}`)
.query({ guest: 1 })
.reply(200, {
status: 'FAILURE',
statusText: 'Tests failed: 10 (2 new), passed: 99',
})
)
.expectJSON({
name: 'build',
value: 'tests failed: 10 (2 new), passed: 99',
colorB: colorScheme.red,
})
t.create('with auth')
.before(mockTeamCityCreds)
.get('/https/selfhosted.teamcity.com/e/bt678.json?style=_shields_test')
.intercept(nock =>
nock('https://selfhosted.teamcity.com/app/rest/builds')
.get(`/${encodeURIComponent('buildType:(id:bt678)')}`)
.query({})
// This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request.
// Without this the request wouldn't match and the test would fail.
.basicAuth({
user,
pass,
})
.reply(200, {
status: 'FAILURE',
statusText:
'Tests failed: 1 (1 new), passed: 50246, ignored: 1, muted: 12',
})
)
.finally(restore)
.expectJSON({
name: 'build',
value: 'tests failed: 1 (1 new), passed: 50246, ignored: 1, muted: 12',
colorB: colorScheme.red,
})

View File

@@ -1,92 +1,107 @@
'use strict'
const LegacyService = require('../legacy-service')
const { makeBadgeData: getBadgeData } = require('../../lib/badge-data')
const {
coveragePercentage: coveragePercentageColor,
} = require('../../lib/color-formatters')
const Joi = require('joi')
const { InvalidResponse } = require('../errors')
const TeamCityBase = require('./teamcity-base')
const { coveragePercentage } = require('../../lib/color-formatters')
// This legacy service should be rewritten to use e.g. BaseJsonService.
//
// Tips for rewriting:
// https://github.com/badges/shields/blob/master/doc/rewriting-services.md
//
// Do not base new services on this code.
module.exports = class TeamcityCoverage extends LegacyService {
static get category() {
return 'quality'
}
const buildStatisticsSchema = Joi.object({
property: Joi.array()
.items(
Joi.object({
name: Joi.string().required(),
value: Joi.string().required(),
})
)
.required(),
}).required()
static get route() {
module.exports = class TeamCityCoverage extends TeamCityBase {
static render({ coverage }) {
return {
base: 'teamcity/coverage',
pattern: ':buildType',
message: `${coverage.toFixed(0)}%`,
color: coveragePercentage(coverage),
}
}
static get examples() {
return [
{
title: 'TeamCity CodeBetter Coverage',
namedParams: { buildType: 'bt428' },
staticPreview: { message: '55%', color: 'yellow' },
},
]
}
static get defaultBadgeData() {
return {
label: 'coverage',
}
}
static registerLegacyRouteHandler({ camp, cache }) {
camp.route(
/^\/teamcity\/coverage\/(.*)\.(svg|png|gif|jpg|json)$/,
cache((data, match, sendBadge, request) => {
const buildType = match[1] // eg, `bt428`.
const format = match[2]
const apiUrl = `http://teamcity.codebetter.com/app/rest/builds/buildType:(id:${buildType})/statistics?guest=1`
const badgeData = getBadgeData('coverage', data)
request(
apiUrl,
{ headers: { Accept: 'application/json' } },
(err, res, buffer) => {
if (err != null) {
badgeData.text[1] = 'inaccessible'
sendBadge(format, badgeData)
return
}
try {
const data = JSON.parse(buffer)
let covered
let total
static get category() {
return 'quality'
}
data.property.forEach(property => {
if (property.name === 'CodeCoverageAbsSCovered') {
covered = property.value
} else if (property.name === 'CodeCoverageAbsSTotal') {
total = property.value
}
})
static get route() {
return {
base: 'teamcity/coverage',
format: '(?:(http|https)/(.+)/)?([^/]+)',
capture: ['protocol', 'hostAndPath', 'buildId'],
}
}
if (covered === undefined || total === undefined) {
badgeData.text[1] = 'malformed'
sendBadge(format, badgeData)
return
}
static get examples() {
return [
{
title: 'TeamCity Coverage (CodeBetter)',
pattern: ':buildId',
namedParams: {
buildId: 'bt428',
},
staticPreview: this.render({
coverage: 82,
}),
},
{
title: 'TeamCity Coverage',
pattern: ':protocol/:hostAndPath/s/:buildId',
namedParams: {
protocol: 'https',
hostAndPath: 'https/teamcity.jetbrains.com',
buildId: 'bt428',
},
staticPreview: this.render({
coverage: 95,
}),
},
]
}
const percentage = (covered / total) * 100
badgeData.text[1] = `${percentage.toFixed(0)}%`
badgeData.colorscheme = coveragePercentageColor(percentage)
sendBadge(format, badgeData)
} catch (e) {
badgeData.text[1] = 'invalid'
sendBadge(format, badgeData)
}
}
)
})
)
async handle({ protocol, hostAndPath, buildId }) {
// JetBrains Docs: https://confluence.jetbrains.com/display/TCD18/REST+API#RESTAPI-Statistics
const buildLocator = `buildType:(id:${buildId})`
const apiPath = `app/rest/builds/${encodeURIComponent(
buildLocator
)}/statistics`
const data = await this.fetch({
protocol,
hostAndPath,
apiPath,
schema: buildStatisticsSchema,
})
const { coverage } = this.transform({ data })
return this.constructor.render({ coverage })
}
transform({ data }) {
let covered, total
for (const p of data.property) {
if (p.name === 'CodeCoverageAbsSCovered') {
covered = +p.value
} else if (p.name === 'CodeCoverageAbsSTotal') {
total = +p.value
}
if (covered !== undefined && total !== undefined) {
const coverage = covered ? (covered / total) * 100 : 0
return { coverage }
}
}
throw new InvalidResponse({ prettyMessage: 'no coverage data available' })
}
}

View File

@@ -0,0 +1,117 @@
'use strict'
const Joi = require('joi')
const { colorScheme } = require('../test-helpers')
const { isIntegerPercentage } = require('../test-validators')
const {
mockTeamCityCreds,
pass,
user,
restore,
} = require('./teamcity-test-helpers')
const t = (module.exports = require('../create-service-tester')())
t.create('live: valid buildId')
.get('/bt428.json')
.expectJSONTypes(
Joi.object().keys({
name: 'coverage',
value: isIntegerPercentage,
})
)
t.create('live: specified instance valid buildId')
.get('/https/teamcity.jetbrains.com/bt428.json')
.expectJSONTypes(
Joi.object().keys({
name: 'coverage',
value: isIntegerPercentage,
})
)
t.create('live: invalid buildId')
.get('/btABC999.json')
.expectJSON({ name: 'coverage', value: 'build not found' })
t.create('live: specified instance invalid buildId')
.get('/https/teamcity.jetbrains.com/btABC000.json')
.expectJSON({ name: 'coverage', value: 'build not found' })
t.create('404 latest build error response')
.get('/bt123.json')
.intercept(nock =>
nock('https://teamcity.jetbrains.com/app/rest/builds')
.get(`/${encodeURIComponent('buildType:(id:bt123)')}/statistics`)
.query({ guest: 1 })
.reply(404)
)
.expectJSON({ name: 'coverage', value: 'build not found' })
t.create('no coverage data for build')
.get('/bt234.json')
.intercept(nock =>
nock('https://teamcity.jetbrains.com/app/rest/builds')
.get(`/${encodeURIComponent('buildType:(id:bt234)')}/statistics`)
.query({ guest: 1 })
.reply(200, { property: [] })
)
.expectJSON({ name: 'coverage', value: 'no coverage data available' })
t.create('zero lines covered')
.get('/bt345.json?style=_shields_test')
.intercept(nock =>
nock('https://teamcity.jetbrains.com/app/rest/builds')
.get(`/${encodeURIComponent('buildType:(id:bt345)')}/statistics`)
.query({ guest: 1 })
.reply(200, {
property: [
{
name: 'CodeCoverageAbsSCovered',
value: '0',
},
{
name: 'CodeCoverageAbsSTotal',
value: '345',
},
],
})
)
.expectJSON({
name: 'coverage',
value: '0%',
colorB: colorScheme.red,
})
t.create('with auth, lines covered')
.before(mockTeamCityCreds)
.get('/https/selfhosted.teamcity.com/bt678.json?style=_shields_test')
.intercept(nock =>
nock('https://selfhosted.teamcity.com/app/rest/builds')
.get(`/${encodeURIComponent('buildType:(id:bt678)')}/statistics`)
.query({})
// This ensures that the expected credentials from serverSecrets are actually being sent with the HTTP request.
// Without this the request wouldn't match and the test would fail.
.basicAuth({
user,
pass,
})
.reply(200, {
property: [
{
name: 'CodeCoverageAbsSCovered',
value: '82',
},
{
name: 'CodeCoverageAbsSTotal',
value: '100',
},
],
})
)
.finally(restore)
.expectJSON({
name: 'coverage',
value: '82%',
colorB: colorScheme.yellowgreen,
})

View File

@@ -0,0 +1,25 @@
'use strict'
const sinon = require('sinon')
const serverSecrets = require('../../lib/server-secrets')
const user = 'admin'
const pass = 'password'
function mockTeamCityCreds() {
serverSecrets['teamcity_user'] = undefined
serverSecrets['teamcity_pass'] = undefined
sinon.stub(serverSecrets, 'teamcity_user').value(user)
sinon.stub(serverSecrets, 'teamcity_pass').value(pass)
}
function restore() {
sinon.restore()
}
module.exports = {
user,
pass,
mockTeamCityCreds,
restore,
}