Merge changes I51f888f8,I751f63ac,Ie3097ae4

* changes:
  ChangeScreen: Update token when 'Diff Against' selection is changed
  ChangeScreen: Keep 'Auto Merge' selected when navigating to diff and back
  Refactor: Add a new class to represent an object that can be diffed
diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt
index 004d32b..1c7981a 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -2461,6 +2461,10 @@
 +
 A link:http://lucene.apache.org/[Lucene] index is used.
 +
++
+* `ELASTICSEARCH`
++
+An link:http://www.elasticsearch.org/[Elasticsearch] index is used.
 
 +
 By default, `LUCENE`.
@@ -2585,6 +2589,43 @@
   maxBufferedDocs = 500
 ----
 
+
+==== Elasticsearch configuration
+
+WARNING: ElasticSearch implementation is incomplete. Right now it is
+still using parts of Lucene index.
+
+Open and closed changes are indexed in a single index, separated
+into types 'open_changes' and 'closed_changes' respectively.
+
+The following settings are only used when the index type is
+`ELASTICSEARCH`.
+
+[[index.protocol]]index.protocol::
++
+Elasticsearch server protocol [http|https].
++
+Defaults to `http`.
+
+[[index.hostname]]index.hostname::
++
+Elasticsearch server hostname.
+
+Defaults to `localhost`.
+
+[[index.port]]index.port::
++
+Elasticsearch server port.
++
+Defauls to `9200`.
+
+[[index.name]]index.name::
++
+This setting can be used to index changes from multiple Gerrit
+instances in a single Elasticsearch cluster.
++
+Defaults to 'gerrit'.
+
 [[ldap]]
 === Section ldap
 
diff --git a/Documentation/config-robot-comments.txt b/Documentation/config-robot-comments.txt
new file mode 100644
index 0000000..cf5de10
--- /dev/null
+++ b/Documentation/config-robot-comments.txt
@@ -0,0 +1,49 @@
+= Gerrit Code Review - Robot Comments
+
+Gerrit has special support for inline comments that are generated by
+automated third-party systems, so called "robot comments". For example
+robot comments can be used to represent the results of code analyzers.
+
+In contrast to regular inline comments which are free-text comments,
+robot comments are more structured and can contain additional data,
+such as a robot ID, a robot run ID and a URL, see
+link:rest-api-changes.html#robot-comment-info[RobotCommentInfo] for
+details.
+
+It is planned to visualize robot comments differently in the web UI so
+that they can be easily distinguished from human comments. Users should
+also be able to use filtering on robot comments, so that only part of
+the robot comments or no robot comments are shown. In addition it is
+planned that robot comments can contain fixes, that users can apply by
+a single click.
+
+== REST endpoints
+
+* Posting robot comments is done by the
+  link:rest-api-changes.html[Set Review] REST endpoint. The
+  link:rest-api-changes.html#review-input[input] for this REST endpoint
+  can contain robot comments in its `robot_comments` field.
+* link:rest-api-changes.html#list-robot-comments[List Robot Comments]
+* link:rest-api-changes.html#get-robot-comment[Get Robot Comment]
+
+== Storage
+
+Robot comments are stored per change in a
+`refs/changes/XX/YYYY/robot-comments` ref, where `XX/YYYY` is the
+sharded change ID.
+
+Robot comments can be dropped by deleting this ref.
+
+== Limitations
+
+* Robot comments are only supported with NoteDb, but not with ReviewDb.
+* Robot comments are not displayed in the web UI yet.
+* There is no support for draft robot comments, but robot comments are
+  always published and visible to everyone who can see the change.
+
+GERRIT
+------
+Part of link:index.html[Gerrit Code Review]
+
+SEARCHBOX
+---------
diff --git a/Documentation/index.txt b/Documentation/index.txt
index f53463c..06a416d 100644
--- a/Documentation/index.txt
+++ b/Documentation/index.txt
@@ -45,6 +45,7 @@
 . link:config-hooks.html[Hooks]
 . link:config-mail.html[Mail Templates]
 . link:config-cla.html[Contributor Agreements]
+. link:config-robot-comments.html[Robot Comments]
 
 == Server Administration
 . link:install.html[Installation Guide]
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 95fa1c9..88c1765 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -3904,6 +3904,102 @@
   }
 ----
 
+[[list-comments]]
+=== List Robot Comments
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/robotcomments/'
+--
+
+Lists the link:config-robot-comments.html[robot comments] of a
+revision.
+
+As result a map is returned that maps the file path to a list of
+link:#robot-comment-info[RobotCommentInfo] entries. The entries in the
+map are sorted by file path.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/robotcomments/ HTTP/1.0
+----
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "gerrit-server/src/main/java/com/google/gerrit/server/project/RefControl.java": [
+      {
+        "id": "TvcXrmjM",
+        "line": 23,
+        "message": "unused import",
+        "updated": "2016-02-26 15:40:43.986000000",
+        "author": {
+          "_account_id": 1000110,
+          "name": "Code Analyzer",
+          "email": "code.analyzer@example.com"
+        },
+        "robotId": "importChecker",
+        "robotRunId": "76b1375aa8626ea7149792831fe2ed85e80d9e04"
+      },
+      {
+        "id": "TveXwFiA",
+        "line": 49,
+        "message": "wrong indention",
+        "updated": "2016-02-26 15:40:45.328000000",
+        "author": {
+          "_account_id": 1000110,
+          "name": "Code Analyzer",
+          "email": "code.analyzer@example.com"
+        },
+        "robotId": "styleChecker",
+        "robotRunId": "5c606c425dd45184484f9d0a2ffd725a7607839b"
+      }
+    ]
+  }
+----
+
+[[get-robot-comment]]
+=== Get Robot Comment
+--
+'GET /changes/link:#change-id[\{change-id\}]/revisions/link:#revision-id[\{revision-id\}]/robotcomments/link:#comment-id[\{comment-id\}]'
+--
+
+Retrieves a link:config-robot-comments.html[robot comment] of a
+revision.
+
+.Request
+----
+  GET /changes/myProject~master~I8473b95934b5732ac55d26311a706c9c2bde9940/revisions/674ac754f91e64a0efb8087e59a176484bd534d1/robotcomments/TvcXrmjM HTTP/1.0
+----
+
+As response a link:#robot-comment-info[RobotCommentInfo] entity is
+returned that describes the robot comment.
+
+.Response
+----
+  HTTP/1.1 200 OK
+  Content-Disposition: attachment
+  Content-Type: application/json; charset=UTF-8
+
+  )]}'
+  {
+    "id": "TvcXrmjM",
+    "line": 23,
+    "message": "unused import",
+    "updated": "2016-02-26 15:40:43.986000000",
+    "author": {
+      "_account_id": 1000110,
+      "name": "Code Analyzer",
+      "email": "code.analyzer@example.com"
+    },
+    "robotId": "importChecker",
+    "robotRunId": "76b1375aa8626ea7149792831fe2ed85e80d9e04"
+  }
+----
+
 [[list-files]]
 === List Files
 --
@@ -5465,6 +5561,9 @@
 |`comments`               |optional|
 The comments that should be added as a map that maps a file path to a
 list of link:#comment-input[CommentInput] entities.
+|`robot_comments`         |optional|
+The robot comments that should be added as a map that maps a file path
+to a list of link:#robot-comment-input[RobotCommentInput] entities.
 |`strict_labels`          |`true` if not set|
 Whether all labels are required to be within the user's permitted ranges
 based on access controls. +
@@ -5591,6 +5690,29 @@
 certificate was provided, it is set to an empty object.
 |===========================
 
+[[robot-comment-info]]
+=== RobotCommentInfo
+The `RobotCommentInfo` entity contains information about a robot inline
+comment.
+
+`RobotCommentInfo` has the same fields as link:#[CommentInfo].
+In addition `RobotCommentInfo` has the following fields:
+
+[options="header",cols="1,^1,5"]
+|===========================
+|Field Name     ||Description
+|`robot_id`     ||The ID of the robot that generated this comment.
+|`robot_run_id` ||An ID of the run of the robot.
+|`url`          |optional|URL to more information.
+|===========================
+
+[[robot-comment-input]]
+=== RobotCommentInput
+The `RobotCommentInput` entity contains information for creating an inline
+robot comment.
+
+`RobotCommentInput` has the same fields as link:#[RobotCommentInfo].
+
 [[rule-input]]
 === RuleInput
 The `RuleInput` entity contains information to test a Prolog rule.
diff --git a/ReleaseNotes/ReleaseNotes-2.13.2.txt b/ReleaseNotes/ReleaseNotes-2.13.2.txt
new file mode 100644
index 0000000..87e902c
--- /dev/null
+++ b/ReleaseNotes/ReleaseNotes-2.13.2.txt
@@ -0,0 +1,34 @@
+= Release notes for Gerrit 2.13.2
+
+Gerrit 2.13.2 is now available:
+
+link:https://gerrit-releases.storage.googleapis.com/gerrit-2.13.2.war[
+https://gerrit-releases.storage.googleapis.com/gerrit-2.13.2.war]
+
+== Schema Upgrade
+
+There are no schema changes from link:ReleaseNotes-2.13.1.html[2.13.1].
+
+== Bug Fixes
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4630[Issue 4630]:
+Fix server error when navigating up to change while 'Working' is displayed.
+
+* link:https://bugs.chromium.org/p/gerrit/issues/detail?id=4631[Issue 4631]:
+Read project watches from database.
++
+Project watches were being read from the git backend by default, but the
+migration to git is not yet completed.
+
+* Hooks plugin: Fix incorrect value passed to `--change-url` parameter.
++
+The URL was being generated using the change's Change-Id rather than the
+change number.
+
+* Check for CLA when creating project config changes from the web UI.
++
+If contributor agreements were enabled and required for a project, and
+the user had not signed a CLA, it was still possible to upload changes
+for review on `refs/meta/config` by making changes in the project access
+editor and pressing 'Save for Review'.
+
diff --git a/ReleaseNotes/index.txt b/ReleaseNotes/index.txt
index 2938c1c6..945f09f 100644
--- a/ReleaseNotes/index.txt
+++ b/ReleaseNotes/index.txt
@@ -2,6 +2,7 @@
 
 [[s2_13]]
 == Version 2.13.x
