Perf dashboard should provide a way to associate bugs with a test run
authorrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Wed, 22 Oct 2014 00:53:39 +0000 (00:53 +0000)
committerrniwa@webkit.org <rniwa@webkit.org@268f45cc-cd09-0410-ab3c-d52691b4dbfc>
Wed, 22 Oct 2014 00:53:39 +0000 (00:53 +0000)
https://bugs.webkit.org/show_bug.cgi?id=137857

Reviewed by Andreas Kling.

Added a "privileged" API, /privileged-api/associate-bug, to associate a bug with a test run.
/privileged-api/ is to be protected by an authentication mechanism such as DigestAuth over https by
the Apache configuration.

The Cross Site Request (CSRF) Forgery prevention for privileged APIs work as follows. When a user is
about to make a privileged API access, the front end code obtains a CSRF token generated by POST'ing
to privileged-api/generate-csrf-token; the page sets a randomly generated salt and an expiration time
via the cookie and returns a token computed from those two values as well as the remote username.

The font end code then POST's the request along with the returned token. The server side code verifies
that the specified token can be generated from the salt and the expiration time set in the cookie, and
the token hasn't expired.

* init-database.sql: Added bug_url to bug_trackers table, and added bugs table. Each bug tracker will
have zero or exactly one bug associated with a test run.

* public/admin/bug-trackers.php: Added the support for editing bug_url.
* public/api/runs.php:
(fetch_runs_for_config): Modified the query to fetch bugs associated with test_runs.
(parse_bugs_array): Added. Parses the aggregated bugs and creates a dictionary that maps a tracker id to
an associated bug if there is one.
(format_run): Calls parse_bugs_array.

* public/include/json-header.php: Added helper functions to deal for CSRF prevention.
(ensure_privileged_api_data): Added. Dies immediately if the request's method is not POST or doesn't
have a valid JSON payload.
(ensure_privileged_api_data_and_token): Ditto. Also checks that the CSRF prevention token is valid.
(compute_token): Computes a CSRF token using the REMOTE_USER (e.g. set via BasicAuth), the salt, and
the expiration time stored in the cookie.
(verify_token): Returns true iff the specified token matches what compute_token returns from the cookie.

* public/include/manifest.php:
(ManifestGenerator::bug_trackers): Include bug_url as bugUrl in the manifest. Also use tracker_id instead
of tracker_name as the key in the manifest. This requires changes to both v1 and v2 front end.

* public/index.html:
(Chart..showTooltipWithResults): Updated for the manifest format changed mentioned above.

* public/privileged-api/associate-bug.php: Added.
(main): Added. Associates or dissociates a bug with a test run inside a transaction. It prevent a CSRF
attack via ensure_privileged_api_data_and_token, which calls verify_token.

* public/privileged-api/generate-csrf-token.php: Added. Generates a CSRF token valid for one hour.

* public/v2/app.css:
(.disabled .icon-button:hover g): Used by the "bugs" icon when a range of points or no points are
selected in a chart.

* public/v2/app.js:
(App.PaneController.actions.toggleBugsPane): Added. Toggles the visibility of the bugs pane when exactly
one point is selected in the chart. Also hides the search pane when making the bugs pane visible since
they would overlap on each other if both of them are shown.
(App.PaneController.actions.associateBug): Makes a privileged API request to associate the specified bug
with the currently selected point (test run). Updates the bug information in "details" and colors of dots
in the charts to reflect new states. Because chart data objects aren't real Ember objects for performance
reasons, we have to use a dirty hack of modifying a dummy counter bugsChangeCount.
(App.PaneController.actions.toggleSearchPane): Renamed from toggleSearch. Also hides the bugs pane when
showing the search pane.
(App.PaneController.actions.rangeChanged): Takes all selected points as the second argument instead of
taking start and end points as the second and the third arguments so that _showDetails can enumerate all
bugs in the selected range.

(App.PaneController._detailsChanged): Added. Hide the bugs pane whenever a new point is selected.
Also update singlySelectedPoint, which is used by toggleBugsPane and associateBug.
(App.PaneController._currentItemChanged): Updated for the _showDetails change.
(App.PaneController._showDetails): Takes an array of selected points in place of old arguments.
Simplified the code to compute the revision information. Calls _updateBugs to format the associated bugs.
(App.PaneController._updateBugs): Sets details.bugTrackers to a dictionary that maps a bug tracker id to
a bug tracker proxy with an array of (bugNumber, bugUrl) pairs and also editedBugNumber, which is used by
the bugs pane to associate or dissociate a bug number, if exactly one point is selected.

(App.InteractiveChartComponent._updateDotsWithBugs): Added. Sets hasBugs class on dots as needed.
(App.InteractiveChartComponent._setCurrentSelection): Finds and passes all points in the selected range
to selectionChanged action instead of just finding the first and the last points.

* public/v2/chart-pane.css: Updated the style.

