Perf dashboard should have the ability to post A/B testing builds
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Sat, 10 Jan 2015 05:26:49 +0000 (05:26 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Sat, 10 Jan 2015 05:26:49 +0000 (05:26 +0000)
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. "build_triggerables", which represents a set of builders
that accept A/B testing. "triggerable_repositories" associates each "triggerable" with a fixed set
of repositories for which an arbitrary revision can be specified for A/B testing.
"triggerable_configurations" specifies a triggerable available on a given test on a given platform.
"roots" table which specifies the revision used in a given root set in each repository.

* init-database.sql: Added "build_triggerables", "triggerable_repositories",
"triggerable_configurations", and "roots" tables. Added references to "build_triggerables",
"platforms", and "tests" 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 "$prefix_" when there are no columns.
(Database::insert_row): Support taking an empty array for values. This is useful in "root_sets"
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 "1 == 1" 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
"tests" 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 "path", the ordered list of test names from
the highest ancestor to the test itself.
(TestPathResolver::ensure_id_to_test_map): Fetches "tests" 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 "commits" for sets A and B respectively. e.g. {"WebKit": [1, 2], "Safari": [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 "roots" 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.
[
    {
        "platform": "mac-mavericks",
        "test": ["Parser", "html5-full-render.html"],
        "builder": "Trunk Syrah Production Perf AB Tests",
        "arguments": {
            "forcescheduler": "force-mac-mavericks-release-perf",
            "webkit_revision": "$WebKit",
            "jobid": "$buildRequest"
        }
    }
]

(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 "failedIfNotCompleted" which results in "failed" 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.

git-svn-id: https://svn.webkit.org/repository/webkit/trunk@178234 268f45cc-cd09-0410-ab3c-d52691b4dbfc

20 files changed:
Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/init-database.sql
Websites/perf.webkit.org/public/admin/tests.php
Websites/perf.webkit.org/public/admin/triggerables.php [new file with mode: 0644]
Websites/perf.webkit.org/public/api/build-requests.php [new file with mode: 0644]
Websites/perf.webkit.org/public/api/runs.php
Websites/perf.webkit.org/public/api/test-groups.php
Websites/perf.webkit.org/public/api/triggerables.php [new file with mode: 0644]
Websites/perf.webkit.org/public/include/admin-header.php
Websites/perf.webkit.org/public/include/build-requests-fetcher.php [new file with mode: 0644]
Websites/perf.webkit.org/public/include/db.php
Websites/perf.webkit.org/public/include/json-header.php
Websites/perf.webkit.org/public/include/report-processor.php
Websites/perf.webkit.org/public/include/test-name-resolver.php
Websites/perf.webkit.org/public/include/test-path-resolver.php [new file with mode: 0644]
Websites/perf.webkit.org/public/privileged-api/create-test-group.php [new file with mode: 0644]
Websites/perf.webkit.org/public/v2/analysis.js
Websites/perf.webkit.org/public/v2/app.js
Websites/perf.webkit.org/public/v2/index.html
Websites/perf.webkit.org/tools/sync-with-buildbot.py [new file with mode: 0755]

index a5fb972..6af3a5a 100644 (file)
@@ -1,5 +1,176 @@
 2015-01-09  Ryosuke Niwa  <rniwa@webkit.org>
 
+        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. "build_triggerables", which represents a set of builders
+        that accept A/B testing. "triggerable_repositories" associates each "triggerable" with a fixed set
+        of repositories for which an arbitrary revision can be specified for A/B testing.
+        "triggerable_configurations" specifies a triggerable available on a given test on a given platform.
+        "roots" table which specifies the revision used in a given root set in each repository.
+
+        * init-database.sql: Added "build_triggerables", "triggerable_repositories",
+        "triggerable_configurations", and "roots" tables. Added references to "build_triggerables",
+        "platforms", and "tests" 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 "$prefix_" when there are no columns.
+        (Database::insert_row): Support taking an empty array for values. This is useful in "root_sets"
+        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 "1 == 1" 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
+        "tests" 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 "path", the ordered list of test names from
+        the highest ancestor to the test itself.
+        (TestPathResolver::ensure_id_to_test_map): Fetches "tests" 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 "commits" for sets A and B respectively. e.g. {"WebKit": [1, 2], "Safari": [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 "roots" 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.
+        [
+            {
+                "platform": "mac-mavericks",
+                "test": ["Parser", "html5-full-render.html"],
+                "builder": "Trunk Syrah Production Perf AB Tests",
+                "arguments": {
+                    "forcescheduler": "force-mac-mavericks-release-perf",
+                    "webkit_revision": "$WebKit",
+                    "jobid": "$buildRequest"
+                }
+            }
+        ]
+
+        (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 "failedIfNotCompleted" which results in "failed" 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  <rniwa@webkit.org>
+
         Cache-control should be set only on api/runs
         https://bugs.webkit.org/show_bug.cgi?id=140312
 
index 533a590..6e51da9 100644 (file)
@@ -17,10 +17,15 @@ DROP TABLE reports CASCADE;
 DROP TABLE tracker_repositories CASCADE;
 DROP TABLE bug_trackers CASCADE;
 DROP TABLE analysis_tasks CASCADE;
+DROP TABLE build_triggerables CASCADE;
+DROP TABLE triggerable_configurations CASCADE;
+DROP TABLE triggerable_repositories CASCADE;
 DROP TABLE bugs CASCADE;
 DROP TABLE analysis_test_groups CASCADE;
 DROP TABLE root_sets CASCADE;
+DROP TABLE roots CASCADE;
 DROP TABLE build_requests CASCADE;
+DROP TYPE build_request_status_type CASCADE;
 
 
 CREATE TABLE platforms (
@@ -171,21 +176,50 @@ CREATE TABLE bugs (
     bug_number integer NOT NULL,
     CONSTRAINT bug_task_and_tracker_must_be_unique UNIQUE(bug_task, bug_tracker));
 
+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));
+
 CREATE TABLE analysis_test_groups (
     testgroup_id serial PRIMARY KEY,
     testgroup_task integer REFERENCES analysis_tasks NOT NULL,
     testgroup_name varchar(256),
-    testgroup_author varchar(256) NOT NULL,
-    testgroup_created_at timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'));
+    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));
 CREATE INDEX testgroup_task_index ON analysis_test_groups(testgroup_task);
 
 CREATE TABLE root_sets (
     rootset_id serial PRIMARY KEY);
 
+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');
 CREATE TABLE build_requests (
     request_id serial PRIMARY KEY,
+    request_triggerable integer REFERENCES build_triggerables NOT NULL,
+    request_platform integer REFERENCES platforms NOT NULL,
+    request_test integer REFERENCES tests NOT NULL,
     request_group integer REFERENCES analysis_test_groups NOT NULL,
     request_order integer NOT NULL,
     request_root_set integer REFERENCES root_sets NOT NULL,
+    request_status build_request_status_type NOT NULL DEFAULT 'pending',
+    request_url varchar(1024),
     request_build integer REFERENCES builds,
+    request_created_at timestamp NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
     CONSTRAINT build_request_order_must_be_unique_in_group UNIQUE(request_group, request_order));
+CREATE INDEX build_request_triggerable ON build_requests(request_triggerable);    
+CREATE INDEX build_request_build ON build_requests(request_build);
index 334cab0..1dc8473 100644 (file)
@@ -32,6 +32,22 @@ if ($action == 'dashboard') {
 } else if ($action == 'update') {
     if (!update_field('tests', 'test', 'url'))
         notice('Invalid parameters');
+} 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' => $test_id, 'platform' => $platform_id, 'triggerable' => $triggerable_id);
+        if (!$db->insert_row('triggerable_configurations', 'trigconfig', $association, NULL)) {
+            $suceeded = FALSE;
+            notice("Failed to associate triggerable $triggerable_id with test $test_id on platform $platform_id.");
+        }
+    } else {
+        $db->query_and_get_affected_rows('DELETE FROM triggerable_configurations WHERE trigrepo_test = $1 AND trigrepo_platform = $2',
+            array($test_id, $platform_id));
+    }
 } else if ($action == 'add') {
     if (array_key_exists('test_id', $_POST) && array_key_exists('metric_name', $_POST)) {
         $id = intval($_POST['test_id']);
@@ -58,6 +74,7 @@ if ($db) {
         foreach ($aggregators_table as $aggregator_row)
             $aggregators[$aggregator_row['aggregator_id']] = $aggregator_row['aggregator_name'];
     }
+    $build_triggerable_table = $db->fetch_table('build_triggerables', 'triggerable_name') or array();
 
     $test_name_resolver = new TestNameResolver($db);
     if ($test_name_resolver->tests()) {
@@ -78,7 +95,7 @@ if ($db) {
 ?>
 <table>
 <thead>
-    <tr><td>Test ID</td><td>Full Name</td><td>Parent ID</td><td>URL</td>
+    <tr><td>Test ID</td><td>Full Name</td><td>Parent ID</td><td>URL</td><td>Triggerables</td>
         <td>Metric ID</td><td>Metric Name</td><td>Aggregator</td><td>Dashboard</td>
 </thead>
 <tbody>
@@ -103,6 +120,37 @@ if ($db) {
 
             $test_url = htmlspecialchars($test['test_url']);
 
+            $triggerable_platforms = $db->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->test_exists_on_platform($test_id, $platform_row['platform_id']))
+                    continue;
+
+                $triggerables .= <<< END
+<form method="POST">
+    <input type="hidden" name="test" value="$test_id">
+    <input type="hidden" name="platform" value="{$platform_row['platform_id']}">
+    <input type="hidden" name="action" value="update-triggerable">
+    <label>
+        {$platform_row['platform_name']}
+        <select name="triggerable" onchange="this.form.submit();">
+            <option value="">None</option>
+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 .= "<option value=\"$triggerable_id\"$selected>{$triggerable_row['triggerable_name']}</option>";
+                }
+                $triggerables .= <<< END
+        </select>
+    </label>
+</form>
+<br>
+END;
+            }
+
             echo <<<EOF
     <tbody class="$tbody_class">
     <tr>
@@ -112,7 +160,8 @@ if ($db) {
         <td rowspan="$row_count">
         <form method="POST"><input type="hidden" name="id" value="$test_id">
         <input type="hidden" name="action" value="update">
-        <input type="url" name="url" value="$test_url" size="80"></form></td>
+        <input type="url" name="url" value="$test_url" size="30"></form></td>
+        <td rowspan="$row_count">$triggerables</td>
 EOF;
 
             if ($test_metrics) {
@@ -169,8 +218,8 @@ EOF;
         <label>Name<select name="metric_name">
 EOF;
 
-                foreach ($child_metrics as $metric_name) {
-                    $metric_name = htmlspecialchars($metric_name);
+                foreach ($child_metrics as $metric) {
+                    $metric_name = htmlspecialchars($metric['metric_name']);
                     echo "
             <option>$metric_name</option>";
                 }
diff --git a/Websites/perf.webkit.org/public/admin/triggerables.php b/Websites/perf.webkit.org/public/admin/triggerables.php
new file mode 100644 (file)
index 0000000..9fed601
--- /dev/null
@@ -0,0 +1,87 @@
+<?php
+
+require('../include/admin-header.php');
+
+if ($db) {
+
+    if ($action == 'add') {
+        if ($db->insert_row('build_triggerables', 'triggerable', array('name' => $_POST['name'], 'location' => $_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->begin_transaction();
+        $db->query_and_get_affected_rows("DELETE FROM triggerable_repositories WHERE trigrepo_triggerable = $1", array($triggerable_id));
+
+        $repositories = array_get($_POST, 'repositories');
+        $suceeded = TRUE;
+        if ($repositories) {
+            foreach ($repositories as $repository_id) {
+                if (!$db->insert_row('triggerable_repositories', 'trigrepo', array('triggerable' => $triggerable_id, 'repository' => $repository_id), NULL)) {
+                    $suceeded = FALSE;
+                    notice("Failed to associate repository $repository_id with triggerable $triggerable_id.");
+                    break;
+                }
+            }
+        }
+        if ($suceeded) {
+            $db->commit_transaction();
+            notice('Updated the association.');
+            regenerate_manifest();
+        } else
+            $db->rollback_transaction();
+    }
+
+    $repository_rows = $db->fetch_table('repositories', 'repository_name');
+    $repository_names = array();
+
+
+    $page = new AdministrativePage($db, 'build_triggerables', 'triggerable', array(
+        'name' => array('editing_mode' => 'string'),
+        'repositories' => array('custom' => function ($triggerable_row) use (&$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->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 = <<< END
+<form method="POST">
+<input type="hidden" name="id" value="$triggerable_id">
+<input type="hidden" name="action" value="update-repositories">
+END;
+
+        foreach ($repository_rows as $row) {
+            $checked = $row['trigrepo_triggerable'] ? ' checked' : '';
+            $form .= <<< END
+<label><input type="checkbox" name="repositories[]" value="{$row['repository_id']}"$checked>{$row['repository_name']}</label>
+END;
+        }
+
+        return $form . <<< END
+<button>Save</button>
+</form>
+END;
+    }
+
+    $page->render_table('name');
+    $page->render_form_to_add();
+}
+
+require('../include/admin-footer.php');
+
+?>
diff --git a/Websites/perf.webkit.org/public/api/build-requests.php b/Websites/perf.webkit.org/public/api/build-requests.php
new file mode 100644 (file)
index 0000000..71ecbb1
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+
+require_once('../include/json-header.php');
+require_once('../include/build-requests-fetcher.php');
+
+function main($path, $post_data) {
+    if (count($path) < 1 || count($path) > 2)
+        exit_with_error('InvalidRequest');
+
+    $db = new Database;
+    if (!$db->connect())
+        exit_with_error('DatabaseConnectionFailure');
+
+    $triggerable_query = array('name' => array_get($path, 0));
+    $triggerable = $db->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->begin_transaction();
+        foreach ($updates as $id => $info) {
+            $id = intval($id);
+            $status = $info['status'];
+            $url = array_get($info, 'url');
+            if ($status == 'failedIfNotCompleted') {
+                $db->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->rollback_transaction();
+                    exit_with_error('UnknownBuildRequestStatus', array('buildRequest' => $id, 'status' => $status));
+                }
+                $db->update_row('build_requests', 'request', array('id' => $id), array('status' => $status, 'url' => $url));
+            } 
+        }
+        $db->commit_transaction();
+    }
+
+    $requests_fetcher = new BuildRequestsFetcher($db);
+    $requests_fetcher->fetch_incomplete_requests_for_triggerable($triggerable['triggerable_id']);
+
+    exit_with_success(array(
+        'buildRequests' => $requests_fetcher->results_with_resolved_ids(),
+        'rootSets' => $requests_fetcher->root_sets(),
+        'updates' => $updates,
+    ));
+}
+
+main(array_key_exists('PATH_INFO', $_SERVER) ? explode('/', trim($_SERVER['PATH_INFO'], '/')) : array(),
+    file_get_contents("php://input"));
+
+?>
index 31cfa87..b47e3c1 100644 (file)
@@ -39,7 +39,7 @@ function fetch_runs_for_config($db, $config) {
             FROM builds
                 LEFT OUTER JOIN build_commits ON commit_build = build_id
                 LEFT OUTER JOIN commits ON build_commit = commit_id, test_runs
-            WHERE run_build = build_id AND run_config = $1
+            WHERE run_build = build_id AND run_config = $1 AND NOT EXISTS (SELECT * FROM build_requests WHERE request_build = build_id)
             GROUP BY build_id, run_id', array($config['config_id']));
 
     $formatted_runs = array();
index ab01ae9..088ff62 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 
-require('../include/json-header.php');
+require_once('../include/json-header.php');
+require_once('../include/build-requests-fetcher.php');
 
 function main($path) {
     $db = new Database;
@@ -10,24 +11,26 @@ function main($path) {
     if (count($path) > 1)
         exit_with_error('InvalidRequest');
 
+    $build_requests_fetcher = new BuildRequestsFetcher($db);
+
     if (count($path) > 0 && $path[0]) {
         $group_id = intval($path[0]);
         $group = $db->select_first_row('analysis_test_groups', 'testgroup', array('id' => $group_id));
         if (!$group)
             exit_with_error('GroupNotFound', array('id' => $group_id));
         $test_groups = array($group);
-        $build_requests = fetch_build_requests_for_group($db, $group_id);
+        $build_requests_fetcher->fetch_for_group($group_id);
     } else {
         $task_id = array_get($_GET, 'task');
         if (!$task_id)
             exit_with_error('TaskIdNotSpecified');
 
-        $test_groups = fetch_test_groups_for_task($db, $task_id);
+        $test_groups = $db->select_rows('analysis_test_groups', 'testgroup', array('task' => $task_id));
         if (!is_array($test_groups))
             exit_with_error('FailedToFetchTestGroups');
-        $build_requests = fetch_build_requests_for_task($db, $task_id);
+        $build_requests_fetcher->fetch_for_task($task_id);
     }
-    if (!is_array($build_requests))
+    if (!$build_requests_fetcher->has_results())
         exit_with_error('FailedToFetchBuildRequests');
 
     $test_groups = array_map("format_test_group", $test_groups);
@@ -35,29 +38,13 @@ function main($path) {
     foreach ($test_groups as &$group)
         $group_by_id[$group['id']] = &$group;
 
-    $build_requests = array_map("format_build_request", $build_requests);
+    $build_requests = $build_requests_fetcher->results();
     foreach ($build_requests as $request)
         array_push($group_by_id[$request['testGroup']]['buildRequests'], $request['id']);
 
     exit_with_success(array('testGroups' => $test_groups, 'buildRequests' => $build_requests));
 }
 
-function fetch_test_groups_for_task($db, $task_id) {
-    return $db->select_rows('analysis_test_groups', 'testgroup', array('task' => $task_id));
-}
-
-function fetch_build_requests_for_task($db, $task_id) {
-    return $db->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->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));
-}
-
 function format_test_group($group_row) {
     return array(
         'id' => $group_row['testgroup_id'],
@@ -69,19 +56,6 @@ function format_test_group($group_row) {
     );
 }
 
-function format_build_request($request_row) {
-    return array(
-        'id' => $request_row['request_id'],
-        'testGroup' => $request_row['request_group'],
-        'order' => $request_row['request_order'],
-        'rootSet' => $request_row['request_root_set'],
-        'build' => $request_row['request_build'],
-        'builder' => $request_row['build_builder'],
-        'buildNumber' => $request_row['build_number'],
-        'buildTime' => $request_row['build_time'] ? strtotime($request_row['build_time']) * 1000 : NULL,
-    );
-}
-
 main(array_key_exists('PATH_INFO', $_SERVER) ? explode('/', trim($_SERVER['PATH_INFO'], '/')) : array());
 
 ?>
diff --git a/Websites/perf.webkit.org/public/api/triggerables.php b/Websites/perf.webkit.org/public/api/triggerables.php
new file mode 100644 (file)
index 0000000..61a717a
--- /dev/null
@@ -0,0 +1,44 @@
+<?php
+
+require('../include/json-header.php');
+
+function main($path) {
+    if (count($path) > 1)
+        exit_with_error('InvalidRequest');
+
+    $db = new Database;
+    if (!$db->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' => $task_id));
+        $query['id'] = $triggerable['id'];
+    }
+
+    $id_to_triggerable = array();
+    foreach ($db->select_rows('build_triggerables', 'triggerable', $query) as $row) {
+        $id = $row['triggerable_id'];
+        $repositories = array();
+        $id_to_triggerable[$id] = array('id' => $id, 'name' => $row['triggerable_name'], 'acceptedRepositories' => &$repositories);
+    }
+
+    $repository_id_to_name = array();
+    foreach ($db->select_rows('repositories', 'repository', array(), 'name') as $row)
+        $repository_id_to_name[$row['repository_id']] = $row['repository_name'];
+
+    foreach ($db->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' => array_values($id_to_triggerable)));
+}
+
+main(array_key_exists('PATH_INFO', $_SERVER) ? explode('/', trim($_SERVER['PATH_INFO'], '/')) : array());
+
+?>
index 717989f..4d1254e 100644 (file)
@@ -19,6 +19,7 @@ require_once('manifest.php');
     <li><a href="/admin/aggregators">Aggregators</a></li>
     <li><a href="/admin/builders">Builders</a></li>
     <li><a href="/admin/build-slaves">Slaves</a></li>
+    <li><a href="/admin/triggerables">Triggerables</a></li>
     <li><a href="/admin/repositories">Repositories</a></li>
     <li><a href="/admin/bug-trackers">Bug Trackers</a></li>
 </ul>
diff --git a/Websites/perf.webkit.org/public/include/build-requests-fetcher.php b/Websites/perf.webkit.org/public/include/build-requests-fetcher.php
new file mode 100644 (file)
index 0000000..ce52cba
--- /dev/null
@@ -0,0 +1,89 @@
+<?php
+
+require_once('test-path-resolver.php');
+
+class BuildRequestsFetcher {
+    function __construct($db) {
+        $this->db = $db;
+        $this->rows = null;
+        $this->root_sets_by_id = array();
+    }
+
+    function fetch_for_task($task_id) {
+        $this->rows = $this->db->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->rows = $this->db->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->rows = $this->db->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->rows); }
+    function results() { return $this->results_internal(false); }
+    function results_with_resolved_ids() { return $this->results_internal(true); }
+
+    private function results_internal($resolve_ids) {
+        if (!$this->rows)
+            return array();
+
+        $id_to_platform_name = array();
+        if ($resolve_ids) {
+            foreach ($this->db->select_rows('platforms', 'platform', array()) as $platform)
+                $id_to_platform_name[$platform['platform_id']] = $platform['platform_name'];
+        }
+        $test_path_resolver = new TestPathResolver($this->db);
+
+        $requests = array();
+        foreach ($this->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->root_sets_by_id))
+                $this->root_sets_by_id[$root_set_id] = $this->fetch_roots_for_set($root_set_id);
+
+            array_push($requests, array(
+                'id' => $row['request_id'],
+                'triggerable' => $row['request_triggerable'],
+                'test' => $resolve_ids ? $test_path_resolver->path_for_test($test_id) : $test_id,
+                'platform' => $resolve_ids ? $id_to_platform_name[$platform_id] : $platform_id,
+                'testGroup' => $row['request_group'],
+                'order' => $row['request_order'],
+                'rootSet' => $root_set_id,
+                'status' => $row['request_status'],
+                'url' => $row['request_url'],
+                'build' => $row['request_build'],
+                'createdAt' => $row['request_created_at'] ? strtotime($row['request_created_at']) * 1000 : NULL,
+            ));
+        }
+        return $requests;
+    }
+
+    function root_sets() {
+        return $this->root_sets_by_id;
+    }
+
+    private function fetch_roots_for_set($root_set_id) {
+        $root_rows = $this->db->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;
+    }
+}
+
+?>
\ No newline at end of file
index 0fe3d95..82ea1b9 100644 (file)
@@ -64,7 +64,7 @@ class Database
     }
 
     private function prefixed_column_names($columns, $prefix = NULL) {
-        if (!$prefix)
+        if (!$prefix || !$columns)
             return join(', ', $columns);
         return $prefix . '_' . join(', ' . $prefix . '_', $columns);
     }
@@ -96,24 +96,29 @@ class Database
         $column_names = $this->prefixed_column_names($column_names, $prefix);
         $placeholders = join(', ', $placeholders);
 
+        $value_query = $column_names ? "($column_names) VALUES ($placeholders)" : ' VALUES (default)';
         if ($returning) {
             $returning_column_name = $this->prefixed_name($returning, $prefix);
-            $rows = $this->query_and_fetch_all("INSERT INTO $table ($column_names) VALUES ($placeholders) RETURNING $returning_column_name", $values);
+            $rows = $this->query_and_fetch_all("INSERT INTO $table $value_query RETURNING $returning_column_name", $values);
             return $rows ? $rows[0][$returning_column_name] : NULL;
         }
 
-        return $this->query_and_get_affected_rows("INSERT INTO $table ($column_names) VALUES ($placeholders)", $values) == 1;
+        return $this->query_and_get_affected_rows("INSERT INTO $table $value_query", $values) == 1;
     }
 
     function select_or_insert_row($table, $prefix, $select_params, $insert_params = NULL, $returning = 'id') {
-        return $this->_select_update_or_insert_row($table, $prefix, $select_params, $insert_params, $returning, FALSE);
+        return $this->_select_update_or_insert_row($table, $prefix, $select_params, $insert_params, $returning, FALSE, TRUE);
     }
 
     function update_or_insert_row($table, $prefix, $select_params, $insert_params = NULL, $returning = 'id') {
-        return $this->_select_update_or_insert_row($table, $prefix, $select_params, $insert_params, $returning, TRUE);
+        return $this->_select_update_or_insert_row($table, $prefix, $select_params, $insert_params, $returning, TRUE, TRUE);
     }
 
-    private function _select_update_or_insert_row($table, $prefix, $select_params, $insert_params, $returning, $should_update) {
+    function update_row($table, $prefix, $select_params, $update_params, $returning = 'id') {
+        return $this->_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) {
         $values = array();
 
         $select_placeholders = array();
@@ -141,7 +146,7 @@ class Database
             $rows = $this->query_and_fetch_all("UPDATE $table SET ($insert_column_names) = ($insert_placeholders)
                 WHERE ($select_column_names) = ($select_placeholders) RETURNING $returning_column_name", $values);
         }
-        if (!$rows) {
+        if (!$rows && $should_insert) {
             $rows = $this->query_and_fetch_all("INSERT INTO $table ($insert_column_names) SELECT $insert_placeholders
                 WHERE NOT EXISTS ($query) RETURNING $returning_column_name", $values);            
         }
@@ -171,6 +176,8 @@ class Database
         $values = array();
         $column_names = $this->prefixed_column_names($this->prepare_params($params, $placeholders, $values), $prefix);
         $placeholders = join(', ', $placeholders);
+        if (!$column_names && !$placeholders)
+            $column_names = $placeholders = '1';
         $query = "SELECT * FROM $table WHERE ($column_names) = ($placeholders)";
         if ($order_by) {
             assert(ctype_alnum_underscore($order_by));
index b52626e..7d54cfd 100644 (file)
@@ -1,6 +1,7 @@
 <?php
 
 require_once('db.php');
+require_once('test-path-resolver.php');
 
 header('Content-type: application/json');
 
@@ -122,4 +123,36 @@ function verify_slave($db, $params) {
         exit_with_error('SlaveNotFound', array('name' => $slave_info['name']));
 }
 
+function find_triggerable_for_task($db, $task_id) {
+    $task_id = intval($task_id);
+
+    $test_rows = $db->query_and_fetch_all('SELECT metric_test AS "test", task_platform as "platform"
+        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->ancestors_for_test($target_test_id);
+
+    $results = $db->query_and_fetch_all('SELECT trigconfig_triggerable AS "triggerable", trigconfig_test AS "test"
+        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' => $triggerable, 'test' => $test_id, 'platform' => $platform_id);
+    }
+
+    return NULL;
+}
+
 ?>
index 54fad0e..52e35a8 100644 (file)
@@ -88,7 +88,9 @@ class ReportProcessor {
         if (!$platform_id)
             $this->exit_with_error('FailedToInsertPlatform', array('name' => $report['platform']));
 
-        $build_id = $this->resolve_build_id($build_data, array_get($report, 'revisions', array()));
+        // FIXME: Deprecate and unsupport "jobId".
+        $build_id = $this->resolve_build_id($build_data, array_get($report, 'revisions', array()),
+            array_get($report, 'jobId') or array_get($report, 'buildRequest'));
 
         $this->runs->commit($platform_id, $build_id);
     }
@@ -111,7 +113,7 @@ class ReportProcessor {
             $this->exit_with_error('FailedToStoreRunReport');
     }
 
-    private function resolve_build_id($build_data, $revisions) {
+    private function resolve_build_id($build_data, $revisions, $build_request_id) {
         // FIXME: This code has a race condition. See <rdar://problem/15876303>.
         $results = $this->db->query_and_fetch_all("SELECT build_id, build_slave FROM builds
             WHERE build_builder = $1 AND build_number = $2 AND build_time <= $3 AND build_time + interval '1 day' > $3",
@@ -126,6 +128,13 @@ class ReportProcessor {
         if (!$build_id)
             $this->exit_with_error('FailedToInsertBuild', $build_data);
 
+        if ($build_request_id) {
+            if ($db->update_row('build_requests', 'request', array('id' => $build_request_id), array('status' => 'completed', 'build' => $build_id))
+                != $build_request_id)
+                $this->exit_with_error('FailedToUpdateBuildRequest', array('buildRequest' => $build_request_id, 'build' => $build_id));
+        }
+
+
         foreach ($revisions as $repository_name => $revision_data) {
             $repository_id = $this->db->select_or_insert_row('repositories', 'repository', array('name' => $repository_name));
             if (!$repository_id)
index 47896bf..5c875a3 100644 (file)
@@ -76,7 +76,7 @@ class TestNameResolver {
                 array_set_default($this->test_id_to_child_metrics, $parent_id, array());
                 $parent_metrics = &$this->test_id_to_child_metrics[$parent_id];
                 if (!in_array($metric_row['metric_name'], $parent_metrics))
-                    array_push($parent_metrics, $metric_row['metric_name']);
+                    array_push($parent_metrics, $metric_row);
             }
         }
         return $test_to_metrics;
@@ -129,6 +129,18 @@ class TestNameResolver {
         $metric_configurations = array_get($this->metric_to_configurations, $metric_id, array());
         return array_get($metric_configurations, $platform_id);
     }
+
+    function test_exists_on_platform($test_id, $platform_id) {
+        foreach ($this->metrics_for_test_id($test_id) as $metric) {
+            if ($this->configurations_for_metric_and_platform($metric['metric_id'], $platform_id))
+                return TRUE;
+        }
+        foreach ($this->child_metrics_for_test_id($test_id) as $metric) {
+            if ($this->configurations_for_metric_and_platform($metric['metric_id'], $platform_id))
+                return TRUE;
+        }
+        return FALSE;
+    }
 }
 
 ?>
diff --git a/Websites/perf.webkit.org/public/include/test-path-resolver.php b/Websites/perf.webkit.org/public/include/test-path-resolver.php
new file mode 100644 (file)
index 0000000..a0270fa
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+
+class TestPathResolver {
+    function __construct($db) {
+        $this->db = $db;
+        $this->id_to_test_map = NULL;
+    }
+
+    function ancestors_for_test($test_id) {
+        $id_to_test = $this->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->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->id_to_test_map == NULL) {
+            $map = array();
+            foreach ($this->db->fetch_table('tests') as $row)
+                $map[$row['test_id']] = $row;
+            $this->id_to_test_map = $map;
+        }
+        return $this->id_to_test_map;
+    }
+}
+
+?>
diff --git a/Websites/perf.webkit.org/public/privileged-api/create-test-group.php b/Websites/perf.webkit.org/public/privileged-api/create-test-group.php
new file mode 100644 (file)
index 0000000..d1c7560
--- /dev/null
@@ -0,0 +1,95 @@
+<?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 < 1)
+        exit_with_error('InvalidRepetitionCount', array('repetitionCount' => $repetition_count));
+
+    $db = connect();
+    $task = $db->select_first_row('analysis_tasks', 'task', array('id' => $task_id));
+    if (!$task)
+        exit_with_error('InvalidTask', array('task' => $task_id));
+    $triggerable = find_triggerable_for_task($db, $task_id);
+    if (!$triggerable)
+        exit_with_error('TriggerableNotFoundForTask', array('task' => $task_id));
+
+    $commit_sets = commit_sets_from_root_sets($db, $root_sets);
+
+    $db->begin_transaction();
+
+    $root_set_id_list = array();
+    foreach ($commit_sets as $commit_list) {
+        $root_set_id = $db->insert_row('root_sets', 'rootset', array());
+        foreach ($commit_list as $commit)
+            $db->insert_row('roots', 'root', array('set' => $root_set_id, 'commit' => $commit), 'commit');
+        array_push($root_set_id_list, $root_set_id);
+    }
+
+    $group_id = $db->insert_row('analysis_test_groups', 'testgroup',
+        array('task' => $task['task_id'], 'name' => $name, 'author' => $author));
+
+    $order = 0;
+    for ($i = 0; $i < $repetition_count; $i++) {
+        foreach ($root_set_id_list as $root_set_id) {
+            $db->insert_row('build_requests', 'request', array(
+                'triggerable' => $triggerable['id'],
+                'platform' => $triggerable['platform'],
+                'test' => $triggerable['test'],
+                'group' => $group_id,
+                'order' => $order,
+                'root_set' => $root_set_id));
+            $order++;
+        }
+    }
+
+    $db->commit_transaction();
+
+    exit_with_success(array('testGroupId' => $group_id));
+}
+
+function commit_sets_from_root_sets($db, $root_sets) {
+    $repository_name_to_id = array();
+    foreach ($db->fetch_table('repositories') as $row)
+        $repository_name_to_id[$row['repository_name']] = $row['repository_id'];
+
+    $commit_sets = array();
+    foreach ($root_sets as $repository_name => $revisions) {
+        $repository_id = array_get($repository_name_to_id, $repository_name);
+        if (!$repository_id)
+            exit_with_error('RepositoryNotFound', array('name' => $repository_name));
+
+        foreach ($revisions as $i => $revision) {
+            $commit = $db->select_first_row('commits', 'commit', array('repository' => $repository_id, 'revision' => $revision));
+            if (!$commit)
+                exit_with_error('RevisionNotFound', array('repository' => $repository_name, 'revision' => $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' => $root_sets));
+    }
+
+    return $commit_sets;
+}
+
+main();
+
+?>
index eb90ea0..067d0d6 100644 (file)
@@ -6,9 +6,16 @@ App.AnalysisTask = App.NameLabelModel.extend({
     startRun: DS.attr('number'),
     endRun: DS.attr('number'),
     bugs: DS.hasMany('bugs'),
-    testGroups: function () {
+    testGroups: function (key, value, oldValue) {
         return this.store.find('testGroup', {task: this.get('id')});
     }.property(),
+    triggerable: function () {
+        return this.store.find('triggerable', {task: this.get('id')}).then(function (triggerables) {
+            return triggerables.objectAt(0);
+        }, function () {
+            return null;
+        });
+    }.property(),
     label: function () {
         var label = this.get('name');
         var bugs = this.get('bugs').map(function (bug) { return bug.get('label'); }).join(' / ');
@@ -63,8 +70,32 @@ App.TestGroup = App.NameLabelModel.extend({
     author: DS.attr('string'),
     createdAt: DS.attr('date'),
     buildRequests: DS.hasMany('buildRequests'),
+    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'),
 });
 
+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']);
+    });
+}
+
 App.TestGroupAdapter = DS.RESTAdapter.extend({
     buildURL: function (type, id)
     {
@@ -72,7 +103,18 @@ App.TestGroupAdapter = DS.RESTAdapter.extend({
     },
 });
 
-App.AnalysisTaskSerializer = App.TestGroupSerializer = DS.RESTSerializer.extend({
+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({
     normalizePayload: function (payload)
     {
         delete payload['status'];
@@ -81,11 +123,32 @@ App.AnalysisTaskSerializer = App.TestGroupSerializer = DS.RESTSerializer.extend(
 });
 
 App.BuildRequest = App.Model.extend({
-    group: DS.belongsTo('testGroup'),
+    testGroup: DS.belongsTo('testGroup'),
     order: DS.attr('number'),
+    orderLabel: function ()
+    {
+        return this.get('order') + 1;
+    }.property('order'),
     rootSet: DS.attr('number'),
+    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'),
     build: DS.attr('number'),
-    buildNumber: DS.attr('number'),
-    buildBuilder: DS.belongsTo('builder'),
-    buildTime: DS.attr('date'),
 });
index 5aa89a0..74d0654 100755 (executable)
@@ -783,9 +783,11 @@ App.AnalysisTaskController = Ember.Controller.extend({
     label: Ember.computed.alias('model.name'),
     platform: Ember.computed.alias('model.platform'),
     metric: Ember.computed.alias('model.metric'),
+    testGroups: Ember.computed.alias('model.testGroups'),
     testSets: [],
     roots: [],
     bugTrackers: [],
+    possibleRepetitionCounts: [1, 2, 3, 4, 5, 6],
     _taskUpdated: function ()
     {
         var model = this.get('model');
@@ -878,11 +880,11 @@ App.AnalysisTaskController = Ember.Controller.extend({
         });
 
     }.observes('testSets.@each.selection'),
-    roots: function ()
+    updateRoots: function ()
     {
         var analysisPoints = this.get('analysisPoints');
         if (!analysisPoints)
-            return [];
+            return;
         var repositoryToRevisions = {};
         analysisPoints.forEach(function (point, pointIndex) {
             var revisions = point.measurement.formattedRevisions();
@@ -897,23 +899,28 @@ App.AnalysisTaskController = Ember.Controller.extend({
             }
         });
 
-        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]}),
-                ],
+        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]}),
+                    ],
+                });
             }));
-        }
-        return roots;
-    }.property('analysisPoints'),
+        });
+    }.observes('analysisPoints'),
     actions: {
         associateBug: function (bugTracker, bugNumber)
         {
@@ -924,6 +931,16 @@ App.AnalysisTaskController = Ember.Controller.extend({
                 }, function (error) {
                     alert('Failed to associate the bug: ' + error);
                 });
-        }
+        },
+        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 () {
+                
+            });
+        },
     },
 });
index 1023d14..237141b 100755 (executable)
                     </table>
                 </div>
             </section>
-
             {{#each testGroups}}
                 <section class="analysis-group">
                     <table>
                         <caption>{{name}}</caption>
                         <thead>
                             <tr>
+                                <td>Order</td>
                                 <td>Configuration</td>
+                                <td>Status</td>
                                 <td>Build</td>
-                                <td>Build Time</td>
                                 <td>{{../metric.fullName}}</td>
                             </tr>
                         </thead>
                         <tbody>
                             {{#each buildRequests}}
                                 <tr>
-                                    <td>{{id}}</td>
-                                    <td>{{buildNumber}}</td>
-                                    <td>{{buildTime}}</td>
+                                    <td>{{orderLabel}}</td>
+                                    <td>{{config}}</td>
+                                    <td>{{#if url}}{{#link-to url}}{{statusLabel}}{{/link-to}}{{else}}{{statusLabel}}{{/if}}</td>
+                                    <td>{{build}}</td>
                                     <td>{{mean}}</td>
                                 </tr>
                             {{/each}}
                 </section>
             {{/each}}
 
-            <form class="analysis-group">
+            {{#if roots}}
+            <form method="POST" {{action "createTestGroup" newTestGroupName repetitionCount on="submit"}} class="analysis-group">
                 <table>
-                    <caption><input name="name" placeholder="Test group name" required></caption>
+                    <caption>{{input name="name" value=newTestGroupName placeholder="Test group name" required=true type="text"}}</caption>
                     <thead>
                         <tr>
                             <th>Root</th>
                             <tr>
                                 <th>{{name}}</th>
                                 {{#each sets}}
-                                    <td>{{view Ember.Select name=name content=revisions
+                                    <td>{{view Ember.Select name=name content=revisions disabled=true
                                         optionValuePath="content.value" optionLabelPath="content.label"
                                         selection=selection}}</td>
                                 {{/each}}
                         <tr>
                             <th>Number of runs</th>
                             <td colspan=2>
-                                <select>
-                                    <option>1</option>
-                                    <option>2</option>
-                                    <option>3</option>
-                                    <option>4</option>
-                                    <option>5</option>
-                                    <option>6</option>
-                                </select>
+                                {{view Ember.Select content=possibleRepetitionCounts value=repetitionCount}}
                             </td>
                         </tr>
                     </tbody>
 
                 <button type="submit">Start A/B testing</button>
             </form>
+            {{/if}}
         {{/if}}
     </script>
 
diff --git a/Websites/perf.webkit.org/tools/sync-with-buildbot.py b/Websites/perf.webkit.org/tools/sync-with-buildbot.py
new file mode 100755 (executable)
index 0000000..8ecc777
--- /dev/null
@@ -0,0 +1,183 @@
+#!/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. "https://build.webkit.org/"')
+    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 "platform", "test", and "builder" '
+        'with the platform name (e.g. mountainlion), the test path (e.g. ["Parser", "html5-full-render"]), 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']) < 1:
+                print "Scheduling the build request %s..." % 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 "Found stale build requests %s..." % ', '.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 >> sys.stderr, "Failed to fetch pending builds for %s: %s" % (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 >> sys.stderr, "Failed to fetch build %d for %s: %s" % (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 >> 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 >> sys.stderr, "Failed to fetch pending builds for %s: %s" % (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', "Basic %s" % 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__ == "__main__":
+    main()