<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.1//EN"
"http://www.w3.org/TR/xhtml11/DTD/xhtml11.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head><meta http-equiv="content-type" content="text/html; charset=utf-8" />
<title>[215061] trunk/Websites/perf.webkit.org</title>
</head>
<body>

<style type="text/css"><!--
#msg dl.meta { border: 1px #006 solid; background: #369; padding: 6px; color: #fff; }
#msg dl.meta dt { float: left; width: 6em; font-weight: bold; }
#msg dt:after { content:':';}
#msg dl, #msg dt, #msg ul, #msg li, #header, #footer, #logmsg { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt;  }
#msg dl a { font-weight: bold}
#msg dl a:link    { color:#fc3; }
#msg dl a:active  { color:#ff0; }
#msg dl a:visited { color:#cc6; }
h3 { font-family: verdana,arial,helvetica,sans-serif; font-size: 10pt; font-weight: bold; }
#msg pre { overflow: auto; background: #ffc; border: 1px #fa0 solid; padding: 6px; }
#logmsg { background: #ffc; border: 1px #fa0 solid; padding: 1em 1em 0 1em; }
#logmsg p, #logmsg pre, #logmsg blockquote { margin: 0 0 1em 0; }
#logmsg p, #logmsg li, #logmsg dt, #logmsg dd { line-height: 14pt; }
#logmsg h1, #logmsg h2, #logmsg h3, #logmsg h4, #logmsg h5, #logmsg h6 { margin: .5em 0; }
#logmsg h1:first-child, #logmsg h2:first-child, #logmsg h3:first-child, #logmsg h4:first-child, #logmsg h5:first-child, #logmsg h6:first-child { margin-top: 0; }
#logmsg ul, #logmsg ol { padding: 0; list-style-position: inside; margin: 0 0 0 1em; }
#logmsg ul { text-indent: -1em; padding-left: 1em; }#logmsg ol { text-indent: -1.5em; padding-left: 1.5em; }
#logmsg > ul, #logmsg > ol { margin: 0 0 1em 0; }
#logmsg pre { background: #eee; padding: 1em; }
#logmsg blockquote { border: 1px solid #fa0; border-left-width: 10px; padding: 1em 1em 0 1em; background: white;}
#logmsg dl { margin: 0; }
#logmsg dt { font-weight: bold; }
#logmsg dd { margin: 0; padding: 0 0 0.5em 0; }
#logmsg dd:before { content:'\00bb';}
#logmsg table { border-spacing: 0px; border-collapse: collapse; border-top: 4px solid #fa0; border-bottom: 1px solid #fa0; background: #fff; }
#logmsg table th { text-align: left; font-weight: normal; padding: 0.2em 0.5em; border-top: 1px dotted #fa0; }
#logmsg table td { text-align: right; border-top: 1px dotted #fa0; padding: 0.2em 0.5em; }
#logmsg table thead th { text-align: center; border-bottom: 1px solid #fa0; }
#logmsg table th.Corner { text-align: left; }
#logmsg hr { border: none 0; border-top: 2px dashed #fa0; height: 1px; }
#header, #footer { color: #fff; background: #636; border: 1px #300 solid; padding: 6px; }
#patch { width: 100%; }
#patch h4 {font-family: verdana,arial,helvetica,sans-serif;font-size:10pt;padding:8px;background:#369;color:#fff;margin:0;}
#patch .propset h4, #patch .binary h4 {margin:0;}
#patch pre {padding:0;line-height:1.2em;margin:0;}
#patch .diff {width:100%;background:#eee;padding: 0 0 10px 0;overflow:auto;}
#patch .propset .diff, #patch .binary .diff  {padding:10px 0;}
#patch span {display:block;padding:0 10px;}
#patch .modfile, #patch .addfile, #patch .delfile, #patch .propset, #patch .binary, #patch .copfile {border:1px solid #ccc;margin:10px 0;}
#patch ins {background:#dfd;text-decoration:none;display:block;padding:0 10px;}
#patch del {background:#fdd;text-decoration:none;display:block;padding:0 10px;}
#patch .lines, .info {color:#888;background:#fff;}
--></style>
<div id="msg">
<dl class="meta">
<dt>Revision</dt> <dd><a href="http://trac.webkit.org/projects/webkit/changeset/215061">215061</a></dd>
<dt>Author</dt> <dd>rniwa@webkit.org</dd>
<dt>Date</dt> <dd>2017-04-06 14:56:59 -0700 (Thu, 06 Apr 2017)</dd>
</dl>

<h3>Log Message</h3>
<pre>Each build request should be associated with a repository group
https://bugs.webkit.org/show_bug.cgi?id=170528

Rubber-stamped by Chris Dumez.

Make the buildbot syncing script use the concept of repository groups so that each repository group can post
a different set of properties to buildbot. In order to do this, we associate each build request with
a repository group to use. Each triggerable's repository groups is now updated by the syncing scripts via
/api/update-triggerable just the same way the set of the supported platform, test pairs are updated.

Each repository group specifies the list of repositories, a dictionary that maps the buildbot property name
to either a string value or a repository name enclosed in &lt; and &gt;:

```js
&quot;repositoryGroups&quot;: {
    &quot;webkit-svn&quot;: {
        &quot;repositories&quot;: [&quot;WebKit&quot;, &quot;macOS&quot;],
        &quot;properties&quot;: {&quot;os&quot;: &quot;&lt;macOS&gt;&quot;, &quot;wk&quot;: &quot;&lt;WebKit&gt;&quot;}
    }
}
```

With this, removed the support for specifying a repository to use in generic dictionary of properties via
a dictionary with a single key of &quot;root&quot;, &quot;rootOptions&quot;, and &quot;rootsExcluding&quot;. We now validate that the list of
repositories in each repository group matches exactly the ones used in buildbot properties as well as ones in
build requests.

After this patch, sync-with-buildbot.js will no longer schedule a build request without a repository group.
Run the appropriate database queries to set the repository group on each build request. Because of this change,
this patch also makes BuildbotTriggerable.prototype.syncOnce more robust against invalid build requests.
Instead of throwing an exception and exiting early, it simply skips all build requests that belong to the same
test group if the next build request to be scheduled does not specify a repository group.

* init-database.sql: Add request_repository_group column to build_requests table, and a unique constraint for
repository and group pair in triggerable_repositories table.

* public/api/update-triggerable.php:
(main): Validate and insert repository groups.
(validate_configurations): Extracted from main.
(validate_repository_groups): Added.

* public/v3/models/repository.js:
(Repository.findTopLevelByName): Added.

* public/include/build-requests-fetcher.php:
(BuildRequestsFetcher::results_internal): Include the repository group of each request in the JSON response.

* public/include/repository-group-finder.php: Added. A helper class to find the repository group for a given
triggerable for a list of repositories.
(RepositoryGroupFinder): Added. 
(RepositoryGroupFinder::__construct): Added.
(RepositoryGroupFinder::find_by_repositories): Added.
(RepositoryGroupFinder::populate_map): Added.

* public/privileged-api/create-test-group.php:
(main): Each element in an array returned by ensure_commit_sets and commit_sets_from_revision_sets now contains
&quot;set&quot;, the list of commit IDs, and &quot;repository_group&quot;, the repository group identified for each commit set.
Use that to set the repository group in each new build request. 
(commit_sets_from_revision_sets): Use RepositoryGroupFinder to find the right repository group.
(ensure_commit_sets): Ditto. There is no need to find a repository group for each commit set here since its
argument is keyed by the repository name. e.g. {&quot;WebKit&quot;: [123, 456], &quot;macOS&quot;: [&quot;16A323&quot;, &quot;16A323&quot;]}

* public/v3/models/build-request.js:
(BuildRequest):
(BuildRequest.prototype.triggerable): Added.
(BuildRequest.prototype.repositoryGroup): Added.
(BuildRequest.constructBuildRequestsFromData): Resolve the triggerable and the repository group.

* public/v3/models/triggerable.js:
(Triggerable.prototype.name): Added.
(Triggerable.prototype.acceptedRepositories): Deleted.
(TriggerableRepositoryGroup):
(TriggerableRepositoryGroup.prototype.accepts): Added. Retruns true if the repository group

* server-tests/api-build-requests-tests.js: Added a test for getting the repository group of a build request.
* server-tests/api-manifest-tests.js: Added assertions for the repository groups.
* server-tests/api-report-tests.js:
(.emptyReport):
(.reportWithTwoLevelsOfAggregations):
* server-tests/api-update-triggerable.js: Added test cases for updating the repository groups associated with
a triggerable.
(.updateWithOSXRepositoryGroup):
(.mapRepositoriesByGroup):
* server-tests/privileged-api-create-test-group-tests.js:
(addTriggerableAndCreateTask): Add two repository groups for testing. Added assertions for repository groups
in existing test cases, and added a test case for creating a test group with two different repository groups.

* server-tests/resources/mock-data.js:
(MockData.resetV3Models): Reset TriggerableRepositoryGroup's static maps.
(MockData.emptyTriggeragbleId): Added.
(MockData.macosRepositoryId): Added.
(MockData.webkitRepositoryId): Added.
(MockData.gitWebkitRepositoryId): Added.
(MockData.addMockData): Create repository groups as needed. Renamed the &quot;OS X&quot; repository to &quot;macOS&quot; since some
tests were using the latter, and now we need mock data to be consistent across tests due to stricter checks.
(MockData.addEmptyTriggerable): Added. Used in api-update-triggerable.js.
(MockData.addMockTestGroupWithGitWebKit): Added. Used in api-build-requests-tests.js.
(MockData.addAnotherMockTestGroup): Cleanup.
(MockData.mockTestSyncConfigWithSingleBuilder): Updated the mock configuration per code changes.
(MockData.mockTestSyncConfigWithTwoBuilders): Ditto.

* server-tests/tools-buildbot-triggerable-tests.js: Updated a test case testing /api/update-triggerable to test
updating the set of repository groups in addition to the set of test, platform pairs.
(.refetchManifest): Added.

* tools/js/buildbot-syncer.js:
(BuildbotSyncer): Now takes a set of configurations shared across syncers: repositoryGroups, slaveArgument,
and buildRequestArgument as the third argument.
(BuildbotSyncer.prototype.repositoryGroups): Added.
(BuildbotSyncer.prototype._testGroupMapForBuildRequests): Cleaned up the code to use Array.prototype.find.
Also added an assertion that the build request is associated with a repository group.
(BuildbotSyncer.prototype._propertiesForBuildRequest): Removed the support for using an arbitary property to
specify a revision in favor of explicity listing each property and repository name in a repository group.
(BuildbotSyncer._loadConfig): Removed the support for &quot;shared&quot;, which specified the set of buildbot properties
shared across syncers, the name of properties which specifies the build slave name and build request ID. These
values are not stored as top-level properties and superseded by the concept of repository groups.
(BuildbotSyncer._parseRepositoryGroup): Parses and validates repository groups.
(BuildbotSyncer._createTestConfiguration): We no longer expect each configuration to specify a dictionary of
properties or buildRequestArgument (often inherited from shared).
(BuildbotSyncer._validateAndMergeConfig): Removed &quot;slaveArgument&quot; and &quot;buildRequestArgument&quot; from the list of
allowed proeprties in each configuration now that they're specified as top-level properties.

* tools/js/buildbot-triggerable.js:
(BuildbotTriggerable.prototype.updateTriggerable): Update the associated repository groups.
(BuildbotTriggerable.prototype.syncOnce): Skip test groups for which the next build request to be scheduled is
not included in the list of valid build requests.
(BuildbotTriggerable.prototype._validateRequests): Now returns the list of valid build requests, which excludes
those that lack a repository group set.
(BuildbotTriggerable.prototype._nextRequestInGroup): Extracted from _scheduleRequestIfSlaveIsAvailable. Finds
the next build request to be scheduled for the test group.
(BuildbotTriggerable.prototype._scheduleRequestIfSlaveIsAvailable): Renamed from
_scheduleNextRequestInGroupIfSlaveIsAvailable. Now takes the syncer and the slave name as arguments instead of
a test group information since syncOnce now calls _nextRequestInGroup to find the next build request.

* tools/js/v3-models.js:

* unit-tests/build-request-tests.js: Fixed the test name.

* unit-tests/buildbot-syncer-tests.js: Removed tests for &quot;rootOptions&quot; and &quot;rootsExcluding&quot;, and added tests
for parsing repository groups.
(sampleiOSConfig): Updated the mock configuration per code changes.
(sampleiOSConfigWithExpansions): Ditto.
(smallConfiguration): Ditto. Now returns the entire configuration instead of a single builder configuration.
Various test cases have been updated to reflect this.
(createSampleBuildRequest): Removed the git hash of WebKit to match the repository groups listed in the mock
configurations. The git hash was there to test &quot;rootOptions&quot;, which this patch removed.
(samplePendingBuild): Removed &quot;root_dict&quot; from the list of properties. This was used to test &quot;rootsExcluding&quot;
which, again, this patch removed.
(sampleInProgressBuild): Ditto.
(sampleFinishedBuild): Ditto.

* unit-tests/resources/mock-v3-models.js:
(MockModels.inject): Added ock repository groups so that existing tests will continue to function.</pre>

<h3>Modified Paths</h3>
<ul>
<li><a href="#trunkWebsitesperfwebkitorgChangeLog">trunk/Websites/perf.webkit.org/ChangeLog</a></li>
<li><a href="#trunkWebsitesperfwebkitorginitdatabasesql">trunk/Websites/perf.webkit.org/init-database.sql</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicapiupdatetriggerablephp">trunk/Websites/perf.webkit.org/public/api/update-triggerable.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicincludebuildrequestsfetcherphp">trunk/Websites/perf.webkit.org/public/include/build-requests-fetcher.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicprivilegedapicreatetestgroupphp">trunk/Websites/perf.webkit.org/public/privileged-api/create-test-group.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicv3modelsbuildrequestjs">trunk/Websites/perf.webkit.org/public/v3/models/build-request.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicv3modelsrepositoryjs">trunk/Websites/perf.webkit.org/public/v3/models/repository.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicv3modelstriggerablejs">trunk/Websites/perf.webkit.org/public/v3/models/triggerable.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgservertestsapibuildrequeststestsjs">trunk/Websites/perf.webkit.org/server-tests/api-build-requests-tests.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgservertestsapimanifesttestsjs">trunk/Websites/perf.webkit.org/server-tests/api-manifest-tests.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgservertestsapireporttestsjs">trunk/Websites/perf.webkit.org/server-tests/api-report-tests.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgservertestsapiupdatetriggerablejs">trunk/Websites/perf.webkit.org/server-tests/api-update-triggerable.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgservertestsprivilegedapicreatetestgrouptestsjs">trunk/Websites/perf.webkit.org/server-tests/privileged-api-create-test-group-tests.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgservertestsresourcesmockdatajs">trunk/Websites/perf.webkit.org/server-tests/resources/mock-data.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgserverteststoolsbuildbottriggerabletestsjs">trunk/Websites/perf.webkit.org/server-tests/tools-buildbot-triggerable-tests.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolsjsbuildbotsyncerjs">trunk/Websites/perf.webkit.org/tools/js/buildbot-syncer.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolsjsbuildbottriggerablejs">trunk/Websites/perf.webkit.org/tools/js/buildbot-triggerable.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolsjsv3modelsjs">trunk/Websites/perf.webkit.org/tools/js/v3-models.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgunittestsbuildrequesttestsjs">trunk/Websites/perf.webkit.org/unit-tests/build-request-tests.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgunittestsbuildbotsyncertestsjs">trunk/Websites/perf.webkit.org/unit-tests/buildbot-syncer-tests.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgunittestsresourcesmockv3modelsjs">trunk/Websites/perf.webkit.org/unit-tests/resources/mock-v3-models.js</a></li>
</ul>

<h3>Added Paths</h3>
<ul>
<li><a href="#trunkWebsitesperfwebkitorgpublicincluderepositorygroupfinderphp">trunk/Websites/perf.webkit.org/public/include/repository-group-finder.php</a></li>
</ul>

