Merge "Allow labelAs-$NAME permission for InternalUser"
diff --git a/BUILD b/BUILD
index 7ae3589..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'])
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/config-gerrit.txt b/Documentation/config-gerrit.txt
index 1c7981a..462c226 100644
--- a/Documentation/config-gerrit.txt
+++ b/Documentation/config-gerrit.txt
@@ -67,6 +67,15 @@
 +
 Default is 20.
 
+[[addReviewer.baseWeight]]addReviewer.baseWeight::
++
+The weight that will be applied in the default reviewer ranking algorithm.
+This can be increased or decreased to give more or less influence to plugins.
+If set to zero, the base ranking will not have any effect. Reviewers will then
+be ordered as ranked by the plugins (if there are any).
++
+By default 1.
+
 [[auth]]
 === Section auth
 
@@ -3907,11 +3916,11 @@
 [[suggest.from]]suggest.from::
 +
 The number of characters that a user must have typed before suggestions
-are provided. If set to 0, suggestions are always provided.
+are provided. If set to 0, suggestions are always provided. This is only
+used for suggesting accounts when adding members to a group.
 +
 By default 0.
 
-
 [[theme]]
 === Section theme
 
diff --git a/Documentation/config-project-config.txt b/Documentation/config-project-config.txt
index 5483d85..34f39c8 100644
--- a/Documentation/config-project-config.txt
+++ b/Documentation/config-project-config.txt
@@ -84,6 +84,11 @@
 also redefine the text and behavior of the built in label types `Code-Review`
 and `Verified`.
 
+Optionally a +commentlink+ section can be added to define project-specific
+comment links. The +commentlink+ section has the same format as the
+link:config-gerrit.html#commentlink[+commentlink+ section in gerrit.config]
+which is used to define global comment links.
+
 [[project-section]]
 === Project section
 
diff --git a/Documentation/dev-plugins.txt b/Documentation/dev-plugins.txt
index 3260e23..ccfc440 100644
--- a/Documentation/dev-plugins.txt
+++ b/Documentation/dev-plugins.txt
@@ -2399,6 +2399,44 @@
 ----
 
 
+[[reviewer-suggestion]]
+== Reviewer Suggestion Plugins
+
+Gerrit provides an extension point that enables Plugins to rank
+the list of reviewer suggestion a user receives upon clicking "Add Reviewer" on
+the change screen.
+Gerrit supports both a default suggestion that appears when the user has not yet
+typed anything and a filtered suggestion that is shown as the user starts
+typing.
+Plugins receive a candidate list and can return a Set of suggested reviewers
+containing the Account.Id and a score for each reviewer.
+The candidate list is non-binding and plugins can choose to return reviewers not
+initially contained in the candidate list.
+Server administrators can configure the overall weight of each plugin using the
+weight config parameter on [addreviewer "<pluginName-exportName>"].
+
+[source, java]
+----
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+
+import java.util.Set;
+
+public class MyPlugin implements ReviewerSuggestion {
+  public Set<SuggestedReviewer> suggestReviewers(Project.NameKey project,
+      @Nullable Change.Id changeId, @Nullable String query,
+      Set<Account.Id> candidates) {
+    Set<SuggestedReviewer> suggestions = new HashSet<>();
+    // Implement your ranking logic here
+    return suggestions;
+  }
+}
+----
+
+
 == SEE ALSO
 
 * link:js-api.html[JavaScript API]
diff --git a/Documentation/dev-readme.txt b/Documentation/dev-readme.txt
index 4959ced..bd7f6e9 100644
--- a/Documentation/dev-readme.txt
+++ b/Documentation/dev-readme.txt
@@ -90,22 +90,49 @@
   java -jar buck-out/gen/gerrit/gerrit.war init -d ../gerrit_testsite
 ----
 
-Accept defaults by pressing Enter until 'init' completes, or add
-the '--batch' command line option to avoid them entirely.  It is
-recommended to change the listen addresses from '*' to 'localhost' to
-prevent outside connections from contacting the development instance.
+During initialization, make two changes to the default settings:
 
-The daemon will automatically start in the background and a web
-browser will launch to the start page, enabling login via OpenID.
+* Change the listen addresses from '*' to 'localhost' to prevent outside
+  connections from contacting the development instance; and
+* Change the auth type from 'OPENID' to 'DEVELOPMENT_BECOME_ANY_ACCOUNT' to
+  allow yourself to create and act as arbitrary test accounts on your
+  development instance.
 
-Shutdown the daemon after registering the administrator account
-through the web interface:
+Continue through init until it completes. The daemon will automatically start in
+the background and a web browser will launch to the start page. From here you
+can sign in as the account created during init, register additional accounts,
+create projects, and more.
+
+When you want to shut down the daemon, simply run:
 
 ----
   ../gerrit_testsite/bin/gerrit.sh stop
 ----
 
 
+[[localdev]]
+== Working with the Local Server
+
+If you need to create additional accounts on your development instance, click
+'become' in the upper right corner, select 'Switch User', and then register
+a new account.
+
+Use the `ssh` protocol to clone from and push to the local server. For
+example, to clone a repository that you've created through the admin
+interface, run:
+
+----
+git clone ssh://username@localhost:29418/projectname
+----
+
+Then you'll be able to create changes the same way users do, with
+
+----
+git push origin HEAD:refs/for/master
+----
+
+
+
 == Testing
 
 
diff --git a/Documentation/intro-project-owner.txt b/Documentation/intro-project-owner.txt
index 7a724f7..72fe717 100644
--- a/Documentation/intro-project-owner.txt
+++ b/Documentation/intro-project-owner.txt
@@ -70,8 +70,8 @@
 commands:
 
 ----
-  $ git fetch origin refs/meta/config:config
-  $ git checkout config
+  $ git fetch ssh://localhost:29418/project refs/meta/config
+  $ git checkout FETCH_HEAD
   $ git log project.config
 ----
 
diff --git a/Documentation/pgm-init.txt b/Documentation/pgm-init.txt
index 39167bd..9a16cdf 100644
--- a/Documentation/pgm-init.txt
+++ b/Documentation/pgm-init.txt
@@ -10,6 +10,7 @@
 _java_ -jar gerrit.war _init_
   -d <SITE_PATH>
   [--batch]
+  [--delete-caches]
   [--no-auto-start]
   [--skip-plugins]
   [--list-plugins]
@@ -42,6 +43,10 @@
 statements to drop these objects is provided. To drop the unused
 objects these SQL statements must be executed manually.
 
+--delete-caches::
+	Force deletion of all persistent cache files. Note that
+	re-creation of these caches may be expensive.
+
 --no-auto-start::
 	Don't automatically start the daemon after initializing a
 	newly created site path.  This permits the administrator
diff --git a/Documentation/rest-api-changes.txt b/Documentation/rest-api-changes.txt
index e7a1cdf..32e93d3 100644
--- a/Documentation/rest-api-changes.txt
+++ b/Documentation/rest-api-changes.txt
@@ -4860,11 +4860,13 @@
 === CherryPickInput
 The `CherryPickInput` entity contains information for cherry-picking a change to a new branch.
 
-[options="header",cols="1,6"]
+[options="header",cols="1,^1,5"]
 |===========================
-|Field Name    |Description
-|`message`     |Commit message for the cherry-picked change
-|`destination` |Destination branch
+|Field Name         ||Description
+|`message`          ||Commit message for the cherry-picked change
+|`destination`      ||Destination branch
+|`parent`           |optional, defaults to 1|
+Number of the parent relative to which the cherry-pick should be considered.
 |===========================
 
 [[comment-info]]
@@ -5579,7 +5581,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. +
@@ -5710,6 +5714,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/WORKSPACE b/WORKSPACE
index 6660924..4487821 100644
--- a/WORKSPACE
+++ b/WORKSPACE
@@ -672,8 +672,8 @@
 
 maven_jar(
   name = 'truth',
-  artifact = 'com.google.truth:truth:0.28',
-  sha1 = '0a388c7877c845ff4b8e19689dda5ac9d34622c4',
+  artifact = 'com.google.truth:truth:0.30',
+  sha1 = '9d591b5a66eda81f0b88cf1c748ab8853d99b18b',
 )
 
 maven_jar(
@@ -962,6 +962,14 @@
   name = "bower",
 )
 
+npm_binary(
+  name = "vulcanize",
+)
+
+npm_binary(
+  name = "crisper",
+)
+
 # bower_archive() seed components.
 bower_archive(
   name = 'iron-autogrow-textarea',
@@ -1040,6 +1048,29 @@
   sha1 = 'a3b598c06cbd7f441402e666ff748326030905d6',
 )
 
+# bower test stuff
+
+bower_archive(
+  name = 'iron-test-helpers',
+  package = 'polymerelements/iron-test-helpers',
+  version = '1.2.5',
+  sha1 = '433b03b106f5ff32049b84150cd70938e18b67ac',
+)
+
+bower_archive(
+  name = 'test-fixture',
+  package = 'polymerelements/test-fixture',
+  version = '1.1.1',
+  sha1 = 'e373bd21c069163c3a754e234d52c07c77b20d3c',
+)
+
+bower_archive(
+  name = 'web-component-tester',
+  package = 'web-component-tester',
+  version = '4.2.2',
+  sha1 = '54556000c33d9ed7949aa546c1b4a1531491a5f0',
+)
+
 # Bower component transitive dependencies.
 load("//lib/js:bower_archives.bzl", "load_bower_archives")
 load_bower_archives()
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/group/GroupAssert.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java
index c3c2224..6c301da 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/api/group/GroupAssert.java
@@ -31,7 +31,7 @@
         .that(actual.remove(g)).isTrue();
     }
     assert_().withFailureMessage("unexpected groups: " + actual)
-      .that((Iterable<?>)actual).isEmpty();
+      .that(actual).isEmpty();
   }
 
   public static void assertGroupInfo(AccountGroup group, GroupInfo info) {
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..92a3382 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,18 +21,17 @@
 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;
 
 import com.google.common.base.Joiner;
+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.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 +39,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 +49,19 @@
 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.BadRequestException;
 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.reviewdb.client.Branch;
 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 +81,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 +130,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();
@@ -438,6 +361,111 @@
   }
 
   @Test
+  public void cherryPickMergeRelativeToDefaultParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+
+    ChangeInfo cherryPickedChangeInfo = gApi.changes()
+        .id(mergeChangeResult.getChangeId())
+        .current()
+        .cherryPick(cherryPickInput)
+        .get();
+
+    Map<String, FileInfo> cherryPickedFilesByName =
+        cherryPickedChangeInfo.revisions
+            .get(cherryPickedChangeInfo.currentRevision)
+            .files;
+    assertThat(cherryPickedFilesByName).containsKey(parent2FileName);
+    assertThat(cherryPickedFilesByName).doesNotContainKey(parent1FileName);
+  }
+
+  @Test
+  public void cherryPickMergeRelativeToSpecificParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+    cherryPickInput.parent = 2;
+
+    ChangeInfo cherryPickedChangeInfo = gApi.changes()
+        .id(mergeChangeResult.getChangeId())
+        .current()
+        .cherryPick(cherryPickInput)
+        .get();
+
+    Map<String, FileInfo> cherryPickedFilesByName =
+        cherryPickedChangeInfo.revisions
+            .get(cherryPickedChangeInfo.currentRevision)
+            .files;
+    assertThat(cherryPickedFilesByName).containsKey(parent1FileName);
+    assertThat(cherryPickedFilesByName).doesNotContainKey(parent2FileName);
+  }
+
+  @Test
+  public void cherryPickMergeUsingInvalidParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+    cherryPickInput.parent = 0;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Cherry Pick: Parent 0 does not exist. Please"
+        + " specify a parent in range [1, 2].");
+    gApi.changes()
+        .id(mergeChangeResult.getChangeId())
+        .current()
+        .cherryPick(cherryPickInput);
+  }
+
+  @Test
+  public void cherryPickMergeUsingNonExistentParent() throws Exception {
+    String parent1FileName = "a.txt";
+    String parent2FileName = "b.txt";
+    PushOneCommit.Result mergeChangeResult =
+        createCherryPickableMerge(parent1FileName, parent2FileName);
+
+    String cherryPickBranchName = "branch_for_cherry_pick";
+    createBranch(new Branch.NameKey(project, cherryPickBranchName));
+
+    CherryPickInput cherryPickInput = new CherryPickInput();
+    cherryPickInput.destination = cherryPickBranchName;
+    cherryPickInput.message = "Cherry-pick a merge commit to another branch";
+    cherryPickInput.parent = 3;
+
+    exception.expect(BadRequestException.class);
+    exception.expectMessage("Cherry Pick: Parent 3 does not exist. Please"
+        + " specify a parent in range [1, 2].");
+    gApi.changes()
+        .id(mergeChangeResult.getChangeId())
+        .current()
+        .cherryPick(cherryPickInput);
+  }
+
+  @Test
   public void canRebase() throws Exception {
     PushOneCommit push = pushFactory.create(db, admin.getIdent(), testRepo);
     PushOneCommit.Result r1 = push.to("refs/for/master");
@@ -887,4 +915,35 @@
     assertDiffForNewFile(diff, pushResult.getCommit(), path,
         expectedContentSideB);
   }
+
+  private PushOneCommit.Result createCherryPickableMerge(String parent1FileName,
+      String parent2FileName) throws Exception {
+    RevCommit initialCommit = getHead(repo());
+
+    String branchAName = "branchA";
+    createBranch(new Branch.NameKey(project, branchAName));
+    String branchBName = "branchB";
+    createBranch(new Branch.NameKey(project, branchBName));
+
+    PushOneCommit.Result changeAResult = pushFactory
+        .create(db, admin.getIdent(), testRepo, "change a",
+            parent1FileName, "Content of a")
+        .to("refs/for/" + branchAName);
+
+    testRepo.reset(initialCommit);
+    PushOneCommit.Result changeBResult = pushFactory
+        .create(db, admin.getIdent(), testRepo, "change b",
+            parent2FileName, "Content of b")
+        .to("refs/for/" + branchBName);
+
+    PushOneCommit pushableMergeCommit = pushFactory.create(db, admin.getIdent(),
+        testRepo, "merge", ImmutableMap.of(parent1FileName, "Content of a",
+            parent2FileName, "Content of b"));
+    pushableMergeCommit.setParents(ImmutableList.of(changeAResult.getCommit(),
+        changeBResult.getCommit()));
+    PushOneCommit.Result mergeChangeResult =
+        pushableMergeCommit.to("refs/for/" + branchAName);
+    mergeChangeResult.assertOkStatus();
+    return mergeChangeResult;
+  }
 }
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/rest/change/HashtagsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
index a044772..0c53658 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/HashtagsIT.java
@@ -245,10 +245,8 @@
     assertMessage(r, "Hashtag added: MyHashtag");
   }
 
-  private IterableSubject<
-        ? extends IterableSubject<?, String, Iterable<String>>,
-        String, Iterable<String>>
-      assertThatGet(PushOneCommit.Result r) throws Exception {
+  private IterableSubject assertThatGet(PushOneCommit.Result r)
+      throws Exception {
     return assertThat(gApi.changes()
         .id(r.getChange().getId().get())
         .getHashtags());
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
index 0f251f5..b80abbb 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/change/SuggestReviewersIT.java
@@ -20,15 +20,20 @@
 import com.google.gerrit.acceptance.AbstractDaemonTest;
 import com.google.gerrit.acceptance.GerritConfig;
 import com.google.gerrit.acceptance.GerritConfigs;
+import com.google.gerrit.acceptance.Sandboxed;
 import com.google.gerrit.acceptance.TestAccount;
 import com.google.gerrit.common.data.GlobalCapability;
 import com.google.gerrit.common.data.GroupDescription;
 import com.google.gerrit.common.data.GroupDescriptions;
+import com.google.gerrit.extensions.api.changes.ReviewInput;
+import com.google.gerrit.extensions.common.ChangeInput;
 import com.google.gerrit.extensions.common.GroupInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
+import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.TopLevelResource;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.AccountGroup;
+import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.group.CreateGroup;
 import com.google.gerrit.server.group.GroupsCollection;
 import com.google.inject.Inject;
@@ -38,7 +43,9 @@
 
 import java.util.Arrays;
 import java.util.List;
+import java.util.stream.Collectors;
 
+@Sandboxed
 public class SuggestReviewersIT extends AbstractDaemonTest {
   @Inject
   private CreateGroup.Factory createGroupFactory;
@@ -89,15 +96,6 @@
   }
 
   @Test
-  @GerritConfig(name = "suggest.from", value = "2")
-  public void suggestReviewersNoResult3() throws Exception {
-    String changeId = createChange().getChangeId();
-    List<SuggestedReviewerInfo> reviewers =
-        suggestReviewers(changeId, name("").substring(0, 1), 6);
-    assertThat(reviewers).isEmpty();
-  }
-
-  @Test
   public void suggestReviewersChange() throws Exception {
     String changeId = createChange().getChangeId();
     List<SuggestedReviewerInfo> reviewers =
@@ -204,7 +202,7 @@
     assertThat(reviewers).hasSize(1);
 
     reviewers = suggestReviewers(changeId, "example.com", 7);
-    assertThat(reviewers).hasSize(6);
+    assertThat(reviewers).hasSize(5);
 
     reviewers = suggestReviewers(changeId, user1.email, 2);
     assertThat(reviewers).hasSize(1);
@@ -267,6 +265,145 @@
     assertThat(reviewer.confirm).isTrue();
   }
 
+  @Test
+  public void defaultReviewerSuggestion() throws Exception{
+    TestAccount user1 = user("customuser1", "User1");
+    TestAccount reviewer1 = user("customuser2", "User2");
+    TestAccount reviewer2 = user("customuser3", "User3");
+
+    setApiUser(user1);
+    String changeId1 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId1);
+
+    setApiUser(user1);
+    String changeId2 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId2);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId2);
+
+    setApiUser(user1);
+    List<SuggestedReviewerInfo>  reviewers =
+        suggestReviewers(createChangeFromApi(), null, 4);
+    assertThat(
+        reviewers.stream()
+            .map(r -> r.account._accountId)
+            .collect(Collectors.toList()))
+        .containsExactly(
+            reviewer1.id.get(),
+            reviewer2.id.get())
+        .inOrder();
+  }
+
+  @Test
+  public void defaultReviewerSuggestionOnFirstChange() throws Exception{
+    TestAccount user1 = user("customuser1", "User1");
+    setApiUser(user1);
+    List<SuggestedReviewerInfo>  reviewers =
+        suggestReviewers(createChange().getChangeId(), "", 4);
+    assertThat(reviewers).isEmpty();
+  }
+
+  @Test
+  @GerritConfig(name = "suggest.maxSuggestedReviewers", value = "10")
+  public void reviewerRanking() throws Exception{
+    // Assert that user are ranked by the number of times they have applied a
+    // a label to a change (highest), added comments (medium) or owned a
+    // change (low).
+    String fullName = "Primum Finalis";
+    TestAccount userWhoOwns = user("customuser1", fullName);
+    TestAccount reviewer1 = user("customuser2", fullName);
+    TestAccount reviewer2 = user("customuser3", fullName);
+    TestAccount userWhoComments = user("customuser4", fullName);
+    TestAccount userWhoLooksForSuggestions = user("customuser5", fullName);
+
+    // Create a change as userWhoOwns and add some reviews
+    setApiUser(userWhoOwns);
+    String changeId1 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId1);
+
+    setApiUser(user1);
+    String changeId2 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId2);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId2);
+
+    // Create a comment as a different user
+    setApiUser(userWhoComments);
+    ReviewInput ri = new ReviewInput();
+    ri.message = "Test";
+    gApi.changes().id(changeId1).revision(1).review(ri);
+
+    // Create a change as a new user to assert that we receive the correct
+    // ranking
+
+    setApiUser(userWhoLooksForSuggestions);
+    List<SuggestedReviewerInfo>  reviewers =
+        suggestReviewers(createChangeFromApi(), "Pri", 4);
+    assertThat(
+        reviewers.stream()
+            .map(r -> r.account._accountId)
+            .collect(Collectors.toList()))
+        .containsExactly(
+            reviewer1.id.get(),
+            reviewer2.id.get(),
+            userWhoOwns.id.get(),
+            userWhoComments.id.get())
+        .inOrder();
+  }
+
+  @Test
+  public void reviewerRankingProjectIsolation() throws Exception{
+    // Create new project
+    Project.NameKey newProject = createProject("test");
+
+    // Create users who review changes in both the default and the new project
+    String fullName = "Primum Finalis";
+    TestAccount userWhoOwns = user("customuser1", fullName);
+    TestAccount reviewer1 = user("customuser2", fullName);
+    TestAccount reviewer2 = user("customuser3", fullName);
+
+    setApiUser(userWhoOwns);
+    String changeId1 = createChangeFromApi();
+
+    setApiUser(reviewer1);
+    reviewChange(changeId1);
+
+    setApiUser(userWhoOwns);
+    String changeId2 = createChangeFromApi(newProject);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId2);
+
+    setApiUser(userWhoOwns);
+    String changeId3 = createChangeFromApi(newProject);
+
+    setApiUser(reviewer2);
+    reviewChange(changeId3);
+
+    setApiUser(userWhoOwns);
+    List<SuggestedReviewerInfo> reviewers =
+        suggestReviewers(createChangeFromApi(), "Prim", 4);
+
+    // Assert that reviewer1 is on top, even though reviewer2 has more reviews
+    // in other projects
+    assertThat(
+        reviewers.stream()
+            .map(r -> r.account._accountId)
+            .collect(Collectors.toList()))
+        .containsExactly(reviewer1.id.get(), reviewer2.id.get())
+        .inOrder();
+  }
+
   private List<SuggestedReviewerInfo> suggestReviewers(String changeId,
       String query, int n) throws Exception {
     return gApi.changes()
@@ -296,4 +433,23 @@
       throws Exception {
     return user(name, fullName, name, groups);
   }
+
+  private void reviewChange(String changeId) throws RestApiException {
+    ReviewInput ri = new ReviewInput();
+    ri.label("Code-Review", 1);
+    gApi.changes().id(changeId).current().review(ri);
+  }
+
+  private String createChangeFromApi() throws RestApiException{
+    return createChangeFromApi(project);
+  }
+
+  private String createChangeFromApi(Project.NameKey project)
+      throws RestApiException{
+    ChangeInput ci = new ChangeInput();
+    ci.project = project.get();
+    ci.subject = "Test change at" + System.nanoTime();
+    ci.branch = "master";
+    return gApi.changes().create(ci).get().changeId;
+  }
 }
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectAssert.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
index e081ce5..e3104bb 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/rest/project/ProjectAssert.java
@@ -31,12 +31,8 @@
 import java.util.Set;
 
 public class ProjectAssert {
-  public static IterableSubject<
-        ? extends IterableSubject<
-            ?, Project.NameKey, Iterable<Project.NameKey>>,
-        Project.NameKey,
-        Iterable<Project.NameKey>>
-      assertThatNameList(Iterable<ProjectInfo> actualIt) {
+  public static IterableSubject assertThatNameList(
+      Iterable<ProjectInfo> actualIt) {
     List<ProjectInfo> actual = ImmutableList.copyOf(actualIt);
     for (ProjectInfo info : actual) {
       assertWithMessage("missing project name").that(info.name).isNotNull();
diff --git a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
index 77cb11b..34301ed 100644
--- a/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
+++ b/gerrit-acceptance-tests/src/test/java/com/google/gerrit/acceptance/server/change/CommentsIT.java
@@ -397,7 +397,7 @@
     setApiUser(admin);
     Map<String, List<CommentInfo>> actual =
         gApi.changes().id(r1.getChangeId()).drafts();
-    assertThat((Iterable<?>) actual.keySet()).containsExactly(FILE_NAME);
+    assertThat(actual.keySet()).containsExactly(FILE_NAME);
     List<CommentInfo> comments = actual.get(FILE_NAME);
     assertThat(comments).hasSize(2);
 
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-acceptance-tests/tests.bzl b/gerrit-acceptance-tests/tests.bzl
index 62a99e3..160234f 100644
--- a/gerrit-acceptance-tests/tests.bzl
+++ b/gerrit-acceptance-tests/tests.bzl
@@ -11,7 +11,8 @@
     flaky = 0,
     deps = [],
     labels = [],
-    vm_args = ['-Xmx256m']):
+    vm_args = ['-Xmx256m'],
+    **kwargs):
   junit_tests(
     name = group,
     srcs = srcs,
@@ -24,4 +25,5 @@
       'slow',
     ],
     jvm_flags = vm_args,
+    **kwargs
   )
diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
index 7ae7ef1..2e1bb13 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/api/changes/CherryPickInput.java
@@ -17,4 +17,5 @@
 public class CherryPickInput {
   public String message;
   public String destination;
+  public Integer parent;
 }
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-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSet.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSet.java
index 28052ef..6eb11bc 100644
--- a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSet.java
+++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/registration/DynamicSet.java
@@ -53,6 +53,7 @@
    * @param member type of entry in the set.
    */
   public static <T> void setOf(Binder binder, Class<T> member) {
+    binder.disableCircularProxies();
     setOf(binder, TypeLiteral.get(member));
   }
 
@@ -71,6 +72,7 @@
     @SuppressWarnings("unchecked")
     Key<DynamicSet<T>> key = (Key<DynamicSet<T>>) Key.get(
         Types.newParameterizedType(DynamicSet.class, member.getType()));
+    binder.disableCircularProxies();
     binder.bind(key)
       .toProvider(new DynamicSetProvider<>(member))
       .in(Scopes.SINGLETON);
@@ -84,6 +86,7 @@
    * @return a binder to continue configuring the new set member.
    */
   public static <T> LinkedBindingBuilder<T> bind(Binder binder, Class<T> type) {
+    binder.disableCircularProxies();
     return bind(binder, TypeLiteral.get(type));
   }
 
@@ -95,6 +98,7 @@
    * @return a binder to continue configuring the new set member.
    */
   public static <T> LinkedBindingBuilder<T> bind(Binder binder, TypeLiteral<T> type) {
+    binder.disableCircularProxies();
     return binder.bind(type).annotatedWith(UniqueAnnotations.create());
   }
 
@@ -110,6 +114,7 @@
   public static <T> LinkedBindingBuilder<T> bind(Binder binder,
       Class<T> type,
       Named name) {
+    binder.disableCircularProxies();
     return bind(binder, TypeLiteral.get(type));
   }
 
@@ -125,6 +130,7 @@
   public static <T> LinkedBindingBuilder<T> bind(Binder binder,
       TypeLiteral<T> type,
       Named name) {
+    binder.disableCircularProxies();
     return binder.bind(type).annotatedWith(name);
   }
 
diff --git a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
index cf5a445..525a837 100644
--- a/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
+++ b/gerrit-gwtexpui/src/main/java/com/google/gwtexpui/safehtml/client/HighlightSuggestOracle.java
@@ -43,7 +43,7 @@
   }
 
   @Override
