Add support for Robot Comments

Extend the PostReview REST endpoint to support posting of robot
comments. For this ReviewInput got a new field that can contain a list
of robot comments. For reading robot comments new REST endpoints have
been added.

Robot comments are only supported with NoteDb, but not with ReviewDb.

Robot comments are always stored as JSON, even if notedb.writeJson is
set to false.

In NoteDb robot comments are not stored in the change meta ref but in
a separate refs/changes/XX/YYYY/robot-comments ref. By storing robot
comments in a separate ref we can delete robot comments without
rewriting the history of the change meta branch which is needed for
auditing and hence cannot be rewritten. Deletion of robot comments is
not implemented in this change, but we may want to support this later
as the amount and size of robot comments can be large.

Draft robot comments are not supported, but robot comments are always
published comments.

Change-Id: I2d8a5ca59e9a8b2c34863c54a3a9576599f69526
Signed-off-by: Edwin Kempin <ekempin@google.com>
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/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-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-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/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/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/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;
+}