+* link:ReleaseNotes-2.13.2.html[2.13.2]
 * link:ReleaseNotes-2.13.1.html[2.13.1]
 * link:ReleaseNotes-2.13.html[2.13]
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
new file mode 100644
index 0000000..064b206
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RobotCommentsIT.java
@@ -0,0 +1,130 @@
+// Copyright (C) 2016 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.
+
+package com.google.gerrit.acceptance.api.revision;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+import static com.google.gerrit.acceptance.PushOneCommit.FILE_NAME;
+
+import com.google.common.collect.Iterables;
+import com.google.gerrit.acceptance.AbstractDaemonTest;
+import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
+
+import org.junit.Test;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+public class RobotCommentsIT extends AbstractDaemonTest {
+  @Test
+  public void comments() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    RobotCommentInput in = createRobotCommentInput();
+    ReviewInput reviewInput = new ReviewInput();
+    Map<String, List<RobotCommentInput>> robotComments = new HashMap<>();
+    robotComments.put(in.path, Collections.singletonList(in));
+    reviewInput.robotComments = robotComments;
+    reviewInput.message = "comment test";
+    gApi.changes()
+       .id(r.getChangeId())
+       .current()
+       .review(reviewInput);
+
+    Map<String, List<RobotCommentInfo>> out = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .robotComments();
+    assertThat(out).hasSize(1);
+    RobotCommentInfo comment = Iterables.getOnlyElement(out.get(in.path));
+    assertRobotComment(comment, in, false);
+
+    List<RobotCommentInfo> list = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .robotCommentsAsList();
+    assertThat(list).hasSize(1);
+
+    RobotCommentInfo comment2 = list.get(0);
+    assertRobotComment(comment2, in);
+
+    RobotCommentInfo comment3 = gApi.changes()
+        .id(r.getChangeId())
+        .revision(r.getCommit().name())
+        .robotComment(comment.id)
+        .get();
+    assertRobotComment(comment3, in);
+  }
+
+  @Test
+  public void robotCommentsNotSupported() throws Exception {
+    assume().that(notesMigration.enabled()).isFalse();
+
+    PushOneCommit.Result r = createChange();
+    RobotCommentInput in = createRobotCommentInput();
+    ReviewInput reviewInput = new ReviewInput();
+    Map<String, List<RobotCommentInput>> robotComments = new HashMap<>();
+    robotComments.put(FILE_NAME, Collections.singletonList(in));
+    reviewInput.robotComments = robotComments;
+    reviewInput.message = "comment test";
+
+    exception.expect(MethodNotAllowedException.class);
+    exception.expectMessage("robot comments not supported");
+    gApi.changes()
+       .id(r.getChangeId())
+       .current()
+       .review(reviewInput);
+  }
+
+  private RobotCommentInput createRobotCommentInput() {
+    RobotCommentInput in = new RobotCommentInput();
+    in.robotId = "happyRobot";
+    in.robotRunId = "1";
+    in.url = "http://www.happy-robot.com";
+    in.line = 1;
+    in.message = "nit: trailing whitespace";
+    in.path = FILE_NAME;
+    return in;
+  }
+
+  private void assertRobotComment(RobotCommentInfo c,
+      RobotCommentInput expected) {
+    assertRobotComment(c, expected, true);
+  }
+
+  private void assertRobotComment(RobotCommentInfo c,
+      RobotCommentInput expected, boolean expectPath) {
+    assertThat(c.robotId).isEqualTo(expected.robotId);
+    assertThat(c.robotRunId).isEqualTo(expected.robotRunId);
+    assertThat(c.url).isEqualTo(expected.url);
+    assertThat(c.line).isEqualTo(expected.line);
+    assertThat(c.message).isEqualTo(expected.message);
+
+    assertThat(c.author.email).isEqualTo(admin.email);
+
+    if (expectPath) {
+      assertThat(c.path).isEqualTo(expected.path);
+    } else {
+      assertThat(c.path).isNull();
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/BUCK b/gerrit-elasticsearch/BUCK
new file mode 100644
index 0000000..a2641df
--- /dev/null
+++ b/gerrit-elasticsearch/BUCK
@@ -0,0 +1,51 @@
+java_library(
+  name = 'elasticsearch',
+  srcs = glob(['src/main/java/**/*.java']),
+  deps = [
+    '//gerrit-antlr:query_exception',
+    '//gerrit-extension-api:api',
+    '//gerrit-lucene:lucene', # only for LuceneAccountIndex
+    '//gerrit-reviewdb:client',
+    '//gerrit-reviewdb:server',
+    '//gerrit-server:server',
+    '//gerrit-index:index',
+    '//lib:gson',
+    '//lib:guava',
+    '//lib:gwtorm',
+    '//lib:protobuf',
+    '//lib/commons:codec',
+    '//lib/commons:lang',
+    '//lib/elasticsearch:elasticsearch',
+    '//lib/elasticsearch:jest',
+    '//lib/elasticsearch:jest-common',
+    '//lib/guice:guice',
+    '//lib/guice:guice-assistedinject',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/joda:joda-time',
+    '//lib/log:api',
+    '//lib/lucene:lucene-analyzers-common',
+    '//lib/lucene:lucene-core',
+  ],
+  visibility = ['PUBLIC'],
+)
+
+java_test(
+  name = 'elasticsearch_tests',
+  labels = ['elastic'],
+  srcs = glob(['src/test/java/**/*.java']),
+  deps = [
+    ':elasticsearch',
+    '//gerrit-extension-api:api',
+    '//gerrit-server:server',
+    '//gerrit-server:testutil',
+    '//gerrit-server:query_tests',
+    '//lib:gson',
+    '//lib:guava',
+    '//lib:junit',
+    '//lib:truth',
+    '//lib/elasticsearch:elasticsearch',
+    '//lib/guice:guice',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib/jgit/org.eclipse.jgit.junit:junit',
+  ],
+)
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
new file mode 100644
index 0000000..a46edc7
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/AbstractElasticIndex.java
@@ -0,0 +1,207 @@
+// Copyright (C) 2014 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.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkState;
+import static org.elasticsearch.common.xcontent.XContentFactory.jsonBuilder;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.Iterables;
+import com.google.gerrit.index.IndexUtils;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.FieldDef.FillArgs;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.Schema.Values;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+import org.eclipse.jgit.lib.Config;
+import org.elasticsearch.common.xcontent.XContentBuilder;
+
+import java.io.IOException;
+import java.net.MalformedURLException;
+import java.net.URL;
+import java.util.concurrent.TimeUnit;
+
+import io.searchbox.client.JestClientFactory;
+import io.searchbox.client.JestResult;
+import io.searchbox.client.config.HttpClientConfig;
+import io.searchbox.client.http.JestHttpClient;
+import io.searchbox.core.Bulk;
+import io.searchbox.core.Delete;
+import io.searchbox.indices.CreateIndex;
+import io.searchbox.indices.DeleteIndex;
+import io.searchbox.indices.IndicesExists;
+
+abstract class AbstractElasticIndex<K, V> implements Index<K, V> {
+  private static final String DEFAULT_INDEX_NAME = "gerrit";
+
+  private final Schema<V> schema;
+  private final FillArgs fillArgs;
+  private final SitePaths sitePaths;
+
+  protected final boolean refresh;
+  protected final String indexName;
+  protected final JestHttpClient client;
+
+
+  @Inject
+  AbstractElasticIndex(@GerritServerConfig Config cfg,
+      FillArgs fillArgs,
+      SitePaths sitePaths,
+      @Assisted Schema<V> schema) {
+    this.fillArgs = fillArgs;
+    this.sitePaths = sitePaths;
+    this.schema = schema;
+    String protocol = getRequiredConfigOption(cfg, "protocol");
+    String hostname = getRequiredConfigOption(cfg, "hostname");
+    String port = getRequiredConfigOption(cfg, "port");
+
+    this.indexName =
+        firstNonNull(cfg.getString("index", null, "name"), DEFAULT_INDEX_NAME);
+
+    // By default Elasticsearch has a 1s delay before changes are available in
+    // the index.  Setting refresh(true) on calls to the index makes the index
+    // refresh immediately.
+    //
+    // Discovery should be disabled during test mode to prevent spurious
+    // connection failures caused by the client starting up and being ready
+    // before the test node.
+    //
+    // This setting should only be set to true during testing, and is not
+    // documented.
+    this.refresh = cfg.getBoolean("index", "elasticsearch", "test", false);
+
+    String url = buildUrl(protocol, hostname, port);
+    JestClientFactory factory = new JestClientFactory();
+    factory.setHttpClientConfig(new HttpClientConfig
+        .Builder(url)
+        .multiThreaded(true)
+        .discoveryEnabled(!refresh)
+        .discoveryFrequency(1L, TimeUnit.MINUTES)
+        .build());
+    client = (JestHttpClient) factory.getObject();
+  }
+
+  @Override
+  public Schema<V> getSchema() {
+    return schema;
+  }
+
+  @Override
+  public void close() {
+    client.shutdownClient();
+  }
+
+  @Override
+  public void markReady(boolean ready) throws IOException {
+    IndexUtils.setReady(sitePaths, indexName, schema.getVersion(), ready);
+  }
+
+  @Override
+  public void delete(K c) throws IOException {
+    Bulk bulk = addActions(new Bulk.Builder(), c).refresh(refresh).build();
+    JestResult result = client.execute(bulk);
+    if (!result.isSucceeded()) {
+      throw new IOException(String.format(
+          "Failed to delete change %s in index %s: %s", c, indexName,
+          result.getErrorMessage()));
+    }
+  }
+
+  @Override
+  public void deleteAll() throws IOException {
+    // Delete the index, if it exists.
+    JestResult result = client.execute(
+        new IndicesExists.Builder(indexName).build());
+    if (result.isSucceeded()) {
+      result = client.execute(
+          new DeleteIndex.Builder(indexName).build());
+      if (!result.isSucceeded()) {
+        throw new IOException(String.format(
+            "Failed to delete index %s: %s", indexName,
+            result.getErrorMessage()));
+      }
+    }
+
+    // Recreate the index.
+    result = client.execute(
+        new CreateIndex.Builder(indexName).settings(getMappings()).build());
+    if (!result.isSucceeded()) {
+      String error = String.format("Failed to create index %s: %s",
+          indexName, result.getErrorMessage());
+      throw new IOException(error);
+    }
+  }
+
+  protected abstract Bulk.Builder addActions(Bulk.Builder builder, K c);
+
+  protected abstract String getMappings();
+
+  protected abstract String getId(V v);
+
+  protected Delete delete(String type, K c) {
+    String id = c.toString();
+    return new Delete.Builder(id)
+        .index(indexName)
+        .type(type)
+        .build();
+  }
+
+  protected io.searchbox.core.Index insert(String type, V v) throws IOException {
+    String id = getId(v);
+    String doc = toDoc(v);
+    return new io.searchbox.core.Index.Builder(doc)
+        .index(indexName)
+        .type(type)
+        .id(id)
+        .build();
+  }
+
+  private String toDoc(V v) throws IOException {
+    XContentBuilder builder = jsonBuilder().startObject();
+    for (Values<V> values : schema.buildFields(v, fillArgs)) {
+      String name = values.getField().getName();
+      if (values.getField().isRepeatable()) {
+        builder.array(name, values.getValues());
+      } else {
+        Object element = Iterables.getOnlyElement(values.getValues(), "");
+        if (!(element instanceof String) || !((String) element).isEmpty()) {
+          builder.field(name, element);
+        }
+      }
+    }
+    return builder.endObject().string();
+  }
+
+  private String getRequiredConfigOption(Config cfg, String name) {
+    String option = cfg.getString("index", null, name);
+    checkState(!Strings.isNullOrEmpty(option), "index." + name + " must be supplied");
+    return option;
+  }
+
+  private String buildUrl(String protocol, String hostname, String port) {
+    try {
+      return new URL(protocol, hostname, Integer.parseInt(port), "").toString();
+    } catch (MalformedURLException | NumberFormatException e) {
+      throw new RuntimeException(
+          "Cannot build url to Elasticsearch from values: protocol=" + protocol
+              + " hostname=" + hostname + " port=" + port, e);
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
new file mode 100644
index 0000000..c55ea1c
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticChangeIndex.java
@@ -0,0 +1,389 @@
+// Copyright (C) 2014 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.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
+import static com.google.gerrit.server.index.change.ChangeIndexRewriter.OPEN_STATUSES;
+import static com.google.gson.FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES;
+
+import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Lists;
+import com.google.common.collect.Sets;
+import com.google.gerrit.elasticsearch.ElasticMapping.MappingProperties;
+import com.google.gerrit.index.IndexUtils;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Change.Id;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.ReviewerSet;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.FieldDef.FillArgs;
+import com.google.gerrit.server.index.FieldType;
+import com.google.gerrit.server.index.QueryOptions;
+import com.google.gerrit.server.index.Schema;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.index.change.ChangeField.ChangeProtoField;
+import com.google.gerrit.server.index.change.ChangeField.PatchSetApprovalProtoField;
+import com.google.gerrit.server.index.change.ChangeField.PatchSetProtoField;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.gerrit.server.index.change.ChangeIndexRewriter;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeDataSource;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.gson.JsonArray;
+import com.google.gson.JsonElement;
+import com.google.gson.JsonObject;
+import com.google.gwtorm.protobuf.ProtobufCodec;
+import com.google.gwtorm.server.OrmException;
+import com.google.gwtorm.server.ResultSet;
+import com.google.inject.Provider;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import org.apache.commons.codec.binary.Base64;
+import org.eclipse.jgit.lib.Config;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.search.builder.SearchSourceBuilder;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.io.IOException;
+import java.util.Collections;
+import java.util.Iterator;
+import java.util.List;
+import java.util.Set;
+
+import io.searchbox.client.JestResult;
+import io.searchbox.core.Bulk;
+import io.searchbox.core.Bulk.Builder;
+import io.searchbox.core.Search;
+import io.searchbox.core.search.sort.Sort;
+import io.searchbox.core.search.sort.Sort.Sorting;
+
+/** Secondary index implementation using Elasticsearch. */
+class ElasticChangeIndex extends AbstractElasticIndex<Change.Id, ChangeData>
+    implements ChangeIndex {
+  private static final Logger log =
+      LoggerFactory.getLogger(ElasticChangeIndex.class);
+
+  static class ChangeMapping {
+    MappingProperties openChanges;
+    MappingProperties closedChanges;
+
+    ChangeMapping(Schema<ChangeData> schema) {
+      ElasticMapping.Builder mappingBuilder = new ElasticMapping.Builder();
+      for (FieldDef<?, ?> field : schema.getFields().values()) {
+        String name = field.getName();
+        FieldType<?> fieldType = field.getType();
+        if (fieldType == FieldType.EXACT) {
+          mappingBuilder.addExactField(name);
+        } else if (fieldType == FieldType.TIMESTAMP) {
+          mappingBuilder.addTimestamp(name);
+        } else if (fieldType == FieldType.INTEGER
+            || fieldType == FieldType.INTEGER_RANGE
+            || fieldType == FieldType.LONG) {
+          mappingBuilder.addNumber(name);
+        } else if (fieldType == FieldType.PREFIX
+            || fieldType == FieldType.FULL_TEXT
+            || fieldType == FieldType.STORED_ONLY) {
+          mappingBuilder.addString(name);
+        } else {
+          throw new IllegalArgumentException(
+              "Unsupported filed type " + fieldType.getName());
+        }
+      }
+      MappingProperties mapping = mappingBuilder.build();
+      openChanges = mapping;
+      closedChanges = mapping;
+    }
+  }
+
+  static final String OPEN_CHANGES = "open_changes";
+  static final String CLOSED_CHANGES = "closed_changes";
+
+  private final Gson gson;
+  private final ChangeMapping mapping;
+  private final Provider<ReviewDb> db;
+  private final ElasticQueryBuilder queryBuilder;
+  private final ChangeData.Factory changeDataFactory;
+
+  @AssistedInject
+  ElasticChangeIndex(
+      @GerritServerConfig Config cfg,
+      Provider<ReviewDb> db,
+      ChangeData.Factory changeDataFactory,
+      FillArgs fillArgs,
+      SitePaths sitePaths,
+      @Assisted Schema<ChangeData> schema) {
+    super(cfg, fillArgs, sitePaths, schema);
+    this.db = db;
+    this.changeDataFactory = changeDataFactory;
+    mapping = new ChangeMapping(schema);
+
+    this.queryBuilder = new ElasticQueryBuilder();
+    this.gson = new GsonBuilder()
+        .setFieldNamingPolicy(LOWER_CASE_WITH_UNDERSCORES).create();
+  }
+
+  private static <T> List<T> decodeProtos(JsonObject doc, String fieldName,
+      ProtobufCodec<T> codec) {
+    return FluentIterable.from(doc.getAsJsonArray(fieldName))
+        .transform(i -> codec.decode(Base64.decodeBase64(i.toString())))
+        .toList();
+  }
+
+  @Override
+  public void replace(ChangeData cd) throws IOException {
+    String deleteIndex;
+    String insertIndex;
+
+    try {
+      if (cd.change().getStatus().isOpen()) {
+        insertIndex = OPEN_CHANGES;
+        deleteIndex = CLOSED_CHANGES;
+      } else {
+        insertIndex = CLOSED_CHANGES;
+        deleteIndex = OPEN_CHANGES;
+      }
+    } catch (OrmException e) {
+      throw new IOException(e);
+    }
+
+    Bulk bulk = new Bulk.Builder()
+        .defaultIndex(indexName)
+        .defaultType("changes")
+        .addAction(insert(insertIndex, cd))
+        .addAction(delete(deleteIndex, cd.getId()))
+        .refresh(refresh)
+        .build();
+    JestResult result = client.execute(bulk);
+    if (!result.isSucceeded()) {
+      throw new IOException(String.format(
+          "Failed to replace change %s in index %s: %s", cd.getId(), indexName,
+          result.getErrorMessage()));
+    }
+  }
+
+  @Override
+  public ChangeDataSource getSource(Predicate<ChangeData> p, QueryOptions opts)
+      throws QueryParseException {
+    Set<Change.Status> statuses = ChangeIndexRewriter.getPossibleStatus(p);
+    List<String> indexes = Lists.newArrayListWithCapacity(2);
+    if (!Sets.intersection(statuses, OPEN_STATUSES).isEmpty()) {
+      indexes.add(OPEN_CHANGES);
+    }
+    if (!Sets.intersection(statuses, CLOSED_STATUSES).isEmpty()) {
+      indexes.add(CLOSED_CHANGES);
+    }
+    return new QuerySource(indexes, p, opts);
+  }
+
+  @Override
+  protected Builder addActions(Builder builder, Id c) {
+    return builder
+        .addAction(delete(OPEN_CHANGES, c))
+        .addAction(delete(OPEN_CHANGES, c));
+  }
+
+  @Override
+  protected String getMappings() {
+    return gson.toJson(ImmutableMap.of("mappings", mapping));
+  }
+
+  @Override
+  protected String getId(ChangeData cd) {
+    return cd.getId().toString();
+  }
+
+  private class QuerySource implements ChangeDataSource {
+    private final Search search;
+    private final Set<String> fields;
+
+    public QuerySource(List<String> types, Predicate<ChangeData> p,
+        QueryOptions opts) throws QueryParseException {
+      List<Sort> sorts = ImmutableList.of(
+          new Sort(ChangeField.UPDATED.getName(), Sorting.DESC),
+          new Sort(ChangeField.LEGACY_ID.getName(), Sorting.DESC));
+      for (Sort sort : sorts) {
+        sort.setIgnoreUnmapped();
+      }
+      QueryBuilder qb = queryBuilder.toQueryBuilder(p);
+      fields = IndexUtils.fields(opts);
+      SearchSourceBuilder searchSource = new SearchSourceBuilder()
+          .query(qb)
+          .from(opts.start())
+          .size(opts.limit())
+          .fields(Lists.newArrayList(fields));
+
+      search = new Search.Builder(searchSource.toString())
+          .addType(types)
+          .addSort(sorts)
+          .addIndex(indexName)
+          .build();
+    }
+
+    @Override
+    public int getCardinality() {
+      return 10;
+    }
+
+    @Override
+    public ResultSet<ChangeData> read() throws OrmException {
+      try {
+        List<ChangeData> results = Collections.emptyList();
+        JestResult result = client.execute(search);
+        if (result.isSucceeded()) {
+          JsonObject obj = result.getJsonObject().getAsJsonObject("hits");
+          if (obj.get("hits") != null) {
+            JsonArray json = obj.getAsJsonArray("hits");
+            results = Lists.newArrayListWithCapacity(json.size());
+            for (int i = 0; i < json.size(); i++) {
+              results.add(toChangeData(json.get(i)));
+            }
+          }
+        } else {
+          log.error(result.getErrorMessage());
+        }
+        final List<ChangeData> r = Collections.unmodifiableList(results);
+        return new ResultSet<ChangeData>() {
+          @Override
+          public Iterator<ChangeData> iterator() {
+            return r.iterator();
+          }
+
+          @Override
+          public List<ChangeData> toList() {
+            return r;
+          }
+
+          @Override
+          public void close() {
+            // Do nothing.
+          }
+        };
+      } catch (IOException e) {
+        throw new OrmException(e);
+      }
+    }
+
+    @Override
+    public boolean hasChange() {
+      return false;
+    }
+
+    @Override
+    public String toString() {
+      return search.toString();
+    }
+
+    private ChangeData toChangeData(JsonElement json) {
+      JsonElement sourceElement = json.getAsJsonObject().get("_source");
+      if (sourceElement == null) {
+        sourceElement = json.getAsJsonObject().get("fields");
+      }
+      JsonObject source = sourceElement.getAsJsonObject();
+      JsonElement c = source.get(ChangeField.CHANGE.getName());
+
+      if (c == null) {
+        int id = source.get(ChangeField.LEGACY_ID.getName()).getAsInt();
+        String projectName =
+            source.get(ChangeField.PROJECT.getName()).getAsString();
+        if (projectName == null) {
+          return changeDataFactory.createOnlyWhenNoteDbDisabled(
+              db.get(), new Change.Id(id));
+        }
+        return changeDataFactory.create(
+            db.get(), new Project.NameKey(projectName), new Change.Id(id));
+      }
+
+      ChangeData cd = changeDataFactory.create(db.get(),
+          ChangeProtoField.CODEC.decode(Base64.decodeBase64(c.getAsString())));
+
+      // Patch sets.
+      cd.setPatchSets(decodeProtos(
+          source, ChangeField.PATCH_SET.getName(), PatchSetProtoField.CODEC));
+
+      // Approvals.
+      if (source.get(ChangeField.APPROVAL.getName()) != null) {
+        cd.setCurrentApprovals(decodeProtos(source,
+            ChangeField.APPROVAL.getName(), PatchSetApprovalProtoField.CODEC));
+      } else if (fields.contains(ChangeField.APPROVAL.getName())) {
+        cd.setCurrentApprovals(Collections.emptyList());
+      }
+
+      JsonElement addedElement = source.get(ChangeField.ADDED.getName());
+      JsonElement deletedElement = source.get(ChangeField.DELETED.getName());
+      if (addedElement != null && deletedElement != null) {
+        // Changed lines.
+        int added = addedElement.getAsInt();
+        int deleted = deletedElement.getAsInt();
+        if (added != 0 && deleted != 0) {
+          cd.setChangedLines(added, deleted);
+        }
+      }
+
+      // Mergeable.
+      JsonElement mergeableElement = source.get(ChangeField.MERGEABLE.getName());
+      if (mergeableElement != null) {
+        String mergeable = mergeableElement.getAsString();
+        if ("1".equals(mergeable)) {
+          cd.setMergeable(true);
+        } else if ("0".equals(mergeable)) {
+          cd.setMergeable(false);
+        }
+      }
+
+      // Reviewed-by.
+      if (source.get(ChangeField.REVIEWEDBY.getName()) != null) {
+        JsonArray reviewedBy =
+            source.get(ChangeField.REVIEWEDBY.getName()).getAsJsonArray();
+        if (reviewedBy.size() > 0) {
+          Set<Account.Id> accounts =
+              Sets.newHashSetWithExpectedSize(reviewedBy.size());
+          for (int i = 0; i < reviewedBy.size() ; i++) {
+            int aId = reviewedBy.get(i).getAsInt();
+            if (reviewedBy.size() == 1 && aId == ChangeField.NOT_REVIEWED) {
+              break;
+            }
+            accounts.add(new Account.Id(aId));
+          }
+          cd.setReviewedBy(accounts);
+        }
+      } else if (fields.contains(ChangeField.REVIEWEDBY.getName())) {
+        cd.setReviewedBy(Collections.emptySet());
+      }
+
+      if (source.get(ChangeField.REVIEWER.getName()) != null) {
+        cd.setReviewers(
+            ChangeField.parseReviewerFieldValues(FluentIterable
+                .from(
+                    source.get(ChangeField.REVIEWER.getName()).getAsJsonArray())
+                .transform(JsonElement::getAsString)));
+      } else if (fields.contains(ChangeField.REVIEWER.getName())) {
+        cd.setReviewers(ReviewerSet.empty());
+      }
+
+      return cd;
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
new file mode 100644
index 0000000..e108dca
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticIndexModule.java
@@ -0,0 +1,73 @@
+// Copyright (C) 2014 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.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.index.SingleVersionModule;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.lucene.LuceneAccountIndex;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.IndexConfig;
+import com.google.gerrit.server.index.IndexModule;
+import com.google.gerrit.server.index.account.AccountIndex;
+import com.google.gerrit.server.index.change.ChangeIndex;
+import com.google.inject.Provides;
+import com.google.inject.Singleton;
+import com.google.inject.assistedinject.FactoryModuleBuilder;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.util.Map;
+
+public class ElasticIndexModule extends LifecycleModule {
+  private final int threads;
+  private final Map<String, Integer> singleVersions;
+
+  public static ElasticIndexModule singleVersionWithExplicitVersions(
+      Map<String, Integer> versions, int threads) {
+    return new ElasticIndexModule(versions, threads);
+  }
+
+  public static ElasticIndexModule latestVersionWithOnlineUpgrade() {
+    return new ElasticIndexModule(null, 0);
+  }
+
+  private ElasticIndexModule(Map<String, Integer> singleVersions, int threads) {
+    this.singleVersions = singleVersions;
+    this.threads = threads;
+  }
+
+  @Override
+  protected void configure() {
+    install(
+        new FactoryModuleBuilder()
+            .implement(ChangeIndex.class, ElasticChangeIndex.class)
+            .build(ChangeIndex.Factory.class));
+    install(
+        new FactoryModuleBuilder()
+            // until we implement Elasticsearch index for accounts we need to
+            // use Lucene to make all tests green and Gerrit server to work
+            .implement(AccountIndex.class, LuceneAccountIndex.class)
+            .build(AccountIndex.Factory.class));
+
+    install(new IndexModule(threads));
+    install(new SingleVersionModule(singleVersions));
+  }
+
+  @Provides
+  @Singleton
+  IndexConfig getIndexConfig(@GerritServerConfig Config cfg) {
+    return IndexConfig.fromConfig(cfg);
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
new file mode 100644
index 0000000..e3f7e96
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticMapping.java
@@ -0,0 +1,79 @@
+// Copyright (C) 2016 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.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.common.collect.ImmutableMap;
+
+import java.util.Map;
+
+class ElasticMapping {
+  static class Builder {
+    private final ImmutableMap.Builder<String, FieldProperties> fields =
+        new ImmutableMap.Builder<>();
+
+    MappingProperties build() {
+      MappingProperties properties = new MappingProperties();
+      properties.properties = fields.build();
+      return properties;
+    }
+
+    Builder addExactField(String name) {
+      FieldProperties key = new FieldProperties("string");
+      key.index = "not_analyzed";
+      FieldProperties properties = new FieldProperties("string");
+      properties.fields = ImmutableMap.of("key", key);
+      fields.put(name, properties);
+      return this;
+    }
+
+    Builder addTimestamp(String name) {
+      FieldProperties properties = new FieldProperties("date");
+      properties.type = "date";
+      properties.format = "dateOptionalTime";
+      fields.put(name, properties);
+      return this;
+    }
+
+    Builder addNumber(String name) {
+      fields.put(name, new FieldProperties("long"));
+      return this;
+    }
+
+    Builder addString(String name) {
+      fields.put(name, new FieldProperties("string"));
+      return this;
+    }
+
+    Builder add(String name, String type) {
+      fields.put(name, new FieldProperties(type));
+      return this;
+    }
+  }
+
+  static class MappingProperties {
+    Map<String, FieldProperties> properties;
+  }
+
+  static class FieldProperties {
+    String type;
+    String index;
+    String format;
+    Map<String, FieldProperties> fields;
+
+    FieldProperties(String type) {
+      this.type = type;
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
new file mode 100644
index 0000000..51b14a4
--- /dev/null
+++ b/gerrit-elasticsearch/src/main/java/com/google/gerrit/elasticsearch/ElasticQueryBuilder.java
@@ -0,0 +1,181 @@
+// Copyright (C) 2014 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.
+
+package com.google.gerrit.elasticsearch;
+
+import com.google.gerrit.server.index.FieldDef;
+import com.google.gerrit.server.index.FieldType;
+import com.google.gerrit.server.index.IndexPredicate;
+import com.google.gerrit.server.index.IntegerRangePredicate;
+import com.google.gerrit.server.index.RegexPredicate;
+import com.google.gerrit.server.index.TimestampRangePredicate;
+import com.google.gerrit.server.query.AndPredicate;
+import com.google.gerrit.server.query.NotPredicate;
+import com.google.gerrit.server.query.OrPredicate;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.AfterPredicate;
+
+import org.apache.lucene.search.BooleanQuery;
+import org.elasticsearch.index.query.BoolQueryBuilder;
+import org.elasticsearch.index.query.QueryBuilder;
+import org.elasticsearch.index.query.QueryBuilders;
+import org.joda.time.DateTime;
+
+public class ElasticQueryBuilder {
+
+  protected <T> QueryBuilder toQueryBuilder(Predicate<T> p)
+      throws QueryParseException {
+    if (p instanceof AndPredicate) {
+      return and(p);
+    } else if (p instanceof OrPredicate) {
+      return or(p);
+    } else if (p instanceof NotPredicate) {
+      return not(p);
+    } else if (p instanceof IndexPredicate) {
+      return fieldQuery((IndexPredicate<T>) p);
+    } else {
+      throw new QueryParseException("cannot create query for index: " + p);
+    }
+  }
+
+  private <T> BoolQueryBuilder and(Predicate<T> p)
+      throws QueryParseException {
+    try {
+      BoolQueryBuilder b = QueryBuilders.boolQuery();
+      for (Predicate<T> c : p.getChildren()) {
+        b.must(toQueryBuilder(c));
+      }
+      return b;
+    } catch (BooleanQuery.TooManyClauses e) {
+      throw new QueryParseException("cannot create query for index: " + p, e);
+    }
+  }
+
+  private <T> BoolQueryBuilder or(Predicate<T> p)
+      throws QueryParseException {
+    try {
+      BoolQueryBuilder q = QueryBuilders.boolQuery();
+      for (Predicate<T> c : p.getChildren()) {
+        q.should(toQueryBuilder(c));
+      }
+      return q;
+    } catch (BooleanQuery.TooManyClauses e) {
+      throw new QueryParseException("cannot create query for index: " + p, e);
+    }
+  }
+
+  private <T> QueryBuilder not(Predicate<T> p)
+      throws QueryParseException {
+    Predicate<T> n = p.getChild(0);
+    if (n instanceof TimestampRangePredicate) {
+      return notTimestamp((TimestampRangePredicate<T>) n);
+    }
+
+    // Lucene does not support negation, start with all and subtract.
+    BoolQueryBuilder q = QueryBuilders.boolQuery();
+    q.must(QueryBuilders.matchAllQuery());
+    q.mustNot(toQueryBuilder(n));
+    return q;
+  }
+
+  private <T> QueryBuilder fieldQuery(IndexPredicate<T> p)
+      throws QueryParseException {
+    FieldType<?> type = p.getType();
+    FieldDef<?,?> field = p.getField();
+    String name = field.getName();
+    String value = p.getValue();
+
+    if (type == FieldType.INTEGER) {
+      // QueryBuilder encodes integer fields as prefix coded bits,
+      // which elasticsearch's queryString can't handle.
+      // Create integer terms with string representations instead.
+      return QueryBuilders.termQuery(name, value);
+    } else if (type == FieldType.INTEGER_RANGE) {
+      return intRangeQuery(p);
+    } else if (type == FieldType.TIMESTAMP) {
+      return timestampQuery(p);
+    } else if (type == FieldType.EXACT) {
+      return exactQuery(p);
+    } else if (type == FieldType.PREFIX) {
+      return QueryBuilders.matchPhrasePrefixQuery(name, value);
+    } else if (type == FieldType.FULL_TEXT) {
+      return QueryBuilders.matchPhraseQuery(name, value);
+    } else {
+      throw FieldType.badFieldType(p.getType());
+    }
+  }
+
+  private <T> QueryBuilder intRangeQuery(IndexPredicate<T> p)
+      throws QueryParseException {
+    if (p instanceof IntegerRangePredicate) {
+      IntegerRangePredicate<T> r = (IntegerRangePredicate<T>) p;
+      int minimum = r.getMinimumValue();
+      int maximum = r.getMaximumValue();
+      if (minimum == maximum) {
+        // Just fall back to a standard integer query.
+        return QueryBuilders.termQuery(p.getField().getName(), minimum);
+      }
+      return QueryBuilders.rangeQuery(p.getField().getName())
+          .gte(minimum)
+          .lte(maximum);
+    }
+    throw new QueryParseException("not an integer range: " + p);
+  }
+
+  private <T> QueryBuilder notTimestamp(TimestampRangePredicate<T> r)
+      throws QueryParseException {
+    if (r.getMinTimestamp().getTime() == 0) {
+      return QueryBuilders.rangeQuery(r.getField().getName())
+          .gt(new DateTime(r.getMaxTimestamp().getTime()));
+    }
+    throw new QueryParseException("cannot negate: " + r);
+  }
+
+  private <T> QueryBuilder timestampQuery(IndexPredicate<T> p)
+      throws QueryParseException {
+    if (p instanceof TimestampRangePredicate) {
+      TimestampRangePredicate<T> r =
+          (TimestampRangePredicate<T>) p;
+      if (p instanceof AfterPredicate) {
+        return QueryBuilders.rangeQuery(r.getField().getName())
+            .gte(new DateTime(r.getMinTimestamp().getTime()));
+      }
+      return QueryBuilders.rangeQuery(r.getField().getName())
+          .gte(new DateTime(r.getMinTimestamp().getTime()))
+          .lte(new DateTime(r.getMaxTimestamp().getTime()));
+    }
+    throw new QueryParseException("not a timestamp: " + p);
+  }
+
+  private <T> QueryBuilder exactQuery(IndexPredicate<T> p){
+    String name = p.getField().getName();
+    String value = p.getValue();
+
+    if (value.isEmpty()) {
+      return new BoolQueryBuilder().mustNot(QueryBuilders.existsQuery(name));
+    } else if (p instanceof RegexPredicate) {
+      if (value.startsWith("^")) {
+        value = value.substring(1);
+      }
+      if (value.endsWith("$") && !value.endsWith("\\$")
+          && !value.endsWith("\\\\$")) {
+        value = value.substring(0, value.length() - 1);
+      }
+      return QueryBuilders.regexpQuery(name + ".key", value);
+    } else {
+      return QueryBuilders.termQuery(name + ".key", value);
+    }
+  }
+}
diff --git a/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
new file mode 100644
index 0000000..e2e7585
--- /dev/null
+++ b/gerrit-elasticsearch/src/test/java/com/google/gerrit/elasticsearch/ElasticQueryChangesTest.java
@@ -0,0 +1,178 @@
+// Copyright (C) 2014 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.
+
+package com.google.gerrit.elasticsearch;
+
+import static com.google.common.base.Preconditions.checkState;
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.elasticsearch.ElasticChangeIndex.CLOSED_CHANGES;
+import static com.google.gerrit.elasticsearch.ElasticChangeIndex.OPEN_CHANGES;
+
+import com.google.common.base.Strings;
+import com.google.common.io.Files;
+import com.google.gerrit.elasticsearch.ElasticChangeIndex.ChangeMapping;
+import com.google.gerrit.server.index.IndexModule.IndexType;
+import com.google.gerrit.server.index.change.ChangeSchemaDefinitions;
+import com.google.gerrit.server.query.change.AbstractQueryChangesTest;
+import com.google.gerrit.testutil.InMemoryModule;
+import com.google.gson.FieldNamingPolicy;
+import com.google.gson.Gson;
+import com.google.gson.GsonBuilder;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+
+import org.eclipse.jgit.lib.Config;
+import org.elasticsearch.action.admin.cluster.node.info.NodesInfoRequest;
+import org.elasticsearch.common.settings.Settings;
+import org.elasticsearch.node.Node;
+import org.elasticsearch.node.NodeBuilder;
+import org.junit.After;
+import org.junit.AfterClass;
+import org.junit.BeforeClass;
+
+import java.io.File;
+import java.nio.file.Path;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.concurrent.ExecutionException;
+
+public class ElasticQueryChangesTest extends AbstractQueryChangesTest {
+  private static final Gson gson = new GsonBuilder()
+      .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+      .create();
+  private static Node node;
+  private static String port;
+  private static File elasticDir;
+
+  static class NodeInfo {
+    String httpAddress;
+  }
+
+  static class Info {
+    Map<String, NodeInfo> nodes;
+  }
+
+  @BeforeClass
+  public static void startIndexService()
+      throws InterruptedException, ExecutionException {
+    if (node != null) {
+      // do not start Elasticsearch twice
+      return;
+    }
+    elasticDir = Files.createTempDir();
+    Path elasticDirPath = elasticDir.toPath();
+    Settings settings = Settings.settingsBuilder()
+        .put("cluster.name", "gerrit")
+        .put("node.name", "Gerrit Elasticsearch Test Node")
+        .put("node.local", true)
+        .put("discovery.zen.ping.multicast.enabled", false)
+        .put("index.store.fs.memory.enabled", true)
+        .put("index.gateway.type", "none")
+        .put("index.max_result_window", Integer.MAX_VALUE)
+        .put("gateway.type", "default")
+        .put("http.port", 0)
+        .put("discovery.zen.ping.unicast.hosts", "[\"localhost\"]")
+        .put("path.home", elasticDirPath.toAbsolutePath())
+        .put("path.data", elasticDirPath.resolve("data").toAbsolutePath())
+        .put("path.work", elasticDirPath.resolve("work").toAbsolutePath())
+        .put("path.logs", elasticDirPath.resolve("logs").toAbsolutePath())
+        .build();
+
+    // Start the node
+    node = NodeBuilder.nodeBuilder()
+        .settings(settings)
+        .node();
+
+    // Wait for it to be ready
+    node.client()
+        .admin()
+        .cluster()
+        .prepareHealth()
+        .setWaitForYellowStatus()
+        .execute()
+        .actionGet();
+
+    createIndexes();
+
+    assertThat(node.isClosed()).isFalse();
+    port = getHttpPort();
+  }
+
+  @After
+  public void cleanupIndex() {
+    node.client().admin().indices().prepareDelete("gerrit").execute();
+    createIndexes();
+  }
+
+  @AfterClass
+  public static void stopElasticsearchServer() {
+    if (node != null) {
+      node.close();
+      node = null;
+    }
+    if (elasticDir != null && elasticDir.delete()) {
+      elasticDir = null;
+    }
+  }
+
+  @Override
+  protected Injector createInjector() {
+    Config elasticsearchConfig = new Config(config);
+    InMemoryModule.setDefaults(elasticsearchConfig);
+    elasticsearchConfig.setEnum("index", null, "type", IndexType.ELASTICSEARCH);
+    elasticsearchConfig.setString("index", null, "protocol", "http");
+    elasticsearchConfig.setString("index", null, "hostname", "localhost");
+    elasticsearchConfig.setString("index", null, "port", port);
+    elasticsearchConfig.setString("index", null, "name", "gerrit");
+    elasticsearchConfig.setBoolean("index", "elasticsearch", "test", true);
+    return Guice.createInjector(
+        new InMemoryModule(elasticsearchConfig, notesMigration));
+  }
+
+  private static void createIndexes() {
+    ChangeMapping openChangesMapping =
+        new ChangeMapping(ChangeSchemaDefinitions.INSTANCE.getLatest());
+    ChangeMapping closedChangesMapping =
+        new ChangeMapping(ChangeSchemaDefinitions.INSTANCE.getLatest());
+    openChangesMapping.closedChanges = null;
+    closedChangesMapping.openChanges = null;
+    node.client()
+        .admin()
+        .indices()
+        .prepareCreate("gerrit")
+        .addMapping(OPEN_CHANGES, gson.toJson(openChangesMapping))
+        .addMapping(CLOSED_CHANGES, gson.toJson(closedChangesMapping))
+        .execute()
+        .actionGet();
+  }
+
+  private static String getHttpPort()
+      throws InterruptedException, ExecutionException {
+    String nodes = node.client().admin().cluster()
+        .nodesInfo(new NodesInfoRequest("*")).get().toString();
+    Gson gson = new GsonBuilder()
+        .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES)
+        .create();
+    Info info = gson.fromJson(nodes, Info.class);
+
+    checkState(info.nodes != null && info.nodes.size() == 1);
+    Iterator<NodeInfo> values = info.nodes.values().iterator();
+    String httpAddress = values.next().httpAddress;
+
+    checkState(
+        !Strings.isNullOrEmpty(httpAddress) && httpAddress.indexOf(':') > 0);
+    return httpAddress.substring(httpAddress.indexOf(':') + 1,
+        httpAddress.length());
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
index cbe16ed..8f1e283 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/ReviewInput.java
@@ -34,6 +34,7 @@
 
   public Map<String, Short> labels;
   public Map<String, List<CommentInput>> comments;
+  public Map<String, List<RobotCommentInput>> robotComments;
 
   /**
    * If true require all labels to be within the user's permitted ranges based
@@ -94,6 +95,12 @@
   public static class CommentInput extends Comment {
   }
 
+  public static class RobotCommentInput extends CommentInput {
+    public String robotId;
+    public String robotRunId;
+    public String url;
+  }
+
   public ReviewInput message(String msg) {
     message = msg != null && !msg.isEmpty() ? msg : null;
     return this;
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
index d6897b1..29bf00b 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RevisionApi.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.NotImplementedException;
@@ -54,15 +55,18 @@
   MergeableInfo mergeableOtherBranches() throws RestApiException;
 
   Map<String, List<CommentInfo>> comments() throws RestApiException;
+  Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException;
   Map<String, List<CommentInfo>> drafts() throws RestApiException;
 
   List<CommentInfo> commentsAsList() throws RestApiException;
   List<CommentInfo> draftsAsList() throws RestApiException;
+  List<RobotCommentInfo> robotCommentsAsList() throws RestApiException;
 
   DraftApi createDraft(DraftInput in) throws RestApiException;
   DraftApi draft(String id) throws RestApiException;
 
   CommentApi comment(String id) throws RestApiException;
+  RobotCommentApi robotComment(String id) throws RestApiException;
 
   /**
    * Returns patch of revision.
@@ -197,6 +201,12 @@
     }
 
     @Override
+    public Map<String, List<RobotCommentInfo>> robotComments()
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public List<CommentInfo> commentsAsList() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -207,6 +217,12 @@
     }
 
     @Override
+    public List<RobotCommentInfo> robotCommentsAsList()
+        throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public Map<String, List<CommentInfo>> drafts() throws RestApiException {
       throw new NotImplementedException();
     }
@@ -227,6 +243,11 @@
     }
 
     @Override
+    public RobotCommentApi robotComment(String id) throws RestApiException {
+      throw new NotImplementedException();
+    }
+
+    @Override
     public BinaryResult patch() throws RestApiException {
       throw new NotImplementedException();
     }
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java
new file mode 100644
index 0000000..e1ed107
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/RobotCommentApi.java
@@ -0,0 +1,35 @@
+// Copyright (C) 2016 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.
+
+
+package com.google.gerrit.extensions.api.changes;
+
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.NotImplementedException;
+import com.google.gerrit.extensions.restapi.RestApiException;
+
+public interface RobotCommentApi {
+  RobotCommentInfo get() throws RestApiException;
+
+  /**
+   * A default implementation which allows source compatibility
+   * when adding new methods to the interface.
+   **/
+  class NotImplemented implements RobotCommentApi {
+    @Override
+    public RobotCommentInfo get() throws RestApiException {
+      throw new NotImplementedException();
+    }
+  }
+}
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfo.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfo.java
new file mode 100644
index 0000000..a6b7593
--- /dev/null
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/common/RobotCommentInfo.java
@@ -0,0 +1,21 @@
+// Copyright (C) 2016 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.
+
+package com.google.gerrit.extensions.common;
+
+public class RobotCommentInfo extends CommentInfo {
+  public String robotId;
+  public String robotRunId;
+  public String url;
+}
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
index 4a01128..111dfc9 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/rpc/project/ProjectAccessHandler.java
@@ -18,11 +18,13 @@
 
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.common.data.AccessSection;
+import com.google.gerrit.common.data.Capable;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.common.data.PermissionRule;
 import com.google.gerrit.common.errors.InvalidNameException;
 import com.google.gerrit.common.errors.NoSuchGroupException;
+import com.google.gerrit.common.errors.PermissionDeniedException;
 import com.google.gerrit.common.errors.UpdateParentFailedException;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
@@ -89,10 +91,15 @@
   @Override
   public final T call() throws NoSuchProjectException, IOException,
       ConfigInvalidException, InvalidNameException, NoSuchGroupException,
-      OrmException, UpdateParentFailedException {
+      OrmException, UpdateParentFailedException, PermissionDeniedException {
     final ProjectControl projectControl =
         projectControlFactory.controlFor(projectName);
 
+    Capable r = projectControl.canPushToAtLeastOneRef();
+    if (r != Capable.OK) {
+      throw new PermissionDeniedException(r.getMessage());
+    }
+
     try (MetaDataUpdate md = metaDataUpdateFactory.create(projectName)) {
       ProjectConfig config = ProjectConfig.read(md, base);
       Set<String> toDelete = scanSectionNames(config);
diff --git a/gerrit-index/BUCK b/gerrit-index/BUCK
new file mode 100644
index 0000000..ea97f88
--- /dev/null
+++ b/gerrit-index/BUCK
@@ -0,0 +1,13 @@
+java_library(
+  name = 'index',
+  srcs = glob(['src/main/java/**/*.java']),
+  deps = [
+    '//gerrit-extension-api:api',
+    '//gerrit-server:server',
+    '//gerrit-patch-jgit:server',
+    '//lib/guice:guice',
+    '//lib/jgit/org.eclipse.jgit:jgit',
+    '//lib:guava',
+  ],
+  visibility = ['PUBLIC'],
+)
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexStatus.java b/gerrit-index/src/main/java/com/google/gerrit/index/GerritIndexStatus.java
similarity index 84%
rename from gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexStatus.java
rename to gerrit-index/src/main/java/com/google/gerrit/index/GerritIndexStatus.java
index f43e385..cafd30e 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/GerritIndexStatus.java
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/GerritIndexStatus.java
@@ -12,7 +12,7 @@
 // See the License for the specific language governing permissions and
 // limitations under the License.
 
-package com.google.gerrit.lucene;
+package com.google.gerrit.index;
 
 import com.google.common.primitives.Ints;
 import com.google.gerrit.server.config.SitePaths;
@@ -24,13 +24,13 @@
 
 import java.io.IOException;
 
-class GerritIndexStatus {
+public class GerritIndexStatus {
   private static final String SECTION = "index";
   private static final String KEY_READY = "ready";
 
   private final FileBasedConfig cfg;
 
-  GerritIndexStatus(SitePaths sitePaths)
+  public GerritIndexStatus(SitePaths sitePaths)
       throws ConfigInvalidException, IOException {
     cfg = new FileBasedConfig(
         sitePaths.index_dir.resolve("gerrit_index.config").toFile(),
@@ -39,16 +39,16 @@
     convertLegacyConfig();
   }
 
-  void setReady(String indexName, int version, boolean ready) {
+  public void setReady(String indexName, int version, boolean ready) {
     cfg.setBoolean(SECTION, indexDirName(indexName, version), KEY_READY, ready);
   }
 
-  boolean getReady(String indexName, int version) {
+  public boolean getReady(String indexName, int version) {
     return cfg.getBoolean(SECTION, indexDirName(indexName, version), KEY_READY,
         false);
   }
 
-  void save() throws IOException {
+  public void save() throws IOException {
     cfg.save();
   }
 
@@ -62,8 +62,8 @@
         if (ready != null) {
           dirty = false;
           cfg.unset(SECTION, subsection, KEY_READY);
-          cfg.setString(SECTION,
-              indexDirName(ChangeSchemaDefinitions.NAME, v), KEY_READY, ready);
+          cfg.setString(SECTION, indexDirName(ChangeSchemaDefinitions.NAME, v),
+              KEY_READY, ready);
         }
       }
     }
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/IndexUtils.java b/gerrit-index/src/main/java/com/google/gerrit/index/IndexUtils.java
new file mode 100644
index 0000000..f00f5c2
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/IndexUtils.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2013 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.
+
+package com.google.gerrit.index;
+
+import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
+import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
+import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
+
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.common.collect.Sets;
+import com.google.gerrit.server.config.SitePaths;
+import com.google.gerrit.server.index.QueryOptions;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+
+import java.io.IOException;
+import java.util.Map;
+import java.util.Set;
+
+public final class IndexUtils {
+  public static final Map<String, String> CUSTOM_CHAR_MAPPING =
+      ImmutableMap.of("_", " ", ".", " ");
+
+  public static void setReady(SitePaths sitePaths, String name, int version,
+      boolean ready) throws IOException {
+    try {
+      GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
+      cfg.setReady(name, version, ready);
+      cfg.save();
+    } catch (ConfigInvalidException e) {
+      throw new IOException(e);
+    }
+  }
+
+  public static Set<String> fields(QueryOptions opts) {
+    // Ensure we request enough fields to construct a ChangeData. We need both
+    // change ID and project, which can either come via the Change field or
+    // separate fields.
+    Set<String> fs = opts.fields();
+    if (fs.contains(CHANGE.getName())) {
+      // A Change is always sufficient.
+      return fs;
+    }
+    if (fs.contains(PROJECT.getName()) && fs.contains(LEGACY_ID.getName())) {
+      return fs;
+    }
+    return Sets.union(fs,
+        ImmutableSet.of(LEGACY_ID.getName(), PROJECT.getName()));
+  }
+
+  private IndexUtils() {
+    // hide default constructor
+  }
+}
diff --git a/gerrit-index/src/main/java/com/google/gerrit/index/SingleVersionModule.java b/gerrit-index/src/main/java/com/google/gerrit/index/SingleVersionModule.java
new file mode 100644
index 0000000..d547b06
--- /dev/null
+++ b/gerrit-index/src/main/java/com/google/gerrit/index/SingleVersionModule.java
@@ -0,0 +1,106 @@
+// Copyright (C) 2013 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.
+
+package com.google.gerrit.index;
+
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.lifecycle.LifecycleModule;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.index.Index;
+import com.google.gerrit.server.index.IndexDefinition;
+import com.google.gerrit.server.index.Schema;
+import com.google.inject.Inject;
+import com.google.inject.ProvisionException;
+import com.google.inject.Singleton;
+import com.google.inject.TypeLiteral;
+import com.google.inject.name.Named;
+import com.google.inject.name.Names;
+
+import org.eclipse.jgit.lib.Config;
+
+import java.util.Collection;
+import java.util.Map;
+import java.util.Set;
+
+@Singleton
+public class SingleVersionModule extends LifecycleModule {
+  static final String SINGLE_VERSIONS = "IndexModule/SingleVersions";
+
+  private final Map<String, Integer> singleVersions;
+
+  public SingleVersionModule(Map<String, Integer> singleVersions) {
+    this.singleVersions = singleVersions;
+  }
+
+  @Override
+  public void configure() {
+    listener().to(SingleVersionListener.class);
+    bind(new TypeLiteral<Map<String, Integer>>() {})
+        .annotatedWith(Names.named(SINGLE_VERSIONS))
+        .toInstance(singleVersions);
+  }
+
+  @Singleton
+  static class SingleVersionListener implements LifecycleListener {
+    private final Set<String> disabled;
+    private final Collection<IndexDefinition<?, ?, ?>> defs;
+    private final Map<String, Integer> singleVersions;
+
+    @Inject
+    SingleVersionListener(
+        @GerritServerConfig Config cfg,
+        Collection<IndexDefinition<?, ?, ?>> defs,
+        @Named(SINGLE_VERSIONS) Map<String, Integer> singleVersions) {
+      this.defs = defs;
+      this.singleVersions = singleVersions;
+
+      disabled = ImmutableSet.copyOf(
+          cfg.getStringList("index", null, "testDisable"));
+    }
+
+    @Override
+    public void start() {
+      for (IndexDefinition<?, ?, ?> def : defs) {
+        start(def);
+      }
+    }
+
+    private <K, V, I extends Index<K, V>> void start(
+        IndexDefinition<K, V, I> def) {
+      if (disabled.contains(def.getName())) {
+        return;
+      }
+      Schema<V> schema;
+      Integer v = singleVersions.get(def.getName());
+      if (v == null) {
+        schema = def.getLatest();
+      } else {
+        schema = def.getSchemas().get(v);
+        if (schema == null) {
+          throw new ProvisionException(String.format(
+                "Unrecognized %s schema version: %s", def.getName(), v));
+        }
+      }
+      I index = def.getIndexFactory().create(schema);
+      def.getIndexCollection().setSearchIndex(index);
+      def.getIndexCollection().addWriteIndex(index);
+    }
+
+    @Override
+    public void stop() {
+      // Do nothing; indexes are closed by IndexCollection.
+    }
+  }
+}
\ No newline at end of file
diff --git a/gerrit-lucene/BUCK b/gerrit-lucene/BUCK
index 771a021..f4f097c 100644
--- a/gerrit-lucene/BUCK
+++ b/gerrit-lucene/BUCK
@@ -27,6 +27,7 @@
     '//gerrit-extension-api:api',
     '//gerrit-reviewdb:server',
     '//gerrit-server:server',
+    '//gerrit-index:index',
     '//lib:guava',
     '//lib:gwtorm',
     '//lib/guice:guice',
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
index eb0dfaa..e869afb 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/AbstractLuceneIndex.java
@@ -25,6 +25,7 @@
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.MoreExecutors;
 import com.google.common.util.concurrent.ThreadFactoryBuilder;
+import com.google.gerrit.index.IndexUtils;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.FieldDef;
 import com.google.gerrit.server.index.FieldDef.FillArgs;
@@ -51,7 +52,6 @@
 import org.apache.lucene.search.SearcherFactory;
 import org.apache.lucene.store.AlreadyClosedException;
 import org.apache.lucene.store.Directory;
-import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -75,17 +75,6 @@
     return f.getName() + "_SORT";
   }
 
-  public static void setReady(SitePaths sitePaths, String name, int version,
-      boolean ready) throws IOException {
-    try {
-      GerritIndexStatus cfg = new GerritIndexStatus(sitePaths);
-      cfg.setReady(name, version, ready);
-      cfg.save();
-    } catch (ConfigInvalidException e) {
-      throw new IOException(e);
-    }
-  }
-
   private final Schema<V> schema;
   private final SitePaths sitePaths;
   private final Directory dir;
@@ -198,7 +187,7 @@
 
   @Override
   public void markReady(boolean ready) throws IOException {
-    setReady(sitePaths, name, schema.getVersion(), ready);
+    IndexUtils.setReady(sitePaths, name, schema.getVersion(), ready);
   }
 
   @Override
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
index 2decff5..4775ac4 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneChangeIndex.java
@@ -18,7 +18,6 @@
 import static com.google.gerrit.lucene.AbstractLuceneIndex.sortFieldName;
 import static com.google.gerrit.lucene.LuceneVersionManager.CHANGES_PREFIX;
 import static com.google.gerrit.server.git.QueueProvider.QueueType.INTERACTIVE;
-import static com.google.gerrit.server.index.change.ChangeField.CHANGE;
 import static com.google.gerrit.server.index.change.ChangeField.LEGACY_ID;
 import static com.google.gerrit.server.index.change.ChangeField.PROJECT;
 import static com.google.gerrit.server.index.change.ChangeIndexRewriter.CLOSED_STATUSES;
@@ -27,7 +26,6 @@
 import com.google.common.base.Throwables;
 import com.google.common.collect.ArrayListMultimap;
 import com.google.common.collect.FluentIterable;
-import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Multimap;
@@ -35,6 +33,7 @@
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
+import com.google.gerrit.index.IndexUtils;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -317,7 +316,7 @@
         throw new OrmException("interrupted");
       }
 
-      final Set<String> fields = fields(opts);
+      final Set<String> fields = IndexUtils.fields(opts);
       return new ChangeDataResults(
           executor.submit(new Callable<List<Document>>() {
             @Override
@@ -405,22 +404,6 @@
     }
   }
 
-  private Set<String> fields(QueryOptions opts) {
-    // Ensure we request enough fields to construct a ChangeData. We need both
-    // change ID and project, which can either come via the Change field or
-    // separate fields.
-    Set<String> fs = opts.fields();
-    if (fs.contains(CHANGE.getName())) {
-      // A Change is always sufficient.
-      return fs;
-    }
-    if (fs.contains(PROJECT.getName()) && fs.contains(LEGACY_ID.getName())) {
-      return fs;
-    }
-    return Sets.union(fs,
-        ImmutableSet.of(LEGACY_ID.getName(), PROJECT.getName()));
-  }
-
   private static Multimap<String, IndexableField> fields(Document doc,
       Set<String> fields) {
     Multimap<String, IndexableField> stored =
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
index f5d5146..58890176 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneIndexModule.java
@@ -15,37 +15,23 @@
 package com.google.gerrit.lucene;
 
 import com.google.common.collect.ImmutableMap;
-import com.google.common.collect.ImmutableSet;
-import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.index.SingleVersionModule;
 import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.server.config.GerritServerConfig;
-import com.google.gerrit.server.index.Index;
 import com.google.gerrit.server.index.IndexConfig;
-import com.google.gerrit.server.index.IndexDefinition;
 import com.google.gerrit.server.index.IndexModule;
-import com.google.gerrit.server.index.Schema;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.change.ChangeIndex;
-import com.google.inject.Inject;
 import com.google.inject.Provides;
-import com.google.inject.ProvisionException;
 import com.google.inject.Singleton;
-import com.google.inject.TypeLiteral;
 import com.google.inject.assistedinject.FactoryModuleBuilder;
-import com.google.inject.name.Named;
-import com.google.inject.name.Names;
 
 import org.apache.lucene.search.BooleanQuery;
 import org.eclipse.jgit.lib.Config;
 
-import java.util.Collection;
 import java.util.Map;
-import java.util.Set;
 
 public class LuceneIndexModule extends LifecycleModule {
-  private static final String SINGLE_VERSIONS =
-      "LuceneIndexModule/SingleVersions";
-
   public static LuceneIndexModule singleVersionAllLatest(int threads) {
     return new LuceneIndexModule(ImmutableMap.<String, Integer> of(), threads);
   }
@@ -86,7 +72,7 @@
     if (singleVersions == null) {
       install(new MultiVersionModule());
     } else {
-      install(new SingleVersionModule());
+      install(new SingleVersionModule(singleVersions));
     }
   }
 
@@ -104,66 +90,4 @@
       listener().to(LuceneVersionManager.class);
     }
   }
-
-  private class SingleVersionModule extends LifecycleModule {
-    @Override
-    public void configure() {
-      listener().to(SingleVersionListener.class);
-      bind(new TypeLiteral<Map<String, Integer>>() {})
-          .annotatedWith(Names.named(SINGLE_VERSIONS))
-          .toInstance(singleVersions);
-    }
-  }
-
-  @Singleton
-  static class SingleVersionListener implements LifecycleListener {
-    private final Set<String> disabled;
-    private final Collection<IndexDefinition<?, ?, ?>> defs;
-    private final Map<String, Integer> singleVersions;
-
-    @Inject
-    SingleVersionListener(
-        @GerritServerConfig Config cfg,
-        Collection<IndexDefinition<?, ?, ?>> defs,
-        @Named(SINGLE_VERSIONS) Map<String, Integer> singleVersions) {
-      this.defs = defs;
-      this.singleVersions = singleVersions;
-
-      disabled = ImmutableSet.copyOf(
-          cfg.getStringList("index", null, "testDisable"));
-    }
-
-    @Override
-    public void start() {
-      for (IndexDefinition<?, ?, ?> def : defs) {
-        start(def);
-      }
-    }
-
-    private <K, V, I extends Index<K, V>> void start(
-        IndexDefinition<K, V, I> def) {
-      if (disabled.contains(def.getName())) {
-        return;
-      }
-      Schema<V> schema;
-      Integer v = singleVersions.get(def.getName());
-      if (v == null) {
-        schema = def.getLatest();
-      } else {
-        schema = def.getSchemas().get(v);
-        if (schema == null) {
-          throw new ProvisionException(String.format(
-                "Unrecognized %s schema version: %s", def.getName(), v));
-        }
-      }
-      I index = def.getIndexFactory().create(schema);
-      def.getIndexCollection().setSearchIndex(index);
-      def.getIndexCollection().addWriteIndex(index);
-    }
-
-    @Override
-    public void stop() {
-      // Do nothing; indexes are closed by IndexCollection.
-    }
-  }
 }
diff --git a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
index b46f1f6..2f871fc 100644
--- a/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
+++ b/gerrit-lucene/src/main/java/com/google/gerrit/lucene/LuceneVersionManager.java
@@ -20,6 +20,7 @@
 import com.google.common.collect.Maps;
 import com.google.common.primitives.Ints;
 import com.google.gerrit.extensions.events.LifecycleListener;
+import com.google.gerrit.index.GerritIndexStatus;
 import com.google.gerrit.server.config.GerritServerConfig;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.gerrit.server.index.Index;
diff --git a/gerrit-pgm/BUCK b/gerrit-pgm/BUCK
index 8852133..5f2ef43 100644
--- a/gerrit-pgm/BUCK
+++ b/gerrit-pgm/BUCK
@@ -47,7 +47,7 @@
     ':init-api',
     ':util',
     '//gerrit-common:annotations',
-    '//gerrit-lucene:lucene',
+    '//gerrit-index:index',
     '//lib:args4j',
     '//lib:derby',
     '//lib:gwtjsonrpc',
@@ -66,6 +66,7 @@
 
 REST_UTIL_DEPS = [
   '//gerrit-cache-h2:cache-h2',
+  '//gerrit-elasticsearch:elasticsearch',
   '//gerrit-util-cli:cli',
   '//lib:args4j',
   '//lib:gwtorm',
@@ -120,6 +121,7 @@
   ':init-api',
   ':util',
   '//gerrit-cache-h2:cache-h2',
+  '//gerrit-elasticsearch:elasticsearch',
   '//gerrit-gpg:gpg',
   '//gerrit-lucene:lucene',
   '//gerrit-oauth:oauth',
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
index eb17530..9d4120c 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Daemon.java
@@ -20,6 +20,7 @@
 import com.google.common.annotations.VisibleForTesting;
 import com.google.common.base.MoreObjects;
 import com.google.gerrit.common.EventBroker;
+import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.AllRequestFilter;
@@ -408,15 +409,18 @@
     return cfgInjector.createChildInjector(modules);
   }
 
-  private AbstractModule createIndexModule() {
+  private Module createIndexModule() {
     if (slave) {
       return new DummyIndexModule();
     }
+    if (luceneModule != null) {
+      return luceneModule;
+    }
     switch (indexType) {
       case LUCENE:
-        return luceneModule != null
-            ? luceneModule
-            : LuceneIndexModule.latestVersionWithOnlineUpgrade();
+        return LuceneIndexModule.latestVersionWithOnlineUpgrade();
+      case ELASTICSEARCH:
+        return ElasticIndexModule.latestVersionWithOnlineUpgrade();
       default:
         throw new IllegalStateException("unsupported index.type = " + indexType);
     }
@@ -426,6 +430,7 @@
     indexType = IndexModule.getIndexType(cfgInjector);
     switch (indexType) {
       case LUCENE:
+      case ELASTICSEARCH:
         break;
       default:
         throw new IllegalStateException("unsupported index.type = " + indexType);
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
index 501b115..ee0d02f 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Reindex.java
@@ -20,6 +20,7 @@
 
 import com.google.common.collect.Sets;
 import com.google.gerrit.common.Die;
+import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.config.FactoryModule;
 import com.google.gerrit.lifecycle.LifecycleManager;
 import com.google.gerrit.lucene.LuceneIndexModule;
@@ -161,6 +162,10 @@
         indexModule = LuceneIndexModule.singleVersionWithExplicitVersions(
             versions, threads);
         break;
+      case ELASTICSEARCH:
+        indexModule = ElasticIndexModule
+            .singleVersionWithExplicitVersions(versions, threads);
+        break;
       default:
         throw new IllegalStateException("unsupported index.type");
     }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
index 185063b..c8d8edb 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitIndex.java
@@ -15,7 +15,8 @@
 package com.google.gerrit.pgm.init;
 
 import com.google.common.collect.Iterables;
-import com.google.gerrit.lucene.AbstractLuceneIndex;
+import com.google.common.collect.Sets;
+import com.google.gerrit.index.IndexUtils;
 import com.google.gerrit.pgm.init.api.ConsoleUI;
 import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
@@ -61,9 +62,17 @@
       type = index.select("Type", "type", type);
     }
 
+    if (type == IndexType.ELASTICSEARCH) {
+      index.select("Transport protocol", "protocol", "http",
+          Sets.newHashSet("http", "https"));
+      index.string("Hostname", "hostname", "localhost");
+      index.string("Port", "port", "9200");
+      index.string("Index Name", "name", "gerrit");
+    }
+
     if ((site.isNew || isEmptySite()) && type == IndexType.LUCENE) {
       for (SchemaDefinitions<?> def : IndexModule.ALL_SCHEMA_DEFS) {
-        AbstractLuceneIndex.setReady(
+        IndexUtils.setReady(
             site, def.getName(), def.getLatest().getVersion(), true);
       }
     } else {
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
index f3a68a1..0f3d45c 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Change.java
@@ -149,6 +149,7 @@
       }
       int ce = nextNonDigit(ref, cs);
       if (ref.substring(ce).equals(RefNames.META_SUFFIX)
+          || ref.substring(ce).equals(RefNames.ROBOT_COMMENTS_SUFFIX)
           || PatchSet.Id.fromRef(ref, ce) >= 0) {
         return new Change.Id(Integer.parseInt(ref.substring(cs, ce)));
       }
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
index b2bd818..7629705 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RefNames.java
@@ -68,6 +68,9 @@
   /** Suffix of a meta ref in the NoteDb. */
   public static final String META_SUFFIX = "/meta";
 
+  /** Suffix of a ref that stores robot comments in the NoteDb. */
+  public static final String ROBOT_COMMENTS_SUFFIX = "/robot-comments";
+
   public static final String EDIT_PREFIX = "edit-";
 
   public static String fullName(String ref) {
@@ -92,6 +95,14 @@
     return r.toString();
   }
 
+  public static String robotCommentsRef(Change.Id id) {
+    StringBuilder r = new StringBuilder();
+    r.append(REFS_CHANGES);
+    r.append(shard(id.get()));
+    r.append(ROBOT_COMMENTS_SUFFIX);
+    return r.toString();
+  }
+
   public static String refsUsers(Account.Id accountId) {
     StringBuilder r = new StringBuilder();
     r.append(REFS_USERS);
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RobotComment.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RobotComment.java
new file mode 100644
index 0000000..da9584d
--- /dev/null
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/RobotComment.java
@@ -0,0 +1,54 @@
+// Copyright (C) 2016 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.
+
+package com.google.gerrit.reviewdb.client;
+
+import java.sql.Timestamp;
+import java.util.Objects;
+
+public class RobotComment extends Comment {
+  public String robotId;
+  public String robotRunId;
+  public String url;
+
+  public RobotComment(Key key, Account.Id author, Timestamp writtenOn,
+      short side, String message, String serverId, String robotId,
+      String robotRunId) {
+    super(key, author, writtenOn, side, message, serverId);
+    this.robotId = robotId;
+    this.robotRunId = robotRunId;
+  }
+
+  @Override
+  public String toString() {
+    return new StringBuilder()
+        .append("RobotComment{")
+        .append("key=").append(key).append(',')
+        .append("robotId=").append(robotId).append(',')
+        .append("robotRunId=").append(robotRunId).append(',')
+        .append("lineNbr=").append(lineNbr).append(',')
+        .append("author=").append(author.getId().get()).append(',')
+        .append("writtenOn=").append(writtenOn.toString()).append(',')
+        .append("side=").append(side).append(',')
+        .append("message=").append(Objects.toString(message, "")).append(',')
+        .append("parentUuid=")
+            .append(Objects.toString(parentUuid, "")).append(',')
+        .append("range=").append(Objects.toString(range, "")).append(',')
+        .append("revId=").append(revId != null ? revId : "").append(',')
+        .append("tag=").append(Objects.toString(tag, "")).append(',')
+        .append("url=").append(url)
+        .append('}')
+        .toString();
+  }
+}
diff --git a/gerrit-server/BUCK b/gerrit-server/BUCK
index 66fc545..a50df82 100644
--- a/gerrit-server/BUCK
+++ b/gerrit-server/BUCK
@@ -91,6 +91,7 @@
   ':server',
   '//gerrit-common:server',
   '//gerrit-cache-h2:cache-h2',
+  '//gerrit-elasticsearch:elasticsearch',
   '//gerrit-extension-api:api',
   '//gerrit-gpg:gpg',
   '//gerrit-lucene:lucene',
@@ -181,6 +182,7 @@
     '//gerrit-server/src/main/prolog:common',
     '//lib/antlr:java_runtime',
   ],
+  visibility = ['PUBLIC'],
 )
 
 java_test(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
index f15ff66..8197689 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CommentsUtil.java
@@ -21,6 +21,7 @@
 import com.google.common.base.Optional;
 import com.google.common.collect.ComparisonChain;
 import com.google.common.collect.FluentIterable;
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
@@ -34,6 +35,7 @@
 import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerId;
@@ -156,9 +158,17 @@
     }
 
     notes.load();
-    List<Comment> comments = new ArrayList<>();
-    comments.addAll(notes.getComments().values());
-    return sort(comments);
+    return sort(Lists.newArrayList(notes.getComments().values()));
+  }
+
+  public List<RobotComment> robotCommentsByChange(ChangeNotes notes)
+      throws OrmException {
+    if (!migration.readChanges()) {
+      return ImmutableList.of();
+    }
+
+    notes.load();
+    return sort(Lists.newArrayList(notes.getRobotComments().values()));
   }
 
   public List<Comment> draftByChange(ReviewDb db, ChangeNotes notes)
@@ -221,6 +231,14 @@
         commentsOnPatchSet(notes.load().getComments().values(), psId));
   }
 
+  public List<RobotComment> robotCommentsByPatchSet(ChangeNotes notes,
+      PatchSet.Id psId) throws OrmException {
+    if (!migration.readChanges()) {
+      return ImmutableList.of();
+    }
+    return commentsOnPatchSet(notes.load().getRobotComments().values(), psId);
+  }
+
   /**
    * For the commit message the A side in a diff view is always empty when a
    * comparison against an ancestor is done, so there can't be any comments on
@@ -309,6 +327,13 @@
         .upsert(toPatchLineComments(update.getId(), status, comments));
   }
 
+  public void putRobotComments(ChangeUpdate update,
+      Iterable<RobotComment> comments) {
+    for (RobotComment c : comments) {
+      update.putRobotComment(c);
+    }
+  }
+
   public void deleteComments(ReviewDb db, ChangeUpdate update,
       Iterable<Comment> comments) throws OrmException {
     for (Comment c : comments) {
@@ -352,11 +377,11 @@
     return sort(result);
   }
 
-  private static List<Comment> commentsOnPatchSet(
-      Collection<Comment> allComments,
+  private static <T extends Comment> List<T> commentsOnPatchSet(
+      Collection<T> allComments,
       PatchSet.Id psId) {
-    List<Comment> result = new ArrayList<>(allComments.size());
-    for (Comment c : allComments) {
+    List<T> result = new ArrayList<>(allComments.size());
+    for (T c : allComments) {
       if (c.key.patchSetId == psId.get()) {
         result.add(c);
       }
@@ -400,7 +425,7 @@
         RefNames.refsDraftCommentsPrefix(changeId)).values();
   }
 
-  private static List<Comment> sort(List<Comment> comments) {
+  private static <T extends Comment> List<T> sort(List<T> comments) {
     Collections.sort(comments, COMMENT_ORDER);
     return comments;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
index 63d2ddb..149931d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountCacheImpl.java
@@ -174,7 +174,7 @@
       this.loader = loader;
       this.byName = byUsername;
       this.readFromGit =
-          cfg.getBoolean("user", null, "readProjectWatchesFromGit", true);
+          cfg.getBoolean("user", null, "readProjectWatchesFromGit", false);
       this.watchConfig = watchConfig;
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
index 7cda472..3748e17 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/GetWatchedProjects.java
@@ -60,7 +60,7 @@
     this.dbProvider = dbProvider;
     this.self = self;
     this.readFromGit =
-        cfg.getBoolean("user", null, "readProjectWatchesFromGit", true);
+        cfg.getBoolean("user", null, "readProjectWatchesFromGit", false);
     this.watchConfig = watchConfig;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
index 871b1cd..aa32d27 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/VersionedAuthorizedKeys.java
@@ -101,7 +101,7 @@
       return read(accountId).getKey(seq);
     }
 
-    public AccountSshKey addKey(Account.Id accountId, String pub)
+    public synchronized AccountSshKey addKey(Account.Id accountId, String pub)
         throws IOException, ConfigInvalidException, InvalidSshKeyException {
       VersionedAuthorizedKeys authorizedKeys = read(accountId);
       AccountSshKey key = authorizedKeys.addKey(pub);
@@ -109,7 +109,7 @@
       return key;
     }
 
-    public void deleteKey(Account.Id accountId, int seq)
+    public synchronized void deleteKey(Account.Id accountId, int seq)
         throws IOException, ConfigInvalidException {
       VersionedAuthorizedKeys authorizedKeys = read(accountId);
       if (authorizedKeys.deleteKey(seq)) {
@@ -117,7 +117,7 @@
       }
     }
 
-    public void markKeyInvalid(Account.Id accountId, int seq)
+    public synchronized void markKeyInvalid(Account.Id accountId, int seq)
         throws IOException, ConfigInvalidException {
       VersionedAuthorizedKeys authorizedKeys = read(accountId);
       if (authorizedKeys.markKeyInvalid(seq)) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
index c3d28ca..a3cd0c9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/WatchConfig.java
@@ -121,7 +121,7 @@
       }
     }
 
-    public void upsertProjectWatches(Account.Id accountId,
+    public synchronized void upsertProjectWatches(Account.Id accountId,
         Map<ProjectWatchKey, Set<NotifyType>> newProjectWatches)
         throws IOException, ConfigInvalidException {
       WatchConfig watchConfig = read(accountId);
@@ -131,7 +131,7 @@
       commit(watchConfig);
     }
 
-    public void deleteProjectWatches(Account.Id accountId,
+    public synchronized void deleteProjectWatches(Account.Id accountId,
         Collection<ProjectWatchKey> projectWatchKeys)
             throws IOException, ConfigInvalidException {
       WatchConfig watchConfig = read(accountId);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java
index 228dad6..bc38df2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/Module.java
@@ -24,6 +24,7 @@
 
     factory(ChangeApiImpl.Factory.class);
     factory(CommentApiImpl.Factory.class);
+    factory(RobotCommentApiImpl.Factory.class);
     factory(DraftApiImpl.Factory.class);
     factory(RevisionApiImpl.Factory.class);
     factory(FileApiImpl.Factory.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
index fed792b..4e847a1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RevisionApiImpl.java
@@ -26,6 +26,7 @@
 import com.google.gerrit.extensions.api.changes.RebaseInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
+import com.google.gerrit.extensions.api.changes.RobotCommentApi;
 import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.client.SubmitType;
 import com.google.gerrit.extensions.common.ActionInfo;
@@ -33,6 +34,7 @@
 import com.google.gerrit.extensions.common.CommitInfo;
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.common.TestSubmitRuleInput;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.IdString;
@@ -50,6 +52,7 @@
 import com.google.gerrit.server.change.GetRevisionActions;
 import com.google.gerrit.server.change.ListRevisionComments;
 import com.google.gerrit.server.change.ListRevisionDrafts;
+import com.google.gerrit.server.change.ListRobotComments;
 import com.google.gerrit.server.change.Mergeable;
 import com.google.gerrit.server.change.PostReview;
 import com.google.gerrit.server.change.PreviewSubmit;
@@ -58,6 +61,7 @@
 import com.google.gerrit.server.change.RebaseUtil;
 import com.google.gerrit.server.change.Reviewed;
 import com.google.gerrit.server.change.RevisionResource;
+import com.google.gerrit.server.change.RobotComments;
 import com.google.gerrit.server.change.Submit;
 import com.google.gerrit.server.change.TestSubmitType;
 import com.google.gerrit.server.git.GitRepositoryManager;
@@ -101,12 +105,15 @@
   private final Mergeable mergeable;
   private final FileApiImpl.Factory fileApi;
   private final ListRevisionComments listComments;
+  private final ListRobotComments listRobotComments;
   private final ListRevisionDrafts listDrafts;
   private final CreateDraftComment createDraft;
   private final DraftComments drafts;
   private final DraftApiImpl.Factory draftFactory;
   private final Comments comments;
   private final CommentApiImpl.Factory commentFactory;
+  private final RobotComments robotComments;
+  private final RobotCommentApiImpl.Factory robotCommentFactory;
   private final GetRevisionActions revisionActions;
   private final TestSubmitType testSubmitType;
   private final TestSubmitType.Get getSubmitType;
@@ -131,12 +138,15 @@
       Mergeable mergeable,
       FileApiImpl.Factory fileApi,
       ListRevisionComments listComments,
+      ListRobotComments listRobotComments,
       ListRevisionDrafts listDrafts,
       CreateDraftComment createDraft,
       DraftComments drafts,
       DraftApiImpl.Factory draftFactory,
       Comments comments,
       CommentApiImpl.Factory commentFactory,
+      RobotComments robotComments,
+      RobotCommentApiImpl.Factory robotCommentFactory,
       GetRevisionActions revisionActions,
       TestSubmitType testSubmitType,
       TestSubmitType.Get getSubmitType,
@@ -160,12 +170,15 @@
     this.mergeable = mergeable;
     this.fileApi = fileApi;
     this.listComments = listComments;
+    this.robotComments = robotComments;
+    this.listRobotComments = listRobotComments;
     this.listDrafts = listDrafts;
     this.createDraft = createDraft;
     this.drafts = drafts;
     this.draftFactory = draftFactory;
     this.comments = comments;
     this.commentFactory = commentFactory;
+    this.robotCommentFactory = robotCommentFactory;
     this.revisionActions = revisionActions;
     this.testSubmitType = testSubmitType;
     this.getSubmitType = getSubmitType;
@@ -353,6 +366,15 @@
   }
 
   @Override
+  public Map<String, List<RobotCommentInfo>> robotComments() throws RestApiException {
+    try {
+      return listRobotComments.apply(revision);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot retrieve robot comments", e);
+    }
+  }
+
+  @Override
   public List<CommentInfo> commentsAsList() throws RestApiException {
     try {
       return listComments.getComments(revision);
@@ -371,6 +393,15 @@
   }
 
   @Override
+  public List<RobotCommentInfo> robotCommentsAsList() throws RestApiException {
+    try {
+      return listRobotComments.getComments(revision);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot retrieve robot comments", e);
+    }
+  }
+
+  @Override
   public List<CommentInfo> draftsAsList() throws RestApiException {
     try {
       return listDrafts.getComments(revision);
@@ -413,6 +444,16 @@
   }
 
   @Override
+  public RobotCommentApi robotComment(String id) throws RestApiException {
+    try {
+      return robotCommentFactory
+          .create(robotComments.parse(revision, IdString.fromDecoded(id)));
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot retrieve robot comment", e);
+    }
+  }
+
+  @Override
   public BinaryResult patch() throws RestApiException {
     try {
       return getPatch.apply(revision);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
new file mode 100644
index 0000000..9169a4f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/api/changes/RobotCommentApiImpl.java
@@ -0,0 +1,49 @@
+// Copyright (C) 2016 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.
+
+package com.google.gerrit.server.api.changes;
+
+import com.google.gerrit.extensions.api.changes.RobotCommentApi;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
+import com.google.gerrit.server.change.GetRobotComment;
+import com.google.gerrit.server.change.RobotCommentResource;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.assistedinject.Assisted;
+
+public class RobotCommentApiImpl implements RobotCommentApi {
+  interface Factory {
+    RobotCommentApiImpl create(RobotCommentResource c);
+  }
+
+  private final GetRobotComment getComment;
+  private final RobotCommentResource comment;
+
+  @Inject
+  RobotCommentApiImpl(GetRobotComment getComment,
+      @Assisted RobotCommentResource comment) {
+    this.getComment = getComment;
+    this.comment = comment;
+  }
+
+  @Override
+  public RobotCommentInfo get() throws RestApiException {
+    try {
+      return getComment.apply(comment);
+    } catch (OrmException e) {
+      throw new RestApiException("Cannot retrieve robot comment", e);
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
index 1e48717..be019fb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CommentJson.java
@@ -21,8 +21,10 @@
 import com.google.gerrit.extensions.client.Comment.Range;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.RobotCommentInfo;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -55,100 +57,134 @@
     return this;
   }
 
-  CommentInfo format(Comment c) throws OrmException {
-    AccountLoader loader = null;
-    if (fillAccounts) {
-      loader = accountLoaderFactory.create(true);
-    }
-    CommentInfo commentInfo = toCommentInfo(c, loader);
-    if (fillAccounts) {
-      loader.fill();
-    }
-    return commentInfo;
+  public CommentFormatter newCommentFormatter() {
+    return new CommentFormatter();
   }
 
-  Map<String, List<CommentInfo>> format(Iterable<Comment> l)
-      throws OrmException {
-    Map<String, List<CommentInfo>> out = new TreeMap<>();
-    AccountLoader accountLoader = fillAccounts
-        ? accountLoaderFactory.create(true)
-        : null;
+  public RobotCommentFormatter newRobotCommentFormatter() {
+    return new RobotCommentFormatter();
+  }
 
-    for (Comment c : l) {
-      CommentInfo o = toCommentInfo(c, accountLoader);
-      List<CommentInfo> list = out.get(o.path);
-      if (list == null) {
-        list = new ArrayList<>();
-        out.put(o.path, list);
+  private abstract class BaseCommentFormatter<F extends Comment,
+      T extends CommentInfo> {
+    public T format(F comment) throws OrmException {
+      AccountLoader loader =
+          fillAccounts ? accountLoaderFactory.create(true) : null;
+      T info = toInfo(comment, loader);
+      if (loader != null) {
+        loader.fill();
       }
-      o.path = null;
-      list.add(o);
+      return info;
     }
 
-    for (List<CommentInfo> list : out.values()) {
-      Collections.sort(list, COMMENT_INFO_ORDER);
+    public Map<String, List<T>> format(Iterable<F> comments)
+        throws OrmException {
+      AccountLoader loader =
+          fillAccounts ? accountLoaderFactory.create(true) : null;
+
+      Map<String, List<T>> out = new TreeMap<>();
+
+      for (F c : comments) {
+        T o = toInfo(c, loader);
+        List<T> list = out.get(o.path);
+        if (list == null) {
+          list = new ArrayList<>();
+          out.put(o.path, list);
+        }
+        o.path = null;
+        list.add(o);
+      }
+
+      for (List<T> list : out.values()) {
+        Collections.sort(list, COMMENT_INFO_ORDER);
+      }
+
+      if (loader != null) {
+        loader.fill();
+      }
+      return out;
     }
 
-    if (accountLoader != null) {
-      accountLoader.fill();
+    public List<T> formatAsList(Iterable<F> comments) throws OrmException {
+      AccountLoader loader =
+          fillAccounts ? accountLoaderFactory.create(true) : null;
+
+      List<T> out = FluentIterable.from(comments)
+          .transform(c -> toInfo(c, loader))
+          .toSortedList(COMMENT_INFO_ORDER);
+
+      if (loader != null) {
+        loader.fill();
+      }
+      return out;
     }
 
-    return out;
-  }
+    protected abstract T toInfo(F comment, AccountLoader loader);
 
-  List<CommentInfo> formatAsList(Iterable<Comment> l)
-      throws OrmException {
-    AccountLoader accountLoader = fillAccounts
-        ? accountLoaderFactory.create(true)
-        : null;
-    List<CommentInfo> out = FluentIterable
-        .from(l)
-        .transform(c -> toCommentInfo(c, accountLoader))
-        .toSortedList(COMMENT_INFO_ORDER);
-
-    if (accountLoader != null) {
-      accountLoader.fill();
-    }
-
-    return out;
-  }
-
-  private CommentInfo toCommentInfo(Comment c, AccountLoader loader) {
-    CommentInfo r = new CommentInfo();
-    if (fillPatchSet) {
-      r.patchSet = c.key.patchSetId;
-    }
-    r.id = Url.encode(c.key.uuid);
-    r.path = c.key.filename;
-    if (c.side <= 0) {
-      r.side = Side.PARENT;
-      if (c.side < 0) {
-        r.parent = -c.side;
+    protected void fillCommentInfo(Comment c, CommentInfo r,
+        AccountLoader loader) {
+      if (fillPatchSet) {
+        r.patchSet = c.key.patchSetId;
+      }
+      r.id = Url.encode(c.key.uuid);
+      r.path = c.key.filename;
+      if (c.side <= 0) {
+        r.side = Side.PARENT;
+        if (c.side < 0) {
+          r.parent = -c.side;
+        }
+      }
+      if (c.lineNbr > 0) {
+        r.line = c.lineNbr;
+      }
+      r.inReplyTo = Url.encode(c.parentUuid);
+      r.message = Strings.emptyToNull(c.message);
+      r.updated = c.writtenOn;
+      r.range = toRange(c.range);
+      r.tag = c.tag;
+      if (loader != null) {
+        r.author = loader.get(c.author.getId());
       }
     }
-    if (c.lineNbr > 0) {
-      r.line = c.lineNbr;
+
+    private Range toRange(Comment.Range commentRange) {
+      Range range = null;
+      if (commentRange != null) {
+        range = new Range();
+        range.startLine = commentRange.startLine;
+        range.startCharacter = commentRange.startChar;
+        range.endLine = commentRange.endLine;
+        range.endCharacter = commentRange.endChar;
+      }
+      return range;
     }
-    r.inReplyTo = Url.encode(c.parentUuid);
-    r.message = Strings.emptyToNull(c.message);
-    r.updated = c.writtenOn;
-    r.range = toRange(c.range);
-    r.tag = c.tag;
-    if (loader != null) {
-      r.author = loader.get(c.author.getId());
-    }
-    return r;
   }
 
-  private Range toRange(Comment.Range commentRange) {
-    Range range = null;
-    if (commentRange != null) {
-      range = new Range();
-      range.startLine = commentRange.startLine;
-      range.startCharacter = commentRange.startChar;
-      range.endLine = commentRange.endLine;
-      range.endCharacter = commentRange.endChar;
+  class CommentFormatter extends BaseCommentFormatter<Comment, CommentInfo> {
+    @Override
+    protected CommentInfo toInfo(Comment c, AccountLoader loader) {
+      CommentInfo ci = new CommentInfo();
+      fillCommentInfo(c, ci, loader);
+      return ci;
     }
-    return range;
+
+    private CommentFormatter() {
+    }
+  }
+
+  class RobotCommentFormatter
+      extends BaseCommentFormatter<RobotComment, RobotCommentInfo> {
+    @Override
+    protected RobotCommentInfo toInfo(RobotComment c, AccountLoader loader) {
+      RobotCommentInfo rci = new RobotCommentInfo();
+      rci.robotId = c.robotId;
+      rci.robotRunId = c.robotRunId;
+      rci.url = c.url;
+      fillCommentInfo(c, rci, loader);
+      return rci;
+    }
+
+    private RobotCommentFormatter() {
+    }
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
index 37af37f..7ca8478 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
@@ -90,8 +90,8 @@
       Op op = new Op(rsrc.getPatchSet().getId(), in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
-      return Response.created(
-          commentJson.get().setFillAccounts(false).format(op.comment));
+      return Response.created(commentJson.get().setFillAccounts(false)
+          .newCommentFormatter().format(op.comment));
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetComment.java
index d87c7eb..d601737 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetComment.java
@@ -33,6 +33,6 @@
 
   @Override
   public CommentInfo apply(CommentResource rsrc) throws OrmException {
-    return commentJson.get().format(rsrc.getComment());
+    return commentJson.get().newCommentFormatter().format(rsrc.getComment());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDraftComment.java
index 22f90c9..a380ce3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetDraftComment.java
@@ -33,6 +33,6 @@
 
   @Override
   public CommentInfo apply(DraftCommentResource rsrc) throws OrmException {
-    return commentJson.get().format(rsrc.getComment());
+    return commentJson.get().newCommentFormatter().format(rsrc.getComment());
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRobotComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRobotComment.java
new file mode 100644
index 0000000..c10cd2e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/GetRobotComment.java
@@ -0,0 +1,39 @@
+// Copyright (C) 2016 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.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+@Singleton
+public class GetRobotComment implements RestReadView<RobotCommentResource> {
+
+  private final Provider<CommentJson> commentJson;
+
+  @Inject
+  GetRobotComment(Provider<CommentJson> commentJson) {
+    this.commentJson = commentJson;
+  }
+
+  @Override
+  public RobotCommentInfo apply(RobotCommentResource rsrc) throws OrmException {
+    return commentJson.get().newRobotCommentFormatter()
+        .format(rsrc.getComment());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java
index 40fa7c8..32b5ae8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeComments.java
@@ -53,6 +53,7 @@
     return commentJson.get()
         .setFillAccounts(true)
         .setFillPatchSet(true)
+        .newCommentFormatter()
         .format(commentsUtil.publishedByChange(db.get(), cd.notes()));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java
index dbbd35d..6a3e237 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListChangeDrafts.java
@@ -59,6 +59,6 @@
     return commentJson.get()
         .setFillAccounts(false)
         .setFillPatchSet(true)
-        .format(drafts);
+        .newCommentFormatter().format(drafts);
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionDrafts.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionDrafts.java
index 2c45219..21d427c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionDrafts.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRevisionDrafts.java
@@ -57,13 +57,13 @@
       throws OrmException {
     return commentJson.get()
         .setFillAccounts(includeAuthorInfo())
-        .format(listComments(rsrc));
+        .newCommentFormatter().format(listComments(rsrc));
   }
 
   public List<CommentInfo> getComments(RevisionResource rsrc)
       throws OrmException {
     return commentJson.get()
         .setFillAccounts(includeAuthorInfo())
-        .formatAsList(listComments(rsrc));
+        .newCommentFormatter().formatAsList(listComments(rsrc));
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRobotComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRobotComments.java
new file mode 100644
index 0000000..01ad9ee
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ListRobotComments.java
@@ -0,0 +1,67 @@
+// Copyright (C) 2016 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.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.common.RobotCommentInfo;
+import com.google.gerrit.extensions.restapi.RestReadView;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+import com.google.inject.Singleton;
+
+import java.util.List;
+import java.util.Map;
+
+@Singleton
+public class ListRobotComments implements RestReadView<RevisionResource> {
+  protected final Provider<ReviewDb> db;
+  protected final Provider<CommentJson> commentJson;
+  protected final CommentsUtil commentsUtil;
+
+  @Inject
+  ListRobotComments(Provider<ReviewDb> db,
+      Provider<CommentJson> commentJson,
+      CommentsUtil commentsUtil) {
+    this.db = db;
+    this.commentJson = commentJson;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public Map<String, List<RobotCommentInfo>> apply(RevisionResource rsrc)
+      throws OrmException {
+    return commentJson.get()
+        .setFillAccounts(true)
+        .newRobotCommentFormatter()
+        .format(listComments(rsrc));
+  }
+
+  public List<RobotCommentInfo> getComments(RevisionResource rsrc)
+      throws OrmException {
+    return commentJson.get()
+        .setFillAccounts(true)
+        .newRobotCommentFormatter()
+        .formatAsList(listComments(rsrc));
+  }
+
+  private Iterable<RobotComment> listComments(RevisionResource rsrc)
+      throws OrmException {
+    return commentsUtil.robotCommentsByPatchSet(
+        rsrc.getNotes(), rsrc.getPatchSet().getId());
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
index 9b284fa..a52920a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Module.java
@@ -21,6 +21,7 @@
 import static com.google.gerrit.server.change.FileResource.FILE_KIND;
 import static com.google.gerrit.server.change.ReviewerResource.REVIEWER_KIND;
 import static com.google.gerrit.server.change.RevisionResource.REVISION_KIND;
+import static com.google.gerrit.server.change.RobotCommentResource.ROBOT_COMMENT_KIND;
 import static com.google.gerrit.server.change.VoteResource.VOTE_KIND;
 
 import com.google.gerrit.extensions.registration.DynamicMap;
@@ -37,11 +38,13 @@
     bind(Reviewers.class);
     bind(DraftComments.class);
     bind(Comments.class);
+    bind(RobotComments.class);
     bind(Files.class);
     bind(Votes.class);
 
     DynamicMap.mapOf(binder(), CHANGE_KIND);
     DynamicMap.mapOf(binder(), COMMENT_KIND);
+    DynamicMap.mapOf(binder(), ROBOT_COMMENT_KIND);
     DynamicMap.mapOf(binder(), DRAFT_COMMENT_KIND);
     DynamicMap.mapOf(binder(), FILE_KIND);
     DynamicMap.mapOf(binder(), REVIEWER_KIND);
@@ -116,6 +119,9 @@
     child(REVISION_KIND, "comments").to(Comments.class);
     get(COMMENT_KIND).to(GetComment.class);
 
+    child(REVISION_KIND, "robotcomments").to(RobotComments.class);
+    get(ROBOT_COMMENT_KIND).to(GetRobotComment.class);
+
     child(REVISION_KIND, "files").to(Files.class);
     put(FILE_KIND, "reviewed").to(PutReviewed.class);
     delete(FILE_KIND, "reviewed").to(DeleteReviewed.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
index 1ac9e11..7ccda0d 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReview.java
@@ -19,6 +19,7 @@
 import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static java.nio.charset.StandardCharsets.UTF_8;
+import static java.util.stream.Collectors.toSet;
 import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
 
 import com.google.auto.value.AutoValue;
@@ -43,10 +44,12 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.DraftHandling;
+import com.google.gerrit.extensions.api.changes.ReviewInput.RobotCommentInput;
 import com.google.gerrit.extensions.api.changes.ReviewResult;
 import com.google.gerrit.extensions.client.Side;
 import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.MethodNotAllowedException;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
@@ -59,6 +62,7 @@
 import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
@@ -76,6 +80,7 @@
 import com.google.gerrit.server.git.UpdateException;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gerrit.server.notedb.NotesMigration;
 import com.google.gerrit.server.patch.PatchListCache;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.query.change.ChangeData;
@@ -94,7 +99,6 @@
 import java.util.Collection;
 import java.util.Collections;
 import java.util.HashMap;
-import java.util.HashSet;
 import java.util.Iterator;
 import java.util.List;
 import java.util.Map;
@@ -118,6 +122,7 @@
   private final CommentAdded commentAdded;
   private final PostReviewers postReviewers;
   private final String serverId;
+  private final NotesMigration migration;
 
   @Inject
   PostReview(Provider<ReviewDb> db,
@@ -133,7 +138,8 @@
       EmailReviewComments.Factory email,
       CommentAdded commentAdded,
       PostReviewers postReviewers,
-      @GerritServerId String serverId) {
+      @GerritServerId String serverId,
+      NotesMigration migration) {
     this.db = db;
     this.batchUpdateFactory = batchUpdateFactory;
     this.changes = changes;
@@ -148,6 +154,7 @@
     this.commentAdded = commentAdded;
     this.postReviewers = postReviewers;
     this.serverId = serverId;
+    this.migration = migration;
   }
 
   @Override
@@ -173,6 +180,12 @@
     if (input.comments != null) {
       checkComments(revision, input.comments);
     }
+    if (input.robotComments != null) {
+      if (!migration.readChanges()) {
+        throw new MethodNotAllowedException("robot comments not supported");
+      }
+      checkRobotComments(revision, input.robotComments);
+    }
     if (input.notify == null) {
       log.warn("notify = null; assuming notify = NONE");
       input.notify = NotifyHandling.NONE;
@@ -316,16 +329,16 @@
     }
   }
 
-  private void checkComments(RevisionResource revision, Map<String, List<CommentInput>> in)
-      throws BadRequestException, OrmException {
-    Iterator<Map.Entry<String, List<CommentInput>>> mapItr =
-        in.entrySet().iterator();
+  private <T extends CommentInput> void checkComments(RevisionResource revision,
+      Map<String, List<T>> in) throws BadRequestException, OrmException {
+    Iterator<? extends Map.Entry<String, List<T>>> mapItr =
+            in.entrySet().iterator();
     Set<String> filePaths =
         Sets.newHashSet(changeDataFactory.create(
             db.get(), revision.getControl()).filePaths(
                 revision.getPatchSet()));
     while (mapItr.hasNext()) {
-      Map.Entry<String, List<CommentInput>> ent = mapItr.next();
+      Map.Entry<String, List<T>> ent = mapItr.next();
       String path = ent.getKey();
       if (!filePaths.contains(path) && !Patch.isMagic(path)) {
         throw new BadRequestException(String.format(
@@ -333,7 +346,7 @@
             path, revision.getChange().currentPatchSetId()));
       }
       if (Patch.isMagic(path)) {
-        for (CommentInput comment : ent.getValue()) {
+        for (T comment : ent.getValue()) {
           if (comment.side == Side.PARENT && comment.parent == null) {
             throw new BadRequestException(
                 String.format("cannot comment on %s on auto-merge", path));
@@ -341,15 +354,15 @@
         }
       }
 
-      List<CommentInput> list = ent.getValue();
+      List<T> list = ent.getValue();
       if (list == null) {
         mapItr.remove();
         continue;
       }
 
-      Iterator<CommentInput> listItr = list.iterator();
+      Iterator<T> listItr = list.iterator();
       while (listItr.hasNext()) {
-        CommentInput c = listItr.next();
+        T c = listItr.next();
         if (c == null) {
           listItr.remove();
           continue;
@@ -370,6 +383,25 @@
     }
   }
 
+  private void checkRobotComments(RevisionResource revision,
+      Map<String, List<RobotCommentInput>> in)
+          throws BadRequestException, OrmException {
+    for (Map.Entry<String, List<RobotCommentInput>> e : in.entrySet()) {
+      String path = e.getKey();
+      for (RobotCommentInput c : e.getValue()) {
+        if (c.robotId == null) {
+          throw new BadRequestException(String
+              .format("robotId is missing for robot comment on %s", path));
+        }
+        if (c.robotRunId == null) {
+          throw new BadRequestException(String
+              .format("robotRunId is missing for robot comment on %s", path));
+        }
+      }
+    }
+    checkComments(revision, in);
+  }
+
   /**
    * Used to compare Comments with CommentInput comments.
    */
@@ -427,6 +459,7 @@
       ps = psUtil.get(ctx.getDb(), ctx.getNotes(), psId);
       boolean dirty = false;
       dirty |= insertComments(ctx);
+      dirty |= insertRobotComments(ctx);
       dirty |= updateLabels(ctx);
       dirty |= insertMessage(ctx);
       return dirty;
@@ -471,7 +504,7 @@
 
       Set<CommentSetEntry> existingIds = in.omitDuplicateComments
           ? readExistingComments(ctx)
-          : Collections.<CommentSetEntry>emptySet();
+          : Collections.emptySet();
 
       for (Map.Entry<String, List<CommentInput>> ent : map.entrySet()) {
         String path = ent.getKey();
@@ -530,14 +563,53 @@
       return !toDel.isEmpty() || !toPublish.isEmpty();
     }
 
+    private boolean insertRobotComments(ChangeContext ctx) throws OrmException {
+      if (in.robotComments == null) {
+        return false;
+      }
+
+      List<RobotComment> toAdd = new ArrayList<>(in.robotComments.size());
+
+      Set<CommentSetEntry> existingIds = in.omitDuplicateComments
+          ? readExistingRobotComments(ctx)
+          : Collections.emptySet();
+
+      for (Map.Entry<String, List<RobotCommentInput>> ent : in.robotComments.entrySet()) {
+        String path = ent.getKey();
+        for (RobotCommentInput c : ent.getValue()) {
+          RobotComment e = new RobotComment(
+              new Comment.Key(ChangeUtil.messageUUID(ctx.getDb()), path,
+                  psId.get()),
+              user.getAccountId(), ctx.getWhen(), c.side(), c.message, serverId,
+              c.robotId, c.robotRunId);
+          e.parentUuid = Url.decode(c.inReplyTo);
+          e.url = c.url;
+          e.setLineNbrAndRange(c.line, c.range);
+          e.tag = in.tag;
+          setCommentRevId(e, patchListCache, ctx.getChange(), ps);
+
+          if (existingIds.contains(CommentSetEntry.create(e))) {
+            continue;
+          }
+          toAdd.add(e);
+        }
+      }
+
+      commentsUtil.putRobotComments(ctx.getUpdate(psId), toAdd);
+      comments.addAll(toAdd);
+      return !toAdd.isEmpty();
+    }
+
     private Set<CommentSetEntry> readExistingComments(ChangeContext ctx)
         throws OrmException {
-      Set<CommentSetEntry> r = new HashSet<>();
-      for (Comment c : commentsUtil.publishedByChange(ctx.getDb(),
-            ctx.getNotes())) {
-        r.add(CommentSetEntry.create(c));
-      }
-      return r;
+      return commentsUtil.publishedByChange(ctx.getDb(), ctx.getNotes())
+          .stream().map(CommentSetEntry::create).collect(toSet());
+    }
+
+    private Set<CommentSetEntry> readExistingRobotComments(ChangeContext ctx)
+        throws OrmException {
+      return commentsUtil.robotCommentsByChange(ctx.getNotes())
+          .stream().map(CommentSetEntry::create).collect(toSet());
     }
 
     private Map<String, Comment> changeDrafts(ChangeContext ctx)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
index feb17b3..0808f95 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutDraftComment.java
@@ -92,8 +92,9 @@
       Op op = new Op(rsrc.getComment().key, in);
       bu.addOp(rsrc.getChange().getId(), op);
       bu.execute();
-      return Response.ok(
-          commentJson.get().setFillAccounts(false).format(op.comment));
+      return Response.ok(commentJson.get()
+          .setFillAccounts(false)
+          .newCommentFormatter().format(op.comment));
     }
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotCommentResource.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotCommentResource.java
new file mode 100644
index 0000000..856c777
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotCommentResource.java
@@ -0,0 +1,51 @@
+// Copyright (C) 2016 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.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.restapi.RestResource;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.inject.TypeLiteral;
+
+public class RobotCommentResource implements RestResource {
+  public static final TypeLiteral<RestView<RobotCommentResource>> ROBOT_COMMENT_KIND =
+      new TypeLiteral<RestView<RobotCommentResource>>() {};
+
+  private final RevisionResource rev;
+  private final RobotComment comment;
+
+  public RobotCommentResource(RevisionResource rev, RobotComment c) {
+    this.rev = rev;
+    this.comment = c;
+  }
+
+  public PatchSet getPatchSet() {
+    return rev.getPatchSet();
+  }
+
+  RobotComment getComment() {
+    return comment;
+  }
+
+  String getId() {
+    return comment.key.uuid;
+  }
+
+  Account.Id getAuthorId() {
+    return comment.author.getId();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotComments.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotComments.java
new file mode 100644
index 0000000..886af1d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/RobotComments.java
@@ -0,0 +1,69 @@
+// Copyright (C) 2016 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.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.extensions.restapi.ChildCollection;
+import com.google.gerrit.extensions.restapi.IdString;
+import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
+import com.google.gerrit.extensions.restapi.RestView;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class RobotComments
+    implements ChildCollection<RevisionResource, RobotCommentResource> {
+  private final DynamicMap<RestView<RobotCommentResource>> views;
+  private final ListRobotComments list;
+  private final CommentsUtil commentsUtil;
+
+  @Inject
+  RobotComments(DynamicMap<RestView<RobotCommentResource>> views,
+      ListRobotComments list,
+      CommentsUtil commentsUtil) {
+    this.views = views;
+    this.list = list;
+    this.commentsUtil = commentsUtil;
+  }
+
+  @Override
+  public DynamicMap<RestView<RobotCommentResource>> views() {
+    return views;
+  }
+
+  @Override
+  public ListRobotComments list() {
+    return list;
+  }
+
+  @Override
+  public RobotCommentResource parse(RevisionResource rev, IdString id)
+      throws ResourceNotFoundException, OrmException {
+    String uuid = id.get();
+    ChangeNotes notes = rev.getNotes();
+
+    for (RobotComment c : commentsUtil.robotCommentsByPatchSet(
+        notes, rev.getPatchSet().getId())) {
+      if (uuid.equals(c.key.uuid)) {
+        return new RobotCommentResource(rev, c);
+      }
+    }
+    throw new ResourceNotFoundException(id);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
index 6a25862..9e0be86 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/index/IndexModule.java
@@ -55,7 +55,7 @@
  */
 public class IndexModule extends LifecycleModule {
   public enum IndexType {
-    LUCENE
+    LUCENE, ELASTICSEARCH
   }
 
   public static final ImmutableCollection<SchemaDefinitions<?>> ALL_SCHEMA_DEFS =
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java
index 93f820d..bce114f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java
@@ -24,6 +24,7 @@
 import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.patch.PatchFile;
 import com.google.gerrit.server.patch.PatchList;
@@ -165,6 +166,14 @@
 
   private void appendComment(StringBuilder out, int contextLines,
       PatchFile currentFileData, Comment comment) {
+    if (comment instanceof RobotComment) {
+      RobotComment robotComment = (RobotComment) comment;
+      out.append("Robot Comment from ")
+         .append(robotComment.robotId)
+         .append(" (run ID ")
+         .append(robotComment.robotRunId)
+         .append("):\n");
+    }
     short side = comment.side;
     Comment.Range range = comment.range;
     if (range != null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
index 6d546cd..b4ab290 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeDraftUpdate.java
@@ -138,7 +138,7 @@
   private CommitBuilder storeCommentsInNotes(RevWalk rw, ObjectInserter ins,
       ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, OrmException, IOException {
-    RevisionNoteMap rnm = getRevisionNoteMap(rw, curr);
+    RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
     Set<RevId> updatedRevs =
         Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
@@ -158,7 +158,7 @@
     for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
       updatedRevs.add(e.getKey());
       ObjectId id = ObjectId.fromString(e.getKey().get());
-      byte[] data = e.getValue().build(noteUtil);
+      byte[] data = e.getValue().build(noteUtil, noteUtil.getWriteJson());
       if (!Arrays.equals(data, e.getValue().baseRaw)) {
         touchedAnyRevs = true;
       }
@@ -189,8 +189,8 @@
     return cb;
   }
 
-  private RevisionNoteMap getRevisionNoteMap(RevWalk rw, ObjectId curr)
-      throws ConfigInvalidException, OrmException, IOException {
+  private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw,
+      ObjectId curr) throws ConfigInvalidException, OrmException, IOException {
     if (migration.readChanges()) {
       // If reading from changes is enabled, then the old DraftCommentNotes
       // already parsed the revision notes. We can reuse them as long as the ref
@@ -202,7 +202,8 @@
         if (draftNotes != null) {
           ObjectId idFromNotes =
               firstNonNull(draftNotes.getRevision(), ObjectId.zeroId());
-          RevisionNoteMap rnm = draftNotes.getRevisionNoteMap();
+          RevisionNoteMap<ChangeRevisionNote> rnm =
+              draftNotes.getRevisionNoteMap();
           if (idFromNotes.equals(curr) && rnm != null) {
             return rnm;
           }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 313d8b7..360785f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -45,6 +45,7 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ReviewerSet;
@@ -339,10 +340,11 @@
 
   // Parsed note map state, used by ChangeUpdate to make in-place editing of
   // notes easier.
-  RevisionNoteMap revisionNoteMap;
+  RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
   private NoteDbUpdateManager.Result rebuildResult;
   private DraftCommentNotes draftCommentNotes;
+  private RobotCommentNotes robotCommentNotes;
 
   @VisibleForTesting
   public ChangeNotes(Args args, Change change) {
@@ -448,6 +450,12 @@
         filtered);
   }
 
+  public ImmutableListMultimap<RevId, RobotComment> getRobotComments()
+      throws OrmException {
+    loadRobotComments();
+    return robotCommentNotes.getComments();
+  }
+
   /**
    * If draft comments have already been loaded for this author, then they will
    * not be reloaded. However, this method will load the comments if no draft
@@ -464,11 +472,22 @@
     }
   }
 
+  private void loadRobotComments() throws OrmException {
+    if (robotCommentNotes == null) {
+      robotCommentNotes = new RobotCommentNotes(args, change);
+      robotCommentNotes.load();
+    }
+  }
+
   @VisibleForTesting
   DraftCommentNotes getDraftCommentNotes() {
     return draftCommentNotes;
   }
 
+  RobotCommentNotes getRobotCommentNotes() {
+    return robotCommentNotes;
+  }
+
   public boolean containsComment(Comment c) throws OrmException {
     if (containsCommentPublished(c)) {
       return true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index a8f85a4..85df4b7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -73,14 +73,14 @@
      * used as an optimization; {@link ChangeNotes} is capable of lazily loading
      * it as necessary.
      */
-    @Nullable abstract RevisionNoteMap revisionNoteMap();
+    @Nullable abstract RevisionNoteMap<ChangeRevisionNote> revisionNoteMap();
   }
 
   private class Loader implements Callable<ChangeNotesState> {
     private final Key key;
     private final ChangeNotesRevWalk rw;
 
-    private RevisionNoteMap revisionNoteMap;
+    private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
     private Loader(Key key, ChangeNotesRevWalk rw) {
       this.key = key;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
index a0e7f305..de37b72 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesParser.java
@@ -149,7 +149,7 @@
   private String submissionId;
   private String tag;
   private PatchSet.Id currentPatchSetId;
-  private RevisionNoteMap revisionNoteMap;
+  private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
   ChangeNotesParser(Change.Id changeId, ObjectId tip, ChangeNotesRevWalk walk,
       ChangeNoteUtil noteUtil, NoteDbMetrics metrics) {
@@ -195,7 +195,7 @@
     return buildState();
   }
 
-  RevisionNoteMap getRevisionNoteMap() {
+  RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap() {
     return revisionNoteMap;
   }
 
@@ -630,18 +630,18 @@
     revisionNoteMap = RevisionNoteMap.parse(
         noteUtil, id, reader, NoteMap.read(reader, tipCommit),
         PatchLineComment.Status.PUBLISHED);
-    Map<RevId, RevisionNote> rns = revisionNoteMap.revisionNotes;
+    Map<RevId, ChangeRevisionNote> rns = revisionNoteMap.revisionNotes;
 
-    for (Map.Entry<RevId, RevisionNote> e : rns.entrySet()) {
-      for (Comment c : e.getValue().comments) {
+    for (Map.Entry<RevId, ChangeRevisionNote> e : rns.entrySet()) {
+      for (Comment c : e.getValue().getComments()) {
         comments.put(e.getKey(), c);
       }
     }
 
     for (PatchSet ps : patchSets.values()) {
-      RevisionNote rn = rns.get(ps.getRevision());
-      if (rn != null && rn.pushCert != null) {
-        ps.setPushCertificate(rn.pushCert);
+      ChangeRevisionNote rn = rns.get(ps.getRevision());
+      if (rn != null && rn.getPushCert() != null) {
+        ps.setPushCertificate(rn.getPushCert());
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
new file mode 100644
index 0000000..b95c92a
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeRevisionNote.java
@@ -0,0 +1,114 @@
+// Copyright (C) 2016 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.
+
+package com.google.gerrit.server.notedb;
+
+import static java.nio.charset.StandardCharsets.UTF_8;
+
+import com.google.common.primitives.Bytes;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.PatchLineComment;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.util.MutableInteger;
+import org.eclipse.jgit.util.RawParseUtils;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.List;
+
+class ChangeRevisionNote extends RevisionNote<Comment> {
+  private static final byte[] CERT_HEADER =
+      "certificate version ".getBytes(UTF_8);
+  // See org.eclipse.jgit.transport.PushCertificateParser.END_SIGNATURE
+  private static final byte[] END_SIGNATURE =
+      "-----END PGP SIGNATURE-----\n".getBytes(UTF_8);
+
+  private final ChangeNoteUtil noteUtil;
+  private final Change.Id changeId;
+  private final PatchLineComment.Status status;
+  private String pushCert = null;
+
+  ChangeRevisionNote(ChangeNoteUtil noteUtil, Change.Id changeId,
+      ObjectReader reader, ObjectId noteId, PatchLineComment.Status status) {
+    super(reader, noteId);
+    this.noteUtil = noteUtil;
+    this.changeId = changeId;
+    this.status = status;
+  }
+
+  public String getPushCert() {
+    checkParsed();
+    return pushCert;
+  }
+
+  @Override
+  protected List<Comment> parse(byte[] raw, int offset)
+      throws IOException, ConfigInvalidException {
+    MutableInteger p = new MutableInteger();
+    p.value = offset;
+
+    if (isJson(raw, p.value)) {
+      RevisionNoteData data = parseJson(noteUtil, raw, p.value);
+      if (status == PatchLineComment.Status.PUBLISHED) {
+        pushCert = data.pushCert;
+      } else {
+        pushCert = null;
+      }
+      return data.comments;
+    }
+
+    if (status == PatchLineComment.Status.PUBLISHED) {
+      pushCert = parsePushCert(changeId, raw, p);
+      trimLeadingEmptyLines(raw, p);
+    } else {
+      pushCert = null;
+    }
+    return noteUtil.parseNote(raw, p, changeId);
+  }
+
+  private static boolean isJson(byte[] raw, int offset) {
+    return raw[offset] == '{' || raw[offset] == '[';
+  }
+
+  private RevisionNoteData parseJson(ChangeNoteUtil noteUtil, byte[] raw,
+      int offset) throws IOException {
+    try (InputStream is = new ByteArrayInputStream(
+        raw, offset, raw.length - offset);
+        Reader r = new InputStreamReader(is)) {
+      return noteUtil.getGson().fromJson(r, RevisionNoteData.class);
+    }
+  }
+
+  private static String parsePushCert(Change.Id changeId, byte[] bytes,
+      MutableInteger p) throws ConfigInvalidException {
+    if (RawParseUtils.match(bytes, p.value, CERT_HEADER) < 0) {
+      return null;
+    }
+    int end = Bytes.indexOf(bytes, END_SIGNATURE);
+    if (end < 0) {
+      throw ChangeNotes.parseException(
+          changeId, "invalid push certificate in note");
+    }
+    int start = p.value;
+    p.value = end + END_SIGNATURE.length;
+    return new String(bytes, start, p.value);
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
index 263edab..96cd4c6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeUpdate.java
@@ -51,6 +51,7 @@
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.client.RobotComment;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.config.AnonymousCowardName;
@@ -110,6 +111,7 @@
 
   private final AccountCache accountCache;
   private final ChangeDraftUpdate.Factory draftUpdateFactory;
+  private final RobotCommentUpdate.Factory robotCommentUpdateFactory;
   private final NoteDbUpdateManager.Factory updateManagerFactory;
 
   private final Table<String, Account.Id, Optional<Short>> approvals;
@@ -135,6 +137,7 @@
   private boolean isAllowWriteToNewtRef;
 
   private ChangeDraftUpdate draftUpdate;
+  private RobotCommentUpdate robotCommentUpdate;
 
   @AssistedInject
   private ChangeUpdate(
@@ -144,11 +147,12 @@
       AccountCache accountCache,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
       ProjectCache projectCache,
       @Assisted ChangeControl ctl,
       ChangeNoteUtil noteUtil) {
     this(serverIdent, anonymousCowardName, migration, accountCache,
-        updateManagerFactory, draftUpdateFactory,
+        updateManagerFactory, draftUpdateFactory, robotCommentUpdateFactory,
         projectCache, ctl, serverIdent.getWhen(), noteUtil);
   }
 
@@ -160,13 +164,14 @@
       AccountCache accountCache,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
       ProjectCache projectCache,
       @Assisted ChangeControl ctl,
       @Assisted Date when,
       ChangeNoteUtil noteUtil) {
     this(serverIdent, anonymousCowardName, migration, accountCache,
-        updateManagerFactory, draftUpdateFactory, ctl,
-        when,
+        updateManagerFactory, draftUpdateFactory, robotCommentUpdateFactory,
+        ctl, when,
         projectCache.get(getProjectName(ctl)).getLabelTypes().nameComparator(),
         noteUtil);
   }
@@ -188,6 +193,7 @@
       AccountCache accountCache,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
       @Assisted ChangeControl ctl,
       @Assisted Date when,
       @Assisted Comparator<String> labelNameComparator,
@@ -196,6 +202,7 @@
         anonymousCowardName, noteUtil, when);
     this.accountCache = accountCache;
     this.draftUpdateFactory = draftUpdateFactory;
+    this.robotCommentUpdateFactory = robotCommentUpdateFactory;
     this.updateManagerFactory = updateManagerFactory;
     this.approvals = approvals(labelNameComparator);
   }
@@ -208,6 +215,7 @@
       AccountCache accountCache,
       NoteDbUpdateManager.Factory updateManagerFactory,
       ChangeDraftUpdate.Factory draftUpdateFactory,
+      RobotCommentUpdate.Factory robotCommentUpdateFactory,
       ChangeNoteUtil noteUtil,
       @Assisted Change change,
       @Assisted @Nullable Account.Id accountId,
@@ -218,6 +226,7 @@
         accountId, authorIdent, when);
     this.accountCache = accountCache;
     this.draftUpdateFactory = draftUpdateFactory;
+    this.robotCommentUpdateFactory = robotCommentUpdateFactory;
     this.updateManagerFactory = updateManagerFactory;
     this.approvals = approvals(labelNameComparator);
   }
@@ -320,6 +329,12 @@
     }
   }
 
+  public void putRobotComment(RobotComment c) {
+    verifyComment(c);
+    createRobotCommentUpdateIfNull();
+    robotCommentUpdate.putComment(c);
+  }
+
   public void deleteComment(Comment c) {
     verifyComment(c);
     createDraftUpdateIfNull().deleteComment(c);
@@ -340,6 +355,21 @@
     return draftUpdate;
   }
 
+  @VisibleForTesting
+  RobotCommentUpdate createRobotCommentUpdateIfNull() {
+    if (robotCommentUpdate == null) {
+      ChangeNotes notes = getNotes();
+      if (notes != null) {
+        robotCommentUpdate =
+            robotCommentUpdateFactory.create(notes, accountId, authorIdent, when);
+      } else {
+        robotCommentUpdate = robotCommentUpdateFactory.create(
+            getChange(), accountId, authorIdent, when);
+      }
+    }
+    return robotCommentUpdate;
+  }
+
   private void verifyComment(Comment c) {
     checkArgument(c.revId != null, "RevId required for comment: %s", c);
     checkArgument(c.author.getId().equals(getAccountId()),
@@ -415,7 +445,7 @@
     if (comments.isEmpty() && pushCert == null) {
       return null;
     }
-    RevisionNoteMap rnm = getRevisionNoteMap(rw, curr);
+    RevisionNoteMap<ChangeRevisionNote> rnm = getRevisionNoteMap(rw, curr);
 
     RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
     for (Comment c : comments) {
@@ -431,15 +461,15 @@
 
     for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
       ObjectId data = inserter.insert(
-          OBJ_BLOB, e.getValue().build(noteUtil));
+          OBJ_BLOB, e.getValue().build(noteUtil, noteUtil.getWriteJson()));
       rnm.noteMap.set(ObjectId.fromString(e.getKey().get()), data);
     }
 
     return rnm.noteMap.writeTree(inserter);
   }
 
-  private RevisionNoteMap getRevisionNoteMap(RevWalk rw, ObjectId curr)
-      throws ConfigInvalidException, OrmException, IOException {
+  private RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap(RevWalk rw,
+      ObjectId curr) throws ConfigInvalidException, OrmException, IOException {
     if (curr.equals(ObjectId.zeroId())) {
       return RevisionNoteMap.emptyMap();
     }
@@ -467,12 +497,12 @@
         PatchLineComment.Status.PUBLISHED);
   }
 
-  private void checkComments(Map<RevId, RevisionNote> existingNotes,
+  private void checkComments(Map<RevId, ChangeRevisionNote> existingNotes,
       Map<RevId, RevisionNoteBuilder> toUpdate) throws OrmException {
     // Prohibit various kinds of illegal operations on comments.
     Set<Comment.Key> existing = new HashSet<>();
-    for (RevisionNote rn : existingNotes.values()) {
-      for (Comment c : rn.comments) {
+    for (ChangeRevisionNote rn : existingNotes.values()) {
+      for (Comment c : rn.getComments()) {
         existing.add(c.key);
         if (draftUpdate != null) {
           // Take advantage of an existing update on All-Users to prune any
@@ -677,6 +707,10 @@
     return draftUpdate;
   }
 
+  RobotCommentUpdate getRobotCommentUpdate() {
+    return robotCommentUpdate;
+  }
+
   public void setAllowWriteToNewRef(boolean allow) {
     isAllowWriteToNewtRef = allow;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
index 34da36d..661112e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/DraftCommentNotes.java
@@ -70,7 +70,7 @@
   private final NoteDbUpdateManager.Result rebuildResult;
 
   private ImmutableListMultimap<RevId, Comment> comments;
-  private RevisionNoteMap revisionNoteMap;
+  private RevisionNoteMap<ChangeRevisionNote> revisionNoteMap;
 
   @AssistedInject
   DraftCommentNotes(
@@ -103,7 +103,7 @@
     this.rebuildResult = rebuildResult;
   }
 
-  RevisionNoteMap getRevisionNoteMap() {
+  RevisionNoteMap<ChangeRevisionNote> getRevisionNoteMap() {
     return revisionNoteMap;
   }
 
@@ -144,8 +144,8 @@
         args.noteUtil, getChangeId(), reader, NoteMap.read(reader, tipCommit),
         PatchLineComment.Status.DRAFT);
     Multimap<RevId, Comment> cs = ArrayListMultimap.create();
-    for (RevisionNote rn : revisionNoteMap.revisionNotes.values()) {
-      for (Comment c : rn.comments) {
+    for (ChangeRevisionNote rn : revisionNoteMap.revisionNotes.values()) {
+      for (Comment c : rn.getComments()) {
         cs.put(new RevId(c.revId), c);
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
index 876f47f..72a1f1b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbModule.java
@@ -53,6 +53,8 @@
     factory(ChangeUpdate.Factory.class);
     factory(ChangeDraftUpdate.Factory.class);
     factory(DraftCommentNotes.Factory.class);
+    factory(RobotCommentUpdate.Factory.class);
+    factory(RobotCommentNotes.Factory.class);
     factory(NoteDbUpdateManager.Factory.class);
     if (!useTestBindings) {
       install(ChangeNotesCache.module());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
index 7f6eb5c..300f753 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/NoteDbUpdateManager.java
@@ -179,6 +179,7 @@
   private final Project.NameKey projectName;
   private final ListMultimap<String, ChangeUpdate> changeUpdates;
   private final ListMultimap<String, ChangeDraftUpdate> draftUpdates;
+  private final ListMultimap<String, RobotCommentUpdate> robotCommentUpdates;
   private final Set<Change.Id> toDelete;
 
   private OpenRepo changeRepo;
@@ -199,6 +200,7 @@
     this.projectName = projectName;
     changeUpdates = ArrayListMultimap.create();
     draftUpdates = ArrayListMultimap.create();
+    robotCommentUpdates = ArrayListMultimap.create();
     toDelete = new HashSet<>();
   }
 
@@ -273,6 +275,7 @@
     }
     return changeUpdates.isEmpty()
         && draftUpdates.isEmpty()
+        && robotCommentUpdates.isEmpty()
         && toDelete.isEmpty();
   }
 
@@ -294,6 +297,10 @@
     if (du != null) {
       draftUpdates.put(du.getRefName(), du);
     }
+    RobotCommentUpdate rcu = update.getRobotCommentUpdate();
+    if (rcu != null) {
+      robotCommentUpdates.put(rcu.getRefName(), rcu);
+    }
   }
 
   public void add(ChangeDraftUpdate draftUpdate) {
@@ -453,6 +460,9 @@
     if (!draftUpdates.isEmpty()) {
       addUpdates(draftUpdates, allUsersRepo);
     }
+    if (!robotCommentUpdates.isEmpty()) {
+      addUpdates(robotCommentUpdates, changeRepo);
+    }
     for (Change.Id id : toDelete) {
       doDelete(id);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
index 7d44ffb..46e6dc5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNote.java
@@ -14,106 +14,66 @@
 
 package com.google.gerrit.server.notedb;
 
-import static java.nio.charset.StandardCharsets.UTF_8;
+import static com.google.common.base.Preconditions.checkState;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.common.collect.ImmutableList;
-import com.google.common.primitives.Bytes;
-import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.Comment;
-import com.google.gerrit.reviewdb.client.PatchLineComment;
 
 import org.eclipse.jgit.errors.ConfigInvalidException;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectReader;
 import org.eclipse.jgit.util.MutableInteger;
-import org.eclipse.jgit.util.RawParseUtils;
 
-import java.io.ByteArrayInputStream;
 import java.io.IOException;
-import java.io.InputStream;
-import java.io.InputStreamReader;
-import java.io.Reader;
+import java.util.List;
 
-class RevisionNote {
+abstract class RevisionNote<T extends Comment> {
   static final int MAX_NOTE_SZ = 25 << 20;
 
-  private static final byte[] CERT_HEADER =
-      "certificate version ".getBytes(UTF_8);
-  // See org.eclipse.jgit.transport.PushCertificateParser.END_SIGNATURE
-  private static final byte[] END_SIGNATURE =
-      "-----END PGP SIGNATURE-----\n".getBytes(UTF_8);
-
-  private static void trimLeadingEmptyLines(byte[] bytes, MutableInteger p) {
+  protected static void trimLeadingEmptyLines(byte[] bytes, MutableInteger p) {
     while (p.value < bytes.length && bytes[p.value] == '\n') {
       p.value++;
     }
   }
 
-  private static String parsePushCert(Change.Id changeId, byte[] bytes,
-      MutableInteger p) throws ConfigInvalidException {
-    if (RawParseUtils.match(bytes, p.value, CERT_HEADER) < 0) {
-      return null;
-    }
-    int end = Bytes.indexOf(bytes, END_SIGNATURE);
-    if (end < 0) {
-      throw ChangeNotes.parseException(
-          changeId, "invalid push certificate in note");
-    }
-    int start = p.value;
-    p.value = end + END_SIGNATURE.length;
-    return new String(bytes, start, p.value);
+  private final ObjectReader reader;
+  private final ObjectId noteId;
+
+  private byte[] raw;
+  private ImmutableList<T> comments;
+
+  RevisionNote(ObjectReader reader, ObjectId noteId) {
+    this.reader = reader;
+    this.noteId = noteId;
   }
 
-  final byte[] raw;
-  final ImmutableList<Comment> comments;
-  final String pushCert;
+  public byte[] getRaw() {
+    checkParsed();
+    return raw;
+  }
 
-  RevisionNote(ChangeNoteUtil noteUtil, Change.Id changeId,
-      ObjectReader reader, ObjectId noteId, PatchLineComment.Status status)
-      throws ConfigInvalidException, IOException {
+  public ImmutableList<T> getComments() {
+    checkParsed();
+    return comments;
+  }
 
+  public void parse() throws IOException, ConfigInvalidException {
     raw = reader.open(noteId, OBJ_BLOB).getCachedBytes(MAX_NOTE_SZ);
     MutableInteger p = new MutableInteger();
     trimLeadingEmptyLines(raw, p);
     if (p.value >= raw.length) {
       comments = null;
-      pushCert = null;
       return;
     }
 
-    if (isJson(raw, p.value)) {
-      RevisionNoteData data = parseJson(noteUtil, p.value);
-      comments = ImmutableList.copyOf(data.comments);
-      if (status == PatchLineComment.Status.PUBLISHED) {
-        pushCert = data.pushCert;
-      } else {
-        pushCert = null;
-      }
-      return;
-    }
-
-    if (status == PatchLineComment.Status.PUBLISHED) {
-      pushCert = parsePushCert(changeId, raw, p);
-      trimLeadingEmptyLines(raw, p);
-    } else {
-      pushCert = null;
-    }
-    comments = ImmutableList.copyOf(noteUtil.parseNote(raw, p, changeId));
+    comments = ImmutableList.copyOf(parse(raw, p.value));
   }
 
-  private static boolean isJson(byte[] raw, int offset) {
-    return raw[offset] == '{' || raw[offset] == '[';
-  }
+  protected abstract List<T> parse(byte[] raw, int offset)
+      throws IOException, ConfigInvalidException;
 
-  private RevisionNoteData parseJson(ChangeNoteUtil noteUtil, int offset)
-      throws IOException{
-    RevisionNoteData data;
-    try (InputStream is = new ByteArrayInputStream(
-        raw, offset, raw.length - offset);
-        Reader r = new InputStreamReader(is)) {
-      data = noteUtil.getGson().fromJson(r, RevisionNoteData.class);
-    }
-    return data;
+  protected void checkParsed() {
+    checkState(raw != null, "revision note not parsed yet");
   }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
index 266a891..8eacb1c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteBuilder.java
@@ -37,10 +37,12 @@
 
 class RevisionNoteBuilder {
   static class Cache {
-    private final RevisionNoteMap revisionNoteMap;
+    private final RevisionNoteMap<?
+        extends RevisionNote<? extends Comment>> revisionNoteMap;
     private final Map<RevId, RevisionNoteBuilder> builders;
 
-    Cache(RevisionNoteMap revisionNoteMap) {
+    Cache(RevisionNoteMap<?
+        extends RevisionNote<? extends Comment>> revisionNoteMap) {
       this.revisionNoteMap = revisionNoteMap;
       this.builders = new HashMap<>();
     }
@@ -61,18 +63,20 @@
   }
 
   final byte[] baseRaw;
-  final List<Comment> baseComments;
+  final List<? extends Comment> baseComments;
   final Map<Comment.Key, Comment> put;
   final Set<Comment.Key> delete;
 
   private String pushCert;
 
-  RevisionNoteBuilder(RevisionNote base) {
+  RevisionNoteBuilder(RevisionNote<? extends Comment> base) {
     if (base != null) {
-      baseRaw = base.raw;
-      baseComments = base.comments;
-      put = Maps.newHashMapWithExpectedSize(base.comments.size());
-      pushCert = base.pushCert;
+      baseRaw = base.getRaw();
+      baseComments = base.getComments();
+      put = Maps.newHashMapWithExpectedSize(baseComments.size());
+      if (base instanceof ChangeRevisionNote) {
+        pushCert = ((ChangeRevisionNote) base).getPushCert();
+      }
     } else {
       baseRaw = new byte[0];
       baseComments = Collections.emptyList();
@@ -82,9 +86,10 @@
     delete = new HashSet<>();
   }
 
-  public byte[] build(ChangeNoteUtil noteUtil) throws IOException {
+  public byte[] build(ChangeNoteUtil noteUtil, boolean writeJson)
+      throws IOException {
     ByteArrayOutputStream out = new ByteArrayOutputStream();
-    if (noteUtil.getWriteJson()) {
+    if (writeJson) {
       buildNoteJson(noteUtil, out);
     } else {
       buildNoteLegacy(noteUtil, out);
@@ -123,7 +128,7 @@
     return all;
   }
 
-  private void buildNoteJson(final ChangeNoteUtil noteUtil, OutputStream out)
+  private void buildNoteJson(ChangeNoteUtil noteUtil, OutputStream out)
       throws IOException {
     Multimap<Integer, Comment> comments = buildCommentMap();
     if (comments.isEmpty() && pushCert == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
index 1783c1a..8a9f711 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RevisionNoteMap.java
@@ -16,6 +16,7 @@
 
 import com.google.common.collect.ImmutableMap;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.RevId;
 
@@ -28,30 +29,45 @@
 import java.util.HashMap;
 import java.util.Map;
 
-class RevisionNoteMap {
+class RevisionNoteMap<T extends RevisionNote<? extends Comment>> {
   final NoteMap noteMap;
-  final ImmutableMap<RevId, RevisionNote> revisionNotes;
+  final ImmutableMap<RevId, T> revisionNotes;
 
-  static RevisionNoteMap parse(ChangeNoteUtil noteUtil,
+  static RevisionNoteMap<ChangeRevisionNote> parse(ChangeNoteUtil noteUtil,
       Change.Id changeId, ObjectReader reader, NoteMap noteMap,
       PatchLineComment.Status status)
-      throws ConfigInvalidException, IOException {
-    Map<RevId, RevisionNote> result = new HashMap<>();
+          throws ConfigInvalidException, IOException {
+    Map<RevId, ChangeRevisionNote> result = new HashMap<>();
     for (Note note : noteMap) {
-      RevisionNote rn = new RevisionNote(
+      ChangeRevisionNote rn = new ChangeRevisionNote(
           noteUtil, changeId, reader, note.getData(), status);
+      rn.parse();
       result.put(new RevId(note.name()), rn);
     }
-    return new RevisionNoteMap(noteMap, ImmutableMap.copyOf(result));
+    return new RevisionNoteMap<>(noteMap, ImmutableMap.copyOf(result));
   }
 
-  static RevisionNoteMap emptyMap() {
-    return new RevisionNoteMap(NoteMap.newEmptyMap(),
-        ImmutableMap.<RevId, RevisionNote> of());
+  static RevisionNoteMap<RobotCommentsRevisionNote> parseRobotComments(
+      ChangeNoteUtil noteUtil, ObjectReader reader, NoteMap noteMap)
+          throws ConfigInvalidException, IOException {
+    Map<RevId, RobotCommentsRevisionNote> result = new HashMap<>();
+    for (Note note : noteMap) {
+      RobotCommentsRevisionNote rn = new RobotCommentsRevisionNote(
+          noteUtil, reader, note.getData());
+      rn.parse();
+      result.put(new RevId(note.name()), rn);
+    }
+    return new RevisionNoteMap<>(noteMap, ImmutableMap.copyOf(result));
+  }
+
+  static <T extends RevisionNote<? extends Comment>> RevisionNoteMap<T>
+      emptyMap() {
+    return new RevisionNoteMap<>(NoteMap.newEmptyMap(),
+        ImmutableMap.<RevId, T> of());
   }
 
   private RevisionNoteMap(NoteMap noteMap,
-      ImmutableMap<RevId, RevisionNote> revisionNotes) {
+      ImmutableMap<RevId, T> revisionNotes) {
     this.noteMap = noteMap;
     this.revisionNotes = revisionNotes;
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
new file mode 100644
index 0000000..4a26d7e
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentNotes.java
@@ -0,0 +1,108 @@
+// Copyright (C) 2016 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.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableListMultimap;
+import com.google.common.collect.Multimap;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RefNames;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevCommit;
+
+import java.io.IOException;
+
+public class RobotCommentNotes extends AbstractChangeNotes<RobotCommentNotes> {
+  public interface Factory {
+    RobotCommentNotes create(Change change);
+  }
+
+  private final Change change;
+
+  private ImmutableListMultimap<RevId, RobotComment> comments;
+  private RevisionNoteMap<RobotCommentsRevisionNote> revisionNoteMap;
+
+  @AssistedInject
+  RobotCommentNotes(
+      Args args,
+      @Assisted Change change) {
+    super(args, change.getId(), false);
+    this.change = change;
+  }
+
+  RevisionNoteMap<RobotCommentsRevisionNote> getRevisionNoteMap() {
+    return revisionNoteMap;
+  }
+
+  public ImmutableListMultimap<RevId, RobotComment> getComments() {
+    return comments;
+  }
+
+  public boolean containsComment(RobotComment c) {
+    for (RobotComment existing : comments.values()) {
+      if (c.key.equals(existing.key)) {
+        return true;
+      }
+    }
+    return false;
+  }
+
+  @Override
+  protected String getRefName() {
+    return RefNames.robotCommentsRef(getChangeId());
+  }
+
+  @Override
+  protected void onLoad(LoadHandle handle)
+      throws IOException, ConfigInvalidException {
+    ObjectId rev = handle.id();
+    if (rev == null) {
+      loadDefaults();
+      return;
+    }
+
+    RevCommit tipCommit = handle.walk().parseCommit(rev);
+    ObjectReader reader = handle.walk().getObjectReader();
+    revisionNoteMap = RevisionNoteMap.parseRobotComments(args.noteUtil, reader,
+        NoteMap.read(reader, tipCommit));
+    Multimap<RevId, RobotComment> cs = ArrayListMultimap.create();
+    for (RobotCommentsRevisionNote rn :
+        revisionNoteMap.revisionNotes.values()) {
+      for (RobotComment c : rn.getComments()) {
+        cs.put(new RevId(c.revId), c);
+      }
+    }
+    comments = ImmutableListMultimap.copyOf(cs);
+  }
+
+  @Override
+  protected void loadDefaults() {
+    comments = ImmutableListMultimap.of();
+  }
+
+  @Override
+  public Project.NameKey getProjectName() {
+    return change.getProject();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
new file mode 100644
index 0000000..fd47d02
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentUpdate.java
@@ -0,0 +1,222 @@
+// Copyright (C) 2016 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.
+
+package com.google.gerrit.server.notedb;
+
+import static com.google.common.base.MoreObjects.firstNonNull;
+import static com.google.common.base.Preconditions.checkArgument;
+import static com.google.gerrit.reviewdb.client.RefNames.robotCommentsRef;
+import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
+
+import com.google.common.collect.Sets;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+import com.google.gerrit.reviewdb.client.RevId;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.config.AnonymousCowardName;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.assistedinject.Assisted;
+import com.google.inject.assistedinject.AssistedInject;
+
+import org.eclipse.jgit.errors.ConfigInvalidException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectInserter;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.notes.NoteMap;
+import org.eclipse.jgit.revwalk.RevWalk;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * A single delta to apply atomically to a change.
+ * <p>
+ * This delta contains only robot comments on a single patch set of a change by
+ * a single author. This delta will become a single commit in the repository.
+ * <p>
+ * This class is not thread safe.
+ */
+public class RobotCommentUpdate extends AbstractChangeUpdate{
+  public interface Factory {
+    RobotCommentUpdate create(ChangeNotes notes, Account.Id accountId,
+        PersonIdent authorIdent, Date when);
+    RobotCommentUpdate create(Change change, Account.Id accountId,
+        PersonIdent authorIdent, Date when);
+  }
+
+  private List<RobotComment> put = new ArrayList<>();
+
+  @AssistedInject
+  private RobotCommentUpdate(
+      @GerritPersonIdent PersonIdent serverIdent,
+      @AnonymousCowardName String anonymousCowardName,
+      NotesMigration migration,
+      ChangeNoteUtil noteUtil,
+      @Assisted ChangeNotes notes,
+      @Assisted Account.Id accountId,
+      @Assisted PersonIdent authorIdent,
+      @Assisted Date when) {
+    super(migration, noteUtil, serverIdent, anonymousCowardName, notes, null,
+        accountId, authorIdent, when);
+  }
+
+  @AssistedInject
+  private RobotCommentUpdate(
+      @GerritPersonIdent PersonIdent serverIdent,
+      @AnonymousCowardName String anonymousCowardName,
+      NotesMigration migration,
+      ChangeNoteUtil noteUtil,
+      @Assisted Change change,
+      @Assisted Account.Id accountId,
+      @Assisted PersonIdent authorIdent,
+      @Assisted Date when) {
+    super(migration, noteUtil, serverIdent, anonymousCowardName, null, change,
+        accountId, authorIdent, when);
+  }
+
+  public void putComment(RobotComment c) {
+    verifyComment(c);
+    put.add(c);
+  }
+
+  private void verifyComment(RobotComment comment) {
+    checkArgument(comment.author.getId().equals(accountId),
+        "The author for the following comment does not match the author of"
+        + " this RobotCommentUpdate (%s): %s", accountId, comment);
+  }
+
+  private CommitBuilder storeCommentsInNotes(RevWalk rw, ObjectInserter ins,
+      ObjectId curr, CommitBuilder cb)
+      throws ConfigInvalidException, OrmException, IOException {
+    RevisionNoteMap<RobotCommentsRevisionNote> rnm =
+        getRevisionNoteMap(rw, curr);
+    Set<RevId> updatedRevs =
+        Sets.newHashSetWithExpectedSize(rnm.revisionNotes.size());
+    RevisionNoteBuilder.Cache cache = new RevisionNoteBuilder.Cache(rnm);
+
+    for (RobotComment c : put) {
+      cache.get(new RevId(c.revId)).putComment(c);
+    }
+
+    Map<RevId, RevisionNoteBuilder> builders = cache.getBuilders();
+    boolean touchedAnyRevs = false;
+    boolean hasComments = false;
+    for (Map.Entry<RevId, RevisionNoteBuilder> e : builders.entrySet()) {
+      updatedRevs.add(e.getKey());
+      ObjectId id = ObjectId.fromString(e.getKey().get());
+      byte[] data = e.getValue().build(noteUtil, true);
+      if (!Arrays.equals(data, e.getValue().baseRaw)) {
+        touchedAnyRevs = true;
+      }
+      if (data.length == 0) {
+        rnm.noteMap.remove(id);
+      } else {
+        hasComments = true;
+        ObjectId dataBlob = ins.insert(OBJ_BLOB, data);
+        rnm.noteMap.set(id, dataBlob);
+      }
+    }
+
+    // If we didn't touch any notes, tell the caller this was a no-op update. We
+    // couldn't have done this in isEmpty() below because we hadn't read the old
+    // data yet.
+    if (!touchedAnyRevs) {
+      return NO_OP_UPDATE;
+    }
+
+    // If we touched every revision and there are no comments left, tell the
+    // caller to delete the entire ref.
+    boolean touchedAllRevs = updatedRevs.equals(rnm.revisionNotes.keySet());
+    if (touchedAllRevs && !hasComments) {
+      return null;
+    }
+
+    cb.setTreeId(rnm.noteMap.writeTree(ins));
+    return cb;
+  }
+
+  private RevisionNoteMap<RobotCommentsRevisionNote> getRevisionNoteMap(
+      RevWalk rw, ObjectId curr)
+          throws ConfigInvalidException, OrmException, IOException {
+    if (curr.equals(ObjectId.zeroId())) {
+      return RevisionNoteMap.emptyMap();
+    }
+    if (migration.readChanges()) {
+      // If reading from changes is enabled, then the old RobotCommentNotes
+      // already parsed the revision notes. We can reuse them as long as the ref
+      // hasn't advanced.
+      ChangeNotes changeNotes = getNotes();
+      if (changeNotes != null) {
+        RobotCommentNotes robotCommentNotes =
+            changeNotes.load().getRobotCommentNotes();
+        if (robotCommentNotes != null) {
+          ObjectId idFromNotes =
+              firstNonNull(robotCommentNotes.getRevision(), ObjectId.zeroId());
+          RevisionNoteMap<RobotCommentsRevisionNote> rnm =
+              robotCommentNotes.getRevisionNoteMap();
+          if (idFromNotes.equals(curr) && rnm != null) {
+            return rnm;
+          }
+        }
+      }
+    }
+    NoteMap noteMap;
+    if (!curr.equals(ObjectId.zeroId())) {
+      noteMap = NoteMap.read(rw.getObjectReader(), rw.parseCommit(curr));
+    } else {
+      noteMap = NoteMap.newEmptyMap();
+    }
+    // Even though reading from changes might not be enabled, we need to
+    // parse any existing revision notes so we can merge them.
+    return RevisionNoteMap.parseRobotComments(
+        noteUtil,
+        rw.getObjectReader(),
+        noteMap);
+  }
+
+  @Override
+  protected CommitBuilder applyImpl(RevWalk rw, ObjectInserter ins,
+      ObjectId curr) throws OrmException, IOException {
+    CommitBuilder cb = new CommitBuilder();
+    cb.setMessage("Update robot comments");
+    try {
+      return storeCommentsInNotes(rw, ins, curr, cb);
+    } catch (ConfigInvalidException e) {
+      throw new OrmException(e);
+    }
+  }
+
+  @Override
+  protected Project.NameKey getProjectName() {
+    return getNotes().getProjectName();
+  }
+
+  @Override
+  protected String getRefName() {
+    return robotCommentsRef(getId());
+  }
+
+  @Override
+  public boolean isEmpty() {
+    return put.isEmpty();
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
new file mode 100644
index 0000000..e007ff3
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNote.java
@@ -0,0 +1,48 @@
+// Copyright (C) 2016 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.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gerrit.reviewdb.client.RobotComment;
+
+import org.eclipse.jgit.lib.ObjectId;
+import org.eclipse.jgit.lib.ObjectReader;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.Reader;
+import java.util.List;
+
+public class RobotCommentsRevisionNote extends RevisionNote<RobotComment> {
+  private final ChangeNoteUtil noteUtil;
+
+  RobotCommentsRevisionNote(ChangeNoteUtil noteUtil, ObjectReader reader,
+      ObjectId noteId) {
+    super(reader, noteId);
+    this.noteUtil = noteUtil;
+  }
+
+  @Override
+  protected List<RobotComment> parse(byte[] raw, int offset)
+      throws IOException {
+    try (InputStream is = new ByteArrayInputStream(
+        raw, offset, raw.length - offset);
+        Reader r = new InputStreamReader(is)) {
+      return noteUtil.getGson().fromJson(r,
+          RobotCommentsRevisionNoteData.class).comments;
+    }
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java
new file mode 100644
index 0000000..ea3a149
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/RobotCommentsRevisionNoteData.java
@@ -0,0 +1,23 @@
+// Copyright (C) 2016 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.
+
+package com.google.gerrit.server.notedb;
+
+import com.google.gerrit.reviewdb.client.RobotComment;
+
+import java.util.List;
+
+public class RobotCommentsRevisionNoteData {
+  List<RobotComment> comments;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
index 9573f99..3a0c699 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/H2AccountPatchReviewStore.java
@@ -176,7 +176,11 @@
       }
       stmt.executeBatch();
     } catch (SQLException e) {
-      throw convertError("insert", e);
+      OrmException ormException = convertError("insert", e);
+      if (ormException instanceof OrmDuplicateKeyException) {
+        return;
+      }
+      throw ormException;
     }
   }
 
diff --git a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
index 84fd9d7..71401d7 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/testutil/InMemoryModule.java
@@ -220,6 +220,9 @@
         case LUCENE:
           install(luceneIndexModule());
           break;
+        case ELASTICSEARCH:
+          install(elasticIndexModule());
+          break;
         default:
           throw new ProvisionException(
               "index type unsupported in tests: " + indexType);
@@ -242,14 +245,21 @@
   }
 
   private Module luceneIndexModule() {
+    return indexModule("com.google.gerrit.lucene.LuceneIndexModule");
+  }
+
+  private Module elasticIndexModule() {
+    return indexModule("com.google.gerrit.elasticsearch.ElasticIndexModule");
+  }
+
+  private Module indexModule(String moduleClassName) {
     try {
       Map<String, Integer> singleVersions = new HashMap<>();
       int version = cfg.getInt("index", "lucene", "testVersion", -1);
       if (version > 0) {
         singleVersions.put(ChangeSchemaDefinitions.INSTANCE.getName(), version);
       }
-      Class<?> clazz =
-          Class.forName("com.google.gerrit.lucene.LuceneIndexModule");
+      Class<?> clazz = Class.forName(moduleClassName);
       Method m = clazz.getMethod(
           "singleVersionWithExplicitVersions", Map.class, int.class);
       return (Module) m.invoke(null, singleVersions, 0);
diff --git a/gerrit-war/BUCK b/gerrit-war/BUCK
index 6d74a83..5dd1b04 100644
--- a/gerrit-war/BUCK
+++ b/gerrit-war/BUCK
@@ -5,6 +5,7 @@
   srcs = glob(['src/main/java/**/*.java']),
   deps = [
     '//gerrit-cache-h2:cache-h2',
+    '//gerrit-elasticsearch:elasticsearch',
     '//gerrit-extension-api:api',
     '//gerrit-gpg:gpg',
     '//gerrit-httpd:httpd',
diff --git a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
index fc0beae..9dba629 100644
--- a/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
+++ b/gerrit-war/src/main/java/com/google/gerrit/httpd/WebAppInitializer.java
@@ -19,6 +19,7 @@
 
 import com.google.common.base.Splitter;
 import com.google.gerrit.common.EventBroker;
+import com.google.gerrit.elasticsearch.ElasticIndexModule;
 import com.google.gerrit.extensions.client.AuthType;
 import com.google.gerrit.gpg.GpgModule;
 import com.google.gerrit.httpd.auth.oauth.OAuthModule;
@@ -343,6 +344,8 @@
     switch (indexType) {
       case LUCENE:
         return LuceneIndexModule.latestVersionWithOnlineUpgrade();
+      case ELASTICSEARCH:
+        return ElasticIndexModule.latestVersionWithOnlineUpgrade();
       default:
         throw new IllegalStateException("unsupported index.type = " + indexType);
     }
diff --git a/lib/commons/BUCK b/lib/commons/BUCK
index 7c27477..55c07a6 100644
--- a/lib/commons/BUCK
+++ b/lib/commons/BUCK
@@ -47,6 +47,13 @@
 )
 
 maven_jar(
+  name = 'lang3',
+  id = 'org.apache.commons:commons-lang3:3.3.2',
+  sha1 = '90a3822c38ec8c996e84c16a3477ef632cbc87a3',
+  license = 'Apache2.0',
+)
+
+maven_jar(
   name = 'net',
   id = 'commons-net:commons-net:3.5',
   sha1 = '342fc284019f590e1308056990fdb24a08f06318',
diff --git a/lib/elasticsearch/BUCK b/lib/elasticsearch/BUCK
new file mode 100644
index 0000000..86594ce
--- /dev/null
+++ b/lib/elasticsearch/BUCK
@@ -0,0 +1,104 @@
+include_defs('//lib/maven.defs')
+
+# Java client library for Elasticsearch.
+maven_jar(
+  name = 'elasticsearch',
+  id = 'org.elasticsearch:elasticsearch:2.4.0',
+  sha1 = 'aeb9704a76fa8654c348f38fcbb993a952a7ab07',
+  attach_source = True,
+  repository = MAVEN_CENTRAL,
+  license = 'Apache2.0',
+  deps = [
+    ':jna',
+    ':hppc',
+    ':jsr166e',
+    ':netty',
+    ':t-digest',
+    ':compress-lzf',
+    '//lib/joda:joda-time',
+    '//lib/lucene:lucene-codecs',
+    '//lib/lucene:lucene-highlighter',
+    '//lib/lucene:lucene-join',
+    '//lib/lucene:lucene-memory',
+    '//lib/lucene:lucene-sandbox',
+    '//lib/lucene:lucene-suggest',
+    '//lib/lucene:lucene-queries',
+    '//lib/lucene:lucene-spatial',
+    '//lib/jackson:jackson-core',
+    '//lib/jackson:jackson-dataformat-cbor',
+    '//lib/jackson:jackson-dataformat-smile',
+  ]
+)
+
+# Java REST client for Elasticsearch.
+VERSION = '0.1.7'
+
+maven_jar(
+  name = 'jest-common',
+  id = 'io.searchbox:jest-common:' + VERSION,
+  sha1 = 'ff6e2694405557a3a02b444cb7f7da28c4d99f07',
+  license = 'Apache2.0',
+)
+
+maven_jar(
+  name = 'jest',
+  id = 'io.searchbox:jest:' + VERSION,
+  sha1 = '686619c7141edb50b562ad2a39d32ea4cf20b567',
+  license = 'Apache2.0',
+  deps = [
+    ':elasticsearch',
+    ':jest-common',
+    '//lib/commons:lang3',
+    '//lib/httpcomponents:httpasyncclient',
+    '//lib/httpcomponents:httpclient',
+    '//lib/httpcomponents:httpcore-nio',
+    '//lib/httpcomponents:httpcore-niossl',
+  ],
+)
+
+maven_jar(
+  name = 'compress-lzf',
+  id = 'com.ning:compress-lzf:1.0.2',
+  sha1 = '62896e6fca184c79cc01a14d143f3ae2b4f4b4ae',
+  license = 'Apache2.0',
+  visibility = ['//lib/elasticsearch:elasticsearch'],
+)
+
+maven_jar(
+  name = 'hppc',
+  id = 'com.carrotsearch:hppc:0.7.1',
+  sha1 = '8b5057f74ea378c0150a1860874a3ebdcb713767',
+  license = 'Apache2.0',
+  visibility = ['//lib/elasticsearch:elasticsearch'],
+)
+
+maven_jar(
+  name = 'jsr166e',
+  id = 'com.twitter:jsr166e:1.1.0',
+  sha1 = '233098147123ee5ddcd39ffc57ff648be4b7e5b2',
+  license = 'Apache2.0',
+  visibility = ['//lib/elasticsearch:elasticsearch'],
+)
+
+maven_jar(
+  name = 'netty',
+  id = 'io.netty:netty:3.10.0.Final',
+  sha1 = 'ad61cd1bba067e6634ddd3e160edf0727391ac30',
+  license = 'Apache2.0',
+  visibility = ['//lib/elasticsearch:elasticsearch'],
+)
+
+maven_jar(
+  name = 't-digest',
+  id = 'com.tdunning:t-digest:3.0',
+  sha1 = '84ccf145ac2215e6bfa63baa3101c0af41017cfc',
+  license = 'Apache2.0',
+  visibility = ['//lib/elasticsearch:elasticsearch'],
+)
+
+maven_jar(
+  name = 'jna',
+  id = 'net.java.dev.jna:jna:4.1.0',
+  sha1 = '1c12d070e602efd8021891cdd7fd18bc129372d4',
+  license = 'Apache2.0',
+)
diff --git a/lib/httpcomponents/BUCK b/lib/httpcomponents/BUCK
index 03669f2..1e56f94 100644
--- a/lib/httpcomponents/BUCK
+++ b/lib/httpcomponents/BUCK
@@ -39,3 +39,25 @@
   src_sha1 = '5394d3715181a87009032335a55b0a9789f6e26f',
   license = 'Apache2.0',
 )
+
+maven_jar(
+  name = 'httpasyncclient',
+  id = 'org.apache.httpcomponents:httpasyncclient:4.1.2',
+  sha1 = '95aa3e6fb520191a0970a73cf09f62948ee614be',
+  license = 'Apache2.0',
+)
+
+maven_jar(
+  name = 'httpcore-nio',
+  id = 'org.apache.httpcomponents:httpcore-nio:' + VERSION,
+  sha1 = 'a8c5e3c3bfea5ce23fb647c335897e415eb442e3',
+  license = 'Apache2.0',
+)
+
+maven_jar(
+  name = 'httpcore-niossl',
+  id = 'org.apache.httpcomponents:httpcore-niossl:4.0-alpha6',
+  sha1 = '9c662e7247ca8ceb1de5de629f685c9ef3e4ab58',
+  license = 'Apache2.0',
+  attach_source = False,
+)
diff --git a/lib/jackson/BUCK b/lib/jackson/BUCK
new file mode 100644
index 0000000..46056b5
--- /dev/null
+++ b/lib/jackson/BUCK
@@ -0,0 +1,26 @@
+include_defs('//lib/maven.defs')
+
+VERSION = '2.6.6'
+
+maven_jar(
+  name = 'jackson-core',
+  id = 'com.fasterxml.jackson.core:jackson-core:' + VERSION,
+  sha1 = '02eb801df67aacaf5b1deb4ac626e1964508e47b',
+  license = 'Apache2.0',
+)
+
+maven_jar(
+  name = 'jackson-dataformat-smile',
+  id = 'com.fasterxml.jackson.dataformat:jackson-dataformat-smile:' + VERSION,
+  sha1 = 'ccbfc948748ed2754a58c1af9e0a02b5cc1aed69',
+  license = 'Apache2.0',
+)
+
+maven_jar(
+  name = 'jackson-dataformat-cbor',
+  id = 'com.fasterxml.jackson.dataformat:jackson-dataformat-cbor:' + VERSION,
+  sha1 = '34c7b7ff495fc6b049612bdc9db0900a68e112f8',
+  license = 'Apache2.0'
+)
+
+
diff --git a/lib/lucene/BUCK b/lib/lucene/BUCK
index dee8ce8..8f2efa2 100644
--- a/lib/lucene/BUCK
+++ b/lib/lucene/BUCK
@@ -14,6 +14,17 @@
 )
 
 maven_jar(
+  name = 'lucene-codecs',
+  id = 'org.apache.lucene:lucene-codecs:' + VERSION,
+  sha1 = 'e01fe463d9490bb1b4a6a168e771f7b7255a50b1',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+)
+
+maven_jar(
   name = 'lucene-core',
   id = 'org.apache.lucene:lucene-core:' + VERSION,
   sha1 = 'de5e5c3161ea01e89f2a09a14391f9b7ed66cdbb',
@@ -22,7 +33,7 @@
     'META-INF/LICENSE.txt',
     'META-INF/NOTICE.txt',
   ],
-  visibility = [],
+  visibility = ['//gerrit-elasticsearch:elasticsearch'],
 )
 
 maven_jar(
@@ -51,6 +62,39 @@
 )
 
 maven_jar(
+  name = 'lucene-highlighter',
+  id = 'org.apache.lucene:lucene-highlighter:' + VERSION,
+  sha1 = 'd127ac514e9df965ab0b57d92bbe0c68d3d145b8',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+)
+
+maven_jar(
+  name = 'lucene-join',
+  id = 'org.apache.lucene:lucene-join:'+ VERSION,
+  sha1 = 'dac1b322508f3f2696ecc49a97311d34d8382054',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+)
+
+maven_jar(
+  name = 'lucene-memory',
+  id = 'org.apache.lucene:lucene-memory:' + VERSION,
+  sha1 = '7409db9863d8fbc265c27793c6cc7511304182c2',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+)
+
+maven_jar(
   name = 'lucene-misc',
   id = 'org.apache.lucene:lucene-misc:' + VERSION,
   sha1 = '37bbe5a2fb429499dfbe75d750d1778881fff45d',
@@ -63,6 +107,49 @@
 )
 
 maven_jar(
+  name = 'lucene-sandbox',
+  id = 'org.apache.lucene:lucene-sandbox:' + VERSION,
+  sha1 = '30a91f120706ba66732d5a974b56c6971b3c8a16',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+)
+
+maven_jar(
+  name = 'lucene-spatial',
+  id = 'org.apache.lucene:lucene-spatial:' + VERSION,
+  sha1 = '8ed7a9a43d78222038573dd1c295a61f3c0bb0db',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+)
+maven_jar(
+  name = 'lucene-suggest',
+  id = 'org.apache.lucene:lucene-suggest:' + VERSION,
+  sha1 = 'e8316b37dddcf2092a54dab2ce6aad0d5ad78585',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+)
+
+maven_jar(
+  name = 'lucene-queries',
+  id = 'org.apache.lucene:lucene-queries:' + VERSION,
+  sha1 = '692f1ad887cf4e006a23f45019e6de30f3312d3f',
+  license = 'Apache2.0',
+  exclude = [
+    'META-INF/LICENSE.txt',
+    'META-INF/NOTICE.txt',
+  ],
+)
+
+maven_jar(
   name = 'lucene-queryparser',
   id = 'org.apache.lucene:lucene-queryparser:' + VERSION,
   sha1 = '8ac921563e744463605284c6d9d2d95e1be5b87c',
@@ -73,3 +160,4 @@
     'META-INF/NOTICE.txt',
   ],
 )
+
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
index a7d99a7..f58f762 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
@@ -15,6 +15,7 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 
 <dom-module id="gr-comment-list">
   <template>
@@ -39,8 +40,6 @@
       }
       .message {
         flex: 1;
-        white-space: pre-wrap;
-        word-wrap: break-word;
       }
     </style>
     <template is="dom-repeat" items="[[_computeFilesFromComments(comments)]]" as="file">
@@ -60,7 +59,10 @@
                File comment:
              </span>
           </a>
-          <div class="message">[[comment.message]]</div>
+          <gr-linked-text class="message"
+              pre
+              content="[[comment.message]]"
+              config="[[projectConfig.commentlinks]]"></gr-linked-text>
         </div>
       </template>
     </template>
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
index eaafc447..0362089 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.js
@@ -21,6 +21,7 @@
       changeNum: Number,
       comments: Object,
       patchNum: Number,
+      projectConfig: Object,
     },
 
     _computeFilesFromComments: function(comments) {
diff --git a/polygerrit-ui/app/elements/change/gr-message/gr-message.html b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
index c0773d3..287e9e4 100644
--- a/polygerrit-ui/app/elements/change/gr-message/gr-message.html
+++ b/polygerrit-ui/app/elements/change/gr-message/gr-message.html
@@ -121,7 +121,8 @@
             <gr-comment-list
                 comments="[[comments]]"
                 change-num="[[changeNum]]"
-                patch-num="[[message._revision_number]]"></gr-comment-list>
+                patch-num="[[message._revision_number]]"
+                project-config="[[projectConfig]]"></gr-comment-list>
           </div>
           <a class="date" href$="[[_computeMessageHash(message)]]" on-tap="_handleLinkTap">
             <gr-date-formatter date-str="[[message.date]]"></gr-date-formatter>
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 efe8684..6c73e08 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
@@ -174,6 +174,10 @@
           this._patchRange.patchNum, this._path, reviewed);
     },
 
+    _checkForModifiers: function(e) {
+      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || false;
+    },
+
     _handleKey: function(e) {
       if (this.shouldSupressKeyboardShortcut(e)) { return; }
 
@@ -201,6 +205,7 @@
           this.$.cursor.moveUp();
           break;
         case 67: // 'c'
+          if (this._checkForModifiers(e)) { return; }
           if (!this.$.diff.isRangeSelected()) {
             e.preventDefault();
             var line = this.$.cursor.getTargetLineElement();
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 14fd2b7..1eb3f95 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
@@ -509,5 +509,13 @@
       assert.equal(element.$.cursor.initialLineNumber, 345);
       assert.equal(element.$.cursor.side, 'left');
     });
+
+    test('_checkForModifiers', function() {
+      assert.isTrue(element._checkForModifiers({altKey: true}));
+      assert.isTrue(element._checkForModifiers({ctrlKey: true}));
+      assert.isTrue(element._checkForModifiers({metaKey: true}));
+      assert.isTrue(element._checkForModifiers({shiftKey: true}));
+      assert.isFalse(element._checkForModifiers({}));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index d565a12..fbd1ef3 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -74,9 +74,14 @@
       return rect;
     },
 
+    _checkForModifiers: function(e) {
+      return e.altKey || e.ctrlKey || e.metaKey || e.shiftKey || false;
+    },
+
     _handleKey: function(e) {
       if (this.shouldSupressKeyboardShortcut(e)) { return; }
       if (e.keyCode === 67) { // 'c'
+        if (this._checkForModifiers(e)) { return; }
         e.preventDefault();
         this._fireCreateComment();
       }
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
index adc8532..c12966d 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box_test.html
@@ -117,5 +117,13 @@
         document.createRange.restore();
       });
     });
+
+    test('_checkForModifiers', function() {
+      assert.isTrue(element._checkForModifiers({altKey: true}));
+      assert.isTrue(element._checkForModifiers({ctrlKey: true}));
+      assert.isTrue(element._checkForModifiers({metaKey: true}));
+      assert.isTrue(element._checkForModifiers({shiftKey: true}));
+      assert.isFalse(element._checkForModifiers({}));
+    });
   });
 </script>