Merge "bazel: add rules for vulcanize & crisper, and use them in //polygerrit-ui/app."
diff --git a/.buckversion b/.buckversion
index efb68ecf..af38772 100644
--- a/.buckversion
+++ b/.buckversion
@@ -1 +1 @@
-fd3105a0b62899f74662f4cdc156de6990bdc24c
+7b7817c48f30687781040b2b82ac9218d5c4eaa4
diff --git a/.gitignore b/.gitignore
index 815c5fa..c89cfb8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,7 @@
 /.settings/org.maven.ide.eclipse.prefs
 /.settings/org.eclipse.m2e.core.prefs
 /.settings/org.eclipse.ltk.core.refactoring.prefs
+/.metadata
 /test_site
 /.idea
 *.iml
diff --git a/BUILD b/BUILD
index 76e2177..4fa30f2 100644
--- a/BUILD
+++ b/BUILD
@@ -8,6 +8,14 @@
   visibility = ['//visibility:public'],
 )
 
+genrule(
+  name = "LICENSES",
+  srcs = ["//Documentation:licenses.txt"],
+  cmd = "cp $< $@",
+  outs = ["LICENSES.txt"],
+  visibility = ['//visibility:public'],
+)
+
 pkg_war(name = 'gerrit')
 pkg_war(name = 'headless', ui = None)
-#pkg_war(name = 'release', ui = 'ui_optdbg_r', context = ['//plugins:core'])
+pkg_war(name = 'release', ui = 'ui_optdbg_r', context = ['//plugins:core'])
diff --git a/Documentation/BUILD b/Documentation/BUILD
index c2acc9c..0542b5d 100644
--- a/Documentation/BUILD
+++ b/Documentation/BUILD
@@ -1,6 +1,46 @@
-
+load("//tools/bzl:asciidoc.bzl", "documentation_attributes")
+load("//tools/bzl:asciidoc.bzl", "genasciidoc")
+load("//tools/bzl:asciidoc.bzl", "genasciidoc_zip")
 load("//tools/bzl:license.bzl", "license_map")
 
+exports_files([
+  "replace_macros.py",
+])
+
+filegroup(
+  name = "prettify_files",
+  srcs = [
+    ":prettify.min.css",
+    ":prettify.min.js",
+  ],
+)
+
+genrule(
+  name = "prettify_min_css",
+  srcs = ["//gerrit-prettify:src/main/resources/com/google/gerrit/prettify/client/prettify.css"],
+  cmd = "cp $< $@",
+  outs = ["prettify.min.css"],
+)
+
+genrule(
+  name = "prettify_min_js",
+  srcs = ["//gerrit-prettify:src/main/resources/com/google/gerrit/prettify/client/prettify.js"],
+  cmd = "cp $< $@",
+  outs = ["prettify.min.js"],
+)
+
+filegroup(
+  name = "resources",
+  srcs = glob([
+    "images/*.jpg",
+    "images/*.png",
+  ]) + [
+    ":prettify_files",
+    "//:LICENSES.txt",
+  ],
+  visibility = ['//visibility:public'],
+)
+
 license_map(
   name = "licenses",
   targets = [
@@ -8,10 +48,11 @@
     "//gerrit-gwtui:ui_module",
   ],
   opts = ["--asciidoctor"],
+  visibility = ['//visibility:public'],
 )
 
 DOC_DIR = "Documentation"
-SRCS = glob(["*.txt"])
+SRCS = glob(["*.txt"]) + [":licenses.txt"]
 
 genrule(
   name = "index",
@@ -20,12 +61,38 @@
       '--prefix "%s/" ' % DOC_DIR +
       '--in-ext ".txt" ' +
       '--out-ext ".html" ' +
-      "$(SRCS) " +
-      "$(location :licenses.txt)",
-  tools = [
-    ":licenses.txt",
-    "//lib/asciidoctor:doc_indexer",
-  ],
+      "$(SRCS)",
+  tools = ["//lib/asciidoctor:doc_indexer"],
   srcs = SRCS,
   outs = ["index.jar"],
 )
+
+# For the same srcs, we can have multiple genasciidoc_zip rules, but only one
+# genasciidoc rule. Because multiple genasciidoc rules will have conflicting
+# output files.
+genasciidoc(
+  name = "Documentation",
+  srcs = SRCS,
+  attributes = documentation_attributes(),
+  backend = "html5",
+  visibility = ["//visibility:public"],
+)
+
+genasciidoc_zip(
+  name = "html",
+  srcs = SRCS,
+  attributes = documentation_attributes(),
+  backend = "html5",
+  directory = DOC_DIR,
+  visibility = ["//visibility:public"],
+)
+
+genasciidoc_zip(
+  name = "searchfree",
+  srcs = SRCS,
+  attributes = documentation_attributes(),
+  backend = "html5",
+  directory = DOC_DIR,
+  searchbox = False,
+  visibility = ["//visibility:public"],
+)
diff --git a/Documentation/dev-bazel.txt b/Documentation/dev-bazel.txt
index 15409c6..f063b88 100644
--- a/Documentation/dev-bazel.txt
+++ b/Documentation/dev-bazel.txt
@@ -83,6 +83,36 @@
 
 === Plugins
 
+----
+  bazel build plugins:core
+----
+
+The output JAR files for individual plugins will be placed in:
+
+----
+  bazel-bin/plugins/<name>/<name>_deploy.jar
+----
+
+The JAR files will also be packaged in:
+
+----
+  bazel-genfiles/plugins/core.zip
+----
+
+To build a specific plugin:
+
+----
+  bazel build plugins/<name>:<name>_deploy.jar
+----
+
+The output JAR file will be be placed in:
+
+----
+  bazel-bin/plugins/<name>/<name>_deploy.jar
+----
+
+Note that when building an individual plugin, the `core.zip` package
+is not regenerated.
 
 
 [[documentation]]
@@ -93,6 +123,9 @@
 [[release]]
 === Gerrit Release WAR File
 
+----
+  bazel build release
+----
 
 [[tests]]
 == Running Unit Tests
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index f3938b3..ae02475 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -303,7 +303,7 @@
 
 [[submittable]]
 --
-* `SUBMITTABLE`: include the `submittable` field in link:#commit-info[CommitInfo],
+* `SUBMITTABLE`: include the `submittable` field in link:#change-info[ChangeInfo],
   which can be used to tell if the change is reviewed and ready for submit.
 --
 
@@ -3430,7 +3430,8 @@
 (This assumes no non-fastforward pushes).
 
 You need to give a parameter '?format=zip' or '?format=tar' to specify the
-format for the outer container.
+format for the outer container. It is always possible to use tgz, even if
+tgz is not in the list of allowed archive formats.
 
 To make good use of this call, you would roughly need code as found at:
 ----
@@ -5578,7 +5579,9 @@
 Allowed values are `DELETE`, `PUBLISH`, `PUBLISH_ALL_REVISIONS` and
 `KEEP`. All values except `PUBLISH_ALL_REVISIONS` operate only on drafts
 for a single revision. +
-If not set, the default is `DELETE`.
+Only `KEEP` is allowed when used in conjunction with `on_behalf_of`. +
+If not set, the default is `DELETE`, unless `on_behalf_of` is set, in
+which case the default is `KEEP` and any other value is disallowed.
 |`notify`                 |optional|
 Notify handling that defines to whom email notifications should be sent
 after the review is stored. +
@@ -5709,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/ReleaseNotes/BUILD b/ReleaseNotes/BUILD
new file mode 100644
index 0000000..9bf572e
--- /dev/null
+++ b/ReleaseNotes/BUILD
@@ -0,0 +1,27 @@
+load("//tools/bzl:asciidoc.bzl", "release_notes_attributes")
+load("//tools/bzl:asciidoc.bzl", "genasciidoc")
+load("//tools/bzl:asciidoc.bzl", "genasciidoc_zip")
+
+
+SRCS = glob(['*.txt'])
+
+
+genasciidoc(
+  name = 'ReleaseNotes',
+  srcs = SRCS,
+  attributes = release_notes_attributes(),
+  backend = 'html5',
+  searchbox = False,
+  resources = False,
+  visibility = ["//visibility:public"],
+)
+
+genasciidoc_zip(
+  name = "html",
+  srcs = SRCS,
+  attributes = release_notes_attributes(),
+  backend = 'html5',
+  searchbox = False,
+  resources = False,
+  visibility = ["//visibility:public"],
+)
diff --git a/contrib/git-push-review b/contrib/git-push-review
index aeea552..87eaa4c 100755
--- a/contrib/git-push-review
+++ b/contrib/git-push-review
@@ -72,7 +72,8 @@
   opts = collections.defaultdict(list)
   is_hashtag = lambda x: x.startswith('#')
   opts['r'].extend(
-      get_config('reviewer.' + r) for r in args.args if not is_hashtag(r))
+      (get_config('reviewer.' + r) or r)
+      for r in args.args if not is_hashtag(r))
   opts['t'].extend(t[1:] for t in args.args if is_hashtag(t))
   if args.topic:
     opts['topic'].append(args.topic)
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/AccountCreator.java b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
index bce0b5a..114ef6a 100644
--- a/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
+++ b/gerrit-acceptance-framework/src/test/java/com/google/gerrit/acceptance/AccountCreator.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.acceptance;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static java.nio.charset.StandardCharsets.US_ASCII;
 
@@ -104,6 +105,7 @@
         for (String n : groups) {
           AccountGroup.NameKey k = new AccountGroup.NameKey(n);
           AccountGroup g = groupCache.get(k);
+          checkArgument(g != null, "group not found: %s", n);
           AccountGroupMember m =
               new AccountGroupMember(new AccountGroupMember.Key(id, g.getId()));
           db.accountGroupMembers().insert(Collections.singleton(m));
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/change/ChangeIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
index 2475efa..3dd1ff3 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/change/ChangeIT.java
@@ -415,37 +415,6 @@
   }
 
   @Test
-  public void voteOnBehalfOf() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    LabelType codeReviewType = Util.codeReview();
-    String forCodeReviewAs = Permission.forLabelAs(codeReviewType.getName());
-    String heads = "refs/heads/*";
-    AccountGroup.UUID owner =
-        SystemGroupBackend.getGroup(CHANGE_OWNER).getUUID();
-    Util.allow(cfg, forCodeReviewAs, -1, 1, owner, heads);
-    saveProjectConfig(project, cfg);
-
-    PushOneCommit.Result r = createChange();
-    RevisionApi revision = gApi.changes()
-        .id(r.getChangeId())
-        .current();
-
-    ReviewInput in = ReviewInput.recommend();
-    in.onBehalfOf = user.id.toString();
-    revision.review(in);
-
-    ChangeInfo c = gApi.changes()
-        .id(r.getChangeId())
-        .get();
-
-    LabelInfo codeReview = c.labels.get("Code-Review");
-    assertThat(codeReview.all).hasSize(1);
-    ApprovalInfo approval = codeReview.all.get(0);
-    assertThat(approval._accountId).isEqualTo(user.id.get());
-    assertThat(approval.value).isEqualTo(1);
-  }
-
-  @Test
   public void rebaseUpToDateChange() throws Exception {
     PushOneCommit.Result r = createChange();
     exception.expect(ResourceConflictException.class);
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
index 545be3e..7f5c6bc 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/revision/RevisionIT.java
@@ -21,7 +21,6 @@
 import static com.google.gerrit.acceptance.PushOneCommit.SUBJECT;
 import static com.google.gerrit.reviewdb.client.Patch.COMMIT_MSG;
 import static com.google.gerrit.reviewdb.client.Patch.MERGE_LIST;
-import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.HEAD;
 import static org.junit.Assert.fail;
@@ -31,8 +30,6 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.RestResponse;
-import com.google.gerrit.acceptance.TestAccount;
-import com.google.gerrit.common.data.Permission;
 import com.google.gerrit.extensions.api.changes.ChangeApi;
 import com.google.gerrit.extensions.api.changes.CherryPickInput;
 import com.google.gerrit.extensions.api.changes.DraftApi;
@@ -40,7 +37,6 @@
 import com.google.gerrit.extensions.api.changes.ReviewInput;
 import com.google.gerrit.extensions.api.changes.ReviewInput.CommentInput;
 import com.google.gerrit.extensions.api.changes.RevisionApi;
-import com.google.gerrit.extensions.api.changes.SubmitInput;
 import com.google.gerrit.extensions.api.projects.BranchInput;
 import com.google.gerrit.extensions.client.ChangeStatus;
 import com.google.gerrit.extensions.client.SubmitType;
@@ -51,23 +47,17 @@
 import com.google.gerrit.extensions.common.FileInfo;
 import com.google.gerrit.extensions.common.MergeableInfo;
 import com.google.gerrit.extensions.common.RevisionInfo;
-import com.google.gerrit.extensions.restapi.AuthException;
 import com.google.gerrit.extensions.restapi.BinaryResult;
 import com.google.gerrit.extensions.restapi.ETagView;
 import com.google.gerrit.extensions.restapi.ResourceConflictException;
-import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 import com.google.gerrit.server.change.GetRevisionActions;
 import com.google.gerrit.server.change.RevisionResource;
-import com.google.gerrit.server.git.ProjectConfig;
-import com.google.gerrit.server.group.SystemGroupBackend;
-import com.google.gerrit.server.project.Util;
 import com.google.inject.Inject;
 
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.PersonIdent;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.revwalk.RevCommit;
-import org.junit.Before;
 import org.junit.Test;
 
 import java.io.ByteArrayOutputStream;
@@ -87,13 +77,6 @@
   @Inject
   private GetRevisionActions getRevisionActions;
 
-  private TestAccount admin2;
-
-  @Before
-  public void setUp() throws Exception {
-    admin2 = accounts.admin2();
-  }
-
   @Test
   public void reviewTriplet() throws Exception {
     PushOneCommit.Result r = createChange();
@@ -143,70 +126,6 @@
         .isEqualTo(ChangeStatus.MERGED);
   }
 
-  private void allowSubmitOnBehalfOf() throws Exception {
-    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
-    Util.allow(cfg,
-        Permission.SUBMIT_AS,
-        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID(),
-        "refs/heads/*");
-    saveProjectConfig(project, cfg);
-  }
-
-  @Test
-  public void submitOnBehalfOf() throws Exception {
-    allowSubmitOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-    String changeId = project.get() + "~master~" + r.getChangeId();
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .review(ReviewInput.approve());
-    SubmitInput in = new SubmitInput();
-    in.onBehalfOf = admin2.email;
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .submit(in);
-    assertThat(gApi.changes().id(changeId).get().status)
-        .isEqualTo(ChangeStatus.MERGED);
-  }
-
-  @Test
-  public void submitOnBehalfOfInvalidUser() throws Exception {
-    allowSubmitOnBehalfOf();
-    PushOneCommit.Result r = createChange();
-    String changeId = project.get() + "~master~" + r.getChangeId();
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .review(ReviewInput.approve());
-    SubmitInput in = new SubmitInput();
-    in.onBehalfOf = "doesnotexist";
-    exception.expect(UnprocessableEntityException.class);
-    exception.expectMessage("Account Not Found: doesnotexist");
-    gApi.changes()
-        .id(changeId)
-        .current()
-        .submit(in);
-  }
-
-  @Test
-  public void submitOnBehalfOfNotPermitted() throws Exception {
-    PushOneCommit.Result r = createChange();
-    gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId())
-        .current()
-        .review(ReviewInput.approve());
-    SubmitInput in = new SubmitInput();
-    in.onBehalfOf = admin2.email;
-    exception.expect(AuthException.class);
-    exception.expectMessage("submit on behalf of not permitted");
-    gApi.changes()
-        .id(project.get() + "~master~" + r.getChangeId())
-        .current()
-        .submit(in);
-  }
-
   @Test
   public void deleteDraft() throws Exception {
     PushOneCommit.Result r = createDraft();
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
new file mode 100644
index 0000000..c1f4237
--- /dev/null
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/account/ImpersonationIT.java
@@ -0,0 +1,656 @@
+// 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.rest.account;
+
+import static com.google.common.truth.Truth.assertThat;
+import static com.google.common.truth.TruthJUnit.assume;
+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;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.Iterables;
+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;
+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.RevisionApi;
+import com.google.gerrit.extensions.api.changes.SubmitInput;
+import com.google.gerrit.extensions.api.groups.GroupInput;
+import com.google.gerrit.extensions.client.Side;
+import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.common.ChangeMessageInfo;
+import com.google.gerrit.extensions.common.CommentInfo;
+import com.google.gerrit.extensions.common.GroupInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
+import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.ChangeMessage;
+import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.Patch;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.reviewdb.client.RobotComment;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
+import com.google.gerrit.server.CommentsUtil;
+import com.google.gerrit.server.account.AccountControl;
+import com.google.gerrit.server.git.ProjectConfig;
+import com.google.gerrit.server.group.SystemGroupBackend;
+import com.google.gerrit.server.project.Util;
+import com.google.gerrit.server.query.change.ChangeData;
+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;
+
+public class ImpersonationIT extends AbstractDaemonTest {
+  @Inject
+  private AccountControl.Factory accountControlFactory;
+
+  @Inject
+  private ApprovalsUtil approvalsUtil;
+
+  @Inject
+  private ChangeMessagesUtil cmUtil;
+
+  @Inject
+  private CommentsUtil commentsUtil;
+
+  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");
+    gi.members = ImmutableList.of(user.id.toString());
+    newGroup = gApi.groups().create(gi).get();
+  }
+
+  @After
+  public void tearDown() throws Exception {
+    removeRunAs();
+  }
+
+  @Test
+  public void voteOnBehalfOf() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = ReviewInput.recommend();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+    revision.review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(
+        r.getChange().approvals().values());
+    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getAccountId()).isEqualTo(user.id);
+    assertThat(psa.getValue()).isEqualTo(1);
+    assertThat(psa.getRealAccountId()).isEqualTo(admin.id);
+
+    ChangeData cd = r.getChange();
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(db, cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(user.id);
+    assertThat(m.getRealAuthor()).isEqualTo(admin.id);
+  }
+
+  @Test
+  public void voteOnBehalfOfRequiresLabel() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+
+    exception.expect(AuthException.class);
+    exception.expectMessage(
+        "label required to post review on behalf of \"" + in.onBehalfOf + '"');
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfInvalidLabel() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.strictLabels = true;
+    in.label("Not-A-Label", 5);
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage(
+        "label \"Not-A-Label\" is not a configured label");
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfInvalidLabelIgnoredWithoutStrictLabels()
+      throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.strictLabels = false;
+    in.label("Code-Review", 1);
+    in.label("Not-A-Label", 5);
+
+    revision.review(in);
+
+    assertThat(gApi.changes().id(r.getChangeId()).get().labels)
+        .doesNotContainKey("Not-A-Label");
+  }
+
+  @Test
+  public void voteOnBehalfOfLabelNotPermitted() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType verified = Util.verified();
+    cfg.getLabelSections().put(verified.getName(), verified);
+    saveProjectConfig(project, cfg);
+
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Verified", 1);
+
+    exception.expect(AuthException.class);
+    exception.expectMessage(
+        "not permitted to modify label \"Verified\" on behalf of \""
+            + in.onBehalfOf + '"');
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfWithComment() throws Exception {
+    testVoteOnBehalfOfWithComment();
+  }
+
+  @GerritConfig(name = "notedb.writeJson", value = "true")
+  @Test
+  public void voteOnBehalfOfWithCommentWritingJson() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    testVoteOnBehalfOfWithComment();
+  }
+
+  private void testVoteOnBehalfOfWithComment() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+    CommentInput ci = new CommentInput();
+    ci.path = Patch.COMMIT_MSG;
+    ci.side = Side.REVISION;
+    ci.line = 1;
+    ci.message = "message";
+    in.comments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    PatchSetApproval psa = Iterables.getOnlyElement(
+        r.getChange().approvals().values());
+    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getAccountId()).isEqualTo(user.id);
+    assertThat(psa.getValue()).isEqualTo(1);
+    assertThat(psa.getRealAccountId()).isEqualTo(admin.id);
+
+    ChangeData cd = r.getChange();
+    Comment c = Iterables.getOnlyElement(
+        commentsUtil.publishedByChange(db, cd.notes()));
+    assertThat(c.message).isEqualTo(ci.message);
+    assertThat(c.author.getId()).isEqualTo(user.id);
+    assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id);
+  }
+
+  @GerritConfig(name = "notedb.writeJson", value = "true")
+  @Test
+  public void voteOnBehalfOfWithRobotComment() throws Exception {
+    assume().that(notesMigration.readChanges()).isTrue();
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+    RobotCommentInput ci = new RobotCommentInput();
+    ci.robotId = "my-robot";
+    ci.robotRunId = "abcd1234";
+    ci.path = Patch.COMMIT_MSG;
+    ci.side = Side.REVISION;
+    ci.line = 1;
+    ci.message = "message";
+    in.robotComments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
+    gApi.changes().id(r.getChangeId()).current().review(in);
+
+    ChangeData cd = r.getChange();
+    RobotComment c = Iterables.getOnlyElement(
+        commentsUtil.robotCommentsByChange(cd.notes()));
+    assertThat(c.message).isEqualTo(ci.message);
+    assertThat(c.robotId).isEqualTo(ci.robotId);
+    assertThat(c.robotRunId).isEqualTo(ci.robotRunId);
+    assertThat(c.author.getId()).isEqualTo(user.id);
+    assertThat(c.getRealAuthor().getId()).isEqualTo(admin.id);
+  }
+
+  @Test
+  public void voteOnBehalfOfCannotModifyDrafts() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+
+    setApiUser(user);
+    DraftInput di = new DraftInput();
+    di.path = Patch.COMMIT_MSG;
+    di.side = Side.REVISION;
+    di.line = 1;
+    di.message = "message";
+    gApi.changes().id(r.getChangeId()).current().createDraft(di);
+
+    setApiUser(admin);
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+    in.drafts = DraftHandling.PUBLISH;
+
+    exception.expect(AuthException.class);
+    exception.expectMessage("not allowed to modify other user's drafts");
+    gApi.changes().id(r.getChangeId()).current().review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfMissingUser() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = "doesnotexist";
+    in.label("Code-Review", 1);
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: doesnotexist");
+    revision.review(in);
+  }
+
+  @Test
+  public void voteOnBehalfOfFailsWhenUserCannotSeeDestinationRef()
+      throws Exception {
+    blockRead(newGroup);
+
+    allowCodeReviewOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage(
+        "on_behalf_of account " + user.id + " cannot see destination ref");
+    revision.review(in);
+  }
+
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  @Test
+  public void voteOnBehalfOfInvisibleUserNotAllowed() throws Exception {
+    allowCodeReviewOnBehalfOf();
+    setApiUser(accounts.user2());
+    assertThat(accountControlFactory.get().canSee(user.id)).isFalse();
+
+    PushOneCommit.Result r = createChange();
+    RevisionApi revision = gApi.changes()
+        .id(r.getChangeId())
+        .current();
+
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.label("Code-Review", 1);
+
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: " + in.onBehalfOf);
+    revision.review(in);
+  }
+
+  @Test
+  public void submitOnBehalfOf() throws Exception {
+    allowSubmitOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = admin2.email;
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .submit(in);
+
+    ChangeData cd = r.getChange();
+    assertThat(cd.change().getStatus()).isEqualTo(Change.Status.MERGED);
+    PatchSetApproval submitter = approvalsUtil.getSubmitter(
+        db, cd.notes(), cd.change().currentPatchSetId());
+    assertThat(submitter.getAccountId()).isEqualTo(admin2.id);
+    assertThat(submitter.getRealAccountId()).isEqualTo(admin.id);
+  }
+
+  @Test
+  public void submitOnBehalfOfInvalidUser() throws Exception {
+    allowSubmitOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = "doesnotexist";
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: doesnotexist");
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .submit(in);
+  }
+
+  @Test
+  public void submitOnBehalfOfNotPermitted() throws Exception {
+    PushOneCommit.Result r = createChange();
+    gApi.changes()
+        .id(project.get() + "~master~" + r.getChangeId())
+        .current()
+        .review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = admin2.email;
+    exception.expect(AuthException.class);
+    exception.expectMessage("submit on behalf of not permitted");
+    gApi.changes()
+        .id(project.get() + "~master~" + r.getChangeId())
+        .current()
+        .submit(in);
+  }
+
+  @Test
+  public void submitOnBehalfOfFailsWhenUserCannotSeeDestinationRef()
+      throws Exception {
+    blockRead(newGroup);
+
+    allowSubmitOnBehalfOf();
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = user.email;
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage(
+        "on_behalf_of account " + user.id + " cannot see destination ref");
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .submit(in);
+  }
+
+  @GerritConfig(name = "accounts.visibility", value = "SAME_GROUP")
+  @Test
+  public void submitOnBehalfOfInvisibleUserNotAllowed() throws Exception {
+    allowSubmitOnBehalfOf();
+    setApiUser(accounts.user2());
+    assertThat(accountControlFactory.get().canSee(user.id)).isFalse();
+
+    PushOneCommit.Result r = createChange();
+    String changeId = project.get() + "~master~" + r.getChangeId();
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .review(ReviewInput.approve());
+    SubmitInput in = new SubmitInput();
+    in.onBehalfOf = user.email;
+    exception.expect(UnprocessableEntityException.class);
+    exception.expectMessage("Account Not Found: " + in.onBehalfOf);
+    gApi.changes()
+        .id(changeId)
+        .current()
+        .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();
+  }
+
+  @Test
+  public void runAsWithOnBehalfOf() throws Exception {
+    // - Has the same restrictions as on_behalf_of (e.g. requires labels).
+    // - Takes the effective user from on_behalf_of (user).
+    // - Takes the real user from the real caller, not the intermediate
+    //   X-Gerrit-RunAs user (user2).
+    allowRunAs();
+    allowCodeReviewOnBehalfOf();
+    TestAccount user2 = accounts.user2();
+
+    PushOneCommit.Result r = createChange();
+    ReviewInput in = new ReviewInput();
+    in.onBehalfOf = user.id.toString();
+    in.message = "Message on behalf of";
+
+    String endpoint =
+        "/changes/" + r.getChangeId() + "/revisions/current/review";
+    RestResponse res =
+        adminRestSession.postWithHeader(endpoint, in, runAsHeader(user2.id));
+    res.assertForbidden();
+    assertThat(res.getEntityContent()).isEqualTo(
+        "label required to post review on behalf of \"" + in.onBehalfOf + '"');
+
+    in.label("Code-Review", 1);
+    adminRestSession.postWithHeader(endpoint, in, runAsHeader(user2.id))
+        .assertOK();
+
+    PatchSetApproval psa = Iterables.getOnlyElement(
+        r.getChange().approvals().values());
+    assertThat(psa.getPatchSetId().get()).isEqualTo(1);
+    assertThat(psa.getLabel()).isEqualTo("Code-Review");
+    assertThat(psa.getAccountId()).isEqualTo(user.id);
+    assertThat(psa.getValue()).isEqualTo(1);
+    assertThat(psa.getRealAccountId()).isEqualTo(admin.id); // not user2
+
+    ChangeData cd = r.getChange();
+    ChangeMessage m = Iterables.getLast(cmUtil.byChange(db, cd.notes()));
+    assertThat(m.getMessage()).endsWith(in.message);
+    assertThat(m.getAuthor()).isEqualTo(user.id);
+    assertThat(m.getRealAuthor()).isEqualTo(admin.id); // not user2
+  }
+
+  private void allowCodeReviewOnBehalfOf() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    LabelType codeReviewType = Util.codeReview();
+    String forCodeReviewAs = Permission.forLabelAs(codeReviewType.getName());
+    String heads = "refs/heads/*";
+    AccountGroup.UUID uuid =
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    Util.allow(cfg, forCodeReviewAs, -1, 1, uuid, heads);
+    saveProjectConfig(project, cfg);
+  }
+
+  private void allowSubmitOnBehalfOf() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    String heads = "refs/heads/*";
+    AccountGroup.UUID uuid =
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID();
+    Util.allow(cfg, Permission.SUBMIT_AS, uuid, heads);
+    Util.allow(cfg, Permission.SUBMIT, uuid, heads);
+    LabelType codeReviewType = Util.codeReview();
+    Util.allow(cfg, Permission.forLabel(codeReviewType.getName()),
+        -2, 2, uuid, heads);
+    saveProjectConfig(project, cfg);
+  }
+
+  private void blockRead(GroupInfo group) throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(project).getConfig();
+    Util.block(
+        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-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
index a30674f..dcdce92 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/notedb/ChangeRebuilderIT.java
@@ -18,6 +18,7 @@
 import static com.google.common.truth.TruthJUnit.assume;
 import static com.google.gerrit.reviewdb.client.RefNames.changeMetaRef;
 import static com.google.gerrit.reviewdb.client.RefNames.refsDraftComments;
+import static com.google.gerrit.server.group.SystemGroupBackend.REGISTERED_USERS;
 import static java.nio.charset.StandardCharsets.UTF_8;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 import static org.junit.Assert.fail;
@@ -25,14 +26,18 @@
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableMap;
 import com.google.common.collect.Iterables;
+import com.google.common.collect.Ordering;
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.AcceptanceTestRequestScope;
 import com.google.gerrit.acceptance.PushOneCommit;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.TimeUtil;
+import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.extensions.api.changes.DraftInput;
 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.client.Side;
 import com.google.gerrit.extensions.restapi.ResourceNotFoundException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.reviewdb.client.Account;
@@ -41,6 +46,7 @@
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchLineComment;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDb;
@@ -54,8 +60,10 @@
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
+import com.google.gerrit.server.git.ProjectConfig;
 import com.google.gerrit.server.git.RepoRefCache;
 import com.google.gerrit.server.git.UpdateException;
+import com.google.gerrit.server.group.SystemGroupBackend;
 import com.google.gerrit.server.notedb.ChangeBundle;
 import com.google.gerrit.server.notedb.ChangeBundleReader;
 import com.google.gerrit.server.notedb.ChangeNotes;
@@ -63,6 +71,7 @@
 import com.google.gerrit.server.notedb.NoteDbUpdateManager;
 import com.google.gerrit.server.notedb.TestChangeRebuilderWrapper;
 import com.google.gerrit.server.notedb.rebuild.ChangeRebuilder.NoPatchSetsException;
+import com.google.gerrit.server.project.Util;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.NoteDbChecker;
 import com.google.gerrit.testutil.NoteDbMode;
@@ -72,6 +81,8 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
+import org.apache.http.Header;
+import org.apache.http.message.BasicHeader;
 import org.eclipse.jgit.lib.Config;
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.ObjectInserter;
@@ -85,6 +96,7 @@
 import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 import java.util.concurrent.TimeUnit;
 
@@ -971,6 +983,94 @@
     checker.rebuildAndCheckChanges(id);
   }
 