</div>
<div id="patch">
<h3>Diff</h3>
<a id="trunkWebsitesperfwebkitorgChangeLog"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/ChangeLog (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/ChangeLog        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/ChangeLog        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -1,3 +1,159 @@
</span><ins>+2017-04-06  Ryosuke Niwa  &lt;rniwa@webkit.org&gt;
+
+        Each build request should be associated with a repository group
+        https://bugs.webkit.org/show_bug.cgi?id=170528
+
+        Rubber-stamped by Chris Dumez.
+
+        Make the buildbot syncing script use the concept of repository groups so that each repository group can post
+        a different set of properties to buildbot. In order to do this, we associate each build request with
+        a repository group to use. Each triggerable's repository groups is now updated by the syncing scripts via
+        /api/update-triggerable just the same way the set of the supported platform, test pairs are updated.
+
+        Each repository group specifies the list of repositories, a dictionary that maps the buildbot property name
+        to either a string value or a repository name enclosed in &lt; and &gt;:
+
+        ```js
+        &quot;repositoryGroups&quot;: {
+            &quot;webkit-svn&quot;: {
+                &quot;repositories&quot;: [&quot;WebKit&quot;, &quot;macOS&quot;],
+                &quot;properties&quot;: {&quot;os&quot;: &quot;&lt;macOS&gt;&quot;, &quot;wk&quot;: &quot;&lt;WebKit&gt;&quot;}
+            }
+        }
+        ```
+
+        With this, removed the support for specifying a repository to use in generic dictionary of properties via
+        a dictionary with a single key of &quot;root&quot;, &quot;rootOptions&quot;, and &quot;rootsExcluding&quot;. We now validate that the list of
+        repositories in each repository group matches exactly the ones used in buildbot properties as well as ones in
+        build requests.
+
+        After this patch, sync-with-buildbot.js will no longer schedule a build request without a repository group.
+        Run the appropriate database queries to set the repository group on each build request. Because of this change,
+        this patch also makes BuildbotTriggerable.prototype.syncOnce more robust against invalid build requests.
+        Instead of throwing an exception and exiting early, it simply skips all build requests that belong to the same
+        test group if the next build request to be scheduled does not specify a repository group.
+
+        * init-database.sql: Add request_repository_group column to build_requests table, and a unique constraint for
+        repository and group pair in triggerable_repositories table.
+
+        * public/api/update-triggerable.php:
+        (main): Validate and insert repository groups.
+        (validate_configurations): Extracted from main.
+        (validate_repository_groups): Added.
+
+        * public/v3/models/repository.js:
+        (Repository.findTopLevelByName): Added.
+
+        * public/include/build-requests-fetcher.php:
+        (BuildRequestsFetcher::results_internal): Include the repository group of each request in the JSON response.
+
+        * public/include/repository-group-finder.php: Added. A helper class to find the repository group for a given
+        triggerable for a list of repositories.
+        (RepositoryGroupFinder): Added. 
+        (RepositoryGroupFinder::__construct): Added.
+        (RepositoryGroupFinder::find_by_repositories): Added.
+        (RepositoryGroupFinder::populate_map): Added.
+
+        * public/privileged-api/create-test-group.php:
+        (main): Each element in an array returned by ensure_commit_sets and commit_sets_from_revision_sets now contains
+        &quot;set&quot;, the list of commit IDs, and &quot;repository_group&quot;, the repository group identified for each commit set.
+        Use that to set the repository group in each new build request. 
+        (commit_sets_from_revision_sets): Use RepositoryGroupFinder to find the right repository group.
+        (ensure_commit_sets): Ditto. There is no need to find a repository group for each commit set here since its
+        argument is keyed by the repository name. e.g. {&quot;WebKit&quot;: [123, 456], &quot;macOS&quot;: [&quot;16A323&quot;, &quot;16A323&quot;]}
+
+        * public/v3/models/build-request.js:
+        (BuildRequest):
+        (BuildRequest.prototype.triggerable): Added.
+        (BuildRequest.prototype.repositoryGroup): Added.
+        (BuildRequest.constructBuildRequestsFromData): Resolve the triggerable and the repository group.
+
+        * public/v3/models/triggerable.js:
+        (Triggerable.prototype.name): Added.
+        (Triggerable.prototype.acceptedRepositories): Deleted.
+        (TriggerableRepositoryGroup):
+        (TriggerableRepositoryGroup.prototype.accepts): Added. Retruns true if the repository group
+
+        * server-tests/api-build-requests-tests.js: Added a test for getting the repository group of a build request.
+        * server-tests/api-manifest-tests.js: Added assertions for the repository groups.
+        * server-tests/api-report-tests.js:
+        (.emptyReport):
+        (.reportWithTwoLevelsOfAggregations):
+        * server-tests/api-update-triggerable.js: Added test cases for updating the repository groups associated with
+        a triggerable.
+        (.updateWithOSXRepositoryGroup):
+        (.mapRepositoriesByGroup):
+        * server-tests/privileged-api-create-test-group-tests.js:
+        (addTriggerableAndCreateTask): Add two repository groups for testing. Added assertions for repository groups
+        in existing test cases, and added a test case for creating a test group with two different repository groups.
+
+        * server-tests/resources/mock-data.js:
+        (MockData.resetV3Models): Reset TriggerableRepositoryGroup's static maps.
+        (MockData.emptyTriggeragbleId): Added.
+        (MockData.macosRepositoryId): Added.
+        (MockData.webkitRepositoryId): Added.
+        (MockData.gitWebkitRepositoryId): Added.
+        (MockData.addMockData): Create repository groups as needed. Renamed the &quot;OS X&quot; repository to &quot;macOS&quot; since some
+        tests were using the latter, and now we need mock data to be consistent across tests due to stricter checks.
+        (MockData.addEmptyTriggerable): Added. Used in api-update-triggerable.js.
+        (MockData.addMockTestGroupWithGitWebKit): Added. Used in api-build-requests-tests.js.
+        (MockData.addAnotherMockTestGroup): Cleanup.
+        (MockData.mockTestSyncConfigWithSingleBuilder): Updated the mock configuration per code changes.
+        (MockData.mockTestSyncConfigWithTwoBuilders): Ditto.
+
+        * server-tests/tools-buildbot-triggerable-tests.js: Updated a test case testing /api/update-triggerable to test
+        updating the set of repository groups in addition to the set of test, platform pairs.
+        (.refetchManifest): Added.
+
+        * tools/js/buildbot-syncer.js:
+        (BuildbotSyncer): Now takes a set of configurations shared across syncers: repositoryGroups, slaveArgument,
+        and buildRequestArgument as the third argument.
+        (BuildbotSyncer.prototype.repositoryGroups): Added.
+        (BuildbotSyncer.prototype._testGroupMapForBuildRequests): Cleaned up the code to use Array.prototype.find.
+        Also added an assertion that the build request is associated with a repository group.
+        (BuildbotSyncer.prototype._propertiesForBuildRequest): Removed the support for using an arbitary property to
+        specify a revision in favor of explicity listing each property and repository name in a repository group.
+        (BuildbotSyncer._loadConfig): Removed the support for &quot;shared&quot;, which specified the set of buildbot properties
+        shared across syncers, the name of properties which specifies the build slave name and build request ID. These
+        values are not stored as top-level properties and superseded by the concept of repository groups.
+        (BuildbotSyncer._parseRepositoryGroup): Parses and validates repository groups.
+        (BuildbotSyncer._createTestConfiguration): We no longer expect each configuration to specify a dictionary of
+        properties or buildRequestArgument (often inherited from shared).
+        (BuildbotSyncer._validateAndMergeConfig): Removed &quot;slaveArgument&quot; and &quot;buildRequestArgument&quot; from the list of
+        allowed proeprties in each configuration now that they're specified as top-level properties.
+
+        * tools/js/buildbot-triggerable.js:
+        (BuildbotTriggerable.prototype.updateTriggerable): Update the associated repository groups.
+        (BuildbotTriggerable.prototype.syncOnce): Skip test groups for which the next build request to be scheduled is
+        not included in the list of valid build requests.
+        (BuildbotTriggerable.prototype._validateRequests): Now returns the list of valid build requests, which excludes
+        those that lack a repository group set.
+        (BuildbotTriggerable.prototype._nextRequestInGroup): Extracted from _scheduleRequestIfSlaveIsAvailable. Finds
+        the next build request to be scheduled for the test group.
+        (BuildbotTriggerable.prototype._scheduleRequestIfSlaveIsAvailable): Renamed from
+        _scheduleNextRequestInGroupIfSlaveIsAvailable. Now takes the syncer and the slave name as arguments instead of
+        a test group information since syncOnce now calls _nextRequestInGroup to find the next build request.
+
+        * tools/js/v3-models.js:
+
+        * unit-tests/build-request-tests.js: Fixed the test name.
+
+        * unit-tests/buildbot-syncer-tests.js: Removed tests for &quot;rootOptions&quot; and &quot;rootsExcluding&quot;, and added tests
+        for parsing repository groups.
+        (sampleiOSConfig): Updated the mock configuration per code changes.
+        (sampleiOSConfigWithExpansions): Ditto.
+        (smallConfiguration): Ditto. Now returns the entire configuration instead of a single builder configuration.
+        Various test cases have been updated to reflect this.
+        (createSampleBuildRequest): Removed the git hash of WebKit to match the repository groups listed in the mock
+        configurations. The git hash was there to test &quot;rootOptions&quot;, which this patch removed.
+        (samplePendingBuild): Removed &quot;root_dict&quot; from the list of properties. This was used to test &quot;rootsExcluding&quot;
+        which, again, this patch removed.
+        (sampleInProgressBuild): Ditto.
+        (sampleFinishedBuild): Ditto.
+
+        * unit-tests/resources/mock-v3-models.js:
+        (MockModels.inject): Added ock repository groups so that existing tests will continue to function.
+
</ins><span class="cx"> 2017-04-05  Ryosuke Niwa  &lt;rniwa@webkit.org&gt;
</span><span class="cx"> 
</span><span class="cx">         Introduce the notion of repository groups to triggerables
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorginitdatabasesql"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/init-database.sql (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/init-database.sql        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/init-database.sql        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -243,7 +243,8 @@
</span><span class="cx"> 
</span><span class="cx"> CREATE TABLE triggerable_repositories (
</span><span class="cx">     trigrepo_repository integer REFERENCES repositories NOT NULL,
</span><del>-    trigrepo_group integer REFERENCES triggerable_repository_groups NOT NULL);
</del><ins>+    trigrepo_group integer REFERENCES triggerable_repository_groups NOT NULL,
+    CONSTRAINT repository_must_be_unique_for_repository_group UNIQUE(trigrepo_repository, trigrepo_group));
</ins><span class="cx"> 
</span><span class="cx"> CREATE TABLE triggerable_configurations (
</span><span class="cx">     trigconfig_test integer REFERENCES tests NOT NULL,
</span><span class="lines">@@ -285,6 +286,7 @@
</span><span class="cx"> CREATE TABLE build_requests (
</span><span class="cx">     request_id serial PRIMARY KEY,
</span><span class="cx">     request_triggerable integer REFERENCES build_triggerables NOT NULL,
</span><ins>+    request_repository_group integer REFERENCES triggerable_repository_groups,
</ins><span class="cx">     request_platform integer REFERENCES platforms NOT NULL,
</span><span class="cx">     request_test integer REFERENCES tests NOT NULL,
</span><span class="cx">     request_group integer REFERENCES analysis_test_groups NOT NULL,
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicapiupdatetriggerablephp"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/api/update-triggerable.php (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/api/update-triggerable.php        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/public/api/update-triggerable.php        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -1,8 +1,10 @@
</span><span class="cx"> &lt;?php
</span><span class="cx"> 
</span><del>-require('../include/json-header.php');
</del><ins>+require_once('../include/json-header.php');
+require_once('../include/repository-group-finder.php');
</ins><span class="cx"> 
</span><del>-function main($post_data) {
</del><ins>+function main($post_data)
+{
</ins><span class="cx">     $db = new Database;
</span><span class="cx">     if (!$db-&gt;connect())
</span><span class="cx">         exit_with_error('DatabaseConnectionFailure');
</span><span class="lines">@@ -17,14 +19,15 @@
</span><span class="cx">     $triggerable_id = $triggerable['triggerable_id'];
</span><span class="cx"> 
</span><span class="cx">     $configurations = array_get($report, 'configurations');
</span><del>-    if (!is_array($configurations))
-        exit_with_error('InvalidConfigurations', array('configurations' =&gt; $configurations));
</del><ins>+    validate_configurations($db, $configurations);
</ins><span class="cx"> 
</span><del>-    foreach ($configurations as $entry) {
-        if (!is_array($entry) || !array_key_exists('test', $entry) || !array_key_exists('platform', $entry))
-            exit_with_error('InvalidConfigurationEntry', array('configurationEntry' =&gt; $entry));
-    }
</del><ins>+    $repository_groups = array_get($report, 'repositoryGroups', array());
+    validate_repository_groups($db, $repository_groups);
</ins><span class="cx"> 
</span><ins>+    $finder = new RepositoryGroupFinder($db, $triggerable_id);
+    foreach ($repository_groups as &amp;$group)
+        $group['existingGroup'] = $finder-&gt;find_by_repositories($group['repositories']);
+
</ins><span class="cx">     $db-&gt;begin_transaction();
</span><span class="cx">     if ($db-&gt;query_and_get_affected_rows('DELETE FROM triggerable_configurations WHERE trigconfig_triggerable = $1', array($triggerable_id)) === false) {
</span><span class="cx">         $db-&gt;rollback_transaction();
</span><span class="lines">@@ -31,7 +34,7 @@
</span><span class="cx">         exit_with_error('FailedToDeleteExistingConfigurations', array('triggerable' =&gt; $triggerable_id));
</span><span class="cx">     }
</span><span class="cx"> 
</span><del>-    foreach ($configurations as $entry) {
</del><ins>+    foreach ($configurations as &amp;$entry) {
</ins><span class="cx">         $config_info = array('test' =&gt; $entry['test'], 'platform' =&gt; $entry['platform'], 'triggerable' =&gt; $triggerable_id);
</span><span class="cx">         if (!$db-&gt;insert_row('triggerable_configurations', 'trigconfig', $config_info, null)) {
</span><span class="cx">             $db-&gt;rollback_transaction();
</span><span class="lines">@@ -39,10 +42,73 @@
</span><span class="cx">         }
</span><span class="cx">     }
</span><span class="cx"> 
</span><ins>+    foreach ($repository_groups as &amp;$group) {
+        $group_id = $group['existingGroup'];
+        if ($group_id) {
+            $group_info = array('name' =&gt; $group['name'], 'description' =&gt; array_get($group, 'description'));
+            if (!$db-&gt;update_row('triggerable_repository_groups', 'repositorygroup', array('id' =&gt; $group_id), $group_info)) {
+                $db-&gt;rollback_transaction();
+                exit_with_error('FailedToInsertRepositoryGroup', array('repositoryGroup' =&gt; $group));
+            }
+        } else {
+            $group_id = $db-&gt;update_or_insert_row('triggerable_repository_groups', 'repositorygroup',
+                array('triggerable' =&gt; $triggerable_id, 'name' =&gt; $group['name']),
+                array('triggerable' =&gt; $triggerable_id, 'name' =&gt; $group['name'], 'description' =&gt; array_get($group, 'description')));
+            if (!$group_id) {
+                $db-&gt;rollback_transaction();
+                exit_with_error('FailedToInsertRepositoryGroup', array('repositoryGroup' =&gt; $group));
+            }
+        }
+        if ($db-&gt;query_and_get_affected_rows('DELETE FROM triggerable_repositories WHERE trigrepo_group = $1', array($group_id)) === FALSE) {
+            $db-&gt;rollback_transaction();
+            exit_with_error('FailedToDisassociateRepositories', array('repositoryGroup' =&gt; $group));
+        }
+        foreach ($group['repositories'] as $repository_id) {
+            if (!$db-&gt;insert_row('triggerable_repositories', 'trigrepo', array('group' =&gt; $group_id, 'repository' =&gt; $repository_id), null)) {
+                $db-&gt;rollback_transaction();
+                exit_with_error('FailedToAssociateRepository', array('repositoryGroup' =&gt; $group, 'repository' =&gt; $repository_id));
+            }
+        }
+    }
+
</ins><span class="cx">     $db-&gt;commit_transaction();
</span><span class="cx">     exit_with_success();
</span><span class="cx"> }
</span><span class="cx"> 
</span><ins>+function validate_configurations($db, $configurations)
+{
+    if (!is_array($configurations))
+        exit_with_error('InvalidConfigurations', array('configurations' =&gt; $configurations));
+
+    foreach ($configurations as $entry) {
+        if (!is_array($entry) || !array_key_exists('test', $entry) || !array_key_exists('platform', $entry))
+            exit_with_error('InvalidConfigurationEntry', array('configurationEntry' =&gt; $entry));
+    }
+}
+
+function validate_repository_groups($db, $repository_groups)
+{
+    if (!is_array($repository_groups))
+        exit_with_error('InvalidRepositoryGroups', array('repositoryGroups' =&gt; $repository_groups));
+
+    $top_level_repositories = $db-&gt;select_rows('repositories', 'repository', array('owner' =&gt; null));
+    $top_level_repository_ids = array();
+    foreach ($top_level_repositories as $repository_row)
+        $top_level_repository_ids[$repository_row['repository_id']] = true;
+
+    foreach ($repository_groups as &amp;$group) {
+        if (!is_array($group) || !array_key_exists('name', $group) || !array_key_exists('repositories', $group) || !is_array($group['repositories']))
+            exit_with_error('InvalidRepositoryGroup', array('repositoryGroup' =&gt; $group));
+        $repository_list = $group['repositories'];
+        $group_repository_list = array();
+        foreach ($repository_list as $repository_id) {
+            if (!array_key_exists($repository_id, $top_level_repository_ids) || array_key_exists($repository_id, $group_repository_list))
+                exit_with_error('InvalidRepository', array('repositoryGroup' =&gt; $group, 'repository' =&gt; $repository_id));
+            $group_repository_list[$repository_id] = true;
+        }
+    }
+}
+
</ins><span class="cx"> main($HTTP_RAW_POST_DATA);
</span><span class="cx"> 
</span><span class="cx"> ?&gt;
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicincludebuildrequestsfetcherphp"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/include/build-requests-fetcher.php (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/include/build-requests-fetcher.php        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/public/include/build-requests-fetcher.php        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -68,6 +68,7 @@
</span><span class="cx">                 'id' =&gt; $row['request_id'],
</span><span class="cx">                 'task' =&gt; $row['task_id'],
</span><span class="cx">                 'triggerable' =&gt; $row['request_triggerable'],
</span><ins>+                'repositoryGroup' =&gt; $row['request_repository_group'],
</ins><span class="cx">                 'test' =&gt; $resolve_ids ? $test_path_resolver-&gt;path_for_test($test_id) : $test_id,
</span><span class="cx">                 'platform' =&gt; $resolve_ids ? $id_to_platform_name[$platform_id] : $platform_id,
</span><span class="cx">                 'testGroup' =&gt; $row['request_group'],
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicincluderepositorygroupfinderphp"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/public/include/repository-group-finder.php (0 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/include/repository-group-finder.php                                (rev 0)
+++ trunk/Websites/perf.webkit.org/public/include/repository-group-finder.php        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -0,0 +1,43 @@
</span><ins>+&lt;?php
+
+class RepositoryGroupFinder
+{
+
+    function __construct($db, $triggerable_id) {
+        $this-&gt;db = $db;
+        $this-&gt;triggerable_id = $triggerable_id;
+        $this-&gt;repositories_by_group = NULL;
+    }
+
+    function find_by_repositories($repositories)
+    {
+        if ($this-&gt;repositories_by_group === NULL)
+            $this-&gt;populate_map();
+        sort($repositories, SORT_NUMERIC);
+        foreach ($this-&gt;repositories_by_group as $group_id =&gt; $group_repositories) {
+            if (count($repositories) == count($group_repositories) &amp;&amp; !array_diff($repositories, $group_repositories))
+                return $group_id;
+        }
+        return NULL;
+    }
+
+    private function populate_map()
+    {
+        $repository_rows = $this-&gt;db-&gt;query_and_fetch_all('SELECT * FROM triggerable_repositories WHERE trigrepo_group IN
+            (SELECT repositorygroup_id FROM triggerable_repository_groups WHERE repositorygroup_triggerable = $1)
+            ORDER BY trigrepo_group, trigrepo_repository', array($this-&gt;triggerable_id));
+        if ($repository_rows === FALSE)
+            exit_with_error('FailedToFetchRepositoryGroups', array('triggerable' =&gt; $this-&gt;triggerable_id));
+
+        $repositories_by_group = array();
+        foreach ($repository_rows as &amp;$row) {
+            $group_id = $row['trigrepo_group'];
+            array_ensure_item_has_array($repositories_by_group, $group_id);
+            array_push($repositories_by_group[$group_id], $row['trigrepo_repository']);
+        }
+
+        $this-&gt;repositories_by_group = &amp;$repositories_by_group;
+    }
+}
+
+?&gt;
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicprivilegedapicreatetestgroupphp"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/privileged-api/create-test-group.php (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/privileged-api/create-test-group.php        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/public/privileged-api/create-test-group.php        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -1,8 +1,10 @@
</span><span class="cx"> &lt;?php
</span><span class="cx"> 
</span><span class="cx"> require_once('../include/json-header.php');
</span><ins>+require_once('../include/repository-group-finder.php');
</ins><span class="cx"> 
</span><del>-function main() {
</del><ins>+function main()
+{
</ins><span class="cx">     $db = connect();
</span><span class="cx">     $data = ensure_privileged_api_data_and_token_or_slave($db);
</span><span class="cx">     $author = remote_user_name($data);
</span><span class="lines">@@ -35,18 +37,18 @@
</span><span class="cx">         exit_with_error('TriggerableNotFoundForTask', array('task' =&gt; $task_id));
</span><span class="cx"> 
</span><span class="cx">     if ($revision_set_list)
</span><del>-        $commit_sets = commit_sets_from_revision_sets($db, $revision_set_list);
</del><ins>+        $commit_sets = commit_sets_from_revision_sets($db, $triggerable['id'], $revision_set_list);
</ins><span class="cx">     else // V2 UI compatibility
</span><del>-        $commit_sets = ensure_commit_sets($db, $commit_sets_info);
</del><ins>+        $commit_sets = ensure_commit_sets($db, $triggerable['id'], $commit_sets_info);
</ins><span class="cx"> 
</span><span class="cx">     $db-&gt;begin_transaction();
</span><span class="cx"> 
</span><del>-    $commit_set_id_list = array();
</del><ins>+    $configuration_list = array();
</ins><span class="cx">     foreach ($commit_sets as $commit_list) {
</span><span class="cx">         $commit_set_id = $db-&gt;insert_row('commit_sets', 'commitset', array());
</span><del>-        foreach ($commit_list as $commit)
</del><ins>+        foreach ($commit_list['set'] as $commit)
</ins><span class="cx">             $db-&gt;insert_row('commit_set_relationships', 'commitset', array('set' =&gt; $commit_set_id, 'commit' =&gt; $commit), 'commit');
</span><del>-        array_push($commit_set_id_list, $commit_set_id);
</del><ins>+        array_push($configuration_list, array('commit_set' =&gt; $commit_set_id, 'repository_group' =&gt; $commit_list['repository_group']));
</ins><span class="cx">     }
</span><span class="cx"> 
</span><span class="cx">     $group_id = $db-&gt;insert_row('analysis_test_groups', 'testgroup',
</span><span class="lines">@@ -54,14 +56,15 @@
</span><span class="cx"> 
</span><span class="cx">     $order = 0;
</span><span class="cx">     for ($i = 0; $i &lt; $repetition_count; $i++) {
</span><del>-        foreach ($commit_set_id_list as $commit_set_id) {
</del><ins>+        foreach ($configuration_list as $config) {
</ins><span class="cx">             $db-&gt;insert_row('build_requests', 'request', array(
</span><span class="cx">                 'triggerable' =&gt; $triggerable['id'],
</span><ins>+                'repository_group' =&gt; $config['repository_group'],
</ins><span class="cx">                 'platform' =&gt; $triggerable['platform'],
</span><span class="cx">                 'test' =&gt; $triggerable['test'],
</span><span class="cx">                 'group' =&gt; $group_id,
</span><span class="cx">                 'order' =&gt; $order,
</span><del>-                'commit_set' =&gt; $commit_set_id));
</del><ins>+                'commit_set' =&gt; $config['commit_set'],));
</ins><span class="cx">             $order++;
</span><span class="cx">         }
</span><span class="cx">     }
</span><span class="lines">@@ -71,18 +74,20 @@
</span><span class="cx">     exit_with_success(array('testGroupId' =&gt; $group_id));
</span><span class="cx"> }
</span><span class="cx"> 
</span><del>-function commit_sets_from_revision_sets($db, $revision_set_list)
</del><ins>+function commit_sets_from_revision_sets($db, $triggerable_id, $revision_set_list)
</ins><span class="cx"> {
</span><span class="cx">     if (count($revision_set_list) &lt; 2)
</span><span class="cx">         exit_with_error('InvalidRevisionSets', array('revisionSets' =&gt; $revision_set_list));
</span><span class="cx"> 
</span><ins>+    $finder = new RepositoryGroupFinder($db, $triggerable_id);
</ins><span class="cx">     $commit_set_list = array();
</span><span class="cx">     foreach ($revision_set_list as $revision_set) {
</span><del>-        $commit_set = array();
-
</del><span class="cx">         if (!count($revision_set))
</span><span class="cx">             exit_with_error('InvalidRevisionSets', array('revisionSets' =&gt; $revision_set_list));
</span><span class="cx"> 
</span><ins>+        $commit_set = array();
+        $repository_list = array();
+
</ins><span class="cx">         foreach ($revision_set as $repository_id =&gt; $revision) {
</span><span class="cx">             if (!is_numeric($repository_id))
</span><span class="cx">                 exit_with_error('InvalidRepository', array('repository' =&gt; $repository_id));
</span><span class="lines">@@ -91,39 +96,53 @@
</span><span class="cx">             if (!$commit)
</span><span class="cx">                 exit_with_error('RevisionNotFound', array('repository' =&gt; $repository_id, 'revision' =&gt; $revision));
</span><span class="cx">             array_push($commit_set, $commit['commit_id']);
</span><ins>+            array_push($repository_list, $repository_id);
</ins><span class="cx">         }
</span><del>-        array_push($commit_set_list, $commit_set);
</del><ins>+
+        $repository_group_id = $finder-&gt;find_by_repositories($repository_list);
+        if (!$repository_group_id)
+            exit_with_error('NoMatchingRepositoryGroup', array('repositoris' =&gt; $repository_list));
+
+        array_push($commit_set_list, array('repository_group' =&gt; $repository_group_id, 'set' =&gt; $commit_set));
</ins><span class="cx">     }
</span><span class="cx"> 
</span><span class="cx">     return $commit_set_list;
</span><span class="cx"> }
</span><span class="cx"> 
</span><del>-function ensure_commit_sets($db, $commit_sets_info) {
</del><ins>+function ensure_commit_sets($db, $triggerable_id, $commit_sets_info) {
</ins><span class="cx">     $repository_name_to_id = array();
</span><span class="cx">     foreach ($db-&gt;select_rows('repositories', 'repository', array('owner' =&gt; NULL)) as $row)
</span><span class="cx">         $repository_name_to_id[$row['repository_name']] = $row['repository_id'];
</span><span class="cx"> 
</span><span class="cx">     $commit_sets = array();
</span><ins>+    $repository_list = array();
</ins><span class="cx">     foreach ($commit_sets_info as $repository_name =&gt; $revisions) {
</span><span class="cx">         $repository_id = array_get($repository_name_to_id, $repository_name);
</span><span class="cx">         if (!$repository_id)
</span><span class="cx">             exit_with_error('RepositoryNotFound', array('name' =&gt; $repository_name));
</span><ins>+        array_push($repository_list, $repository_id);
</ins><span class="cx"> 
</span><span class="cx">         foreach ($revisions as $i =&gt; $revision) {
</span><span class="cx">             $commit = $db-&gt;select_first_row('commits', 'commit', array('repository' =&gt; $repository_id, 'revision' =&gt; $revision));
</span><span class="cx">             if (!$commit)
</span><span class="cx">                 exit_with_error('RevisionNotFound', array('repository' =&gt; $repository_name, 'revision' =&gt; $revision));
</span><del>-            array_set_default($commit_sets, $i, array());
-            array_push($commit_sets[$i], $commit['commit_id']);
</del><ins>+            array_set_default($commit_sets, $i, array('set' =&gt; array()));
+            array_push($commit_sets[$i]['set'], $commit['commit_id']);
</ins><span class="cx">         }
</span><span class="cx">     }
</span><span class="cx"> 
</span><ins>+    $finder = new RepositoryGroupFinder($db, $triggerable_id);
+    $repository_group_id = $finder-&gt;find_by_repositories($repository_list);
+    if (!$repository_group_id)
+        exit_with_error('NoMatchingRepositoryGroup', array('repositoris' =&gt; $repository_list));
+
</ins><span class="cx">     if (count($commit_sets) &lt; 2)
</span><span class="cx">         exit_with_error('InvalidCommitSets', array('commitSets' =&gt; $commit_sets_info));
</span><span class="cx"> 
</span><del>-    $commit_count_per_set = count($commit_sets[0]);
-    foreach ($commit_sets as $commits) {
-        if ($commit_count_per_set != count($commits))
</del><ins>+    $commit_count_per_set = count($commit_sets[0]['set']);
+    foreach ($commit_sets as &amp;$commits) {
+        $commits['repository_group'] = $repository_group_id;
+        if ($commit_count_per_set != count($commits['set']))
</ins><span class="cx">             exit_with_error('InvalidCommitSets', array('commitSets' =&gt; $commit_sets));
</span><span class="cx">     }
</span><span class="cx"> 
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicv3modelsbuildrequestjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/v3/models/build-request.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/v3/models/build-request.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/public/v3/models/build-request.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -6,6 +6,7 @@
</span><span class="cx">     {
</span><span class="cx">         super(id, object);
</span><span class="cx">         this._triggerable = object.triggerable;
</span><ins>+        console.assert(!object.repositoryGroup || object.repositoryGroup instanceof TriggerableRepositoryGroup);
</ins><span class="cx">         this._analysisTaskId = object.task;
</span><span class="cx">         this._testGroupId = object.testGroupId;
</span><span class="cx">         console.assert(!object.testGroup || object.testGroup instanceof TestGroup);
</span><span class="lines">@@ -12,6 +13,7 @@
</span><span class="cx">         this._testGroup = object.testGroup;
</span><span class="cx">         if (this._testGroup)
</span><span class="cx">             this._testGroup.addBuildRequest(this);
</span><ins>+        this._repositoryGroup = object.repositoryGroup;
</ins><span class="cx">         console.assert(object.platform instanceof Platform);
</span><span class="cx">         this._platform = object.platform;
</span><span class="cx">         console.assert(object.test instanceof Test);
</span><span class="lines">@@ -36,9 +38,11 @@
</span><span class="cx">         this._buildId = object.build;
</span><span class="cx">     }
</span><span class="cx"> 
</span><ins>+    triggerable() { return this._triggerable; }
</ins><span class="cx">     analysisTaskId() { return this._analysisTaskId; }
</span><span class="cx">     testGroupId() { return this._testGroupId; }
</span><span class="cx">     testGroup() { return this._testGroup; }
</span><ins>+    repositoryGroup() { return this._repositoryGroup; }
</ins><span class="cx">     platform() { return this._platform; }
</span><span class="cx">     test() { return this._test; }
</span><span class="cx">     order() { return +this._order; }
</span><span class="lines">@@ -129,6 +133,8 @@
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         return data['buildRequests'].map(function (rawData) {
</span><ins>+            rawData.triggerable = Triggerable.findById(rawData.triggerable);
+            rawData.repositoryGroup = TriggerableRepositoryGroup.findById(rawData.repositoryGroup);
</ins><span class="cx">             rawData.platform = Platform.findById(rawData.platform);
</span><span class="cx">             rawData.test = Test.findById(rawData.test);
</span><span class="cx">             rawData.testGroupId = rawData.testGroup;
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicv3modelsrepositoryjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/v3/models/repository.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/v3/models/repository.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/public/v3/models/repository.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -8,8 +8,17 @@
</span><span class="cx">         this._blameUrl = object.blameUrl;
</span><span class="cx">         this._hasReportedCommits = object.hasReportedCommits;
</span><span class="cx">         this._owner = object.owner;
</span><ins>+
+        if (!object.owner)
+            this.ensureNamedStaticMap('topLevelName')[this.name()] = this;
</ins><span class="cx">     }
</span><span class="cx"> 
</span><ins>+    static findTopLevelByName(name)
+    {
+        const map = this.namedStaticMap('topLevelName');
+        return map ? map[name] : null;
+    }
+
</ins><span class="cx">     hasUrlForRevision() { return !!this._url; }
</span><span class="cx"> 
</span><span class="cx">     urlForRevision(currentRevision)
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicv3modelstriggerablejs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/v3/models/triggerable.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/v3/models/triggerable.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/public/v3/models/triggerable.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -19,8 +19,8 @@
</span><span class="cx">         }
</span><span class="cx">     }
</span><span class="cx"> 
</span><ins>+    name() { return this._name; }
</ins><span class="cx">     isDisabled() { return this._isDisabled; }
</span><del>-    acceptedRepositories() { return this._acceptedRepositories; }
</del><span class="cx">     repositoryGroups() { return this._repositoryGroups; }
</span><span class="cx"> 
</span><span class="cx">     acceptsTest(test) { return this._acceptedTests.has(test); }
</span><span class="lines">@@ -46,9 +46,21 @@
</span><span class="cx">         super(id, object);
</span><span class="cx">         this._description = object.description;
</span><span class="cx">         this._acceptsCustomRoots = !!object.acceptsCustomRoots;
</span><del>-        this._repositories = object.repositories;
</del><ins>+        this._repositories = Repository.sortByName(object.repositories);
</ins><span class="cx">     }
</span><span class="cx"> 
</span><ins>+    accepts(commitSet)
+    {
+        const commitSetRepositories = Repository.sortByName(commitSet.repositories());
+        if (this._repositories.length != commitSetRepositories.length)
+            return false;
+        for (let i = 0; i &lt; this._repositories.length; i++) {
+            if (this._repositories[i] != commitSetRepositories[i])
+                return false;
+        }
+        return true;
+    }
+
</ins><span class="cx">     description() { return this._description || this.name(); }
</span><span class="cx">     acceptsCustomRoots() { return this._acceptsCustomRoots; }
</span><span class="cx">     repositories() { return this._repositories; }
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgservertestsapibuildrequeststestsjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/server-tests/api-build-requests-tests.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/api-build-requests-tests.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/server-tests/api-build-requests-tests.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -95,7 +95,7 @@
</span><span class="cx"> 
</span><span class="cx">             assert.equal(content['commits'].length, 3);
</span><span class="cx">             assert.equal(content['commits'][0].id, 87832);
</span><del>-            assert.equal(content['commits'][0].repository, 'OS X');
</del><ins>+            assert.equal(content['commits'][0].repository, 'macOS');
</ins><span class="cx">             assert.equal(content['commits'][0].revision, '10.11 15A284');
</span><span class="cx">             assert.equal(content['commits'][1].id, 93116);
</span><span class="cx">             assert.equal(content['commits'][1].repository, 'WebKit');
</span><span class="lines">@@ -194,7 +194,7 @@
</span><span class="cx">             assert.equal(buildRequests[3].statusLabel(), 'Waiting');
</span><span class="cx"> 
</span><span class="cx">             let osx = Repository.findById(9);
</span><del>-            assert.equal(osx.name(), 'OS X');
</del><ins>+            assert.equal(osx.name(), 'macOS');
</ins><span class="cx"> 
</span><span class="cx">             let webkit = Repository.findById(11);
</span><span class="cx">             assert.equal(webkit.name(), 'WebKit');
</span><span class="lines">@@ -325,6 +325,42 @@
</span><span class="cx">         });
</span><span class="cx">     });
</span><span class="cx"> 
</span><ins>+    it('should specify the repository group for build requests if set', () =&gt; {
+        const db = TestServer.database();
+        let groups;
+        return MockData.addMockData(db).then(() =&gt; {
+            return MockData.addMockTestGroupWithGitWebKit(db);
+        }).then(() =&gt; {
+            return Manifest.fetch();
+        }).then(() =&gt; {
+            const triggerable = Triggerable.all().find((triggerable) =&gt; triggerable.name() == 'build-webkit');
+            assert.equal(triggerable.repositoryGroups().length, 2);
+            groups = TriggerableRepositoryGroup.sortByName(triggerable.repositoryGroups());
+            assert.equal(groups[0].name(), 'webkit-git');
+            assert.equal(groups[1].name(), 'webkit-svn');
+            return BuildRequest.fetchForTriggerable('build-webkit');
+        }).then((buildRequests) =&gt; {
+            assert.equal(buildRequests.length, 8);
+            assert.equal(buildRequests[0].id(), 700);
+            assert.equal(buildRequests[0].repositoryGroup(), groups[1]);
+            assert.equal(buildRequests[1].id(), 701);
+            assert.equal(buildRequests[1].repositoryGroup(), groups[1]);
+            assert.equal(buildRequests[2].id(), 702);
+            assert.equal(buildRequests[2].repositoryGroup(), groups[1]);
+            assert.equal(buildRequests[3].id(), 703);
+            assert.equal(buildRequests[3].repositoryGroup(), groups[1]);
+
+            assert.equal(buildRequests[4].id(), 1700);
+            assert.equal(buildRequests[4].repositoryGroup(), groups[0]);
+            assert.equal(buildRequests[5].id(), 1701);
+            assert.equal(buildRequests[5].repositoryGroup(), groups[0]);
+            assert.equal(buildRequests[6].id(), 1702);
+            assert.equal(buildRequests[6].repositoryGroup(), groups[0]);
+            assert.equal(buildRequests[7].id(), 1703);
+            assert.equal(buildRequests[7].repositoryGroup(), groups[0]);
+        });
+    });
+
</ins><span class="cx">     it('should place build requests created by user before automatically created ones', () =&gt; {
</span><span class="cx">         let db = TestServer.database();
</span><span class="cx">         return Promise.all([MockData.addMockData(db), MockData.addAnotherMockTestGroup(db, null, 'rniwa')]).then(() =&gt; {
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgservertestsapimanifesttestsjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/server-tests/api-manifest-tests.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/api-manifest-tests.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/server-tests/api-manifest-tests.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -62,7 +62,7 @@
</span><span class="cx">             db.insert('bug_trackers', bugzillaData),
</span><span class="cx">             db.insert('bug_trackers', radarData),
</span><span class="cx">             db.insert('repositories', {id: 11, name: 'WebKit', url: 'https://trac.webkit.org/$1'}),
</span><del>-            db.insert('repositories', {id: 9, name: 'OS X'}),
</del><ins>+            db.insert('repositories', {id: 9, name: 'macOS'}),
</ins><span class="cx">             db.insert('repositories', {id: 22, name: 'iOS'}),
</span><span class="cx">             db.insert('tracker_repositories', {tracker: bugzillaData.id, repository: 11}),
</span><span class="cx">             db.insert('tracker_repositories', {tracker: radarData.id, repository: 9}),
</span><span class="lines">@@ -77,9 +77,9 @@
</span><span class="cx">             assert.equal(webkit.name(), 'WebKit');
</span><span class="cx">             assert.equal(webkit.urlForRevision(123), 'https://trac.webkit.org/123');
</span><span class="cx"> 
</span><del>-            let osx = Repository.findById(9);
-            assert(osx);
-            assert.equal(osx.name(), 'OS X');
</del><ins>+            let macos = Repository.findById(9);
+            assert(macos);
+            assert.equal(macos.name(), 'macOS');
</ins><span class="cx"> 
</span><span class="cx">             let ios = Repository.findById(22);
</span><span class="cx">             assert(ios);
</span><span class="lines">@@ -95,7 +95,7 @@
</span><span class="cx">             tracker = BugTracker.findById(2);
</span><span class="cx">             assert(tracker);
</span><span class="cx">             assert.equal(tracker.name(), 'Radar');
</span><del>-            assert.deepEqual(Repository.sortByName(tracker.repositories()), [osx, ios]);
</del><ins>+            assert.deepEqual(Repository.sortByName(tracker.repositories()), [ios, macos]);
</ins><span class="cx">         });
</span><span class="cx">     });
</span><span class="cx"> 
</span><span class="lines">@@ -264,7 +264,7 @@
</span><span class="cx">         let db = TestServer.database();
</span><span class="cx">         return Promise.all([
</span><span class="cx">             db.insert('repositories', {id: 11, name: 'WebKit', url: 'https://trac.webkit.org/$1'}),
</span><del>-            db.insert('repositories', {id: 9, name: 'OS X'}),
</del><ins>+            db.insert('repositories', {id: 9, name: 'macOS'}),
</ins><span class="cx">             db.insert('repositories', {id: 101, name: 'WebKit', owner: 9, url: 'https://trac.webkit.org/$1'}),
</span><span class="cx">             db.insert('build_triggerables', {id: 200, name: 'build.webkit.org'}),
</span><span class="cx">             db.insert('build_triggerables', {id: 201, name: 'ios-build.webkit.org'}),
</span><span class="lines">@@ -314,8 +314,8 @@
</span><span class="cx">             assert.equal(osWebkit1.owner(), 9);
</span><span class="cx">             assert.equal(osWebkit1.urlForRevision(123), 'https://trac.webkit.org/123');
</span><span class="cx"> 
</span><del>-            const osx = Repository.findById(9);
-            assert.equal(osx.name(), 'OS X');
</del><ins>+            const macos = Repository.findById(9);
+            assert.equal(macos.name(), 'macOS');
</ins><span class="cx"> 
</span><span class="cx">             const someTest = Test.findById(1);
</span><span class="cx">             assert.equal(someTest.name(), 'SomeTest');
</span><span class="lines">@@ -337,17 +337,15 @@
</span><span class="cx"> 
</span><span class="cx">             assert.equal(Triggerable.all().length, 3);
</span><span class="cx"> 
</span><del>-            const osxTriggerable = Triggerable.findByTestConfiguration(someTest, mavericks);
-            assert.equal(osxTriggerable.name(), 'build.webkit.org');
-            assert.deepEqual(osxTriggerable.acceptedRepositories(), [webkit]);
</del><ins>+            const macosTriggerable = Triggerable.findByTestConfiguration(someTest, mavericks);
+            assert.equal(macosTriggerable.name(), 'build.webkit.org');
</ins><span class="cx"> 
</span><del>-            assert.equal(Triggerable.findByTestConfiguration(someOtherTest, mavericks), osxTriggerable);
-            assert.equal(Triggerable.findByTestConfiguration(childTest, mavericks), osxTriggerable);
</del><ins>+            assert.equal(Triggerable.findByTestConfiguration(someOtherTest, mavericks), macosTriggerable);
+            assert.equal(Triggerable.findByTestConfiguration(childTest, mavericks), macosTriggerable);
</ins><span class="cx"> 
</span><span class="cx">             const iosTriggerable = Triggerable.findByTestConfiguration(someOtherTest, ios9iphone5s);
</span><del>-            assert.notEqual(iosTriggerable, osxTriggerable);
</del><ins>+            assert.notEqual(iosTriggerable, macosTriggerable);
</ins><span class="cx">             assert.equal(iosTriggerable.name(), 'ios-build.webkit.org');
</span><del>-            assert.deepEqual(iosTriggerable.acceptedRepositories(), [webkit]);
</del><span class="cx"> 
</span><span class="cx">             assert.equal(Triggerable.findByTestConfiguration(someOtherTest, ios9iphone5s), iosTriggerable);
</span><span class="cx">             assert.equal(Triggerable.findByTestConfiguration(childTest, ios9iphone5s), iosTriggerable);
</span><span class="lines">@@ -354,7 +352,6 @@
</span><span class="cx"> 
</span><span class="cx">             const macTriggerable = Triggerable.findByTestConfiguration(someTest, sierra);
</span><span class="cx">             assert.equal(macTriggerable.name(), 'mac-build.webkit.org');
</span><del>-            assert.deepEqual(Repository.sortByName(macTriggerable.acceptedRepositories()), [osx, webkit]);
</del><span class="cx">             assert(macTriggerable.acceptsTest(someTest));
</span><span class="cx"> 
</span><span class="cx">             const groups = macTriggerable.repositoryGroups();
</span><span class="lines">@@ -361,14 +358,33 @@
</span><span class="cx">             assert.deepEqual(groups.length, 2);
</span><span class="cx">             TriggerableRepositoryGroup.sortByName(groups);
</span><span class="cx"> 
</span><ins>+            const emptyCustomSet = new CustomCommitSet;
+
+            const customSetWithOSX = new CustomCommitSet;
+            customSetWithOSX.setRevisionForRepository(macos, '10.11 15A284');
+
+            const cusomSetWithOSXAndWebKit = new CustomCommitSet;
+            cusomSetWithOSXAndWebKit.setRevisionForRepository(webkit, '191622');
+            cusomSetWithOSXAndWebKit.setRevisionForRepository(macos, '10.11 15A284');
+
+            const cusomSetWithWebKit = new CustomCommitSet;
+            cusomSetWithWebKit.setRevisionForRepository(webkit, '191622');
+
</ins><span class="cx">             assert.equal(groups[0].name(), 'system-and-roots');
</span><span class="cx">             assert.equal(groups[0].acceptsCustomRoots(), true);
</span><del>-            assert.deepEqual(Repository.sortByName(groups[0].repositories()), [osx]);
</del><ins>+            assert.deepEqual(Repository.sortByName(groups[0].repositories()), [macos]);
+            assert.equal(groups[0].accepts(emptyCustomSet), false);
+            assert.equal(groups[0].accepts(customSetWithOSX), true);
+            assert.equal(groups[0].accepts(cusomSetWithOSXAndWebKit), false);
+            assert.equal(groups[0].accepts(cusomSetWithWebKit), false);
</ins><span class="cx"> 
</span><span class="cx">             assert.equal(groups[1].name(), 'system-and-webkit');
</span><span class="cx">             assert.equal(groups[1].acceptsCustomRoots(), false);
</span><del>-            assert.deepEqual(Repository.sortByName(groups[1].repositories()), [osx, webkit]);
-
</del><ins>+            assert.deepEqual(Repository.sortByName(groups[1].repositories()), [webkit, macos]);
+            assert.equal(groups[1].accepts(emptyCustomSet), false);
+            assert.equal(groups[1].accepts(customSetWithOSX), false);
+            assert.equal(groups[1].accepts(cusomSetWithOSXAndWebKit), true);
+            assert.equal(groups[1].accepts(cusomSetWithWebKit), false);
</ins><span class="cx">         });
</span><span class="cx">     });
</span><span class="cx"> 
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgservertestsapireporttestsjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/server-tests/api-report-tests.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/api-report-tests.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/server-tests/api-report-tests.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -21,7 +21,7 @@
</span><span class="cx">             &quot;platform&quot;: &quot;Mountain Lion&quot;,
</span><span class="cx">             &quot;tests&quot;: {},
</span><span class="cx">             &quot;revisions&quot;: {
</span><del>-                &quot;OS X&quot;: {
</del><ins>+                &quot;macOS&quot;: {
</ins><span class="cx">                     &quot;revision&quot;: &quot;10.8.2 12C60&quot;
</span><span class="cx">                 },
</span><span class="cx">                 &quot;WebKit&quot;: {
</span><span class="lines">@@ -43,7 +43,7 @@
</span><span class="cx">             &quot;platform&quot;: &quot;Mountain Lion&quot;,
</span><span class="cx">             &quot;tests&quot;: {},
</span><span class="cx">             &quot;revisions&quot;: {
</span><del>-                &quot;OS X&quot;: {
</del><ins>+                &quot;macOS&quot;: {
</ins><span class="cx">                     &quot;revision&quot;: &quot;10.8.2 12C60&quot;
</span><span class="cx">                 },
</span><span class="cx">                 &quot;WebKit&quot;: {
</span><span class="lines">@@ -232,7 +232,7 @@
</span><span class="cx">             const commits = result[1];
</span><span class="cx">             const buildCommitsRelations = result[2];
</span><span class="cx">             assert.equal(repositories.length, 2);
</span><del>-            assert.deepEqual(repositories.map((row) =&gt; { return row['name']; }).sort(), ['OS X', 'WebKit']);
</del><ins>+            assert.deepEqual(repositories.map((row) =&gt; row['name']).sort(), ['WebKit', 'macOS']);
</ins><span class="cx"> 
</span><span class="cx">             assert.equal(commits.length, 2);
</span><span class="cx">             assert.equal(buildCommitsRelations.length, 2);
</span><span class="lines">@@ -248,7 +248,7 @@
</span><span class="cx">             for (let commit of commits)
</span><span class="cx">                 repositoryNameToRevisionRow[repositoryIdToName[commit['repository']]] = commit;
</span><span class="cx"> 
</span><del>-            assert.equal(repositoryNameToRevisionRow['OS X']['revision'], '10.8.2 12C60');
</del><ins>+            assert.equal(repositoryNameToRevisionRow['macOS']['revision'], '10.8.2 12C60');
</ins><span class="cx">             assert.equal(repositoryNameToRevisionRow['WebKit']['revision'], '141977');
</span><span class="cx">             assert.equal(repositoryNameToRevisionRow['WebKit']['time'].toString(),
</span><span class="cx">                 new Date('2013-02-06 08:55:20.9').toString());
</span><span class="lines">@@ -368,7 +368,7 @@
</span><span class="cx">             }
</span><span class="cx">         },
</span><span class="cx">         &quot;revisions&quot;: {
</span><del>-            &quot;OS X&quot;: {
</del><ins>+            &quot;macOS&quot;: {
</ins><span class="cx">                 &quot;revision&quot;: &quot;10.8.2 12C60&quot;
</span><span class="cx">             },
</span><span class="cx">             &quot;WebKit&quot;: {
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgservertestsapiupdatetriggerablejs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/server-tests/api-update-triggerable.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/api-update-triggerable.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/server-tests/api-update-triggerable.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -68,7 +68,7 @@
</span><span class="cx">             return Promise.all([
</span><span class="cx">                 addSlaveForReport(emptyUpdate),
</span><span class="cx">                 db.insert('triggerable_configurations',
</span><del>-                    {'triggerable': 1 /* build-webkit */, 'test': MockData.someTestId(), 'platform': MockData.somePlatformId()})
</del><ins>+                    {'triggerable': 1000 /* build-webkit */, 'test': MockData.someTestId(), 'platform': MockData.somePlatformId()})
</ins><span class="cx">             ]);
</span><span class="cx">         }).then(() =&gt; {
</span><span class="cx">             return TestServer.remoteAPI().postJSON('/api/update-triggerable/', emptyUpdate);
</span><span class="lines">@@ -112,4 +112,291 @@
</span><span class="cx">         });
</span><span class="cx">     });
</span><span class="cx"> 
</span><ins>+    function updateWithOSXRepositoryGroup()
+    {
+        return {
+            'slaveName': 'someSlave',
+            'slavePassword': 'somePassword',
+            'triggerable': 'empty-triggerable',
+            'configurations': [
+                {test: MockData.someTestId(), platform: MockData.somePlatformId()}
+            ],
+            'repositoryGroups': [
+                {name: 'system-only', repositories: [MockData.macosRepositoryId()]},
+            ]
+        };
+    }
+
+    it('should reject when repositoryGroups is not an array', () =&gt; {
+        const update = updateWithOSXRepositoryGroup();
+        update.repositoryGroups = 1;
+        return MockData.addEmptyTriggerable(TestServer.database()).then(() =&gt; {
+            return addSlaveForReport(update);
+        }).then(() =&gt; {
+            return TestServer.remoteAPI().postJSON('/api/update-triggerable/', update);
+        }).then((response) =&gt; {
+            assert.equal(response['status'], 'InvalidRepositoryGroups');
+        });
+    });
+
+    it('should reject when the name of a repository group is not specified', () =&gt; {
+        const update = updateWithOSXRepositoryGroup();
+        delete update.repositoryGroups[0].name;
+        return MockData.addEmptyTriggerable(TestServer.database()).then(() =&gt; {
+            return addSlaveForReport(update);
+        }).then(() =&gt; {
+            return TestServer.remoteAPI().postJSON('/api/update-triggerable/', update);
+        }).then((response) =&gt; {
+            assert.equal(response['status'], 'InvalidRepositoryGroup');
+        });
+    });
+
+    it('should reject when the repository list is not specified for a repository group', () =&gt; {
+        const update = updateWithOSXRepositoryGroup();
+        delete update.repositoryGroups[0].repositories;
+        return MockData.addEmptyTriggerable(TestServer.database()).then(() =&gt; {
+            return addSlaveForReport(update);
+        }).then(() =&gt; {
+            return TestServer.remoteAPI().postJSON('/api/update-triggerable/', update);
+        }).then((response) =&gt; {
+            assert.equal(response['status'], 'InvalidRepositoryGroup');
+        });
+    });
+
+    it('should reject when the repository list of a repository group is not an array', () =&gt; {
+        const update = updateWithOSXRepositoryGroup();
+        update.repositoryGroups[0].repositories = 'hi';
+        return MockData.addEmptyTriggerable(TestServer.database()).then(() =&gt; {
+            return addSlaveForReport(update);
+        }).then(() =&gt; {
+            return TestServer.remoteAPI().postJSON('/api/update-triggerable/', update);
+        }).then((response) =&gt; {
+            assert.equal(response['status'], 'InvalidRepositoryGroup');
+        });
+    });
+
+    it('should reject when a repository group contains an invalid repository id', () =&gt; {
+        const update = updateWithOSXRepositoryGroup();
+        update.repositoryGroups[0].repositories[0] = 999;
+        return MockData.addEmptyTriggerable(TestServer.database()).then(() =&gt; {
+            return addSlaveForReport(update);
+        }).then(() =&gt; {
+            return TestServer.remoteAPI().postJSON('/api/update-triggerable/', update);
+        }).then((response) =&gt; {
+            assert.equal(response['status'], 'InvalidRepository');
+        });
+    });
+
+    it('should reject when a repository group contains a duplicate repository id', () =&gt; {
+        const update = updateWithOSXRepositoryGroup();
+        const group = update.repositoryGroups[0];
+        group.repositories.push(group.repositories[0]);
+        return MockData.addEmptyTriggerable(TestServer.database()).then(() =&gt; {
+            return addSlaveForReport(update);
+        }).then(() =&gt; {
+            return TestServer.remoteAPI().postJSON('/api/update-triggerable/', update);
+        }).then((response) =&gt; {
+            assert.equal(response['status'], 'InvalidRepository');
+        });
+    });
+
+    it('should add a new repository group when there are none', () =&gt; {
+        const db = TestServer.database();
+        return MockData.addEmptyTriggerable(db).then(() =&gt; {
+            return addSlaveForReport(updateWithOSXRepositoryGroup());
+        }).then(() =&gt; {
+            return TestServer.remoteAPI().postJSON('/api/update-triggerable/', updateWithOSXRepositoryGroup());
+        }).then((response) =&gt; {
+            assert.equal(response['status'], 'OK');
+            return Promise.all([db.selectAll('triggerable_configurations', 'test'), db.selectAll('triggerable_repository_groups')]);
+        }).then((result) =&gt; {
+            const [configurations, repositoryGroups] = result;
+
+            assert.equal(configurations.length, 1);
+            assert.equal(configurations[0]['test'], MockData.someTestId());
+            assert.equal(configurations[0]['platform'], MockData.somePlatformId());
+
+            assert.equal(repositoryGroups.length, 1);
+            assert.equal(repositoryGroups[0]['name'], 'system-only');
+            assert.equal(repositoryGroups[0]['triggerable'], MockData.emptyTriggeragbleId());
+        });
+    });
+
+    it('should not add a duplicate repository group when there is a group of the same name', () =&gt; {
+        const db = TestServer.database();
+        let initialResult;
+        return MockData.addEmptyTriggerable(db).then(() =&gt; {
+            return addSlaveForReport(updateWithOSXRepositoryGroup());
+        }).then(() =&gt; {
+            return TestServer.remoteAPI().postJSONWithStatus('/api/update-triggerable/', updateWithOSXRepositoryGroup());
+        }).then((response) =&gt; {
+            return Promise.all([db.selectAll('triggerable_configurations', 'test'), db.selectAll('triggerable_repository_groups')]);
+        }).then((result) =&gt; {
+            initialResult = result;
+            return TestServer.remoteAPI().postJSONWithStatus('/api/update-triggerable/', updateWithOSXRepositoryGroup());
+        }).then(() =&gt; {
+            return Promise.all([db.selectAll('triggerable_configurations', 'test'), db.selectAll('triggerable_repository_groups')]);
+        }).then((result) =&gt; {
+            const [initialConfigurations, initialRepositoryGroups] = initialResult;
+            const [configurations, repositoryGroups] = result;
+            assert.deepEqual(configurations, initialConfigurations);
+            assert.deepEqual(repositoryGroups, initialRepositoryGroups);
+        })
+    });
+
+    it('should not add a duplicate repository group when there is a group of the same name', () =&gt; {
+        const db = TestServer.database();
+        let initialResult;
+        return MockData.addEmptyTriggerable(db).then(() =&gt; {
+            return addSlaveForReport(updateWithOSXRepositoryGroup());
+        }).then(() =&gt; {
+            return TestServer.remoteAPI().postJSONWithStatus('/api/update-triggerable/', updateWithOSXRepositoryGroup());
+        }).then((response) =&gt; {
+            return Promise.all([db.selectAll('triggerable_configurations', 'test'), db.selectAll('triggerable_repository_groups')]);
+        }).then((result) =&gt; {
+            initialResult = result;
+            return TestServer.remoteAPI().postJSONWithStatus('/api/update-triggerable/', updateWithOSXRepositoryGroup());
+        }).then(() =&gt; {
+            return Promise.all([db.selectAll('triggerable_configurations', 'test'), db.selectAll('triggerable_repository_groups')]);
+        }).then((result) =&gt; {
+            const [initialConfigurations, initialRepositoryGroups] = initialResult;
+            const [configurations, repositoryGroups] = result;
+            assert.deepEqual(configurations, initialConfigurations);
+            assert.deepEqual(repositoryGroups, initialRepositoryGroups);
+        })
+    });
+
+    it('should update the description of a repository group when the name matches', () =&gt; {
+        const db = TestServer.database();
+        const initialUpdate = updateWithOSXRepositoryGroup();
+        const secondUpdate = updateWithOSXRepositoryGroup();
+        secondUpdate.repositoryGroups[0].description = 'this group is awesome';
+        return MockData.addEmptyTriggerable(db).then(() =&gt; {
+            return addSlaveForReport(initialUpdate);
+        }).then(() =&gt; {
+            return TestServer.remoteAPI().postJSONWithStatus('/api/update-triggerable/', initialUpdate);
+        }).then((response) =&gt; db.selectAll('triggerable_repository_groups')).then((repositoryGroups) =&gt; {
+            assert.equal(repositoryGroups.length, 1);
+            assert.equal(repositoryGroups[0]['name'], 'system-only');
+            assert.equal(repositoryGroups[0]['description'], null);
+            return TestServer.remoteAPI().postJSONWithStatus('/api/update-triggerable/', secondUpdate);
+        }).then(() =&gt; db.selectAll('triggerable_repository_groups')).then((repositoryGroups) =&gt; {
+            assert.equal(repositoryGroups.length, 1);
+            assert.equal(repositoryGroups[0]['name'], 'system-only');
+            assert.equal(repositoryGroups[0]['description'], 'this group is awesome');
+        });
+    });
+
+    function updateWithMacWebKitRepositoryGroups()
+    {
+        return {
+            'slaveName': 'someSlave',
+            'slavePassword': 'somePassword',
+            'triggerable': 'empty-triggerable',
+            'configurations': [
+                {test: MockData.someTestId(), platform: MockData.somePlatformId()}
+            ],
+            'repositoryGroups': [
+                {name: 'system-only', repositories: [MockData.macosRepositoryId()]},
+                {name: 'system-and-webkit', repositories: [MockData.webkitRepositoryId(), MockData.macosRepositoryId()]},
+            ]
+        };
+    }
+
+    function mapRepositoriesByGroup(repositories)
+    {
+        const map = {};
+        for (const row of repositories) {
+            const groupId = row['group'];
+            if (!(groupId in map))
+                map[groupId] = [];
+            map[groupId].push(row['repository']);
+        }
+        return map;
+    }
+
+    it('should replace a repository when the repository group name matches', () =&gt; {
+        const db = TestServer.database();
+        const initialUpdate = updateWithMacWebKitRepositoryGroups();
+        const secondUpdate = updateWithMacWebKitRepositoryGroups();
+        let initialGroups;
+        secondUpdate.repositoryGroups[1].repositories[0] = MockData.gitWebkitRepositoryId();
+        return MockData.addEmptyTriggerable(db).then(() =&gt; {
+            return addSlaveForReport(initialUpdate);
+        }).then(() =&gt; {
+            return TestServer.remoteAPI().postJSONWithStatus('/api/update-triggerable/', initialUpdate);
+        }).then((response) =&gt; {
+            return Promise.all([db.selectAll('triggerable_repository_groups', 'name'), db.selectAll('triggerable_repositories', 'repository')]);
+        }).then((result) =&gt; {
+            const [repositoryGroups, repositories] = result;
+            assert.equal(repositoryGroups.length, 2);
+            assert.equal(repositoryGroups[0]['name'], 'system-and-webkit');
+            assert.equal(repositoryGroups[0]['triggerable'], MockData.emptyTriggeragbleId());
+            assert.equal(repositoryGroups[1]['name'], 'system-only');
+            assert.equal(repositoryGroups[1]['triggerable'], MockData.emptyTriggeragbleId());
+            initialGroups = repositoryGroups;
+
+            const repositoriesByGroup = mapRepositoriesByGroup(repositories);
+            assert.equal(Object.keys(repositoriesByGroup).length, 2);
+            assert.deepEqual(repositoriesByGroup[repositoryGroups[0]['id']], [MockData.macosRepositoryId(), MockData.webkitRepositoryId()]);
+            assert.deepEqual(repositoriesByGroup[repositoryGroups[1]['id']], [MockData.macosRepositoryId()]);
+
+            return TestServer.remoteAPI().postJSONWithStatus('/api/update-triggerable/', secondUpdate);
+        }).then(() =&gt; {
+            return Promise.all([db.selectAll('triggerable_repository_groups', 'name'), db.selectAll('triggerable_repositories', 'repository')]);
+        }).then((result) =&gt; {
+            const [repositoryGroups, repositories] = result;
+            assert.deepEqual(repositoryGroups, initialGroups);
+
+            const repositoriesByGroup = mapRepositoriesByGroup(repositories);
+            assert.equal(Object.keys(repositoriesByGroup).length, 2);
+            assert.deepEqual(repositoriesByGroup[initialGroups[0]['id']], [MockData.macosRepositoryId(), MockData.gitWebkitRepositoryId()]);
+            assert.deepEqual(repositoriesByGroup[initialGroups[1]['id']], [MockData.macosRepositoryId()]);
+        });
+    });
+
+    it('should replace a repository when the list of repositories matches', () =&gt; {
+        const db = TestServer.database();
+        const initialUpdate = updateWithMacWebKitRepositoryGroups();
+        const secondUpdate = updateWithMacWebKitRepositoryGroups();
+        let initialGroups;
+        let initialRepositories;
+        secondUpdate.repositoryGroups[0].name = 'mac-only';
+        return MockData.addEmptyTriggerable(db).then(() =&gt; {
+            return addSlaveForReport(initialUpdate);
+        }).then(() =&gt; {
+            return TestServer.remoteAPI().postJSONWithStatus('/api/update-triggerable/', initialUpdate);
+        }).then((response) =&gt; {
+            return Promise.all([db.selectAll('triggerable_repository_groups', 'name'), db.selectAll('triggerable_repositories', 'repository')]);
+        }).then((result) =&gt; {
+            const [repositoryGroups, repositories] = result;
+            assert.equal(repositoryGroups.length, 2);
+            assert.equal(repositoryGroups[0]['name'], 'system-and-webkit');
+            assert.equal(repositoryGroups[0]['triggerable'], MockData.emptyTriggeragbleId());
+            assert.equal(repositoryGroups[1]['name'], 'system-only');
+            assert.equal(repositoryGroups[1]['triggerable'], MockData.emptyTriggeragbleId());
+            initialGroups = repositoryGroups;
+
+            const repositoriesByGroup = mapRepositoriesByGroup(repositories);
+            assert.equal(Object.keys(repositoriesByGroup).length, 2);
+            assert.deepEqual(repositoriesByGroup[repositoryGroups[0]['id']], [MockData.macosRepositoryId(), MockData.webkitRepositoryId()]);
+            assert.deepEqual(repositoriesByGroup[repositoryGroups[1]['id']], [MockData.macosRepositoryId()]);
+            initialRepositories = repositories;
+
+            return TestServer.remoteAPI().postJSONWithStatus('/api/update-triggerable/', secondUpdate);
+        }).then(() =&gt; {
+            return Promise.all([db.selectAll('triggerable_repository_groups', 'name'), db.selectAll('triggerable_repositories', 'repository')]);
+        }).then((result) =&gt; {
+            const [repositoryGroups, repositories] = result;
+
+            assert.equal(repositoryGroups.length, 2);
+            assert.equal(repositoryGroups[0]['name'], 'mac-only');
+            assert.equal(repositoryGroups[0]['triggerable'], initialGroups[1]['triggerable']);
+            assert.equal(repositoryGroups[1]['name'], 'system-and-webkit');
+            assert.equal(repositoryGroups[1]['triggerable'], initialGroups[0]['triggerable']);
+
+            assert.deepEqual(repositories, initialRepositories);
+        });
+    });
+
</ins><span class="cx"> });
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgservertestsprivilegedapicreatetestgrouptestsjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/server-tests/privileged-api-create-test-group-tests.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/privileged-api-create-test-group-tests.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/server-tests/privileged-api-create-test-group-tests.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -100,6 +100,10 @@
</span><span class="cx">         'configurations': [
</span><span class="cx">             {test: MockData.someTestId(), platform: MockData.somePlatformId()}
</span><span class="cx">         ],
</span><ins>+        'repositoryGroups': [
+            {name: 'webkit-only', repositories: [MockData.webkitRepositoryId()]},
+            {name: 'system-and-webkit', repositories: [MockData.macosRepositoryId(), MockData.webkitRepositoryId()]},
+        ]
</ins><span class="cx">     };
</span><span class="cx">     return MockData.addMockData(TestServer.database()).then(() =&gt; {
</span><span class="cx">         return addSlaveForReport(report);
</span><span class="lines">@@ -281,7 +285,7 @@
</span><span class="cx">     it('should create a test group from commitSets with the repetition count of one when repetitionCount is omitted', () =&gt; {
</span><span class="cx">         return addTriggerableAndCreateTask('some task').then((taskId) =&gt; {
</span><span class="cx">             let insertedGroupId;
</span><del>-            return PrivilegedAPI.sendRequest('create-test-group', {name: 'test', task: taskId, commitSets: {'WebKit': ['191622', '191623']}}).then((content) =&gt; {
</del><ins>+            return PrivilegedAPI.sendRequest('create-test-group', {name: 'test', task: taskId, commitSets: {'macOS': ['15A284', '15A284'], 'WebKit': ['191622', '191623']}}).then((content) =&gt; {
</ins><span class="cx">                 insertedGroupId = content['testGroupId'];
</span><span class="cx">                 return TestGroup.fetchByTask(taskId);
</span><span class="cx">             }).then((testGroups) =&gt; {
</span><span class="lines">@@ -291,18 +295,31 @@
</span><span class="cx">                 assert.equal(group.repetitionCount(), 1);
</span><span class="cx">                 const requests = group.buildRequests();
</span><span class="cx">                 assert.equal(requests.length, 2);
</span><del>-                const webkit = Repository.all().filter((repository) =&gt; repository.name() == 'WebKit')[0];
-                assert.deepEqual(requests[0].commitSet().repositories(), [webkit]);
-                assert.deepEqual(requests[1].commitSet().repositories(), [webkit]);
-                assert.equal(requests[0].commitSet().revisionForRepository(webkit), '191622');
-                assert.equal(requests[1].commitSet().revisionForRepository(webkit), '191623');
</del><ins>+
+                const macos = Repository.findById(MockData.macosRepositoryId());
+                const webkit = Repository.findById(MockData.webkitRepositoryId());
+                const set0 = requests[0].commitSet();
+                const set1 = requests[1].commitSet();
+                assert.deepEqual(Repository.sortByNamePreferringOnesWithURL(set0.repositories()), [webkit, macos]);
+                assert.deepEqual(Repository.sortByNamePreferringOnesWithURL(set1.repositories()), [webkit, macos]);
+                assert.equal(set0.revisionForRepository(macos), '15A284');
+                assert.equal(set0.revisionForRepository(webkit), '191622');
+                assert.equal(set1.revisionForRepository(macos), '15A284');
+                assert.equal(set1.revisionForRepository(webkit), '191623');
+
+                const repositoryGroup = requests[0].repositoryGroup();
+                assert.equal(repositoryGroup.name(), 'system-and-webkit');
+                assert.equal(requests[1].repositoryGroup(), repositoryGroup);
+                assert(repositoryGroup.accepts(set0));
+                assert(repositoryGroup.accepts(set1));
</ins><span class="cx">             });
</span><span class="cx">         });
</span><span class="cx">     });
</span><span class="cx"> 
</span><span class="cx">     it('should create a test group from revisionSets with the repetition count of one when repetitionCount is omitted', () =&gt; {
</span><ins>+        let webkit;
</ins><span class="cx">         return addTriggerableAndCreateTask('some task').then((taskId) =&gt; {
</span><del>-            const webkit = Repository.all().find((repository) =&gt; repository.name() == 'WebKit');
</del><ins>+            const webkit = Repository.findById(MockData.webkitRepositoryId());
</ins><span class="cx">             const params = {name: 'test', task: taskId, revisionSets: [{[webkit.id()]: '191622'}, {[webkit.id()]: '191623'}]};
</span><span class="cx">             let insertedGroupId;
</span><span class="cx">             return PrivilegedAPI.sendRequest('create-test-group', params).then((content) =&gt; {
</span><span class="lines">@@ -315,11 +332,19 @@
</span><span class="cx">                 assert.equal(group.repetitionCount(), 1);
</span><span class="cx">                 const requests = group.buildRequests();
</span><span class="cx">                 assert.equal(requests.length, 2);
</span><del>-                const webkit = Repository.all().filter((repository) =&gt; repository.name() == 'WebKit')[0];
-                assert.deepEqual(requests[0].commitSet().repositories(), [webkit]);
-                assert.deepEqual(requests[1].commitSet().repositories(), [webkit]);
-                assert.equal(requests[0].commitSet().revisionForRepository(webkit), '191622');
-                assert.equal(requests[1].commitSet().revisionForRepository(webkit), '191623');
</del><ins>+
+                const set0 = requests[0].commitSet();
+                const set1 = requests[1].commitSet();
+                assert.deepEqual(set0.repositories(), [webkit]);
+                assert.deepEqual(set1.repositories(), [webkit]);
+                assert.equal(set0.revisionForRepository(webkit), '191622');
+                assert.equal(set1.revisionForRepository(webkit), '191623');
+
+                const repositoryGroup = requests[0].repositoryGroup();
+                assert.equal(repositoryGroup.name(), 'webkit-only');
+                assert.equal(repositoryGroup, requests[1].repositoryGroup());
+                assert(repositoryGroup.accepts(set0));
+                assert(repositoryGroup.accepts(set1));
</ins><span class="cx">             });
</span><span class="cx">         });
</span><span class="cx">     });
</span><span class="lines">@@ -340,19 +365,72 @@
</span><span class="cx">                 assert.equal(requests.length, 4);
</span><span class="cx">                 const webkit = Repository.all().filter((repository) =&gt; repository.name() == 'WebKit')[0];
</span><span class="cx">                 const macos = Repository.all().filter((repository) =&gt; repository.name() == 'macOS')[0];
</span><del>-                const set1 = requests[0].commitSet();
-                const set2 = requests[1].commitSet();
-                assert.equal(requests[2].commitSet(), set1);
-                assert.equal(requests[3].commitSet(), set2);
</del><ins>+
+                const set0 = requests[0].commitSet();
+                const set1 = requests[1].commitSet();
+                assert.equal(requests[2].commitSet(), set0);
+                assert.equal(requests[3].commitSet(), set1);
+                assert.deepEqual(Repository.sortByNamePreferringOnesWithURL(set0.repositories()), [webkit, macos]);
</ins><span class="cx">                 assert.deepEqual(Repository.sortByNamePreferringOnesWithURL(set1.repositories()), [webkit, macos]);
</span><del>-                assert.deepEqual(Repository.sortByNamePreferringOnesWithURL(set2.repositories()), [webkit, macos]);
-                assert.equal(set1.revisionForRepository(webkit), '191622');
</del><ins>+                assert.equal(set0.revisionForRepository(webkit), '191622');
+                assert.equal(set0.revisionForRepository(macos), '15A284');
+                assert.equal(set1.revisionForRepository(webkit), '191623');
</ins><span class="cx">                 assert.equal(set1.revisionForRepository(macos), '15A284');
</span><del>-                assert.equal(set2.revisionForRepository(webkit), '191623');
-                assert.equal(set2.revisionForRepository(macos), '15A284');
-                assert.equal(set1.commitForRepository(macos), set2.commitForRepository(macos));
</del><ins>+                assert.equal(set0.commitForRepository(macos), set1.commitForRepository(macos));
+
+                const repositoryGroup = requests[0].repositoryGroup();
+                assert.equal(repositoryGroup.name(), 'system-and-webkit');
+                assert.equal(requests[1].repositoryGroup(), repositoryGroup);
+                assert.equal(requests[2].repositoryGroup(), repositoryGroup);
+                assert.equal(requests[3].repositoryGroup(), repositoryGroup);
+                assert(repositoryGroup.accepts(set0));
+                assert(repositoryGroup.accepts(set1));
</ins><span class="cx">             });
</span><span class="cx">         });
</span><span class="cx">     });
</span><span class="cx"> 
</span><ins>+    it('should create a test group using different repository groups if needed', () =&gt; {
+        let webkit;
+        let macos;
+        return addTriggerableAndCreateTask('some task').then((taskId) =&gt; {
+            webkit = Repository.findById(MockData.webkitRepositoryId());
+            macos = Repository.findById(MockData.macosRepositoryId());
+            const params = {name: 'test', task: taskId, repetitionCount: 2,
+                revisionSets: [{[macos.id()]: '15A284', [webkit.id()]: '191622'}, {[webkit.id()]: '191623'}]};
+            let insertedGroupId;
+            return PrivilegedAPI.sendRequest('create-test-group', params).then((content) =&gt; {
+                insertedGroupId = content['testGroupId'];
+                return TestGroup.fetchByTask(taskId);
+            }).then((testGroups) =&gt; {
+                assert.equal(testGroups.length, 1);
+                const group = testGroups[0];
+                assert.equal(group.id(), insertedGroupId);
+                assert.equal(group.repetitionCount(), 2);
+                const requests = group.buildRequests();
+                assert.equal(requests.length, 4);
+
+                const set0 = requests[0].commitSet();
+                const set1 = requests[1].commitSet();
+                assert.deepEqual(Repository.sortByNamePreferringOnesWithURL(set0.repositories()), [webkit, macos]);
+                assert.deepEqual(set1.repositories(), [webkit]);
+                assert.equal(set0.revisionForRepository(webkit), '191622');
+                assert.equal(set0.revisionForRepository(macos), '15A284');
+                assert.equal(set1.revisionForRepository(webkit), '191623');
+                assert.equal(set1.revisionForRepository(macos), null);
+
+                const repositoryGroup0 = requests[0].repositoryGroup();
+                assert.equal(repositoryGroup0.name(), 'system-and-webkit');
+                assert.equal(repositoryGroup0, requests[2].repositoryGroup());
+                assert(repositoryGroup0.accepts(set0));
+                assert(!repositoryGroup0.accepts(set1));
+
+                const repositoryGroup1 = requests[1].repositoryGroup();
+                assert.equal(repositoryGroup1.name(), 'webkit-only');
+                assert.equal(repositoryGroup1, requests[3].repositoryGroup());
+                assert(!repositoryGroup1.accepts(set0));
+                assert(repositoryGroup1.accepts(set1));
+            });
+        });
+    });
+
</ins><span class="cx"> });
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgservertestsresourcesmockdatajs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/server-tests/resources/mock-data.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/resources/mock-data.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/server-tests/resources/mock-data.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -16,21 +16,29 @@
</span><span class="cx">         Test.clearStaticMap();
</span><span class="cx">         TestGroup.clearStaticMap();
</span><span class="cx">         Triggerable.clearStaticMap();
</span><ins>+        TriggerableRepositoryGroup.clearStaticMap();
</ins><span class="cx">     },
</span><ins>+    emptyTriggeragbleId() { return 1001; },
</ins><span class="cx">     someTestId() { return 200; },
</span><span class="cx">     somePlatformId() { return 65; },
</span><ins>+    macosRepositoryId() { return 9; },
+    webkitRepositoryId() { return 11; },
+    gitWebkitRepositoryId() { return 111; },
</ins><span class="cx">     addMockData: function (db, statusList)
</span><span class="cx">     {
</span><span class="cx">         if (!statusList)
</span><span class="cx">             statusList = ['pending', 'pending', 'pending', 'pending'];
</span><span class="cx">         return Promise.all([
</span><del>-            db.insert('build_triggerables', {id: 1, name: 'build-webkit'}),
</del><ins>+            db.insert('build_triggerables', {id: 1000, name: 'build-webkit'}),
</ins><span class="cx">             db.insert('build_slaves', {id: 20, name: 'sync-slave', password_hash: crypto.createHash('sha256').update('password').digest('hex')}),
</span><del>-            db.insert('repositories', {id: 9, name: 'OS X'}),
-            db.insert('repositories', {id: 11, name: 'WebKit'}),
-            db.insert('commits', {id: 87832, repository: 9, revision: '10.11 15A284'}),
-            db.insert('commits', {id: 93116, repository: 11, revision: '191622', time: (new Date(1445945816878)).toISOString()}),
-            db.insert('commits', {id: 96336, repository: 11, revision: '192736', time: (new Date(1448225325650)).toISOString()}),
</del><ins>+            db.insert('repositories', {id: this.macosRepositoryId(), name: 'macOS'}),
+            db.insert('repositories', {id: this.webkitRepositoryId(), name: 'WebKit'}),
+            db.insert('triggerable_repository_groups', {id: 2001, name: 'webkit-svn', triggerable: 1000}),
+            db.insert('triggerable_repositories', {repository: this.macosRepositoryId(), group: 2001}),
+            db.insert('triggerable_repositories', {repository: this.webkitRepositoryId(), group: 2001}),
+            db.insert('commits', {id: 87832, repository: this.macosRepositoryId(), revision: '10.11 15A284'}),
+            db.insert('commits', {id: 93116, repository: this.webkitRepositoryId(), revision: '191622', time: (new Date(1445945816878)).toISOString()}),
+            db.insert('commits', {id: 96336, repository: this.webkitRepositoryId(), revision: '192736', time: (new Date(1448225325650)).toISOString()}),
</ins><span class="cx">             db.insert('platforms', {id: MockData.somePlatformId(), name: 'some platform'}),
</span><span class="cx">             db.insert('tests', {id: MockData.someTestId(), name: 'some test'}),
</span><span class="cx">             db.insert('test_metrics', {id: 300, test: 200, name: 'some metric'}),
</span><span class="lines">@@ -43,22 +51,59 @@
</span><span class="cx">             db.insert('commit_set_relationships', {set: 402, commit: 96336}),
</span><span class="cx">             db.insert('analysis_tasks', {id: 500, platform: 65, metric: 300, name: 'some task'}),
</span><span class="cx">             db.insert('analysis_test_groups', {id: 600, task: 500, name: 'some test group'}),
</span><del>-            db.insert('build_requests', {id: 700, status: statusList[0], triggerable: 1, platform: 65, test: 200, group: 600, order: 0, commit_set: 401}),
-            db.insert('build_requests', {id: 701, status: statusList[1], triggerable: 1, platform: 65, test: 200, group: 600, order: 1, commit_set: 402}),
-            db.insert('build_requests', {id: 702, status: statusList[2], triggerable: 1, platform: 65, test: 200, group: 600, order: 2, commit_set: 401}),
-            db.insert('build_requests', {id: 703, status: statusList[3], triggerable: 1, platform: 65, test: 200, group: 600, order: 3, commit_set: 402}),
</del><ins>+            db.insert('build_requests', {id: 700, status: statusList[0], triggerable: 1000, repository_group: 2001, platform: 65, test: 200, group: 600, order: 0, commit_set: 401}),
+            db.insert('build_requests', {id: 701, status: statusList[1], triggerable: 1000, repository_group: 2001, platform: 65, test: 200, group: 600, order: 1, commit_set: 402}),
+            db.insert('build_requests', {id: 702, status: statusList[2], triggerable: 1000, repository_group: 2001, platform: 65, test: 200, group: 600, order: 2, commit_set: 401}),
+            db.insert('build_requests', {id: 703, status: statusList[3], triggerable: 1000, repository_group: 2001, platform: 65, test: 200, group: 600, order: 3, commit_set: 402}),
</ins><span class="cx">         ]);
</span><span class="cx">     },
</span><ins>+    addEmptyTriggerable(db)
+    {
+        return Promise.all([
+            db.insert('build_triggerables', {id: this.emptyTriggeragbleId(), name: 'empty-triggerable'}),
+            db.insert('repositories', {id: this.macosRepositoryId(), name: 'macOS'}),
+            db.insert('repositories', {id: this.webkitRepositoryId(), name: 'WebKit'}),
+            db.insert('repositories', {id: this.gitWebkitRepositoryId(), name: 'Git-WebKit'}),
+            db.insert('platforms', {id: MockData.somePlatformId(), name: 'some platform'}),
+            db.insert('tests', {id: MockData.someTestId(), name: 'some test'}),
+        ]);
+    },
+    addMockTestGroupWithGitWebKit(db)
+    {
+        return Promise.all([
+            db.insert('repositories', {id: this.gitWebkitRepositoryId(), name: 'Git-WebKit'}),
+            db.insert('triggerable_repository_groups', {id: 2002, name: 'webkit-git', triggerable: 1000}),
+            db.insert('triggerable_repositories', {repository: this.macosRepositoryId(), group: 2002}),
+            db.insert('triggerable_repositories', {repository: this.gitWebkitRepositoryId(), group: 2002}),
+            db.insert('commits', {id: 193116, repository: this.gitWebkitRepositoryId(), revision: '2ceda45d3cd63cde58d0dbf5767714e03d902e43', time: (new Date(1445945816878)).toISOString()}),
+            db.insert('commits', {id: 196336, repository: this.gitWebkitRepositoryId(), revision: '8e294365a452a89785d6536ca7f0fc8a95fa152d', time: (new Date(1448225325650)).toISOString()}),
+            db.insert('commit_sets', {id: 1401}),
+            db.insert('commit_set_relationships', {set: 1401, commit: 87832}),
+            db.insert('commit_set_relationships', {set: 1401, commit: 193116}),
+            db.insert('commit_sets', {id: 1402}),
+            db.insert('commit_set_relationships', {set: 1402, commit: 87832}),
+            db.insert('commit_set_relationships', {set: 1402, commit: 196336}),
+            db.insert('analysis_test_groups', {id: 1600, task: 500, name: 'test group with git'}),
+            db.insert('build_requests', {id: 1700, status: 'pending', triggerable: 1000, repository_group: 2002, platform: 65, test: 200, group: 1600, order: 0, commit_set: 1401}),
+            db.insert('build_requests', {id: 1701, status: 'pending', triggerable: 1000, repository_group: 2002, platform: 65, test: 200, group: 1600, order: 1, commit_set: 1402}),
+            db.insert('build_requests', {id: 1702, status: 'pending', triggerable: 1000, repository_group: 2002, platform: 65, test: 200, group: 1600, order: 2, commit_set: 1401}),
+            db.insert('build_requests', {id: 1703, status: 'pending', triggerable: 1000, repository_group: 2002, platform: 65, test: 200, group: 1600, order: 3, commit_set: 1402}),
+        ]);
+    },
</ins><span class="cx">     addAnotherMockTestGroup: function (db, statusList, author)
</span><span class="cx">     {
</span><span class="cx">         if (!statusList)
</span><span class="cx">             statusList = ['pending', 'pending', 'pending', 'pending'];
</span><ins>+        const test = MockData.someTestId();
+        const triggerable = 1000;
+        const platform = 65;
+        const repository_group = 2001;
</ins><span class="cx">         return Promise.all([
</span><del>-            db.insert('analysis_test_groups', {id: 601, task: 500, name: 'another test group', author: author}),
-            db.insert('build_requests', {id: 713, status: statusList[3], triggerable: 1, platform: 65, test: 200, group: 601, order: 3, commit_set: 402}),
-            db.insert('build_requests', {id: 710, status: statusList[0], triggerable: 1, platform: 65, test: 200, group: 601, order: 0, commit_set: 401}),
-            db.insert('build_requests', {id: 712, status: statusList[2], triggerable: 1, platform: 65, test: 200, group: 601, order: 2, commit_set: 401}),
-            db.insert('build_requests', {id: 711, status: statusList[1], triggerable: 1, platform: 65, test: 200, group: 601, order: 1, commit_set: 402}),
</del><ins>+            db.insert('analysis_test_groups', {id: 601, task: 500, name: 'another test group', author}),
+            db.insert('build_requests', {id: 713, status: statusList[3], triggerable, repository_group, platform, test, group: 601, order: 3, commit_set: 402}),
+            db.insert('build_requests', {id: 710, status: statusList[0], triggerable, repository_group, platform, test, group: 601, order: 0, commit_set: 401}),
+            db.insert('build_requests', {id: 712, status: statusList[2], triggerable, repository_group, platform, test, group: 601, order: 2, commit_set: 401}),
+            db.insert('build_requests', {id: 711, status: statusList[1], triggerable, repository_group, platform, test, group: 601, order: 1, commit_set: 402}),
</ins><span class="cx">         ]);
</span><span class="cx">     },
</span><span class="cx">     mockTestSyncConfigWithSingleBuilder: function ()
</span><span class="lines">@@ -66,16 +111,21 @@
</span><span class="cx">         return {
</span><span class="cx">             'triggerableName': 'build-webkit',
</span><span class="cx">             'lookbackCount': 2,
</span><ins>+            'buildRequestArgument': 'build-request-id',
+            'repositoryGroups': {
+                'webkit-svn': {
+                    'repositories': ['WebKit', 'macOS'],
+                    'properties': {
+                        'os': '&lt;macOS&gt;',
+                        'wk': '&lt;WebKit&gt;',
+                    }
+                }
+            },
</ins><span class="cx">             'configurations': [
</span><span class="cx">                 {
</span><span class="cx">                     'platform': 'some platform',
</span><span class="cx">                     'test': ['some test'],
</span><span class="cx">                     'builder': 'some-builder-1',
</span><del>-                    'arguments': {
-                        'wk': {'root': 'WebKit'},
-                        'os': {'root': 'OS X'},
-                    },
-                    'buildRequestArgument': 'build-request-id',
</del><span class="cx">                 }
</span><span class="cx">             ]
</span><span class="cx">         }
</span><span class="lines">@@ -85,26 +135,26 @@
</span><span class="cx">         return {
</span><span class="cx">             'triggerableName': 'build-webkit',
</span><span class="cx">             'lookbackCount': 2,
</span><ins>+            'buildRequestArgument': 'build-request-id',
+            'repositoryGroups': {
+                'webkit-svn': {
+                    'repositories': ['WebKit', 'macOS'],
+                    'properties': {
+                        'os': '&lt;macOS&gt;',
+                        'wk': '&lt;WebKit&gt;',
+                    }
+                }
+            },
</ins><span class="cx">             'configurations': [
</span><span class="cx">                 {
</span><span class="cx">                     'platform': 'some platform',
</span><span class="cx">                     'test': ['some test'],
</span><span class="cx">                     'builder': 'some-builder-1',
</span><del>-                    'arguments': {
-                        'wk': {'root': 'WebKit'},
-                        'os': {'root': 'OS X'},
-                    },
-                    'buildRequestArgument': 'build-request-id',
</del><span class="cx">                 },
</span><span class="cx">                 {
</span><span class="cx">                     'platform': 'some platform',
</span><span class="cx">                     'test': ['some test'],
</span><span class="cx">                     'builder': 'some builder 2',
</span><del>-                    'arguments': {
-                        'wk': {'root': 'WebKit'},
-                        'os': {'root': 'OS X'},
-                    },
-                    'buildRequestArgument': 'build-request-id',
</del><span class="cx">                 }
</span><span class="cx">             ]
</span><span class="cx">         }
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgserverteststoolsbuildbottriggerabletestsjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/server-tests/tools-buildbot-triggerable-tests.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/server-tests/tools-buildbot-triggerable-tests.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/server-tests/tools-buildbot-triggerable-tests.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -917,35 +917,53 @@
</span><span class="cx">     });
</span><span class="cx"> 
</span><span class="cx">     describe('updateTriggerables', () =&gt; {
</span><ins>+
+        function refetchManifest()
+        {
+            MockData.resetV3Models();
+            return TestServer.remoteAPI().getJSON('/api/manifest').then((content) =&gt; Manifest._didFetchManifest(content));
+        }
+
</ins><span class="cx">         it('should update available triggerables', () =&gt; {
</span><span class="cx">             const db = TestServer.database();
</span><ins>+            let macos;
+            let webkit;
</ins><span class="cx">             return MockData.addMockData(db).then(() =&gt; {
</span><span class="cx">                 return Manifest.fetch();
</span><span class="cx">             }).then(() =&gt; {
</span><ins>+                macos = Repository.findById(9);
+                assert.equal(macos.name(), 'macOS');
+                webkit = Repository.findById(11);
+                assert.equal(webkit.name(), 'WebKit');
+
</ins><span class="cx">                 return db.selectAll('triggerable_configurations', 'test');
</span><span class="cx">             }).then((configurations) =&gt; {
</span><span class="cx">                 assert.equal(configurations.length, 0);
</span><span class="cx">                 assert.equal(Triggerable.all().length, 1);
</span><span class="cx"> 
</span><del>-                let triggerable = Triggerable.all()[0];
</del><ins>+                const triggerable = Triggerable.all()[0];
</ins><span class="cx">                 assert.equal(triggerable.name(), 'build-webkit');
</span><del>-                assert.deepEqual(triggerable.acceptedRepositories(), []);
</del><span class="cx"> 
</span><del>-                let test = Test.findById(MockData.someTestId());
-                let platform = Platform.findById(MockData.somePlatformId());
</del><ins>+                const test = Test.findById(MockData.someTestId());
+                const platform = Platform.findById(MockData.somePlatformId());
</ins><span class="cx">                 assert.equal(Triggerable.findByTestConfiguration(test, platform), null);
</span><span class="cx"> 
</span><del>-                let config = MockData.mockTestSyncConfigWithSingleBuilder();
-                let logger = new MockLogger;
-                let slaveInfo = {name: 'sync-slave', password: 'password'};
-                let buildbotTriggerable = new BuildbotTriggerable(config, TestServer.remoteAPI(), MockRemoteAPI, slaveInfo, logger);
</del><ins>+                const groups = TriggerableRepositoryGroup.sortByName(triggerable.repositoryGroups());
+                assert.equal(groups.length, 1);
+                assert.equal(groups[0].name(), 'webkit-svn');
+                assert.deepEqual(groups[0].repositories(), [webkit, macos]);
+
+                const config = MockData.mockTestSyncConfigWithSingleBuilder();
+                config.repositoryGroups = [
+                    {name: 'system-only', repositories: ['macOS'], properties: {'os': '&lt;macOS&gt;'}},
+                    {name: 'system-and-webkit', repositories: ['WebKit', 'macOS'], properties: {'os': '&lt;macOS&gt;', 'wk': '&lt;WebKit&gt;'}},
+                ]
+
+                const logger = new MockLogger;
+                const slaveInfo = {name: 'sync-slave', password: 'password'};
+                const buildbotTriggerable = new BuildbotTriggerable(config, TestServer.remoteAPI(), MockRemoteAPI, slaveInfo, logger);
</ins><span class="cx">                 return buildbotTriggerable.updateTriggerable();
</span><del>-            }).then(() =&gt; {
-                MockData.resetV3Models();
-                assert.equal(Triggerable.all().length, 0);
-                return TestServer.remoteAPI().getJSON('/api/manifest');
-            }).then((manifestContent) =&gt; {
-                Manifest._didFetchManifest(manifestContent);
</del><ins>+            }).then(() =&gt; refetchManifest()).then(() =&gt; {
</ins><span class="cx">                 return db.selectAll('triggerable_configurations', 'test');
</span><span class="cx">             }).then((configurations) =&gt; {
</span><span class="cx">                 assert.equal(configurations.length, 1);
</span><span class="lines">@@ -958,7 +976,30 @@
</span><span class="cx">                 let platform = Platform.findById(MockData.somePlatformId());
</span><span class="cx">                 let triggerable = Triggerable.findByTestConfiguration(test, platform);
</span><span class="cx">                 assert.equal(triggerable.name(), 'build-webkit');
</span><del>-            });
</del><ins>+
+                const groups = TriggerableRepositoryGroup.sortByName(triggerable.repositoryGroups());
+                assert.equal(groups.length, 2);
+                assert.equal(groups[0].name(), 'system-and-webkit');
+                assert.deepEqual(groups[0].repositories(), [webkit, macos]);
+                assert.equal(groups[1].name(), 'system-only');
+                assert.deepEqual(groups[1].repositories(), [macos]);
+
+                const config = MockData.mockTestSyncConfigWithSingleBuilder();
+                config.repositoryGroups = [ ];
+
+                const logger = new MockLogger;
+                const slaveInfo = {name: 'sync-slave', password: 'password'};
+                const buildbotTriggerable = new BuildbotTriggerable(config, TestServer.remoteAPI(), MockRemoteAPI, slaveInfo, logger);
+                return buildbotTriggerable.updateTriggerable();
+            }).then(() =&gt; refetchManifest()).then(() =&gt; {
+                assert.equal(Triggerable.all().length, 1);
+                const groups = TriggerableRepositoryGroup.sortByName(Triggerable.all()[0].repositoryGroups());
+                assert.equal(groups.length, 2);
+                assert.equal(groups[0].name(), 'system-and-webkit');
+                assert.deepEqual(groups[0].repositories(), [webkit, macos]);
+                assert.equal(groups[1].name(), 'system-only');
+                assert.deepEqual(groups[1].repositories(), [macos]);
+            })
</ins><span class="cx">         });
</span><span class="cx">     });
</span><span class="cx"> 
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolsjsbuildbotsyncerjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/tools/js/buildbot-syncer.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/js/buildbot-syncer.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/tools/js/buildbot-syncer.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -57,14 +57,15 @@
</span><span class="cx"> 
</span><span class="cx"> class BuildbotSyncer {
</span><span class="cx"> 
</span><del>-    constructor(remote, object)
</del><ins>+    constructor(remote, object, commonConfigurations)
</ins><span class="cx">     {
</span><span class="cx">         this._remote = remote;
</span><span class="cx">         this._testConfigurations = [];
</span><ins>+        this._repositoryGroups = commonConfigurations.repositoryGroups;
+        this._slavePropertyName = commonConfigurations.slaveArgument;
+        this._buildRequestPropertyName = commonConfigurations.buildRequestArgument;
</ins><span class="cx">         this._builderName = object.builder;
</span><del>-        this._slavePropertyName = object.slaveArgument;
</del><span class="cx">         this._slaveList = object.slaveList;
</span><del>-        this._buildRequestPropertyName = object.buildRequestArgument;
</del><span class="cx">         this._entryList = null;
</span><span class="cx">         this._slavesWithNewRequests = new Set;
</span><span class="cx">     }
</span><span class="lines">@@ -78,6 +79,7 @@
</span><span class="cx">         this._testConfigurations.push({test, platform, propertiesTemplate});
</span><span class="cx">     }
</span><span class="cx">     testConfigurations() { return this._testConfigurations; }
</span><ins>+    repositoryGroups() { return this._repositoryGroups; }
</ins><span class="cx"> 
</span><span class="cx">     matchesConfiguration(request)
</span><span class="cx">     {
</span><span class="lines">@@ -200,34 +202,25 @@
</span><span class="cx">         for (let repository of commitSet.repositories())
</span><span class="cx">             repositoryByName[repository.name()] = repository;
</span><span class="cx"> 
</span><del>-        let propertiesTemplate = null;
-        for (let config of this._testConfigurations) {
-            if (config.platform == buildRequest.platform() &amp;&amp; config.test == buildRequest.test())
-                propertiesTemplate = config.propertiesTemplate;
-        }
-        assert(propertiesTemplate);
</del><ins>+        const matchingConfiguration = this._testConfigurations.find((config) =&gt; config.platform == buildRequest.platform() &amp;&amp; config.test == buildRequest.test());
+        assert(matchingConfiguration, `Build request ${buildRequest.id()} does not match a configuration in the builder &quot;${this._builderName}&quot;`);
+        const propertiesTemplate = matchingConfiguration.propertiesTemplate;
</ins><span class="cx"> 
</span><ins>+        const repositoryGroup = buildRequest.repositoryGroup();
+        assert(repositoryGroup.accepts(commitSet), `Build request ${buildRequest.id()} does not specify a commit set accepted by the repository group ${repositoryGroup.id()}`);
+
+        const repositoryGroupConfiguration = this._repositoryGroups[repositoryGroup.name()];
+        assert(repositoryGroupConfiguration, `Build request ${buildRequest.id()} uses an unsupported repository group &quot;${repositoryGroup.name()}&quot;`);
+
</ins><span class="cx">         const properties = {};
</span><del>-        for (let key in propertiesTemplate) {
-            const value = propertiesTemplate[key];
-            if (typeof(value) != 'object')
-                properties[key] = value;
-            else if ('root' in value) {
-                const repositoryName = value['root'];
-                const repository = repositoryByName[repositoryName];
-                assert(repository, `&quot;${repositoryName}&quot; must be specified`);
-                properties[key] = commitSet.revisionForRepository(repository);
-            } else if ('rootOptions' in value) {
-                const filteredOptions = value['rootOptions'].filter((option) =&gt; option in repositoryByName);
-                assert.equal(filteredOptions.length, 1, `There should be exactly one valid root among &quot;${value['rootOptions']}&quot;.`);
-                properties[key] = commitSet.revisionForRepository(repositoryByName[filteredOptions[0]]);
-            }
-            else if ('rootsExcluding' in value) {
-                const revisionSet = this._revisionSetFromCommitSetWithExclusionList(commitSet, value['rootsExcluding']);
-                properties[key] = JSON.stringify(revisionSet);
-            }
</del><ins>+        for (let propertyName in propertiesTemplate)
+            properties[propertyName] = propertiesTemplate[propertyName];
+
+        const repositoryGroupTemplate = repositoryGroupConfiguration.propertiesTemplate;
+        for (let propertyName in repositoryGroupTemplate) {
+            const value = repositoryGroupTemplate[propertyName];
+            properties[propertyName] = value instanceof Repository ? commitSet.revisionForRepository(value) : value;
</ins><span class="cx">         }
</span><del>-
</del><span class="cx">         properties[this._buildRequestPropertyName] = buildRequest.id();
</span><span class="cx"> 
</span><span class="cx">         return properties;
</span><span class="lines">@@ -252,35 +245,87 @@
</span><span class="cx"> 
</span><span class="cx">     static _loadConfig(remote, config)
</span><span class="cx">     {
</span><del>-        const shared = config['shared'] || {};
</del><span class="cx">         const types = config['types'] || {};
</span><span class="cx">         const builders = config['builders'] || {};
</span><span class="cx"> 
</span><ins>+        assert(config.buildRequestArgument, 'buildRequestArgument must specify the name of the property used to store the build request ID');
+
+        assert.equal(typeof(config.repositoryGroups), 'object', 'repositoryGroups must specify a dictionary from the name to its definition');
+
+        const repositoryGroups = {};
+        for (const name in config.repositoryGroups)
+            repositoryGroups[name] = this._parseRepositoryGroup(name, config.repositoryGroups[name]);
+
+        const commonConfigurations = {
+            repositoryGroups,
+            slaveArgument: config.slaveArgument,
+            buildRequestArgument: config.buildRequestArgument,
+        };
+
</ins><span class="cx">         let syncerByBuilder = new Map;
</span><ins>+        const expandedConfigurations = [];
</ins><span class="cx">         for (let entry of config['configurations']) {
</span><del>-            const newConfig = {};
-            this._validateAndMergeConfig(newConfig, shared);
-            this._validateAndMergeConfig(newConfig, entry);
</del><ins>+            for (const expandedConfig of this._expandTypesAndPlatforms(entry))
+                expandedConfigurations.push(expandedConfig);
+        }
</ins><span class="cx"> 
</span><del>-            const expandedConfigurations = this._expandTypesAndPlatforms(newConfig);
-            for (let config of expandedConfigurations) {
-                if ('type' in config) {
-                    const type = config['type'];
-                    assert(type, `${type} is not a valid type in the configuration`);
-                    this._validateAndMergeConfig(config, types[type]);
-                }
</del><ins>+        for (let entry of expandedConfigurations) {
+            const mergedConfig = {};
+            this._validateAndMergeConfig(mergedConfig, entry);
</ins><span class="cx"> 
</span><del>-                const builder = entry['builder'];
-                if (builders[builder])
-                    this._validateAndMergeConfig(config, builders[builder]);
</del><ins>+            if ('type' in mergedConfig) {
+                const type = mergedConfig['type'];
+                assert(type, `${type} is not a valid type in the configuration`);
+                this._validateAndMergeConfig(mergedConfig, types[type]);
+            }
</ins><span class="cx"> 
</span><del>-                this._createTestConfiguration(remote, syncerByBuilder, config);
-            }
</del><ins>+            const builder = entry['builder'];
+            if (builders[builder])
+                this._validateAndMergeConfig(mergedConfig, builders[builder]);
+
+            this._createTestConfiguration(remote, syncerByBuilder, mergedConfig, commonConfigurations);
</ins><span class="cx">         }
</span><span class="cx"> 
</span><span class="cx">         return Array.from(syncerByBuilder.values());
</span><span class="cx">     }
</span><span class="cx"> 
</span><ins>+    static _parseRepositoryGroup(name, group)
+    {
+        assert(Array.isArray(group.repositories), 'Each repository group must specify a list of repositories');
+        assert(group.repositories.length, 'Each repository group must specify a list of repositories');
+        assert(!('description' in group) || typeof(group['description']) == 'string', 'The description of a repository group must be a string');
+        assert.equal(typeof(group.properties), 'object', 'Each repository group must specify a dictionary of properties');
+
+        const repositoryByName = {};
+        const repositories = group.repositories.map((repositoryName) =&gt; {
+            const repository = Repository.findTopLevelByName(repositoryName);
+            assert(repository, `&quot;${repositoryName}&quot; is not a valid repository name`);
+            repositoryByName[repositoryName] = repository;
+            return repository;
+        });
+        const propertiesTemplate = {};
+        const usedRepositories = [];
+        for (const propertyName in group.properties) {
+            let value = group.properties[propertyName];
+            const match = value.match(/^&lt;(.+)&gt;$/);
+            if (match) {
+                const repositoryName = match[1];
+                value = repositoryByName[repositoryName];
+                assert(value, `Repository group &quot;${name}&quot; uses &quot;${repositoryName}&quot; in its property but does not list in the list of repositories`);
+                usedRepositories.push(value);
+            }
+            propertiesTemplate[propertyName] = value;
+        }
+        assert.equal(repositories.length, usedRepositories.length, `Repository group &quot;${name}&quot; does not use some of the listed repositories`);
+        return {
+            name: group.name,
+            description: group.description,
+            propertiesTemplate,
+            arguments: group.arguments,
+            repositories: repositories.map((repository) =&gt; repository.id()),
+        };
+    }
+
</ins><span class="cx">     static _expandTypesAndPlatforms(unresolvedConfig)
</span><span class="cx">     {
</span><span class="cx">         const typeExpanded = [];
</span><span class="lines">@@ -302,13 +347,11 @@
</span><span class="cx">         return configurations;
</span><span class="cx">     }
</span><span class="cx"> 
</span><del>-    static _createTestConfiguration(remote, syncerByBuilder, newConfig)
</del><ins>+    static _createTestConfiguration(remote, syncerByBuilder, newConfig, commonConfigurations)
</ins><span class="cx">     {
</span><span class="cx">         assert('platform' in newConfig, 'configuration must specify a platform');
</span><span class="cx">         assert('test' in newConfig, 'configuration must specify a test');
</span><span class="cx">         assert('builder' in newConfig, 'configuration must specify a builder');
</span><del>-        assert('properties' in newConfig, 'configuration must specify arguments to post on a builder');
-        assert('buildRequestArgument' in newConfig, 'configuration must specify buildRequestArgument');
</del><span class="cx"> 
</span><span class="cx">         const test = Test.findByPath(newConfig.test);
</span><span class="cx">         assert(test, `${newConfig.test} is not a valid test path`);
</span><span class="lines">@@ -318,10 +361,10 @@
</span><span class="cx"> 
</span><span class="cx">         let syncer = syncerByBuilder.get(newConfig.builder);
</span><span class="cx">         if (!syncer) {
</span><del>-            syncer = new BuildbotSyncer(remote, newConfig);
</del><ins>+            syncer = new BuildbotSyncer(remote, newConfig, commonConfigurations);
</ins><span class="cx">             syncerByBuilder.set(newConfig.builder, syncer);
</span><span class="cx">         }
</span><del>-        syncer.addTestConfiguration(test, platform, newConfig.properties);
</del><ins>+        syncer.addTestConfiguration(test, platform, newConfig.properties || {});
</ins><span class="cx">     }
</span><span class="cx"> 
</span><span class="cx">     static _validateAndMergeConfig(config, valuesToMerge, excludedProperty)
</span><span class="lines">@@ -341,7 +384,7 @@
</span><span class="cx">                 break;
</span><span class="cx">             case 'test': // Fallthrough
</span><span class="cx">             case 'slaveList': // Fallthrough
</span><del>-            case 'platforms':
</del><ins>+            case 'platforms': // Fallthrough
</ins><span class="cx">             case 'types':
</span><span class="cx">                 assert(value instanceof Array, `${name} should be an array`);
</span><span class="cx">                 assert(value.every(function (part) { return typeof part == 'string'; }), `${name} should be an array of strings`);
</span><span class="lines">@@ -350,8 +393,6 @@
</span><span class="cx">             case 'type': // Fallthrough
</span><span class="cx">             case 'builder': // Fallthrough
</span><span class="cx">             case 'platform': // Fallthrough
</span><del>-            case 'slaveArgument': // Fallthrough
-            case 'buildRequestArgument':
</del><span class="cx">                 assert.equal(typeof(value), 'string', `${name} should be of string type`);
</span><span class="cx">                 config[name] = value;
</span><span class="cx">                 break;
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolsjsbuildbottriggerablejs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/tools/js/buildbot-triggerable.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/js/buildbot-triggerable.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/tools/js/buildbot-triggerable.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -30,17 +30,21 @@
</span><span class="cx">     updateTriggerable()
</span><span class="cx">     {
</span><span class="cx">         const map = new Map;
</span><ins>+        let repositoryGroups = [];
</ins><span class="cx">         for (const syncer of this._syncers) {
</span><span class="cx">             for (const config of syncer.testConfigurations()) {
</span><span class="cx">                 const entry = {test: config.test.id(), platform: config.platform.id()};
</span><span class="cx">                 map.set(entry.test + '-' + entry.platform, entry);
</span><span class="cx">             }
</span><ins>+            // FIXME: Move BuildbotSyncer._loadConfig here and store repository groups directly.
+            repositoryGroups = syncer.repositoryGroups();
</ins><span class="cx">         }
</span><del>-        return this._remote.postJSON(`/api/update-triggerable/`, {
</del><ins>+        return this._remote.postJSONWithStatus(`/api/update-triggerable/`, {
</ins><span class="cx">             'slaveName': this._slaveInfo.name,
</span><span class="cx">             'slavePassword': this._slaveInfo.password,
</span><span class="cx">             'triggerable': this._name,
</span><del>-            'configurations': Array.from(map.values())});
</del><ins>+            'configurations': Array.from(map.values()),
+            'repositoryGroups': Object.keys(repositoryGroups).map((groupName) =&gt; repositoryGroups[groupName])});
</ins><span class="cx">     }
</span><span class="cx"> 
</span><span class="cx">     syncOnce()
</span><span class="lines">@@ -49,8 +53,9 @@
</span><span class="cx">         let buildReqeustsByGroup = new Map;
</span><span class="cx"> 
</span><span class="cx">         this._logger.log(`Fetching build requests for ${this._name}...`);
</span><ins>+        let validRequests;
</ins><span class="cx">         return BuildRequest.fetchForTriggerable(this._name).then((buildRequests) =&gt; {
</span><del>-            this._validateRequests(buildRequests);
</del><ins>+            validRequests = this._validateRequests(buildRequests);
</ins><span class="cx">             buildReqeustsByGroup = BuildbotTriggerable._testGroupMapForBuildRequests(buildRequests);
</span><span class="cx">             return this._pullBuildbotOnAllSyncers(buildReqeustsByGroup);
</span><span class="cx">         }).then((updates) =&gt; {
</span><span class="lines">@@ -58,7 +63,10 @@
</span><span class="cx">             const promistList = [];
</span><span class="cx">             const testGroupList = Array.from(buildReqeustsByGroup.values()).sort(function (a, b) { return a.groupOrder - b.groupOrder; });
</span><span class="cx">             for (const group of testGroupList) {
</span><del>-                const promise = this._scheduleNextRequestInGroupIfSlaveIsAvailable(group, updates);
</del><ins>+                const nextRequest = this._nextRequestInGroup(group, updates);
+                if (!validRequests.has(nextRequest))
+                    continue;
+                const promise = this._scheduleRequestIfSlaveIsAvailable(nextRequest, group.syncer, group.slaveName);
</ins><span class="cx">                 if (promise)
</span><span class="cx">                     promistList.push(promise);
</span><span class="cx">             }
</span><span class="lines">@@ -78,14 +86,37 @@
</span><span class="cx">     _validateRequests(buildRequests)
</span><span class="cx">     {
</span><span class="cx">         const testPlatformPairs = {};
</span><ins>+        const validatedRequests = new Set;
</ins><span class="cx">         for (let request of buildRequests) {
</span><span class="cx">             if (!this._syncers.some((syncer) =&gt; syncer.matchesConfiguration(request))) {
</span><span class="cx">                 const key = request.platform().id + '-' + request.test().id();
</span><span class="cx">                 if (!(key in testPlatformPairs))
</span><del>-                    this._logger.error(`No matching configuration for &quot;${request.test().fullName()}&quot; on &quot;${request.platform().name()}&quot;.`);                
</del><ins>+                    this._logger.error(`Build request ${request.id()} has no matching configuration: &quot;${request.test().fullName()}&quot; on &quot;${request.platform().name()}&quot;.`);
</ins><span class="cx">                 testPlatformPairs[key] = true;
</span><ins>+                continue;
</ins><span class="cx">             }
</span><ins>+            const triggerable = request.triggerable();
+            if (!triggerable) {
+                this._logger.error(`Build request ${request.id()} does not specify a valid triggerable`);
+                continue;
+            }
+            assert(triggerable instanceof Triggerable, 'Must specify a valid triggerable');
+            assert.equal(triggerable.name(), this._name, 'Must specify the triggerable of this syncer');
+            const repositoryGroup = request.repositoryGroup();
+            if (!repositoryGroup) {
+                this._logger.error(`Build request ${request.id()} does not specify a repository group. Such a build request is no longer supported.`);
+                continue;
+            }
+            const acceptedGroups = triggerable.repositoryGroups();
+            if (!acceptedGroups.includes(repositoryGroup)) {
+                const acceptedNames = acceptedGroups.map((group) =&gt; group.name()).join(', ');
+                this._logger.error(`Build request ${request.id()} specifies ${repositoryGroup.name()} but triggerable ${this._name} only accepts ${acceptedNames}`);
+                continue;
+            }
+            validatedRequests.add(request);
</ins><span class="cx">         }
</span><ins>+
+        return validatedRequests;
</ins><span class="cx">     }
</span><span class="cx"> 
</span><span class="cx">     _pullBuildbotOnAllSyncers(buildReqeustsByGroup)
</span><span class="lines">@@ -129,24 +160,25 @@
</span><span class="cx">         }).then(() =&gt; updates);
</span><span class="cx">     }
</span><span class="cx"> 
</span><del>-    _scheduleNextRequestInGroupIfSlaveIsAvailable(groupInfo, pendingUpdates)
</del><ins>+    _nextRequestInGroup(groupInfo, pendingUpdates)
</ins><span class="cx">     {
</span><del>-        let nextRequest = null;
</del><span class="cx">         for (const request of groupInfo.requests) {
</span><span class="cx">             if (request.isScheduled() || (request.id() in pendingUpdates &amp;&amp; pendingUpdates[request.id()]['status'] == 'scheduled'))
</span><del>-                break;
-            if (request.isPending() &amp;&amp; !(request.id() in pendingUpdates)) {
-                nextRequest = request;
-                break;
-            }
</del><ins>+                return null;
+            if (request.isPending() &amp;&amp; !(request.id() in pendingUpdates))
+                return request;
</ins><span class="cx">         }
</span><ins>+        return null;
+    }
+
+    _scheduleRequestIfSlaveIsAvailable(nextRequest, syncer, slaveName)
+    {
</ins><span class="cx">         if (!nextRequest)
</span><span class="cx">             return null;
</span><span class="cx"> 
</span><span class="cx">         if (!!nextRequest.order()) {
</span><del>-            const syncer = groupInfo.syncer;
</del><span class="cx">             if (syncer)
</span><del>-                return this._scheduleRequestWithLog(syncer, nextRequest, groupInfo.slaveName);
</del><ins>+                return this._scheduleRequestWithLog(syncer, nextRequest, slaveName);
</ins><span class="cx">             this._logger.error(`Could not identify the syncer for ${nextRequest.id()}.`);
</span><span class="cx">         }
</span><span class="cx"> 
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolsjsv3modelsjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/tools/js/v3-models.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/js/v3-models.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/tools/js/v3-models.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -1,6 +1,7 @@
</span><span class="cx"> 'use strict';
</span><span class="cx"> 
</span><del>-function importFromV3(file, name) {
</del><ins>+function importFromV3(file, name)
+{
</ins><span class="cx">     const modelsDirectory = '../../public/v3/';
</span><span class="cx"> 
</span><span class="cx">     global[name] = require(modelsDirectory + file)[name];
</span><span class="lines">@@ -24,6 +25,7 @@
</span><span class="cx"> importFromV3('models/repository.js', 'Repository');
</span><span class="cx"> importFromV3('models/commit-set.js', 'MeasurementCommitSet');
</span><span class="cx"> importFromV3('models/commit-set.js', 'CommitSet');
</span><ins>+importFromV3('models/commit-set.js', 'CustomCommitSet');
</ins><span class="cx"> importFromV3('models/test.js', 'Test');
</span><span class="cx"> importFromV3('models/test-group.js', 'TestGroup');
</span><span class="cx"> importFromV3('models/time-series.js', 'TimeSeries');
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgunittestsbuildrequesttestsjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/unit-tests/build-request-tests.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/unit-tests/build-request-tests.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/unit-tests/build-request-tests.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -53,7 +53,7 @@
</span><span class="cx">     };
</span><span class="cx"> }
</span><span class="cx"> 
</span><del>-describe('TestGroup', function () {
</del><ins>+describe('BuildRequest', function () {
</ins><span class="cx">     MockModels.inject();
</span><span class="cx"> 
</span><span class="cx">     describe('waitingTime', function () {
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgunittestsbuildbotsyncertestsjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/unit-tests/buildbot-syncer-tests.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/unit-tests/buildbot-syncer-tests.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/unit-tests/buildbot-syncer-tests.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -12,16 +12,17 @@
</span><span class="cx"> function sampleiOSConfig()
</span><span class="cx"> {
</span><span class="cx">     return {
</span><del>-        'shared':
-            {
-                'arguments': {
-                    'desired_image': {'root': 'iOS'},
-                    'opensource': {'rootOptions': ['WebKit-SVN', 'WebKit-Git']},
-                    'roots_dict': {'rootsExcluding': ['iOS']}
-                },
-                'slaveArgument': 'slavename',
-                'buildRequestArgument': 'build_request_id'
-            },
</del><ins>+        'slaveArgument': 'slavename',
+        'buildRequestArgument': 'build_request_id',
+        'repositoryGroups': {
+            'ios-svn-webkit': {
+                'repositories': ['WebKit', 'iOS'],
+                'properties': {
+                    'desired_image': '&lt;iOS&gt;',
+                    'opensource': '&lt;WebKit&gt;',
+                }
+            }
+        },
</ins><span class="cx">         'types': {
</span><span class="cx">             'speedometer': {
</span><span class="cx">                 'test': ['Speedometer'],
</span><span class="lines">@@ -63,14 +64,8 @@
</span><span class="cx"> {
</span><span class="cx">     return {
</span><span class="cx">         &quot;triggerableName&quot;: &quot;build-webkit-ios&quot;,
</span><del>-        &quot;shared&quot;:
-            {
-                &quot;arguments&quot;: {
-                    &quot;webkit-revision&quot;: {&quot;root&quot;: &quot;WebKit&quot;},
-                    &quot;os-version&quot;: {&quot;root&quot;: &quot;iOS&quot;}
-                },
-                &quot;buildRequestArgument&quot;: &quot;build-request-id&quot;
-            },
</del><ins>+        &quot;buildRequestArgument&quot;: &quot;build-request-id&quot;,
+        &quot;repositoryGroups&quot;: { },
</ins><span class="cx">         &quot;types&quot;: {
</span><span class="cx">             &quot;iphone-plt&quot;: {
</span><span class="cx">                 &quot;test&quot;: [&quot;PLT-iPhone&quot;],
</span><span class="lines">@@ -110,35 +105,25 @@
</span><span class="cx">     }
</span><span class="cx"> }
</span><span class="cx"> 
</span><del>-let sampleCommitSetData = {
-    'WebKit': {
-        'id': '111127',
-        'time': 1456955807334,
-        'repository': 'WebKit',
-        'revision': '197463',
-    },
-    'Shared': {
-        'id': '111237',
-        'time': 1456931874000,
-        'repository': 'Shared',
-        'revision': '80229',
-    },
-    'WebKit-Git': {
-        &quot;id&quot;:&quot;111239&quot;,
-        &quot;time&quot;:1456931874000,
-        &quot;repository&quot;:&quot;WebKit-Git&quot;,
-        &quot;revision&quot;:&quot;9abcdef&quot;,
-    },
-};
-
</del><span class="cx"> function smallConfiguration()
</span><span class="cx"> {
</span><span class="cx">     return {
</span><del>-        'builder': 'some builder',
-        'platform': 'Some platform',
-        'test': ['Some test'],
-        'arguments': {},
-        'buildRequestArgument': 'id'};
</del><ins>+        'buildRequestArgument': 'id',
+        'repositoryGroups': {
+            'ios-svn-webkit': {
+                'repositories': ['iOS', 'WebKit'],
+                'properties': {
+                    'os': '&lt;iOS&gt;',
+                    'wk': '&lt;WebKit&gt;'
+                }
+            }
+        },
+        'configurations': [{
+            'builder': 'some builder',
+            'platform': 'Some platform',
+            'test': ['Some test']
+        }]
+    };
</ins><span class="cx"> }
</span><span class="cx"> 
</span><span class="cx"> function smallPendingBuild()
</span><span class="lines">@@ -210,12 +195,12 @@
</span><span class="cx">     let commitSet = CommitSet.ensureSingleton('4197', {commits: [
</span><span class="cx">         {'id': '111127', 'time': 1456955807334, 'repository': MockModels.webkit, 'revision': '197463'},
</span><span class="cx">         {'id': '111237', 'time': 1456931874000, 'repository': MockModels.sharedRepository, 'revision': '80229'},
</span><del>-        {'id': '111239', 'time': 1456931874000, 'repository': MockModels.webkitGit, 'revision': '9abcdef'},
</del><span class="cx">         {'id': '88930', 'time': 0, 'repository': MockModels.ios, 'revision': '13A452'},
</span><span class="cx">     ]});
</span><span class="cx"> 
</span><del>-    let request = BuildRequest.ensureSingleton('16733-' + platform.id(), {'commitSet': commitSet, 'status': 'pending', 'platform': platform, 'test': test});
-    return request;
</del><ins>+    return BuildRequest.ensureSingleton('16733-' + platform.id(), {'triggerable': MockModels.triggerable,
+        repositoryGroup: MockModels.svnRepositoryGroup,
+        'commitSet': commitSet, 'status': 'pending', 'platform': platform, 'test': test});
</ins><span class="cx"> }
</span><span class="cx"> 
</span><span class="cx"> function samplePendingBuild(buildRequestId, buildTime, slaveName)
</span><span class="lines">@@ -229,11 +214,6 @@
</span><span class="cx">             ['owner', '&lt;unknown&gt;', 'Force Build Form'],
</span><span class="cx">             ['test_name', 'speedometer', 'Force Build Form'],
</span><span class="cx">             ['reason', 'force build','Force Build Form'],
</span><del>-            [
-                'roots_dict',
-                JSON.stringify(sampleCommitSetData),
-                'Force Build Form'
-            ],
</del><span class="cx">             ['slavename', slaveName, ''],
</span><span class="cx">             ['scheduler', 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler', 'Scheduler']
</span><span class="cx">         ],
</span><span class="lines">@@ -280,7 +260,6 @@
</span><span class="cx">             ['desired_image', '13A452', 'Force Build Form'],
</span><span class="cx">             ['owner', '&lt;unknown&gt;', 'Force Build Form'],
</span><span class="cx">             ['reason', 'force build', 'Force Build Form'],
</span><del>-            ['roots_dict', JSON.stringify(sampleCommitSetData), 'Force Build Form'],
</del><span class="cx">             ['scheduler', 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler', 'Scheduler'],
</span><span class="cx">             ['slavename', slaveName || 'ABTest-iPad-0', 'BuildSlave'],
</span><span class="cx">         ],
</span><span class="lines">@@ -356,7 +335,6 @@
</span><span class="cx">             ['desired_image', '13A452', 'Force Build Form'],
</span><span class="cx">             ['owner', '&lt;unknown&gt;', 'Force Build Form'],
</span><span class="cx">             ['reason', 'force build', 'Force Build Form'],
</span><del>-            ['roots_dict', JSON.stringify(sampleCommitSetData), 'Force Build Form'],
</del><span class="cx">             ['scheduler', 'ABTest-iPad-RunBenchmark-Tests-ForceScheduler', 'Scheduler'],
</span><span class="cx">             ['slavename', slaveName || 'ABTest-iPad-0', 'BuildSlave'],
</span><span class="cx">         ],
</span><span class="lines">@@ -423,90 +401,69 @@
</span><span class="cx">     describe('_loadConfig', () =&gt; {
</span><span class="cx"> 
</span><span class="cx">         it('should create BuildbotSyncer objects for a configuration that specify all required options', () =&gt; {
</span><del>-            let syncers = BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [smallConfiguration()]});
-            assert.equal(syncers.length, 1);
</del><ins>+            assert.equal(BuildbotSyncer._loadConfig(MockRemoteAPI, smallConfiguration()).length, 1);
</ins><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should throw when some required options are missing', () =&gt; {
</span><span class="cx">             assert.throws(() =&gt; {
</span><del>-                let config = smallConfiguration();
-                delete config['builder'];
-                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
</del><ins>+                const config = smallConfiguration();
+                delete config.configurations[0].builder;
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
</ins><span class="cx">             }, 'builder should be a required option');
</span><span class="cx">             assert.throws(() =&gt; {
</span><del>-                let config = smallConfiguration();
-                delete config['platform'];
-                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
</del><ins>+                const config = smallConfiguration();
+                delete config.configurations[0].platform;
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
</ins><span class="cx">             }, 'platform should be a required option');
</span><span class="cx">             assert.throws(() =&gt; {
</span><del>-                let config = smallConfiguration();
-                delete config['test'];
-                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
</del><ins>+                const config = smallConfiguration();
+                delete config.configurations[0].test;
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
</ins><span class="cx">             }, 'test should be a required option');
</span><span class="cx">             assert.throws(() =&gt; {
</span><del>-                let config = smallConfiguration();
-                delete config['arguments'];
-                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
-            });
-            assert.throws(() =&gt; {
-                let config = smallConfiguration();
-                delete config['buildRequestArgument'];
-                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
-            });
</del><ins>+                const config = smallConfiguration();
+                delete config.buildRequestArgument;
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
+            }, 'buildRequestArgument should be required');
</ins><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should throw when a test name is not an array of strings', () =&gt; {
</span><span class="cx">             assert.throws(() =&gt; {
</span><del>-                let config = smallConfiguration();
-                config.test = 'some test';
-                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
</del><ins>+                const config = smallConfiguration();
+                config.configurations[0].test = 'some test';
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
</ins><span class="cx">             });
</span><span class="cx">             assert.throws(() =&gt; {
</span><del>-                let config = smallConfiguration();
-                config.test = [1];
-                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
</del><ins>+                const config = smallConfiguration();
+                config.configurations[0].test = [1];
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
</ins><span class="cx">             });
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should throw when arguments is not an object', () =&gt; {
</span><span class="cx">             assert.throws(() =&gt; {
</span><del>-                let config = smallConfiguration();
-                config.arguments = 'hello';
-                BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [config]});
</del><ins>+                const config = smallConfiguration();
+                config.configurations[0].arguments = 'hello';
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
</ins><span class="cx">             });
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should throw when arguments\'s values are malformed', () =&gt; {
</span><span class="cx">             assert.throws(() =&gt; {
</span><del>-                let config = smallConfiguration();
-                config.arguments = {'some': {'root': 'some root', 'rootsExcluding': ['other root']}};
-                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
</del><ins>+                const config = smallConfiguration();
+                config.configurations[0].arguments = {'some': {'otherKey': 'some root'}};
+                BuildbotSyncer._loadConfig(RemoteAPI, config);
</ins><span class="cx">             });
</span><span class="cx">             assert.throws(() =&gt; {
</span><del>-                let config = smallConfiguration();
-                config.arguments = {'some': {'otherKey': 'some root'}};
-                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
</del><ins>+                const config = smallConfiguration();
+                config.configurations[0].arguments = {'some': {'root': ['a', 'b']}};
+                BuildbotSyncer._loadConfig(RemoteAPI, config);
</ins><span class="cx">             });
</span><span class="cx">             assert.throws(() =&gt; {
</span><del>-                let config = smallConfiguration();
-                config.arguments = {'some': {'root': ['a', 'b']}};
-                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
</del><ins>+                const config = smallConfiguration();
+                config.configurations[0].arguments = {'some': {'root': 1}};
+                BuildbotSyncer._loadConfig(RemoteAPI, config);
</ins><span class="cx">             });
</span><del>-            assert.throws(() =&gt; {
-                let config = smallConfiguration();
-                config.arguments = {'some': {'root': 1}};
-                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
-            });
-            assert.throws(() =&gt; {
-                let config = smallConfiguration();
-                config.arguments = {'some': {'rootsExcluding': 'a'}};
-                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
-            });
-            assert.throws(() =&gt; {
-                let config = smallConfiguration();
-                config.arguments = {'some': {'rootsExcluding': [1]}};
-                BuildbotSyncer._loadConfig(RemoteAPI, {'configurations': [config]});
-            });
</del><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should create BuildbotSyncer objects for valid configurations', () =&gt; {
</span><span class="lines">@@ -565,6 +522,98 @@
</span><span class="cx">             assert.equal(configurations[1].platform, MockModels.ipad);
</span><span class="cx">             assert.equal(configurations[1].test, MockModels.speedometer);
</span><span class="cx">         });
</span><ins>+
+        it('should throw when repositoryGroups is not an object', () =&gt; {
+            assert.throws(() =&gt; {
+                const config = smallConfiguration();
+                config.repositoryGroups = 1;
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
+            });
+            assert.throws(() =&gt; {
+                const config = smallConfiguration();
+                config.repositoryGroups = 'hello';
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
+            });
+        });
+
+        it('should throw when a repository group does not specify a list of repository', () =&gt; {
+            assert.throws(() =&gt; {
+                const config = smallConfiguration();
+                config.repositoryGroups = {'some-group': {}};
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
+            });
+            assert.throws(() =&gt; {
+                const config = smallConfiguration();
+                config.repositoryGroups = {'some-group': {'repositories': 1}};
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
+            });
+        });
+
+        it('should throw when a repository group specifies an empty list of repository', () =&gt; {
+            assert.throws(() =&gt; {
+                const config = smallConfiguration();
+                config.repositoryGroups = {'some-group': {'repositories': []}};
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
+            });
+        });
+
+        it('should throw when a repository group specifies a valid repository', () =&gt; {
+            assert.throws(() =&gt; {
+                const config = smallConfiguration();
+                config.repositoryGroups = {'some-group': {'repositories': ['InvalidRepositoryName']}};
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
+            });
+        });
+
+        it('should throw when the description of a repository group is not a string', () =&gt; {
+            assert.throws(() =&gt; {
+                const config = smallConfiguration();
+                config.repositoryGroups = {'some-group': {'repositories': ['WebKit'], 'description': 1}};
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
+            });
+            assert.throws(() =&gt; {
+                const config = smallConfiguration();
+                config.repositoryGroups = {'some-group': {'repositories': ['WebKit'], 'description': [1, 2]}};
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
+            });
+        });
+
+        it('should throw when a repository group does not specify a dictionary of properties', () =&gt; {
+            assert.throws(() =&gt; {
+                const config = smallConfiguration();
+                config.repositoryGroups = {'some-group': {'repositories': ['WebKit'], properties: 1}};
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
+            });
+            assert.throws(() =&gt; {
+                const config = smallConfiguration();
+                config.repositoryGroups = {'some-group': {'repositories': ['WebKit'], properties: 'hello'}};
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
+            });
+        });
+
+        it('should throw when a repository group refers to a non-existent repository in the properties dictionary', () =&gt; {
+            assert.throws(() =&gt; {
+                const config = smallConfiguration();
+                config.repositoryGroups = {'some-group': {'repositories': ['WebKit'], properties: {'wk': '&lt;InvalidRepository&gt;'}}};
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
+            });
+        });
+
+        it('should throw when a repository group refers to a repository in the properties dictionary which is not listed in the list of repositories', () =&gt; {
+            assert.throws(() =&gt; {
+                const config = smallConfiguration();
+                config.repositoryGroups = {'some-group': {'repositories': ['WebKit'], properties: {'os': '&lt;iOS&gt;'}}};
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
+            });
+        });
+
+        it('should throw when a repository group does not use a lited repository', () =&gt; {
+            assert.throws(() =&gt; {
+                const config = smallConfiguration();
+                config.repositoryGroups = {'some-group': {'repositories': ['WebKit'], properties: {}}};
+                BuildbotSyncer._loadConfig(MockRemoteAPI, config);
+            });
+        });
</ins><span class="cx">     });
</span><span class="cx"> 
</span><span class="cx">     describe('_propertiesForBuildRequest', () =&gt; {
</span><span class="lines">@@ -571,7 +620,7 @@
</span><span class="cx">         it('should include all properties specified in a given configuration', () =&gt; {
</span><span class="cx">             let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
</span><span class="cx">             let properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest(MockModels.iphone, MockModels.speedometer));
</span><del>-            assert.deepEqual(Object.keys(properties), ['desired_image', 'opensource', 'roots_dict', 'test_name', 'forcescheduler', 'build_request_id']);
</del><ins>+            assert.deepEqual(Object.keys(properties).sort(), ['build_request_id', 'desired_image', 'forcescheduler', 'opensource', 'test_name']);
</ins><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should preserve non-parametric property values', () =&gt; {
</span><span class="lines">@@ -591,18 +640,6 @@
</span><span class="cx">             assert.equal(properties['desired_image'], '13A452');
</span><span class="cx">         });
</span><span class="cx"> 
</span><del>-        it('should resolve &quot;rootOptions&quot;', () =&gt; {
-            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
-            let properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest(MockModels.iphone, MockModels.speedometer));
-            assert.equal(properties['roots_dict'], JSON.stringify(sampleCommitSetData));
-        });
-
-        it('should resolve &quot;rootsExcluding&quot;', () =&gt; {
-            let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
-            let properties = syncers[0]._propertiesForBuildRequest(createSampleBuildRequest(MockModels.iphone, MockModels.speedometer));
-            assert.equal(properties['roots_dict'], JSON.stringify(sampleCommitSetData));
-        });
-
</del><span class="cx">         it('should set the property for the build request id', () =&gt; {
</span><span class="cx">             let syncers = BuildbotSyncer._loadConfig(RemoteAPI, sampleiOSConfig());
</span><span class="cx">             let request = createSampleBuildRequest(MockModels.iphone, MockModels.speedometer);
</span><span class="lines">@@ -887,11 +924,8 @@
</span><span class="cx">                 assert.deepEqual(requests[0].data, {
</span><span class="cx">                     'build_request_id': '16733-' + MockModels.iphone.id(),
</span><span class="cx">                     'desired_image': '13A452',
</span><del>-                    &quot;opensource&quot;: &quot;9abcdef&quot;,
</del><ins>+                    &quot;opensource&quot;: &quot;197463&quot;,
</ins><span class="cx">                     'forcescheduler': 'ABTest-iPhone-RunBenchmark-Tests-ForceScheduler',
</span><del>-                    'roots_dict': '{&quot;WebKit&quot;:{&quot;id&quot;:&quot;111127&quot;,&quot;time&quot;:1456955807334,&quot;repository&quot;:&quot;WebKit&quot;,&quot;revision&quot;:&quot;197463&quot;},'
-                        + '&quot;Shared&quot;:{&quot;id&quot;:&quot;111237&quot;,&quot;time&quot;:1456931874000,&quot;repository&quot;:&quot;Shared&quot;,&quot;revision&quot;:&quot;80229&quot;},'
-                        + '&quot;WebKit-Git&quot;:{&quot;id&quot;:&quot;111239&quot;,&quot;time&quot;:1456931874000,&quot;repository&quot;:&quot;WebKit-Git&quot;,&quot;revision&quot;:&quot;9abcdef&quot;}}',
</del><span class="cx">                     'slavename': 'some-slave',
</span><span class="cx">                     'test_name': 'speedometer'
</span><span class="cx">                 });
</span><span class="lines">@@ -915,7 +949,7 @@
</span><span class="cx">         }
</span><span class="cx"> 
</span><span class="cx">         it('should schedule a build if builder has no builds if slaveList is not specified', () =&gt; {
</span><del>-            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [smallConfiguration()]})[0];
</del><ins>+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, smallConfiguration())[0];
</ins><span class="cx"> 
</span><span class="cx">             return pullBuildbotWithAssertion(syncer, [], {}).then(() =&gt; {
</span><span class="cx">                 syncer.scheduleRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.somePlatform, MockModels.someTest));
</span><span class="lines">@@ -922,12 +956,12 @@
</span><span class="cx">                 assert.equal(requests.length, 1);
</span><span class="cx">                 assert.equal(requests[0].url, '/builders/some%20builder/force');
</span><span class="cx">                 assert.equal(requests[0].method, 'POST');
</span><del>-                assert.deepEqual(requests[0].data, {id: '16733-' + MockModels.somePlatform.id()});
</del><ins>+                assert.deepEqual(requests[0].data, {id: '16733-' + MockModels.somePlatform.id(), 'os': '13A452', 'wk': '197463'});
</ins><span class="cx">             });
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should schedule a build if builder only has finished builds if slaveList is not specified', () =&gt; {
</span><del>-            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [smallConfiguration()]})[0];
</del><ins>+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, smallConfiguration())[0];
</ins><span class="cx"> 
</span><span class="cx">             return pullBuildbotWithAssertion(syncer, [], {[-1]: smallFinishedBuild()}).then(() =&gt; {
</span><span class="cx">                 syncer.scheduleRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.somePlatform, MockModels.someTest));
</span><span class="lines">@@ -934,12 +968,12 @@
</span><span class="cx">                 assert.equal(requests.length, 1);
</span><span class="cx">                 assert.equal(requests[0].url, '/builders/some%20builder/force');
</span><span class="cx">                 assert.equal(requests[0].method, 'POST');
</span><del>-                assert.deepEqual(requests[0].data, {id: '16733-' + MockModels.somePlatform.id()});
</del><ins>+                assert.deepEqual(requests[0].data, {id: '16733-' + MockModels.somePlatform.id(), 'os': '13A452', 'wk': '197463'});
</ins><span class="cx">             });
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should not schedule a build if builder has a pending build if slaveList is not specified', () =&gt; {
</span><del>-            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [smallConfiguration()]})[0];
</del><ins>+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, smallConfiguration())[0];
</ins><span class="cx"> 
</span><span class="cx">             return pullBuildbotWithAssertion(syncer, [smallPendingBuild()], {}).then(() =&gt; {
</span><span class="cx">                 syncer.scheduleRequestInGroupIfAvailable(createSampleBuildRequest(MockModels.somePlatform, MockModels.someTest));
</span><span class="lines">@@ -1041,7 +1075,7 @@
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">         it('should not schedule a build if a new request had been submitted to the same builder without slaveList', () =&gt; {
</span><del>-            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, {'configurations': [smallConfiguration()]})[0];
</del><ins>+            let syncer = BuildbotSyncer._loadConfig(MockRemoteAPI, smallConfiguration())[0];
</ins><span class="cx"> 
</span><span class="cx">             return pullBuildbotWithAssertion(syncer, [], {}).then(() =&gt; {
</span><span class="cx">                 syncer.scheduleRequest(createSampleBuildRequest(MockModels.somePlatform, MockModels.someTest), null);
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgunittestsresourcesmockv3modelsjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/unit-tests/resources/mock-v3-models.js (215060 => 215061)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/unit-tests/resources/mock-v3-models.js        2017-04-06 21:35:09 UTC (rev 215060)
+++ trunk/Websites/perf.webkit.org/unit-tests/resources/mock-v3-models.js        2017-04-06 21:56:59 UTC (rev 215061)
</span><span class="lines">@@ -13,6 +13,7 @@
</span><span class="cx">             Test.clearStaticMap();
</span><span class="cx">             TestGroup.clearStaticMap();
</span><span class="cx">             BuildRequest.clearStaticMap();
</span><ins>+            Triggerable.clearStaticMap();
</ins><span class="cx"> 
</span><span class="cx">             MockModels.osx = Repository.ensureSingleton(9, {name: 'OS X'});
</span><span class="cx">             MockModels.ios = Repository.ensureSingleton(22, {name: 'iOS'});
</span><span class="lines">@@ -39,6 +40,23 @@
</span><span class="cx">             MockModels.iPhonePLT = Test.ensureSingleton(1500, {name: 'PLT-iPhone'});
</span><span class="cx">             MockModels.pltMean = Metric.ensureSingleton(1158, {name: 'Time', aggregator: 'Arithmetic', test: MockModels.plt});
</span><span class="cx">             MockModels.elCapitan = Platform.ensureSingleton(31, {name: 'El Capitan', metrics: [MockModels.pltMean]});
</span><ins>+
+            MockModels.osRepositoryGroup = new TriggerableRepositoryGroup(31, {
+                name: 'ios',
+                repositories: [MockModels.ios]
+            });
+            MockModels.svnRepositoryGroup = new TriggerableRepositoryGroup(32, {
+                name: 'ios-svn-webkit',
+                repositories: [MockModels.ios, MockModels.webkit, MockModels.sharedRepository]
+            });
+            MockModels.gitRepositoryGroup = new TriggerableRepositoryGroup(33, {
+                name: 'ios-git-webkit',
+                repositories: [MockModels.ios, MockModels.webkitGit, MockModels.sharedRepository]
+            });
+            MockModels.triggerable = new Triggerable(3, {name: 'build-webkit',
+                repositoryGroups: [MockModels.osRepositoryGroup, MockModels.svnRepositoryGroup, MockModels.gitRepositoryGroup],
+                configurations: [{test: MockModels.iPhonePLT, platform: MockModels.iphone}]});
+
</ins><span class="cx">         });
</span><span class="cx">     }
</span><span class="cx"> }
</span></span></pre>
</div>
</div>

</body>
</html>