<!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>[178234] 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/178234">178234</a></dd>
<dt>Author</dt> <dd>rniwa@webkit.org</dd>
<dt>Date</dt> <dd>2015-01-09 21:26:49 -0800 (Fri, 09 Jan 2015)</dd>
</dl>

<h3>Log Message</h3>
<pre>Perf dashboard should have the ability to post A/B testing builds
https://bugs.webkit.org/show_bug.cgi?id=140317

Rubber-stamped by Simon Fraser.

This patch adds the support for triggering A/B testing from the perf dashboard.

We add a few new tables to the database. &quot;build_triggerables&quot;, which represents a set of builders
that accept A/B testing. &quot;triggerable_repositories&quot; associates each &quot;triggerable&quot; with a fixed set
of repositories for which an arbitrary revision can be specified for A/B testing.
&quot;triggerable_configurations&quot; specifies a triggerable available on a given test on a given platform.
&quot;roots&quot; table which specifies the revision used in a given root set in each repository.

* init-database.sql: Added &quot;build_triggerables&quot;, &quot;triggerable_repositories&quot;,
&quot;triggerable_configurations&quot;, and &quot;roots&quot; tables. Added references to &quot;build_triggerables&quot;,
&quot;platforms&quot;, and &quot;tests&quot; tables as well as columns to store status, status url, and creation time
to build_requests table. Also made each test group's name unique in a given analysis task as it
would be confusing to have multiple test groups of the same name.

* public/admin/tests.php: Added the UI and the code to associate a test with a triggerable.

* public/admin/triggerables.php: Added. Manages the list of triggerables as well as repositories
for which a specific revision can be set in an A/B testing on a given triggerable.

* public/api/build-requests.php: Added. Returns the list of open build requests on a specified
triggerable. Also updates the status' and the status urls of specified build requests when
buildRequestUpdates is provided in the raw POST data.
(main):

* public/api/runs.php:
(fetch_runs_for_config): Don't include results associated with a build request, meaning they are
results of an A/B testing.

* public/api/test-groups.php:
(main): Use the newly added BuildRequestsFetcher. Also merged fetch_test_groups_for_task back.

* public/api/triggerables.php: Added.
(main): Returns a list of triggerables or a triggerable associated with a given analysis task.

* public/include/admin-header.php:

* public/include/build-requests-fetcher.php: Added. Extracted from public/api/test-groups.php.
(BuildRequestsFetcher): This class abstracts the process of fetching a list of builds requests
and root sets used in those requests.D
(BuildRequestsFetcher::__construct):
(BuildRequestsFetcher::fetch_for_task):
(BuildRequestsFetcher::fetch_for_group):
(BuildRequestsFetcher::fetch_incomplete_requests_for_triggerable):
(BuildRequestsFetcher::has_results):
(BuildRequestsFetcher::results):
(BuildRequestsFetcher::results_with_resolved_ids):
(BuildRequestsFetcher::results_internal):
(BuildRequestsFetcher::root_sets):
(BuildRequestsFetcher::fetch_roots_for_set):

* public/include/db.php:
(Database::prefixed_column_names): Don't return &quot;$prefix_&quot; when there are no columns.
(Database::insert_row): Support taking an empty array for values. This is useful in &quot;root_sets&quot;
table since it only has the primary key, id, column.
(Database::select_or_insert_row):
(Database::update_or_insert_row):
(Database::update_row): Added.
(Database::_select_update_or_insert_row): Takes an extra argument specifying whether a new row
should be inserted when no row matches the specified criteria. This is used while updating
build_requests' status and url in public/api/build-requests.php since we shouldn't be inserting
new build requests in that API.
(Database::select_rows): Also use &quot;1 == 1&quot; in the select query when the query criteria is empty.
This is used in public/api/triggerables.php when no analysis task is specified.

* public/include/json-header.php:
(find_triggerable_for_task): Added. Finds a triggerable available on a given test. We return the
triggerable associated with the closest ancestor of the test. Since issuing a new query for each
ancestor test is expensive, we retrieve triggerable for all ancestor tests at once and manually
find the closest ancestor with a triggerable.

* public/include/report-processor.php:
(ReportProcessor::process):
(ReportProcessor::resolve_build_id): Associate a build request with the newly created build
if jobId or buildRequest is specified.

* public/include/test-name-resolver.php:
(TestNameResolver::map_metrics_to_tests): Store the entire metric row instead of its name so that
test_exists_on_platform can use it. The last diff in public/admin/tests.php adopts this change.
(TestNameResolver::test_exists_on_platform): Added. Returns true iff the test has ever run on
a given platform.

* public/include/test-path-resolver.php: Added.
(TestPathResolver): This class abstracts the ancestor chains of a test. It retrieves the entire
&quot;tests&quot; table to do this since there could be arbitrary number of ancestors for a given test.
This class is a lot more lightweight than TestNameResolver, which retrieves a whole bunch of tables
in order to compute full test metric names.
(TestPathResolver::__construct):
(TestPathResolver::ancestors_for_test): Returns the ordered list of ancestors from the closest to
the highest (a test without a parent).
(TestPathResolver::path_for_test): Returns a test &quot;path&quot;, the ordered list of test names from
the highest ancestor to the test itself.
(TestPathResolver::ensure_id_to_test_map): Fetches &quot;tests&quot; table to construct id_to_test_map.

* public/privileged-api/create-test-group.php: Added. An API to create A/B testing groups.
(main):
(commit_sets_from_root_sets): Given a dictionary of repository names to a pair of revisions
for sets A and B respectively, returns a pair of arrays, each of which contains the corresponding
set of &quot;commits&quot; for sets A and B respectively. e.g. {&quot;WebKit&quot;: [1, 2], &quot;Safari&quot;: [3, 4]} will
result in [[WebKit commit at <a href="http://trac.webkit.org/projects/webkit/changeset/1">r1</a>, Safari commit at <a href="http://trac.webkit.org/projects/webkit/changeset/3">r3</a>], [WebKit commit at <a href="http://trac.webkit.org/projects/webkit/changeset/2">r2</a>, Safari commit at <a href="http://trac.webkit.org/projects/webkit/changeset/4">r4</a>]].

* public/v2/analysis.js:
(App.AnalysisTask.testGroups): Takes arguments so that set('testGroups') will invalidate the cache.
(App.AnalysisTask.triggerable): Added. Retrieves the triggerable associated with the task lazily.
(App.TestGroup.rootSets): Added. Returns the list of root set ids used in this A/B testing group.
(App.TestGroup.create): Added. Creates a new A/B testing group.
(App.Triggerable): Added.
(App.TriggerableAdapter): Added.
(App.TriggerableAdapter.buildURL): Added.
(App.BuildRequest.testGroup): Renamed from group.
(App.BuildRequest.orderLabel): Added. One-based index to be used in labels.
(App.BuildRequest.config): Added. Returns either 'A' or 'B' depending on the configuration used
in this build request.
(App.BuildRequest.status): Added.
(App.BuildRequest.statusLabel): Added. Returns a human friendly label for the current status.
(App.BuildRequest): Removed buildNumber, buildBuilder, as well as buildTime as they're unused.

* public/v2/app.js:
(App.AnalysisTaskController.testGroups): Added.
(App.AnalysisTaskController.possibleRepetitionCounts): Added.
(App.AnalysisTaskController.updateRoots): Renamed from roots. This is also no longer a property
but an observer that updates &quot;roots&quot; property. Filter out the repositories that are not accepted
by the associated triggerable as they will be ignored.
(App.AnalysisTaskController.actions.createTestGroup): Added.

* public/v2/index.html: Updated the UI, and added a form element to trigger createTestGroup action.

* tools/sync-with-buildbot.py: Added. This scripts posts new builds on buildbot and reports back
the status of those builds to the perf dashboard. A similar script can be written to support
other continuous builds systems.
(main): Fetches the list of pending builds as well as currently running or completed builds from
a buildbot, and report new statuses of builds requests to the perf dashboard. It will then schedule
a single new build on each builder with no pending builds, and marks the set of open build requests
that have been scheduled to run on the buildbot but not found in the first step as stale.
(load_config): Loads a JSON that contains the configurations for each builder. e.g.
[
    {
        &quot;platform&quot;: &quot;mac-mavericks&quot;,
        &quot;test&quot;: [&quot;Parser&quot;, &quot;html5-full-render.html&quot;],
        &quot;builder&quot;: &quot;Trunk Syrah Production Perf AB Tests&quot;,
        &quot;arguments&quot;: {
            &quot;forcescheduler&quot;: &quot;force-mac-mavericks-release-perf&quot;,
            &quot;webkit_revision&quot;: &quot;$WebKit&quot;,
            &quot;jobid&quot;: &quot;$buildRequest&quot;
        }
    }
]

(find_request_updates): Return a list of build request status updates to make based on the pending
builds as well as in-progress and completed builds on each builder on the buildbot. When a build is
completed, we use the special status &quot;failedIfNotCompleted&quot; which results in &quot;failed&quot; status only
if the build request had not been completed. This is necessary because a failed build will not
report its failed-ness back to the perf dashboard in some cases; e.g. lost slave or svn up failure.
(update_and_fetch_build_requests): Submit the build request status updates and retrieve the list
of open requests the perf dashboard has.
(find_stale_request_updates): Compute the list of build requests that have been scheduled on the
buildbot but not found in find_request_updates. These build requests are lost. e.g. a master reboot
or human canceling a build may trigger such a state.
(schedule_request): Schedules a build with the arguments specified in the configuration JSON after
replacing repository names with their revisions and buildRequest with the build request id.
(config_for_request): Finds a builder for the test and the platform of a build request.
(fetch_json): Fetches a JSON from the specified URL, optionally with BasicAuth.
(property_value_from_build): Returns the value of a specific property in a buildbot build.
(request_id_from_build): Returns the build request id of a given buildbot build if there is one.</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="#trunkWebsitesperfwebkitorgpublicadmintestsphp">trunk/Websites/perf.webkit.org/public/admin/tests.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicapirunsphp">trunk/Websites/perf.webkit.org/public/api/runs.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicapitestgroupsphp">trunk/Websites/perf.webkit.org/public/api/test-groups.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicincludeadminheaderphp">trunk/Websites/perf.webkit.org/public/include/admin-header.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicincludedbphp">trunk/Websites/perf.webkit.org/public/include/db.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicincludejsonheaderphp">trunk/Websites/perf.webkit.org/public/include/json-header.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicincludereportprocessorphp">trunk/Websites/perf.webkit.org/public/include/report-processor.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicincludetestnameresolverphp">trunk/Websites/perf.webkit.org/public/include/test-name-resolver.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicv2analysisjs">trunk/Websites/perf.webkit.org/public/v2/analysis.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicv2appjs">trunk/Websites/perf.webkit.org/public/v2/app.js</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicv2indexhtml">trunk/Websites/perf.webkit.org/public/v2/index.html</a></li>
</ul>