* public/v2/data.js:
(PrivilegedAPI): Added. A wrapper for privileged APIs' CSRF tokens.
(PrivilegedAPI.sendRequest): Makes a privileged API call. Fetches a new CSRF token if needed.
(PrivilegedAPI._generateTokenInServerIfNeeded): Makes a request to privileged-api/generate-csrf-token if
we haven't already obtained a CSRF token or if the token has already been expired.
(PrivilegedAPI._post): Makes a single POST request to /privileged-api/* with a JSON payload.

(Measurement.prototype.bugs): Added.
(Measurement.prototype.hasBugs): Returns true iff bugs has more than one bug number.
(Measurement.prototype.associateBug): Associates a bug with a test run via privileged-api/associate-bug.

* public/v2/index.html: Added the bugs pane. Also added a list of bugs associated with the current run in
the details.

* public/v2/manifest.js:
(App.BugTracker.bugUrl):
(App.BugTracker.newBugUrl): Added.
(App.BugTracker.repositories): Added. This was a missing back reference to repositories.
(App.MetricSerializer.normalizePayload): Now parses/loads the list of bug trackers from the manifest.
(App.Manifest.repositoriesWithReportedCommits): Now initialized to an empty array instead of null.
(App.Manifest.bugTrackers): Added.
(App.Manifest._fetchedManifest): Sets App.Manifest.bugTrackers. Also sorts the list of repositories by
their respective ids to make the ordering stable.

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

15 files changed:
Websites/perf.webkit.org/ChangeLog
Websites/perf.webkit.org/init-database.sql
Websites/perf.webkit.org/public/admin/bug-trackers.php
Websites/perf.webkit.org/public/api/runs.php
Websites/perf.webkit.org/public/include/json-header.php
Websites/perf.webkit.org/public/include/manifest.php
Websites/perf.webkit.org/public/index.html
Websites/perf.webkit.org/public/privileged-api/associate-bug.php [new file with mode: 0644]
Websites/perf.webkit.org/public/privileged-api/generate-csrf-token.php [new file with mode: 0644]
Websites/perf.webkit.org/public/v2/app.css
Websites/perf.webkit.org/public/v2/app.js
Websites/perf.webkit.org/public/v2/chart-pane.css
Websites/perf.webkit.org/public/v2/data.js
Websites/perf.webkit.org/public/v2/index.html
Websites/perf.webkit.org/public/v2/manifest.js

index b9db737a0a0c71c9a7c20dc845199eac74045d81..b90e78794b2530e7c329387dc35b3d473951afe0 100644 (file)
@@ -1,3 +1,113 @@
+2014-10-18  Ryosuke Niwa  <rniwa@webkit.org>
+
+        Perf dashboard should provide a way to associate bugs with a test run
+        https://bugs.webkit.org/show_bug.cgi?id=137857
+
+        Reviewed by Andreas Kling.
+
+        Added a "privileged" API, /privileged-api/associate-bug, to associate a bug with a test run.
+        /privileged-api/ is to be protected by an authentication mechanism such as DigestAuth over https by
+        the Apache configuration.
+
+
+        The Cross Site Request (CSRF) Forgery prevention for privileged APIs work as follows. When a user is
+        about to make a privileged API access, the front end code obtains a CSRF token generated by POST'ing
+        to privileged-api/generate-csrf-token; the page sets a randomly generated salt and an expiration time
+        via the cookie and returns a token computed from those two values as well as the remote username.
+
+        The font end code then POST's the request along with the returned token. The server side code verifies
+        that the specified token can be generated from the salt and the expiration time set in the cookie, and
+        the token hasn't expired.
+
+
+        * init-database.sql: Added bug_url to bug_trackers table, and added bugs table. Each bug tracker will
+        have zero or exactly one bug associated with a test run.
+
+        * public/admin/bug-trackers.php: Added the support for editing bug_url.
+        * public/api/runs.php:
+        (fetch_runs_for_config): Modified the query to fetch bugs associated with test_runs.
+        (parse_bugs_array): Added. Parses the aggregated bugs and creates a dictionary that maps a tracker id to
+        an associated bug if there is one.
+        (format_run): Calls parse_bugs_array.
+
+        * public/include/json-header.php: Added helper functions to deal for CSRF prevention.
+        (ensure_privileged_api_data): Added. Dies immediately if the request's method is not POST or doesn't
+        have a valid JSON payload.
+        (ensure_privileged_api_data_and_token): Ditto. Also checks that the CSRF prevention token is valid.
+        (compute_token): Computes a CSRF token using the REMOTE_USER (e.g. set via BasicAuth), the salt, and
+        the expiration time stored in the cookie.
+        (verify_token): Returns true iff the specified token matches what compute_token returns from the cookie.
+
+        * public/include/manifest.php:
+        (ManifestGenerator::bug_trackers): Include bug_url as bugUrl in the manifest. Also use tracker_id instead
+        of tracker_name as the key in the manifest. This requires changes to both v1 and v2 front end.
+
+        * public/index.html:
+        (Chart..showTooltipWithResults): Updated for the manifest format changed mentioned above.
+
+        * public/privileged-api/associate-bug.php: Added.
+        (main): Added. Associates or dissociates a bug with a test run inside a transaction. It prevent a CSRF
+        attack via ensure_privileged_api_data_and_token, which calls verify_token.
+
+        * public/privileged-api/generate-csrf-token.php: Added. Generates a CSRF token valid for one hour.
+
+        * public/v2/app.css:
+        (.disabled .icon-button:hover g): Used by the "bugs" icon when a range of points or no points are
+        selected in a chart.
+
+        * public/v2/app.js:
+        (App.PaneController.actions.toggleBugsPane): Added. Toggles the visibility of the bugs pane when exactly
+        one point is selected in the chart. Also hides the search pane when making the bugs pane visible since
+        they would overlap on each other if both of them are shown.
+        (App.PaneController.actions.associateBug): Makes a privileged API request to associate the specified bug
+        with the currently selected point (test run). Updates the bug information in "details" and colors of dots
+        in the charts to reflect new states. Because chart data objects aren't real Ember objects for performance
+        reasons, we have to use a dirty hack of modifying a dummy counter bugsChangeCount.
+        (App.PaneController.actions.toggleSearchPane): Renamed from toggleSearch. Also hides the bugs pane when
+        showing the search pane.
+        (App.PaneController.actions.rangeChanged): Takes all selected points as the second argument instead of
+        taking start and end points as the second and the third arguments so that _showDetails can enumerate all
+        bugs in the selected range.
+
+        (App.PaneController._detailsChanged): Added. Hide the bugs pane whenever a new point is selected.
+        Also update singlySelectedPoint, which is used by toggleBugsPane and associateBug.
+        (App.PaneController._currentItemChanged): Updated for the _showDetails change.
+        (App.PaneController._showDetails): Takes an array of selected points in place of old arguments.
+        Simplified the code to compute the revision information. Calls _updateBugs to format the associated bugs.
+        (App.PaneController._updateBugs): Sets details.bugTrackers to a dictionary that maps a bug tracker id to
+        a bug tracker proxy with an array of (bugNumber, bugUrl) pairs and also editedBugNumber, which is used by
+        the bugs pane to associate or dissociate a bug number, if exactly one point is selected.
+
+        (App.InteractiveChartComponent._updateDotsWithBugs): Added. Sets hasBugs class on dots as needed.
+        (App.InteractiveChartComponent._setCurrentSelection): Finds and passes all points in the selected range
+        to selectionChanged action instead of just finding the first and the last points.
+
+        * public/v2/chart-pane.css: Updated the style.
+
+        * public/v2/data.js:
+        (PrivilegedAPI): Added. A wrapper for privileged APIs' CSRF tokens.
+        (PrivilegedAPI.sendRequest): Makes a privileged API call. Fetches a new CSRF token if needed.
+        (PrivilegedAPI._generateTokenInServerIfNeeded): Makes a request to privileged-api/generate-csrf-token if
+        we haven't already obtained a CSRF token or if the token has already been expired.
+        (PrivilegedAPI._post): Makes a single POST request to /privileged-api/* with a JSON payload.
+
+        (Measurement.prototype.bugs): Added.
+        (Measurement.prototype.hasBugs): Returns true iff bugs has more than one bug number.
+        (Measurement.prototype.associateBug): Associates a bug with a test run via privileged-api/associate-bug.
+
+        * public/v2/index.html: Added the bugs pane. Also added a list of bugs associated with the current run in
+        the details.
+
+        * public/v2/manifest.js:
+        (App.BugTracker.bugUrl):
+        (App.BugTracker.newBugUrl): Added.
+        (App.BugTracker.repositories): Added. This was a missing back reference to repositories.
+        (App.MetricSerializer.normalizePayload): Now parses/loads the list of bug trackers from the manifest.
+        (App.Manifest.repositoriesWithReportedCommits): Now initialized to an empty array instead of null.
+        (App.Manifest.bugTrackers): Added.
+        (App.Manifest._fetchedManifest): Sets App.Manifest.bugTrackers. Also sorts the list of repositories by
+        their respective ids to make the ordering stable.
+
 2014-10-14  Ryosuke Niwa  <rniwa@webkit.org>
 
         Remove unused jobs table
index 743f48d4c9555dd056f3cce0d6b5a3f28498a4da..e527ab0ac84076ab7c0e931aff267456edfbc433 100644 (file)
@@ -29,6 +29,7 @@ CREATE TABLE repositories (
 CREATE TABLE bug_trackers (
     tracker_id serial PRIMARY KEY,
     tracker_name varchar(64) NOT NULL,
+    tracker_bug_url varchar(1024),
     tracker_new_bug_url varchar(1024));
 
 CREATE TABLE tracker_repositories (
@@ -128,3 +129,12 @@ CREATE TABLE reports (
     report_content text,
     report_failure varchar(64),
     report_failure_details text);
+
+CREATE TABLE bugs (
+    bug_id serial PRIMARY KEY,
+    bug_run integer REFERENCES test_runs NOT NULL,
+    bug_tracker integer REFERENCES bug_trackers NOT NULL,
+    bug_number integer NOT NULL,
+    CONSTRAINT bug_tracker_and_run_must_be_unique UNIQUE(bug_tracker, bug_run));
+CREATE INDEX bugs_tracker_number_index ON bugs(bug_tracker, bug_number);
+CREATE INDEX bugs_run_index ON bugs(bug_run);
index b25cd63c3919c6234445ebf1ed3bc5baa43465b2..b704e514c0a5446677c3c7ebcaab2389f78b14e9 100644 (file)
@@ -11,7 +11,9 @@ if ($db) {
         } else
             notice('Could not add the bug tracker.');
     } else if ($action == 'update') {
-        if (update_field('bug_trackers', 'tracker', 'name') || update_field('bug_trackers', 'tracker', 'new_bug_url'))
+        if (update_field('bug_trackers', 'tracker', 'name')
+            || update_field('bug_trackers', 'tracker', 'bug_url')
+            || update_field('bug_trackers', 'tracker', 'new_bug_url'))
             regenerate_manifest();
         else
             notice('Invalid parameters.');
@@ -63,6 +65,7 @@ END;
 
     $page = new AdministrativePage($db, 'bug_trackers', 'tracker', array(
         'name' => array('editing_mode' => 'string'),
+        'bug_url' => array('editing_mode' => 'url', 'label' => 'Bug URL ($number)'),
         'new_bug_url' => array('editing_mode' => 'text', 'label' => 'New Bug URL ($title, $description)'),
         'Associated repositories' => array('custom' => function ($row) { return associated_repositories($row); }),
     ));
index a059848f5914630e55301ff4d9b33c862c9844cb..ff5766b798f4f51e688debc441e44507f455ca9f 100644 (file)
@@ -30,10 +30,14 @@ if ($repository_table = $db->fetch_table('repositories')) {
 
 function fetch_runs_for_config($db, $config) {
     $raw_runs = $db->query_and_fetch_all('
-    SELECT test_runs.*, builds.*, array_agg((commit_repository, commit_revision, commit_time)) AS revisions
-        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
-        GROUP BY build_id, run_id', array($config['config_id']));
+        SELECT test_runs.*, builds.*, array_agg((commit_repository, commit_revision, commit_time)) AS revisions
+            FROM builds LEFT OUTER JOIN build_commits ON commit_build = build_id
+                LEFT OUTER JOIN commits ON build_commit = commit_id,
+                (SELECT test_runs.*, array_agg((bug_tracker, bug_number)) AS bugs
+                    FROM test_runs LEFT OUTER JOIN bugs ON bug_run = run_id WHERE run_config = $1 GROUP BY run_id) as test_runs
+                WHERE run_build = build_id
+                GROUP BY run_id, run_config, run_build, run_mean_cache, run_iteration_count_cache,
+                    run_sum_cache, run_square_sum_cache, bugs, build_id', array($config['config_id']));
 
     $formatted_runs = array();
     if (!$raw_runs)
@@ -62,6 +66,19 @@ function parse_revisions_array($postgres_array) {
     return $revisions;
 }
 
+function parse_bugs_array($postgres_array) {
+    // e.g. {"(1 /* Bugzilla */, 12345)","(2 /* Radar */, 67890)"}
+    $outer_array = json_decode('[' . trim($postgres_array, '{}') . ']');
+    $bugs = array();
+    foreach ($outer_array as $item) {
+        $raw_data = explode(',', trim($item, '()'));
+        if (!$raw_data[0])
+            continue;
+        $bugs[trim($raw_data[0], '"')] = trim($raw_data[1], '"');
+    }
+    return $bugs;
+}
+
 function format_run($run) {
     return array(
         'id' => intval($run['run_id']),
@@ -70,6 +87,7 @@ function format_run($run) {
         'sum' => floatval($run['run_sum_cache']),
         'squareSum' => floatval($run['run_square_sum_cache']),
         'revisions' => parse_revisions_array($run['revisions']),
+        'bugs' => parse_bugs_array($run['bugs']),
         'buildTime' => strtotime($run['build_time']) * 1000,
         'buildNumber' => intval($run['build_number']),
         'builder' => $run['build_builder']);
index fcd259830585e92e401b2d2716e571c2308d7c99..3504319445d95051951c2cf91c7c7ab0feb9cdf4 100644 (file)
@@ -69,4 +69,42 @@ function require_existence_of($array, $list_of_arguments, $prefix = '') {
     }
 }
 
+function ensure_privileged_api_data() {
+    global $HTTP_RAW_POST_DATA;
+
+    if ($_SERVER['REQUEST_METHOD'] != 'POST')
+        exit_with_error('InvalidRequestMethod');
+
+    if (!isset($HTTP_RAW_POST_DATA))
+        exit_with_error('InvalidRequestContent');
+
+    $data = json_decode($HTTP_RAW_POST_DATA, true);
+
+    if ($data === NULL)
+        exit_with_error('InvalidRequestContent');
+
+    return $data;
+}
+
+function ensure_privileged_api_data_and_token() {
+    $data = ensure_privileged_api_data();
+    if (!verify_token(array_get($data, 'token')))
+        exit_with_error('InvalidToken');
+    return $data;
+}
+
+function compute_token() {
+    if (!array_key_exists('CSRFSalt', $_COOKIE) || !array_key_exists('CSRFExpiration', $_COOKIE))
+        return NULL;
+    $user = array_get($_SERVER, 'REMOTE_USER');
+    $salt = $_COOKIE['CSRFSalt'];
+    $expiration = $_COOKIE['CSRFExpiration'];
+    return hash('sha256', "$salt|$user|$expiration");
+}
+
+function verify_token($token) {
+    $expected_token = compute_token();
+    return $expected_token && $token == $expected_token && $_COOKIE['CSRFExpiration'] > time();
+}
+
 ?>
index 269b3a84c8d8b7bce5adc8ee103d3b410b314dfe..4678e95dba8a07486a98ab7b83de4370289c1500 100644 (file)
@@ -139,7 +139,10 @@ class ManifestGenerator {
         $bug_trackers_table = $this->db->fetch_table('bug_trackers');
         if ($bug_trackers_table) {
             foreach ($bug_trackers_table as $row) {
-                $bug_trackers[$row['tracker_name']] = array('newBugUrl' => $row['tracker_new_bug_url'],
+                $bug_trackers[$row['tracker_id']] = array(
+                    'name' => $row['tracker_name'],
+                    'bugUrl' => $row['tracker_bug_url'],
+                    'newBugUrl' => $row['tracker_new_bug_url'],
                     'repositories' => $tracker_id_to_repositories[$row['tracker_id']]);
             }
         }
index dd8b8ae2492c55545157a7290fb40a2470c24016..b37c27129316a54612d93a41420baf7825ba7ecc 100644 (file)
@@ -634,8 +634,9 @@ td, th {
                     + ' around ' + result.build().formattedTime();
                 var revisions = result.build().formattedRevisions(resultToCompare.build());
 
-                for (var trackerName in bugTrackers) {
-                    var repositories = bugTrackers[trackerName].repositories;
+                for (var trackerId in bugTrackers) {
+                    var tracker = bugTrackers[trackerId];
+                    var repositories = tracker.repositories;
                     var description = 'Platform: ' + result.build().platform().name + '\n\n';
                     for (var i = 0; i < repositories.length; ++i) {
                         var repositoryName = repositories[i];
@@ -648,13 +649,13 @@ td, th {
                             description += revision.label;
                         description += '\n';
                     }
-                    var url = bugTrackers[trackerName].newBugUrl
+                    var url = tracker.newBugUrl
                         .replace(/\$title/g, encodeURIComponent(title))
                         .replace(/\$description/g, encodeURIComponent(description))
                         .replace(/\$link/g, encodeURIComponent(location.href));
                     if (newBugUrls)
                         newBugUrls += ',';
-                    newBugUrls += ' <a href="' + url + '" target="_blank">' + trackerName + '</a>';
+                    newBugUrls += ' <a href="' + url + '" target="_blank">' + tracker.name + '</a>';
                 }
                 newBugUrls = 'File:' + newBugUrls;
             }
diff --git a/Websites/perf.webkit.org/public/privileged-api/associate-bug.php b/Websites/perf.webkit.org/public/privileged-api/associate-bug.php
new file mode 100644 (file)
index 0000000..6734005
--- /dev/null
@@ -0,0 +1,39 @@
+<?php
+
+require_once('../include/json-header.php');
+
+function main() {
+    $data = ensure_privileged_api_data_and_token();
+
+    $run_id = array_get($data, 'run');
+    $bug_tracker_id = array_get($data, 'tracker');
+    $bug_number = array_get($data, 'bugNumber');
+
+    if (!$run_id)
+        exit_with_error('InvalidRunId', array('run' => $run_id));
+    if (!$bug_tracker_id)
+        exit_with_error('InvalidBugTrackerId', array('tracker' => $bug_tracker_id));
+
+    $db = connect();
+    $db->begin_transaction();
+
+    $bug_id = NULL;
+    if (!$bug_number) {
+        $count = $db->query_and_get_affected_rows("DELETE FROM bugs WHERE bug_run = $1 AND bug_tracker = $2",
+            array($run_id, $bug_tracker_id));
+        if ($count > 1) {
+            $db->rollback_transaction();
+            exit_with_error('UnexpectedNumberOfAffectedRows', array('affectedRows' => $count));
+        }
+    } else {
+        $bug_id = $db->update_or_insert_row('bugs', 'bug', array('run' => $run_id, 'tracker' => $bug_tracker_id),
+            array('run' => $run_id, 'tracker' => $bug_tracker_id, 'number' => $bug_number));
+    }
+    $db->commit_transaction();
+
+    exit_with_success(array('bug_id' => $bug_id));
+}
+
+main();
+
+?>
diff --git a/Websites/perf.webkit.org/public/privileged-api/generate-csrf-token.php b/Websites/perf.webkit.org/public/privileged-api/generate-csrf-token.php
new file mode 100644 (file)
index 0000000..45d57a5
--- /dev/null
@@ -0,0 +1,18 @@
+<?php
+
+require_once('../include/json-header.php');
+
+ensure_privileged_api_data();
+
+$user = array_get($_SERVER, 'REMOTE_USER');
+
+$expiritaion = time() + 3600; // Valid for one hour.
+$_COOKIE['CSRFSalt'] = rand();
+$_COOKIE['CSRFExpiration'] = $expiritaion;
+
+setcookie('CSRFSalt', $_COOKIE['CSRFSalt']);
+setcookie('CSRFExpiration', $expiritaion);
+
+exit_with_success(array('user' => $user, 'token' => compute_token(), 'expiration' => $expiritaion * 1000));
+
+?>
index a9938e091b062f4f7e12742004d426366adf95c6..bb33db9beab181d50380d71abe8c5bcec34e547b 100755 (executable)
@@ -125,6 +125,9 @@ body {
 .icon-button:hover g {
     stroke: #666;
 }
+.disabled .icon-button:hover g {
+    stroke: #ccc;
+}
 
 
 #header {
index c34cd56f5913f2856b2ffc64f1ecfa52143af81d..ee7dd3ebd408abd98c99886041b922b5bab4c4e7 100755 (executable)
@@ -664,6 +664,7 @@ App.PaneController = Ember.ObjectController.extend({
     sharedTime: Ember.computed.alias('parentController.sharedTime'),
     sharedSelection: Ember.computed.alias('parentController.sharedSelection'),
     selection: null,
+    bugsChangeCount: 0, // Dirty hack. Used to call InteractiveChartComponent's _updateDotsWithBugs.
     actions: {
         toggleDetails: function()
         {
@@ -673,14 +674,33 @@ App.PaneController = Ember.ObjectController.extend({
         {
             this.parentController.removePane(this.get('model'));
         },
-        toggleSearch: function ()
+        toggleBugsPane: function ()
+        {
+            if (!App.Manifest.bugTrackers || !this.get('singlySelectedPoint'))
+                return;
+            if (this.toggleProperty('showingBugsPane'))
+                this.set('showingSearchPane', false);
+        },
+        associateBug: function (bugTracker, bugNumber)
+        {
+            var point = this.get('singlySelectedPoint');
+            if (!point)
+                return;
+            var self = this;
+            point.measurement.associateBug(bugTracker.get('id'), bugNumber).then(function () {
+                self._updateBugs();
+                self.set('bugsChangeCount', self.get('bugsChangeCount') + 1);
+            });
+        },
+        toggleSearchPane: function ()
         {
             if (!App.Manifest.repositoriesWithReportedCommits)
                 return;
             var model = this.get('model');
             if (!model.get('commitSearchRepository'))
                 model.set('commitSearchRepository', App.Manifest.repositoriesWithReportedCommits[0]);
-            this.toggleProperty('showingSearchPane');
+            if (this.toggleProperty('showingSearchPane'))
+                this.set('showingBugsPane', false);
         },
         searchCommit: function () {
             var model = this.get('model');
@@ -697,19 +717,24 @@ App.PaneController = Ember.ObjectController.extend({
             this.set('intrinsicDomain', intrinsicDomain);
             this.get('parentController').updateSharedDomain();
         },
-        rangeChanged: function (extent, startPoint, endPoint)
+        rangeChanged: function (extent, points)
         {
-            if (!startPoint || !endPoint) {
+            if (!points) {
                 this._hasRange = false;
                 this.set('details', null);
                 this.set('timeRange', null);
                 return;
             }
             this._hasRange = true;
-            this._showDetails(startPoint.measurement, endPoint.measurement, false);
+            this._showDetails(points);
             this.set('timeRange', extent);
         },
     },
+    _detailsChanged: function ()
+    {
+        this.set('showingBugsPane', false);
+        this.set('singlySelectedPoint', !this._hasRange && this._selectedPoints ? this._selectedPoints[0] : null);
+    }.observes('details'),
     _overviewSelectionChanged: function ()
     {
         var overviewSelection = this.get('overviewSelection');
@@ -745,29 +770,25 @@ App.PaneController = Ember.ObjectController.extend({
         if (!point || !point.measurement)
             this.set('details', null);
         else
-            this._showDetails(point.series.previousPoint(point).measurement, point.measurement, true);
+            this._showDetails([point]);
     }.observes('currentItem'),
-    _showDetails: function (oldMeasurement, currentMeasurement, isShowingEndPoint)
+    _showDetails: function (points)
     {
-        var revisions = [];
-
+        var isShowingEndPoint = !this._hasRange;
+        var currentMeasurement = points[0].measurement;
+        var oldMeasurement = points[points.length - 1].measurement;
         var formattedRevisions = currentMeasurement.formattedRevisions(oldMeasurement);
-        var repositoryNames = [];
-        for (var repositoryName in formattedRevisions)
-            repositoryNames.push(repositoryName);
-        var revisions = [];
-        repositoryNames.sort().forEach(function (repositoryName) {
-            var revision = formattedRevisions[repositoryName];
-            var repository = App.Manifest.repository(repositoryName);
-            revision['url'] = false;
-            if (repository) {
-                revision['url'] = revision.previousRevision
-                    ? repository.urlForRevisionRange(revision.previousRevision, revision.currentRevision)
-                    : repository.urlForRevision(revision.currentRevision);
-            }
+        var revisions = App.Manifest.get('repositories')
+            .filter(function (repository) { return formattedRevisions[repository.get('id')]; })
+            .map(function (repository) {
+            var repositoryName = repository.get('id');
+            var revision = Ember.Object.create(formattedRevisions[repositoryName]);
+            revision['url'] = revision.previousRevision
+                ? repository.urlForRevisionRange(revision.previousRevision, revision.currentRevision)
+                : repository.urlForRevision(revision.currentRevision);
             revision['name'] = repositoryName;
             revision['repository'] = repository;
-            revisions.push(Ember.Object.create(revision));            
+            return revision; 
         });
 
         var buildNumber = null;
@@ -778,14 +799,48 @@ App.PaneController = Ember.ObjectController.extend({
             if (builder)
                 buildURL = builder.urlFromBuildNumber(buildNumber);
         }
-        this.set('details', {
+
+        this._selectedPoints = points;
+        this.set('details', Ember.Object.create({
             currentValue: currentMeasurement.mean().toFixed(2),
             oldValue: oldMeasurement && !isShowingEndPoint ? oldMeasurement.mean().toFixed(2) : null,
             buildNumber: buildNumber,
             buildURL: buildURL,
             buildTime: currentMeasurement.formattedBuildTime(),
             revisions: revisions,
+        }));
+        this._updateBugs();
+    },
+    _updateBugs: function ()
+    {
+        if (!this._selectedPoints)
+            return;
+
+        var bugTrackers = App.Manifest.get('bugTrackers');
+        var trackerToBugNumbers = {};
+        bugTrackers.forEach(function (tracker) { trackerToBugNumbers[tracker.get('id')] = new Array(); });
+        this._selectedPoints.map(function (point) {
+            var bugs = point.measurement.bugs();
+            bugTrackers.forEach(function (tracker) {
+                var bugNumber = bugs[tracker.get('id')];
+                if (bugNumber)
+                    trackerToBugNumbers[tracker.get('id')].push(bugNumber);
+            });
         });
+
+        this.set('details.bugTrackers', App.Manifest.get('bugTrackers').map(function (tracker) {
+            var bugNumbers = trackerToBugNumbers[tracker.get('id')];
+            return Ember.ObjectProxy.create({
+                content: tracker,
+                bugs: bugNumbers.map(function (bugNumber) {
+                    return {
+                        bugNumber: bugNumber,
+                        bugUrl: bugNumber && tracker.get('bugUrl') ? tracker.get('bugUrl').replace(/\$number/g, bugNumber) : null
+                    };
+                }),
+                editedBugNumber: this._hasRange ? null : bugNumbers[0],
+            }); // FIXME: Create urls for new bugs.
+        }));
     }
 });
 
@@ -1063,6 +1118,7 @@ App.InteractiveChartComponent = Ember.Component.extend({
                 .attr("cx", function(measurement) { return xScale(measurement.time); })
                 .attr("cy", function(measurement) { return yScale(measurement.value); });
         });
+        this._updateDotsWithBugs();
         this._updateHighlightPositions();
 
         if (this._brush) {
@@ -1091,6 +1147,13 @@ App.InteractiveChartComponent = Ember.Component.extend({
             .style("z-index", "100")
             .text(this._yAxisUnit);
     },
+    _updateDotsWithBugs: function () {
+        if (!this.get('interactive'))
+            return;
+        this._dots.forEach(function (dot) {
+            dot.classed('hasBugs', function (point) { return !!point.measurement.hasBugs(); });
+        })
+    }.observes('bugsChangeCount'), // Never used for anything but to call this method :(
     _updateHighlightPositions: function () {
         var xScale = this._x;
         var yScale = this._y;
@@ -1424,22 +1487,12 @@ App.InteractiveChartComponent = Ember.Component.extend({
         if (this._brushExtent === newSelection)
             return;
 
+        var points = null;
         if (newSelection) {
-            var startPoint;
-            var endPoint;
-            for (var i = 0; i < this._currentTimeSeriesData.length; i++) {
-                var point = this._currentTimeSeriesData[i];
-                if (!startPoint) {
-                    if (point.time >= newSelection[0]) {
-                        if (point.time > newSelection[1])
-                            break;
-                        startPoint = point;
-                    }
-                } else if (point.time > newSelection[1])
-                    break;
-                if (point.time >= newSelection[0] && point.time <= newSelection[1])
-                    endPoint = point;
-            }
+            points = this._currentTimeSeriesData
+                .filter(function (point) { return point.time >= newSelection[0] && point.time <= newSelection[1]; });
+            if (!points.length)
+                points = null;
         }
 
         this._brushExtent = newSelection;
@@ -1447,7 +1500,7 @@ App.InteractiveChartComponent = Ember.Component.extend({
         this._updateSelectionToolbar();
 
         this.set('sharedSelection', newSelection);
-        this.sendAction('selectionChanged', newSelection, startPoint, endPoint);
+        this.sendAction('selectionChanged', newSelection, points);
     },
     _updateSelectionToolbar: function ()
     {
index 39f0e59076a324b475555275e1ef1c7abc8210d4..6384389eede43f0ebe5009cedee550a2a4744b72 100755 (executable)
     top: 0.55rem;
 }
 
+.chart-pane a.bugs-button {
+    display: inline-block;
+    position: absolute;
+    right: 1.85rem;
+    top: 0.55rem;
+}
+
 .chart-pane a.search-button {
     display: inline-block;
     position: absolute;
@@ -57,9 +64,8 @@
     top: 0.55rem;
 }
 
-.search-pane {
+.search-pane, .bugs-pane {
     position: absolute;
-    right: 0rem;
     top: 1.7rem;
     border: 1px solid #bbb;
     padding: 0;
     background: white;
 }
 
+.bugs-pane {
+    right: 1.3rem;
+}
+
+.bugs-pane table {
+    margin: 0.2rem;
+    font-size: 0.8rem;
+}
+
+.bugs-pane th {
+    font-weight: normal;
+}
+
+.search-pane {
+    right: 0rem;
+}
+
+.bugs-pane.hidden,
 .search-pane.hidden {
     display: none;
 }
 }
 
 .chart-pane .details-table th {
-    width: 4rem;
+    width: 7rem;
     text-align: right;
     font-weight: normal;
     padding: 0;
 }
 
+.chart-pane .details-table .bugs th {
+    font-weight: bold;
+}
+
 .chart-pane .details-table th:after {
     content: " : ";
 }
     stroke: none;
 }
 
+.chart .hasBugs {
+    fill: #33f;
+}
+
 .chart path.area {
     stroke: none;
     fill: #ccc;
index b95dc64b6668be0e0cdf009a20ecfdd27745d0ba..68317ce97cb500c7a1ed6758c33f40e9266e8903 100755 (executable)
@@ -1,5 +1,55 @@
 // We don't use DS.Model for these object types because we can't afford to process millions of them.
 
+var PrivilegedAPI = {
+    _token: null,
+    _expiration: null,
+    _maxNetworkLatency: 3 * 60 * 1000 /* 3 minutes */,
+};
+
+PrivilegedAPI.sendRequest = function (url, parameters)
+{
+    return this._generateTokenInServerIfNeeded().then(function (token) {
+        return PrivilegedAPI._post(url, $.extend({token: token}, parameters));
+    });
+}
+
+PrivilegedAPI._generateTokenInServerIfNeeded = function ()
+{
+    var self = this;
+    return new Ember.RSVP.Promise(function (resolve, reject) {
+        if (self._token && self._expiration > Date.now() + self._maxNetworkLatency)
+            resolve(self._token);
+
+        PrivilegedAPI._post('generate-csrf-token')
+            .then(function (result, reject) {
+                self._token = result['token'];
+                self._expiration = new Date(result['expiration']);
+                resolve(self._token);
+            }).catch(reject);
+    });
+}
+
+PrivilegedAPI._post = function (url, parameters)
+{
+    return new Ember.RSVP.Promise(function (resolve, reject) {
+        $.ajax({
+            url: '../privileged-api/' + url,
+            type: 'POST',
+            contentType: 'application/json',
+            data: parameters ? JSON.stringify(parameters) : '{}',
+            dataType: 'json',
+        }).done(function (data) {
+            if (data.status != 'OK')
+                reject(data.status);
+            else
+                resolve(data);
+        }).fail(function (xhr, status, error) {
+            console.log(xhr);
+            reject(xhr.status + (error ? ', ' + error : ''));
+        });
+    });
+}
+
 var CommitLogs = {
     _cachedCommitsByRepository: {}
 };
@@ -220,6 +270,34 @@ Measurement._formatDate = function (date)
     return date.toISOString().replace('T', ' ').replace(/\.\d+Z$/, '');
 }
 
+Measurement.prototype.bugs = function ()
+{
+    return this._raw['bugs'];
+}
+
+Measurement.prototype.hasBugs = function ()
+{
+    var bugs = this.bugs();
+    return bugs && Object.keys(bugs).length;
+}
+
+Measurement.prototype.associateBug = function (trackerId, bugNumber)
+{
+    var bugs = this._raw['bugs'];
+    trackerId = parseInt(trackerId);
+    bugNumber = bugNumber ? parseInt(bugNumber) : null;
+    return PrivilegedAPI.sendRequest('associate-bug', {
+        run: this.id(),
+        tracker: trackerId,
+        bugNumber: bugNumber,
+    }).then(function () {
+        if (bugNumber)
+            bugs[trackerId] = bugNumber;
+        else
+            delete bugs[trackerId];
+    });
+}
+
 function RunsData(rawData)
 {
     this._measurements = rawData.map(function (run) { return new Measurement(run); });
index b1c14e14577c5c2a3f5aea07432a762c0e2dc6bb..934ba8d62e613b45b71189fa8886c48852569eb3 100755 (executable)
                     {{metric.label}}
                     - {{ platform.name}}</h2>
                     <a href="#" title="Close" class="close-button" {{action "close"}}>{{partial "close-button"}}</a>
-                    {{if App.Manifest.repositoriesWithReportedCommits}}
-                        <a href="#" title="Search" class="search-button" {{action "toggleSearch"}}>{{partial "search-button"}}</a>
+                    {{#if App.Manifest.bugTrackers}}
+                        <a href="#" title="Bugs"
+                            {{bind-attr class=":bugs-button singlySelectedPoint::disabled"}}
+                            {{action "toggleBugsPane"}}>
+                            {{partial "bugs-button"}}
+                        </a>
+                    {{/if}}
+                    {{#if App.Manifest.repositoriesWithReportedCommits}}
+                        <a href="#" title="Search" class="search-button" {{action "toggleSearchPane"}}>{{partial "search-button"}}</a>
                     {{/if}}
                 </header>
 
                             sharedSelection=sharedSelection
                             selectionChanged="rangeChanged"
                             selectionIsLocked=timeRangeIsLocked
+                            bugsChangeCount=bugsChangeCount
                             zoom="zoomed"}}
                     {{else}}
                         {{#if failure}}
                     {{input action="searchCommit" placeholder="Name or email" value=commitSearchKeyword}}
                 </form>
 
+                <div {{bind-attr class=":bugs-pane showingBugsPane::hidden"}}>
+                    <table>
+                        {{#each details.bugTrackers}}
+                            <tr>
+                                <th>{{label}}</th>
+                                <td>
+                                    <form {{action "associateBug" this editedBugNumber on="submit"}}>
+                                        {{input type=text value=editedBugNumber}}
+                                    </form>
+                                </td>
+                            </tr>
+                        {{/each}}
+                    </table>
+                </div>
+
             </section>
         {{/each}}
     </script>
     <script type="text/x-handlebars" data-template-name="chart-details">
     <div class="details-table-container">
         <table class="details-table">
+            <tbody class="bugs">
+            {{#each details.bugTrackers}}
+                {{#if bugs}}
+                    <tr>
+                        <th>{{label}}</th>
+                        <td>
+                            {{#each bugs}}
+                                <a {{bind-attr href=bugUrl}} target="_blank">{{bugNumber}}</a>
+                            {{/each}}
+                        </td>
+                    </tr>
+                {{/if}}
+            {{/each}}
+            </tbody>
             <tr><th>Current</th><td>{{details.currentValue}} {{chartData.unit}}
             {{#if details.oldValue}}
                 (from {{details.oldValue}})
         </svg>
     </script>
 
+    <script type="text/x-handlebars" data-template-name="bugs-button">
+        <svg class="bugs-button icon-button" viewBox="0 0 100 100">
+            <g stroke="black" stroke-width="15">
+                <circle cx="50" cy="50" r="40" fill="transparent"/>
+                <line x1="50" y1="25" x2="50" y2="55"/>
+                <circle cx="50" cy="67.5" r="2.5" fill="transparent"/>
+            </g>
+        </svg>
+    </script>
+
     <script type="text/x-handlebars" data-template-name="search-button">
         <svg class="search-button icon-button" viewBox="0 0 100 100">
             <g stroke="black" stroke-width="15">
index 87284b043f042fce5f231bb9676408f01a65b6b0..f579d108e5a5816b52355063a84a2008990d5e8d 100755 (executable)
@@ -42,7 +42,9 @@ App.Builder = App.NameLabelModel.extend({
 });
 
 App.BugTracker = App.NameLabelModel.extend({
-    buildUrl: DS.attr('string'),
+    bugUrl: DS.attr('string'),
+    newBugUrl: DS.attr('string'),
+    repositories: DS.hasMany('repository'),
 });
 
 App.Platform = App.NameLabelModel.extend({
@@ -93,6 +95,7 @@ App.MetricSerializer = App.PlatformSerializer = DS.RESTSerializer.extend({
             }),
             metrics: this._normalizeIdMap(payload['metrics']),
             repositories: this._normalizeIdMap(payload['repositories']),
+            bugTrackers: this._normalizeIdMap(payload['bugTrackers']),
         };
 
         for (var testId in payload['tests']) {
@@ -144,11 +147,12 @@ App.MetricAdapter = DS.RESTAdapter.extend({
 App.Manifest = Ember.Controller.extend({
     platforms: null,
     topLevelTests: null,
+    repositories: [],
+    repositoriesWithReportedCommits: [],
+    bugTrackers: [],
     _platformById: {},
     _metricById: {},
     _builderById: {},
-    repositories: null,
-    repositoriesWithReportedCommits: null,
     _repositoryById: {},
     _fetchPromise: null,
     fetch: function ()
@@ -196,8 +200,10 @@ App.Manifest = Ember.Controller.extend({
         repositories.forEach(function (repository) {
             self._repositoryById[repository.get('id')] = repository;
         });
-        this.set('repositories', repositories);
+        this.set('repositories', repositories.sortBy('id'));
         this.set('repositoriesWithReportedCommits',
             repositories.filter(function (repository) { return repository.get('hasReportedCommits'); }));
+
+        this.set('bugTrackers', store.all('bugTracker').sortBy('name'));
     }
 }).create();