Merge "Centralized comment requests"
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index 36d6f0e..aca7a2e 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -19,6 +19,7 @@
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../core/gr-navigation/gr-navigation.html">
 <link rel="import" href="../../core/gr-reporting/gr-reporting.html">
+<link rel="import" href="../../diff/gr-comment-api/gr-comment-api.html">
 <link rel="import" href="../../diff/gr-diff/gr-diff.html">
 <link rel="import" href="../../diff/gr-diff-cursor/gr-diff-cursor.html">
 <link rel="import" href="../../shared/gr-button/gr-button.html">
@@ -410,6 +411,7 @@
         focus-on-move
         cursor-target-class="selected"></gr-cursor-manager>
     <gr-reporting id="reporting"></gr-reporting>
+    <gr-comment-api id="commentAPI"></gr-comment-api>
   </template>
   <script src="gr-file-list.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index c38b324..05e658f 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -161,6 +161,9 @@
         });
       }));
 
+      // Load all comments for the change.
+      promises.push(this.$.commentAPI.loadAll(this.changeNum));
+
       this._localPrefs = this.$.storage.getPreferences();
       promises.push(this._getDiffPreferences().then(prefs => {
         this.diffPrefs = prefs;
@@ -879,6 +882,9 @@
       console.log('Expanding diff', 1 + initialCount - paths.length, 'of',
           initialCount, ':', paths[0]);
       const diffElem = this._findDiffByPath(paths[0], diffElements);
+      diffElem.comments = this.$.commentAPI.getCommentsForPath(paths[0],
+          this.patchRange, this.projectConfig);
+
       const promises = [diffElem.reload()];
       if (this._isLoggedIn) {
         promises.push(this._reviewFile(paths[0]));
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index 4791dc3..fcf8c74 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -60,6 +60,12 @@
       stub('gr-diff', {
         reload() { return Promise.resolve(); },
       });
+      stub('gr-comment-api', {
+        loadAll() { return Promise.resolve(); },
+        getPaths() { return {}; },
+        getCommentsForPath() { return {meta: {}, left: [], right: []}; },
+      });
+
       element = fixture('basic');
       element.numFilesShown = 200;
       saveStub = sandbox.stub(element, '_saveReviewedState',
@@ -965,7 +971,7 @@
     const setupDiff = function(diff) {
       const mock = document.createElement('mock-diff-response');
       diff._diff = mock.diffResponse;
-      diff._comments = {
+      diff.comments = {
         left: [],
         right: [],
       };
@@ -1013,6 +1019,11 @@
       stub('gr-diff', {
         reload() { return Promise.resolve(); },
       });
+      stub('gr-comment-api', {
+        loadAll() { return Promise.resolve(); },
+        getPaths() { return {}; },
+        getCommentsForPath() { return {meta: {}, left: [], right: []}; },
+      });
       element = fixture('basic');
       element.numFilesShown = 75;
       element.selectedIndex = 0;
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.html b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.html
new file mode 100644
index 0000000..68e2ff8
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.html
@@ -0,0 +1,26 @@
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../../behaviors/gr-patch-set-behavior/gr-patch-set-behavior.html">
+<link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
+
+<dom-module id="gr-comment-api">
+  <template>
+    <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
+  </template>
+  <script src="gr-comment-api.js"></script>
+</dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
new file mode 100644
index 0000000..81e5371
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api.js
@@ -0,0 +1,174 @@
+// Copyright (C) 2017 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+(function() {
+  'use strict';
+
+  const PARENT = 'PARENT';
+
+  Polymer({
+    is: 'gr-comment-api',
+
+    properties: {
+      _changeNum: Number,
+      _comments: Object,
+      _drafts: Object,
+      _robotComments: Object,
+    },
+
+    behaviors: [
+      Gerrit.PatchSetBehavior,
+    ],
+
+    /**
+     * Load all comments (with drafts and robot comments) for the given change
+     * number. The returned promise resolves when the comments have loaded, but
+     * does not yield the comment data.
+     *
+     * @param {!number} changeNum
+     * @return {!Promise}
+     */
+    loadAll(changeNum) {
+      this._changeNum = changeNum;
+
+      // Reset comment arrays.
+      this._comments = undefined;
+      this._drafts = undefined;
+      this._robotComments = undefined;
+
+      const promises = [];
+      promises.push(this.$.restAPI.getDiffComments(changeNum)
+          .then(comments => { this._comments = comments; }));
+      promises.push(this.$.restAPI.getDiffRobotComments(changeNum)
+          .then(robotComments => { this._robotComments = robotComments; }));
+      promises.push(this.$.restAPI.getLoggedIn()
+          .then(loggedIn => {
+            if (!loggedIn) { return Promise.resolve({}); }
+            return this.$.restAPI.getDiffDrafts(changeNum);
+          })
+          .then(drafts => { this._drafts = drafts; }));
+
+      return Promise.all(promises);
+    },
+
+    /**
+     * Get an object mapping file paths to a boolean representing whether that
+     * path contains diff comments in the given patch set (including drafts and
+     * robot comments).
+     *
+     * Paths with comments are mapped to true, whereas paths without comments
+     * are not mapped.
+     *
+     * @param {!Object} patchRange The patch-range object containing patchNum
+     *     and basePatchNum properties to represent the range.
+     * @return {Object}
+     */
+    getPaths(patchRange) {
+      const responses = [this._comments, this._drafts, this._robotComments];
+      const commentMap = {};
+      for (const response of responses) {
+        for (const path in response) {
+          if (response.hasOwnProperty(path) &&
+              response[path].some(c => this._isInPatchRange(c, patchRange))) {
+            commentMap[path] = true;
+          }
+        }
+      }
+      return commentMap;
+    },
+
+    /**
+     * Get the comments (with drafts and robot comments) for a path and
+     * patch-range. Returns an object with left and right properties mapping to
+     * arrays of comments in on either side of the patch range for that path.
+     *
+     * @param {!string} path
+     * @param {!Object} patchRange The patch-range object containing patchNum
+     *     and basePatchNum properties to represent the range.
+     * @param {Object} opt_projectConfig Optional project config object to
+     *     include in the meta sub-object.
+     * @return {Object}
+     */
+    getCommentsForPath(path, patchRange, opt_projectConfig) {
+      const comments = this._comments[path] || [];
+      const drafts = this._drafts[path] || [];
+      const robotComments = this._robotComments[path] || [];
+
+      drafts.forEach(d => { d.__draft = true; });
+
+      const all = comments.concat(drafts).concat(robotComments);
+
+      const baseComments = all.filter(c =>
+          this._isInBaseOfPatchRange(c, patchRange));
+      const revisionComments = all.filter(c =>
+          this._isInRevisionOfPatchRange(c, patchRange));
+
+      return {
+        meta: {
+          changeNum: this._changeNum,
+          path,
+          patchRange,
+          projectConfig: opt_projectConfig,
+        },
+        left: baseComments,
+        right: revisionComments,
+      };
+    },
+
+    /**
+     * Whether the given comment should be included in the base side of the
+     * given patch range.
+     * @param {!Object} comment
+     * @param {!Object} range
+     * @return {boolean}
+     */
+    _isInBaseOfPatchRange(comment, range) {
+      // If the base of the range is the parent of the patch:
+      if (range.basePatchNum === PARENT &&
+          comment.side === PARENT &&
+          this.patchNumEquals(comment.patch_set, range.patchNum)) {
+        return true;
+      }
+      // If the base of the range is not the parent of the patch:
+      if (range.basePatchNum !== PARENT &&
+          comment.side !== PARENT &&
+          this.patchNumEquals(comment.patch_set, range.basePatchNum)) {
+        return true;
+      }
+      return false;
+    },
+
+    /**
+     * Whether the given comment should be included in the revision side of the
+     * given patch range.
+     * @param {!Object} comment
+     * @param {!Object} range
+     * @return {boolean}
+     */
+    _isInRevisionOfPatchRange(comment, range) {
+      return comment.side !== PARENT &&
+          this.patchNumEquals(comment.patch_set, range.patchNum);
+    },
+
+    /**
+     * Whether the given comment should be included in the given patch range.
+     * @param {!Object} comment
+     * @param {!Object} range
+     * @return {boolean}
+     */
+    _isInPatchRange(comment, range) {
+      return this._isInBaseOfPatchRange(comment, range) ||
+          this._isInRevisionOfPatchRange(comment, range);
+    },
+  });
+})();
diff --git a/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
new file mode 100644
index 0000000..1fdc872
--- /dev/null
+++ b/polygerrit-ui/app/elements/diff/gr-comment-api/gr-comment-api_test.html
@@ -0,0 +1,194 @@
+<!DOCTYPE html>
+<!--
+Copyright (C) 2017 The Android Open Source Project
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1.0, user-scalable=yes">
+<title>gr-comment-api</title>
+
+<script src="../../../bower_components/webcomponentsjs/webcomponents-lite.min.js"></script>
+<script src="../../../bower_components/web-component-tester/browser.js"></script>
+<link rel="import" href="../../../test/common-test-setup.html"/>
+
+<link rel="import" href="./gr-comment-api.html">
+
+<script>void(0);</script>
+
+<test-fixture id="basic">
+  <template>
+    <gr-comment-api></gr-comment-api>
+  </template>
+</test-fixture>
+
+<script>
+  suite('gr-comment-api tests', () => {
+    const PARENT = 'PARENT';
+
+    let element;
+    let sandbox;
+
+    setup(() => {
+      sandbox = sinon.sandbox.create();
+      element = fixture('basic');
+    });
+
+    teardown(() => { sandbox.restore(); });
+
+    test('loads logged-out', () => {
+      const changeNum = 1234;
+
+      sandbox.stub(element.$.restAPI, 'getLoggedIn')
+          .returns(Promise.resolve(false));
+      sandbox.stub(element.$.restAPI, 'getDiffComments')
+          .returns(Promise.resolve({
+            'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
+          }));
+      sandbox.stub(element.$.restAPI, 'getDiffRobotComments')
+          .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
+      sandbox.stub(element.$.restAPI, 'getDiffDrafts');
+
+      return element.loadAll(changeNum).then(() => {
+        assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
+            changeNum));
+        assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
+            changeNum));
+        assert.isFalse(element.$.restAPI.getDiffDrafts.called);
+        assert.isOk(element._comments);
+        assert.isOk(element._robotComments);
+        assert.deepEqual(element._drafts, {});
+      });
+    });
+
+    test('loads logged-in', () => {
+      const changeNum = 1234;
+
+      sandbox.stub(element.$.restAPI, 'getLoggedIn')
+          .returns(Promise.resolve(true));
+      sandbox.stub(element.$.restAPI, 'getDiffComments')
+          .returns(Promise.resolve({
+            'foo.c': [{id: '123', message: 'foo bar', in_reply_to: '321'}],
+          }));
+      sandbox.stub(element.$.restAPI, 'getDiffRobotComments')
+          .returns(Promise.resolve({'foo.c': [{id: '321', message: 'done'}]}));
+      sandbox.stub(element.$.restAPI, 'getDiffDrafts')
+          .returns(Promise.resolve({'foo.c': [{id: '555', message: 'ack'}]}));
+
+      return element.loadAll(changeNum).then(() => {
+        assert.isTrue(element.$.restAPI.getDiffComments.calledWithExactly(
+            changeNum));
+        assert.isTrue(element.$.restAPI.getDiffRobotComments.calledWithExactly(
+            changeNum));
+        assert.isTrue(element.$.restAPI.getDiffDrafts.calledWithExactly(
+            changeNum));
+        assert.isOk(element._comments);
+        assert.isOk(element._robotComments);
+        assert.notDeepEqual(element._drafts, {});
+      });
+    });
+
+    test('_isInBaseOfPatchRange', () => {
+      const comment = {patch_set: 1};
+      const patchRange = {basePatchNum: 1, patchNum: 2};
+      assert.isTrue(element._isInBaseOfPatchRange(comment, patchRange));
+
+      patchRange.basePatchNum = PARENT;
+      assert.isFalse(element._isInBaseOfPatchRange(comment, patchRange));
+
+      comment.side = PARENT;
+      assert.isFalse(element._isInBaseOfPatchRange(comment, patchRange));
+
+      comment.patch_set = 2;
+      assert.isTrue(element._isInBaseOfPatchRange(comment, patchRange));
+    });
+
+    test('_isInRevisionOfPatchRange', () => {
+      const comment = {patch_set: 123};
+      const patchRange = {basePatchNum: 122, patchNum: 124};
+      assert.isFalse(element._isInRevisionOfPatchRange(comment, patchRange));
+
+      patchRange.patchNum = 123;
+      assert.isTrue(element._isInRevisionOfPatchRange(comment, patchRange));
+
+      comment.side = PARENT;
+      assert.isFalse(element._isInRevisionOfPatchRange(comment, patchRange));
+    });
+
+    suite('comment ranges and paths', () => {
+      setup(() => {
+        element._changeNum = 1234;
+        element._drafts = {};
+        element._robotComments = {};
+        element._comments = {
+          'file/one': [
+            {patch_set: 2, side: PARENT},
+            {patch_set: 2},
+          ],
+          'file/two': [
+            {patch_set: 2},
+            {patch_set: 3},
+          ],
+          'file/three': [
+            {patch_set: 2, side: PARENT},
+            {patch_set: 3},
+          ],
+        };
+      });
+
+      test('getPaths', () => {
+        const patchRange = {basePatchNum: 1, patchNum: 4};
+        let paths = element.getPaths(patchRange);
+        assert.equal(Object.keys(paths).length, 0);
+
+        patchRange.basePatchNum = PARENT;
+        patchRange.patchNum = 3;
+        paths = element.getPaths(patchRange);
+        assert.notProperty(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+
+        patchRange.patchNum = 2;
+        paths = element.getPaths(patchRange);
+        assert.property(paths, 'file/one');
+        assert.property(paths, 'file/two');
+        assert.property(paths, 'file/three');
+      });
+
+      test('getCommentsForPath', () => {
+        const patchRange = {basePatchNum: 1, patchNum: 3};
+        let path = 'file/one';
+        let comments = element.getCommentsForPath(path, patchRange);
+        assert.equal(comments.meta.changeNum, 1234);
+        assert.equal(comments.left.length, 0);
+        assert.equal(comments.right.length, 0);
+
+        path = 'file/two';
+        comments = element.getCommentsForPath(path, patchRange);
+        assert.equal(comments.left.length, 0);
+        assert.equal(comments.right.length, 1);
+
+        patchRange.basePatchNum = 2;
+        comments = element.getCommentsForPath(path, patchRange);
+        assert.equal(comments.left.length, 1);
+        assert.equal(comments.right.length, 1);
+
+        patchRange.basePatchNum = PARENT;
+        path = 'file/three';
+        comments = element.getCommentsForPath(path, patchRange);
+        assert.equal(comments.left.length, 0);
+        assert.equal(comments.right.length, 1);
+      });
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
index 8be6e92..15a405a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -59,6 +59,7 @@
       // Register the diff with the cursor.
       cursorElement.push('diffs', diffElement);
 
+      diffElement.comments = {left: [], right: []};
       diffElement.$.restAPI.getDiffPreferences().then(prefs => {
         diffElement.prefs = prefs;
       });
@@ -67,19 +68,8 @@
         return Promise.resolve(mockDiffResponse.diffResponse);
       });
 
-      sandbox.stub(diffElement, '_getDiffComments', () => {
-        return Promise.resolve({baseComments: [], comments: []});
-      });
-
-      sandbox.stub(diffElement, '_getDiffDrafts', () => {
-        return Promise.resolve({baseComments: [], comments: []});
-      });
-
-      sandbox.stub(diffElement, '_getDiffRobotComments', () => {
-        return Promise.resolve({baseComments: [], comments: []});
-      });
-
       const setupDone = () => {
+        cursorElement._updateStops();
         cursorElement.moveToFirstChunk();
         diffElement.removeEventListener('render', setupDone);
         done();
@@ -218,12 +208,13 @@
       const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
       const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk');
 
-      diffElement.addEventListener('render', () => {
+      function renderHandler() {
+        diffElement.removeEventListener('render', renderHandler);
         assert.isFalse(moveToNumStub.called);
         assert.isTrue(moveToChunkStub.called);
         done();
-      });
-
+      }
+      diffElement.addEventListener('render', renderHandler);
       diffElement.reload();
     });
 
@@ -231,13 +222,15 @@
       const moveToNumStub = sandbox.stub(cursorElement, 'moveToLineNumber');
       const moveToChunkStub = sandbox.stub(cursorElement, 'moveToFirstChunk');
 
-      diffElement.addEventListener('render', () => {
+      function renderHandler() {
+        diffElement.removeEventListener('render', renderHandler);
         assert.isFalse(moveToChunkStub.called);
         assert.isTrue(moveToNumStub.called);
         assert.equal(moveToNumStub.lastCall.args[0], 10);
         assert.equal(moveToNumStub.lastCall.args[1], 'right');
         done();
-      });
+      }
+      diffElement.addEventListener('render', renderHandler);
 
       cursorElement.initialLineNumber = 10;
       cursorElement.side = 'right';
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
index 4bd469d..e39e6ec 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.html
@@ -24,6 +24,7 @@
 <link rel="import" href="../../shared/gr-fixed-panel/gr-fixed-panel.html">
 <link rel="import" href="../../shared/gr-rest-api-interface/gr-rest-api-interface.html">
 <link rel="import" href="../../shared/gr-select/gr-select.html">
+<link rel="import" href="../gr-comment-api/gr-comment-api.html">
 <link rel="import" href="../gr-diff-cursor/gr-diff-cursor.html">
 <link rel="import" href="../gr-diff-preferences/gr-diff-preferences.html">
 <link rel="import" href="../gr-diff/gr-diff.html">
@@ -337,6 +338,7 @@
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
     <gr-storage id="storage"></gr-storage>
     <gr-diff-cursor id="cursor"></gr-diff-cursor>
+    <gr-comment-api id="commentAPI"></gr-comment-api>
   </template>
   <script src="gr-diff-view.js"></script>
 </dom-module>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index db534b4..e4901f5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -21,6 +21,8 @@
 
   const COMMENT_SAVE = 'Try again when all comments have saved.';
 
+  const PARENT = 'PARENT';
+
   const DiffSides = {
     LEFT: 'left',
     RIGHT: 'right',
@@ -99,6 +101,8 @@
        */
       _commentMap: Object,
 
+      _commentsForDiff: Object,
+
       /**
        * Object to contain the path of the next and previous file in the current
        * change and patch range that has comments.
@@ -464,7 +468,7 @@
       this._changeNum = value.changeNum;
       this._patchRange = {
         patchNum: value.patchNum,
-        basePatchNum: value.basePatchNum || 'PARENT',
+        basePatchNum: value.basePatchNum || PARENT,
       };
       this._path = value.path;
 
@@ -493,14 +497,13 @@
 
       promises.push(this._getChangeDetail(this._changeNum));
 
+      promises.push(this._loadComments());
+
       Promise.all(promises).then(() => {
         this._loading = false;
+        this.$.diff.comments = this._commentsForDiff;
         this.$.diff.reload();
       });
-
-      this._loadCommentMap().then(commentMap => {
-        this._commentMap = commentMap;
-      });
     },
 
     _changeViewStateChanged(changeViewState) {
@@ -557,7 +560,7 @@
     _patchRangeStr(patchRange) {
       let patchStr = patchRange.patchNum;
       if (patchRange.basePatchNum != null &&
-          patchRange.basePatchNum != 'PARENT') {
+          patchRange.basePatchNum != PARENT) {
         patchStr = patchRange.basePatchNum + '..' + patchRange.patchNum;
       }
       return patchStr;
@@ -584,7 +587,7 @@
       for (const rev of Object.values(revisions || {})) {
         latestPatchNum = Math.max(latestPatchNum, rev._number);
       }
-      if (patchRange.basePatchNum !== 'PARENT' ||
+      if (patchRange.basePatchNum !== PARENT ||
           parseInt(patchRange.patchNum, 10) !== latestPatchNum) {
         patchNum = patchRange.patchNum;
         basePatchNum = patchRange.basePatchNum;
@@ -717,33 +720,12 @@
       return url;
     },
 
-    /**
-     * Request all comments (and drafts and robot comments) for the current
-     * change and construct the map of file paths that have comments for the
-     * current patch range.
-     * @return {Promise} A promise that yields a comment map object.
-     */
-    _loadCommentMap() {
-      const filterByRange = comment =>
-          this.patchNumEquals(comment.patch_set, this._patchRange.patchNum) ||
-            this.patchNumEquals(comment.patch_set,
-                this._patchRange.basePatchNum);
+    _loadComments() {
+      return this.$.commentAPI.loadAll(this._changeNum).then(() => {
+        this._commentMap = this.$.commentAPI.getPaths(this._patchRange);
 
-      return Promise.all([
-        this.$.restAPI.getDiffComments(this._changeNum),
-        this._getDiffDrafts(),
-        this.$.restAPI.getDiffRobotComments(this._changeNum),
-      ]).then(results => {
-        const commentMap = {};
-        for (const response of results) {
-          for (const path in response) {
-            if (response.hasOwnProperty(path) &&
-                response[path].filter(filterByRange).length) {
-              commentMap[path] = true;
-            }
-          }
-        }
-        return commentMap;
+        this._commentsForDiff = this.$.commentAPI.getCommentsForPath(this._path,
+            this._patchRange, this._projectConfig);
       });
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index afba895..8ff75c1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -56,7 +56,7 @@
         getDiffChangeDetail() { return Promise.resolve(null); },
         getChangeFiles() { return Promise.resolve({}); },
         saveFileReviewed() { return Promise.resolve(); },
-        getDiffRobotComments() { return Promise.resolve(); },
+        getDiffRobotComments() { return Promise.resolve({}); },
         getDiffDrafts() { return Promise.resolve(); },
       });
       element = fixture('basic');
@@ -639,57 +639,41 @@
       assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
     });
 
-    suite('_loadCommentMap', () => {
+    suite('_loadComments', () => {
       test('empty', done => {
-        stub('gr-rest-api-interface', {
-          getDiffComments() { return Promise.resolve({}); },
+        stub('gr-comment-api', {
+          loadAll() { return Promise.resolve(); },
+          getPaths() { return {}; },
+          getCommentsForPath() { return {meta: {}}; },
         });
-        element._loadCommentMap().then(map => {
-          assert.equal(Object.keys(map).length, 0);
+        element._loadComments().then(() => {
+          assert.equal(Object.keys(element._commentMap).length, 0);
           done();
         });
       });
 
-      test('paths in patch range', done => {
-        stub('gr-rest-api-interface', {
-          getDiffComments() {
-            return Promise.resolve({
+      test('has paths', done => {
+        stub('gr-comment-api', {
+          loadAll() { return Promise.resolve(); },
+          getPaths() {
+            return {
               'path/to/file/one.cpp': [{patch_set: 3, message: 'lorem'}],
               'path-to/file/two.py': [{patch_set: 5, message: 'ipsum'}],
-            });
+            };
           },
+          getCommentsForPath() { return {meta: {}}; },
         });
         element._changeNum = '42';
         element._patchRange = {
           basePatchNum: '3',
           patchNum: '5',
         };
-        element._loadCommentMap().then(map => {
-          assert.deepEqual(Object.keys(map),
+        element._loadComments().then(() => {
+          assert.deepEqual(Object.keys(element._commentMap),
               ['path/to/file/one.cpp', 'path-to/file/two.py']);
           done();
         });
       });
-
-      test('empty for paths outside patch range', done => {
-        stub('gr-rest-api-interface', {
-          getDiffComments() {
-            return Promise.resolve({
-              'path/to/file/one.cpp': [{patch_set: PARENT, message: 'lorem'}],
-              'path-to/file/two.py': [{patch_set: 2, message: 'ipsum'}],
-            });
-          },
-        });
-        element._changeNum = '42';
-        element._patchRange = {
-          basePatchNum: '3',
-          patchNum: '5',
-        };
-        element._loadCommentMap().then(map => {
-          assert.equal(Object.keys(map).length, 0);
-          done();
-        });
-      });
     });
 
     suite('_computeCommentSkips', () => {
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
index 43af472..a638b296 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.html
@@ -225,10 +225,10 @@
         <gr-diff-highlight
             id="highlights"
             logged-in="[[_loggedIn]]"
-            comments="{{_comments}}">
+            comments="{{comments}}">
           <gr-diff-builder
               id="diffBuilder"
-              comments="[[_comments]]"
+              comments="[[comments]]"
               diff="[[_diff]]"
               diff-path="[[path]]"
               view-mode="[[viewMode]]"
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index a0abec9..cbbc7e4 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -77,6 +77,7 @@
         reflectToAttribute: true,
       },
       noRenderOnPrefsChange: Boolean,
+      comments: Object,
       _loggedIn: {
         type: Boolean,
         value: false,
@@ -101,7 +102,6 @@
         type: String,
         value: '',
       },
-      _comments: Object,
       _baseImage: Object,
       _revisionImage: Object,
 
@@ -152,10 +152,6 @@
         return this._loadDiffAssets();
       }));
 
-      promises.push(this._getDiffCommentsAndDrafts().then(comments => {
-        this._comments = comments;
-      }));
-
       return Promise.all(promises).then(() => {
         if (this.prefs) {
           return this._renderDiffTable();
@@ -373,7 +369,7 @@
       const comment = e.detail.comment;
       const side = e.detail.comment.__commentSide;
       const idx = this._findDraftIndex(comment, side);
-      this.set(['_comments', side, idx], comment);
+      this.set(['comments', side, idx], comment);
     },
 
     _handleCommentUpdate(e) {
@@ -384,9 +380,9 @@
         idx = this._findDraftIndex(comment, side);
       }
       if (idx !== -1) { // Update draft or comment.
-        this.set(['_comments', side, idx], comment);
+        this.set(['comments', side, idx], comment);
       } else { // Create new draft.
-        this.push(['_comments', side], comment);
+        this.push(['comments', side], comment);
       }
     },
 
@@ -396,24 +392,24 @@
         idx = this._findDraftIndex(comment, side);
       }
       if (idx !== -1) {
-        this.splice('_comments.' + side, idx, 1);
+        this.splice('comments.' + side, idx, 1);
       }
     },
 
     _findCommentIndex(comment, side) {
-      if (!comment.id || !this._comments[side]) {
+      if (!comment.id || !this.comments[side]) {
         return -1;
       }
-      return this._comments[side].findIndex(item => {
+      return this.comments[side].findIndex(item => {
         return item.id === comment.id;
       });
     },
 
     _findDraftIndex(comment, side) {
-      if (!comment.__draftID || !this._comments[side]) {
+      if (!comment.__draftID || !this.comments[side]) {
         return -1;
       }
-      return this._comments[side].findIndex(item => {
+      return this.comments[side].findIndex(item => {
         return item.__draftID === comment.__draftID;
       });
     },
@@ -463,7 +459,7 @@
 
       this.updateStyles(stylesToUpdate);
 
-      if (this._diff && this._comments && !this.noRenderOnPrefsChange) {
+      if (this._diff && this.comments && !this.noRenderOnPrefsChange) {
         this._renderDiffTable();
       }
     },
@@ -477,7 +473,7 @@
       }
 
       this._showWarning = false;
-      return this.$.diffBuilder.render(this._comments, this._getBypassPrefs());
+      return this.$.diffBuilder.render(this.comments, this._getBypassPrefs());
     },
 
     /**
@@ -519,73 +515,6 @@
           });
     },
 
-    _getDiffComments() {
-      return this.$.restAPI.getDiffComments(
-          this.changeNum,
-          this.patchRange.basePatchNum,
-          this.patchRange.patchNum,
-          this.path);
-    },
-
-    _getDiffDrafts() {
-      return this._getLoggedIn().then(loggedIn => {
-        if (!loggedIn) {
-          return Promise.resolve({baseComments: [], comments: []});
-        }
-        return this.$.restAPI.getDiffDrafts(
-            this.changeNum,
-            this.patchRange.basePatchNum,
-            this.patchRange.patchNum,
-            this.path);
-      });
-    },
-
-    _getDiffRobotComments() {
-      return this.$.restAPI.getDiffRobotComments(
-          this.changeNum,
-          this.patchRange.basePatchNum,
-          this.patchRange.patchNum,
-          this.path);
-    },
-
-    _getDiffCommentsAndDrafts() {
-      const promises = [];
-      promises.push(this._getDiffComments());
-      promises.push(this._getDiffDrafts());
-      promises.push(this._getDiffRobotComments());
-      return Promise.all(promises).then(results => {
-        return Promise.resolve({
-          comments: results[0],
-          drafts: results[1],
-          robotComments: results[2],
-        });
-      }).then(this._normalizeDiffCommentsAndDrafts.bind(this));
-    },
-
-    _normalizeDiffCommentsAndDrafts(results) {
-      function markAsDraft(d) {
-        d.__draft = true;
-        return d;
-      }
-      const baseDrafts = results.drafts.baseComments.map(markAsDraft);
-      const drafts = results.drafts.comments.map(markAsDraft);
-
-      const baseRobotComments = results.robotComments.baseComments;
-      const robotComments = results.robotComments.comments;
-      return Promise.resolve({
-        meta: {
-          path: this.path,
-          changeNum: this.changeNum,
-          patchRange: this.patchRange,
-          projectConfig: this.projectConfig,
-        },
-        left: results.comments.baseComments.concat(baseDrafts)
-            .concat(baseRobotComments),
-        right: results.comments.comments.concat(drafts)
-            .concat(robotComments),
-      });
-    },
-
     _getLoggedIn() {
       return this.$.restAPI.getLoggedIn();
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index 921d285..8077e3a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -53,8 +53,6 @@
 
       // Stub the network calls into requests that never resolve.
       sandbox.stub(element, '_getDiff', () => new Promise(() => {}));
-      sandbox.stub(element, '_getDiffCommentsAndDrafts',
-          () => new Promise(() => {}));
 
       element.reload();
       assert.isTrue(cancelStub.called);
@@ -105,29 +103,6 @@
             element.$$('.diffContainer').classList.contains('displayLine'));
       });
 
-      test('get drafts', done => {
-        element.patchRange = {basePatchNum: 0, patchNum: 0};
-
-        const getDraftsStub = sandbox.stub(element.$.restAPI, 'getDiffDrafts');
-        element._getDiffDrafts().then(result => {
-          assert.deepEqual(result, {baseComments: [], comments: []});
-          sinon.assert.notCalled(getDraftsStub);
-          done();
-        });
-      });
-
-      test('get robot comments', done => {
-        element.patchRange = {basePatchNum: 0, patchNum: 0};
-
-        const getDraftsStub = sandbox.stub(element.$.restAPI,
-            'getDiffRobotComments');
-        element._getDiffDrafts().then(result => {
-          assert.deepEqual(result, {baseComments: [], comments: []});
-          sinon.assert.notCalled(getDraftsStub);
-          done();
-        });
-      });
-
       test('loads files weblinks', done => {
         sandbox.stub(element.$.restAPI, 'getDiff').returns(
             Promise.resolve({
@@ -149,7 +124,7 @@
       });
 
       test('remove comment', () => {
-        element._comments = {
+        element.comments = {
           meta: {
             changeNum: '42',
             patchRange: {
@@ -176,7 +151,7 @@
         element._removeComment({});
         // Using JSON.stringify because Safari 9.1 (11601.5.17.1) doesn’t seem
         // to believe that one object deepEquals another even when they do :-/.
-        assert.equal(JSON.stringify(element._comments), JSON.stringify({
+        assert.equal(JSON.stringify(element.comments), JSON.stringify({
           meta: {
             changeNum: '42',
             patchRange: {
@@ -202,7 +177,7 @@
 
         element._removeComment({id: 'bc2', side: 'PARENT',
           __commentSide: 'left'});
-        assert.deepEqual(element._comments, {
+        assert.deepEqual(element.comments, {
           meta: {
             changeNum: '42',
             patchRange: {
@@ -226,7 +201,7 @@
         });
 
         element._removeComment({id: 'd2', __commentSide: 'right'});
-        assert.deepEqual(element._comments, {
+        assert.deepEqual(element.comments, {
           meta: {
             changeNum: '42',
             patchRange: {
@@ -354,6 +329,7 @@
               () => Promise.resolve(mockComments)));
 
           element.patchRange = {basePatchNum: 'PARENT', patchNum: 1};
+          element.comments = {left: [], right: []};
         });
 
         test('renders image diffs with same file name', done => {
@@ -691,7 +667,7 @@
         const setupDiff = function() {
           const mock = document.createElement('mock-diff-response');
           element._diff = mock.diffResponse;
-          element._comments = {
+          element.comments = {
             left: [],
             right: [],
           };
@@ -747,102 +723,6 @@
         element = fixture('basic');
       });
 
-      test('get drafts', done => {
-        element.patchRange = {basePatchNum: 0, patchNum: 0};
-        const draftsResponse = {
-          baseComments: [{id: 'foo'}],
-          comments: [{id: 'bar'}],
-        };
-        sandbox.stub(element.$.restAPI, 'getDiffDrafts',
-            () => Promise.resolve(draftsResponse));
-        element._getDiffDrafts().then(result => {
-          assert.deepEqual(result, draftsResponse);
-          done();
-        });
-      });
-
-      test('get comments and drafts', done => {
-        const comments = {
-          baseComments: [
-            {id: 'bc1', __commentSide: 'left'},
-            {id: 'bc2', __commentSide: 'left'},
-          ],
-          comments: [
-            {id: 'c1', __commentSide: 'right'},
-            {id: 'c2', __commentSide: 'right'},
-          ],
-        };
-        sandbox.stub(element, '_getDiffComments',
-            () => Promise.resolve(comments));
-
-        const drafts = {
-          baseComments: [
-            {id: 'bd1', __commentSide: 'left'},
-            {id: 'bd2', __commentSide: 'left'},
-          ],
-          comments: [
-            {id: 'd1', __commentSide: 'right'},
-            {id: 'd2', __commentSide: 'right'},
-          ],
-        };
-
-        sandbox.stub(element, '_getDiffDrafts', () => Promise.resolve(drafts));
-
-        const robotComments = {
-          baseComments: [
-            {id: 'br1', __commentSide: 'left'},
-            {id: 'br2', __commentSide: 'left'},
-          ],
-          comments: [
-            {id: 'r1', __commentSide: 'right'},
-            {id: 'r2', __commentSide: 'right'},
-          ],
-        };
-
-        sandbox.stub(element,
-            '_getDiffRobotComments', () => Promise.resolve(robotComments));
-
-        element.changeNum = '42';
-        element.patchRange = {
-          basePatchNum: 'PARENT',
-          patchNum: 3,
-        };
-        element.path = '/path/to/foo';
-        element.projectConfig = {foo: 'bar'};
-
-        element._getDiffCommentsAndDrafts().then(result => {
-          assert.deepEqual(result, {
-            meta: {
-              changeNum: '42',
-              patchRange: {
-                basePatchNum: 'PARENT',
-                patchNum: 3,
-              },
-              path: '/path/to/foo',
-              projectConfig: {foo: 'bar'},
-            },
-            left: [
-              {id: 'bc1', __commentSide: 'left'},
-              {id: 'bc2', __commentSide: 'left'},
-              {id: 'bd1', __draft: true, __commentSide: 'left'},
-              {id: 'bd2', __draft: true, __commentSide: 'left'},
-              {id: 'br1', __commentSide: 'left'},
-              {id: 'br2', __commentSide: 'left'},
-            ],
-            right: [
-              {id: 'c1', __commentSide: 'right'},
-              {id: 'c2', __commentSide: 'right'},
-              {id: 'd1', __draft: true, __commentSide: 'right'},
-              {id: 'd2', __draft: true, __commentSide: 'right'},
-              {id: 'r1', __commentSide: 'right'},
-              {id: 'r2', __commentSide: 'right'},
-            ],
-          });
-
-          done();
-        });
-      });
-
       test('addDraftAtLine', done => {
         const fakeLineEl = {getAttribute: sandbox.stub().returns(42)};
         sandbox.stub(element, '_selectLine');
@@ -868,7 +748,7 @@
             change_type: 'MODIFIED',
             content: [{skip: 66}],
           };
-          element._comments = {
+          element.comments = {
             meta: {
               changeNum: '42',
               patchRange: {
@@ -912,7 +792,7 @@
 
       suite('handle comment-update', () => {
         setup(() => {
-          element._comments = {
+          element.comments = {
             meta: {
               changeNum: '42',
               patchRange: {
@@ -941,7 +821,7 @@
           const comment = {__draft: true, __draftID: 'tempID', side: 'PARENT',
             __commentSide: 'left'};
           element.fire('comment-update', {comment});
-          assert.include(element._comments.left, comment);
+          assert.include(element.comments.left, comment);
         });
 
         test('saving a draft', () => {
@@ -953,10 +833,10 @@
             side: 'PARENT',
             __commentSide: 'left',
           };
-          element._comments.left.push(comment);
+          element.comments.left.push(comment);
           comment.id = id;
           element.fire('comment-update', {comment});
-          const drafts = element._comments.left.filter(item => {
+          const drafts = element.comments.left.filter(item => {
             return item.__draftID === draftID;
           });
           assert.equal(drafts.length, 1);
@@ -1007,7 +887,7 @@
             () => Promise.resolve());
         const mock = document.createElement('mock-diff-response');
         element._diff = mock.diffResponse;
-        element._comments = {left: [], right: []};
+        element.comments = {left: [], right: []};
         element.noRenderOnPrefsChange = true;
       });
 
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index fd055c6..355a7b6 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -71,6 +71,7 @@
     'core/gr-router/gr-router_test.html',
     'core/gr-reporting/gr-reporting_test.html',
     'core/gr-search-bar/gr-search-bar_test.html',
+    'diff/gr-comment-api/gr-comment-api_test.html',
     'diff/gr-diff-builder/gr-diff-builder_test.html',
     'diff/gr-diff-comment-thread-group/gr-diff-comment-thread-group_test.html',
     'diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html',