+  @Test
+  public void rebuildEntitiesCreatedByImpersonation() throws Exception {
+    PushOneCommit.Result r = createChange();
+    Change.Id id = r.getPatchSetId().getParentKey();
+    PatchSet.Id psId = new PatchSet.Id(id, 1);
+    String prefix = "/changes/" + id + "/revisions/current/";
+
+    // For each of the entities that have a real user field, create one entity
+    // without impersonation and one with.
+    CommentInput ci = new CommentInput();
+    ci.path = Patch.COMMIT_MSG;
+    ci.side = Side.REVISION;
+    ci.line = 1;
+    ci.message = "comment without impersonation";
+    ReviewInput ri = new ReviewInput();
+    ri.label("Code-Review", -1);
+    ri.message = "message without impersonation";
+    ri.drafts = DraftHandling.KEEP;
+    ri.comments = ImmutableMap.of(ci.path, ImmutableList.of(ci));
+    userRestSession.post(prefix + "review", ri).assertOK();
+
+    DraftInput di = new DraftInput();
+    di.path = Patch.COMMIT_MSG;
+    di.side = Side.REVISION;
+    di.line = 1;
+    di.message = "draft without impersonation";
+    userRestSession.put(prefix + "drafts", di).assertCreated();
+
+    allowRunAs();
+    try {
+      Header runAs = new BasicHeader("X-Gerrit-RunAs", user.id.toString());
+      ci.message = "comment with impersonation";
+      ri.message = "message with impersonation";
+      ri.label("Code-Review", 1);
+      adminRestSession.postWithHeader(prefix + "review", ri, runAs).assertOK();
+
+      di.message = "draft with impersonation";
+      adminRestSession.putWithHeader(prefix + "drafts", runAs, di)
+          .assertCreated();
+    } finally {
+      removeRunAs();
+    }
+
+    List<ChangeMessage> msgs =
+        Ordering.natural().onResultOf(ChangeMessage::getWrittenOn)
+            .sortedCopy(db.changeMessages().byChange(id));
+    assertThat(msgs).hasSize(3);
+    assertThat(msgs.get(1).getMessage())
+        .endsWith("message without impersonation");
+    assertThat(msgs.get(1).getAuthor()).isEqualTo(user.id);
+    assertThat(msgs.get(1).getRealAuthor()).isEqualTo(user.id);
+    assertThat(msgs.get(2).getMessage()).endsWith("message with impersonation");
+    assertThat(msgs.get(2).getAuthor()).isEqualTo(user.id);
+    assertThat(msgs.get(2).getRealAuthor()).isEqualTo(admin.id);
+
+    List<PatchSetApproval> psas = db.patchSetApprovals().byChange(id).toList();
+    assertThat(psas).hasSize(1);
+    assertThat(psas.get(0).getLabel()).isEqualTo("Code-Review");
+    assertThat(psas.get(0).getValue()).isEqualTo(1);
+    assertThat(psas.get(0).getAccountId()).isEqualTo(user.id);
+    assertThat(psas.get(0).getRealAccountId()).isEqualTo(admin.id);
+
+    Ordering<PatchLineComment> commentOrder =
+        Ordering.natural().onResultOf(PatchLineComment::getWrittenOn);
+    List<PatchLineComment> drafts = commentOrder.sortedCopy(
+        db.patchComments().draftByPatchSetAuthor(psId, user.id));
+    assertThat(drafts).hasSize(2);
+    assertThat(drafts.get(0).getMessage())
+        .isEqualTo("draft without impersonation");
+    assertThat(drafts.get(0).getAuthor()).isEqualTo(user.id);
+    assertThat(drafts.get(0).getRealAuthor()).isEqualTo(user.id);
+    assertThat(drafts.get(1).getMessage())
+        .isEqualTo("draft with impersonation");
+    assertThat(drafts.get(1).getAuthor()).isEqualTo(user.id);
+    assertThat(drafts.get(1).getRealAuthor()).isEqualTo(admin.id);
+
+    List<PatchLineComment> pub = commentOrder.sortedCopy(
+        db.patchComments().publishedByPatchSet(psId));
+    assertThat(pub).hasSize(2);
+    assertThat(pub.get(0).getMessage())
+        .isEqualTo("comment without impersonation");
+    assertThat(pub.get(0).getAuthor()).isEqualTo(user.id);
+    assertThat(pub.get(0).getRealAuthor()).isEqualTo(user.id);
+    assertThat(pub.get(1).getMessage()).isEqualTo("comment with impersonation");
+    assertThat(pub.get(1).getAuthor()).isEqualTo(user.id);
+    assertThat(pub.get(1).getRealAuthor()).isEqualTo(admin.id);
+  }
+
   private void assertChangesReadOnly(RestApiException e) throws Exception {
     Throwable cause = e.getCause();
     assertThat(cause).isInstanceOf(UpdateException.class);
@@ -1086,4 +1186,19 @@
     ReviewDb db = dbProvider.get();
     return ReviewDbUtil.unwrapDb(db);
   }
+
+  private void allowRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.allow(cfg, GlobalCapability.RUN_AS,
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
+  private void removeRunAs() throws Exception {
+    ProjectConfig cfg = projectCache.checkedGet(allProjects).getConfig();
+    Util.remove(cfg, GlobalCapability.RUN_AS,
+        SystemGroupBackend.getGroup(REGISTERED_USERS).getUUID());
+    saveProjectConfig(allProjects, cfg);
+  }
+
 }
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 8f1e283..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
@@ -49,8 +49,11 @@
   /**
    * How to process draft comments already in the database that were not also
    * described in this input request.
+   * <p>
+   * Defaults to DELETE, unless {@link #onBehalfOf} is set, in which case it
+   * defaults to KEEP and any other value is disallowed.
    */
-  public DraftHandling drafts = DraftHandling.DELETE;
+  public DraftHandling drafts;
 
   /** Who to send email notifications to after review is stored. */
   public NotifyHandling notify = NotifyHandling.ALL;
@@ -99,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-gwtexpui/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java
index 6c820a8..a33f605 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/linker/server/UserAgentRule.java
@@ -30,7 +30,7 @@
  * Ported from JavaScript in {@code com.google.gwt.user.UserAgent.gwt.xml}.
  */
 public class UserAgentRule {
-  private static final Pattern msie = compile(".*msie ([0-9]+)\\.([0-9]+).*");
+  private static final Pattern msie = compile(".*msie ([0-11]+)\\.([0-11]+).*");
   private static final Pattern gecko = compile(".*rv:([0-9]+)\\.([0-9]+).*");
 
   public String getName() {
@@ -58,6 +58,9 @@
       Matcher m = msie.matcher(ua);
       if (m.matches() && m.groupCount() == 2) {
         int v = makeVersion(m);
+        if (v >= 11000) {
+          return "ie11";
+        }
         if (v >= 10000) {
           return "ie10";
         }
@@ -70,6 +73,8 @@
       }
       return null;
 
+    } else if (ua.contains("edge")) {
+      return "edge";
     } else if (ua.contains("gecko")) {
       Matcher m = gecko.matcher(ua);
       if (m.matches() && m.groupCount() == 2) {
diff --git a/gerrit-gwtui/gwt.defs b/gerrit-gwtui/gwt.defs
index 3b34d16..cd8fa74 100644
--- a/gerrit-gwtui/gwt.defs
+++ b/gerrit-gwtui/gwt.defs
@@ -18,12 +18,14 @@
   'firefox',
   'gecko1_8',
   'safari',
-  'msie', 'ie8', 'ie9',
+  'msie', 'ie8', 'ie9', 'ie10', 'ie11',
+  'edge',
 ]
 ALIASES = {
   'chrome': 'safari',
   'firefox': 'gecko1_8',
-  'msie': 'ie9',
+  'msie': 'ie11',
+  'edge': 'edge',
 }
 MODULE = 'com.google.gerrit.GerritGwtUI'
 CPU_COUNT = cpu_count()
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/UserAgent.gwt.xml b/gerrit-gwtui/src/main/java/com/google/gerrit/UserAgent.gwt.xml
index c02518b..9644093 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/UserAgent.gwt.xml
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/UserAgent.gwt.xml
@@ -34,6 +34,8 @@
       <when-property-is name="user.agent" value="ie8"/>
       <when-property-is name="user.agent" value="ie9"/>
       <when-property-is name="user.agent" value="ie10"/>
+      <when-property-is name="user.agent" value="ie11"/>
+      <when-property-is name="user.agent" value="edge"/>
     </any>
   </replace-with>
 </module>
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-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.css b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.css
index e72c840..f4f1e83 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.css
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/diff/CommentBox.css
@@ -63,7 +63,8 @@
   -webkit-user-select: initial;
   -khtml-user-select: initial;
   -moz-user-select: text;
-  -ms-user-select: initial;
+  -ms-user-select: text;
+  user-select: initial;
 }
 
 .commentBox {
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-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
index 1d7195b..c3d516b 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/SitePathInitializer.java
@@ -115,6 +115,7 @@
     extractMailExample("FooterHtml.soy");
     extractMailExample("HeaderHtml.soy");
     extractMailExample("Merged.soy");
+    extractMailExample("MergedHtml.soy");
     extractMailExample("NewChange.soy");
     extractMailExample("NewChangeHtml.soy");
     extractMailExample("RegisterNewEmail.soy");
diff --git a/gerrit-prettify/BUILD b/gerrit-prettify/BUILD
index 063feee..b8d4dd6 100644
--- a/gerrit-prettify/BUILD
+++ b/gerrit-prettify/BUILD
@@ -33,3 +33,8 @@
   ],
   visibility = ['//visibility:public'],
 )
+
+exports_files([
+  'src/main/resources/com/google/gerrit/prettify/client/prettify.css',
+  'src/main/resources/com/google/gerrit/prettify/client/prettify.js',
+])
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
index 898dc94..db44d33 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/ChangeMessage.java
@@ -18,6 +18,7 @@
 import com.google.gwtorm.client.StringKey;
 
 import java.sql.Timestamp;
+import java.util.Objects;
 
 /** A message attached to a {@link Change}. */
 public final class ChangeMessage {
@@ -78,6 +79,13 @@
   @Column(id = 6, notNull = false)
   protected String tag;
 
+  /**
+   * Real user that added this message on behalf of the user recorded in {@link
+   * #author}.
+   */
+  @Column(id = 7, notNull = false)
+  protected Account.Id realAuthor;
+
   protected ChangeMessage() {
   }
 
@@ -105,6 +113,15 @@
     author = accountId;
   }
 
+  public Account.Id getRealAuthor() {
+    return realAuthor != null ? realAuthor : getAuthor();
+  }
+
+  public void setRealAuthor(Account.Id id) {
+    // Use null for same real author, as before the column was added.
+    realAuthor = Objects.equals(getAuthor(), id) ? null : id;
+  }
+
   public Timestamp getWrittenOn() {
     return writtenOn;
   }
@@ -142,6 +159,7 @@
     return "ChangeMessage{"
         + "key=" + key
         + ", author=" + author
+        + ", realAuthor=" + realAuthor
         + ", writtenOn=" + writtenOn
         + ", patchset=" + patchset
         + ", tag=" + tag
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
index f37b6bd..5ec3e47 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/Comment.java
@@ -181,6 +181,7 @@
   public Key key;
   public int lineNbr;
   public Identity author;
+  protected Identity realAuthor;
   public Timestamp writtenOn;
   public short side;
   public String message;
@@ -194,6 +195,7 @@
     this(new Key(c.key), c.author.getId(), new Timestamp(c.writtenOn.getTime()),
         c.side, c.message, c.serverId);
     this.lineNbr = c.lineNbr;
+    this.realAuthor = c.realAuthor;
     this.range = c.range != null ? new Range(c.range) : null;
     this.tag = c.tag;
     this.revId = c.revId;
@@ -203,6 +205,7 @@
       short side, String message, String serverId) {
     this.key = key;
     this.author = new Comment.Identity(author);
+    this.realAuthor = this.author;
     this.writtenOn = writtenOn;
     this.side = side;
     this.message = message;
@@ -229,6 +232,16 @@
     this.revId = revId != null ? revId.get() : null;
   }
 
+  public void setRealAuthor(Account.Id id) {
+    realAuthor = id != null && id.get() != author.id
+        ? new Comment.Identity(id)
+        : null;
+  }
+
+  public Identity getRealAuthor() {
+    return realAuthor != null ? realAuthor : author;
+  }
+
   @Override
   public boolean equals(Object o) {
     if (o instanceof Comment) {
@@ -249,6 +262,9 @@
         .append("key=").append(key).append(',')
         .append("lineNbr=").append(lineNbr).append(',')
         .append("author=").append(author.getId().get()).append(',')
+        .append("realAuthor=")
+            .append(realAuthor != null ? realAuthor.getId().get() : "")
+            .append(',')
         .append("writtenOn=").append(writtenOn.toString()).append(',')
         .append("side=").append(side).append(',')
         .append("message=").append(Objects.toString(message, "")).append(',')
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
index 5a3cc16..5d2f3bb 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchLineComment.java
@@ -123,6 +123,7 @@
     plc.setTag(c.tag);
     plc.setRevId(new RevId(c.revId));
     plc.setStatus(status);
+    plc.setRealAuthor(c.getRealAuthor().getId());
     return plc;
   }
 
@@ -167,6 +168,13 @@
   protected String tag;
 
   /**
+   * Real user that added this comment on behalf of the user recorded in {@link
+   * #author}.
+   */
+  @Column(id = 11, notNull = false)
+  protected Account.Id realAuthor;
+
+  /**
    * The RevId for the commit to which this comment is referring.
    *
    * Note that this field is not stored in the database. It is just provided
@@ -192,6 +200,7 @@
     key = o.key;
     lineNbr = o.lineNbr;
     author = o.author;
+    realAuthor = o.realAuthor;
     writtenOn = o.writtenOn;
     status = o.status;
     side = o.side;
@@ -227,6 +236,15 @@
     return author;
   }
 
+  public Account.Id getRealAuthor() {
+    return realAuthor != null ? realAuthor : getAuthor();
+  }
+
+  public void setRealAuthor(Account.Id id) {
+    // Use null for same real author, as before the column was added.
+    realAuthor = Objects.equals(getAuthor(), id) ? null : id;
+  }
+
   public Timestamp getWrittenOn() {
     return writtenOn;
   }
@@ -309,6 +327,7 @@
     c.lineNbr = lineNbr;
     c.parentUuid = parentUuid;
     c.tag = tag;
+    c.setRealAuthor(getRealAuthor());
     return c;
   }
 
@@ -343,6 +362,8 @@
     builder.append("key=").append(key).append(',');
     builder.append("lineNbr=").append(lineNbr).append(',');
     builder.append("author=").append(author.get()).append(',');
+    builder.append("realAuthor=")
+        .append(realAuthor != null ? realAuthor.get() : "").append(',');
     builder.append("writtenOn=").append(writtenOn.toString()).append(',');
     builder.append("status=").append(status).append(',');
     builder.append("side=").append(side).append(',');
diff --git a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
index 1d0d29b..9cc7816 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSetApproval.java
@@ -93,6 +93,13 @@
   @Column(id = 6, notNull = false)
   protected String tag;
 
+  /**
+   * Real user that made this approval on behalf of the user recorded in {@link
+   * Key#accountId}.
+   */
+  @Column(id = 7, notNull = false)
+  protected Account.Id realAccountId;
+
   // DELETED: id = 4 (changeOpen)
   // DELETED: id = 5 (changeSortKey)
 
@@ -110,6 +117,7 @@
         new PatchSetApproval.Key(psId, src.getAccountId(), src.getLabelId());
     value = src.getValue();
     granted = src.granted;
+    realAccountId = src.realAccountId;
   }
 
   public PatchSetApproval.Key getKey() {
@@ -124,6 +132,15 @@
     return key.accountId;
   }
 
+  public Account.Id getRealAccountId() {
+    return realAccountId != null ? realAccountId : getAccountId();
+  }
+
+  public void setRealAccountId(Account.Id id) {
+    // Use null for same real author, as before the column was added.
+    realAccountId = Objects.equals(getAccountId(), id) ? null : id;
+  }
+
   public LabelId getLabelId() {
     return key.categoryId;
   }
@@ -166,8 +183,12 @@
 
   @Override
   public String toString() {
-    return new StringBuilder().append('[').append(key).append(": ")
-        .append(value).append(",tag:").append(tag).append(']').toString();
+    return "["
+        + key + ": "
+        + value
+        + ",tag:" + tag
+        + ",realAccountId:" + realAccountId
+        + ']';
   }
 
   @Override
@@ -177,7 +198,8 @@
       return Objects.equals(key, p.key)
           && Objects.equals(value, p.value)
           && Objects.equals(granted, p.granted)
-          && Objects.equals(tag, p.tag);
+          && Objects.equals(tag, p.tag)
+          && Objects.equals(realAccountId, p.realAccountId);
     }
     return false;
   }
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..ecb952a 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,
@@ -39,6 +41,9 @@
         .append("robotRunId=").append(robotRunId).append(',')
         .append("lineNbr=").append(lineNbr).append(',')
         .append("author=").append(author.getId().get()).append(',')
+        .append("realAuthor=")
+            .append(realAuthor != null ? realAuthor.getId().get() : "")
+            .append(',')
         .append("writtenOn=").append(writtenOn.toString()).append(',')
         .append("side=").append(side).append(',')
         .append("message=").append(Objects.toString(message, "")).append(',')
@@ -47,7 +52,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/ApprovalCopier.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
index d69ad3f..1fcb5b6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalCopier.java
@@ -161,7 +161,8 @@
             continue;
           }
 
-          ChangeKind kind = changeKindCache.getChangeKind(project, repo,
+          ChangeKind kind = changeKindCache.getChangeKind(
+              project.getProject().getNameKey(), repo,
               ObjectId.fromString(priorPs.getRevision().get()),
               ObjectId.fromString(ps.getRevision().get()));
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
index 43f20cc..67f07bc 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ApprovalsUtil.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server;
 
+import static com.google.common.base.Preconditions.checkArgument;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.CC;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
 import static java.util.Comparator.comparing;
@@ -26,6 +27,7 @@
 import com.google.common.collect.Lists;
 import com.google.common.collect.Ordering;
 import com.google.common.collect.Sets;
+import com.google.common.primitives.Shorts;
 import com.google.gerrit.common.data.LabelType;
 import com.google.gerrit.common.data.LabelTypes;
 import com.google.gerrit.common.data.Permission;
@@ -86,6 +88,19 @@
     return SORT_APPROVALS.sortedCopy(approvals);
   }
 
