Extend 'ifBuilt' config key to set property based on whether certain repositories...
[WebKit.git] / Websites / perf.webkit.org / tools / js / buildbot-syncer.js
1 'use strict';
2
3 let assert = require('assert');
4
5 require('./v3-models.js');
6
7 class BuildbotBuildEntry {
8     constructor(syncer, rawData)
9     {
10         assert.equal(syncer.builderName(), rawData['builderName']);
11
12         this._syncer = syncer;
13         this._slaveName = null;
14         this._buildRequestId = null;
15         this._isInProgress = rawData['currentStep'] || (rawData['times'] && !rawData['times'][1]);
16         this._buildNumber = rawData['number'];
17
18         for (let propertyTuple of (rawData['properties'] || [])) {
19             // e.g. ['build_request_id', '16733', 'Force Build Form']
20             const name = propertyTuple[0];
21             const value = propertyTuple[1];
22             if (name == syncer._slavePropertyName)
23                 this._slaveName = value;
24             else if (name == syncer._buildRequestPropertyName)
25                 this._buildRequestId = value;
26         }
27     }
28
29     syncer() { return this._syncer; }
30     buildNumber() { return this._buildNumber; }
31     slaveName() { return this._slaveName; }
32     buildRequestId() { return this._buildRequestId; }
33     isPending() { return typeof(this._buildNumber) != 'number'; }
34     isInProgress() { return this._isInProgress; }
35     hasFinished() { return !this.isPending() && !this.isInProgress(); }
36     url() { return this.isPending() ? this._syncer.url() : this._syncer.urlForBuildNumber(this._buildNumber); }
37
38     buildRequestStatusIfUpdateIsNeeded(request)
39     {
40         assert.equal(request.id(), this._buildRequestId);
41         if (!request)
42             return null;
43         if (this.isPending()) {
44             if (request.isPending())
45                 return 'scheduled';
46         } else if (this.isInProgress()) {
47             if (!request.hasStarted() || request.isScheduled())
48                 return 'running';
49         } else if (this.hasFinished()) {
50             if (!request.hasFinished())
51                 return 'failedIfNotCompleted';
52         }
53         return null;
54     }
55 }
56
57
58 class BuildbotSyncer {
59
60     constructor(remote, object, commonConfigurations)
61     {
62         this._remote = remote;
63         this._type = null;
64         this._configurations = [];
65         this._repositoryGroups = commonConfigurations.repositoryGroups;
66         this._slavePropertyName = commonConfigurations.slaveArgument;
67         this._platformPropertyName = commonConfigurations.platformArgument;
68         this._buildRequestPropertyName = commonConfigurations.buildRequestArgument;
69         this._builderName = object.builder;
70         this._builderID = object.builderID;
71         this._slaveList = object.slaveList;
72         this._entryList = null;
73         this._slavesWithNewRequests = new Set;
74     }
75
76     builderName() { return this._builderName; }
77
78     addTestConfiguration(test, platform, propertiesTemplate)
79     {
80         assert(test instanceof Test);
81         assert(platform instanceof Platform);
82         assert(this._type == null || this._type == 'tester');
83         this._type = 'tester';
84         this._configurations.push({test, platform, propertiesTemplate});
85     }
86     testConfigurations() { return this._type == 'tester' ? this._configurations : []; }
87
88     addBuildConfiguration(platform, propertiesTemplate)
89     {
90         assert(platform instanceof Platform);
91         assert(this._type == null || this._type == 'builder');
92         this._type = 'builder';
93         this._configurations.push({test: null, platform, propertiesTemplate});
94     }
95     buildConfigurations() { return this._type == 'builder' ? this._configurations : []; }
96
97     isTester() { return this._type == 'tester'; }
98
99     repositoryGroups() { return this._repositoryGroups; }
100
101     matchesConfiguration(request)
102     {
103         return this._configurations.some((config) => config.platform == request.platform() && config.test == request.test());
104     }
105
106     scheduleRequest(newRequest, requestsInGroup, slaveName)
107     {
108         assert(!this._slavesWithNewRequests.has(slaveName));
109         let properties = this._propertiesForBuildRequest(newRequest, requestsInGroup);
110
111         assert.equal(!this._slavePropertyName, !slaveName);
112         if (this._slavePropertyName)
113             properties[this._slavePropertyName] = slaveName;
114
115         if (this._platformPropertyName)
116             properties[this._platformPropertyName] = newRequest.platform().name();
117
118         this._slavesWithNewRequests.add(slaveName);
119         return this._remote.postFormUrlencodedData(this.pathForForceBuild(), properties);
120     }
121
122     scheduleRequestInGroupIfAvailable(newRequest, requestsInGroup, slaveName)
123     {
124         assert(newRequest instanceof BuildRequest);
125
126         if (!this.matchesConfiguration(newRequest))
127             return null;
128
129         let hasPendingBuildsWithoutSlaveNameSpecified = false;
130         let usedSlaves = new Set;
131         for (let entry of this._entryList) {
132             let entryPreventsNewRequest = entry.isPending();
133             if (entry.isInProgress()) {
134                 const requestInProgress = BuildRequest.findById(entry.buildRequestId());
135                 if (!requestInProgress || requestInProgress.testGroupId() != newRequest.testGroupId())
136                     entryPreventsNewRequest = true;
137             }
138             if (entryPreventsNewRequest) {
139                 if (!entry.slaveName())
140                     hasPendingBuildsWithoutSlaveNameSpecified = true;
141                 usedSlaves.add(entry.slaveName());
142             }
143         }
144
145         if (!this._slaveList || hasPendingBuildsWithoutSlaveNameSpecified) {
146             if (usedSlaves.size || this._slavesWithNewRequests.size)
147                 return null;
148             return this.scheduleRequest(newRequest, requestsInGroup, null);
149         }
150
151         if (slaveName) {
152             if (!usedSlaves.has(slaveName) && !this._slavesWithNewRequests.has(slaveName))
153                 return this.scheduleRequest(newRequest, requestsInGroup, slaveName);
154             return null;
155         }
156
157         for (let slaveName of this._slaveList) {
158             if (!usedSlaves.has(slaveName) && !this._slavesWithNewRequests.has(slaveName))
159                 return this.scheduleRequest(newRequest, requestsInGroup, slaveName);
160         }
161
162         return null;
163     }
164
165     pullBuildbot(count)
166     {
167         return this._remote.getJSON(this.pathForPendingBuildsJSON()).then((content) => {
168             let pendingEntries = content.map((entry) => new BuildbotBuildEntry(this, entry));
169             return this._pullRecentBuilds(count).then((entries) => {
170                 let entryByRequest = {};
171
172                 for (let entry of pendingEntries)
173                     entryByRequest[entry.buildRequestId()] = entry;
174
175                 for (let entry of entries)
176                     entryByRequest[entry.buildRequestId()] = entry;
177
178                 let entryList = [];
179                 for (let id in entryByRequest)
180                     entryList.push(entryByRequest[id]);
181
182                 this._entryList = entryList;
183                 this._slavesWithNewRequests.clear();
184
185                 return entryList;
186             });
187         });
188     }
189
190     _pullRecentBuilds(count)
191     {
192         if (!count)
193             return Promise.resolve([]);
194
195         let selectedBuilds = new Array(count);
196         for (let i = 0; i < count; i++)
197             selectedBuilds[i] = -i - 1;
198
199         return this._remote.getJSON(this.pathForBuildJSON(selectedBuilds)).then((content) => {
200             const entries = [];
201             for (let index of selectedBuilds) {
202                 const entry = content[index];
203                 if (entry && !entry['error'])
204                     entries.push(new BuildbotBuildEntry(this, entry));
205             }
206             return entries;
207         });
208     }
209
210     pathForPendingBuildsJSON() { return `/json/builders/${escape(this._builderName)}/pendingBuilds`; }
211     pathForBuildJSON(selectedBuilds)
212     {
213         return `/json/builders/${escape(this._builderName)}/builds/?` + selectedBuilds.map((number) => 'select=' + number).join('&');
214     }
215     pathForForceBuild() { return `/builders/${escape(this._builderName)}/force`; }
216
217     url() { return this._remote.url(`/builders/${escape(this._builderName)}/`); }
218     urlForBuildNumber(number) { return this._remote.url(`/builders/${escape(this._builderName)}/builds/${number}`); }
219
220     _propertiesForBuildRequest(buildRequest, requestsInGroup)
221     {
222         assert(buildRequest instanceof BuildRequest);
223         assert(requestsInGroup[0] instanceof BuildRequest);
224
225         const commitSet = buildRequest.commitSet();
226         assert(commitSet instanceof CommitSet);
227
228         const repositoryByName = {};
229         for (let repository of commitSet.repositories())
230             repositoryByName[repository.name()] = repository;
231
232         const matchingConfiguration = this._configurations.find((config) => config.platform == buildRequest.platform() && config.test == buildRequest.test());
233         assert(matchingConfiguration, `Build request ${buildRequest.id()} does not match a configuration in the builder "${this._builderName}"`);
234         const propertiesTemplate = matchingConfiguration.propertiesTemplate;
235
236         const repositoryGroup = buildRequest.repositoryGroup();
237         assert(repositoryGroup.accepts(commitSet), `Build request ${buildRequest.id()} does not specify a commit set accepted by the repository group ${repositoryGroup.id()}`);
238
239         const repositoryGroupConfiguration = this._repositoryGroups[repositoryGroup.name()];
240         assert(repositoryGroupConfiguration, `Build request ${buildRequest.id()} uses an unsupported repository group "${repositoryGroup.name()}"`);
241
242         const properties = {};
243         for (let propertyName in propertiesTemplate)
244             properties[propertyName] = propertiesTemplate[propertyName];
245
246         const repositoryGroupTemplate = buildRequest.isBuild() ? repositoryGroupConfiguration.buildPropertiesTemplate : repositoryGroupConfiguration.testPropertiesTemplate;
247         for (let propertyName in repositoryGroupTemplate) {
248             let value = repositoryGroupTemplate[propertyName];
249             const type = typeof(value) == 'object' ? value.type : 'string';
250             switch (type) {
251             case 'string':
252                 break;
253             case 'revision':
254                 value = commitSet.revisionForRepository(value.repository);
255                 break;
256             case 'patch':
257                 const patch = commitSet.patchForRepository(value.repository);
258                 if (!patch)
259                     continue;
260                 value = patch.url();
261                 break;
262             case 'roots':
263                 const rootFiles = commitSet.allRootFiles();
264                 if (!rootFiles.length)
265                     continue;
266                 value = JSON.stringify(rootFiles.map((file) => ({url: file.url()})));
267                 break;
268             case 'ownedRevisions':
269                 const ownedRepositories = commitSet.ownedRepositoriesForOwnerRepository(value.ownerRepository);
270                 if (!ownedRepositories)
271                     continue;
272
273                 const revisionInfo = {};
274                 revisionInfo[value.ownerRepository.name()] = ownedRepositories.map((ownedRepository) => {
275                     return {
276                         'revision': commitSet.revisionForRepository(ownedRepository),
277                         'repository': ownedRepository.name(),
278                         'ownerRevision': commitSet.ownerRevisionForRepository(ownedRepository)
279                     };
280                 });
281                 value = JSON.stringify(revisionInfo);
282                 break;
283             case 'conditional':
284                 switch (value.condition) {
285                 case 'built':
286                     const repositoryRequirement = value.repositoryRequirement;
287                     const meetRepositoryRequirement = !repositoryRequirement.length || repositoryRequirement.some((repository) => commitSet.requiresBuildForRepository(repository));
288                     if (!meetRepositoryRequirement || !requestsInGroup.some((otherRequest) => otherRequest.isBuild() && otherRequest.commitSet() == buildRequest.commitSet()))
289                         continue;
290                     break;
291                 case 'requiresBuild':
292                     const requiresBuild = value.repositoriesToCheck.some((repository) => commitSet.requiresBuildForRepository(repository));
293                     if (!requiresBuild)
294                         continue;
295                     break;
296                 }
297                 value = value.value;
298             }
299             properties[propertyName] = value;
300         }
301         properties[this._buildRequestPropertyName] = buildRequest.id();
302
303         return properties;
304     }
305
306     _revisionSetFromCommitSetWithExclusionList(commitSet, exclusionList)
307     {
308         const revisionSet = {};
309         for (let repository of commitSet.repositories()) {
310             if (exclusionList.indexOf(repository.name()) >= 0)
311                 continue;
312             const commit = commitSet.commitForRepository(repository);
313             revisionSet[repository.name()] = {
314                 id: commit.id(),
315                 time: +commit.time(),
316                 repository: repository.name(),
317                 revision: commit.revision(),
318             };
319         }
320         return revisionSet;
321     }
322
323     static _loadConfig(remote, config, builderNameToIDMap)
324     {
325         assert(builderNameToIDMap);
326         const types = config['types'] || {};
327         const builders = config['builders'] || {};
328
329         assert(config.buildRequestArgument, 'buildRequestArgument must specify the name of the property used to store the build request ID');
330
331         assert.equal(typeof(config.repositoryGroups), 'object', 'repositoryGroups must specify a dictionary from the name to its definition');
332
333         const repositoryGroups = {};
334         for (const name in config.repositoryGroups)
335             repositoryGroups[name] = this._parseRepositoryGroup(name, config.repositoryGroups[name]);
336
337         const commonConfigurations = {
338             repositoryGroups,
339             slaveArgument: config.slaveArgument,
340             buildRequestArgument: config.buildRequestArgument,
341             platformArgument: config.platformArgument,
342         };
343
344         const syncerByBuilder = new Map;
345         const ensureBuildbotSyncer = (builderInfo) => {
346             let builderSyncer = syncerByBuilder.get(builderInfo.builder);
347             if (!builderSyncer) {
348                 builderSyncer = new BuildbotSyncer(remote, builderInfo, commonConfigurations);
349                 syncerByBuilder.set(builderInfo.builder, builderSyncer);
350             }
351             return builderSyncer;
352         }
353
354         assert(Array.isArray(config['testConfigurations']), `The test configuration must be an array`);
355         this._resolveBuildersWithPlatforms('test', config['testConfigurations'], builders, builderNameToIDMap).forEach((entry, configurationIndex) => {
356             assert(Array.isArray(entry['types']), `The test configuration ${configurationIndex} does not specify "types" as an array`);
357             for (const type of entry['types']) {
358                 const typeConfig = this._validateAndMergeConfig({}, entry.builderConfig);
359                 assert(types[type], `"${type}" is not a valid type in the configuration`);
360                 this._validateAndMergeConfig(typeConfig, types[type]);
361
362                 const testPath = typeConfig.test;
363                 const test = Test.findByPath(testPath);
364                 assert(test, `"${testPath.join('", "')}" is not a valid test path in the test configuration ${configurationIndex}`);
365
366                 ensureBuildbotSyncer(entry.builderConfig).addTestConfiguration(test, entry.platform, typeConfig.properties);
367             }
368         });
369
370         const buildConfigurations = config['buildConfigurations'];
371         if (buildConfigurations) {
372             assert(Array.isArray(buildConfigurations), `The test configuration must be an array`);
373             this._resolveBuildersWithPlatforms('test', buildConfigurations, builders, builderNameToIDMap).forEach((entry, configurationIndex) => {
374                 const syncer = ensureBuildbotSyncer(entry.builderConfig);
375                 assert(!syncer.isTester(), `The build configuration ${configurationIndex} uses a tester: ${syncer.builderName()}`);
376                 syncer.addBuildConfiguration(entry.platform, entry.builderConfig.properties);
377             });
378         }
379
380         return Array.from(syncerByBuilder.values());
381     }
382
383     static _resolveBuildersWithPlatforms(configurationType, configurationList, builders, builderNameToIDMap)
384     {
385         const resolvedConfigurations = [];
386         let configurationIndex = 0;
387         for (const entry of configurationList) {
388             configurationIndex++;
389             assert(Array.isArray(entry['builders']), `The ${configurationType} configuration ${configurationIndex} does not specify "builders" as an array`);
390             assert(Array.isArray(entry['platforms']), `The ${configurationType} configuration ${configurationIndex} does not specify "platforms" as an array`);
391             for (const builderKey of entry['builders']) {
392                 const matchingBuilder = builders[builderKey];
393                 assert(matchingBuilder, `"${builderKey}" is not a valid builder in the configuration`);
394                 assert('builder' in matchingBuilder, `Builder ${builderKey} does not specify a buildbot builder name`);
395                 assert(matchingBuilder.builder in builderNameToIDMap, `Builder ${matchingBuilder.builder} not found in Buildbot configuration.`);
396                 matchingBuilder['builderID'] = builderNameToIDMap[matchingBuilder.builder];
397                 const builderConfig = this._validateAndMergeConfig({}, matchingBuilder);
398                 for (const platformName of entry['platforms']) {
399                     const platform = Platform.findByName(platformName);
400                     assert(platform, `${platformName} is not a valid platform name`);
401                     resolvedConfigurations.push({types: entry.types, builderConfig, platform});
402                 }
403             }
404         }
405         return resolvedConfigurations;
406     }
407
408     static _parseRepositoryGroup(name, group)
409     {
410         assert.equal(typeof(group.repositories), 'object',
411             `Repository group "${name}" does not specify a dictionary of repositories`);
412         assert(!('description' in group) || typeof(group['description']) == 'string',
413             `Repository group "${name}" have an invalid description`);
414         assert([undefined, true, false].includes(group.acceptsRoots),
415             `Repository group "${name}" contains invalid acceptsRoots value: ${JSON.stringify(group.acceptsRoots)}`);
416
417         const repositoryByName = {};
418         const parsedRepositoryList = [];
419         const patchAcceptingRepositoryList = new Set;
420         for (const repositoryName in group.repositories) {
421             const options = group.repositories[repositoryName];
422             const repository = Repository.findTopLevelByName(repositoryName);
423             assert(repository, `"${repositoryName}" is not a valid repository name`);
424             repositoryByName[repositoryName] = repository;
425             assert.equal(typeof(options), 'object', `"${repositoryName}" specifies a non-dictionary value`);
426             assert([undefined, true, false].includes(options.acceptsPatch),
427                 `"${repositoryName}" contains invalid acceptsPatch value: ${JSON.stringify(options.acceptsPatch)}`);
428             if (options.acceptsPatch)
429                 patchAcceptingRepositoryList.add(repository);
430             repositoryByName[repositoryName] = repository;
431             parsedRepositoryList.push({repository: repository.id(), acceptsPatch: options.acceptsPatch});
432         }
433         assert(parsedRepositoryList.length, `Repository group "${name}" does not specify any repository`);
434
435         assert.equal(typeof(group.testProperties), 'object', `Repository group "${name}" specifies the test configurations with an invalid type`);
436
437         const resolveRepository = (repositoryName) => {
438             const repository = repositoryByName[repositoryName];
439             assert(repository, `Repository group "${name}" an invalid repository "${repositoryName}"`);
440             return repository;
441         }
442
443         const testRepositories = new Set;
444         let specifiesRoots = false;
445         const testPropertiesTemplate = this._parseRepositoryGroupPropertyTemplate('test', name, group.testProperties, (type, value, condition) => {
446             assert(type != 'patch', `Repository group "${name}" specifies a patch for "${value}" in the properties for testing`);
447             switch (type) {
448             case 'revision':
449                 const repository = resolveRepository(value);
450                 testRepositories.add(repository);
451                 return {type, repository};
452             case 'roots':
453                 assert(group.acceptsRoots, `Repository group "${name}" specifies roots in a property but it does not accept roots`);
454                 specifiesRoots = true;
455                 return {type};
456             case 'ifBuilt':
457                 assert('condition', 'condition must set if type is "ifBuilt"');
458                 return {type: 'conditional', condition: 'built', value, repositoryRequirement: condition.map(resolveRepository)};
459             }
460             return null;
461         });
462         assert(!group.acceptsRoots == !specifiesRoots,
463             `Repository group "${name}" accepts roots but does not specify roots in testProperties`);
464         assert.equal(parsedRepositoryList.length, testRepositories.size,
465             `Repository group "${name}" does not use some of the repositories listed in testing`);
466
467         let buildPropertiesTemplate = null;
468         if ('buildProperties' in group) {
469             assert(patchAcceptingRepositoryList.size, `Repository group "${name}" specifies the properties for building but does not accept any patches`);
470             assert(group.acceptsRoots, `Repository group "${name}" specifies the properties for building but does not accept roots in testing`);
471             const revisionRepositories = new Set;
472             const patchRepositories = new Set;
473             buildPropertiesTemplate = this._parseRepositoryGroupPropertyTemplate('build', name, group.buildProperties, (type, value, condition) => {
474                 assert(type != 'roots', `Repository group "${name}" specifies roots in the properties for building`);
475                 let repository = null;
476                 switch (type) {
477                 case 'patch':
478                     repository = resolveRepository(value);
479                     assert(patchAcceptingRepositoryList.has(repository), `Repository group "${name}" specifies a patch for "${value}" but it does not accept a patch`);
480                     patchRepositories.add(repository);
481                     return {type, repository};
482                 case 'revision':
483                     repository = resolveRepository(value);
484                     revisionRepositories.add(repository);
485                     return {type, repository};
486                 case 'ownedRevisions':
487                     return {type, ownerRepository: resolveRepository(value)};
488                 case 'ifRepositorySet':
489                     assert(condition, 'condition must set if type is "ifRepositorySet"');
490                     return {type: 'conditional', condition: 'requiresBuild', value, repositoriesToCheck: condition.map(resolveRepository)};
491                 }
492                 return null;
493             });
494             for (const repository of patchRepositories)
495                 assert(revisionRepositories.has(repository), `Repository group "${name}" specifies a patch for "${repository.name()}" but does not specify a revision`);
496             assert.equal(patchAcceptingRepositoryList.size, patchRepositories.size,
497                 `Repository group "${name}" does not use some of the repositories listed in building a patch`);
498         }
499
500         return {
501             name: group.name,
502             description: group.description,
503             acceptsRoots: group.acceptsRoots,
504             testPropertiesTemplate: testPropertiesTemplate,
505             buildPropertiesTemplate: buildPropertiesTemplate,
506             repositoryList: parsedRepositoryList,
507         };
508     }
509
510     static _parseRepositoryGroupPropertyTemplate(parsingMode, groupName, properties, makeOption)
511     {
512         const propertiesTemplate = {};
513         for (const propertyName in properties) {
514             let value = properties[propertyName];
515             const isDictionary = typeof(value) == 'object';
516             assert(isDictionary || typeof(value) == 'string',
517                 `Repository group "${groupName}" uses an invalid value "${value}" in property "${propertyName}"`);
518
519             if (!isDictionary) {
520                 propertiesTemplate[propertyName] = value;
521                 continue;
522             }
523
524             const keys = Object.keys(value);
525             assert(keys.length == 1 || keys.length == 2,
526                 `Repository group "${groupName}" specifies more than two types in property "${propertyName}": "${keys.join('", "')}"`);
527             let type;
528             let condition = null;
529             let optionValue;
530             if (keys.length == 2) {
531                 assert(keys.includes('value'), `Repository group "${groupName}" with two types in property "${propertyName}": "${keys.join('", "')}" should contains 'value' as one type`);
532                 type = keys.find((key) => key != 'value');
533                 optionValue = value.value;
534                 condition = value[type];
535             }
536             else {
537                 type = keys[0];
538                 optionValue = value[type];
539             }
540             const option = makeOption(type, optionValue, condition);
541             assert(option, `Repository group "${groupName}" specifies an invalid type "${type}" in property "${propertyName}"`);
542             propertiesTemplate[propertyName] = option;
543         }
544         return propertiesTemplate;
545     }
546
547     static _validateAndMergeConfig(config, valuesToMerge, excludedProperty)
548     {
549         for (const name in valuesToMerge) {
550             const value = valuesToMerge[name];
551             if (name == excludedProperty)
552                 continue;
553
554             switch (name) {
555             case 'properties': // Fallthrough
556                 assert.equal(typeof(value), 'object', 'Build properties should be a dictionary');
557                 if (!config['properties'])
558                     config['properties'] = {};
559                 const properties = config['properties'];
560                 for (const name in value) {
561                     assert.equal(typeof(value[name]), 'string', `Build properties "${name}" specifies a non-string value of type "${typeof(value)}"`);
562                     properties[name] = value[name];
563                 }
564                 break;
565             case 'test': // Fallthrough
566             case 'slaveList': // Fallthrough
567                 assert(value instanceof Array, `${name} should be an array`);
568                 assert(value.every(function (part) { return typeof part == 'string'; }), `${name} should be an array of strings`);
569                 config[name] = value.slice();
570                 break;
571             case 'builder': // Fallthrough
572                 assert.equal(typeof(value), 'string', `${name} should be of string type`);
573                 config[name] = value;
574                 break;
575             case 'builderID':
576                 assert(value, 'builderID should not be undefined.');
577                 config[name] = value;
578                 break;
579             default:
580                 assert(false, `Unrecognized parameter "${name}"`);
581             }
582         }
583         return config;
584     }
585
586 }
587
588 if (typeof module != 'undefined') {
589     module.exports.BuildbotSyncer = BuildbotSyncer;
590     module.exports.BuildbotBuildEntry = BuildbotBuildEntry;
591 }