<h3>Added Paths</h3>
<ul>
<li><a href="#trunkWebsitesperfwebkitorgpublicadmintriggerablesphp">trunk/Websites/perf.webkit.org/public/admin/triggerables.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicapibuildrequestsphp">trunk/Websites/perf.webkit.org/public/api/build-requests.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicapitriggerablesphp">trunk/Websites/perf.webkit.org/public/api/triggerables.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicincludebuildrequestsfetcherphp">trunk/Websites/perf.webkit.org/public/include/build-requests-fetcher.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicincludetestpathresolverphp">trunk/Websites/perf.webkit.org/public/include/test-path-resolver.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgpublicprivilegedapicreatetestgroupphp">trunk/Websites/perf.webkit.org/public/privileged-api/create-test-group.php</a></li>
<li><a href="#trunkWebsitesperfwebkitorgtoolssyncwithbuildbotpy">trunk/Websites/perf.webkit.org/tools/sync-with-buildbot.py</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 (178233 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/ChangeLog        2015-01-10 03:53:54 UTC (rev 178233)
+++ trunk/Websites/perf.webkit.org/ChangeLog        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -1,5 +1,176 @@
</span><span class="cx"> 2015-01-09  Ryosuke Niwa  &lt;rniwa@webkit.org&gt;
</span><span class="cx"> 
</span><ins>+        Perf dashboard should have the ability to post A/B testing builds
+        https://bugs.webkit.org/show_bug.cgi?id=140317
+
+        Rubber-stamped by Simon Fraser.
+
+        This patch adds the support for triggering A/B testing from the perf dashboard.
+
+        We add a few new tables to the database. &quot;build_triggerables&quot;, which represents a set of builders
+        that accept A/B testing. &quot;triggerable_repositories&quot; associates each &quot;triggerable&quot; with a fixed set
+        of repositories for which an arbitrary revision can be specified for A/B testing.
+        &quot;triggerable_configurations&quot; specifies a triggerable available on a given test on a given platform.
+        &quot;roots&quot; table which specifies the revision used in a given root set in each repository.
+
+        * init-database.sql: Added &quot;build_triggerables&quot;, &quot;triggerable_repositories&quot;,
+        &quot;triggerable_configurations&quot;, and &quot;roots&quot; tables. Added references to &quot;build_triggerables&quot;,
+        &quot;platforms&quot;, and &quot;tests&quot; tables as well as columns to store status, status url, and creation time
+        to build_requests table. Also made each test group's name unique in a given analysis task as it
+        would be confusing to have multiple test groups of the same name.
+
+        * public/admin/tests.php: Added the UI and the code to associate a test with a triggerable.
+
+        * public/admin/triggerables.php: Added. Manages the list of triggerables as well as repositories
+        for which a specific revision can be set in an A/B testing on a given triggerable.
+
+        * public/api/build-requests.php: Added. Returns the list of open build requests on a specified
+        triggerable. Also updates the status' and the status urls of specified build requests when
+        buildRequestUpdates is provided in the raw POST data.
+        (main):
+
+        * public/api/runs.php:
+        (fetch_runs_for_config): Don't include results associated with a build request, meaning they are
+        results of an A/B testing.
+
+        * public/api/test-groups.php:
+        (main): Use the newly added BuildRequestsFetcher. Also merged fetch_test_groups_for_task back.
+
+        * public/api/triggerables.php: Added.
+        (main): Returns a list of triggerables or a triggerable associated with a given analysis task.
+
+        * public/include/admin-header.php:
+
+        * public/include/build-requests-fetcher.php: Added. Extracted from public/api/test-groups.php.
+        (BuildRequestsFetcher): This class abstracts the process of fetching a list of builds requests
+        and root sets used in those requests.D
+        (BuildRequestsFetcher::__construct):
+        (BuildRequestsFetcher::fetch_for_task):
+        (BuildRequestsFetcher::fetch_for_group):
+        (BuildRequestsFetcher::fetch_incomplete_requests_for_triggerable):
+        (BuildRequestsFetcher::has_results):
+        (BuildRequestsFetcher::results):
+        (BuildRequestsFetcher::results_with_resolved_ids):
+        (BuildRequestsFetcher::results_internal):
+        (BuildRequestsFetcher::root_sets):
+        (BuildRequestsFetcher::fetch_roots_for_set):
+
+        * public/include/db.php:
+        (Database::prefixed_column_names): Don't return &quot;$prefix_&quot; when there are no columns.
+        (Database::insert_row): Support taking an empty array for values. This is useful in &quot;root_sets&quot;
+        table since it only has the primary key, id, column.
+        (Database::select_or_insert_row):
+        (Database::update_or_insert_row):
+        (Database::update_row): Added.
+        (Database::_select_update_or_insert_row): Takes an extra argument specifying whether a new row
+        should be inserted when no row matches the specified criteria. This is used while updating
+        build_requests' status and url in public/api/build-requests.php since we shouldn't be inserting
+        new build requests in that API.
+        (Database::select_rows): Also use &quot;1 == 1&quot; in the select query when the query criteria is empty.
+        This is used in public/api/triggerables.php when no analysis task is specified.
+
+        * public/include/json-header.php:
+        (find_triggerable_for_task): Added. Finds a triggerable available on a given test. We return the
+        triggerable associated with the closest ancestor of the test. Since issuing a new query for each
+        ancestor test is expensive, we retrieve triggerable for all ancestor tests at once and manually
+        find the closest ancestor with a triggerable.
+
+        * public/include/report-processor.php:
+        (ReportProcessor::process):
+        (ReportProcessor::resolve_build_id): Associate a build request with the newly created build
+        if jobId or buildRequest is specified.
+
+        * public/include/test-name-resolver.php:
+        (TestNameResolver::map_metrics_to_tests): Store the entire metric row instead of its name so that
+        test_exists_on_platform can use it. The last diff in public/admin/tests.php adopts this change.
+        (TestNameResolver::test_exists_on_platform): Added. Returns true iff the test has ever run on
+        a given platform.
+
+        * public/include/test-path-resolver.php: Added.
+        (TestPathResolver): This class abstracts the ancestor chains of a test. It retrieves the entire
+        &quot;tests&quot; table to do this since there could be arbitrary number of ancestors for a given test.
+        This class is a lot more lightweight than TestNameResolver, which retrieves a whole bunch of tables
+        in order to compute full test metric names.
+        (TestPathResolver::__construct):
+        (TestPathResolver::ancestors_for_test): Returns the ordered list of ancestors from the closest to
+        the highest (a test without a parent).
+        (TestPathResolver::path_for_test): Returns a test &quot;path&quot;, the ordered list of test names from
+        the highest ancestor to the test itself.
+        (TestPathResolver::ensure_id_to_test_map): Fetches &quot;tests&quot; table to construct id_to_test_map.
+
+        * public/privileged-api/create-test-group.php: Added. An API to create A/B testing groups.
+        (main):
+        (commit_sets_from_root_sets): Given a dictionary of repository names to a pair of revisions
+        for sets A and B respectively, returns a pair of arrays, each of which contains the corresponding
+        set of &quot;commits&quot; for sets A and B respectively. e.g. {&quot;WebKit&quot;: [1, 2], &quot;Safari&quot;: [3, 4]} will
+        result in [[WebKit commit at r1, Safari commit at r3], [WebKit commit at r2, Safari commit at r4]].
+
+        * public/v2/analysis.js:
+        (App.AnalysisTask.testGroups): Takes arguments so that set('testGroups') will invalidate the cache.
+        (App.AnalysisTask.triggerable): Added. Retrieves the triggerable associated with the task lazily.
+        (App.TestGroup.rootSets): Added. Returns the list of root set ids used in this A/B testing group.
+        (App.TestGroup.create): Added. Creates a new A/B testing group.
+        (App.Triggerable): Added.
+        (App.TriggerableAdapter): Added.
+        (App.TriggerableAdapter.buildURL): Added.
+        (App.BuildRequest.testGroup): Renamed from group.
+        (App.BuildRequest.orderLabel): Added. One-based index to be used in labels.
+        (App.BuildRequest.config): Added. Returns either 'A' or 'B' depending on the configuration used
+        in this build request.
+        (App.BuildRequest.status): Added.
+        (App.BuildRequest.statusLabel): Added. Returns a human friendly label for the current status.
+        (App.BuildRequest): Removed buildNumber, buildBuilder, as well as buildTime as they're unused.
+
+        * public/v2/app.js:
+        (App.AnalysisTaskController.testGroups): Added.
+        (App.AnalysisTaskController.possibleRepetitionCounts): Added.
+        (App.AnalysisTaskController.updateRoots): Renamed from roots. This is also no longer a property
+        but an observer that updates &quot;roots&quot; property. Filter out the repositories that are not accepted
+        by the associated triggerable as they will be ignored.
+        (App.AnalysisTaskController.actions.createTestGroup): Added.
+
+        * public/v2/index.html: Updated the UI, and added a form element to trigger createTestGroup action.
+
+        * tools/sync-with-buildbot.py: Added. This scripts posts new builds on buildbot and reports back
+        the status of those builds to the perf dashboard. A similar script can be written to support
+        other continuous builds systems.
+        (main): Fetches the list of pending builds as well as currently running or completed builds from
+        a buildbot, and report new statuses of builds requests to the perf dashboard. It will then schedule
+        a single new build on each builder with no pending builds, and marks the set of open build requests
+        that have been scheduled to run on the buildbot but not found in the first step as stale.
+        (load_config): Loads a JSON that contains the configurations for each builder. e.g.
+        [
+            {
+                &quot;platform&quot;: &quot;mac-mavericks&quot;,
+                &quot;test&quot;: [&quot;Parser&quot;, &quot;html5-full-render.html&quot;],
+                &quot;builder&quot;: &quot;Trunk Syrah Production Perf AB Tests&quot;,
+                &quot;arguments&quot;: {
+                    &quot;forcescheduler&quot;: &quot;force-mac-mavericks-release-perf&quot;,
+                    &quot;webkit_revision&quot;: &quot;$WebKit&quot;,
+                    &quot;jobid&quot;: &quot;$buildRequest&quot;
+                }
+            }
+        ]
+
+        (find_request_updates): Return a list of build request status updates to make based on the pending
+        builds as well as in-progress and completed builds on each builder on the buildbot. When a build is
+        completed, we use the special status &quot;failedIfNotCompleted&quot; which results in &quot;failed&quot; status only
+        if the build request had not been completed. This is necessary because a failed build will not
+        report its failed-ness back to the perf dashboard in some cases; e.g. lost slave or svn up failure.
+        (update_and_fetch_build_requests): Submit the build request status updates and retrieve the list
+        of open requests the perf dashboard has.
+        (find_stale_request_updates): Compute the list of build requests that have been scheduled on the
+        buildbot but not found in find_request_updates. These build requests are lost. e.g. a master reboot
+        or human canceling a build may trigger such a state.
+        (schedule_request): Schedules a build with the arguments specified in the configuration JSON after
+        replacing repository names with their revisions and buildRequest with the build request id.
+        (config_for_request): Finds a builder for the test and the platform of a build request.
+        (fetch_json): Fetches a JSON from the specified URL, optionally with BasicAuth.
+        (property_value_from_build): Returns the value of a specific property in a buildbot build.
+        (request_id_from_build): Returns the build request id of a given buildbot build if there is one.
+
+2015-01-09  Ryosuke Niwa  &lt;rniwa@webkit.org&gt;
+
</ins><span class="cx">         Cache-control should be set only on api/runs
</span><span class="cx">         https://bugs.webkit.org/show_bug.cgi?id=140312
</span><span class="cx"> 
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorginitdatabasesql"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/init-database.sql (178233 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/init-database.sql        2015-01-10 03:53:54 UTC (rev 178233)
+++ trunk/Websites/perf.webkit.org/init-database.sql        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -17,10 +17,15 @@
</span><span class="cx"> DROP TABLE tracker_repositories CASCADE;
</span><span class="cx"> DROP TABLE bug_trackers CASCADE;
</span><span class="cx"> DROP TABLE analysis_tasks CASCADE;
</span><ins>+DROP TABLE build_triggerables CASCADE;
+DROP TABLE triggerable_configurations CASCADE;
+DROP TABLE triggerable_repositories CASCADE;
</ins><span class="cx"> DROP TABLE bugs CASCADE;
</span><span class="cx"> DROP TABLE analysis_test_groups CASCADE;
</span><span class="cx"> DROP TABLE root_sets CASCADE;
</span><ins>+DROP TABLE roots CASCADE;
</ins><span class="cx"> DROP TABLE build_requests CASCADE;
</span><ins>+DROP TYPE build_request_status_type CASCADE;
</ins><span class="cx"> 
</span><span class="cx"> 
</span><span class="cx"> CREATE TABLE platforms (
</span><span class="lines">@@ -171,21 +176,50 @@
</span><span class="cx">     bug_number integer NOT NULL,
</span><span class="cx">     CONSTRAINT bug_task_and_tracker_must_be_unique UNIQUE(bug_task, bug_tracker));
</span><span class="cx"> 
</span><ins>+CREATE TABLE build_triggerables (
+    triggerable_id serial PRIMARY KEY,
+    triggerable_name varchar(64) NOT NULL UNIQUE);
+
+CREATE TABLE triggerable_repositories (
+    trigrepo_triggerable integer REFERENCES build_triggerables NOT NULL,
+    trigrepo_repository integer REFERENCES repositories NOT NULL,
+    trigrepo_sub_roots boolean NOT NULL DEFAULT FALSE);
+
+CREATE TABLE triggerable_configurations (
+    trigconfig_test integer REFERENCES tests NOT NULL,
+    trigconfig_platform integer REFERENCES platforms NOT NULL,
+    trigconfig_triggerable integer REFERENCES build_triggerables NOT NULL,
+    CONSTRAINT triggerable_must_be_unique_for_test_and_platform UNIQUE(trigconfig_test, trigconfig_platform));
+
</ins><span class="cx"> CREATE TABLE analysis_test_groups (
</span><span class="cx">     testgroup_id serial PRIMARY KEY,
</span><span class="cx">     testgroup_task integer REFERENCES analysis_tasks NOT NULL,
</span><span class="cx">     testgroup_name varchar(256),
</span><del>-    testgroup_author varchar(256) NOT NULL,
-    testgroup_created_at timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'));
</del><ins>+    testgroup_author varchar(256),
+    testgroup_created_at timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
+    CONSTRAINT testgroup_name_must_be_unique_for_each_task UNIQUE(testgroup_task, testgroup_name));
</ins><span class="cx"> CREATE INDEX testgroup_task_index ON analysis_test_groups(testgroup_task);
</span><span class="cx"> 
</span><span class="cx"> CREATE TABLE root_sets (
</span><span class="cx">     rootset_id serial PRIMARY KEY);
</span><span class="cx"> 
</span><ins>+CREATE TABLE roots (
+    root_set integer REFERENCES root_sets NOT NULL,
+    root_commit integer REFERENCES commits NOT NULL);
+
+CREATE TYPE build_request_status_type as ENUM ('pending', 'scheduled', 'running', 'failed', 'completed');
</ins><span class="cx"> CREATE TABLE build_requests (
</span><span class="cx">     request_id serial PRIMARY KEY,
</span><ins>+    request_triggerable integer REFERENCES build_triggerables NOT NULL,
+    request_platform integer REFERENCES platforms NOT NULL,
+    request_test integer REFERENCES tests NOT NULL,
</ins><span class="cx">     request_group integer REFERENCES analysis_test_groups NOT NULL,
</span><span class="cx">     request_order integer NOT NULL,
</span><span class="cx">     request_root_set integer REFERENCES root_sets NOT NULL,
</span><ins>+    request_status build_request_status_type NOT NULL DEFAULT 'pending',
+    request_url varchar(1024),
</ins><span class="cx">     request_build integer REFERENCES builds,
</span><ins>+    request_created_at timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
</ins><span class="cx">     CONSTRAINT build_request_order_must_be_unique_in_group UNIQUE(request_group, request_order));
</span><ins>+CREATE INDEX build_request_triggerable ON build_requests(request_triggerable);    
+CREATE INDEX build_request_build ON build_requests(request_build);
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicadmintestsphp"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/admin/tests.php (178233 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/admin/tests.php        2015-01-10 03:53:54 UTC (rev 178233)
+++ trunk/Websites/perf.webkit.org/public/admin/tests.php        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -32,6 +32,22 @@
</span><span class="cx"> } else if ($action == 'update') {
</span><span class="cx">     if (!update_field('tests', 'test', 'url'))
</span><span class="cx">         notice('Invalid parameters');
</span><ins>+} else if ($action == 'update-triggerable') {
+    $test_id = intval($_POST['test']);
+    $platform_id = intval($_POST['platform']);
+
+    $triggerable_id = array_get($_POST, 'triggerable');
+    if ($triggerable_id) {
+        $triggerable_id = intval($triggerable_id);
+        $association = array('test' =&gt; $test_id, 'platform' =&gt; $platform_id, 'triggerable' =&gt; $triggerable_id);
+        if (!$db-&gt;insert_row('triggerable_configurations', 'trigconfig', $association, NULL)) {
+            $suceeded = FALSE;
+            notice(&quot;Failed to associate triggerable $triggerable_id with test $test_id on platform $platform_id.&quot;);
+        }
+    } else {
+        $db-&gt;query_and_get_affected_rows('DELETE FROM triggerable_configurations WHERE trigrepo_test = $1 AND trigrepo_platform = $2',
+            array($test_id, $platform_id));
+    }
</ins><span class="cx"> } else if ($action == 'add') {
</span><span class="cx">     if (array_key_exists('test_id', $_POST) &amp;&amp; array_key_exists('metric_name', $_POST)) {
</span><span class="cx">         $id = intval($_POST['test_id']);
</span><span class="lines">@@ -58,6 +74,7 @@
</span><span class="cx">         foreach ($aggregators_table as $aggregator_row)
</span><span class="cx">             $aggregators[$aggregator_row['aggregator_id']] = $aggregator_row['aggregator_name'];
</span><span class="cx">     }
</span><ins>+    $build_triggerable_table = $db-&gt;fetch_table('build_triggerables', 'triggerable_name') or array();
</ins><span class="cx"> 
</span><span class="cx">     $test_name_resolver = new TestNameResolver($db);
</span><span class="cx">     if ($test_name_resolver-&gt;tests()) {
</span><span class="lines">@@ -78,7 +95,7 @@
</span><span class="cx"> ?&gt;
</span><span class="cx"> &lt;table&gt;
</span><span class="cx"> &lt;thead&gt;
</span><del>-    &lt;tr&gt;&lt;td&gt;Test ID&lt;/td&gt;&lt;td&gt;Full Name&lt;/td&gt;&lt;td&gt;Parent ID&lt;/td&gt;&lt;td&gt;URL&lt;/td&gt;
</del><ins>+    &lt;tr&gt;&lt;td&gt;Test ID&lt;/td&gt;&lt;td&gt;Full Name&lt;/td&gt;&lt;td&gt;Parent ID&lt;/td&gt;&lt;td&gt;URL&lt;/td&gt;&lt;td&gt;Triggerables&lt;/td&gt;
</ins><span class="cx">         &lt;td&gt;Metric ID&lt;/td&gt;&lt;td&gt;Metric Name&lt;/td&gt;&lt;td&gt;Aggregator&lt;/td&gt;&lt;td&gt;Dashboard&lt;/td&gt;
</span><span class="cx"> &lt;/thead&gt;
</span><span class="cx"> &lt;tbody&gt;
</span><span class="lines">@@ -103,6 +120,37 @@
</span><span class="cx"> 
</span><span class="cx">             $test_url = htmlspecialchars($test['test_url']);
</span><span class="cx"> 
</span><ins>+            $triggerable_platforms = $db-&gt;query_and_fetch_all('SELECT * FROM platforms LEFT OUTER JOIN triggerable_configurations
+                ON trigconfig_platform = platform_id AND trigconfig_test = $1 ORDER BY platform_name', array($test_id));
+            $triggerables = '';
+            foreach ($triggerable_platforms as $platform_row) {
+                if (!$test_name_resolver-&gt;test_exists_on_platform($test_id, $platform_row['platform_id']))
+                    continue;
+
+                $triggerables .= &lt;&lt;&lt; END
+&lt;form method=&quot;POST&quot;&gt;
+    &lt;input type=&quot;hidden&quot; name=&quot;test&quot; value=&quot;$test_id&quot;&gt;
+    &lt;input type=&quot;hidden&quot; name=&quot;platform&quot; value=&quot;{$platform_row['platform_id']}&quot;&gt;
+    &lt;input type=&quot;hidden&quot; name=&quot;action&quot; value=&quot;update-triggerable&quot;&gt;
+    &lt;label&gt;
+        {$platform_row['platform_name']}
+        &lt;select name=&quot;triggerable&quot; onchange=&quot;this.form.submit();&quot;&gt;
+            &lt;option value=&quot;&quot;&gt;None&lt;/option&gt;
+END;
+                $selected_triggerable = array_get($platform_row, 'trigrepo_triggerable');
+                foreach ($build_triggerable_table as $triggerable_row) {
+                    $triggerable_id = $triggerable_row['triggerable_id'];
+                    $selected = $triggerable_id == $selected_triggerable ? ' selected' : '';
+                    $triggerables .= &quot;&lt;option value=\&quot;$triggerable_id\&quot;$selected&gt;{$triggerable_row['triggerable_name']}&lt;/option&gt;&quot;;
+                }
+                $triggerables .= &lt;&lt;&lt; END
+        &lt;/select&gt;
+    &lt;/label&gt;
+&lt;/form&gt;
+&lt;br&gt;
+END;
+            }
+
</ins><span class="cx">             echo &lt;&lt;&lt;EOF
</span><span class="cx">     &lt;tbody class=&quot;$tbody_class&quot;&gt;
</span><span class="cx">     &lt;tr&gt;
</span><span class="lines">@@ -112,7 +160,8 @@
</span><span class="cx">         &lt;td rowspan=&quot;$row_count&quot;&gt;
</span><span class="cx">         &lt;form method=&quot;POST&quot;&gt;&lt;input type=&quot;hidden&quot; name=&quot;id&quot; value=&quot;$test_id&quot;&gt;
</span><span class="cx">         &lt;input type=&quot;hidden&quot; name=&quot;action&quot; value=&quot;update&quot;&gt;
</span><del>-        &lt;input type=&quot;url&quot; name=&quot;url&quot; value=&quot;$test_url&quot; size=&quot;80&quot;&gt;&lt;/form&gt;&lt;/td&gt;
</del><ins>+        &lt;input type=&quot;url&quot; name=&quot;url&quot; value=&quot;$test_url&quot; size=&quot;30&quot;&gt;&lt;/form&gt;&lt;/td&gt;
+        &lt;td rowspan=&quot;$row_count&quot;&gt;$triggerables&lt;/td&gt;
</ins><span class="cx"> EOF;
</span><span class="cx"> 
</span><span class="cx">             if ($test_metrics) {
</span><span class="lines">@@ -169,8 +218,8 @@
</span><span class="cx">         &lt;label&gt;Name&lt;select name=&quot;metric_name&quot;&gt;
</span><span class="cx"> EOF;
</span><span class="cx"> 
</span><del>-                foreach ($child_metrics as $metric_name) {
-                    $metric_name = htmlspecialchars($metric_name);
</del><ins>+                foreach ($child_metrics as $metric) {
+                    $metric_name = htmlspecialchars($metric['metric_name']);
</ins><span class="cx">                     echo &quot;
</span><span class="cx">             &lt;option&gt;$metric_name&lt;/option&gt;&quot;;
</span><span class="cx">                 }
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicadmintriggerablesphp"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/public/admin/triggerables.php (0 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/admin/triggerables.php                                (rev 0)
+++ trunk/Websites/perf.webkit.org/public/admin/triggerables.php        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -0,0 +1,87 @@
</span><ins>+&lt;?php
+
+require('../include/admin-header.php');
+
+if ($db) {
+
+    if ($action == 'add') {
+        if ($db-&gt;insert_row('build_triggerables', 'triggerable', array('name' =&gt; $_POST['name'], 'location' =&gt; $_POST['location']))) {
+            notice('Inserted the new triggerable.');
+            regenerate_manifest();
+        } else
+            notice('Could not add the triggerable.');
+    } else if ($action == 'update') {
+        if (update_field('build_triggerables', 'triggerable', 'name'))
+            regenerate_manifest();
+        else if (update_field('build_triggerables', 'triggerable', 'location'))
+            regenerate_manifest();
+        else
+            notice('Invalid parameters.');
+    } else if ($action == 'update-repositories') {
+        $triggerable_id = intval($_POST['id']);
+
+        $db-&gt;begin_transaction();
+        $db-&gt;query_and_get_affected_rows(&quot;DELETE FROM triggerable_repositories WHERE trigrepo_triggerable = $1&quot;, array($triggerable_id));
+
+        $repositories = array_get($_POST, 'repositories');
+        $suceeded = TRUE;
+        if ($repositories) {
+            foreach ($repositories as $repository_id) {
+                if (!$db-&gt;insert_row('triggerable_repositories', 'trigrepo', array('triggerable' =&gt; $triggerable_id, 'repository' =&gt; $repository_id), NULL)) {
+                    $suceeded = FALSE;
+                    notice(&quot;Failed to associate repository $repository_id with triggerable $triggerable_id.&quot;);
+                    break;
+                }
+            }
+        }
+        if ($suceeded) {
+            $db-&gt;commit_transaction();
+            notice('Updated the association.');
+            regenerate_manifest();
+        } else
+            $db-&gt;rollback_transaction();
+    }
+
+    $repository_rows = $db-&gt;fetch_table('repositories', 'repository_name');
+    $repository_names = array();
+
+
+    $page = new AdministrativePage($db, 'build_triggerables', 'triggerable', array(
+        'name' =&gt; array('editing_mode' =&gt; 'string'),
+        'repositories' =&gt; array('custom' =&gt; function ($triggerable_row) use (&amp;$repository_rows) {
+            return array(generate_repository_checkboxes($triggerable_row['triggerable_id'], $repository_rows));
+        }),
+    ));
+
+    function generate_repository_checkboxes($triggerable_id, $repository_rows) {
+        global $db;
+
+        $repository_rows = $db-&gt;query_and_fetch_all('SELECT * FROM repositories LEFT OUTER JOIN triggerable_repositories
+            ON trigrepo_repository = repository_id AND trigrepo_triggerable = $1 ORDER BY repository_name', array($triggerable_id));
+
+        $form = &lt;&lt;&lt; END
+&lt;form method=&quot;POST&quot;&gt;
+&lt;input type=&quot;hidden&quot; name=&quot;id&quot; value=&quot;$triggerable_id&quot;&gt;
+&lt;input type=&quot;hidden&quot; name=&quot;action&quot; value=&quot;update-repositories&quot;&gt;
+END;
+
+        foreach ($repository_rows as $row) {
+            $checked = $row['trigrepo_triggerable'] ? ' checked' : '';
+            $form .= &lt;&lt;&lt; END
+&lt;label&gt;&lt;input type=&quot;checkbox&quot; name=&quot;repositories[]&quot; value=&quot;{$row['repository_id']}&quot;$checked&gt;{$row['repository_name']}&lt;/label&gt;
+END;
+        }
+
+        return $form . &lt;&lt;&lt; END
+&lt;button&gt;Save&lt;/button&gt;
+&lt;/form&gt;
+END;
+    }
+
+    $page-&gt;render_table('name');
+    $page-&gt;render_form_to_add();
+}
+
+require('../include/admin-footer.php');
+
+?&gt;
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicapibuildrequestsphp"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/public/api/build-requests.php (0 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/api/build-requests.php                                (rev 0)
+++ trunk/Websites/perf.webkit.org/public/api/build-requests.php        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -0,0 +1,56 @@
</span><ins>+&lt;?php
+
+require_once('../include/json-header.php');
+require_once('../include/build-requests-fetcher.php');
+
+function main($path, $post_data) {
+    if (count($path) &lt; 1 || count($path) &gt; 2)
+        exit_with_error('InvalidRequest');
+
+    $db = new Database;
+    if (!$db-&gt;connect())
+        exit_with_error('DatabaseConnectionFailure');
+
+    $triggerable_query = array('name' =&gt; array_get($path, 0));
+    $triggerable = $db-&gt;select_first_row('build_triggerables', 'triggerable', $triggerable_query);
+    if (!$triggerable)
+        exit_with_error('TriggerableNotFound', $triggerable_query);
+
+    $report = $post_data ? json_decode($post_data, true) : array();
+    $updates = array_get($report, 'buildRequestUpdates');
+    if ($updates) {
+        verify_slave($db, $report);
+
+        $db-&gt;begin_transaction();
+        foreach ($updates as $id =&gt; $info) {
+            $id = intval($id);
+            $status = $info['status'];
+            $url = array_get($info, 'url');
+            if ($status == 'failedIfNotCompleted') {
+                $db-&gt;query_and_get_affected_rows('UPDATE build_requests SET (request_status, request_url) = ($1, $2)
+                    WHERE request_id = $3 AND request_status != $1', array('failed', $url, $id));
+            } else {
+                if (!in_array($status, array('pending', 'scheduled', 'running', 'failed', 'completed'))) {
+                    $db-&gt;rollback_transaction();
+                    exit_with_error('UnknownBuildRequestStatus', array('buildRequest' =&gt; $id, 'status' =&gt; $status));
+                }
+                $db-&gt;update_row('build_requests', 'request', array('id' =&gt; $id), array('status' =&gt; $status, 'url' =&gt; $url));
+            } 
+        }
+        $db-&gt;commit_transaction();
+    }
+
+    $requests_fetcher = new BuildRequestsFetcher($db);
+    $requests_fetcher-&gt;fetch_incomplete_requests_for_triggerable($triggerable['triggerable_id']);
+
+    exit_with_success(array(
+        'buildRequests' =&gt; $requests_fetcher-&gt;results_with_resolved_ids(),
+        'rootSets' =&gt; $requests_fetcher-&gt;root_sets(),
+        'updates' =&gt; $updates,
+    ));
+}
+
+main(array_key_exists('PATH_INFO', $_SERVER) ? explode('/', trim($_SERVER['PATH_INFO'], '/')) : array(),
+    file_get_contents(&quot;php://input&quot;));
+
+?&gt;
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicapirunsphp"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/api/runs.php (178233 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/api/runs.php        2015-01-10 03:53:54 UTC (rev 178233)
+++ trunk/Websites/perf.webkit.org/public/api/runs.php        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -39,7 +39,7 @@
</span><span class="cx">             FROM builds
</span><span class="cx">                 LEFT OUTER JOIN build_commits ON commit_build = build_id
</span><span class="cx">                 LEFT OUTER JOIN commits ON build_commit = commit_id, test_runs
</span><del>-            WHERE run_build = build_id AND run_config = $1
</del><ins>+            WHERE run_build = build_id AND run_config = $1 AND NOT EXISTS (SELECT * FROM build_requests WHERE request_build = build_id)
</ins><span class="cx">             GROUP BY build_id, run_id', array($config['config_id']));
</span><span class="cx"> 
</span><span class="cx">     $formatted_runs = array();
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicapitestgroupsphp"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/api/test-groups.php (178233 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/api/test-groups.php        2015-01-10 03:53:54 UTC (rev 178233)
+++ trunk/Websites/perf.webkit.org/public/api/test-groups.php        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -1,6 +1,7 @@
</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/build-requests-fetcher.php');
</ins><span class="cx"> 
</span><span class="cx"> function main($path) {
</span><span class="cx">     $db = new Database;
</span><span class="lines">@@ -10,24 +11,26 @@
</span><span class="cx">     if (count($path) &gt; 1)
</span><span class="cx">         exit_with_error('InvalidRequest');
</span><span class="cx"> 
</span><ins>+    $build_requests_fetcher = new BuildRequestsFetcher($db);
+
</ins><span class="cx">     if (count($path) &gt; 0 &amp;&amp; $path[0]) {
</span><span class="cx">         $group_id = intval($path[0]);
</span><span class="cx">         $group = $db-&gt;select_first_row('analysis_test_groups', 'testgroup', array('id' =&gt; $group_id));
</span><span class="cx">         if (!$group)
</span><span class="cx">             exit_with_error('GroupNotFound', array('id' =&gt; $group_id));
</span><span class="cx">         $test_groups = array($group);
</span><del>-        $build_requests = fetch_build_requests_for_group($db, $group_id);
</del><ins>+        $build_requests_fetcher-&gt;fetch_for_group($group_id);
</ins><span class="cx">     } else {
</span><span class="cx">         $task_id = array_get($_GET, 'task');
</span><span class="cx">         if (!$task_id)
</span><span class="cx">             exit_with_error('TaskIdNotSpecified');
</span><span class="cx"> 
</span><del>-        $test_groups = fetch_test_groups_for_task($db, $task_id);
</del><ins>+        $test_groups = $db-&gt;select_rows('analysis_test_groups', 'testgroup', array('task' =&gt; $task_id));
</ins><span class="cx">         if (!is_array($test_groups))
</span><span class="cx">             exit_with_error('FailedToFetchTestGroups');
</span><del>-        $build_requests = fetch_build_requests_for_task($db, $task_id);
</del><ins>+        $build_requests_fetcher-&gt;fetch_for_task($task_id);
</ins><span class="cx">     }
</span><del>-    if (!is_array($build_requests))
</del><ins>+    if (!$build_requests_fetcher-&gt;has_results())
</ins><span class="cx">         exit_with_error('FailedToFetchBuildRequests');
</span><span class="cx"> 
</span><span class="cx">     $test_groups = array_map(&quot;format_test_group&quot;, $test_groups);
</span><span class="lines">@@ -35,29 +38,13 @@
</span><span class="cx">     foreach ($test_groups as &amp;$group)
</span><span class="cx">         $group_by_id[$group['id']] = &amp;$group;
</span><span class="cx"> 
</span><del>-    $build_requests = array_map(&quot;format_build_request&quot;, $build_requests);
</del><ins>+    $build_requests = $build_requests_fetcher-&gt;results();
</ins><span class="cx">     foreach ($build_requests as $request)
</span><span class="cx">         array_push($group_by_id[$request['testGroup']]['buildRequests'], $request['id']);
</span><span class="cx"> 
</span><span class="cx">     exit_with_success(array('testGroups' =&gt; $test_groups, 'buildRequests' =&gt; $build_requests));
</span><span class="cx"> }
</span><span class="cx"> 
</span><del>-function fetch_test_groups_for_task($db, $task_id) {
-    return $db-&gt;select_rows('analysis_test_groups', 'testgroup', array('task' =&gt; $task_id));
-}
-
-function fetch_build_requests_for_task($db, $task_id) {
-    return $db-&gt;query_and_fetch_all('SELECT * FROM build_requests, builds
-        WHERE request_build = build_id
-            AND request_group IN (SELECT testgroup_id FROM analysis_test_groups WHERE testgroup_task = $1)
-        ORDER BY request_group, request_order', array($task_id));
-}
-
-function fetch_build_requests_for_group($db, $test_group_id) {
-    return $db-&gt;query_and_fetch_all('SELECT * FROM build_requests, builds
-        WHERE request_build = build_id AND request_group = $1 ORDER BY request_order', array($test_group_id));
-}
-
</del><span class="cx"> function format_test_group($group_row) {
</span><span class="cx">     return array(
</span><span class="cx">         'id' =&gt; $group_row['testgroup_id'],
</span><span class="lines">@@ -69,19 +56,6 @@
</span><span class="cx">     );
</span><span class="cx"> }
</span><span class="cx"> 
</span><del>-function format_build_request($request_row) {
-    return array(
-        'id' =&gt; $request_row['request_id'],
-        'testGroup' =&gt; $request_row['request_group'],
-        'order' =&gt; $request_row['request_order'],
-        'rootSet' =&gt; $request_row['request_root_set'],
-        'build' =&gt; $request_row['request_build'],
-        'builder' =&gt; $request_row['build_builder'],
-        'buildNumber' =&gt; $request_row['build_number'],
-        'buildTime' =&gt; $request_row['build_time'] ? strtotime($request_row['build_time']) * 1000 : NULL,
-    );
-}
-
</del><span class="cx"> main(array_key_exists('PATH_INFO', $_SERVER) ? explode('/', trim($_SERVER['PATH_INFO'], '/')) : array());
</span><span class="cx"> 
</span><span class="cx"> ?&gt;
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicapitriggerablesphp"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/public/api/triggerables.php (0 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/api/triggerables.php                                (rev 0)
+++ trunk/Websites/perf.webkit.org/public/api/triggerables.php        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -0,0 +1,44 @@
</span><ins>+&lt;?php
+
+require('../include/json-header.php');
+
+function main($path) {
+    if (count($path) &gt; 1)
+        exit_with_error('InvalidRequest');
+
+    $db = new Database;
+    if (!$db-&gt;connect())
+        exit_with_error('DatabaseConnectionFailure');
+
+    $task_id = array_get($_GET, 'task');
+    $query = array();
+    if ($task_id) {
+        $triggerable = find_triggerable_for_task($db, $task_id);
+        if (!$triggerable)
+            exit_with_error('TriggerableNotFoundForTask', array('task' =&gt; $task_id));
+        $query['id'] = $triggerable['id'];
+    }
+
+    $id_to_triggerable = array();
+    foreach ($db-&gt;select_rows('build_triggerables', 'triggerable', $query) as $row) {
+        $id = $row['triggerable_id'];
+        $repositories = array();
+        $id_to_triggerable[$id] = array('id' =&gt; $id, 'name' =&gt; $row['triggerable_name'], 'acceptedRepositories' =&gt; &amp;$repositories);
+    }
+
+    $repository_id_to_name = array();
+    foreach ($db-&gt;select_rows('repositories', 'repository', array(), 'name') as $row)
+        $repository_id_to_name[$row['repository_id']] = $row['repository_name'];
+
+    foreach ($db-&gt;select_rows('triggerable_repositories', 'trigrepo', array()) as $row) {
+        $triggerable = $id_to_triggerable[$row['trigrepo_triggerable']];
+        if ($triggerable)
+            array_push($triggerable['acceptedRepositories'], $repository_id_to_name[$row['trigrepo_repository']]);
+    }
+
+    exit_with_success(array('triggerables' =&gt; array_values($id_to_triggerable)));
+}
+
+main(array_key_exists('PATH_INFO', $_SERVER) ? explode('/', trim($_SERVER['PATH_INFO'], '/')) : array());
+
+?&gt;
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicincludeadminheaderphp"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/include/admin-header.php (178233 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/include/admin-header.php        2015-01-10 03:53:54 UTC (rev 178233)
+++ trunk/Websites/perf.webkit.org/public/include/admin-header.php        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -19,6 +19,7 @@
</span><span class="cx">     &lt;li&gt;&lt;a href=&quot;/admin/aggregators&quot;&gt;Aggregators&lt;/a&gt;&lt;/li&gt;
</span><span class="cx">     &lt;li&gt;&lt;a href=&quot;/admin/builders&quot;&gt;Builders&lt;/a&gt;&lt;/li&gt;
</span><span class="cx">     &lt;li&gt;&lt;a href=&quot;/admin/build-slaves&quot;&gt;Slaves&lt;/a&gt;&lt;/li&gt;
</span><ins>+    &lt;li&gt;&lt;a href=&quot;/admin/triggerables&quot;&gt;Triggerables&lt;/a&gt;&lt;/li&gt;
</ins><span class="cx">     &lt;li&gt;&lt;a href=&quot;/admin/repositories&quot;&gt;Repositories&lt;/a&gt;&lt;/li&gt;
</span><span class="cx">     &lt;li&gt;&lt;a href=&quot;/admin/bug-trackers&quot;&gt;Bug Trackers&lt;/a&gt;&lt;/li&gt;
</span><span class="cx"> &lt;/ul&gt;
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicincludebuildrequestsfetcherphp"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/public/include/build-requests-fetcher.php (0 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/include/build-requests-fetcher.php                                (rev 0)
+++ trunk/Websites/perf.webkit.org/public/include/build-requests-fetcher.php        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -0,0 +1,89 @@
</span><ins>+&lt;?php
+
+require_once('test-path-resolver.php');
+
+class BuildRequestsFetcher {
+    function __construct($db) {
+        $this-&gt;db = $db;
+        $this-&gt;rows = null;
+        $this-&gt;root_sets_by_id = array();
+    }
+
+    function fetch_for_task($task_id) {
+        $this-&gt;rows = $this-&gt;db-&gt;query_and_fetch_all('SELECT *
+            FROM build_requests LEFT OUTER JOIN builds ON request_build = build_id, analysis_test_groups
+            WHERE request_group = testgroup_id AND testgroup_task = $1
+            ORDER BY request_group, request_order', array($task_id));
+    }
+
+    function fetch_for_group($test_group_id) {
+        $this-&gt;rows = $this-&gt;db-&gt;query_and_fetch_all('SELECT *
+            FROM build_requests LEFT OUTER JOIN builds ON request_build = build_id
+            WHERE request_group = $1 ORDER BY request_order', array($test_group_id));
+    }
+
+    function fetch_incomplete_requests_for_triggerable($triggerable_id) {
+        $this-&gt;rows = $this-&gt;db-&gt;query_and_fetch_all('SELECT * FROM build_requests
+            WHERE request_triggerable = $1 AND request_status != \'completed\'
+            ORDER BY request_created_at, request_group, request_order', array($triggerable_id));
+    }
+
+    function has_results() { return is_array($this-&gt;rows); }
+    function results() { return $this-&gt;results_internal(false); }
+    function results_with_resolved_ids() { return $this-&gt;results_internal(true); }
+
+    private function results_internal($resolve_ids) {
+        if (!$this-&gt;rows)
+            return array();
+
+        $id_to_platform_name = array();
+        if ($resolve_ids) {
+            foreach ($this-&gt;db-&gt;select_rows('platforms', 'platform', array()) as $platform)
+                $id_to_platform_name[$platform['platform_id']] = $platform['platform_name'];
+        }
+        $test_path_resolver = new TestPathResolver($this-&gt;db);
+
+        $requests = array();
+        foreach ($this-&gt;rows as $row) {
+            $test_id = $row['request_test'];
+            $platform_id = $row['request_platform'];
+            $root_set_id = $row['request_root_set'];
+
+            if (!array_key_exists($root_set_id, $this-&gt;root_sets_by_id))
+                $this-&gt;root_sets_by_id[$root_set_id] = $this-&gt;fetch_roots_for_set($root_set_id);
+
+            array_push($requests, array(
+                'id' =&gt; $row['request_id'],
+                'triggerable' =&gt; $row['request_triggerable'],
+                'test' =&gt; $resolve_ids ? $test_path_resolver-&gt;path_for_test($test_id) : $test_id,
+                'platform' =&gt; $resolve_ids ? $id_to_platform_name[$platform_id] : $platform_id,
+                'testGroup' =&gt; $row['request_group'],
+                'order' =&gt; $row['request_order'],
+                'rootSet' =&gt; $root_set_id,
+                'status' =&gt; $row['request_status'],
+                'url' =&gt; $row['request_url'],
+                'build' =&gt; $row['request_build'],
+                'createdAt' =&gt; $row['request_created_at'] ? strtotime($row['request_created_at']) * 1000 : NULL,
+            ));
+        }
+        return $requests;
+    }
+
+    function root_sets() {
+        return $this-&gt;root_sets_by_id;
+    }
+
+    private function fetch_roots_for_set($root_set_id) {
+        $root_rows = $this-&gt;db-&gt;query_and_fetch_all('SELECT *
+            FROM roots, commits LEFT OUTER JOIN repositories ON commit_repository = repository_id
+            WHERE root_commit = commit_id AND root_set = $1', array($root_set_id));
+
+        $roots = array();
+        foreach ($root_rows as $row)
+            $roots[$row['repository_name']] = $row['commit_revision'];
+
+        return $roots;
+    }
+}
+
+?&gt;
</ins><span class="cx">\ No newline at end of file
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicincludedbphp"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/include/db.php (178233 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/include/db.php        2015-01-10 03:53:54 UTC (rev 178233)
+++ trunk/Websites/perf.webkit.org/public/include/db.php        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -64,7 +64,7 @@
</span><span class="cx">     }
</span><span class="cx"> 
</span><span class="cx">     private function prefixed_column_names($columns, $prefix = NULL) {
</span><del>-        if (!$prefix)
</del><ins>+        if (!$prefix || !$columns)
</ins><span class="cx">             return join(', ', $columns);
</span><span class="cx">         return $prefix . '_' . join(', ' . $prefix . '_', $columns);
</span><span class="cx">     }
</span><span class="lines">@@ -96,24 +96,29 @@
</span><span class="cx">         $column_names = $this-&gt;prefixed_column_names($column_names, $prefix);
</span><span class="cx">         $placeholders = join(', ', $placeholders);
</span><span class="cx"> 
</span><ins>+        $value_query = $column_names ? &quot;($column_names) VALUES ($placeholders)&quot; : ' VALUES (default)';
</ins><span class="cx">         if ($returning) {
</span><span class="cx">             $returning_column_name = $this-&gt;prefixed_name($returning, $prefix);
</span><del>-            $rows = $this-&gt;query_and_fetch_all(&quot;INSERT INTO $table ($column_names) VALUES ($placeholders) RETURNING $returning_column_name&quot;, $values);
</del><ins>+            $rows = $this-&gt;query_and_fetch_all(&quot;INSERT INTO $table $value_query RETURNING $returning_column_name&quot;, $values);
</ins><span class="cx">             return $rows ? $rows[0][$returning_column_name] : NULL;
</span><span class="cx">         }
</span><span class="cx"> 
</span><del>-        return $this-&gt;query_and_get_affected_rows(&quot;INSERT INTO $table ($column_names) VALUES ($placeholders)&quot;, $values) == 1;
</del><ins>+        return $this-&gt;query_and_get_affected_rows(&quot;INSERT INTO $table $value_query&quot;, $values) == 1;
</ins><span class="cx">     }
</span><span class="cx"> 
</span><span class="cx">     function select_or_insert_row($table, $prefix, $select_params, $insert_params = NULL, $returning = 'id') {
</span><del>-        return $this-&gt;_select_update_or_insert_row($table, $prefix, $select_params, $insert_params, $returning, FALSE);
</del><ins>+        return $this-&gt;_select_update_or_insert_row($table, $prefix, $select_params, $insert_params, $returning, FALSE, TRUE);
</ins><span class="cx">     }
</span><span class="cx"> 
</span><span class="cx">     function update_or_insert_row($table, $prefix, $select_params, $insert_params = NULL, $returning = 'id') {
</span><del>-        return $this-&gt;_select_update_or_insert_row($table, $prefix, $select_params, $insert_params, $returning, TRUE);
</del><ins>+        return $this-&gt;_select_update_or_insert_row($table, $prefix, $select_params, $insert_params, $returning, TRUE, TRUE);
</ins><span class="cx">     }
</span><span class="cx"> 
</span><del>-    private function _select_update_or_insert_row($table, $prefix, $select_params, $insert_params, $returning, $should_update) {
</del><ins>+    function update_row($table, $prefix, $select_params, $update_params, $returning = 'id') {
+        return $this-&gt;_select_update_or_insert_row($table, $prefix, $select_params, $update_params, $returning, TRUE, FALSE);
+    }
+
+    private function _select_update_or_insert_row($table, $prefix, $select_params, $insert_params, $returning, $should_update, $should_insert) {
</ins><span class="cx">         $values = array();
</span><span class="cx"> 
</span><span class="cx">         $select_placeholders = array();
</span><span class="lines">@@ -141,7 +146,7 @@
</span><span class="cx">             $rows = $this-&gt;query_and_fetch_all(&quot;UPDATE $table SET ($insert_column_names) = ($insert_placeholders)
</span><span class="cx">                 WHERE ($select_column_names) = ($select_placeholders) RETURNING $returning_column_name&quot;, $values);
</span><span class="cx">         }
</span><del>-        if (!$rows) {
</del><ins>+        if (!$rows &amp;&amp; $should_insert) {
</ins><span class="cx">             $rows = $this-&gt;query_and_fetch_all(&quot;INSERT INTO $table ($insert_column_names) SELECT $insert_placeholders
</span><span class="cx">                 WHERE NOT EXISTS ($query) RETURNING $returning_column_name&quot;, $values);            
</span><span class="cx">         }
</span><span class="lines">@@ -171,6 +176,8 @@
</span><span class="cx">         $values = array();
</span><span class="cx">         $column_names = $this-&gt;prefixed_column_names($this-&gt;prepare_params($params, $placeholders, $values), $prefix);
</span><span class="cx">         $placeholders = join(', ', $placeholders);
</span><ins>+        if (!$column_names &amp;&amp; !$placeholders)
+            $column_names = $placeholders = '1';
</ins><span class="cx">         $query = &quot;SELECT * FROM $table WHERE ($column_names) = ($placeholders)&quot;;
</span><span class="cx">         if ($order_by) {
</span><span class="cx">             assert(ctype_alnum_underscore($order_by));
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicincludejsonheaderphp"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/include/json-header.php (178233 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/include/json-header.php        2015-01-10 03:53:54 UTC (rev 178233)
+++ trunk/Websites/perf.webkit.org/public/include/json-header.php        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -1,6 +1,7 @@
</span><span class="cx"> &lt;?php
</span><span class="cx"> 
</span><span class="cx"> require_once('db.php');
</span><ins>+require_once('test-path-resolver.php');
</ins><span class="cx"> 
</span><span class="cx"> header('Content-type: application/json');
</span><span class="cx"> 
</span><span class="lines">@@ -122,4 +123,36 @@
</span><span class="cx">         exit_with_error('SlaveNotFound', array('name' =&gt; $slave_info['name']));
</span><span class="cx"> }
</span><span class="cx"> 
</span><ins>+function find_triggerable_for_task($db, $task_id) {
+    $task_id = intval($task_id);
+
+    $test_rows = $db-&gt;query_and_fetch_all('SELECT metric_test AS &quot;test&quot;, task_platform as &quot;platform&quot;
+        FROM analysis_tasks JOIN test_metrics ON task_metric = metric_id WHERE task_id = $1', array($task_id));
+    if (!$test_rows)
+        return NULL;
+    $target_test_id = $test_rows[0]['test'];
+    $platform_id = $test_rows[0]['platform'];
+
+    $path_resolver = new TestPathResolver($db);
+    $test_ids = $path_resolver-&gt;ancestors_for_test($target_test_id);
+
+    $results = $db-&gt;query_and_fetch_all('SELECT trigconfig_triggerable AS &quot;triggerable&quot;, trigconfig_test AS &quot;test&quot;
+        FROM triggerable_configurations WHERE trigconfig_platform = $1 AND trigconfig_test = ANY($2)',
+        array($platform_id, '{' . implode(', ', $test_ids) . '}'));
+    if (!$results)
+        return NULL;
+
+    $test_to_triggerable = array();
+    foreach ($results as $row)
+        $test_to_triggerable[$row['test']] = $row['triggerable'];
+
+    foreach ($test_ids as $test_id) {
+        $triggerable = array_get($test_to_triggerable, $test_id);
+        if ($triggerable)
+            return array('id' =&gt; $triggerable, 'test' =&gt; $test_id, 'platform' =&gt; $platform_id);
+    }
+
+    return NULL;
+}
+
</ins><span class="cx"> ?&gt;
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicincludereportprocessorphp"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/include/report-processor.php (178233 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/include/report-processor.php        2015-01-10 03:53:54 UTC (rev 178233)
+++ trunk/Websites/perf.webkit.org/public/include/report-processor.php        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -88,7 +88,9 @@
</span><span class="cx">         if (!$platform_id)
</span><span class="cx">             $this-&gt;exit_with_error('FailedToInsertPlatform', array('name' =&gt; $report['platform']));
</span><span class="cx"> 
</span><del>-        $build_id = $this-&gt;resolve_build_id($build_data, array_get($report, 'revisions', array()));
</del><ins>+        // FIXME: Deprecate and unsupport &quot;jobId&quot;.
+        $build_id = $this-&gt;resolve_build_id($build_data, array_get($report, 'revisions', array()),
+            array_get($report, 'jobId') or array_get($report, 'buildRequest'));
</ins><span class="cx"> 
</span><span class="cx">         $this-&gt;runs-&gt;commit($platform_id, $build_id);
</span><span class="cx">     }
</span><span class="lines">@@ -111,7 +113,7 @@
</span><span class="cx">             $this-&gt;exit_with_error('FailedToStoreRunReport');
</span><span class="cx">     }
</span><span class="cx"> 
</span><del>-    private function resolve_build_id($build_data, $revisions) {
</del><ins>+    private function resolve_build_id($build_data, $revisions, $build_request_id) {
</ins><span class="cx">         // FIXME: This code has a race condition. See &lt;rdar://problem/15876303&gt;.
</span><span class="cx">         $results = $this-&gt;db-&gt;query_and_fetch_all(&quot;SELECT build_id, build_slave FROM builds
</span><span class="cx">             WHERE build_builder = $1 AND build_number = $2 AND build_time &lt;= $3 AND build_time + interval '1 day' &gt; $3&quot;,
</span><span class="lines">@@ -126,6 +128,13 @@
</span><span class="cx">         if (!$build_id)
</span><span class="cx">             $this-&gt;exit_with_error('FailedToInsertBuild', $build_data);
</span><span class="cx"> 
</span><ins>+        if ($build_request_id) {
+            if ($db-&gt;update_row('build_requests', 'request', array('id' =&gt; $build_request_id), array('status' =&gt; 'completed', 'build' =&gt; $build_id))
+                != $build_request_id)
+                $this-&gt;exit_with_error('FailedToUpdateBuildRequest', array('buildRequest' =&gt; $build_request_id, 'build' =&gt; $build_id));
+        }
+
+
</ins><span class="cx">         foreach ($revisions as $repository_name =&gt; $revision_data) {
</span><span class="cx">             $repository_id = $this-&gt;db-&gt;select_or_insert_row('repositories', 'repository', array('name' =&gt; $repository_name));
</span><span class="cx">             if (!$repository_id)
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicincludetestnameresolverphp"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/include/test-name-resolver.php (178233 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/include/test-name-resolver.php        2015-01-10 03:53:54 UTC (rev 178233)
+++ trunk/Websites/perf.webkit.org/public/include/test-name-resolver.php        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -76,7 +76,7 @@
</span><span class="cx">                 array_set_default($this-&gt;test_id_to_child_metrics, $parent_id, array());
</span><span class="cx">                 $parent_metrics = &amp;$this-&gt;test_id_to_child_metrics[$parent_id];
</span><span class="cx">                 if (!in_array($metric_row['metric_name'], $parent_metrics))
</span><del>-                    array_push($parent_metrics, $metric_row['metric_name']);
</del><ins>+                    array_push($parent_metrics, $metric_row);
</ins><span class="cx">             }
</span><span class="cx">         }
</span><span class="cx">         return $test_to_metrics;
</span><span class="lines">@@ -129,6 +129,18 @@
</span><span class="cx">         $metric_configurations = array_get($this-&gt;metric_to_configurations, $metric_id, array());
</span><span class="cx">         return array_get($metric_configurations, $platform_id);
</span><span class="cx">     }
</span><ins>+
+    function test_exists_on_platform($test_id, $platform_id) {
+        foreach ($this-&gt;metrics_for_test_id($test_id) as $metric) {
+            if ($this-&gt;configurations_for_metric_and_platform($metric['metric_id'], $platform_id))
+                return TRUE;
+        }
+        foreach ($this-&gt;child_metrics_for_test_id($test_id) as $metric) {
+            if ($this-&gt;configurations_for_metric_and_platform($metric['metric_id'], $platform_id))
+                return TRUE;
+        }
+        return FALSE;
+    }
</ins><span class="cx"> }
</span><span class="cx"> 
</span><span class="cx"> ?&gt;
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicincludetestpathresolverphp"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/public/include/test-path-resolver.php (0 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/include/test-path-resolver.php                                (rev 0)
+++ trunk/Websites/perf.webkit.org/public/include/test-path-resolver.php        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -0,0 +1,39 @@
</span><ins>+&lt;?php
+
+class TestPathResolver {
+    function __construct($db) {
+        $this-&gt;db = $db;
+        $this-&gt;id_to_test_map = NULL;
+    }
+
+    function ancestors_for_test($test_id) {
+        $id_to_test = $this-&gt;ensure_id_to_test_map();
+        $ancestors = array();
+        for (; $test_id; $test_id = $id_to_test[$test_id]['test_parent'])
+            array_push($ancestors, $test_id);
+        return $ancestors;
+    }
+
+    function path_for_test($test_id) {
+        $id_to_test = $this-&gt;ensure_id_to_test_map();
+        $path = array();
+        while ($test_id) {
+            $test = $id_to_test[$test_id];
+            $test_id = $test['test_parent'];
+            array_unshift($path, $test['test_name']);
+        }
+        return $path;
+    }
+
+    private function ensure_id_to_test_map() {
+        if ($this-&gt;id_to_test_map == NULL) {
+            $map = array();
+            foreach ($this-&gt;db-&gt;fetch_table('tests') as $row)
+                $map[$row['test_id']] = $row;
+            $this-&gt;id_to_test_map = $map;
+        }
+        return $this-&gt;id_to_test_map;
+    }
+}
+
+?&gt;
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicprivilegedapicreatetestgroupphp"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/public/privileged-api/create-test-group.php (0 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/privileged-api/create-test-group.php                                (rev 0)
+++ trunk/Websites/perf.webkit.org/public/privileged-api/create-test-group.php        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -0,0 +1,95 @@
</span><ins>+&lt;?php
+
+require_once('../include/json-header.php');
+
+function main() {
+    $data = ensure_privileged_api_data_and_token();
+
+    $author = remote_user_name();
+
+    $task_id = array_get($data, 'task');
+    $name = array_get($data, 'name');
+    $root_sets = array_get($data, 'rootSets');
+    $repetition_count = intval(array_get($data, 'repetitionCount', 1));
+
+    if (!$name)
+        exit_with_error('MissingName');
+    if (!$root_sets)
+        exit_with_error('MissingRootSets');
+    if ($repetition_count &lt; 1)
+        exit_with_error('InvalidRepetitionCount', array('repetitionCount' =&gt; $repetition_count));
+
+    $db = connect();
+    $task = $db-&gt;select_first_row('analysis_tasks', 'task', array('id' =&gt; $task_id));
+    if (!$task)
+        exit_with_error('InvalidTask', array('task' =&gt; $task_id));
+    $triggerable = find_triggerable_for_task($db, $task_id);
+    if (!$triggerable)
+        exit_with_error('TriggerableNotFoundForTask', array('task' =&gt; $task_id));
+
+    $commit_sets = commit_sets_from_root_sets($db, $root_sets);
+
+    $db-&gt;begin_transaction();
+
+    $root_set_id_list = array();
+    foreach ($commit_sets as $commit_list) {
+        $root_set_id = $db-&gt;insert_row('root_sets', 'rootset', array());
+        foreach ($commit_list as $commit)
+            $db-&gt;insert_row('roots', 'root', array('set' =&gt; $root_set_id, 'commit' =&gt; $commit), 'commit');
+        array_push($root_set_id_list, $root_set_id);
+    }
+
+    $group_id = $db-&gt;insert_row('analysis_test_groups', 'testgroup',
+        array('task' =&gt; $task['task_id'], 'name' =&gt; $name, 'author' =&gt; $author));
+
+    $order = 0;
+    for ($i = 0; $i &lt; $repetition_count; $i++) {
+        foreach ($root_set_id_list as $root_set_id) {
+            $db-&gt;insert_row('build_requests', 'request', array(
+                'triggerable' =&gt; $triggerable['id'],
+                'platform' =&gt; $triggerable['platform'],
+                'test' =&gt; $triggerable['test'],
+                'group' =&gt; $group_id,
+                'order' =&gt; $order,
+                'root_set' =&gt; $root_set_id));
+            $order++;
+        }
+    }
+
+    $db-&gt;commit_transaction();
+
+    exit_with_success(array('testGroupId' =&gt; $group_id));
+}
+
+function commit_sets_from_root_sets($db, $root_sets) {
+    $repository_name_to_id = array();
+    foreach ($db-&gt;fetch_table('repositories') as $row)
+        $repository_name_to_id[$row['repository_name']] = $row['repository_id'];
+
+    $commit_sets = array();
+    foreach ($root_sets as $repository_name =&gt; $revisions) {
+        $repository_id = array_get($repository_name_to_id, $repository_name);
+        if (!$repository_id)
+            exit_with_error('RepositoryNotFound', array('name' =&gt; $repository_name));
+
+        foreach ($revisions as $i =&gt; $revision) {
+            $commit = $db-&gt;select_first_row('commits', 'commit', array('repository' =&gt; $repository_id, 'revision' =&gt; $revision));
+            if (!$commit)
+                exit_with_error('RevisionNotFound', array('repository' =&gt; $repository_name, 'revision' =&gt; $revision));
+            array_set_default($commit_sets, $i, array());
+            array_push($commit_sets[$i], $commit['commit_id']);
+        }
+    }
+
+    $commit_count_per_set = count($commit_sets[0]);
+    foreach ($commit_sets as $commits) {
+        if ($commit_count_per_set != count($commits))
+            exit_with_error('InvalidRootSets', array('rootSets' =&gt; $root_sets));
+    }
+
+    return $commit_sets;
+}
+
+main();
+
+?&gt;
</ins></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicv2analysisjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/v2/analysis.js (178233 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/v2/analysis.js        2015-01-10 03:53:54 UTC (rev 178233)
+++ trunk/Websites/perf.webkit.org/public/v2/analysis.js        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -6,9 +6,16 @@
</span><span class="cx">     startRun: DS.attr('number'),
</span><span class="cx">     endRun: DS.attr('number'),
</span><span class="cx">     bugs: DS.hasMany('bugs'),
</span><del>-    testGroups: function () {
</del><ins>+    testGroups: function (key, value, oldValue) {
</ins><span class="cx">         return this.store.find('testGroup', {task: this.get('id')});
</span><span class="cx">     }.property(),
</span><ins>+    triggerable: function () {
+        return this.store.find('triggerable', {task: this.get('id')}).then(function (triggerables) {
+            return triggerables.objectAt(0);
+        }, function () {
+            return null;
+        });
+    }.property(),
</ins><span class="cx">     label: function () {
</span><span class="cx">         var label = this.get('name');
</span><span class="cx">         var bugs = this.get('bugs').map(function (bug) { return bug.get('label'); }).join(' / ');
</span><span class="lines">@@ -63,8 +70,32 @@
</span><span class="cx">     author: DS.attr('string'),
</span><span class="cx">     createdAt: DS.attr('date'),
</span><span class="cx">     buildRequests: DS.hasMany('buildRequests'),
</span><ins>+    rootSets: function ()
+    {
+        var rootSetIds = [];
+        this.get('buildRequests').forEach(function (request) {
+            var rootSet = request.get('rootSet');
+            if (!rootSetIds.contains(rootSet))
+                rootSetIds.push(rootSet);
+        });
+        return rootSetIds;
+    }.property('buildRequests'),
</ins><span class="cx"> });
</span><span class="cx"> 
</span><ins>+App.TestGroup.create = function (analysisTask, name, rootSets, repetitionCount)
+{
+    var param = {
+        task: analysisTask.get('id'),
+        name: name,
+        rootSets: rootSets,
+        repetitionCount: repetitionCount,
+    };
+    return PrivilegedAPI.sendRequest('create-test-group', param).then(function (data) {
+        analysisTask.set('testGroups'); // Refetch test groups.
+        return analysisTask.store.find('testGroup', data['testGroupId']);
+    });
+}
+
</ins><span class="cx"> App.TestGroupAdapter = DS.RESTAdapter.extend({
</span><span class="cx">     buildURL: function (type, id)
</span><span class="cx">     {
</span><span class="lines">@@ -72,7 +103,18 @@
</span><span class="cx">     },
</span><span class="cx"> });
</span><span class="cx"> 
</span><del>-App.AnalysisTaskSerializer = App.TestGroupSerializer = DS.RESTSerializer.extend({
</del><ins>+App.Triggerable = App.NameLabelModel.extend({
+    acceptedRepositories: DS.hasMany('repositories'),
+});
+
+App.TriggerableAdapter = DS.RESTAdapter.extend({
+    buildURL: function (type, id)
+    {
+        return '../api/triggerables/' + (id ? id : '');
+    },
+});
+
+App.AnalysisTaskSerializer = App.TestGroupSerializer = App.TriggerableSerializer = DS.RESTSerializer.extend({
</ins><span class="cx">     normalizePayload: function (payload)
</span><span class="cx">     {
</span><span class="cx">         delete payload['status'];
</span><span class="lines">@@ -81,11 +123,32 @@
</span><span class="cx"> });
</span><span class="cx"> 
</span><span class="cx"> App.BuildRequest = App.Model.extend({
</span><del>-    group: DS.belongsTo('testGroup'),
</del><ins>+    testGroup: DS.belongsTo('testGroup'),
</ins><span class="cx">     order: DS.attr('number'),
</span><ins>+    orderLabel: function ()
+    {
+        return this.get('order') + 1;
+    }.property('order'),
</ins><span class="cx">     rootSet: DS.attr('number'),
</span><ins>+    config: function ()
+    {
+        var rootSets = this.get('testGroup').get('rootSets');
+        var index = rootSets.indexOf(this.get('rootSet'));
+        return String.fromCharCode('A'.charCodeAt(0) + index);
+    }.property('testGroup', 'testGroup.rootSets'),
+    status: DS.attr('string'),
+    statusLabel: function ()
+    {
+        switch (this.get('status')) {
+        case 'pending':
+            return 'Waiting to be scheduled';
+        case 'scheduled':
+            return 'Scheduled';
+        case 'running':
+            return 'Running';
+        case 'completed':
+            return 'Finished';
+        }
+    }.property('status'),
</ins><span class="cx">     build: DS.attr('number'),
</span><del>-    buildNumber: DS.attr('number'),
-    buildBuilder: DS.belongsTo('builder'),
-    buildTime: DS.attr('date'),
</del><span class="cx"> });
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicv2appjs"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/v2/app.js (178233 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/v2/app.js        2015-01-10 03:53:54 UTC (rev 178233)
+++ trunk/Websites/perf.webkit.org/public/v2/app.js        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -783,9 +783,11 @@
</span><span class="cx">     label: Ember.computed.alias('model.name'),
</span><span class="cx">     platform: Ember.computed.alias('model.platform'),
</span><span class="cx">     metric: Ember.computed.alias('model.metric'),
</span><ins>+    testGroups: Ember.computed.alias('model.testGroups'),
</ins><span class="cx">     testSets: [],
</span><span class="cx">     roots: [],
</span><span class="cx">     bugTrackers: [],
</span><ins>+    possibleRepetitionCounts: [1, 2, 3, 4, 5, 6],
</ins><span class="cx">     _taskUpdated: function ()
</span><span class="cx">     {
</span><span class="cx">         var model = this.get('model');
</span><span class="lines">@@ -878,11 +880,11 @@
</span><span class="cx">         });
</span><span class="cx"> 
</span><span class="cx">     }.observes('testSets.@each.selection'),
</span><del>-    roots: function ()
</del><ins>+    updateRoots: function ()
</ins><span class="cx">     {
</span><span class="cx">         var analysisPoints = this.get('analysisPoints');
</span><span class="cx">         if (!analysisPoints)
</span><del>-            return [];
</del><ins>+            return;
</ins><span class="cx">         var repositoryToRevisions = {};
</span><span class="cx">         analysisPoints.forEach(function (point, pointIndex) {
</span><span class="cx">             var revisions = point.measurement.formattedRevisions();
</span><span class="lines">@@ -897,23 +899,28 @@
</span><span class="cx">             }
</span><span class="cx">         });
</span><span class="cx"> 
</span><del>-        var roots = [];
-        for (var repositoryName in repositoryToRevisions) {
-            var revisions = [{value: ' ', label: 'None'}].concat(repositoryToRevisions[repositoryName]);
-            roots.push(Ember.Object.create({
-                name: repositoryName,
-                sets: [
-                    Ember.Object.create({name: 'A[' + repositoryName + ']',
-                        revisions: revisions,
-                        selection: revisions[1]}),
-                    Ember.Object.create({name: 'B[' + repositoryName + ']',
-                        revisions: revisions,
-                        selection: revisions[revisions.length - 1]}),
-                ],
</del><ins>+        var self = this;
+        this.get('model').get('triggerable').then(function (triggerable) {
+            if (!triggerable)
+                return;
+
+            self.set('roots', triggerable.get('acceptedRepositories').map(function (repository) {
+                var repositoryName = repository.get('id');
+                var revisions = [{value: ' ', label: 'None'}].concat(repositoryToRevisions[repositoryName]);
+                return Ember.Object.create({
+                    name: repositoryName,
+                    sets: [
+                        Ember.Object.create({name: 'A[' + repositoryName + ']',
+                            revisions: revisions,
+                            selection: revisions[1]}),
+                        Ember.Object.create({name: 'B[' + repositoryName + ']',
+                            revisions: revisions,
+                            selection: revisions[revisions.length - 1]}),
+                    ],
+                });
</ins><span class="cx">             }));
</span><del>-        }
-        return roots;
-    }.property('analysisPoints'),
</del><ins>+        });
+    }.observes('analysisPoints'),
</ins><span class="cx">     actions: {
</span><span class="cx">         associateBug: function (bugTracker, bugNumber)
</span><span class="cx">         {
</span><span class="lines">@@ -924,6 +931,16 @@
</span><span class="cx">                 }, function (error) {
</span><span class="cx">                     alert('Failed to associate the bug: ' + error);
</span><span class="cx">                 });
</span><del>-        }
</del><ins>+        },
+        createTestGroup: function (name, repetitionCount)
+        {
+            var roots = {};
+            this.get('roots').map(function (root) {
+                roots[root.get('name')] = root.get('sets').map(function (item) { return item.get('selection').value; });
+            });
+            App.TestGroup.create(this.get('model'), name, roots, repetitionCount).then(function () {
+                
+            });
+        },
</ins><span class="cx">     },
</span><span class="cx"> });
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgpublicv2indexhtml"></a>
<div class="modfile"><h4>Modified: trunk/Websites/perf.webkit.org/public/v2/index.html (178233 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/public/v2/index.html        2015-01-10 03:53:54 UTC (rev 178233)
+++ trunk/Websites/perf.webkit.org/public/v2/index.html        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -485,25 +485,26 @@
</span><span class="cx">                     &lt;/table&gt;
</span><span class="cx">                 &lt;/div&gt;
</span><span class="cx">             &lt;/section&gt;
</span><del>-
</del><span class="cx">             {{#each testGroups}}
</span><span class="cx">                 &lt;section class=&quot;analysis-group&quot;&gt;
</span><span class="cx">                     &lt;table&gt;
</span><span class="cx">                         &lt;caption&gt;{{name}}&lt;/caption&gt;
</span><span class="cx">                         &lt;thead&gt;
</span><span class="cx">                             &lt;tr&gt;
</span><ins>+                                &lt;td&gt;Order&lt;/td&gt;
</ins><span class="cx">                                 &lt;td&gt;Configuration&lt;/td&gt;
</span><ins>+                                &lt;td&gt;Status&lt;/td&gt;
</ins><span class="cx">                                 &lt;td&gt;Build&lt;/td&gt;
</span><del>-                                &lt;td&gt;Build Time&lt;/td&gt;
</del><span class="cx">                                 &lt;td&gt;{{../metric.fullName}}&lt;/td&gt;
</span><span class="cx">                             &lt;/tr&gt;
</span><span class="cx">                         &lt;/thead&gt;
</span><span class="cx">                         &lt;tbody&gt;
</span><span class="cx">                             {{#each buildRequests}}
</span><span class="cx">                                 &lt;tr&gt;
</span><del>-                                    &lt;td&gt;{{id}}&lt;/td&gt;
-                                    &lt;td&gt;{{buildNumber}}&lt;/td&gt;
-                                    &lt;td&gt;{{buildTime}}&lt;/td&gt;
</del><ins>+                                    &lt;td&gt;{{orderLabel}}&lt;/td&gt;
+                                    &lt;td&gt;{{config}}&lt;/td&gt;
+                                    &lt;td&gt;{{#if url}}{{#link-to url}}{{statusLabel}}{{/link-to}}{{else}}{{statusLabel}}{{/if}}&lt;/td&gt;
+                                    &lt;td&gt;{{build}}&lt;/td&gt;
</ins><span class="cx">                                     &lt;td&gt;{{mean}}&lt;/td&gt;
</span><span class="cx">                                 &lt;/tr&gt;
</span><span class="cx">                             {{/each}}
</span><span class="lines">@@ -512,9 +513,10 @@
</span><span class="cx">                 &lt;/section&gt;
</span><span class="cx">             {{/each}}
</span><span class="cx"> 
</span><del>-            &lt;form class=&quot;analysis-group&quot;&gt;
</del><ins>+            {{#if roots}}
+            &lt;form method=&quot;POST&quot; {{action &quot;createTestGroup&quot; newTestGroupName repetitionCount on=&quot;submit&quot;}} class=&quot;analysis-group&quot;&gt;
</ins><span class="cx">                 &lt;table&gt;
</span><del>-                    &lt;caption&gt;&lt;input name=&quot;name&quot; placeholder=&quot;Test group name&quot; required&gt;&lt;/caption&gt;
</del><ins>+                    &lt;caption&gt;{{input name=&quot;name&quot; value=newTestGroupName placeholder=&quot;Test group name&quot; required=true type=&quot;text&quot;}}&lt;/caption&gt;
</ins><span class="cx">                     &lt;thead&gt;
</span><span class="cx">                         &lt;tr&gt;
</span><span class="cx">                             &lt;th&gt;Root&lt;/th&gt;
</span><span class="lines">@@ -535,7 +537,7 @@
</span><span class="cx">                             &lt;tr&gt;
</span><span class="cx">                                 &lt;th&gt;{{name}}&lt;/th&gt;
</span><span class="cx">                                 {{#each sets}}
</span><del>-                                    &lt;td&gt;{{view Ember.Select name=name content=revisions
</del><ins>+                                    &lt;td&gt;{{view Ember.Select name=name content=revisions disabled=true
</ins><span class="cx">                                         optionValuePath=&quot;content.value&quot; optionLabelPath=&quot;content.label&quot;
</span><span class="cx">                                         selection=selection}}&lt;/td&gt;
</span><span class="cx">                                 {{/each}}
</span><span class="lines">@@ -546,14 +548,7 @@
</span><span class="cx">                         &lt;tr&gt;
</span><span class="cx">                             &lt;th&gt;Number of runs&lt;/th&gt;
</span><span class="cx">                             &lt;td colspan=2&gt;
</span><del>-                                &lt;select&gt;
-                                    &lt;option&gt;1&lt;/option&gt;
-                                    &lt;option&gt;2&lt;/option&gt;
-                                    &lt;option&gt;3&lt;/option&gt;
-                                    &lt;option&gt;4&lt;/option&gt;
-                                    &lt;option&gt;5&lt;/option&gt;
-                                    &lt;option&gt;6&lt;/option&gt;
-                                &lt;/select&gt;
</del><ins>+                                {{view Ember.Select content=possibleRepetitionCounts value=repetitionCount}}
</ins><span class="cx">                             &lt;/td&gt;
</span><span class="cx">                         &lt;/tr&gt;
</span><span class="cx">                     &lt;/tbody&gt;
</span><span class="lines">@@ -561,6 +556,7 @@
</span><span class="cx"> 
</span><span class="cx">                 &lt;button type=&quot;submit&quot;&gt;Start A/B testing&lt;/button&gt;
</span><span class="cx">             &lt;/form&gt;
</span><ins>+            {{/if}}
</ins><span class="cx">         {{/if}}
</span><span class="cx">     &lt;/script&gt;
</span><span class="cx"> 
</span></span></pre></div>
<a id="trunkWebsitesperfwebkitorgtoolssyncwithbuildbotpy"></a>
<div class="addfile"><h4>Added: trunk/Websites/perf.webkit.org/tools/sync-with-buildbot.py (0 => 178234)</h4>
<pre class="diff"><span>
<span class="info">--- trunk/Websites/perf.webkit.org/tools/sync-with-buildbot.py                                (rev 0)
+++ trunk/Websites/perf.webkit.org/tools/sync-with-buildbot.py        2015-01-10 05:26:49 UTC (rev 178234)
</span><span class="lines">@@ -0,0 +1,183 @@
</span><ins>+#!/usr/bin/python
+
+import argparse
+import base64
+import copy
+import json
+import sys
+import time
+import urllib
+import urllib2
+
+
+def main():
+    parser = argparse.ArgumentParser()
+    parser.add_argument('--build-requests-url', required=True, help='URL for the build requests JSON API; e.g. https://perf.webkit.org/api/build-requests/build.webkit.org/')
+    parser.add_argument('--build-requests-user', help='The username for Basic Authentication to access the build requests JSON API')
+    parser.add_argument('--build-requests-password', help='The password for Basic Authentication to access the build requests JSON API')
+    parser.add_argument('--slave-name', required=True, help='The slave name used to update the build requets status')
+    parser.add_argument('--slave-password', required=True, help='The slave password used to update the build requets status')
+    parser.add_argument('--buildbot-url', required=True, help='URL for a buildbot builder; e.g. &quot;https://build.webkit.org/&quot;')
+    parser.add_argument('--builder-config-json', required=True, help='The path to a JSON file that specifies which test and platform will be posted to which builder. '
+        'The JSON should contain an array of dictionaries with keys &quot;platform&quot;, &quot;test&quot;, and &quot;builder&quot; '
+        'with the platform name (e.g. mountainlion), the test path (e.g. [&quot;Parser&quot;, &quot;html5-full-render&quot;]), and the builder name (e.g. Apple MountainLion Release (Perf)) as values.')
+    parser.add_argument('--lookback-count', type=int, default=10, help='The number of builds to look back when finding in-progress builds on the buildbot')
+    parser.add_argument('--seconds-to-sleep', type=float, default=120, help='The seconds to sleep between iterations')
+    args = parser.parse_args()
+
+    configurations = load_config(args.builder_config_json, args.buildbot_url.strip('/'))
+    build_request_auth = {'user': args.build_requests_user, 'password': args.build_requests_password or ''} if args.build_requests_user else None
+    request_updates = {}
+    while True:
+        request_updates.update(find_request_updates(configurations, args.lookback_count))
+        if request_updates:
+            print 'Updating the build requests %s...' % ', '.join(map(str, request_updates.keys()))
+        else:
+            print 'No updates...'
+
+        payload = {'buildRequestUpdates': request_updates, 'slaveName': args.slave_name, 'slavePassword': args.slave_password}
+        response = update_and_fetch_build_requests(args.build_requests_url, build_request_auth, payload)
+        root_sets = response.get('rootSets', {})
+        open_requests = response.get('buildRequests', [])
+
+        for request in filter(lambda request: request['status'] == 'pending', open_requests):
+            config = config_for_request(configurations, request)
+            if len(config['scheduledRequests']) &lt; 1:
+                print &quot;Scheduling the build request %s...&quot; % str(request['id'])
+                schedule_request(config, request, root_sets)
+
+        request_updates = find_stale_request_updates(configurations, open_requests, request_updates.keys())
+        if request_updates:
+            print &quot;Found stale build requests %s...&quot; % ', '.join(map(str, request_updates.keys()))
+
+        time.sleep(args.seconds_to_sleep)
+
+
+def load_config(config_json_path, buildbot_url):
+    with open(config_json_path) as config_json:
+        configurations = json.load(config_json)
+
+    for config in configurations:
+        escaped_builder_name = urllib.quote(config['builder'])
+        config['url'] = '%s/builders/%s/' % (buildbot_url, escaped_builder_name)
+        config['jsonURL'] = '%s/json/builders/%s/' % (buildbot_url, escaped_builder_name)
+        config['scheduledRequests'] = set()
+
+    return configurations
+
+
+def find_request_updates(configurations, lookback_count):
+    request_updates = {}
+
+    for config in configurations:
+        try:
+            pending_builds = fetch_json(config['jsonURL'] + 'pendingBuilds')
+            scheduled_requests = filter(None, [request_id_from_build(build) for build in pending_builds])
+            for request_id in scheduled_requests:
+                request_updates[request_id] = {'status': 'scheduled', 'url': config['url']}
+            config['scheduledRequests'] = set(scheduled_requests)
+        except (IOError, ValueError) as error:
+            print &gt;&gt; sys.stderr, &quot;Failed to fetch pending builds for %s: %s&quot; % (config['builder'], str(error))
+
+    for config in configurations:
+        for i in range(1, lookback_count + 1):
+            build_error = None
+            build_index = -i
+            try:
+                build = fetch_json(config['jsonURL'] + 'builds/%d' % build_index)
+                request_id = request_id_from_build(build)
+                if not request_id:
+                    continue
+
+                in_progress = build.get('currentStep') and build.get('eta')
+                if in_progress:
+                    request_updates[request_id] = {'status': 'running', 'url': config['url']}
+                    config['scheduledRequests'].discard(request_id)
+                else:
+                    url = config['url'] + 'builds/' + str(build['number'])
+                    request_updates[request_id] = {'status': 'failedIfNotCompleted', 'url': url}
+            except urllib2.HTTPError as error:
+                if error.code == 404:
+                    break
+                else:
+                    build_error = error
+            except ValueError as error:
+                build_error = error
+            if build_error:
+                print &gt;&gt; sys.stderr, &quot;Failed to fetch build %d for %s: %s&quot; % (build_index, config['builder'], str(build_error))
+
+    return request_updates
+
+
+def update_and_fetch_build_requests(build_requests_url, build_request_auth, payload):
+    try:
+        response = fetch_json(build_requests_url, payload=json.dumps(payload), auth=build_request_auth)
+        if response['status'] != 'OK':
+            raise ValueError(response['status'])
+        return response
+    except (IOError, ValueError) as error:
+        print &gt;&gt; sys.stderr, 'Failed to update or fetch build requests at %s: %s' % (build_requests_url, str(error))
+    return {}
+
+
+def find_stale_request_updates(configurations, open_requests, requests_on_buildbot):
+    request_updates = {}
+    for request in open_requests:
+        request_id = int(request['id'])
+        should_be_on_buildbot = request['status'] in ('scheduled', 'running')
+        if should_be_on_buildbot and request_id not in requests_on_buildbot:
+            config = config_for_request(configurations, request)
+            if config:
+                request_updates[request_id] = {'status': 'failed', 'url': config['url']}
+    return request_updates
+
+
+def schedule_request(config, request, root_sets):
+    replacements = root_sets.get(request['rootSet'], {})
+    replacements['buildRequest'] = request['id']
+
+    payload = {}
+    for property_name, property_value in config['arguments'].iteritems():
+        for key, value in replacements.iteritems():
+            property_value = property_value.replace('$' + key, value)
+        payload[property_name] = property_value
+
+    try:
+        urllib2.urlopen(urllib2.Request(config['url'] + 'force'), urllib.urlencode(payload))
+        config['scheduledRequests'].add(request['id'])
+    except (IOError, ValueError) as error:
+        print &gt;&gt; sys.stderr, &quot;Failed to fetch pending builds for %s: %s&quot; % (config['builder'], str(error))
+
+
+def config_for_request(configurations, request):
+    for config in configurations:
+        if config['platform'] == request['platform'] and config['test'] == request['test']:
+            return config
+    return None
+
+
+def fetch_json(url, auth={}, payload=None):
+    request = urllib2.Request(url)
+    if auth:
+        request.add_header('Authorization', &quot;Basic %s&quot; % base64.encodestring('%s:%s' % (auth['user'], auth['password'])).rstrip('\n'))
+    response = urllib2.urlopen(request, payload).read()
+    try:
+        return json.loads(response)
+    except ValueError as error:
+        raise ValueError(str(error) + '\n' + response)
+
+
+def property_value_from_build(build, name):
+    for prop in build.get('properties', []):
+        if prop[0] == name:
+            return prop[1]
+    return None
+
+
+def request_id_from_build(build):
+    job_id = property_value_from_build(build, 'jobid')
+    return int(job_id) if job_id and job_id.isdigit() else None
+
+
+if __name__ == &quot;__main__&quot;:
+    main()
</ins><span class="cx">Property changes on: trunk/Websites/perf.webkit.org/tools/sync-with-buildbot.py
</span><span class="cx">___________________________________________________________________
</span></span></pre></div>
<a id="svnexecutable"></a>
<div class="addfile"><h4>Added: svn:executable</h4></div>
</div>

</body>
</html>