+  public static PatchSetApproval newApproval(PatchSet.Id psId, CurrentUser user,
+      LabelId labelId, int value, Date when) {
+    PatchSetApproval psa = new PatchSetApproval(
+        new PatchSetApproval.Key(
+            psId,
+            user.getAccountId(),
+            labelId),
+        Shorts.checkedCast(value),
+        when);
+    user.updateRealAccountId(psa::setRealAccountId);
+    return psa;
+  }
+
   private static Iterable<PatchSetApproval> filterApprovals(
       Iterable<PatchSetApproval> psas, final Account.Id accountId) {
     return Iterables.filter(
@@ -266,7 +281,7 @@
   }
 
   /**
-   * Adds approvals to ChangeUpdate and writes to ReviewDb.
+   * Adds approvals to ChangeUpdate for a new patch set, and writes to ReviewDb.
    *
    * @param db review database.
    * @param update change update.
@@ -276,9 +291,14 @@
    * @param approvals approvals to add.
    * @throws OrmException
    */
-  public Iterable<PatchSetApproval> addApprovals(ReviewDb db, ChangeUpdate update,
-      LabelTypes labelTypes, PatchSet ps, ChangeControl changeCtl,
-      Map<String, Short> approvals) throws OrmException {
+  public Iterable<PatchSetApproval> addApprovalsForNewPatchSet(ReviewDb db,
+      ChangeUpdate update, LabelTypes labelTypes, PatchSet ps,
+      ChangeControl changeCtl, Map<String, Short> approvals)
+      throws OrmException {
+    Account.Id accountId = changeCtl.getUser().getAccountId();
+    checkArgument(accountId.equals(ps.getUploader()),
+        "expected user %s to match patch set uploader %s",
+        accountId, ps.getUploader());
     if (approvals.isEmpty()) {
       return Collections.emptyList();
     }
@@ -287,12 +307,9 @@
     Date ts = update.getWhen();
     for (Map.Entry<String, Short> vote : approvals.entrySet()) {
       LabelType lt = labelTypes.byLabel(vote.getKey());
-      cells.add(new PatchSetApproval(new PatchSetApproval.Key(
-          ps.getId(),
-          ps.getUploader(),
-          lt.getLabelId()),
-          vote.getValue(),
-          ts));
+      cells.add(
+          newApproval(ps.getId(), changeCtl.getUser(), lt.getLabelId(),
+              vote.getValue(), ts));
     }
     for (PatchSetApproval psa : cells) {
       update.putApproval(psa.getLabel(), psa.getValue());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
index f3fdbcb..13b289f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ChangeMessagesUtil.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server;
 
+import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.common.base.Preconditions.checkState;
 
 import com.google.common.annotations.VisibleForTesting;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
+import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
 import com.google.gerrit.server.notedb.NotesMigration;
@@ -27,6 +30,7 @@
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import java.sql.Timestamp;
 import java.util.Collections;
 import java.util.List;
 import java.util.Objects;
@@ -39,6 +43,26 @@
  */
 @Singleton
 public class ChangeMessagesUtil {
+  public static ChangeMessage newMessage(BatchUpdate.ChangeContext ctx,
+      String body) throws OrmException {
+    return newMessage(
+        ctx.getDb(), ctx.getChange().currentPatchSetId(),
+        ctx.getUser(), ctx.getWhen(), body);
+  }
+
+  public static ChangeMessage newMessage(
+      ReviewDb db, PatchSet.Id psId, CurrentUser user, Timestamp when,
+      String body) throws OrmException {
+    checkNotNull(psId);
+    Account.Id accountId = user.isInternalUser() ? null : user.getAccountId();
+    ChangeMessage m = new ChangeMessage(
+        new ChangeMessage.Key(psId.getParentKey(), ChangeUtil.messageUUID(db)),
+        accountId, when, psId);
+    m.setMessage(body);
+    user.updateRealAccountId(m::setRealAuthor);
+    return m;
+  }
+
   private static List<ChangeMessage> sortChangeMessages(
       Iterable<ChangeMessage> changeMessage) {
     return ChangeNotes.MESSAGE_BY_TIME.sortedCopy(changeMessage);
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 8197689..81ec4eb 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
@@ -39,6 +39,7 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.config.AllUsersName;
 import com.google.gerrit.server.config.GerritServerId;
+import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.notedb.ChangeUpdate;
@@ -129,6 +130,26 @@
     this.serverId = serverId;
   }
 
+  public Comment newComment(ChangeContext ctx, String path, PatchSet.Id psId,
+      short side, String message) throws OrmException {
+    Comment c = new Comment(
+        new Comment.Key(ChangeUtil.messageUUID(ctx.getDb()), path, psId.get()),
+        ctx.getUser().getAccountId(), ctx.getWhen(), side, message, serverId);
+    ctx.getUser().updateRealAccountId(c::setRealAuthor);
+    return c;
+  }
+
+  public RobotComment newRobotComment(ChangeContext ctx, String path,
+      PatchSet.Id psId, short side, String message, String robotId,
+      String robotRunId) throws OrmException {
+    RobotComment c = new RobotComment(
+        new Comment.Key(ChangeUtil.messageUUID(ctx.getDb()), path, psId.get()),
+        ctx.getUser().getAccountId(), ctx.getWhen(), side, message, serverId,
+        robotId, robotRunId);
+    ctx.getUser().updateRealAccountId(c::setRealAuthor);
+    return c;
+  }
+
   public Optional<Comment> get(ReviewDb db, ChangeNotes notes,
       Comment.Key key) throws OrmException {
     if (!migration.readChanges()) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
index 34a2d02..668b344 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/CurrentUser.java
@@ -20,6 +20,8 @@
 import com.google.gerrit.server.account.GroupMembership;
 import com.google.inject.servlet.RequestScoped;
 
+import java.util.function.Consumer;
+
 /**
  * Information about the currently logged in user.
  * <p>
@@ -72,6 +74,16 @@
   }
 
   /**
+   * If the {@link #getRealUser()} has an account ID associated with it, call
+   * the given setter with that ID.
+   */
+  public void updateRealAccountId(Consumer<Account.Id> setter) {
+    if (getRealUser().isIdentifiedUser()) {
+      setter.accept(getRealUser().getAccountId());
+    }
+  }
+
+  /**
    * Get the set of groups the user is currently a member of.
    * <p>
    * The returned set may be a subset of the user's actual groups; if the user's
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
index 04ebc87..c7ce1b7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AccountsCollection.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.server.account;
 
+import com.google.gerrit.common.Nullable;
 import com.google.gerrit.extensions.registration.DynamicMap;
 import com.google.gerrit.extensions.restapi.AcceptsCreate;
 import com.google.gerrit.extensions.restapi.AuthException;
@@ -90,15 +91,7 @@
    */
   public IdentifiedUser parse(String id) throws AuthException,
       UnprocessableEntityException, OrmException {
-    IdentifiedUser user = parseId(id);
-    if (user == null) {
-      throw new UnprocessableEntityException(String.format(
-          "Account Not Found: %s", id));
-    } else if (!accountControlFactory.get().canSee(user.getAccount())) {
-      throw new UnprocessableEntityException(String.format(
-          "Account Not Found: %s", id));
-    }
-    return user;
+    return parseOnBehalfOf(null, id);
   }
 
   /**
@@ -115,6 +108,29 @@
    * @throws OrmException
    */
   public IdentifiedUser parseId(String id) throws AuthException, OrmException {
+    return parseIdOnBehalfOf(null, id);
+  }
+
+  /**
+   * Like {@link #parse(String)}, but also sets the {@link
+   * CurrentUser#getRealUser()} on the result.
+   */
+  public IdentifiedUser parseOnBehalfOf(@Nullable CurrentUser caller,
+      String id)
+      throws AuthException, UnprocessableEntityException, OrmException {
+    IdentifiedUser user = parseIdOnBehalfOf(caller, id);
+    if (user == null) {
+      throw new UnprocessableEntityException(String.format(
+          "Account Not Found: %s", id));
+    } else if (!accountControlFactory.get().canSee(user.getAccount())) {
+      throw new UnprocessableEntityException(String.format(
+          "Account Not Found: %s", id));
+    }
+    return user;
+  }
+
+  private IdentifiedUser parseIdOnBehalfOf(@Nullable CurrentUser caller,
+      String id) throws AuthException, OrmException {
     if (id.equals("self")) {
       CurrentUser user = self.get();
       if (user.isIdentifiedUser()) {
@@ -130,7 +146,8 @@
     if (match == null) {
       return null;
     }
-    return userFactory.create(match.getId());
+    CurrentUser realUser = caller != null ? caller.getRealUser() : null;
+    return userFactory.runAs(null, match.getId(), realUser);
   }
 
   @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
index c4bd68d..45f0afe 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Abandon.java
@@ -31,7 +31,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.ChangeAbandoned;
@@ -106,7 +105,7 @@
 
   public Change abandon(ChangeControl control, String msgTxt,
       NotifyHandling notifyHandling) throws RestApiException, UpdateException {
-    Op op = new Op(control.getUser(), msgTxt, notifyHandling);
+    Op op = new Op(msgTxt, notifyHandling);
     try (BatchUpdate u =
         batchUpdateFactory.create(
             dbProvider.get(),
@@ -142,8 +141,7 @@
                   control.getProject().getNameKey().get(),
                   project.get()));
         }
-        u.addOp(
-            control.getId(), new Op(control.getUser(), msgTxt, notifyHandling));
+        u.addOp(control.getId(), new Op(msgTxt, notifyHandling));
       }
       u.execute();
     }
@@ -164,18 +162,14 @@
   private class Op extends BatchUpdate.Op {
     private final String msgTxt;
     private final NotifyHandling notifyHandling;
-    private final Account account;
 
     private Change change;
     private PatchSet patchSet;
     private ChangeMessage message;
 
-    private Op(CurrentUser user, String msgTxt, NotifyHandling notifyHandling) {
+    private Op(String msgTxt, NotifyHandling notifyHandling) {
       this.msgTxt = msgTxt;
       this.notifyHandling = notifyHandling;
-      account = user.isIdentifiedUser()
-          ? user.asIdentifiedUser().getAccount()
-          : null;
     }
 
     @Override
@@ -208,19 +202,14 @@
         msg.append(msgTxt.trim());
       }
 
-      ChangeMessage message = new ChangeMessage(
-          new ChangeMessage.Key(
-              change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          account != null ? account.getId() : null,
-          ctx.getWhen(),
-          change.currentPatchSetId());
-      message.setMessage(msg.toString());
-      return message;
+      return ChangeMessagesUtil.newMessage(ctx, msg.toString());
     }
 
     @Override
     public void postUpdate(Context ctx) throws OrmException {
+      Account account = ctx.getUser().isIdentifiedUser()
+          ? ctx.getUser().asIdentifiedUser().getAccount()
+          : null;
       try {
         ReplyToChangeSender cm =
             abandonedSenderFactory.create(ctx.getProject(), change.getId());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
index 6906fb2..188d4a4d6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeInserter.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -361,14 +360,12 @@
         patchSetInfo,
         filterOnChangeVisibility(db, ctx.getNotes(), reviewers),
         Collections.<Account.Id> emptySet());
-    approvalsUtil.addApprovals(db, update, labelTypes, patchSet,
-        ctx.getControl(), approvals);
+    approvalsUtil.addApprovalsForNewPatchSet(
+        db, update, labelTypes, patchSet, ctx.getControl(), approvals);
     if (message != null) {
-      changeMessage =
-          new ChangeMessage(new ChangeMessage.Key(change.getId(),
-              ChangeUtil.messageUUID(db)), ctx.getAccountId(),
-              patchSet.getCreatedOn(), patchSet.getId());
-      changeMessage.setMessage(message);
+      changeMessage = ChangeMessagesUtil.newMessage(
+          db, patchSet.getId(), ctx.getUser(), patchSet.getCreatedOn(),
+          message);
       cmUtil.addChangeMessage(db, update, changeMessage);
     }
     return true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
index f0075ee..e971eff 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCache.java
@@ -18,8 +18,8 @@
 import com.google.gerrit.extensions.client.ChangeKind;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.PatchSet;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 
 import org.eclipse.jgit.lib.ObjectId;
@@ -32,7 +32,7 @@
  * implementation changes, which might invalidate old entries).
  */
 public interface ChangeKindCache {
-  ChangeKind getChangeKind(ProjectState project, @Nullable Repository repo,
+  ChangeKind getChangeKind(Project.NameKey project, @Nullable Repository repo,
       ObjectId prior, ObjectId next);
 
   ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
index b23bcf8..c0c0492 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ChangeKindCacheImpl.java
@@ -33,8 +33,6 @@
 import com.google.gerrit.server.git.GitRepositoryManager;
 import com.google.gerrit.server.git.InMemoryInserter;
 import com.google.gerrit.server.git.MergeUtil;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gerrit.server.query.change.ChangeData;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -85,7 +83,6 @@
   public static class NoCache implements ChangeKindCache {
     private final boolean useRecursiveMerge;
     private final ChangeData.Factory changeDataFactory;
-    private final ProjectCache projectCache;
     private final GitRepositoryManager repoManager;
 
 
@@ -93,25 +90,21 @@
     NoCache(
         @GerritServerConfig Config serverConfig,
         ChangeData.Factory changeDataFactory,
-        ProjectCache projectCache,
         GitRepositoryManager repoManager) {
       this.useRecursiveMerge = MergeUtil.useRecursiveMerge(serverConfig);
       this.changeDataFactory = changeDataFactory;
-      this.projectCache = projectCache;
       this.repoManager = repoManager;
     }
 
     @Override
-    public ChangeKind getChangeKind(ProjectState project,
+    public ChangeKind getChangeKind(Project.NameKey project,
         @Nullable Repository repo, ObjectId prior, ObjectId next) {
       try {
         Key key = new Key(prior, next, useRecursiveMerge);
-        return new Loader(
-                key, repoManager, project.getProject().getNameKey(), repo)
-            .call();
+        return new Loader(key, repoManager, project, repo).call();
       } catch (IOException e) {
         log.warn("Cannot check trivial rebase of new patch set " + next.name()
-            + " in " + project.getProject().getName(), e);
+            + " in " + project, e);
         return ChangeKind.REWORK;
       }
     }
@@ -120,13 +113,13 @@
     public ChangeKind getChangeKind(ReviewDb db, Change change,
         PatchSet patch) {
       return getChangeKindInternal(this, db, change, patch, changeDataFactory,
-          projectCache, repoManager);
+          repoManager);
     }
 
     @Override
     public ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd,
         PatchSet patch) {
-      return getChangeKindInternal(this, repo, cd, patch, projectCache);
+      return getChangeKindInternal(this, repo, cd, patch);
     }
   }
 
@@ -322,7 +315,6 @@
   private final Cache<Key, ChangeKind> cache;
   private final boolean useRecursiveMerge;
   private final ChangeData.Factory changeDataFactory;
-  private final ProjectCache projectCache;
   private final GitRepositoryManager repoManager;
 
   @Inject
@@ -330,27 +322,22 @@
       @GerritServerConfig Config serverConfig,
       @Named(ID_CACHE) Cache<Key, ChangeKind> cache,
       ChangeData.Factory changeDataFactory,
-      ProjectCache projectCache,
       GitRepositoryManager repoManager) {
     this.cache = cache;
     this.useRecursiveMerge = MergeUtil.useRecursiveMerge(serverConfig);
     this.changeDataFactory = changeDataFactory;
-    this.projectCache = projectCache;
     this.repoManager = repoManager;
   }
 
   @Override
-  public ChangeKind getChangeKind(ProjectState project,
+  public ChangeKind getChangeKind(Project.NameKey project,
       @Nullable Repository repo, ObjectId prior, ObjectId next) {
     try {
       Key key = new Key(prior, next, useRecursiveMerge);
-      return cache.get(
-          key,
-          new Loader(
-                key, repoManager, project.getProject().getNameKey(), repo));
+      return cache.get(key, new Loader(key, repoManager, project, repo));
     } catch (ExecutionException e) {
       log.warn("Cannot check trivial rebase of new patch set " + next.name()
-          + " in " + project.getProject().getName(), e);
+          + " in " + project, e);
       return ChangeKind.REWORK;
     }
   }
@@ -358,27 +345,25 @@
   @Override
   public ChangeKind getChangeKind(ReviewDb db, Change change, PatchSet patch) {
     return getChangeKindInternal(this, db, change, patch, changeDataFactory,
-        projectCache, repoManager);
+        repoManager);
   }
 
   @Override
   public ChangeKind getChangeKind(@Nullable Repository repo, ChangeData cd,
       PatchSet patch) {
-    return getChangeKindInternal(this, repo, cd, patch, projectCache);
+    return getChangeKindInternal(this, repo, cd, patch);
   }
 
   private static ChangeKind getChangeKindInternal(
       ChangeKindCache cache,
       @Nullable Repository repo,
       ChangeData change,
-      PatchSet patch,
-      ProjectCache projectCache) {
+      PatchSet patch) {
     ChangeKind kind = ChangeKind.REWORK;
     // Trivial case: if we're on the first patch, we don't need to use
     // the repository.
     if (patch.getId().get() > 1) {
       try {
-        ProjectState projectState = projectCache.checkedGet(change.project());
         Collection<PatchSet> patchSetCollection = change.patchSets();
         PatchSet priorPs = patch;
         for (PatchSet ps : patchSetCollection) {
@@ -396,11 +381,11 @@
         // and deletes the draft.
         if (priorPs != patch) {
           kind =
-              cache.getChangeKind(projectState, repo,
+              cache.getChangeKind(change.project(), repo,
                   ObjectId.fromString(priorPs.getRevision().get()),
                   ObjectId.fromString(patch.getRevision().get()));
         }
-      } catch (IOException | OrmException e) {
+      } catch (OrmException e) {
         // Do nothing; assume we have a complex change
         log.warn("Unable to get change kind for patchSet " + patch.getPatchSetId() +
             "of change " + change.getId(), e);
@@ -415,7 +400,6 @@
       Change change,
       PatchSet patch,
       ChangeData.Factory changeDataFactory,
-      ProjectCache projectCache,
       GitRepositoryManager repoManager) {
     // TODO - dborowitz: add NEW_CHANGE type for default.
     ChangeKind kind = ChangeKind.REWORK;
@@ -424,8 +408,7 @@
     if (patch.getId().get() > 1) {
       try (Repository repo = repoManager.openRepository(change.getProject())) {
         kind = getChangeKindInternal(cache, repo,
-            changeDataFactory.create(db, change), patch,
-            projectCache);
+            changeDataFactory.create(db, change), patch);
       } catch (IOException e) {
         // Do nothing; assume we have a complex change
         log.warn("Unable to get change kind for patchSet " + patch.getPatchSetId() +
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
index 0c620e2..248acd3 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPickChange.java
@@ -272,10 +272,6 @@
 
     @Override
     public boolean updateChange(ChangeContext ctx) throws OrmException {
-      ChangeMessage changeMessage = new ChangeMessage(
-          new ChangeMessage.Key(
-              ctx.getChange().getId(), ChangeUtil.messageUUID(ctx.getDb())),
-              ctx.getAccountId(), ctx.getWhen(), psId);
       StringBuilder sb = new StringBuilder("Patch Set ")
           .append(psId.get())
           .append(": Cherry Picked")
@@ -284,8 +280,8 @@
           .append(destBranch)
           .append(" as commit ")
           .append(cherryPickCommit.name());
-      changeMessage.setMessage(sb.toString());
-
+      ChangeMessage changeMessage = ChangeMessagesUtil.newMessage(
+          ctx.getDb(), psId, ctx.getUser(), ctx.getWhen(), sb.toString());
       cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), changeMessage);
       return true;
     }
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/CreateDraftComment.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CreateDraftComment.java
index 7ca8478..47821f6 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
@@ -30,10 +30,8 @@
 import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.PatchSetUtil;
-import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
 import com.google.gerrit.server.git.UpdateException;
@@ -53,7 +51,6 @@
   private final CommentsUtil commentsUtil;
   private final PatchSetUtil psUtil;
   private final PatchListCache patchListCache;
-  private final String serverId;
 
   @Inject
   CreateDraftComment(Provider<ReviewDb> db,
@@ -61,15 +58,13 @@
       Provider<CommentJson> commentJson,
       CommentsUtil commentsUtil,
       PatchSetUtil psUtil,
-      PatchListCache patchListCache,
-      @GerritServerId String serverId) {
+      PatchListCache patchListCache) {
     this.db = db;
     this.updateFactory = updateFactory;
     this.commentJson = commentJson;
     this.commentsUtil = commentsUtil;
     this.psUtil = psUtil;
     this.patchListCache = patchListCache;
-    this.serverId = serverId;
   }
 
   @Override
@@ -113,14 +108,8 @@
       if (ps == null) {
         throw new ResourceNotFoundException("patch set not found: " + psId);
       }
-      comment = new Comment(
-          new Comment.Key(ChangeUtil.messageUUID(ctx.getDb()), in.path,
-              ps.getPatchSetId()),
-          ctx.getAccountId(),
-          ctx.getWhen(),
-          in.side(),
-          in.message.trim(),
-          serverId);
+      comment = commentsUtil.newComment(
+          ctx, in.path, ps.getId(), in.side(), in.message.trim());
       comment.parentUuid = Url.decode(in.inReplyTo);
       comment.setLineNbrAndRange(in.line, in.range);
       comment.tag = in.tag;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
index a572c58..70c2ae7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteAssignee.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.account.AccountInfoCacheFactory;
 import com.google.gerrit.server.account.AccountJson;
 import com.google.gerrit.server.change.DeleteAssignee.Input;
@@ -115,14 +114,8 @@
 
     private void addMessage(BatchUpdate.ChangeContext ctx,
         ChangeUpdate update, Account deleted) throws OrmException {
-      ChangeMessage cmsg = new ChangeMessage(
-          new ChangeMessage.Key(
-              ctx.getChange().getId(),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          ctx.getAccountId(), ctx.getWhen(),
-          ctx.getChange().currentPatchSetId());
-      cmsg.setMessage(
-          "Assignee deleted: " + deleted.getName(anonymousCowardName));
+      ChangeMessage cmsg = ChangeMessagesUtil.newMessage(
+          ctx, "Assignee deleted: " + deleted.getName(anonymousCowardName));
       cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
     }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
index 099f8e0..72ffa77 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteReviewer.java
@@ -36,7 +36,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.ReviewerDeleted;
@@ -185,11 +184,7 @@
       ChangeUpdate update = ctx.getUpdate(currPs.getId());
       update.removeReviewer(reviewerId);
 
-      changeMessage = new ChangeMessage(
-          new ChangeMessage.Key(currChange.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          ctx.getAccountId(), ctx.getWhen(), currPs.getId());
-      changeMessage.setMessage(msg.toString());
+      changeMessage = ChangeMessagesUtil.newMessage(ctx, msg.toString());
       cmUtil.addChangeMessage(ctx.getDb(), update, changeMessage);
 
       return true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
index e25e2292..eda2fcf 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/DeleteVote.java
@@ -35,7 +35,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.VoteDeleted;
@@ -170,20 +169,13 @@
       ctx.getDb().patchSetApprovals().upsert(
           Collections.singleton(deletedApproval(ctx)));
 
-      changeMessage =
-          new ChangeMessage(new ChangeMessage.Key(change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())),
-              ctx.getAccountId(),
-              ctx.getWhen(),
-              change.currentPatchSetId());
       StringBuilder msg = new StringBuilder();
       msg.append("Removed ");
       LabelVote.appendTo(msg, label, checkNotNull(oldApprovals.get(label)));
-      changeMessage.setMessage(
-          msg.append(" by ")
-              .append(userFactory.create(accountId).getNameEmail())
-              .append("\n")
-              .toString());
+      msg.append(" by ")
+          .append(userFactory.create(accountId).getNameEmail())
+          .append("\n");
+      changeMessage = ChangeMessagesUtil.newMessage(ctx, msg.toString());
       cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId),
           changeMessage);
 
@@ -191,6 +183,9 @@
     }
 
     private PatchSetApproval deletedApproval(ChangeContext ctx) {
+      // Set the effective user to the account we're trying to remove, and don't
+      // set the real user; this preserves the calling user as the NoteDb
+      // committer.
       return new PatchSetApproval(
           new PatchSetApproval.Key(
               ps.getId(),
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
index 2139ec4..954392c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Move.java
@@ -33,8 +33,6 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
-import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
@@ -94,7 +92,7 @@
 
     try (BatchUpdate u = batchUpdateFactory.create(dbProvider.get(),
         req.getChange().getProject(), control.getUser(), TimeUtil.nowTs())) {
-      u.addOp(req.getChange().getId(), new Op(control, input));
+      u.addOp(req.getChange().getId(), new Op(input));
       u.execute();
     }
 
@@ -103,14 +101,12 @@
 
   private class Op extends BatchUpdate.Op {
     private final MoveInput input;
-    private final IdentifiedUser caller;
 
     private Change change;
     private Branch.NameKey newDestKey;
 
-    Op(ChangeControl ctl, MoveInput input) {
+    Op(MoveInput input) {
       this.input = input;
-      this.caller = ctl.getUser().asIdentifiedUser();
     }
 
     @Override
@@ -179,11 +175,8 @@
         msgBuf.append("\n\n");
         msgBuf.append(input.message);
       }
-      ChangeMessage cmsg = new ChangeMessage(
-          new ChangeMessage.Key(change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          caller.getAccountId(), ctx.getWhen(), change.currentPatchSetId());
-      cmsg.setMessage(msgBuf.toString());
+      ChangeMessage cmsg =
+          ChangeMessagesUtil.newMessage(ctx, msgBuf.toString());
       cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
 
       return true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
index 3f38fc3..5914ccb 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PatchSetInserter.java
@@ -30,7 +30,6 @@
 import com.google.gerrit.server.ApprovalCopier;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.events.CommitReceivedEvent;
@@ -225,9 +224,8 @@
     }
 
     if (message != null) {
-      changeMessage = new ChangeMessage(
-          new ChangeMessage.Key(ctl.getId(), ChangeUtil.messageUUID(db)),
-          ctx.getAccountId(), ctx.getWhen(), patchSet.getId());
+      changeMessage = ChangeMessagesUtil.newMessage(
+          db, patchSet.getId(), ctx.getUser(), ctx.getWhen(), message);
       changeMessage.setMessage(message);
     }
 
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 74b1b20..9210d2b 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
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.change;
 
-import static com.google.common.base.MoreObjects.firstNonNull;
 import static com.google.common.base.Preconditions.checkNotNull;
 import static com.google.gerrit.server.CommentsUtil.setCommentRevId;
 import static com.google.gerrit.server.notedb.ReviewerStateInternal.REVIEWER;
@@ -58,6 +57,7 @@
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.client.Comment;
+import com.google.gerrit.reviewdb.client.LabelId;
 import com.google.gerrit.reviewdb.client.Patch;
 import com.google.gerrit.reviewdb.client.PatchLineComment.Status;
 import com.google.gerrit.reviewdb.client.PatchSet;
@@ -66,13 +66,11 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CommentsUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.account.AccountsCollection;
-import com.google.gerrit.server.config.GerritServerId;
 import com.google.gerrit.server.extensions.events.CommentAdded;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
@@ -121,7 +119,6 @@
   private final EmailReviewComments.Factory email;
   private final CommentAdded commentAdded;
   private final PostReviewers postReviewers;
-  private final String serverId;
   private final NotesMigration migration;
 
   @Inject
@@ -138,7 +135,6 @@
       EmailReviewComments.Factory email,
       CommentAdded commentAdded,
       PostReviewers postReviewers,
-      @GerritServerId String serverId,
       NotesMigration migration) {
     this.db = db;
     this.batchUpdateFactory = batchUpdateFactory;
@@ -153,7 +149,6 @@
     this.email = email;
     this.commentAdded = commentAdded;
     this.postReviewers = postReviewers;
-    this.serverId = serverId;
     this.migration = migration;
   }
 
@@ -173,6 +168,8 @@
     }
     if (input.onBehalfOf != null) {
       revision = onBehalfOf(revision, input);
+    } else if (input.drafts == null) {
+      input.drafts = DraftHandling.DELETE;
     }
     if (input.labels != null) {
       checkLabels(revision, input.strictLabels, input.labels);
@@ -248,6 +245,13 @@
           "label required to post review on behalf of \"%s\"",
           in.onBehalfOf));
     }
+    if (in.drafts == null) {
+      in.drafts = DraftHandling.KEEP;
+    }
+    if (in.drafts != DraftHandling.KEEP) {
+      throw new AuthException("not allowed to modify other user's drafts");
+    }
+
 
     ChangeControl caller = rev.getControl();
     Iterator<Map.Entry<String, Short>> itr = in.labels.entrySet().iterator();
@@ -275,7 +279,13 @@
           in.onBehalfOf));
     }
 
-    ChangeControl target = caller.forUser(accounts.parse(in.onBehalfOf));
+    ChangeControl target = caller.forUser(
+        accounts.parseOnBehalfOf(caller.getUser(), in.onBehalfOf));
+    if (!target.getRefControl().isVisible()) {
+      throw new UnprocessableEntityException(String.format(
+          "on_behalf_of account %s cannot see destination ref",
+          target.getUser().getAccountId()));
+    }
     return new RevisionResource(changes.parse(target), rev.getPatchSet());
   }
 
@@ -512,14 +522,7 @@
           String parent = Url.decode(c.inReplyTo);
           Comment e = drafts.remove(Url.decode(c.id));
           if (e == null) {
-            e = new Comment(
-                new Comment.Key(ChangeUtil.messageUUID(ctx.getDb()), path,
-                    psId.get()),
-                user.getAccountId(),
-                ctx.getWhen(),
-                c.side(),
-                c.message,
-                serverId);
+            e = commentsUtil.newComment(ctx, path, psId, c.side(), c.message);
           } else {
             e.writtenOn = ctx.getWhen();
             e.side = c.side();
@@ -540,7 +543,7 @@
         }
       }
 
-      switch (firstNonNull(in.drafts, DraftHandling.DELETE)) {
+      switch (in.drafts) {
         case KEEP:
         default:
           break;
@@ -577,13 +580,11 @@
       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);
+          RobotComment e = commentsUtil.newRobotComment(
+              ctx, path, psId, c.side(), c.message, 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);
@@ -646,6 +647,10 @@
         Comment c, PatchSet ps) throws OrmException {
       c.writtenOn = ctx.getWhen();
       c.tag = in.tag;
+      // Draft may have been created by a different real user; copy the current
+      // real user. (Only applies to X-Gerrit-RunAs, since modifying drafts via
+      // on_behalf_of is not allowed.)
+      ctx.getUser().updateRealAccountId(c::setRealAuthor);
       setCommentRevId(c, patchListCache, ctx.getChange(), checkNotNull(ps));
       return c;
     }
@@ -766,6 +771,7 @@
           c.setValue(ent.getValue());
           c.setGranted(ctx.getWhen());
           c.setTag(in.tag);
+          ctx.getUser().updateRealAccountId(c::setRealAccountId);
           ups.add(c);
           addLabelDelta(normName, c.getValue());
           oldApprovals.put(normName, previous.get(normName));
@@ -776,11 +782,8 @@
           oldApprovals.put(normName, null);
           approvals.put(normName, c.getValue());
         } else if (c == null) {
-          c = new PatchSetApproval(new PatchSetApproval.Key(
-                  psId,
-                  user.getAccountId(),
-                  lt.getLabelId()),
-              ent.getValue(), ctx.getWhen());
+          c = ApprovalsUtil.newApproval(
+              psId, user, lt.getLabelId(), ent.getValue(), ctx.getWhen());
           c.setTag(in.tag);
           c.setGranted(ctx.getWhen());
           ups.add(c);
@@ -818,12 +821,10 @@
         if (del.isEmpty()) {
           // If no existing label is being set to 0, hack in the caller
           // as a reviewer by picking the first server-wide LabelType.
-          PatchSetApproval c = new PatchSetApproval(new PatchSetApproval.Key(
-              psId,
-              user.getAccountId(),
-              ctx.getControl().getLabelTypes().getLabelTypes().get(0)
-                  .getLabelId()),
-              (short) 0, ctx.getWhen());
+          LabelId labelId = ctx.getControl().getLabelTypes().getLabelTypes()
+              .get(0).getLabelId();
+          PatchSetApproval c = ApprovalsUtil.newApproval(
+              psId, user, labelId, 0, ctx.getWhen());
           c.setTag(in.tag);
           c.setGranted(ctx.getWhen());
           ups.add(c);
@@ -882,17 +883,10 @@
         return false;
       }
 
-      message = new ChangeMessage(
-          new ChangeMessage.Key(
-            psId.getParentKey(), ChangeUtil.messageUUID(ctx.getDb())),
-          user.getAccountId(),
-          ctx.getWhen(),
-          psId);
+      message = ChangeMessagesUtil.newMessage(
+          ctx.getDb(), psId, user, ctx.getWhen(),
+          "Patch Set " + psId.get() + ":" + buf);
       message.setTag(in.tag);
-      message.setMessage(String.format(
-          "Patch Set %d:%s",
-          psId.get(),
-          buf.toString()));
       cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(psId), message);
       return true;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
index b4ed31b..435832a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PreviewSubmit.java
@@ -75,6 +75,12 @@
       throw new BadRequestException("format is not specified");
     }
     ArchiveFormat f = allowedFormats.extensions.get("." + format);
+    if (f == null && format.equals("tgz")) {
+      // Always allow tgz, even when the allowedFormats doesn't contain it.
+      // Then we allow at least one format even if the list of allowed
+      // formats is empty.
+      f = ArchiveFormat.TGZ;
+    }
     if (f == null) {
       throw new BadRequestException("unknown archive format");
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java
index 9d997f6..35b0ac7 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PublishDraftPatchSet.java
@@ -34,7 +34,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
@@ -252,15 +252,12 @@
 
     private void sendReplacePatchSet(Context ctx)
         throws EmailException, OrmException {
-      Account.Id accountId = ctx.getAccountId();
-      ChangeMessage msg =
-          new ChangeMessage(new ChangeMessage.Key(change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())), accountId,
-              ctx.getWhen(), psId);
-      msg.setMessage("Uploaded patch set " + psId.get() + ".");
+      ChangeMessage msg = ChangeMessagesUtil.newMessage(
+          ctx.getDb(), psId, ctx.getUser(), ctx.getWhen(),
+          "Uploaded patch set " + psId.get() + ".");
       ReplacePatchSetSender cm =
           replacePatchSetFactory.create(ctx.getProject(), change.getId());
-      cm.setFrom(accountId);
+      cm.setFrom(ctx.getAccountId());
       cm.setPatchSet(patchSet, patchSetInfo);
       cm.setChangeMessage(msg.getMessage(), ctx.getWhen());
       cm.addReviewers(recipients.getReviewers());
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 0808f95..91ad849 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
@@ -121,6 +121,9 @@
       }
       Comment origComment = maybeComment.get();
       comment = new Comment(origComment);
+      // Copy constructor preserved old real author; replace with current real
+      // user.
+      ctx.getUser().updateRealAccountId(comment::setRealAuthor);
 
       PatchSet.Id psId =
           new PatchSet.Id(ctx.getChange().getId(), origComment.key.patchSetId);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
index 31ae892..c0b3517 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutTopic.java
@@ -26,7 +26,6 @@
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.change.PutTopic.Input;
 import com.google.gerrit.server.extensions.events.TopicEdited;
 import com.google.gerrit.server.git.BatchUpdate;
@@ -115,13 +114,7 @@
       change.setTopic(Strings.emptyToNull(newTopicName));
       update.setTopic(change.getTopic());
 
-      ChangeMessage cmsg = new ChangeMessage(
-          new ChangeMessage.Key(
-              change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          ctx.getAccountId(), ctx.getWhen(),
-          change.currentPatchSetId());
-      cmsg.setMessage(summary);
+      ChangeMessage cmsg = ChangeMessagesUtil.newMessage(ctx, summary);
       cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
       return true;
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
index 9c4c6d9..f317ef2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Restore.java
@@ -29,7 +29,6 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.ChangeRestored;
 import com.google.gerrit.server.git.BatchUpdate;
@@ -131,16 +130,7 @@
         msg.append("\n\n");
         msg.append(input.message.trim());
       }
-
-      ChangeMessage message = new ChangeMessage(
-          new ChangeMessage.Key(
-              change.getId(),
-              ChangeUtil.messageUUID(ctx.getDb())),
-          ctx.getAccountId(),
-          ctx.getWhen(),
-          change.currentPatchSetId());
-      message.setMessage(msg.toString());
-      return message;
+      return ChangeMessagesUtil.newMessage(ctx, msg.toString());
     }
 
     @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
index 3ca496a..c42b257 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Revert.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.PatchSetUtil;
@@ -274,14 +273,8 @@
     public boolean updateChange(ChangeContext ctx) throws Exception {
       Change change = ctx.getChange();
       PatchSet.Id patchSetId = change.currentPatchSetId();
-      ChangeMessage changeMessage = new ChangeMessage(
-          new ChangeMessage.Key(change.getId(),
-              ChangeUtil.messageUUID(db.get())),
-          ctx.getAccountId(), ctx.getWhen(), patchSetId);
-      StringBuilder msgBuf = new StringBuilder();
-      msgBuf.append("Created a revert of this change as ")
-          .append("I").append(computedChangeId.name());
-      changeMessage.setMessage(msgBuf.toString());
+      ChangeMessage changeMessage = ChangeMessagesUtil.newMessage(ctx,
+          "Created a revert of this change as I" + computedChangeId.name());
       cmUtil.addChangeMessage(ctx.getDb(), ctx.getUpdate(patchSetId),
           changeMessage);
       return true;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
index a8ca147..dba5358 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetAssigneeOp.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.AccountInfoCacheFactory;
 import com.google.gerrit.server.account.AccountsCollection;
@@ -139,13 +138,7 @@
       msg.append(" to: ");
       msg.append(newAssignee.getName(anonymousCowardName));
     }
-    ChangeMessage cmsg = new ChangeMessage(
-        new ChangeMessage.Key(
-            change.getId(),
-            ChangeUtil.messageUUID(ctx.getDb())),
-        ctx.getAccountId(), ctx.getWhen(),
-        change.currentPatchSetId());
-    cmsg.setMessage(msg.toString());
+    ChangeMessage cmsg = ChangeMessagesUtil.newMessage(ctx, msg.toString());
     cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
index 34c611b..410e6ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SetHashtagsOp.java
@@ -28,7 +28,6 @@
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gerrit.reviewdb.client.ChangeMessage;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.extensions.events.HashtagsEdited;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
@@ -129,18 +128,12 @@
     return true;
   }
 
