Merge "Make sure user logged in before auto opening revert popup"
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index 34c72a6..ae02475 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -5712,6 +5712,8 @@
 |`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.
+|`properties`   |optional|
+Robot specific properties as map that maps arbitrary keys to values.
 |===========================
 
 [[robot-comment-input]]
diff --git a/contrib/populate-fixture-data.py b/contrib/populate-fixture-data.py
index c35f82c..b77c41a 100644
--- a/contrib/populate-fixture-data.py
+++ b/contrib/populate-fixture-data.py
@@ -182,14 +182,15 @@
 
 
 def get_random_users(num_users):
-  users = [(f, l) for f in FIRST_NAMES for l in LAST_NAMES][:num_users]
+  users = random.sample([(f, l) for f in FIRST_NAMES for l in LAST_NAMES],
+                        num_users)
   names = []
   for u in users:
     names.append({"firstname": u[0],
                   "lastname": u[1],
                   "name": u[0] + " " + u[1],
                   "username": u[0] + u[1],
-                  "email": u[0] + "." + u[1] + "@gmail.com",
+                  "email": u[0] + "." + u[1] + "@gerritcodereview.com",
                   "http_password": "secret",
                   "groups": []})
   return names
@@ -293,6 +294,7 @@
   project_names = create_gerrit_projects(group_names)
 
   for idx, u in enumerate(gerrit_users):
-    create_change(u, project_names[4 * idx / len(gerrit_users)])
+    for _ in xrange(random.randint(1, 5)):
+      create_change(u, project_names[4 * idx / len(gerrit_users)])
 
 main()
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpSession.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpSession.java
index 669b991..e5182df 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpSession.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/HttpSession.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance;
 
 import com.google.common.base.CharMatcher;
+import com.google.gerrit.common.Nullable;
 
 import org.apache.http.HttpHost;
 import org.apache.http.client.fluent.Executor;
@@ -24,17 +25,20 @@
 import java.net.URI;
 
 public class HttpSession {
-
+  protected TestAccount account;
   protected final String url;
   private final Executor executor;
 
-  public HttpSession(GerritServer server, TestAccount account) {
+  public HttpSession(GerritServer server, @Nullable TestAccount account) {
     this.url = CharMatcher.is('/').trimTrailingFrom(server.getUrl());
     URI uri = URI.create(url);
-    this.executor = Executor
-        .newInstance()
-        .auth(new HttpHost(uri.getHost(), uri.getPort()),
+    this.executor = Executor.newInstance();
+    this.account = account;
+    if (account != null) {
+        executor.auth(
+            new HttpHost(uri.getHost(), uri.getPort()),
             account.username, account.httpPassword);
+    }
   }
 
   public String url() {
diff --git a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java
index 90ece46..689b2d0 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/RestSession.java
@@ -18,6 +18,7 @@
 
 import com.google.common.base.Preconditions;
 import com.google.common.net.HttpHeaders;
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.restapi.RawInput;
 import com.google.gerrit.server.OutputFormat;
 
@@ -32,7 +33,7 @@
 
 public class RestSession extends HttpSession {
 
-  public RestSession(GerritServer server, TestAccount account) {
+  public RestSession(GerritServer server, @Nullable TestAccount account) {
     super(server, account);
   }
 
@@ -47,7 +48,7 @@
 
   public RestResponse getWithHeader(String endPoint, Header header)
       throws IOException {
-    Request get = Request.Get(url + "/a" + endPoint);
+    Request get = Request.Get(getUrl(endPoint));
     if (header != null) {
       get.addHeader(header);
     }
@@ -55,7 +56,7 @@
   }
 
   public RestResponse head(String endPoint) throws IOException {
-    return execute(Request.Head(url + "/a" + endPoint));
+    return execute(Request.Head(getUrl(endPoint)));
   }
 
   public RestResponse put(String endPoint) throws IOException {
@@ -73,7 +74,7 @@
 
   public RestResponse putWithHeader(String endPoint, Header header,
       Object content) throws IOException {
-    Request put = Request.Put(url + "/a" + endPoint);
+    Request put = Request.Put(getUrl(endPoint));
     if (header != null) {
       put.addHeader(header);
     }
@@ -88,7 +89,7 @@
 
   public RestResponse putRaw(String endPoint, RawInput stream) throws IOException {
     Preconditions.checkNotNull(stream);
-    Request put = Request.Put(url + "/a" + endPoint);
+    Request put = Request.Put(getUrl(endPoint));
     put.addHeader(new BasicHeader("Content-Type", stream.getContentType()));
     put.body(new BufferedHttpEntity(
         new InputStreamEntity(
@@ -102,7 +103,15 @@
   }
 
   public RestResponse post(String endPoint, Object content) throws IOException {
-    Request post = Request.Post(url + "/a" + endPoint);
+    return postWithHeader(endPoint, content, null);
+  }
+
+  public RestResponse postWithHeader(String endPoint, Object content,
+      Header header) throws IOException {
+    Request post = Request.Post(getUrl(endPoint));
+    if (header != null) {
+      post.addHeader(header);
+    }
     if (content != null) {
       post.addHeader(new BasicHeader("Content-Type", "application/json"));
       post.body(new StringEntity(
@@ -113,6 +122,10 @@
   }
 
   public RestResponse delete(String endPoint) throws IOException {
-    return execute(Request.Delete(url + "/a" + endPoint));
+    return execute(Request.Delete(getUrl(endPoint)));
+  }
+
+  private String getUrl(String endPoint) {
+    return url + (account != null ? "/a" : "") + endPoint;
   }
 }
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
index 064b206..7aa5876 100644
--- 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
@@ -76,6 +76,31 @@
   }
 
   @Test
+  public void noOptionalFields() throws Exception {
+    assume().that(notesMigration.enabled()).isTrue();
+
+    PushOneCommit.Result r = createChange();
+    RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
+    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);
+  }
+
+  @Test
   public void robotCommentsNotSupported() throws Exception {
     assume().that(notesMigration.enabled()).isFalse();
 
@@ -95,17 +120,25 @@
        .review(reviewInput);
   }
 
-  private RobotCommentInput createRobotCommentInput() {
+  private RobotCommentInput createRobotCommentInputWithMandatoryFields() {
     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 RobotCommentInput createRobotCommentInput() {
+    RobotCommentInput in = createRobotCommentInputWithMandatoryFields();
+    in.url = "http://www.happy-robot.com";
+    in.properties = new HashMap<>();
+    in.properties.put("key1", "value1");
+    in.properties.put("key2", "value2");
+    return in;
+  }
+
   private void assertRobotComment(RobotCommentInfo c,
       RobotCommentInput expected) {
     assertRobotComment(c, expected, true);
@@ -116,6 +149,7 @@
     assertThat(c.robotId).isEqualTo(expected.robotId);
     assertThat(c.robotRunId).isEqualTo(expected.robotRunId);
     assertThat(c.url).isEqualTo(expected.url);
+    assertThat(c.properties).isEqualTo(expected.properties);
     assertThat(c.line).isEqualTo(expected.line);
     assertThat(c.message).isEqualTo(expected.message);
 
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
index c990c8e..bdb6d2e 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.acceptance.rest.account;
 
 import static com.google.common.truth.Truth.assertThat;
+import static com.google.gerrit.server.group.SystemGroupBackend.ANONYMOUS_USERS;
 import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 
 import com.google.common.collect.ImmutableList;
@@ -23,7 +24,10 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.PushOneCommit;
+import com.google.gerrit.acceptance.RestResponse;
+import com.google.gerrit.acceptance.RestSession;
 import com.google.gerrit.acceptance.TestAccount;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.DraftInput;
@@ -35,6 +39,7 @@
 import com.google.gerrit.extensions.api.groups.GroupInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.ApprovalInfo;
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.common.ChangeMessageInfo;
@@ -52,6 +57,9 @@
 import com.google.gerrit.server.project.Util;
 import com.google.inject.Inject;
 
+import org.apache.http.Header;
+import org.apache.http.message.BasicHeader;
+import org.junit.After;
 import org.junit.Before;
 import org.junit.Test;
 
@@ -59,11 +67,13 @@
   @Inject
   private AccountControl.Factory accountControlFactory;
 
+  private RestSession anonRestSession;
   private TestAccount admin2;
   private GroupInfo newGroup;
 
   @Before
   public void setUp() throws Exception {
+    anonRestSession = new RestSession(server, null);
     admin2 = accounts.admin2();
     GroupInput gi = new GroupInput();
     gi.name = name("New-Group");
@@ -71,6 +81,11 @@
     newGroup = gApi.groups().create(gi).get();
   }
 
+  @After
+  public void tearDown() throws Exception {
+    removeRunAs();
+  }
+
   @Test
   public void voteOnBehalfOf() throws Exception {
     allowCodeReviewOnBehalfOf();
@@ -386,6 +401,96 @@
         .submit(in);
   }
 
+  @Test
+  public void runAsValidUser() throws Exception {
+    allowRunAs();
+    RestResponse res =
+        adminRestSession.getWithHeader("/accounts/self", runAsHeader(user.id));
+    res.assertOK();
+    AccountInfo account =
+        newGson().fromJson(res.getEntityContent(), AccountInfo.class);
+    assertThat(account._accountId).isEqualTo(user.id.get());
+  }
+
+  @GerritConfig(name = "auth.enableRunAs", value = "false")
+  @Test
+  public void runAsDisabledByConfig() throws Exception {
+    allowRunAs();
+    RestResponse res =
+        adminRestSession.getWithHeader("/changes/", runAsHeader(user.id));
+    res.assertForbidden();
+    assertThat(res.getEntityContent())
+        .isEqualTo("X-Gerrit-RunAs disabled by auth.enableRunAs = false");
+  }
+
+  @Test
+  public void runAsNotPermitted() throws Exception {
+    RestResponse res =
+        adminRestSession.getWithHeader("/changes/", runAsHeader(user.id));
+    res.assertForbidden();
+    assertThat(res.getEntityContent())
+        .isEqualTo("not permitted to use X-Gerrit-RunAs");
+  }
+
+  @Test
+  public void runAsNeverPermittedForAnonymousUsers() throws Exception {
+    allowRunAs();
+    RestResponse res =
+        anonRestSession.getWithHeader("/changes/", runAsHeader(user.id));
+    res.assertForbidden();
+    assertThat(res.getEntityContent())
+        .isEqualTo("not permitted to use X-Gerrit-RunAs");
+  }
+
+  @Test
+  public void runAsInvalidUser() throws Exception {
+    allowRunAs();
+    RestResponse res = adminRestSession.getWithHeader(
+        "/changes/", runAsHeader("doesnotexist"));
+    res.assertForbidden();
+    assertThat(res.getEntityContent())
+        .isEqualTo("no account matches X-Gerrit-RunAs");
+  }
+
+  @Test
+  public void voteUsingRunAsAvoidsRestrictionsOfOnBehalfOf() throws Exception {
+    allowRunAs();
+    PushOneCommit.Result r = createChange();
+
+    setApiUser(user);
+    DraftInput di = new DraftInput();
+    di.path = Patch.COMMIT_MSG;
+    di.side = Side.REVISION;
+    di.line = 1;
+    di.message = "inline comment";
+    gApi.changes().id(r.getChangeId()).current().createDraft(di);
+    setApiUser(admin);
+
+    // Things that aren't allowed with on_behalf_of:
+    //  - no labels.
+    //  - publish other user's drafts.
+    ReviewInput in = new ReviewInput();
+    in.message = "message";
+    in.drafts = DraftHandling.PUBLISH;
+    RestResponse res = adminRestSession.postWithHeader(
+        "/changes/" + r.getChangeId() + "/revisions/current/review", in,
+        runAsHeader(user.id));
+    res.assertOK();
+
+    ChangeMessageInfo m = Iterables.getLast(
+        gApi.changes().id(r.getChangeId()).get().messages);
+    assertThat(m.message).endsWith(in.message);
+    assertThat(m.author._accountId).isEqualTo(user.id.get());
+
+    CommentInfo c = Iterables.getOnlyElement(
+        gApi.changes().id(r.getChangeId()).comments().get(di.path));
+    assertThat(c.author._accountId).isEqualTo(user.id.get());
+    assertThat(c.message).isEqualTo(di.message);
+
+    setApiUser(user);
+    assertThat(gApi.changes().id(r.getChangeId()).drafts()).isEmpty();
+  }
+
   private void allowCodeReviewOnBehalfOf() throws Exception {
     ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
     LabelType codeReviewType = Util.codeReview();
@@ -416,4 +521,22 @@
         cfg, Permission.READ, new AccountGroup.UUID(group.id), "refs/heads/master");
     saveProjectConfig(project, cfg);
   }
+
+  private void allowRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.allow(cfg, GlobalCapability.RUN_AS,
+        SystemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  private void removeRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.remove(cfg, GlobalCapability.RUN_AS,
+        SystemGroupBackend.getGroup(ANONYMOUS_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  private static Header runAsHeader(Object user) {
+    return new BasicHeader("X-Gerrit-RunAs", user.toString());
+  }
 }
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 b1c25d0..472559b 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
@@ -102,6 +102,7 @@
     public String robotId;
     public String robotRunId;
     public String url;
+    public Map<String, String> properties;
   }
 
   public ReviewInput message(String msg) {
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
index a6b7593..9028a1d 100644
--- 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
@@ -14,8 +14,11 @@
 
 package com.google.gerrit.extensions.common;
 
+import java.util.Map;
+
 public class RobotCommentInfo extends CommentInfo {
   public String robotId;
   public String robotRunId;
   public String url;
+  public Map<String, String> properties;
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java
index 7921ebe..2956ffc 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Assignee.java
@@ -15,6 +15,7 @@
 package com.google.gerrit.client.change;
 
 import com.google.gerrit.client.FormatUtil;
+import com.google.gerrit.client.Gerrit;
 import com.google.gerrit.client.NotSignedInDialog;
 import com.google.gerrit.client.changes.ChangeApi;
 import com.google.gerrit.client.changes.Util;
@@ -36,6 +37,8 @@
 import com.google.gwt.uibinder.client.UiBinder;
 import com.google.gwt.uibinder.client.UiField;
 import com.google.gwt.uibinder.client.UiHandler;
+import com.google.gwt.user.client.DOM;
+import com.google.gwt.user.client.EventListener;
 import com.google.gwt.user.client.rpc.StatusCodeException;
 import com.google.gwt.user.client.ui.Composite;
 import com.google.gwt.user.client.ui.HTMLPanel;
@@ -161,6 +164,10 @@
             public void onSuccess(AccountInfo result) {
               onCloseForm();
               setAssignee(result);
+              Reviewers reviewers = getReviewers();
+              if (reviewers != null) {
+                reviewers.updateReviewerList();
+              }
             }
 
             @Override
@@ -180,7 +187,7 @@
 
   private void setAssignee(AccountInfo assignee) {
     currentAssignee = assignee;
-    assigneeLink.setText(assignee != null ? assignee.name() : null);
+    assigneeLink.setText(assignee != null ? getName(assignee) : null);
     assigneeLink.setTargetHistoryToken(assignee != null
         ? PageLinks.toAssigneeQuery(assignee.name() != null
             ? assignee.name()
@@ -189,4 +196,26 @@
                 : String.valueOf(assignee._accountId()))
         : "");
   }
+
+  private Reviewers getReviewers() {
+      Element e = DOM.getParent(getElement());
+      for (e = DOM.getParent(e); e != null; e = DOM.getParent(e)) {
+        EventListener l = DOM.getEventListener(e);
+        if (l instanceof ChangeScreen) {
+          ChangeScreen screen =  (ChangeScreen) l;
+          return screen.reviewers;
+        }
+      }
+      return null;
+  }
+
+  private String getName(AccountInfo info) {
+    if (info.name() != null) {
+      return info.name();
+    }
+    if (info.email() != null) {
+      return info.email();
+    }
+    return Gerrit.info().user().anonymousCowardName();
+  }
 }
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
index b69d1c0..aa30760 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/Reviewers.java
@@ -198,7 +198,7 @@
         });
   }
 
-  private void updateReviewerList() {
+  void updateReviewerList() {
     ChangeApi.detail(changeId.get(),
         new GerritCallback<ChangeInfo>() {
           @Override
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
index 210800d..7e71639 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/RunAsFilter.java
@@ -90,7 +90,10 @@
       }
 
       CurrentUser self = session.get().getUser();
-      if (!self.getCapabilities().canRunAs()) {
+      if (!self.getCapabilities().canRunAs()
+          // Always disallow for anonymous users, even if permitted by the ACL,
+          // because that would be crazy.
+          || !self.isIdentifiedUser()) {
         replyError(req, res,
             SC_FORBIDDEN,
             "not permitted to use " + RUN_AS,
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java
index 8d8937c..18445b3 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/UrlModule.java
@@ -98,6 +98,11 @@
     serveRegex("^/r/(.+)/?$").with(DirectChangeByCommit.class);
 
     filter("/a/*").through(RequireIdentifiedUserFilter.class);
+
+    // Must be after RequireIdentifiedUserFilter so auth happens before checking
+    // for RunAs capability.
+    install(new RunAsFilter.Module());
+
     serveRegex("^/(?:a/)?tools/(.*)$").with(ToolServlet.class);
 
     // Bind servlets for REST root collections.
diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java
index fa551e8..d5a4b00 100644
--- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java
+++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/WebModule.java
@@ -56,8 +56,6 @@
     bind(RequestScopePropagator.class).to(GuiceRequestScopePropagator.class);
     bind(HttpRequestContext.class);
 
-    install(new RunAsFilter.Module());
-
     installAuthModule();
     if (options.enableMasterFeatures()) {
       install(new UrlModule(options, authConfig));
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
index da9584d..eadcfd0 100644
--- 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
@@ -15,12 +15,14 @@
 package com.google.gerrit.reviewdb.client;
 
 import java.sql.Timestamp;
+import java.util.Map;
 import java.util.Objects;
 
 public class RobotComment extends Comment {
   public String robotId;
   public String robotRunId;
   public String url;
+  public Map<String, String> properties;
 
   public RobotComment(Key key, Account.Id author, Timestamp writtenOn,
       short side, String message, String serverId, String robotId,
@@ -47,7 +49,8 @@
         .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("url=").append(url).append(',')
+        .append("properties=").append(properties != null ? properties : "")
         .append('}')
         .toString();
   }
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 be019fb..54f73bc 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
@@ -180,6 +180,7 @@
       rci.robotId = c.robotId;
       rci.robotRunId = c.robotRunId;
       rci.url = c.url;
+      rci.properties = c.properties;
       fillCommentInfo(c, rci, loader);
       return rci;
     }
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 fcb041b..c4c526b 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
@@ -597,6 +597,7 @@
               c.robotId, c.robotRunId);
           e.parentUuid = Url.decode(c.inReplyTo);
           e.url = c.url;
+          e.properties = c.properties;
           e.setLineNbrAndRange(c.line, c.range);
           e.tag = in.tag;
           setCommentRevId(e, patchListCache, ctx.getChange(), ps);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index 414ba5f..d081fe6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -96,29 +96,6 @@
       }
     }
 
-    synchronized void format(StringBuilder s, boolean first) {
-      if (count == 0) {
-        return;
-      }
-
-      if (!first) {
-        s.append(',');
-      }
-      s.append(' ');
-
-      if (!Strings.isNullOrEmpty(name)) {
-        s.append(name).append(": ");
-      }
-
-      if (total == UNKNOWN) {
-        s.append(count);
-      } else {
-        s.append(String.format("%d%% (%d/%d)",
-            count * 100 / total,
-            count, total));
-      }
-    }
-
     /**
      * Indicate that this sub-task is finished.
      * <p>
@@ -339,9 +316,32 @@
     StringBuilder s = new StringBuilder().append("\r").append(taskName)
         .append(':');
 
-    int firstLength = s.length();
-    for (Task t : tasks) {
-      t.format(s, s.length() == firstLength);
+    if (!tasks.isEmpty()) {
+      boolean first = true;
+      for (Task t : tasks) {
+        int count = t.count;
+        if (count == 0) {
+          continue;
+        }
+
+        if (!first) {
+          s.append(',');
+        } else {
+          first = false;
+        }
+
+        s.append(' ');
+        if (!Strings.isNullOrEmpty(t.name)) {
+          s.append(t.name).append(": ");
+        }
+        if (t.total == UNKNOWN) {
+          s.append(count);
+        } else {
+          s.append(String.format("%d%% (%d/%d)",
+              count * 100 / t.total,
+              count, t.total));
+        }
+      }
     }
 
     if (spinnerState != NO_SPINNER) {
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 96cd4c6..18dd7f6 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
@@ -646,10 +646,8 @@
             addFooter(msg, FOOTER_SUBMITTED_WITH)
                 .append(label.status).append(": ").append(label.label);
             if (label.appliedBy != null) {
-              PersonIdent ident =
-                  newIdent(accountCache.get(label.appliedBy).getAccount(), when);
-              msg.append(": ").append(ident.getName())
-                  .append(" <").append(ident.getEmailAddress()).append('>');
+              msg.append(": ");
+              addIdent(msg, label.appliedBy);
             }
             msg.append('\n');
           }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
index a990e19..bc8af82 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeMessageEvent.java
@@ -31,11 +31,6 @@
   private static final Pattern TOPIC_REMOVED_REGEXP =
       Pattern.compile("^Topic (.+) removed$");
 
-  private static final Pattern STATUS_ABANDONED_REGEXP =
-      Pattern.compile("^Abandoned(\n.*)*$");
-  private static final Pattern STATUS_RESTORED_REGEXP =
-      Pattern.compile("^Restored(\n.*)*$");
-
   private final ChangeMessage message;
   private final Change noteDbChange;
 
@@ -57,7 +52,6 @@
     checkUpdate(update);
     update.setChangeMessage(message.getMessage());
     setTopic(update);
-    setStatus(update);
   }
 
   private void setTopic(ChangeUpdate update) {
@@ -86,21 +80,4 @@
       noteDbChange.setTopic(null);
     }
   }
-
-  private void setStatus(ChangeUpdate update) {
-    String msg = message.getMessage();
-    if (msg == null) {
-      return;
-    }
-    if (STATUS_ABANDONED_REGEXP.matcher(msg).matches()) {
-      update.setStatus(Change.Status.ABANDONED);
-      noteDbChange.setStatus(Change.Status.ABANDONED);
-      return;
-    }
-
-    if (STATUS_RESTORED_REGEXP.matcher(msg).matches()) {
-      update.setStatus(Change.Status.NEW);
-      noteDbChange.setStatus(Change.Status.NEW);
-    }
-  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
index d4caa6c..cdd895a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ChangeRebuilderImpl.java
@@ -355,19 +355,16 @@
 
     Change noteDbChange = new Change(null, null, null, null, null);
     for (ChangeMessage msg : bundle.getChangeMessages()) {
-      if (msg.getPatchSetId() == null) {
-        // No dependency necessary; will get assigned to most recent patch set
-        // in sortAndFillEvents.
-        events.add(
-            new ChangeMessageEvent(msg, noteDbChange, change.getCreatedOn()));
-        continue;
+      List<Event> msgEvents = parseChangeMessage(msg, change, noteDbChange);
+      if (msg.getPatchSetId() != null) {
+        PatchSetEvent pse = patchSetEvents.get(msg.getPatchSetId());
+        if (pse != null) {
+          for (Event e : msgEvents) {
+            e.addDep(pse);
+          }
+        }
       }
-      PatchSetEvent pse = patchSetEvents.get(msg.getPatchSetId());
-      if (pse != null) {
-        events.add(
-            new ChangeMessageEvent(msg, noteDbChange, change.getCreatedOn())
-                .addDep(pse));
-      }
+      events.addAll(msgEvents);
     }
 
     sortAndFillEvents(change, noteDbChange, events, minPsNum);
@@ -396,6 +393,18 @@
     }
   }
 
+  private List<Event> parseChangeMessage(ChangeMessage msg, Change change,
+      Change noteDbChange) {
+    List<Event> events = new ArrayList<>(2);
+    events.add(new ChangeMessageEvent(msg, noteDbChange, change.getCreatedOn()));
+    Optional<StatusChangeEvent> sce =
+        StatusChangeEvent.parseFromMessage(msg, change, noteDbChange);
+    if (sce.isPresent()) {
+      events.add(sce.get());
+    }
+    return events;
+  }
+
   private static Integer getMinPatchSetNum(ChangeBundle bundle) {
     Integer minPsNum = null;
     for (PatchSet ps : bundle.getPatchSets()) {
@@ -418,8 +427,8 @@
 
   private void sortAndFillEvents(Change change, Change noteDbChange,
       List<Event> events, Integer minPsNum) {
-    new EventSorter(events).sort();
     events.add(new FinalUpdatesEvent(change, noteDbChange));
+    new EventSorter(events).sort();
 
     // Ensure the first event in the list creates the change, setting the author
     // and any required footers.
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/Event.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/Event.java
index d04d1b5..b7b08b0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/Event.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/Event.java
@@ -79,10 +79,6 @@
 
   abstract void apply(ChangeUpdate update) throws OrmException, IOException;
 
-  protected boolean isPatchSet() {
-    return false;
-  }
-
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
@@ -95,6 +91,7 @@
   @Override
   public int compareTo(Event other) {
     return ComparisonChain.start()
+        .compareFalseFirst(this.isFinalUpdates(), other.isFinalUpdates())
         .compare(this.when, other.when)
         .compareTrueFirst(isPatchSet(), isPatchSet())
         .compareTrueFirst(this.predatesChange, other.predatesChange)
@@ -103,4 +100,12 @@
             ReviewDbUtil.intKeyOrdering().nullsLast())
         .result();
   }
+
+  private boolean isPatchSet() {
+    return this instanceof PatchSetEvent;
+  }
+
+  private boolean isFinalUpdates() {
+    return this instanceof FinalUpdatesEvent;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
index 3080be7..26f6b1f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/FinalUpdatesEvent.java
@@ -46,9 +46,14 @@
       // TODO(dborowitz): Stamp approximate approvals at this time.
       update.fixStatus(change.getStatus());
     }
-    if (change.getSubmissionId() != null) {
+    if (change.getSubmissionId() != null
+        && noteDbChange.getSubmissionId() == null) {
       update.setSubmissionId(change.getSubmissionId());
     }
+    if (!Objects.equals(change.getAssignee(), noteDbChange.getAssignee())) {
+      // TODO(dborowitz): Parse intermediate values out from messages.
+      update.setAssignee(change.getAssignee());
+    }
     if (!update.isEmpty()) {
       update.setSubjectForCommit("Final NoteDb migration updates");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java
index 5baddd3..c3fb267 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/PatchSetEvent.java
@@ -66,11 +66,6 @@
     }
   }
 
-  @Override
-  protected boolean isPatchSet() {
-    return true;
-  }
-
   private void setRevision(ChangeUpdate update, PatchSet ps)
       throws IOException {
     String rev = ps.getRevision().get();
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/StatusChangeEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/StatusChangeEvent.java
new file mode 100644
index 0000000..29e0868
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/StatusChangeEvent.java
@@ -0,0 +1,90 @@
+// 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.rebuild;
+
+import com.google.common.base.Optional;
+import com.google.common.collect.ImmutableMap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.server.notedb.ChangeUpdate;
+import com.google.gwtorm.server.OrmException;
+
+import java.sql.Timestamp;
+import java.util.Map;
+import java.util.regex.Pattern;
+
+class StatusChangeEvent extends Event {
+  private static final ImmutableMap<Change.Status, Pattern> PATTERNS =
+      ImmutableMap.of(
+          Change.Status.ABANDONED, Pattern.compile("^Abandoned(\n.*)*$"),
+          Change.Status.MERGED, Pattern.compile(
+              "^Change has been successfully"
+              + " (merged|cherry-picked|rebased|pushed).*$"),
+          Change.Status.NEW, Pattern.compile("^Restored(\n.*)*$"));
+
+  static Optional<StatusChangeEvent> parseFromMessage(ChangeMessage message,
+      Change change, Change noteDbChange) {
+    String msg = message.getMessage();
+    if (msg == null) {
+      return Optional.absent();
+    }
+    for (Map.Entry<Change.Status, Pattern> e : PATTERNS.entrySet()) {
+      if (e.getValue().matcher(msg).matches()) {
+        return Optional.of(new StatusChangeEvent(
+            message, change, noteDbChange, e.getKey()));
+      }
+    }
+    return Optional.absent();
+  }
+
+  private final Change change;
+  private final Change noteDbChange;
+  private final Change.Status status;
+
+  private StatusChangeEvent(ChangeMessage message, Change change,
+      Change noteDbChange, Change.Status status) {
+    this(message.getPatchSetId(), message.getAuthor(),
+        message.getWrittenOn(), change, noteDbChange, message.getTag(),
+        status);
+  }
+
+  private StatusChangeEvent(PatchSet.Id psId, Account.Id author,
+      Timestamp when, Change change, Change noteDbChange,
+      String tag, Change.Status status) {
+    super(psId, author, when, change.getCreatedOn(), tag);
+    this.change = change;
+    this.noteDbChange = noteDbChange;
+    this.status = status;
+  }
+
+  @Override
+  boolean uniquePerUpdate() {
+    return true;
+  }
+
+  @SuppressWarnings("deprecation")
+  @Override
+  void apply(ChangeUpdate update) throws OrmException {
+    checkUpdate(update);
+    update.fixStatus(status);
+    noteDbChange.setStatus(status);
+    if (status == Change.Status.MERGED) {
+      update.setSubmissionId(change.getSubmissionId());
+      noteDbChange.setSubmissionId(change.getSubmissionId());
+    }
+  }
+}
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
index dcb2ca7..87e03dc 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.html
@@ -201,7 +201,8 @@
         </div>
       </div>
       <gr-diff
-          hidden$="[[_computeHiddenState(file.__expanded)]]"
+          hidden$="[[!file.__expanded]]"
+          expanded="[[file.__expanded]]"
           project="[[change.project]]"
           commit="[[change.current_revision]]"
           change-num="[[changeNum]]"
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
index dae798d..a562338 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list.js
@@ -437,10 +437,6 @@
       return expanded ? '▼' : '◀';
     },
 
-    _computeHiddenState: function(expanded) {
-      return !expanded;
-    },
-
     _computeFilesShown: function(numFilesShown, files) {
       return files.base.slice(0, numFilesShown);
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
index c3b6233..864712d 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.html
@@ -44,15 +44,20 @@
         padding: .5em .7em;
       }
       .header {
+        cursor: pointer;
         display: flex;
-        padding-bottom: 0;
         font-family: 'Open Sans', sans-serif;
+        padding-bottom: 0;
       }
-      .headerLeft {
+      .headerMiddle {
+        color: #666;
         flex: 1;
+        overflow: hidden;
       }
       .authorName,
       .draftLabel {
+        display: block;
+        float: left;
         font-weight: bold;
       }
       .draftLabel {
@@ -62,6 +67,7 @@
       .date {
         justify-content: flex-end;
         margin-left: 5px;
+        white-space: nowrap;
       }
       a.date:link,
       a.date:visited {
@@ -113,19 +119,62 @@
         background-color: #fff;
         display: block;
       }
+      .show-hide {
+        margin-left: .4em;
+      }
+      input.show-hide {
+        display: none;
+      }
+      label.show-hide {
+        color: #000;
+        cursor: pointer;
+        display: block;
+        font-size: .8em;
+        height: 1.1em;
+        margin-top: .1em;
+      }
+      #container .collapsedContent {
+        display: none;
+      }
+      #container.collapsed {
+        padding-bottom: 3px;
+      }
+      #container.collapsed .collapsedContent {
+        display: block;
+        overflow: hidden;
+        padding-left: 5px;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+      }
+      #container.collapsed .actions,
+      #container.collapsed gr-linked-text,
+      #container.collapsed iron-autogrow-textarea {
+        display: none;
+      }
     </style>
     <div id="container"
         class="container"
         on-mouseenter="_handleMouseEnter"
         on-mouseleave="_handleMouseLeave">
-      <div class="header" id="header">
+      <div class="header" id="header" on-click="_handleToggleCollapsed">
         <div class="headerLeft">
           <span class="authorName">[[comment.author.name]]</span>
           <span class="draftLabel">DRAFT</span>
         </div>
+        <div class="headerMiddle">
+          <span class="collapsedContent">[[comment.message]]</span>
+        </div>
         <a class="date" href$="[[_computeLinkToComment(comment)]]" on-tap="_handleLinkTap">
           <gr-date-formatter date-str="[[comment.updated]]"></gr-date-formatter>
         </a>
+        <div class="show-hide">
+          <label class="show-hide">
+            <input type="checkbox" class="show-hide"
+               checked$="[[_commentCollapsed]]"
+               on-change="_handleToggleCollapsed">
+            [[_computeShowHideText(_commentCollapsed)]]
+          </label>
+        </div>
       </div>
       <iron-autogrow-textarea
           id="editTextarea"
@@ -137,6 +186,7 @@
       <gr-linked-text class="message"
           pre
           content="[[comment.message]]"
+          collapsed="[[_commentCollapsed]]"
           config="[[projectConfig.commentlinks]]"></gr-linked-text>
       <div class="actions" hidden$="[[!showActions]]">
         <gr-button class="action reply" on-tap="_handleReply">Reply</gr-button>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
index 07badbf..791f949 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment.js
@@ -81,6 +81,11 @@
       },
       patchNum: String,
       showActions: Boolean,
+      _commentCollapsed: {
+        type: Boolean,
+        value: true,
+        observer: '_toggleCollapseClass',
+      },
       projectConfig: Object,
 
       _xhrPromise: Object,  // Used for testing.
@@ -96,10 +101,20 @@
       '_loadLocalDraft(changeNum, patchNum, comment)',
     ],
 
+    attached: function() {
+      if (this.editing) {
+        this._commentCollapsed = false;
+      }
+    },
+
     detached: function() {
       this.cancelDebouncer('fire-update');
     },
 
+    _computeShowHideText: function(collapsed) {
+      return collapsed ? '◀' : '▼';
+    },
+
     save: function() {
       this.comment.message = this._messageText;
       this.disabled = true;
@@ -210,6 +225,18 @@
       }
     },
 
+    _handleToggleCollapsed: function() {
+      this._commentCollapsed = !this._commentCollapsed;
+    },
+
+    _toggleCollapseClass: function(_commentCollapsed) {
+      if (_commentCollapsed) {
+        this.$.container.classList.add('collapsed');
+      } else {
+        this.$.container.classList.remove('collapsed');
+      }
+    },
+
     _commentMessageChanged: function(message) {
       this._messageText = message || '';
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
index bddc3ab..b05746e 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment/gr-diff-comment_test.html
@@ -39,6 +39,12 @@
 </test-fixture>
 
 <script>
+
+  function isVisible(el) {
+    assert.ok(el);
+    return getComputedStyle(el).getPropertyValue('display') !== 'none';
+  }
+
   suite('gr-diff-comment tests', function() {
     var element;
     setup(function() {
@@ -58,6 +64,32 @@
       };
     });
 
+    test('collapsible comments', function() {
+      // When a comment (not draft) is loaded, it should be collapsed
+      assert.isFalse(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is not visible');
+      assert.isFalse(isVisible(element.$$('.actions')),
+          'actions are not visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+
+      // The header middle content is only visible when comments are collapsed.
+      // It shows the message in a condensed way, and limits to a single line.
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      // When the header row is clicked, the comment should expand
+      MockInteractions.tap(element.$.header);
+      assert.isTrue(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is not visible');
+    });
+
     test('proper event fires on reply', function(done) {
       element.addEventListener('reply', function(e) {
         assert.ok(e.detail.comment);
@@ -135,11 +167,6 @@
       };
     });
 
-    function isVisible(el) {
-      assert.ok(el);
-      return getComputedStyle(el).getPropertyValue('display') != 'none';
-    }
-
     test('button visibility states', function() {
       element.showActions = false;
       assert.isTrue(element.$$('.actions').hasAttribute('hidden'));
@@ -181,6 +208,67 @@
       assert.isTrue(isVisible(element.$$('.cancel')), 'cancel is visible');
     });
 
+    test('collapsible drafts', function() {
+      element.addEventListener('reply', function(e) {
+        assert.ok(e.detail.comment);
+        done();
+      });
+      assert.isFalse(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is not visible');
+      assert.isFalse(isVisible(element.$$('.actions')),
+          'actions are not visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      MockInteractions.tap(element.$.header);
+      assert.isTrue(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is is not visible');
+
+      // When the edit button is pressed, should still see the actions
+      // and also textarea
+      MockInteractions.tap(element.$$('.edit'));
+      assert.isFalse(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is not visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isTrue(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is not visible');
+
+      // When toggle again, everything should be hidden except for textarea
+      // and header middle content should be visible
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is not visible');
+      assert.isFalse(isVisible(element.$$('.actions')),
+          'actions are not visible');
+      assert.isFalse(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is not visible');
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      // When toggle again, textarea should remain open in the state it was
+      // before
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(isVisible(element.$$('gr-linked-text')),
+          'gr-linked-text is not visible');
+      assert.isTrue(isVisible(element.$$('.actions')),
+          'actions are visible');
+      assert.isTrue(isVisible(element.$$('iron-autogrow-textarea')),
+          'textarea is visible');
+      assert.isFalse(isVisible(element.$$('.collapsedContent')),
+          'header middle content is not visible');
+    });
+
     test('draft creation/cancelation', function(done) {
       assert.isFalse(element.editing);
       MockInteractions.tap(element.$$('.edit'));
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
index 2dd4c91..f91c8ef 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor.js
@@ -86,6 +86,7 @@
     },
 
     detached: function() {
+      this.cancel();
       this.unlisten(window, 'scroll', '_handleWindowScroll');
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
index 4f8c532..c89d9b5 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-processor/gr-diff-processor_test.html
@@ -591,5 +591,12 @@
         });
       });
     });
+
+    test('detaching cancels', function() {
+      element = fixture('basic');
+      sandbox.stub(element, 'cancel');
+      element.detached();
+      assert(element.cancel.called);
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
index dc818e3..1a6759b 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff.js
@@ -34,8 +34,9 @@
 
     properties: {
       changeNum: String,
-      hidden: {
+      expanded: {
         type: Boolean,
+        value: true,
         observer: '_handleShowDiff',
       },
       patchRange: Object,
@@ -92,14 +93,7 @@
     },
 
     ready: function() {
-      // Reload only if the diff is not hidden and params are supplied.
-      if (this.changeNum && this.patchRange && this.path && !this.hidden) {
-        this.reload();
-      }
-    },
-
-    _handleShowDiff: function(hidden) {
-      if (!hidden) {
+      if (this._canRender()) {
         this.reload();
       }
     },
@@ -126,7 +120,7 @@
     },
 
     getCursorStops: function() {
-      if (this.hidden) {
+      if (!this.expanded) {
         return [];
       }
 
@@ -159,6 +153,16 @@
       this.toggleClass('no-left');
     },
 
+    _handleShowDiff: function() {
+      if (this._canRender()) {
+        this.reload();
+      }
+    },
+
+    _canRender: function() {
+      return this.changeNum && this.patchRange && this.path && this.expanded;
+    },
+
     _getCommentThreads: function() {
       return Polymer.dom(this.root).querySelectorAll('gr-diff-comment-thread');
     },
diff --git a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
index 2f0b6bd..b9b49c0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff/gr-diff_test.html
@@ -468,15 +468,20 @@
           assert.equal(drafts[0].id, id);
         });
 
-        test('_handleShowDiff reloads when hidden is made false',
+        test('_handleShowDiff reloads when expanded is made true',
             function(done) {
+          element.expanded = false;
+          element.changeNum = element._comments.meta.changeNum;
+          element.patchRange = element._comments.meta.patchRange;
+          element.path = element._comments.meta.path;
+
           var stub = sinon.stub(element, 'reload', function() {
             assert.isTrue(stub.called);
             stub.restore();
             done();
           });
           var spy = sinon.spy(element, '_handleShowDiff');
-          element.set('hidden', false);
+          element.set('expanded', true);
         });
       });
     });
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
index d3585ef..8d89692 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.html
@@ -36,7 +36,8 @@
     <span>
       <a href$="[[_computeOwnerLink(account)]]">
         <gr-account-label account="[[account]]"
-            avatar-image-size="[[avatarImageSize]]"></gr-account-label>
+            avatar-image-size="[[avatarImageSize]]"
+            show-email="[[_computeShowEmail(account)]]"></gr-account-label>
       </a>
     </span>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
index 058b27d..0c2ad0b 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link.js
@@ -30,5 +30,9 @@
       var accountID = account.email || account._account_id;
       return '/q/owner:' + encodeURIComponent(accountID) + '+status:open';
     },
+
+    _computeShowEmail: function(account) {
+      return !!(account && !account.name);
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
index 2b5b831..1a84d15 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-link/gr-account-link_test.html
@@ -48,6 +48,10 @@
 
       assert.equal(element._computeOwnerLink({_account_id: 42}),
           '/q/owner:42+status:open');
+
+      assert.equal(element._computeShowEmail({name: 'asd'}), false);
+
+      assert.equal(element._computeShowEmail({}), true);
     });
 
   });