This merges the `node-8` branch. The heavy lift was by @Daniel15 with refactoring from me and a patch by @RedSparr0w. * New API for registering services (#963) * Disable Node 6 tests on node-8 branch (#1423) * BaseService: Factor out methods _regex and _namedParamsForMatch (#1425) - Adjust test grouping - Rename data -> queryParams, text -> message * BaseService tests: Use Chai (#1450) * BaseService: make serviceData and badgeData explicit and declarative (#1451) * fix isValidStyle test (#1544) * Run tests in Node 9, not Node 6 (#1543)
This commit is contained in:
+11
-11
@@ -3,7 +3,7 @@ version: 2
|
||||
jobs:
|
||||
npm-install:
|
||||
docker:
|
||||
- image: shieldsio/shields-node-8:0.0.1
|
||||
- image: shieldsio/shields-ci-node-8:0.0.2
|
||||
working_directory: ~/repo
|
||||
steps:
|
||||
- checkout
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
|
||||
main:
|
||||
docker:
|
||||
- image: shieldsio/shields-node-8:0.0.1
|
||||
- image: shieldsio/shields-ci-node-8:0.0.2
|
||||
working_directory: ~/repo
|
||||
steps:
|
||||
- checkout
|
||||
@@ -57,9 +57,9 @@ jobs:
|
||||
name: Upload Greenkeeper lockfile
|
||||
command: greenkeeper-lockfile-upload
|
||||
|
||||
main@node-6:
|
||||
main@node-9:
|
||||
docker:
|
||||
- image: shieldsio/shields-node-6:0.0.1
|
||||
- image: shieldsio/shields-ci-node-9:0.0.2
|
||||
working_directory: ~/repo
|
||||
steps:
|
||||
- checkout
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
|
||||
frontend:
|
||||
docker:
|
||||
- image: shieldsio/shields-node-8:0.0.1
|
||||
- image: shieldsio/shields-ci-node-8:0.0.2
|
||||
working_directory: ~/repo
|
||||
steps:
|
||||
- checkout
|
||||
@@ -103,7 +103,7 @@ jobs:
|
||||
|
||||
services-pr:
|
||||
docker:
|
||||
- image: shieldsio/shields-node-8:0.0.1
|
||||
- image: shieldsio/shields-ci-node-8:0.0.2
|
||||
working_directory: ~/repo
|
||||
steps:
|
||||
- checkout
|
||||
@@ -135,9 +135,9 @@ jobs:
|
||||
echo 'This is not a pull request. Skipping.'
|
||||
fi
|
||||
|
||||
services-pr@node-6:
|
||||
services-pr@node-9:
|
||||
docker:
|
||||
- image: shieldsio/shields-node-6:0.0.1
|
||||
- image: shieldsio/shields-ci-node-9:0.0.2
|
||||
working_directory: ~/repo
|
||||
steps:
|
||||
- checkout
|
||||
@@ -171,7 +171,7 @@ jobs:
|
||||
|
||||
services-daily:
|
||||
docker:
|
||||
- image: shieldsio/shields-node-8:0.0.1
|
||||
- image: shieldsio/shields-ci-node-8:0.0.2
|
||||
working_directory: ~/repo
|
||||
steps:
|
||||
- checkout
|
||||
@@ -195,7 +195,7 @@ workflows:
|
||||
- main:
|
||||
requires:
|
||||
- npm-install
|
||||
- main@node-6:
|
||||
- main@node-9:
|
||||
requires:
|
||||
- npm-install
|
||||
- frontend:
|
||||
@@ -204,7 +204,7 @@ workflows:
|
||||
- services-pr:
|
||||
requires:
|
||||
- npm-install
|
||||
- services-pr@node-6:
|
||||
- services-pr@node-9:
|
||||
requires:
|
||||
- npm-install
|
||||
|
||||
|
||||
@@ -14,7 +14,8 @@ Prerequisites
|
||||
Updating the images
|
||||
-------------------
|
||||
|
||||
Note: Increment the patch version on the tag in each change.
|
||||
Note: Increment the patch version on the tag in each change. Check
|
||||
[Docker Hub][] to see the current versions.
|
||||
|
||||
```console
|
||||
IMAGE_TAG=<version> npm run circle-images:build
|
||||
@@ -24,6 +25,8 @@ IMAGE_TAG=<version> npm run circle-images:push
|
||||
|
||||
After pushing the images, bump the tag in `.circleci/config.yml`.
|
||||
|
||||
[Docker Hub]: https://hub.docker.com/u/shieldsio/
|
||||
|
||||
Reference
|
||||
---------
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
FROM node:6
|
||||
FROM node:9
|
||||
ADD .circleci/images/prepare-container.sh /root/prepare-container.sh
|
||||
RUN /root/prepare-container.sh
|
||||
RUN rm /root/prepare-container.sh
|
||||
@@ -1,3 +1,6 @@
|
||||
parserOptions:
|
||||
ecmaVersion: 8
|
||||
|
||||
env:
|
||||
node: true
|
||||
# We use Promise, Map, and occasional ES6 syntax.
|
||||
|
||||
@@ -105,6 +105,7 @@ function makeBadgeData(defaultLabel, overrides) {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
toArray,
|
||||
prependPrefix,
|
||||
isDataUri,
|
||||
isSixHex,
|
||||
|
||||
@@ -167,6 +167,18 @@ function handleRequest (makeBadge, handlerOptions) {
|
||||
});
|
||||
}
|
||||
|
||||
// Wrapper around `cachingRequest` that returns a promise rather than
|
||||
// needing to pass a callback.
|
||||
cachingRequest.asPromise = (uri, options) => new Promise((resolve, reject) => {
|
||||
cachingRequest(uri, options, (err, res, buffer) => {
|
||||
if (err) {
|
||||
reject(err);
|
||||
} else {
|
||||
resolve({res, buffer});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
vendorDomain.run(() => {
|
||||
handlerOptions.handler(filteredQueryParams, match, function sendBadge(format, badgeData) {
|
||||
if (serverUnresponsive) { return; }
|
||||
|
||||
+3
-3
@@ -54,14 +54,14 @@
|
||||
"lint": "eslint '**/*.js'",
|
||||
"danger": "danger",
|
||||
"test:js:frontend": "NODE_ENV=mocha mocha --require babel-polyfill --require babel-register 'frontend/**/*.spec.js'",
|
||||
"test:js:server": "mocha '*.spec.js' 'lib/**/*.spec.js' 'service-tests/**/*.spec.js'",
|
||||
"test:js:server": "mocha '*.spec.js' 'lib/**/*.spec.js' 'services/**/*.spec.js' 'service-tests/**/*.spec.js'",
|
||||
"test:services": "mocha --delay service-tests/runner/cli.js",
|
||||
"test:services:pr:prepare": "node service-tests/runner/pull-request-services-cli.js > pull-request-services.log",
|
||||
"test:services:pr:run": "mocha --delay service-tests/runner/cli.js --stdin < pull-request-services.log",
|
||||
"test:services:pr": "npm run test:services:pr:prepare && npm run test:services:pr:run",
|
||||
"test": "npm run lint && npm run test:js:frontend && npm run test:js:server",
|
||||
"circle-images:build": "docker build -t shieldsio/shields-node-8:${IMAGE_TAG} -f .circleci/images/node-8/Dockerfile . && docker build -t shieldsio/shields-node-6:${IMAGE_TAG} -f .circleci/images/node-6/Dockerfile .",
|
||||
"circle-images:push": "docker push shieldsio/shields-node-8:${IMAGE_TAG} && docker push shieldsio/shields-node-6:${IMAGE_TAG}",
|
||||
"circle-images:build": "docker build -t shieldsio/shields-ci-node-8:${IMAGE_TAG} -f .circleci/images/node-8/Dockerfile . && docker build -t shieldsio/shields-ci-node-9:${IMAGE_TAG} -f .circleci/images/node-9/Dockerfile .",
|
||||
"circle-images:push": "docker push shieldsio/shields-ci-node-8:${IMAGE_TAG} && docker push shieldsio/shields-ci-node-9:${IMAGE_TAG}",
|
||||
"frontend-depcheck": "check-node-version --node \">= 8.0\"",
|
||||
"server-depcheck": "check-node-version --node \">= 6.0 < 9.0\"",
|
||||
"postinstall": "npm run server-depcheck",
|
||||
|
||||
@@ -5,6 +5,7 @@ const dom = require('xmldom').DOMParser;
|
||||
const jp = require('jsonpath');
|
||||
const path = require('path');
|
||||
const prettyBytes = require('pretty-bytes');
|
||||
const glob = require('glob');
|
||||
const queryString = require('query-string');
|
||||
const semver = require('semver');
|
||||
const xml2js = require('xml2js');
|
||||
@@ -193,6 +194,12 @@ camp.notfound(/.*/, function(query, match, end, request) {
|
||||
|
||||
// Vendors.
|
||||
|
||||
// New-style services
|
||||
glob.sync(`${__dirname}/services/*.js`)
|
||||
.filter(path => !path.endsWith('base.js') && !path.endsWith('.spec.js'))
|
||||
.map(path => require(path))
|
||||
.forEach(serviceClass => serviceClass.register(camp, cache));
|
||||
|
||||
// JIRA issue integration
|
||||
camp.route(/^\/jira\/issue\/(http(?:s)?)\/(.+)\/([^/]+)\.(svg|png|gif|jpg|json)$/,
|
||||
cache(function (data, match, sendBadge, request) {
|
||||
@@ -726,48 +733,6 @@ cache(function (data, match, sendBadge, request) {
|
||||
});
|
||||
}));
|
||||
|
||||
// AppVeyor CI integration.
|
||||
camp.route(/^\/appveyor\/ci\/([^/]+\/[^/]+)(?:\/(.+))?\.(svg|png|gif|jpg|json)$/,
|
||||
cache(function(data, match, sendBadge, request) {
|
||||
var repo = match[1]; // eg, `gruntjs/grunt`.
|
||||
var branch = match[2];
|
||||
var format = match[3];
|
||||
var apiUrl = 'https://ci.appveyor.com/api/projects/' + repo;
|
||||
if (branch != null) {
|
||||
apiUrl += '/branch/' + branch;
|
||||
}
|
||||
var badgeData = getBadgeData('build', data);
|
||||
request(apiUrl, { headers: { 'Accept': 'application/json' } }, function(err, res, buffer) {
|
||||
if (err != null) {
|
||||
badgeData.text[1] = 'inaccessible';
|
||||
sendBadge(format, badgeData);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (res.statusCode === 404) {
|
||||
badgeData.text[1] = 'project not found or access denied';
|
||||
sendBadge(format, badgeData);
|
||||
return;
|
||||
}
|
||||
var data = JSON.parse(buffer);
|
||||
var status = data.build.status;
|
||||
if (status === 'success') {
|
||||
badgeData.text[1] = 'passing';
|
||||
badgeData.colorscheme = 'brightgreen';
|
||||
} else if (status !== 'running' && status !== 'queued') {
|
||||
badgeData.text[1] = 'failing';
|
||||
badgeData.colorscheme = 'red';
|
||||
} else {
|
||||
badgeData.text[1] = status;
|
||||
}
|
||||
sendBadge(format, badgeData);
|
||||
} catch(e) {
|
||||
badgeData.text[1] = 'invalid';
|
||||
sendBadge(format, badgeData);
|
||||
}
|
||||
});
|
||||
}));
|
||||
|
||||
// AppVeyor test status integration.
|
||||
camp.route(/^\/appveyor\/tests\/([^/]+\/[^/]+)(?:\/(.+))?\.(svg|png|gif|jpg|json)$/,
|
||||
cache(function(data, match, sendBadge, request) {
|
||||
|
||||
@@ -46,7 +46,7 @@ function servicesForTitle (title) {
|
||||
}
|
||||
|
||||
const services = matches[1].toLowerCase().split(' ');
|
||||
const blacklist = ['wip'];
|
||||
const blacklist = ['wip', 'rfc'];
|
||||
return difference(services, blacklist);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
'use strict';
|
||||
|
||||
const BaseService = require('./base');
|
||||
|
||||
/**
|
||||
* AppVeyor CI integration.
|
||||
*/
|
||||
module.exports = class AppVeyor extends BaseService {
|
||||
async handle({repo, branch}) {
|
||||
let apiUrl = 'https://ci.appveyor.com/api/projects/' + repo;
|
||||
if (branch != null) {
|
||||
apiUrl += '/branch/' + branch;
|
||||
}
|
||||
const {buffer, res} = await this._sendAndCacheRequest(apiUrl, {
|
||||
headers: { 'Accept': 'application/json' }
|
||||
});
|
||||
|
||||
if (res.statusCode === 404) {
|
||||
return {message: 'project not found or access denied'};
|
||||
}
|
||||
|
||||
const data = JSON.parse(buffer);
|
||||
const status = data.build.status;
|
||||
if (status === 'success') {
|
||||
return {message: 'passing', colorscheme: 'brightgreen'};
|
||||
} else if (status !== 'running' && status !== 'queued') {
|
||||
return {message: 'failing', colorscheme: 'red'};
|
||||
} else {
|
||||
return {message: status};
|
||||
}
|
||||
}
|
||||
|
||||
// Metadata
|
||||
static get category() {
|
||||
return 'build';
|
||||
}
|
||||
|
||||
static get uri() {
|
||||
return {
|
||||
format: '/appveyor/ci/([^/]+/[^/]+)(?:/(.+))?',
|
||||
capture: ['repo', 'branch']
|
||||
};
|
||||
}
|
||||
|
||||
static getExamples() {
|
||||
return [
|
||||
{
|
||||
uri: '/appveyor/ci/gruntjs/grunt',
|
||||
},
|
||||
{
|
||||
name: 'Branch',
|
||||
uri: '/appveyor/ci/gruntjs/grunt/master',
|
||||
},
|
||||
];
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,157 @@
|
||||
'use strict';
|
||||
|
||||
const {
|
||||
makeLogo,
|
||||
toArray,
|
||||
makeColor,
|
||||
setBadgeColor,
|
||||
} = require('../lib/badge-data');
|
||||
|
||||
module.exports = class BaseService {
|
||||
constructor({sendAndCacheRequest}) {
|
||||
this._sendAndCacheRequest = sendAndCacheRequest;
|
||||
}
|
||||
|
||||
/**
|
||||
* Asynchronous function to handle requests for this service. Takes the URI
|
||||
* parameters (as defined in the `uri` property), performs a request using
|
||||
* `this._sendAndCacheRequest`, and returns the badge data.
|
||||
*/
|
||||
async handle(namedParams) {
|
||||
throw new Error(
|
||||
`Handler not implemented for ${this.constructor.name}`
|
||||
);
|
||||
}
|
||||
|
||||
// Metadata
|
||||
|
||||
/**
|
||||
* Name of the category to sort this badge into (eg. "build"). Used to sort
|
||||
* the badges on the main shields.io website.
|
||||
*/
|
||||
static get category() {
|
||||
return 'unknown';
|
||||
}
|
||||
/**
|
||||
* Returns an object with two fields:
|
||||
* - format: Regular expression to use for URIs for this service's badges
|
||||
* - capture: Array of names for the capture groups in the regular
|
||||
* expression. The handler will be passed an object containing
|
||||
* the matches.
|
||||
*/
|
||||
static get uri() {
|
||||
throw new Error(`URI not defined for ${this.name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Default data for the badge. Can include things such as default logo, color,
|
||||
* etc. These defaults will be used if the value is not explicitly overridden
|
||||
* by either the handler or by the user via URL parameters.
|
||||
*/
|
||||
static get defaultBadgeData() {
|
||||
return {};
|
||||
}
|
||||
|
||||
/**
|
||||
* Example URIs for this service. These should use the format
|
||||
* specified in `uri`, and can be used to demonstrate how to use badges for
|
||||
* this service.
|
||||
*/
|
||||
static getExamples() {
|
||||
return [];
|
||||
}
|
||||
|
||||
static get _regex() {
|
||||
// Regular expressions treat "/" specially, so we need to escape them
|
||||
const escapedPath = this.uri.format.replace(/\//g, '\\/');
|
||||
const fullRegex = '^' + escapedPath + '.(svg|png|gif|jpg|json)$';
|
||||
return new RegExp(fullRegex);
|
||||
}
|
||||
|
||||
static _namedParamsForMatch(match) {
|
||||
// Assume the last match is the format, and drop match[0], which is the
|
||||
// entire match.
|
||||
const captures = match.slice(1, -1);
|
||||
|
||||
if (this.uri.capture.length !== captures.length) {
|
||||
throw new Error(
|
||||
`Service ${this.constructor.name} declares incorrect number of capture groups `+
|
||||
`(expected ${this.uri.capture.length}, got ${captures.length})`
|
||||
);
|
||||
}
|
||||
|
||||
const result = {};
|
||||
this.uri.capture.forEach((name, index) => {
|
||||
result[name] = captures[index];
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
static _makeBadgeData(overrides, serviceData) {
|
||||
const {
|
||||
style,
|
||||
label: overrideLabel,
|
||||
logo: overrideLogo,
|
||||
logoWidth: overrideLogoWidth,
|
||||
link: overrideLink,
|
||||
colorA: overrideColorA,
|
||||
colorB: overrideColorB,
|
||||
} = overrides;
|
||||
|
||||
const {
|
||||
label: serviceLabel,
|
||||
message: serviceMessage,
|
||||
color: serviceColor,
|
||||
link: serviceLink,
|
||||
} = serviceData;
|
||||
|
||||
const defaultLabel = this.category;
|
||||
const {
|
||||
color: defaultColor,
|
||||
logo: defaultLogo,
|
||||
} = this.defaultBadgeData;
|
||||
|
||||
const badgeData = {
|
||||
text: [
|
||||
overrideLabel || serviceLabel || defaultLabel,
|
||||
serviceMessage || 'n/a',
|
||||
],
|
||||
template: style,
|
||||
logo: makeLogo(style === 'social' ? defaultLogo : undefined, { logo: overrideLogo }),
|
||||
logoWidth: +overrideLogoWidth,
|
||||
links: toArray(overrideLink || serviceLink),
|
||||
colorA: makeColor(overrideColorA),
|
||||
};
|
||||
const color = makeColor(overrideColorB || serviceColor || defaultColor || 'lightgrey');
|
||||
setBadgeColor(badgeData, color);
|
||||
|
||||
return badgeData;
|
||||
}
|
||||
|
||||
static register(camp, handleRequest) {
|
||||
const serviceClass = this; // In a static context, "this" is the class.
|
||||
|
||||
camp.route(this._regex,
|
||||
handleRequest(async (queryParams, match, sendBadge, request) => {
|
||||
let serviceData;
|
||||
|
||||
try {
|
||||
const namedParams = this._namedParamsForMatch(match);
|
||||
const serviceInstance = new serviceClass({
|
||||
sendAndCacheRequest: request.asPromise,
|
||||
});
|
||||
serviceData = await serviceInstance.handle(namedParams);
|
||||
} catch (error) {
|
||||
serviceData = { message: 'error' };
|
||||
console.log(error);
|
||||
}
|
||||
|
||||
// Assumes the final capture group is the extension
|
||||
const format = match.slice(-1)[0];
|
||||
|
||||
const badgeData = this._makeBadgeData(queryParams, serviceData);
|
||||
|
||||
sendBadge(format, badgeData);
|
||||
}));
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
'use strict';
|
||||
|
||||
const { expect } = require('chai');
|
||||
const sinon = require('sinon');
|
||||
|
||||
const BaseService = require('./base');
|
||||
|
||||
require('../lib/register-chai-plugins.spec');
|
||||
|
||||
class DummyService extends BaseService {
|
||||
async handle({someArg}) {
|
||||
return {
|
||||
message: 'Hello ' + someArg,
|
||||
};
|
||||
}
|
||||
|
||||
static get category() { return 'cat'; }
|
||||
static get uri() {
|
||||
return {
|
||||
format: '/foo/([^/]+)',
|
||||
capture: ['someArg']
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('BaseService', () => {
|
||||
describe('_makeBadgeData', function () {
|
||||
describe('Overrides', function () {
|
||||
it('overrides the label', function () {
|
||||
const badgeData = DummyService._makeBadgeData({ label: 'purr count' }, { label: 'purrs' });
|
||||
expect(badgeData.text).to.deep.equal(['purr count', 'n/a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Service data', function () {
|
||||
it('applies the service message', function () {
|
||||
const badgeData = DummyService._makeBadgeData({}, { message: '10k' });
|
||||
expect(badgeData.text).to.deep.equal(['cat', '10k']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Defaults', function () {
|
||||
it('uses the default label', function () {
|
||||
const badgeData = DummyService._makeBadgeData({}, {});
|
||||
expect(badgeData.text).to.deep.equal(['cat', 'n/a']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ScoutCamp integration', function () {
|
||||
const expectedRouteRegex = /^\/foo\/([^/]+).(svg|png|gif|jpg|json)$/;
|
||||
|
||||
let mockCamp;
|
||||
let mockHandleRequest;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCamp = {
|
||||
route: sinon.spy(),
|
||||
};
|
||||
mockHandleRequest = sinon.spy();
|
||||
DummyService.register(mockCamp, mockHandleRequest);
|
||||
});
|
||||
|
||||
it('registers the service', () => {
|
||||
expect(mockCamp.route).to.have.been.calledOnce;
|
||||
expect(mockCamp.route).to.have.been.calledWith(expectedRouteRegex);
|
||||
});
|
||||
|
||||
it('handles the request', async () => {
|
||||
expect(mockHandleRequest).to.have.been.calledOnce;
|
||||
const requestHandler = mockHandleRequest.getCall(0).args[0];
|
||||
|
||||
const mockSendBadge = sinon.spy();
|
||||
const mockRequest = {
|
||||
asPromise: sinon.spy(),
|
||||
};
|
||||
await requestHandler(
|
||||
/*data*/ {},
|
||||
/*match*/ '/foo/bar.svg'.match(expectedRouteRegex),
|
||||
mockSendBadge,
|
||||
mockRequest
|
||||
);
|
||||
|
||||
expect(mockSendBadge).to.have.been.calledOnce;
|
||||
expect(mockSendBadge).to.have.been.calledWith(
|
||||
/*format*/ 'svg',
|
||||
{
|
||||
text: ['cat', 'Hello bar'],
|
||||
colorscheme: 'lightgrey',
|
||||
template: undefined,
|
||||
logo: undefined,
|
||||
logoWidth: NaN,
|
||||
links: [],
|
||||
colorA: undefined,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user