-  private void addMessage(Context ctx, ChangeUpdate update)
+  private void addMessage(ChangeContext ctx, ChangeUpdate update)
       throws OrmException {
     StringBuilder msg = new StringBuilder();
     appendHashtagMessage(msg, "added", toAdd);
     appendHashtagMessage(msg, "removed", toRemove);
-    ChangeMessage cmsg = new ChangeMessage(
-        new ChangeMessage.Key(
-            change.getId(),
-            ChangeUtil.messageUUID(ctx.getDb())),
-        ctx.getAccountId(), ctx.getWhen(),
-        change.currentPatchSetId());
-    cmsg.setMessage(msg.toString());
+    ChangeMessage cmsg = ChangeMessagesUtil.newMessage(ctx, msg.toString());
     cmUtil.addChangeMessage(ctx.getDb(), update, cmsg);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
index b8443be..e80e758 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/Submit.java
@@ -485,16 +485,12 @@
     if (!caller.canSubmitAs()) {
       throw new AuthException("submit on behalf of not permitted");
     }
-    IdentifiedUser targetUser = accounts.parseId(in.onBehalfOf);
-    if (targetUser == null) {
-      throw new UnprocessableEntityException(String.format(
-          "Account Not Found: %s", in.onBehalfOf));
-    }
-    ChangeControl target = caller.forUser(targetUser);
+    ChangeControl target = caller.forUser(
+        accounts.parseOnBehalfOf(caller.getUser(), in.onBehalfOf));
     if (!target.getRefControl().isVisible()) {
       throw new UnprocessableEntityException(String.format(
           "on_behalf_of account %s cannot see destination ref",
-          targetUser.getAccountId()));
+          target.getUser().getAccountId()));
     }
     return new RevisionResource(changes.parse(target), rsrc.getPatchSet());
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
index b09e0fa..8fca453 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/edit/ChangeEditUtil.java
@@ -41,8 +41,6 @@
 import com.google.gerrit.server.index.change.ChangeIndexer;
 import com.google.gerrit.server.project.ChangeControl;
 import com.google.gerrit.server.project.NoSuchChangeException;
-import com.google.gerrit.server.project.ProjectCache;
-import com.google.gerrit.server.project.ProjectState;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
@@ -71,7 +69,6 @@
   private final PatchSetInserter.Factory patchSetInserterFactory;
   private final ChangeControl.GenericFactory changeControlFactory;
   private final ChangeIndexer indexer;
-  private final ProjectCache projectCache;
   private final Provider<ReviewDb> db;
   private final Provider<CurrentUser> user;
   private final ChangeKindCache changeKindCache;
@@ -83,7 +80,6 @@
       PatchSetInserter.Factory patchSetInserterFactory,
       ChangeControl.GenericFactory changeControlFactory,
       ChangeIndexer indexer,
-      ProjectCache projectCache,
       Provider<ReviewDb> db,
       Provider<CurrentUser> user,
       ChangeKindCache changeKindCache,
@@ -93,7 +89,6 @@
     this.patchSetInserterFactory = patchSetInserterFactory;
     this.changeControlFactory = changeControlFactory;
     this.indexer = indexer;
-    this.projectCache = projectCache;
     this.db = db;
     this.user = user;
     this.changeKindCache = changeKindCache;
@@ -196,10 +191,10 @@
         .append(inserter.getPatchSetId().get())
         .append(": ");
 
-      ProjectState project = projectCache.get(change.getDest().getParentKey());
       // Previously checked that the base patch set is the current patch set.
       ObjectId prior = ObjectId.fromString(basePatchSet.getRevision().get());
-      ChangeKind kind = changeKindCache.getChangeKind(project, repo, prior, squashed);
+      ChangeKind kind = changeKindCache.getChangeKind(
+          change.getProject(), repo, prior, squashed);
       if (kind == ChangeKind.NO_CODE_CHANGE) {
         message.append("Commit message was updated.");
       } else {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
index a3125a5..71b29a1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeOp.java
@@ -47,7 +47,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.InternalUser;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
@@ -764,11 +763,10 @@
 
               change.setStatus(Change.Status.ABANDONED);
 
-              ChangeMessage msg = new ChangeMessage(
-                  new ChangeMessage.Key(change.getId(),
-                      ChangeUtil.messageUUID(ctx.getDb())),
-                  null, change.getLastUpdatedOn(), change.currentPatchSetId());
-              msg.setMessage("Project was deleted.");
+              ChangeMessage msg = ChangeMessagesUtil.newMessage(
+                  ctx.getDb(), change.currentPatchSetId(),
+                  internalUserFactory.create(), change.getLastUpdatedOn(),
+                  "Project was deleted.");
               cmUtil.addChangeMessage(ctx.getDb(),
                   ctx.getUpdate(change.currentPatchSetId()), msg);
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
index 2ccc849..6da1335 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergedByPushOp.java
@@ -22,8 +22,8 @@
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.PatchSetInfo;
+import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.extensions.events.ChangeMerged;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
@@ -149,19 +149,13 @@
       }
     }
     msgBuf.append(".");
-    ChangeMessage msg = new ChangeMessage(
-        new ChangeMessage.Key(change.getId(),
-            ChangeUtil.messageUUID(ctx.getDb())),
-        ctx.getAccountId(), ctx.getWhen(), psId);
-    msg.setMessage(msgBuf.toString());
+    ChangeMessage msg = ChangeMessagesUtil.newMessage(
+        ctx.getDb(), psId, ctx.getUser(), ctx.getWhen(), msgBuf.toString());
     cmUtil.addChangeMessage(ctx.getDb(), update, msg);
 
-    PatchSetApproval submitter = new PatchSetApproval(
-          new PatchSetApproval.Key(
-              change.currentPatchSetId(),
-              ctx.getAccountId(),
-              LabelId.legacySubmit()),
-              (short) 1, ctx.getWhen());
+    PatchSetApproval submitter = ApprovalsUtil.newApproval(
+        change.currentPatchSetId(), ctx.getUser(), LabelId.legacySubmit(),
+        1, ctx.getWhen());
     update.putApproval(submitter.getLabel(), submitter.getValue());
     ctx.getDb().patchSetApprovals().upsert(
         Collections.singleton(submitter));
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
index a70fa7a..afb682c 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ReplaceOp.java
@@ -34,7 +34,6 @@
 import com.google.gerrit.server.ApprovalCopier;
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.ChangeMessagesUtil;
-import com.google.gerrit.server.ChangeUtil;
 import com.google.gerrit.server.PatchSetUtil;
 import com.google.gerrit.server.account.AccountResolver;
 import com.google.gerrit.server.change.ChangeKindCache;
@@ -193,7 +192,8 @@
 
   @Override
   public void updateRepo(RepoContext ctx) throws Exception {
-    changeKind = changeKindCache.getChangeKind(projectControl.getProjectState(),
+    changeKind = changeKindCache.getChangeKind(
+        projectControl.getProject().getNameKey(),
         ctx.getRepository(), priorCommit, commit);
 
     if (checkMergedInto) {
@@ -256,7 +256,7 @@
     MailRecipients oldRecipients =
         getRecipientsFromReviewers(cd.reviewers());
     Iterable<PatchSetApproval> newApprovals =
-        approvalsUtil.addApprovals(ctx.getDb(), update,
+        approvalsUtil.addApprovalsForNewPatchSet(ctx.getDb(), update,
             projectControl.getLabelTypes(), newPatchSet, ctx.getControl(),
             approvals);
     approvalCopier.copy(ctx.getDb(), ctx.getControl(), newPatchSet,
@@ -278,11 +278,8 @@
     if (!Strings.isNullOrEmpty(reviewMessage)) {
       message.append("\n").append(reviewMessage);
     }
-    msg = new ChangeMessage(
-        new ChangeMessage.Key(change.getId(),
-            ChangeUtil.messageUUID(ctx.getDb())),
-        ctx.getAccountId(), ctx.getWhen(), patchSetId);
-    msg.setMessage(message.toString());
+    msg = ChangeMessagesUtil.newMessage(ctx.getDb(), patchSetId, ctx.getUser(),
+        ctx.getWhen(), message.toString());
     cmUtil.addChangeMessage(ctx.getDb(), update, msg);
 
     if (mergedByPushOp == null) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
index b35de7b..7dacc6f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/SubmitStrategyOp.java
@@ -33,7 +33,8 @@
 import com.google.gerrit.reviewdb.client.RefNames;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
-import com.google.gerrit.server.ChangeUtil;
+import com.google.gerrit.server.ApprovalsUtil;
+import com.google.gerrit.server.ChangeMessagesUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.git.BatchUpdate;
 import com.google.gerrit.server.git.BatchUpdate.ChangeContext;
@@ -329,15 +330,9 @@
       byKey.put(psa.getKey(), psa);
     }
 
-    submitter = new PatchSetApproval(
-          new PatchSetApproval.Key(
-              psId,
-              ctx.getAccountId(),
-              LabelId.legacySubmit()),
-              (short) 1, ctx.getWhen());
+    submitter = ApprovalsUtil.newApproval(
+        psId, ctx.getUser(), LabelId.legacySubmit(), 1, ctx.getWhen());
     byKey.put(submitter.getKey(), submitter);
-    submitter.setValue((short) 1);
-    submitter.setGranted(ctx.getWhen());
 
     // Flatten out existing approvals for this patch set based upon the current
     // permissions. Once the change is closed the approvals are not updated at
@@ -415,7 +410,7 @@
   }
 
   private ChangeMessage message(ChangeContext ctx, CodeReviewCommit commit,
-      CommitMergeStatus s) {
+      CommitMergeStatus s) throws OrmException {
     checkNotNull(s, "CommitMergeStatus may not be null");
     String txt = s.getMessage();
     if (s == CommitMergeStatus.CLEAN_MERGE) {
@@ -452,19 +447,9 @@
   }
 
   private ChangeMessage message(ChangeContext ctx, PatchSet.Id psId,
-      String body) {
-    checkNotNull(psId);
-    String uuid;
-    try {
-      uuid = ChangeUtil.messageUUID(ctx.getDb());
-    } catch (OrmException e) {
-      return null;
-    }
-    ChangeMessage m = new ChangeMessage(
-        new ChangeMessage.Key(psId.getParentKey(), uuid),
-        ctx.getAccountId(), ctx.getWhen(), psId);
-    m.setMessage(body);
-    return m;
+      String body) throws OrmException {
+    return ChangeMessagesUtil.newMessage(
+        ctx.getDb(), psId, ctx.getUser(), ctx.getWhen(), body);
   }
 
   private void setMerged(ChangeContext ctx, ChangeMessage msg)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailSoyTofuProvider.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailSoyTofuProvider.java
index 9aef496..26bd99e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailSoyTofuProvider.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MailSoyTofuProvider.java
@@ -53,6 +53,7 @@
     "FooterHtml.soy",
     "HeaderHtml.soy",
     "Merged.soy",
+    "MergedHtml.soy",
     "NewChange.soy",
     "NewChangeHtml.soy",
     "RegisterNewEmail.soy",
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergedSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergedSender.java
index c2a3cdd..17a0854 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergedSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/MergedSender.java
@@ -59,6 +59,10 @@
   @Override
   protected void formatChange() throws EmailException {
     appendText(textTemplate("Merged"));
+
+    if (useHtml()) {
+      appendHtml(soyHtmlTemplate("MergedHtml"));
+    }
   }
 
   public String getApprovals() {
@@ -129,4 +133,9 @@
     super.setupSoyContext();
     soyContextEmailData.put("approvals", getApprovals());
   }
+
+  @Override
+  protected boolean supportsHtml() {
+    return true;
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
index 70a5f4f..fa23b80 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/AbstractChangeUpdate.java
@@ -20,6 +20,7 @@
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Comment;
 import com.google.gerrit.reviewdb.client.PatchSet;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.CurrentUser;
@@ -45,6 +46,7 @@
   protected final ChangeNoteUtil noteUtil;
   protected final String anonymousCowardName;
   protected final Account.Id accountId;
+  protected final Account.Id realAccountId;
   protected final PersonIdent authorIdent;
   protected final Date when;
 
@@ -69,6 +71,9 @@
     this.notes = ctl.getNotes();
     this.change = notes.getChange();
     this.accountId = accountId(ctl.getUser());
+    Account.Id realAccountId = accountId(ctl.getUser().getRealUser());
+    this.realAccountId =
+        realAccountId != null ? realAccountId : accountId;
     this.authorIdent =
         ident(noteUtil, serverIdent, anonymousCowardName, ctl.getUser(), when);
     this.when = when;
@@ -82,6 +87,7 @@
       @Nullable ChangeNotes notes,
       @Nullable Change change,
       Account.Id accountId,
+      Account.Id realAccountId,
       PersonIdent authorIdent,
       Date when) {
     checkArgument(
@@ -95,6 +101,7 @@
     this.notes = notes;
     this.change = change != null ? change : notes.getChange();
     this.accountId = accountId;
+    this.realAccountId = realAccountId;
     this.authorIdent = authorIdent;
     this.when = when;
   }
@@ -255,4 +262,18 @@
   private static ObjectId emptyTree(ObjectInserter ins) throws IOException {
     return ins.insert(Constants.OBJ_TREE, new byte[] {});
   }
+
+  protected void verifyComment(Comment c) {
+    checkArgument(c.revId != null, "RevId required for comment: %s", c);
+    checkArgument(
+        c.author.getId().equals(getAccountId()),
+        "The author for the following comment does not match the author of"
+            + " this %s (%s): %s",
+        getClass().getSimpleName(), getAccountId(), c);
+    checkArgument(
+        c.getRealAuthor().getId().equals(realAccountId),
+        "The real author for the following comment does not match the real"
+            + " author of this %s (%s): %s",
+        getClass().getSimpleName(), realAccountId, c);
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
index ca595d7..3322776 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeBundle.java
@@ -226,13 +226,13 @@
     checkColumns(Change.class,
         1, 2, 3, 4, 5, 7, 8, 10, 12, 13, 14, 17, 18, 19, 101);
     checkColumns(ChangeMessage.Key.class, 1, 2);
-    checkColumns(ChangeMessage.class, 1, 2, 3, 4, 5, 6);
+    checkColumns(ChangeMessage.class, 1, 2, 3, 4, 5, 6, 7);
     checkColumns(PatchSet.Id.class, 1, 2);
     checkColumns(PatchSet.class, 1, 2, 3, 4, 5, 6, 8);
     checkColumns(PatchSetApproval.Key.class, 1, 2, 3);
-    checkColumns(PatchSetApproval.class, 1, 2, 3, 6);
+    checkColumns(PatchSetApproval.class, 1, 2, 3, 6, 7);
     checkColumns(PatchLineComment.Key.class, 1, 2);
-    checkColumns(PatchLineComment.class, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10);
+    checkColumns(PatchLineComment.class, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11);
   }
 
   private final Change change;
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 b4ab290..57d5dce 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
@@ -15,7 +15,6 @@
 package com.google.gerrit.server.notedb;
 
 import static com.google.common.base.MoreObjects.firstNonNull;
-import static com.google.common.base.Preconditions.checkArgument;
 import static org.eclipse.jgit.lib.Constants.OBJ_BLOB;
 
 import com.google.auto.value.AutoValue;
@@ -62,10 +61,19 @@
  */
 public class ChangeDraftUpdate extends AbstractChangeUpdate {
   public interface Factory {
-    ChangeDraftUpdate create(ChangeNotes notes, Account.Id accountId,
-        PersonIdent authorIdent, Date when);
-    ChangeDraftUpdate create(Change change, Account.Id accountId,
-        PersonIdent authorIdent, Date when);
+    ChangeDraftUpdate create(
+        ChangeNotes notes,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
+
+    ChangeDraftUpdate create(
+        Change change,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
   }
 
   @AutoValue
@@ -91,11 +99,12 @@
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
       @Assisted ChangeNotes notes,
-      @Assisted Account.Id accountId,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
       @Assisted Date when) {
     super(migration, noteUtil, serverIdent, anonymousCowardName, notes, null,
-        accountId, authorIdent, when);
+        accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
   }
 
@@ -107,11 +116,12 @@
       AllUsersName allUsers,
       ChangeNoteUtil noteUtil,
       @Assisted Change change,
-      @Assisted Account.Id accountId,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
       @Assisted Date when) {
     super(migration, noteUtil, serverIdent, anonymousCowardName, null, change,
-        accountId, authorIdent, when);
+        accountId, realAccountId, authorIdent, when);
     this.draftsProject = allUsers;
   }
 
@@ -129,12 +139,6 @@
     delete.add(new AutoValue_ChangeDraftUpdate_Key(revId, key));
   }
 
-  private void verifyComment(Comment comment) {
-    checkArgument(comment.author.getId().equals(accountId),
-        "The author for the following comment does not match the author of"
-        + " this ChangeDraftUpdate (%s): %s", accountId, comment);
-  }
-
   private CommitBuilder storeCommentsInNotes(RevWalk rw, ObjectInserter ins,
       ObjectId curr, CommitBuilder cb)
       throws ConfigInvalidException, OrmException, IOException {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
index 078adf1..239b54e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNoteUtil.java
@@ -71,6 +71,7 @@
   public static final FooterKey FOOTER_HASHTAGS = new FooterKey("Hashtags");
   public static final FooterKey FOOTER_LABEL = new FooterKey("Label");
   public static final FooterKey FOOTER_PATCH_SET = new FooterKey("Patch-set");
+  public static final FooterKey FOOTER_REAL_USER = new FooterKey("Real-user");
   public static final FooterKey FOOTER_STATUS = new FooterKey("Status");
   public static final FooterKey FOOTER_SUBJECT = new FooterKey("Subject");
   public static final FooterKey FOOTER_SUBMISSION_ID =
@@ -88,6 +89,7 @@
   private static final String PARENT = "Parent";
   private static final String PARENT_NUMBER = "Parent-number";
   private static final String PATCH_SET = "Patch-set";
+  private static final String REAL_AUTHOR = "Real-author";
   private static final String REVISION = "Revision";
   private static final String UUID = "UUID";
   private static final String TAG = FOOTER_TAG.getName();
@@ -232,7 +234,14 @@
     }
 
     Timestamp commentTime = parseTimestamp(note, curr, changeId);
-    Account.Id aId = parseAuthor(note, curr, changeId);
+    Account.Id aId = parseAuthor(note, curr, changeId, AUTHOR);
+    boolean hasRealAuthor =
+        (RawParseUtils.match(note, curr.value, REAL_AUTHOR.getBytes(UTF_8)))
+            != -1;
+    Account.Id raId = null;
+    if (hasRealAuthor) {
+      raId = parseAuthor(note, curr, changeId, REAL_AUTHOR);
+    }
 
     boolean hasParent =
         (RawParseUtils.match(note, curr.value, PARENT.getBytes(UTF_8))) != -1;
@@ -269,6 +278,9 @@
     c.parentUuid = parentUUID;
     c.tag = tag;
     c.setRevId(revId);
+    if (raId != null) {
+      c.setRealAuthor(raId);
+    }
 
     if (range.getStartCharacter() != -1) {
       c.setRange(range);
@@ -411,15 +423,15 @@
   }
 
   private Account.Id parseAuthor(byte[] note, MutableInteger curr,
-      Change.Id changeId) throws ConfigInvalidException {
-    checkHeaderLineFormat(note, curr, AUTHOR, changeId);
+      Change.Id changeId, String fieldName) throws ConfigInvalidException {
+    checkHeaderLineFormat(note, curr, fieldName, changeId);
     int startOfAccountId =
         RawParseUtils.endOfFooterLineKey(note, curr.value) + 2;
     PersonIdent ident =
         RawParseUtils.parsePersonIdent(note, startOfAccountId);
     Account.Id aId = parseIdent(ident, changeId);
     curr.value = RawParseUtils.nextLF(note, curr.value);
-    return checkResult(aId, "comment author", changeId);
+    return checkResult(aId, fieldName, changeId);
   }
 
   private static int parseCommentLength(byte[] note, MutableInteger curr,
@@ -564,15 +576,10 @@
     writer.print(formatTime(serverIdent, c.writtenOn));
     writer.print("\n");
 
-    PersonIdent ident = newIdent(
-        accountCache.get(c.author.getId()).getAccount(),
-        c.writtenOn, serverIdent, anonymousCowardName);
-    StringBuilder name = new StringBuilder();
-    PersonIdent.appendSanitized(name, ident.getName());
-    name.append(" <");
-    PersonIdent.appendSanitized(name, ident.getEmailAddress());
-    name.append('>');
-    appendHeaderField(writer, AUTHOR, name.toString());
+    appendIdent(writer, AUTHOR, c.author.getId(), c.writtenOn);
+    if (!c.getRealAuthor().equals(c.author)) {
+      appendIdent(writer, REAL_AUTHOR, c.getRealAuthor().getId(), c.writtenOn);
+    }
 
     String parent = c.parentUuid;
     if (parent != null) {
@@ -592,4 +599,17 @@
     writer.print(c.message);
     writer.print("\n\n");
   }
+
+  private void appendIdent(PrintWriter writer, String header, Account.Id id,
+      Timestamp ts) {
+    PersonIdent ident = newIdent(
+        accountCache.get(id).getAccount(),
+        ts, serverIdent, anonymousCowardName);
+    StringBuilder name = new StringBuilder();
+    PersonIdent.appendSanitized(name, ident.getName());
+    name.append(" <");
+    PersonIdent.appendSanitized(name, ident.getEmailAddress());
+    name.append('>');
+    appendHeaderField(writer, header, name.toString());
+  }
 }
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 de37b72..a3ef2ce 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
@@ -22,6 +22,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
@@ -303,6 +304,7 @@
     if (accountId != null) {
       ownerId = accountId;
     }
+    Account.Id realAccountId = parseRealAccountId(commit, accountId);
 
     if (changeId == null) {
       changeId = parseChangeId(commit);
@@ -316,7 +318,7 @@
       originalSubject = currSubject;
     }
 
-    parseChangeMessage(psId, accountId, commit, ts);
+    parseChangeMessage(psId, accountId, realAccountId, commit, ts);
     if (topic == null) {
       topic = parseTopic(commit);
     }
@@ -342,7 +344,7 @@
     }
 
     for (String line : commit.getFooterLineValues(FOOTER_LABEL)) {
-      parseApproval(psId, accountId, ts, line);
+      parseApproval(psId, accountId, realAccountId, ts, line);
     }
 
     for (ReviewerStateInternal state : ReviewerStateInternal.values()) {
@@ -379,6 +381,16 @@
     return parseOneFooter(commit, FOOTER_SUBJECT);
   }
 
+  private Account.Id parseRealAccountId(ChangeNotesCommit commit,
+      Account.Id effectiveAccountId) throws ConfigInvalidException {
+    String realUser = parseOneFooter(commit, FOOTER_REAL_USER);
+    if (realUser == null) {
+      return effectiveAccountId;
+    }
+    PersonIdent ident = RawParseUtils.parsePersonIdent(realUser);
+    return noteUtil.parseIdent(ident, id);
+  }
+
   private String parseTopic(ChangeNotesCommit commit)
       throws ConfigInvalidException {
     return parseOneFooter(commit, FOOTER_TOPIC);
@@ -565,7 +577,8 @@
   }
 
   private void parseChangeMessage(PatchSet.Id psId,
-      Account.Id accountId, ChangeNotesCommit commit, Timestamp ts) {
+      Account.Id accountId, Account.Id realAccountId,
+      ChangeNotesCommit commit, Timestamp ts) {
     byte[] raw = commit.getRawBuffer();
     int size = raw.length;
     Charset enc = RawParseUtils.parseEncoding(raw);
@@ -614,11 +627,10 @@
         changeMessageStart, changeMessageEnd + 1);
     ChangeMessage changeMessage = new ChangeMessage(
         new ChangeMessage.Key(psId.getParentKey(), commit.name()),
-        accountId,
-        ts,
-        psId);
+        accountId, ts, psId);
     changeMessage.setMessage(changeMsgString);
     changeMessage.setTag(tag);
+    changeMessage.setRealAuthor(realAccountId);
     changeMessagesByPatchSet.put(psId, changeMessage);
     allChangeMessages.add(changeMessage);
   }
@@ -647,31 +659,45 @@
   }
 
   private void parseApproval(PatchSet.Id psId, Account.Id accountId,
-      Timestamp ts, String line) throws ConfigInvalidException {
+      Account.Id realAccountId, Timestamp ts, String line)
+      throws ConfigInvalidException {
     if (accountId == null) {
       throw parseException(
           "patch set %s requires an identified user as uploader", psId.get());
     }
     if (line.startsWith("-")) {
-      parseRemoveApproval(psId, accountId, ts, line);
+      parseRemoveApproval(psId, accountId, realAccountId, ts, line);
     } else {
-      parseAddApproval(psId, accountId, ts, line);
+      parseAddApproval(psId, accountId, realAccountId, ts, line);
     }
   }
 
   private void parseAddApproval(PatchSet.Id psId, Account.Id committerId,
-      Timestamp ts, String line) throws ConfigInvalidException {
-    Account.Id accountId;
+      Account.Id realAccountId, Timestamp ts, String line)
+      throws ConfigInvalidException {
+    // There are potentially 3 accounts involved here:
+    //  1. The account from the commit, which is the effective IdentifiedUser
+    //     that produced the update.
+    //  2. The account in the label footer itself, which is used during submit
+    //     to copy other users' labels to a new patch set.
+    //  3. The account in the Real-user footer, indicating that the whole
+    //     update operation was executed by this user on behalf of the effective
+    //     user.
+    Account.Id effectiveAccountId;
     String labelVoteStr;
     int s = line.indexOf(' ');
     if (s > 0) {
+      // Account in the label line (2) becomes the effective ID of the
+      // approval. If there is a real user (3) different from the commit user
+      // (2), we actually don't store that anywhere in this case; it's more
+      // important to record that the real user (3) actually initiated submit.
       labelVoteStr = line.substring(0, s);
       PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
       checkFooter(ident != null, FOOTER_LABEL, line);
-      accountId = noteUtil.parseIdent(ident, id);
+      effectiveAccountId = noteUtil.parseIdent(ident, id);
     } else {
       labelVoteStr = line;
-      accountId = committerId;
+      effectiveAccountId = committerId;
     }
 
     LabelVote l;
@@ -687,30 +713,36 @@
     PatchSetApproval psa = new PatchSetApproval(
         new PatchSetApproval.Key(
             psId,
-            accountId,
+            effectiveAccountId,
             new LabelId(l.label())),
         l.value(),
         ts);
     psa.setTag(tag);
-    ApprovalKey k = ApprovalKey.create(psId, accountId, l.label(), tag);
+    if (!Objects.equals(realAccountId, committerId)) {
+      psa.setRealAccountId(realAccountId);
+    }
+    ApprovalKey k =
+        ApprovalKey.create(psId, effectiveAccountId, l.label(), tag);
     if (!approvals.containsKey(k)) {
       approvals.put(k, psa);
     }
   }
 
   private void parseRemoveApproval(PatchSet.Id psId, Account.Id committerId,
-      Timestamp ts, String line) throws ConfigInvalidException {
-    Account.Id accountId;
+      Account.Id realAccountId, Timestamp ts, String line)
+      throws ConfigInvalidException {
+    // See comments in parseAddApproval about the various users involved.
+    Account.Id effectiveAccountId;
     String label;
     int s = line.indexOf(' ');
     if (s > 0) {
       label = line.substring(1, s);
       PersonIdent ident = RawParseUtils.parsePersonIdent(line.substring(s + 1));
       checkFooter(ident != null, FOOTER_LABEL, line);
-      accountId = noteUtil.parseIdent(ident, id);
+      effectiveAccountId = noteUtil.parseIdent(ident, id);
     } else {
       label = line.substring(1);
-      accountId = committerId;
+      effectiveAccountId = committerId;
     }
 
     try {
@@ -731,11 +763,14 @@
     PatchSetApproval remove = new PatchSetApproval(
         new PatchSetApproval.Key(
             psId,
-            accountId,
+            effectiveAccountId,
             new LabelId(label)),
         (short) 0,
         ts);
-    ApprovalKey k = ApprovalKey.create(psId, accountId, label, tag);
+    if (!Objects.equals(realAccountId, committerId)) {
+      remove.setRealAccountId(realAccountId);
+    }
+    ApprovalKey k = ApprovalKey.create(psId, effectiveAccountId, label, tag);
     if (!approvals.containsKey(k)) {
       approvals.put(k, remove);
     }
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..3642dff 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
@@ -27,6 +27,7 @@
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_HASHTAGS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_LABEL;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_PATCH_SET;
+import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_REAL_USER;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_STATUS;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBJECT;
 import static com.google.gerrit.server.notedb.ChangeNoteUtil.FOOTER_SUBMISSION_ID;
@@ -82,6 +83,7 @@
 import java.util.LinkedHashMap;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 import java.util.Set;
 
 /**
@@ -100,8 +102,13 @@
   public interface Factory {
     ChangeUpdate create(ChangeControl ctl);
     ChangeUpdate create(ChangeControl ctl, Date when);
-    ChangeUpdate create(Change change, @Nullable Account.Id accountId,
-        PersonIdent authorIdent, Date when,
+
+    ChangeUpdate create(
+        Change change,
+        @Assisted("effective") @Nullable Account.Id accountId,
+        @Assisted("real") @Nullable Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when,
         Comparator<String> labelNameComparator);
 
     @VisibleForTesting
@@ -218,12 +225,13 @@
       RobotCommentUpdate.Factory robotCommentUpdateFactory,
       ChangeNoteUtil noteUtil,
       @Assisted Change change,
-      @Assisted @Nullable Account.Id accountId,
+      @Assisted("effective") @Nullable Account.Id accountId,
+      @Assisted("real") @Nullable Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
       @Assisted Date when,
       @Assisted Comparator<String> labelNameComparator) {
     super(migration, noteUtil, serverIdent, anonymousCowardName, null, change,
-        accountId, authorIdent, when);
+        accountId, realAccountId, authorIdent, when);
     this.accountCache = accountCache;
     this.draftUpdateFactory = draftUpdateFactory;
     this.robotCommentUpdateFactory = robotCommentUpdateFactory;
@@ -345,11 +353,11 @@
     if (draftUpdate == null) {
       ChangeNotes notes = getNotes();
       if (notes != null) {
-        draftUpdate =
-            draftUpdateFactory.create(notes, accountId, authorIdent, when);
+        draftUpdate = draftUpdateFactory.create(
+            notes, accountId, realAccountId, authorIdent, when);
       } else {
         draftUpdate = draftUpdateFactory.create(
-            getChange(), accountId, authorIdent, when);
+            getChange(), accountId, realAccountId, authorIdent, when);
       }
     }
     return draftUpdate;
@@ -360,24 +368,16 @@
     if (robotCommentUpdate == null) {
       ChangeNotes notes = getNotes();
       if (notes != null) {
-        robotCommentUpdate =
-            robotCommentUpdateFactory.create(notes, accountId, authorIdent, when);
+        robotCommentUpdate = robotCommentUpdateFactory.create(
+            notes, accountId, realAccountId, authorIdent, when);
       } else {
         robotCommentUpdate = robotCommentUpdateFactory.create(
-            getChange(), accountId, authorIdent, when);
+            getChange(), accountId, realAccountId, 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()),
-        "The author for the following comment does not match the author of"
-        + " this ChangeUpdate (%s): %s", getAccountId(), c);
-
-  }
-
   public void setTopic(String topic) {
     this.topic = Strings.nullToEmpty(topic);
   }
@@ -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');
           }
@@ -657,6 +655,11 @@
       }
     }
 
+    if (!Objects.equals(accountId, realAccountId)) {
+      addFooter(msg, FOOTER_REAL_USER);
+      addIdent(msg, realAccountId).append('\n');
+    }
+
     cb.setMessage(msg.toString());
     try {
       ObjectId treeId = storeRevisionNotes(rw, ins, curr);
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
index fd47d02..9744632 100644
--- 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
@@ -15,7 +15,6 @@
 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;
 
@@ -55,12 +54,21 @@
  * <p>
  * This class is not thread safe.
  */
-public class RobotCommentUpdate extends AbstractChangeUpdate{
+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);
+    RobotCommentUpdate create(
+        ChangeNotes notes,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
+
+    RobotCommentUpdate create(
+        Change change,
+        @Assisted("effective") Account.Id accountId,
+        @Assisted("real") Account.Id realAccountId,
+        PersonIdent authorIdent,
+        Date when);
   }
 
   private List<RobotComment> put = new ArrayList<>();
@@ -72,11 +80,12 @@
       NotesMigration migration,
       ChangeNoteUtil noteUtil,
       @Assisted ChangeNotes notes,
-      @Assisted Account.Id accountId,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
       @Assisted Date when) {
     super(migration, noteUtil, serverIdent, anonymousCowardName, notes, null,
-        accountId, authorIdent, when);
+        accountId, realAccountId, authorIdent, when);
   }
 
   @AssistedInject