-  public final void requestSuggestions(final Request request, final Callback cb) {
+  public final void requestSuggestions(Request request, Callback cb) {
     onRequestSuggestions(request, new Callback() {
       @Override
       public void onSuggestionsReady(final Request request,
@@ -88,27 +88,28 @@
         ds = escape(ds);
       }
 
-      StringBuilder pattern = new StringBuilder();
-      for (String qterm : splitQuery(qstr)) {
-        qterm = escape(qterm);
-        // We now surround qstr by <strong>. But the chosen approach is not too
-        // smooth, if qstr is small (e.g.: "t") and this small qstr may occur in
-        // escapes (e.g.: "Tim &lt;email@example.org&gt;"). Those escapes will
-        // get <strong>-ed as well (e.g.: "&lt;" -> "&<strong>l</strong>t;"). But
-        // as repairing those mangled escapes is easier than not mangling them in
-        // the first place, we repair them afterwards.
-
-        if (pattern.length() > 0) {
-          pattern.append("|");
+      if (qstr != null && !qstr.isEmpty()) {
+        StringBuilder pattern = new StringBuilder();
+        for (String qterm : splitQuery(qstr)) {
+          qterm = escape(qterm);
+          // We now surround qstr by <strong>. But the chosen approach is not too
+          // smooth, if qstr is small (e.g.: "t") and this small qstr may occur in
+          // escapes (e.g.: "Tim &lt;email@example.org&gt;"). Those escapes will
+          // get <strong>-ed as well (e.g.: "&lt;" -> "&<strong>l</strong>t;"). But
+          // as repairing those mangled escapes is easier than not mangling them in
+          // the first place, we repair them afterwards.
+          if (pattern.length() > 0) {
+            pattern.append("|");
+          }
+          pattern.append(qterm);
         }
-        pattern.append(qterm);
+
+        ds = sgi(ds, "(" + pattern.toString() + ")", "<strong>$1</strong>");
+
+        // Repairing <strong>-ed escapes.
+        ds = sgi(ds, "(&[a-z]*)<strong>([a-z]*)</strong>([a-z]*;)", "$1$2$3");
       }
 
-      ds = sgi(ds, "(" + pattern.toString() + ")", "<strong>$1</strong>");
-
-      // Repairing <strong>-ed escapes.
-      ds = sgi(ds, "(&[a-z]*)<strong>([a-z]*)</strong>([a-z]*;)", "$1$2$3");
-
       displayString = ds;
     }
 
diff --git a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java
index cf7e1d8..5a6918a 100644
--- a/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java
+++ b/gerrit-gwtui-common/src/main/java/com/google/gerrit/client/ui/RemoteSuggestOracle.java
@@ -14,6 +14,7 @@
 
 package com.google.gerrit.client.ui;
 
+import com.google.gwt.user.client.Timer;
 import com.google.gwt.user.client.ui.SuggestOracle;
 
 /**
@@ -31,6 +32,10 @@
   private final SuggestOracle oracle;
   private Query query;
   private String last;
+  private Timer requestRetentionTimer;
+  private boolean cancelOutstandingRequest;
+
+  private boolean serveSuggestions;
 
   public RemoteSuggestOracle(SuggestOracle src) {
     oracle = src;
@@ -42,13 +47,33 @@
 
   @Override
   public void requestSuggestions(Request req, Callback cb) {
-    Query q = new Query(req, cb);
-    if (query == null) {
-      query = q;
-      q.start();
-    } else {
-      query = q;
+    if (!serveSuggestions){
+      return;
     }
+
+    // Use a timer for key stroke retention, such that we don't query the
+    // backend for each and every keystroke we receive.
+    if (requestRetentionTimer != null) {
+      requestRetentionTimer.cancel();
+    }
+    requestRetentionTimer = new Timer() {
+      @Override
+      public void run() {
+        Query q = new Query(req, cb);
+        if (query == null) {
+          query = q;
+          q.start();
+        } else {
+          query = q;
+        }
+      }
+    };
+    requestRetentionTimer.schedule(200);
+  }
+
+  @Override
+  public void requestDefaultSuggestions(Request req, Callback cb) {
+    requestSuggestions(req, cb);
   }
 
   @Override
@@ -56,6 +81,19 @@
     return oracle.isDisplayStringHTML();
   }
 
+  public void cancelOutstandingRequest() {
+    if (requestRetentionTimer != null) {
+      requestRetentionTimer.cancel();
+    }
+    if (query != null) {
+      cancelOutstandingRequest = true;
+    }
+  }
+
+  public void setServeSuggestions(boolean serveSuggestions) {
+    this.serveSuggestions = serveSuggestions;
+  }
+
   private class Query implements Callback {
     final Request request;
     final Callback callback;
@@ -71,7 +109,11 @@
 
     @Override
     public void onSuggestionsReady(Request req, Response res) {
-      if (query == this) {
+      if (cancelOutstandingRequest || !serveSuggestions) {
+        // If cancelOutstandingRequest() was called, we ignore this response
+        cancelOutstandingRequest = false;
+        query = null;
+      } else if (query == this) {
         // No new request was started while this query was running.
         // Propose this request's response as the suggestions.
         query = null;
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/ReviewerSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java
index 404f3c8..6f518b1 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/change/ReviewerSuggestOracle.java
@@ -21,21 +21,21 @@
 import com.google.gerrit.client.rpc.GerritCallback;
 import com.google.gerrit.client.rpc.Natives;
 import com.google.gerrit.client.ui.AccountSuggestOracle;
-import com.google.gerrit.client.ui.SuggestAfterTypingNCharsOracle;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwt.core.client.JavaScriptObject;
 import com.google.gwt.core.client.JsArray;
+import com.google.gwtexpui.safehtml.client.HighlightSuggestOracle;
 
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.List;
 
 /** REST API based suggestion Oracle for reviewers. */
-public class ReviewerSuggestOracle extends SuggestAfterTypingNCharsOracle {
+public class ReviewerSuggestOracle extends HighlightSuggestOracle {
   private Change.Id changeId;
 
   @Override
-  protected void _onRequestSuggestions(final Request req, final Callback cb) {
+  protected void onRequestSuggestions(final Request req, final Callback cb) {
     ChangeApi
         .suggestReviewers(changeId.get(), req.getQuery(), req.getLimit(), false)
         .get(new GerritCallback<JsArray<SuggestReviewerInfo>>() {
@@ -56,6 +56,11 @@
         });
   }
 
+  @Override
+  public void requestDefaultSuggestions(final Request req, final Callback cb) {
+    requestSuggestions(req, cb);
+  }
+
   public void setChange(Change.Id changeId) {
     this.changeId = changeId;
   }
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..e0c252c 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
@@ -81,6 +81,7 @@
   Reviewers() {
     reviewerSuggestOracle = new ReviewerSuggestOracle();
     suggestBox = new RemoteSuggestBox(reviewerSuggestOracle);
+    suggestBox.enableDefaultSuggestions();
     suggestBox.setVisibleLength(55);
     suggestBox.setHintText(Util.C.approvalTableAddReviewerHint());
     suggestBox.addCloseHandler(new CloseHandler<RemoteSuggestBox>() {
@@ -123,6 +124,7 @@
     UIObject.setVisible(form, true);
     UIObject.setVisible(error, false);
     addReviewerIcon.setVisible(false);
+    suggestBox.setServeSuggestionsOnOracle(true);
     suggestBox.setFocus(true);
   }
 
@@ -143,6 +145,7 @@
     UIObject.setVisible(form, false);
     suggestBox.setFocus(false);
     suggestBox.setText("");
+    suggestBox.setServeSuggestionsOnOracle(false);
   }
 
   private void addReviewer(final String reviewer, boolean confirmed) {
@@ -198,7 +201,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/changes/ChangeApi.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
index 4882b97..a008149 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/changes/ChangeApi.java
@@ -171,10 +171,13 @@
   }
 
   public static RestApi suggestReviewers(int id, String q, int n, boolean e) {
-    return change(id).view("suggest_reviewers")
-        .addParameter("q", q)
+    RestApi api = change(id).view("suggest_reviewers")
         .addParameter("n", n)
         .addParameter("e", e);
+    if (q != null) {
+      api.addParameter("q", q);
+    }
+    return api;
   }
 
   public static RestApi vote(int id, int reviewer, String vote) {
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
index 3702e68..bfeeaec 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/AccountSuggestOracle.java
@@ -61,7 +61,8 @@
 
     public static String format(AccountInfo info, String query) {
       String s = FormatUtil.nameEmail(info);
-      if (!containsQuery(s, query) && info.secondaryEmails() != null) {
+      if (query != null && !containsQuery(s, query) &&
+          info.secondaryEmails() != null) {
         for (String email : Natives.asList(info.secondaryEmails())) {
           AccountInfo info2 = AccountInfo.create(info._accountId(), info.name(),
               email, info.username());
diff --git a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java
index 084cb9a..57cd849 100644
--- a/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java
+++ b/gerrit-gwtui/src/main/java/com/google/gerrit/client/ui/RemoteSuggestBox.java
@@ -13,6 +13,8 @@
 // limitations under the License.
 package com.google.gerrit.client.ui;
 
+import com.google.gwt.event.dom.client.FocusEvent;
+import com.google.gwt.event.dom.client.FocusHandler;
 import com.google.gwt.event.dom.client.KeyCodes;
 import com.google.gwt.event.dom.client.KeyDownEvent;
 import com.google.gwt.event.dom.client.KeyDownHandler;
@@ -42,6 +44,7 @@
 
   public RemoteSuggestBox(SuggestOracle oracle) {
     remoteSuggestOracle = new RemoteSuggestOracle(oracle);
+    remoteSuggestOracle.setServeSuggestions(true);
     display = new DefaultSuggestionDisplay();
 
     textBox = new HintTextBox();
@@ -49,7 +52,6 @@
       @Override
       public void onKeyDown(KeyDownEvent e) {
         submitOnSelection = false;
-
         if (e.getNativeKeyCode() == KeyCodes.KEY_ESCAPE) {
           CloseEvent.fire(RemoteSuggestBox.this, RemoteSuggestBox.this);
         } else if (e.getNativeKeyCode() == KeyCodes.KEY_ENTER) {
@@ -70,10 +72,11 @@
     suggestBox.addSelectionHandler(new SelectionHandler<Suggestion>() {
       @Override
       public void onSelection(SelectionEvent<Suggestion> event) {
-        textBox.setFocus(true);
         if (submitOnSelection) {
           SelectionEvent.fire(RemoteSuggestBox.this, getText());
         }
+        remoteSuggestOracle.cancelOutstandingRequest();
+        display.hideSuggestions();
       }
     });
     initWidget(suggestBox);
@@ -138,4 +141,19 @@
   public void selectAll() {
     suggestBox.getValueBox().selectAll();
   }
+
+  public void enableDefaultSuggestions() {
+    textBox.addFocusHandler(new FocusHandler() {
+      @Override
+      public void onFocus(FocusEvent focusEvent) {
+        if (textBox.getText().equals("")) {
+          suggestBox.showSuggestionList();
+        }
+      }
+    });
+  }
+
+  public void setServeSuggestionsOnOracle(boolean serveSuggestions) {
+    remoteSuggestOracle.setServeSuggestions(serveSuggestions);
+  }
 }
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.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java
index 2ed4c36..b3813f6 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/Init.java
@@ -49,6 +49,10 @@
       usage = "Batch mode; skip interactive prompting")
   private boolean batchMode;
 
+  @Option(name = "--delete-caches",
+      usage = "Delete all persistent caches without asking")
+  private boolean deleteCaches;
+
   @Option(name = "--no-auto-start", usage = "Don't automatically start daemon after init")
   private boolean noAutoStart;
 
@@ -160,6 +164,12 @@
   }
 
   @Override
+  protected boolean getDeleteCaches() {
+    return deleteCaches;
+  }
+
+
+  @Override
   protected boolean skipPlugins() {
     return skipPlugins;
   }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
index 36163b6..8ccdebc 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/BaseInit.java
@@ -119,6 +119,8 @@
     init.flags.autoStart = getAutoStart() && init.site.isNew;
     init.flags.dev = isDev() && init.site.isNew;
     init.flags.skipPlugins = skipPlugins();
+    init.flags.deleteCaches = getDeleteCaches();
+
 
     final SiteRun run;
     try {
@@ -471,4 +473,8 @@
   protected boolean isDev() {
     return false;
   }
+
+  protected boolean getDeleteCaches() {
+    return false;
+  }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java
index b5b230d..aac2b36 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitCache.java
@@ -15,28 +15,41 @@
 package com.google.gerrit.pgm.init;
 
 import com.google.gerrit.common.FileUtil;
+import com.google.gerrit.pgm.init.api.ConsoleUI;
+import com.google.gerrit.pgm.init.api.InitFlags;
 import com.google.gerrit.pgm.init.api.InitStep;
 import com.google.gerrit.pgm.init.api.Section;
 import com.google.gerrit.server.config.SitePaths;
 import com.google.inject.Inject;
 import com.google.inject.Singleton;
 
+import java.io.IOException;
+import java.nio.file.DirectoryStream;
+import java.nio.file.Files;
 import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
 
 /** Initialize the {@code cache} configuration section. */
 @Singleton
 class InitCache implements InitStep {
+  private final ConsoleUI ui;
+  private final InitFlags flags;
   private final SitePaths site;
   private final Section cache;
 
   @Inject
-  InitCache(final SitePaths site, final Section.Factory sections) {
+  InitCache(final ConsoleUI ui, final InitFlags flags,
+      final SitePaths site, final Section.Factory sections) {
+    this.ui = ui;
+    this.flags = flags;
     this.site = site;
     this.cache = sections.get("cache", null);
   }
 
   @Override
   public void run() {
+    ui.header("Cache");
     String path = cache.get("directory");
 
     if (path != null && path.isEmpty()) {
@@ -53,5 +66,27 @@
 
     Path loc = site.resolve(path);
     FileUtil.mkdirsOrDie(loc, "cannot create cache.directory");
+    List<Path> cacheFiles = new ArrayList<>();
+    try (DirectoryStream<Path> stream =
+        Files.newDirectoryStream(loc, "*.{lock,h2,trace}.db")) {
+      for (Path entry : stream) {
+        cacheFiles.add(entry);
+      }
+    } catch (IOException e) {
+      ui.message("IO error during cache directory scan");
+      return;
+    }
+    if (!cacheFiles.isEmpty()) {
+      for (Path entry : cacheFiles) {
+        if (flags.deleteCaches ||
+            ui.yesno(false, "Delete cache file %s", entry)) {
+          try {
+            Files.deleteIfExists(entry);
+          } catch (IOException e) {
+            ui.message("Could not delete " + entry);
+          }
+        }
+      }
+    }
   }
 }
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDev.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDev.java
new file mode 100644
index 0000000..5500da8
--- /dev/null
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitDev.java
@@ -0,0 +1,42 @@
+// 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.pgm.init;
+
+import com.google.gerrit.pgm.init.api.InitFlags;
+import com.google.gerrit.pgm.init.api.InitStep;
+import com.google.gerrit.pgm.init.api.Section;
+import com.google.inject.Inject;
+import com.google.inject.Singleton;
+
+@Singleton
+public class InitDev implements InitStep {
+  private final InitFlags flags;
+  private final Section plugins;
+
+  @Inject
+  InitDev(InitFlags flags,
+      Section.Factory sections) {
+    this.flags = flags;
+    this.plugins = sections.get("plugins", null);
+  }
+
+  @Override
+  public void run() throws Exception {
+    if (!flags.dev) {
+      return;
+    }
+    plugins.set("allowRemoteAdmin", "true");
+  }
+}
diff --git a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
index b5aa625..a442f29 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/InitModule.java
@@ -64,6 +64,7 @@
     step().to(InitHttpd.class);
     step().to(InitCache.class);
     step().to(InitPlugins.class);
+    step().to(InitDev.class);
   }
 
   protected LinkedBindingBuilder<InitStep> step() {
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-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
index f0674d6..fa62d93 100644
--- a/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
+++ b/gerrit-pgm/src/main/java/com/google/gerrit/pgm/init/api/InitFlags.java
@@ -39,6 +39,9 @@
   /** Skip plugins */
   public boolean skipPlugins;
 
+  /** Delete all cache files */
+  public boolean deleteCaches;
+
   /** Dev mode */
   public boolean dev;
 
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/PatchSet.java b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
index a8bf07b..2210319 100644
--- a/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
+++ b/gerrit-reviewdb/src/main/java/com/google/gerrit/reviewdb/client/PatchSet.java
@@ -201,6 +201,16 @@
     id = k;
   }
 
+  public PatchSet(PatchSet src) {
+    this.id = src.id;
+    this.revision = src.revision;
+    this.uploader = src.uploader;
+    this.createdOn = src.createdOn;
+    this.draft = src.draft;
+    this.groups = src.groups;
+    this.pushCertificate = src.pushCertificate;
+  }
+
   public PatchSet.Id getId() {
     return id;
   }
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..a2becc2 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,12 @@
         new PatchSetApproval.Key(psId, src.getAccountId(), src.getLabelId());
     value = src.getValue();
     granted = src.granted;
+    realAccountId = src.realAccountId;
+    tag = src.tag;
+  }
+
+  public PatchSetApproval(PatchSetApproval src) {
+    this(src.getPatchSetId(), src);
   }
 
   public PatchSetApproval.Key getKey() {
@@ -124,6 +137,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 +188,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 +203,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/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/ReviewerRecommender.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
new file mode 100644
index 0000000..69f294d
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewerRecommender.java
@@ -0,0 +1,269 @@
+// 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;
+
+import com.google.common.base.Strings;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableMap;
+import com.google.common.collect.ImmutableSet;
+import com.google.gerrit.common.data.LabelType;
+import com.google.gerrit.extensions.registration.DynamicMap;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.PatchSetApproval;
+import com.google.gerrit.server.account.AccountDirectory.FillOptions;
+import com.google.gerrit.server.account.AccountLoader;
+import com.google.gerrit.server.change.ReviewerSuggestion;
+import com.google.gerrit.server.change.SuggestReviewers;
+import com.google.gerrit.server.change.SuggestedReviewer;
+import com.google.gerrit.server.config.GerritServerConfig;
+import com.google.gerrit.server.git.WorkQueue;
+import com.google.gerrit.server.index.change.ChangeField;
+import com.google.gerrit.server.notedb.ChangeNotes;
+import com.google.gerrit.server.project.ProjectControl;
+import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
+import com.google.gerrit.server.query.change.ChangeData;
+import com.google.gerrit.server.query.change.ChangeQueryBuilder;
+import com.google.gerrit.server.query.change.InternalChangeQuery;
+import com.google.gwtorm.server.OrmException;
+import com.google.inject.Inject;
+
+import org.apache.commons.lang.mutable.MutableDouble;
+import org.eclipse.jgit.lib.Config;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.EnumSet;
+import java.util.HashMap;
+import java.util.Iterator;
+import java.util.List;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Map.Entry;
+import java.util.Set;
+import java.util.concurrent.Callable;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+public class ReviewerRecommender {
+  private static final Logger log =
+      LoggerFactory.getLogger(ReviewersUtil.class);
+  private static final double BASE_REVIEWER_WEIGHT = 10;
+  private static final double BASE_OWNER_WEIGHT = 1;
+  private static final double BASE_COMMENT_WEIGHT = 0.5;
+  private static final double[] WEIGHTS = new double[] {
+      BASE_REVIEWER_WEIGHT, BASE_OWNER_WEIGHT, BASE_COMMENT_WEIGHT,};
+  private static final long PLUGIN_QUERY_TIMEOUT = 500; //ms
+
+  private final ChangeQueryBuilder changeQueryBuilder;
+  private final Config config;
+  private final DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap;
+  private final InternalChangeQuery internalChangeQuery;
+  private final WorkQueue workQueue;
+
+  @Inject
+  ReviewerRecommender(ChangeQueryBuilder changeQueryBuilder,
+      DynamicMap<ReviewerSuggestion> reviewerSuggestionPluginMap,
+      InternalChangeQuery internalChangeQuery,
+      WorkQueue workQueue,
+      @GerritServerConfig Config config) {
+    Set<FillOptions> fillOptions = EnumSet.of(FillOptions.SECONDARY_EMAILS);
+    fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
+    this.changeQueryBuilder = changeQueryBuilder;
+    this.config = config;
+    this.internalChangeQuery = internalChangeQuery;
+    this.reviewerSuggestionPluginMap = reviewerSuggestionPluginMap;
+    this.workQueue = workQueue;
+  }
+
+  public List<Account.Id> suggestReviewers(
+      ChangeNotes changeNotes,
+      SuggestReviewers suggestReviewers, ProjectControl projectControl,
+      List<Account.Id> candidateList)
+      throws OrmException {
+    String query = suggestReviewers.getQuery();
+    double baseWeight = config.getInt("addReviewer", "baseWeight", 1);
+
+    Map<Account.Id, MutableDouble> reviewerScores;
+    if (Strings.isNullOrEmpty(query)) {
+      reviewerScores = baseRankingForEmptyQuery(baseWeight);
+    } else {
+      reviewerScores = baseRankingForCandidateList(
+          candidateList, projectControl, baseWeight);
+    }
+
+    // Send the query along with a candidate list to all plugins and merge the
+    // results. Plugins don't necessarily need to use the candidates list, they
+    // can also return non-candidate account ids.
+    List<Callable<Set<SuggestedReviewer>>> tasks =
+        new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
+    List<Double> weights =
+        new ArrayList<>(reviewerSuggestionPluginMap.plugins().size());
+
+    for (DynamicMap.Entry<ReviewerSuggestion> plugin :
+        reviewerSuggestionPluginMap) {
+      tasks.add(() -> plugin.getProvider().get()
+          .suggestReviewers(projectControl.getProject().getNameKey(),
+              changeNotes.getChangeId(), query, reviewerScores.keySet()));
+      String pluginWeight = config.getString("addReviewer",
+          plugin.getPluginName() + "-" + plugin.getExportName(), "weight");
+      if (Strings.isNullOrEmpty(pluginWeight)) {
+        pluginWeight = "1";
+      }
+      try {
+        weights.add(Double.parseDouble(pluginWeight));
+      } catch (NumberFormatException e) {
+        log.error("Exception while parsing weight for " +
+            plugin.getPluginName() + "-" + plugin.getExportName(), e);
+        weights.add(1d);
+      }
+    }
+
+    try {
+      List<Future<Set<SuggestedReviewer>>> futures = workQueue
+          .getDefaultQueue()
+          .invokeAll(tasks, PLUGIN_QUERY_TIMEOUT, TimeUnit.MILLISECONDS);
+      Iterator<Double> weightIterator = weights.iterator();
+      for (Future<Set<SuggestedReviewer>> f : futures) {
+        double weight = weightIterator.next();
+        for (SuggestedReviewer s : f.get()) {
+          if (reviewerScores.containsKey(s.account)) {
+            reviewerScores.get(s.account).add(s.score * weight);
+          } else {
+            reviewerScores.put(s.account, new MutableDouble(s.score * weight));
+          }
+        }
+      }
+    } catch (ExecutionException | InterruptedException e) {
+      log.error("Exception while suggesting reviewers", e);
+      return ImmutableList.of();
+    }
+
+    // Remove change owner
+    if (changeNotes != null) {
+      reviewerScores.remove(changeNotes.getChange().getOwner());
+    }
+
+    // Sort results
+    Stream<Entry<Account.Id, MutableDouble>> sorted =
+        reviewerScores.entrySet().stream()
+            .sorted(Collections.reverseOrder(Map.Entry.comparingByValue()));
+    List<Account.Id> sortedSuggestions = sorted
+        .map(Map.Entry::getKey)
+        .collect(Collectors.toList());
+    return sortedSuggestions;
+  }
+
+  private Map<Account.Id, MutableDouble> baseRankingForEmptyQuery(
+      double baseWeight) throws OrmException{
+    // Get the user's last 50 changes, check approvals
+    try {
+      List<ChangeData> result = internalChangeQuery
+          .setLimit(50)
+          .setRequestedFields(ImmutableSet.of(ChangeField.REVIEWER.getName()))
+          .query(changeQueryBuilder.owner("self"));
+      Map<Account.Id, MutableDouble> suggestions = new HashMap<>();
+      for (ChangeData cd : result) {
+        for (PatchSetApproval approval : cd.currentApprovals()) {
+          Account.Id id = approval.getAccountId();
+          if (suggestions.containsKey(id)) {
+            suggestions.get(id).add(baseWeight);
+          } else {
+            suggestions.put(id, new MutableDouble(baseWeight));
+          }
+        }
+      }
+      return suggestions;
+    } catch (QueryParseException e) {
+      // Unhandled, because owner:self will never provoke a QueryParseException
+      log.error("Exception while suggesting reviewers", e);
+      return ImmutableMap.of();
+    }
+  }
+
+  private Map<Account.Id, MutableDouble> baseRankingForCandidateList(
+      List<Account.Id> candidates,
+      ProjectControl projectControl,
+      double baseWeight) throws OrmException {
+    // Get each reviewer's activity based on number of applied labels
+    // (weighted 10d), number of comments (weighted 0.5d) and number of owned
+    // changes (weighted 1d).
+    Map<Account.Id, MutableDouble> reviewers = new LinkedHashMap<>();
+    if (candidates.size() == 0) {
+      return reviewers;
+    }
+    List<Predicate<ChangeData>> predicates = new ArrayList<>();
+    for (Account.Id id : candidates) {
+      try {
+        Predicate<ChangeData> projectQuery =
+            changeQueryBuilder.project(projectControl.getProject().getName());
+
+        // Get all labels for this project and create a compound OR query to
+        // fetch all changes where users have applied one of these labels
+        List<LabelType> labelTypes =
+            projectControl.getLabelTypes().getLabelTypes();
+        List<Predicate<ChangeData>> labelPredicates =
+            new ArrayList<>(labelTypes.size());
+        for (LabelType type : labelTypes) {
+          labelPredicates
+              .add(changeQueryBuilder.label(type.getName() + ",user=" + id));
+        }
+        Predicate<ChangeData> reviewerQuery =
+            Predicate.and(projectQuery, Predicate.or(labelPredicates));
+
+        Predicate<ChangeData> ownerQuery = Predicate.and(projectQuery,
+            changeQueryBuilder.owner(id.toString()));
+        Predicate<ChangeData> commentedByQuery = Predicate.and(projectQuery,
+            changeQueryBuilder.commentby(id.toString()));
+
+        predicates.add(reviewerQuery);
+        predicates.add(ownerQuery);
+        predicates.add(commentedByQuery);
+        reviewers.put(id, new MutableDouble());
+      } catch (QueryParseException e) {
+        // Unhandled: If an exception is thrown, we won't increase the
+        // candidates's score
+        log.error("Exception while suggesting reviewers", e);
+      }
+    }
+
+    List<List<ChangeData>> result = internalChangeQuery
+        .setLimit(100 * predicates.size())
+        .setRequestedFields(ImmutableSet.of())
+        .query(predicates);
+
+    Iterator<List<ChangeData>> queryResultIterator = result.iterator();
+    Iterator<Account.Id> reviewersIterator = reviewers.keySet().iterator();
+
+    int i = 0;
+    Account.Id currentId = null;
+    while (queryResultIterator.hasNext()) {
+      List<ChangeData> currentResult = queryResultIterator.next();
+      if (i % WEIGHTS.length == 0) {
+        currentId = reviewersIterator.next();
+      }
+
+      reviewers.get(currentId).add(WEIGHTS[i % WEIGHTS.length] *
+          baseWeight * currentResult.size());
+      i++;
+    }
+    return reviewers;
+  }
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
index b01c233..1781f1a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/ReviewersUtil.java
@@ -14,20 +14,15 @@
 
 package com.google.gerrit.server;
 
-import static java.util.Comparator.comparing;
-
-import com.google.common.base.MoreObjects;
 import com.google.common.base.Strings;
 import com.google.common.collect.ImmutableList;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
-import com.google.common.collect.Ordering;
 import com.google.gerrit.common.data.GroupReference;
 import com.google.gerrit.common.errors.NoSuchGroupException;
 import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.extensions.common.GroupBaseInfo;
 import com.google.gerrit.extensions.common.SuggestedReviewerInfo;
-import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Url;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.AccountExternalId;
@@ -44,6 +39,7 @@
 import com.google.gerrit.server.change.SuggestReviewers;
 import com.google.gerrit.server.index.account.AccountIndex;
 import com.google.gerrit.server.index.account.AccountIndexCollection;
+import com.google.gerrit.server.notedb.ChangeNotes;
 import com.google.gerrit.server.project.NoSuchProjectException;
 import com.google.gerrit.server.project.ProjectControl;
 import com.google.gerrit.server.query.QueryParseException;
@@ -56,98 +52,95 @@
 
 import java.io.IOException;
 import java.util.ArrayList;
-import java.util.Collection;
 import java.util.Collections;
 import java.util.EnumSet;
-import java.util.HashMap;
-import java.util.LinkedHashMap;
+import java.util.HashSet;
 import java.util.List;
-import java.util.Map;
 import java.util.Set;
 
 public class ReviewersUtil {
   private static final String MAX_SUFFIX = "\u9fa5";
-  private static final Ordering<SuggestedReviewerInfo> ORDERING =
-      Ordering.<SuggestedReviewerInfo> from(comparing(
-          suggestedReviewerInfo -> {
-            if (suggestedReviewerInfo == null) {
-              return null;
-            }
-            return suggestedReviewerInfo.account != null
-                ? MoreObjects.firstNonNull(suggestedReviewerInfo.account.email,
-                Strings.nullToEmpty(suggestedReviewerInfo.account.name))
-                : Strings.nullToEmpty(suggestedReviewerInfo.group.name);
-          }));
+  // Generate a candidate list at 3x the size of what the user wants to see to
+  // give the ranking algorithm a good set of candidates it can work with
+  private static final int CANDIDATE_LIST_MULTIPLIER = 3;
 
-  private final AccountLoader accountLoader;
   private final AccountCache accountCache;
-  private final AccountIndexCollection indexes;
-  private final AccountQueryBuilder queryBuilder;
-  private final AccountQueryProcessor queryProcessor;
   private final AccountControl accountControl;
-  private final Provider<ReviewDb> dbProvider;
+  private final AccountIndexCollection accountIndexes;
+  private final AccountLoader accountLoader;
+  private final AccountQueryBuilder accountQueryBuilder;
+  private final AccountQueryProcessor accountQueryProcessor;
   private final GroupBackend groupBackend;
   private final GroupMembers.Factory groupMembersFactory;
   private final Provider<CurrentUser> currentUser;
+  private final Provider<ReviewDb> dbProvider;
+  private final ReviewerRecommender reviewerRecommender;
 
   @Inject
-  ReviewersUtil(AccountLoader.Factory accountLoaderFactory,
-      AccountCache accountCache,
-      AccountIndexCollection indexes,
-      AccountQueryBuilder queryBuilder,
-      AccountQueryProcessor queryProcessor,
+  ReviewersUtil(AccountCache accountCache,
       AccountControl.Factory accountControlFactory,
-      Provider<ReviewDb> dbProvider,
+      AccountIndexCollection accountIndexes,
+      AccountLoader.Factory accountLoaderFactory,
+      AccountQueryBuilder accountQueryBuilder,
+      AccountQueryProcessor accountQueryProcessor,
       GroupBackend groupBackend,
       GroupMembers.Factory groupMembersFactory,
-      Provider<CurrentUser> currentUser) {
+      Provider<CurrentUser> currentUser,
+      Provider<ReviewDb> dbProvider,
+      ReviewerRecommender reviewerRecommender) {
     Set<FillOptions> fillOptions = EnumSet.of(FillOptions.SECONDARY_EMAILS);
     fillOptions.addAll(AccountLoader.DETAILED_OPTIONS);
-    this.accountLoader = accountLoaderFactory.create(fillOptions);
     this.accountCache = accountCache;
-    this.indexes = indexes;
-    this.queryBuilder = queryBuilder;
-    this.queryProcessor = queryProcessor;
     this.accountControl = accountControlFactory.get();
+    this.accountIndexes = accountIndexes;
+    this.accountLoader = accountLoaderFactory.create(fillOptions);
+    this.accountQueryBuilder = accountQueryBuilder;
+    this.accountQueryProcessor = accountQueryProcessor;
+    this.currentUser = currentUser;
     this.dbProvider = dbProvider;
     this.groupBackend = groupBackend;
     this.groupMembersFactory = groupMembersFactory;
-    this.currentUser = currentUser;
+    this.reviewerRecommender = reviewerRecommender;
   }
 
   public interface VisibilityControl {
     boolean isVisibleTo(Account.Id account) throws OrmException;
   }
 
-  public List<SuggestedReviewerInfo> suggestReviewers(
+  public List<SuggestedReviewerInfo> suggestReviewers(ChangeNotes changeNotes,
       SuggestReviewers suggestReviewers, ProjectControl projectControl,
       VisibilityControl visibilityControl, boolean excludeGroups)
-      throws IOException, OrmException, BadRequestException {
+      throws IOException, OrmException {
     String query = suggestReviewers.getQuery();
-    boolean suggestAccounts = suggestReviewers.getSuggestAccounts();
-    int suggestFrom = suggestReviewers.getSuggestFrom();
     int limit = suggestReviewers.getLimit();
 
-    if (Strings.isNullOrEmpty(query)) {
-      throw new BadRequestException("missing query field");
-    }
-
-    if (!suggestAccounts || query.length() < suggestFrom) {
+    if (!suggestReviewers.getSuggestAccounts()) {
       return Collections.emptyList();
     }
 
-    Collection<AccountInfo> suggestedAccounts =
-        suggestAccounts(suggestReviewers, visibilityControl);
-
-    List<SuggestedReviewerInfo> reviewer = new ArrayList<>();
-    for (AccountInfo a : suggestedAccounts) {
-      SuggestedReviewerInfo info = new SuggestedReviewerInfo();
-      info.account = a;
-      info.count = 1;
-      reviewer.add(info);
+    List<Account.Id> candidateList = new ArrayList<>();
+    if (!Strings.isNullOrEmpty(query)) {
+      candidateList = suggestAccounts(suggestReviewers, visibilityControl);
     }
 
-    if (!excludeGroups) {
+    List<Account.Id> sortedRecommendations = reviewerRecommender
+        .suggestReviewers(changeNotes, suggestReviewers, projectControl,
+            candidateList);
+
+    // Populate AccountInfo
+    List<SuggestedReviewerInfo> reviewer = new ArrayList<>();
+    for (Account.Id id : sortedRecommendations) {
+      AccountInfo account = accountLoader.get(id);
+      if (account != null) {
+        SuggestedReviewerInfo info = new SuggestedReviewerInfo();
+        info.account = account;
+        info.count = 1;
+        reviewer.add(info);
+      }
+    }
+    accountLoader.fill();
+
+    if (!excludeGroups && !Strings.isNullOrEmpty(query)) {
       for (GroupReference g : suggestAccountGroup(suggestReviewers, projectControl)) {
         GroupAsReviewer result = suggestGroupAsReviewer(
             suggestReviewers, projectControl.getProject(), g, visibilityControl);
@@ -161,59 +154,56 @@
           if (result.allowedWithConfirmation) {
             suggestedReviewerInfo.confirm = true;
           }
+          // Always add groups at the end as individual accounts are usually
+          // more important
           reviewer.add(suggestedReviewerInfo);
         }
       }
     }
 
-    reviewer = ORDERING.immutableSortedCopy(reviewer);
     if (reviewer.size() <= limit) {
       return reviewer;
     }
     return reviewer.subList(0, limit);
   }
 
-  private Collection<AccountInfo> suggestAccounts(SuggestReviewers suggestReviewers,
+  private List<Account.Id> suggestAccounts(SuggestReviewers suggestReviewers,
       VisibilityControl visibilityControl)
       throws OrmException {
-    AccountIndex searchIndex = indexes.getSearchIndex();
+    AccountIndex searchIndex = accountIndexes.getSearchIndex();
     if (searchIndex != null) {
       return suggestAccountsFromIndex(suggestReviewers);
     }
     return suggestAccountsFromDb(suggestReviewers, visibilityControl);
   }
 
-  private Collection<AccountInfo> suggestAccountsFromIndex(
+  private List<Account.Id> suggestAccountsFromIndex(
       SuggestReviewers suggestReviewers) throws OrmException {
     try {
-      Map<Account.Id, AccountInfo> matches = new LinkedHashMap<>();
-      QueryResult<AccountState> result = queryProcessor
-          .setLimit(suggestReviewers.getLimit())
-          .query(queryBuilder.defaultQuery(suggestReviewers.getQuery()));
+      Set<Account.Id> matches = new HashSet<>();
+      QueryResult<AccountState> result = accountQueryProcessor
+          .setLimit(suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER)
+          .query(accountQueryBuilder.defaultQuery(suggestReviewers.getQuery()));
       for (AccountState accountState : result.entities()) {
         Account.Id id = accountState.getAccount().getId();
-        matches.put(id, accountLoader.get(id));
+        matches.add(id);
       }
-
-      accountLoader.fill();
-
-      return matches.values();
+      return new ArrayList<>(matches);
     } catch (QueryParseException e) {
       return ImmutableList.of();
     }
   }
 
-  private Collection<AccountInfo> suggestAccountsFromDb(
+  private List<Account.Id> suggestAccountsFromDb(
       SuggestReviewers suggestReviewers, VisibilityControl visibilityControl)
           throws OrmException {
     String query = suggestReviewers.getQuery();
-    int limit = suggestReviewers.getLimit();
+    int limit = suggestReviewers.getLimit() * CANDIDATE_LIST_MULTIPLIER;
 
     String a = query;
     String b = a + MAX_SUFFIX;
 
-    Map<Account.Id, AccountInfo> r = new LinkedHashMap<>();
-    Map<Account.Id, String> queryEmail = new HashMap<>();
+    Set<Account.Id> r = new HashSet<>();
 
     for (Account p : dbProvider.get().accounts()
         .suggestByFullName(a, b, limit)) {
@@ -234,36 +224,26 @@
     if (r.size() < limit) {
       for (AccountExternalId e : dbProvider.get().accountExternalIds()
           .suggestByEmailAddress(a, b, limit - r.size())) {
-        if (!r.containsKey(e.getAccountId())) {
+        if (!r.contains(e.getAccountId())) {
           Account p = accountCache.get(e.getAccountId()).getAccount();
           if (p.isActive()) {
-            if (addSuggestion(r, p.getId(), visibilityControl)) {
-              queryEmail.put(e.getAccountId(), e.getEmailAddress());
-            }
+            addSuggestion(r, p.getId(), visibilityControl);
           }
         }
       }
     }
-
-    accountLoader.fill();
-    for (Map.Entry<Account.Id, String> p : queryEmail.entrySet()) {
-      AccountInfo info = r.get(p.getKey());
-      if (info != null) {
-        info.email = p.getValue();
-      }
-    }
-    return new ArrayList<>(r.values());
+    return new ArrayList<>(r);
   }
 
-  private boolean addSuggestion(Map<Account.Id, AccountInfo> map,
+  private boolean addSuggestion(Set<Account.Id> map,
       Account.Id account, VisibilityControl visibilityControl)
       throws OrmException {
-    if (!map.containsKey(account)
+    if (!map.contains(account)
         // Can the suggestion see the change?
         && visibilityControl.isVisibleTo(account)
         // Can the current user see the account?
         && accountControl.canSee(account)) {
-      map.put(account, accountLoader.get(account));
+      map.add(account);
       return true;
     }
     return false;
@@ -282,7 +262,8 @@
     int size;
   }
 
-  private GroupAsReviewer suggestGroupAsReviewer(SuggestReviewers suggestReviewers,
+  private GroupAsReviewer suggestGroupAsReviewer(
+      SuggestReviewers suggestReviewers,
       Project project, GroupReference group,
       VisibilityControl visibilityControl) throws OrmException, IOException {
     GroupAsReviewer result = new GroupAsReviewer();
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..6f6e964 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;
@@ -433,7 +430,7 @@
      * show a transition from an oldValue of 0 to the new value.
      */
     if (fireRevisionCreated) {
-      revisionCreated.fire(change, patchSet, ctx.getAccountId(),
+      revisionCreated.fire(change, patchSet, ctx.getAccount(),
           ctx.getWhen(), notify);
       if (approvals != null && !approvals.isEmpty()) {
         ChangeControl changeControl = changeControlFactory.controlFor(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
index 1a063f4..b5eb193 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/CherryPick.java
@@ -60,10 +60,12 @@
   public ChangeInfo apply(RevisionResource revision, CherryPickInput input)
       throws OrmException, IOException, UpdateException, RestApiException {
     final ChangeControl control = revision.getControl();
+    int parent = input.parent == null ? 1 : input.parent;
 
     if (input.message == null || input.message.trim().isEmpty()) {
       throw new BadRequestException("message must be non-empty");
-    } else if (input.destination == null || input.destination.trim().isEmpty()) {
+    } else if (input.destination == null
+        || input.destination.trim().isEmpty()) {
       throw new BadRequestException("destination must be non-empty");
     }
 
@@ -91,7 +93,7 @@
       Change.Id cherryPickedChangeId =
           cherryPickChange.cherryPick(revision.getChange(),
               revision.getPatchSet(), input.message, refName,
-              refControl);
+              refControl, parent);
       return json.create(ChangeJson.NO_OPTIONS).format(revision.getProject(),
           cherryPickedChangeId);
     } catch (InvalidChangeOperationException e) {
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..1a18357 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
@@ -114,8 +114,8 @@
   }
 
   public Change.Id cherryPick(Change change, PatchSet patch,
-      final String message, final String ref,
-      final RefControl refControl) throws NoSuchChangeException,
+      final String message, final String ref, final RefControl refControl,
+      int parent) throws NoSuchChangeException,
       OrmException, MissingObjectException,
       IncorrectObjectTypeException, IOException,
       InvalidChangeOperationException, IntegrationException, UpdateException,
@@ -147,6 +147,13 @@
       CodeReviewCommit commitToCherryPick =
           revWalk.parseCommit(ObjectId.fromString(patch.getRevision().get()));
 
+      if (parent <= 0 || parent > commitToCherryPick.getParentCount()) {
+        throw new InvalidChangeOperationException(String.format(
+            "Cherry Pick: Parent %s does not exist. Please specify a parent in"
+                + " range [1, %s].",
+            parent, commitToCherryPick.getParentCount()));
+      }
+
       Timestamp now = TimeUtil.nowTs();
       PersonIdent committerIdent =
           identifiedUser.newCommitterIdent(now, serverTimeZone);
@@ -160,10 +167,12 @@
 
       CodeReviewCommit cherryPickCommit;
       try {
-        ProjectState projectState = refControl.getProjectControl().getProjectState();
-        cherryPickCommit =
-            mergeUtilFactory.create(projectState).createCherryPickFromCommit(git, oi, mergeTip,
-                commitToCherryPick, committerIdent, commitMessage, revWalk);
+        ProjectState projectState = refControl.getProjectControl()
+            .getProjectState();
+        cherryPickCommit = mergeUtilFactory.create(projectState)
+            .createCherryPickFromCommit(git, oi, mergeTip,
+                commitToCherryPick, committerIdent, commitMessage, revWalk,
+                parent - 1);
 
         Change.Key changeKey;
         final List<String> idList = cherryPickCommit.getFooterLines(
@@ -272,10 +281,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 +289,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..9a6455c 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);
     }
 
@@ -265,7 +263,7 @@
     }
 
     if (fireRevisionCreated) {
-      revisionCreated.fire(change, patchSet, ctx.getAccountId(),
+      revisionCreated.fire(change, patchSet, ctx.getAccount(),
           ctx.getWhen(), notify);
     }
   }
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 0db6f0a..cde49a3 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();
@@ -279,7 +283,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());
   }
 
@@ -516,14 +526,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();
@@ -544,7 +547,7 @@
         }
       }
 
-      switch (firstNonNull(in.drafts, DraftHandling.DELETE)) {
+      switch (in.drafts) {
         case KEEP:
         default:
           break;
@@ -581,13 +584,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);
@@ -650,6 +651,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;
     }
@@ -770,6 +775,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));
@@ -780,11 +786,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);
@@ -822,12 +825,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);
@@ -886,17 +887,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/PostReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
index 287ee7f..ee771f1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PostReviewers.java
@@ -41,6 +41,7 @@
 import com.google.gerrit.server.ApprovalsUtil;
 import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.PatchSetUtil;
+import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountLoader;
 import com.google.gerrit.server.account.AccountsCollection;
 import com.google.gerrit.server.account.GroupMembers;
@@ -99,6 +100,7 @@
   private final ReviewerJson json;
   private final ReviewerAdded reviewerAdded;
   private final NotesMigration migration;
+  private final AccountCache accountCache;
 
   @Inject
   PostReviewers(AccountsCollection accounts,
@@ -116,7 +118,8 @@
       @GerritServerConfig Config cfg,
       ReviewerJson json,
       ReviewerAdded reviewerAdded,
-      NotesMigration migration) {
+      NotesMigration migration,
+      AccountCache accountCache) {
     this.accounts = accounts;
     this.reviewerFactory = reviewerFactory;
     this.approvalsUtil = approvalsUtil;
@@ -133,6 +136,7 @@
     this.json = json;
     this.reviewerAdded = reviewerAdded;
     this.migration = migration;
+    this.accountCache = accountCache;
   }
 
   @Override
@@ -362,8 +366,8 @@
         }
         emailReviewers(rsrc.getChange(), addedReviewers, addedCCs, notify);
         if (!addedReviewers.isEmpty()) {
-          List<Account.Id> reviewers =
-              Lists.transform(addedReviewers, PatchSetApproval::getAccountId);
+          List<Account> reviewers = Lists.transform(addedReviewers,
+              psa -> accountCache.get(psa.getAccountId()).getAccount());
           reviewerAdded.fire(rsrc.getChange(), patchSet, reviewers,
               ctx.getAccount(), ctx.getWhen());
         }
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..1dcbf85 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;
@@ -223,7 +223,7 @@
 
     @Override
     public void postUpdate(Context ctx) throws OrmException {
-      draftPublished.fire(change, patchSet, ctx.getAccountId(),
+      draftPublished.fire(change, patchSet, ctx.getAccount(),
           ctx.getWhen());
       if (patchSet.isDraft() && change.getStatus() == Change.Status.DRAFT) {
         // Skip emails if the patch set is still a draft.
@@ -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/PutAssignee.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
index 4298937..5002436 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/PutAssignee.java
@@ -14,12 +14,15 @@
 
 package com.google.gerrit.server.change;
 
+import com.google.common.base.Strings;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.changes.AddReviewerInput;
 import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.extensions.client.ReviewerState;
 import com.google.gerrit.extensions.common.AccountInfo;
+import com.google.gerrit.extensions.restapi.AuthException;
+import com.google.gerrit.extensions.restapi.BadRequestException;
 import com.google.gerrit.extensions.restapi.Response;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.RestModifyView;
@@ -59,10 +62,17 @@
   @Override
   public Response<AccountInfo> apply(ChangeResource rsrc, AssigneeInput input)
       throws RestApiException, UpdateException, OrmException, IOException {
+    if (!rsrc.getControl().canEditAssignee()) {
+      throw new AuthException("Changing Assignee not permitted");
+    }
+    if (Strings.isNullOrEmpty(input.assignee)) {
+      throw new BadRequestException("missing assignee field");
+    }
+
     try (BatchUpdate bu = batchUpdateFactory.create(db.get(),
         rsrc.getChange().getProject(), rsrc.getControl().getUser(),
         TimeUtil.nowTs())) {
-      SetAssigneeOp op = assigneeFactory.create(input);
+      SetAssigneeOp op = assigneeFactory.create(input.assignee);
       bu.addOp(rsrc.getId(), op);
 
       PostReviewers.Addition reviewersAddition =
@@ -70,7 +80,6 @@
       bu.addOp(rsrc.getId(), reviewersAddition.op);
 
       bu.execute();
-      reviewersAddition.gatherResults();
       return Response.ok(AccountJson.toAccountInfo(op.getNewAssignee()));
     }
   }
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/ReviewerSuggestion.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerSuggestion.java
new file mode 100644
index 0000000..6affd9f
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/ReviewerSuggestion.java
@@ -0,0 +1,45 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.common.Nullable;
+import com.google.gerrit.extensions.annotations.ExtensionPoint;
+import com.google.gerrit.reviewdb.client.Account;
+import com.google.gerrit.reviewdb.client.Change;
+import com.google.gerrit.reviewdb.client.Project;
+
+import java.util.Set;
+
+/**
+ * Listener to provide reviewer suggestions.
+ * <p>
+ * Invoked by Gerrit a user who is searching for a reviewer to add to a change.
+ */
+@ExtensionPoint
+public interface ReviewerSuggestion {
+  /**
+   * Reviewer suggestion.
+   *
+   * @param project The name key of the project the suggestion is for.
+   * @param changeId The changeId that the suggestion is for. Can be an {@code null}.
+   * @param query The query as typed by the user. Can be an {@code null}.
+   * @param candidates A set of candidates for the ranking. Can be empty.
+   * @return Set of suggested reviewers as a tuple of account id and score.
+   *         The account ids listed here don't have to be a part of candidates.
+   */
+  Set<SuggestedReviewer> suggestReviewers(Project.NameKey project,
+      @Nullable Change.Id changeId, @Nullable String query,
+      Set<Account.Id> candidates);
+}
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..7ef72ec 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
@@ -14,18 +14,18 @@
 
 package com.google.gerrit.server.change;
 
+import static com.google.common.base.Preconditions.checkNotNull;
+
 import com.google.common.base.Optional;
-import com.google.gerrit.extensions.api.changes.AssigneeInput;
 import com.google.gerrit.extensions.registration.DynamicSet;
 import com.google.gerrit.extensions.restapi.AuthException;
-import com.google.gerrit.extensions.restapi.BadRequestException;
+import com.google.gerrit.extensions.restapi.ResourceConflictException;
 import com.google.gerrit.extensions.restapi.RestApiException;
 import com.google.gerrit.extensions.restapi.UnprocessableEntityException;
 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.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;
@@ -42,14 +42,14 @@
 
 public class SetAssigneeOp extends BatchUpdate.Op {
   public interface Factory {
-    SetAssigneeOp create(AssigneeInput input);
+    SetAssigneeOp create(String assignee);
   }
 
   private final AccountsCollection accounts;
   private final ChangeMessagesUtil cmUtil;
   private final AccountInfoCacheFactory.Factory accountInfosFactory;
   private final DynamicSet<AssigneeValidationListener> validationListeners;
-  private final AssigneeInput input;
+  private final String assignee;
   private final String anonymousCowardName;
   private final AssigneeChanged assigneeChanged;
 
@@ -64,37 +64,28 @@
       DynamicSet<AssigneeValidationListener> validationListeners,
       AssigneeChanged assigneeChanged,
       @AnonymousCowardName String anonymousCowardName,
-      @Assisted AssigneeInput input) {
+      @Assisted String assignee) {
     this.accounts = accounts;
     this.cmUtil = cmUtil;
     this.accountInfosFactory = accountInfosFactory;
     this.validationListeners = validationListeners;
     this.assigneeChanged = assigneeChanged;
     this.anonymousCowardName = anonymousCowardName;
-    this.input = input;
+    this.assignee = checkNotNull(assignee);
   }
 
   @Override
   public boolean updateChange(BatchUpdate.ChangeContext ctx)
       throws OrmException, RestApiException {
-    if (!ctx.getControl().canEditAssignee()) {
-      throw new AuthException("Changing Assignee not permitted");
-    }
     change = ctx.getChange();
     ChangeUpdate update = ctx.getUpdate(change.currentPatchSetId());
     Optional<Account.Id> oldAssigneeId =
         Optional.fromNullable(change.getAssignee());
-    if (input.assignee == null) {
-      if (oldAssigneeId.isPresent()) {
-        throw new BadRequestException("Cannot set Assignee to empty");
-      }
-      return false;
-    }
     oldAssignee = null;
     if (oldAssigneeId.isPresent()) {
       oldAssignee = accountInfosFactory.create().get(oldAssigneeId.get());
     }
-    IdentifiedUser newAssigneeUser = accounts.parse(input.assignee);
+    IdentifiedUser newAssigneeUser = accounts.parse(assignee);
     if (oldAssigneeId.isPresent() &&
         oldAssigneeId.get().equals(newAssigneeUser.getAccountId())) {
       newAssignee = oldAssignee;
@@ -102,20 +93,20 @@
     }
     if (!newAssigneeUser.getAccount().isActive()) {
       throw new UnprocessableEntityException(String.format(
-          "Account of %s is not active", input.assignee));
+          "Account of %s is not active", assignee));
     }
     if (!ctx.getControl().forUser(newAssigneeUser).isRefVisible()) {
       throw new AuthException(String.format(
           "Change %s is not visible to %s.",
           change.getChangeId(),
-          input.assignee));
+          assignee));
     }
     try {
       for (AssigneeValidationListener validator : validationListeners) {
         validator.validateAssignee(change, newAssigneeUser.getAccount());
       }
     } catch (ValidationException e) {
-      throw new BadRequestException(e.getMessage());
+      throw new ResourceConflictException(e.getMessage());
     }
     // notedb
     update.setAssignee(newAssigneeUser.getAccountId());
@@ -139,13 +130,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..cf6d307 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);
   }
 
@@ -166,7 +159,7 @@
   @Override
   public void postUpdate(Context ctx) throws OrmException {
     if (updated() && fireEvent) {
-      hashtagsEdited.fire(change, ctx.getAccountId(), updatedHashtags,
+      hashtagsEdited.fire(change, ctx.getAccount(), updatedHashtags,
           toAdd, toRemove, ctx.getWhen());
     }
   }
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/change/SuggestChangeReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
index a1d53e0..131513b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestChangeReviewers.java
@@ -54,7 +54,7 @@
   @Override
   public List<SuggestedReviewerInfo> apply(ChangeResource rsrc)
       throws BadRequestException, OrmException, IOException {
-    return reviewersUtil.suggestReviewers(this,
+    return reviewersUtil.suggestReviewers(rsrc.getNotes(), this,
         rsrc.getControl().getProjectControl(), getVisibility(rsrc), excludeGroups);
   }
 
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
index f159c69..2af1f6b 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestReviewers.java
@@ -33,7 +33,6 @@
   protected final ReviewersUtil reviewersUtil;
 
   private final boolean suggestAccounts;
-  private final int suggestFrom;
   private final int maxAllowed;
   private final int maxAllowedWithoutConfirmation;
   protected int limit;
@@ -62,10 +61,6 @@
     return suggestAccounts;
   }
 
-  public int getSuggestFrom() {
-    return suggestFrom;
-  }
-
   public int getLimit() {
     return limit;
   }
@@ -98,7 +93,6 @@
       this.suggestAccounts = (av != AccountVisibility.NONE);
     }
 
-    this.suggestFrom = cfg.getInt("suggest", null, "from", 0);
     this.maxAllowed = cfg.getInt("addreviewer", "maxAllowed",
         PostReviewers.DEFAULT_MAX_REVIEWERS);
     this.maxAllowedWithoutConfirmation = cfg.getInt(
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestedReviewer.java b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestedReviewer.java
new file mode 100644
index 0000000..353bf3b
--- /dev/null
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/change/SuggestedReviewer.java
@@ -0,0 +1,22 @@
+// Copyright (C) 2016 The Android Open Source Project
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+// http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+package com.google.gerrit.server.change;
+
+import com.google.gerrit.reviewdb.client.Account;
+
+public class SuggestedReviewer {
+
+  public Account.Id account;
+  public double score;
+}
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
index fecb156..51dbff2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java
@@ -100,6 +100,7 @@
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.change.ChangeKindCacheImpl;
 import com.google.gerrit.server.change.MergeabilityCacheImpl;
+import com.google.gerrit.server.change.ReviewerSuggestion;
 import com.google.gerrit.server.events.EventFactory;
 import com.google.gerrit.server.events.EventsMetrics;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -352,6 +353,7 @@
     DynamicMap.mapOf(binder(), DownloadScheme.class);
     DynamicMap.mapOf(binder(), DownloadCommand.class);
     DynamicMap.mapOf(binder(), CloneCommand.class);
+    DynamicMap.mapOf(binder(), ReviewerSuggestion.class);
     DynamicSet.setOf(binder(), ExternalIncludedIn.class);
     DynamicMap.mapOf(binder(), ProjectConfigEntry.class);
     DynamicSet.setOf(binder(), PatchSetWebLink.class);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java b/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
index 85098d4d..c867d26 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/events/StreamEventsApiListener.java
@@ -39,7 +39,6 @@
 import com.google.gerrit.extensions.events.TopicEditedListener;
 import com.google.gerrit.extensions.registration.DynamicItem;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.lifecycle.LifecycleModule;
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Branch;
 import com.google.gerrit.reviewdb.client.Change;
@@ -56,6 +55,7 @@
 import com.google.gerrit.server.project.NoSuchChangeException;
 import com.google.gerrit.server.project.ProjectCache;
 import com.google.gwtorm.server.OrmException;
+import com.google.inject.AbstractModule;
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 import com.google.inject.Singleton;
@@ -90,7 +90,7 @@
   private static final Logger log =
       LoggerFactory.getLogger(StreamEventsApiListener.class);
 
-  public static class Module extends LifecycleModule {
+  public static class Module extends AbstractModule {
     @Override
     protected void configure() {
       DynamicSet.bind(binder(), AssigneeChangedListener.class)
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
index 018d408..c910a7a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AgreementSignup.java
@@ -20,13 +20,7 @@
 import com.google.gerrit.reviewdb.client.Account;
 import com.google.inject.Inject;
 
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-
 public class AgreementSignup {
-  private static final Logger log =
-      LoggerFactory.getLogger(AgreementSignup.class);
-
   private final DynamicSet<AgreementSignupListener> listeners;
   private final EventUtil util;
 
@@ -46,7 +40,7 @@
       try {
         l.onAgreementSignup(event);
       } catch (Exception e) {
-        log.warn("Error in event listener", e);
+        util.logEventListenerError(this, l, e);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
index 2234556..53d837f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/AssigneeChanged.java
@@ -43,31 +43,24 @@
     this.util = util;
   }
 
-  public void fire(ChangeInfo change, AccountInfo editor, AccountInfo oldAssignee,
-      Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    Event event = new Event(change, editor, oldAssignee, when);
-    for (AssigneeChangedListener l : listeners) {
-      try {
-        l.onAssigneeChanged(event);
-      } catch (Exception e) {
-        log.warn("Error in event listener", e);
-      }
-    }
-  }
-
   public void fire(Change change, Account account, Account oldAssignee,
       Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      fire(util.changeInfo(change),
+      Event event = new Event(
+          util.changeInfo(change),
           util.accountInfo(account),
           util.accountInfo(oldAssignee),
           when);
+      for (AssigneeChangedListener l : listeners) {
+        try {
+          l.onAssigneeChanged(event);
+        } catch (Exception e) {
+          util.logEventListenerError(event, l, e);
+        }
+      }
     } catch (OrmException e) {
       log.error("Couldn't fire event", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
index c76e76b..5a7aec2 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeAbandoned.java
@@ -48,33 +48,24 @@
     this.util = util;
   }
 
-  public void fire(ChangeInfo change, RevisionInfo revision,
-      AccountInfo abandoner, String reason, Timestamp when,
-      NotifyHandling notifyHandling) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    Event event = new Event(change, revision, abandoner, reason, when,
-        notifyHandling);
-    for (ChangeAbandonedListener l : listeners) {
-      try {
-        l.onChangeAbandoned(event);
-      } catch (Exception e) {
-        log.warn("Error in event listener", e);
-      }
-    }
-  }
-
   public void fire(Change change, PatchSet ps, Account abandoner, String reason,
       Timestamp when, NotifyHandling notifyHandling) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      fire(util.changeInfo(change),
+      Event event = new Event(
+          util.changeInfo(change),
           util.revisionInfo(change.getProject(), ps),
           util.accountInfo(abandoner),
           reason, when, notifyHandling);
+      for (ChangeAbandonedListener l : listeners) {
+        try {
+          l.onChangeAbandoned(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
     } catch (PatchListNotAvailableException | GpgException | IOException
         | OrmException e) {
       log.error("Couldn't fire event", e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
index 378f2b7..8b4a6a0 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeMerged.java
@@ -48,31 +48,24 @@
     this.util = util;
   }
 
-  public void fire(ChangeInfo change, RevisionInfo revision,
-      AccountInfo merger, String newRevisionId, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    Event event = new Event(change, revision, merger, newRevisionId, when);
-    for (ChangeMergedListener l : listeners) {
-      try {
-        l.onChangeMerged(event);
-      } catch (Exception e) {
-        log.warn("Error in event listener", e);
-      }
-    }
-  }
-
   public void fire(Change change, PatchSet ps, Account merger,
       String newRevisionId, Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      fire(util.changeInfo(change),
+      Event event = new Event(
+          util.changeInfo(change),
           util.revisionInfo(change.getProject(), ps),
           util.accountInfo(merger),
           newRevisionId, when);
+      for (ChangeMergedListener l : listeners) {
+        try {
+          l.onChangeMerged(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
     } catch (PatchListNotAvailableException | GpgException | IOException
         | OrmException e) {
       log.error("Couldn't fire event", e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
index 05e0d21..1d2682a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeRestored.java
@@ -48,31 +48,24 @@
     this.util = util;
   }
 
-  public void fire(ChangeInfo change, RevisionInfo revision,
-      AccountInfo restorer, String reason, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    Event event = new Event(change, revision, restorer, reason, when);
-    for (ChangeRestoredListener l : listeners) {
-      try {
-        l.onChangeRestored(event);
-      } catch (Exception e) {
-        log.warn("Error in event listener", e);
-      }
-    }
-  }
-
   public void fire(Change change, PatchSet ps, Account restorer, String reason,
       Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      fire(util.changeInfo(change),
+      Event event = new Event(
+          util.changeInfo(change),
           util.revisionInfo(change.getProject(), ps),
           util.accountInfo(restorer),
           reason, when);
+      for (ChangeRestoredListener l : listeners) {
+        try {
+          l.onChangeRestored(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
     } catch (PatchListNotAvailableException | GpgException | IOException
         | OrmException e) {
       log.error("Couldn't fire event", e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeReverted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
index f95236d..d963a47 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ChangeReverted.java
@@ -46,27 +46,20 @@
       return;
     }
     try {
-      fire(util.changeInfo(change), util.changeInfo(revertChange), when);
+      Event event = new Event(
+          util.changeInfo(change), util.changeInfo(revertChange), when);
+      for (ChangeRevertedListener l : listeners) {
+        try {
+          l.onChangeReverted(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
     } catch (OrmException e) {
       log.error("Couldn't fire event", e);
     }
   }
 
-  public void fire (ChangeInfo change, ChangeInfo revertChange,
-      Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    Event event = new Event(change, revertChange, when);
-    for (ChangeRevertedListener l : listeners) {
-      try {
-        l.onChangeReverted(event);
-      } catch (Exception e) {
-        log.warn("Error in event listener", e);
-      }
-    }
-  }
-
   private static class Event extends AbstractChangeEvent
       implements ChangeRevertedListener.Event {
     private final ChangeInfo revertChange;
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
index 8e27ce9..f1bb50a 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/CommentAdded.java
@@ -50,23 +50,6 @@
     this.util = util;
   }
 
-  public void fire(ChangeInfo change, RevisionInfo revision, AccountInfo author,
-      String comment, Map<String, ApprovalInfo> approvals,
-      Map<String, ApprovalInfo> oldApprovals, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    Event event = new Event(
-        change, revision, author, comment, approvals, oldApprovals, when);
-    for (CommentAddedListener l : listeners) {
-      try {
-        l.onCommentAdded(event);
-      } catch (Exception e) {
-        log.warn("Error in event listener", e);
-      }
-    }
-  }
-
   public void fire(Change change, PatchSet ps, Account author,
       String comment, Map<String, Short> approvals,
       Map<String, Short> oldApprovals, Timestamp when) {
@@ -74,13 +57,20 @@
       return;
     }
     try {
-      fire(util.changeInfo(change),
+      Event event = new Event(util.changeInfo(change),
           util.revisionInfo(change.getProject(), ps),
           util.accountInfo(author),
           comment,
           util.approvals(author, approvals, when),
           util.approvals(author, oldApprovals, when),
           when);
+      for (CommentAddedListener l : listeners) {
+        try {
+          l.onCommentAdded(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
     } catch (PatchListNotAvailableException | GpgException | IOException
         | OrmException e) {
       log.error("Couldn't fire event", e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
index 6b8ce3d..4f6d298 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/DraftPublished.java
@@ -48,28 +48,21 @@
     this.util = util;
   }
 
-  public void fire(ChangeInfo change, RevisionInfo revision,
-      AccountInfo publisher, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    Event event = new Event(change, revision, publisher, when);
-    for (DraftPublishedListener l : listeners) {
-      try {
-        l.onDraftPublished(event);
-      } catch (Exception e) {
-        log.warn("Error in event listener", e);
-      }
-    }
-  }
-
-  public void fire(Change change, PatchSet patchSet, Account.Id accountId,
+  public void fire(Change change, PatchSet patchSet, Account accountId,
       Timestamp when) {
     try {
-      fire(util.changeInfo(change),
+      Event event = new Event(
+          util.changeInfo(change),
           util.revisionInfo(change.getProject(), patchSet),
           util.accountInfo(accountId),
           when);
+      for (DraftPublishedListener l : listeners) {
+        try {
+          l.onDraftPublished(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
     } catch (PatchListNotAvailableException | GpgException | IOException
         | OrmException e) {
       log.error("Couldn't fire event", e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
index 1c8782b..682d9ec 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/EventUtil.java
@@ -25,7 +25,6 @@
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.server.ReviewDb;
 import com.google.gerrit.server.GpgException;
-import com.google.gerrit.server.account.AccountCache;
 import com.google.gerrit.server.account.AccountJson;
 import com.google.gerrit.server.change.ChangeJson;
 import com.google.gerrit.server.patch.PatchListNotAvailableException;
@@ -35,6 +34,9 @@
 import com.google.inject.Inject;
 import com.google.inject.Provider;
 
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
 import java.io.IOException;
 import java.sql.Timestamp;
 import java.util.EnumSet;
@@ -42,22 +44,21 @@
 import java.util.Map;
 
 public class EventUtil {
+  private static final Logger log = LoggerFactory.getLogger(EventUtil.class);
 
   private final ChangeData.Factory changeDataFactory;
   private final Provider<ReviewDb> db;
   private final ChangeJson changeJson;
-  private final AccountCache accountCache;
 
   @Inject
   EventUtil(ChangeJson.Factory changeJsonFactory,
       ChangeData.Factory changeDataFactory,
-      Provider<ReviewDb> db,
-      AccountCache accountCache) {
+      Provider<ReviewDb> db) {
     this.changeDataFactory = changeDataFactory;
     this.db = db;
-    this.changeJson = changeJsonFactory.create(
-        EnumSet.allOf(ListChangesOption.class));
-    this.accountCache = accountCache;
+    EnumSet<ListChangesOption> opts = EnumSet.allOf(ListChangesOption.class);
+    opts.remove(ListChangesOption.CHECK);
+    this.changeJson = changeJsonFactory.create(opts);
   }
 
   public ChangeInfo changeInfo(Change change) throws OrmException {
@@ -86,10 +87,6 @@
     return AccountJson.toAccountInfo(a);
   }
 
-  public AccountInfo accountInfo(Account.Id accountId) {
-    return accountInfo(accountCache.get(accountId).getAccount());
-  }
-
   public Map<String, ApprovalInfo> approvals(Account a,
       Map<String, Short> approvals, Timestamp ts) {
     Map<String, ApprovalInfo> result = new HashMap<>();
@@ -100,4 +97,17 @@
     }
     return result;
   }
+
+  public void logEventListenerError(Object event, Object listener,
+      Exception error) {
+    if (log.isDebugEnabled()) {
+      log.debug(String.format(
+          "Error in event listener %s for event %s",
+          listener.getClass().getName(), event.getClass().getName()), error);
+    } else {
+      log.warn("Error in listener {} for event {}: {}",
+          listener.getClass().getName(), event.getClass().getName(),
+          error.getMessage());
+    }
+  }
 }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
index 386bcea..381dced 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/GitReferenceUpdated.java
@@ -26,13 +26,8 @@
 import org.eclipse.jgit.lib.ObjectId;
 import org.eclipse.jgit.lib.RefUpdate;
 import org.eclipse.jgit.transport.ReceiveCommand;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
 
 public class GitReferenceUpdated {
-  private static final Logger log = LoggerFactory
-      .getLogger(GitReferenceUpdated.class);
-
   public static final GitReferenceUpdated DISABLED = new GitReferenceUpdated() {
     @Override
     public void fire(Project.NameKey project, RefUpdate refUpdate,
@@ -40,17 +35,9 @@
 
     @Override
     public void fire(Project.NameKey project, RefUpdate refUpdate,
-        ReceiveCommand.Type type, Account.Id updater) {}
-
-    @Override
-    public void fire(Project.NameKey project, RefUpdate refUpdate,
         Account updater) {}
 
     @Override
-    public void fire(Project.NameKey project, RefUpdate refUpdate,
-        AccountInfo updater) {}
-
-    @Override
     public void fire(Project.NameKey project, String ref, ObjectId oldObjectId,
         ObjectId newObjectId, Account updater) {}
 
@@ -60,10 +47,10 @@
 
     @Override
     public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate,
-        Account.Id updater) {}
+        Account updater) {}
   };
 
-  private final Iterable<GitReferenceUpdatedListener> listeners;
+  private final DynamicSet<GitReferenceUpdatedListener> listeners;
   private final EventUtil util;
 
   @Inject
@@ -85,24 +72,12 @@
   }
 
   public void fire(Project.NameKey project, RefUpdate refUpdate,
-      ReceiveCommand.Type type, Account.Id updater) {
-    fire(project, refUpdate.getName(), refUpdate.getOldObjectId(),
-        refUpdate.getNewObjectId(), type, util.accountInfo(updater));
-  }
-
-  public void fire(Project.NameKey project, RefUpdate refUpdate,
       Account updater) {
     fire(project, refUpdate.getName(), refUpdate.getOldObjectId(),
         refUpdate.getNewObjectId(), ReceiveCommand.Type.UPDATE,
         util.accountInfo(updater));
   }
 
-  public void fire(Project.NameKey project, RefUpdate refUpdate,
-      AccountInfo updater) {
-    fire(project, refUpdate.getName(), refUpdate.getOldObjectId(),
-        refUpdate.getNewObjectId(), ReceiveCommand.Type.UPDATE, updater);
-  }
-
   public void fire(Project.NameKey project, String ref, ObjectId oldObjectId,
       ObjectId newObjectId, Account updater) {
     fire(project, ref, oldObjectId, newObjectId, ReceiveCommand.Type.UPDATE,
@@ -115,23 +90,22 @@
   }
 
   public void fire(Project.NameKey project, BatchRefUpdate batchRefUpdate,
-      Account.Id updater) {
+      Account updater) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     for (ReceiveCommand cmd : batchRefUpdate.getCommands()) {
       if (cmd.getResult() == ReceiveCommand.Result.OK) {
-        fire(project, cmd, util.accountInfo(updater));
+        fire(project,
+            cmd.getRefName(),
+            cmd.getOldId(),
+            cmd.getNewId(),
+            cmd.getType(),
+            util.accountInfo(updater));
       }
     }
   }
 
-  private void fire(Project.NameKey project, ReceiveCommand cmd,
-      AccountInfo updater) {
-    fire(project, cmd.getRefName(), cmd.getOldId(), cmd.getNewId(), cmd.getType(),
-        updater);
-  }
-
   private void fire(Project.NameKey project, String ref, ObjectId oldObjectId,
       ObjectId newObjectId, ReceiveCommand.Type type, AccountInfo updater) {
     if (!listeners.iterator().hasNext()) {
@@ -144,7 +118,7 @@
       try {
         l.onGitReferenceUpdated(event);
       } catch (Exception e) {
-        log.warn("Error in event listener", e);
+        util.logEventListenerError(this, l, e);
       }
     }
   }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
index f18b963..233a89e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/HashtagsEdited.java
@@ -20,7 +20,7 @@
 import com.google.gerrit.extensions.common.ChangeInfo;
 import com.google.gerrit.extensions.events.HashtagsEditedListener;
 import com.google.gerrit.extensions.registration.DynamicSet;
-import com.google.gerrit.reviewdb.client.Account.Id;
+import com.google.gerrit.reviewdb.client.Account;
 import com.google.gerrit.reviewdb.client.Change;
 import com.google.gwtorm.server.OrmException;
 import com.google.inject.Inject;
@@ -46,33 +46,25 @@
     this.util = util;
   }
 
-  public void fire(ChangeInfo change, AccountInfo editor,
-      Collection<String> hashtags, Collection<String> added,
-      Collection<String> removed, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    Event event = new Event(change, editor, hashtags, added, removed, when);
-    for (HashtagsEditedListener l : listeners) {
-      try {
-        l.onHashtagsEdited(event);
-      } catch (Exception e) {
-        log.warn("Error in event listener", e);
-      }
-    }
-  }
-
-  public void fire(Change change, Id accountId,
+  public void fire(Change change, Account editor,
       ImmutableSortedSet<String> hashtags, Set<String> added,
       Set<String> removed, Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      fire(util.changeInfo(change),
-          util.accountInfo(accountId),
+      Event event = new Event(
+          util.changeInfo(change),
+          util.accountInfo(editor),
           hashtags, added, removed,
           when);
+      for (HashtagsEditedListener l : listeners) {
+        try {
+          l.onHashtagsEdited(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
     } catch (OrmException e) {
       log.error("Couldn't fire event", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
index 9dbbeef..8860a42 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerAdded.java
@@ -50,33 +50,26 @@
     this.util = util;
   }
 
-  public void fire(ChangeInfo change, RevisionInfo revision,
-      List<AccountInfo> reviewers, AccountInfo adder, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    Event event = new Event(change, revision, reviewers, adder, when);
-    for (ReviewerAddedListener l : listeners) {
-      try {
-        l.onReviewersAdded(event);
-      } catch (Exception e) {
-        log.warn("Error in event listener, e");
-      }
-    }
-  }
-
-  public void fire(Change change, PatchSet patchSet, List<Account.Id> reviewers,
+  public void fire(Change change, PatchSet patchSet, List<Account> reviewers,
       Account adder, Timestamp when) {
     if (!listeners.iterator().hasNext() || reviewers.isEmpty()) {
       return;
     }
 
     try {
-      fire(util.changeInfo(change),
+      Event event = new Event(
+          util.changeInfo(change),
           util.revisionInfo(change.getProject(), patchSet),
           Lists.transform(reviewers, util::accountInfo),
           util.accountInfo(adder),
           when);
+      for (ReviewerAddedListener l : listeners) {
+        try {
+          l.onReviewersAdded(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
     } catch (PatchListNotAvailableException | GpgException | IOException
         | OrmException e) {
       log.error("Couldn't fire event", e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
index b519c46..4bc4764 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/ReviewerDeleted.java
@@ -50,25 +50,6 @@
     this.util = util;
   }
 
-  public void fire(ChangeInfo change, RevisionInfo revision,
-      AccountInfo reviewer, AccountInfo remover, String message,
-      Map<String, ApprovalInfo> newApprovals,
-      Map<String, ApprovalInfo> oldApprovals, NotifyHandling notify,
-      Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    Event event = new Event(change, revision, reviewer, remover, message,
-        newApprovals, oldApprovals, notify, when);
-    for (ReviewerDeletedListener listener : listeners) {
-      try {
-        listener.onReviewerDeleted(event);
-      } catch (Exception e) {
-        log.warn("Error in event listener", e);
-      }
-    }
-  }
-
   public void fire(Change change, PatchSet patchSet, Account reviewer,
       Account remover, String message, Map<String, Short> newApprovals,
       Map<String, Short> oldApprovals, NotifyHandling notify, Timestamp when) {
@@ -76,7 +57,8 @@
       return;
     }
     try {
-      fire(util.changeInfo(change),
+      Event event = new Event(
+          util.changeInfo(change),
           util.revisionInfo(change.getProject(), patchSet),
           util.accountInfo(reviewer),
           util.accountInfo(remover),
@@ -85,6 +67,13 @@
           util.approvals(reviewer, oldApprovals, when),
           notify,
           when);
+      for (ReviewerDeletedListener listener : listeners) {
+        try {
+          listener.onReviewerDeleted(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, listener, e);
+        }
+      }
     } catch (PatchListNotAvailableException | GpgException | IOException
         | OrmException e) {
       log.error("Couldn't fire event", e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
index 71bc9ec..7f03c63 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/RevisionCreated.java
@@ -48,31 +48,24 @@
     this.util = util;
   }
 
-  public void fire(ChangeInfo change, RevisionInfo revision,
-      AccountInfo uploader, Timestamp when, NotifyHandling notify) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    Event event = new Event(change, revision, uploader, when, notify);
-    for (RevisionCreatedListener l : listeners) {
-      try {
-        l.onRevisionCreated(event);
-      } catch (Exception e) {
-        log.warn("Error in event listener", e);
-      }
-    }
-  }
-
-  public void fire(Change change, PatchSet patchSet, Account.Id uploader,
+  public void fire(Change change, PatchSet patchSet, Account uploader,
       Timestamp when, NotifyHandling notify) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      fire(util.changeInfo(change),
+      Event event = new Event(
+          util.changeInfo(change),
           util.revisionInfo(change.getProject(), patchSet),
           util.accountInfo(uploader),
           when, notify);
+      for (RevisionCreatedListener l : listeners) {
+        try {
+          l.onRevisionCreated(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
     } catch ( PatchListNotAvailableException | GpgException | IOException
         | OrmException e) {
       log.error("Couldn't fire event", e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
index 77c1647..2e583a8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/TopicEdited.java
@@ -43,31 +43,24 @@
     this.util = util;
   }
 
-  public void fire(ChangeInfo change, AccountInfo editor, String oldTopic,
-      Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    Event event = new Event(change, editor, oldTopic, when);
-    for (TopicEditedListener l : listeners) {
-      try {
-        l.onTopicEdited(event);
-      } catch (Exception e) {
-        log.warn("Error in event listener", e);
-      }
-    }
-  }
-
   public void fire(Change change, Account account, String oldTopicName,
       Timestamp when) {
     if (!listeners.iterator().hasNext()) {
       return;
     }
     try {
-      fire(util.changeInfo(change),
+      Event event = new Event(
+          util.changeInfo(change),
           util.accountInfo(account),
           oldTopicName,
           when);
+      for (TopicEditedListener l : listeners) {
+        try {
+          l.onTopicEdited(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
     } catch (OrmException e) {
       log.error("Couldn't fire event", e);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
index 7772d9c..e421ea6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/extensions/events/VoteDeleted.java
@@ -51,26 +51,6 @@
     this.util = util;
   }
 
-  public void fire(ChangeInfo change, RevisionInfo revision,
-      Map<String, ApprovalInfo> approvals,
-      Map<String, ApprovalInfo> oldApprovals,
-      NotifyHandling notify, String message,
-      AccountInfo remover, Timestamp when) {
-    if (!listeners.iterator().hasNext()) {
-      return;
-    }
-    Event event = new Event(
-        change, revision, approvals, oldApprovals, notify, message,
-        remover, when);
-    for (VoteDeletedListener l : listeners) {
-      try {
-        l.onVoteDeleted(event);
-      } catch (Exception e) {
-        log.warn("Error in event listener", e);
-      }
-    }
-  }
-
   public void fire(Change change, PatchSet ps,
       Map<String, Short> approvals,
       Map<String, Short> oldApprovals,
@@ -80,12 +60,20 @@
       return;
     }
     try {
-      fire(util.changeInfo(change),
+      Event event = new Event(
+          util.changeInfo(change),
           util.revisionInfo(change.getProject(), ps),
           util.approvals(remover, approvals, when),
           util.approvals(remover, oldApprovals, when),
           notify, message,
           util.accountInfo(remover), when);
+      for (VoteDeletedListener l : listeners) {
+        try {
+          l.onVoteDeleted(event);
+        } catch (Exception e) {
+          util.logEventListenerError(this, l, e);
+        }
+      }
     } catch (PatchListNotAvailableException | GpgException | IOException
         | OrmException e) {
       log.error("Couldn't fire event", e);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
index dc1798a..49221c9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/BatchUpdate.java
@@ -444,7 +444,9 @@
           u.gitRefUpdated.fire(
               u.project,
               u.batchRefUpdate,
-              u.getUser().isIdentifiedUser() ? u.getUser().getAccountId() : null);
+              u.getUser().isIdentifiedUser()
+                  ? u.getUser().asIdentifiedUser().getAccount()
+                  : null);
         }
       }
       if (!dryrun) {
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..0be2a38 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;
@@ -480,7 +479,9 @@
       BatchUpdate.execute(orm.batchUpdates(allProjects),
           new SubmitStrategyListener(submitInput, strategies, commits),
           submissionId, dryrun);
-    } catch (UpdateException | SubmoduleException e) {
+    } catch (SubmoduleException e) {
+      throw new IntegrationException(e);
+    } catch (UpdateException e) {
       // BatchUpdate may have inadvertently wrapped an IntegrationException
       // thrown by some legacy SubmitStrategyOp code that intended the error
       // message to be user-visible. Copy the message from the wrapped
@@ -492,8 +493,7 @@
       if (e.getCause() instanceof IntegrationException) {
         msg = e.getCause().getMessage();
       } else {
-        msg = "Error submitting change" + (cs.size() != 1 ? "s" : "") + ": \n"
-            + e.getMessage();
+        msg = "Error submitting change" + (cs.size() != 1 ? "s" : "");
       }
       throw new IntegrationException(msg, e);
     }
@@ -764,11 +764,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/MergeUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
index 0667e14..90edfb1 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MergeUtil.java
@@ -184,13 +184,13 @@
   public CodeReviewCommit createCherryPickFromCommit(Repository repo,
       ObjectInserter inserter, RevCommit mergeTip, RevCommit originalCommit,
       PersonIdent cherryPickCommitterIdent, String commitMsg,
-      CodeReviewRevWalk rw)
+      CodeReviewRevWalk rw, int parentIndex)
       throws MissingObjectException, IncorrectObjectTypeException, IOException,
       MergeIdenticalTreeException, MergeConflictException {
 
     final ThreeWayMerger m = newThreeWayMerger(repo, inserter);
 
-    m.setBase(originalCommit.getParent(0));
+    m.setBase(originalCommit.getParent(parentIndex));
     if (m.merge(mergeTip, originalCommit)) {
       ObjectId tree = m.getResultTreeId();
       if (tree.equals(mergeTip.getTree())) {
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/MultiProgressMonitor.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
index 414ba5f..d081fe6 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/MultiProgressMonitor.java
@@ -96,29 +96,6 @@
       }
     }
 
-    synchronized void format(StringBuilder s, boolean first) {
-      if (count == 0) {
-        return;
-      }
-
-      if (!first) {
-        s.append(',');
-      }
-      s.append(' ');
-
-      if (!Strings.isNullOrEmpty(name)) {
-        s.append(name).append(": ");
-      }
-
-      if (total == UNKNOWN) {
-        s.append(count);
-      } else {
-        s.append(String.format("%d%% (%d/%d)",
-            count * 100 / total,
-            count, total));
-      }
-    }
-
     /**
      * Indicate that this sub-task is finished.
      * <p>
@@ -339,9 +316,32 @@
     StringBuilder s = new StringBuilder().append("\r").append(taskName)
         .append(':');
 
-    int firstLength = s.length();
-    for (Task t : tasks) {
-      t.format(s, s.length() == firstLength);
+    if (!tasks.isEmpty()) {
+      boolean first = true;
+      for (Task t : tasks) {
+        int count = t.count;
+        if (count == 0) {
+          continue;
+        }
+
+        if (!first) {
+          s.append(',');
+        } else {
+          first = false;
+        }
+
+        s.append(' ');
+        if (!Strings.isNullOrEmpty(t.name)) {
+          s.append(t.name).append(": ");
+        }
+        if (t.total == UNKNOWN) {
+          s.append(count);
+        } else {
+          s.append(String.format("%d%% (%d/%d)",
+              count * 100 / t.total,
+              count, t.total));
+        }
+      }
     }
 
     if (spinnerState != NO_SPINNER) {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java
index d7424c6..99abbc8 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/NotesBranchUtil.java
@@ -14,7 +14,6 @@
 
 package com.google.gerrit.server.git;
 
-import com.google.gerrit.extensions.common.AccountInfo;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.server.GerritPersonIdent;
 import com.google.gerrit.server.extensions.events.GitReferenceUpdated;
@@ -262,7 +261,7 @@
         throw new IOException("Couldn't update " + notesBranch + ". "
             + result.name());
       } else {
-        gitRefUpdated.fire(project, refUpdate, (AccountInfo) null);
+        gitRefUpdated.fire(project, refUpdate, null);
         break;
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
index 1429079..801f259 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/ProjectConfig.java
@@ -713,7 +713,7 @@
         // no valid UUID for it. Pool the reference anyway so at least
         // all rules in the same file share the same GroupReference.
         //
-        ref = rule.getGroup();
+        ref = resolve(rule.getGroup());
         groupsByName.put(ref.getName(), ref);
         error(new ValidationError(PROJECT_CONFIG,
             "group \"" + ref.getName() + "\" not in " + GroupList.FILE_NAME));
@@ -1098,7 +1098,7 @@
         boolean needRange = GlobalCapability.hasRange(permission.getName());
         List<String> rules = new ArrayList<>();
         for (PermissionRule rule : sort(permission.getRules())) {
-          GroupReference group = rule.getGroup();
+          GroupReference group = resolve(rule.getGroup());
           if (group.getUUID() != null) {
             keepGroups.add(group.getUUID());
           }
@@ -1143,7 +1143,7 @@
         boolean needRange = Permission.hasRange(permission.getName());
         List<String> rules = new ArrayList<>();
         for (PermissionRule rule : sort(permission.getRules())) {
-          GroupReference group = rule.getGroup();
+          GroupReference group = resolve(rule.getGroup());
           if (group.getUUID() != null) {
             keepGroups.add(group.getUUID());
           }
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 baab110..a9dcd4b 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;
@@ -257,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,
@@ -279,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) {
@@ -410,7 +406,7 @@
     NotifyHandling notify = magicBranch != null && magicBranch.notify != null
         ? magicBranch.notify
         : NotifyHandling.ALL;
-    revisionCreated.fire(change, newPatchSet, ctx.getAccountId(),
+    revisionCreated.fire(change, newPatchSet, ctx.getAccount(),
         ctx.getWhen(), notify);
     try {
       fireCommentAddedEvent(ctx);
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
index 31da05c..c0d96c9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/git/strategy/CherryPick.java
@@ -107,7 +107,7 @@
       try {
         newCommit = args.mergeUtil.createCherryPickFromCommit(
             args.repo, args.inserter, args.mergeTip.getCurrentTip(), toMerge,
-            committer, cherryPickCmtMsg, args.rw);
+            committer, cherryPickCmtMsg, args.rw, 0);
       } catch (MergeConflictException mce) {
         // Keep going in the case of a single merge failure; the goal is to
         // cherry-pick as many commits as possible.
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/CommentSender.java b/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java
index bce114f..dd8bbaa 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/mail/CommentSender.java
@@ -18,6 +18,7 @@
 import com.google.common.base.Strings;
 import com.google.common.collect.Ordering;
 import com.google.gerrit.common.errors.EmailException;
+import com.google.gerrit.common.errors.NoSuchEntityException;
 import com.google.gerrit.extensions.api.changes.NotifyHandling;
 import com.google.gerrit.reviewdb.client.AccountProjectWatch.NotifyType;
 import com.google.gerrit.reviewdb.client.Change;
@@ -44,7 +45,8 @@
 import java.util.List;
 import java.util.Set;
 
-/** Send comments, after the author of them hit used Publish Comments in the UI. */
+/** Send comments, after the author of them hit used Publish Comments in the UI.
+ */
 public class CommentSender extends ReplyToChangeSender {
   private static final Logger log = LoggerFactory
       .getLogger(CommentSender.class);
@@ -183,32 +185,36 @@
         out.append(n == range.startLine
             ? prefix
             : Strings.padStart(": ", prefix.length(), ' '));
-        try {
-          String s = currentFileData.getLine(side, n);
-          if (n == range.startLine && n == range.endLine) {
-            s = s.substring(
-                Math.min(range.startChar, s.length()),
-                Math.min(range.endChar, s.length()));
-          } else if (n == range.startLine) {
-            s = s.substring(Math.min(range.startChar, s.length()));
-          } else if (n == range.endLine) {
-            s = s.substring(0, Math.min(range.endChar, s.length()));
-          }
-          out.append(s);
-        } catch (Throwable e) {
-          // Don't quote the line if we can't safely convert it.
+        String s = getLine(currentFileData, side, n);
+        if (n == range.startLine && n == range.endLine) {
+          s = s.substring(
+              Math.min(range.startChar, s.length()),
+              Math.min(range.endChar, s.length()));
+        } else if (n == range.startLine) {
+          s = s.substring(Math.min(range.startChar, s.length()));
+        } else if (n == range.endLine) {
+          s = s.substring(0, Math.min(range.endChar, s.length()));
         }
-        out.append('\n');
+        out.append(s).append('\n');
       }
       appendQuotedParent(out, comment);
       out.append(comment.message.trim()).append('\n');
     } else {
       int lineNbr = comment.lineNbr;
-      int maxLines;
+
+      // Initialize maxLines to the known line number.
+      int maxLines = lineNbr;
+
       try {
         maxLines = currentFileData.getLineCount(side);
-      } catch (Throwable e) {
-        maxLines = lineNbr;
+      } catch (IOException err) {
+        // The file could not be read, leave the max as is.
+        log.warn(String.format("Failed to read file %s on side %d",
+            comment.key.filename, side), err);
+      } catch (NoSuchEntityException err) {
+        // The file could not be read, leave the max as is.
+        log.warn(String.format("Side %d of file %s didn't exist",
+             side, comment.key.filename), err);
       }
 
       final int startLine = Math.max(1, lineNbr - contextLines + 1);
@@ -226,16 +232,14 @@
     }
   }
 
-  private void appendFileLine(StringBuilder cmts, PatchFile fileData, short side, int line) {
-    cmts.append("Line " + line);
-    try {
-      final String lineStr = fileData.getLine(side, line);
-      cmts.append(": ");
-      cmts.append(lineStr);
-    } catch (Throwable e) {
-      // Don't quote the line if we can't safely convert it.
-    }
-    cmts.append("\n");
+  private void appendFileLine(StringBuilder cmts, PatchFile fileData,
+      short side, int line) {
+    String lineStr = getLine(fileData, side, line);
+    cmts.append("Line ")
+        .append(line)
+        .append(": ")
+        .append(lineStr)
+        .append("\n");
   }
 
   private void appendQuotedParent(StringBuilder out, Comment child) {
@@ -293,4 +297,24 @@
     soyContextEmailData.put("inlineComments", getInlineComments());
     soyContextEmailData.put("hasInlineComments", hasInlineComments());
   }
+
+  private String getLine(PatchFile fileInfo, short side, int lineNbr) {
+    try {
+      return fileInfo.getLine(side, lineNbr);
+    } catch (IOException err) {
+      // Default to the empty string if the file cannot be safely read.
+      log.warn(String.format("Failed to read file on side %d", side), err);
+      return "";
+    } catch (IndexOutOfBoundsException err) {
+      // Default to the empty string if the given line number does not appear
+      // in the file.
+      log.warn(String.format("Failed to get line number of file on side %d",
+          side), err);
+      return "";
+    } catch (NoSuchEntityException err) {
+      // Default to the empty string if the side cannot be found.
+      log.warn(String.format("Side %d of file didn't exist", side), err);
+      return "";
+    }
+  }
 }
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/ChangeNotes.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
index 360785f..edb5b4e 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotes.java
@@ -30,6 +30,7 @@
 import com.google.common.collect.ImmutableSortedSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.ListMultimap;
+import com.google.common.collect.Maps;
 import com.google.common.collect.Multimap;
 import com.google.common.collect.Multimaps;
 import com.google.common.collect.Ordering;
@@ -346,6 +347,11 @@
   private DraftCommentNotes draftCommentNotes;
   private RobotCommentNotes robotCommentNotes;
 
+  // Lazy defensive copies of mutable ReviewDb types, to avoid polluting the
+  // ChangeNotesCache from handlers.
+  private ImmutableMap<PatchSet.Id, PatchSet> patchSets;
+  private ImmutableListMultimap<PatchSet.Id, PatchSetApproval> approvals;
+
   @VisibleForTesting
   public ChangeNotes(Args args, Change change) {
     this(args, change, true, null);
@@ -363,11 +369,19 @@
   }
 
   public ImmutableMap<PatchSet.Id, PatchSet> getPatchSets() {
-    return state.patchSets();
+    if (patchSets == null) {
+      patchSets = ImmutableMap.copyOf(
+          Maps.transformValues(state.patchSets(), PatchSet::new));
+    }
+    return patchSets;
   }
 
   public ImmutableListMultimap<PatchSet.Id, PatchSetApproval> getApprovals() {
-    return state.approvals();
+    if (approvals == null) {
+      approvals = ImmutableListMultimap.copyOf(
+          Multimaps.transformValues(state.approvals(), PatchSetApproval::new));
+    }
+    return approvals;
   }
 
   public ReviewerSet getReviewers() {
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
index 85df4b7..198eb2f 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/notedb/ChangeNotesCache.java
@@ -33,6 +33,9 @@
 import org.eclipse.jgit.lib.ObjectId;
 
 import java.io.IOException;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
 import java.util.concurrent.Callable;
 import java.util.concurrent.ExecutionException;
 
@@ -49,7 +52,8 @@
         cache(CACHE_NAME,
             Key.class,
             ChangeNotesState.class)
-          .maximumWeight(1000);
+          .weigher(Weigher.class)
+          .maximumWeight(10 << 20);
       }
     };
   }
@@ -61,6 +65,144 @@
     abstract ObjectId id();
   }
 
+  public static class Weigher
+      implements com.google.common.cache.Weigher<Key, ChangeNotesState> {
+    // Single object overhead.
+    private static final int O = 16;
+
+    // Single pointer overhead.
+    private static final int P = 8;
+
+    // Single IntKey overhead.
+    private static final int K = O + 4;
+
+    // Single Timestamp overhead.
+    private static final int T = O + 8;
+
+    @Override
+    public int weigh(Key key, ChangeNotesState state) {
+      // Take all columns and all collection sizes into account, but use
+      // estimated average element sizes rather than iterating over collections.
+      // Numbers are largely hand-wavy based on
+      // http://stackoverflow.com/questions/258120/what-is-the-memory-consumption-of-an-object-in-java
+      return
+          K // changeId
+          + str(40) // changeKey
+          + T // createdOn
+          + T // lastUpdatedOn
+          + P + K // owner
+          + P + str(state.columns().branch())
+          + P + patchSetId() // currentPatchSetId
+          + P + str(state.columns().subject())
+          + P + str(state.columns().topic())
+          + P + str(state.columns().originalSubject())
+          + P + str(state.columns().submissionId())
+          + ptr(state.columns().assignee(), K) // assignee
+          + P // status
+          + P + set(state.pastAssignees(), K)
+          + P + set(state.hashtags(), str(10))
+          + P + map(state.patchSets(), patchSet())
+          + P + list(state.reviewerUpdates(), 4 * O + K + K + P)
+          + P + list(state.submitRecords(), P + list(2, str(4) + P + K) + P)
+          + P + list(state.allChangeMessages(), changeMessage())
+          // Just key overhead for map, already counted messages in previous.
+          + P + map(state.changeMessagesByPatchSet().asMap(), patchSetId())
+          + P + map(state.publishedComments().asMap(), comment());
+    }
+
+    private static int ptr(Object o, int size) {
+      return o != null ? P + size : P;
+    }
+
+    private static int str(String s) {
+      if (s == null) {
+        return P;
+      }
+      return str(s.length());
+    }
+
+    private static int str(int n) {
+      return 8 + 24 + 2 * n;
+    }
+
+    private static int patchSetId() {
+      return O + 4 + O + 4;
+    }
+
+    private static int set(Set<?> set, int elemSize) {
+      if (set == null) {
+        return P;
+      }
+      return hashtable(set.size(), elemSize);
+    }
+
+    private static int map(Map<?, ?> map, int elemSize) {
+      if (map == null) {
+        return P;
+      }
+      return hashtable(map.size(), elemSize);
+    }
+
+    private static int hashtable(int n, int elemSize) {
+      // Made up numbers.
+      int overhead = 32;
+      int elemOverhead = O + 32;
+      return overhead + elemOverhead * n * elemSize;
+    }
+
+    private static int list(List<?> list, int elemSize) {
+      if (list == null) {
+        return P;
+      }
+      return list(list.size(), elemSize);
+    }
+
+    private static int list(int n, int elemSize) {
+      return O + O + n * (P + elemSize);
+    }
+
+    private static int patchSet() {
+      return O
+          + P + patchSetId()
+          + str(40) // revision
+          + P + K // uploader
+          + P + T // createdOn
+          + 1 // draft
+          + str(40) // groups
+          + P; // pushCertificate
+    }
+
+    private static int changeMessage() {
+      int key = K + str(20);
+      return O
+          + P + key
+          + P + K // author
+          + P + T // writtenON
+          + str(64) // message
+          + P + patchSetId()
+          + P
+          + P; // realAuthor
+    }
+
+    private static int comment() {
+      int key = P + str(20) + P + str(32) + 4;
+      int ident = O + 4;
+      return O
+          + P + key
+          + 4 // lineNbr
+          + P + ident // author
+          + P + ident //realAuthor
+          + P + T // writtenOn
+          + 2 // side
+          + str(32) // message
+          + str(10) // parentUuid
+          + (P + O + 4 + 4 + 4 + 4) / 2 // range on 50% of comments
+          + P // tag
+          + P + str(40) // revId
+          + P + str(36); // serverId
+    }
+  }
+
   @AutoValue
   abstract static class Value {
     abstract ChangeNotesState state();
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/project/CreateProject.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
index fa385f2..939d8d4 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/CreateProject.java
@@ -42,8 +42,8 @@
 import com.google.gerrit.reviewdb.client.AccountGroup;
 import com.google.gerrit.reviewdb.client.Project;
 import com.google.gerrit.reviewdb.client.RefNames;
-import com.google.gerrit.server.CurrentUser;
 import com.google.gerrit.server.GerritPersonIdent;
+import com.google.gerrit.server.IdentifiedUser;
 import com.google.gerrit.server.account.GroupBackend;
 import com.google.gerrit.server.config.AllProjectsName;
 import com.google.gerrit.server.config.ProjectOwnerGroupsProvider;
@@ -103,7 +103,7 @@
   private final GitReferenceUpdated referenceUpdated;
   private final RepositoryConfig repositoryCfg;
   private final PersonIdent serverIdent;
-  private final Provider<CurrentUser> currentUser;
+  private final Provider<IdentifiedUser> identifiedUser;
   private final Provider<PutConfig> putConfig;
   private final AllProjectsName allProjects;
   private final String name;
@@ -122,7 +122,7 @@
       GitReferenceUpdated referenceUpdated,
       RepositoryConfig repositoryCfg,
       @GerritPersonIdent PersonIdent serverIdent,
-      Provider<CurrentUser> currentUser,
+      Provider<IdentifiedUser> identifiedUser,
       Provider<PutConfig> putConfig,
       AllProjectsName allProjects,
       @Assisted String name) {
@@ -140,7 +140,7 @@
     this.referenceUpdated = referenceUpdated;
     this.repositoryCfg = repositoryCfg;
     this.serverIdent = serverIdent;
-    this.currentUser = currentUser;
+    this.identifiedUser = identifiedUser;
     this.putConfig = putConfig;
     this.allProjects = allProjects;
     this.name = name;
@@ -214,8 +214,8 @@
 
     if (input.pluginConfigValues != null) {
       try {
-        ProjectControl projectControl =
-            projectControlFactory.controlFor(p.getNameKey(), currentUser.get());
+        ProjectControl projectControl = projectControlFactory.controlFor(
+            p.getNameKey(), identifiedUser.get());
         ConfigInput in = new ConfigInput();
         in.pluginConfigValues = input.pluginConfigValues;
         putConfig.get().apply(projectControl, in);
@@ -355,7 +355,7 @@
         switch (result) {
           case NEW:
             referenceUpdated.fire(project, ru, ReceiveCommand.Type.CREATE,
-                currentUser.get().getAccountId());
+                identifiedUser.get().getAccount());
             break;
           case FAST_FORWARD:
           case FORCED:
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java
index be6f892..8effe44 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/GetAccess.java
@@ -245,7 +245,10 @@
           info.max = r.getMax();
           info.min = r.getMin();
         }
-        pInfo.rules.put(r.getGroup().getUUID().get(), info);
+        AccountGroup.UUID group = r.getGroup().getUUID();
+        if (group != null) {
+          pInfo.rules.put(group.get(), info);
+        }
       }
       accessSectionInfo.permissions.put(p.getName(), pInfo);
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
index 4574a7a..1e5a7c9 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/project/SetAccess.java
@@ -267,7 +267,9 @@
               r.setMin(pri.min);
             }
             r.setAction(GetAccess.ACTION_TYPE.inverse().get(pri.action));
-            r.setForce(pri.force);
+            if (pri.force != null) {
+              r.setForce(pri.force);
+            }
           }
           p.add(r);
         }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
index 3a21ce4..644ed63 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryBuilder.java
@@ -353,6 +353,9 @@
       } catch (RuntimeException | IllegalAccessException e) {
         throw error("Error in operator " + name + ":" + value, e);
       } catch (InvocationTargetException e) {
+        if (e.getCause() instanceof QueryParseException) {
+          throw (QueryParseException) e.getCause();
+        }
         throw error("Error in operator " + name + ":" + value, e.getCause());
       }
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
index 0997a40..ae88887 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/QueryProcessor.java
@@ -162,7 +162,7 @@
       int page = (start / limit) + 1;
       if (page > indexConfig.maxPages()) {
         throw new QueryParseException(
-            "Cannot go beyond page " + indexConfig.maxPages() + "of results");
+            "Cannot go beyond page " + indexConfig.maxPages() + " of results");
       }
 
       // Always bump limit by 1, even if this results in exceeding the permitted
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
index 5d05daf..57f5710 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeQueryBuilder.java
@@ -418,7 +418,8 @@
   }
 
   @Operator
-  public Predicate<ChangeData> status(String statusName) {
+  public Predicate<ChangeData> status(String statusName)
+      throws QueryParseException {
     if ("reviewed".equalsIgnoreCase(statusName)) {
       return IsReviewedPredicate.create();
     }
diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
index 1c92ecf..1ae8591 100644
--- a/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
+++ b/gerrit-server/src/main/java/com/google/gerrit/server/query/change/ChangeStatusPredicate.java
@@ -18,6 +18,7 @@
 import com.google.gerrit.reviewdb.client.Change.Status;
 import com.google.gerrit.server.index.change.ChangeField;
 import com.google.gerrit.server.query.Predicate;
+import com.google.gerrit.server.query.QueryParseException;
 import com.google.gwtorm.server.OrmException;
 
 import java.util.ArrayList;
@@ -63,7 +64,8 @@
     return status.name().toLowerCase();
   }
 
-  public static Predicate<ChangeData> parse(String value) {
+  public static Predicate<ChangeData> parse(String value)
+      throws QueryParseException {
     String lower = value.toLowerCase();
     NavigableMap<String, Predicate<ChangeData>> head =
         PREDICATES.tailMap(lower, true);
@@ -75,7 +77,7 @@
         return e.getValue();
       }
     }
-    throw new IllegalArgumentException("invalid change status: " + value);
+    throw new QueryParseException("invalid change status: " + value);
   }
 
   public static Predicate<ChangeData> open() {
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/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
index 222fb14..bf36738 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/config/RepositoryConfigTest.java
@@ -145,7 +145,6 @@
         RepositoryConfig.OWNER_GROUP_NAME, ownerGroups);
   }
 
-  @SuppressWarnings("cast")
   @Test
   public void testBasePathWhenNotConfigured() {
     assertThat((Object)repoCfg.getBasePath(new NameKey("someProject"))).isNull();
@@ -159,7 +158,6 @@
         .isEqualTo(basePath);
   }
 
-  @SuppressWarnings("cast")
   @Test
   public void testBasePathForSpecificFilter() {
     String basePath = "/someAbsolutePath/someDirectory";
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/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
index 59ae73f..4758338 100644
--- a/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
+++ b/gerrit-server/src/test/java/com/google/gerrit/server/query/change/AbstractQueryChangesTest.java
@@ -21,7 +21,6 @@
 import static java.util.concurrent.TimeUnit.MILLISECONDS;
 import static java.util.concurrent.TimeUnit.MINUTES;
 import static java.util.concurrent.TimeUnit.SECONDS;
-import static org.junit.Assert.fail;
 
 import com.google.common.base.MoreObjects;
 import com.google.common.collect.FluentIterable;
@@ -30,6 +29,7 @@
 import com.google.common.collect.ImmutableSet;
 import com.google.common.collect.Iterables;
 import com.google.common.collect.Lists;
+import com.google.common.truth.ThrowableSubject;
 import com.google.gerrit.common.Nullable;
 import com.google.gerrit.common.TimeUtil;
 import com.google.gerrit.extensions.api.GerritApi;
@@ -244,7 +244,8 @@
     assertQuery("change:repo~branch~" + k.substring(0, 10), change);
 
     assertQuery("foo~bar");
-    assertBadQuery("change:foo~bar");
+    assertThatQueryException("change:foo~bar")
+        .hasMessage("Invalid change format");
     assertQuery("otherrepo~branch~" + k);
     assertQuery("change:otherrepo~branch~" + k);
     assertQuery("repo~otherbranch~" + k);
@@ -342,8 +343,10 @@
     assertQuery("status:N", change1);
     assertQuery("status:nE", change1);
     assertQuery("status:neW", change1);
-    assertBadQuery("status:nx");
-    assertBadQuery("status:newx");
+    assertThatQueryException("status:nx")
+        .hasMessage("invalid change status: nx");
+    assertThatQueryException("status:newx")
+        .hasMessage("invalid change status: newx");
   }
 
   @Test
@@ -764,7 +767,8 @@
     assertQuery(query, change);
     assertQuery(query.withStart(1));
     assertQuery(query.withStart(99));
-    assertBadQuery(query.withStart(100));
+    assertThatQueryException(query.withStart(100))
+        .hasMessage("Cannot go beyond page 10 of results");
     assertQuery(query.withLimit(100).withStart(100));
   }
 
@@ -1613,16 +1617,19 @@
     return inserter.getChange();
   }
 
-  protected void assertBadQuery(Object query) throws Exception {
-    assertBadQuery(newQuery(query));
+  protected ThrowableSubject assertThatQueryException(Object query)
+      throws Exception {
+    return assertThatQueryException(newQuery(query));
   }
 
-  protected void assertBadQuery(QueryRequest query) throws Exception {
+  protected ThrowableSubject assertThatQueryException(QueryRequest query)
+      throws Exception {
     try {
       query.get();
-      fail("expected BadRequestException for query: " + query);
+      throw new AssertionError(
+          "expected BadRequestException for query: " + query);
     } catch (BadRequestException e) {
-      // Expected.
+      return assertThat(e);
     }
   }
 
diff --git a/lib/BUCK b/lib/BUCK
index 14ae9df..5ec54f7 100644
--- a/lib/BUCK
+++ b/lib/BUCK
@@ -246,8 +246,8 @@
 
 maven_jar(
   name = 'truth',
-  id = 'com.google.truth:truth:0.28',
-  sha1 = '0a388c7877c845ff4b8e19689dda5ac9d34622c4',
+  id = 'com.google.truth:truth:0.30',
+  sha1 = '9d591b5a66eda81f0b88cf1c748ab8853d99b18b',
   license = 'DO_NOT_DISTRIBUTE',
   exported_deps = [
     ':guava',
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/BUILD b/lib/js/BUILD
index 3030013..249f5d0 100644
--- a/lib/js/BUILD
+++ b/lib/js/BUILD
@@ -27,3 +27,18 @@
   srcs = [ "//lib/highlightjs:highlight.min.js" ],
   license =  '//lib:LICENSE-highlightjs',
 )
+
+bower_component(
+     name = 'iron-test-helpers',
+     seed = True,
+)
+
+bower_component(
+     name = 'test-fixture',
+     seed = True,
+)
+
+bower_component(
+     name = 'web-component-tester',
+     seed = True,
+)
diff --git a/lib/prolog/prolog.bzl b/lib/prolog/prolog.bzl
index d4e9e08..995d81c 100644
--- a/lib/prolog/prolog.bzl
+++ b/lib/prolog/prolog.bzl
@@ -18,7 +18,7 @@
     name,
     srcs,
     deps = [],
-    visibility = []):
+    **kwargs):
   genrule2(
     name = name + '__pl2j',
     cmd = '$(location //lib/prolog:compiler_bin) ' +
@@ -32,5 +32,5 @@
     name = name,
     srcs = [':' + name + '__pl2j'],
     deps = ['//lib/prolog:runtime'] + deps,
-    visibility = visibility,
+    **kwargs
   )
diff --git a/polygerrit-ui/BUILD b/polygerrit-ui/BUILD
index 6378bfc..9191ab7 100644
--- a/polygerrit-ui/BUILD
+++ b/polygerrit-ui/BUILD
@@ -1,9 +1,12 @@
+package(
+  default_visibility=["//visibility:public"]
+)
 
 load("//tools/bzl:js.bzl", "bower_component_bundle")
 load('//tools/bzl:genrule2.bzl', 'genrule2')
 
 bower_component_bundle(
-  name = "components",
+  name = "polygerrit_components",
   deps = [
     '//lib/js:es6-promise',
     '//lib/js:fetch',
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/BUILD b/polygerrit-ui/app/BUILD
new file mode 100644
index 0000000..29bbfff
--- /dev/null
+++ b/polygerrit-ui/app/BUILD
@@ -0,0 +1,37 @@
+package(
+  default_visibility = ["//visibility:public"])
+load("//tools/bzl:js.bzl", "bower_component_bundle", "vulcanize")
+
+WCT_TEST_PATTERNS = [
+  'test/*.js',
+  'test/*.html',
+  '**/*_test.html',
+]
+PY_TEST_PATTERNS = ['polygerrit_wct_tests.py']
+APP_SRCS = glob(
+  ['**'],
+  exclude = [
+    'BUCK',
+    '*~',
+    '**/BUILD',
+    'index.html',
+    'test/**',
+  ] + WCT_TEST_PATTERNS + PY_TEST_PATTERNS)
+
+
+bower_component_bundle(
+  name = 'test_components',
+  deps = [
+    '//polygerrit-ui:polygerrit_components',
+    '//lib/js:iron-test-helpers',
+    '//lib/js:test-fixture',
+    '//lib/js:web-component-tester',
+  ],
+)
+
+vulcanize(
+  name = "gr-app",
+  app = 'elements/gr-app.html',
+  srcs = APP_SRCS,
+  deps = [ "//polygerrit-ui:polygerrit_components"],
+)
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/elements/change/gr-change-actions/gr-change-actions.html b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
index 09d5b84..96c6193 100644
--- a/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
+++ b/polygerrit-ui/app/elements/change/gr-change-actions/gr-change-actions.html
@@ -66,6 +66,7 @@
       <section hidden$="[[!_actionCount(actions.*, _additionalActions.*)]]">
         <template is="dom-repeat" items="[[_changeActionValues]]" as="action">
           <gr-button title$="[[action.title]]"
+              hidden$="[[_computeActionHidden(action.__key, _hiddenChangeActions.*)]]"
               primary$="[[action.__primary]]"
               hidden$="[[!action.enabled]]"
               data-action-key$="[[action.__key]]"
@@ -78,6 +79,7 @@
       <section hidden$="[[!_actionCount(_revisionActions.*, _additionalActions.*)]]">
         <template is="dom-repeat" items="[[_revisionActionValues]]" as="action">
           <gr-button title$="[[action.title]]"
+              hidden$="[[_computeActionHidden(action.__key, _hiddenRevisionActions.*)]]"
               primary$="[[action.__primary]]"
               disabled$="[[!action.enabled]]"
               data-action-key$="[[action.__key]]"
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 89c06c4..e35d3cb 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
@@ -102,6 +102,14 @@
         type: Array,
         value: function() { return []; },
       },
+      _hiddenChangeActions: {
+        type: Array,
+        value: function() { return []; },
+      },
+      _hiddenRevisionActions: {
+        type: Array,
+        value: function() { return []; },
+      },
     },
 
     ActionType: ActionType,
@@ -169,6 +177,24 @@
       ], value);
     },
 
+    setActionHidden: function(type, key, hidden) {
+      var path;
+      if (type === ActionType.CHANGE) {
+        path = '_hiddenChangeActions';
+      } else if (type === ActionType.REVISION) {
+        path = '_hiddenRevisionActions';
+      } else {
+        throw Error('Invalid action type given: ' + type);
+      }
+
+      var idx = this.get(path).indexOf(key);
+      if (hidden && idx === -1) {
+        this.push(path, key);
+      } else if (!hidden && idx !== -1) {
+        this.splice(path, idx, 1);
+      }
+    },
+
     _indexOfActionButtonWithKey: function(key) {
       for (var i = 0; i < this._additionalActions.length; i++) {
         if (this._additionalActions[i].__key === key) {
@@ -270,6 +296,12 @@
           this._getRevision(this.change, this.patchNum));
     },
 
+    _computeActionHidden: function(key, hiddenActionsChangeRecord) {
+      var hiddenActions =
+          (hiddenActionsChangeRecord && hiddenActionsChangeRecord.base) || [];
+      return hiddenActions.indexOf(key) !== -1;
+    },
+
     _getRevision: function(change, patchNum) {
       var num = window.parseInt(patchNum, 10);
       for (var hash in change.revisions) {
@@ -283,7 +315,14 @@
 
     _modifyRevertMsg: function() {
       return this.$.jsAPI.modifyRevertMsg(this.change,
-          this.$.confirmRevertDialog.message);
+          this.$.confirmRevertDialog.message, this.commitMessage);
+    },
+
+    showRevertDialog: function() {
+      this.$.confirmRevertDialog.populateRevertMessage(
+          this.commitMessage, this.change.current_revision);
+      this.$.confirmRevertDialog.message = this._modifyRevertMsg();
+      this._showActionDialog(this.$.confirmRevertDialog);
     },
 
     _handleActionTap: function(e) {
@@ -298,9 +337,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 {
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 bbd51e2..5fe41ec 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
@@ -97,6 +97,64 @@
       return element.reload();
     });
 
+    test('hide revision action', function(done) {
+      flush(function() {
+        var buttonEl = element.$$('[data-action-key="submit"]');
+        assert.isOk(buttonEl);
+        assert.isFalse(buttonEl.hasAttribute('hidden'));
+        assert.throws(element.setActionHidden.bind(element, 'invalid type'));
+        element.setActionHidden(element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT, true);
+        assert.lengthOf(element._hiddenRevisionActions, 1);
+        element.setActionHidden(element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT, true);
+        assert.lengthOf(element._hiddenRevisionActions, 1);
+        flush(function() {
+          var buttonEl = element.$$('[data-action-key="submit"]');
+          assert.isOk(buttonEl);
+          assert.isTrue(buttonEl.hasAttribute('hidden'));
+
+          element.setActionHidden(element.ActionType.REVISION,
+            element.RevisionActions.SUBMIT, false);
+          flush(function() {
+            var buttonEl = element.$$('[data-action-key="submit"]');
+            assert.isOk(buttonEl);
+            assert.isFalse(buttonEl.hasAttribute('hidden'));
+            done();
+          });
+        });
+      });
+    });
+
+    test('hide change action', function(done) {
+      flush(function() {
+        var buttonEl = element.$$('[data-action-key="/"]');
+        assert.isOk(buttonEl);
+        assert.isFalse(buttonEl.hasAttribute('hidden'));
+        assert.throws(element.setActionHidden.bind(element, 'invalid type'));
+        element.setActionHidden(element.ActionType.CHANGE,
+            element.ChangeActions.DELETE, true);
+        assert.lengthOf(element._hiddenChangeActions, 1);
+        element.setActionHidden(element.ActionType.CHANGE,
+            element.ChangeActions.DELETE, true);
+        assert.lengthOf(element._hiddenChangeActions, 1);
+        flush(function() {
+          var buttonEl = element.$$('[data-action-key="/"]');
+          assert.isOk(buttonEl);
+          assert.isTrue(buttonEl.hasAttribute('hidden'));
+
+          element.setActionHidden(element.ActionType.CHANGE,
+            element.RevisionActions.DELETE, false);
+          flush(function() {
+            var buttonEl = element.$$('[data-action-key="/"]');
+            assert.isOk(buttonEl);
+            assert.isFalse(buttonEl.hasAttribute('hidden'));
+            done();
+          });
+        });
+      });
+    });
+
     test('buttons show', function(done) {
       flush(function() {
         var buttonEls = Polymer.dom(element.root).querySelectorAll('gr-button');
@@ -334,6 +392,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; });
@@ -353,6 +414,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.js b/polygerrit-ui/app/elements/change/gr-change-metadata/gr-change-metadata.js
index 28935ce..661a296 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
@@ -88,7 +88,7 @@
 
     _handleTopicChanged: function(e, topic) {
       if (!topic.length) { topic = null; }
-      this.$.restAPI.setChangeTopic(this.change.id, topic);
+      this.$.restAPI.setChangeTopic(this.change.change_id, topic);
     },
 
     _computeTopicReadOnly: function(mutable, change) {
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 218e124..22080e8 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
@@ -85,6 +85,8 @@
         sandbox.stub(element, '_computeValueTooltip').returns('');
         sandbox.stub(element, '_computeTopicReadOnly').returns(true);
         element.change = {
+          change_id: 'the id',
+          topic: 'the topic',
           status: 'NEW',
           submit_type: 'CHERRY_PICK',
           labels: {
@@ -145,6 +147,13 @@
           done();
         });
       });
+
+      test('changing topic calls setChangeTopic', function() {
+        var topicStub = sandbox.stub(element.$.restAPI, 'setChangeTopic',
+            function() {});
+        element._handleTopicChanged({}, 'the new topic');
+        assert.isTrue(topicStub.calledWith('the id', 'the new topic'));
+      });
     });
   });
 </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 a4b0a9b..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]]"
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 9f90800..3d1cac7e 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
@@ -42,6 +42,7 @@
         notify: true,
         value: function() { return {}; },
       },
+      backPage: String,
       serverConfig: Object,
       keyEventTarget: {
         type: Object,
@@ -368,6 +369,8 @@
 
       this._maybeShowReplyDialog();
 
+      this._maybeShowRevertDialog();
+
       this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.SHOW_CHANGE, {
         change: this._change,
         patchNum: this._patchRange.patchNum,
@@ -395,6 +398,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; }
@@ -573,11 +605,17 @@
           break;
         case 85:  // 'u'
           e.preventDefault();
-          page.show('/');
+          this._determinePageBack();
           break;
       }
     },
 
+    _determinePageBack: function() {
+      // Default backPage to '/' if user came to change view page
+      // via an email link, etc.
+      page.show(this.backPage || '/');
+    },
+
     _labelsChanged: function(changeRecord) {
       if (!changeRecord) { return; }
       this.$.jsAPI.handleEvent(this.$.jsAPI.EventType.LABEL_CHANGE, {
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 b819837..8cc4343 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
@@ -45,13 +45,21 @@
     });
 
     suite('keyboard shortcuts', function() {
-      test('U should navigate to /', function() {
+      test('U should navigate to / if no backPage set', function() {
         var showStub = sinon.stub(page, 'show');
         MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'U'
         assert(showStub.lastCall.calledWithExactly('/'));
         showStub.restore();
       });
 
+      test('U should navigate to backPage if set', function() {
+        element.backPage = '/dashboard/self';
+        var showStub = sinon.stub(page, 'show');
+        MockInteractions.pressAndReleaseKeyOn(element, 85);  // 'U'
+        assert(showStub.lastCall.calledWithExactly('/dashboard/self'));
+        showStub.restore();
+      });
+
       test('A should toggle overlay', function() {
         MockInteractions.pressAndReleaseKeyOn(element, 65);  // 'A'
         var overlayEl = element.$.replyOverlay;
@@ -445,6 +453,60 @@
       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) {
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..8f621f0 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,25 +33,19 @@
       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] + '.';
-      // Add '> ' in front of the original commit text.
-      var originalCommitText = message.replace(/^/gm, '> ');
+      var revertCommitText = 'This reverts commit ' + commitHash + '.';
 
       this.message = revertTitle + '\n\n' +
                      revertCommitText + '\n\n' +
-                     'Reason for revert: <INSERT REASONING HERE>\n\n' +
-                     'Original change\'s description:\n' + originalCommitText;
+                     'Reason for revert: <INSERT REASONING HERE>\n';
     },
 
     _handleConfirmTap: function(e) {
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..f5672d3 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,60 +41,52 @@
     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' +
-                     'Reason for revert: <INSERT REASONING HERE>\n\n' +
-                     'Original change\'s description:\n' +
-                     '> one line commit\n> \n' +
-                     '> Change-Id: abcdefg\n> ';
+                     'This reverts commit abcd123.\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n';
       assert.equal(element.message, expected);
     });
 
     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' +
-                     'Reason for revert: <INSERT REASONING HERE>\n\n' +
-                     'Original change\'s description:\n' +
-                     '> many lines\n> commit\n> \n> message\n> \n' +
-                     '> Change-Id: abcdefg\n> ';
+                     'This reverts commit abcd123.\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n';
       assert.equal(element.message, expected);
     });
 
     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' +
-                     'Reason for revert: <INSERT REASONING HERE>\n\n' +
-                     'Original change\'s description:\n' +
-                     '> much lines\n> very\n> \n> commit\n> \n' +
-                     '> Bug: Issue 42\n' +
-                     '> Change-Id: abcdefg\n> ';
+                     'This reverts commit abcd123.\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n';
       assert.equal(element.message, expected);
     });
 
     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' +
-                     'Reason for revert: <INSERT REASONING HERE>\n\n' +
-                     'Original change\'s description:\n' +
-                     '> Revert "one line commit"\n> \n' +
-                     '> Change-Id: abcdefg\n> ';
+                     'This reverts commit abcd123.\n\n' +
+                     'Reason for revert: <INSERT REASONING HERE>\n';
       assert.equal(element.message, expected);
     });
   });
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 b3d3849..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',
 
@@ -271,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-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
index 6e88267..7a7e761 100644
--- a/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
+++ b/polygerrit-ui/app/elements/core/gr-keyboard-shortcuts-dialog/gr-keyboard-shortcuts-dialog.html
@@ -98,13 +98,6 @@
             <td><span class="key">u</span></td>
             <td>Up to change list</td>
           </tr>
-          <tr>
-            <td>
-              <span class="key modifier">Shift</span>
-              <span class="key">i</span>
-            </td>
-            <td>Show/hide inline diffs</td>
-          </tr>
         </tbody>
         <!-- Diff View -->
         <tbody hidden$="[[!_computeInView(view, 'gr-diff-view')]]" hidden>
@@ -177,6 +170,13 @@
             <td>Show selected file</td>
           </tr>
           <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">i</span>
+            </td>
+            <td>Show/hide all inline diffs</td>
+          </tr>
+          <tr>
             <td><span class="key">i</span></td>
             <td>Show/hide selected inline diff</td>
           </tr>
@@ -214,6 +214,17 @@
             <td>Go to previous comment thread</td>
           </tr>
           <tr>
+            <td><span class="key">e</span></td>
+            <td>Expand all comment threads</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">e</span>
+            </td>
+            <td>Collapse all comment threads</td>
+          </tr>
+          <tr>
             <td>
               <span class="key modifier">Shift</span>
               <span class="key">←</span>
@@ -277,6 +288,17 @@
             <td>Show previous comment thread</td>
           </tr>
           <tr>
+            <td><span class="key">e</span></td>
+            <td>Expand all comment threads</td>
+          </tr>
+          <tr>
+            <td>
+              <span class="key modifier">Shift</span>
+              <span class="key">e</span>
+            </td>
+            <td>Collapse all comment threads</td>
+          </tr>
+          <tr>
             <td>
               <span class="key modifier">Shift</span>
               <span class="key">←</span>
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..9c0e902 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.
      */
@@ -91,7 +111,7 @@
     locationChanged: function() {
       var page = '';
       var pathname = this._getPathname();
-      if (pathname.startsWith('/q/')) {
+      if (pathname.indexOf('/q/') === 0) {
         page = '/q/';
       } else if (pathname.match(CHANGE_VIEW_REGEX)) { // change view
         page = '/c/';
@@ -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 071050c..05b191c 100644
--- a/polygerrit-ui/app/elements/core/gr-router/gr-router.js
+++ b/polygerrit-ui/app/elements/core/gr-router/gr-router.js
@@ -59,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-thread/gr-diff-comment-thread.js b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
index 305c36a..963084a 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread.js
@@ -29,6 +29,10 @@
         type: Array,
         value: function() { return []; },
       },
+      keyEventTarget: {
+        type: Object,
+        value: function() { return document.body; },
+      },
       patchNum: String,
       path: String,
       projectConfig: Object,
@@ -41,6 +45,10 @@
       _orderedComments: Array,
     },
 
+    behaviors: [
+      Gerrit.KeyboardShortcutBehavior,
+    ],
+
     listeners: {
       'comment-update': '_handleCommentUpdate',
     },
@@ -80,6 +88,22 @@
       this._orderedComments = this._sortedComments(this.comments);
     },
 
+    _handleKey: function(e) {
+      if (this.shouldSupressKeyboardShortcut(e)) { return; }
+      if (e.keyCode === 69) { // 'e'
+        e.preventDefault();
+        this._expandCollapseComments(e.shiftKey);
+      }
+    },
+
+    _expandCollapseComments: function(actionIsCollapse) {
+      var comments =
+          Polymer.dom(this.root).querySelectorAll('gr-diff-comment');
+      comments.forEach(function(comment) {
+        comment.collapsed = actionIsCollapse;
+      });
+    },
+
     _sortedComments: function(comments) {
       comments.sort(function(c1, c2) {
         var c1Date = c1.__date || util.parseDate(c1.updated);
diff --git a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
index 641dc0f..eb87f56 100644
--- a/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-diff-comment-thread/gr-diff-comment-thread_test.html
@@ -54,30 +54,25 @@
           message: 'i like you, too',
           in_reply_to: 'sallys_confession',
           updated: '2015-12-25 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_confession',
           message: 'i like you, jack',
           updated: '2015-12-24 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sally_to_dr_finklestein',
           message: 'i’m running away',
           updated: '2015-10-31 09:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_defiance',
           in_reply_to: 'sally_to_dr_finklestein',
           message: 'i will poison you so i can get away',
           updated: '2015-10-31 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'dr_finklesteins_response',
           in_reply_to: 'sally_to_dr_finklestein',
           message: 'no i will pull a thread and your arm will fall off',
           updated: '2015-10-31 11:00:20.396000000'
-        },
-        {
+        }, {
           id: 'sallys_mission',
           message: 'i have to find santa',
           updated: '2015-12-24 21:00:20.396000000'
@@ -89,31 +84,26 @@
           id: 'sally_to_dr_finklestein',
           message: 'i’m running away',
           updated: '2015-10-31 09:00:20.396000000',
-        },
-        {
+        }, {
           id: 'dr_finklesteins_response',
           in_reply_to: 'sally_to_dr_finklestein',
           message: 'no i will pull a thread and your arm will fall off',
           updated: '2015-10-31 11:00:20.396000000'
-        },
-        {
+        }, {
           id: 'sallys_defiance',
           in_reply_to: 'sally_to_dr_finklestein',
           message: 'i will poison you so i can get away',
           updated: '2015-10-31 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_confession',
           message: 'i like you, jack',
           updated: '2015-12-24 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'jacks_reply',
           message: 'i like you, too',
           in_reply_to: 'sallys_confession',
           updated: '2015-12-25 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_mission',
           message: 'i have to find santa',
           updated: '2015-12-24 21:00:20.396000000'
@@ -247,20 +237,17 @@
           message: 'i like you, too',
           in_reply_to: 'sallys_confession',
           updated: '2015-12-25 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_confession',
           in_reply_to: 'nonexistent_comment',
           message: 'i like you, jack',
           updated: '2015-12-24 15:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sally_to_dr_finklestein',
           in_reply_to: 'nonexistent_comment',
           message: 'i’m running away',
           updated: '2015-10-31 09:00:20.396000000',
-        },
-        {
+        }, {
           id: 'sallys_defiance',
           message: 'i will poison you so i can get away',
           updated: '2015-10-31 15:00:20.396000000',
@@ -268,5 +255,37 @@
       element.comments = comments;
       assert.equal(4, element._orderedComments.length);
     });
+
+    test('keyboard shortcuts', function() {
+      var comments = [
+        {
+          id: 'jacks_reply',
+          message: 'i like you, too',
+          in_reply_to: 'sallys_confession',
+          updated: '2015-12-25 15:00:20.396000000',
+        }, {
+          id: 'sallys_confession',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i like you, jack',
+          updated: '2015-12-24 15:00:20.396000000',
+        }, {
+          id: 'sally_to_dr_finklestein',
+          in_reply_to: 'nonexistent_comment',
+          message: 'i’m running away',
+          updated: '2015-10-31 09:00:20.396000000',
+        }, {
+          id: 'sallys_defiance',
+          message: 'i will poison you so i can get away',
+          updated: '2015-10-31 15:00:20.396000000',
+        }];
+      element.comments = comments;
+      var expandCollapseStub = sinon.stub(element, '_expandCollapseComments');
+      MockInteractions.pressAndReleaseKeyOn(element, 69);  // 'e'
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(false));
+
+      MockInteractions.pressAndReleaseKeyOn(element, 69, 'shift');  // 'e'
+      assert.isTrue(expandCollapseStub.lastCall.calledWith(true));
+      expandCollapseStub.restore();
+    });
   });
 </script>
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..ebb1f87 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$="[[collapsed]]"
+               on-change="_handleToggleCollapsed">
+            [[_computeShowHideText(collapsed)]]
+          </label>
+        </div>
       </div>
       <iron-autogrow-textarea
           id="editTextarea"
@@ -137,6 +186,7 @@
       <gr-linked-text class="message"
           pre
           content="[[comment.message]]"
+          collapsed="[[collapsed]]"
           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..2abd0a8 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,
+      collapsed: {
+        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.collapsed = 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.collapsed = !this.collapsed;
+    },
+
+    _toggleCollapseClass: function(collapsed) {
+      if (collapsed) {
+        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..0a591ae 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,34 @@
       };
     });
 
+    test('collapsible comments', function() {
+      // When a comment (not draft) is loaded, it should be collapsed
+      assert.isTrue(element.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.isFalse(element.collapsed);
+      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);
@@ -92,6 +126,29 @@
           'Should navigate to ' + dest + ' without triggering nav');
       showStub.restore();
     });
+
+    test('comment expand and collapse', function() {
+      element.collapsed = true;
+      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');
+
+      element.collapsed = false;
+      assert.isFalse(element.collapsed);
+      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');
+    });
   });
 
   suite('gr-diff-comment draft tests', function() {
@@ -135,11 +192,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 +233,71 @@
       assert.isTrue(isVisible(element.$$('.cancel')), 'cancel is visible');
     });
 
+    test('collapsible drafts', function() {
+      element.addEventListener('reply', function(e) {
+        assert.ok(e.detail.comment);
+        done();
+      });
+      assert.isTrue(element.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');
+      assert.isTrue(isVisible(element.$$('.collapsedContent')),
+          'header middle content is visible');
+
+      MockInteractions.tap(element.$.header);
+      assert.isFalse(element.collapsed);
+      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(element.collapsed);
+      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.isTrue(element.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');
+      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..d3cc461 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');
     },
 
@@ -257,7 +258,7 @@
       }
 
       // If there is a range to hide.
-      if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 0) {
+      if (context !== WHOLE_FILE && hiddenRange[1] - hiddenRange[0] > 1) {
         var linesBeforeCtx = lines.slice(0, hiddenRange[0]);
         var hiddenLines = lines.slice(hiddenRange[0], hiddenRange[1]);
         var linesAfterCtx = lines.slice(hiddenRange[1]);
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..f6d0e37 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
@@ -511,6 +511,17 @@
           assert.equal(result[0].lines.length, rows.length);
         });
 
+        test('_sharedGroupsFromRows no single line collapse', function() {
+          rows = rows.slice(0, 7);
+          var context = 3;
+          var result = element._sharedGroupsFromRows(
+              rows, context, 10, 100);
+
+          // Results in one uncollapsed group with all rows.
+          assert.equal(result.length, 1, 'Results in one group');
+          assert.equal(result[0].lines.length, rows.length);
+        });
+
         test('_deltaLinesFromRows', function() {
           var startLineNum = 10;
           var result = element._deltaLinesFromRows(GrDiffLine.Type.ADD, rows,
@@ -591,5 +602,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-view/gr-diff-view.js b/polygerrit-ui/app/elements/diff/gr-diff-view/gr-diff-view.js
index 6c73e08..ae5dfd2 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
@@ -16,6 +16,8 @@
 
   var COMMIT_MESSAGE_PATH = '/COMMIT_MSG';
 
+  var MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX = 900;
+
   var DiffViewMode = {
     SIDE_BY_SIDE: 'SIDE_BY_SIDE',
     UNIFIED: 'UNIFIED_DIFF',
@@ -104,14 +106,18 @@
           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.
-        this.set('changeViewState.diffMode', DiffViewMode.SIDE_BY_SIDE);
-        this.$.restAPI.getPreferences().then(function(prefs) {
-          this.set('changeViewState.diffMode', prefs.diff_view);
-        }.bind(this));
+        // If screen size is small, always default to unified view.
+        if (this._getWindowWidth() < MAX_UNIFIED_DEFAULT_WINDOW_WIDTH_PX) {
+          this.set('changeViewState.diffMode', DiffViewMode.UNIFIED);
+        } else {
+          // Initialize with user's diff mode preference. Default to
+          // SIDE_BY_SIDE in the meantime.
+          this.set('changeViewState.diffMode', DiffViewMode.SIDE_BY_SIDE);
+          this.$.restAPI.getPreferences().then(function(prefs) {
+            this.set('changeViewState.diffMode', prefs.diff_view);
+          }.bind(this));
+        }
       }
 
       if (this._path) {
@@ -156,6 +162,10 @@
       return this.$.restAPI.getPreferences();
     },
 
+    _getWindowWidth: function() {
+      return window.innerWidth;
+    },
+
     _handleReviewedChange: function(e) {
       this._setReviewed(Polymer.dom(e).rootTarget.checked);
     },
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..e81bc2f 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});
@@ -492,6 +491,39 @@
       assert.equal(select.value, 'SIDE_BY_SIDE');
     });
 
+    test('unified view is always default on small screens', function() {
+      var resolvePrefs;
+      var prefsPromise = new Promise(function(resolve) {
+        resolvePrefs = resolve;
+      });
+
+      var getPreferencesStub = sinon.stub(element.$.restAPI, 'getPreferences',
+          function() { return prefsPromise; });
+
+      // Attach a new gr-diff-view so we can intercept the preferences fetch.
+      var view = document.createElement('gr-diff-view');
+
+      view.changeViewState = {diffMode: null};
+
+      sinon.stub(view, '_getWindowWidth', function() {
+        return 800;
+      });
+
+      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, 'UNIFIED_DIFF');
+
+      // Receive the overriding preference.
+      resolvePrefs({diff_view: 'SIDE_BY_SIDE'});
+      flushAsynchronousOperations();
+
+      // On small screens, unified should override user perferences
+      assert.equal(select.value, 'UNIFIED_DIFF');
+    });
+
     test('_loadHash', function() {
       assert.isNotOk(element.$.cursor.initialLineNumber);
 
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/diff/gr-selection-action-box/gr-selection-action-box.html b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
index 9a8ea37..98f6b01 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.html
@@ -21,26 +21,25 @@
   <template>
     <style>
       :host {
-        --gr-arrow-size: .6em;
+        --gr-arrow-size: .65em;
 
-        background-color: #fff;
-        border: 1px solid #000;
-        border-radius: .5em;
+        background-color: rgba(22, 22, 22, .9);
+        border-radius: 3px;
+        color: #fff;
         cursor: pointer;
-        padding: .3em;
+        font-family: var(--font-family);
+        padding: .5em .75em;
         position: absolute;
         white-space: nowrap;
       }
       .arrow {
-        background: #fff;
-        border: var(--gr-arrow-size) solid #000;
-        border-width: 0 1px 1px 0;
-        height: var(--gr-arrow-size);
-        left: calc(50% - 1em);
-        margin-top: .05em;
+        border: var(--gr-arrow-size) solid transparent;
+        border-top: var(--gr-arrow-size) solid rgba(22, 22, 22, 0.9);
+        height: 0;
+        left: calc(50% - var(--gr-arrow-size));
+        margin-top: .5em;
         position: absolute;
-        transform: rotate(45deg);
-        width: var(--gr-arrow-size);
+        width: 0;
       }
     </style>
     Press <strong>C</strong> to comment.
diff --git a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
index fbd1ef3..29b3c19 100644
--- a/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
+++ b/polygerrit-ui/app/elements/diff/gr-selection-action-box/gr-selection-action-box.js
@@ -48,7 +48,7 @@
     ],
 
     listeners: {
-      'tap': '_handleTap',
+      'mousedown': '_handleMouseDown', // See https://crbug.com/gerrit/4767
     },
 
     placeAbove: function(el) {
@@ -87,7 +87,9 @@
       }
     },
 
-    _handleTap: function() {
+    _handleMouseDown: function(e) {
+      e.preventDefault();
+      e.stopPropagation();
       this._fireCreateComment();
     },
 
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
index c0ee1fa..84e97bd 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer.js
@@ -255,13 +255,14 @@
         var nodeLength = GrAnnotation.getLength(node);
         // Note: HLJS may emit a span with class undefined when it thinks there
         // may be a syntax error.
-        if (node.tagName === 'SPAN' && node.className !== 'undefined' &&
-            CLASS_WHITELIST.hasOwnProperty(node.className)) {
-          result.push({
-            start: offset,
-            length: nodeLength,
-            className: node.className,
-          });
+        if (node.tagName === 'SPAN' && node.className !== 'undefined') {
+          if (CLASS_WHITELIST.hasOwnProperty(node.className)) {
+            result.push({
+              start: offset,
+              length: nodeLength,
+              className: node.className,
+            });
+          }
           if (node.children.length) {
             result = result.concat(this._rangesFromElement(node, offset));
           }
diff --git a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
index 178f61b..aa37f1a 100644
--- a/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
+++ b/polygerrit-ui/app/elements/diff/gr-syntax-layer/gr-syntax-layer_test.html
@@ -370,6 +370,15 @@
       assert.equal(result[1].className, className);
     });
 
+    test('_rangesFromString whitelist allows recursion', function() {
+      var str = [
+          '<span class="non-whtelisted-class">',
+            '<span class="gr-diff gr-syntax gr-syntax-keyword">public</span>',
+          '</span>'].join('');
+      var result = element._rangesFromString(str);
+      assert.notEqual(result.length, 0);
+    });
+
     test('_isSectionDone', function() {
       var state = {sectionIndex: 0, lineIndex: 0};
       assert.isFalse(element._isSectionDone(state));
diff --git a/polygerrit-ui/app/elements/gr-app.html b/polygerrit-ui/app/elements/gr-app.html
index 9382f3a..633e8f2 100644
--- a/polygerrit-ui/app/elements/gr-app.html
+++ b/polygerrit-ui/app/elements/gr-app.html
@@ -104,7 +104,8 @@
         <gr-change-view
             params="[[params]]"
             server-config="[[_serverConfig]]"
-            view-state="{{_viewState.changeView}}"></gr-change-view>
+            view-state="{{_viewState.changeView}}"
+            back-page="[[_lastSearchPage]]"></gr-change-view>
       </template>
       <template is="dom-if" if="[[_showDiffView]]" restamp="true">
         <gr-diff-view
diff --git a/polygerrit-ui/app/elements/gr-app.js b/polygerrit-ui/app/elements/gr-app.js
index 2c7a999..a188ea1 100644
--- a/polygerrit-ui/app/elements/gr-app.js
+++ b/polygerrit-ui/app/elements/gr-app.js
@@ -43,6 +43,7 @@
       _showSettingsView: Boolean,
       _viewState: Object,
       _lastError: Object,
+      _lastSearchPage: String,
       _path: String,
     },
 
@@ -110,10 +111,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);
       }
     },
@@ -163,10 +166,18 @@
     _handleLocationChange: function(e) {
       var hash = e.detail.hash.substring(1);
       var pathname = e.detail.pathname;
-      if (pathname.startsWith('/c/') && parseInt(hash, 10) > 0) {
+      if (pathname.indexOf('/c/') === 0 && parseInt(hash, 10) > 0) {
         pathname += '@' + hash;
       }
       this.set('_path', pathname);
+      this._handleSearchPageChange();
+    },
+
+    _handleSearchPageChange: function() {
+      var viewsToCheck = ['gr-change-list-view', 'gr-dashboard-view'];
+      if (viewsToCheck.indexOf(this.params.view) !== -1) {
+        this.set('_lastSearchPage', location.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 c5c68ea..9373820 100644
--- a/polygerrit-ui/app/elements/gr-app_test.html
+++ b/polygerrit-ui/app/elements/gr-app_test.html
@@ -74,9 +74,18 @@
 
       var event = {detail: curLocation};
       var gwtLink = element.$$('#gwtLink');
+
+      sinon.stub(element, '_handleSearchPageChange');
+
       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-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-change-actions-js-api.js b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
index f7c337b..72c7f6e 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api.js
@@ -33,6 +33,11 @@
     });
   };
 
+  GrChangeActionsInterface.prototype.setActionHidden = function(type, key,
+      hidden) {
+    return this._el.setActionHidden(type, key, hidden);
+  };
+
   GrChangeActionsInterface.prototype.add = function(type, label) {
     return this._el.addActionButton(type, label);
   };
diff --git a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
index 4919a5a..93e676c 100644
--- a/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
+++ b/polygerrit-ui/app/elements/shared/gr-js-api-interface/gr-change-actions-js-api_test.html
@@ -119,5 +119,22 @@
         });
       });
     });
+
+    test('hide action buttons', function(done) {
+      var key = changeActions.add(changeActions.ActionType.REVISION, 'Bork!');
+      flush(function() {
+        var button = element.$$('[data-action-key="' + key + '"]');
+        assert.isOk(button);
+        assert.isFalse(button.hasAttribute('hidden'));
+        changeActions.setActionHidden(changeActions.ActionType.REVISION, key,
+            true);
+        flush(function() {
+          var button = element.$$('[data-action-key="' + key + '"]');
+          assert.isOk(button);
+          assert.isTrue(button.hasAttribute('hidden'));
+          done();
+        });
+      });
+    });
   });
 </script>
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 06b25de..bb407aa 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
@@ -150,15 +150,15 @@
       });
     },
 
-    modifyRevertMsg: function(change, msg) {
+    modifyRevertMsg: function(change, revertMsg, origMsg) {
       this._getEventCallbacks(EventType.REVERT).forEach(function(callback) {
         try {
-          msg = callback(change, msg);
+          revertMsg = callback(change, revertMsg, origMsg);
         } catch (err) {
           console.error(err);
         }
       });
-      return msg;
+      return revertMsg;
     },
 
     getLabelValuesPostRevert: function(change) {
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..766da84 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
@@ -103,21 +103,23 @@
     });
 
     test('revert event', function(done) {
-      function appendToRevertMsg(c, msg) {
-        return msg + '\ninfo';
+      function appendToRevertMsg(c, revertMsg, originalMsg) {
+        return revertMsg + '\n' + originalMsg.replace(/^/gm, '> ') + '\ninfo';
       }
       done();
 
-      assert.equal(element.modifyRevertMsg(null, 'test'), 'test');
+      assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'), 'test');
       assert.equal(errorStub.callCount, 0);
 
       plugin.on(element.EventType.REVERT, throwErrFn);
       plugin.on(element.EventType.REVERT, appendToRevertMsg);
-      assert.equal(element.modifyRevertMsg(null, 'test'), 'test\ninfo');
+      assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
+                   'test\n> origTest\ninfo');
       assert.isTrue(errorStub.calledOnce);
 
       plugin.on(element.EventType.REVERT, appendToRevertMsg);
-      assert.equal(element.modifyRevertMsg(null, 'test'), 'test\ninfo\ninfo');
+      assert.equal(element.modifyRevertMsg(null, 'test', 'origTest'),
+                   'test\n> origTest\ninfo\n> origTest\ninfo');
       assert.isTrue(errorStub.calledTwice);
     });
 
@@ -172,5 +174,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/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/js.bzl b/tools/bzl/js.bzl
index 978059a..38d6c91 100644
--- a/tools/bzl/js.bzl
+++ b/tools/bzl/js.bzl
@@ -39,11 +39,12 @@
   python = ctx.which("python")
   script = ctx.path(ctx.attr._download_script)
 
-  args = [python, script, "-o", dest, "-u", url]
+  sha1 = NPM_SHA1S[name]
+  args = [python, script, "-o", dest, "-u", url, "-v", sha1]
   out = ctx.execute(args)
   if out.return_code:
     fail("failed %s: %s" % (args, out.stderr))
-  ctx.file("BUILD", "filegroup(name='tarball', srcs=['%s'])" % base, False)
+  ctx.file("BUILD", "package(default_visibility=['//visibility:public'])\nfilegroup(name='tarball', srcs=['%s'])" % base, False)
 
 npm_binary = repository_rule(
     implementation=_npm_binary_impl,
@@ -55,12 +56,13 @@
     })
 
 
-def _run_npm_binary_str(ctx, tarball, name):
+# for use in repo rules.
+def _run_npm_binary_str(ctx, tarball, args):
   python_bin = ctx.which("python")
   return " ".join([
     python_bin,
     ctx.path(ctx.attr._run_npm),
-    ctx.path(tarball)])
+    ctx.path(tarball)] + args)
 
 
 def _bower_archive(ctx):
@@ -72,7 +74,7 @@
   cmd = [
       ctx.which("python"),
       ctx.path(ctx.attr._download_bower),
-      '-b', '%s' % _run_npm_binary_str(ctx, ctx.attr._bower_archive, "bower"),
+      '-b', '%s' % _run_npm_binary_str(ctx, ctx.attr._bower_archive, []),
       '-n', ctx.name,
       '-p', ctx.attr.package,
       '-v', ctx.attr.version,
@@ -235,6 +237,10 @@
   for d in ctx.attr.deps:
     versions += d.transitive_versions
 
+  licenses = set([])
+  for d in ctx.attr.deps:
+    licenses += d.transitive_versions
+
   out_zip = ctx.outputs.zip
   out_versions = ctx.outputs.version_json
 
@@ -259,6 +265,11 @@
     mnemonic="BowerVersions",
     command="(echo '{' ; for j in  %s ; do cat $j; echo ',' ; done ; echo \\\"\\\":\\\"\\\"; echo '}') > %s" % (" ".join([v.path for v in versions]), out_versions.path))
 
+  return struct(
+    transitive_zipfiles=zips,
+    transitive_versions=versions,
+    transitive_licenses=licenses)
+
 
 bower_component_bundle = rule(
   _bower_component_bundle_impl,
@@ -268,3 +279,88 @@
     "version_json": "%{name}-versions.json",
   }
 )
+
+def _vulcanize_impl(ctx):
+  destdir = ctx.outputs.vulcanized.path + ".dir"
+  zips =  [z for d in ctx.attr.deps for z in d.transitive_zipfiles ]
+
+  hermetic_npm_binary = " ".join([
+    'python',
+    "$p/" + ctx.file._run_npm.path,
+    "$p/" + ctx.file._vulcanize_archive.path,
+    '--inline-scripts',
+    '--inline-css',
+    '--strip-comments',
+    '--out-html', "$p/" + ctx.outputs.vulcanized.path,
+    ctx.file.app.path
+  ])
+
+  pkg_dir = ctx.attr.pkg.lstrip("/")
+  cmd = " && ".join([
+    # unpack dependencies.
+    "export PATH",
+    "p=$PWD",
+    "rm -rf %s" % destdir,
+    "mkdir -p %s/%s/bower_components" % (destdir, pkg_dir),
+    "for z in %s; do unzip -qd %s/%s/bower_components/ $z; done" % (
+      ' '.join([z.path for z in zips]), destdir, pkg_dir),
+    "tar -cf - %s | tar -C %s -xf -" % (" ".join([s.path for s in ctx.files.srcs]), destdir),
+    "cd %s" % destdir,
+    hermetic_npm_binary,
+  ])
+  ctx.action(
+    mnemonic = "Vulcanize",
+    inputs = [ctx.file._run_npm, ctx.file.app,
+              ctx.file._vulcanize_archive
+    ] + list(zips) + ctx.files.srcs,
+    outputs = [ctx.outputs.vulcanized],
+    command = cmd)
+
+  hermetic_npm_command = "export PATH && " + " ".join([
+    'python',
+    ctx.file._run_npm.path,
+    ctx.file._crisper_archive.path,
+    "--always-write-script",
+    "--source", ctx.outputs.vulcanized.path,
+    "--html", ctx.outputs.html.path,
+    "--js", ctx.outputs.js.path])
+
+  ctx.action(
+    mnemonic = "Crisper",
+    inputs = [ctx.file._run_npm, ctx.file.app,
+              ctx.file._crisper_archive, ctx.outputs.vulcanized],
+    outputs = [ctx.outputs.js, ctx.outputs.html],
+    command = hermetic_npm_command)
+
+
+_vulcanize_rule = rule(
+  _vulcanize_impl,
+  attrs = {
+    "deps": attr.label_list(providers=["transitive_zipfiles"]),
+    "app": attr.label(mandatory=True, allow_single_file=True),
+    "srcs": attr.label_list(allow_files=[".js", ".html", ".txt", ".css", ".ico"]),
+
+    "pkg": attr.string(mandatory=True),
+    "_run_npm": attr.label(
+      default=Label("//tools/js:run_npm_binary.py"),
+      allow_single_file=True
+    ),
+    "_vulcanize_archive": attr.label(
+      default=Label("@vulcanize//:%s" % _npm_tarball("vulcanize")),
+      allow_single_file=True
+    ),
+    "_crisper_archive": attr.label(
+      default=Label("@crisper//:%s" % _npm_tarball("crisper")),
+      allow_single_file=True
+    ),
+  },
+  outputs = {
+    "vulcanized": "%{name}.vulcanized.html",
+    "html": "%{name}.crisped.html",
+    "js": "%{name}.crisped.js",
+  }
+)
+
+def vulcanize(*args, **kwargs):
+  """Vulcanize runs vulcanize and crisper on a set of sources."""
+  _vulcanize_rule(*args, pkg=PACKAGE_NAME, **kwargs)
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):
diff --git a/tools/bzl/pkg_war.bzl b/tools/bzl/pkg_war.bzl
index ef0fe51..aa7d07f 100644
--- a/tools/bzl/pkg_war.bzl
+++ b/tools/bzl/pkg_war.bzl
@@ -124,7 +124,7 @@
   outputs = {'war' : '%{name}.war'},
 )
 
-def pkg_war(name, ui = 'ui_optdbg', context = []):
+def pkg_war(name, ui = 'ui_optdbg', context = [], **kwargs):
   ui_deps = []
   if ui:
     ui_deps.append('//gerrit-gwtui:%s' % ui)
@@ -136,4 +136,5 @@
       '//gerrit-main:main_bin_deploy.jar',
       '//gerrit-war:webapp_assets',
     ],
+    **kwargs
   )
diff --git a/tools/bzl/plugin.bzl b/tools/bzl/plugin.bzl
index bdd1794..32f57e86 100644
--- a/tools/bzl/plugin.bzl
+++ b/tools/bzl/plugin.bzl
@@ -4,7 +4,8 @@
     deps = [],
     srcs = [],
     resources = [],
-    manifest_entries = []):
+    manifest_entries = [],
+    **kwargs):
   # TODO(davido): Fix stamping: run git describe in plugin directory
   # https://github.com/bazelbuild/bazel/issues/1758
   manifest_lines = [
@@ -31,4 +32,5 @@
       ':%s__plugin' % name,
     ],
     visibility = ['//visibility:public'],
+    **kwargs
   )
diff --git a/tools/eclipse/BUCK b/tools/eclipse/BUCK
index dfd271d..a8b3f01 100644
--- a/tools/eclipse/BUCK
+++ b/tools/eclipse/BUCK
@@ -15,7 +15,6 @@
     '//gerrit-reviewdb:client_tests',
     '//gerrit-server:server',
     '//gerrit-server:server_tests',
-    '//lib:jimfs',
     '//lib/asciidoctor:asciidoc_lib',
     '//lib/asciidoctor:doc_indexer_lib',
     '//lib/auto:auto-value',
diff --git a/tools/js/BUILD b/tools/js/BUILD
index e69de29..fedaf7f 100644
--- a/tools/js/BUILD
+++ b/tools/js/BUILD
@@ -0,0 +1 @@
+exports_files(["run_npm_binary.py"])