diff --git a/.eslintrc b/.eslintrc index 89dd83c..eb26ee1 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,3 +1,5 @@ +parser: babel-eslint + parserOptions: ecmaVersion": 6, sourceType: module diff --git a/README.md b/README.md index cffe415..5898c9d 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,96 @@ Install $ npm install --save teamcity-build-queue ``` +Usage +----- + +```js +const queueInfo = require('teamcity-build-queue'); + +queueInfo('http://teamcity.domain.com', { + projectPattern: 'project :: Pull requests :: *', + ignoreDependencies: true, // ignore builds with dependencies that have not been built yet + ignoreIncompatibleAgents: true // ignore builds without compatible agents +}) +.then(queue => { + console.log(queue.builds); + console.log(queue.size); +}); +``` + +API +--- + +### queueInfo(url[, options]) + +Returns a Promise, that resolves to object with builds from Build Queue. + +#### url + +Type: `string` + +The URL to TeamCity host. + +#### options + +Type: `object` + +#### options.projectPattern + +Type: `string` + +The pattern of project name to filter builds. + +If pattern is not specified, then all builds will be in the result. + +**Wildcards** + +```js +queueInfo('http://teamcity.domain.com', { + projectPattern: 'project :: Pull requests :: *' +}); + +// Will be taken into account builds the following assemblies: + +// project :: Pull requests :: build +// project :: Pull requests :: tests :: unit +// project :: Pull requests :: tests :: e2e +// project :: Pull requests :: docs +// project :: Pull requests :: deploy +// ... +``` + +**Brace Expansion** + +```js +queueInfo('http://teamcity.domain.com', { + projectPattern: 'project :: {Pull requests, dev} :: *' +}); + +// Will be taken into account builds the following configurations: +// +// project :: Pull requests :: build +// project :: Pull requests :: tests +// ... +// project :: dev :: build +// project :: dev :: tests +// ... +``` + +Read more about it in [micromatch](https://github.com/jonschlinkert/micromatch#features) package. + +#### options.ignoreDependencies + +Type: `string` Default: `false` + +To ignore builds with dependencies that have not been built yet. + +#### options.ignoreIncompatibleAgents + +Type: `string` Default: `false` + +To ignore builds without compatible agents. + License ------- diff --git a/index.js b/index.js new file mode 100644 index 0000000..c7f2c08 --- /dev/null +++ b/index.js @@ -0,0 +1,43 @@ +'use strict'; + +const assert = require('assert'); +const url = require('url'); + +const isUrl = require('is-url'); +const got = require('got'); + +const load = require('./lib/load-build'); +const filterBuilds = require('./lib/filter-builds'); + +const TEAMCITY_BUILD_QUEUE_PATHNAME = 'guestAuth/app/rest/buildQueue'; + +/** + * Loads info about TeamCity Build Queue. + * + * @param {string} teamcityUrl - the URL to TeamCity host. + * @param {object} options - the options. + * @param {string} options.projectPattern - the pattern of project name to filter builds. + * @param {boolean} options.ignoreDependencies - to ignore builds with dependencies that have not been built yet. + * @param {boolean} options.ignoreIncompatibleAgents - to ignore builds without compatible agents. + * @returns {{builds: object[], size: number}} + */ +module.exports = (teamcityUrl, options) => { + assert(isUrl(teamcityUrl), 'You should specify url to TeamCity'); + + return got(url.resolve(teamcityUrl, TEAMCITY_BUILD_QUEUE_PATHNAME), { json: true }) + .then(response => { + const body = response.body; + const builds = body && body.build || []; + + return Promise.all(builds.map(build => { + const buildUrl = url.resolve(teamcityUrl, build.href); + + return load(buildUrl); + })); + }) + .then(builds => filterBuilds(builds, options)) + .then(builds => ({ + builds: builds, + size: builds.length + })); +}; diff --git a/lib/filter-builds.js b/lib/filter-builds.js new file mode 100644 index 0000000..748632e --- /dev/null +++ b/lib/filter-builds.js @@ -0,0 +1,74 @@ +const mm = require('micromatch'); + +/** + * Removes slashes. + * + * @param {string} str - some string. + * @returns {string} + */ +function clearSlash(str) { + return str.replace(/\//g, ''); +} + +/** + * Returns `true` if the project name of build match the project pattern. + * + * @param {object} build - the build info. + * @param {string} projectPattern - the pattern of project name to filter builds. + * @returns {boolean} + */ +function isMatchProjectName(build, projectPattern) { + const buildType = build.buildType; + const projectName = buildType && buildType.projectName; + + return mm.isMatch(clearSlash(projectName), clearSlash(projectPattern), { nocase: true }); +} + +/** + * Returns `true` the build has dependency builds. + * + * @param {object} build - the build info. + * @returns {boolean} + */ +function hasDependencies(build) { + return build.waitReason === 'Build dependencies have not been built yet'; +} + +/** + * Returns `true` the build in queue and has compatible agents. + * + * @param {object} build - the build info. + * @returns {boolean} + */ +function isQueuedWithCompatibleAgents(build) { + const compatibleAgents = build.compatibleAgents; + const isQueued = build.state === 'queued'; + const hasCompatibleAgents = compatibleAgents && compatibleAgents.length !== 0; + + return isQueued ? hasCompatibleAgents : true; +} + +/** + * Filters builds by projectPattern and ignores inappropriate builds. + * + * @param {object[]} builds - the objects with build info. + * @param {string} options - the options. + * @param {string} options.projectPattern - the pattern of project name to filter builds. + * @param {boolean} options.ignoreDependencies - to ignore builds with dependencies that have not been built yet. + * @param {boolean} options.ignoreIncompatibleAgents - to ignore builds without compatible agents. + * @returns {object[]} + */ +module.exports = (builds, options) => { + const projectPattern = options.projectPattern; + + return builds.filter(build => { + if (projectPattern && !isMatchProjectName(build, projectPattern) + || options.ignoreDependencies && hasDependencies(build) + || options.ignoreIncompatibleAgents && !isQueuedWithCompatibleAgents(build) + ) { + return false; + } + + return true; + }); +}; diff --git a/lib/load-build.js b/lib/load-build.js new file mode 100644 index 0000000..0aad70e --- /dev/null +++ b/lib/load-build.js @@ -0,0 +1,46 @@ +const got = require('got'); + +/** + * Loads info about compatible agents of build. + * + * @param {string} buildUrl - the relative url to build. + * @returns {object} + */ +function loadBuild(buildUrl) { + return got(buildUrl, { json: true }) + .then(response => response.body); +} + +/** + * Loads info about compatible agents of build. + * + * @param {string} buildUrl - the relative url to build. + * @returns {object} + */ +function loadCompatibleAgents(buildUrl) { + const agentsUrl = `${buildUrl}/compatibleAgents`; + + return got(agentsUrl, { json: true }) + .then(response => response.body.agent) + .catch(() => ([])); // If build had time to start, compatible agents url returns error. +} + +/** + * Loads info about build with it compatible agents. + * + * @param {string} buildUrl - the relative url to build. + * @returns {object} + */ +module.exports = (buildUrl) => { + return Promise.all([ + loadBuild(buildUrl), + loadCompatibleAgents(buildUrl) + ]).then(data => { + const build = data[0]; + const compatibleAgents = data[1]; + + build.compatibleAgents = compatibleAgents; + + return build; + }); +}; diff --git a/package.json b/package.json index 0aa28e1..85cba2c 100644 --- a/package.json +++ b/package.json @@ -19,17 +19,30 @@ "engines": { "node": ">= 4.0" }, - "dependencies": {}, + "dependencies": { + "got": "6.3.0", + "is-url": "1.2.1", + "micromatch": "2.3.8" + }, "devDependencies": { "ava": "^0.15.0", + "babel-eslint": "^6.0.4", "coveralls": "^2.11.9", "eslint": "^2.10.2", "eslint-config-pedant": "^0.5.1", - "nyc": "^6.4.4" + "nyc": "^6.4.4", + "proxyquire": "^1.7.9", + "sinon": "^1.17.4", + "sinon-as-promised": "^4.0.0" }, "scripts": { "pretest": "eslint .", "test": "nyc ava", "coveralls": "nyc report --reporter=text-lcov | coveralls" + }, + "ava": { + "require": [ + "sinon-as-promised" + ] } } diff --git a/test/api.test.js b/test/api.test.js new file mode 100644 index 0000000..b94acdd --- /dev/null +++ b/test/api.test.js @@ -0,0 +1,57 @@ +'use strict'; + +const test = require('ava'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +test.beforeEach(t => { + t.context.gotStub = sinon.stub().resolves({ body: {} }); + t.context.loadStub = sinon.stub().resolves([]); + t.context.filterStub = sinon.stub().returns([]); + + t.context.run = proxyquire('../index', { + got: t.context.gotStub, + './lib/load-build': t.context.loadStub, + './lib/filter-builds': t.context.filterStub + }); +}); + +test('should get info by url', async t => { + await t.context.run('http://tc.url'); + + t.true(t.context.gotStub.calledWith('http://tc.url/guestAuth/app/rest/buildQueue')); +}); + +test('should returns builds of queue', async t => { + const builds = [{ id: '1' }]; + + t.context.filterStub.returns(builds); + + const queue = await t.context.run('http://tc.url'); + + t.deepEqual(queue.builds, builds); +}); + +test('should returns queue size', async t => { + const builds = [{ id: '1' }]; + + t.context.filterStub.returns(builds); + + const queue = await t.context.run('http://tc.url'); + + t.is(queue.size, builds.length); +}); + +test('should load build info', async t => { + t.context.gotStub.resolves({ + body: { + href: '/guestAuth/app/rest/buildQueue', + count: 1, + build: [{ href: 'build-url-1' }] + } + }); + + await t.context.run('http://tc.url'); + + t.true(t.context.loadStub.calledWith('http://tc.url/build-url-1')); +}); diff --git a/test/errors.test.js b/test/errors.test.js new file mode 100644 index 0000000..503899d --- /dev/null +++ b/test/errors.test.js @@ -0,0 +1,19 @@ +'use strict'; + +const test = require('ava'); + +const queueInfo = require('../index'); + +test('should throw error if url is not speҁified', t => { + t.throws( + () => queueInfo(), + 'You should specify url to TeamCity' + ); +}); + +test('should throw error if url is not url', t => { + t.throws( + () => queueInfo('bla'), + 'You should specify url to TeamCity' + ); +}); diff --git a/test/filter-builds.test.js b/test/filter-builds.test.js new file mode 100644 index 0000000..3e5f3f0 --- /dev/null +++ b/test/filter-builds.test.js @@ -0,0 +1,86 @@ +'use strict'; + +const test = require('ava'); + +const filter = require('../lib/filter-builds'); + +test('should filter builds by project name', t => { + const builds = [ + { buildType: { projectName: 'Project :: PR' } }, + { buildType: { projectName: 'Other Project :: PR' } } + ]; + const filteredBuilds = filter(builds, { projectPattern: 'Project :: PR' }); + + t.deepEqual(filteredBuilds, [{ buildType: { projectName: 'Project :: PR' } }]); +}); + +test('should filter builds by pattern of project name', t => { + const builds = [ + { buildType: { projectName: 'Project :: dev' } }, + { buildType: { projectName: 'Project :: PR' } }, + { buildType: { projectName: 'Other Project :: PR' } } + ]; + const filteredBuilds = filter(builds, { projectPattern: 'project :: *' }); + + t.deepEqual(filteredBuilds, [ + { buildType: { projectName: 'Project :: dev' } }, + { buildType: { projectName: 'Project :: PR' } } + ]); +}); + +test('should filter builds by pattern of project name with slash', t => { + const builds = [ + { buildType: { projectName: 'Project :: test/PR/deploy' } }, + { buildType: { projectName: 'Project :: dev' } } + ]; + const filteredBuilds = filter(builds, { projectPattern: 'project :: *' }); + + t.deepEqual(filteredBuilds, [ + { buildType: { projectName: 'Project :: test/PR/deploy' } }, + { buildType: { projectName: 'Project :: dev' } } + ]); +}); + +test('should ignore builds with dependency reason', t => { + const builds = [ + { waitReason: 'Build dependencies have not been built yet' }, + { waitReason: 'This build will not start because there are no compatible agents which can run it' } + ]; + + const filteredBuilds = filter(builds, { ignoreDependencies: true }); + + t.deepEqual(filteredBuilds, [{ + waitReason: 'This build will not start because there are no compatible agents which can run it' + }]); +}); + +test('should not ignore builds without wait reason', t => { + const builds = [{ state: 'queued' }]; + const filteredBuilds = filter(builds, { ignoreDependencies: true }); + + t.deepEqual(filteredBuilds, [{ state: 'queued' }]); +}); + +test('should ignore builds without info about compatible agents', t => { + const builds = [{ state: 'queued' }]; + const filteredBuilds = filter(builds, { ignoreIncompatibleAgents: true }); + + t.deepEqual(filteredBuilds, []); +}); + +test('should not ignore builds without info about compatible agents if state is not queued', t => { + const builds = [{ state: 'started' }]; + const filteredBuilds = filter(builds, { ignoreIncompatibleAgents: true }); + + t.deepEqual(filteredBuilds, [{ state: 'started' }]); +}); + +test('should ignore builds without compatible agents', t => { + const builds = [ + { state: 'queued', compatibleAgents: [] }, + { state: 'queued', compatibleAgents: [{ id: '1' }] } + ]; + const filteredBuilds = filter(builds, { ignoreIncompatibleAgents: true }); + + t.deepEqual(filteredBuilds, [{ state: 'queued', compatibleAgents: [{ id: '1' }] }]); +}); diff --git a/test/load-build.test.js b/test/load-build.test.js new file mode 100644 index 0000000..bad17b1 --- /dev/null +++ b/test/load-build.test.js @@ -0,0 +1,47 @@ +'use strict'; + +const test = require('ava'); +const proxyquire = require('proxyquire'); +const sinon = require('sinon'); + +test.beforeEach(t => { + t.context.gotStub = sinon.stub(); + t.context.gotStub.withArgs('build_url').resolves({ body: {} }); + t.context.gotStub.withArgs('build_url/compatibleAgents').resolves({ body: { agent: [] } }); + + t.context.load = proxyquire('../lib/load-build', { + got: t.context.gotStub + }); +}); + +test('should return build info', async t => { + t.context.gotStub.withArgs('build_url').resolves({ body: { id: '1' } }); + + const info = await t.context.load('build_url'); + + t.is(info.id, '1'); +}); + +test('should add empty field if no compatible agents', async t => { + const info = await t.context.load('build_url') + + t.deepEqual(info.compatibleAgents, []); +}); + +test('should add empty field with compatible agents', async t => { + const agents = [{ id: '1' }]; + + t.context.gotStub.withArgs('build_url/compatibleAgents').resolves({ body: { agent: agents } }); + + const info = await t.context.load('build_url'); + + t.deepEqual(info.compatibleAgents, agents); +}); + +test('should not throw if an error occurred while getting compatible agents', async t => { + t.context.gotStub.withArgs('build_url/compatibleAgents').rejects(); + + const info = await t.context.load('build_url'); + + t.deepEqual(info.compatibleAgents, []); +});