@@ -86,11 +95,12 @@
       NotesMigration migration,
       ChangeNoteUtil noteUtil,
       @Assisted Change change,
-      @Assisted Account.Id accountId,
+      @Assisted("effective") Account.Id accountId,
+      @Assisted("real") Account.Id realAccountId,
       @Assisted PersonIdent authorIdent,
       @Assisted Date when) {
     super(migration, noteUtil, serverIdent, anonymousCowardName, null, change,
-        accountId, authorIdent, when);
+        accountId, realAccountId, authorIdent, when);
   }
 
   public void putComment(RobotComment c) {
@@ -98,12 +108,6 @@
     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 {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java
index a00334d..3aba7c5 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ApprovalEvent.java
@@ -23,8 +23,8 @@
   private PatchSetApproval psa;
 
   ApprovalEvent(PatchSetApproval psa, Timestamp changeCreatedOn) {
-    super(psa.getPatchSetId(), psa.getAccountId(), psa.getGranted(),
-        changeCreatedOn, psa.getTag());
+    super(psa.getPatchSetId(), psa.getAccountId(), psa.getRealAccountId(),
+        psa.getGranted(), changeCreatedOn, psa.getTag());
     this.psa = psa;
   }
 
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..ed5cd8b 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,17 +31,12 @@
   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;
 
   ChangeMessageEvent(ChangeMessage message, Change noteDbChange,
       Timestamp changeCreatedOn) {
-    super(message.getPatchSetId(), message.getAuthor(),
+    super(message.getPatchSetId(), message.getAuthor(), message.getRealAuthor(),
         message.getWrittenOn(), changeCreatedOn, message.getTag());
     this.message = message;
     this.noteDbChange = 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..1916610 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,13 +427,14 @@
 
   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.
     Event first = events.get(0);
-    if (first instanceof PatchSetEvent && change.getOwner().equals(first.who)) {
+    if (first instanceof PatchSetEvent
+        && change.getOwner().equals(first.user)) {
       ((PatchSetEvent) first).createChange = true;
     } else {
       events.add(0, new CreateChangeEvent(change, minPsNum));
@@ -483,6 +493,7 @@
     ChangeUpdate update = updateFactory.create(
         change,
         events.getAccountId(),
+        events.getRealAccountId(),
         newAuthorIdent(events),
         events.getWhen(),
         labelNameComparator);
@@ -505,6 +516,7 @@
     ChangeDraftUpdate update = draftUpdateFactory.create(
         change,
         events.getAccountId(),
+        events.getRealAccountId(),
         newAuthorIdent(events),
         events.getWhen());
     update.setPatchSetId(events.getPatchSetId());
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
index 51ade79..8f461a2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CommentEvent.java
@@ -34,7 +34,7 @@
   CommentEvent(Comment c, Change change, PatchSet ps,
       PatchListCache cache) {
     super(CommentsUtil.getCommentPsId(change.getId(), c), c.author.getId(),
-        c.writtenOn, change.getCreatedOn(), c.tag);
+        c.getRealAuthor().getId(), c.writtenOn, change.getCreatedOn(), c.tag);
     this.c = c;
     this.change = change;
     this.ps = ps;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java
index 886f6c4..b020911 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/CreateChangeEvent.java
@@ -37,8 +37,8 @@
   }
 
   CreateChangeEvent(Change change, Integer minPsNum) {
-    super(psId(change, minPsNum), change.getOwner(), change.getCreatedOn(),
-        change.getCreatedOn(), null);
+    super(psId(change, minPsNum), change.getOwner(), change.getOwner(),
+        change.getCreatedOn(), change.getCreatedOn(), null);
     this.change = change;
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
index 360dfff..2938480 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/DraftCommentEvent.java
@@ -34,7 +34,7 @@
   DraftCommentEvent(Comment c, Change change, PatchSet ps,
       PatchListCache cache) {
     super(CommentsUtil.getCommentPsId(change.getId(), c), c.author.getId(),
-        c.writtenOn, change.getCreatedOn(), c.tag);
+        c.getRealAuthor().getId(), c.writtenOn, change.getCreatedOn(), c.tag);
     this.c = c;
     this.change = change;
     this.ps = ps;
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..78df2d2 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
@@ -36,17 +36,24 @@
   // NOTE: EventList only supports direct subclasses, not an arbitrary
   // hierarchy.
 
-  final Account.Id who;
+  final Account.Id user;
+  final Account.Id realUser;
   final String tag;
   final boolean predatesChange;
   final List<Event> deps;
   Timestamp when;
   PatchSet.Id psId;
 
-  protected Event(PatchSet.Id psId, Account.Id who, Timestamp when,
-      Timestamp changeCreatedOn, String tag) {
+  protected Event(
+      PatchSet.Id psId,
+      Account.Id effectiveUser,
+      Account.Id realUser,
+      Timestamp when,
+      Timestamp changeCreatedOn,
+      String tag) {
     this.psId = psId;
-    this.who = who;
+    this.user = effectiveUser;
+    this.realUser = realUser != null ? realUser : effectiveUser;
     this.tag = tag;
     // Truncate timestamps at the change's createdOn timestamp.
     predatesChange = when.before(changeCreatedOn);
@@ -61,9 +68,9 @@
     checkState(when.getTime() - update.getWhen().getTime() <= MAX_WINDOW_MS,
         "event at %s outside update window starting at %s",
         when, update.getWhen());
-    checkState(Objects.equals(update.getNullableAccountId(), who),
+    checkState(Objects.equals(update.getNullableAccountId(), user),
         "cannot apply event by %s to update by %s",
-        who, update.getNullableAccountId());
+        user, update.getNullableAccountId());
   }
 
   Event addDep(Event e) {
@@ -79,15 +86,12 @@
 
   abstract void apply(ChangeUpdate update) throws OrmException, IOException;
 
-  protected boolean isPatchSet() {
-    return false;
-  }
-
   @Override
   public String toString() {
     return MoreObjects.toStringHelper(this)
         .add("psId", psId)
-        .add("who", who)
+        .add("effectiveUser", user)
+        .add("realUser", realUser)
         .add("when", when)
         .toString();
   }
@@ -95,12 +99,23 @@
   @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)
-        .compare(this.who, other.who, ReviewDbUtil.intKeyOrdering())
+        .compare(this.user, other.user,
+            ReviewDbUtil.intKeyOrdering())
+        .compare(this.realUser, other.realUser, ReviewDbUtil.intKeyOrdering())
         .compare(this.psId, other.psId,
             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/EventList.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventList.java
index 398657b..4f6e2e8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventList.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/EventList.java
@@ -49,7 +49,8 @@
     }
 
     Event last = getLast();
-    if (!Objects.equals(e.who, last.who)
+    if (!Objects.equals(e.user, last.user)
+        || !Objects.equals(e.realUser, last.realUser)
         || !e.psId.equals(last.psId)
         || !Objects.equals(e.tag, last.tag)) {
       return false; // Different patch set, author, or tag.
@@ -93,10 +94,19 @@
   }
 
   Account.Id getAccountId() {
-    Account.Id id = get(0).who;
+    Account.Id id = get(0).user;
     for (int i = 1; i < size(); i++) {
-      checkState(Objects.equals(id, get(i).who),
-          "mismatched users in EventList: %s != %s", id, get(i).who);
+      checkState(Objects.equals(id, get(i).user),
+          "mismatched users in EventList: %s != %s", id, get(i).user);
+    }
+    return id;
+  }
+
+  Account.Id getRealAccountId() {
+    Account.Id id = get(0).realUser;
+    for (int i = 1; i < size(); i++) {
+      checkState(Objects.equals(id, get(i).realUser),
+          "mismatched real users in EventList: %s != %s", id, get(i).realUser);
     }
     return id;
   }
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..9babc28 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
@@ -25,7 +25,7 @@
   private final Change noteDbChange;
 
   FinalUpdatesEvent(Change change, Change noteDbChange) {
-    super(change.currentPatchSetId(), change.getOwner(),
+    super(change.currentPatchSetId(), change.getOwner(), change.getOwner(),
         change.getLastUpdatedOn(), change.getCreatedOn(), null);
     this.change = change;
     this.noteDbChange = noteDbChange;
@@ -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/HashtagsEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java
index 21b3b6e..f5bea3e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/HashtagsEvent.java
@@ -27,7 +27,7 @@
 
   HashtagsEvent(PatchSet.Id psId, Account.Id who, Timestamp when,
       Set<String> hashtags, Timestamp changeCreatdOn) {
-    super(psId, who, when, changeCreatdOn,
+    super(psId, who, who, when, changeCreatdOn,
         // Somewhat confusingly, hashtags do not use the setTag method on
         // AbstractChangeUpdate, so pass null as the tag.
         null);
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..abac1b0 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
@@ -35,7 +35,7 @@
   boolean createChange;
 
   PatchSetEvent(Change change, PatchSet ps, RevWalk rw) {
-    super(ps.getId(), ps.getUploader(), ps.getCreatedOn(),
+    super(ps.getId(), ps.getUploader(), ps.getUploader(), ps.getCreatedOn(),
         change.getCreatedOn(), null);
     this.change = change;
     this.ps = ps;
@@ -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/ReviewerEvent.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java
index ef9c5c6..c82f108 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/rebuild/ReviewerEvent.java
@@ -34,7 +34,13 @@
         // (although as an implementation detail they were in ReviewDb). Just
         // use the latest patch set at the time of the event.
         null,
-        reviewer.getColumnKey(), reviewer.getValue(), changeCreatedOn, null);
+        reviewer.getColumnKey(),
+        // TODO(dborowitz): Real account ID shouldn't really matter for
+        // reviewers, but we might have to deal with this to avoid ChangeBundle
+        // diffs when run against real data.
+        reviewer.getColumnKey(),
+        reviewer.getValue(),
+        changeCreatedOn, null);
     this.reviewer = reviewer;
   }
 
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..c7632e8
--- /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, 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/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
index be9bbad..73d7b1e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/plugins/JarScanner.java
@@ -195,7 +195,7 @@
     Iterable<String> exports;
 
     private ClassData(Iterable<String> exports) {
-      super(Opcodes.ASM4);
+      super(Opcodes.ASM5);
       this.exports = exports;
     }
 
@@ -264,7 +264,7 @@
   private abstract static class AbstractAnnotationVisitor extends
       AnnotationVisitor {
     AbstractAnnotationVisitor() {
-      super(Opcodes.ASM4);
+      super(Opcodes.ASM5);
     }
 
     @Override
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
index ab14d8b..a7a8191 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/SchemaVersion.java
@@ -33,7 +33,7 @@
 /** A version of the database schema. */
 public abstract class SchemaVersion {
   /** The current schema version. */
-  public static final Class<Schema_132> C = Schema_132.class;
+  public static final Class<Schema_133> C = Schema_133.class;
 
   public static int getBinaryVersion() {
     return guessVersion(C);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_133.java b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_133.java
new file mode 100644
index 0000000..31d330b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/schema/Schema_133.java
@@ -0,0 +1,25 @@
+// 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.schema;
+
+import com.google.inject.Inject;
+import com.google.inject.Provider;
+
+public class Schema_133 extends SchemaVersion {
+  @Inject
+  Schema_133(Provider<Schema_132> prior) {
+    super(prior);
+  }
+}
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
new file mode 100644
index 0000000..33dd7b8
--- /dev/null
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/mail/MergedHtml.soy
@@ -0,0 +1,53 @@
+/**
+ * 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.
+ */
+
+{namespace com.google.gerrit.server.mail.template}
+
+/**
+ * @param change
+ * @param email
+ * @param fromName
+ */
+{template .MergedHtml autoescape="strict" kind="html"}
+  <p>
+    {$fromName} has submitted this change and it was merged.
+  </p>
+
+  {if $email.changeUrl}
+    <p>
+      {call .ViewChangeButton data="all" /}
+    </p>
+  {/if}
+
+  <p>
+    Change subject: {$change.subject}
+  </p>
+  <hr/>
+
+  <pre>
+    {$email.changeDetail}
+  </pre>
+
+  <pre>
+    {$email.approvals}
+  </pre>
+
+  {if $email.includeDiff}
+    <pre>
+      {$email.unifiedDiff}
+    </pre>
+  {/if}
+{/template}
\ No newline at end of file
diff --git a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh
index 256be9c..d76c239 100644
--- a/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh
+++ b/gerrit-server/src/main/resources/com/google/gerrit/server/tools/root/scripts/preview_submit_test.sh
@@ -37,7 +37,7 @@
 fi
 
 curl --digest -u $gerrituser -w '%{http_code}' -o preview \
-    $server/a/changes/$changeId/revisions/current/preview_submit?format=zip >http_code
+    $server/a/changes/$changeId/revisions/current/preview_submit?format=tgz >http_code
 if ! grep 200 http_code >/dev/null
 then
         # error out:
@@ -45,9 +45,9 @@
         cat preview
         echo
 else
-        # valid zip file, extract and obtain a bundle for each project
+        # valid tgz file, extract and obtain a bundle for each project
         mkdir tmp-bundles
-        unzip preview -d tmp-bundles
+        (cd tmp-bundles && tar -zxf ../preview)
         for project in $(cd tmp-bundles && find -type f)
         do
                 # Projects may contain slashes, so create the required
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
index 393b8f8..ea33e65 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/ChangeNotesTest.java
@@ -46,6 +46,7 @@
 import com.google.gerrit.reviewdb.client.PatchSetApproval;
 import com.google.gerrit.reviewdb.client.RevId;
 import com.google.gerrit.reviewdb.server.ReviewDbUtil;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.ReviewerSet;
 import com.google.gerrit.server.config.GerritServerId;
@@ -1666,6 +1667,59 @@
   }
 
   @Test
+  public void patchLineCommentNotesFormatRealAuthor() throws Exception {
+    Change c = newChange();
+    CurrentUser ownerAsOtherUser =
+        userFactory.runAs(null, otherUserId, changeOwner);
+    ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
+    String uuid = "uuid";
+    String message = "comment";
+    CommentRange range = new CommentRange(1, 1, 2, 1);
+    Timestamp time = TimeUtil.nowTs();
+    PatchSet.Id psId = c.currentPatchSetId();
+    RevId revId = new RevId("abcd1234abcd1234abcd1234abcd1234abcd1234");
+
+    Comment comment = newComment(psId, "file", uuid, range,
+        range.getEndLine(), otherUser, null, time, message, (short) 1,
+        revId.get());
+    comment.setRealAuthor(changeOwner.getAccountId());
+    update.setPatchSetId(psId);
+    update.putComment(Status.PUBLISHED, comment);
+    update.commit();
+
+    ChangeNotes notes = newNotes(c);
+
+    try (RevWalk walk = new RevWalk(repo)) {
+      ArrayList<Note> notesInTree =
+          Lists.newArrayList(notes.revisionNoteMap.noteMap.iterator());
+      Note note = Iterables.getOnlyElement(notesInTree);
+
+      byte[] bytes =
+          walk.getObjectReader().open(
+              note.getData(), Constants.OBJ_BLOB).getBytes();
+      String noteString = new String(bytes, UTF_8);
+
+      if (!testJson()) {
+        assertThat(noteString).isEqualTo(
+            "Revision: abcd1234abcd1234abcd1234abcd1234abcd1234\n"
+                + "Patch-set: 1\n"
+                + "File: file\n"
+                + "\n"
+                + "1:1-2:1\n"
+                + ChangeNoteUtil.formatTime(serverIdent, time) + "\n"
+                + "Author: Other Account <2@gerrit>\n"
+                + "Real-author: Change Owner <1@gerrit>\n"
+                + "UUID: uuid\n"
+                + "Bytes: 7\n"
+                + "comment\n"
+                + "\n");
+      }
+    }
+    assertThat(notes.getComments())
+        .isEqualTo(ImmutableMultimap.of(revId, comment));
+  }
+
+  @Test
   public void patchLineCommentNotesFormatWeirdUser() throws Exception {
     Account account = new Account(new Account.Id(3), TimeUtil.nowTs());
     account.setFullName("Weird\n\u0002<User>\n");
@@ -2355,6 +2409,21 @@
     assertThat(comments.get(1).message).isEqualTo("comment 2");
   }
 
+  @Test
+  public void realUser() throws Exception {
+    Change c = newChange();
+    CurrentUser ownerAsOtherUser =
+        userFactory.runAs(null, otherUserId, changeOwner);
+    ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
+    update.setChangeMessage("Message on behalf of other user");
+    update.commit();
+
+    ChangeMessage msg = Iterables.getLast(newNotes(c).getChangeMessages());
+    assertThat(msg.getMessage()).isEqualTo("Message on behalf of other user");
+    assertThat(msg.getAuthor()).isEqualTo(otherUserId);
+    assertThat(msg.getRealAuthor()).isEqualTo(changeOwner.getAccountId());
+  }
+
   private boolean testJson() {
     return noteUtil.getWriteJson();
   }
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
index 5a8c12b..b674159 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/CommitMessageOutputTest.java
@@ -22,6 +22,7 @@
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.util.RequestId;
 import com.google.gerrit.testutil.ConfigSuite;
 import com.google.gerrit.testutil.TestChanges;
@@ -332,6 +333,29 @@
         update.getResult());
   }
 
+  @Test
+  public void realUser() throws Exception {
+    Change c = newChange();
+    CurrentUser ownerAsOtherUser =
+        userFactory.runAs(null, otherUserId, changeOwner);
+    ChangeUpdate update = newUpdate(c, ownerAsOtherUser);
+    update.setChangeMessage("Message on behalf of other user");
+    update.commit();
+
+    RevCommit commit = parseCommit(update.getResult());
+    PersonIdent author = commit.getAuthorIdent();
+    assertThat(author.getName()).isEqualTo("Other Account");
+    assertThat(author.getEmailAddress()).isEqualTo("2@gerrit");
+
+    assertBodyEquals("Update patch set 1\n"
+        + "\n"
+        + "Message on behalf of other user\n"
+        + "\n"
+        + "Patch-set: 1\n"
+        + "Real-user: Change Owner <1@gerrit>\n",
+        commit);
+  }
+
   private RevCommit parseCommit(ObjectId id) throws Exception {
     if (id instanceof RevCommit) {
       return (RevCommit) id;
diff --git a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java
index 969adf0..f5fdf13 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/notedb/rebuild/EventSorterTest.java
@@ -37,7 +37,7 @@
     protected TestEvent(Timestamp when) {
       super(
           new PatchSet.Id(new Change.Id(1), 1),
-          new Account.Id(1000),
+          new Account.Id(1000), new Account.Id(1000),
           when, changeCreatedOn, null);
     }
 
diff --git a/lib/asciidoctor/BUILD b/lib/asciidoctor/BUILD
index 4b4e958..d1b98f8 100644
--- a/lib/asciidoctor/BUILD
+++ b/lib/asciidoctor/BUILD
@@ -1,3 +1,10 @@
+java_binary(
+  name = "asciidoc",
+  main_class = "AsciiDoctor",
+  runtime_deps = [":asciidoc_lib"],
+  visibility = ["//visibility:public"],
+)
+
 java_library(
   name = "asciidoc_lib",
   srcs = ["java/AsciiDoctor.java"],
diff --git a/lib/asciidoctor/java/AsciiDoctor.java b/lib/asciidoctor/java/AsciiDoctor.java
index 8e18feb1..c66c4aa 100644
--- a/lib/asciidoctor/java/AsciiDoctor.java
+++ b/lib/asciidoctor/java/AsciiDoctor.java
@@ -24,12 +24,14 @@
 import org.kohsuke.args4j.CmdLineException;
 import org.kohsuke.args4j.CmdLineParser;
 import org.kohsuke.args4j.Option;
-
+import java.io.BufferedReader;
 import java.io.File;
 import java.io.FileInputStream;
 import java.io.FileOutputStream;
+import java.io.FileReader;
 import java.io.FilenameFilter;
 import java.io.IOException;
+import java.nio.file.Files;
 import java.util.ArrayList;
 import java.util.HashMap;
 import java.util.List;
@@ -41,6 +43,7 @@
 
   private static final String DOCTYPE = "article";
   private static final String ERUBY = "erb";
+  private static final String REVNUMBER_NAME = "revnumber";
 
   @Option(name = "-b", usage = "set output format backend")
   private String backend = "html5";
@@ -60,13 +63,26 @@
   @Option(name = "--tmp", usage = "temporary output path")
   private File tmpdir;
 
+  @Option(name = "--mktmp", usage = "create a temporary output path")
+  private boolean mktmp;
+
   @Option(name = "-a", usage =
       "a list of attributes, in the form key or key=value pair")
   private List<String> attributes = new ArrayList<>();
 
+  @Option(name = "--bazel", usage =
+      "bazel mode: generate multiple output files instead of a single zip file")
+  private boolean bazel;
+
+  @Option(name = "--revnumber-file", usage =
+      "the file contains revnumber string")
+  private File revnumberFile;
+
   @Argument(usage = "input files")
   private List<String> inputFiles = new ArrayList<>();
 
+  private String revnumber;
+
   public static String mapInFileToOutFile(
       String inFile, String inExt, String outExt) {
     String basename = new File(inFile).getName();
@@ -82,19 +98,26 @@
     return basename + outExt;
   }
 
-  private Options createOptions(File outputFile) {
+  private Options createOptions(File base, File outputFile) {
     OptionsBuilder optionsBuilder = OptionsBuilder.options();
 
-    optionsBuilder.backend(backend).docType(DOCTYPE).eruby(ERUBY)
-      .safe(SafeMode.UNSAFE).baseDir(basedir);
-    // XXX(fishywang): ideally we should just output to a string and add the
-    // content into zip. But asciidoctor will actually ignore all attributes if
-    // not output to a file. So we *have* to output to a file then read the
-    // content of the file into zip.
-    optionsBuilder.toFile(outputFile);
+    optionsBuilder
+        .backend(backend)
+        .docType(DOCTYPE)
+        .eruby(ERUBY)
+        .safe(SafeMode.UNSAFE)
+        .baseDir(base)
+        // XXX(fishywang): ideally we should just output to a string and add the
+        // content into zip. But asciidoctor will actually ignore all attributes
+        // if not output to a file. So we *have* to output to a file then read
+        // the content of the file into zip.
+        .toFile(outputFile);
 
     AttributesBuilder attributesBuilder = AttributesBuilder.attributes();
     attributesBuilder.attributes(getAttributes());
+    if (revnumber != null) {
+      attributesBuilder.attribute(REVNUMBER_NAME, revnumber);
+    }
     optionsBuilder.attributes(attributesBuilder.get());
 
     return optionsBuilder.get();
@@ -133,31 +156,52 @@
       return;
     }
 
-    try (ZipOutputStream zip = new ZipOutputStream(new FileOutputStream(zipFile))) {
-      for (String inputFile : inputFiles) {
-        if (!inputFile.endsWith(inExt)) {
-          // We have to use UNSAFE mode in order to make embedding work. But in
-          // UNSAFE mode we'll also need css file in the same directory, so we
-          // have to add css files into the SRCS.
-          continue;
-        }
-
-        String outName = mapInFileToOutFile(inputFile, inExt, outExt);
-        File out = new File(tmpdir, outName);
-        out.getParentFile().mkdirs();
-        Options options = createOptions(out);
-        renderInput(options, new File(inputFile));
-        zipFile(out, outName, zip);
+    if (revnumberFile != null) {
+      try (BufferedReader reader =
+          new BufferedReader(new FileReader(revnumberFile))) {
+        revnumber = reader.readLine();
       }
+    }
 
-      File[] cssFiles = tmpdir.listFiles(new FilenameFilter() {
-        @Override
-        public boolean accept(File dir, String name) {
-          return name.endsWith(".css");
+    if (mktmp) {
+      tmpdir = Files.createTempDirectory("asciidoctor-").toFile();
+    }
+
+    if (bazel) {
+      renderFiles(inputFiles, null);
+    } else {
+      try (ZipOutputStream zip =
+          new ZipOutputStream(new FileOutputStream(zipFile))) {
+        renderFiles(inputFiles, zip);
+
+        File[] cssFiles = tmpdir.listFiles(new FilenameFilter() {
+          @Override
+          public boolean accept(File dir, String name) {
+            return name.endsWith(".css");
+          }
+        });
+        for (File css : cssFiles) {
+          zipFile(css, css.getName(), zip);
         }
-      });
-      for (File css : cssFiles) {
-        zipFile(css, css.getName(), zip);
+      }
+    }
+  }
+
+  private void renderFiles(List<String> inputFiles, ZipOutputStream zip)
+      throws IOException {
+    Asciidoctor asciidoctor = JRubyAsciidoctor.create();
+    for (String inputFile : inputFiles) {
+      String outName = mapInFileToOutFile(inputFile, inExt, outExt);
+      File out = bazel ? new File(outName) : new File(tmpdir, outName);
+      if (!bazel) {
+        out.getParentFile().mkdirs();
+      }
+      File input = new File(inputFile);
+      Options options =
+          createOptions(basedir != null ? basedir : input.getParentFile(), out);
+      asciidoctor.renderFile(input, options);
+      if (zip != null) {
+        zipFile(out, outName, zip);
       }
     }
   }
@@ -171,11 +215,6 @@
     zip.closeEntry();
   }
 
-  private void renderInput(Options options, File inputFile) {
-    Asciidoctor asciidoctor = JRubyAsciidoctor.create();
-    asciidoctor.renderFile(inputFile, options);
-  }
-
   public static void main(String[] args) {
     try {
       new AsciiDoctor().invoke(args);
diff --git a/lib/js/BUCK b/lib/js/BUCK
index 1c46d35..bb31b94 100644
--- a/lib/js/BUCK
+++ b/lib/js/BUCK
@@ -328,10 +328,10 @@
 bower_component(
   name = 'polymer',
   package = 'polymer/polymer',
-  version = '1.4.0',
+  version = '1.7.0',
   deps = [':webcomponentsjs'],
   license = 'polymer',
-  sha1 = 'b84725939ead7c7bdf9917b065f68ef8dc790d06',
+  sha1 = 'e70caa58fdee0ce51c805d548f544f74cc27d143',
 )
 
 bower_component(
diff --git a/lib/prolog/prolog.bzl b/lib/prolog/prolog.bzl
index 3afb031..d4e9e08 100644
--- a/lib/prolog/prolog.bzl
+++ b/lib/prolog/prolog.bzl
@@ -22,7 +22,7 @@
   genrule2(
     name = name + '__pl2j',
     cmd = '$(location //lib/prolog:compiler_bin) ' +
-      '$$TMP $@ ' +
+      '$$(dirname $@) $@ ' +
       '$(SRCS)',
     srcs = srcs,
     tools = ['//lib/prolog:compiler_bin'],
diff --git a/plugins/BUILD b/plugins/BUILD
new file mode 100644
index 0000000..ffa0713
--- /dev/null
+++ b/plugins/BUILD
@@ -0,0 +1,22 @@
+load('//tools/bzl:genrule2.bzl', 'genrule2')
+
+CORE = [
+  'commit-message-length-validator',
+  'download-commands',
+  'hooks',
+  'replication',
+  'reviewnotes',
+  'singleusergroup'
+]
+
+genrule2(
+  name = 'core',
+  srcs = ['//plugins/%s:%s_deploy.jar' % (n, n) for n in CORE],
+  cmd = 'mkdir -p $$TMP/WEB-INF/plugins;' +
+    'for s in $(SRCS) ; do ' +
+    'ln -s $$ROOT/$$s $$TMP/WEB-INF/plugins;done;' +
+    'cd $$TMP;' +
+    'zip -qr $$ROOT/$@ .',
+  out = 'core.zip',
+  visibility = ['//visibility:public'],
+)
diff --git a/plugins/commit-message-length-validator b/plugins/commit-message-length-validator
index 474e06c..76b9115 160000
--- a/plugins/commit-message-length-validator
+++ b/plugins/commit-message-length-validator
@@ -1 +1 @@
-Subproject commit 474e06cccb1a6a821e19f70b8e03aa1f816ff219
+Subproject commit 76b9115b830cab453c12dd9014f5130c7b7f2ce5
diff --git a/plugins/cookbook-plugin b/plugins/cookbook-plugin
index 7a943fc..09981c0 160000
--- a/plugins/cookbook-plugin
+++ b/plugins/cookbook-plugin
@@ -1 +1 @@
-Subproject commit 7a943fc5b1052068842c13793754cf161b45e370
+Subproject commit 09981c0638f7241a4f435baaa96bd6112a1edaa9
diff --git a/plugins/download-commands b/plugins/download-commands
index e5f1a9b..6326db6 160000
--- a/plugins/download-commands
+++ b/plugins/download-commands
@@ -1 +1 @@
-Subproject commit e5f1a9ba057e9b287a7dd2b7e0136eaef183aa6c
+Subproject commit 6326db67dfa45b13a0c427643bbfa617c18855d7
diff --git a/plugins/replication b/plugins/replication
index 3103cdc..b3606eb 160000
--- a/plugins/replication
+++ b/plugins/replication
@@ -1 +1 @@
-Subproject commit 3103cdc028dfb1d7bc51883981cad67d94e98352
+Subproject commit b3606eb38eb8edc166260184177e68386539381a
diff --git a/plugins/reviewnotes b/plugins/reviewnotes
index eddd8bc..e9c66c6 160000
--- a/plugins/reviewnotes
+++ b/plugins/reviewnotes
@@ -1 +1 @@
-Subproject commit eddd8bc74b180cf80e7ebae2c3f73157a1e7118e
+Subproject commit e9c66c6f08edb641d3c935c2fdcaa3fbf3a85d29
diff --git a/plugins/singleusergroup b/plugins/singleusergroup
index 5f240eb..e985959 160000
--- a/plugins/singleusergroup
+++ b/plugins/singleusergroup
@@ -1 +1 @@
-Subproject commit 5f240eba762f7447ad12994ce6988ee976bb5c3b
+Subproject commit e9859591be48a157c7114d5f3c6acdf27384a408
diff --git a/polygerrit-ui/README.md b/polygerrit-ui/README.md
index 383fb50..1e548d5 100644
--- a/polygerrit-ui/README.md
+++ b/polygerrit-ui/README.md
@@ -13,9 +13,21 @@
 All other platforms: [download from
 nodejs.org](https://nodejs.org/en/download/).
 
-## Optional: installing [go](https://golang.org/)
+## Installing [Buck](https://buckbuild.com/)
 
-This is only required for running the ```run-server.sh``` script for testing. See below.
+Follow the instructions
+[here](https://gerrit-review.googlesource.com/Documentation/dev-buck.html#_installation)
+to get and install Buck.
+
+## Local UI, Production Data
+
+This is a quick and easy way to test your local changes against real data.
+Unfortunately, you can't sign in, so testing certain features will require
+you to use the "test data" technique described below.
+
+### Installing [go](https://golang.org/)
+
+This is required for running the `run-server.sh` script below.
 
 ```sh
 # Debian/Ubuntu
@@ -27,18 +39,18 @@
 
 All other platforms: [download from golang.org](https://golang.org/)
 
-# Add [go] to your path
+Then add go to your path:
 
 ```
 PATH=$PATH:/usr/local/go/bin
 ```
 
-## Local UI, Production Data
+### Running the server
 
 To test the local UI against gerrit-review.googlesource.com:
 
 ```sh
-./polygerrit-ui/run-server.sh
+./run-server.sh
 ```
 
 Then visit http://localhost:8081
@@ -47,10 +59,8 @@
 
 One-time setup:
 
-1. [Install Buck](https://gerrit-review.googlesource.com/Documentation/dev-buck.html#_installation)
-   for building Gerrit.
-2. [Build Gerrit](https://gerrit-review.googlesource.com/Documentation/dev-buck.html#_gerrit_development_war_file)
-   and set up a local test site. Docs
+1. [Build Gerrit](https://gerrit-review.googlesource.com/Documentation/dev-buck.html#_gerrit_development_war_file)
+2. Set up a local test site. Docs
    [here](https://gerrit-review.googlesource.com/Documentation/install-quick.html) and
    [here](https://gerrit-review.googlesource.com/Documentation/dev-readme.html#init).
 
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
similarity index 94%
rename from polygerrit-ui/app/behaviors/gr-path-list-behavior.html
rename to polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
index fefecd4..fa8289f 100644
--- a/polygerrit-ui/app/behaviors/gr-path-list-behavior.html
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior.html
@@ -31,11 +31,11 @@
 
       var aLastDotIndex = a.lastIndexOf('.');
       var aExt = a.substr(aLastDotIndex + 1);
-      var aFile = a.substr(0, aLastDotIndex);
+      var aFile = a.substr(0, aLastDotIndex) || a;
 
       var bLastDotIndex = b.lastIndexOf('.');
       var bExt = b.substr(bLastDotIndex + 1);
-      var bFile = b.substr(0, bLastDotIndex);
+      var bFile = b.substr(0, bLastDotIndex) || b;
 
       // Sort header files above others with the same base name.
       var headerExts = ['h', 'hxx', 'hpp'];
diff --git a/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
new file mode 100644
index 0000000..530b7be
--- /dev/null
+++ b/polygerrit-ui/app/behaviors/gr-path-list-behavior/gr-path-list-behavior_test.html
@@ -0,0 +1,37 @@
+<!--
+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.
+-->
+<script src="../../bower_components/web-component-tester/browser.js"></script>
+<title>gr-path-list-behavior</title>
+
+<link rel="import" href="../../bower_components/iron-test-helpers/iron-test-helpers.html">
+<link rel="import" href="gr-path-list-behavior.html">
+
+<script>
+  suite('gr-path-list-behavior tests', function() {
+    test('special sort', function() {
+      var sort = Gerrit.PathListBehavior.specialFilePathCompare;
+      var testFiles = [
+        '/a.h',
+        '/a.cpp',
+        '/COMMIT_MSG',
+        '/asdasd',
+        '/mrPeanutbutter.py'
+      ];
+      assert.deepEqual(testFiles.sort(sort),
+          ['/COMMIT_MSG', '/a.h', '/a.cpp', '/asdasd', '/mrPeanutbutter.py']);
+    });
+  });
+</script>
diff --git a/polygerrit-ui/app/behaviors/rest-client-behavior.html b/polygerrit-ui/app/behaviors/rest-client-behavior.html
index 4def9b2..b7cf467 100644
--- a/polygerrit-ui/app/behaviors/rest-client-behavior.html
+++ b/polygerrit-ui/app/behaviors/rest-client-behavior.html
@@ -81,7 +81,13 @@
       COMMIT_FOOTERS: 17,
 
       // Include push certificate information along with any patch sets.
-      PUSH_CERTIFICATES: 18
+      PUSH_CERTIFICATES: 18,
+
+      // Include change's reviewer updates.
+      REVIEWER_UPDATES: 19,
+
+      // Set the submittable boolean.
+      SUBMITTABLE: 20
     },
 
     listChangesOptionsToHex: function() {
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
index 4f9a09d..2260b38 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.js
@@ -266,12 +266,31 @@
     },
 
     _canSubmitChange: function() {
-      return this.$.jsAPI.canSubmitChange();
+      return this.$.jsAPI.canSubmitChange(this.change,
+          this._getRevision(this.change, this.patchNum));
+    },
+
+    _getRevision: function(change, patchNum) {
+      var num = window.parseInt(patchNum, 10);
+      for (var hash in change.revisions) {
+        var rev = change.revisions[hash];
+        if (rev._number === num) {
+          return rev;
+        }
+      }
+      return null;
     },
 
     _modifyRevertMsg: function() {
       return this.$.jsAPI.modifyRevertMsg(this.change,
-                                          this.$.confirmRevertDialog.message);
+          this.$.confirmRevertDialog.message);
+    },
+
+    showRevertDialog: function() {
+      this.$.confirmRevertDialog.populateRevertMessage(
+          this.commitMessage, this.change.current_revision);
+      this.$.confirmRevertDialog.message = this._modifyRevertMsg();
+      this._showActionDialog(this.$.confirmRevertDialog);
     },
 
     _handleActionTap: function(e) {
@@ -286,9 +305,7 @@
       if (type === ActionType.REVISION) {
         this._handleRevisionAction(key);
       } else if (key === ChangeActions.REVERT) {
-        this.$.confirmRevertDialog.populateRevertMessage(this.commitMessage);
-        this.$.confirmRevertDialog.message = this._modifyRevertMsg();
-        this._showActionDialog(this.$.confirmRevertDialog);
+        this.showRevertDialog();
       } else if (key === ChangeActions.ABANDON) {
         this._showActionDialog(this.$.confirmAbandonDialog);
       } else {
@@ -302,6 +319,7 @@
           this._showActionDialog(this.$.confirmRebase);
           break;
         case RevisionActions.CHERRYPICK:
+          this.$.confirmCherrypick.branch = '';
           this._showActionDialog(this.$.confirmCherrypick);
           break;
         case RevisionActions.SUBMIT:
diff --git a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
index 83d5bdc..a342c87 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions_test.html
@@ -125,7 +125,26 @@
       });
     });
 
+    test('get revision object from change', function() {
+      var revObj = {_number: 2, foo: 'bar'};
+      var change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: revObj,
+        },
+      };
+      assert.deepEqual(element._getRevision(change, '2'), revObj);
+    });
+
     test('submit change', function(done) {
+      element.change = {
+        revisions: {
+          rev1: {_number: 1},
+          rev2: {_number: 2},
+        },
+      };
+      element.patchNum = '2';
+
       flush(function() {
         var submitButton = element.$$('gr-button[data-action-key="submit"]');
         assert.ok(submitButton);
@@ -223,8 +242,9 @@
       });
 
       test('works', function() {
-        var rebaseButton = element.$$('gr-button[data-action-key="rebase"]');
-        MockInteractions.tap(rebaseButton);
+        var cherryPickButton =
+            element.$$('gr-button[data-action-key="cherrypick"]');
+        MockInteractions.tap(cherryPickButton);
         var action = {
           __key: 'cherrypick',
           __type: 'revision',
@@ -252,6 +272,25 @@
           }
         ]);
       });
+
+      test('branch name cleared when re-open cherrypick', function() {
+        var cherryPickButton =
+            element.$$('gr-button[data-action-key="cherrypick"]');
+        var action = {
+          __key: 'cherrypick',
+          __type: 'revision',
+          __primary: false,
+          enabled: true,
+          label: 'Cherry Pick',
+          method: 'POST',
+          title: 'Cherry pick change to a different branch',
+        };
+        var emptyBranchName = '';
+        element.$.confirmCherrypick.branch = 'master';
+
+        MockInteractions.tap(cherryPickButton);
+        assert.equal(element.$.confirmCherrypick.branch, emptyBranchName);
+      });
     });
 
     test('custom actions', function(done) {
@@ -295,6 +334,9 @@
       });
 
       test('revert change with plugin hook', function(done) {
+        element.change = {
+          current_revision: 'abc1234',
+        };
         var newRevertMsg = 'Modified revert msg';
         var modifyRevertMsgStub = sinon.stub(element, '_modifyRevertMsg',
             function() { return newRevertMsg; });
@@ -314,6 +356,9 @@
       });
 
       test('works', function() {
+        element.change = {
+          current_revision: 'abc1234',
+        };
         var populateRevertMsgStub = sinon.stub(
             element.$.confirmRevertDialog, 'populateRevertMessage',
             function() { return 'original msg'; });
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
index 84b2391..108ac92 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.html
@@ -16,7 +16,7 @@
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../../behaviors/rest-client-behavior.html">
-<link rel="import" href="../../shared/gr-account-link/gr-account-link.html">
+<link rel="import" href="../../shared/gr-account-chip/gr-account-chip.html">
 <link rel="import" href="../../shared/gr-label/gr-label.html">
 <link rel="import" href="../../shared/gr-date-formatter/gr-date-formatter.html">
 <link rel="import" href="../../shared/gr-editable-label/gr-editable-label.html">
@@ -45,12 +45,13 @@
       }
       .labelValueContainer .approved,
       .labelValueContainer .notApproved {
-        display: inline-block;
+        display: inline-flex;
         padding: .1em .3em;
         border-radius: 3px;
       }
       .labelValue {
         display: inline-block;
+        padding-right: .3em;
       }
       .approved {
         background-color: #d4ffd4;
@@ -150,7 +151,7 @@
         <span class="title">[[labelName]]</span>
         <span class="value">
           <template is="dom-repeat"
-              items="[[_computeLabelValues(labelName, change.labels)]]"
+              items="[[_computeLabelValues(labelName, change.labels.*)]]"
               as="label">
             <div class="labelValueContainer">
               <span class$="[[label.className]]">
@@ -160,7 +161,13 @@
                     class="labelValue">
                   [[label.value]]
                 </gr-label>
-                <gr-account-link account="[[label.account]]"></gr-account-link>
+                <gr-account-chip
+                    account="[[label.account]]"
+                    data-account-id$="[[label.account._account_id]]"
+                    label-name="[[labelName]]"
+                    removable="[[_computeCanDeleteVote(label.account, mutable)]]"
+                    transparent-background
+                    on-remove="_onDeleteVote"></gr-account-chip>
               </span>
             </div>
           </template>
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index 20c117e..28935ce 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
@@ -55,8 +55,9 @@
       return Object.keys(labels).sort();
     },
 
-    _computeLabelValues: function(labelName, labels) {
+    _computeLabelValues: function(labelName, _labels) {
       var result = [];
+      var labels = _labels.base;
       var t = labels[labelName];
       if (!t) { return result; }
       var approvals = t.all || [];
@@ -101,5 +102,46 @@
     _computeShowReviewersByState: function(serverConfig) {
       return !!serverConfig.note_db_enabled;
     },
+
+    /**
+     * A user is able to delete a vote iff the mutable property is true and the
+     * reviewer that left the vote exists in the list of removable_reviewers
+     * received from the backend.
+     *
+     * @param {!Object} reviewer An object describing the reviewer that left the
+     *     vote.
+     * @param {boolean} mutable this.mutable describes whether the
+     *     change-metadata section is modifiable by the current user.
+     */
+    _computeCanDeleteVote: function(reviewer, mutable) {
+      if (!mutable) { return false; }
+      for (var i = 0; i < this.change.removable_reviewers.length; i++) {
+        if (this.change.removable_reviewers[i]._account_id ===
+            reviewer._account_id) {
+          return true;
+        }
+      }
+      return false;
+    },
+
+    _onDeleteVote: function(e) {
+      e.preventDefault();
+      var target = Polymer.dom(e).rootTarget;
+      var labelName = target.labelName;
+      var accountID = parseInt(target.getAttribute('data-account-id'), 10);
+      this._xhrPromise =
+          this.$.restAPI.deleteVote(this.change.id, accountID, labelName)
+          .then(function(response) {
+        if (!response.ok) { return response; }
+
+        var labels = this.change.labels[labelName].all || [];
+        for (var i = 0; i < labels.length; i++) {
+          if (labels[i]._account_id === accountID) {
+            this.splice(['change.labels', labelName, 'all'], i, 1);
+            break;
+          }
+        }
+      }.bind(this));
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
index a2d4946..218e124 100644
--- a/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata_test.html
@@ -77,5 +77,74 @@
       element.serverConfig = {note_db_enabled: true};
       assert.isTrue(hasCc());
     });
+
+    suite('remove reviewer votes', function() {
+      var sandbox;
+      setup(function() {
+        sandbox = sinon.sandbox.create();
+        sandbox.stub(element, '_computeValueTooltip').returns('');
+        sandbox.stub(element, '_computeTopicReadOnly').returns(true);
+        element.change = {
+          status: 'NEW',
+          submit_type: 'CHERRY_PICK',
+          labels: {
+            test: {
+              all: [{_account_id: 1, name: 'bojack', value: 1}],
+              default_value: 0,
+            },
+          },
+          removable_reviewers: [],
+        };
+      });
+
+      teardown(function() {
+        sandbox.restore();
+      });
+
+      test('_computeCanDeleteVote hides delete button', function() {
+        flushAsynchronousOperations();
+        var button = element.$$('gr-account-chip').$$('gr-button');
+        assert.isTrue(button.hasAttribute('hidden'));
+        element.mutable = true;
+        assert.isTrue(button.hasAttribute('hidden'));
+      });
+
+      test('_computeCanDeleteVote shows delete button', function() {
+        element.change.removable_reviewers = [
+          {
+            _account_id: 1,
+            name: 'bojack',
+          }
+        ];
+        element.mutable = true;
+        flushAsynchronousOperations();
+        var button = element.$$('gr-account-chip').$$('gr-button');
+        assert.isFalse(button.hasAttribute('hidden'));
+      });
+
+      test('deletes votes', function(done) {
+        sandbox.stub(element.$.restAPI, 'deleteVote')
+            .returns(Promise.resolve({'ok': true}));
+        element.change.removable_reviewers = [
+          {
+            _account_id: 1,
+            name: 'bojack',
+          }
+        ];
+        element.mutable = true;
+        flushAsynchronousOperations();
+        var button = element.$$('gr-account-chip').$$('gr-button');
+        MockInteractions.tap(button);
+        flushAsynchronousOperations();
+        var spliceStub = sinon.stub(element, 'splice',
+            function(path, index, length) {
+          assert.deepEqual(path, ['change.labels', 'test', 'all']);
+          assert.equal(index, 0);
+          assert.equal(length, 1);
+          spliceStub.restore();
+          done();
+        });
+      });
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
index 37c9f62..3d4e10d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.html
@@ -224,6 +224,10 @@
               mutable="[[_loggedIn]]"
               on-show-reply-dialog="_handleShowReplyDialog">
           </gr-change-metadata>
+          <!-- Plugins insert content into following container.
+               Stop-gap until PolyGerrit plugins interface is ready.
+               This will not work with Shadow DOM. -->
+          <div id="change_plugins"></div>
         </div>
         <div class="changeInfo-column mainChangeInfo">
           <div class="commitActions" hidden$="[[!_loggedIn]]"">
@@ -298,7 +302,8 @@
             drafts="[[_diffDrafts]]"
             revisions="[[_change.revisions]]"
             projectConfig="[[_projectConfig]]"
-            selected-index="{{viewState.selectedFileIndex}}"></gr-file-list>
+            selected-index="{{viewState.selectedFileIndex}}"
+            diff-view-mode="{{viewState.diffMode}}"></gr-file-list>
       </section>
       <gr-messages-list id="messageList"
           change-num="[[_changeNum]]"
@@ -311,6 +316,7 @@
     </div>
     <gr-overlay id="downloadOverlay" with-backdrop>
       <gr-download-dialog
+          id="downloadDialog"
           change="[[_change]]"
           logged-in="[[_loggedIn]]"
           patch-num="[[_patchRange.patchNum]]"
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
index 7933031..6eaf4ff 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view.js
@@ -248,7 +248,11 @@
 
     _handleDownloadTap: function(e) {
       e.preventDefault();
-      this.$.downloadOverlay.open();
+      this.$.downloadOverlay.open().then(function() {
+        this.$.downloadOverlay
+            .setFocusStops(this.$.downloadDialog.getFocusStops());
+        this.$.downloadDialog.focus();
+      }.bind(this));
     },
 
     _handleDownloadDialogClose: function(e) {
@@ -259,7 +263,11 @@
       var msg = e.detail.message.message;
       var quoteStr = msg.split('\n').map(
           function(line) { return '> ' + line; }).join('\n') + '\n\n';
-      this.$.replyDialog.draft += quoteStr;
+
+      if (quoteStr !== this.$.replyDialog.quote) {
+        this.$.replyDialog.draft = quoteStr;
+      }
+      this.$.replyDialog.quote = quoteStr;
       this._openReplyDialog();
     },
 
@@ -360,6 +368,8 @@
 
       this._maybeShowReplyDialog();
 
+      this._maybeShowRevertDialog();
+
       this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
         change: this._change,
         patchNum: this._patchRange.patchNum,
@@ -387,6 +397,35 @@
       }
     },
 
+    _getLocationSearch: function() {
+      // Not inlining to make it easier to test.
+      return window.location.search;
+    },
+
+    _getUrlParameter: function(param) {
+      var pageURL = this._getLocationSearch().substring(1);
+      var vars = pageURL.split('&');
+      for (var i = 0; i < vars.length; i++) {
+        var name = vars[i].split('=');
+        if (name[0] == param) {
+          return name[0];
+        }
+      }
+      return null;
+    },
+
+    _maybeShowRevertDialog: function() {
+      this._getLoggedIn().then(function(loggedIn) {
+        if (!loggedIn || this._change.status !== this.ChangeStatus.MERGED) {
+          // Do not display dialog if not logged-in or the change is not merged.
+          return;
+        }
+        if (!!this._getUrlParameter('revert')) {
+          this.$.actions.showRevertDialog();
+        }
+      }.bind(this));
+    },
+
     _maybeShowReplyDialog: function() {
       this._getLoggedIn().then(function(loggedIn) {
         if (!loggedIn) { return; }
diff --git a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
index 4cc4867..b49f05d 100644
--- a/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
+++ b/polygerrit-ui/app/elements/change/gr-change-view/gr-change-view_test.html
@@ -35,6 +35,7 @@
 <script>
   suite('gr-change-view tests', function() {
     var element;
+    var TEST_SCROLL_TOP_PX = 100;
 
     setup(function() {
       stub('gr-rest-api-interface', {
@@ -444,7 +445,77 @@
       assert.equal(element._computePatchInfoClass('4', allPatcheSets), '');
     });
 
+    test('getUrlParameter functionality', function() {
+      var locationStub = sinon.stub(element, '_getLocationSearch');
+
+      locationStub.returns('?test');
+      assert.equal(element._getUrlParameter('test'), 'test');
+      locationStub.returns('?test2=12&test=3');
+      assert.equal(element._getUrlParameter('test'), 'test');
+      locationStub.returns('');
+      assert.isNull(element._getUrlParameter('test'));
+      locationStub.returns('?');
+      assert.isNull(element._getUrlParameter('test'));
+      locationStub.returns('?test2');
+      assert.isNull(element._getUrlParameter('test'));
+
+      locationStub.restore();
+    });
+
+    test('revert dialog opened with revert param', function(done) {
+      sinon.stub(element.$.restAPI, 'getLoggedIn', function() {
+        return Promise.resolve(true);
+      });
+
+      element._patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: 2,
+      };
+      element._change = {
+          change_id: 'Iad9dc96274af6946f3632be53b106ef80f7ba6ca',
+          revisions: {
+            rev1: {_number: 1},
+          },
+          current_revision: 'rev1',
+          status: element.ChangeStatus.MERGED,
+          labels: {},
+          actions: {},
+      };
+
+      var urlParamStub = sinon.stub(element, '_getUrlParameter',
+          function(param) {
+            assert.equal(param, 'revert');
+            urlParamStub.restore();
+            element.$.restAPI.getLoggedIn.restore();
+            return param;
+          });
+
+      var revertDialogStub = sinon.stub(element.$.actions, 'showRevertDialog',
+          function() {
+            revertDialogStub.restore();
+            done();
+          });
+
+      element._maybeShowRevertDialog();
+    });
+
     suite('scroll related tests', function() {
+      test('document scrolling calls function to set scroll height',
+          function(done) {
+            var scrollStub = sinon.stub(element, '_handleScroll',
+                function() {
+                  assert.isTrue(scrollStub.called);
+                  document.body.style.height =
+                      originalHeight + 'px';
+                  scrollStub.restore();
+                  done();
+                });
+            var originalHeight = document.body.scrollHeight;
+            document.body.style.height = '10000px';
+            document.body.scrollTop = TEST_SCROLL_TOP_PX;
+            element._handleScroll();
+          });
+
       test('history is loaded correctly', function() {
         history.replaceState(
             {
@@ -466,22 +537,49 @@
         // changes to match a regex of change view type.
         element._paramsChanged({view: 'gr-change-view'});
         reloadStub.restore();
+      });
+    });
 
+    suite('reply dialog tests', function() {
+      setup(function() {
+        sinon.stub(element.$.replyDialog, '_draftChanged');
       });
 
-      test('document scrolling calls function to set scroll height',
-          function(done) {
-          var scrollStub = sinon.stub(element, '_handleScroll', function() {
-              assert.isTrue(scrollStub.called);
-              document.getElementsByTagName('body')[0].style.height =
-                  originalHeight + 'px';
-              scrollStub.restore();
-              done();
-          });
+      test('reply from comment adds quote text', function() {
+        var e = {detail: {message: {message: 'quote text'}}};
+        element._handleMessageReply(e);
+        assert.equal(element.$.replyDialog.draft, '> quote text\n\n');
+        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+      });
 
-          var originalHeight = document.body.scrollHeight;
-          document.getElementsByTagName('body')[0].style.height = '10000px';
-          window.scroll(0, 100);
+      test('reply from comment replaces quote text', function() {
+        element.$.replyDialog.draft = '> old quote text\n\n some draft text';
+        element.$.replyDialog.quote = '> old quote text\n\n';
+        var e = {detail: {message: {message: 'quote text'}}};
+        element._handleMessageReply(e);
+        assert.equal(element.$.replyDialog.draft, '> quote text\n\n');
+        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+      });
+
+      test('reply from same comment preserves quote text', function() {
+        element.$.replyDialog.draft = '> quote text\n\n some draft text';
+        element.$.replyDialog.quote = '> quote text\n\n';
+        var e = {detail: {message: {message: 'quote text'}}};
+        element._handleMessageReply(e);
+        assert.equal(element.$.replyDialog.draft,
+            '> quote text\n\n some draft text');
+        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
+      });
+
+      test('reply from top of page contains previous draft', function() {
+        var div = document.createElement('div');
+        element.$.replyDialog.draft = '> quote text\n\n some draft text';
+        element.$.replyDialog.quote = '> quote text\n\n';
+        var e = {target: div, preventDefault: sinon.spy()};
+        element._handleReplyTap(e);
+        assert.equal(element.$.replyDialog.draft,
+            '> quote text\n\n some draft text');
+        assert.equal(element.$.replyDialog.quote, '> quote text\n\n');
       });
     });
   });
diff --git a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
index d462c12..e4ab44b 100644
--- a/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
+++ b/polygerrit-ui/app/elements/change/gr-comment-list/gr-comment-list.html
@@ -14,7 +14,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/gr-path-list-behavior.html">
+<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <link rel="import" href="../../shared/gr-linked-text/gr-linked-text.html">
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
index 979a06a..b668d06 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.html
@@ -55,6 +55,7 @@
         </label>
         <iron-autogrow-textarea
             id="messageInput"
+            max-rows="15"
             class="message"
             bind-value="{{message}}"></iron-autogrow-textarea>
       </div>
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
index 0dbefc5..06775d7 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog.js
@@ -33,18 +33,15 @@
       message: String,
     },
 
-    populateRevertMessage: function(message) {
+    populateRevertMessage: function(message, commitHash) {
       // Figure out what the revert title should be.
       var originalTitle = message.split('\n')[0];
       var revertTitle = 'Revert "' + originalTitle + '"';
-      // Figure out what the revert commit message should be.
-      var commitRegex = /\nChange-Id: (\w+)\n\s*/g;
-      var match = commitRegex.exec(message);
-      if (!match) {
-        alert('Unable to find Change-Id in footer of commit message.');
+      if (!commitHash) {
+        alert('Unable to find the commit hash of this change.');
         return;
       }
-      var revertCommitText = 'This reverts commit ' + match[1] + '.';
+      var revertCommitText = 'This reverts commit ' + commitHash + '.';
       // Add '> ' in front of the original commit text.
       var originalCommitText = message.replace(/^/gm, '> ');
 
diff --git a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
index 8ccfc9a..5e6c53fb 100644
--- a/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-confirm-revert-dialog/gr-confirm-revert-dialog_test.html
@@ -41,16 +41,18 @@
     test('no match', function() {
       assert.isNotOk(element.message);
       var alertStub = sinon.stub(window, 'alert');
-      element.populateRevertMessage('not a change-id in sight');
+      element.populateRevertMessage('not a commitHash in sight', undefined);
       assert.isTrue(alertStub.calledOnce);
       alertStub.restore();
     });
 
     test('single line', function() {
       assert.isNotOk(element.message);
-      element.populateRevertMessage('one line commit\n\nChange-Id: abcdefg\n');
+      element.populateRevertMessage(
+          'one line commit\n\nChange-Id: abcdefg\n',
+          'abcd123');
       var expected = 'Revert "one line commit"\n\n' +
-                     'This reverts commit abcdefg.\n\n' +
+                     'This reverts commit abcd123.\n\n' +
                      'Reason for revert: <INSERT REASONING HERE>\n\n' +
                      'Original change\'s description:\n' +
                      '> one line commit\n> \n' +
@@ -61,9 +63,10 @@
     test('multi line', function() {
       assert.isNotOk(element.message);
       element.populateRevertMessage(
-          'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n');
+          'many lines\ncommit\n\nmessage\n\nChange-Id: abcdefg\n',
+          'abcd123');
       var expected = 'Revert "many lines"\n\n' +
-                     'This reverts commit abcdefg.\n\n' +
+                     'This reverts commit abcd123.\n\n' +
                      'Reason for revert: <INSERT REASONING HERE>\n\n' +
                      'Original change\'s description:\n' +
                      '> many lines\n> commit\n> \n> message\n> \n' +
@@ -74,9 +77,10 @@
     test('issue above change id', function() {
       assert.isNotOk(element.message);
       element.populateRevertMessage(
-          'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n');
+          'much lines\nvery\n\ncommit\n\nBug: Issue 42\nChange-Id: abcdefg\n',
+          'abcd123');
       var expected = 'Revert "much lines"\n\n' +
-                     'This reverts commit abcdefg.\n\n' +
+                     'This reverts commit abcd123.\n\n' +
                      'Reason for revert: <INSERT REASONING HERE>\n\n' +
                      'Original change\'s description:\n' +
                      '> much lines\n> very\n> \n> commit\n> \n' +
@@ -88,9 +92,10 @@
     test('revert a revert', function() {
       assert.isNotOk(element.message);
       element.populateRevertMessage(
-          'Revert "one line commit"\n\nChange-Id: abcdefg\n');
+          'Revert "one line commit"\n\nChange-Id: abcdefg\n',
+          'abcd123');
       var expected = 'Revert "Revert "one line commit""\n\n' +
-                     'This reverts commit abcdefg.\n\n' +
+                     'This reverts commit abcd123.\n\n' +
                      'Reason for revert: <INSERT REASONING HERE>\n\n' +
                      'Original change\'s description:\n' +
                      '> Revert "one line commit"\n> \n' +
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
index b1e5c01..7c888da 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.html
@@ -100,7 +100,9 @@
         </template>
       </ul>
       <span class="closeButtonContainer">
-        <gr-button link on-tap="_handleCloseTap">Close</gr-button>
+        <gr-button id="closeButton"
+            link
+            on-tap="_handleCloseTap">Close</gr-button>
       </span>
     </header>
     <main hidden$="[[!_schemes.length]]" hidden>
@@ -121,7 +123,7 @@
       <div class="patchFiles">
         <label>Patch file</label>
         <div>
-          <a href$="[[_computeDownloadLink(change, patchNum)]]">
+          <a id="download" href$="[[_computeDownloadLink(change, patchNum)]]">
             [[_computeDownloadFilename(change, patchNum)]]
           </a>
           <a href$="[[_computeZipDownloadLink(change, patchNum)]]">
@@ -131,7 +133,7 @@
       </div>
       <div class="archivesContainer" hidden$="[[!config.archives.length]]" hidden>
         <label>Archive</label>
-        <div class="archives">
+        <div id="archives" class="archives">
           <template is="dom-repeat" items="[[config.archives]]" as="format">
             <a href$="[[_computeArchiveDownloadLink(change, patchNum, format)]]">
               [[format]]
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
index 2f3e8e1..b331899 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog.js
@@ -58,6 +58,18 @@
       }.bind(this));
     },
 
+    focus: function() {
+      this.$.download.focus();
+    },
+
+    getFocusStops: function() {
+      var links = this.$$('#archives').querySelectorAll('a');
+      return {
+        start: this.$.closeButton,
+        end: links[links.length - 1],
+      };
+    },
+
     _computeDownloadCommands: function(change, patchNum, _selectedScheme) {
       var commandObj;
       for (var rev in change.revisions) {
diff --git a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
index 70e934d..ad5086b 100644
--- a/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-download-dialog/gr-download-dialog_test.html
@@ -115,6 +115,14 @@
       };
     });
 
+    test('focuses on first download link', function() {
+      var focusStub = sinon.stub(element.$.download, 'focus');
+      element.focus();
+      flushAsynchronousOperations();
+      assert.isTrue(focusStub.called);
+      focusStub.restore();
+    });
+
     test('element visibility', function() {
       assert.isFalse(element.$$('ul').hasAttribute('hidden'));
       assert.isFalse(element.$$('main').hasAttribute('hidden'));
@@ -154,7 +162,6 @@
         assert.isFalse(el.hasAttribute('selected'));
       });
     });
-
   });
 
   suite('gr-download-dialog tests', function() {
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..78e4634 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
@@ -89,7 +89,8 @@
       .invisible {
         visibility: hidden;
       }
-      .row:not(.header) .stats {
+      .row:not(.header) .stats,
+      .total-stats {
         font-family: var(--monospace-font-family);
       }
       .added {
@@ -108,6 +109,11 @@
       .fileListButton {
         margin: .5em;
       }
+      .totalChanges {
+        justify-content: flex-end;
+        padding-right: 2.6em;
+        text-align: right;
+      }
       input.show-hide {
         display: none;
       }
@@ -147,9 +153,18 @@
         /
         <gr-button link on-tap="_collapseAllDiffs">Hide diffs</gr-button>
         /
+        <select
+            id="modeSelect"
+            is="gr-select"
+            bind-value="{{diffViewMode}}"
+            on-change="_handleDropdownChange">
+          <option value="SIDE_BY_SIDE">Side By Side</option>
+          <option value="UNIFIED_DIFF">Unified</option>
+        </select>
+        /
         <label>
           Diff against
-          <select on-change="_handlePatchChange">
+          <select id="patchChange" on-change="_handlePatchChange">
             <option value="PARENT">Base</option>
             <template is="dom-repeat" items="[[_computePatchSets(revisions, patchRange.*)]]" as="patchNum">
               <option
@@ -201,7 +216,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]]"
@@ -209,8 +225,14 @@
           path="[[file.__path]]"
           prefs="[[_diffPrefs]]"
           project-config="[[projectConfig]]"
-          view-mode="[[_userPrefs.diff_view]]"></gr-diff>
+          view-mode="[[_diffMode]]"></gr-diff>
     </template>
+    <div class="row totalChanges">
+      <div class="total-stats" hidden$="[[_hideChangeTotals]]">
+        <span class="added">+[[_patchChange.inserted]]</span>
+        <span class="removed">-[[_patchChange.deleted]]</span>
+      </div>
+    </div>
     <gr-button
         class="fileListButton"
         id="incrementButton"
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..478faf5 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
@@ -16,6 +16,11 @@
 
   var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
 
+  var DiffViewMode = {
+    SIDE_BY_SIDE: 'SIDE_BY_SIDE',
+    UNIFIED: 'UNIFIED_DIFF',
+  };
+
   Polymer({
     is: 'gr-file-list',
 
@@ -36,7 +41,10 @@
         value: function() { return document.body; },
       },
       change: Object,
-
+      diffViewMode: {
+        type: String,
+        notify: true,
+      },
       _files: {
         type: Array,
         observer: '_filesChanged',
@@ -58,15 +66,27 @@
         type: Number,
         value: 75,
       },
+      _patchChange: {
+        type: Object,
+        computed: '_calculatePatchChange(_files)',
+      },
       _fileListIncrement: {
         type: Number,
         readOnly: true,
         value: 75,
       },
+      _hideChangeTotals: {
+        type: Boolean,
+        computed: '_shouldHideChangeTotals(_patchChange)',
+      },
       _shownFiles: {
         type: Array,
         computed: '_computeFilesShown(_numFilesShown, _files.*)',
       },
+      _diffMode: {
+        type: String,
+        computed: '_getDiffViewMode(diffViewMode, _userPrefs)',
+      },
     },
 
     behaviors: [
@@ -100,8 +120,17 @@
         this._diffPrefs = prefs;
       }.bind(this)));
 
+      // Initialize with user's diff mode preference. Default to
+      // SIDE_BY_SIDE in the meantime.
+      var setDiffViewMode = this.diffViewMode === null;
+      if (setDiffViewMode) {
+        this.set('diffViewMode', DiffViewMode.SIDE_BY_SIDE);
+      }
       promises.push(this._getPreferences().then(function(prefs) {
         this._userPrefs = prefs;
+        if (setDiffViewMode) {
+          this.set('diffViewMode', prefs.diff_view);
+        }
       }.bind(this)));
     },
 
@@ -109,6 +138,22 @@
       return Polymer.dom(this.root).querySelectorAll('gr-diff');
     },
 
+    _calculatePatchChange: function(files) {
+      var filesNoCommitMsg = files.filter(function(files) {
+        return files.__path !== '/COMMIT_MSG';
+      });
+
+      return filesNoCommitMsg.reduce(function(acc, obj) {
+        var inserted = obj.lines_inserted ? obj.lines_inserted : 0;
+        var deleted = obj.lines_deleted ? obj.lines_deleted : 0;
+
+        return {
+          inserted: acc.inserted + inserted,
+          deleted: acc.deleted + deleted,
+        };
+      }, {inserted: 0, deleted: 0});
+    },
+
     _getDiffPreferences: function() {
       return this.$.restAPI.getDiffPreferences();
     },
@@ -402,6 +447,10 @@
       window.scrollTo(0, top - document.body.clientHeight / 2);
     },
 
+    _shouldHideChangeTotals: function(_patchChange) {
+      return (_patchChange.inserted === 0 && _patchChange.deleted === 0);
+    },
+
     _computeFileSelected: function(index, selectedIndex) {
       return index === selectedIndex;
     },
@@ -437,10 +486,6 @@
       return expanded ? 'â–¼' : 'â—€';
     },
 
-    _computeHiddenState: function(expanded) {
-      return !expanded;
-    },
-
     _computeFilesShown: function(numFilesShown, files) {
       return files.base.slice(0, numFilesShown);
     },
@@ -478,5 +523,32 @@
     _showAllFiles: function() {
       this._numFilesShown = this._files.length;
     },
+
+    /**
+     * _getDiffViewMode: Get the diff view (side-by-side or unified) based on
+     * the current state.
+     *
+     * The expected behavior is to use the mode specified in the user's
+     * preferences unless they have manually chosen the alternative view. If the
+     * user navigates up to the change view, it should clear this choice and
+     * revert to the preference the next time a diff is viewed.
+     *
+     * Use side-by-side if the user is not logged in.
+     *
+     * @return {String}
+     */
+    _getDiffViewMode: function() {
+      if (this.diffViewMode) {
+        return this.diffViewMode;
+      } else if (this._userPrefs && this._userPrefs.diff_view) {
+        return this.diffViewMode = this._userPrefs.diff_view;
+      }
+
+      return DiffViewMode.SIDE_BY_SIDE;
+    },
+
+    _handleDropdownChange: function(e) {
+      e.target.blur();
+    },
   });
 })();
diff --git a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
index 502597c..b298fef 100644
--- a/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
+++ b/polygerrit-ui/app/elements/change/gr-file-list/gr-file-list_test.html
@@ -32,20 +32,36 @@
   </template>
 </test-fixture>
 
+<test-fixture id="blank">
+  <template>
+    <div></div>
+  </template>
+</test-fixture>
+
 <script>
   suite('gr-file-list tests', function() {
     var element;
+    var sandbox;
 
     setup(function() {
+      sandbox = sinon.sandbox.create();
       stub('gr-rest-api-interface', {
         getLoggedIn: function() { return Promise.resolve(true); },
         getPreferences: function() { return Promise.resolve({}); },
+        fetchJSON: function() { return Promise.resolve({}); },
+      });
+      stub('gr-date-formatter', {
+        _loadTimeFormat: function() { return Promise.resolve(''); }
       });
       element = fixture('basic');
     });
 
+    teardown(function() {
+      sandbox.restore();
+    });
+
     test('get file list', function(done) {
-      var getChangeFilesStub = sinon.stub(element.$.restAPI, 'getChangeFiles',
+      var getChangeFilesStub = sandbox.stub(element.$.restAPI, 'getChangeFiles',
           function() {
             return Promise.resolve({
               '/COMMIT_MSG': {lines_inserted: 9},
@@ -81,6 +97,37 @@
       });
     });
 
+    test('calculate totals for patch number', function() {
+      element._files = [
+        {__path: '/COMMIT_MSG', lines_inserted: 9},
+        {__path: 'file_added_in_rev2.txt', lines_inserted: 1, lines_deleted: 1},
+        {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
+      ];
+      assert.deepEqual(element._patchChange, {inserted: 2, deleted: 2});
+
+      // Test with a commit message that isn't the first file.
+      element._files = [
+        {__path: 'file_added_in_rev2.txt', lines_inserted: 1, lines_deleted: 1},
+        {__path: '/COMMIT_MSG', lines_inserted: 9},
+        {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
+      ];
+      assert.deepEqual(element._patchChange, {inserted: 2, deleted: 2});
+
+      // Test with no commit message.
+      element._files = [
+        {__path: 'file_added_in_rev2.txt', lines_inserted: 1, lines_deleted: 1},
+        {__path: 'myfile.txt', lines_inserted: 1, lines_deleted: 1},
+      ];
+      assert.deepEqual(element._patchChange, {inserted: 2, deleted: 2});
+
+      // Test with files missing either lines_inserted or lines_deleted.
+      element._files = [
+        {__path: 'file_added_in_rev2.txt', lines_inserted: 1},
+        {__path: 'myfile.txt', lines_deleted: 1},
+      ];
+      assert.deepEqual(element._patchChange, {inserted: 1, deleted: 1});
+    });
+
     suite('keyboard shortcuts', function() {
       setup(function() {
         element._files = [
@@ -97,19 +144,22 @@
       });
 
       test('toggle left diff via shortcut', function() {
-        var toggleLeftDiffStub = sinon.stub();
-        sinon.stub(element, 'diffs', {get: function() {
+        var toggleLeftDiffStub = sandbox.stub();
+        // Property getter cannot be stubbed w/ sandbox due to a bug in Sinon.
+        // https://github.com/sinonjs/sinon/issues/781
+        var diffsStub = sinon.stub(element, 'diffs', {get: function() {
           return [{toggleLeftDiff: toggleLeftDiffStub}];
         }});
         MockInteractions.pressAndReleaseKeyOn(element, 65, 'shift');  // 'A'
         assert.isTrue(toggleLeftDiffStub.calledOnce);
+        diffsStub.restore();
       });
 
       test('keyboard shortcuts', function() {
         flushAsynchronousOperations();
         var elementItems = Polymer.dom(element.root).querySelectorAll(
             '.row:not(.header)');
-        assert.equal(elementItems.length, 3);
+        assert.equal(elementItems.length, 4);
         assert.isTrue(elementItems[0].hasAttribute('selected'));
         assert.isFalse(elementItems[1].hasAttribute('selected'));
         assert.isFalse(elementItems[2].hasAttribute('selected'));
@@ -117,7 +167,7 @@
         assert.equal(element.selectedIndex, 1);
         MockInteractions.pressAndReleaseKeyOn(element, 74);  // 'J'
 
-        var showStub = sinon.stub(page, 'show');
+        var showStub = sandbox.stub(page, 'show');
         assert.equal(element.selectedIndex, 2);
         MockInteractions.pressAndReleaseKeyOn(element, 13);  // 'ENTER'
         assert(showStub.lastCall.calledWith('/c/42/2/myfile.txt'),
@@ -241,7 +291,7 @@
       assert.isFalse(fileAdded.checked);
       assert.isTrue(myFile.checked);
 
-      var saveStub = sinon.stub(element, '_saveReviewedState',
+      var saveStub = sandbox.stub(element, '_saveReviewedState',
           function() { return Promise.resolve(); });
 
       MockInteractions.tap(commitMsg);
@@ -272,7 +322,7 @@
     });
 
     test('diff against dropdown', function(done) {
-      var showStub = sinon.stub(page, 'show');
+      var showStub = sandbox.stub(page, 'show');
       element.changeNum = '42';
       element.patchRange = {
         basePatchNum: 'PARENT',
@@ -284,7 +334,7 @@
         rev3: {_number: 3},
       };
       flush(function() {
-        var selectEl = element.$$('select');
+        var selectEl = element.$.patchChange;
         assert.equal(selectEl.value, 'PARENT');
         assert.isTrue(element.$$('option[value="3"]').hasAttribute('disabled'));
         selectEl.addEventListener('change', function() {
@@ -313,7 +363,7 @@
       var fileRows =
           Polymer.dom(element.root).querySelectorAll('.row:not(.header)');
       // Prevent diff from making API call.
-      var diffStub = sinon.stub(element.diffs[0], 'reload');
+      var diffStub = sandbox.stub(element.diffs[0], 'reload');
       var showHideCheck = fileRows[0].querySelector(
           'input.show-hide[type="checkbox"]');
       assert.isTrue(showHideCheck.checked);
@@ -339,5 +389,50 @@
           element.$$('a').getAttribute('href'),
           '/c/42/2/foo+bar/my%252Bfile.txt%2525');
     });
+
+    test('diff mode correctly toggles the diffs', function() {
+      element._files = [
+        {__path: 'myfile.txt', __expanded: false},
+      ];
+      element.changeNum = '42';
+      element.patchRange = {
+        basePatchNum: 'PARENT',
+        patchNum: '2',
+      };
+      element.selectedIndex = 0;
+      flushAsynchronousOperations();
+      var select = element.$.modeSelect;
+      var diffDisplay = element.diffs[0];
+      element._userPrefs = {diff_view: 'SIDE_BY_SIDE'};
+      assert.equal(element._getDiffViewMode(), 'SIDE_BY_SIDE');
+      assert.equal(element.diffViewMode, 'SIDE_BY_SIDE');
+      assert.equal(diffDisplay.viewMode, 'SIDE_BY_SIDE');
+      element.set('diffViewMode', 'UNIFIED_DIFF');
+      assert.equal(element._getDiffViewMode(), 'UNIFIED_DIFF');
+      assert.equal(diffDisplay.viewMode, 'UNIFIED_DIFF');
+    });
+
+    test('diff mode selector initializes from preferences', function() {
+      var resolvePrefs;
+      var prefsPromise = new Promise(function(resolve) {
+        resolvePrefs = resolve;
+      });
+      sandbox.stub(element, '_getPreferences').returns(prefsPromise);
+
+      // Attach a new gr-file-list so we can intercept the preferences fetch.
+      var view = document.createElement('gr-file-list');
+      var select = view.$.modeSelect;
+      fixture('blank').appendChild(view);
+      flushAsynchronousOperations();
+
+      // At this point the diff mode doesn't yet have the user's preference.
+      assert.equal(select.value, 'SIDE_BY_SIDE');
+
+      // Receive the overriding preference.
+      resolvePrefs({diff_view: 'UNIFIED'});
+      flushAsynchronousOperations();
+      assert.equal(select.value, 'SIDE_BY_SIDE');
+      document.getElementById('blank').restore();
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
index 1432d8c..cb6a6f9 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.html
@@ -208,6 +208,12 @@
         </iron-autogrow-textarea>
       </section>
       <section class="labelsContainer">
+        <template is="dom-if" if="[[_isClosed(change)]]" id="labelDisabled">
+          <div class="labelDisabledMessage">
+            Setting labels are disabled for this change because it has been
+            closed.
+          </div>
+        </template>
         <template is="dom-repeat"
             items="[[_computeLabelArray(permittedLabels)]]" as="label">
           <div class="labelContainer">
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
index 00fa05e..a61de4d 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog.js
@@ -23,6 +23,8 @@
     REVIEWERS: 'reviewers',
   };
 
+  var CLOSED_CHANGE_STATUSES = ['ABANDONED', 'MERGED'];
+
   Polymer({
     is: 'gr-reply-dialog',
 
@@ -58,6 +60,10 @@
         value: '',
         observer: '_draftChanged',
       },
+      quote: {
+        type: String,
+        value: ''
+      },
       diffDrafts: Object,
       filterReviewerSuggestion: {
         type: Function,
@@ -267,6 +273,10 @@
       }.bind(this));
     },
 
+    _isClosed: function(change) {
+      return CLOSED_CHANGE_STATUSES.indexOf(change.status) !== -1;
+    },
+
     _computeHideDraftList: function(drafts) {
       return Object.keys(drafts || {}).length == 0;
     },
diff --git a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
index 01ca076..6c77afb 100644
--- a/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
+++ b/polygerrit-ui/app/elements/change/gr-reply-dialog/gr-reply-dialog_test.html
@@ -244,6 +244,18 @@
       }).then(done);
     });
 
+    test('message disabled dialogue appears for closed change', function() {
+      element.change = {status: 'ABANDONED'};
+      flushAsynchronousOperations();
+      assert.isOk(element.$$('.labelDisabledMessage'));
+    });
+
+    test('message disabled dialogue does not appear for open change',
+        function() {
+      element.change = {status: 'NEW'};
+      assert.isNotOk(element.$$('.labelDisabledMessage'));
+    });
+
     test('_getStorageLocation', function() {
       var actual = element._getStorageLocation();
       assert.equal(actual.changeNum, changeNum);
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
index 7a9c4f9..a38152a 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager.js
@@ -18,6 +18,7 @@
   var CHECK_SIGN_IN_INTERVAL_MS = 60000;
   var SIGN_IN_WIDTH_PX = 690;
   var SIGN_IN_HEIGHT_PX = 500;
+  var TOO_MANY_FILES = 'too many files to find conflicts';
 
   Polymer({
     is: 'gr-error-manager',
@@ -38,6 +39,10 @@
       this.unlisten(document, 'network-error', '_handleNetworkError');
     },
 
+    _shouldSupressError: function(msg) {
+      return msg.indexOf(TOO_MANY_FILES) > -1;
+    },
+
     _handleServerError: function(e) {
       if (e.detail.response.status === 403) {
         this._getLoggedIn().then(function(loggedIn) {
@@ -49,7 +54,9 @@
         }.bind(this));
       } else {
         e.detail.response.text().then(function(text) {
-          this._showAlert('Server error: ' + text);
+          if (!this._shouldSupressError(text)) {
+            this._showAlert('Server error: ' + text);
+          }
         }.bind(this));
       }
     },
diff --git a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
index f633a7e..44cbde0 100644
--- a/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
+++ b/polygerrit-ui/app/elements/core/gr-error-manager/gr-error-manager_test.html
@@ -70,6 +70,20 @@
       });
     });
 
+    test('suppress TOO_MANY_FILES error', function(done) {
+      var showAlertStub = sandbox.stub(element, '_showAlert');
+      var textSpy = sandbox.spy(function() {
+        return Promise.resolve('too many files to find conflicts');
+      });
+      element.fire('server-error', {response: {status: 500, text: textSpy}});
+
+      assert.isTrue(textSpy.called);
+      textSpy.lastCall.returnValue.then(function() {
+        assert.isFalse(showAlertStub.called);
+        done();
+      });
+    });
+
     test('show network error', function(done) {
       var consoleErrorStub = sandbox.stub(console, 'error');
       var showAlertStub = sandbox.stub(element, '_showAlert');
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
index 7653655..d10567c 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.html
@@ -15,6 +15,7 @@
 -->
 
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../shared/gr-js-api-interface/gr-js-api-interface.html">
 
 <dom-module id="gr-reporting">
   <script src="gr-reporting.js"></script>
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
index 5236a1c..32dd687 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting.js
@@ -33,6 +33,8 @@
   var CHANGE_VIEW_REGEX = /^\/c\/\d+\/?\d*$/;
   var DIFF_VIEW_REGEX = /^\/c\/\d+\/\d+\/.+$/;
 
+  var pending = [];
+
   Polymer({
     is: 'gr-reporting',
 
@@ -40,7 +42,7 @@
       _baselines: {
         type: Array,
         value: function() { return {}; },
-      }
+      },
     },
 
     get performanceTiming() {
@@ -51,8 +53,13 @@
       return Math.round(10 * window.performance.now()) / 10;
     },
 
-    reporter: function(type, category, eventName, eventValue) {
-      eventValue = eventValue;
+    reporter: function() {
+      var report = (Gerrit._arePluginsLoaded() && !pending.length) ?
+        this.defaultReporter : this.cachingReporter;
+      report.apply(this, arguments);
+    },
+
+    defaultReporter: function(type, category, eventName, eventValue) {
       var detail = {
         type: type,
         category: category,
@@ -63,6 +70,19 @@
       console.log(eventName + ': ' + eventValue);
     },
 
+    cachingReporter: function(type, category, eventName, eventValue) {
+      if (Gerrit._arePluginsLoaded()) {
+        if (pending.length) {
+          pending.splice(0).forEach(function(args) {
+            this.reporter.apply(this, args);
+          }, this);
+        }
+        this.reporter(type, category, eventName, eventValue);
+      } else {
+        pending.push([type, category, eventName, eventValue]);
+      }
+    },
+
     /**
      * User-perceived app start time, should be reported when the app is ready.
      */
@@ -105,6 +125,10 @@
           NAVIGATION.TYPE, NAVIGATION.CATEGORY, NAVIGATION.PAGE, page);
     },
 
+    pluginsLoaded: function() {
+      this.timeEnd('PluginsLoaded');
+    },
+
     _getPathname: function() {
       return window.location.pathname;
     },
diff --git a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
index b9d07fc..082f81b 100644
--- a/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
+++ b/polygerrit-ui/app/elements/core/gr-reporting/gr-reporting_test.html
@@ -90,6 +90,48 @@
       ));
     });
 
+    suite('plugins', function() {
+      setup(function() {
+        element.reporter.restore();
+        sandbox.stub(element, 'defaultReporter');
+        sandbox.stub(Gerrit, '_arePluginsLoaded');
+      });
+
+      test('pluginsLoaded reports time', function() {
+        Gerrit._arePluginsLoaded.returns(true);
+        var nowStub = sinon.stub(element, 'now').returns(42);
+        element.pluginsLoaded();
+        assert.isTrue(element.defaultReporter.calledWithExactly(
+            'timing-report', 'UI Latency', 'PluginsLoaded', 42
+        ));
+      });
+
+      test('caches reports if plugins are not loaded', function() {
+        Gerrit._arePluginsLoaded.returns(false);
+        element.timeEnd('foo');
+        assert.isFalse(element.defaultReporter.called);
+      });
+
+      test('reports if plugins are loaded', function() {
+        Gerrit._arePluginsLoaded.returns(true);
+        element.timeEnd('foo');
+        assert.isTrue(element.defaultReporter.called);
+      });
+
+      test('reports cached events preserving order', function() {
+        Gerrit._arePluginsLoaded.returns(false);
+        element.timeEnd('foo');
+        Gerrit._arePluginsLoaded.returns(true);
+        element.timeEnd('bar');
+        assert.isTrue(element.defaultReporter.firstCall.calledWith(
+            'timing-report', 'UI Latency', 'foo'
+        ));
+        assert.isTrue(element.defaultReporter.secondCall.calledWith(
+            'timing-report', 'UI Latency', 'bar'
+        ));
+      });
+    });
+
     suite('location changed', function() {
       var pathnameStub;
       setup(function() {
diff --git a/polygerrit-ui/app/elements/core/gr-router/gr-router.js b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
index f185977..05b191c 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -36,7 +36,10 @@
       // Fire asynchronously so that the URL is changed by the time the event
       // is processed.
       app.async(function() {
-        app.fire('location-change');
+        app.fire('location-change', {
+          hash: window.location.hash,
+          pathname: window.location.pathname,
+        });
         reporting.locationChanged();
       }, 1);
       next();
@@ -56,6 +59,11 @@
       }
       // For backward compatibility with GWT links.
       if (data.hash) {
+        // In certain login flows the server may redirect to a hash without
+        // a leading slash, which page.js doesn't handle correctly.
+        if (data.hash[0] !== '/') {
+          data.hash = '/' + data.hash
+        }
         page.redirect(data.hash);
         return;
       }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
index 40a90b7..3ad7c74 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder.html
@@ -58,10 +58,20 @@
         SYNTAX: 'Diff Syntax Render',
       };
 
+      // If any line of the diff is more than the character limit, then disable
+      // syntax highlighting for the entire file.
+      var SYNTAX_MAX_LINE_LENGTH = 500;
+
       Polymer({
         is: 'gr-diff-builder',
 
         /**
+         * Fired when the diff begins rendering.
+         *
+         * @event render-start
+         */
+
+        /**
          * Fired when the diff is rendered.
          *
          * @event render
@@ -121,10 +131,16 @@
 
           reporting.time(TimingLabel.TOTAL);
           reporting.time(TimingLabel.CONTENT);
+          this.fire('render-start');
           return this.$.processor.process(this.diff.content).then(function() {
             if (this.isImageDiff) {
               this._builder.renderDiffImages();
             }
+
+            if (this._anyLineTooLong()) {
+              this.$.syntaxLayer.enabled = false;
+            }
+
             reporting.timeEnd(TimingLabel.CONTENT);
             reporting.time(TimingLabel.SYNTAX);
             this.$.syntaxLayer.process().then(function() {
@@ -376,6 +392,18 @@
           Polymer.dom.flush();
           parent.removeChild(thread);
         },
+
+        /**
+         * @returns {Boolean} whether any of the lines in _groups are longer
+         * than SYNTAX_MAX_LINE_LENGTH.
+         */
+        _anyLineTooLong: function() {
+          return this._groups.reduce(function(acc, group) {
+            return acc || group.lines.reduce(function(acc, line) {
+              return acc || line.text.length >= SYNTAX_MAX_LINE_LENGTH;
+            }, false);
+          }, false);
+        },
       });
     })();
   </script>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
index af44629..341e959 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-builder/gr-diff-builder_test.html
@@ -613,6 +613,31 @@
         assert.strictEqual(sections[0], section[0]);
         assert.strictEqual(sections[1], section[1]);
       });
+
+      test('render-start and render are fired', function() {
+        var fireStub = sinon.stub(element, 'fire');
+        element.render({left: [], right: []}, {});
+        flush(function() {
+          assert.isTrue(fireStub.calledWithExactly('render-start'));
+          assert.isTrue(fireStub.calledWithExactly('render'));
+          done();
+        });
+      });
+
+      test('rendering normal-sized diff does not disable syntax', function() {
+        flush(function() {
+          assert.isTrue(element.$.syntaxLayer.enabled);
+        });
+      });
+
+      test('rendering large diff disables syntax', function() {
+        // Before it renders, set the first diff line to 500 '*' characters.
+        element.diff.content[0].a = new Array(501).join('*');
+
+        flush(function() {
+          assert.isFalse(element.$.syntaxLayer.enabled);
+        });
+      });
     });
 
     suite('mock-diff', function() {
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-cursor/gr-diff-cursor.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
index 491eded..c8eea3c 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.html
@@ -21,7 +21,7 @@
   <template>
     <gr-cursor-manager
         id="cursorManager"
-        scroll="keep-visible"
+        scroll="[[_scrollBehavior]]"
         cursor-target-class="target-row"
         target="{{diffRow}}"></gr-cursor-manager>
   </template>
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
index dd11f2c..e783658 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor.js
@@ -24,6 +24,11 @@
     UNIFIED: 'UNIFIED_DIFF',
   };
 
+  var ScrollBehavior = {
+    KEEP_VISIBLE: 'keep-visible',
+    NEVER: 'never',
+  };
+
   var LEFT_SIDE_CLASS = 'target-side-left';
   var RIGHT_SIDE_CLASS = 'target-side-right';
 
@@ -63,6 +68,18 @@
         type: Number,
         value: null,
       },
+
+      /**
+       * The scroll behavior for the cursor. Values are 'never' and
+       * 'keep-visible'. 'keep-visible' will only scroll if the cursor is beyond
+       * the viewport.
+       */
+      _scrollBehavior: {
+        type: String,
+        value: ScrollBehavior.KEEP_VISIBLE,
+      },
+
+      _listeningForScroll: Boolean,
     },
 
     observers: [
@@ -70,6 +87,15 @@
       '_diffsChanged(diffs.splices)',
     ],
 
+    attached: function() {
+      // Catch when users are scrolling as the view loads.
+      this.listen(window, 'scroll', '_handleWindowScroll');
+    },
+
+    detached: function() {
+      this.unlisten(window, 'scroll', '_handleWindowScroll');
+    },
+
     moveLeft: function() {
       this.side = DiffSides.LEFT;
       if (this._isTargetBlank()) {
@@ -169,12 +195,25 @@
       }
     },
 
+    _handleWindowScroll: function() {
+      if (this._listeningForScroll) {
+        this._scrollBehavior = ScrollBehavior.NEVER;
+        this._listeningForScroll = false;
+      }
+    },
+
     handleDiffUpdate: function() {
       this._updateStops();
 
       if (!this.diffRow) {
         this.reInitCursor();
       }
+      this._scrollBehavior = ScrollBehavior.KEEP_VISIBLE;
+      this._listeningForScroll = false;
+    },
+
+    _handleDiffRenderStart: function() {
+      this._listeningForScroll = true;
     },
 
     /**
@@ -320,12 +359,15 @@
         for (i = splice.index;
             i < splice.index + splice.addedCount;
             i++) {
+          this.listen(this.diffs[i], 'render-start', '_handleDiffRenderStart');
           this.listen(this.diffs[i], 'render', 'handleDiffUpdate');
         }
 
         for (i = 0;
             i < splice.removed && splice.removed.length;
             i++) {
+          this.unlisten(splice.removed[i],
+              'render-start', '_handleDiffRenderStart');
           this.unlisten(splice.removed[i], 'render', 'handleDiffUpdate');
         }
       }
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
index 5bdd138..a7d98e6 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-cursor/gr-diff-cursor_test.html
@@ -98,6 +98,17 @@
       assert.equal(cursorElement.diffRow, firstDeltaRow);
     });
 
+    test('cursor scroll behavior', function() {
+      cursorElement._handleDiffRenderStart();
+      assert.equal(cursorElement._scrollBehavior, 'keep-visible');
+
+      cursorElement._handleWindowScroll();
+      assert.equal(cursorElement._scrollBehavior, 'never');
+
+      cursorElement.handleDiffUpdate();
+      assert.equal(cursorElement._scrollBehavior, 'keep-visible');
+    });
+
     suite('unified diff', function() {
 
       setup(function(done) {
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-selection/gr-diff-selection.js b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
index f69eddd..e6d3653 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection.js
@@ -86,6 +86,10 @@
       }
     },
 
+    _getCopyEventTarget: function(e) {
+      return Polymer.dom(e).rootTarget;
+    },
+
     /**
      * Utility function to determine whether an element is a descendant of
      * another element with the particular className.
@@ -107,14 +111,15 @@
 
     _handleCopy: function(e) {
       var commentSelected = false;
-      if (this._elementDescendedFromClass(e.target, SelectionClass.COMMENT)) {
+      var target = this._getCopyEventTarget(e);
+      if (this.classList.contains(SelectionClass.COMMENT)) {
         commentSelected = true;
       } else {
-        if (!this._elementDescendedFromClass(e.target, 'content')) {
+        if (!this._elementDescendedFromClass(target, 'content')) {
           return;
         }
       }
-      var lineEl = this.diffBuilder.getLineElByChild(e.target);
+      var lineEl = this.diffBuilder.getLineElByChild(target);
       if (!lineEl) {
         return;
       }
@@ -214,6 +219,9 @@
       var content = [];
       // Fall back to default copy behavior if the selection lies within one
       // comment body.
+      if (range.startContainer === range.endContainer) {
+        return;
+      }
       if (this._elementDescendedFromClass(range.commonAncestorContainer,
           'message')) {
         return;
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
index f44d349..71fa233 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-selection/gr-diff-selection_test.html
@@ -87,6 +87,7 @@
 <script>
   suite('gr-diff-selection', function() {
     var element;
+    var copyEventStub;
 
     var emulateCopyOn = function(target) {
       var fakeEvent = {
@@ -96,12 +97,14 @@
           setData: sinon.stub(),
         },
       };
+      element._getCopyEventTarget.returns(target);
       element._handleCopy(fakeEvent);
       return fakeEvent;
     };
 
     setup(function() {
       element = fixture('basic');
+      sinon.stub(element, '_getCopyEventTarget');
       element._cachedDiffBuilder = {
         getLineElByChild: sinon.stub().returns({}),
         getSideByLineEl: sinon.stub(),
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 6c73e08..03609c0 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
@@ -104,7 +104,6 @@
           this._setReviewed(true);
         }
       }.bind(this));
-
       if (this.changeViewState.diffMode === null) {
         // Initialize with user's diff mode preference. Default to
         // SIDE_BY_SIDE in the meantime.
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
index 1eb3f95..566c6a1 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view_test.html
@@ -457,7 +457,6 @@
 
       // We will simulate a user change of the selected mode.
       var newMode = 'UNIFIED_DIFF';
-
       // Set the actual value of the select, and simulate the change event.
       select.value = newMode;
       element.fire('change', {}, {node: select});
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/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 372fea9..1580609 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -110,10 +110,12 @@
     },
 
     _loadPlugins: function(plugins) {
+      Gerrit._setPluginsCount(plugins.length);
       for (var i = 0; i < plugins.length; i++) {
         var scriptEl = document.createElement('script');
         scriptEl.defer = true;
         scriptEl.src = '/' + plugins[i];
+        scriptEl.onerror = Gerrit._pluginInstalled;
         document.body.appendChild(scriptEl);
       }
     },
@@ -160,13 +162,13 @@
       }
     },
 
-    _handleLocationChange: function() {
-      var hash = location.hash.substring(1);
-      var pathname = location.pathname;
+    _handleLocationChange: function(e) {
+      var hash = e.detail.hash.substring(1);
+      var pathname = e.detail.pathname;
       if (pathname.startsWith('/c/') && parseInt(hash, 10) > 0) {
         pathname += '@' + hash;
       }
-      this.set('_path', encodeURIComponent(pathname));
+      this.set('_path', pathname);
     },
 
     _handleTitleChange: function(e) {
diff --git a/polygerrit-ui/app/elements/gr-app_test.html b/polygerrit-ui/app/elements/gr-app_test.html
index f941d0f..b04cd4d 100644
--- a/polygerrit-ui/app/elements/gr-app_test.html
+++ b/polygerrit-ui/app/elements/gr-app_test.html
@@ -64,5 +64,25 @@
       assert.equal(gwtLink.href,
           'http://' + location.host + '/?polygerrit=0#/test/path');
     });
+
+    test('_handleLocationChange handles hashes', function() {
+      var curLocation = {
+        pathname: '/c/1/1/testfile.txt',
+        hash: '#2',
+        host: location.host,
+      };
+
+      var event = {detail: curLocation};
+      var gwtLink = element.$$('#gwtLink');
+      element._handleLocationChange(event);
+      assert.equal(gwtLink.href,
+          'http://' + location.host + '/?polygerrit=0#/c/1/1/testfile.txt@2');
+    });
+
+    test('sets plugins count', function() {
+      sandbox.stub(Gerrit, '_setPluginsCount');
+      element._loadPlugins([]);
+      assert.isTrue(Gerrit._setPluginsCount.calledWithExactly(0));
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
index 360c281..dc1de17 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.html
@@ -53,12 +53,17 @@
         padding: 0;
         text-decoration: none;
       }
+      .transparentBackground,
+      gr-button.transparentBackground {
+        background-color: transparent;
+      }
     </style>
-    <div class="container">
+    <div class$="container [[_getBackgroundClass(transparentBackground)]]">
       <gr-account-link account="[[account]]"></gr-account-link>
       <gr-button
           hidden$="[[!removable]]" hidden
-          class="remove" on-tap="_handleRemoveTap">×</gr-button>
+          class$="remove [[_getBackgroundClass(transparentBackground)]]"
+          on-tap="_handleRemoveTap">×</gr-button>
     </div>
     <gr-rest-api-interface id="restAPI"></gr-rest-api-interface>
   </template>
diff --git a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
index 45bf8fe..e33e1fc 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
+++ b/polygerrit-ui/app/elements/shared/gr-account-chip/gr-account-chip.js
@@ -28,6 +28,10 @@
         type: Boolean,
         reflectToAttribute: true,
       },
+      transparentBackground: {
+        type: Boolean,
+        value: false,
+      },
     },
 
     ready: function() {
@@ -36,6 +40,10 @@
       }.bind(this));
     },
 
+    _getBackgroundClass: function(transparent) {
+      return transparent ? 'transparentBackground' : '';
+    },
+
     _handleRemoveTap: function(e) {
       e.preventDefault();
       this.fire('remove', {account: this.account});
diff --git a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
index f136907..9bbcea5 100644
--- a/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
+++ b/polygerrit-ui/app/elements/shared/gr-account-label/gr-account-label.html
@@ -47,5 +47,6 @@
       </span>
     </span>
   </template>
+  <script src="../../../scripts/util.js"></script>
   <script src="gr-account-label.js"></script>
 </dom-module>
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);
     });
 
   });
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
index 1967b80..5c0535b 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.html
@@ -14,6 +14,7 @@
 limitations under the License.
 -->
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
+<link rel="import" href="../../core/gr-reporting/gr-reporting.html">
 <link rel="import" href="../gr-rest-api-interface/gr-rest-api-interface.html">
 
 <dom-module id="gr-js-api-interface">
@@ -23,4 +24,3 @@
   <script src="gr-js-api-interface.js"></script>
   <script src="gr-public-js-api.js"></script>
 </dom-module>
-
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
index be33ad7..06b25de 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface.js
@@ -81,11 +81,11 @@
       this._eventCallbacks[eventName].push(callback);
     },
 
-    canSubmitChange: function() {
+    canSubmitChange: function(change, revision) {
       var submitCallbacks = this._getEventCallbacks(EventType.SUBMIT_CHANGE);
       var cancelSubmit = submitCallbacks.some(function(callback) {
         try {
-          return callback() === false;
+          return callback(change, revision) === false;
         } catch (err) {
           console.error(err);
         }
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
index 97e0a18..2e7c53d 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-js-api-interface_test.html
@@ -172,5 +172,49 @@
       });
     });
 
+    test('_setPluginsCount', function(done) {
+      stub('gr-reporting', {
+        pluginsLoaded: function() {
+          assert.equal(Gerrit._pluginsPending, 0);
+          done();
+        }
+      });
+      Gerrit._setPluginsCount(0);
+    });
+
+    test('_arePluginsLoaded', function() {
+      assert.isFalse(Gerrit._arePluginsLoaded());
+      Gerrit._setPluginsCount(1);
+      assert.isFalse(Gerrit._arePluginsLoaded());
+      Gerrit._setPluginsCount(0);
+      assert.isTrue(Gerrit._arePluginsLoaded());
+    });
+
+    test('_pluginInstalled', function(done) {
+      stub('gr-reporting', {
+        pluginsLoaded: function() {
+          done();
+        }
+      });
+      Gerrit._setPluginsCount(2);
+      Gerrit._pluginInstalled();
+      assert.equal(Gerrit._pluginsPending, 1);
+      Gerrit._pluginInstalled();
+    });
+
+    test('install calls _pluginInstalled', function() {
+      var stub = sinon.stub(Gerrit, '_pluginInstalled');
+      Gerrit.install(function(p) { plugin = p; }, '0.1',
+          'http://test.com/plugins/testplugin/static/test.js');
+      assert.isTrue(stub.calledOnce);
+      stub.restore();
+    });
+
+    test('install calls _pluginInstalled on error', function() {
+      var stub = sinon.stub(Gerrit, '_pluginInstalled');
+      Gerrit.install(function() {}, '0.0pre-alpha');
+      assert.isTrue(stub.calledOnce);
+      stub.restore();
+    });
   });
 </script>
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
index 21d76f1..ad8c135 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-public-js-api.js
@@ -64,6 +64,9 @@
 
   var Gerrit = window.Gerrit || {};
 
+  // Number of plugins to initialize, -1 means 'not yet known'.
+  Gerrit._pluginsPending = -1;
+
   Gerrit.getPluginName = function() {
     console.warn('Gerrit.getPluginName is not supported in PolyGerrit.',
         'Please use self.getPluginName() instead.');
@@ -85,12 +88,14 @@
     if (opt_version && opt_version !== API_VERSION) {
       console.warn('Only version ' + API_VERSION +
           ' is supported in PolyGerrit. ' + opt_version + ' was given.');
+      Gerrit._pluginInstalled();
       return;
     }
 
     // TODO(andybons): Polyfill currentScript for IE10/11 (edge supports it).
     var src = opt_src || (document.currentScript && document.currentScript.src);
     callback(new Plugin(src));
+    Gerrit._pluginInstalled();
   };
 
   Gerrit.getLoggedIn = function() {
@@ -101,5 +106,20 @@
     // NOOP since PolyGerrit doesn’t support GWT plugins.
   };
 
+  Gerrit._setPluginsCount = function(count) {
+    Gerrit._pluginsPending = count;
+    if (Gerrit._arePluginsLoaded()) {
+      document.createElement('gr-reporting').pluginsLoaded();
+    }
+  };
+
+  Gerrit._pluginInstalled = function() {
+    Gerrit._setPluginsCount(Gerrit._pluginsPending - 1);
+  };
+
+  Gerrit._arePluginsLoaded = function() {
+    return Gerrit._pluginsPending === 0;
+  };
+
   window.Gerrit = Gerrit;
 })(window);
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
index d823f8c..f34ffcf 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.html
@@ -14,7 +14,7 @@
 limitations under the License.
 -->
 
-<link rel="import" href="../../../behaviors/gr-path-list-behavior.html">
+<link rel="import" href="../../../behaviors/gr-path-list-behavior/gr-path-list-behavior.html">
 <link rel="import" href="../../../bower_components/polymer/polymer.html">
 <script src="../../../bower_components/es6-promise/dist/es6-promise.min.js"></script>
 <script src="../../../bower_components/fetch/fetch.js"></script>
diff --git a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
index 5eb6e35..6195272 100644
--- a/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
+++ b/polygerrit-ui/app/elements/shared/gr-rest-api-interface/gr-rest-api-interface.js
@@ -62,7 +62,13 @@
     COMMIT_FOOTERS: 17,
 
     // Include push certificate information along with any patch sets.
-    PUSH_CERTIFICATES: 18
+    PUSH_CERTIFICATES: 18,
+
+    // Include change's reviewer updates.
+    REVIEWER_UPDATES: 19,
+
+    // Set the submittable boolean.
+    SUBMITTABLE: 20
   };
 
   Polymer({
@@ -352,7 +358,8 @@
       var options = this._listChangesOptionsToHex(
           ListChangesOption.ALL_REVISIONS,
           ListChangesOption.CHANGE_ACTIONS,
-          ListChangesOption.DOWNLOAD_COMMANDS
+          ListChangesOption.DOWNLOAD_COMMANDS,
+          ListChangesOption.SUBMITTABLE
       );
       return this._getChangeDetail(changeNum, options, opt_errFn,
           opt_cancelCondition);
@@ -858,5 +865,10 @@
     deleteAccountSSHKey: function(id) {
       return this.send('DELETE', '/accounts/self/sshkeys/' + id);
     },
+
+    deleteVote: function(changeID, account, label) {
+      return this.send('DELETE', '/changes/' + changeID +
+          '/reviewers/' + account + '/votes/' + encodeURIComponent(label));
+    },
   });
 })();
diff --git a/polygerrit-ui/app/test/index.html b/polygerrit-ui/app/test/index.html
index 042a497..c5df0a2 100644
--- a/polygerrit-ui/app/test/index.html
+++ b/polygerrit-ui/app/test/index.html
@@ -22,8 +22,10 @@
 <script src="../bower_components/web-component-tester/browser.js"></script>
 <script>
   var testFiles = [];
-  var basePath = '../elements/';
+  var elementsPath = '../elements/';
+  var behaviorsPath = '../behaviors/';
 
+  // Elements tests.
   [
     'change/gr-account-entry/gr-account-entry_test.html',
     'change/gr-account-list/gr-account-list_test.html',
@@ -94,10 +96,18 @@
     'shared/gr-select/gr-select_test.html',
     'shared/gr-storage/gr-storage_test.html',
   ].forEach(function(file) {
-    file = basePath + file;
+    file = elementsPath + file;
     testFiles.push(file);
     testFiles.push(file + '?dom=shadow');
   });
 
+  // Behaviors tests.
+  [
+    'gr-path-list-behavior/gr-path-list-behavior_test.html',
+  ].forEach(function(file) {
+    file = behaviorsPath + file;
+    testFiles.push(file);
+  });
+
   WCT.loadSuites(testFiles);
 </script>
diff --git a/tools/bzl/asciidoc.bzl b/tools/bzl/asciidoc.bzl
new file mode 100644
index 0000000..17736cd
--- /dev/null
+++ b/tools/bzl/asciidoc.bzl
@@ -0,0 +1,338 @@
+def documentation_attributes():
+  return [
+    "toc",
+    'newline="\\n"',
+    'asterisk="&#42;"',
+    'plus="&#43;"',
+    'caret="&#94;"',
+    'startsb="&#91;"',
+    'endsb="&#93;"',
+    'tilde="&#126;"',
+    "last-update-label!",
+    "source-highlighter=prettify",
+    "stylesheet=DEFAULT",
+    "linkcss=true",
+    "prettifydir=.",
+    # Just a placeholder, will be filled in asciidoctor java binary:
+    "revnumber=%s",
+  ]
+
+
+def release_notes_attributes():
+  return [
+    'toc',
+    'newline="\\n"',
+    'asterisk="&#42;"',
+    'plus="&#43;"',
+    'caret="&#94;"',
+    'startsb="&#91;"',
+    'endsb="&#93;"',
+    'tilde="&#126;"',
+    'last-update-label!',
+    'stylesheet=DEFAULT',
+    'linkcss=true',
+  ]
+
+
+def _replace_macros_impl(ctx):
+  cmd = [
+    ctx.file._exe.path,
+    '--suffix', ctx.attr.suffix,
+    "-s", ctx.file.src.path,
+    "-o", ctx.outputs.out.path,
+  ]
+  if ctx.attr.searchbox:
+    cmd.append('--searchbox')
+  else:
+    cmd.append('--no-searchbox')
+  ctx.action(
+    inputs = [ctx.file._exe, ctx.file.src],
+    outputs = [ctx.outputs.out],
+    command = cmd,
+    progress_message = "Replacing macros in %s" % ctx.file.src.short_path,
+  )
+
+_replace_macros = rule(
+  implementation = _replace_macros_impl,
+  attrs = {
+    "_exe": attr.label(
+      default = Label("//Documentation:replace_macros.py"),
+      allow_single_file = True,
+    ),
+    "src": attr.label(
+      mandatory = True,
+      allow_single_file = [".txt"],
+    ),
+    "suffix": attr.string(mandatory = True),
+    "searchbox": attr.bool(default = True),
+    "out": attr.output(mandatory = True),
+  },
+)
+
+
+def _generate_asciidoc_args(ctx):
+  args = []
+  if ctx.attr.backend:
+    args.extend(["-b", ctx.attr.backend])
+  revnumber = False
+  for attribute in ctx.attr.attributes:
+    if attribute.startswith("revnumber="):
+      revnumber = True
+    else:
+      args.extend(["-a", attribute])
+  if revnumber:
+    args.extend([
+      "--revnumber-file", ctx.file.version.path,
+    ])
+  for src in ctx.files.srcs:
+    args.append(src.path)
+  return args
+
+
+def _invoke_replace_macros(name, src, suffix, searchbox):
+  fn = src
+  if fn.startswith(":"):
+    fn = src[1:]
+
+  _replace_macros(
+    name = "macros_%s_%s" % (name, fn),
+    src = src,
+    out = fn + suffix,
+    suffix = suffix,
+    searchbox = searchbox,
+  )
+
+  return ":" + fn + suffix, fn.replace(".txt", ".html")
+
+
+def _asciidoc_impl(ctx):
+  args = [
+    "--bazel",
+    "--in-ext", ".txt" + ctx.attr.suffix,
+    "--out-ext", ".html",
+  ]
+  args.extend(_generate_asciidoc_args(ctx))
+  ctx.action(
+    inputs = ctx.files.srcs + [ctx.executable._exe, ctx.file.version],
+    outputs = ctx.outputs.outs,
+    executable = ctx.executable._exe,
+    arguments = args,
+    progress_message = "Rendering asciidoctor files for %s" % ctx.label.name,
+  )
+
+_asciidoc = rule(
+  implementation = _asciidoc_impl,
+  attrs = {
+    "_exe": attr.label(
+      default = Label("//lib/asciidoctor:asciidoc"),
+      allow_files = True,
+      executable = True,
+    ),
+    "srcs": attr.label_list(mandatory = True, allow_files = True),
+    "version": attr.label(
+      default = Label("//:version.txt"),
+      allow_single_file = True,
+    ),
+    "suffix": attr.string(mandatory = True),
+    "backend": attr.string(),
+    "attributes": attr.string_list(),
+    "outs": attr.output_list(mandatory = True),
+  },
+)
+
+
+def _genasciidoc_htmlonly(
+    name,
+    srcs = [],
+    attributes = [],
+    backend = None,
+    searchbox = True,
+    **kwargs):
+  SUFFIX = "." + name + "_macros"
+  new_srcs = []
+  outs = ["asciidoctor.css"]
+
+  for src in srcs:
+    new_src, html_name = _invoke_replace_macros(name, src, SUFFIX, searchbox)
+    new_srcs.append(new_src)
+    outs.append(html_name)
+
+  _asciidoc(
+    name = name + "_gen",
+    srcs = new_srcs,
+    suffix = SUFFIX,
+    backend = backend,
+    attributes = attributes,
+    outs = outs,
+  )
+
+  native.filegroup(
+    name = name,
+    data = outs,
+    **kwargs
+  )
+
+
+def genasciidoc(
+    name,
+    srcs = [],
+    attributes = [],
+    backend = None,
+    searchbox = True,
+    resources = True,
+    **kwargs):
+  SUFFIX = "_htmlonly"
+
+  _genasciidoc_htmlonly(
+    name = name + SUFFIX if resources else name,
+    srcs = srcs,
+    attributes = attributes,
+    backend = backend,
+    searchbox = searchbox,
+    **kwargs
+  )
+
+  if resources:
+    htmlonly = ":" + name + SUFFIX
+    native.filegroup(
+      name = name,
+      srcs = [
+        htmlonly,
+        "//Documentation:resources",
+      ],
+      **kwargs
+    )
+
+
+def _asciidoc_html_zip_impl(ctx):
+  args = [
+    "--mktmp",
+    "-z", ctx.outputs.out.path,
+    "--in-ext", ".txt" + ctx.attr.suffix,
+    "--out-ext", ".html",
+  ]
+  args.extend(_generate_asciidoc_args(ctx))
+  ctx.action(
+    inputs = ctx.files.srcs + [ctx.executable._exe, ctx.file.version],
+    outputs = [ctx.outputs.out],
+    executable = ctx.executable._exe,
+    arguments = args,
+    progress_message = "Rendering asciidoctor files for %s" % ctx.label.name,
+  )
+
+_asciidoc_html_zip = rule(
+  implementation = _asciidoc_html_zip_impl,
+  attrs = {
+    "_exe": attr.label(
+      default = Label("//lib/asciidoctor:asciidoc"),
+      allow_files = True,
+      executable = True,
+    ),
+    "srcs": attr.label_list(mandatory = True, allow_files = True),
+    "version": attr.label(
+      default = Label("//:version.txt"),
+      allow_single_file = True,
+    ),
+    "suffix": attr.string(mandatory = True),
+    "backend": attr.string(),
+    "attributes": attr.string_list(),
+  },
+  outputs = {
+    "out": "%{name}.zip",
+  }
+)
+
+
+def _genasciidoc_htmlonly_zip(
+    name,
+    srcs = [],
+    attributes = [],
+    backend = None,
+    searchbox = True,
+    **kwargs):
+  SUFFIX = "." + name + "_expn"
+  new_srcs = []
+
+  for src in srcs:
+    new_src, _ = _invoke_replace_macros(name, src, SUFFIX, searchbox)
+    new_srcs.append(new_src)
+
+  _asciidoc_html_zip(
+    name = name,
+    srcs = new_srcs,
+    suffix = SUFFIX,
+    backend = backend,
+    attributes = attributes,
+  )
+
+
+def _asciidoc_zip_impl(ctx):
+  tmpdir = ctx.outputs.out.path + "_tmpdir"
+  cmd = [
+    "p=$PWD",
+    "mkdir -p %s" % tmpdir,
+    "unzip -q %s -d %s/%s/" % (ctx.file.src.path, tmpdir, ctx.attr.directory),
+  ]
+  for r in ctx.files.resources:
+    if r.path == r.short_path:
+      cmd.append("tar -cf- %s | tar -C %s -xf-" % (r.short_path, tmpdir))
+    else:
+      parent = r.path[:-len(r.short_path)]
+      cmd.append(
+        "tar -C %s -cf- %s | tar -C %s -xf-" % (parent, r.short_path, tmpdir))
+  cmd.extend([
+    "cd %s" % tmpdir,
+    "zip -qr $p/%s *" % ctx.outputs.out.path,
+  ])
+  ctx.action(
+    inputs = [ctx.file.src] + ctx.files.resources,
+    outputs = [ctx.outputs.out],
+    command = " && ".join(cmd),
+    progress_message =
+        "Generating asciidoctor zip file %s" % ctx.outputs.out.short_path,
+  )
+
+_asciidoc_zip = rule(
+  implementation = _asciidoc_zip_impl,
+  attrs = {
+    "src": attr.label(
+      mandatory = True,
+      allow_single_file = [".zip"],
+    ),
+    "resources": attr.label_list(mandatory = True, allow_files = True),
+    "directory": attr.string(mandatory = True),
+  },
+  outputs = {
+    "out": "%{name}.zip",
+  }
+)
+
+
+def genasciidoc_zip(
+    name,
+    srcs = [],
+    attributes = [],
+    directory = None,
+    backend = None,
+    searchbox = True,
+    resources = True,
+    **kwargs):
+  SUFFIX = "_htmlonly"
+
+  _genasciidoc_htmlonly_zip(
+    name = name + SUFFIX if resources else name,
+    srcs = srcs,
+    attributes = attributes,
+    backend = backend,
+    searchbox = searchbox,
+    **kwargs
+  )
+
+  if resources:
+    htmlonly = ":" + name + SUFFIX
+    _asciidoc_zip(
+      name = name,
+      src = htmlonly,
+      resources = ["//Documentation:resources"],
+      directory = directory,
+    )
diff --git a/tools/bzl/license.bzl b/tools/bzl/license.bzl
index 37cc70c..60ee60b 100644
--- a/tools/bzl/license.bzl
+++ b/tools/bzl/license.bzl
@@ -2,7 +2,7 @@
 def normalize_target_name(target):
   return target.replace("//", "").replace("/", "__").replace(":", "___")
 
-def license_map(name, targets = [], opts = []):
+def license_map(name, targets = [], opts = [], **kwargs):
   """Generate XML for all targets that depend directly on a LICENSE file"""
   xmls = []
   tools = [ "//tools/bzl:license-map.py", "//lib:all-licenses" ]
@@ -28,7 +28,8 @@
     name = "gen_license_txt_" + name,
     cmd = "python $(location //tools/bzl:license-map.py) %s %s > $@" % (" ".join(opts), " ".join(xmls)),
     outs = [ name + ".txt" ],
-    tools = tools
+    tools = tools,
+    **kwargs
   )
 
 def license_test(name, target):